feat(strategy): project ledger and complete optimization reports
CI / lite (pull_request) Canceled after 0s
CI / lite (pull_request) Canceled after 0s
This commit is contained in:
@@ -32,7 +32,7 @@
|
|||||||
- `retrospective_*_contracts` — 未发布的显式 v2 回顾性合同:区分历史业务日期与实际可得/计算时间,保留 v1 和现有金融公式,不授予历史可得性、发布或执行权限;见 [v2 接口说明](docs/RETROSPECTIVE_COMPUTATION_V2.md)
|
- `retrospective_*_contracts` — 未发布的显式 v2 回顾性合同:区分历史业务日期与实际可得/计算时间,保留 v1 和现有金融公式,不授予历史可得性、发布或执行权限;见 [v2 接口说明](docs/RETROSPECTIVE_COMPUTATION_V2.md)
|
||||||
- `attribution` — 基于实际成交后持仓的隔夜 / 日内 / 交易成本逐日收益归因与闭合审计
|
- `attribution` — 基于实际成交后持仓的隔夜 / 日内 / 交易成本逐日收益归因与闭合审计
|
||||||
- `metrics` — 绝对绩效 + 严格日期对齐的 TE / IR / alpha / beta 基准相对绩效
|
- `metrics` — 绝对绩效 + 严格日期对齐的 TE / IR / alpha / beta 基准相对绩效
|
||||||
- `strategy_research` / `strategy_optimizer` / `trade_pairing` — 七策略候选、下一日开盘执行、统一账本、FIFO双边成本和有界真实优化;见 [策略合同](docs/strategy-research.md)
|
- `strategy_research` / `strategy_optimizer` / `trade_pairing` / `strategy_artifact` — 七策略候选、下一日开盘执行、统一账本、FIFO双边成本、有界真实优化和完整策略报告工件;见 [策略合同](docs/strategy-research.md)
|
||||||
- `factor_diagnostics` — 候选0.1.0:完整键配对、逐日IC/RankIC、样本与未定义值、显式日历前瞻标签;见 [诊断合同](docs/factor-diagnostics.md)
|
- `factor_diagnostics` — 候选0.1.0:完整键配对、逐日IC/RankIC、样本与未定义值、显式日历前瞻标签;见 [诊断合同](docs/factor-diagnostics.md)
|
||||||
- `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test)
|
- `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test)
|
||||||
- `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因)
|
- `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Seven-strategy candidate research
|
# Seven-strategy candidate research
|
||||||
|
|
||||||
The core owns the seven example strategies, sequential decisions, shared execution ledger, FIFO trade pairing and bounded exhaustive optimization. The platform supplies one selected asset's OHLC and displays results. These pure functions perform no source reads, persistence, publication or order routing. `decision_eligible` remains false. Caller dates do not establish an exchange calendar or historical data availability.
|
The core owns the seven example strategies, sequential decisions, shared execution ledger, FIFO trade pairing and bounded exhaustive optimization. The platform supplies one selected asset's OHLC and displays results. The reserved asset identity `CASH` cannot be used as a security, preventing collision with projected cash positions. These pure functions perform no source reads, persistence, publication or order routing. `decision_eligible` remains false. Caller dates do not establish an exchange calendar or historical data availability.
|
||||||
|
|
||||||
## Reuse and scope
|
## Reuse and scope
|
||||||
|
|
||||||
@@ -55,6 +55,16 @@ Other absolute measures reuse `metrics.summary`: 252 supplied trading sessions p
|
|||||||
|
|
||||||
`optimize_strategy_research` validates the entire grid before any trial. It allows at most four axes, ten candidates per axis and 100 combinations. Empty axes, unknown keys, duplicate values, non-native numbers, invalid relationships and any insufficient history fail the whole request. The size bound precedes Cartesian expansion. Every trial runs a fresh real seven-strategy pipeline and ledger with explicit fees. Targets are `total_return`, `sharpe_ratio` or `calmar_ratio`. An unavailable objective fails the ranking instead of silently skipping a candidate. Stable descending sorting preserves canonical axis order and supplied candidate order for ties; zero and negative finite scores remain valid.
|
`optimize_strategy_research` validates the entire grid before any trial. It allows at most four axes, ten candidates per axis and 100 combinations. Empty axes, unknown keys, duplicate values, non-native numbers, invalid relationships and any insufficient history fail the whole request. The size bound precedes Cartesian expansion. Every trial runs a fresh real seven-strategy pipeline and ledger with explicit fees. Targets are `total_return`, `sharpe_ratio` or `calmar_ratio`. An unavailable objective fails the ranking instead of silently skipping a candidate. Stable descending sorting preserves canonical axis order and supplied candidate order for ties; zero and negative finite scores remain valid.
|
||||||
|
|
||||||
|
## Strategy artifact projection
|
||||||
|
|
||||||
|
`strategy_artifact.build_strategy_research_artifact` accepts an actual `StrategyResearchResult` or `StrategyOptimizationResult` and returns the existing schema1.1.0 `ResearchRunArtifact`. The calculation result retains detached OHLC and benchmark-return views and explicit cost inputs; optimization retains a detached grid snapshot. The builder projects the selected result's ledger, actual fees, next-session signal links, close-marked holdings and existing metrics. It does not run a second strategy, accounting system or performance formula.
|
||||||
|
|
||||||
|
`params_json.strategy_report` uses `researchhub.strategy-research.v1`. It covers every causal signal, including `no_next_session`, unfilled targets and partial fills; complete FIFO matches, closed/open lots and net PnL; daily versus completed-trade win rates; benchmark status; exact parameters/costs; and all ranked candidate summaries. A grid artifact stores the selected first-ranked ledger plus every candidate's parameters, objective score, metrics and missing reasons, up to100 trials. It does not repeat100 full ledgers. Report contents are covered by the artifact's canonical content digest. User metadata cannot overwrite this report or its performance explanation.
|
||||||
|
|
||||||
|
The legacy factor-signal table remains empty: strategy signals have no factor score. The report explicitly locates the real strategy signals and marks factor attribution and covariance risk as not computed. Actual fills link to report signal IDs. A zero-value portfolio retains its actual zero values and an undefined weight, with the exact dates and reason recorded; it is not assigned an invented zero or full-cash weight. Non-numerical performance values carry explicit reasons keyed to their fact-table columns, and win-rate basis is completed trades. Requested empty benchmark data retains its identity and empty status, while unrequested data has no identity.
|
||||||
|
|
||||||
|
This is a storage-neutral candidate. The platform's isolated durable-file adapter must validate this report and expose undefined weights before enabling this path. This projection does not validate a production database schema, execute SQL, admit real data, or grant decision eligibility. Existing factor-artifact consumers remain unchanged.
|
||||||
|
|
||||||
## Verification scope
|
## Verification scope
|
||||||
|
|
||||||
The missing public modules and sequential policy first failed actual tests. Further actual RED→GREEN regressions cover unfilled exits, legitimate zero NAV, independent ATR/exit windows, benchmark underflow, nonfinite portfolio returns, tiny residual holdings, Bollinger scale invariance, undefined Sortino, premature FIFO closure, full-exit quantity rounding and phantom residues after three accumulated entries at prices 3, 11 and 13.
|
The missing public modules and sequential policy first failed actual tests. Further actual RED→GREEN regressions cover unfilled exits, legitimate zero NAV, independent ATR/exit windows, benchmark underflow, nonfinite portfolio returns, tiny residual holdings, Bollinger scale invariance, undefined Sortino, premature FIFO closure, full-exit quantity rounding and phantom residues after three accumulated entries at prices 3, 11 and 13.
|
||||||
@@ -64,3 +74,5 @@ Final focused verification is 93 new tests plus 85 existing execution tests, 178
|
|||||||
Two reused read-only reviewers independently exercised causal prefixes and cash/FIFO examples and found substantive defects subsequently turned into permanent regressions. The final independent FIFO review passed after reproducing three-lot full exits, retaining real tiny balances and reconciling fee-bearing add/partial-exit/full-exit PnL with final NAV. Saved revision and central delivery status are separate evidence. These tests do not establish real data coverage, source/PIT qualification, platform task publication or formal production availability.
|
Two reused read-only reviewers independently exercised causal prefixes and cash/FIFO examples and found substantive defects subsequently turned into permanent regressions. The final independent FIFO review passed after reproducing three-lot full exits, retaining real tiny balances and reconciling fee-bearing add/partial-exit/full-exit PnL with final NAV. Saved revision and central delivery status are separate evidence. These tests do not establish real data coverage, source/PIT qualification, platform task publication or formal production availability.
|
||||||
|
|
||||||
The reviewed implementation was saved and pushed as `7d3e840483d7f3b5d7b2d987c95fb79a5dc4a63b` to the existing PR #21 (https://gitea.puyuanfh.cn/ageorge156/quant_engine/pulls/21), advancing the original 3c97102f candidate. This receipt-only checkpoint does not change the tested code. One explicit central candidate refresh and `ship --ready --confirm-l3` is the next boundary; no prior CI wait was polled or resumed. Push is a save checkpoint, and does not establish merge or source admission.
|
The reviewed implementation was saved and pushed as `7d3e840483d7f3b5d7b2d987c95fb79a5dc4a63b` to the existing PR #21 (https://gitea.puyuanfh.cn/ageorge156/quant_engine/pulls/21), advancing the original 3c97102f candidate. This receipt-only checkpoint does not change the tested code. One explicit central candidate refresh and `ship --ready --confirm-l3` is the next boundary; no prior CI wait was polled or resumed. Push is a save checkpoint, and does not establish merge or source admission.
|
||||||
|
|
||||||
|
Artifact increment verification: 26 new artifact tests plus81 strategy/optimizer and7 existing factor-artifact tests passed together (114). The final complete core suite passed1212 tests in15.59s with1170 pandas deprecation warnings. Four-file Ruff and three-module strict typing passed. The read-only reviewer confirmed the reserved-CASH rejection and fact-column explanation keys after actual regressions. The earlier5192370 central receipt remains CI pending without polling; this new increment is saved and gated separately on the same delivery.
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Quant OS factor diagnostics and strategy core increments
|
# Quant OS factor diagnostics and strategy core increments
|
||||||
|
|
||||||
|
## 2026-10-04 strategy artifact increment
|
||||||
|
|
||||||
|
The existing delivery now adds a storage-neutral strategy artifact projection required by platform Draft #102. The earlier central boundary for candidate5192370a9ecfeca09ed39ee9ca3583060d8777d9 returned `waiting_on_dependency`, CI pending, receipt e28c09224685c20569a8a6f627a2c34b6a1c69f3dc43eadabe918c7458476e45. That candidate and the older3c97102f receipt remain preserved and have not been polled or resumed. This is a new independently completed code increment on the same branch, PR #21, lifecycle and sole writer.
|
||||||
|
|
||||||
|
The result keeps detached input/grid snapshots. The new builder reuses schema1.1.0 and the original ledger, projecting real fills, NAV, positions and metrics while covering all strategy signals, FIFO pairing and up to100 ranked trials in `strategy_report`. It does not fabricate factor scores, attribution or covariance risk. Zero-NAV weight is undefined with a reason. A security cannot claim reserved CASH identity. Production schema/storage and source admission remain outside this pure candidate; platform isolated validation and display still need integration.
|
||||||
|
|
||||||
|
Actual missing-module RED preceded implementation. Independent review found a reproducible cash-identity collision (run/optimizer RED) and fact-column explanation naming inconsistency (RED); both are fixed with permanent regressions and final read-only PASS. Final focused run114 passed (26 new artifact tests,81 strategy/optimizer tests,7 existing factor-artifact tests). Final whole-core run1212 passed in15.59s with1170 pandas deprecation warnings. Targeted four-file Ruff and three-source strict mypy passed. Prior financial calculations and their accepted test evidence are not recomputed by the projection. The fixed49 framework/source and delivery route remain unchanged, resume/review critical gpt-6-astra/xhigh was reused/validated, runtime observation remains unknown.
|
||||||
|
|
||||||
|
Next: save this completed increment and enter one central candidate refresh/Ready gate, then bind its saved revision in the platform isolated task/artifact path. The broad platform delivery remains WIP and formal run/optimize remains gated.
|
||||||
|
|
||||||
## 2026-10-04 seven-strategy core increment
|
## 2026-10-04 seven-strategy core increment
|
||||||
|
|
||||||
The same owner, lifecycle, branch and PR #21 now include the independent seven-strategy calculation increment needed by platform Draft #102. The original 3c97102f CI-wait receipt has not been queried, resumed or treated as resolved. New code is being reviewed as a new candidate; previous fixed-archive platform consumers remain bound to their saved revisions.
|
The same owner, lifecycle, branch and PR #21 now include the independent seven-strategy calculation increment needed by platform Draft #102. The original 3c97102f CI-wait receipt has not been queried, resumed or treated as resolved. New code is being reviewed as a new candidate; previous fixed-archive platform consumers remain bound to their saved revisions.
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""Storage-neutral projection of seven-strategy research and bounded grid rankings.
|
||||||
|
|
||||||
|
All accounting and metrics are owned by the completed core result. Strategy
|
||||||
|
signals have no factor scores, so their full causal record lives in the covered
|
||||||
|
report, not the legacy factor-signal table. No source or decision admission is
|
||||||
|
granted by this projection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .artifact import (
|
||||||
|
RESEARCH_ARTIFACT_SCHEMA_VERSION,
|
||||||
|
RISK_COLUMNS,
|
||||||
|
ResearchRunArtifact,
|
||||||
|
_aware_timestamp,
|
||||||
|
_canonical_mapping_json,
|
||||||
|
_required_text,
|
||||||
|
)
|
||||||
|
from .strategy_optimizer import StrategyOptimizationResult
|
||||||
|
from .strategy_research import StrategyResearchResult
|
||||||
|
|
||||||
|
STRATEGY_REPORT_SCHEMA = "researchhub.strategy-research.v1"
|
||||||
|
_PERFORMANCE = {
|
||||||
|
"total_ret": "total_return",
|
||||||
|
"ann_ret": "ann_return",
|
||||||
|
"ann_volatility": "ann_volatility",
|
||||||
|
"sharpe": "sharpe",
|
||||||
|
"sortino": "sortino",
|
||||||
|
"max_dd": "max_drawdown",
|
||||||
|
"calmar": "calmar",
|
||||||
|
"win_rate": "trade_win_rate",
|
||||||
|
}
|
||||||
|
_RELATIVE = {
|
||||||
|
"tracking_error": "tracking_error",
|
||||||
|
"ir": "information_ratio",
|
||||||
|
"alpha": "alpha",
|
||||||
|
"beta": "beta",
|
||||||
|
}
|
||||||
|
_SIGNAL_COLUMNS = [
|
||||||
|
"run_id",
|
||||||
|
"signal_date",
|
||||||
|
"execution_date",
|
||||||
|
"asset_id",
|
||||||
|
"factor_score",
|
||||||
|
"target_weight",
|
||||||
|
]
|
||||||
|
_POSITION_COLUMNS = [
|
||||||
|
"run_id",
|
||||||
|
"trade_date",
|
||||||
|
"asset_id",
|
||||||
|
"asset_type",
|
||||||
|
"quantity",
|
||||||
|
"mark_price",
|
||||||
|
"market_value",
|
||||||
|
"weight",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _report(
|
||||||
|
result: StrategyResearchResult,
|
||||||
|
run_id: str,
|
||||||
|
optimization: StrategyOptimizationResult | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
ranking: dict[str, object] | None = None
|
||||||
|
if optimization is not None:
|
||||||
|
ranking = {
|
||||||
|
"objective": optimization.objective,
|
||||||
|
"grid": optimization.param_grid,
|
||||||
|
"trial_count": len(optimization.trials),
|
||||||
|
"selected_rank": 1,
|
||||||
|
"trials": [
|
||||||
|
{
|
||||||
|
"rank": rank,
|
||||||
|
"parameters": dict(trial.parameters),
|
||||||
|
"score": trial.score,
|
||||||
|
"metrics": dict(trial.result.metrics),
|
||||||
|
"metric_unavailable": dict(trial.result.metric_unavailable),
|
||||||
|
}
|
||||||
|
for rank, trial in enumerate(optimization.trials, start=1)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"schema_version": STRATEGY_REPORT_SCHEMA,
|
||||||
|
"strategy": result.strategy,
|
||||||
|
"asset": result.asset,
|
||||||
|
"parameters": dict(result.parameters),
|
||||||
|
"costs": dict(result.cost_parameters),
|
||||||
|
"execution": {
|
||||||
|
"signal_observation": "after_close",
|
||||||
|
"fill_price": "next_session_open",
|
||||||
|
"valuation_price": "session_close",
|
||||||
|
"lag_sessions": 1,
|
||||||
|
"quantity_basis": "fractional_research",
|
||||||
|
},
|
||||||
|
"signals": [
|
||||||
|
asdict(signal)
|
||||||
|
| {
|
||||||
|
"asset_id": result.asset,
|
||||||
|
"signal_id": f"{run_id}:signal:{signal.decision_date}",
|
||||||
|
}
|
||||||
|
for signal in result.signals
|
||||||
|
],
|
||||||
|
"trade_pairing": asdict(result.pairing),
|
||||||
|
"metrics": dict(result.metrics),
|
||||||
|
"metric_unavailable": dict(result.metric_unavailable),
|
||||||
|
"benchmark": {"status": result.benchmark_status, "metrics": result.benchmark_metrics},
|
||||||
|
"optimization": ranking,
|
||||||
|
"projections": {
|
||||||
|
"signals": "strategy_report.signals",
|
||||||
|
"attribution": "not_computed",
|
||||||
|
"risk": "not_computed",
|
||||||
|
"position_weight_basis": "market_value_over_nav",
|
||||||
|
"undefined_weight_reason": "zero_portfolio_value",
|
||||||
|
"undefined_weight_dates": [
|
||||||
|
position.date
|
||||||
|
for position in result.ledger.positions
|
||||||
|
if position.portfolio_value == 0
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"decision_eligible": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _performance(
|
||||||
|
result: StrategyResearchResult,
|
||||||
|
run_id: str,
|
||||||
|
) -> tuple[pd.DataFrame, dict[str, object]]:
|
||||||
|
values: dict[str, object] = {"run_id": run_id}
|
||||||
|
reasons: dict[str, str] = {}
|
||||||
|
for target, source in _PERFORMANCE.items():
|
||||||
|
value = result.metrics[source]
|
||||||
|
values[target] = value
|
||||||
|
if value is None:
|
||||||
|
reasons[target] = result.metric_unavailable[source]
|
||||||
|
for target, source in _RELATIVE.items():
|
||||||
|
value = None if result.benchmark_metrics is None else result.benchmark_metrics[source]
|
||||||
|
values[target] = value
|
||||||
|
if value is None:
|
||||||
|
reasons[target] = (
|
||||||
|
"benchmark_" + result.benchmark_status
|
||||||
|
if result.benchmark_metrics is None
|
||||||
|
else "benchmark_metric_undefined"
|
||||||
|
)
|
||||||
|
values["n_trades"] = len(result.ledger.trades_frame)
|
||||||
|
values["n_days"] = len(result.ledger.positions)
|
||||||
|
return pd.DataFrame([values]), {
|
||||||
|
"unavailable_reasons": reasons,
|
||||||
|
"win_rate_basis": "completed_trades",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _nav(result: StrategyResearchResult, run_id: str) -> pd.DataFrame:
|
||||||
|
frame = result.ledger.ledger_frame
|
||||||
|
frame.insert(0, "run_id", run_id)
|
||||||
|
frame["trade_date"] = pd.to_datetime(frame["trade_date"]).dt.date
|
||||||
|
frame["total_cost"] = [
|
||||||
|
sum(fill.total_cost for fill in day.executions) for day in result.ledger.daily_executions
|
||||||
|
]
|
||||||
|
benchmark_returns = result.benchmark_returns
|
||||||
|
if result.benchmark_nav is None or benchmark_returns is None:
|
||||||
|
frame["benchmark_nav"] = None
|
||||||
|
frame["benchmark_return"] = None
|
||||||
|
frame["excess_ret"] = None
|
||||||
|
else:
|
||||||
|
frame["benchmark_nav"] = result.benchmark_nav.to_numpy(copy=True)
|
||||||
|
frame["benchmark_return"] = benchmark_returns.to_numpy(copy=True)
|
||||||
|
frame["excess_ret"] = result.ledger.daily_returns.to_numpy() - benchmark_returns.to_numpy()
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
def _trades(result: StrategyResearchResult, run_id: str) -> pd.DataFrame:
|
||||||
|
frame = result.ledger.trades_frame
|
||||||
|
frame.insert(0, "run_id", run_id)
|
||||||
|
frame.insert(
|
||||||
|
1, "trade_id", [f"{run_id}:{sequence:08d}" for sequence in range(1, len(frame) + 1)]
|
||||||
|
)
|
||||||
|
signal_by_execution = {
|
||||||
|
signal.execution_date: signal.decision_date
|
||||||
|
for signal in result.signals
|
||||||
|
if signal.execution_date is not None
|
||||||
|
}
|
||||||
|
frame["signal_id"] = [
|
||||||
|
f"{run_id}:signal:{signal_by_execution[day]}" for day in frame["trade_date"]
|
||||||
|
]
|
||||||
|
frame["trade_date"] = pd.to_datetime(frame["trade_date"]).dt.date
|
||||||
|
frame["total_cost"] = frame["fee"] + frame["slippage"]
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
def _positions(result: StrategyResearchResult, run_id: str) -> pd.DataFrame:
|
||||||
|
rows = []
|
||||||
|
observed = result.bars
|
||||||
|
for day, position in zip(observed.index, result.ledger.positions, strict=True):
|
||||||
|
for asset, quantity in position.holdings.items():
|
||||||
|
if asset != result.asset:
|
||||||
|
raise ValueError("Strategy ledger contains an unexpected asset")
|
||||||
|
price = float(observed.at[day, "close"])
|
||||||
|
market_value = quantity * price
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"trade_date": day.date(),
|
||||||
|
"asset_id": asset,
|
||||||
|
"asset_type": "security",
|
||||||
|
"quantity": quantity,
|
||||||
|
"mark_price": price,
|
||||||
|
"market_value": market_value,
|
||||||
|
"weight": market_value / position.portfolio_value
|
||||||
|
if position.portfolio_value
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"trade_date": day.date(),
|
||||||
|
"asset_id": "CASH",
|
||||||
|
"asset_type": "cash",
|
||||||
|
"quantity": position.cash,
|
||||||
|
"mark_price": 1.0,
|
||||||
|
"market_value": position.cash,
|
||||||
|
"weight": position.cash / position.portfolio_value
|
||||||
|
if position.portfolio_value
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pd.DataFrame(rows, columns=_POSITION_COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
|
def build_strategy_research_artifact(
|
||||||
|
result: StrategyResearchResult | StrategyOptimizationResult,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
strategy_id: str,
|
||||||
|
strategy_name: str,
|
||||||
|
strategy_version: str,
|
||||||
|
engine_version: str,
|
||||||
|
code_revision: str,
|
||||||
|
data_snapshot_id: str,
|
||||||
|
calendar: str,
|
||||||
|
timezone: str,
|
||||||
|
started_at: str | pd.Timestamp,
|
||||||
|
finished_at: str | pd.Timestamp,
|
||||||
|
parameters: Mapping[str, object],
|
||||||
|
benchmark_id: str | None = None,
|
||||||
|
) -> ResearchRunArtifact:
|
||||||
|
"""Snapshot the chosen real ledger plus all bounded trial summaries.
|
||||||
|
|
||||||
|
``parameters`` carries task/source identity supplied by the caller. The core
|
||||||
|
reserves its report and metric explanation; callers cannot substitute them.
|
||||||
|
Empty requested benchmark data remains distinguishable from no request.
|
||||||
|
"""
|
||||||
|
optimization = result if type(result) is StrategyOptimizationResult else None
|
||||||
|
if optimization is not None:
|
||||||
|
if not 1 <= len(optimization.trials) <= 100:
|
||||||
|
raise ValueError("A bounded nonempty optimization result is required")
|
||||||
|
selected = optimization.trials[0].result
|
||||||
|
elif type(result) is StrategyResearchResult:
|
||||||
|
selected = result
|
||||||
|
else:
|
||||||
|
raise TypeError("result must be strategy research or optimization")
|
||||||
|
if selected.decision_eligible or (optimization is not None and optimization.decision_eligible):
|
||||||
|
raise ValueError("Strategy research cannot grant decision eligibility")
|
||||||
|
if selected.asset == "CASH":
|
||||||
|
raise ValueError("Strategy asset cannot use the reserved CASH identity")
|
||||||
|
normalized_run_id = _required_text(run_id, "run_id", max_length=128)
|
||||||
|
metadata = {
|
||||||
|
name: _required_text(value, name)
|
||||||
|
for name, value in {
|
||||||
|
"strategy_id": strategy_id,
|
||||||
|
"strategy_name": strategy_name,
|
||||||
|
"strategy_version": strategy_version,
|
||||||
|
"engine_version": engine_version,
|
||||||
|
"code_revision": code_revision,
|
||||||
|
"data_snapshot_id": data_snapshot_id,
|
||||||
|
"calendar": calendar,
|
||||||
|
"timezone": timezone,
|
||||||
|
}.items()
|
||||||
|
}
|
||||||
|
if not isinstance(parameters, Mapping):
|
||||||
|
raise TypeError("parameters must be a mapping")
|
||||||
|
if set(parameters) & {"strategy_report", "performance_interpretation"}:
|
||||||
|
raise ValueError("Strategy report and performance interpretation are reserved")
|
||||||
|
started, finished = (
|
||||||
|
_aware_timestamp(started_at, "started_at"),
|
||||||
|
_aware_timestamp(finished_at, "finished_at"),
|
||||||
|
)
|
||||||
|
if finished < started:
|
||||||
|
raise ValueError("finished_at must not precede started_at")
|
||||||
|
if (selected.benchmark_status == "not_requested") != (benchmark_id is None):
|
||||||
|
raise ValueError(
|
||||||
|
"Requested benchmark requires its identity; unrequested benchmark cannot have one"
|
||||||
|
)
|
||||||
|
benchmark = "" if benchmark_id is None else _required_text(benchmark_id, "benchmark_id")
|
||||||
|
performance, interpretation = _performance(selected, normalized_run_id)
|
||||||
|
params_json = _canonical_mapping_json(
|
||||||
|
dict(parameters)
|
||||||
|
| {
|
||||||
|
"strategy_report": _report(selected, normalized_run_id, optimization),
|
||||||
|
"performance_interpretation": interpretation,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dates = selected.bars.index
|
||||||
|
run = pd.DataFrame(
|
||||||
|
[
|
||||||
|
metadata
|
||||||
|
| {
|
||||||
|
"schema_version": RESEARCH_ARTIFACT_SCHEMA_VERSION,
|
||||||
|
"run_id": normalized_run_id,
|
||||||
|
"config_hash": hashlib.sha256(params_json.encode("utf-8")).hexdigest(),
|
||||||
|
"benchmark_id": benchmark,
|
||||||
|
"benchmark_alignment_policy": "exact_session_index"
|
||||||
|
if selected.benchmark_status == "present"
|
||||||
|
else "none",
|
||||||
|
"frequency": "1d",
|
||||||
|
"initial_capital": selected.ledger.initial_cash,
|
||||||
|
"start_date": dates[0].date(),
|
||||||
|
"end_date": dates[-1].date(),
|
||||||
|
"status": "success",
|
||||||
|
"started_at": started,
|
||||||
|
"finished_at": finished,
|
||||||
|
"params_json": params_json,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return ResearchRunArtifact(
|
||||||
|
schema_version=RESEARCH_ARTIFACT_SCHEMA_VERSION,
|
||||||
|
_run=run,
|
||||||
|
_signals=pd.DataFrame(columns=_SIGNAL_COLUMNS),
|
||||||
|
_nav=_nav(selected, normalized_run_id),
|
||||||
|
_trades=_trades(selected, normalized_run_id),
|
||||||
|
_positions=_positions(selected, normalized_run_id),
|
||||||
|
_attribution=pd.DataFrame(
|
||||||
|
columns=["run_id", "trade_date", "asset_id", "overnight", "intraday", "asset_total"]
|
||||||
|
),
|
||||||
|
_attribution_daily=pd.DataFrame(
|
||||||
|
columns=[
|
||||||
|
"run_id",
|
||||||
|
"trade_date",
|
||||||
|
"transaction_cost",
|
||||||
|
"explained_return",
|
||||||
|
"residual",
|
||||||
|
"total_return",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
_risk=pd.DataFrame(columns=RISK_COLUMNS),
|
||||||
|
_performance=performance,
|
||||||
|
)
|
||||||
@@ -37,8 +37,13 @@ class StrategyOptimizationResult:
|
|||||||
strategy: str
|
strategy: str
|
||||||
objective: str
|
objective: str
|
||||||
trials: tuple[StrategyTrial, ...]
|
trials: tuple[StrategyTrial, ...]
|
||||||
|
_param_grid: dict[str, list[int | float]]
|
||||||
decision_eligible: bool = False
|
decision_eligible: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def param_grid(self) -> dict[str, list[int | float]]:
|
||||||
|
return {key: list(values) for key, values in self._param_grid.items()}
|
||||||
|
|
||||||
|
|
||||||
def optimize_strategy_research(
|
def optimize_strategy_research(
|
||||||
strategy: str,
|
strategy: str,
|
||||||
@@ -64,7 +69,13 @@ def optimize_strategy_research(
|
|||||||
finite_number(min_trade_amount, "min_trade_amount")
|
finite_number(min_trade_amount, "min_trade_amount")
|
||||||
if min_trade_amount < 0:
|
if min_trade_amount < 0:
|
||||||
raise ValueError("min_trade_amount must be nonnegative")
|
raise ValueError("min_trade_amount must be nonnegative")
|
||||||
if type(asset) is not str or not asset or asset.strip() != asset or len(asset) > 100:
|
if (
|
||||||
|
type(asset) is not str
|
||||||
|
or not asset
|
||||||
|
or asset == "CASH"
|
||||||
|
or asset.strip() != asset
|
||||||
|
or len(asset) > 100
|
||||||
|
):
|
||||||
raise ValueError("An explicit bounded asset key is required")
|
raise ValueError("An explicit bounded asset key is required")
|
||||||
combinations = parameter_grid(strategy, param_grid)
|
combinations = parameter_grid(strategy, param_grid)
|
||||||
observed = validate_strategy_bars(bars)
|
observed = validate_strategy_bars(bars)
|
||||||
@@ -90,4 +101,9 @@ def optimize_strategy_research(
|
|||||||
raise ValueError(f"Optimization objective {objective} is unavailable for {parameters}")
|
raise ValueError(f"Optimization objective {objective} is unavailable for {parameters}")
|
||||||
trials.append(StrategyTrial(dict(parameters), value, result))
|
trials.append(StrategyTrial(dict(parameters), value, result))
|
||||||
trials.sort(key=lambda trial: trial.score, reverse=True)
|
trials.sort(key=lambda trial: trial.score, reverse=True)
|
||||||
return StrategyOptimizationResult(strategy, objective, tuple(trials))
|
return StrategyOptimizationResult(
|
||||||
|
strategy,
|
||||||
|
objective,
|
||||||
|
tuple(trials),
|
||||||
|
{key: list(values) for key, values in param_grid.items()},
|
||||||
|
)
|
||||||
|
|||||||
@@ -61,8 +61,20 @@ class StrategyResearchResult:
|
|||||||
benchmark_status: str
|
benchmark_status: str
|
||||||
benchmark_nav: pd.Series | None
|
benchmark_nav: pd.Series | None
|
||||||
benchmark_metrics: dict[str, float | None] | None
|
benchmark_metrics: dict[str, float | None] | None
|
||||||
|
asset: str
|
||||||
|
_bars: pd.DataFrame
|
||||||
|
_benchmark_returns: pd.Series | None
|
||||||
|
cost_parameters: dict[str, float]
|
||||||
decision_eligible: bool = False
|
decision_eligible: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bars(self) -> pd.DataFrame:
|
||||||
|
return self._bars.copy(deep=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def benchmark_returns(self) -> pd.Series | None:
|
||||||
|
return None if self._benchmark_returns is None else self._benchmark_returns.copy(deep=True)
|
||||||
|
|
||||||
|
|
||||||
def _calendar(index: pd.Index) -> pd.DatetimeIndex:
|
def _calendar(index: pd.Index) -> pd.DatetimeIndex:
|
||||||
if (
|
if (
|
||||||
@@ -367,7 +379,13 @@ def run_strategy_research(
|
|||||||
finite_number(min_trade_amount, "min_trade_amount")
|
finite_number(min_trade_amount, "min_trade_amount")
|
||||||
if min_trade_amount < 0:
|
if min_trade_amount < 0:
|
||||||
raise ValueError("min_trade_amount must be nonnegative")
|
raise ValueError("min_trade_amount must be nonnegative")
|
||||||
if type(asset) is not str or not asset or asset.strip() != asset or len(asset) > 100:
|
if (
|
||||||
|
type(asset) is not str
|
||||||
|
or not asset
|
||||||
|
or asset == "CASH"
|
||||||
|
or asset.strip() != asset
|
||||||
|
or len(asset) > 100
|
||||||
|
):
|
||||||
raise ValueError("An explicit bounded asset key is required")
|
raise ValueError("An explicit bounded asset key is required")
|
||||||
observed = validate_strategy_bars(bars)
|
observed = validate_strategy_bars(bars)
|
||||||
if len(observed) < required_history(strategy, parameters):
|
if len(observed) < required_history(strategy, parameters):
|
||||||
@@ -418,7 +436,7 @@ def run_strategy_research(
|
|||||||
)
|
)
|
||||||
pairing = pair_ledger_trades(ledger)
|
pairing = pair_ledger_trades(ledger)
|
||||||
metrics, unavailable = _metrics(ledger, pairing)
|
metrics, unavailable = _metrics(ledger, pairing)
|
||||||
benchmark_nav, benchmark_metrics = None, None
|
benchmark_nav, benchmark_metrics, benchmark_returns = None, None, None
|
||||||
if benchmark_close is not None:
|
if benchmark_close is not None:
|
||||||
benchmark_nav = benchmark_close / benchmark_close.iloc[0]
|
benchmark_nav = benchmark_close / benchmark_close.iloc[0]
|
||||||
benchmark_returns = benchmark_close.pct_change(fill_method=None)
|
benchmark_returns = benchmark_close.pct_change(fill_method=None)
|
||||||
@@ -441,4 +459,14 @@ def run_strategy_research(
|
|||||||
benchmark_status,
|
benchmark_status,
|
||||||
benchmark_nav,
|
benchmark_nav,
|
||||||
benchmark_metrics,
|
benchmark_metrics,
|
||||||
|
asset,
|
||||||
|
observed.copy(deep=True),
|
||||||
|
benchmark_returns,
|
||||||
|
{
|
||||||
|
"initial_cash": initial_cash,
|
||||||
|
"commission": commission,
|
||||||
|
"stamp_duty": stamp_duty,
|
||||||
|
"min_trade_amount": min_trade_amount,
|
||||||
|
"slippage_bps": 0,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""Strategy reports preserve real ledger facts without factor-score fabrication."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from quant_engine.strategy_artifact import build_strategy_research_artifact
|
||||||
|
from quant_engine.strategy_optimizer import optimize_strategy_research
|
||||||
|
from quant_engine.strategy_research import BenchmarkInput, run_strategy_research
|
||||||
|
|
||||||
|
|
||||||
|
def bars(closes, opens=None):
|
||||||
|
close = np.asarray(closes, dtype=float)
|
||||||
|
opening = np.asarray(opens if opens is not None else closes, dtype=float)
|
||||||
|
return pd.DataFrame(
|
||||||
|
{
|
||||||
|
"open": opening,
|
||||||
|
"high": np.maximum(close, opening),
|
||||||
|
"low": np.minimum(close, opening),
|
||||||
|
"close": close,
|
||||||
|
},
|
||||||
|
index=pd.date_range("2026-01-01", periods=len(close), freq="B"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run(strategy="BuyAndHold", feed=None, **kwargs):
|
||||||
|
return run_strategy_research(
|
||||||
|
strategy,
|
||||||
|
bars([10, 11, 12, 13]) if feed is None else feed,
|
||||||
|
asset="SYNTHETIC",
|
||||||
|
initial_cash=1000,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build(result, **kwargs):
|
||||||
|
metadata = {
|
||||||
|
"run_id": "strategy-run",
|
||||||
|
"strategy_id": "isolated-strategy",
|
||||||
|
"strategy_name": "Synthetic",
|
||||||
|
"strategy_version": "1",
|
||||||
|
"engine_version": "candidate",
|
||||||
|
"code_revision": "candidate",
|
||||||
|
"data_snapshot_id": "synthetic:ohlc-v1",
|
||||||
|
"calendar": "synthetic-sessions",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"started_at": "2026-01-08T10:00:00+08:00",
|
||||||
|
"finished_at": "2026-01-08T10:00:01+08:00",
|
||||||
|
"parameters": {"synthetic": True},
|
||||||
|
}
|
||||||
|
return build_strategy_research_artifact(result, **(metadata | kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def report(artifact):
|
||||||
|
return json.loads(artifact.run.iloc[0]["params_json"])["strategy_report"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_same_ledger_cash_fees_positions_and_completed_trade_basis():
|
||||||
|
result = run(
|
||||||
|
"SmaCross",
|
||||||
|
bars([10, 8, 12, 6, 14, 5, 12]),
|
||||||
|
params={"fast": 1, "slow": 2},
|
||||||
|
commission=0.01,
|
||||||
|
stamp_duty=0.02,
|
||||||
|
)
|
||||||
|
artifact = build(result)
|
||||||
|
detail = report(artifact)
|
||||||
|
assert artifact.schema_version == "1.1.0"
|
||||||
|
assert artifact.nav.portfolio_value.tolist() == result.ledger.nav_series.tolist()
|
||||||
|
assert artifact.nav.pnl_pct.tolist() == result.ledger.daily_returns.tolist()
|
||||||
|
assert artifact.trades.fee.sum() == pytest.approx(result.ledger.trades_frame.fee.sum())
|
||||||
|
assert artifact.nav.total_cost.sum() == pytest.approx(artifact.trades.total_cost.sum())
|
||||||
|
assert artifact.performance.iloc[0].win_rate == result.pairing.win_rate
|
||||||
|
assert artifact.performance.iloc[0].n_trades == len(result.ledger.trades_frame)
|
||||||
|
assert detail["trade_pairing"] == json.loads(json.dumps(asdict(result.pairing)))
|
||||||
|
assert detail["costs"] == {
|
||||||
|
"initial_cash": 1000,
|
||||||
|
"commission": 0.01,
|
||||||
|
"stamp_duty": 0.02,
|
||||||
|
"min_trade_amount": 0,
|
||||||
|
"slippage_bps": 0,
|
||||||
|
}
|
||||||
|
assert detail["decision_eligible"] is False
|
||||||
|
for day, frame in artifact.positions.groupby("trade_date"):
|
||||||
|
nav = artifact.nav.loc[artifact.nav.trade_date == day].iloc[0]
|
||||||
|
assert frame.market_value.sum() == pytest.approx(nav.portfolio_value)
|
||||||
|
assert frame.weight.sum() == pytest.approx(1)
|
||||||
|
assert artifact.signals.empty
|
||||||
|
assert artifact.attribution.empty
|
||||||
|
assert artifact.risk.empty
|
||||||
|
assert detail["projections"]["signals"] == "strategy_report.signals"
|
||||||
|
assert detail["projections"]["attribution"] == "not_computed"
|
||||||
|
params = json.loads(artifact.run.iloc[0].params_json)
|
||||||
|
assert params["performance_interpretation"]["win_rate_basis"] == "completed_trades"
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_signal_is_not_lost_or_fabricated_as_a_factor_signal():
|
||||||
|
result = run("SmaCross", bars([10, 8, 12]), params={"fast": 1, "slow": 2})
|
||||||
|
artifact = build(result)
|
||||||
|
signal = report(artifact)["signals"][0]
|
||||||
|
assert signal["status"] == "no_next_session"
|
||||||
|
assert signal["execution_date"] is None
|
||||||
|
assert signal["signal_id"] == "strategy-run:signal:2026-01-05"
|
||||||
|
assert artifact.trades.empty
|
||||||
|
assert artifact.signals.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_ids_join_actual_fills_and_report():
|
||||||
|
artifact = build(run())
|
||||||
|
signals = {item["signal_id"]: item for item in report(artifact)["signals"]}
|
||||||
|
for fill in artifact.trades.to_dict("records"):
|
||||||
|
signal = signals[fill["signal_id"]]
|
||||||
|
assert signal["execution_date"] == fill["trade_date"].isoformat()
|
||||||
|
assert signal["decision_date"] < signal["execution_date"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
[
|
||||||
|
"BuyAndHold",
|
||||||
|
"SmaCross",
|
||||||
|
"MACross",
|
||||||
|
"RSI",
|
||||||
|
"BollingerBreakout",
|
||||||
|
"DualThrust",
|
||||||
|
"TurtleBreakout",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_all_seven_defaults_have_canonical_serializable_reports(name):
|
||||||
|
values = 10 + np.sin(np.arange(80) / 2) * 2
|
||||||
|
result = run(name, bars(values, np.r_[values[0], values[:-1]]))
|
||||||
|
artifact = build(result)
|
||||||
|
assert report(artifact)["parameters"] == result.parameters
|
||||||
|
assert json.loads(artifact.canonical_json())["schema_version"] == "1.1.0"
|
||||||
|
assert artifact.content_sha256 == build(result).content_sha256
|
||||||
|
assert len(artifact.nav) == 80
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["not_requested", "empty", "present"])
|
||||||
|
def test_benchmark_states_and_original_returns_are_preserved(status):
|
||||||
|
feed = bars([10, 11, 12, 13])
|
||||||
|
closes = (
|
||||||
|
pd.Series([20, 22, 21, 23], index=feed.index)
|
||||||
|
if status == "present"
|
||||||
|
else (pd.Series(dtype=float) if status == "empty" else None)
|
||||||
|
)
|
||||||
|
result = run(feed=feed, benchmark=BenchmarkInput(status, closes))
|
||||||
|
artifact = build(result, benchmark_id="SYNTHETIC-BENCH" if status != "not_requested" else None)
|
||||||
|
assert report(artifact)["benchmark"]["status"] == status
|
||||||
|
if status == "present":
|
||||||
|
assert artifact.nav.benchmark_return.tolist() == pytest.approx(
|
||||||
|
[0, 0.1, 21 / 22 - 1, 23 / 21 - 1]
|
||||||
|
)
|
||||||
|
assert artifact.nav.benchmark_nav.tolist() == pytest.approx([1, 1.1, 1.05, 1.15])
|
||||||
|
assert artifact.run.iloc[0].benchmark_alignment_policy == "exact_session_index"
|
||||||
|
else:
|
||||||
|
assert artifact.nav.benchmark_nav.isna().all()
|
||||||
|
assert artifact.run.iloc[0].benchmark_alignment_policy == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_nav_preserves_zero_value_and_undefined_weight_with_reason():
|
||||||
|
result = run(
|
||||||
|
"SmaCross",
|
||||||
|
bars([10, 8, 12, 6, 14]),
|
||||||
|
params={"fast": 1, "slow": 2},
|
||||||
|
commission=0,
|
||||||
|
stamp_duty=1,
|
||||||
|
)
|
||||||
|
artifact = build(result)
|
||||||
|
last = artifact.positions.iloc[-1]
|
||||||
|
assert last.market_value == 0
|
||||||
|
assert pd.isna(last.weight)
|
||||||
|
assert artifact.nav.iloc[-1].nav == 0
|
||||||
|
assert report(artifact)["projections"]["undefined_weight_dates"] == ["2026-01-07"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_closed_lot_metrics_are_null_with_covered_reason():
|
||||||
|
artifact = build(run())
|
||||||
|
metadata = json.loads(artifact.run.iloc[0].params_json)
|
||||||
|
assert report(artifact)["metrics"]["trade_win_rate"] is None
|
||||||
|
assert (
|
||||||
|
metadata["performance_interpretation"]["unavailable_reasons"]["win_rate"]
|
||||||
|
== "no_closed_lots"
|
||||||
|
)
|
||||||
|
assert pd.isna(artifact.performance.iloc[0].win_rate)
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_snapshots_detach_caller_data_and_returned_views():
|
||||||
|
feed = bars([10, 11, 12, 13])
|
||||||
|
result = run(feed=feed)
|
||||||
|
before = build(result).content_sha256
|
||||||
|
feed.iloc[:] = 999
|
||||||
|
view = result.bars
|
||||||
|
view.iloc[:] = 777
|
||||||
|
artifact = build(result)
|
||||||
|
assert artifact.content_sha256 == before
|
||||||
|
positions = artifact.positions
|
||||||
|
positions["market_value"] = 0
|
||||||
|
assert artifact.content_sha256 == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_artifact_retains_all_ranks_and_selected_ledger_and_detaches_input():
|
||||||
|
grid = {"buy_pct": [0.2, 0.5, 1]}
|
||||||
|
result = optimize_strategy_research(
|
||||||
|
"BuyAndHold",
|
||||||
|
bars([10, 11, 12, 13]),
|
||||||
|
asset="SYNTHETIC",
|
||||||
|
param_grid=grid,
|
||||||
|
objective="total_return",
|
||||||
|
initial_cash=1000,
|
||||||
|
commission=0,
|
||||||
|
stamp_duty=0,
|
||||||
|
)
|
||||||
|
grid["buy_pct"].append(0.9)
|
||||||
|
artifact = build(result)
|
||||||
|
ranking = report(artifact)["optimization"]
|
||||||
|
assert ranking["grid"] == {"buy_pct": [0.2, 0.5, 1]}
|
||||||
|
assert ranking["trial_count"] == 3
|
||||||
|
assert ranking["selected_rank"] == 1
|
||||||
|
assert [trial["rank"] for trial in ranking["trials"]] == [1, 2, 3]
|
||||||
|
assert [trial["score"] for trial in ranking["trials"]] == [
|
||||||
|
trial.score for trial in result.trials
|
||||||
|
]
|
||||||
|
assert ranking["trials"][0]["parameters"] == {"buy_pct": 1}
|
||||||
|
assert (
|
||||||
|
artifact.nav.portfolio_value.tolist() == result.trials[0].result.ledger.nav_series.tolist()
|
||||||
|
)
|
||||||
|
assert all("ledger" not in trial for trial in ranking["trials"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["strategy_report", "performance_interpretation"])
|
||||||
|
def test_callers_cannot_overwrite_authoritative_report_or_metric_explanation(key):
|
||||||
|
with pytest.raises(ValueError, match="reserved"):
|
||||||
|
build(run(), parameters={key: {"decision_eligible": True}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_mutation_changes_canonical_artifact_digest():
|
||||||
|
first = build(run(commission=0))
|
||||||
|
second = build(run(commission=0.01))
|
||||||
|
assert first.content_sha256 != second.content_sha256
|
||||||
|
assert first.run.iloc[0].config_hash != second.run.iloc[0].config_hash
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_metadata_fails_before_artifact_creation():
|
||||||
|
with pytest.raises(ValueError, match="finished_at"):
|
||||||
|
build(run(), finished_at="2026-01-07T10:00:00+08:00")
|
||||||
|
with pytest.raises(ValueError, match="benchmark"):
|
||||||
|
build(run(), benchmark_id="FAKE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_relative_metrics_explain_their_fact_column_names():
|
||||||
|
artifact = build(run())
|
||||||
|
reasons = json.loads(artifact.run.iloc[0].params_json)["performance_interpretation"][
|
||||||
|
"unavailable_reasons"
|
||||||
|
]
|
||||||
|
assert reasons["ir"] == "benchmark_not_requested"
|
||||||
|
assert "information_ratio" not in reasons
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("producer", ["run", "optimization", "artifact"])
|
||||||
|
def test_reserved_cash_asset_cannot_collide_with_cash_position(producer):
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
operation = {
|
||||||
|
"run": lambda: run_strategy_research("BuyAndHold", bars([10, 11]), asset="CASH"),
|
||||||
|
"optimization": lambda: optimize_strategy_research(
|
||||||
|
"BuyAndHold",
|
||||||
|
bars([10, 11]),
|
||||||
|
asset="CASH",
|
||||||
|
param_grid={"buy_pct": [0.5]},
|
||||||
|
objective="total_return",
|
||||||
|
),
|
||||||
|
"artifact": lambda: build(replace(run(params={"buy_pct": 0}), asset="CASH")),
|
||||||
|
}[producer]
|
||||||
|
with pytest.raises(ValueError, match="asset"):
|
||||||
|
operation()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_100_trial_tied_grid_keeps_complete_stable_ranking():
|
||||||
|
grid = {"k1": [index / 10 for index in range(10)], "k2": [index / 10 for index in range(10)]}
|
||||||
|
result = optimize_strategy_research(
|
||||||
|
"DualThrust", bars([10] * 10), asset="SYNTHETIC", param_grid=grid, objective="total_return"
|
||||||
|
)
|
||||||
|
ranking = report(build(result))["optimization"]
|
||||||
|
assert ranking["trial_count"] == 100
|
||||||
|
assert len(ranking["trials"]) == 100
|
||||||
|
assert [trial["parameters"] for trial in ranking["trials"]] == [
|
||||||
|
trial.parameters for trial in result.trials
|
||||||
|
]
|
||||||
|
assert all(trial["score"] == 0 for trial in ranking["trials"])
|
||||||
Reference in New Issue
Block a user