Scoring model (PUT L1, PUT L2, covered call)
The score model is the heart of the options screener. For every strike of a filtered option chain the backend service computes a numeric score; the strikes with the highest metric are listed further up.
Same formula as the desktop app — web is ported 1:1. If they disagree, desktop wins. Sources:
- Web:
wheeltrading-backend/services/option_scoring.py,
services/medal_ranking_service.py, services/option_recommendation.py
- Desktop:
app/utils/option_utils.py
(calculate_put_score, calculate_call_score, calculate_atr_distance_put, calculate_atr_distance_call)
Pipeline at a glance
Option chain (provider)
|
v
[1] Filters: DTE, |Δ|, OI, volume, moneyness, ITM block
|
v
[2] Risk flags: earnings, ex_div, iv_low, iv_extreme, low_liquidity
|
v
[3] SCORE → calculate_put_score / calculate_call_score
|
v
[4] CLASSIFY → recommend_put / recommend_call
(L1 / L2 / CC / 🚫 / ⚠️ + warn icons)
|
v
[5] Sort: score DESC → premium p.a. DESC → DTE ASC → strike
|
v
Candidates (by metric)The five stages are clearly separated:
| Stage | Question | Result |
|---|---|---|
| 1 | Is the strike worth a look at all? | filtered list |
| 2 | What risks are attached? | flag set |
| 3 | How attractive is the strike numerically? | score (0–110 / 0–100) |
| 4 | Does the strike meet L1/L2/CC thresholds? | label L1/L2/CC/🚫 |
| 5 | How are the candidates sorted? | order by metric |
Score and classification are independent: a strike can score high yet receive 🚫 (e.g. CC strike below cost basis), or have a weak classification but still be listed further up if liquidity beats everything else.
Inputs from the SymbolContext
Before scoring, load_symbol_context collects per symbol:
| Field | Source | Used for |
|---|---|---|
has_stock | open long-stock position | PUT L1 vs L2, CC permitted? |
purchase_price | avg. cost of the long position | CC ATR distance + capital gain |
atr_52 | EOD data / DB | ATR distance |
last_close | EOD | underlying fallback |
vix_value | live | rule-of-16 bonus, VIX band |
next_earnings_date | earnings calendar | risk flag + warn icon |
next_ex_div_date | dividend calendar | risk flag + warn icon |
The underlying price comes from the live provider snapshot and is only replaced by last_close when the live quote is missing.
PUT score (max 110 points)
score = premium_score # 0..40
+ atr_score # 0..25
+ oi_score # 0..20
+ iv_score # 0..15
+ rule_16_bonus # 0..10 (L1/L2 only)1. Premium score (0–40, 40 % weight)
premium_score = min(40, premium_pct / target_return * 40)premium_pct = mid / strike * 100 (single-period premium in %, NOT annualized).
target_return depends on the strategy:
| Strategy | has_stock | target_return | Meaning |
|---|---|---|---|
| L1 | no | 1.5 % | initial entry, higher yield demand |
| L2 | yes | 0.8 % | wheel phase 2: cost-basis reduction |
fallback | n/a | 0.6 % | edge case (very weak market) |
Effect (mid 1.10, strike 100 → premium_pct 1.1 %):
| Strategy | Score | Maximum |
|---|---|---|
| L1 | 1.1 / 1.5 * 40 = 29.3 | 40 |
| L2 | 1.1 / 0.8 * 40 = 55 → 40 | 40 |
This is exactly why moderate premiums are rated more attractive for holders (L2) than for newcomers (L1).
2. ATR score (0–25, 23 % weight)
atr_score = max(0, min(25, 25 - |2.5 - atr_distance| * 5))
atr_distance_put = (current_price - strike) / atr52- Optimum at 2.5 ATR below the current price (full points).
- Per 1 ATR off-optimum: −5 points. At 0 or 5 ATR: 0 points.
Why `current_price` and not cost_basis? When selling a put you do not yet own shares — the strike is the future assignment price. The distance measures the safety cushion against today's price.
| atr_distance | score |
|---|---|
| 0.0 | 12.5 |
| 1.5 | 20.0 |
| 2.5 | 25.0 |
| 3.5 | 20.0 |
| 5.0 | 12.5 |
| 7.5 | 0.0 |
3. OI score (0–20, 18 % weight)
oi_score = min(20, open_interest / 100 * 20)Linear up to 100 contracts. Pure liquidity component — volume only serves as a filter, never as a score input.
4. IV score (0–15, 14 % weight)
iv_score = min(15, iv * 100 / 50 * 15) # iv as decimal (0..1)Higher IV → bigger premium → we want it. Saturation at IV ≥ 50 %.
5. Rule-of-16 bonus (0–10, max 9 % weight)
Bonus when the stock runs calmer than the VIX implies:
expected_daily_move = vix_value / 16 # %
actual_daily_move = atr52 / 52 / stock_price * 100
if actual < expected:
bonus = min(10, (expected / actual - 1) * 20)Active for L1 and L2 only, never for fallback. Often the difference between a 70-point and an 80-point strike when everything else is equal.
PUT sub-score weights
| Component | max points | share of max |
|---|---|---|
| Premium % | 40 | 36.4 % |
| ATR distance | 25 | 22.7 % |
| Open interest | 20 | 18.2 % |
| IV | 15 | 13.6 % |
| Rule-of-16 | 10 | 9.1 % |
| Total | 110 | 100 % |
Covered-call score (max 100 points)
score = premium_score # 0..30
+ capital_gain_score # 0..30
+ atr_score # 0..20
+ oi_score # 0..10
+ iv_score # 0..101. Premium score (0–30)
premium_score = min(30, premium_pct / 0.6 * 30)Base 0.6 % mid/strike — full score above 0.6 %.
2. Capital-gain score (0–30) [uses cost_basis]
reference = cost_basis if (has_stock and cost_basis > 0) else current_price
capital_gain_pct = max(0, (strike - reference) / reference * 100)
capital_gain_score = min(30, capital_gain_pct / 5 * 30)Saturates at 5 % above entry. This is the heart of the CC logic:
You already own shares and want a profitable exit on assignment. Reference is your cost basis, not today's price.
3. ATR score (0–20) [uses cost_basis]
atr_distance_call = (strike - reference) / atr52
atr_score = max(0, min(20, 20 - |2.5 - atr_distance| * 4))Same bell shape as for puts but with −4 per ATR and a cap at 20.
4. OI score (0–10) and 5. IV score (0–10)
oi_score = min(10, open_interest / 100 * 10)
iv_score = min(10, iv * 100 / 50 * 10)CC sub-score weights
| Component | max points | share of max |
|---|---|---|
| Premium % | 30 | 30 % |
| Capital gain | 30 | 30 % |
| ATR distance | 20 | 20 % |
| Open interest | 10 | 10 % |
| IV | 10 | 10 % |
| Total | 100 | 100 % |
CMG example
cost_basis 34.00, spot 36.20, ATR52 1.31:
| Strike | atr vs spot | atr vs cost_basis | capital gain | score effect |
|---|---|---|---|---|
| 36 | −0.15 ITM | +1.53 | 5.9 % | clearly stronger |
| 38 | +1.37 | +3.05 | 11.8 % | very strong |
With spot reference strike 36 would not even appear (ITM). With cost_basis it is an attractive CC.
Classification (stage 4): from score to label
The score sorts. The classification labels the strike algorithmically for the user (no investment advice, no personal recommendation). Both functions live in services/option_recommendation.py.
VIX band (minimum ATR)
VIX < 15 → min_atr 1.0 👌 low - stable market
VIX < 20 → min_atr 1.5 🟢 normal
VIX < 30 → min_atr 2.0 🟡 elevated - prefer conservative
VIX ≥ 30 → min_atr 2.5 🔴 EXTREME - safe strikes onlyPUT classification
annual_return = premium_pct * 365 / dte| Condition | Label |
|---|---|
not has_stock and annual ≥ 12 % and atr ≥ min_atr | L1 ✅ |
not has_stock and annual ≥ 12 % and atr ≥ min_atr−0.5 | L1 ⚠️ |
has_stock and annual ≥ 6 % and atr ≥ min_atr | L2 ✅ |
has_stock and annual ≥ 6 % and atr ≥ min_atr−0.5 | L2 ⚠️ |
| else (premium / ATR too low) | - |
Risk icon comes from _risk_band:
risk_score = (vix / 20) * (1 / max(atr_distance, 0.1))
< 0.6 → Low ✅
< 1.2 → Medium ⚠️
≥ 1.2 → High 🔴Covered-call classification
Hard stops (return 🚫 immediately):
- strike ≤ current price (instant ITM)
- strike ≤ purchase price (loss on assignment)
- no shares (
has_stock=False)
Then the minimum-yield gate:
sc_min_roc = 1 % monthly → ~12 % p.a.
min_strike = purchase_price * (1 + sc_min_roc)
sc_atr_min = 1.5 ATR| Condition | Label |
|---|---|
strike < min_strike | ⚠️ |
atr_distance ≥ sc_atr_min and annual ≥ 12 % and atr ≥ min_atr | CC ✅ |
| same as above but atr ≥ min_atr − 0.5 | CC ⚠️ |
| ex-date < expiry + 7 d | ⚠️ |
| else | - |
Warn icons (suffixed to L1 / L2 / CC)
| Icon | Meaning |
|---|---|
📅 | earnings ≤ 7 days before expiry |
💰 | ex-dividend between today and expiry |
Candidate sort order
ORDER BY score DESC,
premium_pct DESC,
dte ASC,
strike ASCTie-breakers: score > premium p.a. > shorter DTE > smaller strike. All candidates must have survived the filters AND the risk-flag excludes.
Worked example (CMG, holder, VIX 18)
Assumptions: cost_basis 34.00, spot 36.20, ATR52 1.31, IV 0.32, OI 800, VIX 18, DTE 40.
CC strike 36, mid 0.55:
premium_pct = 0.55 / 36 * 100 = 1.53
capital_gain_pct = (36 - 34) / 34 * 100 = 5.88 → saturated → 30
atr_distance_call = (36 - 34) / 1.31 = 1.53
→ atr_score = 20 - |2.5 - 1.53| * 4 = 16.1
premium_score = min(30, 1.53 / 0.6 * 30) = 30
oi_score = min(10, 800 / 100 * 10) = 10
iv_score = min(10, 0.32 * 100 / 50 * 10) = 6.4
score = 30 + 30 + 16.1 + 10 + 6.4 = 92.5Classification check:
annual = 1.53 * 365 / 40 = 13.96 % p.a.
min_strike = 34 * 1.01 = 34.34 → 36 ≥ 34.34 ✅
atr_distance 1.53 ≥ 1.5 (sc_atr_min) ✅
atr_distance 1.53 ≥ 1.5 (VIX band, normal) → borderline
risk_score = 18/20 * 1/1.53 = 0.59 → Low ✅
→ label: "CC ✅"Extension notes (for developers)
- Add new sub-scores via
option_scoring.calculate_*_score. Document
the new score maximum in tests so sorting and tooltips stay consistent.
- VIX bands and thresholds (
12 % p.a.L1,6 % p.a.L2,
sc_min_roc 1 %) live in option_recommendation.py — make them per-plan / per-user once needed.
- Sub-score saturation points are deliberately absolute, not
proportional: 0.6 %/30, 1.5 %/40, 5 %/30 etc. When tuning, change the desktop counterpart in app/utils/option_utils.py first.
Related
- "Metric evaluation & risk flags" — how the score is evaluated
- "Volatility regime (VIX)" — the VIX bands in detail
- "Options screening" — the filter stage before scoring
- "Safety gates" — what sits between classification and order send