Deleted all
Browse files- README.md +0 -512
- app.py +0 -251
- backtest_engine.py +0 -235
- config.py +0 -196
- data_layer.py +0 -254
- decision_engine.py +0 -102
- features.py +0 -180
- leakage_audit.py +0 -54
- metrics.py +0 -127
- moirai_model.py +0 -245
- persistence.py +0 -85
- requirements.txt +0 -73
- run_tests.py +0 -55
- tests/fixtures.py +0 -38
- tests/test_backtest_no_lookahead.py +0 -322
- tests/test_config.py +0 -32
- tests/test_data_layer.py +0 -117
- tests/test_decision_and_metrics.py +0 -112
- tests/test_features_and_leakage.py +0 -122
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" [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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|