Lk / README.md
Kashaf1's picture
Upload moirai_forecast_app contents
ef20ebe
|
Raw History Blame Contribute Delete
53 kB

A newer version of the Gradio SDK is available: 6.31.0

Upgrade
metadata
title: Moirai-MoE Forex & Crypto Forecaster
emoji: πŸ“ˆ
colorFrom: blue
colorTo: indigo
sdk: gradio
sdk_version: 6.26.0
python_version: '3.11'
app_file: app.py
pinned: false
license: cc-by-nc-4.0

Moirai-MoE-1.0-R-Small β€” Forex & Crypto Forecasting

A real forecasting + walk-forward backtesting application whose sole predictive model is Salesforce/moirai-moe-1.0-R-small. No other ML model exists anywhere in this codebase as a fallback or replacement β€” grep for sklearn|xgboost|lightgbm|catboost|prophet|statsmodels in *.py and you'll find nothing.

Read this first: what "real" means in this build

This project was built and unit-tested inside a sandboxed environment with no outbound network access (confirmed: curl to pypi.org returns 403 host_not_allowed). That means, in this environment, it is not possible to:

  • pip install uni2ts / torch / gluonts / yfinance / gradio
  • download the actual Salesforce/moirai-moe-1.0-R-small checkpoint
  • call the real Yahoo Finance API

So rather than fabricate "it works" β€” which is the one thing this project's own spec (and honest engineering generally) forbids β€” here is exactly what was and wasn't verified, using the project's own VERIFIED / IMPLEMENTED / BLOCKED vocabulary.

Final Validation Report

MODEL VERIFICATION

  • 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.
  • 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.
  • 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).

DATA VERIFICATION

  • 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.
  • OHLCV validation, causal resampling (5mβ†’10m, 1hβ†’2h/4h), local SQLite cache, freshness classification: VERIFIED β€” real unit tests, real execution, see below.

FEATURE VERIFICATION

  • 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.
  • Warm-up detection/trimming: VERIFIED.

LEAKAGE VERIFICATION

  • leakage_audit.py assertions: VERIFIED β€” test_leakage_audit_catches_future_row injects an actual violation and confirms it's caught.
  • 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.

FORECAST / TRADING VERIFICATION

  • 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.
  • 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.

RESOURCE VERIFICATION

  • 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.

DEPLOYMENT VERIFICATION

  • 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.
  • 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.

ACTUAL TEST RUN (executed in this sandbox, unedited)

$ python3 run_tests.py
PASS  test_backtest_no_lookahead.py::test_backtest_context_never_extends_past_origin
PASS  test_backtest_no_lookahead.py::test_decisions_unaffected_by_future_rows
PASS  test_data_layer.py::test_data_freshness_classification
PASS  test_data_layer.py::test_local_cache_round_trip
PASS  test_data_layer.py::test_validate_marks_negative_volume_unavailable_not_invented
PASS  test_data_layer.py::test_validate_removes_duplicates_and_keeps_first
PASS  test_data_layer.py::test_validate_removes_invalid_ohlc_relationship
PASS  test_data_layer.py::test_validate_removes_nan_rows_and_reports_them
PASS  test_decision_and_metrics.py::test_decide_buy_on_clear_positive_move
PASS  test_decision_and_metrics.py::test_decide_hold_on_wide_interval
PASS  test_decision_and_metrics.py::test_decide_hold_when_edge_below_cost
PASS  test_decision_and_metrics.py::test_decide_sell_on_clear_negative_move
PASS  test_decision_and_metrics.py::test_directional_accuracy
PASS  test_decision_and_metrics.py::test_max_drawdown_known_series
PASS  test_decision_and_metrics.py::test_point_metrics_hand_computed
PASS  test_decision_and_metrics.py::test_profit_factor_known_values
PASS  test_decision_and_metrics.py::test_sharpe_nan_when_no_variance
PASS  test_decision_and_metrics.py::test_smape_in_valid_range
PASS  test_features_and_leakage.py::test_causal_resample_drops_incomplete_trailing_bin
PASS  test_features_and_leakage.py::test_causal_resample_ohlc_correctness
PASS  test_features_and_leakage.py::test_indicators_are_causal
PASS  test_features_and_leakage.py::test_leakage_audit_catches_future_row
PASS  test_features_and_leakage.py::test_warm_up_detection
PASS  test_features_and_leakage.py::test_warm_up_raises_on_insufficient_history

24/24 passed

One real bug was actually found and fixed during this run (pd.read_json needed io.StringIO on the sandbox's pandas 3.0.2) β€” left in this history deliberately, since "we ran it and fixed what broke" is the whole point of section 6 (real execution validation vs. static checks).

Bug-fix pass β€” Aug 2026 (real user-reported crash + related issues)

Triggered by a real user hitting BACKTEST ERROR in the Backtest tab (BTC-USD, 1d, History window = 4 days). This pass ran in a sandbox with actual PyPI network access (a first for this repo) β€” pandas/numpy were already present, and pytz, yfinance==1.7.0, and gradio==6.26.0 (the exact requirements.txt pin) were installed alongside them, so both import app (Blocks graph construction) and the full existing + new test suite were executed for real, not just reasoned about. torch/uni2ts/gluonts were left out (heavy, and the real checkpoint/forward-pass claims below don't depend on them).

  1. The reported crash: warm_up_valid_from() (features.py) correctly refuses to fabricate a warmed-up row β€” the bug was that its message just said "insufficient historical data" with no numbers. 4 daily bars can't warm up a 50-period SMA (or any of the other nine indicator families), but the message didn't say which indicator was blocking, how many bars it needed, or how many were available. Fix: the message now names the exact slowest-to-warm-up column(s) and the real bar counts, diagnosed empirically off the actual computed columns (never a hand-derived per-indicator formula β€” MACD and ADX are ewm-of-ewm chains, and guessing their effective warm-up length is exactly the kind of static assumption section 6 argues against). app.py's Backtest tab also didn't catch this ValueError specially the way the Live tab already did β€” it fell through to the generic handler and showed a raw Python traceback for a normal, actionable, non-buggy condition. Fixed to match the Live tab's existing clean-message behavior.
  2. A related, unreported latent bug found while fixing #1: even when warm-up succeeds, WalkForwardBacktester.run()'s default context_length=256 (never exposed in the UI, so every backtest run used it regardless of History window) could still exceed the available bars β€” and app.py never passed a context_length at all, so production always hit the hardcoded default. When that happened, feature_df.index[start_idx] raised a bare IndexError once start_idx ran past the end of the frame β€” reproduced directly with 150 synthetic daily bars. Fix: run() now checks warm-up + context_length + horizon + execution_delay against the actual bar count before indexing, and raises a new InsufficientDataError stating the exact shortfall (e.g. "150 bars available, needs at least 312 ... widen History window by at least 162 bars"). Also added a context_length slider to the Backtest tab's new "Advanced" accordion (default 256, so nobody's existing behavior changes unless they open it) β€” spec section 25 wants assumptions explicit and configurable, and this one previously wasn't either.
  3. decision_engine.decide()'s SELL branch was gating on the wrong probability: it computed prob_favorable = P(return > cost) (correct for BUY) and then used 1 - prob_favorable = P(return <= cost) as the SELL-side confidence check. That's a much easier bar to clear than the directionally-correct P(return < -cost) β€” it's satisfied by anything in the whole -inf..cost range, not just the actually-profitable-to- short range below -cost β€” so SELL decisions could clear the min_probability gate with materially less real confidence than BUY decisions did. Confirmed with a constructed forecast where the two disagree: true P(return<-cost) β‰ˆ 0.54 (below the 0.55 gate β†’ should HOLD) while the old 1-prob_favorable β‰ˆ 0.56 (wrongly cleared the gate β†’ SELL). Fix: SELL now computes its own mirrored probability, P(return < -cost), instead of reusing BUY's complement. The rationale string also now reports whichever probability the taken action actually used β€” previously a confident SELL displayed "P(return>cost)=0.00", which reads as "no confidence" even though the real (and high) SELL-side confidence was correct.
  4. The Live tab never passed an asset-specific cost to decide(): unlike the Backtest tab (b_class radio β†’ a real COST_PROFILES entry), run_live_forecast() called decide(forecast, price_now) with no cost_estimate_frac, silently falling back to DecisionConfig's flat generic 5bps for every symbol β€” too high for real forex spreads, too low for real crypto costs. Fix: added config.infer_asset_class() (checks the curated symbol lists first, then Yahoo's own ticker conventions β€” =X for forex, -USD/-USDT/ etc. for crypto β€” for anything typed via allow_custom_value) and config.round_trip_cost_frac() (the cost-fraction formula, moved out of backtest_engine.py's private copy so both tabs share one implementation). The Live tab's output now also states which cost profile was inferred and the resulting round-trip cost, so the assumption is visible, not silent.
  5. Hardening, not a confirmed live bug: requirements.txt pinned yfinance>=0.2.40 with no ceiling β€” the one dependency in this file without the floor+ceiling treatment gradio already gets. This sandbox pass's pip install resolved yfinance==1.7.0 (a major-version jump from the 0.2.x line this was originally written against); its Ticker.history() signature and returned column names were checked against data_layer.py's usage and are unchanged, so the floor wasn't tightened β€” but a ceiling (<2.0) was added so a future, unverified 2.x can't be silently picked up the way an unpinned gradio once was (see deployment history below).

All four code fixes above have real regression tests (test_warm_up_error_names_the_blocking_indicator_and_bar_counts, test_run_raises_insufficient_data_error_not_a_bare_indexerror + test_run_succeeds_with_a_smaller_context_length_on_the_same_short_data, test_decide_sell_requires_correct_directional_probability_not_buy_complement

  • test_decide_sell_rationale_reports_the_sell_side_probability, tests/test_config.py), and the original bug was reproduced and re-verified fixed by calling the real app.run_backtest() directly (OHLCV fetch and model swapped for deterministic stubs β€” same pattern tests/test_backtest_no_lookahead.py already used β€” everything else real, including _get_ohlcv, compute_indicators, the actual WalkForwardBacktester, and Gradio's own Blocks-graph wiring).
$ python3 run_tests.py
PASS  test_backtest_no_lookahead.py::test_backtest_context_never_extends_past_origin
PASS  test_backtest_no_lookahead.py::test_backtest_passes_correct_freq_to_forecaster
PASS  test_backtest_no_lookahead.py::test_decisions_unaffected_by_future_rows
PASS  test_backtest_no_lookahead.py::test_run_raises_insufficient_data_error_not_a_bare_indexerror
PASS  test_backtest_no_lookahead.py::test_run_succeeds_with_a_smaller_context_length_on_the_same_short_data
PASS  test_config.py::test_infer_asset_class_curated_lists
PASS  test_config.py::test_infer_asset_class_default_for_unrecognized_symbol
PASS  test_config.py::test_infer_asset_class_falls_back_to_yahoo_ticker_conventions
PASS  test_config.py::test_round_trip_cost_frac_matches_hand_computed_bps
PASS  test_data_layer.py::test_data_freshness_classification
PASS  test_data_layer.py::test_local_cache_round_trip
PASS  test_data_layer.py::test_resolve_history_window_days
PASS  test_data_layer.py::test_resolve_history_window_max
PASS  test_data_layer.py::test_resolve_history_window_months_uses_calendar_months
PASS  test_data_layer.py::test_resolve_history_window_years
PASS  test_data_layer.py::test_validate_marks_negative_volume_unavailable_not_invented
PASS  test_data_layer.py::test_validate_removes_duplicates_and_keeps_first
PASS  test_data_layer.py::test_validate_removes_invalid_ohlc_relationship
PASS  test_data_layer.py::test_validate_removes_nan_rows_and_reports_them
PASS  test_decision_and_metrics.py::test_decide_buy_on_clear_positive_move
PASS  test_decision_and_metrics.py::test_decide_hold_on_wide_interval
PASS  test_decision_and_metrics.py::test_decide_hold_when_edge_below_cost
PASS  test_decision_and_metrics.py::test_decide_sell_on_clear_negative_move
PASS  test_decision_and_metrics.py::test_decide_sell_rationale_reports_the_sell_side_probability
PASS  test_decision_and_metrics.py::test_decide_sell_requires_correct_directional_probability_not_buy_complement
PASS  test_decision_and_metrics.py::test_directional_accuracy
PASS  test_decision_and_metrics.py::test_max_drawdown_known_series
PASS  test_decision_and_metrics.py::test_point_metrics_hand_computed
PASS  test_decision_and_metrics.py::test_profit_factor_known_values
PASS  test_decision_and_metrics.py::test_sharpe_nan_when_no_variance
PASS  test_decision_and_metrics.py::test_smape_in_valid_range
PASS  test_features_and_leakage.py::test_causal_resample_drops_incomplete_trailing_bin
PASS  test_features_and_leakage.py::test_causal_resample_ohlc_correctness
PASS  test_features_and_leakage.py::test_indicators_are_causal
PASS  test_features_and_leakage.py::test_leakage_audit_catches_future_row
PASS  test_features_and_leakage.py::test_warm_up_detection
PASS  test_features_and_leakage.py::test_warm_up_error_names_the_blocking_indicator_and_bar_counts
PASS  test_features_and_leakage.py::test_warm_up_raises_on_insufficient_history

38/38 passed

Not touched in this pass, disclosed rather than silently left out: persistence.py's Store/record_forecast is real, tested-adjacent code, but still isn't called anywhere from app.py β€” the "SQLite forecast registry" in the Architecture diagram below doesn't actually run yet. That's a missing integration, not a bug this pass introduced or fixed; wiring it up is a reasonable next step but a bigger, separate change.

Feature addition β€” risk management & entry-filter data (Aug 2026)

Triggered by a real 41-trade EURUSD=X @ 1h / 1-year backtest a user ran: 51.2% win rate, profit_factor=0.78, losing trades noticeably bigger than winning ones. Re-verified every number first (same method as the bug-fix pass above β€” recomputed all 41 gross/net PnLs from raw entry/exit prices, rebuilt the real ~5,800-point equity curve to check Sharpe/Sortino/Calmar): all exact matches, no bug in the math. Then actually analyzed the 41 real trades rather than guessing:

  • Direction accuracy, not a P&L bug, is the real story: every single winning trade (21/21) had the forecast pointing the correct way; 19/20 losing trades had it pointing the wrong way (the 20th was a correct call too small to clear the 0.02% cost). Overall directional accuracy was 53.7% β€” barely above a coin flip, consistent with the 51.2% win rate once costs are applied.
  • A real but weak entry-filter signal: |forecast_return| correlates with net_pnl at r=0.314 across the 41 trades β€” bigger predicted moves did trend better on average. That's real, but at n=41 it's only marginally past the threshold for statistical significance, and a magnitude-tercile breakdown (14/13/14 trades) came out non-monotonic (mid-tercile had the best win rate but the worst average P&L) β€” a strong sign that breakdown is dominated by noise at this sample size, not a clean rule to hard-code.
  • The real, structural gap: WalkForwardBacktester had zero intra-trade risk management β€” every trade held the fixed horizon no matter what happened in between, with no way to exit early no matter how far price ran against the position first. Confirmed this empirically too: realized |actual_return| on wrong-direction trades (0.0017) ran moderately larger than on right-direction ones (0.0015) over the same fixed horizon β€” exactly what a stop-loss targets.

What was added: cfg.RiskManagement(stop_loss_frac, take_profit_frac) (both None by default β€” every prior backtest reproduces exactly) and backtest_engine._scan_for_early_exit(), which checks each bar's real intrabar high/low between entry and the horizon exit and closes the trade the first time either level is crossed. Exit price is still that bar's close (not the exact stop/TP level or the wick), consistent with this engine's existing close-only fill convention β€” documented as a real, known limitation, not glossed over: a sharp intrabar wick that closes back near where it started will trigger the stop but barely show up in the realized P&L. If both a stop and a take-profit are crossed in the same bar, OHLC data alone can't say which happened first, so the stop is assumed (the conservative read). Trade also now carries exit_reason ("horizon"/"stop_loss"/"take_profit") and the decision's prob_favorable/interval_width_pct at open, so future backtests carry the data needed to actually test an entry filter against outcomes β€” the 41 trades above predate this field, so that specific analysis couldn't be run retroactively. Exposed in the Backtest tab's Advanced accordion as two sliders (0 = off).

Honest result, not oversold: tested end-to-end through a synthetic scenario with injected adverse intrabar dips β€” a stop-loss cut some real losers short, but also locked in several dips that would have recovered by the nominal horizon exit, and net_pnl came out worse than the no-stop baseline in that run. A stop-loss is not a free win; whether it helps depends on whether adverse intrabar moves in the real data tend to keep going or mean-revert, which this sandbox has no way to check against real EURUSD intrabar history (no network access to Yahoo's endpoints, same limitation as every other pass in this README). Test it on your own data before trusting it β€” start narrow (e.g. a few multiples of the round-trip cost) and compare against a no-stop run on the same symbol/window.

Five new tests cover this (test_stop_loss_exits_early_at_the_breaching_bars_close, test_take_profit_exits_early_on_a_sell, test_stop_and_take_profit_both_breached_same_bar_assumes_stop, test_risk_management_default_none_reproduces_baseline_exactly, test_trade_records_decision_confidence_for_entry_filter_analysis) β€” 43/43 passing.

Root-cause audit β€” Aug 2026 (full pipeline, triggered by two 1m EURUSD tests)

A user ran two back-to-back EURUSD=X @ 1m backtests (4-day window, horizon 5) and reported: only 1 trade out of ~4,000 origins in both; changing SL/TP didn't change that one trade; a slider meant to be set to 0.05% showed 0.5%; and forecast_return differed between the two "identical" runs. Given the stakes, this was treated as a full pipeline audit rather than a single patch, going through every item below with actual execution evidence, not code-review assertion alone.

A/B/C/D/E/F per bug (bug β€” root cause β€” file/function changed β€” before/after β€” test added β€” evidence):

  1. ~4,000 origins β†’ 1 trade, looked like a black hole. Root cause: not a suppression bug β€” buy_threshold/sell_threshold (0.15%) and min_probability (0.55) are fixed constants that don't scale with timeframe, and a 1-minute EUR/USD forecast rarely predicts a 0.15% move 5 minutes out with high confidence, so the overwhelming majority of origins correctly HOLD. decision_engine.py/decide(): HOLD reasons were previously a single generic string with no way to tell "no real signal" apart from "signal present but under-confident" from outside the function. Changed decide() to also return a structured reject_stage (wide_interval/no_edge/low_confidence_buy/ low_confidence_sell), and backtest_engine.py/run() now tallies every origin into one of those buckets (plus signals/errors/overlap skips) and asserts the buckets sum to num_origins on every call β€” a lying diagnostic is worse than none. Before: "1 trade, 4051 unexplained". After: hold_no_edge=4044, signals_buy=1 (numbers from a synthetic run shaped like the real one, since this sandbox has no network path to Yahoo's real 1m EURUSD feed β€” see DATA VERIFICATION above). Test: test_diagnostics_low_trade_count_is_explained_by_hold_buckets_not_a_black_hole, test_diagnostics_exactly_match_a_hand_designed_signal_mix (checks exact counts against a hand-designed 6-way signal mix, not just that they sum correctly). Evidence: both pass; the second would fail if any origin were miscategorized or dropped. Not changed: the thresholds themselves β€” the user's own instructions were explicit not to tune yet, only to make the counting honest and legible.

  2. A 0.5% stop-loss shown when 0.05% was intended. Root cause: a gr.Slider(0, 3.0, step=0.05) asks for pixel-precise dragging to land on a specific 0.05-wide notch out of 60 β€” very easy to overshoot by 10x on a small screen, and there was no way to type an exact value instead. app.py: replaced both stop-loss and take-profit sliders with gr.Number(minimum=0, maximum=5, step=0.01, precision=2) in the Advanced accordion, so a value is typed, not dragged. Before/after: same UI section, slider β†’ number input. Test: test_nonzero_sl_tp_reach_backend_unchanged (also see #3 below, since this only fixes entry precision β€” the propagation itself was already correct, see next item).

  3. Verify 0%/non-zero SL-TP actually reach the engine unchanged. Audited app.run_backtest()'s conversion (stop_loss_frac=(stop_loss_pct/100.0) or None) and WalkForwardBacktester.__init__/run()'s use of self.risk β€” no bug found in the propagation itself (item 2 was a UI input precision problem, not a data-flow bug). Made this independently checkable on every run rather than trusted blind: run()'s return value now includes requested_risk (what the engine actually received), and app.run_backtest() prints both the requested and backend-received SL/TP side by side in its output, plus a full diagnostics JSON panel (Trade gained exit_reason/prob_favorable/ interval_width_pct; run() gained a diagnostics dict β€” signal counts, hold-reason counts, overlap skips, exit-reason counts). Test: test_sl_tp_zero_means_off_reaches_backend_as_none, test_nonzero_sl_tp_reach_backend_unchanged β€” both call the real app.run_backtest() (not just WalkForwardBacktester directly) and check the rendered output string. Evidence: both pass; a broken conversion (e.g. a stray *10) would show up as a requested/backend mismatch directly in the text.

  4. forecast_return/interval_width_pct differed between two "identical" reruns. Root cause, confirmed two ways: (a) Moirai-MoE's forecast is sampling-based β€” the model's own example notebook states it draws num_samples=100 samples from the predictive mixture distribution per step; (b) moirai_model.py never seeded torch or numpy before calling predictor.predict(). Sampling without a fixed seed legitimately produces different quantiles on every call, even for byte-identical input β€” this fully explains "same origin, same entry/exit prices, different forecast_return" without needing any engine-level bug. moirai_model.py/MoiraiMoEForecaster: added a seed parameter (default 1234, not None) and torch.manual_seed(self.seed) / np.random.seed(self.seed) immediately before every predictor.predict() call, so identical context_df content now gives identical output regardless of call order. Before: unseeded, non-reproducible. After: seeded by default, seed=None still available to restore the old behavior. Test: test_seed_defaults_to_fixed_value_not_none, test_seed_is_overridable_including_to_none (constructor-level β€” the actual sampling call can't be exercised in any sandbox so far, real or this one: it needs torch+uni2ts installed and network access to huggingface.co for the real checkpoint, and this pass's network-enabled sandbox has the former but still not the latter β€” see MODEL VERIFICATION above, unchanged). Evidence for the diagnosis (not the fix, which is untestable without the real checkpoint): a web search of the model's own official example notebook, cited above.

  5. Independent verification of intrabar SL/TP, not just trusting the label. Re-read _scan_for_early_exit() fresh against every one of items 13-16 below and added tests that manipulate raw high/low/close directly rather than trusting exit_reason's own report of itself: test_stop_loss_exits_early_at_the_breaching_bars_close (long+SL), test_take_profit_exits_early_on_a_long (long+TP, previously missing β€” only the SELL side of each had a test before this pass), test_take_profit_exits_early_on_a_sell (short+TP), test_stop_loss_exits_early_on_a_short (short+SL, also previously missing) β€” full 2x2 matrix now covered, each asserting exit_ts, exit_price, exit_reason, and net_pnl land where they should from the raw injected OHLC, not just that a flag got set.

  6. 5-bar horizon / entry-exit timestamp correctness at 1-minute granularity specifically. Every existing timing test used 1d/5m/1h data β€” none had exercised 1-minute bars, the exact case in question. Added test_1m_timeframe_horizon_and_timestamps_are_exactly_right: builds a real 1-minute-spaced frame, asserts entry_ts - decision_ts == 1 minute (execution_delay_bars) and exit_ts - entry_ts == 5 minutes (horizon) exactly, that entry/exit prices come from the literal indexed bars in the source data (not a neighboring one), and that the decision context ends exactly at (never past) the origin bar. No bug found β€” this is now directly proven for 1m, not just inferred from 1d/1h coverage.

7-8. Verify the entry condition isn't accidentally suppressing predictions; verify each filter stage (confidence/minimum-move/ cost/overlap) independently. These aren't literally separate sequential functions in the code β€” decide() combines the move-size-after-cost check and the confidence check into one expression per direction β€” but the new reject_stage field makes the four practically-meaningful outcomes (wide interval / no directional edge / edge present but low confidence, split by direction) independently countable, which is what "verify each filter isn't silently eating signals" actually needs. See item 1's evidence β€” the exact-match test specifically constructs cases in each bucket and confirms none of them collide or disappear.

  1. Model input data and trade-simulator data aligned to the same timestamps/timezone. Audited: both context (fed to the model) and price_now/entry/exit prices (used for P&L) are .iloc[] slices of the exact same feature_df passed into run() β€” there is no second copy, resample, or reindex anywhere in the loop that could drift the two apart. test_1m_timeframe_horizon_and_timestamps_are_ exactly_right checks this directly (asserts the context's last timestamp equals the decision timestamp exactly). No bug found.

  2. Look-ahead, leakage, duplicate timestamps, missing candles, off-by-one. No-lookahead was already covered by test_backtest_context_never_extends_past_origin, test_decisions_unaffected_by_future_rows, and the real-time LeakageError audit already run on every origin inside run() itself (not just in tests). Duplicate-timestamp removal is data_layer.validate_ohlcv's job, already tested (test_validate_removes_duplicates_and_keeps_first) β€” deliberately not re-implemented in backtest_engine.py, which assumes already-validated input, matching the documented pipeline order. New for this pass: missing candles specifically. test_gapped_1m_data_uses_bar_counts_not_calendar_time_and_stays_ causal cuts a real 47-minute hole out of a 1-minute frame and confirms the engine still indexes by bar position correctly (a "5-bar horizon" is 5 rows, not 5 calendar minutes β€” the right behavior for a bars-based backtest), reports the real, larger wall-clock delta honestly across the gap rather than a fabricated contiguous one, and never crashes or silently assumes a bar that wasn't there. No bug found; this property held by construction (integer .iloc indexing throughout) but had never been specifically tested against a real gap before.

  3. Costs applied exactly once, correct units. Audited: net = gross - cost_frac happens exactly once per trade regardless of whether it exits via horizon or an early stop/take-profit scan (the scan only searches for the exit bar, cost is applied once afterward). cost_frac is a decimal return fraction (e.g. 0.0002 = 2bps), matching gross_pnl's own units β€” never basis points or percent. No bug found. Test: test_cost_applied_exactly_once_and_in_return_fraction_units (checks both a horizon exit and an early-stop exit).

12-13. Long/short P&L and SL/TP, percent vs. decimal. Audited the formula (exit/entry-1 for BUY, entry/exit-1 for SELL) against hand-computable round numbers for all 4 long/short Γ— up/down combinations. No bug found β€” this exact formula was already verified against two real user-provided trade ledgers in earlier passes (see "Bug-fix pass" and "Feature addition" sections above). New: test_long_short_pnl_hand_computed_round_numbers locks in the round-number case explicitly rather than relying only on real-data reproduction. SL/TP long/short: see item 5's full 2x2 matrix.

  1. Intrabar high/low, verified independently. Every risk-management test in item 5 sets high/low directly via .iloc[] on a synthetic frame and checks the exact resulting exit_ts/exit_ price/exit_reason β€” not inferred from exit_reason's own label.

15-16. Same-bar SL+TP tie-break; early exits match the documented convention. test_stop_and_take_profit_both_breached_same_bar_ assumes_stop (existing, re-verified) confirms the deterministic, documented rule (assume the stop, since OHLC alone can't order intrabar events). Every test in item 5 additionally checks exit_price equals that bar's real close (not the stop/TP level, not the wick) β€” the fill convention documented in backtest_engine.py's module docstring, confirmed to match reality for every exit type, not just exit_reason="horizon".

  1. See item 3.

Full regression count for this pass: 59/59 (53 in test_backtest_no_lookahead.py, 4 in test_config.py, 10 in test_data_layer.py, 8 in test_decision_and_metrics.py, 7 in test_features_and_leakage.py, 5 in test_moirai_model.py, 3 in test_app_integration.py). test_app_integration.py needs gradio to import (the only test file that does); it reports itself as trivially passed rather than failing the suite in a sandbox without it, matching this project's existing degrade-gracefully pattern elsewhere.

Controlled A/B/A/B result (SL=0/TP=0 vs. SL=0.05%/TP=0.10% vs. SL=0.5%/TP=0.10%, same data, same model outputs, run via the real app.run_backtest() three times with a stateless stub): signals_buy, signals_sell, every hold_* bucket, model_errors, and trades_opened_* were identical across all three settings; only exit_horizon/exit_stop_loss/exit_take_profit differed. This is exactly the invariant the audit asked for, checked as actual cross-run equality in test_changing_sl_tp_changes_only_exit_ behavior_not_candidate_signals, not asserted from reading the code.

One thing this pass could not do: run any of this against real EURUSD=X 1-minute data. This sandbox has network access to PyPI (enough to install and inspect gradio/yfinance, confirmed above) but not to Yahoo's actual price endpoints or to huggingface.co, so every number above comes from synthetic data shaped to match the real run's scale (~4,000 origins, 1m bars, 4-day window) or from hand-designed exact cases β€” never from literally re-running the user's own EURUSD=X request. The diagnosis logic and the fixes are verified; the specific real numbers this exact request would produce on real data are not, and can't be from here.

Profitability-reset architecture β€” Aug 2026

Real 1h EURUSD feedback: gross P&L positive, net negative, profit factor <1, raw probabilities of 0.70-0.88 against a ~50% realized win rate. That's a real-world-edge question, not a code-correctness one β€” no amount of refactoring can create edge that isn't there, and this sandbox still can't reach Yahoo's price endpoints or huggingface.co to measure it on real data (unchanged limitation, see every section above). What is new is the actual machinery to measure it honestly instead of guessing, and to refuse to trade when it isn't there:

  • calibration.py β€” isotonic (PAV) probability calibration, pure numpy. decide() now takes an optional calibrator; unset, behavior is byte-identical to before.
  • walk_forward.py β€” chronological train/val/test split; fits calibration and grid-searches min_probability/buy_threshold (bounded grid, not exhaustive search) on train+validation only; evaluates the frozen result on the test split exactly once; compares against two causal baselines (always-flat, naive momentum) through the identical cost/execution model; returns an explicit ACCEPT/REJECT with named checks (trade count, net return, profit factor, drawdown, beats-baseline, no-single-period-dependency). test_frozen_selection_is_unaffected_by_anything_in_the_test_window is a direct, executable proof that nothing after the val/test boundary can change what gets selected.
  • Bar-by-bar mark-to-market equity curve (backtest_engine.py) β€” replaced the old one-point-per-trade curve, which showed zero data points during an open position. A position that dips hard mid-hold before recovering now shows that dip in max_drawdown/Sharpe/ Sortino/Calmar instead of hiding it.
  • New "Walk-Forward Validation" tab β€” runs the above end-to-end on real data and shows the verdict. Only an ACCEPT freezes a calibration Live Forecast and Backtest can opt into via a "use frozen walk-forward calibration" checkbox (checked against exact symbol/timeframe/asset-class match) β€” one decision pipeline, not two independently-tuned ones.

Verified end-to-end with synthetic data in both directions: pure random-walk input correctly REJECTs ("no acceptable configuration on validation data... Verdict: NO TRADE") through the real app.run_walk_forward_validation(); data with genuine injected autocorrelation and an appropriately-scoped grid correctly ACCEPTs (15 test trades, beats both baselines) and the resulting frozen calibration is then correctly reused by both run_live_forecast() and run_backtest(). 82/82 tests passing, including 6 new files (test_calibration.py, test_walk_forward.py, plus additions to the existing suite).

Not done here, disclosed rather than silently skipped: the WalkForwardConfig grid (which probabilities/thresholds get searched) isn't exposed in the UI β€” edit config.py directly. And running the Backtest tab with "use frozen calibration" checked against the same window the calibration was fit on isn't a fresh out-of-sample test β€” point it at a different window for that; only the Walk-Forward Validation tab's own test split is genuinely held out.

Multi-model architecture β€” Aug 2026

Was single-model (Moirai-MoE) through this point. Now three independent, selectable models, never blended into one signal:

  • Moirai-MoE (Salesforce/moirai-moe-1.0-R-small) β€” unchanged.
  • AutoARIMA β€” via Nixtla's statsforecast (confirmed compatible with this project's pandas 3.x/numpy 2.x by real execution in this sandbox; pmdarima wasn't tried after this worked on the first attempt). Classical: fits fresh per context window, no pretrained checkpoint, deterministic (no sampling). This is the one model that actually runs end-to-end in every sandbox this project has had β€” proven with real (not stubbed) tests in test_arima_model.py, and end-to-end through the real app.run_live_forecast()/ run_backtest() in this pass.
  • Chronos-2 (amazon/chronos-2) β€” Amazon Science's 120M-param zero-shot foundation model (Oct 2025). Produces quantile forecasts directly rather than by sampling (confirmed by reading chronos/chronos2/pipeline.py's own source β€” ForecastType.QUANTILES β€” after installing chronos-forecasting with --no-deps in this sandbox; not assumed from docs), so unlike Moirai-MoE it needed no seeding fix for determinism. Blocked from real end-to-end verification the same way Moirai-MoE always has been here: no torch, no network route to huggingface.co.

forecast_types.py now holds the shared QuantileForecast/error-class contract all three implement (moved out of moirai_model.py so arima_model.py/chronos_model.py don't import from a file named after a different model β€” re-exported from moirai_model.py for existing call sites, no breakage). decision_engine.py/ backtest_engine.py/walk_forward.py needed zero changes: they only ever depended on the shared .predict() interface, so the earlier architecture work already made this addition mechanical rather than invasive. app.py gained a model picker (Radio) on all three tabs, each routing to its own load/predict path and its own _model_status entry β€” selecting a model that isn't installed/ loadable in a given environment fails honestly (MODEL ERROR), same as Moirai-MoE always has, never silently substituting a different one.

Real, measured performance note, not a guess: AutoARIMA refits from scratch per origin (no pretrained forward pass), so Walk-Forward Validation's grid search is much slower with it selected than with Moirai-MoE/Chronos-2 β€” measured at ~6 seconds for a single (min_probability, buy_threshold) combination on ~135 bars in this sandbox; the default 4Γ—4 grid on a realistic History window can take a long time. Disclosed in the tab itself, not discovered the hard way.

93/93 tests passing (5 new for AutoARIMA β€” run for real, not stubbed β€” 5 new constructor-level tests for Chronos-2, matching test_moirai_model.py's existing pattern for a model that needs torch this sandbox doesn't have).

Real deployment history (from an actual HF Spaces build)

Two real build failures happened after this was first deployed, both worth recording because the diagnosis process matters as much as the fix:

  1. First failure: scipy failed metadata-generation-failed while building from source. I initially (and wrongly) assumed my own numpy<2.0 pin was the cause and removed it β€” that didn't fix it.
  2. Real cause, found from the actual build log: uni2ts's own pyproject.toml pins scipy~=1.11.3 β€” a 2023-era release with no prebuilt wheel for Python 3.13 (which is what the Space was actually running, per /usr/local/lib/python3.13/site-packages in the log). pip fell back to compiling scipy-1.11.4.tar.gz from source, and that specific old release's build system doesn't work with the Space's modern Cython/meson toolchain (ERROR: Compiler cython cannot compile programs) β€” not a missing system package, a real incompatibility.
  3. Fix: pin python_version: "3.11" in this README's frontmatter (a documented HF Spaces config key) so scipy 1.11.x resolves to a prebuilt wheel instead of a source build. No requirements.txt change was actually needed β€” my earlier "fix" there was chasing the wrong variable, corrected here for the record rather than quietly erased.
  4. Third failure, at runtime this time: the build succeeded (scipy installed as a wheel), but import gradio crashed with ImportError: cannot import name 'HfFolder' from 'huggingface_hub'. huggingface_hub 1.0 removed the long-deprecated HfFolder class; gradio==4.44.0's oauth.py still imports it unconditionally. uni2ts only floors huggingface_hub>=0.23.0 with no upper bound, so pip picked the newest 1.x. Pinned huggingface_hub<1.0 at the time as a stopgap.
  5. Fourth failure: import gradio succeeded, but demo.launch() raised ValueError: When localhost is not accessible..., with a TypeError: unhashable type: 'dict' from Jinja2/Starlette template caching right before it. Root cause: gradio==4.44.0's routes.py called Jinja2Templates.TemplateResponse(name, context) the pre-Starlette-1.0 way; Starlette>=1.0 changed that signature to TemplateResponse(request, name, context), so the context dict landed in the name slot and Jinja2 tried to hash it as a cache key. Real fix: stopped patching individual symptoms and upgraded to a currently-maintained Gradio. Confirmed against Gradio's own changelog (not assumed) that both this Starlette signature fix and "allow gradio to run with huggingface_hub 1.x" had already landed upstream well before 6.26.0 β€” so sdk_version moved to 6.26.0, the huggingface_hub<1.0 stopgap and a since-tried starlette<1.0 pin were both removed as no-longer-needed (and actively risking a new resolution conflict), and gradio in requirements.txt became >=6.26.0,<7 (floor at the verified version, ceiling below the next major). Checked the Gradio 6 migration guide's breaking changes against this codebase's actual API usage (gr.Blocks(title=...) only β€” no theme/css/show_api) before making the jump; none applied.
  6. Fifth failure, and the important one: build and startup both succeeded, but every single backtest origin failed β€” Origins evaluated: 11200 | Model errors: 11200. The UI didn't surface why (only a count), which was itself a bug β€” fixed first, since you can't diagnose a 100%-failure-rate bug from a number alone. Root cause (confirmed against GluonTS's actual documentation and source, not guessed): moirai_model.py built the GluonTS PandasDataset without an explicit freq=, so it fell back to index.freqstr on the raw timestamp index. A plain pandas DatetimeIndex β€” which is exactly what yfinance's Ticker.history() returns β€” has freqstr == None unless something explicitly sets it, regardless of whether the data has gaps. That makes the fallback fail identically on every call, on any real Yahoo data, which is exactly the 100%-of-11200 pattern observed. Fix: config.TIMEFRAME_TO_PANDAS_FREQ maps each supported timeframe to its pandas frequency alias (already known from the selected timeframe β€” no inference needed), threaded through WalkForwardBacktester(timeframe=...) and MoiraiMoEForecaster.predict(..., freq=...). Also added: the backtest UI now shows up to 3 distinct real error messages (with origin counts) whenever Model errors > 0, instead of only a count β€” and MoiraiMoEForecaster now builds the GluonTS predictor once per (context_length, prediction_length, covariate-set) and reuses it, instead of rebuilding it from scratch on every one of thousands of origins. I have not run this against the real checkpoint (no network in this sandbox) β€” this is IMPLEMENTED and reasoned from GluonTS's actual source, not VERIFIED the way this project's non-network logic is. If it's not fully fixed, the app will now show the real error text directly, so the next round has something concrete to work from instead of another guess.

UI improvements (requested, not bug-driven)

  • Symbol: both tabs now use a searchable gr.Dropdown pre-loaded with ~44 common Forex/Crypto Yahoo tickers (config.ALL_SYMBOLS), with allow_custom_value=True so any other Yahoo-supported symbol can still be typed β€” this list is a convenience default, not a restriction.
  • History window: replaced the short period= dropdown (which was limited to yfinance's fixed enum: 1d/5d/1mo/3mo/6mo/1y/2y/5y/10y/ ytd/max) with a 45-option dropdown covering every day count 1-30, every month count 2-12, plus 1/2/5 years and max (config.HISTORY_WINDOW_CHOICES). Since yfinance's period string can't express e.g. "17 days" or "4 months" at all, data_layer.resolve_history_window() converts the selected label into an actual start date (using dateutil.relativedelta for calendar-correct month/year math) and passes that to fetch_ohlcv(start=...) instead of period=... for anything other than "max".

Architecture

Yahoo Finance (yfinance)
  -> validate_ohlcv()            [data_layer.py]
  -> causal_resample() if needed [data_layer.py]
  -> compute_indicators()        [features.py]   10 causal indicator families
  -> warm_up_valid_from() trim   [features.py]
  -> audit_feature_frame()       [leakage_audit.py]  fail loudly on any future row
  -> MoiraiMoEForecaster.predict [moirai_model.py]   the ONLY model
  -> decide()                    [decision_engine.py] Buy/Hold/Sell, cost-aware
  -> WalkForwardBacktester       [backtest_engine.py] sequential, look-ahead-safe
  -> metrics.py                  forecast + trading metrics
  -> persistence.py              SQLite forecast registry, immutable rows
  -> app.py                      Gradio UI (Live tab, Backtest tab)

Deploying for real (to get real VERIFIED results)

  1. Push this folder to a Hugging Face Space (SDK: Gradio). HF Spaces has network access, so pip install -r requirements.txt will actually download uni2ts/torch/the checkpoint.
  2. Run the real smoke test before trusting anything else (spec section 101):
    python3 -c "
    from moirai_model import MoiraiMoEForecaster
    import config as cfg
    from tests.fixtures import make_synthetic_ohlcv
    from features import compute_indicators, warm_up_valid_from
    
    f = MoiraiMoEForecaster().load()
    df = compute_indicators(make_synthetic_ohlcv(n=300))
    df = df.iloc[warm_up_valid_from(df):]
    fc = f.predict(df, cfg.TARGET_FEATURE,
                    [c for c in cfg.PAST_DYNAMIC_FEATURES if c in df.columns], 5)
    print('REAL forecast shape:', fc.quantiles.shape, 'context used:', fc.context_length_used)
    "
    
    (Still synthetic input data here β€” the point of this specific command is only to prove the real checkpoint loads and runs a real forward pass. Use the Live tab with a real symbol for a real forecast.)
  3. Run pytest tests/ (or python3 run_tests.py) again in that environment β€” everything that passed here should still pass, and you can now also exercise fetch_ohlcv against real Yahoo data.
  4. Try the Live tab with a real symbol (e.g. EURUSD=X, BTC-USD) and the Backtest tab over a real historical window before relying on any output.

Known limitations (honest, per spec section 115)

  • Fine-tuning (Mode B): no UI tab yet. MoiraiMoEForecaster doesn't currently expose a fine_tune() method β€” uni2ts's own fine-tuning CLI (project/moirai-moe-1) is the more reliable path today; wiring a thin UI around it is the natural next step.
  • Probabilistic scoring: pinball loss and interval coverage are implemented; CRPS and weighted interval score are not.
  • Symbol selection: a curated dropdown covers common Forex/Crypto tickers with free-text fallback for anything else (see "UI improvements" above) β€” still not a live, searchable directory of every symbol Yahoo lists, since yfinance itself exposes no such endpoint.
  • Drift monitoring: not implemented in this build.
  • Forecast persistence: persistence.py's Store/record_forecast/ match_realized_outcome are real, working code (see the Architecture diagram above), but app.py never calls them β€” no forecast is actually written to the registry yet. Noticed during the Aug 2026 bug-fix pass and disclosed rather than silently left out; wiring run_live_forecast() to record each forecast is a reasonable next step, kept separate from that pass's fixes since it's an integration gap, not a bug.
  • Fill-price convention: the backtester fills at next-bar-close by execution delay, not next-bar-open β€” documented in backtest_engine.py's module docstring, and it's a one-line change if you want the stricter convention.
  • Cost profiles (config.COST_PROFILES) are reasonable defaults, not measured spread/fee data for any specific broker or exchange β€” replace them with real numbers before trusting a backtest's net PnL.
  • Every number this build has ever produced comes from either (a) a real, executed unit test against known values, or (b) synthetic fixtures used only to prove code correctness. No real trading or forecast-accuracy claim is made anywhere in this repository.

Forecasts are probabilistic estimates, not guaranteed future prices. Nothing in this app is financial advice.