trader.persistence.decisions module¶
Repository for strategy evaluations (requirements §10).
Every evaluation is recorded, including holds and guardrail rejections. A table holding only the trades that happened cannot answer “why did nothing happen on Tuesday” — which is the question a later review most often needs.
- class trader.persistence.decisions.DecisionRepository(session_factory)[source]¶
Bases:
objectWrites and reads the decisions table.
- Parameters:
session_factory (sessionmaker[Session])
- record(decided_at, strategy_id, ticker, action, reasoning, trade_id=None, inputs=None, rejection_reason=None, outcome_note=None)[source]¶
Record one evaluation. Returns the new row id.
reasoning is what the decider said (WHY — its rationale, always); inputs is what it saw. Keeping them in separate columns is what lets a new prompt be re-run against the same input months later and the two outputs compared.
outcome_note (issue #25) is WHAT happened — an execution summary (“Bought 26 at limit 374.47”) for a decision that actually placed an order, None for a hold/rejection/error, which have nothing to execute. Kept apart from reasoning on purpose: before this field existed, callers appended the execution summary onto reasoning itself for a buy/sell, so reading “why” for a filled trade actually answered “what happened” — the two are different questions.
rejection_reason (issue #21) names which gate produced action when it diverged from the strategy’s own signal — price_floor versus discovery_cap, say, both rendered identically as action=’rejected’ otherwise. None on every row where nothing overrode the signal, which is most of them.
default=str on the dump means a Decimal in inputs serialises rather than raising at the boundary.
- Parameters:
decided_at (datetime)
strategy_id (str)
ticker (str)
action (str)
reasoning (str)
trade_id (int | None)
inputs (dict[str, object] | None)
rejection_reason (str | None)
outcome_note (str | None)
- Return type:
int
- get(decision_id)[source]¶
One decision by id.
- Parameters:
decision_id (int)
- Return type:
Decision | None
- latest_decision(strategy_id, ticker)[source]¶
The newest decision this strategy recorded for this ticker.
Satisfies trader.decisions.DecisionMemory, which is what LlmStrategy holds — the strategy never sees a session or this table’s column names.
Ordered by decided_at and then `id`, both descending. Every decisions row in one cycle shares that cycle’s decided_at, and the pipeline writes exactly one row per (symbol, strategy) per cycle, so in production the timestamp alone resolves it — but two rows sharing a timestamp must still order deterministically rather than by whatever the database happens to return, or a reuse gate reading this would flap between two answers for reasons nothing records.
ticker matches exactly, with no case folding. The pipeline records the same string it evaluates, so a mismatch cannot arise from a symbol travelling through it; if one ever did, this returns None and the caller does its work the slow way, which is the safe direction. Case folding here would instead cost the index and buy a guarantee nothing needs.
Never raises on ordinary data. A row with no inputs_json, or one whose JSON does not parse, or one whose JSON is not an object, yields None: the caller reads this to decide whether it may skip work, so “I could not tell” has to degrade into doing the work.
- Parameters:
strategy_id (str)
ticker (str)
- Return type:
StoredDecision | None
- latest_for_ticker(ticker, on=None)[source]¶
The most recent decision for ticker, optionally on one UTC day.
For the chat CLI’s decision-explain path (issue #14): a user names a symbol and, optionally, a date rather than a row id, and this answers that query directly rather than making every caller filter recent() client-side.
ticker matches exactly, with no case folding — same discipline as latest_decision; the caller is responsible for upper-casing a symbol typed by a person. on bounds the UTC calendar day [00:00, 24:00), matching decided_at’s own timezone rather than the caller’s local one, so “the decision on 2026-08-10” means the same day for anyone reading decided_at back later.
- Parameters:
ticker (str)
on (date | None)
- Return type:
Decision | None
- recent(limit=20)[source]¶
The most recent decisions, newest first.
- Parameters:
limit (int)
- Return type:
list[Decision]
- for_strategy_since(strategy_id, since, *, until=None)[source]¶
Every decision this strategy recorded after since, oldest first.
For the shadow-portfolio simulator (issue #33): it needs to know which symbols this strategy touched at all since its last stepped bar (to build the relevant-symbol set), and then, per bar, every row recorded inside that bar’s [bar_ts, bar_ts + bar_width) span (via until) — since inclusive, until exclusive, matching this project’s other half-open-window conventions (load_bars, NewsArchiveRepository.load_items).
Ordered oldest-first, unlike every other method in this file: the simulator’s “last row per symbol wins within a bar” rule needs to iterate forward and overwrite, not read a single newest row.
- Parameters:
strategy_id (str)
since (datetime)
until (datetime | None)
- Return type:
list[Decision]
- by_trade_ids(trade_ids)[source]¶
The decision that produced each trade, keyed by trade_id.
Not every trade has one: a protective stop firing, or any other broker-side fill this app did not itself decide (a manual trade, an acceptance_forced_buy-style test), never went through record(). Callers must read a missing key as “no recorded decision”, not as a gap to raise on — same discipline AlpacaBroker.get_order_by_id uses for a 404.
- Parameters:
trade_ids (Sequence[int])
- Return type:
dict[int, Decision]