"""A local LLM as a trading strategy.
It implements the same `Strategy` Protocol as RSI or Turtle, which is the
entire point: the model's decisions flow through the guardrails, executor, and
reconciliation that Slice 2 already built and tested. There is no separate
authority path for the model to take, so requirements §6.2's "no strategy
bypasses §8" holds structurally rather than by convention.
Not a frozen dataclass, unlike the rule strategies: this one holds collaborators
and records what the last call saw, so the pipeline can persist it.
"""
import logging
import math
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import UTC, date, datetime
from decimal import Decimal, InvalidOperation
from trader.decisions import DecisionMemory, StoredDecision
from trader.domain import Bar, Position
from trader.errors import ConfigError, MarketDataError, TraderError
from trader.llm.base import LlmProvider
from trader.llm.prompt import (
DECISION_SCHEMA,
PROMPT_TEMPLATE_VERSION,
build_bar_summary,
build_messages,
build_technical_summary,
parse_decision,
prompt_hash,
)
from trader.marketdata.analysts import AnalystOpinion, AnalystProvider
from trader.marketdata.earnings import EarningsProvider
from trader.news.aliases import merge_aliases
from trader.news.base import (
DEFAULT_NEWS_WINDOW_HOURS,
MAX_NEWS_WINDOW_HOURS,
NewsItem,
NewsProvider,
news_fingerprint,
partition_by_age,
published_at_text,
relevance_count,
)
from trader.strategies.base import Action, Signal
__all__ = ["LlmStrategy"]
_logger = logging.getLogger("trader.strategies.llm")
#: The only action ever carried forward from an earlier decision. See
#: `_reuse_or_none` for why this is a constant in code and not a config knob.
_REUSABLE_ACTION = Action.HOLD.value
#: How precisely a reuse's price move is *recorded*. Never used for the band
#: comparison itself, which runs at full `Decimal` precision.
_MOVE_PRECISION = Decimal("0.0001")
def _finite_number(
strategy_id: str, name: str, value: object, *, minimum: float
) -> float:
"""Coerce a configured number, rejecting bools, NaN and infinity.
The same three traps `max_news_age_hours` documents at length above, in one
place because there are now three such parameters. `bool` is an `int`
subclass so a YAML `true` reads as `1.0`; `float("nan") < minimum` is
`False` for every minimum, so a bare comparison lets NaN through; and
`float("inf")` would mean "never expire" for a window meant to bound
staleness. YAML's `.nan` and `.inf` parse to exactly those.
"""
if isinstance(value, bool):
raise ConfigError(
f"Strategy {strategy_id!r}: {name} must be a number, got {value!r} (bool)."
)
try:
number = float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
raise ConfigError(
f"Strategy {strategy_id!r}: {name} must be a number, got {value!r}."
) from None
if not math.isfinite(number) or number < minimum:
raise ConfigError(
f"Strategy {strategy_id!r}: {name} must be a finite number of at "
f"least {minimum:g}, got {value!r}."
)
return number
@dataclass(frozen=True, slots=True)
class _Reuse:
"""An earlier answer this cycle may carry forward instead of asking again."""
action: Action
confidence: float | None
reason: str
inputs: dict[str, object]
def _to_decimal(value: object) -> Decimal | None:
"""A stored price string as `Decimal`, or `None` if it is not one.
`build_bar_summary` writes prices as strings precisely so they survive JSON
without becoming floats, so the round trip is `str` -> `Decimal` and never
`float` -> `Decimal`. A `float` input is refused rather than converted:
CLAUDE.md's rule is that no price ever meets a float, and accepting one here
would launder a violation committed somewhere upstream.
`Decimal("NaN")` parses without complaint and then makes every comparison
`False`, which would read as "inside the band". Rejected explicitly.
"""
if value is None or isinstance(value, (bool, float)):
return None
if isinstance(value, Decimal):
return value if value.is_finite() else None
if not isinstance(value, (str, int)):
return None
try:
parsed = Decimal(value)
except (InvalidOperation, ValueError):
return None
return parsed if parsed.is_finite() else None
def _to_utc(value: object) -> datetime | None:
"""A stored ISO-8601 timestamp as a tz-aware datetime, or `None`.
Returns `None` rather than assuming UTC for a naive value, for the reason
`UtcDateTime` raises on one: guessing a zone silently corrupts the instant,
and here that would mis-measure how stale a decision is — in either
direction, since the caller's own offset is unknown to this function.
"""
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
return parsed if parsed.tzinfo is not None else None
def _to_confidence(value: object) -> float | None:
"""A stored confidence as a finite float, or `None` when there is none.
`None` and `0.0` stay distinguishable, the same claim `_confidence_of` makes
in the pipeline: a missing confidence rendered as zero would say the model
expressed minimum conviction, which it never did.
"""
if value is None or isinstance(value, bool) or not isinstance(value, (int, float)):
return None
number = float(value)
return number if math.isfinite(number) else None
def _reused_model_reason(inputs: dict[str, object]) -> str | None:
"""What the model itself said, from a fresh row or from a carried one."""
carried = inputs.get("reused_model_reason")
if isinstance(carried, str) and carried:
return carried
raw = inputs.get("raw_response")
if isinstance(raw, dict):
reason = raw.get("reason")
if isinstance(reason, str) and reason:
return reason
return None
[docs]
class LlmStrategy:
"""Asks a language model for a long-only decision."""
backtestable = False
def __init__(
self,
id: str, # noqa: A002 - matches the Strategy Protocol
llm: LlmProvider,
news_provider: NewsProvider,
max_news_items: int = 10,
lookback_bars: int = 30,
company_aliases: Sequence[str] = (),
max_news_age_hours: float = DEFAULT_NEWS_WINDOW_HOURS,
max_reuse_age_minutes: float = 240.0,
reuse_price_band_pct: float = 1.0,
decision_memory: DecisionMemory | None = None,
clock: Callable[[], datetime] | None = None,
alias_resolver: Callable[[str], Sequence[str]] | None = None,
analyst_provider: AnalystProvider | None = None,
earnings_provider: EarningsProvider | None = None,
) -> None:
# `bool` is an `int` subclass, and `float(True)` is `1.0` — a silent,
# legitimate-looking one-hour window from a YAML `true`. The
# generic registry path already rejects this explicitly for its own
# numeric parameters (`registry.py`'s "a YAML `true` is not a
# period"); this path bypasses that check entirely, so it needs its
# own guard rather than inheriting one it never goes through.
if isinstance(max_news_age_hours, bool):
raise ConfigError(
f"Strategy {id!r}: max_news_age_hours must be a number, got "
f"{max_news_age_hours!r} (bool)."
)
try:
window = float(max_news_age_hours)
except (TypeError, ValueError):
raise ConfigError(
f"Strategy {id!r}: max_news_age_hours must be a number, got "
f"{max_news_age_hours!r}."
) from None
# `<= 0` alone is not enough: `float("nan") <= 0` is `False`, so NaN
# would sail through this guard and then blow up inside
# `partition_by_age`'s `timedelta(hours=...)` at decision time —
# past the `except TraderError` boundary, since a bare `ValueError`
# is not a `TraderError`. `float("inf")` survives for the same
# reason and would silently accept every item as fresh. Same hazard
# `parse_decision` guards against for the model's confidence.
if not math.isfinite(window) or window <= 0:
raise ConfigError(
f"Strategy {id!r}: max_news_age_hours must be a positive, "
f"finite number, got {max_news_age_hours!r}. Such a window "
"withholds every news item, which would silence the "
"strategy rather than restrict it, and would be "
"indistinguishable from a news drought."
)
if window > MAX_NEWS_WINDOW_HOURS:
raise ConfigError(
f"Strategy {id!r}: max_news_age_hours must be at most "
f"{MAX_NEWS_WINDOW_HOURS} (one year), got {max_news_age_hours!r}. "
"A legitimate news window is hours to days; a window this "
"large would overflow `timedelta` inside `partition_by_age` "
"at decision time instead of failing here at startup."
)
# Both default to a value that *permits* reuse, and both accept 0 as the
# off switch: a zero-minute window can never contain a prior decision,
# and there is deliberately no separate `reuse_enabled` boolean, because
# two fields that can contradict each other is the shape CLAUDE.md
# records behind the `position_open` bool and the `rank` integer.
reuse_age = _finite_number(
id, "max_reuse_age_minutes", max_reuse_age_minutes, minimum=0.0
)
reuse_band = _finite_number(
id, "reuse_price_band_pct", reuse_price_band_pct, minimum=0.0
)
self.id = id
self._llm = llm
self._news = news_provider
self._max_news_items = max_news_items
self._lookback_bars = lookback_bars
self._company_aliases = tuple(company_aliases)
# A collaborator the CLI wires from the broker, not a YAML value — the
# same reasoning as `clock` and `decision_memory`, and it is excluded
# from the registry's accepted params for the same reason. `None` means
# relevance matches on the configured aliases alone, which is the
# behaviour that existed before issue #6.
self._alias_resolver = alias_resolver
# Also a collaborator the CLI wires (from `build_analyst_provider()`,
# the same factory discovery already uses to reject net-negative
# candidates), not a YAML value — same reasoning as `alias_resolver`
# immediately above, and excluded from `registry._LLM_STRATEGY_PARAMS`
# for the same reason. `None` means no analyst data reaches the
# prompt, which is the behaviour that existed before this parameter
# (replay and most tests build this strategy without one).
self._analyst = analyst_provider
# Same reasoning again, one collaborator later (issue #89): the CLI
# wires this from `build_earnings_provider()`, never named in YAML,
# and it is excluded from `registry._LLM_STRATEGY_PARAMS` for the
# identical reason `analyst_provider` is. `None` means no earnings
# data reaches the prompt — the behaviour that existed before this
# parameter (replay and most tests build this strategy without one).
self._earnings = earnings_provider
self._max_news_age_hours = window
self._max_reuse_age_minutes = reuse_age
# `Decimal`, not `float`: this is compared against a price move computed
# from two `Decimal` closes, and CLAUDE.md's rule is that no price ever
# meets a float. `Decimal(str(...))` rather than `Decimal(float)`, so
# a configured 1.0 is exactly 1 rather than 1.0000000000000000208...
self._reuse_price_band_pct = Decimal(str(reuse_band))
# Optional on purpose. `llm-check`, a backtest and most tests build this
# strategy with no database at all, and a strategy that cannot read its
# own history must still decide — it simply calls the model every time,
# which is exactly the behaviour that existed before this gate.
self._memory = decision_memory
# Injected only by tests, and only reachable that way:
# `registry._LLM_STRATEGY_PARAMS` deliberately excludes "clock", so no
# YAML value can ever reach this parameter (see
# `tests/strategies/test_registry_llm.py`). A config-settable clock
# would either freeze the recency window at one instant forever, or —
# given a non-callable value like a YAML string — raise `TypeError:
# 'str' object is not callable` where `now = self._clock()` is read
# below, outside any `try`, aborting the symbol loop before the
# protection ratchet runs. `evaluate` cannot take `now` as a
# parameter either: its signature is the `Strategy` Protocol, shared
# by four strategies and two callers, and CLAUDE.md records how
# expensive changing that atomically was last time.
self._clock = clock or (lambda: datetime.now(UTC))
self.last_inputs: dict[str, object] = {}
# Per-cycle, not per-instance-lifetime: unlike `analyst_provider`
# (a live collaborator asked fresh every `evaluate()` call),
# benchmark bars are set from *outside* via `set_benchmark_bars`,
# because computing them needs the cycle's `BarCache` and decision
# window, which only `run_once.py`'s `_process_symbol` has (issue
# #87). `None` until set — the same "not wired" default
# `analyst_provider=None` already establishes for a collaborator
# replay and most tests never supply.
self._benchmark_bars: Sequence[Bar] | None = None
self._benchmark_symbol: str | None = None
[docs]
def warmup_bars(self) -> int:
"""Enough bars to fill the lookback window.
The model tolerates fewer — it is shown whatever exists — but the
pipeline uses this to size its fetch.
"""
return self._lookback_bars
[docs]
def set_benchmark_bars(self, bars: Sequence[Bar] | None, symbol: str | None) -> None:
"""Give this cycle's benchmark bars to the next `evaluate()` call.
Deliberately not a constructor parameter or an `evaluate()` argument:
the `Strategy` Protocol's `evaluate(bars, position)` signature is
shared by four strategy classes and two callers, and CLAUDE.md
records how expensive changing it atomically was last time. This is
a duck-typed hook instead — `run_once.py`'s `_process_symbol` calls
it via `getattr(strategy, "set_benchmark_bars", None)` immediately
before `evaluate()`, the mirror image of the `getattr(strategy,
"last_inputs", {})` read it already does immediately after. A caller
that never calls this (replay, most tests, `llm-check`) leaves both
fields `None`, which `build_technical_summary` already renders as
"Relative strength: not available." — the same degrade-to-absent
contract every other advisory input in this class uses.
"""
self._benchmark_bars = bars
self._benchmark_symbol = symbol
[docs]
def evaluate(self, bars: Sequence[Bar], position: Position | None) -> Signal:
"""Ask the model, and never let a model failure become a trade."""
if not bars:
self.last_inputs = {"error": "no bars available"}
return Signal(Action.HOLD, "No bars available to evaluate.")
symbol = bars[-1].symbol
now = self._clock()
if now.tzinfo is None:
# `clock` is reachable only from a test (see `__init__`), never
# from YAML, so a naive `now` here is a defect in test setup, not
# a runtime condition to survive. Left unguarded, it makes every
# symbol every cycle resolve to HOLD via the `partition_by_age`
# comparison below, with nothing but a file-only warning to show
# for it — indistinguishable from a normal all-HOLD cycle at the
# terminal. CLAUDE.md's `UtcDateTime` raises on a naive input for
# the same reason: a naive value must never be allowed to travel
# silently. Raise here, loudly, instead of degrading to HOLD.
#
# `ConfigError`, not a bare `ValueError`: `_process_symbol`
# (`run_once.py`) catches only `TraderError`, with no try around
# the symbol loop in `run_once`, so the exception family decides
# whether the cycle still reaches `ratchet_protection`,
# `reconcile_protection`, and the two-sided unprotected/
# double-protected report for every *other* symbol this cycle. A
# bare `ValueError` is not a `TraderError` and takes the exact
# escape route the `AttributeError` regression `42fee0a` just
# closed. The loudness this raise exists for does not require an
# exception family that also aborts protection for symbols this
# guard has nothing to do with — unreachable from production
# today (`clock` is excluded from YAML's `_LLM_STRATEGY_PARAMS`,
# and the default clock cannot return a naive value), but "should
# be unreachable" is not a reason to pick the family that is
# dangerous on the day it isn't.
raise ConfigError(
f"Strategy {self.id!r}: clock returned a naive datetime "
f"{now!r}; it must be tz-aware."
)
all_news, news_error = self._fetch_news(symbol)
analyst_opinion, analyst_error = self._fetch_analyst_opinion(symbol)
earnings_date, earnings_error = self._fetch_earnings_date(symbol)
bar_summary = build_bar_summary(bars, self._lookback_bars)
technicals, technicals_error = self._compute_technicals(bars)
# Only keys that touch no provider-supplied data go here — recorded
# before `partition_by_age` or `build_messages` runs, not merely
# before the model call, so a failure anywhere below still shows what
# was asked. `symbol` is in here for provenance, not for the model:
# this dict is per-instance mutable state and one instance serves
# every symbol in a cycle, so a pipeline that recorded it against the
# wrong symbol would store a *valid* prompt hash for the *wrong*
# prompt — invisible to the check that rebuilds the prompt from
# `inputs_json`. With the symbol inside the snapshot, that mismatch
# is assertable.
self.last_inputs = {
"symbol": symbol,
"model": self._llm.model,
"prompt_template_version": PROMPT_TEMPLATE_VERSION,
# The decision clock, so a later rebuild can reproduce the age
# labels; and the window, so an analysis can tell a window change
# from a feed change.
"now": now.isoformat(),
"max_news_age_hours": self._max_news_age_hours,
"bar_summary": bar_summary,
"position_open": position is not None,
# The figures the model was actually shown, not merely that a
# position existed — a stored decision that cannot distinguish
# "sold at +20%" from "sold at -20%" teaches nothing later.
"position": (
None
if position is None
else {
"quantity": str(position.quantity),
"avg_entry_price": str(position.avg_entry_price),
"current_price": str(position.current_price),
"unrealized_pl": str(position.unrealized_pl),
}
),
# Deliberately not part of `news_fingerprint` or `_reuse_from`'s
# eight conditions: analyst consensus is a lagging, herd-prone
# signal that moves on the order of days (docs/ideas.md), not the
# 15-minute cycle the reuse gate governs. A reuse carrying
# forward an hour-old analyst read is a smaller risk than the
# news-only gate already accepts, and only HOLD is ever carried
# forward regardless.
"analyst_opinion": self._opinion_to_dict(analyst_opinion),
# Also deliberately not part of the fingerprint/reuse conditions,
# for a *different* reason than analyst consensus (issue #63):
# these are recomputed from the same `bars` `_reuse_from`'s
# condition 6 (same `last_timestamp`/`bars_considered`) and
# condition 7 (last-close price band) already gate reuse on. Every
# historical bar in `bars` is immutable once its trading day has
# closed — only today's still-refreshing tail bar can move
# (`BarCache`'s `refresh_tail`) — so if those two conditions hold,
# the `bars` array a reuse would have recomputed technicals from
# is effectively the one they were already computed from, and RSI/
# Bollinger/Donchian/the two moving averages move only through
# that same last close. One known, accepted gap: ATR also depends
# on today's high/low, which the price band does not directly
# bound, so a reused HOLD's ATR reading can be marginally stale
# intraday even when condition 7 holds. Only HOLD is ever carried
# forward regardless (this file's constant, not a setting), so the
# worst case is unchanged: one cycle showing a slightly stale
# volatility read on a decision that was never going to place an
# order, corrected next cycle.
"technicals": technicals,
# Deliberately not part of `news_fingerprint` or `_reuse_from`'s
# eight conditions either, for the same reason as
# `analyst_opinion` above (issue #89): an earnings date moves on
# the order of weeks to months, not the 15-minute cycle the
# reuse gate governs, and only HOLD is ever carried forward
# regardless. Also never gates or blocks an entry in v1 — this
# field is context for the model to read, nothing in this file
# or `run_once.py` reads it to change what gets proposed.
"earnings_date": earnings_date.isoformat() if earnings_date else None,
}
if news_error:
self.last_inputs["news_error"] = news_error
if analyst_error:
self.last_inputs["analyst_error"] = analyst_error
if technicals_error:
self.last_inputs["technicals_error"] = technicals_error
if earnings_error:
self.last_inputs["earnings_error"] = earnings_error
# Everything below touches provider-supplied data, so it is inside
# one `try`, assigned to `last_inputs` progressively rather than in
# one batch at the end. `_news_to_dict` is defensive per item, the
# same discipline `YFinanceNewsProvider._to_item` already applies at
# the fetch boundary ("a single malformed entry is skipped rather
# than failing the fetch: the others are still worth showing the
# model, and news is advisory input rather than the basis of a money
# calculation") — but this `try` is belt-and-braces for a failure
# mode nobody has anticipated yet, not a substitute for that
# defensiveness. Measured: before `_news_to_dict` was made defensive,
# a `NewsItem` with a plain-string `published_at` raised
# `AttributeError: 'str' object has no attribute 'isoformat'`
# straight out of this construction when it ran unguarded — past
# both `_process_symbol`'s `except TraderError` and this file's own
# HOLD boundary, aborting the cycle before `ratchet_protection` or
# `reconcile_protection` ran. Assigning progressively (rather than
# building a local dict and assigning it wholesale afterward) means
# that if `partition_by_age`'s comparison is what actually raises,
# `news_count`/`news_relevant_count`/`news` — already computed by
# then — stay in `last_inputs` rather than being discarded with the
# local variables that held them.
try:
self.last_inputs["news_count"] = len(all_news)
# Deliberately over EVERY fetched item, not just the fresh ones.
# Changing this denominator would make new rows incomparable with
# every row written before the window existed; relevance is its
# own problem, tracked as issue #6.
aliases = self._aliases_for(symbol)
self.last_inputs["news_relevant_count"] = relevance_count(
all_news, symbol, aliases
)
# Recorded so a later analysis can tell a 0/10 that means "no
# headline named the company" from a 0/10 that means "we never
# knew what the company was called" — the two were
# indistinguishable in the rows issue #6 was measured from.
self.last_inputs["news_relevance_aliases"] = list(aliases)
# Every item, fresh and stale. Only the *prompt* is filtered:
# yfinance keeps no news archive, so this snapshot is the only
# copy that will ever exist (see docs/ideas.md).
self.last_inputs["news"] = [self._news_to_dict(n) for n in all_news]
fresh, stale = partition_by_age(all_news, now, self._max_news_age_hours)
except Exception as exc: # noqa: BLE001
# Everything in the LLM path resolves to HOLD, never raises past
# this boundary (the module-level hard rule). `__init__` already
# bounds the window to `MAX_NEWS_WINDOW_HOURS`, so the window
# itself cannot raise here. The likeliest real trigger left:
# `partition_by_age`'s comparison of `published_at` against a
# tz-aware cutoff, which raises `TypeError` both for a naive
# datetime ("can't compare offset-naive and offset-aware
# datetimes") and for a non-datetime value that survived
# `_news_to_dict`'s `str()` fallback (`'>=' not supported between
# instances of 'str' and 'datetime.datetime'`). A narrower
# `except (TypeError, ValueError, OverflowError)` is one
# exception family too narrow regardless: `AttributeError`/
# `KeyError` are the likelier shapes of an *unanticipated*
# wrong-typed provider result, and either would otherwise escape
# `evaluate()` entirely — measured, that aborted the cycle before
# `ratchet_protection` or `reconcile_protection` ran, so a
# genuinely unprotected position was never reported to stdout or
# the log. `YFinanceNewsProvider` forces tz-aware timestamps, so
# a naive datetime is not reachable through it today; the broad
# catch is defence in depth against a future or alternate
# provider, the same shape as `_rank_candidates`' `except
# Exception` in `pipeline/run_once.py`. The failure stays
# visible: it lands in `last_inputs["error"]` and in
# `Signal.reason`, which reaches `decisions.reasoning` and
# `run-once`'s stdout.
_logger.warning(
"%s: news age comparison failed for %s: %s", self.id, symbol, exc
)
self.last_inputs["error"] = str(exc)
return Signal(Action.HOLD, f"News age comparison failed, holding: {exc}")
self.last_inputs["news_fresh_count"] = len(fresh)
self.last_inputs["news_stale_count"] = len(stale)
# Recorded on *every* row that got this far, including the two skip
# branches below, and not only on the rows that reuse something: the
# fingerprint is what the *next* cycle compares against, so a row
# without one is a row that can never be reused from. It is also what
# makes the gate auditable after the fact — `news changed` is a claim,
# and this is the evidence for it.
#
# Its own `try`, and deliberately not folded into the block above,
# because the two failures want opposite outcomes. A `partition_by_age`
# failure means the app does not know how old the news is, so it must
# HOLD. A fingerprint failure means the app cannot prove the news is
# unchanged, so it must *ask the model* — the expensive answer, not the
# inert one. Collapsing them would turn a hashing bug into a strategy
# that silently stops trading.
fingerprint: str | None
try:
fingerprint = news_fingerprint(fresh)
self.last_inputs["news_fingerprint"] = fingerprint
except Exception as exc: # noqa: BLE001
_logger.warning(
"%s: news fingerprint failed for %s: %s", self.id, symbol, exc
)
self.last_inputs["reuse_error"] = f"fingerprint failed: {exc}"
fingerprint = None
if not fresh and position is None:
# Nothing recent to react to, and nothing to exit. Asking the model
# anyway is what bought ANNX twice on a three-week-old Phase 3
# readout. Blocking the entry while leaving the exit open is the
# same asymmetry the pipeline already uses for screening.
self.last_inputs["skipped_model_call"] = "no fresh news"
reason = (
f"No news within the last {self._max_news_age_hours:g}h and no "
"position to exit; not opening one."
)
if news_error:
# Distinguishes a quiet news day from a Yahoo outage — the
# only symptom this branch otherwise gives an operator
# watching stdout is the same sentence either way. Measured:
# 0 of 193 `ollama_news` decision rows have ever carried
# `news_error`, so this has not happened in production yet.
reason += f" News fetch failed: {news_error}."
return Signal(Action.HOLD, reason)
# Placed after the no-fresh-news skip and before the prompt is built.
# After, because that skip is cheaper and better understood, and a
# zero-fresh flat symbol should keep the branch that already covers it.
# Before, because a reused decision must have no `prompt_sha256` — the
# absence of one is this file's established honest signal that no prompt
# existed, and building a prompt only to throw it away would leave a
# hash claiming otherwise.
reuse = self._reuse_or_none(
symbol=symbol,
fingerprint=fingerprint,
position=position,
bar_summary=bar_summary,
fresh_count=len(fresh),
now=now,
news_error=news_error,
)
if reuse is not None:
self.last_inputs.update(reuse.inputs)
indicators: dict[str, Decimal | float] = {}
if reuse.confidence is not None:
# Absent rather than zero when the earlier row recorded none:
# `_confidence_of` in the pipeline treats `None` and `0.0` as
# different claims on purpose, and inventing a zero would say
# the model expressed minimum conviction, which it never did.
indicators["confidence"] = reuse.confidence
return Signal(reuse.action, reuse.reason, indicators)
try:
system, user = build_messages(
symbol,
bar_summary,
fresh,
position,
now=now,
max_age_hours=self._max_news_age_hours,
withheld=len(stale),
analyst=analyst_opinion,
technicals=technicals,
earnings=earnings_date,
)
except Exception as exc: # noqa: BLE001
# Same reasoning as the `partition_by_age` guard above: a naive
# `published_at` reaching `_age_phrase`'s datetime subtraction
# raises `TypeError` here too, and `AttributeError`/`KeyError`
# are equally plausible from a misbehaving provider. Not
# reachable through `YFinanceNewsProvider` today; caught anyway
# so a misbehaving provider resolves to HOLD rather than
# escaping this boundary. The failure stays visible the same way:
# `last_inputs["error"]` and `Signal.reason`.
_logger.warning("%s: prompt build failed for %s: %s", self.id, symbol, exc)
self.last_inputs["error"] = str(exc)
return Signal(Action.HOLD, f"Prompt could not be built, holding: {exc}")
# Set here rather than in the dict above: a skipped decision has no
# prompt, and its absence is the honest signal for that.
self.last_inputs["prompt_sha256"] = prompt_hash(system, user)
try:
payload = self._llm.generate_json(system, user, DECISION_SCHEMA)
except TraderError as exc:
# A model failure is a reason to do nothing, not a reason to crash
# the cycle. Recorded so the gap is visible in the decisions table.
_logger.warning("%s: model call failed for %s: %s", self.id, symbol, exc)
self.last_inputs["error"] = str(exc)
return Signal(Action.HOLD, f"Model unavailable, holding: {exc}")
self.last_inputs["raw_response"] = payload
action, confidence, reason = parse_decision(payload)
return Signal(action, reason, {"confidence": confidence})
def _reuse_or_none(
self,
*,
symbol: str,
fingerprint: str | None,
position: Position | None,
bar_summary: dict[str, object],
fresh_count: int,
now: datetime,
news_error: str | None,
) -> _Reuse | None:
"""The earlier answer this cycle may carry forward, or `None` to ask.
**Never raises**, and a failure resolves to "call the model" rather than
to HOLD. The module-level rule is that everything going wrong in the LLM
path becomes a HOLD; this method is the one place where that would be
the *wrong* containment, because the thing being contained is an attempt
to avoid work. If the lookup breaks, the correct outcome is the work.
Refusing costs one model call; a wrong reuse can spend money.
Measured 2026-08-13 against the 190 stored `ollama_news` rows carrying a
news snapshot: this gate would have served 26 of them (13.7%) from an
earlier answer, and 26 of the 58 rows whose same-ticker predecessor was
less than 60 minutes earlier (44.8%) — the regime a 15-minute daemon
actually runs in. In steady state at a 15-minute interval with unchanged
news, the 60-minute window admits three reuses per fresh call.
`tools/measure_news_reuse.py` reproduces both numbers.
"""
if self._memory is None or fingerprint is None:
return None
if self._max_reuse_age_minutes <= 0:
return None
if news_error:
# A degraded fetch is never a basis for reuse in *either*
# direction. `_fetch_news` turns a Yahoo outage into zero items,
# which fingerprints as "no news" — a perfectly stable value that
# would match the next outage exactly and let one carried decision
# govern an entire outage. An outage must cost model calls, not
# hide behind a cache that agrees with itself.
return None
try:
prior = self._memory.latest_decision(self.id, symbol)
reuse = self._reuse_from(
prior,
fingerprint=fingerprint,
position=position,
bar_summary=bar_summary,
fresh_count=fresh_count,
now=now,
)
except Exception as exc: # noqa: BLE001
# Broad for the same reason `evaluate`'s other two guards are: the
# likely shapes of a wrong-typed stored value are `TypeError`,
# `AttributeError` and `KeyError`, and any of them escaping here
# would abort the symbol loop before `ratchet_protection` and
# `reconcile_protection` run — the failure that left a genuinely
# unprotected position unreported. Visible in `reuse_error`, which
# reaches `decisions.inputs_json`.
_logger.warning(
"%s: could not read the previous decision for %s, so the model "
"will be called: %s",
self.id,
symbol,
exc,
)
self.last_inputs["reuse_error"] = str(exc)
return None
return reuse
def _reuse_from(
self,
prior: StoredDecision | None,
*,
fingerprint: str,
position: Position | None,
bar_summary: dict[str, object],
fresh_count: int,
now: datetime,
) -> _Reuse | None:
"""Decide whether `prior` still answers today's question.
Eight conditions, all of which must hold. Each is here because letting
it drift would change something the model reasoned about:
1. **The fresh news set is identical** (`news_fingerprint`). This is the
whole premise. Note it is the *fresh* set — the items that actually
reached the prompt — not everything the feed returned, because an
item ageing out of the recency window changes the prompt.
2. **The earlier row is a real model answer**, either a fresh one
(`raw_response`) or a labelled carry-forward of one
(`reuse_origin_decision_id`), and carries no `error`. Without this,
the HOLD that `evaluate` records for a model timeout would become
reusable, and one unreachable Ollama would freeze a symbol on HOLD
for as long as the news stayed still.
3. **Neither cycle saw a news fetch error** (`news_error`), for the
reason `_reuse_or_none` gives for the current cycle.
4. **The earlier signal was HOLD.** Never an actionable answer — see
below.
5. **The position has not flipped** between held and flat, because that
changes which actions are legal at all: the prompt tells the model
"Only recommend sell when a position is already open".
6. **The bar window is the same window** (`last_timestamp` and
`bars_considered`). A new daily bar is a whole new trading day of
price action, and the age window alone should not be the only thing
standing between a decision and the next session.
7. **The last close has moved no more than `reuse_price_band_pct`**, in
either direction. A stale decision on a name that has moved is not a
decision.
8. **The *original* model call is younger than
`max_reuse_age_minutes`.** Anchored on the origin, carried through
`reuse_origin_decided_at`, not on the row just read — otherwise a
chain of reuses each 15 minutes apart never expires, and one answer
governs the whole session.
**Only HOLD is ever carried forward, and that is deliberately a constant
rather than a setting.** A carried HOLD cannot place an order by any
path, so the worst case of a wrong reuse is one cycle of inaction, which
the next cycle undoes. A carried BUY spends money on an answer the model
was not asked for at this instant, and money does not come back. It is
also consistent with the rest of this file: every failure mode here
already resolves to HOLD, so HOLD is the one action the codebase has
already argued is safe to produce without a live model answer. Measured,
the restriction costs 10 of 36 available reuses over the stored history
(36 → 26); the asymmetry it introduces into the decision series is
recoverable, because every carried row is labelled.
The cost is *not* zero, and the cost is on the exit side: a held
position whose model answer was HOLD is not re-asked for up to
`max_reuse_age_minutes`, so a SELL the model would now give is delayed.
Three things bound that — the price band (a move past it re-asks), the
window itself, and the protective stop every position carries, which is
the app's guaranteed exit and needs no process of ours to be alive.
Excluding held positions instead was measured and rejected: it drops the
gate from 26 reuses to 11, which is the size of the recency skip this
was meant to improve on.
"""
if prior is None:
return None
inputs = prior.inputs
if not isinstance(inputs, dict):
return None
# 1. Same fresh news.
if inputs.get("news_fingerprint") != fingerprint:
return None
# 2. A real model answer, fresh or carried.
origin_id_raw = inputs.get("reuse_origin_decision_id")
if "raw_response" not in inputs and origin_id_raw is None:
return None
if inputs.get("error"):
return None
# 3. No news outage behind the earlier answer.
if inputs.get("news_error"):
return None
# 4. HOLD only.
if inputs.get("signal_action") != _REUSABLE_ACTION:
return None
# 5. The position has not flipped.
if bool(inputs.get("position_open")) is not (position is not None):
return None
# 6. The same bar window.
prior_bars = inputs.get("bar_summary")
if not isinstance(prior_bars, dict):
return None
if prior_bars.get("last_timestamp") != bar_summary.get("last_timestamp"):
return None
if prior_bars.get("bars_considered") != bar_summary.get("bars_considered"):
return None
# 7. Within the price band.
prior_close = _to_decimal(prior_bars.get("last_close"))
current_close = _to_decimal(bar_summary.get("last_close"))
if prior_close is None or current_close is None or prior_close <= 0:
return None
move_pct = abs(current_close - prior_close) / prior_close * 100
if move_pct > self._reuse_price_band_pct:
return None
# 8. The original answer is still young enough.
#
# Absent and unusable are different, and conflating them was a real bug
# this file shipped for an hour: `_to_utc(...) or prior.decided_at`
# silently substituted the row just read whenever the stored origin was
# naive or malformed — which is precisely the chain-renewal failure the
# origin anchor exists to prevent, since the row just read is always one
# cycle old. Absent means "the earlier row was a fresh model call, so it
# *is* the origin"; unusable means "this claim cannot be trusted",
# and the only safe answer to that is to ask the model.
if origin_id_raw is None:
origin_id: int = prior.decision_id
origin_at: datetime = prior.decided_at
else:
if not isinstance(origin_id_raw, int) or isinstance(origin_id_raw, bool):
return None
parsed_at = _to_utc(inputs.get("reuse_origin_decided_at"))
if parsed_at is None:
return None
origin_id, origin_at = origin_id_raw, parsed_at
if origin_at.tzinfo is None:
return None
age_minutes = (now - origin_at).total_seconds() / 60
# `age < 0` is a prior decision dated in the future — a clock that went
# backwards, or a hand-inserted row. Refuse rather than reason about it.
if age_minutes < 0 or age_minutes >= self._max_reuse_age_minutes:
return None
chain_raw = inputs.get("reuse_chain_length")
chain = (
(chain_raw + 1)
if isinstance(chain_raw, int) and not isinstance(chain_raw, bool)
else 1
)
model_reason = _reused_model_reason(inputs) or prior.reasoning
confidence = _to_confidence(inputs.get("signal_confidence"))
reason = (
f"Reused the HOLD from {origin_at.strftime('%Y-%m-%d %H:%M UTC')} "
f"(decision {origin_id}, {age_minutes:.0f} min old, carry {chain}): the "
f"{fresh_count} fresh news item(s) are identical, the position is still "
f"{'open' if position is not None else 'flat'}, and the last close has "
f"moved {move_pct:.2f}% (band {self._reuse_price_band_pct:g}%), so the "
f"model was not called again. Model said: "
f"{model_reason or '(no reason recorded)'}"
)
return _Reuse(
action=Action.HOLD,
confidence=confidence,
reason=reason,
inputs={
# The same key the no-fresh-news skip uses, with a different
# value: whatever counts model calls should not have to learn a
# second key name to find the second reason one did not happen.
"skipped_model_call": "news unchanged",
# The row this cycle copied from, and the row the model actually
# answered. They differ from the second carry onward, and both
# matter: the first is provenance, the second is what the age
# window is measured against.
"reused_decision_id": prior.decision_id,
"reused_decided_at": prior.decided_at.isoformat(),
"reuse_origin_decision_id": origin_id,
"reuse_origin_decided_at": origin_at.isoformat(),
"reuse_chain_length": chain,
"reuse_age_minutes": round(age_minutes, 3),
# `str`, not `float`: a price move computed from `Decimal`
# closes must not become one on the way into JSON. Quantised
# only for the *record* — the band comparison above ran at full
# precision — because a raw division prints 28 significant
# digits ("0.09345794392523364485981308411" is a real measured
# value) and four decimal places of a percentage is already a
# hundredth of a basis point.
"reuse_price_move_pct": str(move_pct.quantize(_MOVE_PRECISION)),
"reuse_price_band_pct": str(self._reuse_price_band_pct),
"max_reuse_age_minutes": self._max_reuse_age_minutes,
# Carried explicitly rather than re-derived from
# `prior.reasoning` on the next carry: a held position's HOLD is
# recorded as "Already long SYM. <model reason>" by the pipeline,
# so re-reading the column each time would nest that prefix once
# per carry.
"reused_model_reason": model_reason,
},
)
def _aliases_for(self, symbol: str) -> tuple[str, ...]:
"""Configured aliases, plus whatever the resolver can name the issuer.
Never raises, and never lets a resolver failure change the outcome of a
cycle. This feeds `relevance_count` only — a recorded measurement that
no order depends on — so the correct response to a broken lookup is a
less informative number, not a HOLD and certainly not an exception. The
`except` is deliberately inside this method rather than relying on the
caller's HOLD boundary: reaching that boundary would discard the news
snapshot and the fingerprint gate's work along with the aliases.
"""
if self._alias_resolver is None:
return self._company_aliases
try:
resolved = tuple(self._alias_resolver(symbol))
except Exception as exc: # noqa: BLE001 - a measurement, not a decision
_logger.warning(
"%s: could not resolve company aliases for %s: %s",
self.id,
symbol,
exc,
)
return self._company_aliases
return merge_aliases(self._company_aliases, resolved)
def _fetch_news(self, symbol: str) -> tuple[list[NewsItem], str | None]:
"""Recent news, or an empty list plus the reason it is empty.
News is advisory input, and losing it must not raise. But since the
recency window (issue #5), losing it is no longer free of
consequence: a `MarketDataError` here yields zero items, which
`evaluate()` treats as zero fresh news — blocking a new entry while
leaving an exit open, the same asymmetry the pipeline already uses
for screening. So a Yahoo outage no longer "silently stops all
trading" as this docstring used to claim; it silently stops new
entries and leaves exits alone, and `news_error` in `last_inputs`
plus the appended sentence in the skip's `Signal.reason` is what lets
an operator tell that apart from an ordinary quiet news day.
"""
try:
return self._news.get_recent_news(symbol, self._max_news_items), None
except MarketDataError as exc:
_logger.warning("%s: news unavailable for %s: %s", self.id, symbol, exc)
return [], str(exc)
def _fetch_analyst_opinion(
self, symbol: str
) -> tuple[AnalystOpinion | None, str | None]:
"""The latest analyst consensus, or `None` plus why it is missing.
Two different states both return `(None, None)`, deliberately
conflated here and distinguishable only by whether `analyst_error` is
set: no coverage (most ETFs — `AnalystProvider.get_opinion`'s own
contract, "missing analyst coverage passes, and is not the same as
zero") and no provider configured at all (`self._analyst is None` —
replay and most tests build this strategy without one). Neither is a
failure. Only a raised `MarketDataError` sets `analyst_error`, the
same shape `_fetch_news` uses for the same reason: this data is
advisory input, and losing it must not raise or hold — discovery
already treats a failed lookup here as "drop the candidate", never as
"stop the scan" (`trader/discovery/scan.py`), and this call site owes
the evaluation loop the same discipline.
"""
if self._analyst is None:
return None, None
try:
return self._analyst.get_opinion(symbol), None
except MarketDataError as exc:
_logger.warning(
"%s: analyst opinion unavailable for %s: %s", self.id, symbol, exc
)
return None, str(exc)
def _fetch_earnings_date(self, symbol: str) -> tuple[date | None, str | None]:
"""The next known earnings date, or `None` plus why it is missing.
Same shape as `_fetch_analyst_opinion` (issue #89): two different
states both return `(None, None)`, distinguishable only by whether
`earnings_error` is set — no upcoming earnings scheduled (an ETF —
`EarningsProvider.get_next_earnings_date`'s own contract) and no
provider configured at all (`self._earnings is None` — replay and
most tests build this strategy without one). Neither is a failure.
Only a raised `MarketDataError` sets `earnings_error`. This data is
advisory input and never gates or blocks an entry in v1, so losing
it must not raise or hold — the same discipline `_fetch_news` and
`_fetch_analyst_opinion` already use, and the `except` is
deliberately inside this method rather than relying on the caller's
HOLD boundary, for the same reason those two are.
"""
if self._earnings is None:
return None, None
try:
return self._earnings.get_next_earnings_date(symbol), None
except MarketDataError as exc:
_logger.warning(
"%s: earnings date unavailable for %s: %s", self.id, symbol, exc
)
return None, str(exc)
def _compute_technicals(
self, bars: Sequence[Bar]
) -> tuple[dict[str, object], str | None]:
"""RSI/Bollinger/Donchian/trend/ATR readings, or `{}` plus why (issue #63).
Unlike `_fetch_news` and `_fetch_analyst_opinion`, this is not a
network call — `build_technical_summary` is a pure function of the
same `bars` `evaluate()` already has, so there is no outage to
degrade from. The `try` exists anyway, for the same reason the news-
age comparison and prompt-build guards elsewhere in this file are
broad: an unanticipated defect in this computation (a malformed
`Bar`, an arithmetic edge case an existing shadow strategy's own
tests never exercised at this scale) must not raise past `evaluate()`
— this data is advisory input, and losing it must not raise or hold.
Also reads `self._benchmark_bars`/`self._benchmark_symbol`
(`set_benchmark_bars`, issue #87) — set by the caller before
`evaluate()`, `None` unless it was. Passed through as keyword
arguments only when a symbol was actually given, so an unset
benchmark falls through to `build_technical_summary`'s own default
rather than this call site silently overriding it with `None`.
"""
try:
if self._benchmark_symbol is None:
return build_technical_summary(bars), None
return (
build_technical_summary(
bars,
benchmark_bars=self._benchmark_bars,
benchmark_symbol=self._benchmark_symbol,
),
None,
)
except Exception as exc: # noqa: BLE001
symbol = bars[-1].symbol if bars else "?"
_logger.warning(
"%s: technical indicators failed for %s: %s", self.id, symbol, exc
)
return {}, str(exc)
@staticmethod
def _opinion_to_dict(opinion: AnalystOpinion | None) -> dict[str, object] | None:
"""JSON-safe, for `decisions.inputs_json` — mirrors `_news_to_dict`."""
if opinion is None:
return None
return {
"strong_buy": opinion.strong_buy,
"buy": opinion.buy,
"hold": opinion.hold,
"sell": opinion.sell,
"strong_sell": opinion.strong_sell,
"is_net_negative": opinion.is_net_negative,
# issue #27. Prices are strings, not floats, for `build_bar_summary`'s
# reason: this dict is written to `decisions.inputs_json`, and money
# is `Decimal` everywhere else in this codebase.
"recommendation_mean": opinion.recommendation_mean,
"recommendation_mean_is_derived": opinion.recommendation_mean_is_derived,
"target_mean_price": (
str(opinion.target_mean_price)
if opinion.target_mean_price is not None
else None
),
"target_high_price": (
str(opinion.target_high_price)
if opinion.target_high_price is not None
else None
),
}
@staticmethod
def _news_to_dict(item: NewsItem) -> dict[str, object]:
"""JSON-safe, for `decisions.inputs_json`.
Defensive on `published_at` specifically, the one field a method is
called on: `NewsItem`'s type hint says `datetime`, but nothing
enforces that at runtime for an item reaching this class from a
future or alternate `NewsProvider`. Falling back to `str(...)`
mirrors `YFinanceNewsProvider._to_item`'s own reasoning — "a single
malformed entry is skipped rather than failing the fetch: the others
are still worth showing the model, and news is advisory input rather
than the basis of a money calculation" — applied one level deeper, to
a single malformed *field* rather than a whole entry. Measured: before
this guard, a `published_at` that was a plain string raised
`AttributeError: 'str' object has no attribute 'isoformat'` straight
out of `evaluate()`'s `last_inputs` construction, with no `try`
anywhere near it.
The normalisation now lives in `news.base.published_at_text`, which
`news_fingerprint` also calls. One definition, two users, deliberately:
a fingerprint and a stored record that normalise a timestamp differently
would disagree about what the news *was*, and the reuse gate compares
one against the other.
"""
return {
"title": item.title,
"publisher": item.publisher,
"published_at": published_at_text(item.published_at),
"summary": item.summary,
"url": item.url,
"content_type": item.content_type,
}