trader.news.base module¶
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.
- trader.news.base.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.
- trader.news.base.MAX_NEWS_WINDOW_HOURS = 8760¶
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.
- trader.news.base.NEWS_FINGERPRINT_VERSION = '1'¶
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.
- class trader.news.base.ArchivedNewsItem(article_id, item)[source]¶
Bases:
objectA 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.
- Parameters:
article_id (str)
item (NewsItem)
- article_id: str¶
- class trader.news.base.NewsItem(symbol, title, publisher, published_at, summary, url, content_type)[source]¶
Bases:
objectOne recent article or video about — or merely near — a symbol.
- Parameters:
symbol (str)
title (str)
publisher (str)
published_at (datetime)
summary (str | None)
url (str | None)
content_type (str)
- symbol: str¶
- title: str¶
- publisher: str¶
- published_at: datetime¶
- summary: str | None¶
- url: str | None¶
- content_type: str¶
- class trader.news.base.NewsProvider(*args, **kwargs)[source]¶
Bases:
ProtocolRecent news for a symbol.
- trader.news.base.news_fingerprint(items)[source]¶
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.
- Parameters:
items (Sequence[NewsItem])
- Return type:
str
- trader.news.base.partition_by_age(items, now, max_age_hours)[source]¶
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.
- Parameters:
items (Sequence[NewsItem])
now (datetime)
max_age_hours (float)
- Return type:
- trader.news.base.published_at_text(value)[source]¶
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.
- Parameters:
value (object)
- Return type:
str
- trader.news.base.relevance_count(items, symbol, company_aliases=())[source]¶
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.
- Parameters:
items (Sequence[NewsItem])
symbol (str)
company_aliases (Sequence[str])
- Return type:
int