methodology / Methodology

Methodology & Formulas

Every number on VERTEX is computed from Alpaca market data using the formulas below. We document everything so you know exactly what you're looking at — no black boxes.

⚖ Why We Document This

All 7 user personas who evaluated VERTEX told us the same thing: "I can't certify a number if I don't know how it's computed." These docs serve every metric's data source, formula, and assumptions. Nothing is hidden. If the edge isn't there, we say so.

Quick Navigation

1. Data Source & Feed Alpaca IEX 2. Vol Risk Premium (VRP) study_expected_move 3. Expected Move ATM straddle 4. RSI (14-period) rsi_series 5. Stochastic Oscillator stoch_series 6. Gaussian Channel gaussian_channel 7. Backtest Engine run_backtest 8. ML Signal Discovery study_ml 9. Random Benchmark random_bench

1. Data Source & Feed

data_source

All data comes from Alpaca Markets via their REST API. The backend connects to two endpoints:

Feed Detection

The backend auto-detects the available feed at startup:

try: _get("v2/stocks/AAPL/quotes/latest", feed="sip") → _feed = "sip" except: _feed = "iex" # free tier fallback

IEX feed is the default for free-tier Alpaca accounts. It provides NMS (National Market System) data but is 15-minute delayed for options. SIP feed requires a paid Alpaca subscription.

Historical Bar Data

Stock bars use the /v2/stocks/{symbol}/bars endpoint with pagination (max 10,000 per page). Option bars use /v1beta1/options/bars with chunking (40 symbols per request) to avoid truncation.

# Chunking prevents silent data loss on large option requests for i in range(0, len(occ_syms), 40): chunk = occ_syms[i : i + 40] j = _get(f"v1beta1/options/bars", symbols=",".join(chunk))

Trading Calendar

Market-open dates come from Alpaca's /v2/calendar endpoint. Expiration dates are computed as the Nth market-open day on/after the trade date — skipping weekends and holidays.

def expiration_for(trade_date, dte): cal = trading_days() i = bisect_left(cal, trade_date) return cal[min(i + max(0, dte), len(cal) - 1)]

2. Vol Risk Premium (VRP)

vrp

What it measures: Is the market's implied move (what options cost) richer than what actually happens? Positive VRP means options are expensive — sellers have an edge.

Computation (study_expected_move())

For each trading day, we sell an ATM straddle at the open and value it at the close:

# For each trading day: K = round(open / step) * step # ATM strike # Fetch call + put option bars for that strike c_open = cb[0]["o"] # Opening price of the call c_close = cb[-1]["c"] # Closing price of the call p_open = pb[0]["o"] # Opening price of the put p_close = pb[-1]["c"] # Closing price of the put implied = c_open + p_open # Implied move (straddle at open) close_straddle = c_close + p_close # Straddle value at close realized = |close - K| # Realized intrinsic value short_pnl = implied - close_straddle # P&L per share (sell open, cover close)

Key Metrics

MetricFormula
Edge Ratioavg(implied) / avg(realized_intrinsic) — how many × realized the market prices in
Short Straddle Avgmean(short_pnl) × 100 ($/contract/day)
Win Ratedays_where(short_pnl > 0) / total_days
Day Sharpemean(short_pnl) / stdev(short_pnl) — risk-adjusted return per day
Within Band %|close - K| ≤ implied — % of days where price stayed within the implied range
⚠ Caveats: Gross of commissions and spread costs (which would reduce P&L). Options history begins approximately February 2024 (when Alpaca launched options). Uses the IEX feed — delayed by 15 minutes on free tier.

3. Expected Move

expected_move

What it is: The ATM (at-the-money) straddle premium — the market's consensus forecast of how much the underlying will move, expressed as ±$X.XX.

# Expected Move = ATM Call Premium + ATM Put Premium atm_strike = round(underlying_price / step) * step atm_call = bands.find(strike==atm_strike, side="call") atm_put = bands.find(strike==atm_strike, side="put") expected_move = atm_call.premium + atm_put.premium

This is displayed on the Dashboard Option Ladder panel as "Expected Move: ±$X.XX" and on the Chart page as colored ribbons (the call breakeven above, put breakeven below). Multi-DTE ribbons show how the expected move expands with time.

Note: The expected move is the cost of the straddle. If the underlying stays within this range, the short straddle seller profits. If it breaks out, the seller loses. This is not a prediction — it's the market's priced-in consensus.

4. RSI (14-Period Relative Strength Index)

rsi

Source: rsi_series(close, n=14) in backend.py. Based on Wilder's smoothed RSI.

d = close.diff() # Period-over-period change gain = d.clip(lower=0).ewm(alpha=1/n, adjust=False).mean() # Exponential avg of gains loss = (-d.clip(upper=0)).ewm(alpha=1/n, adjust=False).mean() # Exponential avg of losses RSI = 100 - 100 / (1 + gain/loss) # Scale to 0-100
RangeInterpretation
≥ 70Overbought — potential reversal or continuation of strong trend
≤ 30Oversold — potential bounce or continued selling
50Neutral — no directional edge

RSI is computed per timeframe (5min, 15min, 30min, 1day) on the Chart page and shown in the Quote sidebar and momentum table.

5. Stochastic Oscillator (%K / %D)

stochastic

Source: stoch_series(df, k=14, d=3, s=3) in backend.py.

ll = df["l"].rolling(14).min() # Lowest low over 14 periods hh = df["h"].rolling(14).max() # Highest high over 14 periods fast_K = 100 * (close - ll) / (hh - ll) # Raw %K slow_K = fast_K.rolling(3).mean() # Smoothed %K (3-period SMA of fast K) slow_D = slow_K.rolling(3).mean() # %D (3-period SMA of smoothed K)
RangeInterpretation
> 80Overbought — price near top of recent range
< 20Oversold — price near bottom of recent range
%K crosses %DPossible momentum shift (like MACD crossover)

Stochastics are shown in the lower pane of the Chart page. Up to 5 timeframe overlays are drawn simultaneously. The 80/20 guide lines mark traditional overbought/oversold boundaries.

6. Gaussian Channel

gaussian_channel

Source: gaussian_channel(h, l, c, per=144, poles=4, mult=1.414, ...) — an N-pole Ehlers Gaussian filter adapted from DonovanWall's Pine Script v4. The channel tracks the smoothed trend centerline with adaptive volatility bands.

Step-by-step computation

1. Source = HLC3

src = (h[i] + l[i] + c[i]) / 3

2. True Range — same as ATR calculation:

TR[0] = h[0] - l[0] TR[i] = max(h[i]-l[i], |h[i]-c[i-1]|, |l[i]-c[i-1]|)

3. Filter coefficients — derived from period and poles:

beta = (1 - cos(2π / per)) / (1.414^(2/poles) - 1) alpha = -beta + sqrt(beta² + 2*beta) lag = (per - 1) / (2 * poles)

4. Recursive pole filter — applies alpha iteratively for N poles:

# For i poles (i = 1 .. poles): coefficient[k] = (-1)^(k+1) × C(i, k) × (1-alpha)^k # for k = 1..i f[t] = alpha^i × src[t] + Σ(coefficient[k] × f[t-k]) # for k = 1..i

5. Bands — filtered TR scaled by multiplier:

filt = fN (N-pole filtered source) filttr = fNtr (N-pole filtered TR) upper = filt + filttr × mult # Default mult = 1.414 lower = filt - filttr × mult

6. Direction — based on filter slope:

direction = +1 if filt[i] > filt[i-1] # Rising — bullish bias direction = -1 if filt[i] < filt[i-1] # Falling — bearish bias direction = 0 otherwise # Flat — range regime
Usage note: Direction is used by the backtest engine's trend_pullback strategy (entries in the direction of the daily Gaussian slope) and em_fade (only trades when the Gaussian is flat — mean-reversion regime). A rising Gauss + pullback to VWAP is a classic trend-pullback entry.

7. Backtest Engine

backtest

Source: run_backtest() — simulates 4 strategies on historical data with realistic costs and walk-forward validation.

The 4 Strategies

StrategyEntry LogicStop / Target
Trend Pullback Gaussian rising (daily slope +), price near VWAP, %K rising on 5m & 15m, %K < 65 (long side); symmetrical for short Stop: 1R below entry (short: above)
Target: 2R
Expected Move Fade Gaussian flat, price stretched ~80% toward EM band edge, momentum rolling back Stop: beyond the EM band edge by 0.5× half-band
Target: VWAP
VWAP Reversion Price deviates from VWAP by > vwap_stretch (default 0.4%) Stop: entry price ± deviation
Target: VWAP
Gaussian Follow Price crosses the 15-min Gaussian filter line — trend-following entry in the direction of the cross Exit on opposite cross or EOD. No fixed target.
Stop: entry ± 1R

Walk-Forward Split

Every strategy is split into In-Sample (IS) and Out-of-Sample (OOS) periods:

days = sorted(set(entry_dates)) cut_idx = int(len(days) * 0.7) # First 70% of trading days cut_date = days[cut_idx] IS = trades where date < cut_date # "Training" period OOS = trades where date ≥ cut_date # "Unseen" validation period

If IS performance is strong but OOS collapses, the strategy is likely overfit — the pattern doesn't generalize. Honest backtest pages flag this gap.

R-Multiple System

All P&L is expressed in R-multiples (risk units), making performance comparable across strategies and markets:

risk = |entry - stop| # Dollar risk per share risk_pct = risk / entry × 100 # Risk as % of entry price gross_pct = (exit - entry) / entry × 100 × dir # Gross return % net_pct = gross_pct - cost_bps / 100 # Net of round-trip cost R = net_pct / risk_pct # R-multiple (risk-adjusted return)

Default round-trip cost: 2 bps (configurable via cost_bps parameter).

Key Metrics

MetricDefinition
Win Rate% of trades with positive R
Expectancy (R)mean(R) — average risk-adjusted return per trade
Profit Factorsum(win_R) / -sum(loss_R) — gross profit/gross loss ratio
Max DD (R)Largest peak-to-trough decline in cumulative R equity
Total Returnsum(ret_pct) — cumulative % return
⚠ Honest caveat: Backtests are not predictions. Every strategy is compared to a random-entry benchmark (see §9). If a strategy doesn't beat random, we label it "LOSES TO RANDOM" — no exceptions. Sample-size warnings appear when fewer than 30 trades are available (95% CI width > ±18%).

8. ML Signal Discovery

ml

Source: study_ml() — trains a HistGradientBoostingClassifier (scikit-learn) to predict whether the price will be higher in N bars.

Feature Set (15 features)

FeatureDescription
rsi5, rsi15, rsi30RSI on 5min, 15min, 30min timeframe
k5, k15, k30Stochastic %K on 5min, 15min, 30min
vwap_dist% distance from VWAP: (price - VWAP) / price × 100
g5_dist, g15_dist, g1d_dist% distance from Gaussian filter centerline (5min, 15min, 1day)
em_posPosition within expected-move bands: (price - EM_mid) / EM_half
todTime-of-day in minutes since 09:30 ET
ret1, ret3, ret6Prior 1-bar, 3-bar, 6-bar returns (same-session only)
volrelVolume relative to 20-bar average: volume / mean(volume[-20:])

Walk-Forward Validation

tss = TimeSeriesSplit(n_splits=5) # 5-fold time-series CV for train_idx, test_idx in tss.split(X): model.fit(X[train_idx], y[train_idx]) oos_preds += model.predict_proba(X[test_idx])[:, 1] oos_y += y[test_idx] # Final metrics on the accumulated OOS predictions: AUC = roc_auc_score(oy, op) # Area Under ROC Curve accuracy = accuracy_score(oy, op > 0.5) baseline = max(y.mean(), 1 - y.mean()) # Always-predict-the-majority-class baseline

Interpretation

AUCVerdict
0.50Random — model cannot predict direction. Displayed as NOISE.
0.50–0.53Barely above random — likely curve-fitting. Shown as NOISE.
0.53–0.55Mild edge — use with caution. MODERATE confidence.
0.55+Real predictive power. HIGH confidence.
< 0.50Worse than random — the model is anti-predictive. Displayed honestly.
⚠ Crucial honesty signal: Most ML models on market data score ~0.50–0.53 AUC. The OOS AUC is always disclosed — if it's 0.47, we say 0.47. No cherry-picking, no "best fold" reporting. The model uses only same-session forward returns (no overnight gaps).

9. Random Benchmark

random_benchmark

Source: random_bench(ntrades) in backend.py. Every backtest compares strategy performance to a seeded random-entry benchmark with identical risk parameters.

rng = Random(42) # Seeded for reproducibility picks = set(rng.sample(candidates, ntrades)) # Random entry points # Each random trade uses the same R-multiple structure: dir = rng.choice([1, -1]) # Random direction (long/short) stop = entry - dir × R # Same risk unit target = entry + dir × 2R # Same 1:2 reward:risk

The random benchmark is the single most important honesty signal in VERTEX. If a strategy's expectancy doesn't exceed random, the strategy card shows "✗ LOSES TO RANDOM" in red. If it beats random but still loses money (negative expectancy), it shows "✓ BEATS RANDOM (still loses $)" in amber.

⏳ Data Freshness & Delays

Options data on the Alpaca IEX feed is 15-minute delayed. Stock data (quotes, trades, bars) is available in real-time on the IEX feed. The feed field in every API response tells you which feed was used: "iex" (free, delayed options) or "sip" (paid, real-time). We always surface this so you know the age of your data.

📚 Additional Resources

Glossary definitions — 30+ trading terms with plain-English explanations.
API Docs — Full REST API reference for all endpoints.
GitHub — Source code. Everything here is open for inspection.