Files
StarrivandClaude Fable 5 87f2f35e21 feat: backtesting research framework, market-data ops, and unified logging
Snapshot of the accumulated feature work across several sessions:

- backtesting: single-symbol visual backtest with canonical artifact
  (contract 1.0 -> 1.4): point-in-time replay via production scoring,
  21/63d event study with non-overlapping inference and block bootstrap,
  fixed-window and managed (position-sized, 1R breakeven, trailing-stop,
  close-signal exit) simulations, rejected stateful lifecycle experiment,
  self-contained HTML renderer, cross-sectional factor-ic/score/robustness
  studies over a frozen 148-name universe
- market data: full-market warmup, asset-catalog sync and coverage
  reconciliation with shared rate limiting; cache admin hardening
- logging: unified structured stderr logging across all entrypoints
  (JSONL default, console override), stdout reserved for results
- scoring: entry-timing layer refinements, trade-plan trailing stop echo,
  forward-log A/B fields; model card and agent contract docs updated
- make: command gateway with thin forwarding to backtest.py subcommands
- tests: 555 passing, including managed-exit semantics, lifecycle
  validation, logging guards, warmup/reconcile and rate-limit suites

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:41:38 +08:00

278 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""前瞻验证日志:把每次评分固化为样本外证据(docs/model-card.md 的执行侧)。
``record`` 对篮子打分,把模型版本、信号日期、时机分类、介入前后结论与分数
逐票追加到 JSONL;同日同票重跑覆盖旧行
(幂等),跨日只追加、绝不改写历史——这是"样本外"成立的前提。
``evaluate`` 把已满足 horizon(默认 63 交易日,与回测口径一致)的记录与真实
后续收益对账,按 verdict 分桶报均值收益与同窗口 SPY 超额。
纯函数(merge_records / evaluate_records / bucket_stats)不触网,可离线测试;
只有 CLI 的取数环节碰 Alpaca。跨 model_version 的记录不混算。
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import sys
from typing import Any
sys.path.insert(0, os.path.dirname(__file__))
from backtesting.universe import UNIVERSE # noqa: E402
from fetching import fetch_bars # noqa: E402
from pipeline import build_result # noqa: E402
from scoring import MODEL_VERSION # noqa: E402
from skill_logging import ( # noqa: E402
add_logging_arguments,
bind_log_context,
clear_log_context,
configure_logging,
get_logger,
)
DEFAULT_LOG = os.path.join(os.path.dirname(__file__), "..", "data", "forward_log.jsonl")
DEFAULT_HORIZON = 63 # 交易日;与回测/确认层 A/B 的 63 日口径一致
RECORD_LOOKBACK_DAYS = 750 # 打分所需历史(MA200 + 12-1 动量预热)
BENCH = ["SPY", "QQQ"]
logger = get_logger("forward_log")
# ---- 纯函数(离线可测) ----
def merge_records(existing: list[dict[str, Any]], new: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""同 (run_date, symbol, model_version) 的旧行被新行覆盖;其余保序保留。"""
key = lambda r: (r.get("run_date"), r.get("symbol"), r.get("model_version")) # noqa: E731
fresh = {key(r) for r in new}
return [r for r in existing if key(r) not in fresh] + list(new)
def evaluate_records(
records: list[dict[str, Any]],
closes_by_date: dict[str, dict[str, float]],
calendar: list[str],
horizon: int,
) -> dict[str, Any]:
"""对账 v1.1+ 信号与 legacy v1.0 记录。
v1.1+:信号在已完成日线收盘后生成,股票与 SPY 都用 signal_date 后下一交易日
的收盘作为统一、可复现的 entry;legacy 无 signal_date 记录保留原 last_close
口径并标记 method;所有模型版本在统计时严格分桶,不跨版本混算。
"""
date_idx = {d: i for i, d in enumerate(calendar)}
def _close_on_or_before(sym: str, date: str, after: str) -> float | None:
by_date = closes_by_date.get(sym, {})
best = None
for d, c in by_date.items():
if after < d <= date and (best is None or d > best[0]):
best = (d, c)
return best[1] if best else None
matured, immature, invalid = [], 0, 0
for r in records:
run_date = str(r.get("run_date") or "")
signal_date = str(r.get("signal_date") or "")
symbol = str(r.get("symbol") or "")
if signal_date:
# 信号使用 signal_date 收盘数据,执行从下一交易日收盘开始;股票和基准同日。
ti = next((date_idx[d] for d in calendar if d > signal_date), None)
entry_date = calendar[ti] if ti is not None else None
entry_price = closes_by_date.get(symbol, {}).get(entry_date) if entry_date else None
method = "next_session_close"
else:
# v1.0 legacy:缺少真实 signal_date,只能保留旧口径并显式标记。
ti = next((date_idx[d] for d in calendar if d >= run_date), None)
entry_date = calendar[ti] if ti is not None else None
entry_price = r.get("last_close")
method = "legacy_recorded_close"
if ti is None:
immature += 1
continue
if not isinstance(entry_price, (int, float)) or entry_price <= 0:
invalid += 1
continue
if ti + horizon >= len(calendar):
immature += 1
continue
target = calendar[ti + horizon]
c1 = closes_by_date.get(symbol, {}).get(target)
if c1 is None:
c1 = _close_on_or_before(symbol, target, entry_date)
spy0 = closes_by_date.get("SPY", {}).get(entry_date)
spy1 = closes_by_date.get("SPY", {}).get(target)
if not c1 or not spy0 or not spy1:
invalid += 1
continue
ret = (c1 / entry_price - 1) * 100
spy_ret = (spy1 / spy0 - 1) * 100
matured.append({**r, "entry_date": entry_date, "entry_price": entry_price,
"evaluation_method": method, "realized_pct": round(ret, 2),
"spy_pct": round(spy_ret, 2),
"excess_pct": round(ret - spy_ret, 2)})
return {"matured": matured, "immature_count": immature, "invalid_count": invalid}
def bucket_stats(matured: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""按 (model_version, verdict) 分桶的均值统计;跨版本不混算。"""
buckets: dict[str, list[dict[str, Any]]] = {}
for m in matured:
k = f"{m.get('model_version')}/{m.get('verdict')}"
buckets.setdefault(k, []).append(m)
out = {}
for k, rows in sorted(buckets.items()):
n = len(rows)
out[k] = {
"n": n,
"mean_pct": round(sum(r["realized_pct"] for r in rows) / n, 2),
"mean_excess_pct": round(sum(r["excess_pct"] for r in rows) / n, 2),
}
return out
def timing_bucket_stats(matured: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""按 (model_version, entry_timing) 分桶,供第四层样本外 A/B。"""
buckets: dict[str, list[dict[str, Any]]] = {}
for row in matured:
timing = row.get("entry_timing") or "unknown"
key = f"{row.get('model_version')}/{timing}"
buckets.setdefault(key, []).append(row)
out: dict[str, dict[str, Any]] = {}
for key, rows in sorted(buckets.items()):
n = len(rows)
out[key] = {
"n": n,
"mean_pct": round(sum(r["realized_pct"] for r in rows) / n, 2),
"mean_excess_pct": round(sum(r["excess_pct"] for r in rows) / n, 2),
}
return out
# ---- IO ----
def load_jsonl(path: str) -> list[dict[str, Any]]:
if not os.path.exists(path):
return []
with open(path, encoding="utf-8") as f:
return [json.loads(line) for line in f if line.strip()]
def write_jsonl(path: str, records: list[dict[str, Any]]) -> None:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
# ---- CLI 子命令(触网) ----
def _fetch(
symbols: list[str], lookback_days: int, feed: str, adjustment: str, timeout: int,
*, cache_mode: str = "auto", cache_db: str | None = None,
):
end = datetime.date.today().isoformat()
start = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat()
return fetch_bars(
symbols, start, end, feed, adjustment, 10000, timeout,
cache_mode=cache_mode, cache_db=cache_db,
)
def cmd_record(args) -> int:
symbols = ([s.strip().upper() for s in args.symbols.split(",") if s.strip()]
if args.symbols else list(UNIVERSE))
syms = list(dict.fromkeys(symbols + BENCH))
bars, used_feed, note = _fetch(
syms, RECORD_LOOKBACK_DAYS, args.feed, args.adjustment, args.timeout,
cache_mode=args.cache, cache_db=args.cache_db,
)
if note:
logger.info("market_data_note", operation="forward_record", reason=note)
result = build_result(syms, bars, used_feed, args.adjustment)
now_utc = datetime.datetime.now(datetime.timezone.utc)
run_date = now_utc.date().isoformat()
rows = []
for sym, a in result["symbols"].items():
if sym in BENCH or not isinstance(a, dict) or "error" in a:
continue
sc = a.get("score") or {}
if sc.get("composite") is None:
continue
rows.append({
"run_date": run_date,
"recorded_at": now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"model_version": MODEL_VERSION,
"symbol": sym,
"composite": sc.get("composite"),
"base_verdict": sc.get("verdict_before_entry_timing"),
"verdict": sc.get("verdict"),
"entry_timing": (a.get("entry_timing") or {}).get("classification"),
"timing_score_pct": (a.get("entry_timing") or {}).get("timing_score_pct"),
"confirmation_ok": (sc.get("confirmation") or {}).get("ok"),
"market_risk_off": bool(result.get("market_risk_off")),
"signal_date": a.get("last_date"),
"signal_close": a.get("last_close"),
# legacy readers still recognize last_close; v1.1+ evaluates from next-session price.
"last_close": a.get("last_close"),
})
merged = merge_records(load_jsonl(args.log), rows)
write_jsonl(args.log, merged)
print(f"记录 {len(rows)} 票({run_date}, {MODEL_VERSION})→ {args.log},累计 {len(merged)} 行")
return 0
def cmd_evaluate(args) -> int:
records = load_jsonl(args.log)
if not records:
print("日志为空:先运行 record 积累记录")
return 0
syms = sorted({r["symbol"] for r in records} | {"SPY"})
earliest = min(r["run_date"] for r in records)
lookback = (datetime.date.today() - datetime.date.fromisoformat(earliest)).days + 30
bars, _, note = _fetch(
syms, lookback, args.feed, args.adjustment, args.timeout,
cache_mode=args.cache, cache_db=args.cache_db,
)
if note:
logger.info("market_data_note", operation="forward_evaluate", reason=note)
closes = {s: {b["t"][:10]: b["c"] for b in bars.get(s, [])} for s in syms}
calendar = sorted(closes.get("SPY", {}))
res = evaluate_records(records, closes, calendar, args.horizon_days)
print(f"记录 {len(records)} 行:到期 {len(res['matured'])},未到期 {res['immature_count']},无效/缺价 {res['invalid_count']}")
if not res["matured"]:
print(f"暂无可评估样本(horizon={args.horizon_days} 交易日)。继续积累。")
return 0
print(f"\n按 verdict 分桶({args.horizon_days} 交易日,超额基准 SPY):")
for k, st in bucket_stats(res["matured"]).items():
print(f" {k:12} n={st['n']:<4} 均值 {st['mean_pct']:+.2f}% SPY 超额 {st['mean_excess_pct']:+.2f}%")
print("\n按入场时机分桶:")
for k, st in timing_bucket_stats(res["matured"]).items():
print(f" {k:28} n={st['n']:<4} 均值 {st['mean_pct']:+.2f}% SPY 超额 {st['mean_excess_pct']:+.2f}%")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="评分前瞻验证日志(record/evaluate)")
p.add_argument("--log", default=os.path.normpath(DEFAULT_LOG))
p.add_argument("--feed", default="iex")
p.add_argument("--adjustment", default="split")
p.add_argument("--timeout", type=int, default=120)
p.add_argument("--cache", choices=("auto", "refresh", "off"), default="auto")
p.add_argument("--cache-db", help="SQLite 文件路径;默认 skill 根目录 data/market-data.sqlite3")
sub = p.add_subparsers(dest="cmd", required=True)
pr = sub.add_parser("record", help="对篮子打分并追加当日记录(同日重跑覆盖)")
pr.add_argument("--symbols", help="逗号分隔;默认回测篮子 UNIVERSE")
pe = sub.add_parser("evaluate", help="对到期记录对账,按 verdict 分桶")
pe.add_argument("--horizon-days", type=int, default=DEFAULT_HORIZON)
add_logging_arguments(p)
args = p.parse_args(argv)
configure_logging(level=args.log_level, log_format=args.log_format)
clear_log_context()
bind_log_context(command=f"forward_log.{args.cmd}", feed=args.feed)
return cmd_record(args) if args.cmd == "record" else cmd_evaluate(args)
if __name__ == "__main__":
raise SystemExit(main())