diff --git a/backend/app/market/massive_client.py b/backend/app/market/massive_client.py index b986f16f..08f4a79c 100644 --- a/backend/app/market/massive_client.py +++ b/backend/app/market/massive_client.py @@ -102,12 +102,15 @@ async def _poll_once(self) -> None: # Massive timestamps are Unix milliseconds → convert to seconds timestamp = snap.last_trade.timestamp / 1000.0 - # Best-effort previous-close anchor. If the field is missing/None on - # this snapshot (pre-market, a thin plan tier, a transient partial - # response), fall back silently to PriceCache's own "first observed" - # default by passing anchor=None — never let a missing anchor drop - # the price update. + # Best-effort previous-close anchor. If the field is missing/None/zero + # on this snapshot (pre-market, a thin plan tier, a transient partial + # response, a newly-listed ticker with no prior close), fall back + # silently to PriceCache's own "first observed" default by passing + # anchor=None — never let a missing anchor drop the price update, and + # never anchor day-change math on a zero baseline. anchor = getattr(getattr(snap, "day", None), "previous_close", None) + if not anchor: + anchor = None self._cache.update( ticker=snap.ticker, diff --git a/backend/app/market/reconcile.py b/backend/app/market/reconcile.py index 0993ced1..dcf045f1 100644 --- a/backend/app/market/reconcile.py +++ b/backend/app/market/reconcile.py @@ -56,7 +56,7 @@ async def on_watchlist_remove(source: MarketDataSource, db: TrackedTickerStore, position for this ticker — an open position keeps it priced even off the watchlist.""" position = await db.get_position(ticker) - if position is None or position.quantity == 0: + if position is None or position.quantity <= 0: await source.remove_ticker(ticker) @@ -70,9 +70,8 @@ async def on_trade_executed(source: MarketDataSource, db: TrackedTickerStore, ti that nothing references it, stop tracking it. """ position = await db.get_position(ticker) - on_watchlist = await db.is_on_watchlist(ticker) if position and position.quantity > 0: await source.add_ticker(ticker) # covers case 1; no-op if already tracked - elif not on_watchlist: + elif not await db.is_on_watchlist(ticker): await source.remove_ticker(ticker) # covers case 2 diff --git a/backend/app/market/validation.py b/backend/app/market/validation.py index ebcb3a29..c6f36f5c 100644 --- a/backend/app/market/validation.py +++ b/backend/app/market/validation.py @@ -23,7 +23,10 @@ def validate_ticker(raw: str) -> str: - LLM `trades[].ticker` (chat-initiated trade) - LLM `watchlist_changes[].ticker` (chat-initiated watchlist change) """ - ticker = raw.strip().upper() - if not _TICKER_RE.match(ticker): + stripped = raw.strip() + ticker = stripped.upper() + # Some Unicode codepoints expand under .upper() (e.g. 'ß' -> 'SS'), which could + # otherwise slip a non-ASCII, non-letter input through the length/format check below. + if not stripped.isascii() or not _TICKER_RE.match(ticker): raise InvalidTickerError(f"Invalid ticker '{raw}': must be 1-5 letters (A-Z).") return ticker diff --git a/backend/tests/market/test_massive.py b/backend/tests/market/test_massive.py index e607a766..5bd3d24b 100644 --- a/backend/tests/market/test_massive.py +++ b/backend/tests/market/test_massive.py @@ -232,6 +232,21 @@ async def test_missing_previous_close_falls_back_to_first_observed(self): assert cache.get_anchor("AAPL") == 190.50 + async def test_zero_previous_close_falls_back_to_first_observed(self): + """A previous_close of 0.0 (thin snapshot / newly-listed ticker) must not be + treated as a real anchor, or day-change% would be pinned to a bogus baseline.""" + cache = PriceCache() + source = MassiveDataSource(api_key="test-key", price_cache=cache, poll_interval=60.0) + source._tickers = ["AAPL"] + source._client = MagicMock() + + snap = _make_snapshot("AAPL", 190.50, 1707580800000, previous_close=0.0) + + with patch.object(source, "_fetch_snapshots", return_value=[snap]): + await source._poll_once() + + assert cache.get_anchor("AAPL") == 190.50 + async def test_anchor_stays_sticky_across_polls(self): """Test that a later poll's previous_close does not overwrite the captured anchor.""" cache = PriceCache() diff --git a/backend/tests/market/test_reconcile.py b/backend/tests/market/test_reconcile.py index 048b49d9..8ea29d91 100644 --- a/backend/tests/market/test_reconcile.py +++ b/backend/tests/market/test_reconcile.py @@ -120,6 +120,15 @@ async def test_remove_watchlist_unknown_ticker_removes(self): await on_watchlist_remove(source, db, "NOPE") assert source.remove_called + async def test_remove_watchlist_tiny_negative_quantity_removes(self): + """Guards against float drift (e.g. -1e-16) after a 'full' sell leaving + quantity just below zero instead of exactly 0.0.""" + db = FakeDB() + db.set_position("AAPL", quantity=-1e-16) + source = FakeSource() + await on_watchlist_remove(source, db, "AAPL") + assert source.remove_called + @pytest.mark.asyncio class TestOnTradeExecuted: diff --git a/backend/tests/market/test_validation.py b/backend/tests/market/test_validation.py index 34fa2287..12b6ee7f 100644 --- a/backend/tests/market/test_validation.py +++ b/backend/tests/market/test_validation.py @@ -30,6 +30,12 @@ def test_invalid_tickers_raise(self, raw): with pytest.raises(InvalidTickerError): validate_ticker(raw) + def test_unicode_case_folding_expansion_rejected(self): + """'ß'.upper() == 'SS' would otherwise pass the 1-5-letter regex despite + being a single non-ASCII character, not 1-5 letters in the input.""" + with pytest.raises(InvalidTickerError): + validate_ticker("ß") + def test_error_message_includes_original_input(self): with pytest.raises(InvalidTickerError, match="NOT-A-TICKER"): validate_ticker("NOT-A-TICKER")