"""金仓 v2.1 回测引擎:信号复现 + 决策表状态机(纯标准库)。 规则与口径见《金仓v2-全历史回测计划书.md》v2.1(预注册,参数冻结): σ RV-EWMA6 月度递推,月度口径(逐行复刻 app/model.py sigma_rv / rv_sq) q exp-03 W3 walk-forward 分数序列(exp/results/exp03_regime.json)的 逐点「前序排序分位」(≤ 比较,不含自身);2021-04 起可用,此前 q=None pace WGC 季度 cbTonnes;季度结束后第 31 个自然日(end+30d)起可见 决策表优先级(计划书 §2.3): 1 逻辑退出(月度, pace<102 → 卖 1/3, 残值≤max($10,1%V) 清光) > 2 退出解除(本月不买) > 3 q≥90 减仓(只卖不买) > 4 带再平衡([0.8w,1.2w]×V) > 5/6/7 补仓 S1/S2/S3(日频) 执行价 = 信号次一交易日 LBMA PM;成本单边 0.05%;杠杆上限 100%;风险日不买入。 """ import bisect import csv import json import math from calendar import monthrange from datetime import date, timedelta from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DATA = ROOT / "data" HERE = Path(__file__).resolve().parent COST = 0.0005 # 单边 0.05% EXIT_LINE = 102.0 # 央行购金退出线(吨/季),预注册冻结 # ---------------------------------------------------------------- 数据加载 def load_pm(): """LBMA PM(USD/oz):[(iso_date, px)],升序。""" rows = json.loads((DATA / "gold-lbma-pm.json").read_text(encoding="utf-8")) out = [] for r in rows: v = r.get("v") or [] if v and v[0]: out.append((r["d"], float(v[0]))) out.sort(key=lambda x: x[0]) return out def load_daily_gold(): lab = json.loads((DATA / "lab-data.json").read_text(encoding="utf-8")) return lab["daily"]["dates"], lab["daily"]["gold"] def rv_sq_map(dates, gold): """复刻 model.py:86-95:月内日对数收益平方和(收益计入后一日所在月);≥15 个收益日的月有效。""" acc, cnt = {}, {} prev = None for d, p in zip(dates, gold): if prev is not None: mm = d[:7] r = math.log(p) - math.log(prev) acc[mm] = acc.get(mm, 0.0) + r * r cnt[mm] = cnt.get(mm, 0) + 1 prev = p return {k: acc[k] for k in acc if cnt[k] >= 15} def sigma_series(month_axis, rv): """复刻 model.py sigma_rv:158-171:σ(月 t) 只用 ≤ t-1 月,权重 0.5^(age/6),age>240 截断。""" out = {} for t, ym in enumerate(month_axis): sw = swv = 0.0 i = t - 1 while i >= 0: r2 = rv.get(month_axis[i]) if r2 is not None: age = t - 1 - i w = 0.5 ** (age / 6) sw += w swv += w * r2 if age > 240: break i -= 1 if sw > 0: out[ym] = math.sqrt(swv / sw) return out def load_q_rows(path=None): """exp-03 W3 walk-forward 序列 → [(anchor_date, p, q_prior_pct)]。q=在**此前**已预测锚点中的分位。""" p = Path(path) if path else ROOT / "exp" / "results" / "exp03_regime.json" d = json.loads(p.read_text(encoding="utf-8")) rows = sorted(d["results"]["W3"]["rows"], key=lambda r: r["d"]) out, past = [], [] for r in rows: q = None if past: q = 100.0 * bisect.bisect_right(past, r["p"]) / len(past) out.append((r["d"], r["p"], q)) bisect.insort(past, r["p"]) return out def load_cb(): """WGC 季度央行购金:[(qlabel, quarter_end_date, tonnes)],按季度末升序。""" lines = (DATA / "wgc" / "gdt-quarterly.csv").read_text(encoding="utf-8-sig").strip().splitlines() hdr = lines[0].split(",") out = [] for ln in lines[1:]: r = dict(zip(hdr, ln.split(","))) qlab = r["quarter"].strip() y, q = int(qlab[:4]), int(qlab[5:6]) nxt = date(y + (1 if q == 4 else 0), 1 if q == 4 else q * 3 + 1, 1) out.append((qlab, nxt - timedelta(days=1), float(r["cbTonnes"]))) out.sort(key=lambda x: x[1]) return out def pace_at(cb, d): """t 日可见 pace:end+30 天 ≤ d 的最近季度值。""" best = None for _, end, t in cb: if end + timedelta(days=30) <= d: best = t else: break return best def pace_last_two(cb, d): vals = [t for _, end, t in cb if end + timedelta(days=30) <= d] return vals[-2:] if len(vals) >= 2 else [] def prev_month_last(ym): y, m = int(ym[:4]), int(ym[5:7]) py, pm = (y - 1, 12) if m == 1 else (y, m - 1) return date(py, pm, monthrange(py, pm)[1]).isoformat() def build_signals(q_file=None): """共用信号集(主回测与对照任务 §9 共用,防止实现漂移): σ 映射(RV-EWMA6 月度递推)、D14 回撤深度代理分位、exp-03 walk-forward q 序列、WGC pace、PM 序列。""" dates_all, gold_all = load_daily_gold() rv = rv_sq_map(dates_all, gold_all) month_axis = sorted({d[:7] for d in dates_all}) sig = sigma_series(month_axis, rv) month_last_idx = {} for i, d in enumerate(dates_all): month_last_idx[d[:7]] = i depth63 = {} for ym, i in month_last_idx.items(): lo = max(0, i - 62) seg = gold_all[lo:i + 1] peak = seg[0] worst = 0.0 for p in seg: if p > peak: peak = p dd = p / peak - 1.0 if dd < worst: worst = dd depth63[ym] = -worst # 深度取正值,越深分位越高 yms_sorted = sorted(depth63) dvals = [depth63[ym] for ym in yms_sorted] q_proxy = {} for t, ym in enumerate(yms_sorted): window = dvals[max(0, t - 119):t + 1] if len(window) < 24: continue q_proxy[ym] = 100.0 * sum(1 for v in window if v <= dvals[t]) / len(window) return {"dates_all": dates_all, "gold_all": gold_all, "month_axis": month_axis, "sig": sig, "q_proxy": q_proxy, "qrows": load_q_rows(q_file), "cb": load_cb(), "pm": load_pm()} def month_q_eff(S, qrows, ym): """有效 q(D13/D14):真实 exp-03 walk-forward 分位(≤上月末最新锚点)优先,否则回撤深度代理;均缺 → None。""" lim = prev_month_last(ym) q = None for ad, _p, qq in qrows: if ad <= lim: q = qq else: break return q if q is not None else S["q_proxy"].get(ym) # ---------------------------------------------------------------- 引擎 def run(start="2013-01-01", end="2025-12-31", target_vol_ann=12.0, qrows=None, V0=10000.0, tag="run", overlay=None, init_state=None): """跑一段回测,写 results//,返回 (summary, final_state)。 overlay(v3 研究 D17,默认全关): cash_yield 闲置现金年化收益率 %(日计息) cap_half dict ym->bool:True 的月份目标仓位 ×0.5(F2 慢趋势/气候地板;该月暂停买入档) breaker True 启用日频闪断:RV5 > 1.5×当月σ(日频口径)→ 目标 ×0.5,翻转次日卖出至目标, 期间暂停买入档,RV5 回落自动解除(F3) cb_line dict ym->CB*:逻辑退出线替换固定 102(F5;缺月或 γ≈0 回落 102) """ sigma_star = (target_vol_ann / 100.0) / math.sqrt(12.0) ov = overlay or {} cash_yield_daily = (ov.get("cash_yield", 0.0) or 0.0) / 100.0 / 252.0 cys = ov.get("cash_yield_series") # D19:[(date, 年利率%)] 逐日真实利率 cys_d = [x[0] for x in cys] if cys else None cap_half = ov.get("cap_half") breaker_on = bool(ov.get("breaker", False)) cb_line = ov.get("cb_line") S = build_signals() dates_all, gold_all = S["dates_all"], S["gold_all"] sig = S["sig"] q_proxy = S["q_proxy"] qrows = qrows if qrows is not None else S["qrows"] cb = S["cb"] pm = S["pm"] days = [(d, px) for d, px in pm if start <= d <= end] if not days: assert init_state is not None, f"窗口内无交易日: {start}~{end}(数据截至 {pm[-1][0]})" days = [(pm[-1][0], pm[-1][1])] # 锚定今天:数据末日单点(只出状态与价格地图) # F3 日频熔断的 RV5(日频已实现波动,近 5 日对数收益 RMS) rv5 = [None] * len(days) _r = [] for i, (d, px) in enumerate(days): if i > 0: _r.append(math.log(px / days[i - 1][1])) if len(_r) > 5: _r.pop(0) if len(_r) == 5: rv5[i] = math.sqrt(sum(x * x for x in _r) / 5.0) month_start_dates = set() # 日历意义上每月首个交易日(全序列判定) _prev_ym = None for d, _px in pm: if d[:7] != _prev_ym: month_start_dates.add(d) _prev_ym = d[:7] def w_target(s, q): if not s: return None base = min(1.0, sigma_star / s) rf = 1.0 if q is not None: if q >= 90: rf = 0.5 elif q >= 75: rf = 0.7 elif q >= 60: rf = 0.85 return base * rf # ---- 状态 ---- cash, units = V0, 0.0 mode = "FLAT" # FLAT / IN / EXITING entry_price, r0 = None, 0.0 cost_basis = 0.0 # 当前持仓的买入成本(卖出按比例结转) used = {"S1": False, "S2": False, "S3": False} routine_order = None # (exec_j, side, amount, reason, w_sig or None) pending_add = None # (exec_j, amount, reason, [stage,...]) pending_forced = None # (exec_j, "sell", amount, reason) 熔断减仓(F3) brk = False # 熔断状态 brk_flip = False fees = 0.0 trades, curve = [], [] bh_units = V0 * (1 - COST) / days[0][1] exit_spans = [] # [(start_d, end_d)] EXITING 区间(供画图) # 现实锚定(D22):以用户真实持仓为初始状态,从窗口起点继续执行规则 if init_state: cash = float(init_state.get("cash", 0.0) or 0.0) units = float(init_state.get("units", 0.0) or 0.0) cost_basis = float(init_state.get("cost_basis", 0.0) or 0.0) entry_price = float(init_state["entry_price"]) if init_state.get("entry_price") else None mode = "IN" if units > 1e-12 else "FLAT" r0 = cash if mode == "IN" else 0.0 for k in used: used[k] = bool((init_state.get("stages_used") or {}).get(k, False)) bh_units = (cash + units * days[0][1]) * (1 - COST) / days[0][1] def do_buy(j, d, px, amount, reason, w_sig=None, is_entry=False): nonlocal cash, units, fees, mode, entry_price, r0, cost_basis amount = min(amount, cash / (1 + COST)) if amount <= 1e-9: return None fee = amount * COST units += amount / px cost_basis += amount cash -= amount + fee fees += fee if is_entry: mode = "IN" entry_price = px r0 = (cash + units * px) * (1 - w_sig) if w_sig else 0.0 for k in used: used[k] = False trades.append({"date": d, "action": "BUY", "reason": reason, "amount_usd": round(amount, 2), "price": px, "V_after": round(cash + units * px, 2)}) return amount def do_sell(j, d, px, amount, reason): nonlocal cash, units, fees, mode, cost_basis amount = min(amount, units * px) if amount <= 1e-9: return None cost_basis *= (1 - amount / (units * px)) # 卖出部分按比例结转成本 fee = amount * COST units -= amount / px cash += amount - fee fees += fee trades.append({"date": d, "action": "SELL", "reason": reason, "amount_usd": round(amount, 2), "price": px, "V_after": round(cash + units * px, 2)}) return amount for j, (d, px) in enumerate(days): ym = d[:7] s = sig.get(ym) q = month_q_eff(S, qrows, ym) # 有效 q:真实优先,否则回撤深度代理(D13/D14) w = w_target(s, q) dd = date.fromisoformat(d) pace = pace_at(cb, dd) # ---- 0) 覆盖信号(D17:F2 地板 / F3 熔断 / F5 退出线)---- line = EXIT_LINE if cb_line: _cl = cb_line.get(ym) if _cl is not None: line = _cl half_m = bool(cap_half and cap_half.get(ym)) brk_now = bool(breaker_on and s and rv5[j] is not None and rv5[j] > 1.5 * s / math.sqrt(21.0)) if brk_now and not brk: brk, brk_flip = True, True elif not brk_now and brk: brk = False w_day = w * (0.5 if (half_m or brk) else 1.0) if w is not None else None # ---- 1) 执行到期订单 ---- sold_today = False if routine_order and routine_order[0] == j: _, side, amount, reason, w_sig = routine_order routine_order = None if side == "buy": got = do_buy(j, d, px, amount, reason, w_sig=w_sig, is_entry=(reason == "ENTRY")) else: got = do_sell(j, d, px, amount, reason) sold_today = got is not None if units * px <= 1e-9 or units * px <= max(10.0, 0.01 * (cash + units * px)): if mode == "EXITING" and units > 0 and units * px <= max(10.0, 0.01 * (cash + units * px)): got = do_sell(j, d, px, units * px, reason + "+final") sold_today = sold_today or (got is not None) if units * px <= 1e-6: units = 0.0 if mode == "EXITING": mode = "FLAT" if pending_forced and pending_forced[0] == j: _, _side, amount, reason = pending_forced pending_forced = None if not sold_today and mode == "IN": got = do_sell(j, d, px, amount, reason) sold_today = sold_today or (got is not None) if pending_add and pending_add[0] == j: _, amount, reason, stages = pending_add pending_add = None if not sold_today and mode == "IN": got = do_buy(j, d, px, amount, reason) if got is not None: for st in stages: used[st] = True elif sold_today: trades.append({"date": d, "action": "SKIP", "reason": reason, "amount_usd": 0, "price": px, "V_after": round(cash + units * px, 2)}) # ---- 2) 月度例行信号(当月首个交易日收盘评估,次日执行)---- if d in month_start_dates and j + 1 < len(days): band_skip = False if mode in ("IN", "EXITING"): if pace is not None and pace < line: hv = units * px routine_order = (j + 1, "sell", hv / 3.0, f"EXIT pace<{line:.0f}", None) mode = "EXITING" else: if mode == "EXITING": mode = "IN" band_skip = True # 退出解除当月不买(计划书 D2) if mode == "IN" and w_day is not None: hv = units * px V = cash + hv frac = hv / V if V > 0 else 0.0 if q is not None and q >= 90: routine_order = (j + 1, "sell", max(hv / 3.0, hv - 1.2 * w_day * V), "RISK q>=90", None) elif not band_skip: if frac < 0.8 * w_day: routine_order = (j + 1, "buy", w_day * V - hv, "BAND buy", None) elif frac > 1.2 * w_day: routine_order = (j + 1, "sell", hv - w_day * V, "BAND sell", None) elif mode == "FLAT" and w_day is not None: last2 = pace_last_two(cb, dd) if len(last2) == 2 and min(last2) >= line: routine_order = (j + 1, "buy", w_day * cash, "ENTRY", w_day) # ---- 2b) 熔断翻转:次日卖出至减半目标(F3)---- if brk_flip: brk_flip = False if j + 1 < len(days) and mode == "IN" and w_day is not None: hv = units * px V = cash + hv tgt = w_day * V # w_day 已含 ×0.5 if hv > tgt: pending_forced = (j + 1, "sell", hv - tgt, "BREAKER") # ---- 3) 日频补仓触发(收盘评估,次日执行;地板/熔断月暂停买入档)---- if (mode == "IN" and s and w_day is not None and entry_price is not None and j + 1 < len(days) and not brk and not half_m): q_ok = (q is None) or (q < 90) trig = [] if q_ok and not used["S1"] and px <= entry_price * (1 - s): trig.append((0.3 * r0, "ADD_S1", "S1")) if q_ok and not used["S2"] and px <= entry_price * (1 - 2 * s): trig.append((0.3 * r0, "ADD_S2", "S2")) s3_cond = s <= 0.06 or q is None or (q is not None and q <= 60) if s3_cond and not used["S3"]: hv = units * px V = cash + hv need = w_day * V - hv if need > 1e-9: trig.append((min(need, cash), "ADD_S3", "S3")) if trig: pending_add = (j + 1, sum(a for a, _, _ in trig), "+".join(rs for _, rs, _ in trig), [st for _, _, st in trig]) # ---- 4) 计息(当日利息计入当日净值)+ 记录 ---- if cys: k = bisect.bisect_right(cys_d, d) - 1 cash *= (1.0 + (cys[k][1] if k >= 0 else 0.0) / 100.0 / 252.0) else: cash *= (1.0 + cash_yield_daily) # F1:闲置现金日计息 hv = units * px V = cash + hv curve.append({"date": d, "V": V, "bh_V": bh_units * px, "frac": hv / V if V else 0.0, "mode": mode, "sigma": s, "q": q, "pace": pace}) if mode == "EXITING" and (not exit_spans or exit_spans[-1][1] is not None): exit_spans.append([d, None]) elif mode != "EXITING" and exit_spans and exit_spans[-1][1] is None: exit_spans[-1][1] = d end_d = days[-1][0] for sp in exit_spans: if sp[1] is None: sp[1] = end_d final = curve[-1] state = { "as_of": end_d, "mode": mode, "V": final["V"], "cash": cash, "units": units, "px_last": days[-1][1], "cost_basis": cost_basis, "rv5_last": rv5[-1] if rv5 else None, "holdings_usd": units * days[-1][1], "frac": final["frac"], "entry_price": entry_price, "reserve_r0": r0, "stages_used": dict(used), "sigma_last": final["sigma"], "q_last": final["q"], "pace_visible": final["pace"], "fees_total": fees, "w_last": w, "s_last": s, "q_eff_last": q, "pending_routine": routine_order, "pending_add": pending_add, } summary = {"tag": tag, "start": days[0][0], "end": end_d, "target_vol_ann": target_vol_ann, "V0": V0, "V": final["V"], "fees": fees, "n_trades": sum(1 for t in trades if t["action"] != "SKIP"), "exit_spans": exit_spans} return summary, state, {"curve": curve, "trades": trades} # ---------------------------------------------------------------- 输出 def write_outputs(out_dir, curve, trades): out = Path(out_dir) out.mkdir(parents=True, exist_ok=True) with open(out / "curve.csv", "w", newline="", encoding="utf-8") as f: wtr = csv.DictWriter(f, fieldnames=list(curve[0].keys())) wtr.writeheader() wtr.writerows(curve) with open(out / "trades.csv", "w", newline="", encoding="utf-8") as f: wtr = csv.DictWriter(f, fieldnames=["date", "action", "reason", "amount_usd", "price", "V_after"]) wtr.writeheader() wtr.writerows(trades)