fix: address PR review issues for event comparison and backtests

Critical fixes:
- Add validate_and_calculate_units() helper with proper error handling
- Handle division by zero for entry_spot in refresh_preview() and render_report()
- Add server-side validation for initial_value > 0
- Add try/except for derive_entry_spot() to handle fixture source limitations

Important improvements:
- Add dynamic default dates with get_default_backtest_dates()
- Add validate_date_range_for_symbol() for symbol-specific date bounds
- Add SYMBOL_MIN_DATES validation for backtests
- Update date_range_hint based on selected symbol

Tests:
- Add test_page_validation.py with 21 tests for:
  - validate_and_calculate_units edge cases
  - validate_date_range_for_symbol bounds checking
  - get_default_backtest_dates dynamic generation
  - SYMBOL_MIN_DATES constant verification
This commit is contained in:
Bu5hm4nn
2026-03-29 18:45:29 +02:00
parent c2af363eef
commit f9ea7f0b67
3 changed files with 291 additions and 24 deletions

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import date, datetime from datetime import date, datetime, timedelta
from typing import Any from typing import Any
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -48,9 +48,36 @@ SYMBOL_MIN_DATES = {
"XAU": date(1970, 1, 1), # XAU index historical data "XAU": date(1970, 1, 1), # XAU index historical data
} }
# Reasonable default date range (2 years of data) def get_default_backtest_dates() -> tuple[date, date]:
DEFAULT_BACKTEST_START = "2022-01-03" # First trading day of 2022 """Get default backtest date range (last 2 years excluding current week)."""
DEFAULT_BACKTEST_END = "2023-12-29" # Last trading day of 2023 today = date.today()
# Find the most recent Friday that's at least a week old
days_since_friday = (today.weekday() - 4) % 7
if days_since_friday == 0 and today.weekday() != 4:
# Not Friday yet, go back to previous Friday
days_since_friday = 7
end = today - timedelta(days=days_since_friday)
start = end - timedelta(days=730) # ~2 years
return start, end
DEFAULT_BACKTEST_START = get_default_backtest_dates()[0].isoformat()
DEFAULT_BACKTEST_END = get_default_backtest_dates()[1].isoformat()
def validate_date_range_for_symbol(start_date: date, end_date: date, symbol: str) -> str | None:
"""Validate date range is within available data for symbol.
Returns error message if invalid, None if valid.
"""
min_date = SYMBOL_MIN_DATES.get(symbol)
if min_date and start_date < min_date:
return f"Start date must be on or after {min_date.strftime('%Y-%m-%d')} for {symbol} (data availability)."
if end_date > date.today():
return "End date cannot be in the future."
if start_date > end_date:
return "Start date must be before or equal to end date."
return None
def _chart_options(result: BacktestPageRunResult) -> dict: def _chart_options(result: BacktestPageRunResult) -> dict:
@@ -199,11 +226,17 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
default_start_price = 0.0 default_start_price = 0.0
# Derive entry spot from default date range # Derive entry spot from default date range
# Fall back to a reasonable default if fixture source doesn't support the date range
try:
default_entry_spot = service.derive_entry_spot( default_entry_spot = service.derive_entry_spot(
"GLD", "GLD",
date.fromisoformat(DEFAULT_BACKTEST_START), date.fromisoformat(DEFAULT_BACKTEST_START),
date.fromisoformat(DEFAULT_BACKTEST_END), date.fromisoformat(DEFAULT_BACKTEST_END),
) )
except ValueError:
# Fixture source may not support the default date range
# Fall back to a reasonable GLD price
default_entry_spot = 185.0
default_units = ( default_units = (
asset_quantity_from_workspace_config(config, entry_spot=default_entry_spot, symbol="GLD") asset_quantity_from_workspace_config(config, entry_spot=default_entry_spot, symbol="GLD")
if config is not None and default_entry_spot > 0 if config is not None and default_entry_spot > 0
@@ -311,6 +344,9 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
f"GLD data available from {SYMBOL_MIN_DATES['GLD'].strftime('%Y-%m-%d')} (ETF launch)" f"GLD data available from {SYMBOL_MIN_DATES['GLD'].strftime('%Y-%m-%d')} (ETF launch)"
).classes("text-xs text-slate-500 dark:text-slate-400") ).classes("text-xs text-slate-500 dark:text-slate-400")
# Note: date_range_hint will be updated when symbol changes via on_value_change
# The get_symbol_from_dataset function is defined later and referenced in the callback
start_price_input = ( start_price_input = (
ui.number("Start price (0 = auto-derive)", value=default_start_price, min=0, step=0.01) ui.number("Start price (0 = auto-derive)", value=default_start_price, min=0, step=0.01)
.classes("w-full") .classes("w-full")
@@ -380,6 +416,17 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
return "GC" return "GC"
return "GLD" # Default for XNAS.BASIC return "GLD" # Default for XNAS.BASIC
def update_date_range_hint() -> None:
"""Update the date range hint based on selected symbol."""
symbol = get_symbol_from_dataset()
min_date = SYMBOL_MIN_DATES.get(symbol)
if min_date:
date_range_hint.set_text(
f"{symbol} data available from {min_date.strftime('%Y-%m-%d')}"
)
else:
date_range_hint.set_text(f"{symbol} data availability unknown")
def update_cost_estimate() -> None: def update_cost_estimate() -> None:
"""Update cost estimate display based on current settings.""" """Update cost estimate display based on current settings."""
current_data_source = str(data_source_select.value) current_data_source = str(data_source_select.value)
@@ -607,9 +654,17 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
).classes("w-full") ).classes("w-full")
def validate_current_scenario(*, entry_spot: float | None = None) -> str | None: def validate_current_scenario(*, entry_spot: float | None = None) -> str | None:
# Validate date range against symbol data availability
start_date = parse_iso_date(start_input.value, "Start date")
end_date = parse_iso_date(end_input.value, "End date")
symbol = get_symbol_from_dataset()
date_range_error = validate_date_range_for_symbol(start_date, end_date, symbol)
if date_range_error:
return date_range_error
try: try:
service.validate_preview_inputs( service.validate_preview_inputs(
symbol=get_symbol_from_dataset(), symbol=symbol,
start_date=parse_iso_date(start_input.value, "Start date"), start_date=parse_iso_date(start_input.value, "Start date"),
end_date=parse_iso_date(end_input.value, "End date"), end_date=parse_iso_date(end_input.value, "End date"),
template_slug=str(template_select.value or ""), template_slug=str(template_select.value or ""),
@@ -716,13 +771,23 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
def run_backtest() -> None: def run_backtest() -> None:
validation_label.set_text("") validation_label.set_text("")
try: try:
# Validate date range for symbol
start_date = parse_iso_date(start_input.value, "Start date")
end_date = parse_iso_date(end_input.value, "End date")
symbol = get_symbol_from_dataset()
date_range_error = validate_date_range_for_symbol(start_date, end_date, symbol)
if date_range_error:
validation_label.set_text(date_range_error)
render_result_state("Scenario validation failed", date_range_error, tone="warning")
return
# Save settings before running # Save settings before running
save_backtest_settings() save_backtest_settings()
result = service.run_read_only_scenario( result = service.run_read_only_scenario(
symbol=get_symbol_from_dataset(), symbol=symbol,
start_date=parse_iso_date(start_input.value, "Start date"), start_date=start_date,
end_date=parse_iso_date(end_input.value, "End date"), end_date=end_date,
template_slug=str(template_select.value or ""), template_slug=str(template_select.value or ""),
underlying_units=float(units_input.value or 0.0), underlying_units=float(units_input.value or 0.0),
loan_amount=float(loan_input.value or 0.0), loan_amount=float(loan_input.value or 0.0),
@@ -765,7 +830,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
data_source_select.on_value_change(lambda e: on_form_change()) data_source_select.on_value_change(lambda e: on_form_change())
dataset_select.on_value_change(lambda e: on_form_change()) dataset_select.on_value_change(lambda e: on_form_change())
schema_select.on_value_change(lambda e: on_form_change()) schema_select.on_value_change(lambda e: on_form_change())
symbol_select.on_value_change(lambda e: on_form_change()) symbol_select.on_value_change(lambda e: (update_date_range_hint(), on_form_change()))
start_input.on_value_change(lambda e: refresh_workspace_seeded_units()) start_input.on_value_change(lambda e: refresh_workspace_seeded_units())
end_input.on_value_change(lambda e: refresh_workspace_seeded_units()) end_input.on_value_change(lambda e: refresh_workspace_seeded_units())
start_price_input.on_value_change(lambda e: on_form_change()) start_price_input.on_value_change(lambda e: on_form_change())

View File

@@ -13,6 +13,18 @@ from app.services.event_comparison_ui import EventComparisonPageService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def validate_and_calculate_units(initial_value: float, entry_spot: float) -> tuple[float, str | None]:
"""Validate inputs and calculate underlying units.
Returns (units, error_message). If error_message is not None, units is 0.0.
"""
if initial_value <= 0:
return 0.0, "Initial portfolio value must be positive."
if entry_spot <= 0:
return 0.0, "Cannot calculate units: entry spot is invalid. Please select a valid preset."
return initial_value / entry_spot, None
def _chart_options(dates: tuple[str, ...], series: tuple[dict[str, object], ...]) -> dict: def _chart_options(dates: tuple[str, ...], series: tuple[dict[str, object], ...]) -> dict:
return { return {
"tooltip": {"trigger": "axis"}, "tooltip": {"trigger": "axis"},
@@ -143,10 +155,13 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
selected_summary.clear() selected_summary.clear()
with selected_summary: with selected_summary:
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
# Calculate underlying units from initial value and entry spot # Calculate underlying units with validation
computed_units = 0.0 initial_value = float(initial_value_input.value or 0.0)
if entry_spot is not None and entry_spot > 0: computed_units, units_error = (
computed_units = float(initial_value_input.value or 0.0) / entry_spot validate_and_calculate_units(initial_value, entry_spot)
if entry_spot is not None
else (0.0, "Entry spot unavailable.")
)
with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
cards = [ cards = [
( (
@@ -165,8 +180,11 @@ def _render_event_comparison_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-xl font-bold text-slate-900 dark:text-slate-100") ui.label(value).classes("text-xl font-bold text-slate-900 dark:text-slate-100")
if entry_spot_error: # Show validation errors (units_error takes priority, then entry_spot_error)
ui.label(entry_spot_error).classes("text-sm text-amber-700 dark:text-amber-300") display_error = units_error or entry_spot_error
if display_error:
tone_class = "text-rose-600 dark:text-rose-300" if "must be positive" in display_error else "text-amber-700 dark:text-amber-300"
ui.label(display_error).classes(f"text-sm {tone_class}")
def render_result_state(title: str, message: str, *, tone: str = "info") -> None: def render_result_state(title: str, message: str, *, tone: str = "info") -> None:
tone_classes = { tone_classes = {
@@ -218,8 +236,13 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
preset_slug=str(option["slug"]), preset_slug=str(option["slug"]),
template_slugs=template_slugs, template_slugs=template_slugs,
) )
# Calculate underlying units from initial value and entry spot # Validate and calculate underlying units
preview_units = preview_initial_value / preview_entry_spot if preview_entry_spot > 0 else 0.0 preview_units, units_error = validate_and_calculate_units(preview_initial_value, preview_entry_spot)
if units_error:
metadata_label.set_text(f"Preset: {option['label']}{option['description']}")
scenario_label.set_text(units_error)
render_selected_summary(entry_spot=preview_entry_spot, entry_spot_error=units_error)
return units_error
if workspace_id and config is not None and reseed_units: if workspace_id and config is not None and reseed_units:
# Recalculate from workspace config # Recalculate from workspace config
@@ -282,7 +305,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
result_panel.clear() result_panel.clear()
template_slugs = selected_template_slugs() template_slugs = selected_template_slugs()
try: try:
# Get initial portfolio value and calculate underlying units # Get initial portfolio value and calculate underlying units with validation
initial_value = float(initial_value_input.value or 0.0) initial_value = float(initial_value_input.value or 0.0)
# Get entry spot from preview # Get entry spot from preview
option = preset_lookup.get(str(preset_select.value or "")) option = preset_lookup.get(str(preset_select.value or ""))
@@ -293,7 +316,12 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
preset_slug=str(option["slug"]), preset_slug=str(option["slug"]),
template_slugs=template_slugs, template_slugs=template_slugs,
) )
underlying_units = initial_value / entry_spot if entry_spot > 0 else 0.0 # Validate and calculate underlying units
underlying_units, units_error = validate_and_calculate_units(initial_value, entry_spot)
if units_error:
validation_label.set_text(units_error)
render_result_state("Input validation failed", units_error, tone="warning")
return
report = service.run_read_only_comparison( report = service.run_read_only_comparison(
preset_slug=str(preset_select.value or ""), preset_slug=str(preset_select.value or ""),

View File

@@ -0,0 +1,174 @@
"""Tests for page validation functions and constants."""
from __future__ import annotations
from datetime import date, timedelta
from app.pages.backtests import (
SYMBOL_MIN_DATES,
get_default_backtest_dates,
validate_date_range_for_symbol,
)
from app.pages.event_comparison import validate_and_calculate_units
class TestValidateAndCalculateUnits:
"""Tests for validate_and_calculate_units from event_comparison.py."""
def test_positive_values_returns_correct_units(self) -> None:
"""Test positive initial_value and entry_spot returns correct units."""
units, error = validate_and_calculate_units(1000.0, 50.0)
assert error is None
assert units == 20.0
def test_large_values_returns_correct_units(self) -> None:
"""Test large portfolio values calculate correctly."""
units, error = validate_and_calculate_units(1_000_000.0, 100.0)
assert error is None
assert units == 10_000.0
def test_decimal_values_returns_correct_units(self) -> None:
"""Test decimal inputs calculate correctly."""
units, error = validate_and_calculate_units(1500.0, 3.0)
assert error is None
assert units == 500.0
def test_zero_initial_value_returns_error(self) -> None:
"""Test zero initial_value returns error message."""
units, error = validate_and_calculate_units(0.0, 100.0)
assert error is not None
assert "positive" in error.lower()
assert units == 0.0
def test_negative_initial_value_returns_error(self) -> None:
"""Test negative initial_value returns error message."""
units, error = validate_and_calculate_units(-1000.0, 100.0)
assert error is not None
assert "positive" in error.lower()
assert units == 0.0
def test_zero_entry_spot_returns_error(self) -> None:
"""Test zero entry_spot returns error message."""
units, error = validate_and_calculate_units(1000.0, 0.0)
assert error is not None
assert "entry spot" in error.lower()
assert units == 0.0
def test_negative_entry_spot_returns_error(self) -> None:
"""Test negative entry_spot returns error message."""
units, error = validate_and_calculate_units(1000.0, -100.0)
assert error is not None
assert "entry spot" in error.lower()
assert units == 0.0
class TestValidateDateRangeForSymbol:
"""Tests for validate_date_range_for_symbol from backtests.py."""
def test_valid_date_range_returns_none(self) -> None:
"""Test valid date range returns None (no error)."""
start = date(2020, 1, 1)
end = date(2022, 1, 1)
error = validate_date_range_for_symbol(start, end, "GLD")
assert error is None
def test_start_date_before_symbol_min_returns_error(self) -> None:
"""Test start_date before SYMBOL_MIN_DATES returns error."""
# GLD min date is 2004-11-18
start = date(2000, 1, 1)
end = date(2002, 1, 1)
error = validate_date_range_for_symbol(start, end, "GLD")
assert error is not None
assert "2004-11-18" in error
assert "GLD" in error
def test_end_date_in_future_returns_error(self) -> None:
"""Test end_date in the future returns error."""
today = date.today()
start = today - timedelta(days=30)
end = today + timedelta(days=30)
error = validate_date_range_for_symbol(start, end, "GLD")
assert error is not None
assert "future" in error.lower()
def test_start_after_end_returns_error(self) -> None:
"""Test start_date > end_date returns error."""
start = date(2022, 1, 1)
end = date(2020, 1, 1)
error = validate_date_range_for_symbol(start, end, "GLD")
assert error is not None
assert "before" in error.lower() or "equal to" in error.lower()
def test_unknown_symbol_returns_none(self) -> None:
"""Test unknown symbol returns None (no validation)."""
start = date(2020, 1, 1)
end = date(2022, 1, 1)
error = validate_date_range_for_symbol(start, end, "UNKNOWN_SYMBOL")
assert error is None
def test_gc_symbol_with_valid_range(self) -> None:
"""Test GC (futures) with valid historical range."""
# GC has min date of 1974-01-01
start = date(1980, 1, 1)
end = date(2020, 1, 1)
error = validate_date_range_for_symbol(start, end, "GC")
assert error is None
def test_xau_symbol_with_valid_range(self) -> None:
"""Test XAU with valid historical range."""
# XAU has min date of 1970-01-01
start = date(1980, 1, 1)
end = date(2020, 1, 1)
error = validate_date_range_for_symbol(start, end, "XAU")
assert error is None
class TestGetDefaultBacktestDates:
"""Tests for get_default_backtest_dates from backtests.py."""
def test_start_before_end(self) -> None:
"""Test that start date is before end date."""
start, end = get_default_backtest_dates()
assert start < end
def test_dates_approximately_two_years_apart(self) -> None:
"""Test that dates are approximately 2 years apart."""
start, end = get_default_backtest_dates()
delta = end - start
# Should be approximately 730 days (2 years), allow small variance
assert 700 <= delta.days <= 760, f"Delta is {delta.days} days"
def test_end_not_in_future(self) -> None:
"""Test that end date is not in the future."""
start, end = get_default_backtest_dates()
today = date.today()
assert end <= today
def test_end_is_friday_or_before(self) -> None:
"""Test that end date is a Friday or earlier in the week."""
start, end = get_default_backtest_dates()
# End should be on or before the most recent Friday at least a week old
# (implementation allows for flexible Friday calculation)
assert end.weekday() == 4 or end < date.today() # Friday == 4
class TestSymbolMinDates:
"""Tests for SYMBOL_MIN_DATES constant values."""
def test_gld_is_2004_11_18(self) -> None:
"""Test GLD min date is November 18, 2004 (ETF launch)."""
assert SYMBOL_MIN_DATES["GLD"] == date(2004, 11, 18)
def test_all_symbols_have_valid_date_objects(self) -> None:
"""Test all symbols in SYMBOL_MIN_DATES have valid date objects."""
for symbol, min_date in SYMBOL_MIN_DATES.items():
assert isinstance(min_date, date), f"{symbol} has invalid date type: {type(min_date)}"
assert min_date.year >= 1900, f"{symbol} has suspicious date: {min_date}"
def test_symbol_min_dates_are_chronological(self) -> None:
"""Test that min dates are reasonable for each symbol type."""
# GLD (ETF launched 2004)
assert SYMBOL_MIN_DATES["GLD"].year == 2004
# GC (futures have longer history)
assert SYMBOL_MIN_DATES["GC"].year <= 1980
# XAU (index historical)
assert SYMBOL_MIN_DATES["XAU"].year <= 1980