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
49 changes: 49 additions & 0 deletions docs/local_portfolio.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ Record local snapshots for later outcome analysis:
tl portfolio snapshot --risk-mode balanced --notes "morning review"
tl portfolio snapshots
tl decide --risk-mode balanced --snapshot --snapshot-notes "after review"
tl portfolio outcome-record --risk-mode balanced --notes "track decision"
tl portfolio outcome-update
tl portfolio outcomes
tl decide --risk-mode balanced --record-outcome --outcome-notes "track decision"
```

Local portfolio updates:
Expand All @@ -60,6 +64,7 @@ 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 outcome-record --risk-mode balanced --notes "morning review"
tl portfolio gui
```

Expand Down Expand Up @@ -156,6 +161,50 @@ tl portfolio snapshots --limit 10
tl decide --risk-mode balanced --snapshot --snapshot-notes "decision recorded"
```

## Outcome Tracking

Decision outcome tracking is optional local history for evaluating whether daily decisions and suggested order ideas were useful. It appends rows to:

```text
data/processed/portfolio/decision_outcomes.csv
```

That file is under gitignored `data/` and should stay private/local. Outcome commands only read existing reports, local portfolio CSVs, and local market CSVs. They do not run `tlfull`, download prices, train models, run backtests, generate plots, connect to a broker, or place/cancel orders.

Record the current decision state:

```bash
tl portfolio outcome-record --risk-mode balanced --notes "morning decision"
```

Update previously recorded rows after enough local future market data exists:

```bash
tl portfolio outcome-update
```

View recent rows:

```bash
tl portfolio outcomes --limit 10
```

Optional aliases are also available:

```bash
tl outcome record --risk-mode balanced --notes "morning decision"
tl outcome update
tl outcome list
```

`tl decide` does not record outcomes by default, but you can opt in for a single run:

```bash
tl decide --risk-mode balanced --record-outcome --outcome-notes "decision tracked"
```

Future outcome fields are filled only from local market CSVs when enough later rows exist. Until then, rows remain `PENDING` or `INSUFFICIENT_FUTURE_DATA`; missing local price data is marked `PRICE_MISSING`.

## GUI

Start the local dashboard:
Expand Down
107 changes: 96 additions & 11 deletions src/trading_lab/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ def main() -> None:
)
decide.add_argument("--snapshot", action="store_true")
decide.add_argument("--snapshot-notes", default="")
decide.add_argument("--record-outcome", action="store_true")
decide.add_argument("--outcome-notes", default="")

portfolio = sub.add_parser("portfolio")
portfolio_sub = portfolio.add_subparsers(dest="portfolio_command")
Expand All @@ -50,6 +52,16 @@ def main() -> None:
portfolio_snapshot.add_argument("--notes", default="")
portfolio_snapshots = portfolio_sub.add_parser("snapshots")
portfolio_snapshots.add_argument("--limit", type=int, default=10)
portfolio_outcome_record = portfolio_sub.add_parser("outcome-record")
portfolio_outcome_record.add_argument(
"--risk-mode",
choices=["conservative", "balanced", "aggressive"],
default="conservative",
)
portfolio_outcome_record.add_argument("--notes", default="")
portfolio_sub.add_parser("outcome-update")
portfolio_outcomes = portfolio_sub.add_parser("outcomes")
portfolio_outcomes.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 @@ -84,6 +96,19 @@ def main() -> None:
update_order_clear.add_argument("symbol")
update_order_sub.add_parser("clear-all")

outcome = sub.add_parser("outcome")
outcome_sub = outcome.add_subparsers(dest="outcome_command")
outcome_record = outcome_sub.add_parser("record")
outcome_record.add_argument(
"--risk-mode",
choices=["conservative", "balanced", "aggressive"],
default="conservative",
)
outcome_record.add_argument("--notes", default="")
outcome_list = outcome_sub.add_parser("list")
outcome_list.add_argument("--limit", type=int, default=10)
outcome_sub.add_parser("update")

args, rest = parser.parse_known_args()
command = args.command or "status"

Expand All @@ -110,18 +135,35 @@ def main() -> None:
risk_mode=args.risk_mode,
)
print(text)
if not args.snapshot:
print("\nRun tl portfolio snapshot to record this state.")
wrote_extra = False
if args.snapshot:
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}.")
wrote_extra = True
if args.record_outcome:
from trading_lab.portfolio.outcomes import append_outcome

result = append_outcome(
risk_mode=args.risk_mode,
notes=args.outcome_notes,
account_value=args.account_value,
cash=args.cash,
)
print(f"\nRecorded local-only decision outcome row at {result.path}.")
wrote_extra = True
if not wrote_extra:
print(
"\nRun tl portfolio snapshot to record this state.\n"
"Run tl portfolio outcome-record to track this decision outcome."
)
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":
Expand All @@ -132,6 +174,10 @@ def main() -> None:
_update_command(args)
return

if command == "outcome":
_outcome_command(args)
return

if command == "plots":
raise SystemExit(
_run([sys.executable, "scripts/plot_dashboard.py"])
Expand Down Expand Up @@ -229,6 +275,23 @@ def _portfolio_command(args: argparse.Namespace) -> None:

print(format_snapshots(read_snapshots(limit=args.limit)))
return
if command == "outcome-record":
from trading_lab.portfolio.outcomes import append_outcome

result = append_outcome(risk_mode=args.risk_mode, notes=args.notes)
print(f"Recorded local-only decision outcome row at {result.path}.")
return
if command == "outcome-update":
from trading_lab.portfolio.outcomes import OUTCOMES_PATH, update_outcomes

updated = update_outcomes()
print(f"Updated {updated} local decision outcome row(s) in {OUTCOMES_PATH}.")
return
if command == "outcomes":
from trading_lab.portfolio.outcomes import format_outcomes, read_outcomes

print(format_outcomes(read_outcomes(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 All @@ -253,6 +316,28 @@ def _portfolio_command(args: argparse.Namespace) -> None:
raise SystemExit(f"Unknown portfolio command: {command}")


def _outcome_command(args: argparse.Namespace) -> None:
command = args.outcome_command or "list"
if command == "record":
from trading_lab.portfolio.outcomes import append_outcome

result = append_outcome(risk_mode=args.risk_mode, notes=args.notes)
print(f"Recorded local-only decision outcome row at {result.path}.")
return
if command == "list":
from trading_lab.portfolio.outcomes import format_outcomes, read_outcomes

print(format_outcomes(read_outcomes(limit=args.limit)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard default tl outcome path from missing limit

When tl outcome is run without a subcommand, command defaults to "list", but args.limit is only defined on the outcome list subparser. In that invocation, this branch raises AttributeError instead of listing outcomes, so the documented default behavior for the top-level alias is broken unless the user explicitly types list.

Useful? React with 👍 / 👎.

return
if command == "update":
from trading_lab.portfolio.outcomes import OUTCOMES_PATH, update_outcomes

updated = update_outcomes()
print(f"Updated {updated} local decision outcome row(s) in {OUTCOMES_PATH}.")
return
raise SystemExit("Usage: tl outcome {record,list,update} ...")


def _update_command(args: argparse.Namespace) -> None:
from trading_lab.portfolio.state import (
ACCOUNT_PATH,
Expand Down
13 changes: 13 additions & 0 deletions src/trading_lab/portfolio/gui_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ def apply_form_action(path: str, fields: dict[str, str]) -> str:
notes=fields.get("notes", "").strip(),
)
return "recorded local snapshot"
if path == "/outcome/record":
from trading_lab.portfolio.outcomes import append_outcome

append_outcome(
risk_mode=fields.get("risk_mode", "conservative"),
notes=fields.get("notes", "").strip(),
)
return "recorded local outcome"
if path == "/outcome/update":
from trading_lab.portfolio.outcomes import update_outcomes

update_outcomes()
return "updated local outcomes"
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.outcomes import read_outcomes
from trading_lab.portfolio.snapshots import read_snapshots
from trading_lab.portfolio.state import (
ACCOUNT_PATH,
Expand Down Expand Up @@ -105,6 +106,7 @@ def render_status_page(risk_mode: str = "conservative", active_tab: str = "daily
order_summary,
advice,
_snapshot_card(risk_mode),
_outcome_card(risk_mode),
)
positions_tab = _positions_tab(
active_tab,
Expand Down Expand Up @@ -183,6 +185,7 @@ def _daily_tab(
order_summary,
advice,
snapshot_card: str,
outcome_card: str,
) -> str:
return f"""
<section class="{_tab_panel_class("daily", active_tab)}" id="tab-daily">
Expand Down Expand Up @@ -243,6 +246,7 @@ def _daily_tab(
</section>
</div>
{snapshot_card}
{outcome_card}
</section>"""


Expand Down Expand Up @@ -342,6 +346,48 @@ def _snapshot_rows(rows: list[dict[str, str]]) -> str:
return "\n".join(out)


def _outcome_card(risk_mode: str) -> str:
return f"""
<section class="card">
<h2>Decision outcomes</h2>
<p class="muted">Local outcome tracking only. This rereads existing CSVs and never runs daily or contacts a broker.</p>
<form method="post" action="/outcome/record">
<input type="hidden" name="risk_mode" value="{escape(risk_mode)}">
<label class="span-2">Notes <input name="notes"></label>
<button type="submit">Record outcome</button>
</form>
<form method="post" action="/outcome/update">
<button type="submit">Update outcomes</button>
</form>
<h3>Recent outcomes</h3>
<div class="table-scroll mini-scroll">
<table>
<tr><th>Timestamp</th><th>Mode</th><th>Action</th><th>Symbol</th><th>Price</th><th>5d return</th><th>Status</th></tr>
{_outcome_rows(read_outcomes(limit=5))}
</table>
</div>
</section>"""


def _outcome_rows(rows: list[dict[str, str]]) -> str:
if not rows:
return "<tr><td colspan='7'>No local outcomes recorded.</td></tr>"
out = []
for row in reversed(rows):
out.append(
"<tr>"
f"<td>{escape(row.get('decision_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_price_at_decision')))}</td>"
f"<td>{_percent_text(row.get('return_5d', ''))}</td>"
f"<td>{escape(row.get('outcome_status', ''))}</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 @@ -610,3 +656,8 @@ def _allocation(value: float | None) -> str:
if value is None:
return "unknown"
return f"{value:.1%}"


def _percent_text(value: str) -> str:
parsed = _float_or_none(value)
return "" if parsed is None else f"{parsed:.1%}"
Loading
Loading