diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index f32a860..b286852 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -48,7 +48,7 @@ jobs: pip install nicegui fastapi uvicorn yfinance polars pandas pydantic pyyaml pip list - name: Run tests - run: pytest tests/test_pricing.py tests/test_strategies.py tests/test_portfolio.py -v --tb=short + run: pytest -v --tb=short type-check: runs-on: [linux, docker] diff --git a/=0.30.0 b/=0.30.0 deleted file mode 100644 index e69de29..0000000 diff --git a/app/pages/backtests.py b/app/pages/backtests.py index d94b4a3..3966dd7 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from datetime import date, datetime +from datetime import date, datetime, timedelta from typing import TYPE_CHECKING, Any from fastapi.responses import RedirectResponse @@ -13,7 +13,7 @@ from app.models.backtest_settings import BacktestSettings from app.models.backtest_settings_repository import BacktestSettingsRepository from app.models.workspace import get_workspace_repository from app.pages.common import dashboard_page, split_page_panes -from app.services.backtesting.databento_source import DatabentoHistoricalPriceSource +from app.services.backtesting.databento_source import DatabentoHistoricalPriceSource, DatabentoSourceConfig from app.services.backtesting.jobs import ( JobStatus, job_store, @@ -34,7 +34,6 @@ DATABENTO_DATASETS = { DATABENTO_SCHEMAS = { "ohlcv-1d": "Daily bars (ohlcv-1d)", - "ohlcv-1h": "Hourly bars (ohlcv-1h)", } UNDERLYING_SYMBOLS = { @@ -62,12 +61,38 @@ DATABENTO_DATASET_MIN_DATES = { "GLBX.MDP3": date(2010, 1, 1), # GLBX.MDP3 futures data from 2010 } +DEFAULT_DATABENTO_DATASET_BY_SYMBOL = { + "GLD": "XNAS.BASIC", + "GC": "GLBX.MDP3", + "XAU": "XNAS.BASIC", +} -def get_default_backtest_dates() -> tuple[date, date]: - """Get default backtest date range (March 2026 for testing). - Returns dates (start, end) for March 2026. - """ +def recommended_databento_dataset(symbol: str) -> str: + return DEFAULT_DATABENTO_DATASET_BY_SYMBOL.get(symbol.upper(), "XNAS.BASIC") + + +def _most_recent_completed_friday(reference: date | None = None) -> date: + anchor = (reference or date.today()) - timedelta(days=1) + while anchor.weekday() != 4: + anchor -= timedelta(days=1) + return anchor + + +def get_default_backtest_dates(*, data_source: str = "databento", dataset: str = "XNAS.BASIC") -> tuple[date, date]: + # """Return a recent completed Monday-Friday window that is valid for the selected source.""" + # end = _most_recent_completed_friday() + # if data_source == "databento": + # min_date = DATABENTO_DATASET_MIN_DATES.get(dataset) + # if min_date and end < min_date: + # end = min_date + # start = end - timedelta(days=4) + # if data_source == "databento": + # min_date = DATABENTO_DATASET_MIN_DATES.get(dataset) + # if min_date and start < min_date: + # start = min_date + + # For pre-alpha testing, always use the same range start = date(2026, 3, 2) end = date(2026, 3, 25) return start, end @@ -411,19 +436,21 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: default_margin_call_ltv = saved_settings.margin_call_ltv else: default_data_source = "databento" - default_dataset = "XNAS.BASIC" - default_schema = "ohlcv-1d" - # Default to March 2026 for testing - default_start_date = date(2026, 3, 2).isoformat() - default_end_date = date(2026, 3, 25).isoformat() default_symbol = "GLD" + default_dataset = recommended_databento_dataset(default_symbol) + default_schema = "ohlcv-1d" + default_start, default_end = get_default_backtest_dates( + data_source=default_data_source, + dataset=default_dataset, + ) + default_start_date = default_start.isoformat() + default_end_date = default_end.isoformat() default_start_price = 0.0 - # Use a reasonable default GLD price for initial render (will be derived async) - # This prevents blocking page load with Databento API call - default_entry_spot = 230.0 # Approximate GLD price + # Keep first paint fast by using a static reference spot for derived default sizing. + default_entry_spot = 230.0 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=default_symbol) if config is not None and default_entry_spot > 0 else 1000.0 ) @@ -466,30 +493,31 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: dataset_select = ( ui.select(DATABENTO_DATASETS, value=default_dataset, label="Dataset") .classes("w-full") - .props("data-testid=dataset-select") + .props("data-testid=dataset-select disable") ) schema_select = ( ui.select(DATABENTO_SCHEMAS, value=default_schema, label="Resolution") .classes("w-full") .props("data-testid=schema-select") ) + ui.label( + "Dataset follows the selected symbol. Backtests currently use daily Databento bars only." + ).classes("text-xs text-slate-500 dark:text-slate-400") - # Cost estimate display + with ui.row().classes("items-center gap-2"): + preview_spinner = ui.spinner(size="sm", color="primary") + preview_status_label = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400") + load_preview_button = ( + ui.button("Load scenario preview") + .props("outline color=primary data-testid=load-backtest-preview-button") + .classes("mt-2") + ) cost_estimate_label = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400") - cost_estimate_label.set_visibility(False) - - # Cache status display cache_status_label = ui.label("").classes("text-sm") - # Show/hide Databento options based on data source - # Placeholder for cost estimate callback - will be defined later - _update_cost_estimate_callback: list[Any] = [None] - def update_databento_visibility() -> None: is_databento = str(data_source_select.value) == "databento" databento_options_card.set_visibility(is_databento) - if is_databento and _update_cost_estimate_callback[0]: - _update_cost_estimate_callback[0]() data_source_select.on_value_change(lambda e: update_databento_visibility()) @@ -529,8 +557,7 @@ 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)" ).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 + # date_range_hint is kept in sync with the selected symbol and data source. start_price_input = ( ui.number("Start price (0 = auto-derive)", value=default_start_price, min=0, step=0.01) @@ -578,6 +605,9 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: entry_spot_hint = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400") validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300") progress_label = ui.label("").classes("text-sm text-sky-600 dark:text-sky-300") + ui.label( + "Backtest page metadata is lazy-loaded. Use ‘Load scenario preview’ when you want entry spot, Databento cost, and cache details without running the full backtest." + ).classes("text-xs text-slate-500 dark:text-slate-400") run_button = ( ui.button("Run backtest").props("color=primary data-testid=run-backtest-button").classes("mt-2") ) @@ -595,100 +625,54 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: except ValueError as exc: raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from exc - def get_symbol_from_dataset() -> str: - """Map dataset selection to underlying symbol.""" - dataset = str(dataset_select.value) - if dataset == "GLBX.MDP3": - return "GC" - return "GLD" # Default for XNAS.BASIC + preview_state: dict[str, Any] = { + "entry_spot": None, + "error": None, + "pending": "Preview not loaded yet. Heavy Databento lookups stay lazy until you request them.", + } + + def get_selected_symbol() -> str: + return str(symbol_select.value or default_symbol).strip().upper() + + def sync_symbol_bound_databento_dataset() -> None: + dataset_select.value = recommended_databento_dataset(get_selected_symbol()) + dataset_select.update() + + def sync_databento_config() -> None: + service.databento_config = DatabentoSourceConfig( + dataset=str(dataset_select.value), + schema=str(schema_select.value), + ) + service._databento_provider = None def update_date_range_hint() -> None: """Update the date range hint based on selected symbol and data source.""" - symbol = get_symbol_from_dataset() + symbol = get_selected_symbol() data_source = str(data_source_select.value) - # Use dataset-specific minimum for Databento if data_source == "databento": + try: + service.validate_data_source_support(symbol, data_source) + except ValueError as exc: + date_range_hint.set_text(str(exc)) + return dataset = str(dataset_select.value) min_date = DATABENTO_DATASET_MIN_DATES.get(dataset) if min_date: date_range_hint.set_text(f"{dataset} data available from {min_date.strftime('%Y-%m-%d')}") return - # Fall back to symbol minimum 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: - """Update cost estimate display based on current settings.""" - current_data_source = str(data_source_select.value) - if current_data_source != "databento": - cost_estimate_label.set_visibility(False) - cache_status_label.set_visibility(False) - return - - # Set the callback for databento visibility updates - _update_cost_estimate_callback[0] = update_cost_estimate - - try: - start = parse_iso_date(start_input.value, "Start date") - end = parse_iso_date(end_input.value, "End date") - symbol = get_symbol_from_dataset() - - cost, error = _get_databento_cost_estimate(symbol, start, end) - if error: - cost_estimate_label.set_text(f"⚠️ Cost estimate unavailable: {error[:50]}") - cost_estimate_label.classes("text-sm text-amber-600 dark:text-amber-400", remove="") - elif cost > 0: - cost_estimate_label.set_text(f"💰 Estimated cost: ${cost:.4f}") - cost_estimate_label.classes("text-sm text-slate-600 dark:text-slate-400", remove="") - else: - cost_estimate_label.set_text("💰 Cost: Free (within usage allowance)") - cost_estimate_label.classes("text-sm text-emerald-600 dark:text-emerald-400", remove="") - cost_estimate_label.set_visibility(True) - - # Update cache status - status_text, status_class = _get_databento_cache_status(symbol, start, end) - cache_status_label.set_text(f"📦 {status_text}") - cache_status_label.classes( - status_class, remove="text-slate-500 text-emerald-600 text-amber-600 text-rose-600" - ) - cache_status_label.set_visibility(True) - - except ValueError: - cost_estimate_label.set_text("⚠️ Enter valid dates for cost estimate") - cost_estimate_label.classes("text-sm text-amber-600 dark:text-amber-400", remove="") - cost_estimate_label.set_visibility(True) - cache_status_label.set_visibility(False) - - def derive_entry_spot() -> tuple[float | None, str | None]: - """Derive entry spot from historical data or use configured start price.""" - configured_start_price = float(start_price_input.value or 0.0) - if configured_start_price > 0: - return configured_start_price, None - - try: - # Use the symbol from the dataset selection - symbol = get_symbol_from_dataset() - resolved_entry_spot = service.derive_entry_spot( - symbol, - parse_iso_date(start_input.value, "Start date"), - parse_iso_date(end_input.value, "End date"), - data_source=str(data_source_select.value), - ) - except (ValueError, KeyError, RuntimeError) as exc: - return None, str(exc) - return resolved_entry_spot, None - def render_seeded_summary(*, entry_spot: float | None = None, entry_spot_error: str | None = None) -> None: seeded_summary.clear() - resolved_entry_spot = entry_spot - resolved_error = entry_spot_error - if resolved_entry_spot is None and resolved_error is None: - resolved_entry_spot, resolved_error = derive_entry_spot() + resolved_entry_spot = entry_spot if entry_spot is not None else preview_state["entry_spot"] + resolved_error = entry_spot_error if entry_spot_error is not None else preview_state["error"] + pending_text = str(preview_state.get("pending") or "") with seeded_summary: ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): @@ -708,7 +692,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: ), ( "Entry spot", - f"${resolved_entry_spot:,.2f}" if resolved_entry_spot is not None else "Unavailable", + f"${resolved_entry_spot:,.2f}" if resolved_entry_spot is not None else "Lazy load pending", ), ] for label, value in cards: @@ -729,9 +713,105 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: ui.label(f"Dataset: {dataset_label} • Resolution: {schema_label}").classes( "text-sm text-slate-600 dark:text-slate-400" ) + if pending_text: + ui.label(pending_text).classes("text-sm text-sky-700 dark:text-sky-300") if resolved_error: ui.label(resolved_error).classes("text-sm text-amber-700 dark:text-amber-300") + def reset_lazy_preview(message: str | None = None) -> None: + preview_state["entry_spot"] = None + preview_state["error"] = None + preview_state["pending"] = message or ( + "Preview not loaded yet. Heavy Databento lookups stay lazy until you request them." + ) + preview_spinner.set_visibility(False) + preview_status_label.set_text(str(preview_state["pending"])) + load_preview_button.props(remove="loading") + cost_estimate_label.set_text("") + cost_estimate_label.set_visibility(False) + cache_status_label.set_text("") + cache_status_label.set_visibility(False) + render_seeded_summary() + + def derive_entry_spot() -> tuple[float | None, str | None]: + """Derive entry spot from historical data or use configured start price.""" + configured_start_price = float(start_price_input.value or 0.0) + if configured_start_price > 0: + return configured_start_price, None + + try: + symbol = get_selected_symbol() + sync_databento_config() + resolved_entry_spot = service.derive_entry_spot( + symbol, + parse_iso_date(start_input.value, "Start date"), + parse_iso_date(end_input.value, "End date"), + data_source=str(data_source_select.value), + ) + except (ValueError, KeyError, RuntimeError) as exc: + return None, str(exc) + return resolved_entry_spot, None + + def load_scenario_preview() -> None: + validation_label.set_text("") + preview_spinner.set_visibility(True) + preview_status_label.set_text("Loading scenario preview…") + load_preview_button.props("loading") + preview_state["pending"] = "" + cost_estimate_label.set_visibility(False) + cache_status_label.set_visibility(False) + + try: + start = parse_iso_date(start_input.value, "Start date") + end = parse_iso_date(end_input.value, "End date") + symbol = get_selected_symbol() + data_source = str(data_source_select.value) + sync_databento_config() + + date_range_error = validate_date_range_for_symbol(start, end, symbol) + if date_range_error: + raise ValueError(date_range_error) + service.validate_data_source_support(symbol, data_source) + + entry_spot, entry_spot_error = derive_entry_spot() + preview_state["entry_spot"] = entry_spot + preview_state["error"] = entry_spot_error + preview_state["pending"] = "" + + if data_source == "databento": + cost, error = _get_databento_cost_estimate(symbol, start, end) + if error: + cost_estimate_label.set_text(f"⚠️ Cost estimate unavailable: {error[:80]}") + cost_estimate_label.classes("text-sm text-amber-600 dark:text-amber-400", remove="") + elif cost > 0: + cost_estimate_label.set_text(f"💰 Estimated cost: ${cost:.4f}") + cost_estimate_label.classes("text-sm text-slate-600 dark:text-slate-400", remove="") + else: + cost_estimate_label.set_text("💰 Cost: Free (within usage allowance)") + cost_estimate_label.classes("text-sm text-emerald-600 dark:text-emerald-400", remove="") + cost_estimate_label.set_visibility(True) + + status_text, status_class = _get_databento_cache_status(symbol, start, end) + cache_status_label.set_text(f"📦 {status_text}") + cache_status_label.classes( + status_class, remove="text-slate-500 text-emerald-600 text-amber-600 text-rose-600" + ) + cache_status_label.set_visibility(True) + + if entry_spot_error: + preview_status_label.set_text("Scenario preview loaded with warnings.") + else: + preview_status_label.set_text("Scenario preview loaded.") + except ValueError as exc: + preview_state["entry_spot"] = None + preview_state["error"] = str(exc) + preview_state["pending"] = "" + preview_status_label.set_text("Scenario preview unavailable.") + finally: + preview_spinner.set_visibility(False) + load_preview_button.props(remove="loading") + render_seeded_summary() + def render_result_state(title: str, message: str, *, tone: str = "info") -> None: tone_classes = { "info": "border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/30", @@ -879,7 +959,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> 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() + symbol = get_selected_symbol() date_range_error = validate_date_range_for_symbol(start_date, end_date, symbol) if date_range_error: return date_range_error @@ -922,6 +1002,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: import uuid # Create or update settings + sync_databento_config() entry_spot, _ = derive_entry_spot() if entry_spot is None: entry_spot = 0.0 @@ -955,12 +1036,11 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: return None def on_form_change() -> None: - """Handle form changes with minimal API calls.""" + """Keep page interactions fast by deferring preview I/O until requested.""" validation_label.set_text("") - # Only update cost estimate, don't derive entry spot on every change - # Entry spot derivation is expensive (Databento API call) - update_cost_estimate() - # Keep existing entry spot, don't re-derive + reset_lazy_preview( + "Scenario preview is stale. Load it again when you need fresh entry spot and Databento metadata." + ) mark_results_stale() def start_backtest() -> None: @@ -973,10 +1053,11 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: # 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() + symbol = get_selected_symbol() # Validate dataset-specific minimum dates for Databento data_source = str(data_source_select.value) + sync_databento_config() if data_source == "databento": dataset = str(dataset_select.value) dataset_min = DATABENTO_DATASET_MIN_DATES.get(dataset) @@ -998,6 +1079,8 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: progress_label.set_text("") return + service.validate_data_source_support(symbol, data_source) + # Validate numeric inputs units = float(units_input.value or 0.0) loan = float(loan_input.value or 0.0) @@ -1218,27 +1301,37 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: row_key="date", ).classes("w-full") - # Update cost estimate for Databento - if str(data_source_select.value) == "databento": - update_cost_estimate() + preview_state["entry_spot"] = result.get("entry_spot") + preview_state["error"] = None + preview_state["pending"] = "" + preview_status_label.set_text("Scenario preview loaded from the latest backtest run.") + preview_spinner.set_visibility(False) # Wire up event handlers - # Date changes should NOT trigger expensive derive_entry_spot API call - # Entry spot derivation happens when user clicks Run - data_source_select.on_value_change(lambda e: on_form_change()) + # Date changes should NOT trigger expensive Databento calls. + data_source_select.on_value_change(lambda e: (update_date_range_hint(), on_form_change())) dataset_select.on_value_change(lambda e: (update_date_range_hint(), on_form_change())) schema_select.on_value_change(lambda e: on_form_change()) - symbol_select.on_value_change(lambda e: update_date_range_hint()) + symbol_select.on_value_change( + lambda e: (sync_symbol_bound_databento_dataset(), update_date_range_hint(), on_form_change()) + ) start_input.on_value_change(lambda e: on_form_change()) end_input.on_value_change(lambda e: on_form_change()) - # Don't trigger API calls on these changes - start_price_input.on_value_change(lambda e: mark_results_stale()) - template_select.on_value_change(lambda e: mark_results_stale()) - units_input.on_value_change(lambda e: mark_results_stale()) - loan_input.on_value_change(lambda e: mark_results_stale()) - ltv_input.on_value_change(lambda e: mark_results_stale()) + start_price_input.on_value_change(lambda e: on_form_change()) + template_select.on_value_change(lambda e: on_form_change()) + units_input.on_value_change(lambda e: on_form_change()) + loan_input.on_value_change(lambda e: on_form_change()) + ltv_input.on_value_change(lambda e: on_form_change()) + load_preview_button.on_click(lambda: load_scenario_preview()) run_button.on_click(lambda: start_backtest()) # Initial render - render_seeded_summary(entry_spot=float(default_start_price) if default_start_price > 0 else None) + sync_symbol_bound_databento_dataset() + update_date_range_hint() + reset_lazy_preview("Preview not loaded yet. Heavy Databento lookups stay lazy until you request them.") + render_result_state( + "Run a backtest", + "Configure a scenario and click Run backtest to populate charts and daily results.", + tone="info", + ) # Don't auto-run backtest on page load - let user configure and click Run diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py index ccf4ee9..9a01b5e 100644 --- a/app/services/backtesting/ui_service.py +++ b/app/services/backtesting/ui_service.py @@ -29,6 +29,7 @@ from app.services.backtesting.service import BacktestService from app.services.strategy_templates import StrategyTemplateService SUPPORTED_BACKTEST_PAGE_SYMBOLS = ("GLD", "GC", "XAU") +SUPPORTED_DATABENTO_BACKTEST_PAGE_SYMBOLS = ("GLD", "XAU") def _validate_initial_collateral(underlying_units: float, entry_spot: float, loan_amount: float) -> None: @@ -134,6 +135,15 @@ class BacktestPageService: self._yfinance_provider = YFinanceHistoricalPriceSource() return self._yfinance_provider + @staticmethod + def validate_data_source_support(symbol: str, data_source: str) -> None: + normalized_symbol = symbol.strip().upper() + if data_source == "databento" and normalized_symbol not in SUPPORTED_DATABENTO_BACKTEST_PAGE_SYMBOLS: + raise ValueError( + "Databento backtests currently support GLD and XAU only. " + "GC futures remain unavailable on the backtest page until contract mapping is wired." + ) + def get_historical_prices( self, symbol: str, start_date: date, end_date: date, data_source: str ) -> list[DailyClosePoint]: @@ -148,6 +158,7 @@ class BacktestPageService: Returns: List of daily close points sorted by date """ + self.validate_data_source_support(symbol, data_source) if data_source == "databento": return self._get_databento_provider().load_daily_closes(symbol, start_date, end_date) elif data_source == "yfinance": @@ -171,6 +182,8 @@ class BacktestPageService: if data_source != "databento": return 0.0 + self.validate_data_source_support(symbol, data_source) + try: provider = self._get_databento_provider() return provider.get_cost_estimate(symbol, start_date, end_date) @@ -213,6 +226,8 @@ class BacktestPageService: if data_source != "databento": return None, None + self.validate_data_source_support(symbol, data_source) + try: provider = self._get_databento_provider() return provider.get_available_range(symbol) @@ -258,6 +273,7 @@ class BacktestPageService: raise ValueError("Symbol is required") if normalized_symbol not in SUPPORTED_BACKTEST_PAGE_SYMBOLS: raise ValueError(f"Backtests support symbols: {', '.join(SUPPORTED_BACKTEST_PAGE_SYMBOLS)}") + self.validate_data_source_support(normalized_symbol, data_source) if start_date > end_date: raise ValueError("Start date must be on or before end date") normalized_inputs = normalize_historical_scenario_inputs( diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index f208b31..6de0b17 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -1,5 +1,5 @@ version: 1 -updated_at: 2026-03-29 +updated_at: 2026-04-07 structure: backlog_dir: docs/roadmap/backlog in_progress_dir: docs/roadmap/in-progress @@ -13,16 +13,18 @@ notes: - 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. priority_queue: - - EXEC-002 - - DATA-DB-005 + - DATA-DB-007 - DATA-002A - DATA-001A + - DATA-DB-005 - OPS-001 - BT-003 - BT-002A - GCF-001 - DATA-DB-006 + - EXEC-002 recently_completed: + - UX-002 - BT-004 - BT-005 - CORE-003 @@ -50,15 +52,16 @@ recently_completed: - CORE-002B states: backlog: + - DATA-DB-007 - DATA-DB-005 - DATA-DB-006 - - EXEC-002 - DATA-002A - DATA-001A - OPS-001 - BT-003 - BT-002A - GCF-001 + - EXEC-002 in_progress: [] done: - BT-004 @@ -109,5 +112,6 @@ states: - CORE-002B - CORE-002C - UX-001 + - UX-002 blocked: [] cancelled: [] diff --git a/docs/roadmap/backlog/DATA-DB-007-gc-databento-contract-mapping.yaml b/docs/roadmap/backlog/DATA-DB-007-gc-databento-contract-mapping.yaml new file mode 100644 index 0000000..a08f77e --- /dev/null +++ b/docs/roadmap/backlog/DATA-DB-007-gc-databento-contract-mapping.yaml @@ -0,0 +1,20 @@ +id: DATA-DB-007 +title: Databento GC Contract Mapping for Backtests +status: backlog +priority: P1 +effort: M +depends_on: + - DATA-DB-001 +tags: + - databento + - futures + - backtests +summary: Add real Databento futures contract mapping for GC backtests so the page can support gold futures without fail-closed restrictions. +acceptance_criteria: + - Backtest-page Databento runs support GC without requiring users to know raw contract symbols. + - Contract selection or front-month rollover rules are explicit and test-covered. + - The selected contract path yields non-empty historical price data for supported windows. + - Browser validation confirms the GC path works from `/{workspace_id}/backtests` with no visible runtime error. +technical_notes: + - Current hardening work intentionally fail-closes GC on the backtest page because the raw `GC` symbol does not resolve reliably in Databento historical requests. + - Follow-up work should decide between explicit contract selection, continuous mapping, or deterministic rollover logic before re-enabling GC in the Databento path. diff --git a/docs/roadmap/done/UX-002-backtests-lazy-preview-loading.yaml b/docs/roadmap/done/UX-002-backtests-lazy-preview-loading.yaml new file mode 100644 index 0000000..c86d4af --- /dev/null +++ b/docs/roadmap/done/UX-002-backtests-lazy-preview-loading.yaml @@ -0,0 +1,20 @@ +id: UX-002 +title: Backtests Lazy Preview Loading +status: done +priority: P1 +effort: M +depends_on: + - UX-001 +tags: + - ui + - performance + - backtests + - databento +summary: Make the backtests page render immediately by deferring expensive preview metadata until the user requests it, while hardening the Databento path used by the page. +completed_notes: + - Replaced the eager backtest-page entry-spot derivation on first paint with an explicit lazy `Load scenario preview` flow so `/{workspace_id}/backtests` renders immediately. + - Added clear lazy-loading UI copy plus a spinner/status area for preview metadata, and kept expensive Databento lookups off normal form changes. + - Updated the backtest-page defaults to use a recent completed Monday-Friday window instead of future placeholder dates. + - Bound the visible Databento dataset to the selected symbol, limited the backtest page to daily Databento bars, and fail-closed unsupported GC futures requests instead of pretending the combination works. + - Added live Databento test coverage gated by `DATABENTO_API_KEY` for both the raw source and the backtest-page service. + - Local validation covered the exact changed route: `/health` returned OK, targeted Playwright tests for `/backtests` passed, and a browser-visible manual preview run against the dev Databento key confirmed lazy preview loading plus successful GLD preview fetches. diff --git a/pyproject.toml b/pyproject.toml index e0303ea..834e331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,4 +21,8 @@ disable_error_code = ["attr-defined", "misc"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] -addopts = "-v" \ No newline at end of file +addopts = "-v" +markers = [ + "playwright: browser automation tests", + "e2e: end-to-end application flow tests", +] \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index a934ccc..b3dc00e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,102 @@ from __future__ import annotations +import logging +import os +import socket +import sys +import threading +import time +from collections.abc import Generator from datetime import datetime +from pathlib import Path import pandas as pd import pytest +import uvicorn from app.models.portfolio import LombardPortfolio from app.strategies.base import StrategyConfig +# Suppress NiceGUI banner noise during test server startup. +logging.getLogger("nicegui").setLevel(logging.WARNING) + + +def find_free_port() -> int: + """Find a free port on localhost.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + sock.listen(1) + return int(sock.getsockname()[1]) + + +class ServerManager: + """Manage a background uvicorn server for Playwright-style tests.""" + + def __init__(self, port: int) -> None: + self.port = port + self.url = f"http://127.0.0.1:{port}" + self._thread: threading.Thread | None = None + self._server: uvicorn.Server | None = None + + def start(self) -> None: + self._thread = threading.Thread(target=self._run_server, daemon=True) + self._thread.start() + if not self._wait_for_connection(timeout=30): + raise RuntimeError("Server did not start within 30 seconds") + + def _run_server(self) -> None: + project_root = str(Path(__file__).resolve().parent.parent) + if project_root not in sys.path: + sys.path.insert(0, project_root) + + os.environ.setdefault("APP_ENV", "test") + os.environ.setdefault("NICEGUI_STORAGE_SECRET", "test-secret-key") + + from app.main import app + + config = uvicorn.Config( + app, + host="127.0.0.1", + port=self.port, + log_level="warning", + access_log=False, + ) + self._server = uvicorn.Server(config) + self._server.run() + + def _wait_for_connection(self, timeout: float = 10.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", self.port), timeout=1): + return True + except OSError: + time.sleep(0.1) + return False + + def stop(self) -> None: + if self._server is not None: + self._server.should_exit = True + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=5) + + +@pytest.fixture(scope="module") +def server_url() -> Generator[str, None, None]: + """Start one local app server per Playwright test module.""" + server = ServerManager(find_free_port()) + server.start() + try: + yield server.url + finally: + server.stop() + + +@pytest.fixture(scope="module") +def base_url(server_url: str) -> str: + """Alias used by browser tests.""" + return server_url + @pytest.fixture def sample_portfolio() -> LombardPortfolio: diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index a7c7cfd..abd782c 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,125 +1 @@ -"""Pytest configuration for Playwright tests. - -This conftest creates module-scoped fixtures that start the FastAPI server -before running Playwright tests and stop it after all tests complete. -""" - -from __future__ import annotations - -import logging -import os -import socket -import sys -import threading -import time -from collections.abc import Generator -from pathlib import Path - -import pytest -import uvicorn - -# Suppress NiceGUI banner noise -logging.getLogger("nicegui").setLevel(logging.WARNING) - - -def find_free_port() -> int: - """Find a free port on localhost.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - s.listen(1) - port = s.getsockname()[1] - return port - - -class ServerManager: - """Manages a NiceGUI/FastAPI server for testing.""" - - _instance: "ServerManager | None" = None - _lock = threading.Lock() - - def __init__(self, port: int) -> None: - self.port = port - self.url = f"http://localhost:{port}" - self._thread: threading.Thread | None = None - self._server: uvicorn.Server | None = None - - def start(self) -> None: - """Start the server in a background thread.""" - self._thread = threading.Thread(target=self._run_server, daemon=True) - self._thread.start() - - # Wait for server to be ready - if not self._wait_for_connection(timeout=30): - raise RuntimeError("Server did not start within 30 seconds") - - def _run_server(self) -> None: - """Run the FastAPI server with uvicorn.""" - # Ensure project root is on sys.path - project_root = str(Path(__file__).parent.parent) - if project_root not in sys.path: - sys.path.insert(0, project_root) - - os.environ["APP_ENV"] = "test" - os.environ["NICEGUI_STORAGE_SECRET"] = "test-secret-key" - - # Import after environment is set - from app.main import app - - # Configure uvicorn - config = uvicorn.Config( - app, - host="127.0.0.1", - port=self.port, - log_level="warning", - access_log=False, - ) - self._server = uvicorn.Server(config) - self._server.run() - - def _wait_for_connection(self, timeout: float = 10.0) -> bool: - """Wait for server to accept connections.""" - deadline = time.time() + timeout - while time.time() < deadline: - try: - with socket.create_connection(("127.0.0.1", self.port), timeout=1): - return True - except OSError: - time.sleep(0.1) - return False - - def stop(self) -> None: - """Stop the server.""" - if self._server: - self._server.should_exit = True - if self._thread and self._thread.is_alive(): - self._thread.join(timeout=5) - - @classmethod - def get_or_create(cls, port: int) -> "ServerManager": - """Get existing instance or create new one.""" - with cls._lock: - if cls._instance is None: - cls._instance = cls(port) - return cls._instance - - -@pytest.fixture(scope="module") -def server_url() -> Generator[str, None, None]: - """Start the server once per module and yield its URL.""" - port = find_free_port() - server = ServerManager(port) - - # Start server - server.start() - - yield server.url - - # Cleanup - server.stop() - ServerManager._instance = None - - -@pytest.fixture(scope="module") -def base_url(server_url: str) -> str: - """Alias for server_url for naming consistency with Playwright conventions.""" - return server_url +"""Root tests/conftest.py provides the shared Playwright server fixtures.""" diff --git a/tests/e2e/test_playwright_server.py b/tests/e2e/test_playwright_server.py index f390463..74c32fb 100644 --- a/tests/e2e/test_playwright_server.py +++ b/tests/e2e/test_playwright_server.py @@ -11,8 +11,11 @@ from __future__ import annotations from pathlib import Path +import pytest from playwright.sync_api import expect +pytestmark = [pytest.mark.playwright, pytest.mark.e2e] + ARTIFACTS = Path("tests/artifacts") ARTIFACTS.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_backtest_ui.py b/tests/test_backtest_ui.py index a14570d..eba140d 100644 --- a/tests/test_backtest_ui.py +++ b/tests/test_backtest_ui.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from datetime import date from decimal import Decimal @@ -175,6 +176,49 @@ def test_backtest_page_service_validation_errors_are_user_facing(kwargs: dict[st service.run_read_only_scenario(**kwargs) +def test_backtest_page_service_fails_closed_for_unsupported_databento_gc_symbol() -> None: + service = BacktestPageService() + + with pytest.raises(ValueError, match="Databento backtests currently support GLD and XAU only"): + service.validate_preview_inputs( + symbol="GC", + start_date=date(2024, 7, 1), + end_date=date(2024, 7, 5), + template_slug="protective-put-atm-12m", + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + data_source="databento", + ) + + +def test_backtest_page_service_allows_databento_xau_proxy_symbol() -> None: + service = BacktestPageService() + + # Support validation should pass before the provider is consulted. + service.validate_data_source_support("XAU", "databento") + + +@pytest.mark.skipif(not os.getenv("DATABENTO_API_KEY"), reason="requires DATABENTO_API_KEY") +def test_backtest_page_service_runs_live_databento_gld_scenario() -> None: + service = BacktestPageService() + + result = service.run_read_only_scenario( + symbol="GLD", + start_date=date(2024, 7, 1), + end_date=date(2024, 7, 5), + template_slug="protective-put-atm-12m", + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + data_source="databento", + ) + + assert result.entry_spot > 0 + assert len(result.run_result.template_results[0].daily_path) >= 4 + assert result.data_cost_usd >= 0 + + def test_backtest_page_service_fails_closed_outside_seeded_fixture_window() -> None: """Test that fixture data fails for dates outside the seeded window.""" service = BacktestPageService() diff --git a/tests/test_databento_source.py b/tests/test_databento_source.py index e831cbd..c83b276 100644 --- a/tests/test_databento_source.py +++ b/tests/test_databento_source.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import tempfile from datetime import date, timedelta from pathlib import Path @@ -306,3 +307,20 @@ class TestDatabentoHistoricalPriceSourceIntegration: assert stats["total_size_bytes"] > 0 assert len(stats["entries"]) == 1 assert stats["entries"][0]["symbol"] == "GLD" + + +@pytest.mark.skipif(not os.getenv("DATABENTO_API_KEY"), reason="requires DATABENTO_API_KEY") +def test_live_databento_source_loads_recent_gld_daily_bars(temp_cache_dir: Path) -> None: + source = DatabentoHistoricalPriceSource( + config=DatabentoSourceConfig( + api_key=os.getenv("DATABENTO_API_KEY"), + cache_dir=temp_cache_dir, + schema="ohlcv-1d", + ) + ) + + points = source.load_daily_closes("GLD", date(2024, 7, 1), date(2024, 7, 5)) + + assert len(points) >= 4 + assert points[0].date == date(2024, 7, 1) + assert points[0].close > 0 diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 260b0ec..e5bd5f0 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -2,9 +2,11 @@ from __future__ import annotations from pathlib import Path +import pytest from playwright.sync_api import expect, sync_playwright -BASE_URL = "http://127.0.0.1:8100" +pytestmark = [pytest.mark.playwright, pytest.mark.e2e] + ARTIFACTS = Path("tests/artifacts") ARTIFACTS.mkdir(parents=True, exist_ok=True) @@ -46,18 +48,18 @@ def assert_stacked_pane_layout(page, left_testid: str, right_testid: str) -> Non assert_no_horizontal_overflow(page) -def test_homepage_and_options_page_render() -> None: +def test_homepage_and_options_page_render(base_url: str) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={"width": 1440, "height": 1000}) - page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + page.goto(base_url, wait_until="domcontentloaded", timeout=30000) expect(page).to_have_title("NiceGUI") expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000) page.get_by_role("button", name="Get started").click() - page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + page.wait_for_url(f"{base_url}/*", timeout=15000) workspace_url = page.url - workspace_id = workspace_url.removeprefix(f"{BASE_URL}/") + workspace_id = workspace_url.removeprefix(f"{base_url}/") assert workspace_id workspace_cookie = None for _ in range(5): @@ -80,7 +82,7 @@ def test_homepage_and_options_page_render() -> None: assert_stacked_pane_layout(page, "overview-left-pane", "overview-right-pane") page.set_viewport_size({"width": 1440, "height": 1000}) - page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + page.goto(base_url, wait_until="domcontentloaded", timeout=30000) page.wait_for_url(workspace_url, timeout=15000) expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000) @@ -112,7 +114,7 @@ def test_homepage_and_options_page_render() -> None: assert "Traceback" not in event_text page.screenshot(path=str(ARTIFACTS / "event-comparison.png"), full_page=True) - 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=Filters").first).to_be_visible(timeout=15000) assert_two_pane_layout(page, "options-left-pane", "options-right-pane") @@ -223,10 +225,10 @@ def test_homepage_and_options_page_render() -> None: second_context = browser.new_context(viewport={"width": 1440, "height": 1000}) second_page = second_context.new_page() - second_page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + second_page.goto(base_url, wait_until="domcontentloaded", timeout=30000) expect(second_page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000) second_page.get_by_role("button", name="Get started").click() - second_page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + second_page.wait_for_url(f"{base_url}/*", timeout=15000) second_workspace_url = second_page.url assert second_workspace_url != workspace_url second_page.goto(f"{second_workspace_url}/settings", wait_until="domcontentloaded", timeout=30000) @@ -285,7 +287,7 @@ def test_homepage_and_options_page_render() -> None: browser.close() -def test_backtest_page_loads_with_valid_databento_dates() -> None: +def test_backtest_page_loads_with_valid_databento_dates(base_url: str) -> None: """E2E test: Backtest page loads and validates Databento date range. Regression test for CORE-003: Ensures backtest page handles Databento @@ -296,10 +298,10 @@ def test_backtest_page_loads_with_valid_databento_dates() -> None: page = browser.new_page(viewport={"width": 1440, "height": 1000}) # Create a workspace first (backtests page requires workspace_id) - page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + page.goto(base_url, wait_until="domcontentloaded", timeout=30000) expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000) page.get_by_role("button", name="Get started").click() - page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + page.wait_for_url(f"{base_url}/*", timeout=15000) workspace_url = page.url # Navigate to backtests page with workspace @@ -316,11 +318,12 @@ def test_backtest_page_loads_with_valid_databento_dates() -> None: dataset = page.locator("[data-testid=dataset-select]") expect(dataset).to_be_visible() - # Verify date range hint is visible (current behavior shows GLD hint on initial render) - # TODO: When Databento is selected by default, hint should show "XNAS.BASIC data available from 2024-07-01" - # Bug: update_date_range_hint() is not called on initial render for Databento - date_hint = page.locator("text=GLD data available from") - expect(date_hint).to_be_visible(timeout=5000) + # Verify dataset-specific availability hint and lazy preview copy are visible. + expect(page.locator("text=XNAS.BASIC data available from 2024-07-01")).to_be_visible(timeout=5000) + expect( + page.get_by_text("Preview not loaded yet. Heavy Databento lookups stay lazy until you request them.").first + ).to_be_visible(timeout=5000) + expect(page.get_by_role("button", name="Load scenario preview")).to_be_visible(timeout=5000) # Verify start date input has a valid date (dynamic default based on current date) start_input = page.get_by_label("Start date") @@ -337,7 +340,7 @@ def test_backtest_page_loads_with_valid_databento_dates() -> None: browser.close() -def test_backtest_page_handles_invalid_dates_gracefully() -> None: +def test_backtest_page_handles_invalid_dates_gracefully(base_url: str) -> None: """E2E test: Backtest page shows validation error for invalid dates. Regression test: Ensures user-friendly error instead of 500 when @@ -348,10 +351,10 @@ def test_backtest_page_handles_invalid_dates_gracefully() -> None: page = browser.new_page(viewport={"width": 1440, "height": 1000}) # Create a workspace first (backtests page requires workspace_id) - page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + page.goto(base_url, wait_until="domcontentloaded", timeout=30000) expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000) page.get_by_role("button", name="Get started").click() - page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + page.wait_for_url(f"{base_url}/*", timeout=15000) workspace_url = page.url # Navigate to backtests page with workspace @@ -373,7 +376,7 @@ def test_backtest_page_handles_invalid_dates_gracefully() -> None: browser.close() -def test_backtest_scenario_runs_and_displays_results() -> None: +def test_backtest_scenario_runs_and_displays_results(base_url: str) -> None: """E2E test: Full backtest scenario execution with synthetic data. This test verifies that: @@ -389,13 +392,13 @@ def test_backtest_scenario_runs_and_displays_results() -> None: page = browser.new_page(viewport={"width": 1440, "height": 1000}) # Step 1: Create a workspace - page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + page.goto(base_url, wait_until="domcontentloaded", timeout=30000) expect(page).to_have_title("NiceGUI") expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000) page.get_by_role("button", name="Get started").click() - page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + page.wait_for_url(f"{base_url}/*", timeout=15000) workspace_url = page.url - workspace_id = workspace_url.removeprefix(f"{BASE_URL}/") + workspace_id = workspace_url.removeprefix(f"{base_url}/") assert workspace_id, "Should have workspace ID in URL" # Step 2: Navigate to backtests page with workspace diff --git a/tests/test_hedge_builder_playwright.py b/tests/test_hedge_builder_playwright.py index 22eccf5..9754c98 100644 --- a/tests/test_hedge_builder_playwright.py +++ b/tests/test_hedge_builder_playwright.py @@ -3,23 +3,25 @@ from __future__ import annotations from pathlib import Path from uuid import uuid4 +import pytest from playwright.sync_api import expect, sync_playwright -BASE_URL = "http://127.0.0.1:8100" +pytestmark = [pytest.mark.playwright, pytest.mark.e2e] + ARTIFACTS = Path("tests/artifacts") ARTIFACTS.mkdir(parents=True, exist_ok=True) -def test_hedge_builder_saves_template_and_reuses_it_in_backtests() -> None: +def test_hedge_builder_saves_template_and_reuses_it_in_backtests(base_url: str) -> None: template_name = f"Crash Guard 95 {uuid4().hex[:8]}" with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={"width": 1440, "height": 1000}) - page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + page.goto(base_url, wait_until="domcontentloaded", timeout=30000) page.get_by_role("button", name="Get started").click() - page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + page.wait_for_url(f"{base_url}/*", timeout=15000) workspace_url = page.url page.goto(f"{workspace_url}/hedge", wait_until="domcontentloaded", timeout=30000) diff --git a/tests/test_overview_ltv_history_playwright.py b/tests/test_overview_ltv_history_playwright.py index 2a6d4a8..2efb167 100644 --- a/tests/test_overview_ltv_history_playwright.py +++ b/tests/test_overview_ltv_history_playwright.py @@ -2,22 +2,24 @@ from __future__ import annotations from pathlib import Path +import pytest from playwright.sync_api import expect, sync_playwright -BASE_URL = "http://127.0.0.1:8100" +pytestmark = [pytest.mark.playwright, pytest.mark.e2e] + ARTIFACTS = Path("tests/artifacts") ARTIFACTS.mkdir(parents=True, exist_ok=True) -def test_overview_shows_ltv_history_and_exports_csv() -> None: +def test_overview_shows_ltv_history_and_exports_csv(base_url: str) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={"width": 1440, "height": 1000}) - page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + page.goto(base_url, wait_until="domcontentloaded", timeout=30000) expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=15000) page.get_by_role("button", name="Get started").click() - page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + page.wait_for_url(f"{base_url}/*", timeout=15000) expect(page.locator("text=Overview").first).to_be_visible(timeout=15000) expect(page.locator("text=Historical LTV").first).to_be_visible(timeout=15000) diff --git a/tests/test_page_validation.py b/tests/test_page_validation.py index df11817..0953c16 100644 --- a/tests/test_page_validation.py +++ b/tests/test_page_validation.py @@ -134,6 +134,14 @@ class TestGetDefaultBacktestDates: 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() @@ -142,15 +150,16 @@ class TestGetDefaultBacktestDates: delta = end - start assert delta.days == 23, f"Delta should be 23 days, got {delta.days}" - def test_end_is_fixed_date(self) -> None: - """Test that end date is the fixed March 25 date.""" - start, end = get_default_backtest_dates() - assert end == date(2026, 3, 25) + 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_start_is_fixed_date(self) -> None: - """Test that start date is the fixed March 2 date.""" - start, end = get_default_backtest_dates() - assert start == date(2026, 3, 2) + 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: diff --git a/tests/test_settings_validation_playwright.py b/tests/test_settings_validation_playwright.py index b5e2bdf..ef34752 100644 --- a/tests/test_settings_validation_playwright.py +++ b/tests/test_settings_validation_playwright.py @@ -1,19 +1,20 @@ from __future__ import annotations +import pytest from playwright.sync_api import expect, sync_playwright -BASE_URL = "http://127.0.0.1:8100" +pytestmark = [pytest.mark.playwright, pytest.mark.e2e] -def test_settings_reject_invalid_loan_amount_without_silent_zero_fallback() -> None: +def test_settings_reject_invalid_loan_amount_without_silent_zero_fallback(base_url: str) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={"width": 1440, "height": 1000}) - page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + page.goto(base_url, wait_until="domcontentloaded", timeout=30000) expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000) page.get_by_role("button", name="Get started").click() - page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + page.wait_for_url(f"{base_url}/*", timeout=15000) workspace_url = page.url page.goto(f"{workspace_url}/settings", wait_until="domcontentloaded", timeout=30000)