Skip to content

Commit 4ca2423

Browse files
calesthioclaude
andcommitted
Resilient symbol switching + correct strike increments for mixed-grid chains
Frontend: AbortController + correlation-ID guards on every symbol-scoped fetch (bars, contracts, config, quote) so a slow response from the previous symbol can no longer overwrite state for the current one. Same pattern TanStack Query and other modern trading frontends use. Backend: Split strike-increment cache into verified (chain-derived, 1h TTL) vs heuristic-fallback (60s TTL) so a transient API blip can't poison the long-lived cache with a wrong value. Strike-increment algorithm: switched from "min of common increments in ±$20 window" to "mode of adjacent diffs in 9-strike ATM window". Fixes MSTR which has both $1 and $2.50 increments in the chain — the old code picked $1, snapping orders to invalid strikes and zeroing out the premium. Verified increments after fix: SPY=$1, PLTR=$1, MRVL=$2.5, AMZN=$2.5, MSTR=$2.5, MU=$5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 40083a2 commit 4ca2423

4 files changed

Lines changed: 183 additions & 48 deletions

File tree

assisted_trading/backend/alpaca_broker.py

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -501,25 +501,46 @@ def get_tick_size(self, symbol: str, price: float) -> float:
501501
return 0.01
502502

503503
# Cache for strike increments: {symbol: (increment, timestamp)}
504-
_strike_increment_cache = {}
505-
_STRIKE_CACHE_TTL = 3600 # 1 hour
504+
_strike_increment_cache = {} # verified (chain-derived) values
505+
_strike_increment_fallback_cache = {} # heuristic fallbacks (don't poison long-TTL)
506+
_STRIKE_CACHE_TTL = 3600 # 1 hour for verified values
507+
_STRIKE_FALLBACK_TTL = 60 # 60s for heuristic — long enough to dedupe
508+
# within a single user flow, short enough
509+
# to retry the chain query soon after
506510

507511
def get_strike_increment(self, underlying_symbol: str, price: float) -> float:
508512
"""
509513
Get strike price increment for options.
510514
511515
Primary: derives from live option chain data near ATM.
512-
Cached per-symbol for 1 hour.
513516
Fallback: hardcoded heuristics if API call fails.
517+
518+
Cache strategy (added in v0.1.8 to prevent fallback poisoning):
519+
- "verified" cache: only chain-derived values, 1 hour TTL.
520+
These are trustworthy and stable per symbol.
521+
- "fallback" cache: heuristic guesses, 60 second TTL.
522+
If the chain query fails on first load and we cache a bad
523+
heuristic for 1 hour, every order for that symbol gets a wrong
524+
strike for the rest of the hour. The short TTL means a transient
525+
API failure self-heals on the next request instead of poisoning.
514526
"""
515527
import time as _time
516528
from collections import Counter
517529

518-
# Check cache first
519-
cached = self._strike_increment_cache.get(underlying_symbol)
520-
if cached:
521-
increment, cached_at = cached
522-
if _time.time() - cached_at < self._STRIKE_CACHE_TTL:
530+
now = _time.time()
531+
532+
# Prefer verified — it's the long-TTL trustworthy bucket.
533+
verified = self._strike_increment_cache.get(underlying_symbol)
534+
if verified:
535+
increment, cached_at = verified
536+
if now - cached_at < self._STRIKE_CACHE_TTL:
537+
return increment
538+
539+
# Fall through to short-TTL fallback bucket (only used when verified is stale).
540+
fallback = self._strike_increment_fallback_cache.get(underlying_symbol)
541+
if fallback:
542+
increment, cached_at = fallback
543+
if now - cached_at < self._STRIKE_FALLBACK_TTL:
523544
return increment
524545

525546
try:
@@ -566,40 +587,67 @@ def get_strike_increment(self, underlying_symbol: str, price: float) -> float:
566587
if len(strikes) < 2:
567588
raise ValueError("Not enough strikes to compute increment")
568589

569-
# Calculate increments between adjacent strikes
590+
# Compute increment ONLY from strikes IMMEDIATELY adjacent to ATM.
591+
# The old algorithm widened to ±$20 from ATM and picked min — but
592+
# chains often have $1 strikes at deep ITM/OTM (for hedging) AND
593+
# $2.5 strikes near ATM. Picking min returns $1 even though the
594+
# right "where to actually trade" increment is $2.5. For MSTR at
595+
# $161 the widened range had 9× $1 increments and 12× $2.5; the
596+
# old algorithm picked $1, leading to strikes like $161 that
597+
# don't exist (real chain has $160 and $162.5).
598+
#
599+
# New algorithm: find the strike closest to ATM, take 4 neighbors
600+
# on each side (9-strike window), compute adjacent diffs in that
601+
# window only, and pick the mode (most common). This is what
602+
# actual trading platforms do for "find the ATM increment."
603+
closest_idx = min(range(len(strikes)), key=lambda i: abs(strikes[i] - price))
604+
lo = max(0, closest_idx - 4)
605+
hi = min(len(strikes), closest_idx + 5)
606+
atm_window = strikes[lo:hi]
607+
570608
increments = []
571-
for i in range(1, len(strikes)):
572-
diff = round(strikes[i] - strikes[i - 1], 2)
609+
for i in range(1, len(atm_window)):
610+
diff = round(atm_window[i] - atm_window[i - 1], 2)
573611
if diff > 0:
574612
increments.append(diff)
575613

576614
if not increments:
577-
raise ValueError("No valid increments found")
615+
raise ValueError("No valid increments in ATM window")
578616

579-
# Use smallest common increment (appears >= 3 times)
617+
# Use the MOST COMMON increment in the ATM window (mode), not min.
618+
# If the window happens to span an increment boundary (e.g. $1 wing
619+
# transitioning to $2.5 near ATM), mode still picks the dominant
620+
# value at ATM rather than the smaller wing value.
580621
counter = Counter(increments)
581-
common = [inc for inc, count in counter.items() if count >= 3]
582-
if common:
583-
result = min(common)
584-
else:
585-
result = counter.most_common(1)[0][0]
622+
result = counter.most_common(1)[0][0]
623+
logger.info(
624+
"Strike increment for %s near $%.2f: $%s (from ATM window %s, diffs %s)",
625+
underlying_symbol, price, result, atm_window, dict(counter),
626+
)
586627

587628
# Cache it
588629
self._strike_increment_cache[underlying_symbol] = (result, _time.time())
589630
logger.info(f"Detected strike increment for {underlying_symbol}: ${result}")
590631
return result
591632

592633
except Exception as e:
593-
logger.warning(f"Could not detect strike increment for {underlying_symbol}: {e}, using heuristic fallback")
594-
# Fallback heuristics
634+
logger.warning(
635+
"Could not detect strike increment for %s: %s, using heuristic fallback",
636+
underlying_symbol, e,
637+
)
638+
# Fallback heuristics — cached briefly so we retry the real chain
639+
# query soon. Caching the heuristic for 1 hour (the previous bug)
640+
# made a single chain-query failure poison the symbol for an hour.
595641
if underlying_symbol in ['SPY', 'QQQ', 'IWM', 'DIA']:
596-
return 1.0
642+
fallback_val = 1.0
597643
elif price > 200:
598-
return 5.0
644+
fallback_val = 5.0
599645
elif price > 100:
600-
return 2.5
646+
fallback_val = 2.5
601647
else:
602-
return 1.0
648+
fallback_val = 1.0
649+
self._strike_increment_fallback_cache[underlying_symbol] = (fallback_val, _time.time())
650+
return fallback_val
603651

604652
def validate_connection(self) -> Tuple[bool, str]:
605653
"""

assisted_trading/frontendv2/js/chart/ChartManager.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@ class ChartManager {
172172

173173
console.log(`Loading ${symbol} ${this.currentTimeframe} data...`);
174174

175+
// Cancel any in-flight bar load for the previous symbol. Without this,
176+
// a slow getHistoricalBars from the OLD symbol can land AFTER the new
177+
// symbol's response and overwrite the chart with stale bars from a
178+
// different ticker — the kind of bug that makes traders take real
179+
// money trades against the wrong instrument.
180+
this._loadAborter?.abort();
181+
this._loadAborter = new AbortController();
182+
const signal = this._loadAborter.signal;
183+
const requestedSymbol = symbol;
184+
175185
try {
176186
// Unsubscribe from previous symbol BEFORE updating currentSymbol
177187
if (this.currentSymbol && this.currentSymbol !== symbol) {
@@ -184,8 +194,14 @@ class ChartManager {
184194
// Fetch historical data
185195
const response = await window.ApiClient.getHistoricalBars(symbol, {
186196
timeframe: this.currentTimeframe,
187-
limit: 1000
197+
limit: 1000,
198+
signal,
188199
});
200+
// Correlation-ID belt-and-suspenders check.
201+
if (requestedSymbol !== this.currentSymbol) {
202+
console.log(`ChartManager: stale bars for ${requestedSymbol}, now on ${this.currentSymbol}`);
203+
return;
204+
}
189205

190206
if (!response || !response.bars) {
191207
throw new Error('No data received');
@@ -242,8 +258,14 @@ class ChartManager {
242258
}
243259

244260
} catch (error) {
245-
console.error('Error loading chart data:', error);
246-
window.EventBus.emit('chart:error', { symbol, error });
261+
// Aborts are expected on symbol switching; suppress quietly so we
262+
// don't fire a misleading chart:error event for them.
263+
if (error.name === 'AbortError') {
264+
console.log(`ChartManager: load aborted for ${symbol} (now on ${this.currentSymbol})`);
265+
} else {
266+
console.error('Error loading chart data:', error);
267+
window.EventBus.emit('chart:error', { symbol, error });
268+
}
247269
} finally {
248270
this.isLoading = false;
249271
}

assisted_trading/frontendv2/js/data/ApiClient.js

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class ApiClient {
2222
* @param {Object} params - Query parameters
2323
* @returns {Promise<Object>} Response data
2424
*/
25-
async get(endpoint, params = {}) {
25+
async get(endpoint, params = {}, options = {}) {
2626
try {
2727
const url = new URL(`${this.baseUrl}${endpoint}`);
2828
Object.keys(params).forEach(key => {
@@ -31,14 +31,17 @@ class ApiClient {
3131
}
3232
});
3333

34-
const response = await fetch(url);
34+
const response = await fetch(url, { signal: options.signal });
3535

3636
if (!response.ok) {
3737
throw new Error(`HTTP error! status: ${response.status}`);
3838
}
3939

4040
return await response.json();
4141
} catch (error) {
42+
// Aborts are expected when the user switches symbols mid-request;
43+
// let them propagate silently — the caller decides what to do.
44+
if (error.name === 'AbortError') throw error;
4245
console.error(`API GET error (${endpoint}):`, error);
4346
throw error;
4447
}
@@ -50,15 +53,16 @@ class ApiClient {
5053
* @param {Object} data - Request body
5154
* @returns {Promise<Object>} Response data
5255
*/
53-
async post(endpoint, data = {}) {
56+
async post(endpoint, data = {}, options = {}) {
5457
try {
5558
const response = await fetch(`${this.baseUrl}${endpoint}`, {
5659
method: 'POST',
5760
headers: {
5861
'Content-Type': 'application/json',
5962
'X-CSRF-Token': this.csrfToken,
6063
},
61-
body: JSON.stringify(data)
64+
body: JSON.stringify(data),
65+
signal: options.signal,
6266
});
6367

6468
if (!response.ok) {
@@ -68,6 +72,7 @@ class ApiClient {
6872

6973
return await response.json();
7074
} catch (error) {
75+
if (error.name === 'AbortError') throw error;
7176
console.error(`API POST error (${endpoint}):`, error);
7277
throw error;
7378
}
@@ -112,8 +117,8 @@ class ApiClient {
112117
timeframe: options.timeframe || '5Min',
113118
limit: options.limit || 1000,
114119
start: options.start,
115-
end: options.end
116-
});
120+
end: options.end,
121+
}, { signal: options.signal });
117122
}
118123

119124
/**
@@ -122,8 +127,10 @@ class ApiClient {
122127
* @param {boolean} refresh - Force refresh cache
123128
* @returns {Promise<Object>} Valid contracts with DTE and expiration dates
124129
*/
125-
async getSymbolContracts(symbol, refresh = false) {
126-
return this.get(`/api/symbol/contracts/${symbol}`, { refresh: refresh.toString() });
130+
async getSymbolContracts(symbol, refresh = false, options = {}) {
131+
return this.get(`/api/symbol/contracts/${symbol}`,
132+
{ refresh: refresh.toString() },
133+
{ signal: options.signal });
127134
}
128135

129136
/**
@@ -132,21 +139,21 @@ class ApiClient {
132139
* @param {number} price - Current price (optional)
133140
* @returns {Promise<Object>} Symbol config
134141
*/
135-
async getSymbolConfig(symbol, price = null) {
142+
async getSymbolConfig(symbol, price = null, options = {}) {
136143
const params = {};
137144
if (price !== null) {
138145
params.price = price;
139146
}
140-
return this.get(`/api/symbol/config/${symbol}`, params);
147+
return this.get(`/api/symbol/config/${symbol}`, params, { signal: options.signal });
141148
}
142149

143150
/**
144151
* Get real-time option quote
145152
* @param {string} optionSymbol - OCC option symbol
146153
* @returns {Promise<Object>} Option quote with bid, ask, last, mark
147154
*/
148-
async getOptionQuote(optionSymbol) {
149-
return this.get(`/api/option/quote/${optionSymbol}`);
155+
async getOptionQuote(optionSymbol, options = {}) {
156+
return this.get(`/api/option/quote/${optionSymbol}`, {}, { signal: options.signal });
150157
}
151158

152159
// ========== Trading Operations ==========

0 commit comments

Comments
 (0)