"""未了结仓位的浮动亏损轨迹 + "时间止损"反事实对照(只做分析,不改策略代码)。 用法: py -3.14 -B analysis/etf/drawdown.py """ import sys from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) # Windows 控制台默认 GBK,报告里用了「−」等字符,统一切到 UTF-8 输出。 try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: pass from analysis import round_trips # noqa: E402 from backtest import SYMBOLS, analyze, fetch_daily, simulate # noqa: E402 data = {code: fetch_daily(code) for code in SYMBOLS} result = simulate(data) stats = analyze(result) by_date = {code: {bar["date"]: bar for bar in data[code]} for code in data} # 逐日还原:每个标的的"本轮累计净买入成本"与"当前持仓量" cost = {code: 0.0 for code in data} volume = {code: 0 for code in data} fills_by_day = {} for fill in result["fills"]: fills_by_day.setdefault(fill.day.strftime("%Y%m%d"), []).append(fill) track = {code: [] for code in data} for day, equity, market_value, cash in result["curve"]: stamp = day.strftime("%Y%m%d") for fill in fills_by_day.get(stamp, []): amount = fill.price * fill.volume if fill.side == "BUY": cost[fill.code] += amount volume[fill.code] += fill.volume else: # 卖出按比例冲减成本基数(与均价法一致) if volume[fill.code] > 0: ratio = fill.volume / volume[fill.code] cost[fill.code] *= max(0.0, 1 - ratio) volume[fill.code] -= fill.volume for code in data: bar = by_date[code].get(stamp) if bar is None: continue track[code].append((day, cost[code], bar["close"], volume[code])) print("== 持仓期间的浮动盈亏(均价法:市值 − 本轮累计净买入成本)==") print(f"{'标的':11} {'持仓天数':>8} {'最长连续浮亏天数':>16} {'最深浮亏':>10} {'最深浮亏%':>10} {'期末浮亏':>10}") for code in data: rows = [(day, c, close, vol) for day, c, close, vol in track[code] if vol > 0 and c > 0] if not rows: print(f"{code:11} {0:8} {'-':>16} {'-':>10} {'-':>10} {'-':>10}") continue pnl = [(day, close * vol - c, (close * vol - c) / c * 100) for day, c, close, vol in rows] longest = cur = 0 for _, amount, _ in pnl: cur = cur + 1 if amount < 0 else 0 longest = max(longest, cur) worst_amt = min(p[1] for p in pnl) worst_pct = min(p[2] for p in pnl) print(f"{code:11} {len(rows):8} {longest:16} {worst_amt:10.0f} {worst_pct:9.2f}% {pnl[-1][1]:10.0f}") print() print("== 持有天数分布(完整轮次)==") trips = round_trips(result["fills"]) buckets = {"≤1天": 0, "2-3天": 0, "4-7天": 0, "8-15天": 0, ">15天": 0} for t in trips: d = t.days key = "≤1天" if d <= 1 else "2-3天" if d <= 3 else "4-7天" if d <= 7 else "8-15天" if d <= 15 else ">15天" buckets[key] += 1 print(" ", buckets) print() print("== 时间止损反事实:把浮亏且持有超过 N 天的仓位按当日收盘价平掉 ==") print(f"{'规则':22} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'占用ROI':>8} {'佣金':>8} {'强平次数':>8}") def with_time_stop(limit: int) -> tuple[float, int]: """在没有时间止损的结果上,找出"浮亏且连续持有 > limit 天"的仓位并按其后的实际 主出口价格之差估算影响(近似:直接扣掉该仓位到期末的浮亏差额)。""" stops = 0 delta = 0.0 for code in data: rows = [(day, c, close, vol) for day, c, close, vol in track[code] if vol > 0 and c > 0] if not rows: continue start = rows[0][0] for index, (day, c, close, vol) in enumerate(rows): held = (day - start).days if held > limit and close * vol - c < 0: # 反事实:在当天以收盘价平掉,之后不再持有该标的 loss = close * vol - c final_close = rows[-1][2] avoid = (final_close - close) * vol delta += loss * 0 - avoid # 平仓后避免了后续价格变动 stops += 1 break return delta, stops for limit in (0, 5, 10, 20): if limit == 0: print(f"{'不止损(现配置)':22} {stats['final_equity'] - 500000:10.2f} {stats['return_pct']:6.2f}% " f"{stats['max_dd_pct']:7.2f}% " f"{(stats['final_equity'] - 500000) / stats['avg_deployed'] * 100:7.2f}% " f"{stats['fees']:8.2f} {0:8}") continue delta, stops = with_time_stop(limit) equity = stats["final_equity"] - 500000 + delta print(f"{'持有>' + str(limit) + '天且浮亏即平':22} {equity:10.2f} {equity / 500000 * 100:6.2f}% " f"{stats['max_dd_pct']:7.2f}% {equity / stats['avg_deployed'] * 100:7.2f}% " f"{stats['fees']:8.2f} {stops:8}") print() print("注:上表是「平仓后不再持有该标的」的粗略反事实,只用于判断时间止损的方向性收益,") print(" 不是精确回测(未重算后续轮次与资金复用)。")