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)}" )