File size: 6,280 Bytes
590a501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
"""Unified strategy backtest runner."""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from contextlib import contextmanager

import pandas as pd

from config.settings import load_settings
from data_pipeline.factor_loader import build_signal_from_source
from data_pipeline.init_qlib import init_qlib
from qlib.backtest import backtest_loop, get_strategy_executor
from qlib.contrib.evaluate import risk_analysis
from strategies.registry import resolve_strategy


@dataclass
class StrategyRunResult:
    strategy_name: str
    report: pd.DataFrame
    positions: dict
    risk: pd.DataFrame
    signal_stats: dict[str, Any] = field(default_factory=dict)
    meta: dict[str, Any] = field(default_factory=dict)


def _build_exchange_kwargs(backtest_cfg: dict[str, Any], freq: str = "day") -> dict[str, Any]:
    return {
        "freq": freq,
        "limit_threshold": backtest_cfg.get("limit_threshold", 0.095),
        "deal_price": backtest_cfg.get("deal_price", "close"),
        "open_cost": backtest_cfg.get("open_cost", 0.0005),
        "close_cost": backtest_cfg.get("close_cost", 0.0015),
        "min_cost": backtest_cfg.get("min_cost", 5),
    }


@contextmanager
def _disable_qlib_benchmark_default():
    """
    qlib passes benchmark=None as {} and PortfolioMetrics then defaults to CSI300,
    which breaks 30min-only datasets. Treat empty config as no benchmark.
    """
    from qlib.backtest.report import PortfolioMetrics

    original = PortfolioMetrics._cal_benchmark

    @staticmethod
    def _cal_benchmark_no_default(benchmark_config, freq):
        if not benchmark_config or benchmark_config.get("benchmark") is None:
            return None
        return original(benchmark_config, freq)

    PortfolioMetrics._cal_benchmark = _cal_benchmark_no_default
    try:
        yield
    finally:
        PortfolioMetrics._cal_benchmark = original


def _run_qlib_backtest(
    *,
    start_time: str,
    end_time: str,
    strategy,
    executor_config: dict[str, Any],
    account: float | int,
    benchmark: str | None,
    exchange_kwargs: dict[str, Any],
):
    """Run qlib backtest; disable benchmark when None (qlib defaults empty config to CSI300)."""
    with _disable_qlib_benchmark_default():
        trade_strategy, trade_executor = get_strategy_executor(
            start_time=start_time,
            end_time=end_time,
            strategy=strategy,
            executor=executor_config,
            benchmark=benchmark,
            account=account,
            exchange_kwargs=exchange_kwargs,
        )
    return backtest_loop(start_time, end_time, trade_strategy, trade_executor)


def run_strategy_backtest(
    strategy_name: str,
    signal_source: dict[str, Any],
    strategy_kwargs: dict[str, Any] | None = None,
    start_time: str | None = None,
    end_time: str | None = None,
    config_path: str | None = None,
) -> StrategyRunResult:
    settings = load_settings(config_path)
    init_qlib(config_path)
    bt = settings.backtest_config
    data_freq = settings.backtest_freq

    signal = build_signal_from_source(signal_source)
    if signal.index.names != ["instrument", "datetime"]:
        signal = signal.swaplevel().sort_index()
        signal.index.names = ["instrument", "datetime"]

    strategy = resolve_strategy(strategy_name, signal, strategy_kwargs)

    start_time = start_time or settings.segments.get("test", settings.segments["valid"])[0]
    end_time = end_time or settings.segments.get("test", settings.segments["valid"])[1]

    executor_config = {
        "class": "SimulatorExecutor",
        "module_path": "qlib.backtest.executor",
        "kwargs": {
            "time_per_step": data_freq,
            "generate_portfolio_metrics": True,
        },
    }

    benchmark = bt.get("benchmark", settings.benchmark)
    if benchmark in (None, "null", "none", ""):
        benchmark = None

    portfolio_metric_dict, indicator_dict = _run_qlib_backtest(
        start_time=start_time,
        end_time=end_time,
        strategy=strategy,
        executor_config=executor_config,
        account=bt.get("account", 100_000_000),
        benchmark=benchmark,
        exchange_kwargs=_build_exchange_kwargs(bt, freq=data_freq),
    )

    freq_key = next(iter(portfolio_metric_dict.keys()))
    report, positions = portfolio_metric_dict[freq_key]

    risk = risk_analysis(report["return"]) if "return" in report.columns else pd.DataFrame()
    signal_stats = {
        "n_obs": int(signal.notna().sum()),
        "n_instruments": int(signal.index.get_level_values("instrument").nunique()),
        "date_range": [str(signal.index.get_level_values("datetime").min()), str(signal.index.get_level_values("datetime").max())],
    }

    return StrategyRunResult(
        strategy_name=strategy_name,
        report=report,
        positions=positions,
        risk=risk,
        signal_stats=signal_stats,
        meta={"start_time": start_time, "end_time": end_time, "benchmark": benchmark, "freq": freq_key},
    )


def run_strategy_suite(
    signal_source: dict[str, Any],
    strategy_names: list[str],
    start_time: str | None = None,
    end_time: str | None = None,
    config_path: str | None = None,
) -> dict[str, StrategyRunResult]:
    results = {}
    for name in strategy_names:
        results[name] = run_strategy_backtest(
            strategy_name=name,
            signal_source=signal_source,
            start_time=start_time,
            end_time=end_time,
            config_path=config_path,
        )
    return results


def save_backtest_result(result: StrategyRunResult, output_dir: str | Path) -> Path:
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    result.report.to_csv(output_dir / f"{result.strategy_name}_report.csv")
    if not result.risk.empty:
        result.risk.to_csv(output_dir / f"{result.strategy_name}_risk.csv")
    summary = {
        "strategy": result.strategy_name,
        "signal_stats": result.signal_stats,
        "meta": result.meta,
    }
    with open(output_dir / f"{result.strategy_name}_summary.json", "w", encoding="utf-8") as f:
        json.dump(summary, f, indent=2, ensure_ascii=False)
    return output_dir