323 lines
13 KiB
Python
323 lines
13 KiB
Python
"""Tests for page validation functions and constants."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
|
|
import pytest
|
|
|
|
from app.pages.backtests import (
|
|
SYMBOL_MIN_DATES,
|
|
get_default_backtest_dates,
|
|
validate_date_range_for_symbol,
|
|
validate_numeric_inputs,
|
|
)
|
|
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_cover_recent_completed_week(self) -> None:
|
|
# """Default window should be a completed Monday-Friday backtest week."""
|
|
# start, end = get_default_backtest_dates()
|
|
# assert start.weekday() == 0
|
|
# assert end.weekday() == 4
|
|
# delta = end - start
|
|
# assert delta.days == 4, f"Delta should be 4 days for Monday-Friday window, got {delta.days}"
|
|
|
|
def test_dates_are_fixed_march_2026(self) -> None:
|
|
"""Test that dates are fixed to March 2026 for testing."""
|
|
start, end = get_default_backtest_dates()
|
|
assert start == date(2026, 3, 2), f"Start should be 2026-03-02, got {start}"
|
|
assert end == date(2026, 3, 25), f"End should be 2026-03-25, got {end}"
|
|
delta = end - start
|
|
assert delta.days == 23, f"Delta should be 23 days, got {delta.days}"
|
|
|
|
def test_end_is_not_in_future(self) -> None:
|
|
"""Default end date should never point to a future trading day."""
|
|
_, end = get_default_backtest_dates()
|
|
assert end <= date.today()
|
|
|
|
def test_databento_defaults_respect_dataset_min_date(self) -> None:
|
|
"""Databento defaults should never predate dataset availability."""
|
|
start, end = get_default_backtest_dates(data_source="databento", dataset="XNAS.BASIC")
|
|
assert start >= date(2024, 7, 1)
|
|
assert end >= start
|
|
|
|
|
|
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
|
|
|
|
|
|
class TestValidateDateRangeForSymbolBoundaryCases:
|
|
"""Boundary tests for validate_date_range_for_symbol.
|
|
|
|
These tests verify edge cases like exact boundary dates and same-day ranges.
|
|
"""
|
|
|
|
def test_start_date_exactly_at_min_date_is_valid(self) -> None:
|
|
"""Test start_date exactly at SYMBOL_MIN_DATES passes validation."""
|
|
# GLD min date is 2004-11-18, use that exact date
|
|
start = SYMBOL_MIN_DATES["GLD"]
|
|
end = date(2006, 11, 18)
|
|
error = validate_date_range_for_symbol(start, end, "GLD")
|
|
assert error is None
|
|
|
|
def test_same_day_date_range_is_valid(self) -> None:
|
|
"""Test start_date == end_date is valid (single-day range)."""
|
|
# Use a valid date within GLD range
|
|
single_day = date(2020, 6, 15)
|
|
error = validate_date_range_for_symbol(single_day, single_day, "GLD")
|
|
assert error is None
|
|
|
|
def test_end_date_exactly_today_is_valid(self) -> None:
|
|
"""Test end_date == today passes validation."""
|
|
today = date.today()
|
|
start = today - timedelta(days=365)
|
|
error = validate_date_range_for_symbol(start, today, "GLD")
|
|
assert error is None
|
|
|
|
def test_end_date_tomorrow_is_invalid(self) -> None:
|
|
"""Test end_date == tomorrow + 1 is caught as future date."""
|
|
today = date.today()
|
|
start = today - timedelta(days=30)
|
|
end = today + timedelta(days=1)
|
|
error = validate_date_range_for_symbol(start, end, "GLD")
|
|
assert error is not None
|
|
assert "future" in error.lower()
|
|
|
|
def test_validation_order_returns_most_actionable_error(self) -> None:
|
|
"""Test that start > end is caught before symbol-specific bounds."""
|
|
# Inverted range that's also before GLD launch
|
|
start = date(2000, 1, 1) # Before GLD launch
|
|
end = date(1999, 1, 1) # Before start
|
|
error = validate_date_range_for_symbol(start, end, "GLD")
|
|
# Should get "start > end" error, not "before min date" error
|
|
assert error is not None
|
|
assert "before" in error.lower() or "equal to" in error.lower()
|
|
assert "future" not in error.lower() # Not the future date error
|
|
|
|
|
|
class TestValidateAndCalculateUnitsBoundaryCases:
|
|
"""Boundary tests for validate_and_calculate_units.
|
|
|
|
These tests verify edge cases like near-zero values and precision.
|
|
"""
|
|
|
|
def test_very_small_positive_values_calculate_correctly(self) -> None:
|
|
"""Test near-zero positive values don't underflow."""
|
|
units, error = validate_and_calculate_units(1e-10, 1.0)
|
|
assert error is None
|
|
assert units == pytest.approx(1e-10)
|
|
|
|
def test_small_dividend_large_divisor(self) -> None:
|
|
"""Test precision with very small quotient."""
|
|
units, error = validate_and_calculate_units(0.001, 1_000_000.0)
|
|
assert error is None
|
|
assert units == pytest.approx(1e-9)
|
|
|
|
def test_very_large_values_calculate_correctly(self) -> None:
|
|
"""Test large portfolio values don't cause issues."""
|
|
units, error = validate_and_calculate_units(1e15, 1.0)
|
|
assert error is None
|
|
assert units == pytest.approx(1e15)
|
|
|
|
def test_fractional_entry_spot(self) -> None:
|
|
"""Test fractional entry spot calculates correctly."""
|
|
units, error = validate_and_calculate_units(1000.0, 3.14159)
|
|
assert error is None
|
|
assert units == pytest.approx(318.31, abs=0.01)
|
|
|
|
|
|
class TestValidateNumericInputs:
|
|
"""Tests for validate_numeric_inputs from backtests.py."""
|
|
|
|
def test_valid_inputs_return_none(self) -> None:
|
|
"""Test valid inputs return None (no error)."""
|
|
error = validate_numeric_inputs(1000.0, 50000.0, 0.75)
|
|
assert error is None
|
|
|
|
def test_zero_units_returns_error(self) -> None:
|
|
"""Test zero units returns error."""
|
|
error = validate_numeric_inputs(0.0, 50000.0, 0.75)
|
|
assert error is not None
|
|
assert "positive" in error.lower()
|
|
|
|
def test_negative_units_returns_error(self) -> None:
|
|
"""Test negative units returns error."""
|
|
error = validate_numeric_inputs(-100.0, 50000.0, 0.75)
|
|
assert error is not None
|
|
assert "positive" in error.lower()
|
|
|
|
def test_negative_loan_returns_error(self) -> None:
|
|
"""Test negative loan amount returns error."""
|
|
error = validate_numeric_inputs(1000.0, -50000.0, 0.75)
|
|
assert error is not None
|
|
assert "negative" in error.lower()
|
|
|
|
def test_zero_loan_is_valid(self) -> None:
|
|
"""Test zero loan is valid (no loan scenario)."""
|
|
error = validate_numeric_inputs(1000.0, 0.0, 0.75)
|
|
assert error is None
|
|
|
|
def test_ltv_at_zero_is_invalid(self) -> None:
|
|
"""Test LTV at 0 returns error."""
|
|
error = validate_numeric_inputs(1000.0, 50000.0, 0.0)
|
|
assert error is not None
|
|
assert "ltv" in error.lower()
|
|
|
|
def test_ltv_at_one_is_invalid(self) -> None:
|
|
"""Test LTV at 1.0 returns error."""
|
|
error = validate_numeric_inputs(1000.0, 50000.0, 1.0)
|
|
assert error is not None
|
|
assert "ltv" in error.lower()
|
|
|
|
def test_ltv_above_one_is_invalid(self) -> None:
|
|
"""Test LTV above 1.0 returns error."""
|
|
error = validate_numeric_inputs(1000.0, 50000.0, 1.5)
|
|
assert error is not None
|
|
assert "ltv" in error.lower()
|
|
|
|
def test_ltv_at_valid_bounds(self) -> None:
|
|
"""Test LTV at valid bounds (0.01 and 0.99)."""
|
|
error_low = validate_numeric_inputs(1000.0, 50000.0, 0.01)
|
|
assert error_low is None
|
|
error_high = validate_numeric_inputs(1000.0, 50000.0, 0.99)
|
|
assert error_high is None
|