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