← back to portfolio
What this is: A daily FX forecasting and paper-trading system I built solo. Two pairs (JPY/USD and SGD/USD) trade live in paper; three more (EUR, GBP, PHP) run as forecasts only. The whole pipeline lives on my laptop — no cloud, no shared services.
The pieces:
Where it ended up: JPY 1.43% MAPE / Sharpe 1.49 live · SGD 1.00% MAPE / Sharpe 2.24 in 6-cutoff rolling sim (3 trades — small sample). 549/551 tests passing.
Stack: Python (statsmodels, Prophet, LightGBM, Optuna, scikit-learn), SQLite, Streamlit, macOS LaunchAgent, Slack webhook
Mock Pineapple · FX Forecasting · Time Series + MLOps

FX Forecasting on a Laptop

A daily FX forecasting and paper-trading system. Three-model ensemble, walk-forward validated, drift-triggered re-tune. Two pairs in live paper trading; the whole pipeline runs unattended on my laptop.
The interesting work wasn't picking models — it was deciding which knobs to turn automatically and which to leave alone. The drift-triggered retune loop and the per-pair re-tune rules are what make this run without me.
Full system map — End-to-End ML Pipeline

From frankfurter.app rates and macro feeds to a paper-traded position. What runs every morning at 9:50, what runs Mondays at 9:30, what reports back to the dashboard. Three deep-dives below cover the ML core, the trade-signal pipeline, and the drift-triggered re-tuning.

FX RATES (frankfurter.app/ECB)         MACRO FEEDS (yfinance)         ─── DAILY 9:50 AM (LaunchAgent) ───
  │                                      │
  │  JPY/USD, SGD/USD, EUR, GBP, PHP     │  DXY, VIX, US 10Y Treasury,
  │  + 24,076 historical rows            │  gold, oil, yield-spreads
  ▼                                      ▼
data/fx_historical.parquet         data/exogenous/*.parquet
                  │                      │
                  └──────────┬───────────┘
                             ▼
            ┌────────────────────────────────────────┐
            │  ingest_local_fx() — auto-fetch gaps   │
            │  create_features() — 38 features       │
            └────────────────────────────────────────┘
                             │
                             ▼
       ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
       ┃                  STEP 1 — ML CORE                   ┃
       ┃        SARIMA + Prophet + LightGBM → ensemble       ┃
       ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
                             │
                             ▼
        ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
        ┃   STEP 2 — REGIME GATE + KELLY SIZING       ┃
        ┃   ADX < 18 → SKIP   |   half-Kelly cap 20%  ┃
        ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
                             │
                             ▼
        ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
        ┃   STEP 3 — PAPER TRADE EXECUTION + SLACK    ┃
        ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
                             │
        ┌────────────────────┴────────────────────┐
        ▼                                         ▼
   data/mock_pineapple.db              data/daily_forecasts/
   pipeline_runs · forecasts ·         YYYY-MM-DD_signals.json
   signals · trades · portfolio


─── WEEKLY MONDAY 9:30 AM (LaunchAgent) ───
       ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
       ┃          STEP 4 — DRIFT WATCH + RE-TUNE         ┃
       ┃   walk-forward MAPE → 1.5× × 2 weeks → Optuna   ┃
       ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
       └─► trained_models/YYYY-WXX/manifest.json + dashboard regen


─── ALWAYS-ON ───
       Streamlit dashboard/app.py @ localhost:8504
SARIMA days 1-3 Prophet days 4-14 LightGBM days 15-30 ensemble horizon-adaptive 38 features · 3 buckets · Optuna PHP 2.72% → 0.28% (−90%)
Features & Models
38 features, three forecasting models, one ensemble. Optuna tuning cut MAPE 60-80% across all pairs. The biggest accuracy lift came from a mid-sprint pivot to macro features (DXY, VIX, US 10Y), well off the planned milestone list.
Open the ML core →
forecast ADX gate ADX ≥ 18 ? SKIP ranging trade half-Kelly sizing cold start: ≥5 trades, win+loss Sharpe 1.49 JPY (was 1.18) Sharpe 2.24 SGD rolling-sim
Signals & Trades
Forecast accuracy is necessary but not sufficient. An ADX-based regime filter took JPY from Sharpe 1.18 to 1.49 (+26%) without changing the model. Half-Kelly sizing with a cold-start clamp keeps positions honest.
Open the trade pipeline →
baseline MAPE 1.5× drift threshold 2 weeks > 1.5× Optuna · 30 trials SARIMA/JPY: never re-tune | SGD: drift-trigger | LightGBM/JPY: drift-trigger
Drift & Auto-Retune
Walk-forward MAPE → drift detector → Optuna re-tune. The rules are pair-specific: SARIMA on JPY gets worse if I re-tune it (so I don't), SGD benefits from drift-triggered retune. 71% less compute than re-tuning on a calendar.
Open the maintenance loop →
▶ INTERACTIVE
Forecasts vs reality — what 1% MAPE actually looks like
Pick a pair, see the SARIMA forecast trajectory at six cutoff dates plotted against the realized rate over the next 30 business days. Hover for per-day error. Plotly.
Open the chart →

The numbers

Validated MAPE: JPY 1.43%, SGD 1.00% on 6-cutoff walk-forward (2023-2025). Paper trading: JPY Sharpe 1.49 live; SGD Sharpe 2.24 in rolling sim, 3 trades. Tests: 549/551 passing. Backtest speed: >30 min → ~12s per pair (ProcessPool). Daily run: ~90 seconds per pair, unattended.

RETROSPECTIVE
Six things I learned building this
Notes from the analyst report I wrote at the end of the build — what mattered most, what didn't work, and what I'd do differently next time.
Read the retrospective →