diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 1cc1686..17c6cf8 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -18,6 +18,24 @@ from services.vol_surface import Surface, ScenarioSurface DEFAULT_SPREAD_PCT = 0.05 # fallback relative bid/ask spread when no live quote is found +def to_native(obj: Any) -> Any: + """Recursively converts numpy scalars (bool_, int64, float64, ...) to native Python + types. Comparisons/aggregations over numpy-typed inputs (e.g. bid/ask sourced from a + DB row that came back as a numpy type, or scipy's norm.cdf) can leave a stray + numpy.bool_/numpy.float64 buried in a nested result — FastAPI's default JSON encoder + doesn't know those types and fails ('X object is not iterable', then a secondary + 'vars() argument must have __dict__ attribute' from its own fallback). Applied once + at the API boundary (price_combo/payoff_curves/optimizer results) rather than chasing + the exact field through the whole pricing pipeline.""" + if isinstance(obj, dict): + return {k: to_native(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [to_native(v) for v in obj] + if isinstance(obj, np.generic): + return obj.item() + return obj + + def _sign(leg: Dict[str, Any]) -> int: return 1 if leg.get("position", "long") == "long" else -1 @@ -131,7 +149,7 @@ def price_combo( delta_now = greeks_at(legs, spot_now, 0, surface_now, r)["delta"] delta_scenario = greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r)["delta"] - return { + return to_native({ "entry_cost": round(entry_ref, 2), "entry_cost_mid": round(entry_ref_mid, 2), "scenario_value": round(scenario_exec, 2), @@ -145,7 +163,7 @@ def price_combo( "greeks_scenario": greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r), "net_delta_now": delta_now, "net_delta_scenario": delta_scenario, - } + }) def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float) -> Dict[str, Any]: diff --git a/backend/services/strategy_optimizer.py b/backend/services/strategy_optimizer.py index 7807001..40f6220 100644 --- a/backend/services/strategy_optimizer.py +++ b/backend/services/strategy_optimizer.py @@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional from services.option_chain import get_chain_slice from services.vol_surface import Surface, ScenarioSurface, build_surface, apply_scenario -from services.strategy_engine import price_combo, expected_pnl_scenario +from services.strategy_engine import price_combo, expected_pnl_scenario, to_native from services.strategy_templates import generate_all, strikes_for MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40 @@ -168,4 +168,4 @@ def optimize( scored.extend(refined) scored.sort(key=lambda c: c["score"], reverse=True) - return _dedup_top_n(scored, top_n) + return to_native(_dedup_top_n(scored, top_n)) diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 681a288..9941907 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -97,9 +97,9 @@ function GreeksTile({ label, now, scenario }: { label: string; now: number; scen // ── Scenario panel ──────────────────────────────────────────────────────────── function ScenarioPanel({ - symbol, setSymbol, horizonDays, setHorizonDays, scenario, setScenario, watchlistTickers, + symbol, setSymbol, onCommitSymbol, horizonDays, setHorizonDays, scenario, setScenario, watchlistTickers, }: { - symbol: string; setSymbol: (v: string) => void + symbol: string; setSymbol: (v: string) => void; onCommitSymbol: (v?: string) => void horizonDays: number; setHorizonDays: (v: number) => void scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void watchlistTickers: string[] @@ -128,7 +128,15 @@ function ScenarioPanel({ setSymbol(e.target.value.toUpperCase())} + onChange={(e) => { + const v = e.target.value.toUpperCase() + setSymbol(v) + // Native datalist pick lands here as a single onChange with the full value — + // commit immediately rather than waiting for a blur that may not follow. + if (watchlistTickers.includes(v)) onCommitSymbol(v) + }} + onBlur={() => onCommitSymbol()} + onKeyDown={(e) => e.key === 'Enter' && onCommitSymbol()} className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" placeholder="SPY, QQQ, GLD…" list="strategy-builder-watchlist" @@ -573,10 +581,12 @@ export default function StrategyBuilder() { symbol: '', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_shift: 0, manual_grid: [], }) - useEffect(() => { - const t = setTimeout(() => setDebouncedSymbol(symbol.trim()), 500) - return () => clearTimeout(t) - }, [symbol]) + // Chain lookup only commits on blur/Enter/datalist-pick, never mid-keystroke — typing + // "EUU" while aiming for "EUU:XCME" must never flash a "ticker not found" error. Takes + // an optional explicit value so the datalist-pick path (which calls this synchronously + // right after setSymbol in the same onChange) doesn't read a stale pre-update closure. + const commitSymbol = (v?: string) => setDebouncedSymbol((v ?? symbol).trim()) + const [legs, setLegs] = useState([]) const [constraints, setConstraints] = useState({ max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20, @@ -691,7 +701,7 @@ export default function StrategyBuilder() { )} -