Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions config_defaults/help_commands.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,22 @@ exit:
Backups are stored in state/backups/. Configuration YAML files are not included."

# Slash Commands (Issue #506, #507, #508)
/records:
category: Configuration
description: Daily equity, fills and realized P&L (Issue #602)
usage: /records [days|all]
examples:
- /records
- /records 30
- /records all
aliases:
- records
tags:
- information
details: "Show what the routines recorded: equity by day with day P&L, the fills in\
\ that period, and their realized P&L at average cost. Read-only, and makes no\
\ broker calls, so it shows the state as of the last routine."

/gtt:
category: Configuration
description: GTT order management (Issue #506)
Expand Down
106 changes: 106 additions & 0 deletions src/cli/commands/records_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""
Read-only view of the daily records (#602): equity, fills and realized P&L.

Reads what the routines saved in state/user.db through DailyRecords. It makes no broker calls,
so it works offline, and shows the state as of the last routine.
"""

from typing import Any, Dict, List, Optional

from src.trading.state.daily_records import DailyRecords

DEFAULT_DAYS = 10
USAGE = "Usage: /records [days|all] (default: the last 10 recorded days)"


def _money(value: Optional[float], signed: bool = False) -> str:
"""A dollar figure, or a dash for one that wasn't recorded."""
if value is None:
return "-"
return f"{value:+,.2f}" if signed else f"${value:,.2f}"


def _equity_lines(series: List[Dict[str, Any]]) -> List[str]:
lines = ["Equity", f" {'Date':<12}{'Equity':>14}{'Day P&L':>12}"]
for day in series:
lines.append(
f" {day['market_date']:<12}{_money(day['equity']):>14}"
f"{_money(day['day_pnl'], signed=True):>12}"
)
return lines


def _fill_lines(fills: List[Dict[str, Any]]) -> List[str]:
if not fills:
return ["Fills", " None in this period"]
lines = ["Fills"]
for fill in fills:
lines.append(
f" {fill['market_date']:<12}{fill['symbol']:<6} {fill['side']:<4} "
f"{fill['filled_qty']:g} @ {_money(fill['filled_avg_price'])} "
f"({fill['order_type'] or '?'})"
)
return lines


def _realized_lines(records: DailyRecords, fills: List[Dict[str, Any]]) -> List[str]:
"""Realized P&L of the sales in the period, by day, at average cost."""
days = sorted({fill["market_date"] for fill in fills if fill["side"] == "sell"})
sales = [(day, sale) for day in days for sale in records.realized_on(day)]
if not sales:
return ["Realized P&L", " No sales in this period"]

lines = ["Realized P&L (average cost)"]
for day, sale in sales:
pnl = "cost basis unknown" if sale["pnl"] is None else _money(sale["pnl"], signed=True)
lines.append(f" {day:<12}{sale['symbol']:<6} {sale['qty']:g} sold: {pnl}")

known = [sale["pnl"] for _, sale in sales if sale["pnl"] is not None]
unknown = len(sales) - len(known)
total = f" Total: {sum(known):+,.2f} over {len(known)} sale(s)"
lines.append(total + (f"; {unknown} with unknown cost basis" if unknown else ""))
return lines


def show_records(days: Optional[int] = DEFAULT_DAYS) -> str:
"""
The daily records as text.

Args:
days: How many recorded days to show, the latest ones; None for all of them

Returns:
Equity by day with day P&L, the fills in that period, and their realized P&L
"""
records = DailyRecords()
series = records.equity_series()
if not series:
return "No daily records yet: the routines write them on each run."
shown = series if days is None else series[-days:]

# All records include fills older than the first run, recorded when records began
start = None if days is None else shown[0]["market_date"]
fills = [
fill for fill in records.fills_after(None) if start is None or fill["market_date"] >= start
]

span = "all" if days is None else f"last {len(shown)}"
sections = [
[f"Daily Records ({span} recorded days, as of the last routine)"],
_equity_lines(shown),
_fill_lines(fills),
_realized_lines(records, fills),
]
return "\n\n".join("\n".join(section) for section in sections)


def records_command(args: Optional[str] = None) -> str:
"""Text for /records [days|all]."""
arg = (args or "").strip().lower()
if not arg:
return show_records()
if arg == "all":
return show_records(None)
if arg.isdigit() and int(arg) > 0:
return show_records(int(arg))
return USAGE
15 changes: 15 additions & 0 deletions src/cli/commands/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,21 @@ def _gtt_handle_subcommand(subcommand: str, subarg: str | None) -> str:
return f"Unknown subcommand: {subcommand}\nUse /gtt for help"


@command("/records", help_text="Daily equity, fills and realized P&L", has_args=True)
def cmd_records(session, args: str = None):
"""
Show the daily records (read-only, no broker calls).

Usage:
/records # The last 10 recorded days
/records 30 # The last 30 recorded days
/records all # Everything recorded
"""
from src.cli.commands.records_commands import records_command

safe_print(records_command(args))


@command("/gtt", help_text="GTT order management", has_args=True)
def cmd_gtt(session, args: str = None):
"""
Expand Down
166 changes: 166 additions & 0 deletions tests/unit/cli/test_records_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""
/records (#602): equity, fills and realized P&L, read from the daily records. The records are
real, in the test's temp state dir (tests/unit/conftest.py), and nothing reaches a broker.
"""

import asyncio
import socket
from unittest.mock import MagicMock

import pytest
import yaml

from src.cli.commands import slash_commands # noqa: F401 registers the commands
from src.cli.commands.records_commands import USAGE, records_command, show_records
from src.cli.commands.registry import CommandRegistry
from src.trading.state.daily_records import DailyRecords


def _order(order_id, symbol, side, qty, price, at, order_type="market"):
return {
"id": order_id,
"symbol": symbol,
"side": side,
"qty": qty,
"filled_qty": qty,
"filled_avg_price": price,
"filled_at": at,
"order_type": order_type,
"order_class": "simple",
"legs": [],
}


def _day(records, day, equity, last_equity, positions=None):
at = f"{day}T19:50:00+00:00"
account = {"equity": equity, "last_equity": last_equity}
records.record_account(day, "evening", account, at)
records.record_positions(day, "evening", positions or {}, at)


@pytest.fixture
def records():
book = DailyRecords()
_day(book, "2026-10-28", 100_000.0, 99_000.0)
qqq = {"quantity": 3, "entry_price": 616.80, "current_price": 714.47, "unrealized_pl": 293.0}
_day(book, "2026-10-29", 100_500.0, 100_000.0, {"QQQ": qqq})
_day(book, "2026-10-30", 100_250.0, 100_500.0)
book.record_fills(
[
_order("b1", "QQQ", "buy", 3, 616.80, "2025-11-28T15:36:02+00:00"),
_order("s1", "QQQ", "sell", 3, 696.00, "2026-10-30T13:32:32+00:00", "stop"),
]
)
return book


def test_no_records_says_so():
assert "No daily records yet" in show_records()


def test_equity_by_day_with_day_pnl(records):
text = show_records()

assert "2026-10-28" in text
assert "$100,500.00" in text
assert "+500.00" in text
assert "-250.00" in text


def test_the_stop_out_shows_as_a_fill_and_realized_pnl(records):
text = show_records()

assert "QQQ sell 3 @ $696.00 (stop)" in text
assert "QQQ 3 sold: +237.60" in text
assert "Total: +237.60 over 1 sale(s)" in text


def test_days_limits_equity_and_fills_to_that_period(records):
records.record_fills([_order("s2", "SPY", "sell", 1, 700.0, "2026-10-28T15:00:00+00:00")])

text = show_records(2)

assert "2026-10-28" not in text
assert "2026-10-29" in text
assert "SPY" not in text
assert "last 2 recorded days" in text


def test_all_includes_fills_from_before_records_began(records):
text = show_records(None)

assert "2025-11-28" in text
assert "QQQ buy 3 @ $616.80" in text
assert "all recorded days" in text


def test_a_period_with_no_fills_says_so(records):
_day(records, "2026-11-02", 100_300.0, 100_250.0)

text = show_records(1)

assert "None in this period" in text
assert "No sales in this period" in text


def test_an_unknown_cost_basis_is_said_not_guessed():
book = DailyRecords()
_day(book, "2026-10-30", 100_000.0, 100_000.0)
book.record_fills([_order("s1", "AAPL", "sell", 2, 200.0, "2026-10-30T15:00:00+00:00")])

text = show_records()

assert "AAPL 2 sold: cost basis unknown" in text
assert "Total: +0.00 over 0 sale(s); 1 with unknown cost basis" in text


def test_a_missing_equity_shows_a_dash():
book = DailyRecords()
_day(book, "2026-10-30", None, None)

assert " -" in show_records()


@pytest.mark.parametrize(
("args", "expected"),
[
(None, "last 3 recorded days"),
("", "last 3 recorded days"),
("2", "last 2 recorded days"),
(" 2 ", "last 2 recorded days"),
("99", "last 3 recorded days"),
("all", "all recorded days"),
("ALL", "all recorded days"),
],
)
def test_arguments(records, args, expected):
assert expected in records_command(args)


@pytest.mark.parametrize("args", ["0", "-1", "abc", "1.5", "2 3"])
def test_a_bad_argument_shows_usage(records, args):
assert records_command(args) == USAGE


def test_it_reaches_no_network_and_changes_no_record(records, monkeypatch):
def refuse(*args, **kwargs):
raise AssertionError("network used")

monkeypatch.setattr(socket.socket, "connect", refuse)
monkeypatch.setattr(socket, "getaddrinfo", refuse)
before = (records.equity_series(), records.fills_after(None))

show_records()

assert (records.equity_series(), records.fills_after(None)) == before


def test_the_command_is_registered_and_runs(records, capsys):
asyncio.run(CommandRegistry.execute("/records 2", MagicMock()))

assert "last 2 recorded days" in capsys.readouterr().out


def test_help_lists_it():
with open("config_defaults/help_commands.yaml") as f:
assert "/records" in yaml.safe_load(f)
Loading