From f00b1b77556f096682248485691bda55921cae17 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Mon, 6 Apr 2026 11:22:10 +0200 Subject: [PATCH] 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 --- app/backtesting/engine.py | 1 + app/models/backtest.py | 3 +- app/pages/backtests.py | 135 +++++++++++++++--- app/services/backtesting/jobs.py | 1 + docs/roadmap/ROADMAP.yaml | 4 +- .../BT-004-backtest-visualization-chart.yaml | 8 +- 6 files changed, 129 insertions(+), 23 deletions(-) rename docs/roadmap/{backlog => done}/BT-004-backtest-visualization-chart.yaml (71%) diff --git a/app/backtesting/engine.py b/app/backtesting/engine.py index ed5989b..4593c60 100644 --- a/app/backtesting/engine.py +++ b/app/backtesting/engine.py @@ -94,6 +94,7 @@ class SyntheticBacktestEngine: BacktestDailyPoint( date=day.date, 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_high=day.high if day.high is not None else day.close, underlying_value=underlying_value_close, diff --git a/app/models/backtest.py b/app/models/backtest.py index 3bdafe3..5b59aa0 100644 --- a/app/models/backtest.py +++ b/app/models/backtest.py @@ -98,7 +98,8 @@ class BacktestDailyPoint: margin_call_unhedged: bool margin_call_hedged: bool 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_high: float | None = None # Day's high # Option position info diff --git a/app/pages/backtests.py b/app/pages/backtests.py index 8c53844..b746ac4 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -116,38 +116,135 @@ def validate_numeric_inputs( def _chart_options(result: BacktestPageRunResult) -> dict: + """Create ECharts options for candlestick chart with portfolio value line.""" 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 { - "tooltip": {"trigger": "axis"}, - "legend": {"data": ["Spot", "LTV hedged", "LTV unhedged"]}, - "xAxis": {"type": "category", "data": [point.date.isoformat() for point in template_result.daily_path]}, + "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": "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": [ { - "name": "Spot", - "type": "line", - "smooth": True, - "data": [round(point.spot_close, 2) for point in template_result.daily_path], - "lineStyle": {"color": "#0ea5e9"}, + "name": "Price", + "type": "candlestick", + "data": candlestick_data, + "itemStyle": { + "color": "#22c55e", # Green for up + "color0": "#ef4444", # Red for down + "borderColor": "#22c55e", + "borderColor0": "#ef4444", + }, }, { - "name": "LTV hedged", + "name": "Portfolio Value", "type": "line", "yAxisIndex": 1, "smooth": True, - "data": [round(point.ltv_hedged, 4) for point in template_result.daily_path], - "lineStyle": {"color": "#22c55e"}, + "data": portfolio_values, + "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", "yAxisIndex": 1, "smooth": True, - "data": [round(point.ltv_unhedged, 4) for point in template_result.daily_path], - "lineStyle": {"color": "#ef4444"}, + "data": portfolio_values, + "lineStyle": {"color": "#0ea5e9", "width": 2}, + "symbol": "none", }, ], } @@ -1030,6 +1127,12 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: row_key="date", ).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 if str(data_source_select.value) == "databento": update_cost_estimate() diff --git a/app/services/backtesting/jobs.py b/app/services/backtesting/jobs.py index e2809c7..ae24ab8 100644 --- a/app/services/backtesting/jobs.py +++ b/app/services/backtesting/jobs.py @@ -259,6 +259,7 @@ def run_backtest_job( { "date": dp.date.isoformat(), "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_high": dp.spot_high if dp.spot_high is not None else dp.spot_close, "underlying_value": dp.underlying_value, diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index 8aba263..f208b31 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -13,7 +13,6 @@ notes: - 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. priority_queue: - - BT-004 - EXEC-002 - DATA-DB-005 - DATA-002A @@ -24,6 +23,7 @@ priority_queue: - GCF-001 - DATA-DB-006 recently_completed: + - BT-004 - BT-005 - CORE-003 - CONV-001 @@ -50,7 +50,6 @@ recently_completed: - CORE-002B states: backlog: - - BT-004 - DATA-DB-005 - DATA-DB-006 - EXEC-002 @@ -62,6 +61,7 @@ states: - GCF-001 in_progress: [] done: + - BT-004 - BT-005 - CORE-003 - CONV-001 diff --git a/docs/roadmap/backlog/BT-004-backtest-visualization-chart.yaml b/docs/roadmap/done/BT-004-backtest-visualization-chart.yaml similarity index 71% rename from docs/roadmap/backlog/BT-004-backtest-visualization-chart.yaml rename to docs/roadmap/done/BT-004-backtest-visualization-chart.yaml index 412ba51..21da2e2 100644 --- a/docs/roadmap/backlog/BT-004-backtest-visualization-chart.yaml +++ b/docs/roadmap/done/BT-004-backtest-visualization-chart.yaml @@ -1,6 +1,6 @@ id: BT-004 title: Backtest Visualization Chart -status: backlog +status: done priority: P1 effort: M depends_on: @@ -14,6 +14,6 @@ acceptance_criteria: - Chart is responsive and readable on mobile/tablet viewports. - Chart library matches existing app styling (dark mode support). notes: | - Consider using a lightweight charting library that integrates well with NiceGUI. - Portfolio value line should be clearly distinguishable from candlesticks. - May want to add toggle for showing/hiding different series. \ No newline at end of file + Implemented using ECharts candlestick series with portfolio value line on secondary Y-axis. + Added spot_open field to BacktestDailyPoint for complete OHLC data. + Charts render for both immediate results and async job results. \ No newline at end of file