From b546b59b33d456daebc7e6291f7ba2c31fb91ec0 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Mon, 6 Apr 2026 18:50:41 +0200 Subject: [PATCH] feat: split portfolio chart into stacked bar chart above candle chart - Create separate portfolio stacked bar chart (underlying + option value) - Place portfolio chart above candle chart with same X axis alignment - Make candle chart double height (h-[48rem] vs h-48 for portfolio) - Portfolio chart shows underlying (gray) + option value (blue) as stacked bars - Charts now render above the daily results table --- app/pages/backtests.py | 205 +++++++++++++++++++++++++++-------------- 1 file changed, 138 insertions(+), 67 deletions(-) diff --git a/app/pages/backtests.py b/app/pages/backtests.py index b746ac4..65faae5 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -115,8 +115,54 @@ def validate_numeric_inputs( return None -def _chart_options(result: BacktestPageRunResult) -> dict: - """Create ECharts options for candlestick chart with portfolio value line.""" +def _portfolio_chart_options(result: BacktestPageRunResult) -> dict: + """Create ECharts options for stacked bar chart of portfolio value.""" + template_result = result.run_result.template_results[0] + dates = [point.date.isoformat() for point in template_result.daily_path] + + # Underlying value and option value for stacked bars + underlying_values = [round(point.underlying_value, 2) for point in template_result.daily_path] + option_values = [round(point.option_market_value, 2) for point in template_result.daily_path] + + return { + "tooltip": { + "trigger": "axis", + "axisPointer": {"type": "shadow"}, + }, + "legend": {"data": ["Underlying", "Option Value"]}, + "grid": {"left": "10%", "right": "10%", "top": "10%", "bottom": "15%"}, + "xAxis": { + "type": "category", + "data": dates, + "axisLabel": {"show": False}, # Hide labels, will show on candle chart below + }, + "yAxis": { + "type": "value", + "name": "Portfolio", + "position": "left", + "axisLabel": {"formatter": "${value}"}, + }, + "series": [ + { + "name": "Underlying", + "type": "bar", + "stack": "total", + "data": underlying_values, + "itemStyle": {"color": "#64748b"}, # Slate gray + }, + { + "name": "Option Value", + "type": "bar", + "stack": "total", + "data": option_values, + "itemStyle": {"color": "#0ea5e9"}, # Sky blue + }, + ], + } + + +def _candle_chart_options(result: BacktestPageRunResult) -> dict: + """Create ECharts options for candlestick chart.""" template_result = result.run_result.template_results[0] dates = [point.date.isoformat() for point in template_result.daily_path] @@ -131,31 +177,24 @@ def _chart_options(result: BacktestPageRunResult) -> dict: 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", "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, - }, - { - "type": "value", - "name": "Portfolio Value", - "position": "right", - "scale": True, - }, - ], + "legend": {"data": ["Price"], "show": False}, + "grid": {"left": "10%", "right": "10%", "top": "5%", "bottom": "20%"}, + "xAxis": { + "type": "category", + "data": dates, + "axisLabel": {"rotate": 45}, + }, + "yAxis": { + "type": "value", + "name": "Price", + "position": "left", + "scale": True, + }, "series": [ { "name": "Price", @@ -168,20 +207,58 @@ def _chart_options(result: BacktestPageRunResult) -> dict: "borderColor0": "#ef4444", }, }, + ], + } + + +def _portfolio_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] + underlying_values = [round(dp.get("underlying_value", 0), 2) for dp in daily_path] + option_values = [round(dp.get("option_market_value", 0), 2) for dp in daily_path] + + return { + "tooltip": { + "trigger": "axis", + "axisPointer": {"type": "shadow"}, + }, + "legend": {"data": ["Underlying", "Option Value"]}, + "grid": {"left": "10%", "right": "10%", "top": "10%", "bottom": "15%"}, + "xAxis": { + "type": "category", + "data": dates, + "axisLabel": {"show": False}, + }, + "yAxis": { + "type": "value", + "name": "Portfolio", + "position": "left", + "axisLabel": {"formatter": "${value}"}, + }, + "series": [ { - "name": "Portfolio Value", - "type": "line", - "yAxisIndex": 1, - "smooth": True, - "data": portfolio_values, - "lineStyle": {"color": "#0ea5e9", "width": 2}, - "symbol": "none", + "name": "Underlying", + "type": "bar", + "stack": "total", + "data": underlying_values, + "itemStyle": {"color": "#64748b"}, + }, + { + "name": "Option Value", + "type": "bar", + "stack": "total", + "data": option_values, + "itemStyle": {"color": "#0ea5e9"}, }, ], } -def _chart_options_from_dict(result: dict) -> dict: +def _candle_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 {} @@ -200,31 +277,24 @@ def _chart_options_from_dict(result: dict) -> dict: 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, - }, - { - "type": "value", - "name": "Portfolio Value", - "position": "right", - "scale": True, - }, - ], + "legend": {"data": ["Price"], "show": False}, + "grid": {"left": "10%", "right": "10%", "top": "5%", "bottom": "20%"}, + "xAxis": { + "type": "category", + "data": dates, + "axisLabel": {"rotate": 45}, + }, + "yAxis": { + "type": "value", + "name": "Price", + "position": "left", + "scale": True, + }, "series": [ { "name": "Price", @@ -237,15 +307,6 @@ def _chart_options_from_dict(result: dict) -> dict: "borderColor0": "#ef4444", }, }, - { - "name": "Portfolio Value", - "type": "line", - "yAxisIndex": 1, - "smooth": True, - "data": portfolio_values, - "lineStyle": {"color": "#0ea5e9", "width": 2}, - "symbol": "none", - }, ], } @@ -747,8 +808,13 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100") - ui.echart(_chart_options(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" + # Portfolio value stacked bar chart (above candle chart) + ui.echart(_portfolio_chart_options(result)).classes( + "h-48 w-full rounded-t-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) + # Candlestick chart (double height) + ui.echart(_candle_chart_options(result)).classes( + "h-[48rem] w-full rounded-b-2xl border border-t-0 border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900" ) with ui.card().classes( @@ -1072,6 +1138,17 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100") + # Charts above table + if daily_path: + # Portfolio value stacked bar chart + ui.echart(_portfolio_chart_options_from_dict(result)).classes( + "h-48 w-full rounded-t-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) + # Candlestick chart (double height) + ui.echart(_candle_chart_options_from_dict(result)).classes( + "h-[48rem] w-full rounded-b-2xl border border-t-0 border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) + # Daily path table if daily_path: with ui.card().classes( @@ -1127,12 +1204,6 @@ 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()