diff --git a/docs/local_portfolio.md b/docs/local_portfolio.md index 74e9819..9f36eed 100644 --- a/docs/local_portfolio.md +++ b/docs/local_portfolio.md @@ -30,6 +30,14 @@ tl decide --risk-mode balanced tl decide --risk-mode aggressive ``` +Record local snapshots for later outcome analysis: + +```bash +tl portfolio snapshot --risk-mode balanced --notes "morning review" +tl portfolio snapshots +tl decide --risk-mode balanced --snapshot --snapshot-notes "after review" +``` + Local portfolio updates: ```bash @@ -51,6 +59,7 @@ tlfull tl update cash 1000 tl update account-value 5000 tl decide --risk-mode balanced +tl portfolio snapshot --risk-mode balanced --notes "morning review" tl portfolio gui ``` @@ -124,6 +133,29 @@ tl decide --account-value 5000 --cash 1000 Risk mode changes local advice only. It does not trigger downloads, broker actions, or automated trading. +## Snapshot History + +Portfolio snapshots are optional local history rows for future plots and outcome tracking. They read the current local portfolio files, existing market CSVs, and existing reports, then append one row to: + +```text +data/processed/portfolio/snapshots.csv +``` + +That file lives under gitignored `data/`, so it is private local state and should not be committed. Snapshot commands are lightweight: they do not run `tlfull`, download market data, train models, run backtests, generate plots, connect to a broker, or place/cancel orders. + +Use: + +```bash +tl portfolio snapshot --risk-mode balanced --notes "manual review" +tl portfolio snapshots --limit 10 +``` + +`tl decide` does not write snapshots by default, but you can opt in for a single run: + +```bash +tl decide --risk-mode balanced --snapshot --snapshot-notes "decision recorded" +``` + ## GUI Start the local dashboard: diff --git a/src/trading_lab/cli/main.py b/src/trading_lab/cli/main.py index efe159b..d31056d 100644 --- a/src/trading_lab/cli/main.py +++ b/src/trading_lab/cli/main.py @@ -30,6 +30,8 @@ def main() -> None: choices=["conservative", "balanced", "aggressive"], default="conservative", ) + decide.add_argument("--snapshot", action="store_true") + decide.add_argument("--snapshot-notes", default="") portfolio = sub.add_parser("portfolio") portfolio_sub = portfolio.add_subparsers(dest="portfolio_command") @@ -39,6 +41,15 @@ def main() -> None: portfolio_sub.add_parser("positions") portfolio_sub.add_parser("orders") portfolio_sub.add_parser("gui") + portfolio_snapshot = portfolio_sub.add_parser("snapshot") + portfolio_snapshot.add_argument( + "--risk-mode", + choices=["conservative", "balanced", "aggressive"], + default="conservative", + ) + portfolio_snapshot.add_argument("--notes", default="") + portfolio_snapshots = portfolio_sub.add_parser("snapshots") + portfolio_snapshots.add_argument("--limit", type=int, default=10) portfolio_set = portfolio_sub.add_parser("set") portfolio_set.add_argument("symbol") portfolio_set.add_argument("quantity", type=float) @@ -91,15 +102,26 @@ def main() -> None: if command == "decide": from trading_lab.decision import render_daily_decision - print( - render_daily_decision( - profile=args.profile, - account_value=args.account_value, - positions=args.position, - cash=args.cash, - risk_mode=args.risk_mode, - ) + text = render_daily_decision( + profile=args.profile, + account_value=args.account_value, + positions=args.position, + cash=args.cash, + risk_mode=args.risk_mode, ) + print(text) + if not args.snapshot: + print("\nRun tl portfolio snapshot to record this state.") + return + from trading_lab.portfolio.snapshots import append_snapshot + + result = append_snapshot( + risk_mode=args.risk_mode, + notes=args.snapshot_notes, + account_value=args.account_value, + cash=args.cash, + ) + print(f"\nRecorded local-only portfolio snapshot at {result.path}.") return if command == "portfolio": @@ -196,6 +218,17 @@ def _portfolio_command(args: argparse.Namespace) -> None: run_gui() return + if command == "snapshot": + from trading_lab.portfolio.snapshots import append_snapshot + + result = append_snapshot(risk_mode=args.risk_mode, notes=args.notes) + print(f"Recorded local-only portfolio snapshot at {result.path}.") + return + if command == "snapshots": + from trading_lab.portfolio.snapshots import format_snapshots, read_snapshots + + print(format_snapshots(read_snapshots(limit=args.limit))) + return if command == "set": write_position(POSITIONS_PATH, args.symbol, args.quantity) print(f"Updated local position for {args.symbol.upper()} in {POSITIONS_PATH}.") diff --git a/src/trading_lab/portfolio/gui_forms.py b/src/trading_lab/portfolio/gui_forms.py index 2e2be93..60bd5b8 100644 --- a/src/trading_lab/portfolio/gui_forms.py +++ b/src/trading_lab/portfolio/gui_forms.py @@ -46,6 +46,14 @@ def apply_form_action(path: str, fields: dict[str, str]) -> str: symbol = fields.get("symbol", "").strip() clear_open_orders(OPEN_ORDERS_PATH, None if fields.get("all") == "1" else symbol) return "cleared local orders" + if path == "/snapshot": + from trading_lab.portfolio.snapshots import append_snapshot + + append_snapshot( + risk_mode=fields.get("risk_mode", "conservative"), + notes=fields.get("notes", "").strip(), + ) + return "recorded local snapshot" raise ValueError(f"Unknown form action: {path}") diff --git a/src/trading_lab/portfolio/gui_render.py b/src/trading_lab/portfolio/gui_render.py index b082730..8a04b03 100644 --- a/src/trading_lab/portfolio/gui_render.py +++ b/src/trading_lab/portfolio/gui_render.py @@ -13,6 +13,7 @@ ) from trading_lab.portfolio.gui_assets import CSS, dashboard_script from trading_lab.portfolio.gui_forms import render_forms +from trading_lab.portfolio.snapshots import read_snapshots from trading_lab.portfolio.state import ( ACCOUNT_PATH, OPEN_ORDERS_PATH, @@ -103,6 +104,7 @@ def render_status_page(risk_mode: str = "conservative", active_tab: str = "daily metadata, order_summary, advice, + _snapshot_card(risk_mode), ) positions_tab = _positions_tab( active_tab, @@ -180,6 +182,7 @@ def _daily_tab( metadata: dict[str, str], order_summary, advice, + snapshot_card: str, ) -> str: return f"""
@@ -239,6 +242,7 @@ def _daily_tab(

If prices are stale or missing, run market update or tlfull.

+ {snapshot_card} """ @@ -300,6 +304,44 @@ def _edit_tab(active_tab: str) -> str: """ +def _snapshot_card(risk_mode: str) -> str: + return f""" +
+

Portfolio snapshots

+

Local CSV history only. This does not run daily, download data, or contact a broker.

+
+ + + +
+

Recent snapshots

+
+ + + {_snapshot_rows(read_snapshots(limit=5))} +
TimestampModeActionTradedValueNotes
+
+
""" + + +def _snapshot_rows(rows: list[dict[str, str]]) -> str: + if not rows: + return "No local snapshots recorded." + out = [] + for row in reversed(rows): + out.append( + "" + f"{escape(row.get('timestamp', ''))}" + f"{escape(row.get('risk_mode', ''))}" + f"{escape(row.get('action', ''))}" + f"{escape(row.get('traded_symbol', ''))}" + f"{_money(_float_or_none(row.get('traded_value')))}" + f"{escape(row.get('notes', ''))}" + "" + ) + return "\n".join(out) + + def _decision_view_model(decision_text: str, inputs) -> dict[str, object]: lines = decision_text.splitlines() selected = inputs.selected_signal if inputs is not None else {} @@ -545,6 +587,15 @@ def _percent(value: str | None) -> float | None: return None +def _float_or_none(value: str | None) -> float | None: + if value is None: + return None + try: + return float(value.replace("$", "").replace(",", "").strip()) + except ValueError: + return None + + def _money(value: float | None) -> str: if value is None: return "unavailable" diff --git a/src/trading_lab/portfolio/snapshots.py b/src/trading_lab/portfolio/snapshots.py new file mode 100644 index 0000000..0ac899c --- /dev/null +++ b/src/trading_lab/portfolio/snapshots.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import csv +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from trading_lab.config import load_trading_config +from trading_lab.decision import ( + DEFAULT_REPORTS_DIR, + format_decision, + load_decision_inputs, +) +from trading_lab.portfolio.review import normalize_risk_mode +from trading_lab.portfolio.state import ( + ACCOUNT_PATH, + MARKET_DIR, + OPEN_ORDERS_PATH, + POSITIONS_PATH, + build_portfolio_state, +) + + +SNAPSHOTS_PATH = Path("data/processed/portfolio/snapshots.csv") + +SNAPSHOT_COLUMNS = ( + "timestamp", + "risk_mode", + "action", + "account_value", + "cash", + "known_equity_value", + "known_total_value", + "traded_symbol", + "traded_quantity", + "traded_value", + "traded_allocation", + "pending_buy_notional", + "pending_sell_notional", + "pending_buy_count", + "pending_sell_count", + "model_probability", + "active_target_mode", + "active_target_column", + "current_traded_price", + "benchmark_symbol", + "benchmark_price", + "benchmark_trend", + "positions_count", + "open_orders_count", + "notes", + "warnings", +) + + +@dataclass(frozen=True) +class SnapshotResult: + path: Path + row: dict[str, str] + + +def build_snapshot_row( + *, + risk_mode: str = "conservative", + notes: str = "", + reports_dir: Path = DEFAULT_REPORTS_DIR, + positions_path: Path = POSITIONS_PATH, + open_orders_path: Path = OPEN_ORDERS_PATH, + account_path: Path = ACCOUNT_PATH, + market_dir: Path = MARKET_DIR, + account_value: float | None = None, + cash: float | None = None, + now: datetime | None = None, +) -> dict[str, str]: + mode = normalize_risk_mode(risk_mode) + timestamp = (now or datetime.now()).isoformat(timespec="seconds") + config = load_trading_config() + state = build_portfolio_state( + positions_path=positions_path, + open_orders_path=open_orders_path, + account_path=account_path, + market_dir=market_dir, + account_value=account_value, + cash=cash, + ) + inputs = load_decision_inputs( + reports_dir=reports_dir, + positions_path=positions_path, + open_orders_path=open_orders_path, + account_path=account_path, + market_dir=market_dir, + risk_mode=mode, + account_value=account_value, + cash=cash, + ) + + traded_symbol = (inputs.traded_symbol if inputs is not None else config.traded_symbol).upper() + item = state.symbols.get(traded_symbol) + summary = inputs.summary if inputs is not None else {} + selected_signal = inputs.selected_signal if inputs is not None else {} + action = _decision_action(inputs) + account_value = state.account_value + cash = state.cash + known_equity = state.total_market_value + known_total = known_equity + cash if cash is not None else None + placed_buys = [ + order + for order in state.open_orders + if order.status == "placed" and order.side == "buy" + ] + placed_sells = [ + order + for order in state.open_orders + if order.status == "placed" and order.side == "sell" + ] + + return { + "timestamp": timestamp, + "risk_mode": mode, + "action": action, + "account_value": _number(account_value), + "cash": _number(cash), + "known_equity_value": _number(known_equity), + "known_total_value": _number(known_total), + "traded_symbol": traded_symbol, + "traded_quantity": _number(item.quantity if item is not None else 0.0), + "traded_value": _number(item.market_value if item is not None else None), + "traded_allocation": _number(item.allocation_pct if item is not None else None), + "pending_buy_notional": _number(sum(order.exposure for order in placed_buys)), + "pending_sell_notional": _number(sum(order.exposure for order in placed_sells)), + "pending_buy_count": str(len(placed_buys)), + "pending_sell_count": str(len(placed_sells)), + "model_probability": selected_signal.get("probability", ""), + "active_target_mode": summary.get("active_target_mode", ""), + "active_target_column": summary.get("active_target_column", ""), + "current_traded_price": _number( + item.latest_price if item is not None else _float_or_none(summary.get("traded_price")) + ), + "benchmark_symbol": config.benchmark_symbol.upper(), + "benchmark_price": summary.get(config.benchmark_symbol.lower(), ""), + "benchmark_trend": summary.get("benchmark_trend", "") or summary.get("qqq_trend", ""), + "positions_count": str(len(state.positions)), + "open_orders_count": str(len(state.open_orders)), + "notes": notes, + "warnings": " | ".join(state.warnings), + } + + +def append_snapshot( + *, + path: Path = SNAPSHOTS_PATH, + risk_mode: str = "conservative", + notes: str = "", + reports_dir: Path = DEFAULT_REPORTS_DIR, + positions_path: Path = POSITIONS_PATH, + open_orders_path: Path = OPEN_ORDERS_PATH, + account_path: Path = ACCOUNT_PATH, + market_dir: Path = MARKET_DIR, + account_value: float | None = None, + cash: float | None = None, +) -> SnapshotResult: + row = build_snapshot_row( + risk_mode=risk_mode, + notes=notes, + reports_dir=reports_dir, + positions_path=positions_path, + open_orders_path=open_orders_path, + account_path=account_path, + market_dir=market_dir, + account_value=account_value, + cash=cash, + ) + path.parent.mkdir(parents=True, exist_ok=True) + write_header = not path.exists() or path.stat().st_size == 0 + with path.open("a", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(SNAPSHOT_COLUMNS), extrasaction="ignore") + if write_header: + writer.writeheader() + writer.writerow(row) + return SnapshotResult(path=path, row=row) + + +def read_snapshots(path: Path = SNAPSHOTS_PATH, limit: int = 10) -> list[dict[str, str]]: + if not path.exists(): + return [] + if limit <= 0: + return [] + with path.open(newline="", encoding="utf-8") as handle: + rows = [ + {key: value for key, value in row.items() if key is not None} + for row in csv.DictReader(handle) + ] + return rows[-limit:] + + +def format_snapshots(rows: list[dict[str, str]]) -> str: + if not rows: + return "No local portfolio snapshots found." + lines = ["Recent portfolio snapshots:"] + for row in rows: + bits = [ + row.get("timestamp", ""), + row.get("risk_mode", ""), + row.get("action", ""), + row.get("traded_symbol", ""), + _money_text(row.get("traded_value", "")), + f"buys {_money_text(row.get('pending_buy_notional', ''))}", + f"sells {_money_text(row.get('pending_sell_notional', ''))}", + ] + notes = row.get("notes", "").strip() + suffix = f" notes={notes}" if notes else "" + lines.append("- " + " | ".join(part for part in bits if part) + suffix) + return "\n".join(lines) + + +def _decision_action(inputs) -> str: + if inputs is None: + return "" + first = format_decision(inputs).splitlines()[0] + return first.split(":", 1)[1].strip() if ":" in first else "" + + +def _number(value: float | int | None) -> str: + if value is None: + return "" + return f"{float(value):.6g}" + + +def _float_or_none(value: str | None) -> float | None: + if value is None: + return None + try: + return float(value.replace("$", "").replace(",", "").strip()) + except ValueError: + return None + + +def _money_text(value: str) -> str: + parsed = _float_or_none(value) + return "" if parsed is None else f"${parsed:,.2f}" diff --git a/tests/test_portfolio_gui.py b/tests/test_portfolio_gui.py index fbd1720..75988e0 100644 --- a/tests/test_portfolio_gui.py +++ b/tests/test_portfolio_gui.py @@ -3,6 +3,7 @@ from pathlib import Path from trading_lab.portfolio.gui import apply_form_action, render_status_page +from trading_lab.portfolio.snapshots import read_snapshots from trading_lab.portfolio.state import read_account, read_open_orders, read_positions @@ -94,6 +95,10 @@ def test_gui_contains_daily_decision_dark_css_dates_and_saved_account(tmp_path, assert 'id="tab-daily"' in html assert "Portfolio summary" in html assert "Dates and updates" in html + assert "Portfolio snapshots" in html + assert "Recent snapshots" in html + assert 'action="/snapshot"' in html + assert "Record snapshot" in html assert "mini-scroll" in html assert "ACTION" not in html assert "HOLD" in html @@ -269,8 +274,12 @@ def test_gui_apply_form_actions_edit_local_csvs(tmp_path: Path, monkeypatch): {"side": "buy", "symbol": "TQQQ", "quantity": "10", "limit_price": "58"}, ) assert apply_form_action("/order/clear", {"symbol": "TQQQ"}) + assert apply_form_action("/snapshot", {"risk_mode": "balanced", "notes": "gui"}) assert read_account().cash == 1000 assert read_account().account_value == 5000 assert read_positions()[0].quantity == 5 assert read_open_orders()[0].status == "canceled" + rows = read_snapshots(tmp_path / "data" / "processed" / "portfolio" / "snapshots.csv") + assert rows[-1]["risk_mode"] == "balanced" + assert rows[-1]["notes"] == "gui" diff --git a/tests/test_portfolio_snapshots.py b/tests/test_portfolio_snapshots.py new file mode 100644 index 0000000..f5fb7f3 --- /dev/null +++ b/tests/test_portfolio_snapshots.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from pathlib import Path + +from trading_lab.portfolio.snapshots import ( + append_snapshot, + build_snapshot_row, + format_snapshots, + read_snapshots, +) + + +SUMMARY = """Date: 2026-05-04 +QQQ: 420.00 +TQQQ: 60.00 +Profile: default +Active target mode: barrier_first_hit +Active target column: TQQQ_hit_up_before_down_5d +Suggested action: WAIT_FOR_PULLBACK +Max TQQQ allocation: 5% +Selected strategy eligible today: NO +- NO: selected model probability 0.580 < threshold 0.65 +""" + + +def _write_snapshot_fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path, Path]: + portfolio_dir = tmp_path / "data" / "raw" / "portfolio" + market_dir = tmp_path / "data" / "raw" / "market" + reports_dir = tmp_path / "data" / "reports" + portfolio_dir.mkdir(parents=True) + market_dir.mkdir(parents=True) + reports_dir.mkdir(parents=True) + positions = portfolio_dir / "positions.csv" + orders = portfolio_dir / "open_orders.csv" + account = portfolio_dir / "account.csv" + positions.write_text( + "symbol,quantity,notes,updated_at\nTQQQ,4,current_position,2026-05-04\n", + encoding="utf-8", + ) + orders.write_text( + "symbol,side,type,quantity,limit_price,time_in_force,status,submitted_at,notes\n" + "TQQQ,buy,limit,10,58.00,GTC,placed,2026-04-29,current_open_order\n" + "TQQQ,sell,limit,4,68.50,GTC,placed,2026-04-29,current_open_order\n", + encoding="utf-8", + ) + account.write_text( + "key,value,updated_at\ncash,1000,2026-05-04\naccount_value,5000,2026-05-04\n", + encoding="utf-8", + ) + (market_dir / "TQQQ.csv").write_text("date,close\n2026-05-04,60\n", encoding="utf-8") + (reports_dir / "daily_decision_summary.txt").write_text(SUMMARY, encoding="utf-8") + (reports_dir / "selected_model_latest_signal.csv").write_text( + "date,model,probability\n2026-05-04,demo,0.58\n", + encoding="utf-8", + ) + return positions, orders, account, market_dir, reports_dir + + +def test_snapshot_row_creation_from_synthetic_local_files(tmp_path: Path): + positions, orders, account, market_dir, reports_dir = _write_snapshot_fixture(tmp_path) + + row = build_snapshot_row( + risk_mode="balanced", + notes="unit", + reports_dir=reports_dir, + positions_path=positions, + open_orders_path=orders, + account_path=account, + market_dir=market_dir, + ) + + assert row["risk_mode"] == "balanced" + assert row["action"] == "HOLD" + assert row["account_value"] == "5000" + assert row["cash"] == "1000" + assert row["known_equity_value"] == "240" + assert row["known_total_value"] == "1240" + assert row["traded_symbol"] == "TQQQ" + assert row["traded_quantity"] == "4" + assert row["pending_buy_notional"] == "580" + assert row["pending_sell_notional"] == "274" + assert row["pending_buy_count"] == "1" + assert row["pending_sell_count"] == "1" + assert row["model_probability"] == "0.58" + assert row["active_target_mode"] == "barrier_first_hit" + assert row["active_target_column"] == "TQQQ_hit_up_before_down_5d" + assert row["current_traded_price"] == "60" + assert row["notes"] == "unit" + + +def test_missing_local_files_produce_best_effort_snapshot(tmp_path: Path): + row = build_snapshot_row( + reports_dir=tmp_path / "missing_reports", + positions_path=tmp_path / "missing_positions.csv", + open_orders_path=tmp_path / "missing_orders.csv", + account_path=tmp_path / "missing_account.csv", + market_dir=tmp_path / "missing_market", + ) + + assert row["traded_symbol"] + assert row["positions_count"] == "0" + assert row["open_orders_count"] == "0" + assert row["known_equity_value"] == "0" + assert row["action"] == "" + + +def test_snapshot_appends_instead_of_overwriting(tmp_path: Path): + positions, orders, account, market_dir, reports_dir = _write_snapshot_fixture(tmp_path) + path = tmp_path / "data" / "processed" / "portfolio" / "snapshots.csv" + + append_snapshot( + path=path, + notes="first", + reports_dir=reports_dir, + positions_path=positions, + open_orders_path=orders, + account_path=account, + market_dir=market_dir, + ) + append_snapshot( + path=path, + notes="second", + reports_dir=reports_dir, + positions_path=positions, + open_orders_path=orders, + account_path=account, + market_dir=market_dir, + ) + + rows = read_snapshots(path, limit=10) + assert [row["notes"] for row in rows] == ["first", "second"] + + +def test_cli_portfolio_snapshot_and_snapshots_commands(tmp_path: Path, monkeypatch, capsys): + _write_snapshot_fixture(tmp_path) + monkeypatch.chdir(tmp_path) + + import trading_lab.cli.main as cli_main + + monkeypatch.setattr("sys.argv", ["tl", "portfolio", "snapshot", "--risk-mode", "balanced", "--notes", "cli"]) + cli_main.main() + assert "Recorded local-only portfolio snapshot" in capsys.readouterr().out + assert (tmp_path / "data" / "processed" / "portfolio" / "snapshots.csv").exists() + + monkeypatch.setattr("sys.argv", ["tl", "portfolio", "snapshots", "--limit", "1"]) + cli_main.main() + out = capsys.readouterr().out + assert "Recent portfolio snapshots:" in out + assert "balanced" in out + assert "notes=cli" in out + + +def test_cli_decide_snapshot_writes_snapshot_without_workflow(tmp_path: Path, monkeypatch, capsys): + _write_snapshot_fixture(tmp_path) + monkeypatch.chdir(tmp_path) + + import trading_lab.cli.main as cli_main + + def fail_run(command): + raise AssertionError(f"unexpected command run: {command}") + + monkeypatch.setattr(cli_main, "_run", fail_run) + monkeypatch.setattr( + "sys.argv", + [ + "tl", + "decide", + "--risk-mode", + "balanced", + "--account-value", + "6000", + "--cash", + "1200", + "--snapshot", + "--snapshot-notes", + "decide", + ], + ) + cli_main.main() + + out = capsys.readouterr().out + assert "ACTION:" in out + assert "Recorded local-only portfolio snapshot" in out + rows = read_snapshots(tmp_path / "data" / "processed" / "portfolio" / "snapshots.csv") + assert rows[-1]["notes"] == "decide" + assert rows[-1]["account_value"] == "6000" + assert rows[-1]["cash"] == "1200" + + +def test_format_snapshots_handles_empty_and_recent_rows(): + assert format_snapshots([]) == "No local portfolio snapshots found." + text = format_snapshots( + [ + { + "timestamp": "2026-05-04T09:30:00", + "risk_mode": "balanced", + "action": "HOLD", + "traded_symbol": "TQQQ", + "traded_value": "240", + "pending_buy_notional": "580", + "pending_sell_notional": "274", + "notes": "x", + } + ] + ) + assert "$240.00" in text + assert "notes=x" in text