Source code for trader.news.base

"""News as domain objects, plus a cheap relevance signal.

yfinance offers no news *archive* — roughly the last ten items per ticker — so
the snapshot taken at decision time is the only record that will ever exist.
That is why the **live path** stores `NewsItem` into `decisions.inputs_json`
rather than reading it back out of a table: see `docs/ideas.md`.

Since issue #7 step 3 there *is* a news table, and it does not contradict the
above. Alpaca (Benzinga) has history going back to 2023, which is precisely the
property yfinance lacks, so `news_archive` accumulates *backwards* for replay
and scoring. The live prompt still reads from the provider and records what it
saw; nothing in the trading path loads from the archive.
"""

import hashlib
import json
import math
import re
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Protocol, runtime_checkable

__all__ = [
    "DEFAULT_NEWS_WINDOW_HOURS",
    "MAX_NEWS_WINDOW_HOURS",
    "NEWS_FINGERPRINT_VERSION",
    "ArchivedNewsItem",
    "NewsItem",
    "NewsProvider",
    "news_fingerprint",
    "partition_by_age",
    "published_at_text",
    "relevance_count",
]

#: A legitimate news window is hours to days. One year is already absurd, and
#: `timedelta(hours=...)` overflows somewhere past roughly 1.75e7 hours
#: (`OverflowError: date value out of range`, or, for very large floats,
#: `OverflowError: Python int too large to convert to C int`) — well before
#: reaching the value `math.isfinite` would reject, so `partition_by_age`'s
#: finiteness check below cannot catch it; the bound below is a separate
#: check. Bounding here keeps this pure function's own contract
#: self-defending, independent of whatever guard a caller does or does not
#: apply.
#:
#: `LlmStrategy` imports this exact constant rather than defining its own copy
#: — measured, two independent 8760s drifted silently: with the strategy's
#: copy left at 8760 while this one was set to 24, construction raised no
#: `ConfigError`, and every `evaluate()` call raised inside `partition_by_age`
#: instead, was caught, and recorded a HOLD indistinguishable at the terminal
#: from an ordinary all-HOLD cycle. `bar_width(interval)` is the precedent
#: CLAUDE.md already carries for this exact shape of bug. The two *checks*
#: stay separate on purpose — one guards the operator at startup, the other
#: guards this function's own contract at call time — only the constant is
#: shared.
MAX_NEWS_WINDOW_HOURS = 8760

DEFAULT_NEWS_WINDOW_HOURS = 72.0
"""How old news may be and still reach the prompt, absent a configured value.

Shared rather than repeated: `LlmStrategy` filters on it and
`MergedNewsProvider` orders its per-feed quota by it, and the two disagreeing
would let the merge spend a slot on an item the strategy then drops.
"""


[docs] @dataclass(frozen=True, slots=True) class NewsItem: """One recent article or video about — or merely near — a symbol.""" symbol: str title: str publisher: str published_at: datetime summary: str | None url: str | None content_type: str
[docs] @dataclass(frozen=True, slots=True) class ArchivedNewsItem: """A `NewsItem` plus the provider's own id for the article. A pair rather than a field on `NewsItem`, deliberately. The id is needed to *key* an archived row — `(symbol, published_at, article_id)`, because two stories can share a timestamp — and it is needed nowhere else: no prompt renders it, `news_fingerprint` does not digest it, and yfinance has no equivalent to supply. Adding it to the frozen domain object would put an archive-only concern on every item the live path builds, and would invite a later change to the fingerprint's field set, which is the one thing that must not move (see `NEWS_FINGERPRINT_VERSION`). `article_id` is a string even though Alpaca's is an integer: the archive is keyed on it, and `docs/ideas.md`'s fallback source for gaps (web.archive.org) has no integer id to offer. """ article_id: str item: NewsItem
[docs] @runtime_checkable class NewsProvider(Protocol): """Recent news for a symbol."""
[docs] def get_recent_news(self, symbol: str, limit: int = 10) -> list[NewsItem]: """Most recent items first. An empty list is a normal result.""" ...
#: Bump when the *set of fields* `news_fingerprint` digests changes, or when #: their normalisation does. It is mixed into the digest, so a bump makes every #: new fingerprint differ from every stored one — which is the point: two #: definitions of "the same news" must never be able to compare equal, or a #: reuse gate would carry a decision forward on a similarity its own code no #: longer believes in. This is the same reasoning `PROMPT_TEMPLATE_VERSION` #: carries for the prompt, applied to the digest instead of the wording. The #: cost of a bump is one extra model call per symbol, once. NEWS_FINGERPRINT_VERSION = "1"
[docs] def published_at_text(value: object) -> str: """A canonical string for a `NewsItem.published_at`. Shared by `news_fingerprint` and by `LlmStrategy._news_to_dict`, which writes the same value into `decisions.inputs_json`, so that the digest and the stored record can never disagree about what a timestamp *was*. Two independent normalisations is the `bar_width(interval)` bug in miniature: CLAUDE.md records a pair of independent 8760s drifting silently, and the snapping/adjacency pair answering differently for the same grid. Defensive on the type for the reason `_news_to_dict` already is: the annotation says `datetime` but nothing enforces that at runtime for an item reaching this code from a future or alternate `NewsProvider`, and a fingerprint that raises would be a fingerprint that can abort a cycle. """ return value.isoformat() if isinstance(value, datetime) else str(value)
[docs] def news_fingerprint(items: Sequence[NewsItem]) -> str: """A digest of *which* news items these are, stable across cycles. This is the identity of the news, not the rendered prompt. Measured 2026-08-13 over the 190 stored `ollama_news` rows carrying a news snapshot: `prompt_sha256` differs on almost every consecutive cycle pair while the news set is byte-identical on 33 of the 42 pairs less than 30 minutes apart (78.6%), because the prompt also carries the wall clock, each headline's relative age in words, the bar summary and the position's live P/L. Hashing the prompt therefore answers "was the question phrased identically", and the question a reuse gate needs answered is "is this the same news". Order is part of the identity. The provider returns most-recent-first and the prompt renders them in that order, so a reordered feed is a different thing to have been shown even when the set of items is equal. Refusing on a reorder costs one model call; accepting one would be a claim this function has no evidence for. `summary` is digested even though it is only advisory prose, because it reaches the model verbatim — a publisher that silently rewrites a summary under an unchanged headline has changed what the model would read. A canonical JSON dump rather than concatenated fields with a separator: `\\x00`-joining is ambiguous the moment a title legitimately contains the separator, and "two different news sets that digest equal" is precisely the failure that would let a stale decision be reused. """ payload = { "version": NEWS_FINGERPRINT_VERSION, "items": [ [ None if item.url is None else str(item.url), str(item.title), str(item.publisher), published_at_text(item.published_at), None if item.summary is None else str(item.summary), ] for item in items ], } encoded = json.dumps( payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True ) return hashlib.sha256(encoded.encode()).hexdigest()
[docs] def relevance_count( items: Sequence[NewsItem], symbol: str, company_aliases: Sequence[str] = () ) -> int: """How many items actually name the symbol or company. A measured signal, not a filter. On 2026-07-31 this was 6/10 for AAPL and 1/10 for SPY — the feed carries general market commentary alongside real company news. Nothing is dropped on the strength of this number; it is recorded so a later analysis can ask whether decisions made on mostly irrelevant news were worse. Word-boundary matching, because a plain substring test counts "Spyglass" as news about SPY. """ needles = [symbol, *company_aliases] pattern = re.compile( r"\b(" + "|".join(re.escape(n) for n in needles if n) + r")\b", re.IGNORECASE, ) return sum(1 for i in items if pattern.search(f"{i.title} {i.summary or ''}"))
[docs] def partition_by_age( items: Sequence[NewsItem], now: datetime, max_age_hours: float ) -> tuple[list[NewsItem], list[NewsItem]]: """Split `items` into `(fresh, stale)` by publication age. Fresh means `now - published_at <= max_age_hours`, **inclusive** at the boundary. Order is preserved within each list, so "most recent first" survives. There is deliberately **no lower bound**. Measured across the 1,720 news items in `decisions.inputs_json`, the minimum age was -0.0h: publisher clock skew really does produce future-dated entries, and a `0 <= age` guard would discard the newest news in the feed. A future-dated item is the freshest thing there is. Why this exists: measured 2026-08-13, 46% of the items ever shown to the model were over 24h old and 21% over a week, and every `buy`/`sell` decision whose newest item was over 72h old was ANNX — bought twice on an 18-and-then-25-day-old Phase 3 readout the model narrated as a live catalyst. See `docs/superpowers/specs/2026-08-13-news-recency-window-design.md`. Raises: TypeError: an item's `published_at` is a naive datetime (no `tzinfo`), which cannot be compared against a tz-aware `now`. `YFinanceNewsProvider` forces tz-aware timestamps, so this is unreached through it today; a future or alternate provider could still trigger it. `LlmStrategy.evaluate()` catches this (and wider failure shapes) and resolves to HOLD rather than letting it escape. ValueError: `max_age_hours` is not a positive, finite number, or exceeds `MAX_NEWS_WINDOW_HOURS`. `<= 0` alone would not catch NaN or infinity: `float("nan") <= 0` is `False`, so a NaN window would reach `timedelta(hours=...)` below and raise a confusing `ValueError` from deep inside this function instead of failing on the actual bad input; `inf` would survive the same way and silently accept every item as fresh. A non-positive or non-finite window withholds every item (or accepts all of them), which is indistinguishable from a news drought (or its absence) at the call site, so it must fail here rather than silently. A very large *finite* window (verified: `1.8e7` and `1e30` both) is a third failure shape finiteness does not catch: `timedelta(hours= max_age_hours)` itself constructs fine even at `1.8e7`; it is the **subtraction** `now - timedelta(hours=max_age_hours)` below that overflows before ever reaching "accept everything", raising `OverflowError` instead of `ValueError` — the wrong exception type to be caught alongside the others. `LlmStrategy` applies this same bound at construction so a misconfigured window fails at startup; this check exists so the bound holds even for a caller that skips that guard. """ if not math.isfinite(max_age_hours) or max_age_hours <= 0: raise ValueError( f"max_age_hours must be positive and finite, got {max_age_hours!r}." ) if max_age_hours > MAX_NEWS_WINDOW_HOURS: raise ValueError( f"max_age_hours must be at most {MAX_NEWS_WINDOW_HOURS} (one " f"year), got {max_age_hours!r}." ) cutoff = now - timedelta(hours=max_age_hours) fresh: list[NewsItem] = [] stale: list[NewsItem] = [] for item in items: (fresh if item.published_at >= cutoff else stale).append(item) return fresh, stale