Vol Surface: cache-first load + auto-pick next-month 3rd Friday

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 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 08:08:05 +00:00
parent d89ad179f3
commit fc57aecd98

View File

@@ -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.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 {