Source code for trader.chat.context
"""Rendering database rows into plain text a model prompt can carry.
Every function here is defensive on purpose: `ChatService` calls these against
whatever `ORDER BY ... LIMIT` handed back, and a chat turn must never crash
because a row was empty or a JSON blob was oversized. This is the same
grounding discipline `trader.llm.prompt.build_messages` uses for a trading
decision, applied to a conversational answer instead.
"""
from collections.abc import Sequence
from decimal import Decimal
from trader.persistence.models import AccountSnapshot, Decision, RoundTrip, Trade
__all__ = [
"decision_context",
"performance_context",
"positions_context",
"trades_context",
]
#: `decisions.inputs_json` can carry a full news snapshot (issue #7) and is
#: written for `json.dumps`, not for a prompt budget. `OllamaProvider`'s
#: `num_ctx` is sized for this app's own ~1,500-token decision prompts (see
#: its docstring); an unbounded paste here could blow that budget silently on
#: an unusually newsy symbol. Truncated with a visible marker rather than
#: dropped, so the explanation is honest about what the model was and was not
#: shown.
_MAX_INPUTS_CHARS = 4000
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[:limit] + f"... [truncated, {len(text) - limit} more characters]"
[docs]
def positions_context(snapshot: AccountSnapshot | None) -> str:
"""The latest account/position snapshot, or an honest "none yet"."""
if snapshot is None:
return (
"Account/position snapshot: none recorded yet. "
"Run `trader account` to capture one."
)
lines = [
f"Latest account snapshot ({snapshot.captured_at.isoformat()}, "
f"{'paper' if snapshot.is_paper else 'LIVE'} account):",
f" equity={snapshot.equity} cash={snapshot.cash} "
f"buying_power={snapshot.buying_power} portfolio_value={snapshot.portfolio_value}",
]
if snapshot.positions:
lines.append(" Open positions as of that snapshot:")
for position in snapshot.positions:
lines.append(
f" {position.ticker}: qty={position.quantity} "
f"entry={position.avg_entry_price} current={position.current_price} "
f"unrealized_pl={position.unrealized_pl}"
)
else:
lines.append(" No open positions as of that snapshot.")
return "\n".join(lines)
[docs]
def trades_context(trades: Sequence[Trade]) -> str:
"""Recent submitted orders, newest first."""
if not trades:
return "Recent trades: none recorded yet."
lines = ["Recent trades (most recent first):"]
for trade in trades:
lines.append(
f" {trade.submitted_at.isoformat()} {trade.ticker} {trade.side} "
f"qty={trade.quantity} price={trade.price} status={trade.status} "
f"strategy={trade.strategy_id or '-'}"
)
return "\n".join(lines)
[docs]
def performance_context(trips: Sequence[RoundTrip], total_realized_pl: Decimal) -> str:
"""Closed round trips and the running realized P/L, newest first."""
lines = [f"Total realized P/L across all closed round trips: {total_realized_pl}"]
if not trips:
lines.append("No closed round trips yet.")
return "\n".join(lines)
lines.append("Recent closed round trips (most recent first):")
for trip in trips:
lines.append(
f" {trip.closed_at.isoformat()} {trip.symbol} qty={trip.quantity} "
f"entry={trip.entry_price} exit={trip.exit_price} "
f"realized_pl={trip.realized_pl} strategy={trip.strategy_id or '-'}"
)
return "\n".join(lines)
[docs]
def decision_context(decision: Decision) -> str:
"""The exact recorded row a decision-explain answer must be grounded in.
Everything here is what the pipeline actually wrote to `decisions` — the
action taken, what the strategy said (`reasoning`), what actually happened
(`outcome_note`), and what it saw (`inputs_json`) — never a fresh
re-evaluation of the symbol. Issue #14's whole point is that the answer
traces back to this exact row.
`reasoning` and `outcome_note` are deliberately separate lines (issue
#25): before `outcome_note` existed, a buy/sell's `reasoning` held an
execution summary instead of the strategy's rationale, so a
decision-explain answer for a filled trade could only ever describe what
happened, never why. `outcome_note` is `None` for a hold/reject/error,
which have nothing to execute, so it prints as "not executed" rather than
a blank line that reads like a recording gap.
"""
inputs_text = decision.inputs_json or "(no inputs recorded for this decision)"
return (
f"Decision id {decision.id}, decided_at={decision.decided_at.isoformat()}, "
f"strategy={decision.strategy_id}, ticker={decision.ticker}, "
f"action={decision.action}\n"
f"Recorded reasoning (why the strategy decided this): "
f"{decision.reasoning or '(none recorded)'}\n"
f"Recorded outcome (what actually happened): "
f"{decision.outcome_note or '(not executed)'}\n"
f"Recorded inputs (raw JSON — what the strategy saw): "
f"{_truncate(inputs_text, _MAX_INPUTS_CHARS)}"
)