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))