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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user