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
This commit is contained in:
@@ -115,8 +115,54 @@ def validate_numeric_inputs(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _chart_options(result: BacktestPageRunResult) -> dict:
|
def _portfolio_chart_options(result: BacktestPageRunResult) -> dict:
|
||||||
"""Create ECharts options for candlestick chart with portfolio value line."""
|
"""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]
|
template_result = result.run_result.template_results[0]
|
||||||
dates = [point.date.isoformat() for point in template_result.daily_path]
|
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
|
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": {
|
"tooltip": {
|
||||||
"trigger": "axis",
|
"trigger": "axis",
|
||||||
"axisPointer": {"type": "cross"},
|
"axisPointer": {"type": "cross"},
|
||||||
},
|
},
|
||||||
"legend": {"data": ["Price", "Portfolio Value"]},
|
"legend": {"data": ["Price"], "show": False},
|
||||||
"grid": {"right": "20%"},
|
"grid": {"left": "10%", "right": "10%", "top": "5%", "bottom": "20%"},
|
||||||
"xAxis": {"type": "category", "data": dates, "axisLabel": {"rotate": 45}},
|
"xAxis": {
|
||||||
"yAxis": [
|
"type": "category",
|
||||||
{
|
"data": dates,
|
||||||
|
"axisLabel": {"rotate": 45},
|
||||||
|
},
|
||||||
|
"yAxis": {
|
||||||
"type": "value",
|
"type": "value",
|
||||||
"name": "Price",
|
"name": "Price",
|
||||||
"position": "left",
|
"position": "left",
|
||||||
"scale": True,
|
"scale": True,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "value",
|
|
||||||
"name": "Portfolio Value",
|
|
||||||
"position": "right",
|
|
||||||
"scale": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"series": [
|
"series": [
|
||||||
{
|
{
|
||||||
"name": "Price",
|
"name": "Price",
|
||||||
@@ -168,20 +207,58 @@ def _chart_options(result: BacktestPageRunResult) -> dict:
|
|||||||
"borderColor0": "#ef4444",
|
"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",
|
"name": "Underlying",
|
||||||
"type": "line",
|
"type": "bar",
|
||||||
"yAxisIndex": 1,
|
"stack": "total",
|
||||||
"smooth": True,
|
"data": underlying_values,
|
||||||
"data": portfolio_values,
|
"itemStyle": {"color": "#64748b"},
|
||||||
"lineStyle": {"color": "#0ea5e9", "width": 2},
|
},
|
||||||
"symbol": "none",
|
{
|
||||||
|
"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."""
|
"""Create ECharts options from serialized job result dict."""
|
||||||
template_results = result.get("template_results", [])
|
template_results = result.get("template_results", [])
|
||||||
first_template = template_results[0] if template_results else {}
|
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
|
for dp in daily_path
|
||||||
]
|
]
|
||||||
|
|
||||||
# Portfolio value data
|
|
||||||
portfolio_values = [round(dp.get("net_portfolio_value", 0), 2) for dp in daily_path]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"trigger": "axis",
|
"trigger": "axis",
|
||||||
"axisPointer": {"type": "cross"},
|
"axisPointer": {"type": "cross"},
|
||||||
},
|
},
|
||||||
"legend": {"data": ["Price", "Portfolio Value"]},
|
"legend": {"data": ["Price"], "show": False},
|
||||||
"grid": {"right": "20%"},
|
"grid": {"left": "10%", "right": "10%", "top": "5%", "bottom": "20%"},
|
||||||
"xAxis": {"type": "category", "data": dates, "axisLabel": {"rotate": 45}},
|
"xAxis": {
|
||||||
"yAxis": [
|
"type": "category",
|
||||||
{
|
"data": dates,
|
||||||
|
"axisLabel": {"rotate": 45},
|
||||||
|
},
|
||||||
|
"yAxis": {
|
||||||
"type": "value",
|
"type": "value",
|
||||||
"name": "Price",
|
"name": "Price",
|
||||||
"position": "left",
|
"position": "left",
|
||||||
"scale": True,
|
"scale": True,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "value",
|
|
||||||
"name": "Portfolio Value",
|
|
||||||
"position": "right",
|
|
||||||
"scale": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"series": [
|
"series": [
|
||||||
{
|
{
|
||||||
"name": "Price",
|
"name": "Price",
|
||||||
@@ -237,15 +307,6 @@ def _chart_options_from_dict(result: dict) -> dict:
|
|||||||
"borderColor0": "#ef4444",
|
"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(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.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100")
|
||||||
|
|
||||||
ui.echart(_chart_options(result)).classes(
|
# Portfolio value stacked bar chart (above candle chart)
|
||||||
"h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
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(
|
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(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.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
|
# Daily path table
|
||||||
if daily_path:
|
if daily_path:
|
||||||
with ui.card().classes(
|
with ui.card().classes(
|
||||||
@@ -1127,12 +1204,6 @@ 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user