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: object

Writes 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]