""" Gradio UI for Hugging Face Spaces (spec sections 49-53). Wires together data_layer -> features -> moirai_model -> decision_engine -> backtest_engine. Errors are surfaced with their real cause (section 98), never hidden behind a generic "something went wrong". """ from __future__ import annotations import traceback import gradio as gr import pandas as pd import config as cfg import walk_forward as wf from arima_model import AutoArimaForecaster from backtest_engine import InsufficientDataError, WalkForwardBacktester from chronos_model import Chronos2Forecaster from data_layer import ( DataSourceError, causal_resample, data_freshness, fetch_ohlcv, resolve_history_window, validate_ohlcv, ) from decision_engine import decide from features import compute_indicators, warm_up_valid_from from forecast_types import ModelInferenceError, ModelInputError, ModelLoadError from metrics import trading_summary from moirai_model import MoiraiMoEForecaster # Each model is fully independent (own module, own class, own # load()/predict()) -- this dict is the ONLY place they're gathered # together, and only so the UI's model picker can look one up by key. # Nothing here blends or falls back between them; a request uses # exactly one entry, chosen by the person, for its entire lifetime. _forecasters = { "moirai": MoiraiMoEForecaster(), "autoarima": AutoArimaForecaster(), "chronos2": Chronos2Forecaster(), } _model_status = {key: {"loaded": False, "error": None} for key in _forecasters} _MODEL_LABEL_TO_KEY = {v["display_name"]: k for k, v in cfg.AVAILABLE_MODELS.items()} _MODEL_CHOICES = [cfg.AVAILABLE_MODELS[k]["display_name"] for k in _forecasters] _DEFAULT_MODEL_LABEL = cfg.AVAILABLE_MODELS[cfg.DEFAULT_MODEL_KEY]["display_name"] # The one piece of cross-tab state this app keeps: the most recently # ACCEPTED walk-forward result (see run_walk_forward_validation below). # Live Forecast and Backtest both read this (never write it) when their # "use frozen calibration" checkbox is on -- this is the concrete # mechanism that makes them share one decision pipeline (spec item 15) # instead of maintaining calibration state independently. A REJECTED # validation run clears this rather than leaving a stale accepted # result in place from a previous, unrelated symbol/timeframe. Keyed by # model too, since a calibration fit for one model's forecasts has no # meaning applied to a different model's. _frozen_wf = {"calibrator": None, "dc": None, "model_key": None, "symbol": None, "timeframe": None, "asset_class": None} def _ensure_model(model_key: str) -> bool: status = _model_status[model_key] if status["loaded"]: return True try: _forecasters[model_key].load() status["loaded"] = True return True except ModelLoadError as e: status["error"] = str(e) return False def _get_ohlcv(symbol: str, requested_tf: str, history_label: str): """Returns (df, native_or_derived, source_tf, validation_report).""" kind, value = resolve_history_window(history_label) period = value if kind == "period" else None start = value if kind == "start" else None if requested_tf in cfg.DERIVED_MAP: source_tf, _ = cfg.DERIVED_MAP[requested_tf] raw = fetch_ohlcv(symbol, source_tf, start=start, period=period) clean, report = validate_ohlcv(raw) derived = causal_resample(clean, source_tf, requested_tf) return derived, "derived", source_tf, report raw = fetch_ohlcv(symbol, requested_tf, start=start, period=period) clean, report = validate_ohlcv(raw) return clean, "native", requested_tf, report def run_live_forecast(symbol, timeframe, horizon, history_label, model_label=_DEFAULT_MODEL_LABEL, use_frozen_wf=False): model_key = _MODEL_LABEL_TO_KEY[model_label] forecaster = _forecasters[model_key] if not _ensure_model(model_key): return f"**MODEL ERROR ({model_label})**\n\n{_model_status[model_key]['error']}", None, None, None try: df, kind, source_tf, val_report = _get_ohlcv(symbol, timeframe, history_label) except DataSourceError as e: return f"**SOURCE UNAVAILABLE**\n\n{e}", None, None, None except Exception as e: return f"**DATA ERROR**\n\n{e}", None, None, None feat = compute_indicators(df) try: warm = warm_up_valid_from(feat) except ValueError as e: return f"**INSUFFICIENT HISTORY**\n\n{e}", None, None, None feat = feat.iloc[warm:] fresh = data_freshness(feat.index[-1], timeframe) try: forecast = forecaster.predict( context_df=feat, target_col=cfg.TARGET_FEATURE, past_covariate_cols=[c for c in cfg.PAST_DYNAMIC_FEATURES if c in feat.columns], prediction_length=int(horizon), freq=cfg.TIMEFRAME_TO_PANDAS_FREQ[timeframe], ) except (ModelInputError, ModelInferenceError) as e: return f"**MODEL ERROR ({model_label})**\n\n{e}", df.tail(200), val_report.__dict__, fresh price_now = float(feat["close"].iloc[-1]) # Unlike the Backtest tab (which has an explicit asset-class radio), # the Live tab only has a symbol -- infer forex vs. crypto from it so # decide() gets a real, asset-appropriate cost estimate instead of # silently falling back to DecisionConfig's flat generic default # (previously every Live symbol used the same cost regardless of # whether it was forex or crypto, cf. WalkForwardBacktester which # always has a real cost_estimate_frac -- see config.round_trip_cost_frac). asset_class = cfg.infer_asset_class(symbol) cost_frac = cfg.round_trip_cost_frac(cfg.COST_PROFILES[asset_class]) calibrator, dc, calib_note = None, None, "raw (uncalibrated) probabilities" if use_frozen_wf: matches = (_frozen_wf["calibrator"] is not None and _frozen_wf["model_key"] == model_key and _frozen_wf["symbol"] == symbol and _frozen_wf["timeframe"] == timeframe and _frozen_wf["asset_class"] == asset_class) if not matches: return ( f"**NO FROZEN CALIBRATION AVAILABLE FOR {model_label} on {symbol} @ {timeframe} ({asset_class})**\n\n" f"Run Walk-Forward Validation for this exact model/symbol/timeframe/asset-class " f"combination first, and it must reach ACCEPTED — a rejected or not-yet-run " f"validation leaves nothing here to reuse, on purpose (spec: no forced trading " f"on unproven configs).", None, None, None, ) calibrator, dc = _frozen_wf["calibrator"], _frozen_wf["dc"] calib_note = f"frozen walk-forward calibration (min_probability={dc.min_probability}, buy_threshold={dc.buy_threshold:.4%})" decision = decide(forecast, price_now, dc=dc, cost_estimate_frac=cost_frac, calibrator=calibrator) duration_min = cfg.TIMEFRAME_MINUTES[timeframe] * int(horizon) summary = ( f"### {symbol} @ {timeframe} _( {kind} from {source_tf} )_\n\n" f"**Model:** {model_label}\n\n" f"**Data:** {fresh['status'].upper()} — last bar `{fresh['latest_market_ts']}`\n\n" f"**Horizon:** {horizon} bars ≈ {duration_min} min\n\n" f"**Cost profile:** {asset_class} (inferred from symbol) — round-trip ≈ {cost_frac:.3%}\n\n" f"**Probability basis:** {calib_note}\n\n" f"**Last close:** {price_now:.5f}\n\n" f"**Forecast return (P50):** {decision.expected_return:+.4%} " f"  [P10 {decision.p10_return:+.4%}, P90 {decision.p90_return:+.4%}]\n\n" f"### Decision: {decision.action}\n\n{decision.rationale}\n\n" + (f"_HOLD reason: {decision.hold_reason}_\n\n" if decision.hold_reason else "") + f"_Forecasts are probabilistic estimates, not guaranteed future prices. " f"Not financial advice._" ) return summary, df.tail(200), val_report.__dict__, fresh def run_backtest(symbol, timeframe, horizon, history_label, asset_class, context_length=256, stop_loss_pct=0.0, take_profit_pct=0.0, model_label=_DEFAULT_MODEL_LABEL, use_frozen_wf=False): model_key = _MODEL_LABEL_TO_KEY[model_label] forecaster = _forecasters[model_key] if not _ensure_model(model_key): return f"**MODEL ERROR ({model_label})**\n\n{_model_status[model_key]['error']}", None, None try: df, kind, source_tf, val_report = _get_ohlcv(symbol, timeframe, history_label) except DataSourceError as e: return f"**SOURCE UNAVAILABLE**\n\n{e}", None, None feat = compute_indicators(df) # 0 on either slider means "disabled" (a UI sentinel, not a real 0% # stop) -- keeps the default identical to every pre-existing backtest # unless someone explicitly opens Advanced and picks a value. risk = cfg.RiskManagement( stop_loss_frac=(stop_loss_pct / 100.0) or None, take_profit_frac=(take_profit_pct / 100.0) or None, ) calibrator, decision_cfg = None, None if use_frozen_wf: matches = (_frozen_wf["calibrator"] is not None and _frozen_wf["model_key"] == model_key and _frozen_wf["symbol"] == symbol and _frozen_wf["timeframe"] == timeframe and _frozen_wf["asset_class"] == asset_class) if not matches: return ( f"**NO FROZEN CALIBRATION AVAILABLE FOR {model_label} on {symbol} @ {timeframe} ({asset_class})**\n\n" f"Run Walk-Forward Validation for this exact model/symbol/timeframe/asset-class " f"combination first, and it must reach ACCEPTED.", None, None, ) calibrator, decision_cfg = _frozen_wf["calibrator"], _frozen_wf["dc"] bt = WalkForwardBacktester(forecaster, timeframe=timeframe, asset_class=asset_class, horizon=int(horizon), context_length=int(context_length), risk=risk, decision_cfg=decision_cfg, calibrator=calibrator) try: result = bt.run(feat) except (ValueError, InsufficientDataError) as e: # warm_up_valid_from (ValueError) or the context-length/origin check # (InsufficientDataError) -- both mean "your History window / # context length don't have enough bars for this configuration", # a real and common condition, not an unexpected code bug. The # message is already specific and actionable (bars available vs. # needed), so show it cleanly instead of a scary raw traceback -- # matching how the Live tab already handles the same ValueError # from warm_up_valid_from above. return f"**INSUFFICIENT HISTORY**\n\n{e}", None, None except Exception as e: return f"**BACKTEST ERROR**\n\n{e}\n\n```\n{traceback.format_exc(limit=3)}\n```", None, None trades_df = pd.DataFrame([t.__dict__ for t in result["trades"]]) summary = trading_summary(trades_df, result["equity_curve"], cfg.TIMEFRAME_MINUTES[timeframe]) diag = result["diagnostics"] n = result["num_origins"] lines = [ f"### {symbol} @ {timeframe} ({kind} from {source_tf}) — walk-forward backtest", f"Model: **{model_label}**", f"Origins evaluated: {n} | Model errors: {len(result['errors'])} | " f"Probabilities: {'frozen walk-forward calibration' if calibrator else 'raw (uncalibrated)'}", "", ] for k, v in summary.items(): lines.append(f"- **{k}**: {v}") # UI -> app -> engine propagation, stated explicitly rather than # assumed (audit item 17/20): requested is exactly what this # function received as its stop_loss_pct/take_profit_pct arguments; # actual is read back from the RiskManagement object the # WalkForwardBacktester instance actually used, so any conversion # bug between the two would show up here as a mismatch. req = result["requested_risk"] req_sl_pct, req_tp_pct = stop_loss_pct, take_profit_pct actual_sl = "off" if req["stop_loss_frac"] is None else f"{req['stop_loss_frac']*100:.2f}%" actual_tp = "off" if req["take_profit_frac"] is None else f"{req['take_profit_frac']*100:.2f}%" lines.append( f"- **risk params — requested**: SL={req_sl_pct:.2f}%, TP={req_tp_pct:.2f}% | " f"**backend received**: SL={actual_sl}, TP={actual_tp}" ) if trades_df.empty: exit_counts = {"horizon": 0, "stop_loss": 0, "take_profit": 0} else: exit_counts = trades_df["exit_reason"].value_counts().to_dict() lines.append( f"- **exit reasons**: horizon={exit_counts.get('horizon', 0)}, " f"stop_loss={exit_counts.get('stop_loss', 0)}, take_profit={exit_counts.get('take_profit', 0)}" ) diag = dict(diag) # copy before mutating -- result["diagnostics"] should stay pristine diag["num_origins"] = n diag["model_errors_detail_count"] = len(result["errors"]) if result["errors"]: # Every origin failing the same way means a real, systematic bug -- # showing it is far more useful than a bare count (section 98: # errors must say what failed, not just that something did). unique_msgs = {} for err in result["errors"]: unique_msgs.setdefault(err["error"], []).append(err["origin_ts"]) lines.append("\n### Model error detail (first 3 distinct messages)") for msg, origins in list(unique_msgs.items())[:3]: lines.append(f"- `{msg}` — seen at {len(origins)} origin(s), e.g. `{origins[0]}`") return "\n".join(lines), trades_df, diag def run_walk_forward_validation(symbol, timeframe, horizon, history_label, asset_class, context_length=256, model_label=_DEFAULT_MODEL_LABEL): """The one function in this app that CHOOSES calibration/threshold parameters -- everywhere else takes them as an already-frozen given (spec item 15/22). Splits chronologically, fits + selects on train/validation only, evaluates the frozen result on the test split exactly once, compares against two simple causal baselines through the identical cost/execution machinery, and returns an explicit ACCEPT/REJECT verdict. Only an ACCEPT updates the module-level _frozen_wf state Live Forecast and Backtest can opt into reusing -- a REJECT clears it rather than leaving a stale accepted result from a different, unrelated run in place.""" model_key = _MODEL_LABEL_TO_KEY[model_label] forecaster = _forecasters[model_key] if not _ensure_model(model_key): return f"**MODEL ERROR ({model_label})**\n\n{_model_status[model_key]['error']}", None try: df, kind, source_tf, val_report = _get_ohlcv(symbol, timeframe, history_label) except DataSourceError as e: return f"**SOURCE UNAVAILABLE**\n\n{e}", None feat = compute_indicators(df) wf_cfg = cfg.WalkForwardConfig() try: train_df, val_df, test_df = wf.chronological_split(feat, wf_cfg) except ValueError as e: return f"**INSUFFICIENT HISTORY FOR A TRAIN/VALIDATION/TEST SPLIT**\n\n{e}", None try: calibrator, frozen_dc, selection_report = wf.select_frozen_config( forecaster, train_df, val_df, timeframe=timeframe, asset_class=asset_class, horizon=int(horizon), context_length=int(context_length), wf=wf_cfg, ) except ValueError as e: _frozen_wf.update(calibrator=None, dc=None, model_key=None, symbol=None, timeframe=None, asset_class=None) return ( f"**NO ACCEPTABLE CONFIGURATION ON VALIDATION DATA ({model_label})**\n\n{e}\n\n" f"This is a legitimate outcome, not an error to work around: it means no tested " f"combination of confidence/threshold showed enough signal on validation data to be " f"worth evaluating further. **Verdict: NO TRADE.** Nothing was frozen for reuse.", None, ) bt = WalkForwardBacktester(forecaster, timeframe=timeframe, asset_class=asset_class, horizon=int(horizon), context_length=int(context_length), decision_cfg=frozen_dc, calibrator=calibrator) try: test_result = bt.run(test_df) except (ValueError, InsufficientDataError) as e: return f"**INSUFFICIENT TEST-WINDOW HISTORY**\n\n{e}", None test_trades_df = pd.DataFrame([t.__dict__ for t in test_result["trades"]]) test_summary = trading_summary(test_trades_df, test_result["equity_curve"], cfg.TIMEFRAME_MINUTES[timeframe]) \ if not test_trades_df.empty else {"num_trades": 0, "note": "no trades on the frozen test window"} baselines = {} for name, cls in [("zero_skill", wf.ZeroSkillForecaster), ("naive_momentum", wf.NaiveMomentumForecaster)]: baselines[name] = wf.run_baseline(cls, test_df, timeframe, asset_class, int(horizon), int(context_length), frozen_dc, calibrator) acceptance = wf.check_acceptance(test_summary, baselines, test_result["equity_curve"], wf_cfg) if acceptance["accepted"]: _frozen_wf.update(calibrator=calibrator, dc=frozen_dc, model_key=model_key, symbol=symbol, timeframe=timeframe, asset_class=asset_class) else: _frozen_wf.update(calibrator=None, dc=None, model_key=None, symbol=None, timeframe=None, asset_class=None) verdict = "✅ ACCEPTED" if acceptance["accepted"] else "❌ REJECTED — NO TRADE" lines = [ f"### Walk-forward validation — {model_label} on {symbol} @ {timeframe} ({asset_class})", f"Train: {len(train_df)} bars  |  Validation: {len(val_df)} bars  |  " f"Test (frozen, evaluated once): {len(test_df)} bars", f"Selected on validation only: `min_probability={selection_report['chosen_min_probability']}`, " f"`buy_threshold={selection_report['chosen_buy_threshold']:.4%}` " f"(from {len(selection_report['trials'])} validation-only grid trials, " f"{selection_report['n_calibration_pairs']} calibration pairs from the training window)", "", f"## Verdict: {verdict}", "", "**Test-window result (frozen config, evaluated exactly once):**", ] for k, v in test_summary.items(): lines.append(f"- **{k}**: {v}") lines.append("\n**Baseline comparison — same test window, same costs, same execution:**") for name, s in baselines.items(): lines.append(f"- **{name}**: net_pnl={s.get('net_pnl')}, num_trades={s.get('num_trades', 0)}") lines.append("\n**Acceptance checks:**") for k, v in acceptance["checks"].items(): lines.append(f"- {'✅' if v else '❌'} {k}") if acceptance["accepted"]: lines.append( "\n_Live Forecast and Backtest can now use this frozen calibration for this exact " "symbol/timeframe/asset-class via their 'Use frozen walk-forward calibration' checkbox._" ) else: lines.append( "\n_No credible edge survived unseen test data — the honest result is NO TRADE, not a " "forced strategy. Nothing was frozen for reuse; any previously-frozen result for this " "symbol/timeframe was cleared._" ) return "\n".join(lines), acceptance with gr.Blocks(title="Forex & Crypto Forecaster (Moirai-MoE / AutoARIMA / Chronos-2)") as demo: gr.Markdown( "# Forex & Crypto Forecasting\n" "Three independent, selectable models — never blended into one signal: " f"**Moirai-MoE** (`{cfg.MODEL_CHECKPOINT}`), **AutoARIMA** (classical, fit fresh per " f"forecast, no pretrained checkpoint), and **Chronos-2** (`amazon/chronos-2`). " "Pick one per tab; each goes through the identical decision/cost/backtest pipeline. " "Forecasts are probabilistic estimates, not guaranteed future prices — not financial advice." ) with gr.Tab("Live Forecast"): with gr.Row(): symbol_in = gr.Dropdown(choices=cfg.ALL_SYMBOLS, value="EURUSD=X", allow_custom_value=True, label="Symbol (pick one, or type any Yahoo ticker)") tf_in = gr.Dropdown(choices=cfg.ALL_TIMEFRAMES, value="1h", label="Timeframe") horizon_in = gr.Slider(1, 20, value=5, step=1, label="Horizon (bars)") period_in = gr.Dropdown(cfg.HISTORY_WINDOW_CHOICES, value="30 days", label="History window") model_in = gr.Radio(_MODEL_CHOICES, value=_DEFAULT_MODEL_LABEL, label="Model") wf_check_live = gr.Checkbox( value=False, label="Use frozen walk-forward calibration (run Walk-Forward Validation tab first — must ACCEPT, for this exact model)") run_btn = gr.Button("Run Forecast", variant="primary") out_md = gr.Markdown() out_table = gr.Dataframe(label="Recent data") with gr.Accordion("Data validation & freshness", open=False): out_val = gr.JSON(label="Validation report") out_fresh = gr.JSON(label="Freshness") run_btn.click(run_live_forecast, [symbol_in, tf_in, horizon_in, period_in, model_in, wf_check_live], [out_md, out_table, out_val, out_fresh]) with gr.Tab("Walk-Forward Backtest"): with gr.Row(): b_symbol = gr.Dropdown(choices=cfg.ALL_SYMBOLS, value="BTC-USD", allow_custom_value=True, label="Symbol (pick one, or type any Yahoo ticker)") b_tf = gr.Dropdown(choices=cfg.ALL_TIMEFRAMES, value="1d", label="Timeframe") b_horizon = gr.Slider(1, 20, value=5, step=1, label="Horizon (bars)") b_period = gr.Dropdown(cfg.HISTORY_WINDOW_CHOICES, value="1 year", label="History window") b_class = gr.Radio(["forex", "crypto"], value="crypto", label="Asset class (cost profile)") b_model = gr.Radio(_MODEL_CHOICES, value=_DEFAULT_MODEL_LABEL, label="Model") with gr.Accordion("Advanced (model context & risk management)", open=False): gr.Markdown( "**Model context**: bars of lookback the model sees at each " "backtest origin, on top of indicator warm-up. A short " "History window needs a smaller context length here, or " "the backtest has no room left for even one origin — the " "error message will say exactly how many bars are missing " "either way.\n\n" "**Risk management**: 0 = off (the original behavior — " "every trade holds the full horizon no matter what " "happens in between). A stop-loss/take-profit is checked " "against each bar's real intrabar high/low between entry " "and the horizon exit, and closes the trade early — at " "that bar's close, not the exact stop level — the first " "time either is crossed. Type an exact value here — a " "slider this precise (0.05% steps) is very easy to " "overshoot with a drag on a small screen, which is exactly " "how a previous run ended up at 0.5% instead of an " "intended 0.05%." ) b_context = gr.Slider(32, 512, value=256, step=32, label="Model context length (bars)") b_stop = gr.Number(value=0, minimum=0, maximum=5, step=0.01, precision=2, label="Stop-loss (%, 0 = off)") b_tp = gr.Number(value=0, minimum=0, maximum=5, step=0.01, precision=2, label="Take-profit (%, 0 = off)") b_btn = gr.Button("Run Backtest", variant="primary") b_wf_check = gr.Checkbox( value=False, label="Use frozen walk-forward calibration (run Walk-Forward Validation tab first — must ACCEPT)") b_out = gr.Markdown() b_trades = gr.Dataframe(label="Trade ledger") with gr.Accordion("Diagnostics (audit trail: signals, rejections, exits)", open=False): b_diag = gr.JSON(label="Every origin's outcome, broken down") b_btn.click(run_backtest, [b_symbol, b_tf, b_horizon, b_period, b_class, b_context, b_stop, b_tp, b_model, b_wf_check], [b_out, b_trades, b_diag]) with gr.Tab("Walk-Forward Validation"): gr.Markdown( "Splits your History window chronologically into " f"train ({int(cfg.WalkForwardConfig().train_frac*100)}%) / " f"validation ({int(cfg.WalkForwardConfig().val_frac*100)}%) / " f"test ({int((1-cfg.WalkForwardConfig().train_frac-cfg.WalkForwardConfig().val_frac)*100)}%). " "Probability calibration is fit and the confidence/threshold combination is chosen using " "**train+validation only**, then evaluated on the **test window exactly once** — that " "result is compared against two simple baselines (always-flat, naive momentum) run " "through the identical cost/execution model. A REJECT is a real, honest result: it means " "no credible edge survived unseen data, and the system will not force a trade.\n\n" "**AutoARIMA note:** unlike Moirai-MoE/Chronos-2 (one forward pass per origin), AutoARIMA " "re-fits a fresh model at every origin, and this tab evaluates many origins across a grid " "of threshold combinations — measured at ~6s for a single combination on ~135 bars, so " "the default grid on a realistic History window can take a long time. Prefer a shorter " "History window or a smaller model context when using AutoARIMA here." ) with gr.Row(): w_symbol = gr.Dropdown(choices=cfg.ALL_SYMBOLS, value="EURUSD=X", allow_custom_value=True, label="Symbol (pick one, or type any Yahoo ticker)") w_tf = gr.Dropdown(choices=cfg.ALL_TIMEFRAMES, value="1h", label="Timeframe") w_horizon = gr.Slider(1, 20, value=5, step=1, label="Horizon (bars)") w_period = gr.Dropdown(cfg.HISTORY_WINDOW_CHOICES, value="1 year", label="History window") w_class = gr.Radio(["forex", "crypto"], value="forex", label="Asset class (cost profile)") w_model = gr.Radio(_MODEL_CHOICES, value=_DEFAULT_MODEL_LABEL, label="Model") w_context = gr.Slider(32, 512, value=256, step=32, label="Model context length (bars)") w_btn = gr.Button("Run Walk-Forward Validation", variant="primary") w_out = gr.Markdown() with gr.Accordion("Full acceptance report (JSON)", open=False): w_report = gr.JSON(label="Checks, trials, and verdict") w_btn.click(run_walk_forward_validation, [w_symbol, w_tf, w_horizon, w_period, w_class, w_context, w_model], [w_out, w_report]) gr.Markdown( "---\n**Modes implemented in this build:** Live inference (Mode D) and walk-forward " "historical evaluation with trade simulation (Mode C). Fine-tuning (Mode B) has real " "training-loop code in `moirai_model.py` but no UI tab yet in this build — see README " "limitations." ) if __name__ == "__main__": # Gradio 6.26 on HF Spaces appears to auto-detect the container # environment even without this, but binding explicitly is cheap # insurance against any platform that doesn't auto-detect it. demo.launch(server_name="0.0.0.0", server_port=7860)