Source code for trader.replay.news_at
"""A point-in-time `NewsProvider` over the archive.
`LlmStrategy` calls `get_recent_news(symbol, limit)` and nothing else, so
substituting this for the live merged feed puts the entire production news
path — `partition_by_age`, the fresh/stale split, ordering,
`news_fingerprint`, the prompt wording — on archived data with no
reimplementation. That is the point: the replay must ask the same question
the live path would have asked, and "resembles it" is not the same claim.
"""
from datetime import datetime, timedelta
from typing import Protocol
from trader.errors import ReplayError
from trader.news.base import NewsItem
__all__ = ["ArchiveNewsAt"]
class _Archive(Protocol):
def load(self, symbol: str, start: datetime, end: datetime) -> list[NewsItem]: ...
[docs]
class ArchiveNewsAt:
"""The archived news a symbol had at `as_of`, and nothing later."""
def __init__(self, archive: _Archive, as_of: datetime, window_hours: float) -> None:
if as_of.tzinfo is None:
raise ValueError(f"as_of must be tz-aware UTC, got {as_of!r}")
self._archive = archive
self._as_of = as_of
self._window_hours = window_hours
[docs]
def get_recent_news(self, symbol: str, limit: int = 10) -> list[NewsItem]:
"""Most recent first, capped at `limit`, all published before `as_of`.
Raises:
ReplayError: the archive returned an item published at or after
`as_of`. The repository already filters `published_at < end`
in SQL, so this is the second, independent check the scoring
rule requires — one guard in the query and one on the
assembled list, because a single guard is a single place to
lose it.
"""
start = self._as_of - timedelta(hours=self._window_hours)
items = self._archive.load(symbol, start, self._as_of)
for candidate in items:
if candidate.published_at >= self._as_of:
raise ReplayError(
f"{symbol}: archived item published at or after the decision "
f"instant ({candidate.published_at.isoformat()} >= "
f"{self._as_of.isoformat()}): {candidate.title!r}"
)
ordered = sorted(items, key=lambda i: i.published_at, reverse=True)
return ordered[:limit]