LIVE·
fetching live quotes from Yahoo Finance…
--:--:--UTC
Learn/Bots/Direction Ensemble
AI QuantsAIid · ai-direction
▲▼

Direction Ensemble

Will the stock be up or down 20 days from now? GBM ensemble vote.

▶ Try it now↗ Browse all 27srcai quants/models/direction/train.pyapi/api/direction
In plain English

An AI brain made of 7 little decision-tree models, each guessing whether the price will go up or down 20 days from now. We average their guesses to get one probability. When the little models all agree (low spread), trust the answer more. When they disagree, they're guessing — sit it out.

No jargon. Just what this bot does.
The longer version

An ensemble of 7-14 boosted-tree models, each predicting whether the stock closes up or down 20 days from now. The probability is the mean of their votes; the conviction band reflects how much that probability deviates from the 0.5 noise floor. Lower ensemble σ = trees agree = higher trust.

The math
formula
GradientBoosting · 12 levers · cross-asset features · embargo CV
parameters
horizonHorizon (days)range 1 → 60default · 20
ensembleEnsemble sizerange 1 → 30default · 10
Live demo

Real Direction Ensemble bot, running on real Yahoo data when the symbol is available. Drag the params — the bot re-runs instantly.

symbolloading…
loading AMZN bars…
Source code · public

This is the actual code the bot runs — not a re-explanation, not a simplified version. Whatever ships here is what executes when you press Run All in the workbench. Read it, copy it, fork it, build a better one.

lib/quant/ai-bots.ts·lines 581663
TypeScript · MIT-licensed
const directionEnsemble: BotDef = aiBot<DirReq, DirRes>(
  {
    id: "ai-direction",
    name: "Direction Ensemble",
    category: "ai",
    glyph: "▲▼",
    tagline: "Will the stock be up or down 20 days from now? GBM ensemble vote.",
    formula: "GradientBoosting · 12 levers · cross-asset features · embargo CV",
    endpoint: "/api/direction",
    module: "ai quants/models/direction/train.py",
    params: [
      { key: "horizon", label: "Horizon (days)", kind: "number", default: 20, min: 1, max: 60, step: 1 },
      { key: "ensemble", label: "Ensemble size", kind: "number", default: 10, min: 1, max: 30, step: 1 },
    ],
  },
  {
    request: dirRequest,
    build: (data) => {
      const pUp = data.p_up;
      const label = data.prediction.toUpperCase();
      const band = data.conviction_band;
      const expAcc = data.expected_accuracy;
      const conviction = Math.abs(pUp - 0.5);
      return {
        signals: [],
        metrics: [
          { key: "p", label: "P(up)", value: `${(pUp * 100).toFixed(1)}%`, tone: pUp > 0.5 ? "bull" : "bear" },
          { key: "label", label: "Direction", value: label, tone: pUp > 0.5 ? "bull" : "bear" },
          { key: "band", label: "Conviction", value: band.toUpperCase(), tone: band === "ultra" || band === "extreme" ? "bull" : band === "high" ? "info" : "neutral" },
          { key: "std", label: "Ensemble σ", value: fmtNum(data.ensemble_std, 3), tone: data.ensemble_std < 0.05 ? "bull" : "neutral", hint: "lower σ = models agree" },
          { key: "exp", label: "Expected acc", value: `${(expAcc * 100).toFixed(0)}%`, tone: "info" },
          { key: "size", label: "Ensemble", value: `${data.ensemble_size} models`, tone: "neutral" },
        ],
        summary: `Real ensemble of ${data.ensemble_size} GBMs on ${data.ticker} says ${label} (P=${(pUp * 100).toFixed(1)}%) for ${data.horizon_days}d — ${band} conviction. Embargoed CV accuracy ~${(expAcc * 100).toFixed(0)}%.`,
        beginner:
          "Boosted-tree models each vote on whether the stock will be up or down 20 days from now. When they agree (low σ), trust the call more.",
        verdict: {
          side: pUp > 0.6 ? "buy" : pUp < 0.4 ? "sell" : "hold",
          text: `${(pUp * 100).toFixed(0)}% chance ${data.prediction}. ${band} conviction — historical accuracy ~${(expAcc * 100).toFixed(0)}%.`,
          confidence: Math.min(1, conviction * 4),
        },
      };
    },
    mock: (ctx, p) => {
      const horizon = num(p, "horizon", 20);
      const px = closes(ctx.candles);
      const trend = trendStrength(px);
      const rv = realisedVol(px);
      const seed = hashStr(ctx.symbol + horizon + Math.round(px[px.length - 1] * 100));
      const rand = seedRand(seed);
      const probs: number[] = [];
      for (let i = 0; i < num(p, "ensemble", 10); i++) {
        const noise = (rand() - 0.5) * 0.18;
        const raw = 0.5 + trend * 1.4 + noise;
        probs.push(Math.min(0.97, Math.max(0.03, raw)));
      }
      const pUp = probs.reduce((a, b) => a + b, 0) / probs.length;
      const std = Math.sqrt(probs.reduce((a, b) => a + (b - pUp) ** 2, 0) / probs.length);
      const conviction = Math.abs(pUp - 0.5);
      const band = conviction > 0.20 ? "ultra" : conviction > 0.15 ? "extreme" : conviction > 0.07 ? "high" : "low";
      const expAcc = band === "ultra" ? 0.77 : band === "extreme" ? 0.66 : band === "high" ? 0.61 : 0.555;
      const label: "up" | "down" = pUp > 0.5 ? "up" : "down";
      return {
        signals: [],
        metrics: [
          { key: "p", label: "P(up)", value: `${(pUp * 100).toFixed(1)}%`, tone: pUp > 0.5 ? "bull" : "bear" },
          { key: "label", label: "Direction", value: label.toUpperCase(), tone: label === "up" ? "bull" : "bear" },
          { key: "band", label: "Conviction", value: band.toUpperCase(), tone: band === "ultra" || band === "extreme" ? "bull" : band === "high" ? "info" : "neutral" },
          { key: "std", label: "Ensemble σ", value: fmtNum(std, 3), tone: std < 0.05 ? "bull" : "neutral", hint: "lower σ = models agree" },
          { key: "exp", label: "Expected acc", value: `${(expAcc * 100).toFixed(0)}%`, tone: "info" },
          { key: "rv", label: "Realised vol", value: `${(rv * 100).toFixed(0)}%`, tone: "neutral" },
        ],
        summary: `Ensemble of ${probs.length} GBMs says ${label.toUpperCase()} (P=${(pUp * 100).toFixed(1)}%) for ${horizon}d — ${band} conviction.`,
        beginner: "Boosted-tree models each vote on direction. When they agree (low σ), trust the call more.",
        verdict: {
          side: pUp > 0.6 ? "buy" : pUp < 0.4 ? "sell" : "hold",
          text: `${(pUp * 100).toFixed(0)}% chance ${label}. ${band} conviction.`,
          confidence: Math.min(1, conviction * 4),
        },
      };
    },
  },
);
what each piece means
  • id — unique key the workbench uses to find the bot.
  • params — the sliders + inputs you see on the cell.
  • run(ctx, p) — the function that gets called with candles + your params and returns the verdict.
  • verdict — the BUY / SELL / HOLD pill at the top of the cell.
  • metrics — the small stat boxes shown in the cell body.
use this code yourself
  1. Copy the whole block above.
  2. On /quant, click + Import your bot in the bot library.
  3. Paste, hit save. It hot-loads into your workspace.
  4. Edit any param defaults or logic to your taste — it's now yours.
Specialty · when it shines, when it fails
✓ Shines when
  • ·Names with strong macro coupling (SPY, QQQ, AAPL). The features include cross-asset signals — VIX, DXY, 10Y, WTI — that anchor the prediction.
  • ·Mid-cycle markets. The models see lots of those in training and generalise well.
  • ·When ensemble σ is low (<0.05). That means the trees agree; you're not betting on a single fluky leaf.
✗ Fails when
  • ·Earnings windows. The model treats the 20-day horizon as homogeneous, but earnings can flip the sign in a single bar.
  • ·Illiquid tickers. Need ≥250 bars; small-caps with thin history under-perform.
  • ·Tail events. The labels were clipped at the 99th percentile — the model literally hasn't seen 2020 March.
How to read its verdict

BUY at p_up > 0.6, SELL at p_up < 0.4, HOLD in between. Conviction band classifies that probability into LOW / HIGH / EXTREME / ULTRA, and the historical accuracy of each band is published on the card.

Python service

This bot tries to call the FastAPI service first. When it's up, you get real model output. When it's down, the bot transparently falls back to a deterministic TS surrogate.

FastAPI·http://localhost:8000·/api/directionCHECKING…
data flow
01
BotCell.run()
User clicks Run on this bot in /quant
02
callApi()
POST to localhost:8000/api/direction
03
load_surrogate()
ai quants/models/direction/train.py
04
predict()
Forward pass on the inputs you provided
05
BotResult
JSON returned, card flips green Source: Python NN
spin it upcd "ai quants" && uvicorn serve:app --reload --port 8000
FAQ
What features does it use?+
Per-asset: returns over 5/10/20/60/252 days, realized vol, RSI, MACD, distance-from-MA, regime flags. Cross-asset: VIX, DXY, 10Y rate, oil. ~40 features in total, fed into a HistGradientBoostingClassifier ensemble.
Why ensemble of 7 instead of 1?+
Variance reduction. A single GBM has noisy decisions near the threshold; the mean of 7 differently-seeded models is much more stable. The σ across the ensemble doubles as a confidence proxy — when σ is high, we know the trees are guessing.