Source code for trader.persistence.watchlist

"""Repository for the `watchlist_events` audit trail (requirements §7).

Audit only, never a worklist: nothing reads this table to decide what to scan
next cycle — `discover_symbols` starts from `discovery.themes` fresh every
cycle. This is what answers "why did it buy UUUU?" three weeks later, which is
the whole reason `Candidate` carries `headline` and `url` in the first place.

`WatchlistEvent` has existed since Slice 1, with its columns fixed long before
discovery existed: `ticker`, `occurred_at`, `action`, `source`, and a
free-text `note`. There is no column for `theme`, `origin`, `headline`, or
`url`, so `record_discovery` packs all four into `note` as JSON rather than
silently dropping any of them — `note` is the only place left for the
evidence, and the evidence is the point.

`source` is always `SOURCE_CLAUDE_SCAN`. The column's own Slice-1 comment
already names the split it exists for ("user_fixed" or "claude_scan"); this
repository only ever writes the scan side; nothing here records an operator's
own edit to `fixed_tickers`.
"""

import json
from datetime import datetime

from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker

from trader.persistence.models import WatchlistEvent

__all__ = ["ACTION_DISCOVERED", "SOURCE_CLAUDE_SCAN", "WatchlistEventRepository"]

#: The only `source` this repository ever writes.
SOURCE_CLAUDE_SCAN = "claude_scan"

#: The only `action` this repository ever writes. `WatchlistEvent.action` is
#: shared with whatever future writer records a removal from the watchlist;
#: this repository only ever adds.
ACTION_DISCOVERED = "discovered"


[docs] class WatchlistEventRepository: """Writes and reads the `watchlist_events` table.""" def __init__(self, session_factory: sessionmaker[Session]) -> None: self._session_factory = session_factory
[docs] def record_discovery( self, *, symbol: str, theme: str, origin: str, headline: str | None, url: str | None, discovered_at: datetime, ) -> int: """Record one accepted candidate. Returns the new row id. `theme`, `origin`, `headline`, and `url` have no dedicated columns — see the module docstring — so they travel together as JSON in `note`. """ note = json.dumps( {"theme": theme, "origin": origin, "headline": headline, "url": url} ) event = WatchlistEvent( occurred_at=discovered_at, ticker=symbol, action=ACTION_DISCOVERED, source=SOURCE_CLAUDE_SCAN, note=note, ) with self._session_factory() as session: session.add(event) session.commit() return event.id
[docs] def recent(self, limit: int = 20) -> list[WatchlistEvent]: """The most recent events, newest first.""" statement = ( select(WatchlistEvent) .order_by(WatchlistEvent.occurred_at.desc(), WatchlistEvent.id.desc()) .limit(limit) ) with self._session_factory() as session: return list(session.scalars(statement))
[docs] def discovered_symbols_since(self, cutoff: datetime) -> list[str]: """Distinct tickers `record_discovery` wrote at or after `cutoff`. Read-only, for a report to describe "what discovery has recently surfaced" (issue #65's sector-concentration measurement) — still not a worklist per the module docstring: nothing here decides what to scan next, and no caller may use this to choose a candidate. A symbol discovered under more than one theme in the window appears once, not once per event. """ statement = ( select(WatchlistEvent.ticker) .where( WatchlistEvent.action == ACTION_DISCOVERED, WatchlistEvent.occurred_at >= cutoff, ) .distinct() ) with self._session_factory() as session: return list(session.scalars(statement))