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:
@@ -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())
|
||||||
|
|||||||
@@ -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 ""),
|
||||||
|
|||||||
174
tests/test_page_validation.py
Normal file
174
tests/test_page_validation.py
Normal 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
|
||||||
Reference in New Issue
Block a user