Rewrite strike selection with liquidity-aware filters

Replace naive closest-delta picker with a cascading filter pipeline:
- OTM distance cap (0.5%–20%) eliminates 1400% OTM garbage
- Premium floor ($0.05 mid) and positive bid/ask required
- Bid-ask spread ≤ 50% of mid rejects phantom/wide quotes
- Liquidity floor: volume ≥ 5 OR open interest ≥ 20
- Returns nil rather than falling back to untradeable contracts
- Scoring: premium yield × liquidity × 3-10% OTM sweet-spot bonus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 14:07:59 -04:00
parent b140a1163d
commit 21ec5b133c

View File

@@ -18,16 +18,6 @@ enum TimeHorizon: String, CaseIterable, Identifiable {
case .monthly: return "Monthly"
}
}
/// Minimum T in years for this horizon to make sense.
var minT: Double {
switch self {
case .zeroDTE: return 0
case .oneDTE: return 1/365
case .weekly: return 3/365
case .monthly: return 20/365
}
}
}
// MARK: - Engine
@@ -49,7 +39,6 @@ enum RecommendationEngine {
let currentPrice = history.currentPrice
guard currentPrice > 0 else { return nil }
// T in years must be computed before selectBestContract so it can use real DTE
let daysToExp = max(
Calendar.current.dateComponents([.day], from: Date(), to: expiration).day ?? 1,
1
@@ -64,32 +53,25 @@ enum RecommendationEngine {
signal: signal,
daysToExpiry: daysToExp
) else { return nil }
let isCall = strategy == "covered_call"
let delta = SignalEngine.bsDelta(
S: currentPrice,
K: best.strike,
T: T,
S: currentPrice, K: best.strike, T: T,
sigma: best.impliedVolatility > 0 ? best.impliedVolatility : 0.3,
isCall: isCall
)
let theta = SignalEngine.bsTheta(
S: currentPrice,
K: best.strike,
T: T,
S: currentPrice, K: best.strike, T: T,
sigma: best.impliedVolatility > 0 ? best.impliedVolatility : 0.3,
isCall: isCall
)
let earningsWarning = signal.snapshot.earningsWithinWindow
// Fine-grained score including strike context
let baseScore = signal.score(for: strategy)
let finalScore = SignalEngine.strikeScore(
base: baseScore,
strategy: strategy,
strike: best.strike,
base: baseScore, strategy: strategy, strike: best.strike,
nearestSupport: signal.snapshot.nearestSupport,
nearestResistance: signal.snapshot.nearestResistance
)
@@ -98,18 +80,12 @@ enum RecommendationEngine {
else if finalScore >= 2 { strength = "moderate" }
else { strength = "weak" }
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let expirationStr = dateFormatter.string(from: expiration)
let earningsStr = signal.snapshot.earningsDate.map { dateFormatter.string(from: $0) }
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd"
let rationale = buildRationale(
strategy: strategy,
horizon: horizon,
signal: signal,
strike: best.strike,
currentPrice: currentPrice,
strategy: strategy, horizon: horizon, signal: signal,
strike: best.strike, currentPrice: currentPrice,
earningsWarning: earningsWarning
)
@@ -120,24 +96,33 @@ enum RecommendationEngine {
timeHorizon: horizon.rawValue,
currentPrice: currentPrice,
recommendedStrike: best.strike,
recommendedExpiration: expirationStr,
recommendedExpiration: fmt.string(from: expiration),
estimatedPremium: best.mid,
delta: abs(delta),
theta: theta,
ivRank: signal.snapshot.ivRank,
signalStrength: strength,
earningsWarning: earningsWarning,
earningsDate: earningsStr,
earningsDate: signal.snapshot.earningsDate.map { fmt.string(from: $0) },
rationale: rationale,
signalHash: signal.signalHash,
createdAt: Date()
)
}
// MARK: Strike selection
// MARK: - Strike selection (liquidity-aware)
/// Picks the contract with delta closest to 0.25 (target range 0.100.40).
/// `daysToExpiry` is passed in so delta is calculated at the real T, not a fixed 30 days.
/// Picks the best tradeable strike based on real market liquidity signals.
///
/// Filters applied in order:
/// 1. OTM only (above spot for calls, below for puts)
/// 2. **OTM distance cap**: 220% from current price (no 1400% OTM garbage)
/// 3. **Minimum premium**: mid-price $0.05
/// 4. **Bid-ask spread**: spread 50% of mid (no phantom quotes)
/// 5. **Liquidity floor**: volume 5 OR open interest 20
///
/// Scoring: premium-per-dollar × liquidity bonus, with preference for
/// strikes in the 310% OTM sweet spot.
static func selectBestContract(
contracts: [OptionContract],
strategy: String,
@@ -146,82 +131,120 @@ enum RecommendationEngine {
daysToExpiry: Int = 30
) -> OptionContract? {
let isCall = strategy == "covered_call"
// Use at least 1 day so T > 0
let T = Double(max(daysToExpiry, 1)) / 365.0
// Only OTM contracts with reasonable premium
// Configurable thresholds
let maxOTMPct = 0.20 // 20% max distance from spot
let minOTMPct = 0.005 // ~0.5% min (avoid ATM/ITM edge)
let minMid = 0.05 // $0.05 min premium
let maxSpreadPct = 0.50 // spread 50% of mid
let minVolume = 5 // OR condition with OI
let minOI = 20 // OR condition with volume
// 1. Basic OTM + distance cap
let candidates = contracts.filter { c in
let otm = isCall ? c.strike > currentPrice : c.strike < currentPrice
return otm && c.bid > 0 && c.ask > 0 && c.impliedVolatility > 0
let pctOTM: Double
if isCall {
guard c.strike > currentPrice else { return false }
pctOTM = (c.strike - currentPrice) / currentPrice
} else {
guard c.strike < currentPrice else { return false }
pctOTM = (currentPrice - c.strike) / currentPrice
}
return pctOTM >= minOTMPct && pctOTM <= maxOTMPct
}
guard !candidates.isEmpty else { return nil }
// Score each contract: prefer delta near 0.25, above support / below resistance
let scored: [(OptionContract, Double)] = candidates.compactMap { c in
let delta = abs(SignalEngine.bsDelta(
S: currentPrice,
K: c.strike,
T: T,
sigma: c.impliedVolatility,
isCall: isCall
))
// Hard filter: keep delta 0.100.40 relax slightly for very short expirations
let minDelta = daysToExpiry <= 3 ? 0.05 : 0.10
guard delta >= minDelta, delta <= 0.45 else { return nil }
// 2. Premium floor + positive bid/ask
let priced = candidates.filter { c in
c.bid > 0 && c.ask > 0 && c.mid >= minMid
}
// Penalty for deviation from target 0.25
let targetDelta = 0.25
var score = -abs(delta - targetDelta)
// 3. Spread filter
let tight = priced.filter { c in
let spread = c.ask - c.bid
return spread <= c.mid * maxSpreadPct
}
// Bonus for strike above support (CSP) or below resistance (CC)
// 4. Liquidity filter (volume OR open interest)
let liquid = tight.filter { c in
c.volume >= minVolume || c.openInterest >= minOI
}
// Use the most filtered set that still has options; never fall
// through to completely unfiltered contracts.
let pool: [OptionContract]
if !liquid.isEmpty {
pool = liquid
} else if !tight.isEmpty {
// Relax volume/OI but keep spread filter
pool = tight
print("[Strike] \(strategy): relaxing liquidity filter — using \(tight.count) spread-filtered contracts")
} else if !priced.isEmpty {
// Relax spread but keep premium floor
pool = priced
print("[Strike] \(strategy): relaxing spread filter — using \(priced.count) priced contracts")
} else {
print("[Strike] \(strategy): no contracts pass OTM/premium filters (\(candidates.count) candidates, price=$\(currentPrice))")
return nil // Genuinely no tradeable option here don't recommend garbage
}
// 5. Score and rank
// Goal: balance premium yield vs. probability of profit
let scored: [(OptionContract, Double)] = pool.map { c in
let pctOTM = abs(c.strike - currentPrice) / currentPrice
// Base: premium yield (premium per dollar of underlying)
var score = c.mid / currentPrice * 100
// Sweet-spot bonus: 310% OTM is the income sweet spot
if pctOTM >= 0.03 && pctOTM <= 0.10 {
score *= 1.3
}
// Tighter spread = more tradeable
let spreadPct = (c.ask - c.bid) / max(c.mid, 0.01)
if spreadPct < 0.20 { score *= 1.2 }
// Liquidity bonus (log scale so a few hundred OI doesn't dominate)
let liqScore = log(Double(max(c.volume, 1)) + Double(max(c.openInterest, 1)))
score *= (1.0 + liqScore * 0.05)
// Support/resistance alignment
if strategy == "cash_secured_put",
let sup = signal.snapshot.nearestSupport,
c.strike > sup {
score += 0.05
let sup = signal.snapshot.nearestSupport, c.strike >= sup {
score *= 1.1
}
if strategy == "covered_call",
let res = signal.snapshot.nearestResistance,
c.strike < res {
score += 0.05
let res = signal.snapshot.nearestResistance, c.strike <= res {
score *= 1.1
}
return (c, score)
}
if let best = scored.max(by: { $0.1 < $1.1 })?.0 { return best }
// Fallback: delta filter found nothing (e.g., illiquid chain).
// Return the closest-to-ATM OTM contract that has a positive bid.
return candidates
.filter { isCall ? $0.strike > currentPrice : $0.strike < currentPrice }
.min(by: { abs($0.strike - currentPrice) < abs($1.strike - currentPrice) })
return scored.max(by: { $0.1 < $1.1 })?.0
}
// MARK: Expiration helpers
/// Picks the correct expiration date from `available` based on the requested horizon.
static func expiration(for horizon: TimeHorizon, available: [Date], referenceDate: Date = Date()) -> Date? {
let cal = Calendar.current
switch horizon {
case .zeroDTE:
// Same calendar day
return available.first { cal.isDate($0, inSameDayAs: referenceDate) }
case .oneDTE:
// Next trading session (tomorrow or next Monday if Friday)
let tomorrow = cal.date(byAdding: .day, value: 1, to: referenceDate)!
return available.first { $0 >= tomorrow }
case .weekly:
// Closest Friday >= tomorrow
let tomorrow = cal.date(byAdding: .day, value: 1, to: referenceDate)!
let fridays = available.filter {
$0 >= tomorrow && cal.component(.weekday, from: $0) == 6 // 6 = Friday
$0 >= tomorrow && cal.component(.weekday, from: $0) == 6
}
return fridays.first ?? available.first { $0 >= tomorrow }
case .monthly:
// Expiry closest to 30 DTE from today
let target = cal.date(byAdding: .day, value: 30, to: referenceDate)!
return available
.filter { $0 > referenceDate }
@@ -232,17 +255,13 @@ enum RecommendationEngine {
// MARK: Rationale builder
private static func buildRationale(
strategy: String,
horizon: TimeHorizon,
signal: SignalResult,
strike: Double,
currentPrice: Double,
earningsWarning: Bool
strategy: String, horizon: TimeHorizon, signal: SignalResult,
strike: Double, currentPrice: Double, earningsWarning: Bool
) -> String {
var parts: [String] = []
let snap = signal.snapshot
let ivrStr = String(format: "%.0f", snap.ivRank)
if snap.ivRank >= 50 {
parts.append("IV Rank \(ivrStr) — elevated premium environment.")
} else if snap.ivRank >= 30 {
@@ -257,13 +276,17 @@ enum RecommendationEngine {
parts.append("Price below SMA-50 and SMA-200 — bearish pressure.")
}
// Show OTM distance
let pctOTM = abs(strike - currentPrice) / currentPrice * 100
parts.append(String(format: "Strike %.1f%% out of the money.", pctOTM))
if strategy == "cash_secured_put", let sup = snap.nearestSupport {
let dist = String(format: "%.1f%%", (currentPrice - sup) / currentPrice * 100)
parts.append("Strike above nearest support $\(String(format: "%.2f", sup)) (\(dist) cushion).")
parts.append("Above support $\(String(format: "%.2f", sup)) (\(dist) cushion).")
}
if strategy == "covered_call", let res = snap.nearestResistance {
let dist = String(format: "%.1f%%", (res - currentPrice) / currentPrice * 100)
parts.append("Strike below resistance $\(String(format: "%.2f", res)) (\(dist) buffer).")
parts.append("Below resistance $\(String(format: "%.2f", res)) (\(dist) buffer).")
}
if earningsWarning {