Source code for trader.chat.service

"""The chat CLI's core turn-handling logic (issues #13-#16).

`ChatService.handle(message)` is the single entry point: classify the
message (`trader.chat.intent`), fetch whatever data that intent needs from
the database, `config/strategies.yaml`, or the backtest engine, then — for
every branch except a plain backtest result, which is already exact numbers —
ask the local model to compose a conversational answer grounded in that data.

Every branch resolves to a plain string, never an exception and never a
fabricated fact. That is the same discipline `LlmStrategy.evaluate()` uses
for a trading decision (CLAUDE.md: "everything that can go wrong in the LLM
path resolves to HOLD, never a trade"), carried over to a chat turn: nothing
here can place an order or write a file, so the analogous "safe outcome" is a
plain-text reply that says what went wrong instead of crashing the loop or
inventing a number.
"""

import logging
from collections.abc import Sequence
from datetime import UTC, datetime
from pathlib import Path

from trader.backtest.engine import BacktestConfig, BacktestResult, run_backtest
from trader.chat.backtest_format import format_backtest_result
from trader.chat.context import (
    decision_context,
    performance_context,
    positions_context,
    trades_context,
)
from trader.chat.intent import Intent, ParsedIntent, classify
from trader.chat.prompt import (
    CHAT_RESPONSE_SCHEMA,
    build_chat_messages,
    build_explain_decision_messages,
    build_strategy_review_messages,
    parse_chat_response,
)
from trader.config.schema import StrategyConfig
from trader.errors import TraderError
from trader.llm.base import LlmError, LlmProvider
from trader.marketdata.cache import BarCache
from trader.persistence.backtests import BacktestRepository
from trader.persistence.decisions import DecisionRepository
from trader.persistence.models import Decision
from trader.persistence.outcomes import OutcomeRepository
from trader.persistence.snapshots import SnapshotRepository
from trader.persistence.trades import TradeRepository
from trader.strategies.registry import build_strategy

__all__ = ["ChatService"]

_logger = logging.getLogger("trader.chat")

#: How many rows of history ground a general/positions/trades/performance
#: answer. Small on purpose: this text goes straight into the prompt, and
#: `OllamaProvider.num_ctx` is sized for this app's own decision prompts (see
#: its docstring), not an unbounded trade log.
_CONTEXT_ROWS = 10


def _llm_unavailable(exc: LlmError) -> str:
    return f"I couldn't reach the local model ({exc}). Check `trader llm-check`."


[docs] class ChatService: """Answers one chat turn at a time. Holds no conversation state. Statelessness is deliberate for v1: each `handle()` call re-fetches its own grounding data fresh from the database, so there is no session cache that could go stale between turns or leak one user's context into another's. Multi-turn memory is future work, not a requirement any of issues #13-#16 asks for. """
[docs] def __init__( self, *, llm: LlmProvider, decisions: DecisionRepository, trades: TradeRepository, snapshots: SnapshotRepository, outcomes: OutcomeRepository, strategy_entries: Sequence[StrategyConfig], strategies_path: Path, bar_cache: BarCache, backtest_repository: BacktestRepository | None = None, ) -> None: """ Args: backtest_repository: optional. `None` means a chat-triggered backtest still runs and its result is still reported, just not saved — the same "construct without a database" allowance `LlmStrategy`'s `decision_memory` gets, so a test or a database-less caller need not stand one up. """ self._llm = llm self._decisions = decisions self._trades = trades self._snapshots = snapshots self._outcomes = outcomes self._strategy_entries = list(strategy_entries) self._strategies_path = strategies_path self._bar_cache = bar_cache self._backtest_repository = backtest_repository
[docs] def handle(self, message: str) -> str: """Answer one message. Never raises — every failure becomes a reply.""" text = message.strip() if not text: return ( 'Ask me something — e.g. "what are my open positions?", ' '"explain the AAPL decision from 2026-08-10", or ' '"backtest rsi_revert_14 on AAPL from 2024-01-01 to 2024-06-01".' ) try: parsed = classify(text, strategy_ids=[e.id for e in self._strategy_entries]) if parsed.intent is Intent.BACKTEST: return self._handle_backtest(parsed) if parsed.intent is Intent.EXPLAIN_DECISION: return self._handle_explain_decision(parsed) if parsed.intent is Intent.STRATEGY_REVIEW: return self._handle_strategy_review(parsed) return self._handle_general(text) except Exception as exc: # noqa: BLE001 - a chat turn must never crash the loop _logger.warning("chat turn failed: %s", exc, exc_info=True) return ( f"Something went wrong answering that " f"({type(exc).__name__}: {exc}). Try rephrasing, or ask " "something else." )
# --- #13: general Q&A / positions / trades / performance ----------------- def _handle_general(self, message: str) -> str: context = self._grounding_context() system, user = build_chat_messages(message, context) try: payload = self._llm.generate_json(system, user, CHAT_RESPONSE_SCHEMA) except LlmError as exc: return _llm_unavailable(exc) return parse_chat_response(payload) def _grounding_context(self) -> str: positions = self._safe_positions() trades = self._safe_trades() performance = self._safe_performance() return f"{positions}\n\n{trades}\n\n{performance}" def _safe_positions(self) -> str: try: snapshot = self._snapshots.latest_snapshot() except Exception as exc: # noqa: BLE001 - one data source failing must not fail the turn _logger.warning("could not fetch snapshot context: %s", exc) return f"[account/position data unavailable: {type(exc).__name__}: {exc}]" return positions_context(snapshot) def _safe_trades(self) -> str: try: trades = self._trades.recent(limit=_CONTEXT_ROWS) except Exception as exc: # noqa: BLE001 _logger.warning("could not fetch trade context: %s", exc) return f"[trade history unavailable: {type(exc).__name__}: {exc}]" return trades_context(trades) def _safe_performance(self) -> str: try: trips = self._outcomes.recent(limit=_CONTEXT_ROWS) total = self._outcomes.total_realized_pl() except Exception as exc: # noqa: BLE001 _logger.warning("could not fetch performance context: %s", exc) return f"[performance data unavailable: {type(exc).__name__}: {exc}]" return performance_context(trips, total) # --- #14: explain a recorded decision ------------------------------------- def _handle_explain_decision(self, parsed: ParsedIntent) -> str: decision = self._lookup_decision(parsed) if decision is None: hint = f" for {parsed.symbol}" if parsed.symbol else "" return ( f"I couldn't find a recorded decision{hint}. Try naming the " 'ticker and, optionally, a date (e.g. "explain the AAPL ' 'decision from 2026-08-10"), or a decision id ("explain ' 'decision #123").' ) context = decision_context(decision) system, user = build_explain_decision_messages(parsed.raw_message, context) try: payload = self._llm.generate_json(system, user, CHAT_RESPONSE_SCHEMA) except LlmError as exc: # The recorded row already answers "why" — fall back to it # verbatim rather than fail the turn, so a model outage never # hides a decision this app already wrote down. return ( f"{_llm_unavailable(exc)}\n\nHere is the recorded row instead:\n{context}" ) return parse_chat_response(payload) def _lookup_decision(self, parsed: ParsedIntent) -> Decision | None: if parsed.decision_id is not None: return self._decisions.get(parsed.decision_id) if parsed.symbol is None: return None return self._decisions.latest_for_ticker(parsed.symbol, on=parsed.on_date) # --- #15: trigger a backtest conversationally ----------------------------- def _handle_backtest(self, parsed: ParsedIntent) -> str: known_ids = sorted(e.id for e in self._strategy_entries) catalogue = ", ".join(known_ids) if known_ids else "none configured" if parsed.strategy_id is None: return ( "Tell me which strategy to backtest, a ticker, and a start " f"and end date (YYYY-MM-DD). Configured strategies: {catalogue}." ) entry = next( (e for e in self._strategy_entries if e.id == parsed.strategy_id), None ) if entry is None: return f"I don't recognize strategy {parsed.strategy_id!r}. Configured: {catalogue}." # THE hard guard (issue #15 / CLAUDE.md): a conversational trigger # must never reach `LlmStrategy` — `backtestable = False`, and this # path builds no model or news provider for it to use anyway. Checked # by config `type` BEFORE any construction is attempted, so an `llm` # entry is never even built, let alone backtested. if entry.type == "llm": others = ", ".join(i for i in known_ids if i != entry.id) or "none configured" return ( f"I can't backtest {entry.id!r} — it's an LLM strategy " "(`LlmStrategy.backtestable = False`), and this app " "deliberately disallows backtesting it: a multi-hundred-bar " "backtest would mean one model call per bar per symbol, and " "there is no historical news feed to backtest against " "regardless. Ask me to explain one of its live decisions " f"instead, or backtest a rule-based strategy ({others})." ) if parsed.ticker is None or parsed.start is None or parsed.end is None: missing = [] if parsed.ticker is None: missing.append("a ticker") if parsed.start is None or parsed.end is None: missing.append("a start and end date (YYYY-MM-DD)") return f"I need {' and '.join(missing)} to backtest {entry.id}." try: built = build_strategy(entry) except TraderError as exc: return f"Could not build strategy {entry.id!r}: {exc}" # Defense in depth, matching `build_strategy_runs`'s own discipline: # the class attribute is the authority, not the config `type` string, # in case a future rule strategy is ever non-backtestable too. if not built.backtestable: return f"{entry.id!r} is not backtestable, so I can't run this." start = datetime( parsed.start.year, parsed.start.month, parsed.start.day, tzinfo=UTC ) end = datetime(parsed.end.year, parsed.end.month, parsed.end.day, tzinfo=UTC) try: bars = self._bar_cache.ensure(parsed.ticker, start, end) except TraderError as exc: return f"Could not fetch bars for {parsed.ticker}: {exc}" except Exception as exc: # noqa: BLE001 - a stale/missing schema is common return f"Could not read or write cached bars ({type(exc).__name__}: {exc})." try: result = run_backtest(built, parsed.ticker, bars, BacktestConfig()) except TraderError as exc: return f"Backtest failed: {exc}" summary = format_backtest_result(result, len(bars)) saved = self._maybe_save(result, start, end, entry.params) return f"{summary}\n\n{saved}" if saved else summary def _maybe_save( self, result: BacktestResult, start: datetime, end: datetime, params: dict[str, object], ) -> str: if self._backtest_repository is None: return "" try: run_id = self._backtest_repository.save(result, start, end, params) except Exception as exc: # noqa: BLE001 - a stale/missing schema is common _logger.warning("could not save chat-triggered backtest run: %s", exc) return f"(could not save this run: {type(exc).__name__}: {exc})" return f"Saved as backtest run #{run_id}." # --- #16: review strategy configs, read-only ------------------------------ def _handle_strategy_review(self, parsed: ParsedIntent) -> str: try: raw_text = self._strategies_path.read_text() except OSError as exc: return f"Could not read {self._strategies_path}: {exc}" system, user = build_strategy_review_messages(parsed.raw_message, raw_text) try: payload = self._llm.generate_json(system, user, CHAT_RESPONSE_SCHEMA) except LlmError as exc: return f"{_llm_unavailable(exc)}\n\n{self._deterministic_strategy_listing()}" return parse_chat_response(payload) def _deterministic_strategy_listing(self) -> str: if not self._strategy_entries: return "Configured strategies: (none configured)" lines = ["Configured strategies:"] for entry in self._strategy_entries: lines.append( f"- {entry.id} ({entry.type}, mode={entry.mode.value}): {entry.params}" ) return "\n".join(lines)