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
32 changes: 32 additions & 0 deletions docs/local_portfolio.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand Down Expand Up @@ -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:
Expand Down
49 changes: 41 additions & 8 deletions src/trading_lab/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Comment on lines +118 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass decide position overrides into snapshot writer

When tl decide is run with --position ... --snapshot, the decision text is computed using the CLI position overrides, but the snapshot is appended from local CSV state only because no position overrides are forwarded. This can record a different action, traded_quantity, and exposure than the decision the user just reviewed, which makes snapshot history inaccurate for outcome analysis and what-if runs.

Useful? React with 👍 / 👎.

)
print(f"\nRecorded local-only portfolio snapshot at {result.path}.")
return

if command == "portfolio":
Expand Down Expand Up @@ -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}.")
Expand Down
8 changes: 8 additions & 0 deletions src/trading_lab/portfolio/gui_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")


Expand Down
51 changes: 51 additions & 0 deletions src/trading_lab/portfolio/gui_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -180,6 +182,7 @@ def _daily_tab(
metadata: dict[str, str],
order_summary,
advice,
snapshot_card: str,
) -> str:
return f"""
<section class="{_tab_panel_class("daily", active_tab)}" id="tab-daily">
Expand Down Expand Up @@ -239,6 +242,7 @@ def _daily_tab(
<p class="muted">If prices are stale or missing, run market update or tlfull.</p>
</section>
</div>
{snapshot_card}
</section>"""


Expand Down Expand Up @@ -300,6 +304,44 @@ def _edit_tab(active_tab: str) -> str:
</section>"""


def _snapshot_card(risk_mode: str) -> str:
return f"""
<section class="card">
<h2>Portfolio snapshots</h2>
<p class="muted">Local CSV history only. This does not run daily, download data, or contact a broker.</p>
<form method="post" action="/snapshot">
<input type="hidden" name="risk_mode" value="{escape(risk_mode)}">
<label class="span-2">Notes <input name="notes"></label>
<button type="submit">Record snapshot</button>
</form>
<h3>Recent snapshots</h3>
<div class="table-scroll mini-scroll">
<table>
<tr><th>Timestamp</th><th>Mode</th><th>Action</th><th>Traded</th><th>Value</th><th>Notes</th></tr>
{_snapshot_rows(read_snapshots(limit=5))}
</table>
</div>
</section>"""


def _snapshot_rows(rows: list[dict[str, str]]) -> str:
if not rows:
return "<tr><td colspan='6'>No local snapshots recorded.</td></tr>"
out = []
for row in reversed(rows):
out.append(
"<tr>"
f"<td>{escape(row.get('timestamp', ''))}</td>"
f"<td>{escape(row.get('risk_mode', ''))}</td>"
f"<td>{escape(row.get('action', ''))}</td>"
f"<td>{escape(row.get('traded_symbol', ''))}</td>"
f"<td>{_money(_float_or_none(row.get('traded_value')))}</td>"
f"<td>{escape(row.get('notes', ''))}</td>"
"</tr>"
)
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 {}
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading