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 +44 -0
- terminus/cli.py +404 -0
- terminus/fetch.py +169 -0
- terminus/filter.py +211 -0
- terminus/indicators.py +162 -0
- terminus/ml/__init__.py +16 -0
- terminus/ml/optim.py +248 -0
- terminus/ml/regime.py +395 -0
- terminus/portfolio.py +241 -0
- terminus/registry.py +246 -0
- terminus/risk/__init__.py +0 -0
- terminus/risk/factor_model.py +382 -0
- terminus/risk/metrics.py +216 -0
- terminus/rules.py +395 -0
- terminus/simulate.py +312 -0
- terminus/store.py +516 -0
- terminus/sweep.py +260 -0
- terminus/telemetry.py +407 -0
- terminus/walk_forward.py +251 -0
- terminus_lab-0.1.0.dist-info/METADATA +167 -0
- terminus_lab-0.1.0.dist-info/RECORD +25 -0
- terminus_lab-0.1.0.dist-info/WHEEL +5 -0
- terminus_lab-0.1.0.dist-info/entry_points.txt +2 -0
- terminus_lab-0.1.0.dist-info/licenses/LICENSE +27 -0
- terminus_lab-0.1.0.dist-info/top_level.txt +1 -0
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
|