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 {