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:

StageQuestionResult
1Is the strike worth a look at all?filtered list
2What risks are attached?flag set
3How attractive is the strike numerically?score (0–110 / 0–100)
4Does the strike meet L1/L2/CC thresholds?label L1/L2/CC/🚫
5How 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:

FieldSourceUsed for
has_stockopen long-stock positionPUT L1 vs L2, CC permitted?
purchase_priceavg. cost of the long positionCC ATR distance + capital gain
atr_52EOD data / DBATR distance
last_closeEODunderlying fallback
vix_valueliverule-of-16 bonus, VIX band
next_earnings_dateearnings calendarrisk flag + warn icon
next_ex_div_datedividend calendarrisk 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:

Strategyhas_stocktarget_returnMeaning
L1no1.5 %initial entry, higher yield demand
L2yes0.8 %wheel phase 2: cost-basis reduction
fallbackn/a0.6 %edge case (very weak market)

Effect (mid 1.10, strike 100 → premium_pct 1.1 %):

StrategyScoreMaximum
L11.1 / 1.5 * 40 = 29.340
L21.1 / 0.8 * 40 = 55 → 4040

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_distancescore
0.012.5
1.520.0
2.525.0
3.520.0
5.012.5
7.50.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

Componentmax pointsshare of max
Premium %4036.4 %
ATR distance2522.7 %
Open interest2018.2 %
IV1513.6 %
Rule-of-16109.1 %
Total110100 %

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..10

1. 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

Componentmax pointsshare of max
Premium %3030 %
Capital gain3030 %
ATR distance2020 %
Open interest1010 %
IV1010 %
Total100100 %

CMG example

cost_basis 34.00, spot 36.20, ATR52 1.31:

Strikeatr vs spotatr vs cost_basiscapital gainscore effect
36−0.15 ITM+1.535.9 %clearly stronger
38+1.37+3.0511.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 only

PUT classification

annual_return = premium_pct * 365 / dte
ConditionLabel
not has_stock and annual ≥ 12 % and atr ≥ min_atrL1 ✅
not has_stock and annual ≥ 12 % and atr ≥ min_atr−0.5L1 ⚠️
has_stock and annual ≥ 6 % and atr ≥ min_atrL2 ✅
has_stock and annual ≥ 6 % and atr ≥ min_atr−0.5L2 ⚠️
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
ConditionLabel
strike < min_strike⚠️
atr_distance ≥ sc_atr_min and annual ≥ 12 % and atr ≥ min_atrCC ✅
same as above but atr ≥ min_atr − 0.5CC ⚠️
ex-date < expiry + 7 d⚠️
else-

Warn icons (suffixed to L1 / L2 / CC)

IconMeaning
📅earnings ≤ 7 days before expiry
💰ex-dividend between today and expiry

Candidate sort order

ORDER BY score DESC,
         premium_pct DESC,
         dte ASC,
         strike ASC

Tie-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.5

Classification 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