Skip to content

Commit e01a707

Browse files
authored
Merge pull request #429 from coding-kitties/fix/large-report-performance
fix: optimize backtest report for 200+ strategies
2 parents 5d06b85 + 2385673 commit e01a707

4 files changed

Lines changed: 200 additions & 130 deletions

File tree

investing_algorithm_framework/app/reporting/backtest_report.py

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from jinja2 import Environment, FileSystemLoader
1111

1212
from investing_algorithm_framework.domain import (
13-
Backtest, OperationalException
13+
Backtest, OperationalException, tqdm
1414
)
1515

1616
logger = logging.getLogger("investing_algorithm_framework")
@@ -88,6 +88,70 @@ def _is_na(val):
8888
return False
8989

9090

91+
def _downsample(series, max_points=300):
92+
"""Downsample a time-series list to at most *max_points* entries.
93+
94+
Uses the Largest-Triangle-Three-Buckets (LTTB) algorithm to
95+
preserve visual fidelity while drastically reducing data size
96+
for the browser. Always keeps the first and last points.
97+
"""
98+
if not series or len(series) <= max_points:
99+
return series
100+
101+
n = len(series)
102+
# Each element is [value, date_str]
103+
sampled = [series[0]]
104+
bucket_size = (n - 2) / (max_points - 2)
105+
106+
a_idx = 0
107+
for i in range(1, max_points - 1):
108+
# Calculate bucket boundaries
109+
avg_start = int((i) * bucket_size) + 1
110+
avg_end = int((i + 1) * bucket_size) + 1
111+
if avg_end > n - 1:
112+
avg_end = n - 1
113+
114+
# Average point of next bucket
115+
avg_x = 0
116+
avg_y = 0
117+
count = avg_end - avg_start
118+
if count <= 0:
119+
count = 1
120+
avg_end = avg_start + 1
121+
for j in range(avg_start, avg_end):
122+
avg_x += j
123+
avg_y += series[j][0]
124+
avg_x /= count
125+
avg_y /= count
126+
127+
# Current bucket boundaries
128+
range_start = int((i - 1) * bucket_size) + 1
129+
range_end = int(i * bucket_size) + 1
130+
if range_end > n - 1:
131+
range_end = n - 1
132+
133+
# Pick the point with the largest triangle area
134+
a_x = a_idx
135+
a_y = series[a_idx][0]
136+
max_area = -1
137+
best_idx = range_start
138+
139+
for j in range(range_start, range_end):
140+
area = abs(
141+
(a_x - avg_x) * (series[j][0] - a_y)
142+
- (a_x - j) * (avg_y - a_y)
143+
) * 0.5
144+
if area > max_area:
145+
max_area = area
146+
best_idx = j
147+
148+
sampled.append(series[best_idx])
149+
a_idx = best_idx
150+
151+
sampled.append(series[-1])
152+
return sampled
153+
154+
91155
@dataclass
92156
class BacktestReport:
93157
backtests: List[Backtest] = field(default_factory=list)
@@ -162,6 +226,7 @@ def _is_backtest(backtest_path):
162226
def open(
163227
backtests: List[Backtest] = None,
164228
directory_path: Union[str, List[str], None] = None,
229+
show_progress: bool = False,
165230
) -> "BacktestReport":
166231
loaded = []
167232
source_tags = []
@@ -178,11 +243,12 @@ def open(
178243
else:
179244
dir_paths = []
180245

246+
# Collect all backtest paths first for progress tracking
247+
backtest_paths = []
181248
for dp in dir_paths:
182249
tag = os.path.basename(os.path.normpath(dp))
183250
if BacktestReport._is_backtest(dp):
184-
loaded.append(Backtest.open(dp))
185-
source_tags.append(tag)
251+
backtest_paths.append((dp, tag))
186252
else:
187253
for root, dirs, _ in os.walk(dp):
188254
for dir_name in dirs:
@@ -192,10 +258,20 @@ def open(
192258
if BacktestReport._is_backtest(
193259
subdir
194260
):
195-
loaded.append(
196-
Backtest.open(subdir)
261+
backtest_paths.append(
262+
(subdir, tag)
197263
)
198-
source_tags.append(tag)
264+
265+
iterator = backtest_paths
266+
if show_progress:
267+
iterator = tqdm(
268+
backtest_paths,
269+
desc="Loading backtests",
270+
)
271+
272+
for path, tag in iterator:
273+
loaded.append(Backtest.open(path))
274+
source_tags.append(tag)
199275

200276
for bt in backtests:
201277
if not isinstance(bt, Backtest):
@@ -339,10 +415,10 @@ def _build_strategies_data(self):
339415
initial = ec[0][0] if ec else 1
340416
if initial == 0:
341417
initial = 1
342-
rep_eq = [
418+
rep_eq = _downsample([
343419
[(v / initial - 1) * 100, _fmt_date(d)]
344420
for v, d in ec
345-
]
421+
])
346422

347423
# Run IDs / mappings / labels
348424
run_ids, run_name_map, run_labels_list = [], {}, []
@@ -680,6 +756,11 @@ def _build_run_data(self):
680756
),
681757
})
682758

759+
# Downsample heavy time series for browser perf
760+
eq = _downsample(eq)
761+
dd = _downsample(dd)
762+
rs = _downsample(rs)
763+
683764
run_data[rid] = {
684765
'label': label,
685766
'EQ': eq,

investing_algorithm_framework/app/reporting/templates/dashboard.js

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,92 @@ let selectedRunView = 'summary';
6363
});
6464
})();
6565

66+
// ===== LAZY STRATEGY PAGE RENDERING =====
67+
var _renderedStratPages = {};
68+
69+
function ensureStratPage(stratIdx) {
70+
if (_renderedStratPages[stratIdx]) return;
71+
_renderedStratPages[stratIdx] = true;
72+
73+
var s = STRATEGIES[stratIdx];
74+
if (!s) return;
75+
var sid = s.id;
76+
var i = stratIdx;
77+
var rl = s.runLabels || [];
78+
79+
var h = '<div class="page" id="page-' + sid + '" style="display:none">';
80+
h += '<h2 class="page-title">';
81+
h += '<span class="sb-dot" style="background:' + s.color + ';width:12px;height:12px"></span> ';
82+
h += escHtmlLazy(s.name);
83+
if (s.tag) h += ' <span class="tag-badge">' + escHtmlLazy(s.tag) + '</span>';
84+
if (rl.length > 1) {
85+
h += ' <select class="view-select strat-window-select" data-strat="' + i + '" onchange="onStratWindowChange(' + i + ', this.value)">';
86+
h += '<option value="summary">&Sigma; All Windows</option>';
87+
rl.forEach(function(pair) {
88+
var rn = pair[0], lab = pair[1];
89+
var mapped = s.runNameMap[rn] || '';
90+
h += '<option value="' + mapped + '">' + escHtmlLazy(lab) + '</option>';
91+
});
92+
h += '</select>';
93+
}
94+
h += '</h2>';
95+
96+
h += '<div id="' + sid + '-summary-content"></div>';
97+
h += '<div id="' + sid + '-parameters"></div>';
98+
99+
if (rl.length > 1) {
100+
h += '<div class="chart-card collapsed"><div class="chart-title">Backtest Runs</div>';
101+
h += '<div class="table-wrap"><table class="comp-table" id="strat-runs-table-' + i + '">';
102+
h += '<thead><tr><th>Run</th><th>Return</th><th>Sharpe</th><th>Max DD</th><th>Trades</th></tr></thead><tbody>';
103+
rl.forEach(function(pair) {
104+
var rn = pair[0], lab = pair[1];
105+
var mapped = s.runNameMap[rn] || '';
106+
h += '<tr data-run="' + mapped + '"><td>' + escHtmlLazy(lab) + '</td><td></td><td></td><td></td><td></td></tr>';
107+
});
108+
h += '</tbody></table></div></div>';
109+
}
110+
111+
h += '<div class="nav-tabs strat-nav-tabs" id="' + sid + '-tabs">';
112+
h += '<div class="tab active" onclick="switchStratTab(\'' + sid + '\',\'performance\')">Performance</div>';
113+
h += '<div class="tab" onclick="switchStratTab(\'' + sid + '\',\'trading\')">Trading</div>';
114+
h += '</div>';
115+
116+
h += '<div class="tab-panel active" id="' + sid + '-performance">';
117+
h += '<div id="' + sid + '-portfolio-summary"></div>';
118+
h += '<div id="' + sid + '-trading-activity"></div>';
119+
120+
h += '<div class="chart-card"><div class="chart-title" id="eq-title-' + sid + '">Equity Curves (All Runs)</div>';
121+
h += '<div class="chart-wrap"><canvas id="c-' + sid + '-eq"></canvas><div class="tooltip" id="tt-' + sid + '-eq"></div></div></div>';
122+
123+
h += '<div class="chart-card"><div class="chart-title" id="rs-title-' + sid + '">Rolling Sharpe Ratio (All Runs)</div>';
124+
h += '<div class="chart-wrap"><canvas id="c-' + sid + '-rsharpe"></canvas><div class="tooltip" id="tt-' + sid + '-rsharpe"></div></div></div>';
125+
126+
h += '<div class="chart-card"><div class="chart-title" id="dd-title-' + sid + '">Drawdown (All Runs)</div>';
127+
h += '<div class="chart-wrap"><canvas id="c-' + sid + '-dd"></canvas><div class="tooltip" id="tt-' + sid + '-dd"></div></div></div>';
128+
129+
h += '<div id="' + sid + '-monthly-returns"></div>';
130+
h += '<div id="' + sid + '-yearly-returns"></div>';
131+
h += '<div id="' + sid + '-return-scenarios"></div>';
132+
h += '</div>';
133+
134+
h += '<div class="tab-panel" id="' + sid + '-trading">';
135+
h += '<div id="' + sid + '-timeline-scatter"></div>';
136+
h += '<div id="' + sid + '-trades-table"></div>';
137+
h += '<div id="' + sid + '-orders-table"></div>';
138+
h += '<div id="' + sid + '-positions-table"></div>';
139+
h += '</div>';
140+
141+
h += '</div>';
142+
143+
var container = document.getElementById('strategy-pages-container');
144+
if (container) container.insertAdjacentHTML('beforeend', h);
145+
}
146+
147+
function escHtmlLazy(str) {
148+
if (!str) return '';
149+
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
150+
}
151+
66152
function onOverviewWindowChange(value) {
67153
onRunViewChange(value);
68154
}
@@ -448,6 +534,10 @@ function syncSbChallenger() {
448534
}
449535

450536
function showPage(pageId) {
537+
// Lazy-render strategy pages on first visit
538+
var stratMatch = pageId.match(/^strat-(\d+)$/);
539+
if (stratMatch) ensureStratPage(parseInt(stratMatch[1]));
540+
451541
document.querySelectorAll('.page').forEach(p => p.style.display = 'none');
452542
const target = document.getElementById('page-' + pageId);
453543
if (target) target.style.display = 'block';
@@ -4494,8 +4584,9 @@ function drawPageCharts(pageId) {
44944584

44954585
// ===== POPULATE RUNS TAB CELLS =====
44964586
function populateRunsTabs() {
4587+
// Only populate runs tables for already-rendered strategy pages
44974588
STRATEGIES.forEach((s, idx) => {
4498-
populateStratRunsTable(idx);
4589+
if (_renderedStratPages[idx]) populateStratRunsTable(idx);
44994590
});
45004591
}
45014592

@@ -6468,6 +6559,17 @@ function updateStratTables(stratIdx) {
64686559
}
64696560

64706561
// ===== INIT =====
6562+
// Auto-select top 10 by CAGR when there are more than 10 strategies
6563+
if (!IS_SINGLE && STRATEGIES.length > 10) {
6564+
var _initIndices = STRATEGIES.map(function(_,i){return i;});
6565+
_initIndices.sort(function(a,b) {
6566+
var ca = STRATEGIES[a].summary.cagr, cb = STRATEGIES[b].summary.cagr;
6567+
if (ca == null) ca = -Infinity;
6568+
if (cb == null) cb = -Infinity;
6569+
return cb - ca;
6570+
});
6571+
_initIndices.slice(0, 10).forEach(function(i){ selectedForCompare.add(i); });
6572+
}
64716573
buildBenchmarkChips();
64726574
rebuildWindowCoverage();
64736575
rebuildOverviewKPIs();
@@ -6476,6 +6578,7 @@ rebuildOverviewTradingActivity();
64766578
rebuildReturnScenarios();
64776579
initCollapseButtons();
64786580
syncModalCount();
6581+
syncMainTableCheckboxes();
64796582
populateRunsTabs();
64806583
drawPageCharts('overview');
64816584
renderNotesList();

0 commit comments

Comments
 (0)