| """ |
| Forex & Crypto Prediction Dashboard โ Gradio app, entry point for the |
| Hugging Face Space (see README.md for the Space YAML header / deployment). |
| |
| Tabs: |
| 1. Chart & Indicators - candlestick chart + the top 5 technical indicators |
| 2. Prediction - ARIMA / Auto-ARIMA / ARIMA-GARCH / Moirai / TimesFM |
| forecasts, shown together on one chart for |
| visual comparison |
| 3. Backtest - walk-forward backtest; each model gets its own |
| SEPARATE accuracy card + per-candle verify log |
| (numbers are never averaged across models) |
| 4. Live - poll the newest candle, predict the next one, and |
| verify the previous prediction once it lands |
| |
| Educational project โ nothing here is financial advice. |
| """ |
| import numpy as np |
| import pandas as pd |
| import plotly.graph_objects as go |
| import gradio as gr |
|
|
| from config import ALL_DEFAULT_SYMBOLS, CUSTOM_TIMEFRAMES, MODEL_NAMES, YF_NATIVE_INTERVALS |
| from data.historical import get_historical_cached, get_historical_with_features, DataFetchError |
| from indicators.indicators import add_all_indicators, latest_signals |
| from models.registry import get_model, fresh_model |
| from backtest.backtester import ( |
| run_all_models_backtest, run_comparison_backtest, |
| BASELINE_LABEL, FEATURED_LABEL, |
| ) |
| from utils.helpers import infer_step, future_index |
|
|
| TIMEFRAME_CHOICES = list(CUSTOM_TIMEFRAMES.keys()) |
| DISCLAIMER = ( |
| "**Educational / research project โ not financial advice.** " |
| "Forex and crypto trading carries substantial risk of loss." |
| ) |
| FEATURES_HELP = ( |
| "**10-feature input** feeds each model Open/High/Low/Volume plus RSI, MACD, " |
| "Bollinger Bands, SMA/EMA and Stochastic โ computed leak-free (only data " |
| "available at each prediction's own point in time) โ in addition to the " |
| "Close-price series every model already uses. Off by default; this changes " |
| "nothing about the original Close-only behavior unless you turn it on. " |
| "See features/feature_pipeline.py for exactly what's computed and how " |
| "each model consumes it." |
| ) |
|
|
|
|
| |
| |
| |
| def make_candlestick_figure(df: pd.DataFrame, symbol: str, timeframe: str) -> go.Figure: |
| fig = go.Figure(data=[go.Candlestick( |
| x=df.index, open=df["Open"], high=df["High"], low=df["Low"], close=df["Close"], |
| name=symbol, |
| )]) |
| if "SMA_20" in df: |
| fig.add_trace(go.Scatter(x=df.index, y=df["SMA_20"], name="SMA 20", line=dict(width=1))) |
| if "EMA_20" in df: |
| fig.add_trace(go.Scatter(x=df.index, y=df["EMA_20"], name="EMA 20", line=dict(width=1))) |
| if "BB_Upper" in df: |
| fig.add_trace(go.Scatter(x=df.index, y=df["BB_Upper"], name="BB Upper", |
| line=dict(width=1, dash="dot"))) |
| fig.add_trace(go.Scatter(x=df.index, y=df["BB_Lower"], name="BB Lower", |
| line=dict(width=1, dash="dot"))) |
| fig.update_layout(title=f"{symbol} โ {timeframe}", xaxis_rangeslider_visible=False, |
| height=480, margin=dict(l=10, r=10, t=40, b=10)) |
| return fig |
|
|
|
|
| def fetch_and_chart(symbol, timeframe, lookback_days): |
| try: |
| df = get_historical_cached(symbol, timeframe, lookback_days=int(lookback_days), refresh=True) |
| df_ind = add_all_indicators(df) |
| fig = make_candlestick_figure(df_ind, symbol, timeframe) |
| signals = latest_signals(df_ind) |
| signal_rows = [[name, str(v["value"]), v["signal"]] for name, v in signals.items()] |
| status = f"Loaded {len(df)} candles for {symbol} ({timeframe})." |
| return fig, signal_rows, status |
| except DataFetchError as e: |
| return None, [], f"Data error: {e}" |
| except Exception as e: |
| return None, [], f"Unexpected error: {e}" |
|
|
|
|
| |
| |
| |
| def run_prediction(symbol, timeframe, selected_models, horizon, lookback_days, use_features): |
| if not selected_models: |
| return None, "Pick at least one model." |
| try: |
| |
| |
| |
| |
| |
| |
| df, features_df = get_historical_with_features( |
| symbol, timeframe, lookback_days=int(lookback_days), use_features=use_features, refresh=True, |
| ) |
| except Exception as e: |
| return None, f"Data error: {e}" |
|
|
| close = df["Close"] |
| horizon = int(horizon) |
| step = infer_step(df.index) |
|
|
| fig = go.Figure() |
| tail = close.iloc[-150:] |
| fig.add_trace(go.Scatter(x=tail.index, y=tail.values, name="Actual Close", |
| line=dict(color="#888888"))) |
|
|
| mode_label = f" [{FEATURED_LABEL}]" if use_features else "" |
| summary_lines = [] |
| for name in selected_models: |
| try: |
| model = get_model(name, symbol, timeframe, use_features=use_features) |
| forecast = model.predict(close, horizon=horizon, features=features_df) |
| fc_index = future_index(df.index[-1], step, len(forecast)) |
| fig.add_trace(go.Scatter(x=fc_index, y=forecast, name=f"{name}{mode_label} forecast", |
| mode="lines+markers")) |
| direction = "UP" if forecast[-1] > close.iloc[-1] else "DOWN" |
| summary_lines.append(f"**{name}**{mode_label} โ next close (approx.) `{forecast[-1]:.5f}` ({direction})") |
| except Exception as e: |
| summary_lines.append(f"**{name}**{mode_label} โ failed: {e}") |
|
|
| fig.update_layout(title=f"{symbol} โ {timeframe} forecast ({horizon} candle(s) ahead)", |
| height=450, margin=dict(l=10, r=10, t=40, b=10)) |
| return fig, "\n\n".join(summary_lines) + "\n\n" + DISCLAIMER |
|
|
|
|
| |
| |
| |
| FEATURE_MODE_BASELINE = "Close-only (baseline)" |
| FEATURE_MODE_FEATURED = "+10-feature input" |
| FEATURE_MODE_COMPARE = "Compare both" |
|
|
|
|
| def _summary_row(summary: dict, mode_label: str) -> list: |
| return [ |
| mode_label, summary["model"], summary["total_predictions"], summary["correct"], |
| summary["incorrect"], f"{summary['accuracy_pct']}%", |
| summary["mae"], summary["rmse"], summary["failed_predictions"], |
| ] |
|
|
|
|
| def run_backtest_ui(symbol, timeframe, selected_models, window, horizon, step_size, |
| lookback_days, feature_mode): |
| empty = pd.DataFrame() |
| if not selected_models: |
| return [], empty, empty, empty, empty, empty, "Pick at least one model." |
|
|
| needs_features = feature_mode != FEATURE_MODE_BASELINE |
| try: |
| |
| |
| |
| |
| |
| |
| |
| df, features_df = get_historical_with_features( |
| symbol, timeframe, lookback_days=int(lookback_days), |
| use_features=needs_features, refresh=True, |
| ) |
| except Exception as e: |
| return [], empty, empty, empty, empty, empty, f"Data error: {e}" |
|
|
| window, horizon, step_size = int(window), int(horizon), int(step_size) |
| if len(df) < window + horizon + 5: |
| return [], empty, empty, empty, empty, empty, ( |
| f"Only {len(df)} candles available โ raise 'Backtest lookback (days)' " |
| f"or lower the window size to backtest this symbol/timeframe." |
| ) |
|
|
| |
| |
| |
| |
| |
| summary_table = [] |
| detail = {"ARIMA": empty, "Auto-ARIMA": empty, "ARIMA-GARCH": empty, |
| "Moirai": empty, "TimesFM": empty} |
|
|
| if feature_mode == FEATURE_MODE_COMPARE: |
| models = {name: (fresh_model(name), fresh_model(name)) for name in selected_models} |
| try: |
| per_model_results, per_model_summaries = run_comparison_backtest( |
| df, models, features_df, window=window, horizon=horizon, step=step_size, |
| ) |
| except Exception as e: |
| return [], empty, empty, empty, empty, empty, f"Backtest error: {e}" |
| for name in selected_models: |
| for summary in per_model_summaries[name]: |
| summary_table.append(_summary_row(summary, summary["mode"])) |
| detail[name] = per_model_results[name] |
| else: |
| use_features = feature_mode == FEATURE_MODE_FEATURED |
| mode_label = FEATURED_LABEL if use_features else BASELINE_LABEL |
| models = {name: fresh_model(name) for name in selected_models} |
| try: |
| per_model_results, per_model_summaries = run_all_models_backtest( |
| df, models, window=window, horizon=horizon, step=step_size, |
| features_df=features_df if use_features else None, |
| ) |
| except Exception as e: |
| return [], empty, empty, empty, empty, empty, f"Backtest error: {e}" |
| for name in selected_models: |
| summary_table.append(_summary_row(per_model_summaries[name], mode_label)) |
| results = per_model_results[name].copy() |
| results.insert(0, "mode", mode_label) |
| detail[name] = results |
|
|
| status = (f"Backtested {', '.join(selected_models)} on {len(df)} candles of " |
| f"{symbol} ({timeframe}) โ mode: {feature_mode}.") |
| return (summary_table, detail["ARIMA"], detail["Auto-ARIMA"], detail["ARIMA-GARCH"], |
| detail["Moirai"], detail["TimesFM"], status) |
|
|
|
|
| |
| |
| |
| LIVE_COLUMNS = ["predicted_at", "mode", "target_time", "current_close", "predicted_close", |
| "predicted_direction", "actual_close", "actual_direction", "correct"] |
|
|
|
|
| def _required_lookback_days(log_df, minimum_days: int = 2) -> int: |
| """ |
| How many days back we need to fetch to have a chance of covering every |
| still-pending prediction's target candle. |
| |
| BUG FIX: this used to be a hardcoded `lookback_days=2`. If the user |
| doesn't click "Check Latest" for more than 2 days, the oldest pending |
| row's `target_time` falls outside that 2-day window entirely -- |
| `row["target_time"] in df.index` is then False FOREVER for that row |
| (the freshly fetched data never reaches back far enough to contain it), |
| so it silently stays "pending" forever instead of ever being verified. |
| That quietly shrinks the accuracy denominator with no error shown -- |
| exactly the kind of silent corruption this project's other "once the |
| candle closes" fix (see the comment below) was written to prevent, just |
| from the opposite direction (too-narrow fetch instead of too-early read). |
| |
| Fix: size the fetch to the age of the oldest pending row (plus a small |
| safety margin for polling gaps), not a fixed constant. The caller still |
| clamps this to what the timeframe can actually serve (Yahoo's own |
| history-depth limit) -- see the clamp in _check_live_impl. |
| """ |
| if log_df is None or len(log_df) == 0: |
| return minimum_days |
| pending = log_df[log_df["actual_close"].isna()] |
| if len(pending) == 0: |
| return minimum_days |
| oldest_target = pd.to_datetime(pending["target_time"]).min() |
| now = pd.Timestamp.now(tz=oldest_target.tzinfo) |
| age_days = (now - oldest_target).total_seconds() / 86400 |
| return max(minimum_days, int(age_days) + 2) |
|
|
|
|
| def _check_live_impl(symbol, timeframe, model_name, log_df, use_features=False): |
| if log_df is None or len(log_df) == 0: |
| log_df = pd.DataFrame(columns=LIVE_COLUMNS) |
| else: |
| log_df = log_df.copy() |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| source_interval = CUSTOM_TIMEFRAMES.get(timeframe, {}).get("source", timeframe) |
| max_servable_days = YF_NATIVE_INTERVALS.get(source_interval, {}).get("max_days") |
| wanted_days = _required_lookback_days(log_df) |
| fetch_days = min(wanted_days, max_servable_days) if max_servable_days else wanted_days |
|
|
| df, features_df = get_historical_with_features( |
| symbol, timeframe, lookback_days=fetch_days, use_features=use_features, refresh=True, |
| ) |
| latest_time = df.index[-1] |
| latest_close = float(df["Close"].iloc[-1]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pending_mask = log_df["actual_close"].isna() |
| for idx, row in log_df[pending_mask].iterrows(): |
| if row["target_time"] in df.index and row["target_time"] < latest_time: |
| actual_close = float(df.loc[row["target_time"], "Close"]) |
| actual_direction = "up" if actual_close > row["current_close"] else "down" |
| log_df.loc[idx, "actual_close"] = actual_close |
| log_df.loc[idx, "actual_direction"] = actual_direction |
| log_df.loc[idx, "correct"] = bool(actual_direction == row["predicted_direction"]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if max_servable_days is not None: |
| cutoff = pd.Timestamp.now(tz=latest_time.tzinfo) - pd.Timedelta(days=max_servable_days) |
| still_pending = log_df["actual_close"].isna() |
| unreachable = still_pending & (pd.to_datetime(log_df["target_time"]) < cutoff) |
| if unreachable.any(): |
| log_df.loc[unreachable, "actual_direction"] = "unresolvable (past history limit)" |
| log_df.loc[unreachable, "correct"] = None |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| have_pending_for_latest = False |
| if len(log_df): |
| last_row = log_df.iloc[-1] |
| have_pending_for_latest = ( |
| pd.isna(last_row["actual_close"]) and last_row["predicted_at"] == latest_time |
| ) |
|
|
| status_extra = "" |
| if not have_pending_for_latest: |
| try: |
| model = get_model(model_name, symbol, timeframe, use_features=use_features) |
| |
| |
| |
| |
| |
| forecast = model.predict(df["Close"], horizon=1, features=features_df) |
| predicted_close = float(forecast[-1]) |
| predicted_direction = "up" if predicted_close > latest_close else "down" |
| step = infer_step(df.index) |
| new_row = { |
| "predicted_at": latest_time, |
| "mode": FEATURED_LABEL if use_features else BASELINE_LABEL, |
| "target_time": latest_time + step, |
| "current_close": latest_close, "predicted_close": predicted_close, |
| "predicted_direction": predicted_direction, "actual_close": np.nan, |
| "actual_direction": None, "correct": None, |
| } |
| log_df = pd.concat([log_df, pd.DataFrame([new_row])], ignore_index=True) |
| except Exception as e: |
| status_extra = f" (prediction failed: {e})" |
|
|
| verified = log_df.dropna(subset=["correct"]) |
| current_mode = FEATURED_LABEL if use_features else BASELINE_LABEL |
| |
| |
| |
| verified_this_mode = verified[verified["mode"] == current_mode] |
| if len(verified_this_mode): |
| acc = round(100 * verified_this_mode["correct"].sum() / len(verified_this_mode), 2) |
| acc_text = (f"Running accuracy for {model_name} [{current_mode}]: {acc}% " |
| f"({int(verified_this_mode['correct'].sum())}/{len(verified_this_mode)})") |
| other_count = len(verified) - len(verified_this_mode) |
| if other_count: |
| acc_text += f" โ {other_count} verified prediction(s) from the other mode are in the table below but not counted here." |
| else: |
| acc_text = "Collecting data โ check again after the next candle closes to see accuracy." |
|
|
| status = f"Latest {symbol} candle: {latest_time} โ Close {latest_close:.5f}. {acc_text}{status_extra}" |
| return status, log_df.tail(20), log_df |
|
|
|
|
| def check_live(symbol, timeframe, model_name, log_df, use_features=False): |
| try: |
| return _check_live_impl(symbol, timeframe, model_name, log_df, use_features=use_features) |
| except Exception as e: |
| safe_log = log_df if (log_df is not None and len(log_df)) else pd.DataFrame(columns=LIVE_COLUMNS) |
| return f"Error: {e}", safe_log.tail(20), safe_log |
|
|
|
|
| |
| |
| |
| with gr.Blocks(title="Forex & Crypto Prediction Dashboard") as demo: |
| gr.Markdown( |
| "# ๐ Forex & Crypto Prediction Dashboard\n" |
| "ARIMA, Auto-ARIMA, ARIMA-GARCH, Moirai (Salesforce) & TimesFM (Google) โ " |
| "each model's accuracy is tracked and shown **separately**, never averaged " |
| "together.\n\n" + DISCLAIMER |
| ) |
|
|
| with gr.Tab("๐ Chart & Indicators"): |
| with gr.Row(): |
| symbol_in1 = gr.Dropdown(choices=ALL_DEFAULT_SYMBOLS, value="EURUSD=X", |
| allow_custom_value=True, label="Symbol") |
| timeframe_in1 = gr.Dropdown(choices=TIMEFRAME_CHOICES, value="15m", label="Timeframe") |
| lookback_in1 = gr.Slider(1, 60, value=5, step=1, label="Lookback (days)") |
| fetch_btn = gr.Button("Fetch & Analyze", variant="primary") |
| chart_out1 = gr.Plot() |
| signals_out = gr.Dataframe(headers=["Indicator", "Value", "Signal"], |
| label="Top 5 Indicators (latest reading)") |
| status_out1 = gr.Markdown() |
| fetch_btn.click(fetch_and_chart, [symbol_in1, timeframe_in1, lookback_in1], |
| [chart_out1, signals_out, status_out1]) |
|
|
| with gr.Tab("๐ฎ Prediction"): |
| with gr.Row(): |
| symbol_in2 = gr.Dropdown(choices=ALL_DEFAULT_SYMBOLS, value="BTC-USD", |
| allow_custom_value=True, label="Symbol") |
| timeframe_in2 = gr.Dropdown(choices=TIMEFRAME_CHOICES, value="15m", label="Timeframe") |
| with gr.Row(): |
| models_in2 = gr.CheckboxGroup(choices=MODEL_NAMES, value=["ARIMA"], label="Models") |
| horizon_in2 = gr.Slider(1, 10, value=1, step=1, label="Candles ahead") |
| lookback_in2 = gr.Slider(1, 60, value=5, step=1, label="Lookback (days)") |
| use_features_in2 = gr.Checkbox(value=False, label="Use 10-feature input (OHLCV + RSI/MACD/Bollinger/SMA-EMA/Stochastic)") |
| gr.Markdown( |
| "Moirai and TimesFM download pretrained weights the first time you use them " |
| "(needs internet on the Space; a few hundred MB each, one-time download)." |
| ) |
| predict_btn = gr.Button("Predict", variant="primary") |
| chart_out2 = gr.Plot() |
| summary_out2 = gr.Markdown() |
| predict_btn.click(run_prediction, |
| [symbol_in2, timeframe_in2, models_in2, horizon_in2, lookback_in2, use_features_in2], |
| [chart_out2, summary_out2]) |
|
|
| with gr.Tab("๐งช Backtest"): |
| gr.Markdown( |
| "Each model's accuracy is computed and shown **separately** below โ " |
| "results are never averaged together. Raise **Step** to speed up " |
| "Moirai/TimesFM (slower per-prediction than ARIMA on CPU-only hardware)." |
| "\n\n" + FEATURES_HELP |
| ) |
| with gr.Row(): |
| symbol_in3 = gr.Dropdown(choices=ALL_DEFAULT_SYMBOLS, value="EURUSD=X", |
| allow_custom_value=True, label="Symbol") |
| timeframe_in3 = gr.Dropdown(choices=TIMEFRAME_CHOICES, value="15m", label="Timeframe") |
| with gr.Row(): |
| models_in3 = gr.CheckboxGroup(choices=MODEL_NAMES, value=["ARIMA"], label="Models to backtest") |
| window_in3 = gr.Slider(20, 300, value=60, step=10, label="History window per prediction") |
| horizon_in3 = gr.Slider(1, 5, value=1, step=1, label="Horizon (candles ahead)") |
| with gr.Row(): |
| step_in3 = gr.Slider(1, 20, value=1, step=1, label="Step (skip candles between tests)") |
| lookback_in3 = gr.Slider(1, 60, value=10, step=1, label="Backtest lookback (days)") |
| feature_mode_in3 = gr.Radio( |
| choices=[FEATURE_MODE_BASELINE, FEATURE_MODE_FEATURED, FEATURE_MODE_COMPARE], |
| value=FEATURE_MODE_BASELINE, label="Feature mode", |
| info="'Compare both' runs every selected model twice โ Close-only and +10-feature โ on the identical " |
| "symbol/timeframe/window/horizon/step/period, so you can see whether the extra features actually helped.", |
| ) |
| backtest_btn = gr.Button("Run Backtest", variant="primary") |
| summary_out3 = gr.Dataframe( |
| headers=["Mode", "Model", "Total", "Correct", "Incorrect", "Accuracy", "MAE", "RMSE", "Failed"], |
| label="Per-model accuracy โ kept separate, never mixed", |
| ) |
| status_out3 = gr.Markdown() |
| with gr.Tabs(): |
| with gr.Tab("ARIMA โ verify log"): |
| arima_detail = gr.Dataframe(label="Per-candle: predicted vs actual, correct/incorrect") |
| with gr.Tab("Auto-ARIMA โ verify log"): |
| autoarima_detail = gr.Dataframe(label="Per-candle: predicted vs actual, correct/incorrect") |
| with gr.Tab("ARIMA-GARCH โ verify log"): |
| arimagarch_detail = gr.Dataframe(label="Per-candle: predicted vs actual, correct/incorrect") |
| with gr.Tab("Moirai โ verify log"): |
| moirai_detail = gr.Dataframe(label="Per-candle: predicted vs actual, correct/incorrect") |
| with gr.Tab("TimesFM โ verify log"): |
| timesfm_detail = gr.Dataframe(label="Per-candle: predicted vs actual, correct/incorrect") |
|
|
| backtest_btn.click( |
| run_backtest_ui, |
| [symbol_in3, timeframe_in3, models_in3, window_in3, horizon_in3, step_in3, lookback_in3, feature_mode_in3], |
| [summary_out3, arima_detail, autoarima_detail, arimagarch_detail, |
| moirai_detail, timesfm_detail, status_out3], |
| ) |
|
|
| with gr.Tab("๐ก Live"): |
| gr.Markdown( |
| "Polls Yahoo Finance for the newest candle each time you click the button, " |
| "predicts the next one, and verifies the previous prediction once that candle " |
| "has closed. For unattended/continuous logging instead of clicking manually, " |
| "run `live_runner.py` in the background (see README)." |
| ) |
| with gr.Row(): |
| symbol_in4 = gr.Dropdown(choices=ALL_DEFAULT_SYMBOLS, value="BTC-USD", |
| allow_custom_value=True, label="Symbol") |
| timeframe_in4 = gr.Dropdown(choices=TIMEFRAME_CHOICES, value="5m", label="Timeframe") |
| model_in4 = gr.Dropdown(choices=MODEL_NAMES, value="ARIMA", label="Model") |
| use_features_in4 = gr.Checkbox( |
| value=False, label="Use 10-feature input", |
| info="Uses the exact same feature-generation pipeline as the Backtest tab. " |
| "Toggling this mid-session keeps both modes' predictions in the log below, " |
| "but running accuracy is always computed for the current mode only.", |
| ) |
| live_btn = gr.Button("๐ Check Latest & Predict Next", variant="primary") |
| live_log_state = gr.State(pd.DataFrame(columns=LIVE_COLUMNS)) |
| live_status = gr.Markdown() |
| live_table = gr.Dataframe(label="Live verification log (this model, this session)") |
| live_btn.click(check_live, [symbol_in4, timeframe_in4, model_in4, live_log_state, use_features_in4], |
| [live_status, live_table, live_log_state]) |
|
|
| gr.Markdown("---\n" + DISCLAIMER) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|