Source code for trader.persistence.focus_list

"""Repository for `focus_list` (issue #44).

A history, not a pure set: `trading_day` anchors each row to the session
`trader build-focus-list` computed it for (Decision 7), so the intraday read
`_build_universe` needs every cycle is a plain, indexed `(trading_day,
symbol)` lookup — never a scan over `watchlist_events`' JSON-in-text `note`,
which is audit-only and never a worklist (see that module's own docstring).

Structurally typed against `discovery.candidates.Candidate` via
`FocusListCandidate` below, rather than importing it: `persistence/` stays
free of a `discovery/` dependency, the mirror image of `discovery/scan.py`'s
own duck-typed `index_repository`/`consensus_repository` parameters that keep
that package free of a `persistence/` dependency.
"""

from collections.abc import Iterable
from datetime import date, datetime
from typing import Protocol

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

from trader.persistence.models import FocusList

__all__ = ["FocusListCandidate", "FocusListRepository"]


[docs] class FocusListCandidate(Protocol): """What `upsert` reads off each candidate. `discovery.candidates.Candidate` already has every one of these attributes, so it satisfies this structurally with no import needed on either side. Declared as read-only `@property` members, not plain attributes: a plain `Protocol` attribute requires the implementer to support both read *and* write, and `Candidate` is `frozen=True` — assignment raises. `upsert` only ever reads these fields, so the Protocol should ask for no more than that, and mypy's structural check enforces the read/write distinction even though it is invisible in an ad hoc, single-module reproduction. """ @property def symbol(self) -> str: ... @property def theme(self) -> str: ... @property def origin(self) -> str: ... @property def headline(self) -> str | None: ... @property def url(self) -> str | None: ... @property def recommendation_mean(self) -> float | None: ...
[docs] class FocusListRepository: """Writes and reads `focus_list`.""" def __init__(self, session_factory: sessionmaker[Session]) -> None: self._session_factory = session_factory
[docs] def upsert( self, trading_day: date, candidates: Iterable[FocusListCandidate], *, generated_at: datetime, ) -> None: """Record `candidates` as the focus list for `trading_day`. `UNIQUE(trading_day, symbol)` is what makes a same-day rerun (a manual retry after a partial `build-focus-list` failure) update the existing row rather than fail on the constraint or leave a stale duplicate readable — the rerun's data replaces the first attempt's. """ rows = list(candidates) if not rows: return symbols = {c.symbol.strip().upper() for c in rows} with self._session_factory() as session: existing = { row.symbol: row for row in session.scalars( select(FocusList).where( FocusList.trading_day == trading_day, FocusList.symbol.in_(symbols), ) ).all() } for candidate in rows: symbol = candidate.symbol.strip().upper() row = existing.get(symbol) if row is None: row = FocusList(trading_day=trading_day, symbol=symbol) session.add(row) existing[symbol] = row row.theme = candidate.theme row.origin = candidate.origin row.headline = candidate.headline row.url = candidate.url row.recommendation_mean = candidate.recommendation_mean row.generated_at = generated_at session.commit()
[docs] def symbols_for(self, trading_day: date) -> list[str]: """Every symbol recorded under `trading_day`. `[]` when the batch never ran, or ran and stamped a different day — the same "this source contributes nothing" degrade `themes: []` and a never-run `scan-universe` already give, extended to a third source (Decision 7). """ statement = select(FocusList.symbol).where(FocusList.trading_day == trading_day) with self._session_factory() as session: return list(session.scalars(statement).all())