Skip to content

Commit 91af1ae

Browse files
committed
feat: add batch tagging system, multi-directory support, and dashboard sidebar controls (v7.35.0)
- Add persistent tag field to Backtest dataclass (saved/loaded as tag.json) - Add retag_backtests() utility for batch retagging - Support multi-directory BacktestReport.open() with Union[str, List[str], None] - Extract tags from strategy metadata during backtest runs - Dashboard: tag filter bar, tag badges in all tables and strategy pages - Dashboard: collapsible tag groups in sidebar - Dashboard: sidebar selection checkboxes and challenger flag buttons - Dashboard: bidirectional sync between sidebar and compare modal - MCP server: tag support in list_strategies, get_strategy_details, ranking, trading activity - Bump version to v7.35.0
1 parent 8c6dbca commit 91af1ae

19 files changed

Lines changed: 558 additions & 104 deletions

File tree

examples/open.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import os
2-
from investing_algorithm_framework import BacktestReport, recalculate_backtests
2+
from investing_algorithm_framework import BacktestReport, recalculate_backtests, retag_backtests
33

44

55
batch_one_path = os.path.join("examples", "batch_one")
66

77
if __name__ == "__main__":
8+
retag_backtests("batch_one", directory_path=batch_one_path)
89
report = BacktestReport.open(directory_path=batch_one_path)
910
recalculate_backtests(report.backtests)
1011
report.show()

examples/tutorial/notebooks/09_report_and_llm_workflow.ipynb

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,22 @@
9494
"id": "4",
9595
"metadata": {},
9696
"outputs": [],
97+
"source": []
98+
},
99+
{
100+
"cell_type": "code",
101+
"execution_count": null,
102+
"id": "5",
103+
"metadata": {},
104+
"outputs": [],
97105
"source": [
98106
"# Opens the dashboard in your browser (and renders inline in Jupyter)\n",
99107
"report.show()"
100108
]
101109
},
102110
{
103111
"cell_type": "markdown",
104-
"id": "5",
112+
"id": "6",
105113
"metadata": {},
106114
"source": [
107115
"## Step 3 — Start the MCP Server (Connect Your LLM)\n",
@@ -147,7 +155,7 @@
147155
},
148156
{
149157
"cell_type": "markdown",
150-
"id": "6",
158+
"id": "7",
151159
"metadata": {},
152160
"source": [
153161
"## Step 4 — The LLM Toolkit (23 MCP Tools)\n",
@@ -196,7 +204,7 @@
196204
},
197205
{
198206
"cell_type": "markdown",
199-
"id": "7",
207+
"id": "8",
200208
"metadata": {},
201209
"source": [
202210
"## Step 5 — Ask the LLM to Analyze Your Strategies\n",
@@ -257,7 +265,7 @@
257265
},
258266
{
259267
"cell_type": "markdown",
260-
"id": "8",
268+
"id": "9",
261269
"metadata": {},
262270
"source": [
263271
"## Step 6 — The Iterative Workflow\n",
@@ -312,7 +320,7 @@
312320
},
313321
{
314322
"cell_type": "markdown",
315-
"id": "9",
323+
"id": "10",
316324
"metadata": {},
317325
"source": [
318326
"## Step 7 — Notes as Self-Contained Analysis Documents\n",
@@ -352,7 +360,7 @@
352360
},
353361
{
354362
"cell_type": "markdown",
355-
"id": "10",
363+
"id": "11",
356364
"metadata": {},
357365
"source": [
358366
"## Step 8 — Example Conversation with the LLM\n",
@@ -411,7 +419,7 @@
411419
},
412420
{
413421
"cell_type": "markdown",
414-
"id": "11",
422+
"id": "12",
415423
"metadata": {},
416424
"source": [
417425
"## Step 9 — Apply Selections & Export\n",
@@ -448,7 +456,7 @@
448456
},
449457
{
450458
"cell_type": "markdown",
451-
"id": "12",
459+
"id": "13",
452460
"metadata": {},
453461
"source": [
454462
"## Step 10 — You Can Also Save / Reload Programmatically\n",
@@ -461,7 +469,7 @@
461469
{
462470
"cell_type": "code",
463471
"execution_count": null,
464-
"id": "13",
472+
"id": "14",
465473
"metadata": {},
466474
"outputs": [],
467475
"source": [
@@ -472,7 +480,7 @@
472480
},
473481
{
474482
"cell_type": "markdown",
475-
"id": "14",
483+
"id": "15",
476484
"metadata": {},
477485
"source": [
478486
"## TL;DR — The Complete Workflow\n",

investing_algorithm_framework/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
TradeStatus, generate_backtest_summary_metrics, generate_algorithm_id, \
2323
APPLICATION_DIRECTORY, DataSource, OrderExecutor, PortfolioProvider, \
2424
SnapshotInterval, AWS_S3_STATE_BUCKET_NAME, BacktestEvaluationFocus, \
25-
save_backtests_to_directory, BacktestMetrics, DATA_DIRECTORY
25+
save_backtests_to_directory, BacktestMetrics, DATA_DIRECTORY, \
26+
retag_backtests
2627
from .infrastructure import AzureBlobStorageStateHandler, \
2728
CSVOHLCVDataProvider, CSVTickerDataProvider, \
2829
CCXTOHLCVDataProvider, CCXTTickerDataProvider, \
@@ -199,6 +200,7 @@
199200
"BacktestRun",
200201
"load_backtests_from_directory",
201202
"save_backtests_to_directory",
203+
"retag_backtests",
202204
"DataError",
203205
"create_backtest_metrics_for_backtest",
204206
"recalculate_backtests",

investing_algorithm_framework/app/reporting/backtest_report.py

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from dataclasses import dataclass, field
77
from typing import List, Union
88
from datetime import datetime, timedelta
9-
from pathlib import Path
109

1110
from jinja2 import Environment, FileSystemLoader
1211

@@ -86,6 +85,9 @@ class BacktestReport:
8685
directory_path: str = None
8786
# Backward compat with old API (backtest: Backtest)
8887
backtest: object = None
88+
_source_tags: List[str] = field(
89+
default_factory=list, repr=False
90+
)
8991

9092
def __post_init__(self):
9193
# Handle single Backtest passed as first positional arg:
@@ -136,46 +138,75 @@ def _is_backtest(backtest_path):
136138
return (
137139
os.path.exists(backtest_path)
138140
and os.path.isdir(backtest_path)
139-
and os.path.isfile(os.path.join(backtest_path, "algorithm_id.json"))
141+
and os.path.isfile(
142+
os.path.join(backtest_path, "algorithm_id.json")
143+
)
140144
and os.path.isdir(os.path.join(backtest_path, "runs"))
141145
)
142146

143147
@staticmethod
144148
def open(
145149
backtests: List[Backtest] = None,
146-
directory_path=None,
150+
directory_path: Union[str, List[str], None] = None,
147151
) -> "BacktestReport":
148152
loaded = []
153+
source_tags = []
149154

150155
if backtests is None:
151156
backtests = []
152157

158+
# Normalize directory_path to a list
153159
if directory_path is not None:
154-
if BacktestReport._is_backtest(directory_path):
155-
loaded.append(Backtest.open(directory_path))
160+
if isinstance(directory_path, str):
161+
dir_paths = [directory_path]
162+
else:
163+
dir_paths = list(directory_path)
164+
else:
165+
dir_paths = []
166+
167+
for dp in dir_paths:
168+
tag = os.path.basename(os.path.normpath(dp))
169+
if BacktestReport._is_backtest(dp):
170+
loaded.append(Backtest.open(dp))
171+
source_tags.append(tag)
156172
else:
157-
for root, dirs, _ in os.walk(directory_path):
173+
for root, dirs, _ in os.walk(dp):
158174
for dir_name in dirs:
159-
subdir = os.path.join(root, dir_name)
160-
if BacktestReport._is_backtest(subdir):
161-
loaded.append(Backtest.open(subdir))
175+
subdir = os.path.join(
176+
root, dir_name
177+
)
178+
if BacktestReport._is_backtest(
179+
subdir
180+
):
181+
loaded.append(
182+
Backtest.open(subdir)
183+
)
184+
source_tags.append(tag)
162185

163186
for bt in backtests:
164187
if not isinstance(bt, Backtest):
165188
raise OperationalException(
166-
"Provided backtest is not a valid Backtest instance."
189+
"Provided backtest is not a "
190+
"valid Backtest instance."
167191
)
168192
loaded.append(bt)
193+
source_tags.append('')
169194

170195
if not loaded:
171196
raise OperationalException(
172-
f"No valid backtests found at {directory_path}."
197+
f"No valid backtests found "
198+
f"at {directory_path}."
173199
)
174200

175-
return BacktestReport(
201+
# Keep first dir for backward compat
202+
first_dir = dir_paths[0] if dir_paths else None
203+
204+
report = BacktestReport(
176205
backtests=loaded,
177-
directory_path=directory_path,
206+
directory_path=first_dir,
178207
)
208+
report._source_tags = source_tags
209+
return report
179210

180211
# ------------------------------------------------------------------
181212
# Full HTML assembly
@@ -317,10 +348,16 @@ def _build_strategies_data(self):
317348
if len(algo_name) > 8:
318349
algo_name = algo_name[:8]
319350

351+
# Prefer persisted bt.tag, fall back to directory tag
352+
tag = getattr(bt, 'tag', None) or ''
353+
if not tag and i < len(self._source_tags):
354+
tag = self._source_tags[i]
355+
320356
strategies.append({
321357
'id': f'strat-{i}',
322358
'name': algo_name,
323359
'color': color,
360+
'tag': tag,
324361
'summary': summary_dict,
325362
'repEQ': rep_eq,
326363
'runIds': run_ids,
@@ -391,8 +428,10 @@ def _build_run_data(self):
391428
heatmap = {}
392429
if m and m.monthly_returns:
393430
for v, d in m.monthly_returns:
394-
y = d.year if hasattr(d, 'year') else int(str(d)[:4])
395-
mo = d.month if hasattr(d, 'month') else int(str(d)[5:7])
431+
y = d.year if hasattr(d, 'year') \
432+
else int(str(d)[:4])
433+
mo = d.month if hasattr(d, 'month') \
434+
else int(str(d)[5:7])
396435
heatmap.setdefault(y, {})[mo] = round(
397436
v * 100 if abs(v) < 1 else v, 2
398437
)
@@ -410,12 +449,16 @@ def _build_run_data(self):
410449
elif hasattr(t, 'last_reported_price'):
411450
cp = t.last_reported_price or 0
412451
pct = (ng / cost * 100) if cost else 0
452+
op_dt = t.opened_at
453+
cl_dt = t.closed_at
413454
trades_list.append({
414455
'id': idx_t,
415456
'sym': sym,
416-
'opened': _fmt_date(t.opened_at) if t.opened_at else '',
417-
'closed': _fmt_date(t.closed_at) if t.closed_at else '',
418-
'open_price': round(getattr(t, 'open_price', 0) or 0, 2),
457+
'opened': _fmt_date(op_dt) if op_dt else '',
458+
'closed': _fmt_date(cl_dt) if cl_dt else '',
459+
'open_price': round(
460+
getattr(t, 'open_price', 0) or 0, 2
461+
),
419462
'close_price': round(cp, 2),
420463
'cost': round(cost, 2),
421464
'net_gain': round(ng, 2),

investing_algorithm_framework/app/reporting/templates/dashboard.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ body { font-family:'Inter',-apple-system,sans-serif; background:var(--bg); color
2424
.overview-dot { background:var(--accent); }
2525
.sb-divider { height:1px; background:var(--border); margin:0.5rem 1.25rem; }
2626
.sb-label { padding:0.5rem 1.25rem 0.3rem; font-size:0.65rem; font-weight:600; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-dim); }
27+
.sb-tag-group { margin:0; }
28+
.sb-tag-toggle { display:flex; align-items:center; gap:0.4rem; padding:0.45rem 1.25rem; font-size:0.7rem; font-weight:600; color:var(--text-secondary); cursor:pointer; user-select:none; transition:color 0.15s; }
29+
.sb-tag-toggle:hover { color:var(--text); }
30+
.sb-tag-toggle .sb-tag-arrow { display:inline-block; font-size:0.55rem; transition:transform 0.2s; }
31+
.sb-tag-toggle.collapsed .sb-tag-arrow { transform:rotate(-90deg); }
32+
.sb-tag-children { overflow:hidden; transition:max-height 0.25s ease; }
33+
.sb-tag-children.collapsed { max-height:0 !important; }
34+
.sb-tag-children .sb-item { padding-left:2rem; }
2735

2836
/* top controls */
2937
.top-controls { position:fixed; top:0.75rem; right:1.25rem; z-index:20; display:flex; gap:0.5rem; align-items:center; }
@@ -67,6 +75,21 @@ body { font-family:'Inter',-apple-system,sans-serif; background:var(--bg); color
6775
.challenger-row { background:rgba(34,211,238,0.06) !important; }
6876
.challenger-row td { border-bottom:1px solid var(--accent) !important; }
6977
.sb-challenger { margin-left:auto; font-size:11px; color:var(--accent); }
78+
.sb-item .sb-cb { width:13px; height:13px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; margin:0; }
79+
.sb-item .sb-chal-btn { background:none; border:1px solid var(--border); border-radius:3px; cursor:pointer; font-size:11px; color:var(--text-dim); padding:0 3px; margin-left:auto; line-height:1.2; transition:all 0.15s; flex-shrink:0; }
80+
.sb-item .sb-chal-btn:hover { border-color:var(--accent); color:var(--accent); }
81+
.sb-item .sb-chal-btn.active { background:var(--accent); color:#fff; border-color:var(--accent); }
82+
.sb-item .sb-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; }
83+
84+
/* Tag filter bar & badges */
85+
.tag-filter-bar { display:flex; align-items:center; gap:6px; padding:6px 10px; border-bottom:1px solid var(--border); flex-wrap:wrap; }
86+
.tag-filter-label { font-size:0.7rem; color:var(--text-secondary); font-weight:600; white-space:nowrap; }
87+
.tag-chip { font-size:0.65rem; padding:2px 10px; border-radius:10px; border:1px solid var(--accent); background:rgba(34,211,238,0.1); color:var(--accent); cursor:pointer; transition:all 0.15s; font-weight:600; }
88+
.tag-chip:hover { background:rgba(34,211,238,0.2); }
89+
.tag-chip.excluded { background:transparent; color:var(--text-dim); border-color:var(--border); text-decoration:line-through; opacity:0.6; }
90+
.tag-chip.excluded:hover { opacity:0.9; }
91+
.tag-chip.tag-reset { border-style:dashed; color:var(--text-secondary); background:transparent; font-weight:400; }
92+
.tag-badge { font-size:0.58rem; padding:1px 6px; border-radius:8px; background:rgba(34,211,238,0.12); color:var(--accent); font-weight:600; vertical-align:middle; margin-left:4px; white-space:nowrap; }
7093

7194
/* benchmark chips */
7295
.bench-chip { display:inline-flex; align-items:center; gap:5px; background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:2px 10px; font-size:11px; font-weight:500; color:var(--text-secondary); cursor:pointer; transition:all 0.15s; line-height:1.4; }

0 commit comments

Comments
 (0)