867 lines
29 KiB
Python
867 lines
29 KiB
Python
"""Qlib 0.9.7 native momentum backtest and Alpha158/LightGBM workflow.
|
|
|
|
This module never downloads data. A real, user-authorized ``provider_uri`` is
|
|
required. Qlib, pandas and LightGBM are imported only inside execution paths.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import deque
|
|
import datetime as dt
|
|
import hashlib
|
|
import importlib
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
from typing import Any, Dict, Iterable, Mapping, Optional, Sequence
|
|
|
|
from quant60.portable_core import momentum_score
|
|
|
|
|
|
class QlibUnavailableError(RuntimeError):
|
|
"""Raised when Qlib or its configured provider cannot be used."""
|
|
|
|
|
|
def _table_audit(table: Any) -> Optional[Dict[str, Any]]:
|
|
"""Return compact deterministic evidence for a pandas Series/DataFrame."""
|
|
|
|
to_json = getattr(table, "to_json", None)
|
|
if not callable(to_json):
|
|
return None
|
|
encoded = to_json(
|
|
orient="split",
|
|
date_format="iso",
|
|
date_unit="ns",
|
|
double_precision=15,
|
|
).encode("utf-8")
|
|
columns = [
|
|
str(value)
|
|
for value in getattr(table, "columns", [getattr(table, "name", "value")])
|
|
]
|
|
audit: Dict[str, Any] = {
|
|
"row_count": int(len(table)),
|
|
"columns": columns,
|
|
"content_sha256": hashlib.sha256(encoded).hexdigest(),
|
|
}
|
|
index = getattr(table, "index", None)
|
|
if index is not None and len(index):
|
|
audit["period_start"] = str(index[0])
|
|
audit["period_end"] = str(index[-1])
|
|
|
|
def finite_values(name: str) -> list[float]:
|
|
if name not in columns:
|
|
return []
|
|
try:
|
|
raw = table[name].dropna().tolist()
|
|
values = [float(value) for value in raw]
|
|
except (KeyError, TypeError, ValueError, AttributeError):
|
|
return []
|
|
return [value for value in values if math.isfinite(value)]
|
|
|
|
returns = finite_values("return")
|
|
benchmark = finite_values("bench")
|
|
costs = finite_values("cost")
|
|
turnover = finite_values("turnover")
|
|
if returns:
|
|
wealth = 1.0
|
|
peak = 1.0
|
|
max_drawdown = 0.0
|
|
for value in returns:
|
|
wealth *= 1.0 + value
|
|
peak = max(peak, wealth)
|
|
max_drawdown = min(max_drawdown, wealth / peak - 1.0)
|
|
audit["derived_performance"] = {
|
|
"cumulative_return": wealth - 1.0,
|
|
"max_drawdown": max_drawdown,
|
|
"benchmark_cumulative_return": (
|
|
math.prod(1.0 + value for value in benchmark) - 1.0
|
|
if benchmark
|
|
else None
|
|
),
|
|
"total_cost": sum(costs) if costs else None,
|
|
"total_turnover": sum(turnover) if turnover else None,
|
|
}
|
|
return audit
|
|
|
|
|
|
def _mapping_table_audits(values: Any) -> Dict[str, Any]:
|
|
if not isinstance(values, Mapping):
|
|
return {}
|
|
output: Dict[str, Any] = {}
|
|
for frequency, item in sorted(values.items(), key=lambda pair: str(pair[0])):
|
|
table = item[0] if isinstance(item, (tuple, list)) and item else item
|
|
audit = _table_audit(table)
|
|
if audit is not None:
|
|
output[str(frequency)] = audit
|
|
return output
|
|
|
|
|
|
def _version_tuple(value: str) -> tuple[int, ...]:
|
|
parts = []
|
|
for chunk in str(value).split("."):
|
|
digits = "".join(character for character in chunk if character.isdigit())
|
|
if not digits:
|
|
break
|
|
parts.append(int(digits))
|
|
return tuple(parts)
|
|
|
|
|
|
def _load_qlib() -> Dict[str, Any]:
|
|
if importlib.util.find_spec("qlib") is None:
|
|
raise QlibUnavailableError(
|
|
"pyqlib 0.9.7 is not installed in this research environment"
|
|
)
|
|
try:
|
|
qlib = importlib.import_module("qlib")
|
|
data_module = importlib.import_module("qlib.data")
|
|
executor_module = importlib.import_module("qlib.backtest.executor")
|
|
backtest_module = importlib.import_module("qlib.backtest")
|
|
strategy_module = importlib.import_module(
|
|
"qlib.contrib.strategy.signal_strategy"
|
|
)
|
|
except Exception as exc:
|
|
raise QlibUnavailableError(f"Qlib import failed: {exc}") from exc
|
|
version = str(getattr(qlib, "__version__", "unknown"))
|
|
if version != "unknown" and _version_tuple(version) != (0, 9, 7):
|
|
raise QlibUnavailableError(
|
|
f"Qlib exactly 0.9.7 is required by this pinned adapter; found {version}"
|
|
)
|
|
return {
|
|
"qlib": qlib,
|
|
"D": data_module.D,
|
|
"SimulatorExecutor": executor_module.SimulatorExecutor,
|
|
"backtest": backtest_module.backtest,
|
|
"TopkDropoutStrategy": strategy_module.TopkDropoutStrategy,
|
|
"version": version,
|
|
}
|
|
|
|
|
|
def _validate_provider(provider_uri: str) -> str:
|
|
value = str(provider_uri or "").strip()
|
|
if not value:
|
|
raise ValueError("provider_uri is required; no public data is downloaded")
|
|
if "://" not in value:
|
|
path = Path(value).expanduser().resolve()
|
|
if not path.is_dir():
|
|
raise FileNotFoundError(
|
|
f"Qlib provider_uri does not exist: {path}"
|
|
)
|
|
return str(path)
|
|
return value
|
|
|
|
|
|
def _infer_columns(frame: Any) -> tuple[str, str, str]:
|
|
columns = [str(column) for column in frame.columns]
|
|
instrument = next(
|
|
(
|
|
name
|
|
for name in ("instrument", "level_0", "level_1")
|
|
if name in columns
|
|
),
|
|
None,
|
|
)
|
|
datetime_column = next(
|
|
(
|
|
name
|
|
for name in ("datetime", "date", "level_1", "level_0")
|
|
if name in columns and name != instrument
|
|
),
|
|
None,
|
|
)
|
|
close = next(
|
|
(name for name in ("$close", "close") if name in columns),
|
|
None,
|
|
)
|
|
if close is None:
|
|
non_index = [
|
|
name
|
|
for name in columns
|
|
if name not in {instrument, datetime_column}
|
|
]
|
|
close = non_index[0] if len(non_index) == 1 else None
|
|
if instrument is None or datetime_column is None or close is None:
|
|
raise ValueError(
|
|
f"unexpected D.features columns/index after reset: {columns}"
|
|
)
|
|
|
|
# Unnamed MultiIndexes may reset as level_0/level_1. Infer their order
|
|
# from the first non-null value instead of assuming a Qlib build detail.
|
|
if instrument.startswith("level_") and datetime_column.startswith("level_"):
|
|
sample = frame[[instrument, datetime_column]].dropna().head(1)
|
|
if not sample.empty:
|
|
first = sample.iloc[0][instrument]
|
|
if not isinstance(first, str) or not (
|
|
first.upper().startswith(("SH", "SZ", "BJ"))
|
|
or first[:1].isdigit()
|
|
):
|
|
instrument, datetime_column = datetime_column, instrument
|
|
return instrument, datetime_column, close
|
|
|
|
|
|
def generate_portable_momentum_signal(
|
|
D: Any,
|
|
instruments: Any,
|
|
*,
|
|
start_time: str,
|
|
end_time: str,
|
|
feature_start_time: Optional[str] = None,
|
|
lookback: int = 20,
|
|
skip: int = 0,
|
|
rebalance: str = "weekly",
|
|
) -> Any:
|
|
"""Use ``D.features`` and portable momentum to build a Qlib score Series.
|
|
|
|
The result is indexed ``(datetime, instrument)``. Qlib's
|
|
``BaseSignalStrategy`` reads the prior-step score (``shift=1``), so a score
|
|
computed on completed bar T is executed on the next engine step.
|
|
"""
|
|
pandas = importlib.import_module("pandas")
|
|
if feature_start_time is None:
|
|
start = dt.datetime.fromisoformat(str(start_time)[:10])
|
|
padding_days = max(14, 3 * (int(lookback) + int(skip) + 2))
|
|
feature_start_time = (start - dt.timedelta(days=padding_days)).date().isoformat()
|
|
if rebalance not in {"daily", "weekly"}:
|
|
raise ValueError("rebalance must be daily or weekly")
|
|
instrument_query = instruments
|
|
if isinstance(instruments, str):
|
|
instrument_query = D.instruments(market=instruments)
|
|
raw = D.features(
|
|
instrument_query,
|
|
["$close"],
|
|
start_time=feature_start_time,
|
|
end_time=end_time,
|
|
freq="day",
|
|
)
|
|
if raw is None or len(raw) == 0:
|
|
raise QlibUnavailableError("D.features returned no close data")
|
|
records = raw.reset_index()
|
|
instrument_col, datetime_col, close_col = _infer_columns(records)
|
|
records[datetime_col] = pandas.to_datetime(records[datetime_col])
|
|
records = records.sort_values([instrument_col, datetime_col])
|
|
|
|
rows = []
|
|
start_bound = pandas.Timestamp(start_time)
|
|
end_bound = pandas.Timestamp(end_time)
|
|
for instrument, group in records.groupby(instrument_col, sort=True):
|
|
prices = deque(maxlen=int(lookback) + int(skip) + 1)
|
|
for _, row in group.iterrows():
|
|
value = row[close_col]
|
|
if value is None or (
|
|
isinstance(value, float) and not math.isfinite(value)
|
|
):
|
|
prices.append(float("nan"))
|
|
continue
|
|
prices.append(float(value))
|
|
score = momentum_score(prices, lookback=lookback, skip=skip)
|
|
when = pandas.Timestamp(row[datetime_col])
|
|
if score is None or when < start_bound or when > end_bound:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"datetime": when,
|
|
"instrument": str(instrument),
|
|
"score": float(score),
|
|
}
|
|
)
|
|
if not rows:
|
|
raise QlibUnavailableError(
|
|
"no momentum signal was produced; provider history may be shorter "
|
|
"than lookback + skip + 1"
|
|
)
|
|
signal = pandas.DataFrame(rows)
|
|
if rebalance == "weekly":
|
|
# Select the true first provider session of each week, rather than
|
|
# the first date on which a warmed-up signal happens to exist.
|
|
calendar = records[[datetime_col]].drop_duplicates().copy()
|
|
calendar = calendar[
|
|
(calendar[datetime_col] >= start_bound)
|
|
& (calendar[datetime_col] <= end_bound)
|
|
]
|
|
calendar["_week"] = calendar[datetime_col].dt.to_period("W")
|
|
first_dates = set(
|
|
calendar.groupby("_week")[datetime_col].min().tolist()
|
|
)
|
|
signal = signal[signal["datetime"].isin(first_dates)]
|
|
if signal.empty:
|
|
raise QlibUnavailableError(
|
|
"no signal exists on a first weekly provider session"
|
|
)
|
|
return signal.set_index(["datetime", "instrument"]).sort_index()["score"]
|
|
|
|
|
|
def run_native_momentum_backtest(
|
|
*,
|
|
provider_uri: str,
|
|
market: str,
|
|
benchmark: str,
|
|
start_time: str,
|
|
end_time: str,
|
|
feature_start_time: Optional[str] = None,
|
|
lookback: int = 20,
|
|
skip: int = 0,
|
|
rebalance: str = "weekly",
|
|
topk: int = 50,
|
|
n_drop: int = 5,
|
|
account: float = 10_000_000.0,
|
|
deal_price: str = "open",
|
|
open_cost: float = 0.0003,
|
|
close_cost: float = 0.0008,
|
|
min_cost: float = 5.0,
|
|
limit_threshold: float = 0.095,
|
|
) -> Dict[str, Any]:
|
|
"""Run the official SimulatorExecutor + TopkDropoutStrategy stack."""
|
|
provider = _validate_provider(provider_uri)
|
|
api = _load_qlib()
|
|
constant = importlib.import_module("qlib.constant")
|
|
api["qlib"].init(provider_uri=provider, region=constant.REG_CN)
|
|
signal = generate_portable_momentum_signal(
|
|
api["D"],
|
|
market,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
feature_start_time=feature_start_time,
|
|
lookback=lookback,
|
|
skip=skip,
|
|
rebalance=rebalance,
|
|
)
|
|
strategy = api["TopkDropoutStrategy"](
|
|
signal=signal,
|
|
topk=int(topk),
|
|
n_drop=int(n_drop),
|
|
hold_thresh=1,
|
|
only_tradable=True,
|
|
forbid_all_trade_at_limit=False,
|
|
)
|
|
executor = api["SimulatorExecutor"](
|
|
time_per_step="day",
|
|
generate_portfolio_metrics=True,
|
|
)
|
|
portfolio_metrics, indicators = api["backtest"](
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
strategy=strategy,
|
|
executor=executor,
|
|
account=float(account),
|
|
benchmark=benchmark,
|
|
exchange_kwargs={
|
|
"freq": "day",
|
|
"limit_threshold": float(limit_threshold),
|
|
"deal_price": deal_price,
|
|
"open_cost": float(open_cost),
|
|
"close_cost": float(close_cost),
|
|
"min_cost": float(min_cost),
|
|
},
|
|
)
|
|
return {
|
|
"qlib_version": api["version"],
|
|
"signal": signal,
|
|
"portfolio_metrics": portfolio_metrics,
|
|
"indicators": indicators,
|
|
"clock_contract": (
|
|
"weekly_first_provider_session_close_to_next_engine_step"
|
|
if rebalance == "weekly"
|
|
else "T_close_signal_to_T_plus_1_engine_step"
|
|
),
|
|
"strategy_fidelity": (
|
|
"portable_signal_only_research_bridge_not_quant60_"
|
|
"target_or_order_parity"
|
|
),
|
|
"a_share_rule_fidelity": (
|
|
"uniform_limit_threshold_approximation_not_point_in_time_"
|
|
"board_or_ST_rules"
|
|
),
|
|
}
|
|
|
|
|
|
def build_alpha158_lightgbm_task(
|
|
*,
|
|
market: str,
|
|
train: Sequence[str],
|
|
valid: Sequence[str],
|
|
test: Sequence[str],
|
|
topk: int = 50,
|
|
n_drop: int = 5,
|
|
benchmark: str = "SH000300",
|
|
account: float = 10_000_000.0,
|
|
) -> Dict[str, Any]:
|
|
"""Return a Qlib 0.9.7 task plus official portfolio analysis config."""
|
|
if not (len(train) == len(valid) == len(test) == 2):
|
|
raise ValueError("train/valid/test must each contain start and end")
|
|
handler = {
|
|
"start_time": train[0],
|
|
"end_time": test[1],
|
|
"fit_start_time": train[0],
|
|
"fit_end_time": train[1],
|
|
"instruments": market,
|
|
}
|
|
task = {
|
|
"model": {
|
|
"class": "LGBModel",
|
|
"module_path": "qlib.contrib.model.gbdt",
|
|
"kwargs": {
|
|
"loss": "mse",
|
|
"learning_rate": 0.05,
|
|
"num_leaves": 64,
|
|
"max_depth": 8,
|
|
"subsample": 0.9,
|
|
"colsample_bytree": 0.9,
|
|
"lambda_l1": 1.0,
|
|
"lambda_l2": 1.0,
|
|
"num_threads": 4,
|
|
},
|
|
},
|
|
"dataset": {
|
|
"class": "DatasetH",
|
|
"module_path": "qlib.data.dataset",
|
|
"kwargs": {
|
|
"handler": {
|
|
"class": "Alpha158",
|
|
"module_path": "qlib.contrib.data.handler",
|
|
"kwargs": handler,
|
|
},
|
|
"segments": {
|
|
"train": tuple(train),
|
|
"valid": tuple(valid),
|
|
"test": tuple(test),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
portfolio_analysis = {
|
|
"executor": {
|
|
"class": "SimulatorExecutor",
|
|
"module_path": "qlib.backtest.executor",
|
|
"kwargs": {
|
|
"time_per_step": "day",
|
|
"generate_portfolio_metrics": True,
|
|
},
|
|
},
|
|
"strategy": {
|
|
"class": "TopkDropoutStrategy",
|
|
"module_path": "qlib.contrib.strategy.signal_strategy",
|
|
"kwargs": {
|
|
# The runner replaces this with the actual (model, dataset).
|
|
"signal": None,
|
|
"topk": int(topk),
|
|
"n_drop": int(n_drop),
|
|
},
|
|
},
|
|
"backtest": {
|
|
"start_time": test[0],
|
|
"end_time": test[1],
|
|
"account": float(account),
|
|
"benchmark": benchmark,
|
|
"exchange_kwargs": {
|
|
"freq": "day",
|
|
"limit_threshold": 0.095,
|
|
"deal_price": "open",
|
|
"open_cost": 0.0003,
|
|
"close_cost": 0.0008,
|
|
"min_cost": 5.0,
|
|
},
|
|
},
|
|
}
|
|
return {"task": task, "portfolio_analysis": portfolio_analysis}
|
|
|
|
|
|
def run_alpha158_lightgbm_workflow(
|
|
*,
|
|
provider_uri: str,
|
|
market: str,
|
|
benchmark: str,
|
|
train: Sequence[str],
|
|
valid: Sequence[str],
|
|
test: Sequence[str],
|
|
experiment_name: str = "quant60_alpha158_lightgbm",
|
|
topk: int = 50,
|
|
n_drop: int = 5,
|
|
) -> Dict[str, Any]:
|
|
"""Fit, record signals and run PortAnaRecord with official Qlib APIs."""
|
|
provider = _validate_provider(provider_uri)
|
|
# Qlib 0.9.7 defaults to a local MLflow file store. MLflow 3.14 requires
|
|
# an explicit acknowledgement before it will open that backend. Keep the
|
|
# acknowledgement local to this process; a caller-provided tracking
|
|
# configuration still owns the actual destination.
|
|
os.environ.setdefault("MLFLOW_ALLOW_FILE_STORE", "true")
|
|
api = _load_qlib()
|
|
constant = importlib.import_module("qlib.constant")
|
|
utils = importlib.import_module("qlib.utils")
|
|
workflow = importlib.import_module("qlib.workflow")
|
|
records = importlib.import_module("qlib.workflow.record_temp")
|
|
api["qlib"].init(provider_uri=provider, region=constant.REG_CN)
|
|
|
|
config = build_alpha158_lightgbm_task(
|
|
market=market,
|
|
benchmark=benchmark,
|
|
train=train,
|
|
valid=valid,
|
|
test=test,
|
|
topk=topk,
|
|
n_drop=n_drop,
|
|
)
|
|
model = utils.init_instance_by_config(config["task"]["model"])
|
|
dataset = utils.init_instance_by_config(config["task"]["dataset"])
|
|
config["portfolio_analysis"]["strategy"]["kwargs"]["signal"] = (model, dataset)
|
|
|
|
with workflow.R.start(experiment_name=experiment_name):
|
|
model.fit(dataset)
|
|
workflow.R.save_objects(**{"trained_model.pkl": model})
|
|
recorder = workflow.R.get_recorder()
|
|
records.SignalRecord(model, dataset, recorder).generate()
|
|
records.SigAnaRecord(recorder).generate()
|
|
portfolio_record = records.PortAnaRecord(
|
|
recorder,
|
|
config["portfolio_analysis"],
|
|
"day",
|
|
)
|
|
portfolio_record.generate()
|
|
try:
|
|
report = recorder.load_object(
|
|
"portfolio_analysis/report_normal_1day.pkl"
|
|
)
|
|
except Exception as exc:
|
|
raise QlibUnavailableError(
|
|
"PortAnaRecord completed without a readable 1day report"
|
|
) from exc
|
|
report_audit = _table_audit(report)
|
|
if report_audit is None:
|
|
raise QlibUnavailableError(
|
|
"Qlib portfolio report is not a pandas-compatible table"
|
|
)
|
|
recorded_metrics = {
|
|
str(key): float(value)
|
|
for key, value in sorted(recorder.list_metrics().items())
|
|
if math.isfinite(float(value))
|
|
}
|
|
artifact_paths = sorted(
|
|
str(value)
|
|
for value in recorder.list_artifacts("portfolio_analysis")
|
|
)
|
|
recorder_id = getattr(recorder, "id", None)
|
|
return {
|
|
"qlib_version": api["version"],
|
|
"experiment_name": experiment_name,
|
|
"recorder_id": recorder_id,
|
|
"mlflow_local_file_store_explicitly_allowed": (
|
|
os.environ.get("MLFLOW_ALLOW_FILE_STORE") == "true"
|
|
),
|
|
"task": config["task"],
|
|
"portfolio_analysis": config["portfolio_analysis"],
|
|
"recorder_evidence": {
|
|
"portfolio_report": report_audit,
|
|
"recorded_metrics": recorded_metrics,
|
|
"portfolio_artifact_paths": artifact_paths,
|
|
},
|
|
}
|
|
|
|
|
|
def _canonical_cli_date(value: Optional[str], *, name: str) -> str:
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
raise ValueError(f"{name} is required")
|
|
try:
|
|
parsed = dt.date.fromisoformat(raw)
|
|
except ValueError as exc:
|
|
raise ValueError(f"{name} must be an ISO date (YYYY-MM-DD)") from exc
|
|
if parsed.isoformat() != raw:
|
|
raise ValueError(f"{name} must be an ISO date (YYYY-MM-DD)")
|
|
return raw
|
|
|
|
|
|
def _validate_nonempty(value: str, *, name: str) -> str:
|
|
normalized = str(value or "").strip()
|
|
if not normalized:
|
|
raise ValueError(f"{name} must not be empty")
|
|
return normalized
|
|
|
|
|
|
def _validate_common_cli_args(args: argparse.Namespace) -> None:
|
|
args.provider_uri = _validate_nonempty(
|
|
args.provider_uri,
|
|
name="provider-uri",
|
|
)
|
|
args.market = _validate_nonempty(args.market, name="market")
|
|
args.benchmark = _validate_nonempty(args.benchmark, name="benchmark")
|
|
if args.topk <= 0:
|
|
raise ValueError("topk must be greater than zero")
|
|
if args.n_drop < 0:
|
|
raise ValueError("n-drop must be zero or greater")
|
|
if args.n_drop > args.topk:
|
|
raise ValueError("n-drop must not exceed topk")
|
|
if args.output_json is not None:
|
|
args.output_json = _validate_nonempty(
|
|
args.output_json,
|
|
name="output-json",
|
|
)
|
|
|
|
|
|
def _validated_momentum_dates(args: argparse.Namespace) -> tuple[str, str, Optional[str]]:
|
|
start = _canonical_cli_date(args.start, name="start")
|
|
end = _canonical_cli_date(args.end, name="end")
|
|
if start > end:
|
|
raise ValueError("start must be on or before end")
|
|
feature_start = None
|
|
if args.feature_start is not None:
|
|
feature_start = _canonical_cli_date(
|
|
args.feature_start,
|
|
name="feature-start",
|
|
)
|
|
if feature_start > start:
|
|
raise ValueError("feature-start must be on or before start")
|
|
if args.lookback <= 0:
|
|
raise ValueError("lookback must be greater than zero")
|
|
if args.skip < 0:
|
|
raise ValueError("skip must be zero or greater")
|
|
return start, end, feature_start
|
|
|
|
|
|
def _validated_alpha158_segments(
|
|
args: argparse.Namespace,
|
|
) -> tuple[tuple[str, str], tuple[str, str], tuple[str, str]]:
|
|
train = (
|
|
_canonical_cli_date(args.train_start, name="train-start"),
|
|
_canonical_cli_date(args.train_end, name="train-end"),
|
|
)
|
|
valid = (
|
|
_canonical_cli_date(args.valid_start, name="valid-start"),
|
|
_canonical_cli_date(args.valid_end, name="valid-end"),
|
|
)
|
|
test = (
|
|
_canonical_cli_date(args.test_start, name="test-start"),
|
|
_canonical_cli_date(args.test_end, name="test-end"),
|
|
)
|
|
for name, segment in (("train", train), ("valid", valid), ("test", test)):
|
|
if segment[0] > segment[1]:
|
|
raise ValueError(f"{name}-start must be on or before {name}-end")
|
|
if train[1] >= valid[0]:
|
|
raise ValueError("train and valid segments must be ordered and non-overlapping")
|
|
if valid[1] >= test[0]:
|
|
raise ValueError("valid and test segments must be ordered and non-overlapping")
|
|
args.experiment_name = _validate_nonempty(
|
|
args.experiment_name,
|
|
name="experiment-name",
|
|
)
|
|
return train, valid, test
|
|
|
|
|
|
def _portfolio_frequencies(portfolio_metrics: Any) -> list[str]:
|
|
if isinstance(portfolio_metrics, Mapping):
|
|
values = portfolio_metrics.keys()
|
|
elif isinstance(portfolio_metrics, (list, tuple, set, frozenset)):
|
|
values = portfolio_metrics
|
|
else:
|
|
values = ()
|
|
return sorted(str(value) for value in values)
|
|
|
|
|
|
def _momentum_cli_summary(result: Mapping[str, Any]) -> Dict[str, Any]:
|
|
signal = result.get("signal")
|
|
signal_table = (
|
|
signal.to_frame(name="score")
|
|
if callable(getattr(signal, "to_frame", None))
|
|
else None
|
|
)
|
|
return {
|
|
"workflow": "momentum",
|
|
"qlib_version": str(result.get("qlib_version", "unknown")),
|
|
"signal_rows": len(signal) if signal is not None else 0,
|
|
"portfolio_frequencies": _portfolio_frequencies(
|
|
result.get("portfolio_metrics")
|
|
),
|
|
"signal_evidence": _table_audit(signal_table),
|
|
"portfolio_report_evidence": _mapping_table_audits(
|
|
result.get("portfolio_metrics")
|
|
),
|
|
"indicator_evidence": _mapping_table_audits(
|
|
result.get("indicators")
|
|
),
|
|
"clock_contract": result.get("clock_contract"),
|
|
"strategy_fidelity": result.get("strategy_fidelity"),
|
|
"a_share_rule_fidelity": result.get("a_share_rule_fidelity"),
|
|
"investment_value_claim": False,
|
|
}
|
|
|
|
|
|
def _alpha158_cli_summary(
|
|
result: Mapping[str, Any],
|
|
*,
|
|
market: str,
|
|
benchmark: str,
|
|
train: Sequence[str],
|
|
valid: Sequence[str],
|
|
test: Sequence[str],
|
|
) -> Dict[str, Any]:
|
|
recorder_id = result.get("recorder_id")
|
|
return {
|
|
"workflow": "alpha158",
|
|
"qlib_version": str(result.get("qlib_version", "unknown")),
|
|
"experiment_name": str(result.get("experiment_name", "")),
|
|
"recorder_id": None if recorder_id is None else str(recorder_id),
|
|
"mlflow_local_file_store_explicitly_allowed": bool(
|
|
result.get("mlflow_local_file_store_explicitly_allowed", False)
|
|
),
|
|
"market": market,
|
|
"benchmark": benchmark,
|
|
"segments": {
|
|
"train": list(train),
|
|
"valid": list(valid),
|
|
"test": list(test),
|
|
},
|
|
"recorder_evidence": result.get("recorder_evidence"),
|
|
"investment_value_claim": False,
|
|
}
|
|
|
|
|
|
def _write_cli_json(path: str, payload: Mapping[str, Any]) -> None:
|
|
destination = Path(path).expanduser().resolve()
|
|
if destination.exists() and destination.is_dir():
|
|
raise ValueError(f"output-json points to a directory: {destination}")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
encoded = (
|
|
json.dumps(
|
|
dict(payload),
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
sort_keys=True,
|
|
allow_nan=False,
|
|
)
|
|
+ "\n"
|
|
).encode("utf-8")
|
|
descriptor, temporary = tempfile.mkstemp(
|
|
prefix=f".{destination.name}.",
|
|
suffix=".tmp",
|
|
dir=str(destination.parent),
|
|
)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as handle:
|
|
handle.write(encoded)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary, destination)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(temporary)
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def _main(argv: Optional[list[str]] = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--workflow",
|
|
choices=("momentum", "alpha158"),
|
|
default="momentum",
|
|
help="default momentum preserves the original CLI behavior",
|
|
)
|
|
parser.add_argument("--provider-uri", required=True)
|
|
parser.add_argument("--market", default="csi300")
|
|
parser.add_argument("--benchmark", default="SH000300")
|
|
parser.add_argument("--start")
|
|
parser.add_argument("--end")
|
|
parser.add_argument("--feature-start")
|
|
parser.add_argument("--lookback", type=int, default=20)
|
|
parser.add_argument("--skip", type=int, default=0)
|
|
parser.add_argument("--topk", type=int, default=50)
|
|
parser.add_argument("--n-drop", type=int, default=5)
|
|
parser.add_argument("--rebalance", choices=("daily", "weekly"), default="weekly")
|
|
parser.add_argument("--train-start")
|
|
parser.add_argument("--train-end")
|
|
parser.add_argument("--valid-start")
|
|
parser.add_argument("--valid-end")
|
|
parser.add_argument("--test-start")
|
|
parser.add_argument("--test-end")
|
|
parser.add_argument(
|
|
"--experiment-name",
|
|
default="quant60_alpha158_lightgbm",
|
|
)
|
|
parser.add_argument(
|
|
"--output-json",
|
|
"--result-json",
|
|
dest="output_json",
|
|
help="optionally save the same JSON summary printed to stdout",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
_validate_common_cli_args(args)
|
|
if args.workflow == "momentum":
|
|
if any(
|
|
value is not None
|
|
for value in (
|
|
args.train_start,
|
|
args.train_end,
|
|
args.valid_start,
|
|
args.valid_end,
|
|
args.test_start,
|
|
args.test_end,
|
|
)
|
|
):
|
|
raise ValueError(
|
|
"train/valid/test segment arguments are only valid for "
|
|
"workflow=alpha158"
|
|
)
|
|
start, end, feature_start = _validated_momentum_dates(args)
|
|
result = run_native_momentum_backtest(
|
|
provider_uri=args.provider_uri,
|
|
market=args.market,
|
|
benchmark=args.benchmark,
|
|
start_time=start,
|
|
end_time=end,
|
|
feature_start_time=feature_start,
|
|
lookback=args.lookback,
|
|
skip=args.skip,
|
|
topk=args.topk,
|
|
n_drop=args.n_drop,
|
|
rebalance=args.rebalance,
|
|
)
|
|
summary = _momentum_cli_summary(result)
|
|
else:
|
|
if any(
|
|
value is not None
|
|
for value in (args.start, args.end, args.feature_start)
|
|
):
|
|
raise ValueError(
|
|
"start/end/feature-start are only valid for "
|
|
"workflow=momentum"
|
|
)
|
|
train, valid, test = _validated_alpha158_segments(args)
|
|
result = run_alpha158_lightgbm_workflow(
|
|
provider_uri=args.provider_uri,
|
|
market=args.market,
|
|
benchmark=args.benchmark,
|
|
train=train,
|
|
valid=valid,
|
|
test=test,
|
|
experiment_name=args.experiment_name,
|
|
topk=args.topk,
|
|
n_drop=args.n_drop,
|
|
)
|
|
summary = _alpha158_cli_summary(
|
|
result,
|
|
market=args.market,
|
|
benchmark=args.benchmark,
|
|
train=train,
|
|
valid=valid,
|
|
test=test,
|
|
)
|
|
if args.output_json:
|
|
_write_cli_json(args.output_json, summary)
|
|
except (OSError, ValueError) as exc:
|
|
parser.error(str(exc))
|
|
print(
|
|
json.dumps(
|
|
summary,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(_main())
|