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