diff --git a/backend/services/wavelet_signals.py b/backend/services/wavelet_signals.py index 2afac08..690e6f1 100644 --- a/backend/services/wavelet_signals.py +++ b/backend/services/wavelet_signals.py @@ -20,8 +20,11 @@ chat context always has fresh slope/energy/ridge state, not just firing events (see ai_chat_context.py:_block_wavelet_signals). """ import json +import logging from typing import Dict, List, Optional +logger = logging.getLogger(__name__) + def _compute_slope(series: List[float]) -> List[float]: n = len(series) @@ -249,11 +252,18 @@ def _fetch_close_series(ticker: str, saxo_symbol: Optional[str]): bars = get_price_history(saxo_symbol, asset_type, days=400) return [b["close"] for b in bars], [b["date"] for b in bars] except Exception as e: - import logging - logging.getLogger(__name__).warning(f"[wavelet_signals] Saxo fetch failed for '{saxo_symbol}', falling back to yfinance: {e}") + logger.warning(f"[wavelet_signals] Saxo fetch failed for '{saxo_symbol}', falling back to yfinance: {e}") from services.data_fetcher import get_historical - hist = get_historical(ticker, period="1y", interval="1d") + yf_ticker = ticker.upper() + # Bare 6-letter FX pairs (EURUSD, GBPUSD...) are a common Watchlist ticker convention + # here but not a real yfinance symbol (needs the "=X" suffix) — without this, any + # Saxo-linked FX pair whose Saxo fetch fails falls through to a yfinance call that's + # guaranteed to return nothing, permanently keeping it out of the wavelet cache no + # matter how many refreshes run. + if len(yf_ticker) == 6 and yf_ticker.isalpha(): + yf_ticker += "=X" + hist = get_historical(yf_ticker, period="1y", interval="1d") return [h["close"] for h in hist], [h["date"] for h in hist] @@ -299,6 +309,10 @@ def scan_watchlist_wavelet_signals(run_id: Optional[str] = None) -> List[Dict]: try: values, dates = _fetch_close_series(ticker, item.get("saxo_quote_symbol")) if len(values) < lookback + 32: + logger.warning( + f"[wavelet_signals] Skipping '{ticker}': only {len(values)} price points " + f"fetched, need >= {lookback + 32} (lookback={lookback}) — no cache written this pass." + ) continue start_idx = max(lookback, len(values) - 60) decomposed = decomposer( @@ -307,6 +321,7 @@ def scan_watchlist_wavelet_signals(run_id: Optional[str] = None) -> List[Dict]: num_levels=num_levels, wavelet=wavelet, step=1, ) if not decomposed["dates"]: + logger.warning(f"[wavelet_signals] Skipping '{ticker}': decomposition produced no output dates — no cache written this pass.") continue save_wavelet_decomposition_cache(ticker, run_id, method, wavelet, num_levels, lookback, decomposed) price_at_signal = decomposed["original"][-1] @@ -399,8 +414,9 @@ def scan_watchlist_wavelet_signals(run_id: Optional[str] = None) -> List[Dict]: "ridge_period_days": ridge_series[-1], "params_json": json.dumps(ridge_params) if ridge_params else None, }) - except Exception: - continue # one bad ticker must not abort the whole scan + except Exception as e: + logger.warning(f"[wavelet_signals] Skipping '{ticker}': {e}") # one bad ticker must not abort the whole scan + continue return results diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 36ba805..889b055 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -140,8 +140,15 @@ function NormalRoutes() { const location = useLocation() if (KEEP_ALIVE_PATHS.has(location.pathname)) return null - // On instrument paths the keep-alive layer renders the UI; this Routes just registers the tab - const isInstrument = /^\/instruments\/\w/.test(location.pathname) + // On instrument paths the keep-alive layer renders the UI; this Routes just registers the tab. + // Was `/^\/instruments\/\w/` (required a word char right after the slash) — encoded ids + // that start with a character encodeURIComponent escapes (^GSPC -> %5EGSPC) begin with + // "%", which isn't \w, so this evaluated false for that tab. isInstrument then picked + // 'flex-1 overflow-auto' instead of 'hidden' for this otherwise-empty div, and — since + // InstrumentKeepAlive's own div is ALSO flex-1 in the same flex column — the two split + // the available height roughly 50/50 instead of the instrument page getting all of it, + // which is exactly the "page only fills half the screen" symptom. + const isInstrument = location.pathname.startsWith('/instruments/') return (
diff --git a/frontend/src/components/layout/TabBar.tsx b/frontend/src/components/layout/TabBar.tsx index c91d6d7..7413e48 100644 --- a/frontend/src/components/layout/TabBar.tsx +++ b/frontend/src/components/layout/TabBar.tsx @@ -69,7 +69,13 @@ export default function TabBar() { {/* Dynamic instrument tabs */} {instrumentIds.map(id => { const path = `/instruments/${id}` - const isActive = activePath.toLowerCase() === path.toLowerCase() + // activePath keeps its percent-encoding (EURUSD=X -> /instruments/EURUSD%3DX) + // while `id` is already decoded — same mismatch fixed in App.tsx's + // InstrumentKeepAlive, needed again here or this tab never lights up as active + // for any id containing a character encodeURIComponent escapes ("="). + let decodedActivePath = activePath + try { decodedActivePath = decodeURIComponent(activePath) } catch { /* malformed sequence, compare as-is */ } + const isActive = decodedActivePath.toLowerCase() === path.toLowerCase() return (