Kashaf1 commited on
Commit
02b7676
·
1 Parent(s): c584d79

Deleted all

Browse files
README.md DELETED
@@ -1,512 +0,0 @@
1
- ---
2
- title: Moirai-MoE Forex & Crypto Forecaster
3
- emoji: 📈
4
- colorFrom: blue
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: "6.26.0"
8
- python_version: "3.11"
9
- app_file: app.py
10
- pinned: false
11
- license: cc-by-nc-4.0
12
- ---
13
-
14
- # Moirai-MoE-1.0-R-Small — Forex & Crypto Forecasting
15
-
16
- A real forecasting + walk-forward backtesting application whose **sole
17
- predictive model** is `Salesforce/moirai-moe-1.0-R-small`. No other ML
18
- model exists anywhere in this codebase as a fallback or replacement —
19
- grep for `sklearn|xgboost|lightgbm|catboost|prophet|statsmodels` in
20
- `*.py` and you'll find nothing.
21
-
22
- ## Read this first: what "real" means in this build
23
-
24
- This project was built and unit-tested inside a sandboxed environment
25
- with **no outbound network access** (confirmed: `curl` to pypi.org
26
- returns `403 host_not_allowed`). That means, in *this* environment, it
27
- is not possible to:
28
-
29
- - `pip install uni2ts / torch / gluonts / yfinance / gradio`
30
- - download the actual `Salesforce/moirai-moe-1.0-R-small` checkpoint
31
- - call the real Yahoo Finance API
32
-
33
- So rather than fabricate "it works" — which is the one thing this
34
- project's own spec (and honest engineering generally) forbids — here
35
- is exactly what was and wasn't verified, using the project's own
36
- VERIFIED / IMPLEMENTED / BLOCKED vocabulary.
37
-
38
- ## Final Validation Report
39
-
40
- ### MODEL VERIFICATION
41
- - Checkpoint: `Salesforce/moirai-moe-1.0-R-small` — **confirmed to exist** (469MB, safetensors, license cc-by-nc-4.0) via live lookup during this build.
42
- - API surface (`MoiraiMoEModule.from_pretrained`, `MoiraiMoEForecast(...)`, `.create_predictor().predict(...)`) — **confirmed against the current official `uni2ts` repo and PyPI package** (`pip install uni2ts`, v2.0.0) during this build, not from possibly-stale training memory.
43
- - Real checkpoint load / real forward pass: **BLOCKED** — no network in this sandbox to install `uni2ts`/`torch` or download the checkpoint. Verified instead that the failure mode is correct: `MoiraiMoEForecaster().load()` raises `ModelLoadError("uni2ts is not installed/importable...")`, not a silent mock (see `run_tests.py` output below).
44
-
45
- ### DATA VERIFICATION
46
- - `yfinance` retrieval code: **IMPLEMENTED**; the underlying `yfinance` *package* is now installable and was installed in a later, network-enabled sandbox pass (see "Bug-fix pass" below) — resolved to `1.7.0`, and `Ticker.history()`'s signature (`period`/`interval`/`start`/`end`/`auto_adjust`) plus its returned OHLCV column names were confirmed unchanged from what `data_layer.py` assumes. A real *call* to Yahoo's endpoints is still **BLOCKED** in every sandbox pass so far (neither this one nor the later one has network access to `finance.yahoo.com`). Confirmed the failure mode is honest: `fetch_ohlcv()` raises `DataSourceError`, never returns fabricated candles.
47
- - OHLCV validation, causal resampling (5m→10m, 1h→2h/4h), local SQLite cache, freshness classification: **VERIFIED** — real unit tests, real execution, see below.
48
-
49
- ### FEATURE VERIFICATION
50
- - All 10 indicator families (RSI, MACD, Bollinger, SMA, EMA, Stochastic, ATR, ADX, ROC, OBV): **VERIFIED**. `test_indicators_are_causal` recomputes every indicator on a truncated frame and asserts bit-identical values at every shared timestamp — this is a real causality proof, not an assumption.
51
- - Warm-up detection/trimming: **VERIFIED**.
52
-
53
- ### LEAKAGE VERIFICATION
54
- - `leakage_audit.py` assertions: **VERIFIED** — `test_leakage_audit_catches_future_row` injects an actual violation and confirms it's caught.
55
- - Backtest no-lookahead property: **VERIFIED** — `test_decisions_unaffected_by_future_rows` and `test_backtest_context_never_extends_past_origin` run the walk-forward engine twice (full data vs. truncated data) and assert every shared decision is identical, and that no context slice ever contains a timestamp after its own origin.
56
-
57
- ### FORECAST / TRADING VERIFICATION
58
- - Point/probabilistic/trading metrics (MAE, RMSE, sMAPE, directional accuracy, Sharpe, Sortino, Calmar, max drawdown, profit factor): **VERIFIED** against hand-computed values on tiny known examples.
59
- - Real accuracy/PnL numbers on real market data: **BLOCKED** — requires the real checkpoint and real Yahoo data. **This build makes no accuracy or profitability claim of any kind.** Anything computed against `tests/fixtures.py`'s synthetic data is a software-correctness check only, per spec section 100, and is never reported as performance.
60
-
61
- ### RESOURCE VERIFICATION
62
- - Designed for 2 CPU / 16GB RAM / 50GB disk (bounded sample counts, chunked/streaming-friendly cache, no unbounded in-memory tables): **IMPLEMENTED**, not load-tested here.
63
-
64
- ### DEPLOYMENT VERIFICATION
65
- - App imports and Gradio Blocks graph construction: **VERIFIED** as of the "Bug-fix pass" below — a later sandbox pass had real network access to PyPI, installed `gradio==6.26.0` (the exact pin in `requirements.txt`), and confirmed `import app` builds the full Blocks graph (2 backend functions, every Row/Tab/Accordion/component) with no exception. `torch`/`uni2ts`/`gluonts` were deliberately not installed in that pass (heavy, and not needed to exercise this specific claim), so the real checkpoint load and forward pass remain BLOCKED as stated above.
66
- - Full pipeline wiring (data → features → model → decision → backtest → metrics): **VERIFIED end-to-end using a test-only stub in place of the real model** — see `tests/test_backtest_no_lookahead.py`. The stub is never referenced by `app.py`. Also re-verified by calling the real `app.run_backtest()` / `app.run_live_forecast()` directly (model and OHLCV-fetch swapped for stubs, everything else real) — see "Bug-fix pass" below.
67
-
68
- ### ACTUAL TEST RUN (executed in this sandbox, unedited)
69
- ```
70
- $ python3 run_tests.py
71
- PASS test_backtest_no_lookahead.py::test_backtest_context_never_extends_past_origin
72
- PASS test_backtest_no_lookahead.py::test_decisions_unaffected_by_future_rows
73
- PASS test_data_layer.py::test_data_freshness_classification
74
- PASS test_data_layer.py::test_local_cache_round_trip
75
- PASS test_data_layer.py::test_validate_marks_negative_volume_unavailable_not_invented
76
- PASS test_data_layer.py::test_validate_removes_duplicates_and_keeps_first
77
- PASS test_data_layer.py::test_validate_removes_invalid_ohlc_relationship
78
- PASS test_data_layer.py::test_validate_removes_nan_rows_and_reports_them
79
- PASS test_decision_and_metrics.py::test_decide_buy_on_clear_positive_move
80
- PASS test_decision_and_metrics.py::test_decide_hold_on_wide_interval
81
- PASS test_decision_and_metrics.py::test_decide_hold_when_edge_below_cost
82
- PASS test_decision_and_metrics.py::test_decide_sell_on_clear_negative_move
83
- PASS test_decision_and_metrics.py::test_directional_accuracy
84
- PASS test_decision_and_metrics.py::test_max_drawdown_known_series
85
- PASS test_decision_and_metrics.py::test_point_metrics_hand_computed
86
- PASS test_decision_and_metrics.py::test_profit_factor_known_values
87
- PASS test_decision_and_metrics.py::test_sharpe_nan_when_no_variance
88
- PASS test_decision_and_metrics.py::test_smape_in_valid_range
89
- PASS test_features_and_leakage.py::test_causal_resample_drops_incomplete_trailing_bin
90
- PASS test_features_and_leakage.py::test_causal_resample_ohlc_correctness
91
- PASS test_features_and_leakage.py::test_indicators_are_causal
92
- PASS test_features_and_leakage.py::test_leakage_audit_catches_future_row
93
- PASS test_features_and_leakage.py::test_warm_up_detection
94
- PASS test_features_and_leakage.py::test_warm_up_raises_on_insufficient_history
95
-
96
- 24/24 passed
97
- ```
98
- One real bug was actually found and fixed during this run (`pd.read_json`
99
- needed `io.StringIO` on the sandbox's pandas 3.0.2) — left in this
100
- history deliberately, since "we ran it and fixed what broke" is the
101
- whole point of section 6 (real execution validation vs. static checks).
102
-
103
- ## Bug-fix pass — Aug 2026 (real user-reported crash + related issues)
104
-
105
- Triggered by a real user hitting **BACKTEST ERROR** in the Backtest tab
106
- (`BTC-USD`, `1d`, History window = `4 days`). This pass ran in a
107
- sandbox with actual PyPI network access (a first for this repo) —
108
- `pandas`/`numpy` were already present, and `pytz`, `yfinance==1.7.0`,
109
- and `gradio==6.26.0` (the exact requirements.txt pin) were installed
110
- alongside them, so both `import app` (Blocks graph construction) and
111
- the full existing + new test suite were executed for real, not just
112
- reasoned about. `torch`/`uni2ts`/`gluonts` were left out (heavy, and
113
- the real checkpoint/forward-pass claims below don't depend on them).
114
-
115
- 1. **The reported crash**: `warm_up_valid_from()` (`features.py`)
116
- correctly refuses to fabricate a warmed-up row — the bug was that its
117
- message just said "insufficient historical data" with no numbers. 4
118
- daily bars can't warm up a 50-period SMA (or any of the other nine
119
- indicator families), but the message didn't say which indicator was
120
- blocking, how many bars it needed, or how many were available.
121
- **Fix**: the message now names the exact slowest-to-warm-up column(s)
122
- and the real bar counts, diagnosed empirically off the actual
123
- computed columns (never a hand-derived per-indicator formula — MACD
124
- and ADX are ewm-of-ewm chains, and guessing their effective warm-up
125
- length is exactly the kind of static assumption section 6 argues
126
- against). `app.py`'s Backtest tab also didn't catch this `ValueError`
127
- specially the way the Live tab already did — it fell through to the
128
- generic handler and showed a raw Python traceback for a normal,
129
- actionable, non-buggy condition. Fixed to match the Live tab's
130
- existing clean-message behavior.
131
- 2. **A related, unreported latent bug found while fixing #1**: even
132
- when warm-up succeeds, `WalkForwardBacktester.run()`'s default
133
- `context_length=256` (never exposed in the UI, so every backtest run
134
- used it regardless of History window) could still exceed the
135
- available bars — and `app.py` never passed a `context_length` at
136
- all, so production always hit the hardcoded default. When that
137
- happened, `feature_df.index[start_idx]` raised a bare `IndexError`
138
- once `start_idx` ran past the end of the frame — reproduced directly
139
- with 150 synthetic daily bars. **Fix**: `run()` now checks
140
- `warm-up + context_length + horizon + execution_delay` against the
141
- actual bar count *before* indexing, and raises a new
142
- `InsufficientDataError` stating the exact shortfall (e.g. "150 bars
143
- available, needs at least 312 ... widen History window by at least
144
- 162 bars"). Also added a `context_length` slider to the Backtest
145
- tab's new "Advanced" accordion (default 256, so nobody's existing
146
- behavior changes unless they open it) — spec section 25 wants
147
- assumptions explicit and configurable, and this one previously
148
- wasn't either.
149
- 3. **`decision_engine.decide()`'s SELL branch was gating on the wrong
150
- probability**: it computed `prob_favorable = P(return > cost)` (correct
151
- for BUY) and then used `1 - prob_favorable = P(return <= cost)` as the
152
- SELL-side confidence check. That's a much easier bar to clear than the
153
- directionally-correct `P(return < -cost)` — it's satisfied by anything
154
- in the whole `-inf..cost` range, not just the actually-profitable-to-
155
- short range below `-cost` — so SELL decisions could clear the
156
- `min_probability` gate with materially less real confidence than BUY
157
- decisions did. Confirmed with a constructed forecast where the two
158
- disagree: true `P(return<-cost)` ≈ 0.54 (below the 0.55 gate → should
159
- HOLD) while the old `1-prob_favorable` ≈ 0.56 (wrongly cleared the
160
- gate → SELL). **Fix**: SELL now computes its own mirrored probability,
161
- `P(return < -cost)`, instead of reusing BUY's complement. The
162
- rationale string also now reports whichever probability the taken
163
- action actually used — previously a confident SELL displayed
164
- "P(return>cost)=0.00", which reads as "no confidence" even though the
165
- real (and high) SELL-side confidence was correct.
166
- 4. **The Live tab never passed an asset-specific cost to `decide()`**:
167
- unlike the Backtest tab (`b_class` radio → a real `COST_PROFILES`
168
- entry), `run_live_forecast()` called `decide(forecast, price_now)`
169
- with no `cost_estimate_frac`, silently falling back to
170
- `DecisionConfig`'s flat generic 5bps for every symbol — too high for
171
- real forex spreads, too low for real crypto costs. **Fix**: added
172
- `config.infer_asset_class()` (checks the curated symbol lists first,
173
- then Yahoo's own ticker conventions — `=X` for forex, `-USD`/`-USDT`/
174
- etc. for crypto — for anything typed via `allow_custom_value`) and
175
- `config.round_trip_cost_frac()` (the cost-fraction formula, moved out
176
- of `backtest_engine.py`'s private copy so both tabs share one
177
- implementation). The Live tab's output now also states which cost
178
- profile was inferred and the resulting round-trip cost, so the
179
- assumption is visible, not silent.
180
- 5. **Hardening, not a confirmed live bug**: `requirements.txt` pinned
181
- `yfinance>=0.2.40` with no ceiling — the one dependency in this file
182
- without the floor+ceiling treatment gradio already gets. This sandbox
183
- pass's `pip install` resolved `yfinance==1.7.0` (a major-version jump
184
- from the 0.2.x line this was originally written against); its
185
- `Ticker.history()` signature and returned column names were checked
186
- against `data_layer.py`'s usage and are unchanged, so the floor
187
- wasn't tightened — but a ceiling (`<2.0`) was added so a future,
188
- unverified 2.x can't be silently picked up the way an unpinned
189
- `gradio` once was (see deployment history below).
190
-
191
- All four code fixes above have real regression tests
192
- (`test_warm_up_error_names_the_blocking_indicator_and_bar_counts`,
193
- `test_run_raises_insufficient_data_error_not_a_bare_indexerror` +
194
- `test_run_succeeds_with_a_smaller_context_length_on_the_same_short_data`,
195
- `test_decide_sell_requires_correct_directional_probability_not_buy_complement`
196
- + `test_decide_sell_rationale_reports_the_sell_side_probability`,
197
- `tests/test_config.py`), and the original bug was reproduced and
198
- re-verified fixed by calling the real `app.run_backtest()` directly
199
- (OHLCV fetch and model swapped for deterministic stubs — same pattern
200
- `tests/test_backtest_no_lookahead.py` already used — everything else
201
- real, including `_get_ohlcv`, `compute_indicators`, the actual
202
- `WalkForwardBacktester`, and Gradio's own Blocks-graph wiring).
203
-
204
- ```
205
- $ python3 run_tests.py
206
- PASS test_backtest_no_lookahead.py::test_backtest_context_never_extends_past_origin
207
- PASS test_backtest_no_lookahead.py::test_backtest_passes_correct_freq_to_forecaster
208
- PASS test_backtest_no_lookahead.py::test_decisions_unaffected_by_future_rows
209
- PASS test_backtest_no_lookahead.py::test_run_raises_insufficient_data_error_not_a_bare_indexerror
210
- PASS test_backtest_no_lookahead.py::test_run_succeeds_with_a_smaller_context_length_on_the_same_short_data
211
- PASS test_config.py::test_infer_asset_class_curated_lists
212
- PASS test_config.py::test_infer_asset_class_default_for_unrecognized_symbol
213
- PASS test_config.py::test_infer_asset_class_falls_back_to_yahoo_ticker_conventions
214
- PASS test_config.py::test_round_trip_cost_frac_matches_hand_computed_bps
215
- PASS test_data_layer.py::test_data_freshness_classification
216
- PASS test_data_layer.py::test_local_cache_round_trip
217
- PASS test_data_layer.py::test_resolve_history_window_days
218
- PASS test_data_layer.py::test_resolve_history_window_max
219
- PASS test_data_layer.py::test_resolve_history_window_months_uses_calendar_months
220
- PASS test_data_layer.py::test_resolve_history_window_years
221
- PASS test_data_layer.py::test_validate_marks_negative_volume_unavailable_not_invented
222
- PASS test_data_layer.py::test_validate_removes_duplicates_and_keeps_first
223
- PASS test_data_layer.py::test_validate_removes_invalid_ohlc_relationship
224
- PASS test_data_layer.py::test_validate_removes_nan_rows_and_reports_them
225
- PASS test_decision_and_metrics.py::test_decide_buy_on_clear_positive_move
226
- PASS test_decision_and_metrics.py::test_decide_hold_on_wide_interval
227
- PASS test_decision_and_metrics.py::test_decide_hold_when_edge_below_cost
228
- PASS test_decision_and_metrics.py::test_decide_sell_on_clear_negative_move
229
- PASS test_decision_and_metrics.py::test_decide_sell_rationale_reports_the_sell_side_probability
230
- PASS test_decision_and_metrics.py::test_decide_sell_requires_correct_directional_probability_not_buy_complement
231
- PASS test_decision_and_metrics.py::test_directional_accuracy
232
- PASS test_decision_and_metrics.py::test_max_drawdown_known_series
233
- PASS test_decision_and_metrics.py::test_point_metrics_hand_computed
234
- PASS test_decision_and_metrics.py::test_profit_factor_known_values
235
- PASS test_decision_and_metrics.py::test_sharpe_nan_when_no_variance
236
- PASS test_decision_and_metrics.py::test_smape_in_valid_range
237
- PASS test_features_and_leakage.py::test_causal_resample_drops_incomplete_trailing_bin
238
- PASS test_features_and_leakage.py::test_causal_resample_ohlc_correctness
239
- PASS test_features_and_leakage.py::test_indicators_are_causal
240
- PASS test_features_and_leakage.py::test_leakage_audit_catches_future_row
241
- PASS test_features_and_leakage.py::test_warm_up_detection
242
- PASS test_features_and_leakage.py::test_warm_up_error_names_the_blocking_indicator_and_bar_counts
243
- PASS test_features_and_leakage.py::test_warm_up_raises_on_insufficient_history
244
-
245
- 38/38 passed
246
- ```
247
-
248
- Not touched in this pass, disclosed rather than silently left out:
249
- `persistence.py`'s `Store`/`record_forecast` is real, tested-adjacent
250
- code, but still isn't called anywhere from `app.py` — the "SQLite
251
- forecast registry" in the Architecture diagram below doesn't actually
252
- run yet. That's a missing integration, not a bug this pass introduced
253
- or fixed; wiring it up is a reasonable next step but a bigger,
254
- separate change.
255
-
256
- ## Feature addition — risk management & entry-filter data (Aug 2026)
257
-
258
- Triggered by a real 41-trade `EURUSD=X` @ 1h / 1-year backtest a user
259
- ran: 51.2% win rate, `profit_factor=0.78`, losing trades noticeably
260
- bigger than winning ones. Re-verified every number first (same method
261
- as the bug-fix pass above — recomputed all 41 gross/net PnLs from raw
262
- entry/exit prices, rebuilt the real ~5,800-point equity curve to check
263
- Sharpe/Sortino/Calmar): all exact matches, no bug in the math. Then
264
- actually analyzed the 41 real trades rather than guessing:
265
-
266
- - **Direction accuracy, not a P&L bug, is the real story**: every
267
- single winning trade (21/21) had the forecast pointing the correct
268
- way; 19/20 losing trades had it pointing the wrong way (the 20th was
269
- a correct call too small to clear the 0.02% cost). Overall directional
270
- accuracy was 53.7% — barely above a coin flip, consistent with the
271
- 51.2% win rate once costs are applied.
272
- - **A real but weak entry-filter signal**: `|forecast_return|`
273
- correlates with `net_pnl` at r=0.314 across the 41 trades — bigger
274
- predicted moves did trend better on average. That's real, but at
275
- n=41 it's only marginally past the threshold for statistical
276
- significance, and a magnitude-tercile breakdown (14/13/14 trades)
277
- came out *non-monotonic* (mid-tercile had the best win rate but the
278
- worst average P&L) — a strong sign that breakdown is dominated by
279
- noise at this sample size, not a clean rule to hard-code.
280
- - **The real, structural gap**: `WalkForwardBacktester` had *zero*
281
- intra-trade risk management — every trade held the fixed horizon no
282
- matter what happened in between, with no way to exit early no matter
283
- how far price ran against the position first. Confirmed this
284
- empirically too: realized `|actual_return|` on wrong-direction trades
285
- (0.0017) ran moderately larger than on right-direction ones (0.0015)
286
- over the same fixed horizon — exactly what a stop-loss targets.
287
-
288
- **What was added**: `cfg.RiskManagement(stop_loss_frac, take_profit_frac)`
289
- (both `None` by default — every prior backtest reproduces exactly) and
290
- `backtest_engine._scan_for_early_exit()`, which checks each bar's real
291
- intrabar high/low between entry and the horizon exit and closes the
292
- trade the first time either level is crossed. Exit price is still that
293
- bar's *close* (not the exact stop/TP level or the wick), consistent
294
- with this engine's existing close-only fill convention — documented as
295
- a real, known limitation, not glossed over: a sharp intrabar wick that
296
- closes back near where it started will trigger the stop but barely
297
- show up in the realized P&L. If both a stop and a take-profit are
298
- crossed in the same bar, OHLC data alone can't say which happened
299
- first, so the stop is assumed (the conservative read). `Trade` also
300
- now carries `exit_reason` ("horizon"/"stop_loss"/"take_profit") and the
301
- decision's `prob_favorable`/`interval_width_pct` at open, so future
302
- backtests carry the data needed to actually test an entry filter
303
- against outcomes — the 41 trades above predate this field, so that
304
- specific analysis couldn't be run retroactively. Exposed in the
305
- Backtest tab's Advanced accordion as two sliders (0 = off).
306
-
307
- **Honest result, not oversold**: tested end-to-end through a synthetic
308
- scenario with injected adverse intrabar dips — a stop-loss cut some
309
- real losers short, but also locked in several dips that would have
310
- recovered by the nominal horizon exit, and net_pnl came out *worse*
311
- than the no-stop baseline in that run. A stop-loss is not a free
312
- win; whether it helps depends on whether adverse intrabar moves in the
313
- real data tend to keep going or mean-revert, which this sandbox has no
314
- way to check against real EURUSD intrabar history (no network access
315
- to Yahoo's endpoints, same limitation as every other pass in this
316
- README). Test it on your own data before trusting it — start narrow
317
- (e.g. a few multiples of the round-trip cost) and compare against a
318
- no-stop run on the same symbol/window.
319
-
320
- Five new tests cover this (`test_stop_loss_exits_early_at_the_breaching_bars_close`,
321
- `test_take_profit_exits_early_on_a_sell`,
322
- `test_stop_and_take_profit_both_breached_same_bar_assumes_stop`,
323
- `test_risk_management_default_none_reproduces_baseline_exactly`,
324
- `test_trade_records_decision_confidence_for_entry_filter_analysis`) —
325
- 43/43 passing.
326
-
327
- ## Real deployment history (from an actual HF Spaces build)
328
-
329
- Two real build failures happened after this was first deployed, both
330
- worth recording because the diagnosis process matters as much as the fix:
331
-
332
- 1. **First failure**: `scipy` failed `metadata-generation-failed` while
333
- building from source. I initially (and wrongly) assumed my own
334
- `numpy<2.0` pin was the cause and removed it — that didn't fix it.
335
- 2. **Real cause, found from the actual build log**: `uni2ts`'s own
336
- `pyproject.toml` pins `scipy~=1.11.3` — a 2023-era release with no
337
- prebuilt wheel for Python 3.13 (which is what the Space was actually
338
- running, per `/usr/local/lib/python3.13/site-packages` in the log).
339
- pip fell back to compiling `scipy-1.11.4.tar.gz` from source, and that
340
- specific old release's build system doesn't work with the Space's
341
- modern Cython/meson toolchain (`ERROR: Compiler cython cannot compile
342
- programs`) — not a missing system package, a real incompatibility.
343
- 3. **Fix**: pin `python_version: "3.11"` in this README's frontmatter
344
- (a documented HF Spaces config key) so scipy 1.11.x resolves to a
345
- prebuilt wheel instead of a source build. No requirements.txt change
346
- was actually needed — my earlier "fix" there was chasing the wrong
347
- variable, corrected here for the record rather than quietly erased.
348
- 4. **Third failure, at runtime this time**: the build succeeded (scipy
349
- installed as a wheel), but `import gradio` crashed with
350
- `ImportError: cannot import name 'HfFolder' from 'huggingface_hub'`.
351
- `huggingface_hub` 1.0 removed the long-deprecated `HfFolder` class;
352
- `gradio==4.44.0`'s `oauth.py` still imports it unconditionally.
353
- `uni2ts` only floors `huggingface_hub>=0.23.0` with no upper bound,
354
- so pip picked the newest 1.x. Pinned `huggingface_hub<1.0` at the
355
- time as a stopgap.
356
- 5. **Fourth failure**: `import gradio` succeeded, but `demo.launch()`
357
- raised `ValueError: When localhost is not accessible...`, with a
358
- `TypeError: unhashable type: 'dict'` from Jinja2/Starlette template
359
- caching right before it. Root cause: `gradio==4.44.0`'s `routes.py`
360
- called `Jinja2Templates.TemplateResponse(name, context)` the
361
- pre-Starlette-1.0 way; Starlette>=1.0 changed that signature to
362
- `TemplateResponse(request, name, context)`, so the context dict
363
- landed in the `name` slot and Jinja2 tried to hash it as a cache
364
- key. **Real fix**: stopped patching individual symptoms and upgraded
365
- to a currently-maintained Gradio. Confirmed against Gradio's own
366
- changelog (not assumed) that both this Starlette signature fix
367
- *and* "allow gradio to run with huggingface_hub 1.x" had already
368
- landed upstream well before `6.26.0` — so `sdk_version` moved to
369
- `6.26.0`, the `huggingface_hub<1.0` stopgap and a since-tried
370
- `starlette<1.0` pin were both removed as no-longer-needed (and
371
- actively risking a new resolution conflict), and `gradio` in
372
- `requirements.txt` became `>=6.26.0,<7` (floor at the verified
373
- version, ceiling below the next major). Checked the Gradio 6
374
- migration guide's breaking changes against this codebase's actual
375
- API usage (`gr.Blocks(title=...)` only — no `theme`/`css`/`show_api`)
376
- before making the jump; none applied.
377
- 6. **Fifth failure, and the important one**: build and startup both
378
- succeeded, but every single backtest origin failed —
379
- `Origins evaluated: 11200 | Model errors: 11200`. The UI didn't
380
- surface *why* (only a count), which was itself a bug — fixed first,
381
- since you can't diagnose a 100%-failure-rate bug from a number
382
- alone. Root cause (confirmed against GluonTS's actual documentation
383
- and source, not guessed): `moirai_model.py` built the GluonTS
384
- `PandasDataset` without an explicit `freq=`, so it fell back to
385
- `index.freqstr` on the raw timestamp index. A plain pandas
386
- `DatetimeIndex` — which is exactly what `yfinance`'s
387
- `Ticker.history()` returns — has `freqstr == None` unless something
388
- explicitly sets it, regardless of whether the data has gaps. That
389
- makes the fallback fail identically on *every* call, on *any* real
390
- Yahoo data, which is exactly the 100%-of-11200 pattern observed.
391
- **Fix**: `config.TIMEFRAME_TO_PANDAS_FREQ` maps each supported
392
- timeframe to its pandas frequency alias (already known from the
393
- selected timeframe — no inference needed), threaded through
394
- `WalkForwardBacktester(timeframe=...)` and
395
- `MoiraiMoEForecaster.predict(..., freq=...)`. Also added: the
396
- backtest UI now shows up to 3 distinct real error messages (with
397
- origin counts) whenever `Model errors > 0`, instead of only a count
398
- — and `MoiraiMoEForecaster` now builds the GluonTS predictor once
399
- per (context_length, prediction_length, covariate-set) and reuses
400
- it, instead of rebuilding it from scratch on every one of
401
- thousands of origins.
402
- I have not run this against the real checkpoint (no network in this
403
- sandbox) — this is IMPLEMENTED and reasoned from GluonTS's actual
404
- source, not VERIFIED the way this project's non-network logic is.
405
- If it's not fully fixed, the app will now show the real error text
406
- directly, so the next round has something concrete to work from
407
- instead of another guess.
408
-
409
- ## UI improvements (requested, not bug-driven)
410
-
411
- - **Symbol**: both tabs now use a searchable `gr.Dropdown` pre-loaded
412
- with ~44 common Forex/Crypto Yahoo tickers (`config.ALL_SYMBOLS`),
413
- with `allow_custom_value=True` so any other Yahoo-supported symbol
414
- can still be typed — this list is a convenience default, not a
415
- restriction.
416
- - **History window**: replaced the short `period=` dropdown (which was
417
- limited to yfinance's fixed enum: `1d/5d/1mo/3mo/6mo/1y/2y/5y/10y/
418
- ytd/max`) with a 45-option dropdown covering every day count 1-30,
419
- every month count 2-12, plus 1/2/5 years and `max`
420
- (`config.HISTORY_WINDOW_CHOICES`). Since yfinance's `period` string
421
- can't express e.g. "17 days" or "4 months" at all,
422
- `data_layer.resolve_history_window()` converts the selected label
423
- into an actual `start` date (using `dateutil.relativedelta` for
424
- calendar-correct month/year math) and passes that to
425
- `fetch_ohlcv(start=...)` instead of `period=...` for anything other
426
- than "max".
427
-
428
- ## Architecture
429
-
430
- ```
431
- Yahoo Finance (yfinance)
432
- -> validate_ohlcv() [data_layer.py]
433
- -> causal_resample() if needed [data_layer.py]
434
- -> compute_indicators() [features.py] 10 causal indicator families
435
- -> warm_up_valid_from() trim [features.py]
436
- -> audit_feature_frame() [leakage_audit.py] fail loudly on any future row
437
- -> MoiraiMoEForecaster.predict [moirai_model.py] the ONLY model
438
- -> decide() [decision_engine.py] Buy/Hold/Sell, cost-aware
439
- -> WalkForwardBacktester [backtest_engine.py] sequential, look-ahead-safe
440
- -> metrics.py forecast + trading metrics
441
- -> persistence.py SQLite forecast registry, immutable rows
442
- -> app.py Gradio UI (Live tab, Backtest tab)
443
- ```
444
-
445
- ## Deploying for real (to get real VERIFIED results)
446
-
447
- 1. Push this folder to a Hugging Face Space (SDK: Gradio). HF Spaces has
448
- network access, so `pip install -r requirements.txt` will actually
449
- download `uni2ts`/`torch`/the checkpoint.
450
- 2. **Run the real smoke test before trusting anything else** (spec
451
- section 101):
452
- ```bash
453
- python3 -c "
454
- from moirai_model import MoiraiMoEForecaster
455
- import config as cfg
456
- from tests.fixtures import make_synthetic_ohlcv
457
- from features import compute_indicators, warm_up_valid_from
458
-
459
- f = MoiraiMoEForecaster().load()
460
- df = compute_indicators(make_synthetic_ohlcv(n=300))
461
- df = df.iloc[warm_up_valid_from(df):]
462
- fc = f.predict(df, cfg.TARGET_FEATURE,
463
- [c for c in cfg.PAST_DYNAMIC_FEATURES if c in df.columns], 5)
464
- print('REAL forecast shape:', fc.quantiles.shape, 'context used:', fc.context_length_used)
465
- "
466
- ```
467
- (Still synthetic *input* data here — the point of this specific
468
- command is only to prove the real checkpoint loads and runs a real
469
- forward pass. Use the Live tab with a real symbol for a real forecast.)
470
- 3. Run `pytest tests/` (or `python3 run_tests.py`) again in that
471
- environment — everything that passed here should still pass, and
472
- you can now also exercise `fetch_ohlcv` against real Yahoo data.
473
- 4. Try the Live tab with a real symbol (e.g. `EURUSD=X`, `BTC-USD`) and
474
- the Backtest tab over a real historical window before relying on any
475
- output.
476
-
477
- ## Known limitations (honest, per spec section 115)
478
-
479
- - **Fine-tuning (Mode B)**: no UI tab yet. `MoiraiMoEForecaster` doesn't
480
- currently expose a `fine_tune()` method — uni2ts's own fine-tuning CLI
481
- (`project/moirai-moe-1`) is the more reliable path today; wiring a
482
- thin UI around it is the natural next step.
483
- - **Probabilistic scoring**: pinball loss and interval coverage are
484
- implemented; CRPS and weighted interval score are not.
485
- - **Symbol selection**: a curated dropdown covers common Forex/Crypto
486
- tickers with free-text fallback for anything else (see "UI
487
- improvements" above) — still not a live, searchable directory of
488
- every symbol Yahoo lists, since yfinance itself exposes no such
489
- endpoint.
490
- - **Drift monitoring**: not implemented in this build.
491
- - **Forecast persistence**: `persistence.py`'s `Store`/`record_forecast`/
492
- `match_realized_outcome` are real, working code (see the Architecture
493
- diagram above), but `app.py` never calls them — no forecast is
494
- actually written to the registry yet. Noticed during the Aug 2026
495
- bug-fix pass and disclosed rather than silently left out; wiring
496
- `run_live_forecast()` to record each forecast is a reasonable next
497
- step, kept separate from that pass's fixes since it's an integration
498
- gap, not a bug.
499
- - **Fill-price convention**: the backtester fills at next-bar-close by
500
- execution delay, not next-bar-open — documented in
501
- `backtest_engine.py`'s module docstring, and it's a one-line change if
502
- you want the stricter convention.
503
- - **Cost profiles** (`config.COST_PROFILES`) are reasonable defaults,
504
- not measured spread/fee data for any specific broker or exchange —
505
- replace them with real numbers before trusting a backtest's net PnL.
506
- - Every number this build has ever produced comes from either (a) a
507
- real, executed unit test against known values, or (b) synthetic
508
- fixtures used **only** to prove code correctness. **No real trading
509
- or forecast-accuracy claim is made anywhere in this repository.**
510
-
511
- **Forecasts are probabilistic estimates, not guaranteed future prices.
512
- Nothing in this app is financial advice.**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py DELETED
@@ -1,251 +0,0 @@
1
- """
2
- Gradio UI for Hugging Face Spaces (spec sections 49-53). Wires together
3
- data_layer -> features -> moirai_model -> decision_engine -> backtest_engine.
4
- Errors are surfaced with their real cause (section 98), never hidden
5
- behind a generic "something went wrong".
6
- """
7
- from __future__ import annotations
8
-
9
- import traceback
10
-
11
- import gradio as gr
12
- import pandas as pd
13
-
14
- import config as cfg
15
- from backtest_engine import InsufficientDataError, WalkForwardBacktester
16
- from data_layer import (
17
- DataSourceError, causal_resample, data_freshness, fetch_ohlcv,
18
- resolve_history_window, validate_ohlcv,
19
- )
20
- from decision_engine import decide
21
- from features import compute_indicators, warm_up_valid_from
22
- from metrics import trading_summary
23
- from moirai_model import ModelInferenceError, ModelInputError, ModelLoadError, MoiraiMoEForecaster
24
-
25
- _forecaster = MoiraiMoEForecaster()
26
- _model_status = {"loaded": False, "error": None}
27
-
28
-
29
- def _ensure_model() -> bool:
30
- if _model_status["loaded"]:
31
- return True
32
- try:
33
- _forecaster.load()
34
- _model_status["loaded"] = True
35
- return True
36
- except ModelLoadError as e:
37
- _model_status["error"] = str(e)
38
- return False
39
-
40
-
41
- def _get_ohlcv(symbol: str, requested_tf: str, history_label: str):
42
- """Returns (df, native_or_derived, source_tf, validation_report)."""
43
- kind, value = resolve_history_window(history_label)
44
- period = value if kind == "period" else None
45
- start = value if kind == "start" else None
46
-
47
- if requested_tf in cfg.DERIVED_MAP:
48
- source_tf, _ = cfg.DERIVED_MAP[requested_tf]
49
- raw = fetch_ohlcv(symbol, source_tf, start=start, period=period)
50
- clean, report = validate_ohlcv(raw)
51
- derived = causal_resample(clean, source_tf, requested_tf)
52
- return derived, "derived", source_tf, report
53
- raw = fetch_ohlcv(symbol, requested_tf, start=start, period=period)
54
- clean, report = validate_ohlcv(raw)
55
- return clean, "native", requested_tf, report
56
-
57
-
58
- def run_live_forecast(symbol, timeframe, horizon, history_label):
59
- if not _ensure_model():
60
- return f"**MODEL ERROR**\n\n{_model_status['error']}", None, None, None
61
-
62
- try:
63
- df, kind, source_tf, val_report = _get_ohlcv(symbol, timeframe, history_label)
64
- except DataSourceError as e:
65
- return f"**SOURCE UNAVAILABLE**\n\n{e}", None, None, None
66
- except Exception as e:
67
- return f"**DATA ERROR**\n\n{e}", None, None, None
68
-
69
- feat = compute_indicators(df)
70
- try:
71
- warm = warm_up_valid_from(feat)
72
- except ValueError as e:
73
- return f"**INSUFFICIENT HISTORY**\n\n{e}", None, None, None
74
- feat = feat.iloc[warm:]
75
-
76
- fresh = data_freshness(feat.index[-1], timeframe)
77
-
78
- try:
79
- forecast = _forecaster.predict(
80
- context_df=feat, target_col=cfg.TARGET_FEATURE,
81
- past_covariate_cols=[c for c in cfg.PAST_DYNAMIC_FEATURES if c in feat.columns],
82
- prediction_length=int(horizon), freq=cfg.TIMEFRAME_TO_PANDAS_FREQ[timeframe],
83
- )
84
- except (ModelInputError, ModelInferenceError) as e:
85
- return f"**MODEL ERROR**\n\n{e}", df.tail(200), val_report.__dict__, fresh
86
-
87
- price_now = float(feat["close"].iloc[-1])
88
- # Unlike the Backtest tab (which has an explicit asset-class radio),
89
- # the Live tab only has a symbol -- infer forex vs. crypto from it so
90
- # decide() gets a real, asset-appropriate cost estimate instead of
91
- # silently falling back to DecisionConfig's flat generic default
92
- # (previously every Live symbol used the same cost regardless of
93
- # whether it was forex or crypto, cf. WalkForwardBacktester which
94
- # always has a real cost_estimate_frac -- see config.round_trip_cost_frac).
95
- asset_class = cfg.infer_asset_class(symbol)
96
- cost_frac = cfg.round_trip_cost_frac(cfg.COST_PROFILES[asset_class])
97
- decision = decide(forecast, price_now, cost_estimate_frac=cost_frac)
98
- duration_min = cfg.TIMEFRAME_MINUTES[timeframe] * int(horizon)
99
-
100
- summary = (
101
- f"### {symbol} @ {timeframe} _( {kind} from {source_tf} )_\n\n"
102
- f"**Data:** {fresh['status'].upper()} — last bar `{fresh['latest_market_ts']}`\n\n"
103
- f"**Horizon:** {horizon} bars ≈ {duration_min} min\n\n"
104
- f"**Cost profile:** {asset_class} (inferred from symbol) — round-trip ≈ {cost_frac:.3%}\n\n"
105
- f"**Last close:** {price_now:.5f}\n\n"
106
- f"**Forecast return (P50):** {decision.expected_return:+.4%} "
107
- f"&nbsp;&nbsp;[P10 {decision.p10_return:+.4%}, P90 {decision.p90_return:+.4%}]\n\n"
108
- f"### Decision: {decision.action}\n\n{decision.rationale}\n\n"
109
- f"_Forecasts are probabilistic estimates, not guaranteed future prices. "
110
- f"Not financial advice._"
111
- )
112
- return summary, df.tail(200), val_report.__dict__, fresh
113
-
114
-
115
- def run_backtest(symbol, timeframe, horizon, history_label, asset_class, context_length=256,
116
- stop_loss_pct=0.0, take_profit_pct=0.0):
117
- if not _ensure_model():
118
- return f"**MODEL ERROR**\n\n{_model_status['error']}", None
119
-
120
- try:
121
- df, kind, source_tf, val_report = _get_ohlcv(symbol, timeframe, history_label)
122
- except DataSourceError as e:
123
- return f"**SOURCE UNAVAILABLE**\n\n{e}", None
124
-
125
- feat = compute_indicators(df)
126
- # 0 on either slider means "disabled" (a UI sentinel, not a real 0%
127
- # stop) -- keeps the default identical to every pre-existing backtest
128
- # unless someone explicitly opens Advanced and picks a value.
129
- risk = cfg.RiskManagement(
130
- stop_loss_frac=(stop_loss_pct / 100.0) or None,
131
- take_profit_frac=(take_profit_pct / 100.0) or None,
132
- )
133
- bt = WalkForwardBacktester(_forecaster, timeframe=timeframe, asset_class=asset_class,
134
- horizon=int(horizon), context_length=int(context_length), risk=risk)
135
- try:
136
- result = bt.run(feat)
137
- except (ValueError, InsufficientDataError) as e:
138
- # warm_up_valid_from (ValueError) or the context-length/origin check
139
- # (InsufficientDataError) -- both mean "your History window /
140
- # context length don't have enough bars for this configuration",
141
- # a real and common condition, not an unexpected code bug. The
142
- # message is already specific and actionable (bars available vs.
143
- # needed), so show it cleanly instead of a scary raw traceback --
144
- # matching how the Live tab already handles the same ValueError
145
- # from warm_up_valid_from above.
146
- return f"**INSUFFICIENT HISTORY**\n\n{e}", None
147
- except Exception as e:
148
- return f"**BACKTEST ERROR**\n\n{e}\n\n```\n{traceback.format_exc(limit=3)}\n```", None
149
-
150
- trades_df = pd.DataFrame([t.__dict__ for t in result["trades"]])
151
- summary = trading_summary(trades_df, result["equity_curve"], cfg.TIMEFRAME_MINUTES[timeframe])
152
-
153
- lines = [
154
- f"### {symbol} @ {timeframe} ({kind} from {source_tf}) — walk-forward backtest",
155
- f"Origins evaluated: {result['num_origins']} | Model errors: {len(result['errors'])}",
156
- "",
157
- ]
158
- for k, v in summary.items():
159
- lines.append(f"- **{k}**: {v}")
160
-
161
- if risk.stop_loss_frac or risk.take_profit_frac:
162
- counts = trades_df["exit_reason"].value_counts().to_dict() if not trades_df.empty else {}
163
- lines.append(
164
- f"- **exit reasons**: horizon={counts.get('horizon', 0)}, "
165
- f"stop_loss={counts.get('stop_loss', 0)}, take_profit={counts.get('take_profit', 0)}"
166
- )
167
-
168
- if result["errors"]:
169
- # Every origin failing the same way means a real, systematic bug --
170
- # showing it is far more useful than a bare count (section 98:
171
- # errors must say what failed, not just that something did).
172
- unique_msgs = {}
173
- for err in result["errors"]:
174
- unique_msgs.setdefault(err["error"], []).append(err["origin_ts"])
175
- lines.append("\n### Model error detail (first 3 distinct messages)")
176
- for msg, origins in list(unique_msgs.items())[:3]:
177
- lines.append(f"- `{msg}` — seen at {len(origins)} origin(s), e.g. `{origins[0]}`")
178
-
179
- return "\n".join(lines), trades_df
180
-
181
-
182
- with gr.Blocks(title="Moirai-MoE Forex & Crypto Forecaster") as demo:
183
- gr.Markdown(
184
- "# Moirai-MoE-1.0-R-Small — Forex & Crypto Forecasting\n"
185
- f"Sole predictive model: `{cfg.MODEL_CHECKPOINT}`. No fallback model is used. "
186
- "Forecasts are probabilistic estimates, not guaranteed future prices — not financial advice."
187
- )
188
-
189
- with gr.Tab("Live Forecast"):
190
- with gr.Row():
191
- symbol_in = gr.Dropdown(choices=cfg.ALL_SYMBOLS, value="EURUSD=X",
192
- allow_custom_value=True,
193
- label="Symbol (pick one, or type any Yahoo ticker)")
194
- tf_in = gr.Dropdown(choices=cfg.ALL_TIMEFRAMES, value="1h", label="Timeframe")
195
- horizon_in = gr.Slider(1, 20, value=5, step=1, label="Horizon (bars)")
196
- period_in = gr.Dropdown(cfg.HISTORY_WINDOW_CHOICES, value="30 days", label="History window")
197
- run_btn = gr.Button("Run Forecast", variant="primary")
198
- out_md = gr.Markdown()
199
- out_table = gr.Dataframe(label="Recent data")
200
- with gr.Accordion("Data validation & freshness", open=False):
201
- out_val = gr.JSON(label="Validation report")
202
- out_fresh = gr.JSON(label="Freshness")
203
- run_btn.click(run_live_forecast, [symbol_in, tf_in, horizon_in, period_in],
204
- [out_md, out_table, out_val, out_fresh])
205
-
206
- with gr.Tab("Walk-Forward Backtest"):
207
- with gr.Row():
208
- b_symbol = gr.Dropdown(choices=cfg.ALL_SYMBOLS, value="BTC-USD",
209
- allow_custom_value=True,
210
- label="Symbol (pick one, or type any Yahoo ticker)")
211
- b_tf = gr.Dropdown(choices=cfg.ALL_TIMEFRAMES, value="1d", label="Timeframe")
212
- b_horizon = gr.Slider(1, 20, value=5, step=1, label="Horizon (bars)")
213
- b_period = gr.Dropdown(cfg.HISTORY_WINDOW_CHOICES, value="1 year", label="History window")
214
- b_class = gr.Radio(["forex", "crypto"], value="crypto", label="Asset class (cost profile)")
215
- with gr.Accordion("Advanced (model context & risk management)", open=False):
216
- gr.Markdown(
217
- "**Model context**: bars of lookback the model sees at each "
218
- "backtest origin, on top of indicator warm-up. A short "
219
- "History window needs a smaller context length here, or "
220
- "the backtest has no room left for even one origin — the "
221
- "error message will say exactly how many bars are missing "
222
- "either way.\n\n"
223
- "**Risk management**: 0 = off (the original behavior — "
224
- "every trade holds the full horizon no matter what "
225
- "happens in between). A stop-loss/take-profit is checked "
226
- "against each bar's real intrabar high/low between entry "
227
- "and the horizon exit, and closes the trade early — at "
228
- "that bar's close, not the exact stop level — the first "
229
- "time either is crossed."
230
- )
231
- b_context = gr.Slider(32, 512, value=256, step=32, label="Model context length (bars)")
232
- b_stop = gr.Slider(0, 3.0, value=0, step=0.05, label="Stop-loss (%, 0 = off)")
233
- b_tp = gr.Slider(0, 3.0, value=0, step=0.05, label="Take-profit (%, 0 = off)")
234
- b_btn = gr.Button("Run Backtest", variant="primary")
235
- b_out = gr.Markdown()
236
- b_trades = gr.Dataframe(label="Trade ledger")
237
- b_btn.click(run_backtest, [b_symbol, b_tf, b_horizon, b_period, b_class, b_context, b_stop, b_tp],
238
- [b_out, b_trades])
239
-
240
- gr.Markdown(
241
- "---\n**Modes implemented in this build:** Live inference (Mode D) and walk-forward "
242
- "historical evaluation with trade simulation (Mode C). Fine-tuning (Mode B) has real "
243
- "training-loop code in `moirai_model.py` but no UI tab yet in this build — see README "
244
- "limitations."
245
- )
246
-
247
- if __name__ == "__main__":
248
- # Gradio 6.26 on HF Spaces appears to auto-detect the container
249
- # environment even without this, but binding explicitly is cheap
250
- # insurance against any platform that doesn't auto-detect it.
251
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backtest_engine.py DELETED
@@ -1,235 +0,0 @@
1
- """
2
- Walk-forward, look-ahead-safe backtest engine (spec sections 28-30,
3
- 53-54, 92). Decisions at time t only ever see data <= t; future closes
4
- are used strictly afterward, only to score the already-fixed decision
5
- (section 53: "Only AFTER execution/decision is fixed may future data be
6
- used to calculate realized PnL").
7
-
8
- Fill-price convention (documented per section 25's "all assumptions
9
- must be explicit, configurable, and auditable"): entry fills at the
10
- CLOSE of the bar `execution_delay_bars` after the decision origin; exit
11
- fills at the close `horizon` bars after that. Swap for next-bar-OPEN if
12
- you want a stricter convention — it's one line, marked below.
13
-
14
- Single-position convention: this simulates ONE account with no margin
15
- or leverage model, so a new trade is never opened while a previously
16
- opened trade hasn't exited yet (decide() has no notion of position
17
- state, so this is enforced here). Without this, a fresh full-notional
18
- trade would be opened at every origin regardless of what's still open
19
- -- during a run of same-direction signals that's several positions
20
- stacked on the same equity curve at once, which silently multiplies
21
- exposure the cost model and risk metrics know nothing about.
22
- """
23
- from __future__ import annotations
24
-
25
- from dataclasses import dataclass
26
- from typing import List, Optional
27
-
28
- import pandas as pd
29
-
30
- import config as cfg
31
- from decision_engine import Decision, decide
32
- from features import warm_up_valid_from
33
- from leakage_audit import LeakageError, audit_feature_frame
34
-
35
-
36
- class InsufficientDataError(Exception):
37
- """Raised when the feature frame has fewer bars than this backtest
38
- configuration needs for even a single origin -> exit sequence.
39
- Distinct from the ValueError warm_up_valid_from raises (which means
40
- indicator warm-up is impossible at all): this means warm-up succeeded
41
- but there isn't enough data left over for context_length plus at
42
- least one tradeable origin. Previously this case wasn't checked for
43
- at all -- feature_df.index[start_idx] would raise a bare, confusing
44
- IndexError once start_idx ran past the end of the frame. Checking
45
- explicitly means the message can say what failed, not just that
46
- something did (spec section 98)."""
47
-
48
-
49
- @dataclass
50
- class Trade:
51
- decision_ts: pd.Timestamp
52
- entry_ts: pd.Timestamp
53
- exit_ts: pd.Timestamp
54
- action: str
55
- entry_price: float
56
- exit_price: float
57
- forecast_return: float
58
- gross_pnl: float
59
- net_pnl: float
60
- costs: float
61
- exit_reason: str = "horizon" # "horizon" | "stop_loss" | "take_profit"
62
- prob_favorable: float = float("nan") # decision.prob_favorable at open, for post-hoc entry-filter analysis
63
- interval_width_pct: float = float("nan") # decision.interval_width_pct at open, same reason
64
-
65
-
66
- def _scan_for_early_exit(feature_df: pd.DataFrame, exec_idx: int, horizon_exit_idx: int,
67
- action: str, entry_price: float, risk: Optional[cfg.RiskManagement]) -> tuple:
68
- """First bar strictly after entry whose high/low breaches a stop-loss
69
- or take-profit level, or horizon_exit_idx if neither ever triggers
70
- (or risk management is off). Returns (exit_idx, exit_reason).
71
-
72
- The exit PRICE is still that bar's CLOSE, not the stop/TP level
73
- itself -- consistent with the rest of this engine's documented
74
- close-only fill convention (module docstring), and because intrabar
75
- high/low only tells us a threshold was crossed, not what price you'd
76
- actually have been filled at; inventing an exact fill price from
77
- that would be manufacturing data we don't have (spec section 88).
78
- This is conservative in both directions: a stop may report a
79
- smaller loss than a real gap-through would produce, and a
80
- take-profit may report less gain than the intrabar peak. If both
81
- levels are breached within the same bar, we can't tell from OHLC
82
- alone which was touched first, so this assumes the stop -- the
83
- conservative read when direction of causation is unknown."""
84
- if risk is None or (risk.stop_loss_frac is None and risk.take_profit_frac is None):
85
- return horizon_exit_idx, "horizon"
86
-
87
- if action == "BUY":
88
- stop_level = entry_price * (1 - risk.stop_loss_frac) if risk.stop_loss_frac else None
89
- tp_level = entry_price * (1 + risk.take_profit_frac) if risk.take_profit_frac else None
90
- else:
91
- stop_level = entry_price * (1 + risk.stop_loss_frac) if risk.stop_loss_frac else None
92
- tp_level = entry_price * (1 - risk.take_profit_frac) if risk.take_profit_frac else None
93
-
94
- for j in range(exec_idx + 1, horizon_exit_idx + 1):
95
- lo = float(feature_df["low"].iloc[j])
96
- hi = float(feature_df["high"].iloc[j])
97
- hit_stop = stop_level is not None and (
98
- (action == "BUY" and lo <= stop_level) or (action == "SELL" and hi >= stop_level)
99
- )
100
- hit_tp = tp_level is not None and (
101
- (action == "BUY" and hi >= tp_level) or (action == "SELL" and lo <= tp_level)
102
- )
103
- if hit_stop:
104
- return j, "stop_loss"
105
- if hit_tp:
106
- return j, "take_profit"
107
- return horizon_exit_idx, "horizon"
108
-
109
-
110
- class WalkForwardBacktester:
111
- """`forecaster` must expose .predict(context_df, target_col,
112
- past_covariate_cols, prediction_length, freq) -> an object with
113
- .quantiles / .quantile_levels (see moirai_model.QuantileForecast).
114
- In production this is always a MoiraiMoEForecaster; tests may inject
115
- a deterministic stub to verify the pipeline's causal behavior —
116
- that stub is never imported by app.py (see tests/fixtures.py)."""
117
-
118
- def __init__(self, forecaster, timeframe: str, asset_class: str = "forex",
119
- horizon: int = 5, context_length: int = 256,
120
- decision_cfg: Optional[cfg.DecisionConfig] = None,
121
- risk: Optional[cfg.RiskManagement] = None):
122
- self.forecaster = forecaster
123
- self.timeframe = timeframe
124
- self.freq = cfg.TIMEFRAME_TO_PANDAS_FREQ[timeframe]
125
- self.cost_profile = cfg.COST_PROFILES[asset_class]
126
- self.horizon = horizon
127
- self.context_length = context_length
128
- self.decision_cfg = decision_cfg or cfg.DecisionConfig()
129
- self.risk = risk # None => no early exit, identical to every pre-existing backtest
130
-
131
- def run(self, feature_df: pd.DataFrame, start_idx: Optional[int] = None, step: int = 1) -> dict:
132
- cost_frac = cfg.round_trip_cost_frac(self.cost_profile)
133
- warm = warm_up_valid_from(feature_df) # raises ValueError if warm-up is impossible at all
134
-
135
- n = len(feature_df)
136
- delay = self.cost_profile.execution_delay_bars
137
- min_start = max(start_idx or 0, warm, warm + self.context_length)
138
- last_origin = n - self.horizon - delay - 1
139
- if min_start > last_origin:
140
- bars_needed = min_start + self.horizon + delay + 1
141
- raise InsufficientDataError(
142
- f"Not enough data for a single backtest origin: {n} bar(s) "
143
- f"available, but this configuration needs at least "
144
- f"{bars_needed} (warm-up {warm} + context_length "
145
- f"{self.context_length} + horizon {self.horizon} + "
146
- f"execution_delay {delay} + 1). Widen the History window "
147
- f"by at least {bars_needed - n} bar(s), or reduce context "
148
- f"length / horizon."
149
- )
150
- start_idx = min_start
151
-
152
- trades: List[Trade] = []
153
- decisions_log: List[dict] = []
154
- equity_vals = [1.0]
155
- equity_ts = [feature_df.index[start_idx]]
156
- errors: List[dict] = []
157
- n_origins = 0
158
- open_until_idx = -1 # bar index the currently-open trade exits at; -1 == flat
159
-
160
- for i in range(start_idx, max(start_idx, last_origin), step):
161
- n_origins += 1
162
- origin_ts = feature_df.index[i]
163
- context = feature_df.iloc[i - self.context_length + 1: i + 1]
164
-
165
- audit = audit_feature_frame(context, origin_ts)
166
- if not audit["passed"]:
167
- # Fail loudly rather than silently dropping the bad origin
168
- # (spec section 26).
169
- raise LeakageError(f"Backtest halted at {origin_ts}: {audit['violations']}")
170
-
171
- try:
172
- forecast = self.forecaster.predict(
173
- context_df=context, target_col=cfg.TARGET_FEATURE,
174
- past_covariate_cols=[c for c in cfg.PAST_DYNAMIC_FEATURES if c in context.columns],
175
- prediction_length=self.horizon, freq=self.freq,
176
- )
177
- except Exception as e:
178
- errors.append({"origin_ts": str(origin_ts), "error": str(e)})
179
- continue
180
-
181
- price_now = float(feature_df["close"].iloc[i])
182
- decision: Decision = decide(forecast, price_now, self.decision_cfg, cost_estimate_frac=cost_frac)
183
- decisions_log.append({"origin_ts": origin_ts, "action": decision.action})
184
-
185
- exec_idx = i + delay
186
- exit_idx = i + delay + self.horizon
187
- if exit_idx >= n:
188
- continue
189
-
190
- if i < open_until_idx:
191
- # Still holding a position opened at an earlier origin --
192
- # don't stack a second full-notional trade on top of it
193
- # (see "Single-position convention" in the module
194
- # docstring). The already-open trade's own exit will
195
- # still post its P&L to the equity curve when its turn
196
- # comes; this origin just isn't tradable right now.
197
- continue
198
-
199
- entry_price = float(feature_df["close"].iloc[exec_idx]) # fill convention, see module docstring
200
-
201
- if decision.action == "HOLD":
202
- equity_vals.append(equity_vals[-1])
203
- equity_ts.append(feature_df.index[exit_idx])
204
- continue
205
-
206
- actual_exit_idx, exit_reason = _scan_for_early_exit(
207
- feature_df, exec_idx, exit_idx, decision.action, entry_price, self.risk
208
- )
209
- exit_price = float(feature_df["close"].iloc[actual_exit_idx]) # future data — used only to SCORE, decision already fixed
210
-
211
- if decision.action == "BUY":
212
- gross = exit_price / entry_price - 1.0
213
- else: # SELL
214
- gross = entry_price / exit_price - 1.0
215
- net = gross - cost_frac
216
-
217
- trades.append(Trade(
218
- decision_ts=origin_ts, entry_ts=feature_df.index[exec_idx],
219
- exit_ts=feature_df.index[actual_exit_idx], action=decision.action,
220
- entry_price=entry_price, exit_price=exit_price,
221
- forecast_return=decision.expected_return, gross_pnl=gross, net_pnl=net, costs=cost_frac,
222
- exit_reason=exit_reason, prob_favorable=decision.prob_favorable,
223
- interval_width_pct=decision.interval_width_pct,
224
- ))
225
- equity_vals.append(equity_vals[-1] * (1 + net))
226
- equity_ts.append(feature_df.index[actual_exit_idx])
227
- open_until_idx = actual_exit_idx
228
-
229
- return {
230
- "trades": trades,
231
- "decisions_log": decisions_log,
232
- "equity_curve": pd.Series(equity_vals, index=pd.DatetimeIndex(equity_ts)),
233
- "num_origins": n_origins,
234
- "errors": errors,
235
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py DELETED
@@ -1,196 +0,0 @@
1
- """
2
- Central configuration for the Moirai-MoE Forex/Crypto forecasting system.
3
- All thresholds, feature schema, and resource limits live here so they are
4
- auditable and change in one place (spec sections 19-20, 30, 42, 85, 111).
5
- """
6
- from dataclasses import dataclass
7
- from typing import Optional
8
-
9
- # --- The sole predictive model (spec section 3) -----------------------
10
- MODEL_CHECKPOINT = "Salesforce/moirai-moe-1.0-R-small"
11
-
12
- # --- Timeframes ---------------------------------------------------------
13
- # native = directly retrievable from yfinance
14
- # derived = built via causal resampling from a finer native timeframe (section 10-11)
15
- NATIVE_INTRADAY = ["1m", "2m", "5m", "15m", "30m", "1h"]
16
- NATIVE_OTHER = ["1d", "1wk", "1mo"]
17
- DERIVED_MAP = {
18
- # target_tf: (source_tf, bars_per_bin)
19
- "10m": ("5m", 2),
20
- "2h": ("1h", 2),
21
- "4h": ("1h", 4),
22
- }
23
- ALL_TIMEFRAMES = NATIVE_INTRADAY + list(DERIVED_MAP.keys()) + NATIVE_OTHER
24
-
25
- TIMEFRAME_MINUTES = {
26
- "1m": 1, "2m": 2, "5m": 5, "10m": 10, "15m": 15, "30m": 30,
27
- "1h": 60, "2h": 120, "4h": 240, "1d": 1440, "1wk": 10080, "1mo": 43200,
28
- }
29
-
30
- # Approximate Yahoo intraday lookback ceilings in days (source-enforced,
31
- # see spec section 8; yfinance will simply return less data than
32
- # requested rather than erroring, so we surface this explicitly)
33
- YAHOO_INTRADAY_MAX_DAYS = {
34
- "1m": 7, "2m": 60, "5m": 60, "15m": 60, "30m": 60, "1h": 730,
35
- }
36
-
37
- # Pandas frequency alias for each timeframe. This is passed EXPLICITLY to
38
- # gluonts' PandasDataset(freq=...) in moirai_model.py rather than relying
39
- # on pd.infer_freq() on the raw timestamps -- real Yahoo data has gaps
40
- # (weekends, exchange closures, occasional missing bars) that break
41
- # automatic frequency inference deterministically, on every call, which
42
- # is exactly the "Model errors: 11200/11200" failure mode this fixes.
43
- TIMEFRAME_TO_PANDAS_FREQ = {
44
- "1m": "1min", "2m": "2min", "5m": "5min", "10m": "10min",
45
- "15m": "15min", "30m": "30min",
46
- "1h": "1h", "2h": "2h", "4h": "4h",
47
- "1d": "1D", "1wk": "1W", "1mo": "1MS",
48
- }
49
-
50
- # --- History window choices (UI-facing) --------------------------------
51
- # Fine-grained day counts plus month counts, so the person can pick from
52
- # a dropdown instead of typing a period string. data_layer.resolve_history_window()
53
- # turns a label from this list into an actual start date (or "max").
54
- HISTORY_WINDOW_CHOICES = (
55
- [f"{d} day" if d == 1 else f"{d} days" for d in range(1, 31)]
56
- + [f"{m} months" for m in range(2, 13)]
57
- + ["1 year", "2 years", "5 years", "max"]
58
- )
59
-
60
- # --- Symbol choices (UI-facing) -----------------------------------------
61
- # A curated, non-exhaustive list of common Yahoo Finance Forex/Crypto
62
- # tickers for convenient selection (spec section 12 wants search/
63
- # autocomplete "when feasible" -- yfinance itself exposes no symbol
64
- # directory, so this is that best-effort list). The UI Dropdown also
65
- # allows a custom value, so any Yahoo-supported symbol still works even
66
- # if it isn't in this list -- this list is a convenience, not a limit.
67
- FOREX_SYMBOLS = [
68
- "EURUSD=X", "GBPUSD=X", "USDJPY=X", "USDCHF=X", "AUDUSD=X",
69
- "USDCAD=X", "NZDUSD=X", "EURGBP=X", "EURJPY=X", "GBPJPY=X",
70
- "EURCHF=X", "AUDJPY=X", "EURAUD=X", "CHFJPY=X", "GBPCHF=X",
71
- "USDCNY=X", "USDINR=X", "USDMXN=X", "USDZAR=X", "USDTRY=X",
72
- "USDSEK=X", "USDNOK=X", "USDSGD=X", "USDHKD=X",
73
- ]
74
- CRYPTO_SYMBOLS = [
75
- "BTC-USD", "ETH-USD", "BNB-USD", "XRP-USD", "SOL-USD",
76
- "ADA-USD", "DOGE-USD", "AVAX-USD", "DOT-USD", "LTC-USD",
77
- "LINK-USD", "MATIC-USD", "TRX-USD", "SHIB-USD", "BCH-USD",
78
- "XLM-USD", "ATOM-USD", "UNI-USD", "ETC-USD", "XMR-USD",
79
- ]
80
- ALL_SYMBOLS = FOREX_SYMBOLS + CRYPTO_SYMBOLS
81
-
82
-
83
- @dataclass
84
- class IndicatorConfig:
85
- rsi_period: int = 14
86
- macd_fast: int = 12
87
- macd_slow: int = 26
88
- macd_signal: int = 9
89
- bb_period: int = 20
90
- bb_std: float = 2.0
91
- sma_periods: tuple = (20, 50)
92
- ema_periods: tuple = (12, 26)
93
- stoch_k: int = 14
94
- stoch_d: int = 3
95
- atr_period: int = 14
96
- adx_period: int = 14
97
- roc_period: int = 12
98
-
99
-
100
- # Deterministic feature schema — order matters and is persisted with every
101
- # forecast/model artifact (spec section 85). Never silently reordered.
102
- PAST_DYNAMIC_FEATURES = [
103
- "open", "high", "low", "volume",
104
- "rsi",
105
- "macd", "macd_signal", "macd_hist",
106
- "bb_mid", "bb_upper", "bb_lower",
107
- "sma_20", "sma_50",
108
- "ema_12", "ema_26",
109
- "stoch_k", "stoch_d",
110
- "atr", "adx", "roc", "obv",
111
- ]
112
- TARGET_FEATURE = "close"
113
-
114
-
115
- @dataclass
116
- class DecisionConfig:
117
- cost_bps: float = 5.0 # default round-trip cost estimate, basis points
118
- buy_threshold: float = 0.0015 # expected edge required to BUY, post-cost
119
- sell_threshold: float = -0.0015
120
- min_probability: float = 0.55 # P(return favorable) required to act
121
- max_interval_width_pct: float = 0.05 # HOLD if forecast too uncertain (P90-P10)
122
-
123
-
124
- @dataclass
125
- class BacktestCostProfile:
126
- spread_bps: float
127
- commission_bps: float
128
- slippage_bps: float
129
- execution_delay_bars: int = 1
130
-
131
-
132
- @dataclass
133
- class RiskManagement:
134
- """Optional intra-trade exit rules, checked against intrabar high/low
135
- between entry and the horizon exit (see backtest_engine.py). Added
136
- Aug 2026 after real backtest data showed the previously-only exit
137
- rule -- always hold the full fixed horizon, no matter what happens
138
- in between -- was exactly why losing trades ran larger than winning
139
- ones: a trade that turns adverse right after entry still rides out
140
- the entire horizon before closing, with no way to cut it short.
141
- None (the default for both fields) means "no early exit at all",
142
- which reproduces every prior backtest's numbers exactly -- this is
143
- opt-in, not a change to existing behavior."""
144
- stop_loss_frac: Optional[float] = None # e.g. 0.003 = exit if price moves 0.3% against the position
145
- take_profit_frac: Optional[float] = None # e.g. 0.006 = exit if price moves 0.6% in favor of the position
146
-
147
-
148
- # Forex and crypto have different microstructure -> different cost profiles
149
- # (spec section 30). These are reasonable defaults, not measured values —
150
- # override them per-symbol if you have real spread/fee data.
151
- COST_PROFILES = {
152
- "forex": BacktestCostProfile(spread_bps=1.5, commission_bps=0.0, slippage_bps=0.5),
153
- "crypto": BacktestCostProfile(spread_bps=2.0, commission_bps=10.0, slippage_bps=2.0),
154
- }
155
-
156
-
157
- def round_trip_cost_frac(profile: BacktestCostProfile) -> float:
158
- """Round-trip cost (spread+commission+slippage) as a return fraction.
159
- Lives here (not in backtest_engine.py) because it's a pure function of
160
- a COST_PROFILES entry, and both the backtest engine and the Live tab's
161
- decide() call need the same, real, asset-class-specific number rather
162
- than each guessing independently (spec section 30)."""
163
- return (profile.spread_bps + profile.commission_bps + profile.slippage_bps) / 10_000
164
-
165
-
166
- def infer_asset_class(symbol: str) -> str:
167
- """Best-effort classification of a Yahoo ticker into a COST_PROFILES
168
- key. The Live tab has no explicit asset-class selector (unlike the
169
- Backtest tab's `b_class` radio), so without this it silently fell back
170
- to a flat, asset-unaware cost estimate for every symbol -- wrong for
171
- both forex (too high) and crypto (too low). Checks the curated lists
172
- first, then falls back to Yahoo's own ticker conventions ('=X' suffix
173
- for forex pairs, a '-USD'/'-USDT'/etc. suffix for crypto pairs) for any
174
- other symbol typed via allow_custom_value, since those conventions are
175
- Yahoo's, not a guess of ours. Defaults to 'forex' (the cheaper, more
176
- conservative profile) only if nothing matches."""
177
- if symbol in FOREX_SYMBOLS:
178
- return "forex"
179
- if symbol in CRYPTO_SYMBOLS:
180
- return "crypto"
181
- s = symbol.strip().upper()
182
- if s.endswith("=X"):
183
- return "forex"
184
- quote = s.split("-")[-1] if "-" in s else ""
185
- if quote in ("USD", "USDT", "USDC", "BTC", "ETH"):
186
- return "crypto"
187
- return "forex"
188
-
189
-
190
- # Context length ceiling — validate against the ACTUAL loaded checkpoint's
191
- # max_seq_len at runtime (moirai_model.py does this); this is a
192
- # conservative fallback default, not an assumption of unlimited context
193
- # (spec section 86).
194
- MODEL_MAX_CONTEXT_DEFAULT = 512
195
-
196
- RESOURCE_LIMITS = dict(cpu_cores=2, ram_gb=16, disk_gb=50, max_forecast_samples=100, max_workers=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data_layer.py DELETED
@@ -1,254 +0,0 @@
1
- """
2
- Data ingestion, validation, causal resampling, freshness, and local
3
- caching for Yahoo Finance OHLCV data (spec sections 7-14, 55-56, 81, 105).
4
-
5
- NETWORK NOTE: fetch_ohlcv() makes a real yfinance HTTP call. In a
6
- network-isolated environment this raises DataSourceError with the
7
- underlying exception attached — that is the correct, honest failure
8
- mode (spec section 4: "expose the exact failure ... do not fabricate
9
- a result"), not a bug to work around with mock data.
10
- """
11
- from __future__ import annotations
12
-
13
- import hashlib
14
- import io
15
- import sqlite3
16
- from dataclasses import dataclass
17
- from datetime import datetime, timezone
18
- from pathlib import Path
19
- from typing import Optional
20
-
21
- import numpy as np
22
- import pandas as pd
23
-
24
- import config as cfg
25
-
26
-
27
- class DataSourceError(Exception):
28
- pass
29
-
30
-
31
- class DataValidationError(Exception):
32
- pass
33
-
34
-
35
- @dataclass
36
- class ValidationReport:
37
- rows_in: int
38
- rows_out: int
39
- duplicates_removed: int
40
- invalid_ohlc_removed: int
41
- nan_rows_removed: int
42
- timezone: str
43
- monotonic: bool
44
- warnings: list
45
-
46
-
47
- def fetch_ohlcv(symbol: str, interval: str, start=None, end=None,
48
- period: Optional[str] = None) -> pd.DataFrame:
49
- """Real yfinance retrieval of a NATIVE Yahoo interval. Derived
50
- timeframes (e.g. 10m) must be built with causal_resample() from
51
- their configured source interval — this function refuses to guess."""
52
- try:
53
- import yfinance as yf
54
- except ImportError as e:
55
- raise DataSourceError(f"yfinance is not installed: {e}") from e
56
-
57
- if interval not in cfg.NATIVE_INTRADAY + cfg.NATIVE_OTHER:
58
- raise DataSourceError(
59
- f"'{interval}' is not a native Yahoo interval. Fetch "
60
- f"{cfg.DERIVED_MAP.get(interval, ('<source>',))[0]} and call "
61
- f"causal_resample() instead (spec section 19)."
62
- )
63
-
64
- max_days = cfg.YAHOO_INTRADAY_MAX_DAYS.get(interval)
65
- try:
66
- ticker = yf.Ticker(symbol)
67
- if period:
68
- df = ticker.history(period=period, interval=interval, auto_adjust=False)
69
- else:
70
- df = ticker.history(start=start, end=end, interval=interval, auto_adjust=False)
71
- except Exception as e: # network, symbol, rate-limit, etc. — surfaced, not masked
72
- raise DataSourceError(f"Yahoo Finance retrieval failed for {symbol}@{interval}: {e}") from e
73
-
74
- if df is None or df.empty:
75
- hint = f" Yahoo typically limits {interval} history to ~{max_days} days." if max_days else ""
76
- raise DataSourceError(f"No data returned for {symbol}@{interval}.{hint}")
77
-
78
- df = df.rename(columns=str.lower)[["open", "high", "low", "close", "volume"]]
79
- df.index = pd.to_datetime(df.index, utc=True)
80
- df.index.name = "timestamp"
81
- return df
82
-
83
-
84
- def validate_ohlcv(df: pd.DataFrame) -> tuple[pd.DataFrame, ValidationReport]:
85
- """Spec section 13. Removes/flags bad rows; never silently repairs
86
- a suspicious price. Every removal is counted in the report."""
87
- warnings: list[str] = []
88
- rows_in = len(df)
89
- out = df.sort_index().copy()
90
-
91
- dup = out.index.duplicated(keep="first")
92
- duplicates_removed = int(dup.sum())
93
- out = out[~dup]
94
-
95
- monotonic = bool(out.index.is_monotonic_increasing)
96
-
97
- core = out[["open", "high", "low", "close"]]
98
- nan_mask = core.isna().any(axis=1) | ~np.isfinite(core.to_numpy(dtype=float)).all(axis=1)
99
- nan_rows_removed = int(nan_mask.sum())
100
- out = out[~nan_mask]
101
-
102
- core = out[["open", "high", "low", "close"]]
103
- price_positive = (core > 0).all(axis=1)
104
- high_ok = out["high"] >= out[["open", "close", "low"]].max(axis=1)
105
- low_ok = out["low"] <= out[["open", "close", "high"]].min(axis=1)
106
- valid_ohlc = price_positive & high_ok & low_ok
107
- invalid_ohlc_removed = int((~valid_ohlc).sum())
108
- out = out[valid_ohlc]
109
-
110
- if "volume" in out.columns:
111
- bad_vol = out["volume"] < 0
112
- if bad_vol.any():
113
- warnings.append(
114
- f"{int(bad_vol.sum())} row(s) had negative volume; marked "
115
- f"unavailable (NaN), never invented (section 11)."
116
- )
117
- out.loc[bad_vol, "volume"] = np.nan
118
-
119
- report = ValidationReport(
120
- rows_in=rows_in, rows_out=len(out), duplicates_removed=duplicates_removed,
121
- invalid_ohlc_removed=invalid_ohlc_removed, nan_rows_removed=nan_rows_removed,
122
- timezone=str(out.index.tz), monotonic=monotonic, warnings=warnings,
123
- )
124
- return out, report
125
-
126
-
127
- def causal_resample(df: pd.DataFrame, source_interval: str, target_interval: str) -> pd.DataFrame:
128
- """Builds a derived timeframe (e.g. 5m -> 10m) using only completed
129
- source candles that fall entirely within the bin (spec section 11).
130
- A trailing partial bin is dropped, never padded with future data."""
131
- if target_interval not in cfg.DERIVED_MAP:
132
- raise ValueError(f"{target_interval} is not a configured derived timeframe")
133
- expected_source, bars_per_bin = cfg.DERIVED_MAP[target_interval]
134
- if expected_source != source_interval:
135
- raise ValueError(f"{target_interval} must be derived from {expected_source}, got {source_interval}")
136
-
137
- rule = f"{cfg.TIMEFRAME_MINUTES[target_interval]}min"
138
- agg = {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
139
- resampler = df.resample(rule, label="left", closed="left")
140
- resampled = resampler.agg(agg)
141
-
142
- # A derived bin is only valid if it is backed by the full count of
143
- # source candles — otherwise it may be an incomplete trailing bin
144
- # that would silently borrow a "future" partial candle.
145
- counts = resampler["close"].count()
146
- complete = counts >= bars_per_bin
147
- resampled = resampled[complete]
148
-
149
- if df["volume"].isna().all():
150
- resampled["volume"] = np.nan # never invent volume
151
-
152
- return resampled.dropna(subset=["open", "high", "low", "close"])
153
-
154
-
155
- class LocalCache:
156
- """SQLite-backed local cache — no external DB service (spec section 76).
157
- Corrupted/mismatched entries are simply cache misses, never returned
158
- as if they were valid (section 105)."""
159
-
160
- def __init__(self, path: str = "cache.sqlite3"):
161
- self.path = Path(path)
162
- self._conn = sqlite3.connect(self.path)
163
- self._conn.execute(
164
- """CREATE TABLE IF NOT EXISTS ohlcv_cache (
165
- cache_key TEXT PRIMARY KEY,
166
- symbol TEXT, interval TEXT, source TEXT,
167
- start_ts TEXT, end_ts TEXT, fetched_at TEXT,
168
- payload TEXT
169
- )"""
170
- )
171
- self._conn.commit()
172
-
173
- @staticmethod
174
- def _key(symbol, interval, source, start, end) -> str:
175
- raw = f"{symbol}|{interval}|{source}|{start}|{end}"
176
- return hashlib.sha256(raw.encode()).hexdigest()
177
-
178
- def get(self, symbol, interval, source, start, end) -> Optional[pd.DataFrame]:
179
- key = self._key(symbol, interval, source, start, end)
180
- row = self._conn.execute(
181
- "SELECT payload FROM ohlcv_cache WHERE cache_key=?", (key,)
182
- ).fetchone()
183
- if row is None:
184
- return None
185
- try:
186
- return pd.read_json(io.StringIO(row[0]), orient="split")
187
- except ValueError:
188
- return None # corrupted entry -> treat as miss, never as valid data
189
-
190
- def set(self, symbol, interval, source, start, end, df: pd.DataFrame):
191
- key = self._key(symbol, interval, source, start, end)
192
- payload = df.to_json(orient="split", date_format="iso")
193
- self._conn.execute(
194
- """INSERT OR REPLACE INTO ohlcv_cache
195
- (cache_key, symbol, interval, source, start_ts, end_ts, fetched_at, payload)
196
- VALUES (?,?,?,?,?,?,?,?)""",
197
- (key, symbol, interval, source, str(start), str(end),
198
- datetime.now(timezone.utc).isoformat(), payload),
199
- )
200
- self._conn.commit()
201
-
202
-
203
- def data_freshness(latest_ts: pd.Timestamp, interval: str) -> dict:
204
- """Spec section 57. Freshness is judged relative to the bar size —
205
- one stale 1-minute bar is very different from one stale 1-day bar."""
206
- now = pd.Timestamp.now(tz="UTC")
207
- age = now - latest_ts
208
- bar_minutes = cfg.TIMEFRAME_MINUTES[interval]
209
- age_bars = age.total_seconds() / 60 / bar_minutes
210
- if age_bars <= 1.5:
211
- status = "fresh"
212
- elif age_bars <= 5:
213
- status = "delayed"
214
- else:
215
- status = "stale"
216
- return {
217
- "latest_market_ts": latest_ts.isoformat(),
218
- "system_ts": now.isoformat(),
219
- "age_seconds": age.total_seconds(),
220
- "age_bars": round(age_bars, 2),
221
- "status": status,
222
- }
223
-
224
-
225
- def resolve_history_window(label: str):
226
- """Turns a HISTORY_WINDOW_CHOICES label ("1 day", "6 months",
227
- "2 years", "max") into either ("period", "max") for yfinance's
228
- period shorthand, or ("start", <UTC datetime>) for everything else.
229
- yfinance's `period` parameter only accepts a fixed enum (1d, 5d,
230
- 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max) -- it does NOT accept
231
- arbitrary values like "15d" or "4mo", so any day/month count outside
232
- that enum has to be expressed as an explicit start date instead.
233
- """
234
- from datetime import datetime, timezone
235
- from dateutil.relativedelta import relativedelta
236
-
237
- label = label.strip().lower()
238
- if label == "max":
239
- return "period", "max"
240
-
241
- now = datetime.now(timezone.utc)
242
- parts = label.split()
243
- if len(parts) != 2:
244
- raise ValueError(f"Unrecognized history window: {label!r}")
245
- n = int(parts[0])
246
- unit = parts[1]
247
-
248
- if unit.startswith("day"):
249
- return "start", now - relativedelta(days=n)
250
- if unit.startswith("month"):
251
- return "start", now - relativedelta(months=n)
252
- if unit.startswith("year"):
253
- return "start", now - relativedelta(years=n)
254
- raise ValueError(f"Unrecognized history window unit: {unit!r} in {label!r}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decision_engine.py DELETED
@@ -1,102 +0,0 @@
1
- """
2
- Deterministic Buy/Hold/Sell policy applied to Moirai-MoE's forecast
3
- output (spec sections 31-34, 93, 111-113). This module contains NO
4
- predictive model of its own, hidden or otherwise — only arithmetic over
5
- a forecast object produced by moirai_model.py. Nothing in this file
6
- imports sklearn/xgboost/lightgbm/catboost or any other classifier.
7
- """
8
- from __future__ import annotations
9
-
10
- from dataclasses import dataclass
11
-
12
- import numpy as np
13
-
14
- import config as cfg
15
-
16
-
17
- @dataclass
18
- class Decision:
19
- action: str # "BUY" | "SELL" | "HOLD"
20
- expected_return: float
21
- expected_edge: float
22
- p10_return: float
23
- p90_return: float
24
- prob_favorable: float
25
- interval_width_pct: float
26
- rationale: str
27
- hold_reason: str = None
28
-
29
-
30
- def _prob_exceeds(levels: list, values: np.ndarray, threshold: float) -> float:
31
- """P(true return > threshold), estimated by linear interpolation on
32
- the model's own empirical quantile function — not a fabricated
33
- number (spec section 32-33)."""
34
- order = np.argsort(levels)
35
- lv = np.asarray(levels)[order]
36
- vv = np.asarray(values)[order]
37
- if threshold <= vv[0]:
38
- return 1.0
39
- if threshold >= vv[-1]:
40
- return 0.0
41
- p_below = float(np.interp(threshold, vv, lv))
42
- return 1.0 - p_below
43
-
44
-
45
- def decide(forecast, price_now: float, dc: cfg.DecisionConfig = None,
46
- cost_estimate_frac: float = None) -> Decision:
47
- """`forecast` is a QuantileForecast (see moirai_model.py) with
48
- .quantile_levels (list of floats incl. 0.1/0.5/0.9) and .quantiles
49
- of shape (horizon, len(levels))."""
50
- dc = dc or cfg.DecisionConfig()
51
- levels = list(forecast.quantile_levels)
52
- step_prices = np.asarray(forecast.quantiles)[-1] # final horizon step
53
- step_returns = step_prices / price_now - 1.0
54
-
55
- p10 = float(step_returns[levels.index(0.1)])
56
- p50 = float(step_returns[levels.index(0.5)])
57
- p90 = float(step_returns[levels.index(0.9)])
58
-
59
- cost = cost_estimate_frac if cost_estimate_frac is not None else dc.cost_bps / 10_000
60
- interval_width_pct = p90 - p10
61
- prob_favorable = _prob_exceeds(levels, step_returns, threshold=cost) # P(return > +cost) -- the BUY-side confidence
62
- # SELL is profitable when return < -cost (shorting profits from a
63
- # price drop), which is NOT the same event as "return <= +cost" --
64
- # that includes the whole middle range between -cost and +cost too,
65
- # so it isn't "1 - prob_favorable" reused. This is its own directional
66
- # probability, symmetric to prob_favorable but mirrored around
67
- # -cost instead of +cost, exactly the way min_probability's own
68
- # definition ("P(return favorable) required to act", config.py) is
69
- # meant to apply to either direction, not just BUY.
70
- prob_favorable_sell = 1.0 - _prob_exceeds(levels, step_returns, threshold=-cost) # P(return < -cost)
71
-
72
- hold_reason = None
73
- if interval_width_pct > dc.max_interval_width_pct:
74
- action = "HOLD"
75
- hold_reason = "forecast interval too wide relative to expected move (section 93)"
76
- elif (p50 - cost) > dc.buy_threshold and prob_favorable >= dc.min_probability:
77
- action = "BUY"
78
- elif (p50 + cost) < dc.sell_threshold and prob_favorable_sell >= dc.min_probability:
79
- action = "SELL"
80
- else:
81
- action = "HOLD"
82
- hold_reason = "expected edge below threshold after costs (section 93)"
83
-
84
- # Report whichever probability is actually relevant to the action taken
85
- # -- for a SELL, "P(return>cost)" reads as near-zero and looks like the
86
- # decision has no confidence behind it, when the real (and high)
87
- # confidence is in the mirrored SELL-side event. BUY/HOLD keep the
88
- # original P(return>cost) framing.
89
- if action == "SELL":
90
- prob_shown, prob_label = prob_favorable_sell, "P(return<-cost)"
91
- else:
92
- prob_shown, prob_label = prob_favorable, "P(return>cost)"
93
-
94
- rationale = (
95
- f"P50 return {p50:+.4%} vs cost {cost:.4%}; {prob_label}={prob_shown:.2f}; "
96
- f"interval[P10,P90]=[{p10:+.4%},{p90:+.4%}]"
97
- )
98
- return Decision(
99
- action=action, expected_return=p50, expected_edge=p50 - cost,
100
- p10_return=p10, p90_return=p90, prob_favorable=prob_shown,
101
- interval_width_pct=interval_width_pct, rationale=rationale, hold_reason=hold_reason,
102
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
features.py DELETED
@@ -1,180 +0,0 @@
1
- """
2
- The ten standardized causal indicator families (spec sections 19-20, 22)
3
- plus warm-up handling (section 24). Every computation here uses only
4
- rolling/expanding-style pandas ops seeded from row <=t; nothing looks
5
- forward. This is verified directly in tests/test_features_and_leakage.py
6
- by re-computing on a truncated frame and checking the value is unchanged.
7
- """
8
- from __future__ import annotations
9
-
10
- import numpy as np
11
- import pandas as pd
12
-
13
- import config as cfg
14
-
15
-
16
- def _rsi(close: pd.Series, period: int) -> pd.Series:
17
- delta = close.diff()
18
- gain = delta.clip(lower=0)
19
- loss = -delta.clip(upper=0)
20
- avg_gain = gain.ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
21
- avg_loss = loss.ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
22
- rs = avg_gain / avg_loss.replace(0, np.nan)
23
- rsi = 100 - (100 / (1 + rs))
24
- # avg_loss==0 (pure uptrend) -> RSI is 100, not NaN. Warm-up rows
25
- # (avg_loss itself NaN) fall through unchanged since NaN != 0 is True,
26
- # so they correctly stay NaN rather than being fabricated as 50 or 100.
27
- return rsi.where(avg_loss != 0, 100.0)
28
-
29
-
30
- def _macd(close: pd.Series, fast: int, slow: int, signal: int):
31
- ema_fast = close.ewm(span=fast, adjust=False, min_periods=fast).mean()
32
- ema_slow = close.ewm(span=slow, adjust=False, min_periods=slow).mean()
33
- macd = ema_fast - ema_slow
34
- macd_signal = macd.ewm(span=signal, adjust=False, min_periods=signal).mean()
35
- return macd, macd_signal, macd - macd_signal
36
-
37
-
38
- def _bollinger(close: pd.Series, period: int, k: float):
39
- mid = close.rolling(period, min_periods=period).mean()
40
- std = close.rolling(period, min_periods=period).std(ddof=0)
41
- return mid, mid + k * std, mid - k * std
42
-
43
-
44
- def _stochastic(high, low, close, k_period, d_period):
45
- lowest = low.rolling(k_period, min_periods=k_period).min()
46
- highest = high.rolling(k_period, min_periods=k_period).max()
47
- rng = highest - lowest
48
- k = 100 * (close - lowest) / rng.replace(0, np.nan)
49
- # rng==0 means price hasn't moved at all over the lookback window
50
- # (real, observed data -- e.g. a stale/illiquid stretch of identical
51
- # ticks, not warm-up). There's no range to place `close` within, so
52
- # %K is defined as the neutral midpoint (50) rather than left as a
53
- # raw 0/0 NaN. Warm-up rows are unaffected: rng itself is NaN there
54
- # (not 0), so `rng != 0` stays True and the NaN passes through --
55
- # same convention as RSI's avg_loss==0 case above.
56
- k = k.where(rng != 0, 50.0)
57
- d = k.rolling(d_period, min_periods=d_period).mean()
58
- return k, d
59
-
60
-
61
- def _atr(high, low, close, period):
62
- prev_close = close.shift(1)
63
- tr = pd.concat([
64
- high - low,
65
- (high - prev_close).abs(),
66
- (low - prev_close).abs(),
67
- ], axis=1).max(axis=1)
68
- return tr.ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
69
-
70
-
71
- def _adx(high, low, close, period):
72
- up_move = high.diff()
73
- down_move = -low.diff()
74
- plus_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0)
75
- minus_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0)
76
- atr = _atr(high, low, close, period)
77
- plus_dm_ewm = pd.Series(plus_dm, index=high.index).ewm(
78
- alpha=1 / period, adjust=False, min_periods=period).mean()
79
- minus_dm_ewm = pd.Series(minus_dm, index=high.index).ewm(
80
- alpha=1 / period, adjust=False, min_periods=period).mean()
81
- # atr==0 is a real, reachable state, not just theoretical: a long
82
- # enough run of identical high/low/close (illiquid or stale ticks --
83
- # exactly the kind of flat stretch visible in live 1m forex data)
84
- # decays the ATR ewm to an exact float 0.0. +DM/-DM are necessarily
85
- # 0 too whenever atr==0 (no high/low movement to measure), so the
86
- # correct DI reading is 0 (no directional movement), not an
87
- # undefined 0/0. Warm-up rows stay NaN because atr itself is NaN
88
- # there (not 0), so `atr != 0` stays True and the NaN passes through.
89
- plus_di = (100 * plus_dm_ewm / atr.replace(0, np.nan)).where(atr != 0, 0.0)
90
- minus_di = (100 * minus_dm_ewm / atr.replace(0, np.nan)).where(atr != 0, 0.0)
91
- di_sum = plus_di + minus_di
92
- # Same logic one level up: di_sum==0 (both DIs flat-zero, or a
93
- # perfectly balanced up/down) means there's no dominant direction --
94
- # dx is defined as 0 (no trend), not NaN. di_sum is NaN (not 0)
95
- # during genuine warm-up, so that case is still preserved as NaN.
96
- dx = (100 * (plus_di - minus_di).abs() / di_sum.replace(0, np.nan)).where(di_sum != 0, 0.0)
97
- return dx.ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
98
-
99
-
100
- def _roc(close, period):
101
- return 100 * (close / close.shift(period) - 1)
102
-
103
-
104
- def _obv(close, volume):
105
- direction = np.sign(close.diff().fillna(0))
106
- return (direction * volume.fillna(0)).cumsum()
107
-
108
-
109
- def compute_indicators(df: pd.DataFrame, ic: cfg.IndicatorConfig = None) -> pd.DataFrame:
110
- """df must have columns open/high/low/close/volume, ascending index.
111
- Returns df with indicator columns appended; warm-up rows are NaN
112
- (never forward-filled from the future — section 24)."""
113
- ic = ic or cfg.IndicatorConfig()
114
- out = df.copy()
115
- out["rsi"] = _rsi(out["close"], ic.rsi_period)
116
- out["macd"], out["macd_signal"], out["macd_hist"] = _macd(
117
- out["close"], ic.macd_fast, ic.macd_slow, ic.macd_signal)
118
- out["bb_mid"], out["bb_upper"], out["bb_lower"] = _bollinger(out["close"], ic.bb_period, ic.bb_std)
119
- out["sma_20"] = out["close"].rolling(ic.sma_periods[0], min_periods=ic.sma_periods[0]).mean()
120
- out["sma_50"] = out["close"].rolling(ic.sma_periods[1], min_periods=ic.sma_periods[1]).mean()
121
- out["ema_12"] = out["close"].ewm(span=ic.ema_periods[0], adjust=False, min_periods=ic.ema_periods[0]).mean()
122
- out["ema_26"] = out["close"].ewm(span=ic.ema_periods[1], adjust=False, min_periods=ic.ema_periods[1]).mean()
123
- out["stoch_k"], out["stoch_d"] = _stochastic(out["high"], out["low"], out["close"], ic.stoch_k, ic.stoch_d)
124
- out["atr"] = _atr(out["high"], out["low"], out["close"], ic.atr_period)
125
- out["adx"] = _adx(out["high"], out["low"], out["close"], ic.adx_period)
126
- out["roc"] = _roc(out["close"], ic.roc_period)
127
- out["obv"] = _obv(out["close"], out["volume"].fillna(0))
128
- return out
129
-
130
-
131
- def warm_up_valid_from(df: pd.DataFrame) -> int:
132
- """First integer index where ALL configured feature columns are
133
- simultaneously non-NaN. Raises if that never happens (section 88:
134
- 'do NOT manufacture data').
135
-
136
- On failure, the message names the slowest-to-warm-up column(s) and
137
- how many bars they actually need vs. how many are available --
138
- diagnosed empirically from df itself (each column's real first
139
- non-NaN position), never from a hand-derived "expected" formula per
140
- indicator. A hand-derived number for something like MACD or ADX
141
- (ewm-of-an-ewm, with its own min_periods layered on top) is exactly
142
- the kind of guess this project's own spec section 6 (real execution
143
- over static assumption) argues against -- so this reads it back off
144
- the real computed columns instead (section 98: errors must say what
145
- failed, not just that something did)."""
146
- cols = [c for c in cfg.PAST_DYNAMIC_FEATURES if c in df.columns]
147
- valid = df[cols].notna().all(axis=1)
148
- if not bool(valid.any()):
149
- first_valid: dict = {}
150
- never_valid = []
151
- for c in cols:
152
- nn = df[c].notna().to_numpy()
153
- if nn.any():
154
- first_valid[c] = int(np.argmax(nn))
155
- else:
156
- never_valid.append(c)
157
-
158
- if never_valid:
159
- blockers = sorted(never_valid)
160
- detail = (
161
- f"never produced a single valid value anywhere in the "
162
- f"{len(df)} bar(s) available"
163
- )
164
- else:
165
- worst = max(first_valid.values())
166
- blockers = sorted(c for c, idx in first_valid.items() if idx == worst)
167
- detail = (
168
- f"needs {worst + 1} bar(s) before its first valid value "
169
- f"(only {len(df)} available)"
170
- )
171
-
172
- raise ValueError(
173
- f"No fully warmed-up row found — insufficient historical data "
174
- f"for this indicator configuration (spec section 88). Slowest "
175
- f"to warm up: {', '.join(blockers)} — {detail}. Widen the "
176
- f"History window (more bars) or switch to a coarser/finer "
177
- f"timeframe so these indicators have enough lookback before "
178
- f"the backtest/forecast starts."
179
- )
180
- return int(np.argmax(valid.to_numpy()))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
leakage_audit.py DELETED
@@ -1,54 +0,0 @@
1
- """
2
- Explicit leakage-audit layer (spec sections 25-26). These are the
3
- "fail loudly" checks the rest of the system is built around: nothing
4
- downstream should ever see a row timestamped after the prediction origin.
5
- """
6
- from __future__ import annotations
7
-
8
- import pandas as pd
9
-
10
-
11
- class LeakageError(Exception):
12
- pass
13
-
14
-
15
- def assert_no_future_rows(df: pd.DataFrame, origin_ts: pd.Timestamp, context_name: str = "input"):
16
- bad = df.index[df.index > origin_ts]
17
- if len(bad) > 0:
18
- raise LeakageError(
19
- f"{context_name}: {len(bad)} row(s) after origin {origin_ts} "
20
- f"(first offender: {bad[0]}). Forbidden per spec section 25."
21
- )
22
-
23
-
24
- def assert_chronological_split(train_end: pd.Timestamp, val_start: pd.Timestamp,
25
- val_end: pd.Timestamp, test_start: pd.Timestamp):
26
- if not (train_end <= val_start <= val_end <= test_start):
27
- raise LeakageError(
28
- f"Non-chronological split: train_end={train_end}, val_start={val_start}, "
29
- f"val_end={val_end}, test_start={test_start} (spec section 27)."
30
- )
31
-
32
-
33
- def assert_target_strictly_future(origin_ts: pd.Timestamp, target_ts: pd.Timestamp):
34
- if target_ts <= origin_ts:
35
- raise LeakageError(
36
- f"Target timestamp {target_ts} is not strictly after origin {origin_ts} "
37
- f"(spec section 15/25)."
38
- )
39
-
40
-
41
- def audit_feature_frame(feature_df: pd.DataFrame, origin_ts: pd.Timestamp) -> dict:
42
- """Run before every model call; returns a small report that gets
43
- persisted alongside the forecast/backtest run (sections 26, 71)."""
44
- violations = []
45
- try:
46
- assert_no_future_rows(feature_df, origin_ts, "feature_df")
47
- except LeakageError as e:
48
- violations.append(str(e))
49
- return {
50
- "passed": len(violations) == 0,
51
- "violations": violations,
52
- "origin_ts": origin_ts.isoformat(),
53
- "rows_checked": len(feature_df),
54
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
metrics.py DELETED
@@ -1,127 +0,0 @@
1
- """
2
- Forecast-skill and trading-performance metrics (spec sections 36-37).
3
- Every function takes real arrays computed elsewhere — nothing here is
4
- fabricated, and nothing here silently assumes daily-bar annualization
5
- for intraday data (section 37).
6
- """
7
- from __future__ import annotations
8
-
9
- import numpy as np
10
- import pandas as pd
11
-
12
- MINUTES_PER_YEAR = 365.25 * 24 * 60
13
-
14
-
15
- def mae(y_true, y_pred) -> float:
16
- return float(np.mean(np.abs(np.asarray(y_true, dtype=float) - np.asarray(y_pred, dtype=float))))
17
-
18
-
19
- def rmse(y_true, y_pred) -> float:
20
- d = np.asarray(y_true, dtype=float) - np.asarray(y_pred, dtype=float)
21
- return float(np.sqrt(np.mean(d ** 2)))
22
-
23
-
24
- def smape(y_true, y_pred) -> float:
25
- y_true, y_pred = np.asarray(y_true, dtype=float), np.asarray(y_pred, dtype=float)
26
- denom = np.abs(y_true) + np.abs(y_pred)
27
- denom = np.where(denom == 0, np.nan, denom)
28
- return float(np.nanmean(2 * np.abs(y_pred - y_true) / denom))
29
-
30
-
31
- def mase(y_true, y_pred, naive_errors) -> float:
32
- """Scale-aware metric vs. a naive (last-value) baseline (section 36/38)."""
33
- scale = np.mean(np.abs(np.asarray(naive_errors, dtype=float)))
34
- if scale == 0 or not np.isfinite(scale):
35
- return float("nan")
36
- return float(mae(y_true, y_pred) / scale)
37
-
38
-
39
- def directional_accuracy(y_true_return, y_pred_return) -> float:
40
- yt = np.sign(np.asarray(y_true_return, dtype=float))
41
- yp = np.sign(np.asarray(y_pred_return, dtype=float))
42
- mask = yt != 0
43
- if mask.sum() == 0:
44
- return float("nan")
45
- return float(np.mean(yt[mask] == yp[mask]))
46
-
47
-
48
- def pinball_loss(y_true, quantile_preds: dict) -> float:
49
- y_true = np.asarray(y_true, dtype=float)
50
- losses = []
51
- for q, pred in quantile_preds.items():
52
- diff = y_true - np.asarray(pred, dtype=float)
53
- losses.append(np.mean(np.maximum(q * diff, (q - 1) * diff)))
54
- return float(np.mean(losses))
55
-
56
-
57
- def interval_coverage(y_true, lower, upper) -> float:
58
- y_true, lower, upper = (np.asarray(a, dtype=float) for a in (y_true, lower, upper))
59
- return float(np.mean((y_true >= lower) & (y_true <= upper)))
60
-
61
-
62
- def _annualization_factor(timeframe_minutes: int) -> float:
63
- return float(np.sqrt(MINUTES_PER_YEAR / timeframe_minutes))
64
-
65
-
66
- def sharpe_ratio(returns: pd.Series, timeframe_minutes: int, rf: float = 0.0) -> float:
67
- r = returns.dropna()
68
- if len(r) < 2 or r.std(ddof=0) == 0:
69
- return float("nan")
70
- return float((r.mean() - rf) / r.std(ddof=0) * _annualization_factor(timeframe_minutes))
71
-
72
-
73
- def sortino_ratio(returns: pd.Series, timeframe_minutes: int, rf: float = 0.0) -> float:
74
- r = returns.dropna()
75
- downside = r[r < 0]
76
- dd = downside.std(ddof=0)
77
- if len(r) < 2 or dd == 0 or np.isnan(dd):
78
- return float("nan")
79
- return float((r.mean() - rf) / dd * _annualization_factor(timeframe_minutes))
80
-
81
-
82
- def max_drawdown(equity: pd.Series) -> float:
83
- running_max = equity.cummax()
84
- dd = equity / running_max - 1.0
85
- return float(dd.min())
86
-
87
-
88
- def calmar_ratio(equity: pd.Series, timeframe_minutes: int) -> float:
89
- n_bars = len(equity)
90
- bars_per_year = MINUTES_PER_YEAR / timeframe_minutes
91
- years = n_bars / bars_per_year
92
- if years <= 0 or equity.iloc[0] <= 0:
93
- return float("nan")
94
- cagr = (equity.iloc[-1] / equity.iloc[0]) ** (1 / years) - 1
95
- mdd = abs(max_drawdown(equity))
96
- return float(cagr / mdd) if mdd > 0 else float("nan")
97
-
98
-
99
- def profit_factor(net_pnls) -> float:
100
- net_pnls = np.asarray(net_pnls, dtype=float)
101
- gains = net_pnls[net_pnls > 0].sum()
102
- losses = -net_pnls[net_pnls < 0].sum()
103
- return float(gains / losses) if losses > 0 else float("nan")
104
-
105
-
106
- def trading_summary(trades_df: pd.DataFrame, equity: pd.Series, timeframe_minutes: int) -> dict:
107
- if trades_df is None or trades_df.empty:
108
- return {"num_trades": 0, "note": "no trades executed in this window"}
109
- wins = trades_df[trades_df.net_pnl > 0]
110
- losses = trades_df[trades_df.net_pnl <= 0]
111
- returns = equity.pct_change().dropna()
112
- return {
113
- "num_trades": int(len(trades_df)),
114
- "win_rate": float(len(wins) / len(trades_df)),
115
- "avg_win": float(wins.net_pnl.mean()) if len(wins) else 0.0,
116
- "avg_loss": float(losses.net_pnl.mean()) if len(losses) else 0.0,
117
- "profit_factor": profit_factor(trades_df.net_pnl.values),
118
- "gross_pnl": float(trades_df.gross_pnl.sum()),
119
- "net_pnl": float(trades_df.net_pnl.sum()),
120
- "cumulative_return": float(equity.iloc[-1] / equity.iloc[0] - 1.0),
121
- "max_drawdown": max_drawdown(equity),
122
- "sharpe": sharpe_ratio(returns, timeframe_minutes),
123
- "sortino": sortino_ratio(returns, timeframe_minutes),
124
- "calmar": calmar_ratio(equity, timeframe_minutes),
125
- "long_trades": int((trades_df.action == "BUY").sum()),
126
- "short_trades": int((trades_df.action == "SELL").sum()),
127
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
moirai_model.py DELETED
@@ -1,245 +0,0 @@
1
- """
2
- Real Moirai-MoE-1.0-R-Small integration (spec sections 4-6, 42-43, 75, 86).
3
-
4
- This is the ONLY predictive model in the system (section 3). Grep the
5
- rest of this project: nothing imports sklearn, xgboost, lightgbm,
6
- catboost, statsmodels ARIMA/Prophet, or any external prediction API.
7
-
8
- API confirmed against the current official uni2ts repo
9
- (SalesforceAIResearch/uni2ts, "pip install uni2ts", PyPI package
10
- "uni2ts") and the model card at
11
- huggingface.co/Salesforce/moirai-moe-1.0-R-small as of this build:
12
-
13
- from uni2ts.model.moirai_moe import MoiraiMoEForecast, MoiraiMoEModule
14
- module = MoiraiMoEModule.from_pretrained("Salesforce/moirai-moe-1.0-R-small")
15
- model = MoiraiMoEForecast(module=module, prediction_length=..., context_length=...,
16
- patch_size=16, num_samples=100, target_dim=1,
17
- feat_dynamic_real_dim=0, past_feat_dynamic_real_dim=N)
18
- predictor = model.create_predictor(batch_size=1)
19
- forecasts = list(predictor.predict(gluonts_dataset))
20
-
21
- uni2ts is an actively developed research library (it shipped a new
22
- Moirai-2.0 family in Aug 2025) — re-verify this surface against the
23
- installed package version before relying on it in production, per
24
- spec section 75 ("do not assume an old code sample remains compatible
25
- forever").
26
-
27
- NETWORK NOTE: load() downloads the official checkpoint from Hugging
28
- Face on first use. In a network-isolated environment this correctly
29
- raises ModelLoadError with the underlying exception attached (section
30
- 4: "expose the exact failure ... do not fabricate a result").
31
- """
32
- from __future__ import annotations
33
-
34
- from dataclasses import dataclass
35
- from typing import List, Optional
36
-
37
- import numpy as np
38
- import pandas as pd
39
-
40
- import config as cfg
41
-
42
- CHECKPOINT = cfg.MODEL_CHECKPOINT
43
-
44
-
45
- class ModelLoadError(Exception):
46
- pass
47
-
48
-
49
- class ModelInputError(Exception):
50
- pass
51
-
52
-
53
- class ModelInferenceError(Exception):
54
- pass
55
-
56
-
57
- @dataclass
58
- class QuantileForecast:
59
- horizon: int
60
- quantile_levels: List[float]
61
- quantiles: np.ndarray # shape (horizon, len(quantile_levels))
62
- samples: Optional[np.ndarray] # shape (num_samples, horizon), if the backend exposes it
63
- context_length_used: int
64
- prediction_length: int
65
-
66
-
67
- class MoiraiMoEForecaster:
68
- """Thin, honest wrapper. It does not retry-and-hide failures and it
69
- does not substitute any other model if loading or inference fails
70
- (section 4/80) — every method below either returns a real result or
71
- raises with the underlying exception attached."""
72
-
73
- def __init__(self, checkpoint: str = CHECKPOINT, num_samples: int = 100,
74
- patch_size: int = 16):
75
- # patch_size must be a concrete int, not "auto". uni2ts's
76
- # MoiraiMoEForecast.forward() only special-cases the literal
77
- # string "auto" in one property (past_length) -- everywhere else
78
- # (context_token_length/prediction_token_length) it does
79
- # `context_length / patch_size` directly, which raises
80
- # `TypeError: unsupported operand type(s) for /: 'int' and 'str'`
81
- # on every forward pass if patch_size is left as "auto". The
82
- # official eval config for this exact checkpoint
83
- # (cli/conf/eval/model/moirai_moe_1.0_R_small.yaml in
84
- # SalesforceAIResearch/uni2ts) uses `patch_size: 16`, matching
85
- # the worked example in this file's own module docstring above.
86
- self.checkpoint = checkpoint
87
- self.num_samples = min(num_samples, cfg.RESOURCE_LIMITS["max_forecast_samples"])
88
- self.patch_size = patch_size
89
- self._module = None
90
- self._loaded = False
91
- self.max_context = cfg.MODEL_MAX_CONTEXT_DEFAULT
92
- self._predictor_cache_key = None
93
- self._cached_predictor = None
94
-
95
- def load(self) -> "MoiraiMoEForecaster":
96
- try:
97
- from uni2ts.model.moirai_moe import MoiraiMoEModule
98
- except ImportError as e:
99
- raise ModelLoadError(
100
- f"uni2ts is not installed/importable ({e}). Run "
101
- f"`pip install -r requirements.txt`. This is the exact "
102
- f"underlying error, not masked (section 4)."
103
- ) from e
104
- try:
105
- self._module = MoiraiMoEModule.from_pretrained(self.checkpoint)
106
- except Exception as e:
107
- raise ModelLoadError(
108
- f"Failed to load checkpoint '{self.checkpoint}': {e}. "
109
- f"Not replaced with any other model (section 80)."
110
- ) from e
111
-
112
- # Respect the checkpoint's own declared context limit if exposed,
113
- # rather than assuming unlimited context (section 86).
114
- cfg_max = getattr(getattr(self._module, "config", None), "max_seq_len", None)
115
- if isinstance(cfg_max, int) and cfg_max > 0:
116
- self.max_context = cfg_max
117
-
118
- self._loaded = True
119
- return self
120
-
121
- @property
122
- def is_loaded(self) -> bool:
123
- return self._loaded
124
-
125
- def _get_predictor(self, context_length: int, prediction_length: int, past_covariate_cols: List[str]):
126
- """Builds (or reuses) the MoiraiMoEForecast predictor. A
127
- walk-forward backtest calls predict() thousands of times with an
128
- identical configuration (same context_length/prediction_length/
129
- covariates) -- rebuilding the wrapper from scratch on every
130
- origin was pure waste. Cached by the values that actually change
131
- the underlying computation graph."""
132
- key = (context_length, prediction_length, tuple(past_covariate_cols),
133
- self.num_samples, self.patch_size)
134
- if key != self._predictor_cache_key:
135
- from uni2ts.model.moirai_moe import MoiraiMoEForecast
136
- model = MoiraiMoEForecast(
137
- module=self._module,
138
- prediction_length=prediction_length,
139
- context_length=context_length,
140
- patch_size=self.patch_size,
141
- num_samples=self.num_samples,
142
- target_dim=1,
143
- feat_dynamic_real_dim=0,
144
- past_feat_dynamic_real_dim=len(past_covariate_cols),
145
- )
146
- self._cached_predictor = model.create_predictor(batch_size=1)
147
- self._predictor_cache_key = key
148
- return self._cached_predictor
149
-
150
- def predict(self, context_df: pd.DataFrame, target_col: str,
151
- past_covariate_cols: List[str], prediction_length: int,
152
- freq: str) -> QuantileForecast:
153
- if not self._loaded:
154
- raise ModelInferenceError("Model not loaded — call load() first (section 60).")
155
-
156
- required = [target_col, *past_covariate_cols]
157
- missing = [c for c in required if c not in context_df.columns]
158
- if missing:
159
- raise ModelInputError(f"Missing required columns in context: {missing}")
160
- if context_df[required].isna().any().any():
161
- raise ModelInputError(
162
- "NaN present in model input — indicator warm-up was not fully "
163
- "trimmed before calling predict() (section 24/60)."
164
- )
165
- if not np.isfinite(context_df[required].to_numpy(dtype=float)).all():
166
- raise ModelInputError("Non-finite value present in model input.")
167
-
168
- context_length = min(len(context_df), self.max_context)
169
- if context_length < len(context_df):
170
- context_df = context_df.iloc[-context_length:]
171
- if context_length < 32:
172
- raise ModelInputError(
173
- f"Only {context_length} valid context rows available — too "
174
- f"short for a meaningful forecast (section 88)."
175
- )
176
- if prediction_length < 1:
177
- raise ModelInputError("prediction_length must be >= 1.")
178
-
179
- try:
180
- from gluonts.dataset.pandas import PandasDataset
181
- except ImportError as e:
182
- raise ModelLoadError(f"gluonts/uni2ts forecast wrapper unavailable: {e}") from e
183
-
184
- try:
185
- predictor = self._get_predictor(context_length, prediction_length, past_covariate_cols)
186
-
187
- gluon_df = context_df[required].rename(columns={target_col: "target"})
188
- # freq is passed EXPLICITLY rather than left for PandasDataset to
189
- # infer from the timestamps. Real Yahoo data has gaps (weekends,
190
- # exchange closures, occasional missing bars), and pd.infer_freq()
191
- # returns None on gapped/irregular data -- which fails the SAME
192
- # way on every single origin, not just some. We already know the
193
- # intended bar spacing from the requested timeframe, so there's
194
- # no need to infer it at all.
195
- #
196
- # Fixing an explicit freq does NOT by itself satisfy gluonts:
197
- # PandasDataset._pair_to_dataentry() converts the index to a
198
- # PeriodIndex at that freq and then asserts is_uniform() --
199
- # i.e. that consecutive periods are contiguous with no gaps at
200
- # all -- regardless of whether freq was inferred or given
201
- # explicitly. That assertion is exactly what real market data
202
- # (weekend closures, thin-liquidity gaps) violates, and it's
203
- # what raised "Dataframe index is not uniformly spaced" here.
204
- # unchecked=True skips that assertion; we still keep sorting
205
- # (assume_sorted defaults to False) and the model still sees a
206
- # correctly-ordered, correctly-spaced-by-freq sequence of the
207
- # bars that actually exist -- it just isn't forced to treat
208
- # legitimate market-closure gaps as a fatal error.
209
- ds = PandasDataset(gluon_df, target="target", freq=freq,
210
- past_feat_dynamic_real=past_covariate_cols,
211
- unchecked=True)
212
-
213
- forecasts = list(predictor.predict(ds))
214
- except Exception as e:
215
- raise ModelInferenceError(
216
- f"Real Moirai-MoE forward pass failed: {e}. Not masked, "
217
- f"not substituted with another model (section 4/6)."
218
- ) from e
219
-
220
- if not forecasts:
221
- raise ModelInferenceError("Model returned no forecast object.")
222
- fc = forecasts[0]
223
-
224
- levels = [0.1, 0.25, 0.5, 0.75, 0.9]
225
- try:
226
- quantiles = np.stack([np.asarray(fc.quantile(q)) for q in levels], axis=1)
227
- except Exception as e:
228
- raise ModelInferenceError(f"Could not extract quantiles from forecast output: {e}") from e
229
-
230
- if quantiles.shape[0] != prediction_length:
231
- raise ModelInferenceError(
232
- f"Forecast shape mismatch: got {quantiles.shape[0]} steps, "
233
- f"expected {prediction_length} (section 60)."
234
- )
235
- if not np.isfinite(quantiles).all():
236
- raise ModelInferenceError(
237
- "Forecast contains NaN/inf — refusing to return an invalid "
238
- "result rather than silently 'fixing' it (section 61)."
239
- )
240
-
241
- samples = getattr(fc, "samples", None)
242
- return QuantileForecast(
243
- horizon=prediction_length, quantile_levels=levels, quantiles=quantiles,
244
- samples=samples, context_length_used=context_length, prediction_length=prediction_length,
245
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
persistence.py DELETED
@@ -1,85 +0,0 @@
1
- """
2
- Local, self-contained persistence: an immutable forecast registry plus
3
- model-artifact metadata (spec sections 45-46, 66, 76, 90). SQLite only —
4
- no external DB service, consistent with the single-application /
5
- HF-Spaces-friendly deployment target (section 76).
6
- """
7
- from __future__ import annotations
8
-
9
- import sqlite3
10
- import uuid
11
- from datetime import datetime, timezone
12
- from pathlib import Path
13
- from typing import Optional
14
-
15
- import numpy as np
16
-
17
- SCHEMA = """
18
- CREATE TABLE IF NOT EXISTS forecasts (
19
- id TEXT PRIMARY KEY,
20
- created_at TEXT,
21
- symbol TEXT, timeframe TEXT, horizon INTEGER,
22
- model_checkpoint TEXT, config_hash TEXT,
23
- origin_ts TEXT, price_now REAL,
24
- p10 REAL, p50 REAL, p90 REAL,
25
- action TEXT, rationale TEXT,
26
- realized_price REAL, realized_return REAL, forecast_error REAL,
27
- direction_correct INTEGER
28
- );
29
- CREATE TABLE IF NOT EXISTS model_artifacts (
30
- id TEXT PRIMARY KEY,
31
- created_at TEXT,
32
- checkpoint TEXT, library_versions TEXT,
33
- feature_schema TEXT, seed INTEGER,
34
- training_range TEXT, validation_range TEXT, test_range TEXT
35
- );
36
- """
37
-
38
-
39
- class Store:
40
- def __init__(self, path: str = "registry.sqlite3"):
41
- self.path = Path(path)
42
- self.conn = sqlite3.connect(self.path)
43
- self.conn.executescript(SCHEMA)
44
- self.conn.commit()
45
-
46
- def record_forecast(self, **fields) -> str:
47
- """A forecast row is written once and never mutated except by
48
- match_realized_outcome() filling in the realized_* columns
49
- later (section 90: 'A forecast should remain immutable after
50
- creation')."""
51
- fid = str(uuid.uuid4())
52
- fields = {"id": fid, "created_at": datetime.now(timezone.utc).isoformat(), **fields}
53
- cols = ",".join(fields.keys())
54
- qs = ",".join(["?"] * len(fields))
55
- self.conn.execute(f"INSERT INTO forecasts ({cols}) VALUES ({qs})", list(fields.values()))
56
- self.conn.commit()
57
- return fid
58
-
59
- def match_realized_outcome(self, forecast_id: str, realized_price: float) -> None:
60
- row = self.conn.execute(
61
- "SELECT price_now, p50 FROM forecasts WHERE id=?", (forecast_id,)
62
- ).fetchone()
63
- if row is None:
64
- raise KeyError(f"No forecast with id {forecast_id}")
65
- price_now, p50 = row
66
- realized_return = realized_price / price_now - 1.0
67
- error = abs(p50 - realized_return)
68
- direction_correct: Optional[int] = (
69
- int(np.sign(p50) == np.sign(realized_return)) if realized_return != 0 else None
70
- )
71
- self.conn.execute(
72
- "UPDATE forecasts SET realized_price=?, realized_return=?, forecast_error=?, "
73
- "direction_correct=? WHERE id=?",
74
- (realized_price, realized_return, error, direction_correct, forecast_id),
75
- )
76
- self.conn.commit()
77
-
78
- def save_model_artifact(self, **fields) -> str:
79
- aid = str(uuid.uuid4())
80
- fields = {"id": aid, "created_at": datetime.now(timezone.utc).isoformat(), **fields}
81
- cols = ",".join(fields.keys())
82
- qs = ",".join(["?"] * len(fields))
83
- self.conn.execute(f"INSERT INTO model_artifacts ({cols}) VALUES ({qs})", list(fields.values()))
84
- self.conn.commit()
85
- return aid
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt DELETED
@@ -1,73 +0,0 @@
1
- # Core model — the sole predictive model in this system (spec section 3)
2
- uni2ts>=2.0.0
3
- torch>=2.1
4
- gluonts>=0.14
5
-
6
- # --- REVERSED after upgrading to gradio 6.26.0 ---
7
- # This used to be pinned `<1.0`: gradio 4.44.x's oauth.py did
8
- # `from huggingface_hub import HfFolder, whoami` unconditionally at import
9
- # time, and huggingface_hub 1.0 removed HfFolder, so any resolution that
10
- # picked huggingface_hub>=1.0 crashed on `import gradio` before app.py even
11
- # ran. That's gone upstream -- gradio's own changelog has "Allow gradio to
12
- # run with huggingface_hub 1.x", landed well before 6.26.0. uni2ts's
13
- # PyTorchModelHubMixin.from_pretrained() (what moirai_model.py actually
14
- # calls) isn't on the removed API either. No known reason left to cap this.
15
- huggingface_hub>=0.23.0
16
-
17
- # Data
18
- # Floor + ceiling, same pattern as the gradio pin below (and for the same
19
- # reason -- an unbounded `>=` is what let gradio drift onto an untested
20
- # combination previously). yfinance did a 0.2.x -> 1.x major bump; a later
21
- # pass in a network-enabled sandbox installed 1.7.0 and confirmed
22
- # Ticker.history()'s signature (period/interval/start/end/auto_adjust) and
23
- # its returned OHLCV column names are unchanged from what data_layer.py
24
- # assumes, so the floor stays permissive -- the ceiling just blocks an
25
- # unverified future 2.x from being silently picked up.
26
- yfinance>=0.2.40,<2.0
27
- pandas>=2.0
28
- numpy>=1.24
29
-
30
- # UI (Hugging Face Spaces)
31
- # Pinned to the 6.x line (floor at the version this build was verified
32
- # against, ceiling below the next major) instead of a bare `>=` -- that's
33
- # what let pip drift onto an untested gradio/starlette/fastapi combination
34
- # last time. app.py's own component usage (Blocks/Tab/Row/Textbox/Dropdown/
35
- # Slider/Button/Markdown/Dataframe/Accordion/JSON/Radio) doesn't touch any
36
- # of the parameters the Gradio 6 migration guide removed, so no app.py
37
- # changes were needed for this bump.
38
- gradio>=6.26.0,<7
39
-
40
- # --- REMOVED after upgrading to gradio 6.26.0 ---
41
- # This file used to pin `starlette<1.0` because gradio 4.44.x's routes.py
42
- # called Jinja2Templates.TemplateResponse the pre-1.0 way
43
- # (`TemplateResponse(name, context)`), which breaks against Starlette>=1.0
44
- # (the context dict lands in the `name` slot -> Jinja2 tries to hash it as
45
- # a cache key -> `TypeError: unhashable type: 'dict'` on every page load).
46
- # Gradio's own changelog shows a later commit -- "upgrade starlette
47
- # dependency to 1.0.1 or higher" -- that updated routes.py to the new
48
- # `TemplateResponse(request, name, context)` call and raised gradio's own
49
- # floor accordingly. That fix is already in 6.26.0, so pinning
50
- # starlette<1.0 now would fight gradio's real requirement instead of
51
- # working around a bug, and could make pip's resolution fail outright.
52
-
53
- python-dateutil
54
- pytz
55
-
56
- # --- FIX applied after a real HF Spaces build failure ---
57
- # An earlier version of this file pinned `numpy<2.0` and an explicit
58
- # `scipy>=1.11` as an untested precaution. On a real Spaces build this
59
- # forced pip to resolve an old scipy release with no prebuilt wheel for
60
- # the Space's Python version, which then failed trying to build scipy
61
- # from source via meson (no Fortran compiler / BLAS-LAPACK toolchain in
62
- # that install step -> "metadata-generation-failed"). Neither pin was
63
- # load-bearing: nothing in this codebase imports scipy directly, and
64
- # none of our own numpy usage requires numpy<2. Removing both lets
65
- # uni2ts/gluonts/torch resolve numpy (and scipy, transitively) to
66
- # whatever versions they actually ship wheels for.
67
- #
68
- # General pattern if a DIFFERENT package fails "metadata-generation" or
69
- # tries to build from source on Spaces: it's almost always pip being
70
- # forced into a source build for a package with no wheel for that
71
- # Python version. Fix is usually the same shape -- stop pinning that
72
- # package explicitly (or bump its floor) and let its real dependent
73
- # choose a version it has a wheel for.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
run_tests.py DELETED
@@ -1,55 +0,0 @@
1
- """
2
- Lightweight test runner. pytest is not installed in this build sandbox
3
- and there is no network access to install it (see README) -- this
4
- script discovers tests/test_*.py, runs every top-level test_* function,
5
- and reports real PASS/FAIL based on actual execution (spec section 6:
6
- real execution validation, not static syntax checking).
7
-
8
- If you have pytest available (e.g. after `pip install -r requirements.txt`
9
- somewhere with network access), just run `pytest tests/` instead --
10
- these test files use plain assert and need nothing pytest-specific.
11
- """
12
- import importlib.util
13
- import sys
14
- import traceback
15
- from pathlib import Path
16
-
17
- ROOT = Path(__file__).resolve().parent
18
- sys.path.insert(0, str(ROOT))
19
- sys.path.insert(0, str(ROOT / "tests"))
20
-
21
-
22
- def run() -> bool:
23
- test_files = sorted((ROOT / "tests").glob("test_*.py"))
24
- total = 0
25
- failed = 0
26
- for f in test_files:
27
- spec = importlib.util.spec_from_file_location(f.stem, f)
28
- module = importlib.util.module_from_spec(spec)
29
- try:
30
- spec.loader.exec_module(module)
31
- except Exception:
32
- print(f"IMPORT ERROR {f.name}")
33
- traceback.print_exc()
34
- failed += 1
35
- total += 1
36
- continue
37
- for name in dir(module):
38
- if name.startswith("test_"):
39
- fn = getattr(module, name)
40
- if callable(fn):
41
- total += 1
42
- try:
43
- fn()
44
- print(f"PASS {f.name}::{name}")
45
- except Exception:
46
- failed += 1
47
- print(f"FAIL {f.name}::{name}")
48
- traceback.print_exc()
49
- print(f"\n{total - failed}/{total} passed")
50
- return failed == 0
51
-
52
-
53
- if __name__ == "__main__":
54
- ok = run()
55
- sys.exit(0 if ok else 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/fixtures.py DELETED
@@ -1,38 +0,0 @@
1
- """
2
- Deterministic synthetic OHLCV fixtures — used ONLY to test software
3
- correctness (causality, boundary logic, formula correctness), per spec
4
- section 100: "Use small deterministic synthetic/test fixtures ONLY for
5
- software correctness tests. Do not present synthetic-data output as
6
- real model performance."
7
-
8
- None of this data, and no result derived from it, is ever reported as
9
- a real accuracy/backtest number. Real performance requires the real
10
- checkpoint and real Yahoo Finance data (see README limitations).
11
- """
12
- from __future__ import annotations
13
-
14
- import numpy as np
15
- import pandas as pd
16
-
17
-
18
- def make_synthetic_ohlcv(n: int = 400, seed: int = 7, freq: str = "5min",
19
- start_price: float = 100.0) -> pd.DataFrame:
20
- rng = np.random.default_rng(seed)
21
- idx = pd.date_range("2024-01-01", periods=n, freq=freq, tz="UTC")
22
- returns = rng.normal(0, 0.001, n)
23
- close = start_price * np.cumprod(1 + returns)
24
- high = close * (1 + np.abs(rng.normal(0, 0.0005, n)))
25
- low = close * (1 - np.abs(rng.normal(0, 0.0005, n)))
26
- open_ = np.roll(close, 1)
27
- open_[0] = start_price
28
- volume = rng.integers(100, 1000, n).astype(float)
29
-
30
- df = pd.DataFrame(
31
- {"open": open_, "high": high, "low": low, "close": close, "volume": volume},
32
- index=idx,
33
- )
34
- df.index.name = "timestamp"
35
- # Enforce OHLC sanity after the random perturbation above.
36
- df["high"] = df[["open", "high", "low", "close"]].max(axis=1)
37
- df["low"] = df[["open", "high", "low", "close"]].min(axis=1)
38
- return df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_backtest_no_lookahead.py DELETED
@@ -1,322 +0,0 @@
1
- """
2
- The single most important test in this project: proves the backtest
3
- engine's decisions do not change when future rows are appended after
4
- the origin under examination. This is the actual causal-safety property
5
- sections 25/28/29/53 describe, checked by execution rather than assumed
6
- by code review.
7
-
8
- StubForecaster/RecordingStub below are TEST-ONLY doubles. They are
9
- never imported by app.py or moirai_model.py -- the production path
10
- always requires the real MoiraiMoEForecaster and the real downloaded
11
- checkpoint. Using a deterministic stub here is exactly what spec
12
- section 100 permits ("small deterministic synthetic/test fixtures ONLY
13
- for software correctness tests").
14
- """
15
- import numpy as np
16
-
17
- import config as cfg
18
- from backtest_engine import InsufficientDataError, WalkForwardBacktester
19
- from features import compute_indicators
20
- from fixtures import make_synthetic_ohlcv
21
- from moirai_model import QuantileForecast
22
-
23
-
24
- class StubForecaster:
25
- def predict(self, context_df, target_col, past_covariate_cols, prediction_length, freq):
26
- last_close = float(context_df[target_col].iloc[-1])
27
- drift = float(context_df[target_col].diff().mean())
28
- spread = float(context_df[target_col].std() * 0.1) or 0.01
29
- p50 = last_close + drift * prediction_length
30
- prices = [p50 - 2 * spread, p50 - spread, p50, p50 + spread, p50 + 2 * spread]
31
- return QuantileForecast(
32
- horizon=prediction_length, quantile_levels=[0.1, 0.25, 0.5, 0.75, 0.9],
33
- quantiles=np.array([prices]), samples=None,
34
- context_length_used=len(context_df), prediction_length=prediction_length,
35
- )
36
-
37
-
38
- def test_decisions_unaffected_by_future_rows():
39
- df = make_synthetic_ohlcv(n=500, seed=11) # 5-minute-spaced fixture, see tests/fixtures.py
40
- feat = compute_indicators(df)
41
-
42
- result_full = WalkForwardBacktester(StubForecaster(), timeframe="5m", horizon=3, context_length=60).run(feat)
43
-
44
- cutoff = 350
45
- result_trunc = WalkForwardBacktester(StubForecaster(), timeframe="5m", horizon=3, context_length=60).run(
46
- feat.iloc[:cutoff])
47
-
48
- trunc_actions = {d["origin_ts"]: d["action"] for d in result_trunc["decisions_log"]}
49
- checked = 0
50
- for d in result_full["decisions_log"]:
51
- if d["origin_ts"] in trunc_actions:
52
- checked += 1
53
- assert d["action"] == trunc_actions[d["origin_ts"]], (
54
- f"Decision at {d['origin_ts']} changed from "
55
- f"{trunc_actions[d['origin_ts']]} to {d['action']} when future rows were appended"
56
- )
57
- assert checked > 20, "test should compare a meaningful number of shared origins"
58
-
59
-
60
- def test_backtest_context_never_extends_past_origin():
61
- df = make_synthetic_ohlcv(n=300, seed=12)
62
- feat = compute_indicators(df)
63
- seen = []
64
-
65
- class RecordingStub:
66
- def predict(self, context_df, target_col, past_covariate_cols, prediction_length, freq):
67
- seen.append(context_df.index.max())
68
- last_close = float(context_df[target_col].iloc[-1])
69
- prices = [last_close * f for f in (0.99, 0.995, 1.0, 1.005, 1.01)]
70
- return QuantileForecast(
71
- horizon=prediction_length, quantile_levels=[0.1, 0.25, 0.5, 0.75, 0.9],
72
- quantiles=np.array([prices]), samples=None,
73
- context_length_used=len(context_df), prediction_length=prediction_length,
74
- )
75
-
76
- bt = WalkForwardBacktester(RecordingStub(), timeframe="5m", horizon=2, context_length=40)
77
- result = bt.run(feat)
78
-
79
- assert len(seen) > 0
80
- # Cross-check: every recorded max-context timestamp must be found in
81
- # the original feature_df index at or before the corresponding origin.
82
- origins = [d["origin_ts"] for d in result["decisions_log"]]
83
- assert len(seen) == len(origins)
84
- for origin_ts, max_ctx_ts in zip(origins, seen):
85
- assert max_ctx_ts <= origin_ts, (
86
- f"context leaked a row from {max_ctx_ts}, after origin {origin_ts}"
87
- )
88
-
89
-
90
- def test_backtest_passes_correct_freq_to_forecaster():
91
- """Directly guards the actual bug fixed here: freq must reach
92
- forecaster.predict() as the pandas alias for the requested timeframe,
93
- not be left for gluonts to infer from (possibly gapped) timestamps."""
94
- df = make_synthetic_ohlcv(n=200, seed=13)
95
- feat = compute_indicators(df)
96
- seen_freqs = []
97
-
98
- class FreqRecordingStub:
99
- def predict(self, context_df, target_col, past_covariate_cols, prediction_length, freq):
100
- seen_freqs.append(freq)
101
- last_close = float(context_df[target_col].iloc[-1])
102
- prices = [last_close] * 5
103
- return QuantileForecast(
104
- horizon=prediction_length, quantile_levels=[0.1, 0.25, 0.5, 0.75, 0.9],
105
- quantiles=np.array([prices]), samples=None,
106
- context_length_used=len(context_df), prediction_length=prediction_length,
107
- )
108
-
109
- bt = WalkForwardBacktester(FreqRecordingStub(), timeframe="5m", horizon=2, context_length=40)
110
- bt.run(feat)
111
-
112
- assert len(seen_freqs) > 0
113
- assert all(f == "5min" for f in seen_freqs)
114
-
115
-
116
- def test_run_raises_insufficient_data_error_not_a_bare_indexerror():
117
- """Regression test for the exact production bug: warm-up succeeds
118
- (150 bars easily covers the 50-period SMA), but the default
119
- context_length=256 leaves no room for even one origin. Before this
120
- fix, feature_df.index[start_idx] raised a bare, confusing IndexError
121
- once start_idx ran past the end of the frame -- app.py's generic
122
- `except Exception` then showed users a raw traceback for what is
123
- really just "your History window is too short for this context
124
- length", a normal, actionable, non-buggy condition. It must raise
125
- InsufficientDataError with the actual bar counts, not IndexError."""
126
- df = make_synthetic_ohlcv(n=150, seed=20)
127
- feat = compute_indicators(df)
128
-
129
- class StubForecaster:
130
- def predict(self, context_df, target_col, past_covariate_cols, prediction_length, freq):
131
- last_close = float(context_df[target_col].iloc[-1])
132
- prices = [last_close] * 5
133
- return QuantileForecast(
134
- horizon=prediction_length, quantile_levels=[0.1, 0.25, 0.5, 0.75, 0.9],
135
- quantiles=np.array([prices]), samples=None,
136
- context_length_used=len(context_df), prediction_length=prediction_length,
137
- )
138
-
139
- # context_length default (256) is deliberately left unset here, same
140
- # as app.py used to call it before context_length was exposed.
141
- bt = WalkForwardBacktester(StubForecaster(), timeframe="1d", horizon=5)
142
- try:
143
- bt.run(feat)
144
- raise AssertionError("expected InsufficientDataError")
145
- except InsufficientDataError as e:
146
- msg = str(e)
147
- assert "150" in msg, f"should state the 150 bars actually available: {msg}"
148
- assert "context_length" in msg
149
- except IndexError:
150
- raise AssertionError(
151
- "run() raised a bare IndexError instead of InsufficientDataError "
152
- "-- the bounds check regressed"
153
- )
154
-
155
-
156
- def test_run_succeeds_with_a_smaller_context_length_on_the_same_short_data():
157
- """Same 150-bar frame as above, but with a context_length that
158
- actually fits -- confirms the fix is a real validation, not just a
159
- blanket rejection of short windows."""
160
- df = make_synthetic_ohlcv(n=150, seed=21)
161
- feat = compute_indicators(df)
162
-
163
- class StubForecaster:
164
- def predict(self, context_df, target_col, past_covariate_cols, prediction_length, freq):
165
- last_close = float(context_df[target_col].iloc[-1])
166
- prices = [last_close] * 5
167
- return QuantileForecast(
168
- horizon=prediction_length, quantile_levels=[0.1, 0.25, 0.5, 0.75, 0.9],
169
- quantiles=np.array([prices]), samples=None,
170
- context_length_used=len(context_df), prediction_length=prediction_length,
171
- )
172
-
173
- bt = WalkForwardBacktester(StubForecaster(), timeframe="1d", horizon=5, context_length=64)
174
- result = bt.run(feat)
175
- assert result["num_origins"] > 0
176
-
177
-
178
- class _AlwaysBuyStub:
179
- """Deterministically predicts a clear, confident uptrend at every
180
- origin, so the first origin in a short window is always a BUY --
181
- lets the risk-management tests below control exactly which bar's
182
- intrabar high/low the stop/take-profit check will see."""
183
- def predict(self, context_df, target_col, past_covariate_cols, prediction_length, freq):
184
- last_close = float(context_df[target_col].iloc[-1])
185
- p50 = last_close * 1.01
186
- prices = [p50 * f for f in (0.999, 0.9995, 1.0, 1.0005, 1.001)] # tight interval -> clears min_probability
187
- return QuantileForecast(
188
- horizon=prediction_length, quantile_levels=[0.1, 0.25, 0.5, 0.75, 0.9],
189
- quantiles=np.array([prices]), samples=None,
190
- context_length_used=len(context_df), prediction_length=prediction_length,
191
- )
192
-
193
-
194
- def _first_buy_setup(seed=30, horizon=5, context_length=32):
195
- """Shared scaffold for the risk-management tests: builds a feature
196
- frame and runs a dry (no-risk) backtest just far enough to learn the
197
- first trade's exec_idx/entry_price/horizon exit_idx, without yet
198
- injecting the controlled intrabar move."""
199
- df = make_synthetic_ohlcv(n=120, seed=seed)
200
- feat = compute_indicators(df)
201
- bt = WalkForwardBacktester(_AlwaysBuyStub(), timeframe="1d", horizon=horizon, context_length=context_length)
202
- baseline = bt.run(feat)
203
- assert len(baseline["trades"]) > 0, "expected at least one BUY trade from the always-buy stub"
204
- first = baseline["trades"][0]
205
- exec_idx = feat.index.get_loc(first.entry_ts)
206
- horizon_exit_idx = feat.index.get_loc(first.exit_ts)
207
- return feat, bt, first, exec_idx, horizon_exit_idx
208
-
209
-
210
- def test_stop_loss_exits_early_at_the_breaching_bars_close():
211
- feat, _, baseline_trade, exec_idx, horizon_exit_idx = _first_buy_setup(seed=30)
212
- entry_price = baseline_trade.entry_price
213
- mid_idx = exec_idx + 2
214
- assert mid_idx < horizon_exit_idx, "test needs at least one bar strictly between entry and horizon exit"
215
-
216
- feat = feat.copy()
217
- # A real ~3% adverse close at mid_idx (breaching a 2% stop), plus an
218
- # even sharper 5% intrabar wick on low -- close is what the trade
219
- # actually exits at (documented convention), low is only what
220
- # triggers the check, so both must move for this test to be
221
- # meaningful: moving only the wick (leaving close unchanged) would
222
- # trigger the stop but show none of it in net_pnl, silently testing
223
- # nothing about the P&L.
224
- i_close = feat.columns.get_loc("close")
225
- i_low = feat.columns.get_loc("low")
226
- i_high = feat.columns.get_loc("high")
227
- feat.iloc[mid_idx, i_close] = entry_price * 0.97
228
- feat.iloc[mid_idx, i_low] = entry_price * 0.95
229
- feat.iloc[mid_idx, i_high] = max(feat["high"].iloc[mid_idx], entry_price * 0.97)
230
-
231
- bt = WalkForwardBacktester(_AlwaysBuyStub(), timeframe="1d", horizon=5, context_length=32,
232
- risk=cfg.RiskManagement(stop_loss_frac=0.02))
233
- result = bt.run(feat)
234
- trade = result["trades"][0]
235
-
236
- assert trade.exit_reason == "stop_loss"
237
- assert trade.exit_ts == feat.index[mid_idx], "should exit at the breaching bar, not ride out the full horizon"
238
- assert trade.exit_price == float(feat["close"].iloc[mid_idx]), "exit fills at that bar's CLOSE, not the stop level or the wick"
239
- assert trade.net_pnl < -0.025, "the ~3% adverse close should show up as a real loss"
240
-
241
-
242
- def test_take_profit_exits_early_on_a_sell(monkeypatch=None):
243
- """Same mechanism, mirrored for a SELL position: a favorable close
244
- (price down) should trigger take-profit and exit early."""
245
- class _AlwaysSellStub:
246
- def predict(self, context_df, target_col, past_covariate_cols, prediction_length, freq):
247
- last_close = float(context_df[target_col].iloc[-1])
248
- p50 = last_close * 0.99
249
- prices = [p50 * f for f in (0.999, 0.9995, 1.0, 1.0005, 1.001)]
250
- return QuantileForecast(
251
- horizon=prediction_length, quantile_levels=[0.1, 0.25, 0.5, 0.75, 0.9],
252
- quantiles=np.array([prices]), samples=None,
253
- context_length_used=len(context_df), prediction_length=prediction_length,
254
- )
255
-
256
- df = make_synthetic_ohlcv(n=120, seed=31)
257
- feat = compute_indicators(df)
258
- baseline = WalkForwardBacktester(_AlwaysSellStub(), timeframe="1d", horizon=5, context_length=32).run(feat)
259
- assert len(baseline["trades"]) > 0
260
- first = baseline["trades"][0]
261
- entry_price = first.entry_price
262
- exec_idx = feat.index.get_loc(first.entry_ts)
263
- horizon_exit_idx = feat.index.get_loc(first.exit_ts)
264
- mid_idx = exec_idx + 2
265
- assert mid_idx < horizon_exit_idx
266
-
267
- feat = feat.copy()
268
- i_close = feat.columns.get_loc("close")
269
- i_low = feat.columns.get_loc("low")
270
- i_high = feat.columns.get_loc("high")
271
- feat.iloc[mid_idx, i_close] = entry_price * 0.97 # real 3% favorable close for a SELL
272
- feat.iloc[mid_idx, i_low] = entry_price * 0.95 # sharper intrabar favorable wick
273
- feat.iloc[mid_idx, i_high] = min(feat["high"].iloc[mid_idx], entry_price * 0.97)
274
-
275
- bt = WalkForwardBacktester(_AlwaysSellStub(), timeframe="1d", horizon=5, context_length=32,
276
- risk=cfg.RiskManagement(take_profit_frac=0.02))
277
- result = bt.run(feat)
278
- trade = result["trades"][0]
279
- assert trade.exit_reason == "take_profit"
280
- assert trade.exit_ts == feat.index[mid_idx]
281
- assert trade.net_pnl > 0.025
282
-
283
-
284
- def test_stop_and_take_profit_both_breached_same_bar_assumes_stop():
285
- """OHLC alone can't say which of a bar's high/low happened first --
286
- if both levels are crossed within the same bar, the documented,
287
- conservative assumption is the stop (see _scan_for_early_exit)."""
288
- feat, _, baseline_trade, exec_idx, horizon_exit_idx = _first_buy_setup(seed=32)
289
- entry_price = baseline_trade.entry_price
290
- mid_idx = exec_idx + 2
291
- assert mid_idx < horizon_exit_idx
292
-
293
- feat = feat.copy()
294
- feat.iloc[mid_idx, feat.columns.get_loc("low")] = entry_price * 0.95 # breaches a 2% stop
295
- feat.iloc[mid_idx, feat.columns.get_loc("high")] = entry_price * 1.05 # AND a 2% take-profit
296
-
297
- bt = WalkForwardBacktester(_AlwaysBuyStub(), timeframe="1d", horizon=5, context_length=32,
298
- risk=cfg.RiskManagement(stop_loss_frac=0.02, take_profit_frac=0.02))
299
- result = bt.run(feat)
300
- assert result["trades"][0].exit_reason == "stop_loss"
301
-
302
-
303
- def test_risk_management_default_none_reproduces_baseline_exactly():
304
- """The whole point of defaulting risk=None: every backtest run
305
- before this feature existed must produce byte-identical trades
306
- afterward, unless someone explicitly opts in."""
307
- df = make_synthetic_ohlcv(n=150, seed=33)
308
- feat = compute_indicators(df)
309
- r1 = WalkForwardBacktester(_AlwaysBuyStub(), timeframe="1d", horizon=5, context_length=32).run(feat)
310
- r2 = WalkForwardBacktester(_AlwaysBuyStub(), timeframe="1d", horizon=5, context_length=32,
311
- risk=cfg.RiskManagement()).run(feat) # explicit but both fields None
312
- assert [t.net_pnl for t in r1["trades"]] == [t.net_pnl for t in r2["trades"]]
313
- assert all(t.exit_reason == "horizon" for t in r1["trades"])
314
-
315
-
316
- def test_trade_records_decision_confidence_for_entry_filter_analysis():
317
- """prob_favorable/interval_width_pct must reach the Trade ledger --
318
- added so entry-filter tuning can be based on real per-trade
319
- confidence instead of guessing from aggregate stats alone."""
320
- feat, bt, first, _, _ = _first_buy_setup(seed=34)
321
- assert 0.0 <= first.prob_favorable <= 1.0
322
- assert first.interval_width_pct >= 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_config.py DELETED
@@ -1,32 +0,0 @@
1
- """
2
- Covers the small config.py helpers added to fix the Live tab's missing
3
- asset-class cost-awareness: infer_asset_class() (symbol -> "forex"/
4
- "crypto", used only because the Live tab has no explicit asset-class
5
- selector the way the Backtest tab's `b_class` radio does) and
6
- round_trip_cost_frac() (the single, now-shared cost-fraction formula
7
- both tabs call, instead of backtest_engine.py owning a private copy the
8
- Live tab couldn't reach).
9
- """
10
- import config as cfg
11
-
12
-
13
- def test_infer_asset_class_curated_lists():
14
- assert cfg.infer_asset_class("EURUSD=X") == "forex"
15
- assert cfg.infer_asset_class("BTC-USD") == "crypto"
16
-
17
-
18
- def test_infer_asset_class_falls_back_to_yahoo_ticker_conventions():
19
- # Not in the curated lists, but still recognizably Yahoo forex/crypto
20
- # syntax -- allow_custom_value lets a person type either into the UI.
21
- assert cfg.infer_asset_class("USDPLN=X") == "forex"
22
- assert cfg.infer_asset_class("PEPE-USD") == "crypto"
23
- assert cfg.infer_asset_class("ETH-USDT") == "crypto"
24
-
25
-
26
- def test_infer_asset_class_default_for_unrecognized_symbol():
27
- assert cfg.infer_asset_class("SPY") == "forex"
28
-
29
-
30
- def test_round_trip_cost_frac_matches_hand_computed_bps():
31
- profile = cfg.COST_PROFILES["crypto"] # spread=2.0 + commission=10.0 + slippage=2.0 bps
32
- assert abs(cfg.round_trip_cost_frac(profile) - 0.0014) < 1e-12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_data_layer.py DELETED
@@ -1,117 +0,0 @@
1
- """
2
- Covers the parts of data_layer.py that don't require network access:
3
- validate_ohlcv (duplicate/invalid/NaN handling), LocalCache round-trip,
4
- and data_freshness classification. fetch_ohlcv() itself needs a real
5
- network call and is exercised only by the smoke-test script (expected
6
- to fail here with DataSourceError -- see README).
7
- """
8
- import numpy as np
9
- import pandas as pd
10
-
11
- from data_layer import LocalCache, data_freshness, validate_ohlcv
12
-
13
-
14
- def test_validate_removes_duplicates_and_keeps_first():
15
- idx = pd.to_datetime(["2024-01-01", "2024-01-01", "2024-01-02"], utc=True)
16
- df = pd.DataFrame({
17
- "open": [1, 999, 2], "high": [1, 999, 2], "low": [1, 999, 2],
18
- "close": [1, 999, 2], "volume": [10, 10, 10],
19
- }, index=idx)
20
- out, report = validate_ohlcv(df)
21
- assert report.duplicates_removed == 1
22
- assert len(out) == 2
23
- assert out.iloc[0]["close"] == 1 # first occurrence kept, not the duplicate
24
-
25
-
26
- def test_validate_removes_invalid_ohlc_relationship():
27
- idx = pd.date_range("2024-01-01", periods=3, freq="1h", tz="UTC")
28
- df = pd.DataFrame({
29
- "open": [10, 10, 10],
30
- "high": [11, 9, 11], # row 1: high < open -> invalid
31
- "low": [9, 9, 9],
32
- "close": [10.5, 10.5, 10.5],
33
- "volume": [5, 5, 5],
34
- }, index=idx)
35
- out, report = validate_ohlcv(df)
36
- assert report.invalid_ohlc_removed == 1
37
- assert len(out) == 2
38
-
39
-
40
- def test_validate_removes_nan_rows_and_reports_them():
41
- idx = pd.date_range("2024-01-01", periods=3, freq="1h", tz="UTC")
42
- df = pd.DataFrame({
43
- "open": [10, np.nan, 10], "high": [11, 11, 11], "low": [9, 9, 9],
44
- "close": [10.5, 10.5, 10.5], "volume": [5, 5, 5],
45
- }, index=idx)
46
- out, report = validate_ohlcv(df)
47
- assert report.nan_rows_removed == 1
48
- assert len(out) == 2
49
-
50
-
51
- def test_validate_marks_negative_volume_unavailable_not_invented():
52
- idx = pd.date_range("2024-01-01", periods=2, freq="1h", tz="UTC")
53
- df = pd.DataFrame({
54
- "open": [10, 10], "high": [11, 11], "low": [9, 9],
55
- "close": [10.5, 10.5], "volume": [5, -3],
56
- }, index=idx)
57
- out, _ = validate_ohlcv(df)
58
- assert np.isnan(out.iloc[1]["volume"]), "negative volume must become NaN, never a guessed positive number"
59
-
60
-
61
- def test_local_cache_round_trip(tmp_path_str="/tmp/_moirai_test_cache.sqlite3"):
62
- import os
63
- if os.path.exists(tmp_path_str):
64
- os.remove(tmp_path_str)
65
- cache = LocalCache(tmp_path_str)
66
- idx = pd.date_range("2024-01-01", periods=3, freq="1h", tz="UTC")
67
- df = pd.DataFrame({"open": [1, 2, 3], "high": [1, 2, 3], "low": [1, 2, 3],
68
- "close": [1, 2, 3], "volume": [1, 2, 3]}, index=idx)
69
-
70
- assert cache.get("EURUSD=X", "1h", "yfinance", "2024-01-01", "2024-01-02") is None
71
- cache.set("EURUSD=X", "1h", "yfinance", "2024-01-01", "2024-01-02", df)
72
- round_tripped = cache.get("EURUSD=X", "1h", "yfinance", "2024-01-01", "2024-01-02")
73
- assert round_tripped is not None
74
- assert len(round_tripped) == 3
75
- assert list(round_tripped["close"]) == [1, 2, 3]
76
- os.remove(tmp_path_str)
77
-
78
-
79
- def test_data_freshness_classification():
80
- now = pd.Timestamp.now(tz="UTC")
81
- fresh = data_freshness(now - pd.Timedelta(minutes=1), "1h")
82
- stale = data_freshness(now - pd.Timedelta(hours=10), "1h")
83
- assert fresh["status"] == "fresh"
84
- assert stale["status"] == "stale"
85
-
86
-
87
- def test_resolve_history_window_days():
88
- from data_layer import resolve_history_window
89
- kind, value = resolve_history_window("1 day")
90
- assert kind == "start"
91
- kind, value = resolve_history_window("30 days")
92
- assert kind == "start"
93
-
94
-
95
- def test_resolve_history_window_months_uses_calendar_months():
96
- from data_layer import resolve_history_window
97
- from datetime import datetime, timezone
98
- kind, value = resolve_history_window("3 months")
99
- assert kind == "start"
100
- now = datetime.now(timezone.utc)
101
- delta_days = (now - value).days
102
- # 3 calendar months is 89-92 days depending on which months are spanned --
103
- # NOT exactly 90 (which a naive 30*3 approximation would assume).
104
- assert 88 <= delta_days <= 93, f"expected ~3 calendar months, got {delta_days} days"
105
-
106
-
107
- def test_resolve_history_window_max():
108
- from data_layer import resolve_history_window
109
- kind, value = resolve_history_window("max")
110
- assert kind == "period"
111
- assert value == "max"
112
-
113
-
114
- def test_resolve_history_window_years():
115
- from data_layer import resolve_history_window
116
- kind, value = resolve_history_window("2 years")
117
- assert kind == "start"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_decision_and_metrics.py DELETED
@@ -1,112 +0,0 @@
1
- """
2
- Verifies decision_engine.decide() produces the right action for
3
- unambiguous synthetic inputs, and that the metrics formulas match
4
- hand-computed values on small examples.
5
- """
6
- import numpy as np
7
- import pandas as pd
8
-
9
- from decision_engine import decide
10
- from metrics import (
11
- directional_accuracy, mae, max_drawdown, profit_factor, rmse,
12
- sharpe_ratio, smape,
13
- )
14
-
15
-
16
- class _FakeForecast:
17
- def __init__(self, quantiles, levels=(0.1, 0.25, 0.5, 0.75, 0.9)):
18
- self.quantiles = np.array([quantiles]) # single horizon step
19
- self.quantile_levels = list(levels)
20
-
21
-
22
- def test_decide_buy_on_clear_positive_move():
23
- fc = _FakeForecast([100.6, 100.7, 100.8, 100.9, 101.0])
24
- d = decide(fc, price_now=100.0, cost_estimate_frac=0.0002)
25
- assert d.action == "BUY", d.rationale
26
-
27
-
28
- def test_decide_sell_on_clear_negative_move():
29
- fc = _FakeForecast([99.0, 99.1, 99.2, 99.3, 99.4])
30
- d = decide(fc, price_now=100.0, cost_estimate_frac=0.0002)
31
- assert d.action == "SELL", d.rationale
32
-
33
-
34
- def test_decide_hold_on_wide_interval():
35
- fc = _FakeForecast([90.0, 96.0, 100.2, 104.0, 110.0]) # huge P10-P90 spread
36
- d = decide(fc, price_now=100.0, cost_estimate_frac=0.0002)
37
- assert d.action == "HOLD"
38
- assert "wide" in d.hold_reason
39
-
40
-
41
- def test_decide_hold_when_edge_below_cost():
42
- fc = _FakeForecast([99.95, 99.98, 100.02, 100.05, 100.08]) # tiny move, cost-eaten
43
- d = decide(fc, price_now=100.0, cost_estimate_frac=0.0005)
44
- assert d.action == "HOLD"
45
-
46
-
47
- def test_decide_sell_requires_correct_directional_probability_not_buy_complement():
48
- """Regression test for the SELL-branch probability bug: the engine
49
- used to gate SELL on `1 - P(return>cost)`, which is P(return<=cost) --
50
- a much easier bar to clear than the directionally-correct P(return<
51
- -cost), since it's satisfied by anything in the whole -inf..cost
52
- range, not just the profitable-to-short range below -cost. This
53
- forecast is built so the two disagree: true P(return<-cost) is only
54
- ~0.54 (below the 0.55 min_probability gate -> should HOLD), while the
55
- old buggy stand-in 1-P(return>cost) comes out ~0.56 (would wrongly
56
- clear the gate -> SELL). p50 alone already passes the SELL threshold,
57
- so this isolates the probability-gate bug specifically."""
58
- price_now = 100.0
59
- target_returns = [-0.006, -0.004, -0.0025, 0.01, 0.02]
60
- prices = [price_now * (1 + r) for r in target_returns]
61
- fc = _FakeForecast(prices)
62
- d = decide(fc, price_now=price_now, cost_estimate_frac=0.0005)
63
- assert d.action == "HOLD", (
64
- f"expected HOLD (true P(return<-cost) ~0.54 is below the 0.55 "
65
- f"confidence gate), got {d.action}: {d.rationale}"
66
- )
67
- assert "below threshold" in d.hold_reason
68
-
69
-
70
- def test_decide_sell_rationale_reports_the_sell_side_probability():
71
- """A confident SELL's rationale should report P(return<-cost) (high,
72
- matching the actual confidence behind the decision) rather than
73
- P(return>cost) (near-zero for any clear downward forecast, which
74
- reads as "no confidence" even though the decision is well-justified)."""
75
- fc = _FakeForecast([99.0, 99.1, 99.2, 99.3, 99.4])
76
- d = decide(fc, price_now=100.0, cost_estimate_frac=0.0002)
77
- assert d.action == "SELL"
78
- assert "P(return<-cost)" in d.rationale, d.rationale
79
- assert d.prob_favorable > 0.9, "a clearly negative forecast should show high SELL-side confidence"
80
-
81
-
82
- def test_point_metrics_hand_computed():
83
- y_true, y_pred = [10, 12, 14], [11, 11, 15]
84
- assert abs(mae(y_true, y_pred) - 1.0) < 1e-9
85
- assert abs(rmse(y_true, y_pred) - np.sqrt(1.0)) < 1e-9
86
-
87
-
88
- def test_smape_in_valid_range():
89
- v = smape([100], [90])
90
- assert 0 <= v <= 2
91
-
92
-
93
- def test_directional_accuracy():
94
- y_true_ret = [0.01, -0.02, 0.03, -0.01]
95
- y_pred_ret = [0.02, -0.01, -0.01, -0.005]
96
- # signs: true=[+,-,+,-], pred=[+,-,-,-] -> matches at idx 0,1,3 = 3/4
97
- assert abs(directional_accuracy(y_true_ret, y_pred_ret) - 0.75) < 1e-9
98
-
99
-
100
- def test_max_drawdown_known_series():
101
- equity = pd.Series([1.0, 1.2, 0.9, 1.1])
102
- assert abs(max_drawdown(equity) - (0.9 / 1.2 - 1.0)) < 1e-9
103
-
104
-
105
- def test_profit_factor_known_values():
106
- pnls = [10, -5, 20, -5] # gains=30, losses=10 -> PF=3.0
107
- assert abs(profit_factor(pnls) - 3.0) < 1e-9
108
-
109
-
110
- def test_sharpe_nan_when_no_variance():
111
- r = pd.Series([0.01, 0.01, 0.01, 0.01])
112
- assert np.isnan(sharpe_ratio(r, timeframe_minutes=60))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_features_and_leakage.py DELETED
@@ -1,122 +0,0 @@
1
- """
2
- Verifies: (1) indicators are truly causal -- recomputing on a truncated
3
- frame gives bit-identical values at every shared timestamp; (2) warm-up
4
- is correctly identified and never fabricated; (3) causal_resample
5
- builds correct OHLCV and drops incomplete trailing bins; (4) the
6
- leakage auditor actually catches an injected future row.
7
- """
8
- import pandas as pd
9
-
10
- from data_layer import causal_resample
11
- from features import compute_indicators, warm_up_valid_from
12
- from fixtures import make_synthetic_ohlcv
13
- from leakage_audit import LeakageError, assert_no_future_rows, audit_feature_frame
14
-
15
-
16
- def test_indicators_are_causal():
17
- df = make_synthetic_ohlcv(n=200, seed=1)
18
- full = compute_indicators(df)
19
- cutoff = 150
20
- truncated = compute_indicators(df.iloc[:cutoff])
21
- check_cols = [c for c in full.columns if c not in ("open", "high", "low", "close", "volume")]
22
- pd.testing.assert_frame_equal(
23
- full.iloc[:cutoff][check_cols].reset_index(drop=True),
24
- truncated[check_cols].reset_index(drop=True),
25
- check_exact=False, rtol=1e-10,
26
- )
27
-
28
-
29
- def test_warm_up_detection():
30
- df = make_synthetic_ohlcv(n=100, seed=2)
31
- feat = compute_indicators(df)
32
- warm = warm_up_valid_from(feat)
33
- assert warm > 0, "warm-up should be > 0 given a 50-period SMA is in the feature set"
34
- assert feat.iloc[warm:].isna().sum().sum() == 0, "no NaNs should remain after warm-up trim"
35
-
36
-
37
- def test_warm_up_raises_on_insufficient_history():
38
- df = make_synthetic_ohlcv(n=10, seed=3) # far too short for a 50-period SMA
39
- feat = compute_indicators(df)
40
- try:
41
- warm_up_valid_from(feat)
42
- raise AssertionError("expected ValueError for insufficient history")
43
- except ValueError:
44
- pass
45
-
46
-
47
- def test_warm_up_error_names_the_blocking_indicator_and_bar_counts():
48
- """The message must be actionable (spec section 98), not just
49
- "insufficient data" -- it should name sma_50 (the true bottleneck at
50
- 50 bars, longer than every other configured indicator period) and
51
- state the actual bar counts involved, diagnosed empirically off the
52
- real computed columns rather than a hand-derived formula."""
53
- df = make_synthetic_ohlcv(n=10, seed=3)
54
- feat = compute_indicators(df)
55
- try:
56
- warm_up_valid_from(feat)
57
- raise AssertionError("expected ValueError for insufficient history")
58
- except ValueError as e:
59
- msg = str(e)
60
- assert "sma_50" in msg, msg
61
- assert "10" in msg, "should mention the 10 bars actually available"
62
-
63
- # A frame with SOME but not all indicators warmed up should name only
64
- # the genuinely-blocking one(s), not every column that has ever had a
65
- # NaN early on.
66
- df2 = make_synthetic_ohlcv(n=30, seed=4) # enough for rsi(14)/bb(20), not sma_50
67
- feat2 = compute_indicators(df2)
68
- try:
69
- warm_up_valid_from(feat2)
70
- raise AssertionError("expected ValueError for insufficient history")
71
- except ValueError as e:
72
- msg = str(e)
73
- assert "sma_50" in msg, msg
74
- assert "rsi" not in msg, f"rsi is already warmed up by bar 30, shouldn't be blamed: {msg}"
75
-
76
-
77
- def test_causal_resample_ohlc_correctness():
78
- idx = pd.date_range("2024-01-01", periods=6, freq="5min", tz="UTC")
79
- df = pd.DataFrame({
80
- "open": [1, 2, 3, 4, 5, 6],
81
- "high": [1.5, 2.5, 3.5, 4.5, 5.5, 6.5],
82
- "low": [0.5, 1.5, 2.5, 3.5, 4.5, 5.5],
83
- "close": [1.2, 2.2, 3.2, 4.2, 5.2, 6.2],
84
- "volume": [10, 10, 10, 10, 10, 10],
85
- }, index=idx)
86
- out = causal_resample(df, "5m", "10m")
87
- assert len(out) == 3, f"expected 3 complete 10m bars from 6 5m bars, got {len(out)}"
88
- first = out.iloc[0]
89
- assert first["open"] == 1
90
- assert first["high"] == 2.5
91
- assert first["low"] == 0.5
92
- assert first["close"] == 2.2
93
- assert first["volume"] == 20
94
-
95
-
96
- def test_causal_resample_drops_incomplete_trailing_bin():
97
- idx = pd.date_range("2024-01-01", periods=5, freq="5min", tz="UTC") # 2 full 10m bins + 1 partial
98
- df = pd.DataFrame({
99
- "open": range(5), "high": range(5), "low": range(5),
100
- "close": range(5), "volume": [1] * 5,
101
- }, index=idx).astype(float)
102
- out = causal_resample(df, "5m", "10m")
103
- assert len(out) == 2, f"the trailing partial bin must be dropped, got {len(out)} bins"
104
-
105
-
106
- def test_leakage_audit_catches_future_row():
107
- idx = pd.date_range("2024-01-01", periods=5, freq="1h", tz="UTC")
108
- df = pd.DataFrame({"close": range(5)}, index=idx)
109
- origin = idx[2]
110
-
111
- report_ok = audit_feature_frame(df.loc[:origin], origin)
112
- assert report_ok["passed"] is True
113
-
114
- try:
115
- assert_no_future_rows(df, origin, "test_df")
116
- raise AssertionError("expected LeakageError since df contains rows after origin")
117
- except LeakageError:
118
- pass
119
-
120
- report_bad = audit_feature_frame(df, origin)
121
- assert report_bad["passed"] is False
122
- assert len(report_bad["violations"]) == 1