Lk / app.py
Kashaf1's picture
Upload moirai_forecast_app contents
ef20ebe
Raw History Blame Contribute Delete
27.7 kB
"""
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)