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