trader.llm.prompt module¶
Building the decision prompt, and parsing what comes back.
Two rules define this module.
Everything that can go wrong becomes HOLD. An unknown action, a missing field, a non-numeric confidence — none of them raise, and none of them trade. A misbehaving model must never be able to open a position, and the safest thing a long-only system can do is nothing.
The prompt is reconstructible. PROMPT_TEMPLATE_VERSION is stored with every decision alongside the inputs, so the exact prompt can be rebuilt later and re-run against a different model. prompt_hash then proves the rebuild matches what was actually sent. Bump the version whenever the wording changes, or old decisions become silently unreproducible.
- trader.llm.prompt.PROMPT_TEMPLATE_VERSION = '9'¶
Bump on any wording change. Stored with every decision. “3” (2026-08-13): the prompt now states the current date and labels each headline’s relative age, and stale items are withheld. See issue #5. “4” (2026-08-15): the wording is unchanged, but the news reaching it comes from two merged feeds (yfinance plus Alpaca/Benzinga, 0 of 10 shared headlines) instead of one. The corpus is what the model reasons over, so a decision made on the merged feed is not comparable to one made on yfinance alone — this version is the only thing that keeps the two sets of rows distinguishable. See issue #7. “5” (2026-08-19): analyst consensus (strong buy/buy/hold/sell/strong sell counts) is now shown for every symbol, where discovery already fetched it to reject net-negative candidates and then discarded it. See issue #32. “6” (2026-08-19): the analyst-ratings line gains a recommendation mean (Yahoo’s 1..5 consensus scale, labelled when this app derived it rather than yfinance reporting it) and target-price upside off the latest close already in context — no extra fetch. See issue #27. “7” (2026-08-23): a “Technical signals” block is added — RSI(14), Bollinger-band and Donchian-channel position, distance from the 50- and 200-day moving averages, and ATR(14) — computed once per symbol from bars already in the local cache, the same indicators three shadow strategies already compute and previously threw away. See issue #63. “Performance versus SPY and versus sector” (also named in issue #63’s source inventory) is deliberately NOT included: SPY’s own bars are a different symbol’s data never passed to evaluate(), and “sector” needs a symbol->sector taxonomy this codebase has never fetched — both are a new data source/fetch, which contradicts the “no new data source, no new fetch” framing issue #63 itself uses to scope this change in over items 1/6/7 of the same inventory. Left for a follow-up issue with its own collaborator-injection design, the same way analyst_provider was designed rather than folded into this change. “8” (2026-08-25): the technical-signals block gains a relative-strength line — the symbol’s 20-day return minus a benchmark’s (default SPY) same- window return. This is the follow-up issue “7“‘s own comment named: SPY’s bars now do reach evaluate(), via LlmStrategy.set_benchmark_bars (a duck-typed collaborator run_once.py calls before evaluate(), not a Strategy Protocol change), fed from the SAME BarCache the cycle already populated for SPY as a fixed_tickers entry — still no new fetch, no new data source. See issue #87. “9” (2026-08-31): a “Next earnings date” line is added, from a new EarningsProvider (yfinance’s .calendar, see marketdata/earnings.py) – this one IS a new fetch, unlike “7”/”8”. Context only: never gates or blocks an entry in v1, the same posture “5”/”6” shipped analyst consensus with. See issue #89.
- trader.llm.prompt.build_bar_summary(bars, lookback)[source]¶
A compact, JSON-safe description of recent price action.
Prices are strings, not floats: they are Decimal everywhere else in this codebase and this dict is written to decisions.inputs_json, where a float would both lose precision and misrepresent what the model was shown.
- Parameters:
bars (Sequence[Bar])
lookback (int)
- Return type:
dict[str, object]
- trader.llm.prompt.build_messages(symbol, bar_summary, news, position, *, now, max_age_hours, withheld=0, analyst=None, technicals=None, earnings=None)[source]¶
Return (system, user) messages for one decision.
news is expected to be already filtered to the recency window by partition_by_age; withheld is how many items that filter removed. Filtering is the caller’s job because the caller also has to record the stale items in inputs_json — yfinance keeps no archive, so the decision-time snapshot is the only copy that will ever exist.
analyst being None means “no coverage available” — it deliberately does not distinguish a symbol with no analysts (most ETFs) from a fetch that failed, the same non-distinction AnalystProvider.get_opinion already makes at the source. The caller records which one happened in inputs_json; the model is only ever told whether it has the data.
technicals is build_technical_summary’s dict, or None/{} when the caller could not compute it (issue #63) — both render “not available”, the same non-distinction analyst already makes, for the same reason: the model is only ever told whether it has the data, never why it does not.
earnings (issue #89) is the next known earnings date, or None — the same non-distinction again: “no upcoming earnings scheduled” (an ETF) and “the fetch failed” both render “not available.”, and the caller records which one happened via earnings_error in inputs_json. Context only — never gates or blocks an entry in v1, matching how analyst consensus shipped in “5”/”6” before any gate was built on it.
- Parameters:
symbol (str)
bar_summary (dict[str, object])
news (Sequence[NewsItem])
position (Position | None)
now (datetime)
max_age_hours (float)
withheld (int)
analyst (AnalystOpinion | None)
technicals (dict[str, object] | None)
earnings (date | None)
- Return type:
tuple[str, str]
- trader.llm.prompt.build_technical_summary(bars, benchmark_bars=None, benchmark_symbol='SPY')[source]¶
Technical, trend and volatility readings, computed once per symbol.
Unlike build_bar_summary, which is deliberately sliced to the prompt’s recent-closes window, this reads the full bars sequence evaluate() already has — a 200-day moving average needs 200 bars, not the 30 the prompt narrates. No new fetch: bars is exactly what the pipeline already passed in (issue #63), and benchmark_bars, when given, is the same benchmark symbol’s bars the cycle’s BarCache already holds for its own evaluation as a fixed_tickers entry — also no new fetch (issue #87).
Same JSON-safety discipline as build_bar_summary: every price-valued output is a str, not a float, because this dict is written to decisions.inputs_json. RSI and the two channel-position percentages are dimensionless/percentage readings and stay float/str-of-Decimal respectively, matching how rsi_revert/bollinger_revert/turtle already type the same numbers.
A period whose warmup is not yet satisfied, or a division that would be by a zero-width band or channel, reports None for that one field rather than a fabricated number — “not yet available” and “0” are different claims, and build_messages renders the two differently. One field being unavailable never blocks the others: a young symbol with no 200-day history still gets RSI, Bollinger and Donchian readings.
relative_strength_pct is None whenever either leg is unavailable — bars too short for its own _TECH_RELATIVE_STRENGTH_PERIOD-day return, or benchmark_bars missing/empty/too short for the same. It never reports one symbol’s return standing in for a “relative” reading that only had one side of the comparison (“missing is not zero”, same discipline as absent analyst coverage). relative_strength_benchmark_ symbol is set only alongside a computed value, so the render layer never has a benchmark name with no number behind it.
- trader.llm.prompt.parse_decision(payload)[source]¶
Turn a model response into (action, confidence, reason).
Never raises. Every failure mode resolves to HOLD with a reason saying what was wrong, because this is the boundary between an unreliable model and code that can spend money.
- Parameters:
payload (dict[str, object])
- Return type:
tuple[Action, float, str]