Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
fc85e5d
feat(strategy): enforce evidence-first review gates
Pigbibi Jul 11, 2026
4c0b08c
fix(strategy): address review gate findings
Pigbibi Jul 11, 2026
6bb1718
fix(strategy): fail closed before crypto backtest startup
Pigbibi Jul 11, 2026
cb6c6e3
fix(strategy): require panel in crypto runner factory
Pigbibi Jul 11, 2026
883b592
fix(strategy): preserve crypto adapter factory contract
Pigbibi Jul 11, 2026
83b2885
fix(strategy): remove unused adapter import
Pigbibi Jul 11, 2026
bf75f56
fix(strategy): require valid baseline inputs
Pigbibi Jul 11, 2026
394263e
fix(strategy): preserve legacy crypto factory
Pigbibi Jul 11, 2026
3e47556
feat(strategy): emit chinese decision packet
Pigbibi Jul 11, 2026
87ad08b
fix(strategy): preserve fail-closed legacy adapter
Pigbibi Jul 11, 2026
9bcd2a5
fix(strategy): require panel for crypto factory
Pigbibi Jul 11, 2026
1fe5b8c
feat(strategy): report bounded canary policy
Pigbibi Jul 11, 2026
6fdfb8f
fix(strategy): require real panel at adapter construction
Pigbibi Jul 11, 2026
51fad20
fix(strategy): return structured no-data backtest result
Pigbibi Jul 11, 2026
5477ce7
fix(strategy): fail backtest at evidence boundary
Pigbibi Jul 11, 2026
481b8b1
fix(strategy): keep no-data adapter in band
Pigbibi Jul 11, 2026
2b0fbb3
fix(strategy): fail closed at crypto factory boundary
Pigbibi Jul 11, 2026
58c8339
fix(strategy): validate real baseline provenance
Pigbibi Jul 11, 2026
e9c9020
test(strategy): fix unused runner import
Pigbibi Jul 11, 2026
5d44b59
feat(strategy): load validated preflight panel
Pigbibi Jul 11, 2026
89fd313
fix(strategy): align bounded canary packet
Pigbibi Jul 11, 2026
12a085d
fix(strategy): defer preflight loading to run
Pigbibi Jul 11, 2026
5fd6f2b
fix(strategy): align preflight score semantics
Pigbibi Jul 11, 2026
67fc3f3
fix(review): validate walkforward artifact schema
Pigbibi Jul 11, 2026
e6946ab
fix(lifecycle): reject malformed scored panels
Pigbibi Jul 11, 2026
97fbba3
test(lifecycle): exercise real preflight runner
Pigbibi Jul 11, 2026
6a92d48
fix(lifecycle): require exported preflight provenance
Pigbibi Jul 11, 2026
4f9f287
fix(lifecycle): verify and refresh preflight bundles
Pigbibi Jul 11, 2026
4983135
fix(lifecycle): validate universe membership values
Pigbibi Jul 11, 2026
6051c46
fix(lifecycle): preserve v1 bundle compatibility
Pigbibi Jul 11, 2026
7a105e4
fix(lifecycle): require preflight manifest identity
Pigbibi Jul 11, 2026
ec24b82
fix(lifecycle): support historical preflight replay
Pigbibi Jul 11, 2026
f2da506
feat(lifecycle): wire and version preflight runtime contract
Pigbibi Jul 11, 2026
94ce74b
fix(lifecycle): allow additive v2 manifest metadata
Pigbibi Jul 11, 2026
89c22bf
fix(lifecycle): enforce independent preflight freshness
Pigbibi Jul 11, 2026
8ead8fc
fix(lifecycle): validate requested preflight start date
Pigbibi Jul 11, 2026
7ae5cb4
fix(lifecycle): align manifest dates and reject malformed bundles
Pigbibi Jul 11, 2026
b09d4d1
fix(lifecycle): validate legacy manifest dates
Pigbibi Jul 11, 2026
7ec4eeb
fix(lifecycle): guard malformed manifest symbols
Pigbibi Jul 11, 2026
d945cc2
fix(lifecycle): require explicit preflight root
Pigbibi Jul 11, 2026
195dff2
fix(lifecycle): enforce preflight sample sufficiency
Pigbibi Jul 11, 2026
c34d98c
fix(lifecycle): reject stale market artifacts
Pigbibi Jul 11, 2026
3916d43
fix(lifecycle): harden runtime evidence validation
Pigbibi Jul 11, 2026
838cc0d
fix(lifecycle): normalize drawdown for calmar
Pigbibi Jul 11, 2026
83a9864
test(lifecycle): document scored preflight bounds
Pigbibi Jul 11, 2026
d5de6f8
fix(lifecycle): reject duplicate preflight rows
Pigbibi Jul 11, 2026
119a83d
fix(lifecycle): verify completed date and coverage
Pigbibi Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/publish-lifecycle-inputs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
run: |
set -euo pipefail
completed_date="$(python -c 'from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=1)).date())')"
echo "CRYPTO_LIFECYCLE_EXPECTED_END_DATE=${completed_date}" >> "$GITHUB_ENV"
python scripts/download_history.py --top-liquid "${DOWNLOAD_TOP_LIQUID}" \
--end-date "${completed_date}" \
--force-exchange-info
Expand All @@ -52,6 +53,32 @@ jobs:
--universe-mode broad_liquid \
--output-dir "${RUNNER_TEMP}/crypto-lifecycle-inputs"

- name: Inject and verify lifecycle preflight runtime wiring
env:
CRYPTO_LIFECYCLE_PREFLIGHT_ROOT: ${{ runner.temp }}/crypto-lifecycle-inputs
run: |
set -euo pipefail
echo "CRYPTO_LIFECYCLE_PREFLIGHT_ROOT=${CRYPTO_LIFECYCLE_PREFLIGHT_ROOT}" >> "$GITHUB_ENV"
python - <<'PY'
import json
import os
from datetime import date
from src.strategy_lifecycle.backtest_wrapper import build_backtest_runner

with open(os.path.join(os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"], "manifest.json"), encoding="utf-8") as handle:
manifest = json.load(handle)
runner = build_backtest_runner()
expected_end_date = date.fromisoformat(os.environ["CRYPTO_LIFECYCLE_EXPECTED_END_DATE"])
result = runner.run(
"crypto_live_pool_rotation",
{},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify the real strategy configuration

When the publish job reaches this verification step, it calls the lifecycle runner with an empty param set, so CryptoLivePoolBacktestRunner falls back to its hard-coded DEFAULT_BACKTEST_CONFIG (top_n=2, weighting='equal'). I checked the production research path: src/pipeline.py backtests using config/default.yaml, whose strategy is top_n: 3 and weighting: inverse_vol. This means the uploaded preflight can pass while validating a different portfolio than the crypto live-pool rotation operators actually run; pass the loaded strategy config or make the runner defaults match the live config.

Useful? React with 👍 / 👎.

end_date=expected_end_date,
)
if result.observation_count <= 0:
raise SystemExit("real lifecycle preflight produced no observations")
print(f"verified real lifecycle runner observations={result.observation_count}")
PY

- name: Upload lifecycle inputs
uses: actions/upload-artifact@v7
with:
Expand Down
20 changes: 13 additions & 7 deletions scripts/export_lifecycle_preflight_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str,
scored_panel = lifecycle_panel.dropna(subset=["final_score"])
if scored_panel.empty:
raise ValueError("research panel has no scored lifecycle rows")
panel_dates = pd.DatetimeIndex(sorted(scored_panel["date"].unique()))
if len(panel_dates) < MIN_PANEL_DAYS:
# v2 bounds are the scored_panel bounds; warm-up/unscored rows remain in the CSV.
scored_dates = pd.DatetimeIndex(sorted(scored_panel["date"].unique()))
if len(scored_dates) < MIN_PANEL_DAYS:
raise ValueError(f"research panel requires at least {MIN_PANEL_DAYS} scored dates")
in_universe_counts = scored_panel.loc[scored_panel["in_universe"]].groupby("date")["symbol"].nunique()
in_universe_counts = in_universe_counts.reindex(panel_dates, fill_value=0)
in_universe_counts = in_universe_counts.reindex(scored_dates, fill_value=0)
if int(in_universe_counts.min()) < 2:
raise ValueError("research panel requires at least two in-universe symbols per scored date")

Expand All @@ -61,7 +62,7 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str,
or max(symbol_dates) < max(reference_dates)
):
raise ValueError(f"market history has incomplete symbol coverage: {symbol}")
if today - panel_dates.max() > pd.Timedelta(days=MAX_FRESHNESS_DAYS):
if today - scored_dates.max() > pd.Timedelta(days=MAX_FRESHNESS_DAYS):
raise ValueError("research panel is stale")
if today - max(reference_dates) > pd.Timedelta(days=MAX_FRESHNESS_DAYS):
raise ValueError("BTC/ETH market history is stale")
Expand All @@ -73,13 +74,18 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str,
lifecycle_panel.to_csv(panel_path, index=False, compression="gzip")
market_history.to_csv(market_path, index=False, compression="gzip")
manifest = {
"contract_version": "crypto.lifecycle_preflight.v1",
"contract_version": "crypto.lifecycle_preflight.v2",
"domain": "crypto",
"producer": "export_lifecycle_preflight_inputs.py",
"strategy_profile": "crypto_live_pool_rotation",
"panel_rows": int(len(lifecycle_panel)),
"panel_symbols": sorted(lifecycle_panel["symbol"].unique().tolist()),
"market_rows": int(len(market_history)),
"market_symbols": sorted(market_history["symbol"].unique().tolist()),
"start_date": panel_dates.min().date().isoformat(),
"end_date": panel_dates.max().date().isoformat(),
"start_date": scored_dates.min().date().isoformat(),
"end_date": scored_dates.max().date().isoformat(),
"market_start_date": market_history["date"].min().date().isoformat(),
"market_end_date": market_history["date"].max().date().isoformat(),
}
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return manifest
Expand Down
197 changes: 197 additions & 0 deletions scripts/run_crypto_live_pool_baseline_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Produce a fail-closed S2 baseline review for crypto live-pool rotation."""

from __future__ import annotations

import argparse
import csv
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

PROFILE = "crypto_live_pool_rotation"
GATE_NAMES = {
"H1": "falsifiable_hypothesis",
"H2": "data_provenance",
"H3": "sample_adequacy",
"H4": "benchmark_comparison",
"H5": "cost_model",
"H6": "cost_stress",
"H7": "oos_folds",
"H8": "purge_embargo",
"H9": "leakage_control",
"H10": "parameter_stability",
"H11": "risk_metrics",
"H12": "reproducibility",
}


def _gate(gate_id: str, reason: str, *, status: str = "insufficient_evidence", refs: list[str] | None = None) -> dict[str, Any]:
return {
"id": gate_id,
"name": GATE_NAMES[gate_id],
"status": status,
"reason_codes": [reason],
"evidence_refs": refs or [],
}


def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dict[str, Any]:
missing = []
if not _usable_performance_csv(performance_summary):
missing.append(str(performance_summary))
if not _usable_walkforward_csv(walkforward_summary):
missing.append(str(walkforward_summary))
reason = "MISSING_OR_INVALID_REAL_PERFORMANCE_ARTIFACT" if missing else "BASELINE_REVIEW_NOT_YET_FROZEN"
gates = [_gate(gate_id, reason) for gate_id in GATE_NAMES]
return {
"schema_version": "strategy_review.v1",
"profile": PROFILE,
"decision": "insufficient_evidence",
"promotion_allowed": False,
"score": 0,
"hard_gates": gates,
"scorecard": {"total": 0, "max": 100, "scored_gates": 0},
"blocking_reason_codes": [reason],
"evidence": {
"metrics_kind": "performance",
"data_source": "unavailable" if missing else "unfrozen",
"sample_count": 0,
"oos_folds": 0,
"placeholder_metrics": False,
"provenance": {
"snapshot": {"source_revision": "unavailable", "cost_model": "unavailable", "data_timestamp": "unavailable", "status": "unavailable"},
"backtest": {"source_revision": "unavailable", "cost_model": "unavailable", "data_timestamp": "unavailable", "status": "unavailable"},
},
"missing_artifacts": missing,
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
},
"decision_packet": {
"strategy_what": "按评分选择加密资产并进行 live-pool rotation;当前仅评审流程,不执行交易。",
"return_source": "真实 performance artifacts 未提供,收益来源无法确认。",
"loss_scenarios": "未完成真实回测,主要亏损场景无法确认;不得用 placeholder 指标替代。",
"max_risk": "证据不足,最大回撤、容量和流动性风险均不可确认。",
"evidence_sufficiency": "insufficient_evidence",
"version_change": "新增 fail-closed 基线评审与人工决策 packet;未改变 live 参数。",
"system_recommendation": "insufficient_evidence",
"technical_evidence_refs": missing,
"automation_boundary": {
"research_auto_after_hard_gates": True,
"shadow_auto_after_hard_gates": True,
"canary_mode": "bounded_preapproved_only",
"canary_limits": {"max_capital": 1000.0, "capital_currency": "USD", "max_duration_days": 14, "max_drawdown_fraction": 0.05, "max_leverage": 1.0, "max_concurrency": 1},
"auto_scale_allowed": False,
"normal_live_requires_human": True,
"funding_leverage_risk_override_requires_human": True,
"hard_risk_auto_pause_rollback": True,
},
"allowed_human_decisions": ["approve_research", "reject_rollback"],
},
}


def _read_csv(path: Path) -> tuple[list[str], list[list[str]]] | None:
if not path.exists() or not path.is_file():
return None
try:
with path.open(newline="", encoding="utf-8") as handle:
reader = csv.reader(handle)
header = next(reader, None)
data_rows = list(reader)
if not header or not data_rows:
return None
return ["".join(ch for ch in cell.lower() if ch.isalnum() or ch == "_") for cell in header], data_rows
except (OSError, UnicodeError, csv.Error):
return None


def _has_numeric_value(header: list[str], rows: list[list[str]], candidates: set[str]) -> bool:
for row in rows:
for index, name in enumerate(header):
if name not in candidates or index >= len(row):
continue
try:
value = float(row[index])
if math.isfinite(value):
return True
except (TypeError, ValueError):
continue
return False


def _usable_performance_csv(path: Path) -> bool:
parsed = _read_csv(path)
if parsed is None:
return False
header, rows = parsed
metrics = {"cagr", "sharpe", "calmar", "maxdrawdown", "max_dd", "annualizedvolatility"}
if "strategy" in header:
strategy_index = header.index("strategy")
if not any(len(row) > strategy_index and row[strategy_index].strip().lower() == "final_score" for row in rows):
return False
return bool(set(header).intersection(metrics)) and _has_numeric_value(header, rows, metrics)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require metrics on the final_score row

When performance_summary.csv has a strategy column and includes a final_score row whose metric cells are blank/NaN, this still scans every row for a finite metric value. A numeric rule_score row therefore makes build_review() report BASELINE_REVIEW_NOT_YET_FROZEN even though the required final_score performance evidence is missing; the fresh evidence beyond the prior row-existence issue is that a placeholder final_score row is enough to pass. Restrict the numeric metric check to the final_score row when that column is present.

Useful? React with 👍 / 👎.



def _usable_walkforward_csv(path: Path) -> bool:
parsed = _read_csv(path)
if parsed is None:
return False
header, rows = parsed
required = {"window_id", "test_start", "test_end"}
metrics = {"window_cagr", "window_sharpe", "window_max_drawdown", "h30_precision", "h60_precision", "h90_precision"}
if not required.issubset(set(header)) or not set(header).intersection(metrics):
return False
positions = {name: header.index(name) for name in required}
for row in rows:
try:
if any(not row[index].strip() for index in positions.values()):
continue
int(row[positions["window_id"]])
datetime.fromisoformat(row[positions["test_start"]].replace("Z", "+00:00"))
datetime.fromisoformat(row[positions["test_end"]].replace("Z", "+00:00"))
except (IndexError, TypeError, ValueError):
Comment on lines +153 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject reversed walk-forward windows

When a hand-built or corrupted walkforward_validation_summary.csv has test_start after test_end, both timestamps still parse successfully and a finite metric makes _usable_walkforward_csv() return true. That moves the fail-closed review to BASELINE_REVIEW_NOT_YET_FROZEN even though the OOS window is chronologically invalid; require test_start <= test_end before accepting the row as evidence.

Useful? React with 👍 / 👎.

continue
if _has_numeric_value(header, [row], metrics):
return True
return False


def render_markdown(review: dict[str, Any]) -> str:
lines = [
f"# Baseline review: `{review['profile']}`",
"",
f"- Decision: **{review['decision']}**",
f"- Promotion allowed: **{review['promotion_allowed']}**",
f"- Score: **{review['score']}/100**",
"",
"This report is fail-closed. No real performance artifact is promoted by this command.",
"",
"| Gate | Status | Reason |",
"|---|---|---|",
]
for gate in review["hard_gates"]:
lines.append(f"| {gate['id']} {gate['name']} | {gate['status']} | {', '.join(gate['reason_codes'])} |")
lines.extend(["", "## Blocking reasons", "", *[f"- `{reason}`" for reason in review["blocking_reason_codes"]]])
return "\n".join(lines) + "\n"


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--performance-summary", type=Path, default=Path("data/reports/performance_summary.csv"))
parser.add_argument("--walkforward-summary", type=Path, default=Path("data/reports/walkforward_validation_summary.csv"))
parser.add_argument("--output-json", type=Path, required=True)
parser.add_argument("--output-md", type=Path, required=True)
args = parser.parse_args(argv)
review = build_review(performance_summary=args.performance_summary, walkforward_summary=args.walkforward_summary)
args.output_json.parent.mkdir(parents=True, exist_ok=True)
args.output_md.parent.mkdir(parents=True, exist_ok=True)
args.output_json.write_text(json.dumps(review, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
args.output_md.write_text(render_markdown(review), encoding="utf-8")
print(json.dumps({"decision": review["decision"], "promotion_allowed": False, "output": str(args.output_json)}))
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading