"""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 sys 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 _runner_evidence() -> Dict[str, Any]: source = Path(__file__).resolve().read_bytes() return { "runner_source_sha256": hashlib.sha256(source).hexdigest(), "python_version": ( f"{sys.version_info.major}." f"{sys.version_info.minor}." f"{sys.version_info.micro}" ), "python_hash_seed": os.environ.get("PYTHONHASHSEED"), } 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 _managed_provider_evidence(provider_uri: str) -> Dict[str, Any]: """Verify and summarize a Quant OS-managed local provider when present.""" if "://" in provider_uri: return { "managed": False, "manifest_name": None, "verification": "remote provider URI; no local manifest", } root = Path(provider_uri) manifest_path = root / "quant_os_tushare_manifest.json" if not manifest_path.is_file(): return { "managed": False, "manifest_name": None, "verification": "no Quant OS provider manifest", } try: from adapters.tushare_local import verify_qlib_provider verification = verify_qlib_provider(root) manifest_bytes = manifest_path.read_bytes() manifest = json.loads(manifest_bytes) except Exception as exc: raise QlibUnavailableError( f"managed Qlib provider verification failed: {exc}" ) from exc if not isinstance(manifest, Mapping): raise QlibUnavailableError( "managed Qlib provider manifest must be a JSON object" ) evidence: Dict[str, Any] = { "managed": True, "manifest_name": manifest_path.name, "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), "data_version": manifest.get("data_version"), "provider_tree_sha256": manifest.get("provider_tree_sha256"), "source": manifest.get("source", "tushare_local_mirror"), "converter": manifest.get("converter"), "build_parameters": manifest.get("build_parameters"), "calendar": manifest.get("calendar"), "market": manifest.get("market"), "benchmark": manifest.get("benchmark"), "price_adjustment": manifest.get("price_adjustment"), "investment_value_claim": False, } if isinstance(verification, Mapping): evidence["verified_file_count"] = verification.get( "provider_file_count", verification.get("file_count"), ) return evidence def _validate_managed_provider_request( provider_evidence: Mapping[str, Any], *, market: str, benchmark: str, start_time: str, end_time: str, ) -> None: """Fail early on managed-provider mismatches and terminal-calendar use.""" if not provider_evidence.get("managed"): return provider_market = provider_evidence.get("market") if isinstance(provider_market, Mapping): expected_market = str(provider_market.get("name") or "").strip() if expected_market and market != expected_market: raise ValueError( f"managed provider market is {expected_market!r}, " f"not {market!r}" ) provider_benchmark = provider_evidence.get("benchmark") if isinstance(provider_benchmark, Mapping): expected_benchmark = str( provider_benchmark.get("symbol") or "" ).strip() if expected_benchmark and benchmark != expected_benchmark: raise ValueError( f"managed provider benchmark is {expected_benchmark!r}, " f"not {benchmark!r}" ) calendar = provider_evidence.get("calendar") if not isinstance(calendar, Mapping): raise QlibUnavailableError( "managed provider manifest has no calendar evidence" ) try: calendar_start = dt.date.fromisoformat(str(calendar["start"])) calendar_end = dt.date.fromisoformat(str(calendar["end"])) requested_start = dt.date.fromisoformat(str(start_time)[:10]) requested_end = dt.date.fromisoformat(str(end_time)[:10]) except (KeyError, TypeError, ValueError) as exc: raise QlibUnavailableError( "managed provider calendar/request dates must use YYYY-MM-DD" ) from exc if requested_start < calendar_start: raise ValueError( f"start_time {requested_start} precedes managed provider " f"calendar start {calendar_start}" ) if requested_end >= calendar_end: raise ValueError( f"end_time must be before managed provider terminal session " f"{calendar_end}; Qlib's simulator consumes the next provider " "session. Rebuild with a later calendar end or choose the " "previous open session." ) 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) provider_evidence = _managed_provider_evidence(provider) _validate_managed_provider_request( provider_evidence, market=market, benchmark=benchmark, start_time=start_time, end_time=end_time, ) resolved_feature_start = feature_start_time if resolved_feature_start is None: start = dt.datetime.fromisoformat(str(start_time)[:10]) padding_days = max(14, 3 * (int(lookback) + int(skip) + 2)) resolved_feature_start = ( start - dt.timedelta(days=padding_days) ).date().isoformat() 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=resolved_feature_start, 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" ), "run_parameters": { "market": market, "benchmark": benchmark, "start_time": start_time, "end_time": end_time, "feature_start_time": resolved_feature_start, "lookback": int(lookback), "skip": int(skip), "rebalance": rebalance, "topk": int(topk), "n_drop": int(n_drop), "account": float(account), "deal_price": deal_price, "open_cost": float(open_cost), "close_cost": float(close_cost), "min_cost": float(min_cost), "limit_threshold": float(limit_threshold), }, "runner_evidence": _runner_evidence(), "provider_evidence": provider_evidence, } 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) provider_evidence = _managed_provider_evidence(provider) _validate_managed_provider_request( provider_evidence, market=market, benchmark=benchmark, start_time=train[0], end_time=test[1], ) # 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, }, "runner_evidence": _runner_evidence(), "provider_evidence": provider_evidence, } 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"), "run_parameters": result.get("run_parameters"), "runner_evidence": result.get("runner_evidence"), "provider_evidence": result.get("provider_evidence"), "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"), "runner_evidence": result.get("runner_evidence"), "provider_evidence": result.get("provider_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())