Repository navigation
Expand file tree
/
Copy pathbacktest_runner.py
More file actions
354 lines (309 loc) · 14.3 KB
/
Copy pathbacktest_runner.py
File metadata and controls
354 lines (309 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
"""
Backtest runner — replay the arb strategy on historical Polymarket data.
Usage examples
--------------
# Last 7 days (default)
python backtest_runner.py
# Last 30 days
python backtest_runner.py --days 30
# Specific date range
python backtest_runner.py --start 2026-05-01 --end 2026-05-31
# Tune simulation parameters
python backtest_runner.py --days 14 --slippage 0.005 --fill-prob 0.75
# Save trade-level CSV
python backtest_runner.py --days 7 --csv results.csv
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import pickle
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
import yaml
from backtest.historical_fetcher import HistoricalFetcher, HistoricalWindow
from backtest.local_fetcher import LocalFetcher
from backtest.arb_simulator import ArbSimulator
from backtest.performance import PerformanceAnalyzer
from data.data_api import DataAPI
from data.gamma_api import GammaAPI
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)-24s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
# Silence httpx request spam during bulk fetching
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
def _load_config(path: str = "config/settings.yaml") -> dict:
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"Config not found: {p.absolute()}")
with p.open(encoding="utf-8") as fh:
return yaml.safe_load(fh)
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Backtest the poly-arb strategy on historical data"
)
p.add_argument(
"--days", type=int, default=7,
help="Number of past days to backtest (default: 7; ignored if --start/--end set)",
)
p.add_argument("--start", type=str, default=None, help="Start date YYYY-MM-DD (UTC)")
p.add_argument("--end", type=str, default=None, help="End date YYYY-MM-DD (UTC)")
p.add_argument(
"--slippage", type=float, default=0.005,
help="Slippage fraction applied to entry prices (default: 0.005 = 0.5%%)",
)
p.add_argument(
"--fill-prob", type=float, default=0.80,
help="Probability that an arb opportunity fills as FOK (default: 0.80)",
)
p.add_argument(
"--entry-offset", type=float, default=60.0,
help="Seconds before window close to simulate entry (default: 60)",
)
p.add_argument(
"--max-entry-sum", type=float, default=None,
help="Override arb.max_entry_sum from settings.yaml (e.g. 0.979)",
)
p.add_argument(
"--min-profit-pct", type=float, default=None,
help="Override arb.min_profit_pct from settings.yaml (e.g. 0.001)",
)
p.add_argument(
"--leg-size", type=float, default=None,
help="Override sizing.leg_size_usdc (e.g. 10.0 for aggressive T1)",
)
p.add_argument(
"--max-asymmetry", type=float, default=None,
metavar="DELTA",
help=(
"Scarta trade dove |up_ask - down_ask| > DELTA. "
"Filtra scommesse direzionali: usa 0.10 per vera arb simmetrica "
"(es. Up≈Down≈0.45), 0.20 per soglia larga. "
"Default: nessun filtro."
),
)
p.add_argument(
"--max-windows", type=int, default=2000,
help="Max windows to fetch per run (default: 2000; 0 = no limit)",
)
p.add_argument(
"--csv", type=str, default=None,
help="Optional path to save per-trade CSV (e.g. results.csv)",
)
p.add_argument(
"--timeframes", type=str, default=None, metavar="5,15",
help="Filter windows by timeframe in minutes (e.g. '5' or '5,15')",
)
p.add_argument(
"--save-cache", type=str, default=None, metavar="FILE",
help="Save fetched windows to a cache file (e.g. cache_6m.pkl)",
)
p.add_argument(
"--load-cache", type=str, default=None, metavar="FILE",
help="Load windows from cache instead of fetching from API",
)
p.add_argument(
"--append-cache", type=str, default=None, metavar="FILE",
help="Append fetched windows to an existing cache file",
)
p.add_argument(
"--data", type=str, default=None, metavar="FILE.parquet",
help="Usa dati locali scaricati con download_historical.py "
"(es. data/historical/btc5m_20250101_20260601.parquet)",
)
p.add_argument(
"--config", type=str, default="config/settings.yaml",
help="Path to settings.yaml (default: config/settings.yaml)",
)
return p.parse_args()
async def _run(args: argparse.Namespace) -> None:
config = _load_config(args.config)
# ── Apply CLI overrides to config ─────────────────────────────────────────
arb_cfg = config.setdefault("arb", {})
sizing_cfg = config.setdefault("sizing", {})
if args.max_entry_sum is not None:
arb_cfg["max_entry_sum"] = args.max_entry_sum
if args.min_profit_pct is not None:
arb_cfg["min_profit_pct"] = args.min_profit_pct
if args.leg_size is not None:
sizing_cfg["leg_size_usdc"] = args.leg_size
# CLI --max-asymmetry sovrascrive settings.yaml; se None usa il valore
# già in arb_cfg (letto da settings.yaml → max_price_asymmetry: 0.15)
if args.max_asymmetry is not None:
arb_cfg["max_price_asymmetry"] = args.max_asymmetry
# ── Resolve date range ────────────────────────────────────────────────────
now = datetime.now(tz=timezone.utc)
explicit_range = bool(args.start and args.end)
if explicit_range:
start_ts = datetime.strptime(args.start, "%Y-%m-%d").replace(
tzinfo=timezone.utc
).timestamp()
end_ts = (
datetime.strptime(args.end, "%Y-%m-%d").replace(tzinfo=timezone.utc)
+ timedelta(days=1)
).timestamp()
elif args.load_cache:
# When loading from cache without explicit dates, use the full cache range
start_ts = 0.0
end_ts = now.timestamp()
else:
end_ts = now.timestamp()
start_ts = (now - timedelta(days=args.days)).timestamp()
start_str = datetime.fromtimestamp(start_ts, tz=timezone.utc).strftime("%Y-%m-%d") if start_ts > 0 else "cache-start"
end_str = datetime.fromtimestamp(end_ts, tz=timezone.utc).strftime("%Y-%m-%d")
print(f"\n{'='*60}")
print(f" BACKTEST {start_str} to {end_str}")
# Mostra il filtro asimmetria effettivo (CLI o settings.yaml)
effective_asym = arb_cfg.get("max_price_asymmetry")
asym_str = f" max_asymmetry={effective_asym:.2f}" if effective_asym is not None else ""
print(f" slippage={args.slippage*100:.2f}% fill_prob={args.fill_prob*100:.0f}%"
f" entry_offset={args.entry_offset:.0f}s{asym_str}")
print(f" max_entry_sum={arb_cfg['max_entry_sum']:.4f}"
f" min_profit={arb_cfg['min_profit_pct']*100:.2f}%"
f" leg_size=${sizing_cfg.get('leg_size_usdc', 5.0):.1f}")
print(f"{'='*60}\n")
# ── Load or fetch windows ─────────────────────────────────────────────────
slug_patterns: list[str] = config.get("markets", {}).get(
"slug_patterns", ["btc-updown-5m", "btc-updown-15m"]
)
limit = args.max_windows if args.max_windows > 0 else 999_999
if args.data:
# ── Dati locali da parquet (download_historical.py) ───────────────────
logger.info("Caricamento dati locali: %s", args.data)
local = LocalFetcher(args.data)
s = local.summary()
logger.info("Dataset: %d mercati, %s -> %s, %.1f MB",
s["total_markets"], s["date_from"], s["date_to"], s["file_size_mb"])
# Se non sono specificate date esplicite, usa l'intero range del file
if not explicit_range:
logger.info(
"Nessuna data specificata — uso l'intero range del file: %s -> %s",
s["date_from"], s["date_to"],
)
start_ts = 0.0
end_ts = float("inf")
windows = local.fetch_windows(start_ts=start_ts, end_ts=end_ts, limit=limit)
if windows:
actual_start = datetime.fromtimestamp(windows[0].end_ts, tz=timezone.utc).strftime("%Y-%m-%d")
actual_end = datetime.fromtimestamp(windows[-1].end_ts, tz=timezone.utc).strftime("%Y-%m-%d")
start_str, end_str = actual_start, actual_end
else:
logger.warning(
"Nessuna finestra trovata nel range selezionato. "
"Il file copre %s -> %s. "
"Usa --start %s --end %s",
s["date_from"], s["date_to"], s["date_from"], s["date_to"],
)
elif args.load_cache:
cache_path = Path(args.load_cache)
if not cache_path.exists():
print(f"Cache file not found: {cache_path}")
sys.exit(1)
logger.info("Loading windows from cache: %s", cache_path)
with cache_path.open("rb") as fh:
windows: list[HistoricalWindow] = pickle.load(fh)
# Filter to date range (if start_ts=0, no lower bound)
windows = [w for w in windows if (start_ts == 0 or w.end_ts >= start_ts) and w.end_ts <= end_ts]
if windows:
actual_start = datetime.fromtimestamp(windows[0].end_ts, tz=timezone.utc).strftime("%Y-%m-%d")
actual_end = datetime.fromtimestamp(windows[-1].end_ts, tz=timezone.utc).strftime("%Y-%m-%d")
logger.info("Loaded %d windows from cache (%s to %s)", len(windows), actual_start, actual_end)
# Update display strings to reflect actual cache range
start_str, end_str = actual_start, actual_end
else:
logger.info("Connecting to Polymarket APIs...")
async with DataAPI() as data_api, GammaAPI() as gamma_api:
fetcher = HistoricalFetcher(data_api, gamma_api, slug_patterns)
logger.info("Fetching historical windows [%s to %s]...", start_str, end_str)
windows = await fetcher.fetch_windows(
start_ts=start_ts,
end_ts=end_ts,
limit=limit,
)
if not windows:
print("No historical windows found for the selected period.")
print("Try a longer --days range or different --start/--end dates.")
return
logger.info("Enriching %d windows with resolution outcomes...", len(windows))
windows = await fetcher.enrich_with_outcomes(windows)
# Save to new cache
if args.save_cache:
_save_cache(windows, args.save_cache, append=False)
# Append to existing cache
if args.append_cache:
_save_cache(windows, args.append_cache, append=True)
# ── Timeframe filter ──────────────────────────────────────────────────────
if args.timeframes:
allowed = {int(t.strip()) for t in args.timeframes.split(",")}
before = len(windows)
windows = [w for w in windows if w.timeframe_minutes in allowed]
logger.info(
"timeframe filter %s: %d -> %d windows",
args.timeframes, before, len(windows),
)
resolved = sum(1 for w in windows if w.winning_side is not None)
print(f" Windows fetched: {len(windows)}")
print(f" Windows resolved: {resolved}\n")
if resolved == 0:
print("No resolved windows — markets may still be active or too recent.")
print("Try an earlier date range (e.g. --days 14 starting from yesterday).")
return
# ── Simulate arb entries ──────────────────────────────────────────────────
arb_cfg = config.get("arb", {})
sizing_cfg = config.get("sizing", {})
simulator = ArbSimulator(
arb_config=arb_cfg, # include max_price_asymmetry da settings.yaml o CLI
sizing_config=sizing_cfg,
slippage_pct=args.slippage,
fill_probability=args.fill_prob,
entry_offset_seconds=args.entry_offset,
)
logger.info("Simulating arb entries…")
trades = simulator.simulate_all(windows, entry_offset_seconds=args.entry_offset)
# ── Analyse performance ───────────────────────────────────────────────────
analyzer = PerformanceAnalyzer()
report = analyzer.analyze(trades)
analyzer.print_summary(report)
# ── Optional CSV export ───────────────────────────────────────────────────
if args.csv:
df = analyzer.to_dataframe(trades)
df.to_csv(args.csv, index=False)
print(f"\n Trade-level CSV saved -> {args.csv}")
def _save_cache(
windows: list[HistoricalWindow],
path: str,
append: bool = False,
) -> None:
cache_path = Path(path)
if append and cache_path.exists():
with cache_path.open("rb") as fh:
existing: list[HistoricalWindow] = pickle.load(fh)
seen = {w.condition_id for w in existing}
new_ones = [w for w in windows if w.condition_id not in seen]
combined = sorted(existing + new_ones, key=lambda w: w.end_ts)
with cache_path.open("wb") as fh:
pickle.dump(combined, fh)
logger.info(
"Cache updated: %d existing + %d new = %d total -> %s",
len(existing), len(new_ones), len(combined), cache_path,
)
else:
with cache_path.open("wb") as fh:
pickle.dump(windows, fh)
logger.info("Cache saved: %d windows -> %s", len(windows), cache_path)
print(f" Cache saved -> {cache_path} ({cache_path.stat().st_size // 1024} KB)")
def main() -> None:
args = _parse_args()
try:
asyncio.run(_run(args))
except KeyboardInterrupt:
print("\nInterrupted.")
except Exception as exc:
logger.error("Backtest failed: %s", exc, exc_info=True)
sys.exit(1)
if __name__ == "__main__":
main()