From 21ec5b133c65c8160dca4221dd8757f6a23dd81d Mon Sep 17 00:00:00 2001 From: olsch01 Date: Fri, 10 Apr 2026 14:07:59 -0400 Subject: [PATCH] Rewrite strike selection with liquidity-aware filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Services/RecommendationEngine.swift | 203 ++++++++++-------- 1 file changed, 113 insertions(+), 90 deletions(-) diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift index e02a39a..555abde 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift @@ -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,52 +53,39 @@ 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, - nearestSupport: signal.snapshot.nearestSupport, + base: baseScore, strategy: strategy, strike: best.strike, + nearestSupport: signal.snapshot.nearestSupport, nearestResistance: signal.snapshot.nearestResistance ) let strength: String - if finalScore >= 4 { strength = "strong" } + if finalScore >= 4 { strength = "strong" } else if finalScore >= 2 { strength = "moderate" } - else { strength = "weak" } + 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.10–0.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**: 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( 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.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 } + // ── 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: 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", - 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 {