research: lagged futures signals for stock exposure vs matched ETF rules (none passes, retain baseline)

128 fixed runs (32 profiles x daily ledger / overnight-only x 2 costs) plus 12
post-hoc availability diagnostics on overnight_recovery_top10, 2006-10 to 2026-09.
Futures trend/shock/rates rules do not beat the same rules on SPY/QQQ/IEF or a
static 75% exposure; all six default schemes fail the gates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:54:27 -07:00
parent 50b4cb7678
commit 3d69744068
3 changed files with 507 additions and 0 deletions

View File

@@ -102,6 +102,7 @@ PYTHONPATH=. uv run python -m research.lab.union_shield # SPY>MA
PYTHONPATH=. uv run --with numba python -m research.lab.stream_portfolio # risk-budgeted book of momentum/lowrisk/TrendRider/ETF TSMOM/BTC streams, 2006-26 (preregistered; REJECT 2026-10-08)
PYTHONPATH=. uv run python -m research.lab.hedged_momentum # momentum with its beta hedged by index futures (quick check; STOP 2026-10-08)
PYTHONPATH=. uv run python -m research.lab.futures_trend [--no-proxy] # 24-market futures TSMOM on Yahoo =F + data checks (quick check; Sharpe 0.44, DATA 2026-10-08)
uv run python -m research.lab.futures_stock_signals --output data/research/results/futures_stock_signals_new # lagged futures vs ETF stock-exposure rules; 2026-10-17: 128 fixed runs + 12 availability diagnostics, retain baseline; report in futures_stock_signals_20261017/report.md
PYTHONPATH=. uv run python -m research.lab.managed_futures_book [--no-proxy] # three sleeves + managed futures (tsmom24 proxy -> DBMF), 2006-26 (preregistered; REJECT 2026-10-08)
PYTHONPATH=. uv run python -m research.lab.small_cap_reversal # 5-day reversal on S&P 400/600, EW-hedged, spread costs (quick check; STOP 2026-10-08)
PYTHONPATH=. uv run python -m research.lab.turn_of_month # turn-of-month premium 1928-2026, 3x in the window (quick check; STOP 2026-10-08)

View File

@@ -0,0 +1,442 @@
"""Fixed-budget, lagged futures signals for stock exposure; research only.
uv run python -m research.lab.futures_stock_signals --output data/research/results/futures_stock_signals_20261017
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import pickle
import platform
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
import metrics
from backtest_runner import ENGINE_VERSION, prepare_strategy, replay_prepared
from research.lab.fetch_long_history import _cached
from research.lab.overnight_only import legs, nightly, top_mask
from research.lab.trials import append_trials
from strategies.robust_momentum import signal_overnight_recovery
START, END = "2006-10-02", "2026-09-24"
BLOCKS = [(START, "2012-12-31"), ("2013-01-01", "2019-12-31"), ("2020-01-01", END)]
POLICIES = ("equity_trend", "equity_shock", "rates_shock")
SOURCES = {"futures": ("ES=F", "NQ=F", "ZN=F"), "etf": ("SPY", "QQQ", "IEF")}
SETTINGS = ("default", "neighbor_low", "neighbor_high", "delay_two", "jump_masked")
PANEL = Path("data/research/us.panel.pkl")
SEED = 20261017
def clean(value):
if isinstance(value, dict):
return {str(k): clean(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [clean(v) for v in value]
if isinstance(value, (np.integer, np.floating)):
value = value.item()
if isinstance(value, float) and not math.isfinite(value):
return None
return value
def write_json(path, value):
Path(path).write_text(json.dumps(clean(value), indent=2, allow_nan=False) + "\n")
def configurations():
out = [{"name": "baseline"}, {"name": "static_75"}]
for policy in POLICIES:
for setting in SETTINGS:
for source in SOURCES:
look = ({"neighbor_low": 10, "neighbor_high": 40}.get(setting, 20)
if policy == "equity_trend" else 20)
threshold = {"neighbor_low": 1.0, "neighbor_high": 2.0}.get(setting, 1.5)
out.append({"name": f"{source}_{policy}_{setting}", "source": source,
"policy": policy, "setting": setting, "look": look,
"threshold": threshold, "lag": 2 if setting == "delay_two" else 1,
"mask_jumps": setting == "jump_masked"})
return out
def risk_exposure(prices: pd.DataFrame, config: dict) -> tuple[pd.Series, pd.Series]:
"""Row t trades at close t; the newest observable daily bar is t-lag.
Missing features leave the baseline unchanged and are counted as missing,
rather than interpreting an unavailable observation as a bearish signal.
"""
if config["name"] in ("baseline", "static_75"):
value = 1.0 if config["name"] == "baseline" else .75
return pd.Series(value, index=prices.index), pd.Series(True, index=prices.index)
names = SOURCES[config["source"]]
ret = np.log(prices[list(names)] / prices[list(names)].shift(1))
if config["mask_jumps"]:
scale = ret.rolling(63, min_periods=63).std().shift(1)
ret = ret.mask(ret.abs() > 6 * scale)
equity = ret.iloc[:, :2].mean(axis=1).where(ret.iloc[:, :2].notna().all(axis=1))
trend = equity.rolling(config["look"], min_periods=config["look"]).sum()
if config["policy"] == "equity_trend":
valid = trend.notna()
risk = (trend < 0).where(valid)
else:
x = equity if config["policy"] == "equity_shock" else ret.iloc[:, 2]
scale = x.rolling(63, min_periods=63).std().shift(1) * np.sqrt(5)
shock = x.rolling(5, min_periods=5).sum() / scale.where(scale > 0)
valid = shock.notna()
risk = shock < -config["threshold"]
if config["policy"] == "rates_shock":
valid &= trend.notna()
risk &= trend < 0
risk = risk.where(valid)
# Hold the reduction for five sessions after a shock to limit churn.
risk = risk.astype(float).rolling(5, min_periods=1).max().where(valid)
risk = risk.shift(config["lag"])
known = risk.notna()
return 1.0 - .5 * risk.fillna(0).astype(float), known
def overnight_exposure(entry_exposure: pd.Series) -> pd.Series:
# A return dated t is earned close t-1 -> open t, so the entry decision
# must already exist on t-1. This is separate from the source-data lag.
return entry_exposure.shift(1).fillna(1.0)
def describe(returns: pd.Series) -> dict:
r = returns.dropna()
anchor = r.index[0] - pd.Timedelta(days=1)
equity = pd.concat([pd.Series([1.0], index=[anchor]), (1 + r).cumprod()])
s = metrics.raw_summary(equity)
return {"cagr": s["annualizedReturn"], "sharpe": s["sharpeRatio"],
"vol": s["annualizedVolatility"], "mdd": s["maxDrawdown"],
"worst_day": float(r.min()), "es_5pct": float(r[r <= r.quantile(.05)].mean()),
"sessions": len(r),
"blocks": [describe_basic(r.loc[a:b]) for a, b in BLOCKS],
"later_period": describe_basic(r.loc["2017-01-01":]),
"yearly": {str(y): float((1 + g).prod() - 1) for y, g in r.groupby(r.index.year)}}
def describe_basic(r: pd.Series) -> dict:
anchor = r.index[0] - pd.Timedelta(days=1)
s = metrics.raw_summary(pd.concat([pd.Series([1.0], index=[anchor]), (1 + r).cumprod()]))
return {"cagr": s["annualizedReturn"], "sharpe": s["sharpeRatio"], "mdd": s["maxDrawdown"]}
def paired_sharpe_interval(candidate, control, repetitions=2000):
pair = pd.concat([candidate, control], axis=1).dropna().to_numpy()
n = len(pair)
rng = np.random.default_rng(SEED)
differences = []
for _ in range(repetitions):
starts = rng.integers(0, n - 20 + 1, size=math.ceil(n / 20))
idx = (starts[:, None] + np.arange(20)).ravel()[:n]
sample = pair[idx]
sharpe = sample.mean(axis=0) / sample.std(axis=0, ddof=1) * np.sqrt(252)
differences.append(sharpe[0] - sharpe[1])
return {"lower": float(np.quantile(differences, .025)),
"upper": float(np.quantile(differences, .975)),
"block_sessions": 20, "repetitions": repetitions, "seed": SEED,
"role": "paired descriptive interval; dependent on historical stationarity, not independent OOS"}
def data_inventory(prices, dates):
out = {}
for name, p in prices.items():
x = p.reindex(dates)
r = np.log(x / x.shift(1))
jump = r.abs() > 6 * r.rolling(63, min_periods=63).std().shift(1)
out[name] = {"first": str(p.first_valid_index().date()), "last": str(p.last_valid_index().date()),
"coverage": float(x.loc[START:END].notna().mean()), "jump_days": int(jump.sum())}
return out
def protocol(destination, configs):
source_paths = [Path(__file__), Path("execution.py"), Path("signals.py"), Path("backtest_runner.py"),
Path("metrics.py"), Path("trader.py"), Path("research/lab/overnight_only.py"),
Path("strategies/base.py"), Path("strategies/robust_momentum.py"),
Path("tests/test_futures_stock_signals.py")]
data_paths = [PANEL] + [Path(_cached(t)) for tickers in SOURCES.values() for t in tickers]
missing = [str(p) for p in data_paths if not p.exists()]
if missing:
raise FileNotFoundError(f"Required cached data missing: {missing}")
record = {
"created_at": datetime.now(timezone.utc).isoformat(),
"claim": "Lagged futures information improves stock exposure beyond matched ETF signals and simple de-risking.",
"primary": "futures_equity_shock_default on overnight_recovery_top10",
"hypotheses": {"incremental_information": "Futures beats both baseline and the same ETF rule after costs.",
"redundant_or_stale": "ETF control matches it, or costs/delay remove the benefit."},
"span": [START, END], "blocks": BLOCKS,
"role": "All historical periods previously inspected; fixed-rule development study, not independent OOS.",
"design_verdict": "APPROVED TO RUN: user authorized local research and backtests; one local process, no paid data.",
"baseline": "production RobustMomentumStrategy overnight_recovery_top10, PIT panel, unchanged target stock list",
"secondary": "nightly top10 by the same score through entry-day minus one; MOC -> next open, flat intraday",
"engine": ENGINE_VERSION, "capital": 10000, "integer_shares": True,
"daily_costs": [.001, .0025], "daily_fixed_fee": 2.0, "resize_band": .1,
"overnight_costs": [.0001, .0003], "overnight_fixed_fee": 0.0,
"cash_interest": 0.0, "sharpe": "mean daily return / sample standard deviation * sqrt(252), no rf subtraction",
"controlled": "identical stock selections, data, timing, capital and per-engine fees for all exposure rules",
"configurations": configs,
"budget": {"profiles": len(configs), "engines": 2, "costs_per_engine": 2,
"backtests": len(configs) * 4, "bootstrap_repetitions": 2000},
"rules": {"trend": "mean ES/NQ (or SPY/QQQ) log return over 20 sessions < 0",
"shock": "5-session return / (prior 63-session daily vol * sqrt(5)) < -1.5",
"rates": "ZN (or IEF) shock AND equity trend < 0",
"exposure": "0.5 in risk state, 1 otherwise; shock risk held five sessions",
"neighbors": "trend 10/40 sessions; shock threshold 1.0/2.0",
"timing": "entry close t uses source data <= t-1; overnight exit t additionally shifts exposure one session",
"missing": "no backward fill; missing signal preserves baseline, coverage recorded; require >=99% usable signals",
"jumps": "default untouched; sensitivity masks >6 times trailing63 volatility without using future observations"},
"gates": {"G1": "full-span Sharpe >= baseline +0.10 at both costs",
"G2": "full-span Sharpe >= matched ETF +0.05 AND static75 at both costs",
"G3": "CAGR >= baseline -2pp AND MaxDD improves >=3pp at both costs",
"G4": "beats baseline and ETF Sharpe in >=2/3 blocks AND later2017+ at both costs",
"G5": "base-cost later2017+ paired block-bootstrap 95% lower bound of Sharpe delta >0 vs baseline AND ETF",
"G6": "base-cost Sharpe delta vs ETF positive for both neighbors, lag2 and jump-masked variants",
"G7": "default source-feature availability >=99%"},
"decisions": "All gates pass -> candidate for forward validation only; else retain baseline. No live changes.",
"excluded": {"basis": "continuous contracts lack contract maturity, synchronous spot, rate and dividend inputs",
"positions": "no release-time-verified historical futures OI/COT panel",
"premarket": "daily open/close cache cannot reconstruct premarket decision-time quotes",
"industry_selection": "separate next-stage test; this study only changes total stock exposure"},
"known_limits": ["Yahoo continuous contracts are not roll-adjusted; jump masking is sensitivity, not reconstruction.",
"Futures/ETF timestamps and duration differ; lagged inputs do not isolate pure price discovery.",
"Historical PIT member price coverage is incomplete, especially before 2010.",
"Adjusted-share prices and delisting settlement assumptions follow the existing ledger.",
"Overnight open fallback to same-day close is counted and tested with zero-return sensitivity.",
"No independent forward sample; descriptive bootstrap does not resolve model-selection bias."],
"references": ["https://www.cmegroup.com/education/articles-and-reports/faq-daily-settlement-price-determination-time-change.html",
"https://www.cmegroup.com/trading/equity-index/fairvalue.html",
"https://www.cftc.gov/MarketReports/CommitmentsofTraders/index.htm"],
"environment": {"python": sys.version, "platform": platform.platform(), "numpy": np.__version__,
"pandas": pd.__version__, "command": sys.argv,
"git": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()},
"source_hashes": {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in source_paths},
"data_hashes": {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in data_paths},
}
destination.mkdir(parents=True, exist_ok=False)
write_json(destination / "protocol.json", record)
(destination / "experiment_card.md").write_text(
"# Experiment: Lagged Futures Information for Stock Exposure\n\n"
"> Status: Approved for the user-authorized local research run.\n\n"
"## Claim and Decision\n\n"
+ record["claim"] + "\n\n"
"Futures-information hypothesis predicts an improvement over the unchanged stock strategy, "
"matched ETF rule and static 75% exposure. The redundancy hypothesis predicts that the ETF "
"rule matches it or costs and a further one-session lag remove the benefit. "
"All seven fixed gates must pass before recommending a forward experiment; no production promotion.\n\n"
"## Setup\n\n"
"Only exposure changes. Production stock targets, PIT panel, capital and fees stay fixed. "
"Separate production daily-close ledger and nightly MOC/MOO models have their own matched controls. "
"Three fixed signal families, two neighboring parameters, lag2 and causal jump masking; "
"128 runs total. All parameter values and gates are in protocol.json, written before any evaluation. "
"No fresh OOS label is assigned to previously inspected history.\n\n"
"## Expected Artifacts and Review\n\n"
"Daily returns, exposures, production-ledger trades, costs, subperiods, paired 20-session "
"block-bootstrap intervals and complete trial records. Results are tables; no result figure planned. "
"Local low-cost execution is authorized by the user's instruction to start research and backtests.\n\n"
"## Reproduction Details\n\n"
"protocol.json records the command, runtime versions, Git revision and source/data SHA256. "
"No downloads, paid sources, shared-host jobs or live-account writes.\n\n"
"## Benchmark Audit\n\n"
"| Issue | Verdict | Evidence |\n|---|---|---|\n"
"| Timing | PASS | Source lag1 and extra entry-to-exit shift for overnight returns; causal tests |\n"
"| Fair baselines | PASS | Same stocks, costs and execution; ETF and static-exposure controls |\n"
"| Search budget | PASS | Fixed 32 profiles and both costs before execution; all runs retained |\n"
"| Uncertainty | PASS | Paired block bootstrap and three chronological blocks |\n"
"| Independent OOS | NEEDS EVIDENCE | All historical periods previously inspected |\n"
"| Continuous-contract data | NEEDS EVIDENCE | No contract/roll history; use sensitivity, limit claims |\n"
"| Serving workload | N/A | Financial backtest, no serving system or request trace |\n\n"
"## Results\n\nRecorded after execution in results.json and report.md.\n")
return record
def decisions(rows, curves, known):
out = []
for engine, costs in (("daily", (.001, .0025)), ("overnight", (.0001, .0003))):
indexed = {(r["profile"], r["cost"]): r for r in rows if r["engine"] == engine}
for policy in POLICIES:
name, control = f"futures_{policy}_default", f"etf_{policy}_default"
checks = {f"G{i}": True for i in range(1, 8)}
comparisons = []
for cost in costs:
f, e, b, s = (indexed[(k, cost)] for k in (name, control, "baseline", "static_75"))
checks["G1"] &= f["sharpe"] >= b["sharpe"] + .10
checks["G2"] &= f["sharpe"] >= e["sharpe"] + .05 and f["sharpe"] >= s["sharpe"]
checks["G3"] &= f["cagr"] >= b["cagr"] - .02 and f["mdd"] >= b["mdd"] + .03
blocks = sum(x["sharpe"] > max(y["sharpe"], z["sharpe"])
for x, y, z in zip(f["blocks"], b["blocks"], e["blocks"]))
checks["G4"] &= blocks >= 2 and f["later_period"]["sharpe"] > max(
b["later_period"]["sharpe"], e["later_period"]["sharpe"])
comparisons.append({"cost": cost, "sharpe_delta_baseline": f["sharpe"] - b["sharpe"],
"sharpe_delta_etf": f["sharpe"] - e["sharpe"], "blocks_better_than_both": blocks})
intervals = {}
for other in ("baseline", control):
intervals[other] = paired_sharpe_interval(
curves[f"{engine}|{name}|{costs[0]}"] .loc["2017-01-01":],
curves[f"{engine}|{other}|{costs[0]}"] .loc["2017-01-01":])
checks["G5"] = all(c["lower"] > 0 for c in intervals.values())
sensitivities = {}
for setting in SETTINGS[1:]:
f, e = (indexed[(f"{source}_{policy}_{setting}", costs[0])] for source in SOURCES)
sensitivities[setting] = f["sharpe"] - e["sharpe"]
checks["G6"] = all(v > 0 for v in sensitivities.values())
checks["G7"] = known[name] >= .99
out.append({"engine": engine, "policy": policy, "checks": checks,
"comparisons": comparisons, "later_period_bootstrap": intervals,
"sensitivity_sharpe_delta_etf": sensitivities,
"decision": "forward_candidate_only" if all(checks.values()) else "retain_baseline"})
return out
def report(destination, result):
labels = {"baseline": "原策略", "static_75": "固定75%仓位",
"futures_equity_trend_default": "股指期货20日趋势", "etf_equity_trend_default": "对应ETF20日趋势",
"futures_equity_shock_default": "股指期货5日急跌", "etf_equity_shock_default": "对应ETF5日急跌",
"futures_rates_shock_default": "国债期货急跌+股指走弱", "etf_rates_shock_default": "对应ETF国债急跌+股指走弱"}
passed = sum(d["decision"] == "forward_candidate_only" for d in result["decisions"])
text = ["# 期货信号辅助股票仓位:固定规则回测", "",
f"结论:三类期货信号、两种持有方式共6个默认方案,{passed}个通过预定标准。", "",
"本研究只改变股票总仓位,未交易期货,也未改变股票评分。",
"完成32种配置×2种持有方式×2档成本,共128组预先固定的回测。",
"全部历史区间已在过去研究中查看;结果属于开发证据,不能称为独立样本外或实盘收益。", "",
f"区间:{START} 至 {END}。指标中的回撤为负数;Sharpe 未减无风险利率。", ""]
for engine, cost in (("daily", .001), ("overnight", .0001)):
text += ["## " + ("连续持仓版:原股票策略" if engine == "daily" else "隔夜版:收盘买、次日开盘卖"), "",
"| 版本 | 年化收益 | Sharpe | 最大回撤 | 平均目标仓位 |", "|---|---:|---:|---:|---:|"]
for row in result["rows"]:
if row["engine"] == engine and row["cost"] == cost and row["profile"] in labels:
text.append(f"| {labels[row['profile']]} | {row['cagr']:.2%} | {row['sharpe']:.3f} | "
f"{row['mdd']:.2%} | {row['mean_target_exposure']:.1%} |")
text.append("")
text += ["连续持仓版:生产执行账本、$10,000、整股、单边10/25 bp及每单$2,10%调仓带。",
"隔夜版:每日全额往返、单边1/3 bp、无固定佣金、分数股;两种引擎分别比较,不互相排名。", "",
"## 验收", "", "| 引擎 | 信号 | 未通过门槛 | 结论 |", "|---|---|---|---|"]
for d in result["decisions"]:
failed = ", ".join(k for k, v in d["checks"].items() if not v) or "无"
text.append(f"| {d['engine']} | {d['policy']} | {failed} | {d['decision']} |")
text += ["", "G1:超越原策略;G2:超越ETF及简单降仓;G3:收益和回撤门槛;G4:分段稳定;",
"G5:2017年后配对分块bootstrap区间;G6:邻近参数、延迟及跳变敏感性;G7:数据覆盖。", "",
"bootstrap区间为名义95%区间,未作多重比较校正,不能代替独立前向验证。", "",
"## 数据与边界", "", "| 默认期货特征 | 可用率 |", "|---|---:|"]
for policy in POLICIES:
key = f"futures_{policy}_default"
text.append(f"| {labels[key]} | {result['audit']['signal_coverage'][key]:.2%} |")
text += ["", "原始日线有缺口,滚动窗口会进一步放大特征不可用范围;覆盖情况见data_audit.json。",
f"隔夜模型有{result['audit']['overnight_missing_open_nights']}个夜晚涉及个别选中股票缺开盘价;",
"已将这些股票的该夜收益设为0做敏感性检查,完整结果列于results.json。",
"早期指数成员价格覆盖不足;逐年观测成员数列于data_audit.json。", "",
"未测试真正期现基差、期货持仓/COT、盘前分钟信号和行业重排;对应数据缺口列于protocol.json。",
"下一步优先取得带明确时间戳的期货分钟数据、逐月合约及持仓发布记录,再检验信息增量。", "",
"原始日线、源代码及股票面板的SHA256列于protocol.json;完整参数与成本结果见results.csv、",
"results.json;逐日收益、目标仓位见daily_returns.csv.gz和exposure.csv;生产账本成交见trades.csv.gz。", ""]
ablation = destination / "availability_results.json"
if ablation.exists():
a = json.loads(ablation.read_text())
text += ["## 数据缺口匹配诊断", "",
"主实验结束后另固定12组诊断:在期货特征不可用的日期,让ETF版本也保持原仓位。",
"该诊断只解释数据可用性,不参与方案选择。下表是期货Sharpe减去此ETF对照的Sharpe。", "",
"| 信号 | 连续持仓低成本档 | 隔夜低成本档 |", "|---|---:|---:|"]
for policy in POLICIES:
selected = [r for r in a["rows"] if r["profile"] == f"etf_{policy}_availability_matched"
and r["cost"] == (.001 if r["engine"] == "daily" else .0001)]
delta = {r["engine"]: r["futures_sharpe_minus_matched_etf"] for r in selected}
text.append(f"| {labels[f'futures_{policy}_default']} | {delta['daily']:+.4f} | {delta['overnight']:+.4f} |")
text += ["", "完整两档成本及仅在可观测日期计算的信号一致率见availability_results.json。", ""]
if (destination / "execution_source.py").exists():
text += ["首次执行的脚本保存在execution_source.py,匹配预先登记的源代码SHA256;后续仅改善报告排版。", ""]
text += ["复现:", "", "```bash",
f"uv run python -m research.lab.futures_stock_signals --output {destination}_rerun", "```", ""]
(destination / "report.md").write_text("\n".join(text))
def run(destination: Path):
configs = configurations()
record = protocol(destination, configs)
print(f"Protocol saved before evaluation: {destination}; budget {record['budget']}", flush=True)
with PANEL.open("rb") as fh:
p = pickle.load(fh)
prices = pd.DataFrame({t: pd.read_csv(_cached(t), index_col=0, parse_dates=True)["Close"]
for tickers in SOURCES.values() for t in tickers})
prices = prices.reindex(p.dates)
if p.dates[-1] < pd.Timestamp(END):
raise ValueError("Stock panel ends before the fixed evaluation interval")
panels = {field: getattr(p, field) for field in ("open", "high", "low", "close", "volume")}
panels["close"] = p.close.join(p.extra["etf"][["SPY"]])
plan = prepare_strategy("overnight_recovery_top10", "us", panels, list(p.close.columns),
member=p.member, start=START, end=END)
score = signal_overnight_recovery({"open": p.open, "close": p.close,
"eligible": p.member & p.close.notna()})
selected = top_mask(score.shift(2), 10)
overnight, _ = legs(p)
gross_night = nightly(selected, overnight).loc[START:END]
missing_opens = selected & p.open.isna()
zero_open_sensitivity = nightly(selected, overnight.mask(p.open.isna(), 0.0)).loc[START:END]
entries = {}
known = {}
for config in configs:
exposure, valid = risk_exposure(prices, config)
entries[config["name"]] = exposure
known[config["name"]] = float(valid.loc[START:END].mean())
pd.DataFrame(entries).loc[START:END].to_csv(destination / "exposure.csv")
audit = {"daily_bar_inventory": data_inventory(prices, p.dates), "signal_coverage": known,
"pit_mean_observed_members_by_year": p.member.sum(axis=1).groupby(p.dates.year).mean().to_dict(),
"overnight_selected_missing_opens": int(missing_opens.loc[START:END].sum().sum()),
"overnight_missing_open_nights": int(missing_opens.loc[START:END].any(axis=1).sum()),
"max_gross_return_change_zero_open_sensitivity": float((gross_night - zero_open_sensitivity).abs().max())}
write_json(destination / "data_audit.json", audit)
rows, curves, trades = [], {}, []
for config in configs:
name = config["name"]
entry = entries[name].loc[START:END]
daily_plan = dict(plan, targets=plan["targets"].mul(entry, axis=0))
for cost in record["daily_costs"]:
ledger = replay_prepared(daily_plan, 10000, cost, 2.0, integer_shares=True, resize_band=.1)
r = ledger["equity"].pct_change().dropna()
if ledger["cash"].min() < -1e-7:
raise AssertionError("Unfunded replay")
row = {"engine": "daily", "profile": name, "cost": cost, **describe(r),
"mean_target_exposure": float(entry.mean()),
"mean_actual_exposure": float((1 - ledger["cash"] / ledger["equity"]).dropna().mean()),
"orders": len(ledger["trade_log"]),
"fees": sum(t["commission"] for t in ledger["trade_log"]),
"minimum_cash": float(ledger["cash"].min())}
rows.append(row)
curves[f"daily|{name}|{cost}"] = r
trades.extend({"profile": name, "cost": cost, **t} for t in ledger["trade_log"])
append_trials(destination / "trials.jsonl", [row], {"study": "futures_stock_signals", "config": config})
print(f"daily {name:43s} {cost:.4f}: CAGR {row['cagr']:.2%} Sh {row['sharpe']:.3f} DD {row['mdd']:.2%}", flush=True)
night_exp = overnight_exposure(entries[name]).reindex(gross_night.index)
for cost in record["overnight_costs"]:
r = night_exp * (gross_night - 2 * cost)
sensitivity = describe_basic(night_exp * (zero_open_sensitivity - 2 * cost))
row = {"engine": "overnight", "profile": name, "cost": cost, **describe(r),
"mean_target_exposure": float(night_exp.mean()), "zero_open_sensitivity": sensitivity}
rows.append(row)
curves[f"overnight|{name}|{cost}"] = r
append_trials(destination / "trials.jsonl", [row], {"study": "futures_stock_signals", "config": config})
verdicts = decisions(rows, curves, known)
result = {"protocol": record, "audit": audit, "rows": rows, "decisions": verdicts}
write_json(destination / "results.json", result)
pd.DataFrame([{k: v for k, v in r.items() if k not in ("blocks", "later_period", "yearly", "zero_open_sensitivity")}
for r in rows]).to_csv(destination / "results.csv", index=False)
pd.DataFrame(curves).to_csv(destination / "daily_returns.csv.gz", compression="gzip")
pd.DataFrame(trades).to_csv(destination / "trades.csv.gz", index=False, compression="gzip")
report(destination, result)
append_trials(Path("data/research/results/trials_us.jsonl"),
[{"name": f"futures_stock_signals:{r['engine']}:{r['profile']}", "cost": r["cost"],
"cagr": r["cagr"], "sharpe": r["sharpe"], "mdd": r["mdd"]} for r in rows],
{"study": "futures_stock_signals", "role": record["role"], "path": str(destination)})
print(json.dumps(clean(verdicts), indent=2), flush=True)
print(f"Saved {destination / 'report.md'}", flush=True)
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
run(parser.parse_args().output)

View File

@@ -0,0 +1,64 @@
import unittest
import numpy as np
import pandas as pd
from research.lab.futures_stock_signals import configurations, overnight_exposure, risk_exposure
class FuturesTimingTests(unittest.TestCase):
def setUp(self):
dates = pd.bdate_range("2020-01-01", periods=180)
t = np.arange(len(dates))
equity = 100 * np.exp(.001 * t + .002 * np.sin(t))
rates = 100 * np.exp(.0001 * t + .0002 * np.sin(t))
self.prices = pd.DataFrame({"ES=F": equity, "NQ=F": equity, "ZN=F": rates,
"SPY": equity, "QQQ": equity, "IEF": rates}, index=dates)
def test_future_prices_cannot_change_earlier_decisions(self):
changed = self.prices.copy()
changed.iloc[110:, :3] *= .7
for config in configurations():
original, known = risk_exposure(self.prices, config)
modified, modified_known = risk_exposure(changed, config)
last = 110 + config.get("lag", 1)
with self.subTest(config=config["name"]):
pd.testing.assert_series_equal(original.iloc[:last], modified.iloc[:last])
pd.testing.assert_series_equal(known.iloc[:last], modified_known.iloc[:last])
def test_same_day_futures_crash_is_not_usable_for_entry_or_preceding_night(self):
config = next(c for c in configurations() if c["name"] == "futures_equity_shock_default")
changed = self.prices.copy()
changed.iloc[110:, :2] *= .7
entry, _ = risk_exposure(changed, config)
self.assertEqual(entry.iloc[110], 1.0)
self.assertEqual(entry.iloc[111], .5)
night = overnight_exposure(entry)
self.assertEqual(night.iloc[111], 1.0)
self.assertEqual(night.iloc[112], .5)
def test_identical_etf_and_futures_inputs_have_identical_rules(self):
configs = {c["name"]: c for c in configurations()}
for name, config in configs.items():
if not name.startswith("futures_"):
continue
with self.subTest(config=name):
a, av = risk_exposure(self.prices, config)
b, bv = risk_exposure(self.prices, configs[name.replace("futures_", "etf_", 1)])
pd.testing.assert_series_equal(a, b, check_names=False)
pd.testing.assert_series_equal(av, bv, check_names=False)
def test_unavailable_prices_are_not_backward_filled_from_future(self):
config = next(c for c in configurations() if c["name"] == "futures_equity_shock_default")
missing = self.prices.copy()
missing.iloc[110, :2] = np.nan
exposure, known = risk_exposure(missing, config)
self.assertFalse(known.iloc[111])
self.assertEqual(exposure.iloc[111], 1.0)
prefix, prefix_known = risk_exposure(missing.iloc[:112], config)
pd.testing.assert_series_equal(exposure.iloc[:112], prefix)
pd.testing.assert_series_equal(known.iloc[:112], prefix_known)
if __name__ == "__main__":
unittest.main()