← back to mock pineapple
Mock Pineapple · Steps 2-3 · Trade Pipeline

From Forecast to Position — The Signal Pipeline

Forecast accuracy is necessary but not sufficient. Sub-1% MAPE doesn't trade itself. Four sequential gates turn a 30-day forecast into a sized paper-trade position — and the right one cost me +26% Sharpe on JPY.
+26%
JPY Sharpe (regime gate)
2.24
SGD rolling-sim Sharpe
52.6%
JPY win rate (was 48.6%)
4
sequential gates
~25 min
human time saved/day
The four gates — forecast to position

A forecast says "JPY rate will be 156.10 in 30 days, 95% confidence." A trade signal has to answer something harder: should I take this position right now, and how much? The four gates below run in order — any one of them can drop the trade with a logged reason. Most days, every pair gets dropped at gate 1 (regime).

forecast(pair) → trade signal pipeline

      ┌─────────────────────────────────────────────────────┐
      │ GATE 1 · regime_detector.py                         │
      │   ADX(14d) ≥ 18 ?  ─── NO ──► SKIP (ranging market) │
      └────────────────────────────┬────────────────────────┘
                                   ▼
      ┌─────────────────────────────────────────────────────┐
      │ GATE 2 · forecast-range gate                        │
      │   |30d_change| ≥ 2.0% ?  ─── NO ──► SKIP (no edge)  │
      └────────────────────────────┬────────────────────────┘
                                   ▼
      ┌─────────────────────────────────────────────────────┐
      │ GATE 3 · adaptive confidence threshold              │
      │   confidence ≥ adapt(ADX) ?  ─── NO ──► SKIP        │
      └────────────────────────────┬────────────────────────┘
                                   ▼
      ┌─────────────────────────────────────────────────────┐
      │ GATE 4 · half-Kelly sizing (cold-start safe)        │
      │   size = balance × min(0.5×kelly, 0.20)             │
      └────────────────────────────┬────────────────────────┘
                                   ▼
                         OPEN POSITION → portfolio_state.json
                         Slack notify  → "BUY SGD @ 1.2855 ..."
Gate 1 — Regime detection (the biggest mover)

The single highest-leverage decision in the pipeline. Even a great forecast model is wrong in ranging markets, where price oscillates around a mean and any signal is noise. The Average Directional Index (ADX) measures trend strength — high ADX = trending, low ADX = ranging. Built from scratch in source.trading.regime_detector.

A/B TEST
ADX threshold sweep — 15 / 18 / 20 / 25
A/B'd 4 thresholds across 9 walk-forward cutoffs. ADX 18 was the sweet spot — high enough to filter noise, low enough to catch most trends. 15 let too many ranging signals through; 25 filtered out real trends and starved the trader.
→ ADX 18 frozen as production threshold
RESULT — JPY
Sharpe 1.18 → 1.49 (+26%)
Win rate moved from 48.6% → 52.6%. Portfolio return moved from +1.54% → +2.10% (+36%). All from one signal idea, no model retraining.

Why it works on JPY: JPY/USD has long carry-trade trends (USD-JPY rate-differential persistence). When ADX is high, the trend is your friend.
→ S13 KPI (Signal Sharpe ≥1.5) MET
Gates 2-3 — Edge filtering

Two cheap checks that prevent low-edge trades. Both are configurable per pair — JPY has a wider 30-day range than SGD, so the thresholds aren't shared. The principle: if the edge is small, the transaction cost eats it.

GATE 2 · FORECAST RANGE
Skip if 30-day forecast range < 2.0%
Even a perfect forecast of +0.5% over 30 days isn't tradeable — the round-trip transaction cost (real or modeled) eats it. The 2.0% threshold is the rough break-even in our paper-trade model.

This gate is what kills EUR as a tradeable pair (vol gate 0.008) — see Finding 6 on the overview page.
→ Filters EUR out permanently; SGD/JPY pass intermittently
GATE 3 · ADAPTIVE CONFIDENCE
High ADX → lower confidence required
When the market is strongly trending (high ADX), even modest model confidence is meaningful. When ADX is borderline, the model has to be more confident to compensate.

Formula: min_confidence = base − ADX_bonus. Example: GBP base 0.65, ADX 21.7 → min_confidence 0.623.

Lesson: M51 tested adaptive confidence as a standalone — added zero value. Only useful after the regime gate already passed.
→ Stacks with regime, doesn't replace it
Gate 4 — Half-Kelly sizing (cold-start safe)

Once a trade passes the first three gates, the question is how much. Full Kelly is too aggressive (any historical-win-rate error gets amplified to bankruptcy risk). Half-Kelly with a cold-start clamp is the production rule.

# source.trading.position_sizer

def size_position(balance, history):
    # COLD START — <5 trades, or no wins, or no losses
    if len(history) < 5 or no_wins(history) or no_losses(history):
        return 1000.0           # fixed size, builds the win/loss sample

    # FULL KELLY = (p_win × avg_win − p_loss × avg_loss) / avg_win
    p_win    = wins / total
    avg_win  = mean([t.pnl for t in history if t.pnl > 0])
    avg_loss = mean([t.pnl for t in history if t.pnl < 0])
    kelly    = (p_win * avg_win − (1 − p_win) * abs(avg_loss)) / avg_win

    # HALF-KELLY with 20% per-trade cap
    return balance * min(kelly * 0.5, 0.20)
Why cold-start matters: with 0 wins and 1 loss, naive Kelly = −∞. With 2 wins and 0 losses, it's +∞. Both break the system. The fixed-$1000 cold start is sized so a string of bad early trades doesn't blow the account before there's enough history to compute a real Kelly. Activates only after n ≥ 5 with at least one win and one loss.
Step 3 — Paper trade execution

Every daily run does two things in order: close eligible positions first, then open new ones. Closing first means a stale position can't block a new signal on the same pair. State lives in portfolio_state.json + the trades SQLite table.

# Pseudo-code — the daily execution loop

def daily_run():
    # 1. ALWAYS close before opening
    for position in open_positions:
        if position.days_held >= position.target_exit_day:
            close(position, reason='time_expiry')
        elif position.current_rate hits target:
            close(position, reason='target_reached')
        elif stop_loss_triggered(position):
            close(position, reason='stop_loss')

    # 2. Score every pair, run gates, open if eligible
    for pair in ['JPY', 'SGD']:
        if pair has open position:
            continue                          # one-at-a-time per pair

        forecast = ensemble_forecast(pair)
        signal   = generate_signal(forecast)  # gates 1-3
        if signal is None:
            log(f"{pair}: skipped — {reason}")
            continue

        size = size_position(balance, history)  # gate 4
        open(pair, signal, size)
        slack_notify(f"{signal.action} {pair} @ {signal.entry_rate}, "
                     f"target {signal.target_exit_rate}, Sharpe {signal.sharpe}")

    # 3. Health log
    pipeline_health.log(success=True, n_signals=len(opened))
Live results — what's actually in the books

Numbers as of the last successful run. Both positions opened in late March; both are paper trades, not real money. The intent was to validate the signal pipeline against real out-of-sample price action.

PairEnteredActionEntry rateTarget exitSharpe @ entryRegime @ entry
SGD2026-03-20SELL1.28551.2736 (−0.93%)2.77trending_up · ADX 21.4
JPY2026-03-27SELL159.62156.10 (−2.20%)2.80trending_up · ADX 20.8
The Slack feedback loop

Every trade entry and exit pings a Slack webhook. Two reasons: (1) it forces visibility — if the system silently breaks, I notice within a day; (2) it's a discipline aid — paper trades are easy to ignore, named ones aren't.

Slack message format

🟢 Mock Pineapple — TRADE OPENED
SELL SGD @ 1.2855 · target 1.2736 (−0.93%, 30d) · Sharpe 2.77 · regime trending_up (ADX 21.4) · size $1000 (cold start)

Exit messages include realized P&L, days held, and the close reason (time_expiry / target_reached / stop_loss). Closed-position summary posts daily even if no new trades opened.

What the trade pipeline delivers

JPY Sharpe 1.49 live (was 1.18 without regime gate). SGD Sharpe 2.24 in 6-cutoff rolling sim, 100% win rate (3/3 — small sample, suggestive not proven). Daily LaunchAgent runs at 9:50 AM, closes expired positions, opens eligible new ones, ships a Slack ping. The forecast is the input — the gate stack is what makes it tradeable. ~25 minutes/day of human time automated away.