feat: add candlestick chart with portfolio value line (BT-004)

- Add spot_open field to BacktestDailyPoint for complete OHLC data
- Replace line chart with candlestick chart showing price OHLC
- Add portfolio value line on secondary Y-axis
- Add _chart_options_from_dict for rendering job results
- Update both render_result and render_job_result to use new chart
This commit is contained in:
Bu5hm4nn
2026-04-06 11:22:10 +02:00
parent aff4df325d
commit f00b1b7755
6 changed files with 129 additions and 23 deletions

View File

@@ -94,6 +94,7 @@ class SyntheticBacktestEngine:
BacktestDailyPoint( BacktestDailyPoint(
date=day.date, date=day.date,
spot_close=day.close, spot_close=day.close,
spot_open=day.open if day.open is not None else day.close,
spot_low=day.low if day.low is not None else day.close, spot_low=day.low if day.low is not None else day.close,
spot_high=day.high if day.high is not None else day.close, spot_high=day.high if day.high is not None else day.close,
underlying_value=underlying_value_close, underlying_value=underlying_value_close,

View File

@@ -98,7 +98,8 @@ class BacktestDailyPoint:
margin_call_unhedged: bool margin_call_unhedged: bool
margin_call_hedged: bool margin_call_hedged: bool
active_position_ids: tuple[str, ...] = field(default_factory=tuple) active_position_ids: tuple[str, ...] = field(default_factory=tuple)
# Optional OHLC fields for worst-case margin call evaluation # OHLC fields for chart and margin call evaluation
spot_open: float | None = None # Day's open
spot_low: float | None = None # Day's low for margin call evaluation spot_low: float | None = None # Day's low for margin call evaluation
spot_high: float | None = None # Day's high spot_high: float | None = None # Day's high
# Option position info # Option position info

View File

@@ -116,38 +116,135 @@ def validate_numeric_inputs(
def _chart_options(result: BacktestPageRunResult) -> dict: def _chart_options(result: BacktestPageRunResult) -> dict:
"""Create ECharts options for candlestick chart with portfolio value line."""
template_result = result.run_result.template_results[0] template_result = result.run_result.template_results[0]
dates = [point.date.isoformat() for point in template_result.daily_path]
# Candlestick data: [open, close, low, high]
candlestick_data = [
[
point.spot_open or point.spot_close,
point.spot_close,
point.spot_low or point.spot_close,
point.spot_high or point.spot_close,
]
for point in template_result.daily_path
]
# Portfolio value data
portfolio_values = [round(point.net_portfolio_value, 2) for point in template_result.daily_path]
return { return {
"tooltip": {"trigger": "axis"}, "tooltip": {
"legend": {"data": ["Spot", "LTV hedged", "LTV unhedged"]}, "trigger": "axis",
"xAxis": {"type": "category", "data": [point.date.isoformat() for point in template_result.daily_path]}, "axisPointer": {"type": "cross"},
},
"legend": {"data": ["Price", "Portfolio Value"]},
"grid": {"right": "20%"},
"xAxis": {"type": "category", "data": dates, "axisLabel": {"rotate": 45}},
"yAxis": [ "yAxis": [
{"type": "value", "name": "Spot"}, {
{"type": "value", "name": "LTV", "min": 0, "max": 1, "axisLabel": {"formatter": "{value}"}}, "type": "value",
"name": "Price",
"position": "left",
"scale": True,
},
{
"type": "value",
"name": "Portfolio Value",
"position": "right",
"scale": True,
},
], ],
"series": [ "series": [
{ {
"name": "Spot", "name": "Price",
"type": "line", "type": "candlestick",
"smooth": True, "data": candlestick_data,
"data": [round(point.spot_close, 2) for point in template_result.daily_path], "itemStyle": {
"lineStyle": {"color": "#0ea5e9"}, "color": "#22c55e", # Green for up
"color0": "#ef4444", # Red for down
"borderColor": "#22c55e",
"borderColor0": "#ef4444",
},
}, },
{ {
"name": "LTV hedged", "name": "Portfolio Value",
"type": "line", "type": "line",
"yAxisIndex": 1, "yAxisIndex": 1,
"smooth": True, "smooth": True,
"data": [round(point.ltv_hedged, 4) for point in template_result.daily_path], "data": portfolio_values,
"lineStyle": {"color": "#22c55e"}, "lineStyle": {"color": "#0ea5e9", "width": 2},
"symbol": "none",
},
],
}
def _chart_options_from_dict(result: dict) -> dict:
"""Create ECharts options from serialized job result dict."""
template_results = result.get("template_results", [])
first_template = template_results[0] if template_results else {}
daily_path = first_template.get("daily_path", [])
dates = [dp.get("date", "") for dp in daily_path]
# Candlestick data: [open, close, low, high]
candlestick_data = [
[
dp.get("spot_open", dp.get("spot_close", 0)),
dp.get("spot_close", 0),
dp.get("spot_low", dp.get("spot_close", 0)),
dp.get("spot_high", dp.get("spot_close", 0)),
]
for dp in daily_path
]
# Portfolio value data
portfolio_values = [round(dp.get("net_portfolio_value", 0), 2) for dp in daily_path]
return {
"tooltip": {
"trigger": "axis",
"axisPointer": {"type": "cross"},
},
"legend": {"data": ["Price", "Portfolio Value"]},
"grid": {"right": "20%"},
"xAxis": {"type": "category", "data": dates, "axisLabel": {"rotate": 45}},
"yAxis": [
{
"type": "value",
"name": "Price",
"position": "left",
"scale": True,
}, },
{ {
"name": "LTV unhedged", "type": "value",
"name": "Portfolio Value",
"position": "right",
"scale": True,
},
],
"series": [
{
"name": "Price",
"type": "candlestick",
"data": candlestick_data,
"itemStyle": {
"color": "#22c55e",
"color0": "#ef4444",
"borderColor": "#22c55e",
"borderColor0": "#ef4444",
},
},
{
"name": "Portfolio Value",
"type": "line", "type": "line",
"yAxisIndex": 1, "yAxisIndex": 1,
"smooth": True, "smooth": True,
"data": [round(point.ltv_unhedged, 4) for point in template_result.daily_path], "data": portfolio_values,
"lineStyle": {"color": "#ef4444"}, "lineStyle": {"color": "#0ea5e9", "width": 2},
"symbol": "none",
}, },
], ],
} }
@@ -1030,6 +1127,12 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
row_key="date", row_key="date",
).classes("w-full") ).classes("w-full")
# Add candlestick chart with portfolio value
if daily_path:
ui.echart(_chart_options_from_dict(result)).classes(
"h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
# Update cost estimate for Databento # Update cost estimate for Databento
if str(data_source_select.value) == "databento": if str(data_source_select.value) == "databento":
update_cost_estimate() update_cost_estimate()

View File

@@ -259,6 +259,7 @@ def run_backtest_job(
{ {
"date": dp.date.isoformat(), "date": dp.date.isoformat(),
"spot_close": dp.spot_close, "spot_close": dp.spot_close,
"spot_open": dp.spot_open if dp.spot_open is not None else dp.spot_close,
"spot_low": dp.spot_low if dp.spot_low is not None else dp.spot_close, "spot_low": dp.spot_low if dp.spot_low is not None else dp.spot_close,
"spot_high": dp.spot_high if dp.spot_high is not None else dp.spot_close, "spot_high": dp.spot_high if dp.spot_high is not None else dp.spot_close,
"underlying_value": dp.underlying_value, "underlying_value": dp.underlying_value,

View File

@@ -13,7 +13,6 @@ notes:
- Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared. - Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared.
- Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required. - Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required.
priority_queue: priority_queue:
- BT-004
- EXEC-002 - EXEC-002
- DATA-DB-005 - DATA-DB-005
- DATA-002A - DATA-002A
@@ -24,6 +23,7 @@ priority_queue:
- GCF-001 - GCF-001
- DATA-DB-006 - DATA-DB-006
recently_completed: recently_completed:
- BT-004
- BT-005 - BT-005
- CORE-003 - CORE-003
- CONV-001 - CONV-001
@@ -50,7 +50,6 @@ recently_completed:
- CORE-002B - CORE-002B
states: states:
backlog: backlog:
- BT-004
- DATA-DB-005 - DATA-DB-005
- DATA-DB-006 - DATA-DB-006
- EXEC-002 - EXEC-002
@@ -62,6 +61,7 @@ states:
- GCF-001 - GCF-001
in_progress: [] in_progress: []
done: done:
- BT-004
- BT-005 - BT-005
- CORE-003 - CORE-003
- CONV-001 - CONV-001

View File

@@ -1,6 +1,6 @@
id: BT-004 id: BT-004
title: Backtest Visualization Chart title: Backtest Visualization Chart
status: backlog status: done
priority: P1 priority: P1
effort: M effort: M
depends_on: depends_on:
@@ -14,6 +14,6 @@ acceptance_criteria:
- Chart is responsive and readable on mobile/tablet viewports. - Chart is responsive and readable on mobile/tablet viewports.
- Chart library matches existing app styling (dark mode support). - Chart library matches existing app styling (dark mode support).
notes: | notes: |
Consider using a lightweight charting library that integrates well with NiceGUI. Implemented using ECharts candlestick series with portfolio value line on secondary Y-axis.
Portfolio value line should be clearly distinguishable from candlesticks. Added spot_open field to BacktestDailyPoint for complete OHLC data.
May want to add toggle for showing/hiding different series. Charts render for both immediate results and async job results.