Metric evaluation & risk flags

From a filtered option chain the evaluation service computes a metric (score) for each contract and sorts the candidates by it. Each candidate is additionally annotated with risk flags. The sorting is a neutral aid for overview – not a buy or sell signal and not a selection recommendation.

Annualized premium

annualized_premium_pct = (mid / strike) * (365 / dte) * 100

Example: AAPL put 180, mid 2.10, DTE 30 → (2.10 / 180) * (365 / 30) * 100 = 14.2 % p.a.

Sorting by metric

Candidates are sorted by their metric in descending order and shown as a list. Sorting happens after DTE / delta / OI / volume filters are applied. You may optionally exclude strikes carrying certain risk flags (exclude_flags). Which contracts appear and in which order is controlled solely by your filter and sorting settings.

Risk flags

FlagMeaning
earningsEarnings date falls inside the configured window
ex_divEx-dividend date is before expiry – early-assignment risk
iv_lowIV low → small premium
iv_extremeIV extremely high → mean-reversion risk
low_liquidityLow open-interest or volume

Default earnings window: 7 days before expiry.

Configuration

POST /api/options/medals/{SYMBOL}
{
  "expiry": "2026-06-19",
  "filters": { "option_type": "PUT", "dte_min": 7, "dte_max": 60 },
  "top_n": 3,
  "earnings_window_days": 7,
  "exclude_flags": ["earnings", "iv_extreme"]
}

The response carries per candidate: the contract, annualized_premium_pct and risk_flags: [].

Quick view (candidates across all expiries)

The company detail page (/research/companies/{SYMBOL}) uses an endpoint that scans every expiry inside your strategy DTE range (UserSettings.min_dte / max_dte) in a single round-trip and returns one bucket per allowed Wheel strategy (PUT-L1 if you hold no shares, PUT-L2 if you do, plus CC when shares are free for assignment).

POST /api/options/medals-best/{SYMBOL}
{
  "top_n": 3,
  "earnings_window_days": 7,
  "min_open_interest": 0,
  "dte_min": null,   // optional override, otherwise from UserSettings
  "dte_max": null
}

The filter pipeline is identical to the cross-symbol Wheel screener (moneyness_pct=0.15, iv_extreme excluded, min ROC + strike-above-cost from your filter settings, sanity cap 200 % p.a.). Candidates can come from different expiries; each card shows its own expiry.

Reading the list

In the chain page the candidates are shown as cards on top, each with the metric, premium p.a., strike, DTE, mid, delta, IV, OI – plus the colored flag chips below.

The ATR value on a card is the ATR distance (strike distance to spot in ATR52 units), shown as e.g. 1.38× ATR – not the raw ATR52, which is a USD amount shown in the header as "ATR(52) … USD". The mid is displayed with three decimals; returns always use the unrounded mid.

Score model (detail)

The full description of the pipeline (filters → score → sorting), all sub-score weights, the target_return table, the cost_basis logic and the thresholds lives in its own topic "Scoring model (PUT L1/L2, CC)".

Short version below:

The metric uses the same scoring as the desktop app (file services/option_scoring.py, ported 1:1 from app/utils/option_utils.py). A strike is evaluated by several sub-scores; the sum is the metric used for sorting.

PUT (max 110)

ComponentPointsIdea
Premium %0–40min(40, premium_pct / target_return * 40)
ATR distance0–25`25 -2.5 - atr_distance* 5` – peak at 2.5 ATR below spot
Open interest0–20min(20, OI / 100 * 20) – liquidity
IV0–15min(15, IV / 0.5 * 15) – higher IV = higher premium
Rule-of-16 bonus0–10Bonus if ATR daily move < VIX/16 (stock calmer than VIX implies)

`target_return` depends on the PUT strategy:

StrategyWhen?target_return
L1You hold no shares of the symbol1.5 %
L2You already hold shares (wheel phase 2)0.8 %
fallbackedge case0.6 %

L2 demands a lower yield because the additional put primarily serves cost-basis reduction – not building a new position. Identical strikes therefore get a higher metric when has_stock=True.

ATR distance for puts is always measured against the current price:

atr_distance_put = (current_price - strike) / atr52

Why: when selling a put you do not yet own the shares – the strike is the future assignment price. The distance measures the safety cushion relative to the current price.

Covered call (max 100)

ComponentPointsIdea
Premium %0–30min(30, premium_pct / 0.6 * 30)
Capital gain %0–30min(30, capital_gain_pct / 5.0 * 30)
ATR distance0–20`20 -2.5 - atr_distance* 4`
Open interest0–10min(10, OI / 100 * 10)
IV0–10min(10, IV / 0.5 * 10)

Important: for the CC the reference is your cost basis, not the current price. You already own shares and want the call to be exercised above your entry. Both ATR distance and capital gain are computed against cost_basis:

reference        = cost_basis if (has_stock and cost_basis > 0) else current_price
atr_distance     = (strike - reference) / atr52
capital_gain_pct = (strike - reference) / reference * 100

Example CMG, cost_basis 34.00, current price 36.20, ATR52 1.31:

Strikedistance vs. spotdistance vs. cost_basiscapital gain
36-0.15 ATR (ITM)+1.53 ATR+5.9 %
38+1.37 ATR+3.05 ATR+11.8 %

This is why CC strikes that would have a lower metric vs. spot can still surface when you hold the underlying.

Where has_stock and cost_basis come from

The load_symbol_context service inspects your open long-stock positions for the symbol. If found, has_stock=True and the average entry price are passed to the evaluator as purchase_price.

Related

  • "Options screening" (which filters exist)
  • "Smart-limit & mid-drift" (how a candidate becomes a proposal with a limit)
  • "Safety gates" (what is checked before sending)

Legal note

The metric is an algorithmic sorting aid. Not a buy signal, not a sell signal, not investment advice, not a recommendation. It does not replace your own review of market data and news on the underlying company.