Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions backend/app/market/massive_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions backend/app/market/reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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
7 changes: 5 additions & 2 deletions backend/app/market/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions backend/tests/market/test_massive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions backend/tests/market/test_reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions backend/tests/market/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading