From fc57aecd98a39a236f9e3799ae045199a7a7389b Mon Sep 17 00:00:00 2001 From: ojy Date: Wed, 13 May 2026 08:08:05 +0000 Subject: [PATCH] Vol Surface: cache-first load + auto-pick next-month 3rd Friday MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On page load and on every symbol Lookup, target the 3rd Friday of the next calendar month (the standard US monthly options expiry). If that exact date isn't listed (e.g., June 19, 2026 is Juneteenth so SPY's monthly is the Thursday 06-18), fall back to the nearest available expiration. Data flow: 1. Init reads {symbol, expirations} from ViewState. 2. Computes target expiry (3rd Fri next month). 3. Hits a new per-symbol cache at localStorage['optionsPricer:surfaceCache'][symbol:expiry]. - Hit AND cache.date === today → render instantly, no network. - Hit but stale (cache.date !== today) → refetch. - Miss → fetch expirations + load surface. 4. fetchExpirations() now auto-selects the target expiry and triggers _loadForTargetExpiry (cache-aware) — entering a new symbol now produces a rendered surface with one Enter press. 5. Successful loadSurface writes the response into the cache under today's date; cache is pruned to 50 entries. Analytics is no longer stuffed into ViewState (only the lightweight symbol/expirations/expiry pointer is), so the per-symbol cache is the single source of truth for surface data. Co-Authored-By: Claude Sonnet 4.6 --- frontend/surface.html | 111 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 96 insertions(+), 15 deletions(-) diff --git a/frontend/surface.html b/frontend/surface.html index 33a8252..b2cb9e5 100644 --- a/frontend/surface.html +++ b/frontend/surface.html @@ -758,24 +758,101 @@ termChartInstance: null, async init() { - // restore last loaded surface (survives navigating away & back) + // Restore last symbol + expirations list (survives navigating away & back). + // Analytics itself comes from the per-symbol cache below, not ViewState. const vs = ViewState.load('surface'); if (vs) { - this.symbol = vs.symbol ?? this.symbol; + this.symbol = vs.symbol ?? this.symbol; this.expirations = vs.expirations ?? []; - this.expiry = vs.expiry ?? ''; - if (vs.analytics) { - this.analytics = vs.analytics; - this._processAnalytics(vs.analytics); - } + } + if (!this.symbol) return; + + // Always target the 3rd Friday of next calendar month. + const target = this._nextMonthThirdFriday(); + + // Cache hit for *today* — show instantly, skip the network. + const cached = this._getCached(this.symbol, target); + if (cached) { + this.expiry = target; + this.analytics = cached; + this._processAnalytics(cached); + this._persist(); + return; + } + + // Cache miss or stale — fetch expirations and load the target expiry. + await this.fetchExpirations(); + if (this.expiry) await this._loadForTargetExpiry(); + }, + + _persist() { + ViewState.save('surface', { + symbol: this.symbol, expirations: this.expirations, expiry: this.expiry, + }); + }, + + // ---- Per-symbol surface cache (localStorage, today-only) ---- + _todayStr() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + }, + _nextMonthThirdFriday() { + const now = new Date(); + const first = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const daysToFri = (5 - first.getDay() + 7) % 7; + const day = 1 + daysToFri + 14; // 3rd Friday = 1st Friday + 2 weeks + return `${first.getFullYear()}-${String(first.getMonth()+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`; + }, + _pickTargetExpiry(expirations) { + if (!expirations || expirations.length === 0) return ''; + const target = this._nextMonthThirdFriday(); + if (expirations.includes(target)) return target; + const tMs = new Date(target + 'T00:00:00').getTime(); + return expirations.reduce((best, e) => + Math.abs(new Date(e + 'T00:00:00').getTime() - tMs) < + Math.abs(new Date(best + 'T00:00:00').getTime() - tMs) ? e : best + ); + }, + _readCacheStore() { + try { return JSON.parse(localStorage.getItem('optionsPricer:surfaceCache') || '{}'); } + catch { return {}; } + }, + _getCached(symbol, expiry) { + const store = this._readCacheStore(); + const entry = store[`${symbol}:${expiry}`]; + if (!entry || entry.date !== this._todayStr()) return null; + return entry.analytics; + }, + _writeCache(symbol, expiry, analytics) { + const store = this._readCacheStore(); + store[`${symbol}:${expiry}`] = { date: this._todayStr(), analytics }; + // Keep storage bounded — drop oldest beyond 50 entries + const keys = Object.keys(store); + if (keys.length > 50) { + const ordered = keys + .map(k => [k, store[k].date || '']) + .sort((a, b) => b[1].localeCompare(a[1])) + .slice(0, 50) + .map(([k]) => k); + const trimmed = {}; + for (const k of ordered) trimmed[k] = store[k]; + try { localStorage.setItem('optionsPricer:surfaceCache', JSON.stringify(trimmed)); } catch {} + } else { + try { localStorage.setItem('optionsPricer:surfaceCache', JSON.stringify(store)); } catch {} } }, - _persist(includeAnalytics) { - ViewState.save('surface', { - symbol: this.symbol, expirations: this.expirations, expiry: this.expiry, - analytics: includeAnalytics ? this.analytics : null, - }); + // Use cache if it's fresh (today), otherwise hit the API. Assumes symbol+expiry are set. + async _loadForTargetExpiry() { + if (!this.symbol || !this.expiry) return; + const cached = this._getCached(this.symbol, this.expiry); + if (cached) { + this.analytics = cached; + this._processAnalytics(cached); + this._persist(); + return; + } + await this.loadSurface(); }, async fetchExpirations() { @@ -792,8 +869,11 @@ const env = await res.json(); const data = env.data ?? env; this.expirations = data.expirations || (Array.isArray(data) ? data : []); - if (this.expirations.length > 0) this.expiry = this.expirations[0]; - this._persist(false); + // Auto-select the 3rd Friday of next calendar month (nearest available match). + this.expiry = this._pickTargetExpiry(this.expirations); + this._persist(); + // Auto-load the surface for the target expiry (cache-aware). + if (this.expiry) await this._loadForTargetExpiry(); } catch (err) { this.errorMsg = 'Failed to look up symbol: ' + err.message; } finally { @@ -816,7 +896,8 @@ const data = env.data ?? env; this.analytics = data; this._processAnalytics(data); - this._persist(true); + this._writeCache(this.symbol, this.expiry, data); + this._persist(); } catch (err) { this.errorMsg = 'Failed to load surface: ' + err.message; } finally {