feat(BT-001A): add backtest scenario runner page
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
from . import hedge, options, overview, settings
|
from . import backtests, hedge, options, overview, settings
|
||||||
|
|
||||||
__all__ = ["overview", "hedge", "options", "settings"]
|
__all__ = ["overview", "hedge", "options", "backtests", "settings"]
|
||||||
|
|||||||
186
app/pages/backtests.py
Normal file
186
app/pages/backtests.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from app.pages.common import dashboard_page
|
||||||
|
from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_options(result: BacktestPageRunResult) -> dict:
|
||||||
|
template_result = result.run_result.template_results[0]
|
||||||
|
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]},
|
||||||
|
"yAxis": [
|
||||||
|
{"type": "value", "name": "Spot"},
|
||||||
|
{"type": "value", "name": "LTV", "min": 0, "max": 1, "axisLabel": {"formatter": "{value}"}},
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "Spot",
|
||||||
|
"type": "line",
|
||||||
|
"smooth": True,
|
||||||
|
"data": [round(point.spot_close, 2) for point in template_result.daily_path],
|
||||||
|
"lineStyle": {"color": "#0ea5e9"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LTV hedged",
|
||||||
|
"type": "line",
|
||||||
|
"yAxisIndex": 1,
|
||||||
|
"smooth": True,
|
||||||
|
"data": [round(point.ltv_hedged, 4) for point in template_result.daily_path],
|
||||||
|
"lineStyle": {"color": "#22c55e"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LTV unhedged",
|
||||||
|
"type": "line",
|
||||||
|
"yAxisIndex": 1,
|
||||||
|
"smooth": True,
|
||||||
|
"data": [round(point.ltv_unhedged, 4) for point in template_result.daily_path],
|
||||||
|
"lineStyle": {"color": "#ef4444"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ui.page("/backtests")
|
||||||
|
def backtests_page() -> None:
|
||||||
|
service = BacktestPageService()
|
||||||
|
template_options = service.template_options("GLD")
|
||||||
|
select_options = {str(option["slug"]): str(option["label"]) for option in template_options}
|
||||||
|
default_template_slug = str(template_options[0]["slug"]) if template_options else None
|
||||||
|
|
||||||
|
with dashboard_page(
|
||||||
|
"Backtests",
|
||||||
|
"Run a thin read-only historical scenario over the BT-001 synthetic engine and inspect the daily path.",
|
||||||
|
"backtests",
|
||||||
|
):
|
||||||
|
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
|
||||||
|
with ui.card().classes(
|
||||||
|
"w-full max-w-xl rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
):
|
||||||
|
ui.label("Scenario Form").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
||||||
|
ui.label(
|
||||||
|
"Entry spot is auto-derived from the first historical close in the selected window so the scenario stays consistent with BT-001 entry timing."
|
||||||
|
).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||||
|
ui.label("BT-001A currently supports GLD only for this thin read-only page.").classes(
|
||||||
|
"text-sm text-slate-500 dark:text-slate-400"
|
||||||
|
)
|
||||||
|
symbol_input = ui.input("Symbol", value="GLD").props("readonly").classes("w-full")
|
||||||
|
start_input = ui.input("Start date", value="2024-01-02").classes("w-full")
|
||||||
|
end_input = ui.input("End date", value="2024-01-08").classes("w-full")
|
||||||
|
template_select = ui.select(select_options, value=default_template_slug, label="Template").classes(
|
||||||
|
"w-full"
|
||||||
|
)
|
||||||
|
units_input = ui.number("Underlying units", value=1000.0, min=0.0001, step=1).classes("w-full")
|
||||||
|
loan_input = ui.number("Loan amount", value=68000.0, min=0, step=1000).classes("w-full")
|
||||||
|
ltv_input = ui.number("Margin call LTV", value=0.75, min=0.01, max=0.99, step=0.01).classes("w-full")
|
||||||
|
entry_spot_hint = ui.label("Entry spot will be auto-derived on run.").classes(
|
||||||
|
"text-sm text-slate-500 dark:text-slate-400"
|
||||||
|
)
|
||||||
|
validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300")
|
||||||
|
run_button = ui.button("Run backtest").props("color=primary")
|
||||||
|
|
||||||
|
result_panel = ui.column().classes("w-full gap-6")
|
||||||
|
|
||||||
|
def parse_iso_date(raw: object, field_name: str) -> date:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(raw), "%Y-%m-%d").date()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from exc
|
||||||
|
|
||||||
|
def render_result(result: BacktestPageRunResult) -> None:
|
||||||
|
result_panel.clear()
|
||||||
|
template_result = result.run_result.template_results[0]
|
||||||
|
summary = template_result.summary_metrics
|
||||||
|
entry_spot_hint.set_text(f"Auto-derived entry spot: ${result.entry_spot:,.2f}")
|
||||||
|
with result_panel:
|
||||||
|
with ui.card().classes(
|
||||||
|
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
):
|
||||||
|
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
||||||
|
ui.label(f"Template: {template_result.template_name}").classes(
|
||||||
|
"text-sm text-slate-500 dark:text-slate-400"
|
||||||
|
)
|
||||||
|
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
|
||||||
|
cards = [
|
||||||
|
("Start value", f"${summary.start_value:,.0f}"),
|
||||||
|
("End value hedged", f"${summary.end_value_hedged_net:,.0f}"),
|
||||||
|
("Max LTV hedged", f"{summary.max_ltv_hedged:.1%}"),
|
||||||
|
("Hedge cost", f"${summary.total_hedge_cost:,.0f}"),
|
||||||
|
("Margin call days hedged", str(summary.margin_call_days_hedged)),
|
||||||
|
("Margin call days unhedged", str(summary.margin_call_days_unhedged)),
|
||||||
|
(
|
||||||
|
"Hedged survived",
|
||||||
|
"Yes" if not summary.margin_threshold_breached_hedged else "No",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Unhedged breached",
|
||||||
|
"Yes" if summary.margin_threshold_breached_unhedged else "No",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for label, value in cards:
|
||||||
|
with ui.card().classes(
|
||||||
|
"rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
|
||||||
|
):
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.card().classes(
|
||||||
|
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
):
|
||||||
|
ui.label("Daily Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
||||||
|
ui.table(
|
||||||
|
columns=[
|
||||||
|
{"name": "date", "label": "Date", "field": "date", "align": "left"},
|
||||||
|
{"name": "spot_close", "label": "Spot", "field": "spot_close", "align": "right"},
|
||||||
|
{"name": "net_portfolio_value", "label": "Net hedged", "field": "net_portfolio_value", "align": "right"},
|
||||||
|
{"name": "ltv_unhedged", "label": "LTV unhedged", "field": "ltv_unhedged", "align": "right"},
|
||||||
|
{"name": "ltv_hedged", "label": "LTV hedged", "field": "ltv_hedged", "align": "right"},
|
||||||
|
{"name": "margin_call_hedged", "label": "Hedged breach", "field": "margin_call_hedged", "align": "center"},
|
||||||
|
],
|
||||||
|
rows=[
|
||||||
|
{
|
||||||
|
"date": point.date.isoformat(),
|
||||||
|
"spot_close": f"${point.spot_close:,.2f}",
|
||||||
|
"net_portfolio_value": f"${point.net_portfolio_value:,.0f}",
|
||||||
|
"ltv_unhedged": f"{point.ltv_unhedged:.1%}",
|
||||||
|
"ltv_hedged": f"{point.ltv_hedged:.1%}",
|
||||||
|
"margin_call_hedged": "Yes" if point.margin_call_hedged else "No",
|
||||||
|
}
|
||||||
|
for point in template_result.daily_path
|
||||||
|
],
|
||||||
|
row_key="date",
|
||||||
|
).classes("w-full")
|
||||||
|
|
||||||
|
def run_backtest() -> None:
|
||||||
|
validation_label.set_text("")
|
||||||
|
try:
|
||||||
|
result = service.run_read_only_scenario(
|
||||||
|
symbol=str(symbol_input.value or ""),
|
||||||
|
start_date=parse_iso_date(start_input.value, "Start date"),
|
||||||
|
end_date=parse_iso_date(end_input.value, "End date"),
|
||||||
|
template_slug=str(template_select.value or ""),
|
||||||
|
underlying_units=float(units_input.value or 0.0),
|
||||||
|
loan_amount=float(loan_input.value or 0.0),
|
||||||
|
margin_call_ltv=float(ltv_input.value or 0.0),
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError) as exc:
|
||||||
|
result_panel.clear()
|
||||||
|
validation_label.set_text(str(exc))
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
result_panel.clear()
|
||||||
|
validation_label.set_text("Backtest failed. Please verify the scenario inputs and try again.")
|
||||||
|
return
|
||||||
|
render_result(result)
|
||||||
|
|
||||||
|
run_button.on_click(lambda: run_backtest())
|
||||||
|
run_backtest()
|
||||||
@@ -12,6 +12,7 @@ NAV_ITEMS: list[tuple[str, str, str]] = [
|
|||||||
("overview", "/", "Overview"),
|
("overview", "/", "Overview"),
|
||||||
("hedge", "/hedge", "Hedge Analysis"),
|
("hedge", "/hedge", "Hedge Analysis"),
|
||||||
("options", "/options", "Options Chain"),
|
("options", "/options", "Options Chain"),
|
||||||
|
("backtests", "/backtests", "Backtests"),
|
||||||
("settings", "/settings", "Settings"),
|
("settings", "/settings", "Settings"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
141
app/services/backtesting/ui_service.py
Normal file
141
app/services/backtesting/ui_service.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.models.backtest import (
|
||||||
|
BacktestPortfolioState,
|
||||||
|
BacktestRunResult,
|
||||||
|
BacktestScenario,
|
||||||
|
ProviderRef,
|
||||||
|
TemplateRef,
|
||||||
|
)
|
||||||
|
from app.services.backtesting.historical_provider import (
|
||||||
|
DailyClosePoint,
|
||||||
|
HistoricalPriceSource,
|
||||||
|
YFinanceHistoricalPriceSource,
|
||||||
|
)
|
||||||
|
from app.services.backtesting.service import BacktestService
|
||||||
|
from app.services.strategy_templates import StrategyTemplateService
|
||||||
|
|
||||||
|
SUPPORTED_BACKTEST_PAGE_SYMBOL = "GLD"
|
||||||
|
|
||||||
|
DETERMINISTIC_UI_FIXTURE_HISTORY: tuple[DailyClosePoint, ...] = (
|
||||||
|
DailyClosePoint(date=date(2024, 1, 2), close=100.0),
|
||||||
|
DailyClosePoint(date=date(2024, 1, 3), close=96.0),
|
||||||
|
DailyClosePoint(date=date(2024, 1, 4), close=92.0),
|
||||||
|
DailyClosePoint(date=date(2024, 1, 5), close=88.0),
|
||||||
|
DailyClosePoint(date=date(2024, 1, 8), close=85.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureFallbackHistoricalPriceSource:
|
||||||
|
def __init__(self, fallback: HistoricalPriceSource | None = None) -> None:
|
||||||
|
self.fallback = fallback or YFinanceHistoricalPriceSource()
|
||||||
|
|
||||||
|
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||||
|
normalized_symbol = symbol.strip().upper()
|
||||||
|
if (
|
||||||
|
normalized_symbol == SUPPORTED_BACKTEST_PAGE_SYMBOL
|
||||||
|
and start_date == date(2024, 1, 2)
|
||||||
|
and end_date == date(2024, 1, 8)
|
||||||
|
):
|
||||||
|
return list(DETERMINISTIC_UI_FIXTURE_HISTORY)
|
||||||
|
return self.fallback.load_daily_closes(normalized_symbol, start_date, end_date)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BacktestPageRunResult:
|
||||||
|
scenario: BacktestScenario
|
||||||
|
run_result: BacktestRunResult
|
||||||
|
entry_spot: float
|
||||||
|
|
||||||
|
|
||||||
|
class BacktestPageService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
backtest_service: BacktestService | None = None,
|
||||||
|
template_service: StrategyTemplateService | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.template_service = template_service or StrategyTemplateService()
|
||||||
|
self.backtest_service = backtest_service or BacktestService(
|
||||||
|
template_service=self.template_service,
|
||||||
|
provider=None,
|
||||||
|
)
|
||||||
|
if backtest_service is None:
|
||||||
|
provider = self.backtest_service.provider
|
||||||
|
provider.source = FixtureFallbackHistoricalPriceSource(provider.source)
|
||||||
|
|
||||||
|
def template_options(self, symbol: str = "GLD") -> list[dict[str, str | int]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"label": template.display_name,
|
||||||
|
"slug": template.slug,
|
||||||
|
"version": template.version,
|
||||||
|
"description": template.description,
|
||||||
|
}
|
||||||
|
for template in self.template_service.list_active_templates(symbol)
|
||||||
|
]
|
||||||
|
|
||||||
|
def derive_entry_spot(self, symbol: str, start_date: date, end_date: date) -> float:
|
||||||
|
history = self.backtest_service.provider.load_history(symbol.strip().upper(), start_date, end_date)
|
||||||
|
if not history:
|
||||||
|
raise ValueError("No historical prices found for scenario window")
|
||||||
|
if history[0].date != start_date:
|
||||||
|
raise ValueError(
|
||||||
|
"Scenario start date must match the first available historical close for entry-at-start backtests"
|
||||||
|
)
|
||||||
|
return history[0].close
|
||||||
|
|
||||||
|
def run_read_only_scenario(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
template_slug: str,
|
||||||
|
underlying_units: float,
|
||||||
|
loan_amount: float,
|
||||||
|
margin_call_ltv: float,
|
||||||
|
) -> BacktestPageRunResult:
|
||||||
|
normalized_symbol = symbol.strip().upper()
|
||||||
|
if not normalized_symbol:
|
||||||
|
raise ValueError("Symbol is required")
|
||||||
|
if normalized_symbol != SUPPORTED_BACKTEST_PAGE_SYMBOL:
|
||||||
|
raise ValueError("BT-001A backtests are currently limited to GLD on this page")
|
||||||
|
if start_date > end_date:
|
||||||
|
raise ValueError("Start date must be on or before end date")
|
||||||
|
if underlying_units <= 0:
|
||||||
|
raise ValueError("Underlying units must be positive")
|
||||||
|
if loan_amount < 0:
|
||||||
|
raise ValueError("Loan amount must be non-negative")
|
||||||
|
if not 0 < margin_call_ltv < 1:
|
||||||
|
raise ValueError("Margin call LTV must be between 0 and 1")
|
||||||
|
if not template_slug:
|
||||||
|
raise ValueError("Template selection is required")
|
||||||
|
|
||||||
|
template = self.template_service.get_template(template_slug)
|
||||||
|
entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date)
|
||||||
|
scenario = BacktestScenario(
|
||||||
|
scenario_id=(
|
||||||
|
f"{normalized_symbol.lower()}-{start_date.isoformat()}-{end_date.isoformat()}-{template.slug}"
|
||||||
|
),
|
||||||
|
display_name=f"{normalized_symbol} backtest {start_date.isoformat()} → {end_date.isoformat()}",
|
||||||
|
symbol=normalized_symbol,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
initial_portfolio=BacktestPortfolioState(
|
||||||
|
currency="USD",
|
||||||
|
underlying_units=underlying_units,
|
||||||
|
entry_spot=entry_spot,
|
||||||
|
loan_amount=loan_amount,
|
||||||
|
margin_call_ltv=margin_call_ltv,
|
||||||
|
),
|
||||||
|
template_refs=(TemplateRef(slug=template.slug, version=template.version),),
|
||||||
|
provider_ref=ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"),
|
||||||
|
)
|
||||||
|
return BacktestPageRunResult(
|
||||||
|
scenario=scenario,
|
||||||
|
run_result=self.backtest_service.run_scenario(scenario),
|
||||||
|
entry_spot=entry_spot,
|
||||||
|
)
|
||||||
114
tests/test_backtest_ui.py
Normal file
114
tests/test_backtest_ui.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.backtesting.ui_service import BacktestPageService
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_page_service_uses_fixture_window_for_deterministic_run() -> None:
|
||||||
|
service = BacktestPageService()
|
||||||
|
|
||||||
|
entry_spot = service.derive_entry_spot("GLD", date(2024, 1, 2), date(2024, 1, 8))
|
||||||
|
result = service.run_read_only_scenario(
|
||||||
|
symbol="GLD",
|
||||||
|
start_date=date(2024, 1, 2),
|
||||||
|
end_date=date(2024, 1, 8),
|
||||||
|
template_slug="protective-put-atm-12m",
|
||||||
|
underlying_units=1000.0,
|
||||||
|
loan_amount=68000.0,
|
||||||
|
margin_call_ltv=0.75,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entry_spot == 100.0
|
||||||
|
assert result.scenario.initial_portfolio.entry_spot == 100.0
|
||||||
|
assert result.run_result.template_results[0].summary_metrics.margin_threshold_breached_unhedged is True
|
||||||
|
assert result.run_result.template_results[0].summary_metrics.margin_threshold_breached_hedged is False
|
||||||
|
assert [point.date.isoformat() for point in result.run_result.template_results[0].daily_path] == [
|
||||||
|
"2024-01-02",
|
||||||
|
"2024-01-03",
|
||||||
|
"2024-01-04",
|
||||||
|
"2024-01-05",
|
||||||
|
"2024-01-08",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_non_default_template_slug_runs_successfully() -> None:
|
||||||
|
service = BacktestPageService()
|
||||||
|
|
||||||
|
options = service.template_options("GLD")
|
||||||
|
non_default_slug = str(options[1]["slug"])
|
||||||
|
|
||||||
|
result = service.run_read_only_scenario(
|
||||||
|
symbol="GLD",
|
||||||
|
start_date=date(2024, 1, 2),
|
||||||
|
end_date=date(2024, 1, 8),
|
||||||
|
template_slug=non_default_slug,
|
||||||
|
underlying_units=1000.0,
|
||||||
|
loan_amount=68000.0,
|
||||||
|
margin_call_ltv=0.75,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario.template_refs[0].slug == non_default_slug
|
||||||
|
assert result.run_result.template_results[0].template_slug == non_default_slug
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "message"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"symbol": "",
|
||||||
|
"start_date": date(2024, 1, 2),
|
||||||
|
"end_date": date(2024, 1, 8),
|
||||||
|
"template_slug": "protective-put-atm-12m",
|
||||||
|
"underlying_units": 1000.0,
|
||||||
|
"loan_amount": 68000.0,
|
||||||
|
"margin_call_ltv": 0.75,
|
||||||
|
},
|
||||||
|
"Symbol is required",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"symbol": "GLD",
|
||||||
|
"start_date": date(2024, 1, 8),
|
||||||
|
"end_date": date(2024, 1, 2),
|
||||||
|
"template_slug": "protective-put-atm-12m",
|
||||||
|
"underlying_units": 1000.0,
|
||||||
|
"loan_amount": 68000.0,
|
||||||
|
"margin_call_ltv": 0.75,
|
||||||
|
},
|
||||||
|
"Start date must be on or before end date",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"symbol": "GLD",
|
||||||
|
"start_date": date(2024, 1, 2),
|
||||||
|
"end_date": date(2024, 1, 8),
|
||||||
|
"template_slug": "protective-put-atm-12m",
|
||||||
|
"underlying_units": 0.0,
|
||||||
|
"loan_amount": 68000.0,
|
||||||
|
"margin_call_ltv": 0.75,
|
||||||
|
},
|
||||||
|
"Underlying units must be positive",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"symbol": "TLT",
|
||||||
|
"start_date": date(2024, 1, 2),
|
||||||
|
"end_date": date(2024, 1, 8),
|
||||||
|
"template_slug": "protective-put-atm-12m",
|
||||||
|
"underlying_units": 1000.0,
|
||||||
|
"loan_amount": 68000.0,
|
||||||
|
"margin_call_ltv": 0.75,
|
||||||
|
},
|
||||||
|
"BT-001A backtests are currently limited to GLD on this page",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_backtest_page_service_validation_errors_are_user_facing(kwargs: dict[str, object], message: str) -> None:
|
||||||
|
service = BacktestPageService()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
service.run_read_only_scenario(**kwargs)
|
||||||
@@ -14,7 +14,7 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
browser = p.chromium.launch(headless=True)
|
browser = p.chromium.launch(headless=True)
|
||||||
page = browser.new_page(viewport={"width": 1440, "height": 1000})
|
page = browser.new_page(viewport={"width": 1440, "height": 1000})
|
||||||
|
|
||||||
page.goto(BASE_URL, wait_until="networkidle", timeout=30000)
|
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
expect(page).to_have_title("NiceGUI")
|
expect(page).to_have_title("NiceGUI")
|
||||||
expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000)
|
expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000)
|
||||||
expect(page.locator("text=Overview").first).to_be_visible(timeout=10000)
|
expect(page.locator("text=Overview").first).to_be_visible(timeout=10000)
|
||||||
@@ -22,6 +22,28 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000)
|
expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000)
|
||||||
page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True)
|
page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True)
|
||||||
|
|
||||||
|
page.goto(f"{BASE_URL}/backtests", wait_until="networkidle", timeout=30000)
|
||||||
|
expect(page.locator("text=Backtests").first).to_be_visible(timeout=15000)
|
||||||
|
expect(page.locator("text=Scenario Form").first).to_be_visible(timeout=15000)
|
||||||
|
expect(page.locator("text=Scenario Summary").first).to_be_visible(timeout=15000)
|
||||||
|
expect(page.locator("text=Daily Results").first).to_be_visible(timeout=15000)
|
||||||
|
backtests_text = page.locator("body").inner_text(timeout=15000)
|
||||||
|
assert "Auto-derived entry spot: $100.00" in backtests_text
|
||||||
|
assert "RuntimeError" not in backtests_text
|
||||||
|
assert "Server error" not in backtests_text
|
||||||
|
assert "Traceback" not in backtests_text
|
||||||
|
page.screenshot(path=str(ARTIFACTS / "backtests.png"), full_page=True)
|
||||||
|
|
||||||
|
page.get_by_label("Template").click()
|
||||||
|
page.get_by_text("Protective Put 95%", exact=True).click()
|
||||||
|
page.get_by_role("button", name="Run backtest").click()
|
||||||
|
expect(page.locator("text=Scenario Summary").first).to_be_visible(timeout=15000)
|
||||||
|
expect(page.locator("text=Template: Protective Put 95%").first).to_be_visible(timeout=15000)
|
||||||
|
rerun_text = page.locator("body").inner_text(timeout=15000)
|
||||||
|
assert "Margin call days hedged" in rerun_text
|
||||||
|
assert "RuntimeError" not in rerun_text
|
||||||
|
assert "Server error" not in rerun_text
|
||||||
|
|
||||||
page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000)
|
page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000)
|
||||||
expect(page.locator("text=Options Chain").first).to_be_visible(timeout=15000)
|
expect(page.locator("text=Options Chain").first).to_be_visible(timeout=15000)
|
||||||
expect(page.locator("text=Filters").first).to_be_visible(timeout=15000)
|
expect(page.locator("text=Filters").first).to_be_visible(timeout=15000)
|
||||||
|
|||||||
Reference in New Issue
Block a user