dmoera 0.2.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.
dmoera/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """dmoera - local backtesting, grading and validation for dMoERA strategies.
2
+
3
+ Runs the exact simulation engine the dMoERA validation harness uses, on your
4
+ own machine, against Binance data you cache locally (or any OHLCV CSV you
5
+ bring). Every bot in the Crypto-Strategies corpus runs unmodified.
6
+ """
7
+ __version__ = "0.2.0"
8
+
9
+ from dmoera.strategy_contract import ( # noqa: F401
10
+ Strategy, BarContext, Signal, SignalDirection, Bar,
11
+ validate_metadata, CONTRACT_VERSION,
12
+ )
13
+ from dmoera.engine import simulate_strategy, SimTrade, load_strategy # noqa: F401
dmoera/cli.py ADDED
@@ -0,0 +1,378 @@
1
+ """dmoera command line interface.
2
+
3
+ Commands:
4
+ dmoera backtest bot.py [--symbol ETHUSDT] [--interval 5m] [--days 180]
5
+ [--csv FILE] [--friction 6.0] [--export-trades F]
6
+ dmoera grade trades.csv [--fee 12] [--trials 20] [--json]
7
+ dmoera validate bot.py [--days 180] (7 open stages, public thresholds)
8
+ dmoera certify bot.py (official verdict; needs DMOERA_API_KEY)
9
+ """
10
+ import argparse
11
+ import sys
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+
17
+ def _load_bot_file(path: str):
18
+ from dmoera.engine import load_strategy
19
+ code = Path(path).read_text(encoding="utf-8")
20
+ strategy_class, error = load_strategy(code)
21
+ if error:
22
+ print(f"[dmoera] failed to load {path}: {error}")
23
+ sys.exit(1)
24
+ return strategy_class
25
+
26
+
27
+ def _write_trades_csv(path: str, trades, symbol: str, interval: str) -> None:
28
+ """Export SimTrades in the simple format dmoera grade parses:
29
+ timestamp,side,entry_price,exit_price,size[,exit_time,symbol]"""
30
+ import csv as _csv
31
+ from datetime import timedelta
32
+ step = timedelta(seconds={"1m": 60, "3m": 180, "5m": 300, "15m": 900, "30m": 1800,
33
+ "1h": 3600, "2h": 7200, "4h": 14400, "1d": 86400}.get(interval, 300))
34
+ with open(path, "w", newline="") as f:
35
+ w = _csv.writer(f)
36
+ w.writerow(["timestamp", "side", "entry_price", "exit_price", "size",
37
+ "exit_time", "symbol"])
38
+ for t in trades:
39
+ try:
40
+ entry_dt = datetime.fromisoformat(str(t.timestamp).replace("Z", ""))
41
+ except ValueError:
42
+ continue
43
+ exit_dt = entry_dt + step * max(1, t.hold_bars)
44
+ w.writerow([entry_dt.strftime("%Y-%m-%d %H:%M:%S"),
45
+ "long" if t.direction == "long" else "short",
46
+ t.entry_price, t.exit_price, 1,
47
+ exit_dt.strftime("%Y-%m-%d %H:%M:%S"), symbol])
48
+
49
+
50
+ def _resolve_symbol(args) -> str:
51
+ meta = getattr(getattr(args, "_strategy_class", None), "METADATA", {}) or {}
52
+ if args.symbol:
53
+ return args.symbol.upper()
54
+ parts = meta.get("domain", "eth_usdc").split("_")
55
+ quote = "USDC" if len(parts) > 1 and parts[1].lower() == "usdc" else "USDT"
56
+ return parts[0].upper() + quote
57
+
58
+
59
+ def _maybe_features(strategy_class, code: str, symbol: str, interval: str,
60
+ candles, days: int) -> Optional[list]:
61
+ """Build the local feature series if the bot reads ctx.features."""
62
+ from dmoera.features import build_feature_series, used_feature_keys
63
+ keys = used_feature_keys(code)
64
+ if keys is not None and not keys:
65
+ return None
66
+ print("[dmoera] bot reads ctx.features - building local feature series ...")
67
+ return build_feature_series(candles, code, symbol, interval)
68
+
69
+
70
+ def cmd_backtest(args) -> int:
71
+ from dmoera import data as d
72
+ from dmoera.engine import simulate_strategy
73
+
74
+ strategy_class = _load_bot_file(args.file)
75
+ meta = getattr(strategy_class, "METADATA", {}) or {}
76
+ if args.symbol:
77
+ symbol = args.symbol.upper()
78
+ else:
79
+ # domain like "eth_usdc" / "btc_usdt" -> ETHUSDC / BTCUSDT
80
+ parts = meta.get("domain", "eth_usdc").split("_")
81
+ quote = "USDC" if len(parts) > 1 and parts[1].lower() == "usdc" else "USDT"
82
+ symbol = parts[0].upper() + quote
83
+ warmup = int(meta.get("warmup_bars", 0) or 0)
84
+
85
+ print(f"[dmoera] loading candles: {symbol} {args.interval} last {args.days}d ...")
86
+ candles = d.load_candles(symbol, interval=args.interval, days=args.days, csv_path=args.csv)
87
+ if len(candles) < warmup + 10:
88
+ print(f"[dmoera] not enough candles ({len(candles)}) for warmup {warmup}. "
89
+ f"Use --days or --csv to provide more data.")
90
+ return 1
91
+ print(f"[dmoera] {len(candles)} candles "
92
+ f"({candles[0]['timestamp'].date()} -> {candles[-1]['timestamp'].date()})")
93
+
94
+ code = Path(args.file).read_text(encoding="utf-8")
95
+ feats = _maybe_features(strategy_class, code, symbol, args.interval, candles, args.days)
96
+
97
+ strategy = strategy_class()
98
+ trades, metrics = simulate_strategy(
99
+ strategy, candles, symbol=symbol,
100
+ friction_bps=args.friction, warmup_bars=warmup,
101
+ features_series=feats,
102
+ )
103
+ if "error" in metrics:
104
+ print(f"[dmoera] backtest error: {metrics['error']}")
105
+ return 1
106
+
107
+ if args.export_trades:
108
+ _write_trades_csv(args.export_trades, trades, symbol, args.interval)
109
+ print(f"[dmoera] {len(trades)} trades exported -> {args.export_trades} "
110
+ f"(grade them: dmoera grade {args.export_trades})")
111
+
112
+ print()
113
+ print(f" strategy: {meta.get('name', strategy_class.__name__)}")
114
+ print(f" domain: {meta.get('domain', '?')} ({symbol} {args.interval})")
115
+ print(f" trades: {metrics.get('total_trades', 0)}")
116
+ print(f" net return: {metrics.get('total_return_bps', 0.0):+.1f} bps "
117
+ f"(after ~{args.friction * 2:.0f} bps round-trip friction)")
118
+ print(f" win rate: {metrics.get('win_rate', 0.0) * 100:.1f}%")
119
+ print(f" significance: {metrics.get('sharpe', 0.0):.2f} "
120
+ f"(t-stat on {metrics.get('total_trades', 0)} trades)")
121
+ print(f" max drawdown: {metrics.get('max_drawdown_bps', 0.0):.1f} bps")
122
+ print(f" final equity: {metrics.get('final_equity', 100.0):.2f} "
123
+ f"(start {100.0})")
124
+ print()
125
+ print(" Same engine, same friction, same 1000-bar cap as the dMoERA")
126
+ print(" validation harness. A number here means the same thing it")
127
+ print(" means there. Run `dmoera validate` for the 7-stage battery,")
128
+ print(" or `dmoera certify` for the official sealed verdict.")
129
+ return 0
130
+
131
+
132
+ def cmd_grade(args) -> int:
133
+ from dmoera.grade import cli as grade_cli
134
+ argv = [args.file, "--fee", str(args.fee), "--trials", str(args.trials)]
135
+ if args.json:
136
+ argv.append("--json")
137
+ return grade_cli.main(argv)
138
+
139
+
140
+ # ── validate: the 7 open stages, public default thresholds ────────────────
141
+ # These thresholds are PUBLISHED approximations, not the sealed exam's bar.
142
+ # Passing here means "not obviously broken", nothing more.
143
+
144
+ STAGE_SPLIT = 0.6 # in-sample = first 60%, OOS = last 40%
145
+ HOLDOUT_FRAC = 0.15 # last 15% is the holdout slice
146
+ WF_WINDOWS = 5 # rolling walk-forward windows
147
+ RANDOM_STARTS = 5 # randomized-start runs
148
+ STRESS_MULT = 2.0 # slippage stress: friction x2
149
+
150
+
151
+ def _sim_slice(strategy_class, candles, symbol, warmup, lo, hi, friction, feats=None):
152
+ from dmoera.engine import simulate_strategy
153
+ start = max(0, lo - warmup)
154
+ s = strategy_class()
155
+ f = feats[start:hi] if feats is not None else None
156
+ return simulate_strategy(s, candles[start:hi], symbol=symbol,
157
+ friction_bps=friction, warmup_bars=warmup,
158
+ features_series=f)
159
+
160
+
161
+ def _fmt_bps(x):
162
+ return f"{x:+.1f} bps" if x else "0.0 bps"
163
+
164
+
165
+ def cmd_validate(args) -> int:
166
+ from dmoera import data as d
167
+ from dmoera.engine import load_strategy, simulate_strategy
168
+
169
+ strategy_class = _load_bot_file(args.file)
170
+ meta = getattr(strategy_class, "METADATA", {}) or {}
171
+ if args.symbol:
172
+ symbol = args.symbol.upper()
173
+ else:
174
+ parts = meta.get("domain", "eth_usdc").split("_")
175
+ quote = "USDC" if len(parts) > 1 and parts[1].lower() == "usdc" else "USDT"
176
+ symbol = parts[0].upper() + quote
177
+ warmup = int(meta.get("warmup_bars", 0) or 0)
178
+ friction = args.friction
179
+
180
+ print(f"[dmoera] loading candles: {symbol} {args.interval} last {args.days}d ...")
181
+ candles = d.load_candles(symbol, interval=args.interval, days=args.days, csv_path=args.csv)
182
+ n = len(candles)
183
+ if n < warmup + 200:
184
+ print(f"[dmoera] not enough candles ({n}) for the battery. Need ~200+ after warmup.")
185
+ return 1
186
+ print(f"[dmoera] {n} candles ({candles[0]['timestamp'].date()} -> {candles[-1]['timestamp'].date()})")
187
+ print(f"[dmoera] running the 7 open stages (public thresholds, friction {friction} bps/side) ...\n")
188
+
189
+ code = Path(args.file).read_text(encoding="utf-8")
190
+ feats = _maybe_features(strategy_class, code, symbol, args.interval, candles, args.days)
191
+
192
+ holdout_start = int(n * (1 - HOLDOUT_FRAC))
193
+ oos_end = holdout_start
194
+ is_end = int(oos_end * STAGE_SPLIT)
195
+ results = []
196
+
197
+ def stage(name, ok, detail):
198
+ results.append((name, ok, detail))
199
+ print(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
200
+
201
+ # 1. code safety - load_strategy's static checks already ran in _load_bot_file
202
+ stage("1 code safety", True, "static checks passed (no network, no clock, no file IO)")
203
+
204
+ # 2. in-sample
205
+ _, m_is = _sim_slice(strategy_class, candles, symbol, warmup, 0, is_end, friction, feats)
206
+ is_bps = m_is.get("total_return_bps", 0.0)
207
+ stage("2 in-sample", m_is.get("total_trades", 0) > 0,
208
+ f"{m_is.get('total_trades', 0)} trades, {_fmt_bps(is_bps)}")
209
+
210
+ # 3. out-of-sample
211
+ _, m_oos = _sim_slice(strategy_class, candles, symbol, warmup, is_end, oos_end, friction, feats)
212
+ oos_bps = m_oos.get("total_return_bps", 0.0)
213
+ stage("3 out-of-sample", oos_bps > 0,
214
+ f"{m_oos.get('total_trades', 0)} trades, {_fmt_bps(oos_bps)} (bar: positive after friction)")
215
+
216
+ # 4. rolling walk-forward: WF_WINDOWS consecutive slices over the IS+OOS span
217
+ wf_rets = []
218
+ span = oos_end
219
+ wsize = max(warmup + 50, int(span * 0.25))
220
+ for k in range(WF_WINDOWS):
221
+ lo = int(k * (span - wsize) / max(1, WF_WINDOWS - 1))
222
+ _, m_w = _sim_slice(strategy_class, candles, symbol, warmup, lo, lo + wsize, friction, feats)
223
+ wf_rets.append(m_w.get("total_return_bps", 0.0))
224
+ wf_pos = sum(1 for r in wf_rets if r > 0)
225
+ stage("4 walk-forward", wf_pos >= WF_WINDOWS // 2 + 1,
226
+ f"{wf_pos}/{WF_WINDOWS} windows positive (bar: majority)")
227
+
228
+ # 5. randomized starts: short runs from random offsets (seeded, deterministic)
229
+ import random as _random
230
+ rng = _random.Random(20261109)
231
+ rs_rets = []
232
+ rsize = max(warmup + 50, int(n * 0.2))
233
+ for _ in range(RANDOM_STARTS):
234
+ lo = rng.randint(0, max(0, n - rsize - 1))
235
+ _, m_r = _sim_slice(strategy_class, candles, symbol, warmup, lo, lo + rsize, friction, feats)
236
+ rs_rets.append(m_r.get("total_return_bps", 0.0))
237
+ rs_med = sorted(rs_rets)[len(rs_rets) // 2]
238
+ stage("5 randomized starts", rs_med > 0,
239
+ f"median of {RANDOM_STARTS} random windows: {_fmt_bps(rs_med)}")
240
+
241
+ # 6. slippage stress: OOS again at 2x friction
242
+ _, m_st = _sim_slice(strategy_class, candles, symbol, warmup, is_end, oos_end, friction * STRESS_MULT, feats)
243
+ st_bps = m_st.get("total_return_bps", 0.0)
244
+ stage("6 slippage stress", st_bps > 0,
245
+ f"OOS at {friction * STRESS_MULT:.0f} bps/side: {_fmt_bps(st_bps)}")
246
+
247
+ # 7. holdout - reported, never tuned against
248
+ _, m_h = _sim_slice(strategy_class, candles, symbol, warmup, holdout_start, n, friction, feats)
249
+ h_bps = m_h.get("total_return_bps", 0.0)
250
+ stage("7 holdout", h_bps > 0,
251
+ f"last {n - holdout_start} bars: {_fmt_bps(h_bps)} (never used above)")
252
+
253
+ passed = sum(1 for _, ok, _ in results if ok)
254
+ print()
255
+ print(f" VALIDATION: {passed}/7 stages passed (public thresholds)")
256
+ if passed == 7:
257
+ print(" Not obviously broken. The REAL exam is sealed and server-side:")
258
+ print(" `dmoera certify` for the official verdict.")
259
+ else:
260
+ fails = [name for name, ok, _ in results if not ok]
261
+ print(f" Failed: {', '.join(fails)}")
262
+ print(" Fix these locally for free - certify counts attempts, so")
263
+ print(" don't burn server attempts on a bot that fails here.")
264
+ return 0 if passed == 7 else 1
265
+
266
+
267
+ def cmd_certify(args) -> int:
268
+ import hashlib
269
+ import json
270
+ import os
271
+ import urllib.request
272
+
273
+ api_key = args.api_key or os.environ.get("DMOERA_API_KEY")
274
+ if not api_key:
275
+ print("[dmoera] certify needs an API key (it runs the sealed exam on our servers,")
276
+ print(" which costs compute). Get one free at https://dmoera.xyz ->")
277
+ print(" Settings -> API keys, then: set DMOERA_API_KEY=dmo_pat_...")
278
+ return 1
279
+ server = (args.server or os.environ.get("DMOERA_SERVER") or "https://dmoera.xyz").rstrip("/")
280
+
281
+ code = Path(args.file).read_text(encoding="utf-8")
282
+ code_hash = hashlib.sha256(code.encode("utf-8")).hexdigest()
283
+ print(f"[dmoera] code hash: {code_hash[:16]}...")
284
+ print(f"[dmoera] uploading to {server} (sealed exam: real thresholds, holdout, attempt ledger) ...")
285
+
286
+ body = json.dumps({"code": code, "code_hash": code_hash}).encode()
287
+ req = urllib.request.Request(
288
+ f"{server}/api/package/certify", data=body, method="POST",
289
+ headers={"Content-Type": "application/json",
290
+ "Authorization": f"Bearer {api_key}",
291
+ "User-Agent": "dmoera-cli/0.2"})
292
+ try:
293
+ with urllib.request.urlopen(req, timeout=600) as r:
294
+ res = json.load(r)
295
+ except urllib.error.HTTPError as e:
296
+ try:
297
+ err = json.load(e)
298
+ except Exception:
299
+ err = {}
300
+ if e.code == 401:
301
+ print("[dmoera] unauthorized - that API key is not valid. Check DMOERA_API_KEY.")
302
+ elif e.code == 402:
303
+ print(f"[dmoera] {err.get('error', 'not enough credits')}")
304
+ elif e.code == 429:
305
+ print(f"[dmoera] rate limited: {err.get('error', 'try again later')}")
306
+ else:
307
+ print(f"[dmoera] certify failed (HTTP {e.code}): {err.get('error', e.reason)}")
308
+ return 1
309
+ except Exception as e:
310
+ print(f"[dmoera] could not reach {server}: {e}")
311
+ return 1
312
+
313
+ if not res.get("success"):
314
+ print(f"[dmoera] certify failed: {res.get('error', 'unknown error')}")
315
+ return 1
316
+ print()
317
+ print("+" + "-" * 62 + "+")
318
+ print("|" + f" OFFICIAL VERDICT {res.get('verdict', '?')} attempt #{res.get('attempt', '?')}".ljust(62) + "|")
319
+ print("+" + "-" * 62 + "+")
320
+ for k, label in (("oos_return_bps", "OOS return"), ("sharpe", "Sharpe"),
321
+ ("max_drawdown_bps", "Max drawdown"), ("total_trades", "Trades")):
322
+ if k in res:
323
+ print(f" {label:16s} {res[k]}")
324
+ if res.get("cert_id"):
325
+ print(f" cert: {res['cert_id']}")
326
+ print(f" verify: {server}/api/package/certify/verify/{res.get('code_hash', '')}")
327
+ print(f" Share the cert line + hash in your README - anyone can verify it.")
328
+ return 0
329
+
330
+
331
+ def main(argv=None) -> int:
332
+ p = argparse.ArgumentParser(prog="dmoera", description=__doc__,
333
+ formatter_class=argparse.RawDescriptionHelpFormatter)
334
+ sub = p.add_subparsers(dest="cmd", required=True)
335
+
336
+ bt = sub.add_parser("backtest", help="backtest a strategy file locally")
337
+ bt.add_argument("file", help="path to the strategy .py file")
338
+ bt.add_argument("--symbol", default=None, help="e.g. ETHUSDT (default: from METADATA.domain)")
339
+ bt.add_argument("--interval", default="5m",
340
+ choices=["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "1d"])
341
+ bt.add_argument("--days", type=int, default=180, help="history length (default 180)")
342
+ bt.add_argument("--csv", default=None, help="use this OHLCV CSV instead of fetching")
343
+ bt.add_argument("--friction", type=float, default=6.0,
344
+ help="per-side friction in bps (default 6.0)")
345
+ bt.add_argument("--export-trades", default=None,
346
+ help="write the trade list to this CSV (then: dmoera grade FILE)")
347
+ bt.set_defaults(func=cmd_backtest)
348
+
349
+ gr = sub.add_parser("grade", help="grade a trade log S-F after fees, regimes and luck")
350
+ gr.add_argument("file", help="trade log: our CSV, TradingView, Freqtrade JSON, Binance CSV")
351
+ gr.add_argument("--fee", type=float, default=12.0, help="round-trip fee bps (default 12)")
352
+ gr.add_argument("--trials", type=int, default=20,
353
+ help="how many versions you tried before this one (default 20)")
354
+ gr.add_argument("--json", action="store_true", help="print full JSON result")
355
+ gr.set_defaults(func=cmd_grade)
356
+
357
+ va = sub.add_parser("validate", help="run the 7 open validation stages on a bot (public thresholds)")
358
+ va.add_argument("file", help="path to the strategy .py file")
359
+ va.add_argument("--symbol", default=None)
360
+ va.add_argument("--interval", default="5m",
361
+ choices=["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "1d"])
362
+ va.add_argument("--days", type=int, default=180)
363
+ va.add_argument("--csv", default=None, help="use this OHLCV CSV instead of fetching")
364
+ va.add_argument("--friction", type=float, default=6.0)
365
+ va.set_defaults(func=cmd_validate)
366
+
367
+ ce = sub.add_parser("certify", help="official verdict from the dMoERA sealed exam (needs DMOERA_API_KEY)")
368
+ ce.add_argument("file", help="path to the strategy .py file")
369
+ ce.add_argument("--api-key", default=None, help="or set DMOERA_API_KEY env var")
370
+ ce.add_argument("--server", default=None, help="dMoERA API base (default https://dmoera.xyz)")
371
+ ce.set_defaults(func=cmd_certify)
372
+
373
+ args = p.parse_args(argv)
374
+ return args.func(args)
375
+
376
+
377
+ if __name__ == "__main__":
378
+ sys.exit(main())