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:
@@ -18,16 +18,6 @@ enum TimeHorizon: String, CaseIterable, Identifiable {
|
|||||||
case .monthly: return "Monthly"
|
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
|
// MARK: - Engine
|
||||||
@@ -49,7 +39,6 @@ enum RecommendationEngine {
|
|||||||
let currentPrice = history.currentPrice
|
let currentPrice = history.currentPrice
|
||||||
guard currentPrice > 0 else { return nil }
|
guard currentPrice > 0 else { return nil }
|
||||||
|
|
||||||
// T in years — must be computed before selectBestContract so it can use real DTE
|
|
||||||
let daysToExp = max(
|
let daysToExp = max(
|
||||||
Calendar.current.dateComponents([.day], from: Date(), to: expiration).day ?? 1,
|
Calendar.current.dateComponents([.day], from: Date(), to: expiration).day ?? 1,
|
||||||
1
|
1
|
||||||
@@ -64,32 +53,25 @@ enum RecommendationEngine {
|
|||||||
signal: signal,
|
signal: signal,
|
||||||
daysToExpiry: daysToExp
|
daysToExpiry: daysToExp
|
||||||
) else { return nil }
|
) else { return nil }
|
||||||
|
|
||||||
let isCall = strategy == "covered_call"
|
let isCall = strategy == "covered_call"
|
||||||
|
|
||||||
let delta = SignalEngine.bsDelta(
|
let delta = SignalEngine.bsDelta(
|
||||||
S: currentPrice,
|
S: currentPrice, K: best.strike, T: T,
|
||||||
K: best.strike,
|
|
||||||
T: T,
|
|
||||||
sigma: best.impliedVolatility > 0 ? best.impliedVolatility : 0.3,
|
sigma: best.impliedVolatility > 0 ? best.impliedVolatility : 0.3,
|
||||||
isCall: isCall
|
isCall: isCall
|
||||||
)
|
)
|
||||||
|
|
||||||
let theta = SignalEngine.bsTheta(
|
let theta = SignalEngine.bsTheta(
|
||||||
S: currentPrice,
|
S: currentPrice, K: best.strike, T: T,
|
||||||
K: best.strike,
|
|
||||||
T: T,
|
|
||||||
sigma: best.impliedVolatility > 0 ? best.impliedVolatility : 0.3,
|
sigma: best.impliedVolatility > 0 ? best.impliedVolatility : 0.3,
|
||||||
isCall: isCall
|
isCall: isCall
|
||||||
)
|
)
|
||||||
|
|
||||||
let earningsWarning = signal.snapshot.earningsWithinWindow
|
let earningsWarning = signal.snapshot.earningsWithinWindow
|
||||||
|
|
||||||
// Fine-grained score including strike context
|
|
||||||
let baseScore = signal.score(for: strategy)
|
let baseScore = signal.score(for: strategy)
|
||||||
let finalScore = SignalEngine.strikeScore(
|
let finalScore = SignalEngine.strikeScore(
|
||||||
base: baseScore,
|
base: baseScore, strategy: strategy, strike: best.strike,
|
||||||
strategy: strategy,
|
|
||||||
strike: best.strike,
|
|
||||||
nearestSupport: signal.snapshot.nearestSupport,
|
nearestSupport: signal.snapshot.nearestSupport,
|
||||||
nearestResistance: signal.snapshot.nearestResistance
|
nearestResistance: signal.snapshot.nearestResistance
|
||||||
)
|
)
|
||||||
@@ -98,18 +80,12 @@ enum RecommendationEngine {
|
|||||||
else if finalScore >= 2 { strength = "moderate" }
|
else if finalScore >= 2 { strength = "moderate" }
|
||||||
else { strength = "weak" }
|
else { strength = "weak" }
|
||||||
|
|
||||||
let dateFormatter = DateFormatter()
|
let fmt = DateFormatter()
|
||||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
fmt.dateFormat = "yyyy-MM-dd"
|
||||||
let expirationStr = dateFormatter.string(from: expiration)
|
|
||||||
|
|
||||||
let earningsStr = signal.snapshot.earningsDate.map { dateFormatter.string(from: $0) }
|
|
||||||
|
|
||||||
let rationale = buildRationale(
|
let rationale = buildRationale(
|
||||||
strategy: strategy,
|
strategy: strategy, horizon: horizon, signal: signal,
|
||||||
horizon: horizon,
|
strike: best.strike, currentPrice: currentPrice,
|
||||||
signal: signal,
|
|
||||||
strike: best.strike,
|
|
||||||
currentPrice: currentPrice,
|
|
||||||
earningsWarning: earningsWarning
|
earningsWarning: earningsWarning
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,24 +96,33 @@ enum RecommendationEngine {
|
|||||||
timeHorizon: horizon.rawValue,
|
timeHorizon: horizon.rawValue,
|
||||||
currentPrice: currentPrice,
|
currentPrice: currentPrice,
|
||||||
recommendedStrike: best.strike,
|
recommendedStrike: best.strike,
|
||||||
recommendedExpiration: expirationStr,
|
recommendedExpiration: fmt.string(from: expiration),
|
||||||
estimatedPremium: best.mid,
|
estimatedPremium: best.mid,
|
||||||
delta: abs(delta),
|
delta: abs(delta),
|
||||||
theta: theta,
|
theta: theta,
|
||||||
ivRank: signal.snapshot.ivRank,
|
ivRank: signal.snapshot.ivRank,
|
||||||
signalStrength: strength,
|
signalStrength: strength,
|
||||||
earningsWarning: earningsWarning,
|
earningsWarning: earningsWarning,
|
||||||
earningsDate: earningsStr,
|
earningsDate: signal.snapshot.earningsDate.map { fmt.string(from: $0) },
|
||||||
rationale: rationale,
|
rationale: rationale,
|
||||||
signalHash: signal.signalHash,
|
signalHash: signal.signalHash,
|
||||||
createdAt: Date()
|
createdAt: Date()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Strike selection
|
// MARK: - Strike selection (liquidity-aware)
|
||||||
|
|
||||||
/// Picks the contract with delta closest to 0.25 (target range 0.10–0.40).
|
/// Picks the best tradeable strike based on real market liquidity signals.
|
||||||
/// `daysToExpiry` is passed in so delta is calculated at the real T, not a fixed 30 days.
|
///
|
||||||
|
/// Filters applied in order:
|
||||||
|
/// 1. OTM only (above spot for calls, below for puts)
|
||||||
|
/// 2. **OTM distance cap**: 2–20% 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 3–10% OTM sweet spot.
|
||||||
static func selectBestContract(
|
static func selectBestContract(
|
||||||
contracts: [OptionContract],
|
contracts: [OptionContract],
|
||||||
strategy: String,
|
strategy: String,
|
||||||
@@ -146,82 +131,120 @@ enum RecommendationEngine {
|
|||||||
daysToExpiry: Int = 30
|
daysToExpiry: Int = 30
|
||||||
) -> OptionContract? {
|
) -> OptionContract? {
|
||||||
let isCall = strategy == "covered_call"
|
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 candidates = contracts.filter { c in
|
||||||
let otm = isCall ? c.strike > currentPrice : c.strike < currentPrice
|
let pctOTM: Double
|
||||||
return otm && c.bid > 0 && c.ask > 0 && c.impliedVolatility > 0
|
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
|
// ── 2. Premium floor + positive bid/ask ─────────────────
|
||||||
let scored: [(OptionContract, Double)] = candidates.compactMap { c in
|
let priced = candidates.filter { c in
|
||||||
let delta = abs(SignalEngine.bsDelta(
|
c.bid > 0 && c.ask > 0 && c.mid >= minMid
|
||||||
S: currentPrice,
|
}
|
||||||
K: c.strike,
|
|
||||||
T: T,
|
|
||||||
sigma: c.impliedVolatility,
|
|
||||||
isCall: isCall
|
|
||||||
))
|
|
||||||
// Hard filter: keep delta 0.10–0.40 — relax slightly for very short expirations
|
|
||||||
let minDelta = daysToExpiry <= 3 ? 0.05 : 0.10
|
|
||||||
guard delta >= minDelta, delta <= 0.45 else { return nil }
|
|
||||||
|
|
||||||
// Penalty for deviation from target 0.25
|
// ── 3. Spread filter ────────────────────────────────────
|
||||||
let targetDelta = 0.25
|
let tight = priced.filter { c in
|
||||||
var score = -abs(delta - targetDelta)
|
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: 3–10% 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",
|
if strategy == "cash_secured_put",
|
||||||
let sup = signal.snapshot.nearestSupport,
|
let sup = signal.snapshot.nearestSupport, c.strike >= sup {
|
||||||
c.strike > sup {
|
score *= 1.1
|
||||||
score += 0.05
|
|
||||||
}
|
}
|
||||||
if strategy == "covered_call",
|
if strategy == "covered_call",
|
||||||
let res = signal.snapshot.nearestResistance,
|
let res = signal.snapshot.nearestResistance, c.strike <= res {
|
||||||
c.strike < res {
|
score *= 1.1
|
||||||
score += 0.05
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (c, score)
|
return (c, score)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let best = scored.max(by: { $0.1 < $1.1 })?.0 { return best }
|
return scored.max(by: { $0.1 < $1.1 })?.0
|
||||||
|
|
||||||
// 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) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Expiration helpers
|
// 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? {
|
static func expiration(for horizon: TimeHorizon, available: [Date], referenceDate: Date = Date()) -> Date? {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
|
|
||||||
switch horizon {
|
switch horizon {
|
||||||
case .zeroDTE:
|
case .zeroDTE:
|
||||||
// Same calendar day
|
|
||||||
return available.first { cal.isDate($0, inSameDayAs: referenceDate) }
|
return available.first { cal.isDate($0, inSameDayAs: referenceDate) }
|
||||||
|
|
||||||
case .oneDTE:
|
case .oneDTE:
|
||||||
// Next trading session (tomorrow or next Monday if Friday)
|
|
||||||
let tomorrow = cal.date(byAdding: .day, value: 1, to: referenceDate)!
|
let tomorrow = cal.date(byAdding: .day, value: 1, to: referenceDate)!
|
||||||
return available.first { $0 >= tomorrow }
|
return available.first { $0 >= tomorrow }
|
||||||
|
|
||||||
case .weekly:
|
case .weekly:
|
||||||
// Closest Friday >= tomorrow
|
|
||||||
let tomorrow = cal.date(byAdding: .day, value: 1, to: referenceDate)!
|
let tomorrow = cal.date(byAdding: .day, value: 1, to: referenceDate)!
|
||||||
let fridays = available.filter {
|
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 }
|
return fridays.first ?? available.first { $0 >= tomorrow }
|
||||||
|
|
||||||
case .monthly:
|
case .monthly:
|
||||||
// Expiry closest to 30 DTE from today
|
|
||||||
let target = cal.date(byAdding: .day, value: 30, to: referenceDate)!
|
let target = cal.date(byAdding: .day, value: 30, to: referenceDate)!
|
||||||
return available
|
return available
|
||||||
.filter { $0 > referenceDate }
|
.filter { $0 > referenceDate }
|
||||||
@@ -232,17 +255,13 @@ enum RecommendationEngine {
|
|||||||
// MARK: Rationale builder
|
// MARK: Rationale builder
|
||||||
|
|
||||||
private static func buildRationale(
|
private static func buildRationale(
|
||||||
strategy: String,
|
strategy: String, horizon: TimeHorizon, signal: SignalResult,
|
||||||
horizon: TimeHorizon,
|
strike: Double, currentPrice: Double, earningsWarning: Bool
|
||||||
signal: SignalResult,
|
|
||||||
strike: Double,
|
|
||||||
currentPrice: Double,
|
|
||||||
earningsWarning: Bool
|
|
||||||
) -> String {
|
) -> String {
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
let snap = signal.snapshot
|
let snap = signal.snapshot
|
||||||
|
|
||||||
let ivrStr = String(format: "%.0f", snap.ivRank)
|
let ivrStr = String(format: "%.0f", snap.ivRank)
|
||||||
|
|
||||||
if snap.ivRank >= 50 {
|
if snap.ivRank >= 50 {
|
||||||
parts.append("IV Rank \(ivrStr) — elevated premium environment.")
|
parts.append("IV Rank \(ivrStr) — elevated premium environment.")
|
||||||
} else if snap.ivRank >= 30 {
|
} else if snap.ivRank >= 30 {
|
||||||
@@ -257,13 +276,17 @@ enum RecommendationEngine {
|
|||||||
parts.append("Price below SMA-50 and SMA-200 — bearish pressure.")
|
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 {
|
if strategy == "cash_secured_put", let sup = snap.nearestSupport {
|
||||||
let dist = String(format: "%.1f%%", (currentPrice - sup) / currentPrice * 100)
|
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 {
|
if strategy == "covered_call", let res = snap.nearestResistance {
|
||||||
let dist = String(format: "%.1f%%", (res - currentPrice) / currentPrice * 100)
|
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 {
|
if earningsWarning {
|
||||||
|
|||||||
Reference in New Issue
Block a user