terminus-lab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
terminus/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Terminus — ruthless backtesting lab for long-only spot strategies.
2
+
3
+ Public API:
4
+ from terminus import (
5
+ ResearchStore, simulate, VRule,
6
+ build_all_configs, run_full_sweep,
7
+ walk_forward_frozen, filter_sims,
8
+ greedy_portfolio,
9
+ )
10
+
11
+ ML (requires pip install terminus-lab[ml]):
12
+ from terminus.ml import RegimeClassifier, optimize_params
13
+ """
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ from .store import ResearchStore, SimRecord, get_store, hash_config
19
+ from .simulate import simulate_fast as simulate
20
+ from .rules import VRule
21
+ from .indicators import precompute_all, precompute_v2, build_btc_regime_series
22
+ from .registry import (
23
+ build_v1_configs, build_v2_configs, build_all_configs,
24
+ build_configs_with_regime, all_exit_methods, count_configs,
25
+ )
26
+ from .walk_forward import walk_forward_frozen, walk_forward_reopt_anchored
27
+ from .filter import filter_sims, Survivor, survivor_report
28
+ from .portfolio import greedy_portfolio, reconstruct_legs, correlation
29
+ from .sweep import run_full_sweep
30
+ from . import telemetry
31
+
32
+ __all__ = [
33
+ "__version__",
34
+ "ResearchStore", "SimRecord", "get_store", "hash_config",
35
+ "simulate", "VRule",
36
+ "precompute_all", "precompute_v2", "build_btc_regime_series",
37
+ "build_v1_configs", "build_v2_configs", "build_all_configs",
38
+ "build_configs_with_regime", "all_exit_methods", "count_configs",
39
+ "walk_forward_frozen", "walk_forward_reopt_anchored",
40
+ "filter_sims", "Survivor", "survivor_report",
41
+ "greedy_portfolio", "reconstruct_legs", "correlation",
42
+ "run_full_sweep",
43
+ "telemetry",
44
+ ]
terminus/cli.py ADDED
@@ -0,0 +1,404 @@
1
+ """Terminus CLI — subcommands: fetch, sweep, walk-forward, report, portfolio.
2
+
3
+ Usage:
4
+ terminus fetch --pairs BTCUSDT,ETHUSDT --tfs 1h,4h,1d
5
+ terminus sweep --pairs BTCUSDT --exit-methods fixed_tp_stop
6
+ terminus walk-forward --top 15
7
+ terminus report
8
+ terminus portfolio
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import logging
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from .store import get_store
20
+ from .sweep import run_full_sweep, _load_and_precompute
21
+ from .walk_forward import walk_forward_frozen
22
+ from .filter import filter_sims, survivor_report
23
+ from .portfolio import greedy_portfolio
24
+ from .registry import build_all_configs, build_configs_with_regime
25
+ from .indicators import build_btc_regime_series
26
+ from .fetch import BinanceFetcher, load_or_fetch, cache_path, is_fresh
27
+ from .simulate import slip_for
28
+ from . import telemetry
29
+
30
+
31
+ DEFAULT_PAIRS = [
32
+ "BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "BNBUSDT",
33
+ "TRXUSDT", "DOGEUSDT", "ADAUSDT", "AVAXUSDT", "LINKUSDT",
34
+ "MATICUSDT", "DOTUSDT", "LTCUSDT", "ATOMUSDT", "NEARUSDT",
35
+ "SUIUSDT", "APTUSDT", "ARBUSDT", "OPUSDT", "TIAUSDT", "INJUSDT",
36
+ ]
37
+ DEFAULT_TFS = ["15m", "30m", "1h", "2h", "4h", "6h", "12h", "1d"]
38
+
39
+
40
+ def _setup_logging(verbose: bool = False):
41
+ logging.basicConfig(
42
+ level=logging.DEBUG if verbose else logging.INFO,
43
+ format="%(asctime)s [%(name)s] %(message)s",
44
+ )
45
+
46
+
47
+ # --- fetch -----------------------------------------------------------------
48
+
49
+ async def _fetch_all(pairs, tfs, days, concurrency, force):
50
+ store = get_store()
51
+ with store.manifest_scope("prefetch",
52
+ label=f"prefetch-{days}d-{len(pairs)}p-{len(tfs)}tf"):
53
+ async with BinanceFetcher() as fetcher:
54
+ sem = asyncio.Semaphore(concurrency)
55
+ results = []
56
+
57
+ async def worker(pair, tf):
58
+ async with sem:
59
+ t0 = time.time()
60
+ try:
61
+ df = await load_or_fetch(fetcher, pair, tf, days,
62
+ force=force)
63
+ except Exception as e:
64
+ logging.error(f"{pair} {tf}: {e}")
65
+ results.append((pair, tf, "ERROR", 0))
66
+ return
67
+ bars = len(df) if df is not None else 0
68
+ status = "OK" if bars >= 100 else "THIN"
69
+ logging.info(
70
+ f"{pair:<10} {tf:<4} {bars:>8} bars {time.time()-t0:.1f}s"
71
+ )
72
+ if df is not None and bars >= 100:
73
+ first_ts = df["open_time"].iloc[0]
74
+ last_ts = df["open_time"].iloc[-1]
75
+ years = (last_ts - first_ts) / 1000 / 86400 / 365.25
76
+ path = cache_path(pair, tf, days)
77
+ store.record_pair_data(
78
+ pair, tf,
79
+ str(df["ts"].iloc[0].date()),
80
+ str(df["ts"].iloc[-1].date()),
81
+ bars, round(years, 3), str(path),
82
+ )
83
+ results.append((pair, tf, status, bars))
84
+
85
+ await asyncio.gather(*[worker(p, t) for p in pairs for t in tfs])
86
+ return results
87
+
88
+
89
+ def cmd_fetch(args):
90
+ pairs = [p.strip() for p in args.pairs.split(",") if p.strip()] or DEFAULT_PAIRS
91
+ tfs = [t.strip() for t in args.tfs.split(",") if t.strip()] or DEFAULT_TFS
92
+ results = asyncio.run(_fetch_all(pairs, tfs, args.days,
93
+ args.concurrency, args.force))
94
+ ok = sum(1 for _, _, s, _ in results if s == "OK")
95
+ total_bars = sum(b for _, _, _, b in results)
96
+ print(f"\n{ok}/{len(results)} fetches OK; {total_bars:,} total bars cached")
97
+
98
+
99
+ # --- sweep -----------------------------------------------------------------
100
+
101
+ def cmd_sweep(args):
102
+ pairs = [p.strip() for p in args.pairs.split(",") if p.strip()] or DEFAULT_PAIRS
103
+ tfs = [t.strip() for t in args.tfs.split(",") if t.strip()] or DEFAULT_TFS
104
+ exit_methods = [m.strip() for m in args.exit_methods.split(",") if m.strip()]
105
+
106
+ summary = run_full_sweep(
107
+ pairs=pairs, tfs=tfs, days=args.days,
108
+ exit_methods=exit_methods,
109
+ include_regime_wrap=not args.no_regime,
110
+ label=args.label,
111
+ )
112
+ print(f"\nSweep done: {summary}")
113
+
114
+
115
+ # --- walk-forward ----------------------------------------------------------
116
+
117
+ def cmd_walk_forward(args):
118
+ store = get_store()
119
+
120
+ base_configs = build_all_configs()
121
+ btc_df = _load_and_precompute("BTCUSDT", "1d", args.days)
122
+ if btc_df is not None:
123
+ btc_regime = build_btc_regime_series(btc_df)
124
+ regime_configs = build_configs_with_regime(base_configs, btc_regime)
125
+ else:
126
+ regime_configs = []
127
+ all_configs = {c[0]: c for c in base_configs + regime_configs}
128
+
129
+ where = ["calmar >= ?", "n_trades >= ?"]
130
+ params = [args.min_calmar, args.min_trades]
131
+ if args.pairs:
132
+ pair_filter = [p.strip() for p in args.pairs.split(",") if p.strip()]
133
+ ph = ",".join("?" * len(pair_filter))
134
+ where.append(f"pair IN ({ph})")
135
+ params.extend(pair_filter)
136
+ sql = f"""
137
+ SELECT * FROM (
138
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY pair, timeframe ORDER BY calmar DESC) AS rn
139
+ FROM sims WHERE {' AND '.join(where)}
140
+ ) WHERE rn <= ?
141
+ """
142
+ params.append(args.top)
143
+ rows = store.query(sql, params)
144
+ print(f"WF candidates: {len(rows)}")
145
+
146
+ by_pair_tf = {}
147
+ for r in rows:
148
+ by_pair_tf.setdefault((r["pair"], r["timeframe"]), []).append(dict(r))
149
+
150
+ with store.manifest_scope("walk_forward", label=args.label or f"wf-top{args.top}"):
151
+ for (pair, tf), group in by_pair_tf.items():
152
+ pdf = _load_and_precompute(pair, tf, args.days)
153
+ if pdf is None:
154
+ continue
155
+ for r in group:
156
+ cfg = all_configs.get(r["config_name"])
157
+ if cfg is None:
158
+ continue
159
+ name, rule, tp, stop, hold, cd, family = cfg
160
+ if store.get_wf_for(r["hash"], mode="frozen"):
161
+ continue
162
+ try:
163
+ walk_forward_frozen(
164
+ pdf, pair=pair, timeframe=tf,
165
+ config_name=name, family=family,
166
+ rule=rule, tp=tp, stop=stop,
167
+ max_hold=hold, cooldown=cd,
168
+ exit_method=r["exit_method"],
169
+ parent_hash=r["hash"],
170
+ )
171
+ except Exception as e:
172
+ logging.error(f"WF failed {pair} {tf} {name}: {e}")
173
+ print("Walk-forward done.")
174
+
175
+
176
+ # --- report ----------------------------------------------------------------
177
+
178
+ def cmd_report(args):
179
+ store = get_store()
180
+ survivors = filter_sims(
181
+ store,
182
+ min_full_calmar=args.min_calmar,
183
+ min_trades_per_year=args.min_trades_per_year,
184
+ min_total_trades=args.min_trades_total,
185
+ min_bear_return=args.min_bear_return,
186
+ min_pairs_generalization=args.min_pairs_generalization,
187
+ include_frozen_wf=True,
188
+ )
189
+ telemetry.filter_run(store, n_candidates=store.sim_count(),
190
+ n_survivors=len(survivors), min_calmar=args.min_calmar)
191
+ print(f"\n{len(survivors)} survivors\n")
192
+ print(survivor_report(survivors, limit=args.top))
193
+
194
+
195
+ # --- portfolio -------------------------------------------------------------
196
+
197
+ def cmd_portfolio(args):
198
+ store = get_store()
199
+ survivors = filter_sims(
200
+ store,
201
+ min_full_calmar=args.min_calmar,
202
+ min_trades_per_year=4,
203
+ min_total_trades=25,
204
+ min_bear_return=args.min_bear_return,
205
+ min_pairs_generalization=3,
206
+ )
207
+ r = greedy_portfolio(survivors[:args.pool], max_legs=args.max_legs,
208
+ max_corr=args.max_corr, target_annual_pct=args.target)
209
+ m = r.get("metrics", {})
210
+ telemetry.portfolio_complete(
211
+ store,
212
+ n_legs=len(r.get("legs", [])),
213
+ annual_ret=m.get("annual_ret", 0),
214
+ max_dd=m.get("max_dd", 0),
215
+ sharpe=m.get("sharpe", 0),
216
+ legs=r.get("legs", []),
217
+ year_breakdown=r.get("year_breakdown", {}),
218
+ )
219
+ print(f"\nPortfolio ({len(r.get('legs', []))} legs):")
220
+ print(f" Annualized: {m.get('annual_ret', 0):.2f}%")
221
+ print(f" Sharpe: {m.get('sharpe', 0)}")
222
+ print(f" MaxDD: {m.get('max_dd', 0)}%")
223
+ print(f" Max leg-to-leg correlation: {r.get('max_corr_observed', 0)}")
224
+ print(f"\nLegs:")
225
+ for leg in r.get("legs", []):
226
+ print(f" {leg['pair']:<12} {leg['timeframe']:<4} "
227
+ f"{leg['family']:<26} w={leg['weight']}")
228
+ yrs = r.get("year_breakdown", {})
229
+ if yrs:
230
+ print(f"\nPer-year:")
231
+ for y, ret in sorted(yrs.items()):
232
+ print(f" {y}: {ret:+.2f}%")
233
+
234
+
235
+ # --- contribute ------------------------------------------------------------
236
+
237
+ def cmd_contribute(args):
238
+ store = get_store()
239
+ if args.enable:
240
+ telemetry.set_remote_enabled(True)
241
+ print("Remote telemetry enabled for this session (TERMINUS_TELEMETRY=1).")
242
+ print("Persist with: export TERMINUS_TELEMETRY=1 (add to shell profile)")
243
+
244
+ if args.all:
245
+ print(f"Contributing ALL sims with Calmar >= {args.min_calmar}...")
246
+ result = telemetry.contribute_all_sims(
247
+ store, min_calmar=args.min_calmar, limit=args.limit,
248
+ )
249
+ else:
250
+ survivors = filter_sims(
251
+ store,
252
+ min_full_calmar=args.min_calmar,
253
+ min_trades_per_year=4,
254
+ min_total_trades=25,
255
+ )
256
+ if not survivors:
257
+ print("No survivors to contribute. Run walk-forward first.")
258
+ return
259
+ print(f"Contributing {min(len(survivors), args.limit)} survivors...")
260
+ result = telemetry.contribute_batch(store, survivors, limit=args.limit)
261
+
262
+ print(
263
+ f"\n Submitted: {result['submitted']}\n"
264
+ f" Skipped: {result.get('skipped_already_sent', result.get('skipped', 0))}"
265
+ f" (already contributed)\n"
266
+ f" Errors: {result['errors']}\n"
267
+ f" Total: {result['total']}"
268
+ )
269
+ if not telemetry.remote_enabled():
270
+ print(
271
+ "\nResults saved locally only.\n"
272
+ "Use --enable or set TERMINUS_TELEMETRY=1 to share with the community hub."
273
+ )
274
+
275
+
276
+ # --- ml --------------------------------------------------------------------
277
+
278
+ def cmd_ml_regime(args):
279
+ print("Training regime classifier on BTC daily data...")
280
+ t0 = time.time()
281
+ try:
282
+ from .ml.regime import train_regime_classifier
283
+ except ImportError:
284
+ print("ML deps missing. Run: pip install terminus-lab[ml]")
285
+ return
286
+
287
+ df = _load_and_precompute("BTCUSDT", "1d", args.days)
288
+ if df is None:
289
+ print("No BTC daily data found. Run: terminus fetch --pairs BTCUSDT --tfs 1d")
290
+ return
291
+
292
+ clf = train_regime_classifier(df)
293
+ model_path = Path(args.output)
294
+ clf.save(model_path)
295
+ elapsed = time.time() - t0
296
+ print(
297
+ f"Regime model trained in {elapsed:.1f}s\n"
298
+ f" Bars: {clf.trained_on_bars}\n"
299
+ f" Train accuracy: {clf.train_accuracy:.1%}\n"
300
+ f" Saved to: {model_path.with_suffix('.xgb')}"
301
+ )
302
+
303
+ # Preview the current regime
304
+ regime = clf.predict(df)
305
+ last_10 = regime.iloc[-10:]
306
+ print(f"\nLast 10 bars regime:")
307
+ for i, r in zip(df["ts"].iloc[-10:], last_10):
308
+ print(f" {str(i)[:10]} {r}")
309
+
310
+
311
+ # --- main ------------------------------------------------------------------
312
+
313
+ def main():
314
+ p = argparse.ArgumentParser(
315
+ prog="terminus",
316
+ description="Terminus — ruthless backtesting lab for long-only spot strategies.",
317
+ )
318
+ p.add_argument("-v", "--verbose", action="store_true")
319
+ subp = p.add_subparsers(dest="cmd", required=True)
320
+
321
+ # fetch
322
+ f = subp.add_parser("fetch", help="Fetch & cache kline data.")
323
+ f.add_argument("--pairs", default=",".join(DEFAULT_PAIRS))
324
+ f.add_argument("--tfs", default=",".join(DEFAULT_TFS))
325
+ f.add_argument("--days", type=int, default=2920)
326
+ f.add_argument("--concurrency", type=int, default=3)
327
+ f.add_argument("--force", action="store_true")
328
+ f.set_defaults(func=cmd_fetch)
329
+
330
+ # sweep
331
+ s = subp.add_parser("sweep", help="Run the full strategy sweep.")
332
+ s.add_argument("--pairs", default=",".join(DEFAULT_PAIRS))
333
+ s.add_argument("--tfs", default=",".join(DEFAULT_TFS))
334
+ s.add_argument("--days", type=int, default=2920)
335
+ s.add_argument("--exit-methods", default="fixed_tp_stop")
336
+ s.add_argument("--no-regime", action="store_true")
337
+ s.add_argument("--label", default=None)
338
+ s.set_defaults(func=cmd_sweep)
339
+
340
+ # walk-forward
341
+ w = subp.add_parser("walk-forward", help="Walk-forward top candidates.")
342
+ w.add_argument("--top", type=int, default=15)
343
+ w.add_argument("--min-calmar", type=float, default=1.5)
344
+ w.add_argument("--min-trades", type=int, default=25)
345
+ w.add_argument("--days", type=int, default=2920)
346
+ w.add_argument("--pairs", default="")
347
+ w.add_argument("--label", default=None)
348
+ w.set_defaults(func=cmd_walk_forward)
349
+
350
+ # report
351
+ r = subp.add_parser("report", help="Filter survivors and print ranked report.")
352
+ r.add_argument("--min-calmar", type=float, default=1.5)
353
+ r.add_argument("--min-trades-total", type=int, default=25)
354
+ r.add_argument("--min-trades-per-year", type=int, default=4)
355
+ r.add_argument("--min-bear-return", type=float, default=-5.0)
356
+ r.add_argument("--min-pairs-generalization", type=int, default=3)
357
+ r.add_argument("--top", type=int, default=100)
358
+ r.set_defaults(func=cmd_report)
359
+
360
+ # portfolio
361
+ po = subp.add_parser("portfolio", help="Construct a portfolio from survivors.")
362
+ po.add_argument("--min-calmar", type=float, default=1.5)
363
+ po.add_argument("--min-bear-return", type=float, default=-5.0)
364
+ po.add_argument("--max-legs", type=int, default=6)
365
+ po.add_argument("--max-corr", type=float, default=0.65)
366
+ po.add_argument("--pool", type=int, default=60, help="Top-N pool before greedy select")
367
+ po.add_argument("--target", type=float, default=25.0)
368
+ po.set_defaults(func=cmd_portfolio)
369
+
370
+ # contribute
371
+ co = subp.add_parser(
372
+ "contribute",
373
+ help="Submit full sim results and walk-forward data to the community hub.",
374
+ )
375
+ co.add_argument("--min-calmar", type=float, default=0.0,
376
+ help="Include all sims at or above this Calmar (default 0 = all).")
377
+ co.add_argument("--limit", type=int, default=10_000,
378
+ help="Max sims to submit per run.")
379
+ co.add_argument("--all", action="store_true",
380
+ help="Submit every sim in the store (not just walk-forward survivors).")
381
+ co.add_argument("--enable", action="store_true",
382
+ help="Enable remote sharing for this session (TERMINUS_TELEMETRY=1).")
383
+ co.set_defaults(func=cmd_contribute)
384
+
385
+ # ml
386
+ ml_p = subp.add_parser("ml", help="Machine learning utilities.")
387
+ ml_sub = ml_p.add_subparsers(dest="ml_cmd", required=True)
388
+
389
+ ml_regime = ml_sub.add_parser("regime", help="Train regime classifier on BTC daily data.")
390
+ ml_regime.add_argument("--days", type=int, default=2920)
391
+ ml_regime.add_argument(
392
+ "--output",
393
+ default=str(Path.home() / ".terminus" / "regime_model"),
394
+ help="Output path (without extension; .xgb and .json written).",
395
+ )
396
+ ml_regime.set_defaults(func=cmd_ml_regime)
397
+
398
+ args = p.parse_args()
399
+ _setup_logging(args.verbose)
400
+ return args.func(args)
401
+
402
+
403
+ if __name__ == "__main__":
404
+ sys.exit(main() or 0)
terminus/fetch.py ADDED
@@ -0,0 +1,169 @@
1
+ """Kline fetcher — Binance-first, pluggable.
2
+
3
+ Each fetcher returns a DataFrame with columns: open_time (int ms), open,
4
+ high, low, close, volume. All prices float. Open time is the candle open
5
+ in UTC milliseconds.
6
+
7
+ Built-in:
8
+ BinanceFetcher — spot klines via public API, no auth required for
9
+ historical data
10
+
11
+ Pluggable: instantiate any object with the method signature
12
+ async def fetch(pair, interval, days) -> DataFrame | None
13
+ and pass it to the cache layer.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Protocol
22
+
23
+ import httpx
24
+ import pandas as pd
25
+
26
+
27
+ logger = logging.getLogger("terminus.fetch")
28
+
29
+
30
+ TF_MS = {
31
+ "1m": 60_000, "3m": 180_000, "5m": 300_000,
32
+ "15m": 900_000, "30m": 1_800_000,
33
+ "1h": 3_600_000, "2h": 7_200_000, "4h": 14_400_000,
34
+ "6h": 21_600_000, "8h": 28_800_000, "12h": 43_200_000,
35
+ "1d": 86_400_000, "3d": 259_200_000, "1w": 604_800_000,
36
+ }
37
+
38
+
39
+ class Fetcher(Protocol):
40
+ async def fetch(self, pair: str, interval: str, days: int
41
+ ) -> pd.DataFrame | None: ...
42
+
43
+
44
+ class BinanceFetcher:
45
+ """Spot klines via https://api.binance.com/api/v3/klines (no auth)."""
46
+
47
+ def __init__(self, *, timeout: float = 30.0,
48
+ retries: int = 4, initial_backoff: float = 5.0):
49
+ self._timeout = timeout
50
+ self._retries = retries
51
+ self._initial_backoff = initial_backoff
52
+ self._client = httpx.AsyncClient(
53
+ base_url="https://api.binance.com",
54
+ timeout=timeout,
55
+ )
56
+
57
+ async def close(self) -> None:
58
+ await self._client.aclose()
59
+
60
+ async def __aenter__(self):
61
+ return self
62
+
63
+ async def __aexit__(self, *_):
64
+ await self.close()
65
+
66
+ async def _get_chunk(self, pair: str, interval: str,
67
+ start_ms: int, end_ms: int) -> list:
68
+ params = {
69
+ "symbol": pair.upper(),
70
+ "interval": interval,
71
+ "startTime": start_ms,
72
+ "endTime": end_ms,
73
+ "limit": 1000,
74
+ }
75
+ delay = self._initial_backoff
76
+ for attempt in range(self._retries):
77
+ try:
78
+ r = await self._client.get("/api/v3/klines", params=params)
79
+ r.raise_for_status()
80
+ return r.json()
81
+ except Exception as e:
82
+ logger.warning("fetch attempt %d failed: %s", attempt + 1, e)
83
+ await asyncio.sleep(delay)
84
+ delay = min(delay * 2, 60)
85
+ return []
86
+
87
+ async def fetch(self, pair: str, interval: str, days: int
88
+ ) -> pd.DataFrame | None:
89
+ if interval not in TF_MS:
90
+ raise ValueError(f"Unknown interval: {interval}")
91
+ end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
92
+ start_ms = end_ms - days * 86_400_000
93
+ step_ms = TF_MS[interval] * 1000 # 1000 bars per request
94
+
95
+ all_rows = []
96
+ cursor = start_ms
97
+ while cursor < end_ms:
98
+ chunk = await self._get_chunk(pair, interval, cursor,
99
+ min(cursor + step_ms, end_ms))
100
+ if not chunk:
101
+ break
102
+ all_rows.extend(chunk)
103
+ last_open = int(chunk[-1][0])
104
+ # Advance to 1ms past last bar so we don't re-fetch it
105
+ cursor = last_open + TF_MS[interval]
106
+ if len(chunk) < 1000:
107
+ # Done — exchange returned partial batch
108
+ break
109
+
110
+ if not all_rows:
111
+ return None
112
+
113
+ df = pd.DataFrame({
114
+ "open_time": [int(k[0]) for k in all_rows],
115
+ "open": [float(k[1]) for k in all_rows],
116
+ "high": [float(k[2]) for k in all_rows],
117
+ "low": [float(k[3]) for k in all_rows],
118
+ "close": [float(k[4]) for k in all_rows],
119
+ "volume": [float(k[5]) for k in all_rows],
120
+ }).drop_duplicates(subset="open_time").reset_index(drop=True)
121
+ df["ts"] = pd.to_datetime(df["open_time"], unit="ms", utc=True)
122
+ return df
123
+
124
+
125
+ # --- Parquet cache -------------------------------------------------------
126
+
127
+ def cache_path(pair: str, tf: str, days: int,
128
+ cache_dir: Path | None = None) -> Path:
129
+ if cache_dir is None:
130
+ cache_dir = Path.home() / ".terminus" / "cache"
131
+ cache_dir.mkdir(parents=True, exist_ok=True)
132
+ return cache_dir / f"{pair}_{tf}_{days}d.parquet"
133
+
134
+
135
+ def is_fresh(path: Path, tf: str) -> bool:
136
+ """Fresh if last-row timestamp is within 2 bar intervals of now."""
137
+ if not path.exists():
138
+ return False
139
+ try:
140
+ df = pd.read_parquet(path, columns=["open_time"])
141
+ except Exception:
142
+ return False
143
+ if df.empty:
144
+ return False
145
+ last_ms = int(df["open_time"].iloc[-1])
146
+ now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
147
+ age_ms = now_ms - last_ms
148
+ return age_ms < 2 * TF_MS.get(tf, TF_MS["1h"])
149
+
150
+
151
+ async def load_or_fetch(
152
+ fetcher: Fetcher, pair: str, tf: str, days: int,
153
+ *, cache_dir: Path | None = None, force: bool = False,
154
+ ) -> pd.DataFrame | None:
155
+ path = cache_path(pair, tf, days, cache_dir)
156
+ if not force and is_fresh(path, tf):
157
+ try:
158
+ return pd.read_parquet(path)
159
+ except Exception as e:
160
+ logger.warning("cache read failed for %s, refetching: %s", path, e)
161
+
162
+ df = await fetcher.fetch(pair, tf, days)
163
+ if df is None or len(df) < 100:
164
+ return df
165
+ try:
166
+ df.to_parquet(path, compression="snappy")
167
+ except Exception as e:
168
+ logger.warning("cache write failed for %s: %s", path, e)
169
+ return df