Files
my_polymarket_m5_dasboad/backtest.py
T

587 lines
24 KiB
Python
Raw Normal View History

2026-07-26 01:51:18 +08:00
#!/usr/bin/env python3
"""
backtest.py — 历史回测框架
从 持仓/<round>-<winner>-blocks.csv 读取每轮赢家 + 持仓详情,
重建每轮的"信号快照"(streak whales 集中度 / winners 方向 / 鲸鱼分布),
对策略参数组合 grid search, 评估 PnL 与胜率.
数据限制 (重要!):
- CSV 是每轮 END 快照, 不是 mid-round. 所以"信号"是事后信号
- 没有 BTC 价格历史, 也没有 order book 历史
- 因此策略里的 BTC 动量 / book imbalance 无法回测
- 但 streak whales 集中度 / winners 方向 是可重建的核心信号
用法:
python backtest.py # 默认 grid search
python backtest.py --top 5 # 显示 top 5 最佳参数组合
python backtest.py --threshold 0.45 # 单参数扫描
"""
import argparse
import csv
import glob
import os
import re
import sys
from collections import defaultdict
from itertools import product
HOLDINGS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "持仓")
def load_rounds(holdings_dir: str) -> list:
"""加载所有 CSV, 返回 round 列表 (按 round_key 排序)"""
rounds = []
files = sorted(glob.glob(os.path.join(holdings_dir, "*-blocks.csv")))
for fp in files:
fname = os.path.basename(fp)
m = re.match(r"(\d{4}-\d{4})-(up|down)-blocks\.csv", fname)
if not m:
continue
round_key, winner = m.group(1), m.group(2)
entries = []
try:
with open(fp, "r", encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
addr = (row.get("地址") or "").strip().lower()
if not addr:
continue
side = (row.get("方向") or "").strip().lower()
hedged = "⚖" in (row.get("对冲") or "")
try:
shares = float(row.get("持仓") or 0)
except ValueError:
shares = 0
entries.append({
"addr": addr,
"side": side,
"hedged": hedged,
"shares": shares,
})
except Exception as e:
print(f"⚠️ {fname}: {e}", file=sys.stderr)
continue
rounds.append({
"round_key": round_key,
"winner": winner,
"entries": entries,
"winner_addrs": {e["addr"] for e in entries if e["side"] == winner},
})
return rounds
def compute_features(rounds: list) -> list:
"""为每轮计算信号特征:
- streak_up_n / streak_dn_n: streak whales (赢了过去 3 轮的非 hedged) 在本轮的分布
- winners_up_n / winners_dn_n: 上轮赢家的本轮分布
- hedged_count: 本轮 hedged 地址数
"""
n = len(rounds)
for i in range(n):
# Streak set = 过去 3 轮赢家的交集
if i >= 3:
s = (rounds[i-3]["winner_addrs"]
& rounds[i-2]["winner_addrs"]
& rounds[i-1]["winner_addrs"])
else:
s = set()
rounds[i]["streak_set"] = s
# 当前轮中 streak whales 的方向分布
streak_up_n = streak_dn_n = 0
streak_up_shares = streak_dn_shares = 0
for e in rounds[i]["entries"]:
if e["addr"] in s and not e["hedged"]:
if e["side"] == "up":
streak_up_n += 1
streak_up_shares += e["shares"]
elif e["side"] == "down":
streak_dn_n += 1
streak_dn_shares += e["shares"]
rounds[i]["streak_up_n"] = streak_up_n
rounds[i]["streak_dn_n"] = streak_dn_n
rounds[i]["streak_up_shares"] = streak_up_shares
rounds[i]["streak_dn_shares"] = streak_dn_shares
total_streak = streak_up_n + streak_dn_n
rounds[i]["streak_concentration"] = (
(streak_up_n - streak_dn_n) / total_streak if total_streak else 0.0
)
# 上轮赢家在本轮的方向分布 (前一轮赢家的"延续信号")
if i >= 1:
last_winners = rounds[i-1]["winner_addrs"]
win_up_n = win_dn_n = 0
for e in rounds[i]["entries"]:
if e["addr"] in last_winners and not e["hedged"]:
if e["side"] == "up":
win_up_n += 1
elif e["side"] == "down":
win_dn_n += 1
rounds[i]["winners_up_n"] = win_up_n
rounds[i]["winners_dn_n"] = win_dn_n
win_total = win_up_n + win_dn_n
rounds[i]["winners_concentration"] = (
(win_up_n - win_dn_n) / win_total if win_total else 0.0
)
else:
rounds[i]["winners_up_n"] = 0
rounds[i]["winners_dn_n"] = 0
rounds[i]["winners_concentration"] = 0.0
rounds[i]["hedged_count"] = sum(1 for e in rounds[i]["entries"] if e["hedged"])
return rounds
def predict_round(r: dict, threshold: float, min_streak: int = 2,
mode: str = "streak") -> str:
"""基于信号预测本轮赢家 ('up' / 'down' / None)
mode: 'streak' (只 streak), 'winners' (只 winners), 'combined' (加权平均)
"""
s_total = r["streak_up_n"] + r["streak_dn_n"]
w_total = r["winners_up_n"] + r["winners_dn_n"]
if mode == "streak":
if s_total < min_streak:
return None
score = r["streak_concentration"]
elif mode == "winners":
if w_total < 2:
return None
score = r["winners_concentration"]
elif mode == "combined":
# 加权投票: streak 鲸鱼和 winners 都数, 总投票的不平衡
# 排除 winners 重叠 (同一地址既是 streak 也是 winner)
if s_total + w_total < min_streak:
return None
# streak whales 权重 2, winners 权重 1
score = (2 * r["streak_concentration"] * (s_total / max(s_total + w_total, 1))
+ 1 * r["winners_concentration"] * (w_total / max(s_total + w_total, 1)))
else:
return None
if score >= threshold:
return "up"
if score <= -threshold:
return "down"
return None
def estimate_entry_prob(r: dict, side: str) -> float:
"""估算入场价 — 用 combined 信号强度作为 proxy (持仓在 END 总是 50/50)
信号越强 → 其他鲸鱼已建仓 → token 价格已偏 (我们入场价不便宜)
- combined = 0.8 (强 UP) → entry_price ≈ 0.65 (token 已被推高)
- combined = -0.8 (强 DN) → entry_price ≈ 0.35 (我们买 DN, 价格便宜)
- combined = 0 (中性) → entry_price ≈ 0.50
公式: entry_price = 0.5 + combined * 0.20
"""
s_total = r["streak_up_n"] + r["streak_dn_n"]
w_total = r["winners_up_n"] + r["winners_dn_n"]
total_sample = s_total + w_total
if total_sample == 0:
return 0.50
if side == "up":
s_conc = (r["streak_up_n"] - r["streak_dn_n"]) / max(s_total, 1)
w_conc = (r["winners_up_n"] - r["winners_dn_n"]) / max(w_total, 1)
else: # side == "down"
s_conc = -(r["streak_up_n"] - r["streak_dn_n"]) / max(s_total, 1)
w_conc = -(r["winners_up_n"] - r["winners_dn_n"]) / max(w_total, 1)
# Combined (与 predict_round 一致: streak × 2 + winners × 1, 按样本量加权)
total_weight = 2.0 * s_total + 1.0 * w_total
combined = (2.0 * s_conc * s_total + 1.0 * w_conc * w_total) / max(total_weight, 1)
# 映射: combined ∈ [-1, 1] → entry ∈ [0.20, 0.80] (0.30 系数)
# 实测 live 入场价 0.37-0.75, 0.30 系数最接近
entry = 0.5 + combined * 0.30
return max(0.10, min(0.90, entry))
def simulate_trade_pnl(pred: str, actual_winner: str, entry_prob: float,
tp_pct: float, sl_pct: float, slippage: float) -> tuple:
"""模拟一笔交易的 PnL 和持仓时长
模型:
- 入场价 = entry_prob (1 USD 买入 = 1/entry_prob 股)
- 赢: TP 触发, exit = entry * (1 + tp_pct) - slippage
- 输: SL 触发, exit = entry * (1 - sl_pct) - slippage
- 共享: shares = 1 / entry_prob
- pnl = shares * (exit - entry)
返回 (pnl, hold_estimate, exit_price)
"""
if entry_prob <= 0.01:
return 0.0, 0.0, 0.0
shares = 1.0 / entry_prob
if pred == actual_winner:
# 赢: TP 触发
exit_price = min(entry_prob * (1 + tp_pct), 1.0) - slippage
hold = 30 # TP 一般快 (估计 30s)
else:
# 输: SL 触发
exit_price = max(entry_prob * (1 - sl_pct), 0.01) - slippage
hold = 60 # SL 一般慢些 (估计 60s)
exit_price = max(0.01, exit_price)
pnl = shares * (exit_price - entry_prob)
return pnl, hold, exit_price
def run_strategy(rounds: list, threshold: float, mode: str = "streak",
min_streak: int = 2,
tp_pct: float = 0.15, sl_pct: float = 0.25,
slippage: float = 0.02,
min_price: float = 0.0, max_price: float = 1.0) -> dict:
"""跑一遍策略, 返回统计
min_price / max_price: 入场价过滤 (backtest 中 entry_prob 即 token 价格)
"""
hits = misses = no_trade = no_price_filter = 0
pnl = 0.0
pnls = []
win_pnls = []
loss_pnls = []
holds = []
entry_prices = []
for r in rounds:
pred = predict_round(r, threshold, min_streak, mode)
if pred is None:
no_trade += 1
continue
entry_prob = estimate_entry_prob(r, pred)
# 价格过滤
if entry_prob < min_price or entry_prob > max_price:
no_price_filter += 1
continue
trade_pnl, hold, _ = simulate_trade_pnl(
pred, r["winner"], entry_prob, tp_pct, sl_pct, slippage)
pnl += trade_pnl
pnls.append(trade_pnl)
holds.append(hold)
entry_prices.append(entry_prob)
if pred == r["winner"]:
hits += 1
win_pnls.append(trade_pnl)
else:
misses += 1
loss_pnls.append(trade_pnl)
total_trades = hits + misses
win_rate = hits / total_trades if total_trades else 0
return {
"threshold": threshold,
"mode": mode,
"min_streak": min_streak,
"tp_pct": tp_pct,
"sl_pct": sl_pct,
"min_price": min_price,
"max_price": max_price,
"trades": total_trades,
"wins": hits,
"losses": misses,
"no_trade": no_trade,
"no_price_filter": no_price_filter,
"win_rate": win_rate,
"pnl": pnl,
"avg_pnl_per_trade": pnl / total_trades if total_trades else 0,
"avg_win": sum(win_pnls) / len(win_pnls) if win_pnls else 0,
"avg_loss": sum(loss_pnls) / len(loss_pnls) if loss_pnls else 0,
"avg_hold": sum(holds) / len(holds) if holds else 0,
"avg_entry_price": sum(entry_prices) / len(entry_prices) if entry_prices else 0,
}
def grid_search_tp_sl(rounds: list, thresholds: list, mode: str = "combined",
tp_list: list = None, sl_list: list = None,
slippage: float = 0.02,
min_price: float = 0.0, max_price: float = 1.0) -> list:
"""网格搜索 TP × SL × threshold"""
if tp_list is None:
tp_list = [0.10, 0.15, 0.20, 0.25]
if sl_list is None:
sl_list = [0.20, 0.25, 0.30]
results = []
for tp in tp_list:
for sl in sl_list:
if tp >= sl:
continue # 无效: TP 必须 < SL
for t in thresholds:
r = run_strategy(rounds, t, mode, 2, tp, sl, slippage,
min_price, max_price)
results.append(r)
return sorted(results, key=lambda x: x["pnl"], reverse=True)
def grid_search(rounds: list, thresholds: list, mode: str = "streak",
min_streak: int = 2,
tp_pct: float = 0.15, sl_pct: float = 0.25,
slippage: float = 0.02,
min_price: float = 0.0, max_price: float = 1.0) -> list:
"""扫描不同 threshold, 返回所有结果"""
results = []
for t in thresholds:
r = run_strategy(rounds, t, mode, min_streak, tp_pct, sl_pct, slippage,
min_price, max_price)
results.append(r)
return sorted(results, key=lambda x: x["pnl"], reverse=True)
def grid_search_price(rounds: list, threshold: float = 0.30,
mode: str = "combined",
min_price_list: list = None,
max_price_list: list = None,
tp_pct: float = 0.25, sl_pct: float = 0.30,
slippage: float = 0.02) -> list:
"""网格搜索入场价区间 (min/max)"""
if min_price_list is None:
min_price_list = [0.0, 0.30, 0.35, 0.40, 0.45]
if max_price_list is None:
max_price_list = [1.0, 0.85, 0.80, 0.75, 0.70, 0.65]
results = []
for mn in min_price_list:
for mx in max_price_list:
if mn >= mx:
continue
r = run_strategy(rounds, threshold, mode, 2,
tp_pct, sl_pct, slippage, mn, mx)
results.append(r)
return sorted(results, key=lambda x: x["pnl"], reverse=True)
def baseline_stats(rounds: list) -> dict:
"""基线: 全部跟 UP (50/50 期望)"""
up_wins = sum(1 for r in rounds if r["winner"] == "up")
dn_wins = sum(1 for r in rounds if r["winner"] == "down")
return {
"total_rounds": len(rounds),
"up_wins": up_wins,
"dn_wins": dn_wins,
"up_rate": up_wins / len(rounds) if rounds else 0,
}
def feature_quality(rounds: list):
"""打印关键特征的质量 (与 winner 的相关性)"""
print("\n--- FEATURE QUALITY ---")
# Streak concentration vs winner
print("\n[streak_concentration] vs winner:")
print(f" {'bin':>12} {'n':>5} {'UP win%':>8} {'DN win%':>8}")
bins = [(-1.0, -0.7), (-0.7, -0.4), (-0.4, -0.1), (-0.1, 0.1), (0.1, 0.4), (0.4, 0.7), (0.7, 1.01)]
for lo, hi in bins:
seg = [r for r in rounds if lo <= r["streak_concentration"] < hi
and r["streak_up_n"] + r["streak_dn_n"] >= 2]
if not seg:
continue
up_count = sum(1 for r in seg if r["winner"] == "up")
dn_count = sum(1 for r in seg if r["winner"] == "down")
up_pct = up_count / len(seg) * 100
dn_pct = dn_count / len(seg) * 100
print(f" [{lo:+.1f},{hi:+.1f}) {len(seg):>5} {up_pct:>7.1f}% {dn_pct:>7.1f}%")
# Winners concentration vs winner
print("\n[winners_concentration] vs winner:")
print(f" {'bin':>12} {'n':>5} {'UP win%':>8} {'DN win%':>8}")
for lo, hi in bins:
seg = [r for r in rounds if lo <= r["winners_concentration"] < hi
and r["winners_up_n"] + r["winners_dn_n"] >= 2]
if not seg:
continue
up_count = sum(1 for r in seg if r["winner"] == "up")
dn_count = sum(1 for r in seg if r["winner"] == "down")
up_pct = up_count / len(seg) * 100
dn_pct = dn_count / len(seg) * 100
print(f" [{lo:+.1f},{hi:+.1f}) {len(seg):>5} {up_pct:>7.1f}% {dn_pct:>7.1f}%")
# Pure streak whales' positions: how often does their majority side match winner?
print("\n[streak majority matches winner] (any threshold, >=2 whales):")
matches = mismatches = 0
for r in rounds:
s_total = r["streak_up_n"] + r["streak_dn_n"]
if s_total < 2:
continue
majority = "up" if r["streak_up_n"] > r["streak_dn_n"] else "down"
if majority == r["winner"]:
matches += 1
else:
mismatches += 1
total = matches + mismatches
print(f" matches: {matches}/{total} = {matches/total*100:.1f}%" if total else " no data")
# Same for winners majority
print("\n[winners majority matches winner] (>=2 winners in current round):")
matches = mismatches = 0
for r in rounds:
w_total = r["winners_up_n"] + r["winners_dn_n"]
if w_total < 2:
continue
majority = "up" if r["winners_up_n"] > r["winners_dn_n"] else "down"
if majority == r["winner"]:
matches += 1
else:
mismatches += 1
total = matches + mismatches
print(f" matches: {matches}/{total} = {matches/total*100:.1f}%" if total else " no data")
def main():
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(description="历史回测框架 (TP/SL 价格模拟)")
parser.add_argument("--threshold", type=float, default=None,
help="单参数扫描: 只显示该 threshold 的结果")
parser.add_argument("--mode", type=str, default=None,
choices=["streak", "winners", "combined"],
help="信号模式 (默认 grid search 三种)")
parser.add_argument("--top", type=int, default=10, help="显示 top N 参数")
parser.add_argument("--tp", type=float, default=0.15, help="TP 阈值 (token 涨幅, 0.10-0.30)")
parser.add_argument("--sl", type=float, default=0.25, help="SL 阈值 (token 跌幅, 0.20-0.40)")
parser.add_argument("--slippage", type=float, default=0.02, help="单边滑点 (0.02=2¢)")
parser.add_argument("--feature-quality", action="store_true",
help="只显示特征质量分析, 不跑策略")
parser.add_argument("--grid-tp-sl", action="store_true",
help="TP × SL × threshold 网格搜索 (慢, 组合多)")
parser.add_argument("--min-price", type=float, default=None,
help="入场价下限 (0.40-0.50 推荐, 拒绝太便宜 token)")
parser.add_argument("--max-price", type=float, default=None,
help="入场价上限 (0.75-0.85 推荐, 拒绝太贵 token)")
parser.add_argument("--grid-price", action="store_true",
help="min_price × max_price × threshold 网格搜索")
args = parser.parse_args()
print(f"📂 加载 {HOLDINGS_DIR}/")
rounds = load_rounds(HOLDINGS_DIR)
print(f" 发现 {len(rounds)} 个回合")
rounds = compute_features(rounds)
base = baseline_stats(rounds)
print(f"\n基线: {base['up_wins']} UP / {base['dn_wins']} DN "
f"({base['up_rate']*100:.1f}% UP)")
print(f"⚠️ 注意: CSV 是回合 END 快照, 信号是事后信号 (高估实盘胜率)")
feature_quality(rounds)
if args.feature_quality:
return
# 单参数扫描
if args.threshold is not None:
mode = args.mode or "streak"
print(f"\n--- SINGLE SCAN: mode={mode}, threshold={args.threshold}, "
f"TP={args.tp}, SL={args.sl}, "
f"min_price={args.min_price or 0}, max_price={args.max_price or 1} ---")
r = run_strategy(rounds, args.threshold, mode, 2,
args.tp, args.sl, args.slippage,
args.min_price or 0.0, args.max_price or 1.0)
for k, v in r.items():
print(f" {k}: {v}")
return
# TP × SL × threshold 网格
if args.grid_tp_sl:
print(f"\n--- TP × SL × THRESHOLD GRID ---")
thresholds = [0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45]
results = grid_search_tp_sl(rounds, thresholds, "combined",
slippage=args.slippage,
min_price=args.min_price or 0.0,
max_price=args.max_price or 1.0)
print(f"\n{'TP':>5} {'SL':>5} {'thresh':>6} {'trades':>6} {'win%':>6} "
f"{'PnL':>8} {'avg_win':>8} {'avg_loss':>9}")
for r in results[:args.top]:
print(f" {r['tp_pct']:>4.0%} {r['sl_pct']:>4.0%} {r['threshold']:>5.2f} "
f"{r['trades']:>6} {r['win_rate']*100:>5.1f}% "
f"${r['pnl']:>+7.2f} ${r['avg_win']:>+7.4f} ${r['avg_loss']:>+8.4f}")
return
# min_price × max_price 网格
if args.grid_price:
print(f"\n--- MIN/MAX ENTRY PRICE GRID (TP={args.tp}, SL={args.sl}) ---")
results = grid_search_price(rounds, threshold=0.30, mode="combined",
tp_pct=args.tp, sl_pct=args.sl,
slippage=args.slippage)
print(f"\n{'min':>6} {'max':>6} {'trades':>6} {'win%':>6} "
f"{'PnL':>8} {'avg/trade':>10} {'avg_entry':>9}")
for r in results[:args.top]:
print(f" {r['min_price']:>5.2f} {r['max_price']:>5.2f} "
f"{r['trades']:>6} {r['win_rate']*100:>5.1f}% "
f"${r['pnl']:>+7.2f} ${r['avg_pnl_per_trade']:>+9.4f} "
f"{r.get('avg_entry_price', 0):>8.3f}")
# Entry price 分布分析
print(f"\n--- ENTRY PRICE 分布 ---")
all_entry = []
for r in rounds:
pred = predict_round(r, 0.30, 2, "combined")
if pred is None:
continue
ep = estimate_entry_prob(r, pred)
all_entry.append(ep)
if all_entry:
all_entry.sort()
print(f" total signals: {len(all_entry)}")
print(f" min/median/max: {min(all_entry):.3f} / "
f"{all_entry[len(all_entry)//2]:.3f} / {max(all_entry):.3f}")
bins = [(0, 0.30), (0.30, 0.40), (0.40, 0.50), (0.50, 0.60),
(0.60, 0.70), (0.70, 0.80), (0.80, 1.01)]
for lo, hi in bins:
seg = [e for e in all_entry if lo <= e < hi]
if seg:
pct = len(seg) / len(all_entry) * 100
print(f" [{lo:.2f}, {hi:.2f}): n={len(seg):>3} ({pct:.1f}%)")
return
# Grid search (默认单 TP/SL, 扫 threshold × mode)
thresholds = [0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40,
0.45, 0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.90, 1.00]
modes = [args.mode] if args.mode else ["streak", "winners", "combined"]
print(f"\n--- GRID SEARCH ({len(thresholds)} thresholds × {len(modes)} modes, "
f"TP={args.tp}, SL={args.sl}, slippage={args.slippage}, "
f"min_price={args.min_price or 0}, max_price={args.max_price or 1}) ---")
all_results = []
for mode in modes:
rs = grid_search(rounds, thresholds, mode, 2,
args.tp, args.sl, args.slippage,
args.min_price or 0.0, args.max_price or 1.0)
all_results.extend(rs)
all_results.sort(key=lambda x: x["pnl"], reverse=True)
print(f"\n{'mode':>9} {'thresh':>6} {'trades':>7} {'wins':>5} {'win%':>6} "
f"{'PnL':>8} {'avg/trade':>10}")
for r in all_results[:args.top]:
print(f" {r['mode']:>9} {r['threshold']:>5.2f} {r['trades']:>7} "
f"{r['wins']:>5} {r['win_rate']*100:>5.1f}% "
f"${r['pnl']:>+7.2f} ${r['avg_pnl_per_trade']:>+9.4f}")
# 每种 mode 的最优
print(f"\n--- BEST PER MODE ---")
for mode in modes:
rs = grid_search(rounds, thresholds, mode, 2,
args.tp, args.sl, args.slippage,
args.min_price or 0.0, args.max_price or 1.0)
b = rs[0]
print(f" {mode:>9}: threshold={b['threshold']:.2f} "
f"trades={b['trades']} win%={b['win_rate']*100:.1f}% "
f"PnL=${b['pnl']:+.2f} avg=${b['avg_pnl_per_trade']:+.4f}")
# 最优组合详细
best = all_results[0]
print(f"\n--- OVERALL BEST ---")
print(f" Mode: {best['mode']}, threshold: {best['threshold']}, "
f"TP={best['tp_pct']}, SL={best['sl_pct']}")
print(f" Trades: {best['trades']} Wins: {best['wins']} Losses: {best['losses']}")
print(f" No-trade rounds: {best['no_trade']}")
print(f" Win rate: {best['win_rate']*100:.1f}%")
print(f" Total PnL: ${best['pnl']:+.2f}")
print(f" Avg per trade: ${best['avg_pnl_per_trade']:+.4f}")
print(f" Avg win / loss: ${best['avg_win']:+.3f} / ${best['avg_loss']:+.3f}")
if __name__ == "__main__":
main()