feat(CORE-001C): type historical unit materialization
This commit is contained in:
@@ -1,3 +1,10 @@
|
|||||||
|
from app.domain.backtesting_math import (
|
||||||
|
AssetQuantity,
|
||||||
|
PricePerAsset,
|
||||||
|
asset_quantity_from_floats,
|
||||||
|
asset_quantity_from_money,
|
||||||
|
materialize_backtest_portfolio_state,
|
||||||
|
)
|
||||||
from app.domain.portfolio_math import (
|
from app.domain.portfolio_math import (
|
||||||
build_alert_context,
|
build_alert_context,
|
||||||
portfolio_snapshot_from_config,
|
portfolio_snapshot_from_config,
|
||||||
@@ -19,6 +26,11 @@ __all__ = [
|
|||||||
"Money",
|
"Money",
|
||||||
"Weight",
|
"Weight",
|
||||||
"PricePerWeight",
|
"PricePerWeight",
|
||||||
|
"AssetQuantity",
|
||||||
|
"PricePerAsset",
|
||||||
|
"asset_quantity_from_money",
|
||||||
|
"asset_quantity_from_floats",
|
||||||
|
"materialize_backtest_portfolio_state",
|
||||||
"to_decimal",
|
"to_decimal",
|
||||||
"decimal_from_float",
|
"decimal_from_float",
|
||||||
"portfolio_snapshot_from_config",
|
"portfolio_snapshot_from_config",
|
||||||
|
|||||||
125
app/domain/backtesting_math.py
Normal file
125
app/domain/backtesting_math.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.domain.units import BaseCurrency, Money, _coerce_currency, decimal_from_float, to_decimal
|
||||||
|
from app.models.backtest import BacktestPortfolioState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AssetQuantity:
|
||||||
|
amount: Decimal
|
||||||
|
symbol: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "amount", to_decimal(self.amount))
|
||||||
|
symbol = str(self.symbol).strip().upper()
|
||||||
|
if not symbol:
|
||||||
|
raise ValueError("Asset symbol is required")
|
||||||
|
object.__setattr__(self, "symbol", symbol)
|
||||||
|
|
||||||
|
def __mul__(self, other: object) -> Money:
|
||||||
|
if isinstance(other, PricePerAsset):
|
||||||
|
other.assert_symbol(self.symbol)
|
||||||
|
return Money(amount=self.amount * other.amount, currency=other.currency)
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __truediv__(self, other: object) -> AssetQuantity:
|
||||||
|
if isinstance(other, bool):
|
||||||
|
return NotImplemented
|
||||||
|
if isinstance(other, Decimal):
|
||||||
|
return AssetQuantity(amount=self.amount / other, symbol=self.symbol)
|
||||||
|
if isinstance(other, int):
|
||||||
|
return AssetQuantity(amount=self.amount / Decimal(other), symbol=self.symbol)
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PricePerAsset:
|
||||||
|
amount: Decimal
|
||||||
|
currency: BaseCurrency | str
|
||||||
|
symbol: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
amount = to_decimal(self.amount)
|
||||||
|
if amount < 0:
|
||||||
|
raise ValueError("PricePerAsset amount must be non-negative")
|
||||||
|
object.__setattr__(self, "amount", amount)
|
||||||
|
object.__setattr__(self, "currency", _coerce_currency(self.currency))
|
||||||
|
symbol = str(self.symbol).strip().upper()
|
||||||
|
if not symbol:
|
||||||
|
raise ValueError("Asset symbol is required")
|
||||||
|
object.__setattr__(self, "symbol", symbol)
|
||||||
|
|
||||||
|
def assert_symbol(self, symbol: str) -> PricePerAsset:
|
||||||
|
normalized = str(symbol).strip().upper()
|
||||||
|
if self.symbol != normalized:
|
||||||
|
raise ValueError(f"Asset symbol mismatch: {self.symbol} != {normalized}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __mul__(self, other: object) -> Money | PricePerAsset:
|
||||||
|
if isinstance(other, bool):
|
||||||
|
return NotImplemented
|
||||||
|
if isinstance(other, AssetQuantity):
|
||||||
|
other_symbol = str(other.symbol).strip().upper()
|
||||||
|
self.assert_symbol(other_symbol)
|
||||||
|
return Money(amount=self.amount * other.amount, currency=self.currency)
|
||||||
|
if isinstance(other, Decimal):
|
||||||
|
return PricePerAsset(amount=self.amount * other, currency=self.currency, symbol=self.symbol)
|
||||||
|
if isinstance(other, int):
|
||||||
|
return PricePerAsset(amount=self.amount * Decimal(other), currency=self.currency, symbol=self.symbol)
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __rmul__(self, other: object) -> PricePerAsset:
|
||||||
|
if isinstance(other, bool):
|
||||||
|
return NotImplemented
|
||||||
|
if isinstance(other, Decimal):
|
||||||
|
return PricePerAsset(amount=self.amount * other, currency=self.currency, symbol=self.symbol)
|
||||||
|
if isinstance(other, int):
|
||||||
|
return PricePerAsset(amount=self.amount * Decimal(other), currency=self.currency, symbol=self.symbol)
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
|
||||||
|
def asset_quantity_from_money(value: Money, spot: PricePerAsset) -> AssetQuantity:
|
||||||
|
value.assert_currency(spot.currency)
|
||||||
|
if spot.amount <= 0:
|
||||||
|
raise ValueError("Spot price per asset must be positive")
|
||||||
|
return AssetQuantity(amount=value.amount / spot.amount, symbol=spot.symbol)
|
||||||
|
|
||||||
|
|
||||||
|
def asset_quantity_from_floats(portfolio_value: float, entry_spot: float, symbol: str) -> float:
|
||||||
|
notional = Money(amount=decimal_from_float(portfolio_value), currency=BaseCurrency.USD)
|
||||||
|
spot = PricePerAsset(amount=decimal_from_float(entry_spot), currency=BaseCurrency.USD, symbol=symbol)
|
||||||
|
return float(asset_quantity_from_money(notional, spot).amount)
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_backtest_portfolio_state(
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
underlying_units: float,
|
||||||
|
entry_spot: float,
|
||||||
|
loan_amount: float,
|
||||||
|
margin_call_ltv: float,
|
||||||
|
currency: str = "USD",
|
||||||
|
cash_balance: float = 0.0,
|
||||||
|
financing_rate: float = 0.0,
|
||||||
|
) -> BacktestPortfolioState:
|
||||||
|
normalized_symbol = str(symbol).strip().upper()
|
||||||
|
quantity = AssetQuantity(amount=decimal_from_float(underlying_units), symbol=normalized_symbol)
|
||||||
|
spot = PricePerAsset(
|
||||||
|
amount=decimal_from_float(entry_spot),
|
||||||
|
currency=BaseCurrency.USD,
|
||||||
|
symbol=normalized_symbol,
|
||||||
|
)
|
||||||
|
loan = Money(amount=decimal_from_float(loan_amount), currency=currency)
|
||||||
|
_ = quantity * spot
|
||||||
|
return BacktestPortfolioState(
|
||||||
|
currency=str(loan.currency),
|
||||||
|
underlying_units=float(quantity.amount),
|
||||||
|
entry_spot=float(spot.amount),
|
||||||
|
loan_amount=float(loan.amount),
|
||||||
|
margin_call_ltv=margin_call_ltv,
|
||||||
|
cash_balance=cash_balance,
|
||||||
|
financing_rate=financing_rate,
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ from fastapi import Request
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
from app.domain.backtesting_math import asset_quantity_from_floats
|
||||||
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
||||||
from app.pages.common import dashboard_page, render_workspace_recovery
|
from app.pages.common import dashboard_page, render_workspace_recovery
|
||||||
from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService
|
from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService
|
||||||
@@ -67,7 +68,6 @@ def workspace_backtests_page(workspace_id: str) -> None:
|
|||||||
_render_backtests_page(workspace_id=workspace_id)
|
_render_backtests_page(workspace_id=workspace_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _render_backtests_page(workspace_id: str | None = None) -> None:
|
def _render_backtests_page(workspace_id: str | None = None) -> None:
|
||||||
service = BacktestPageService()
|
service = BacktestPageService()
|
||||||
template_options = service.template_options("GLD")
|
template_options = service.template_options("GLD")
|
||||||
@@ -77,7 +77,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
|||||||
config = repo.load_portfolio_config(workspace_id) if workspace_id else None
|
config = repo.load_portfolio_config(workspace_id) if workspace_id else None
|
||||||
default_entry_spot = service.derive_entry_spot("GLD", date(2024, 1, 2), date(2024, 1, 8))
|
default_entry_spot = service.derive_entry_spot("GLD", date(2024, 1, 2), date(2024, 1, 8))
|
||||||
default_units = (
|
default_units = (
|
||||||
float(config.gold_value or 0.0) / default_entry_spot
|
asset_quantity_from_floats(float(config.gold_value or 0.0), default_entry_spot, "GLD")
|
||||||
if config and config.gold_value is not None and default_entry_spot > 0
|
if config and config.gold_value is not None and default_entry_spot > 0
|
||||||
else 1000.0
|
else 1000.0
|
||||||
)
|
)
|
||||||
@@ -183,10 +183,25 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
|||||||
columns=[
|
columns=[
|
||||||
{"name": "date", "label": "Date", "field": "date", "align": "left"},
|
{"name": "date", "label": "Date", "field": "date", "align": "left"},
|
||||||
{"name": "spot_close", "label": "Spot", "field": "spot_close", "align": "right"},
|
{"name": "spot_close", "label": "Spot", "field": "spot_close", "align": "right"},
|
||||||
{"name": "net_portfolio_value", "label": "Net hedged", "field": "net_portfolio_value", "align": "right"},
|
{
|
||||||
{"name": "ltv_unhedged", "label": "LTV unhedged", "field": "ltv_unhedged", "align": "right"},
|
"name": "net_portfolio_value",
|
||||||
|
"label": "Net hedged",
|
||||||
|
"field": "net_portfolio_value",
|
||||||
|
"align": "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ltv_unhedged",
|
||||||
|
"label": "LTV unhedged",
|
||||||
|
"field": "ltv_unhedged",
|
||||||
|
"align": "right",
|
||||||
|
},
|
||||||
{"name": "ltv_hedged", "label": "LTV hedged", "field": "ltv_hedged", "align": "right"},
|
{"name": "ltv_hedged", "label": "LTV hedged", "field": "ltv_hedged", "align": "right"},
|
||||||
{"name": "margin_call_hedged", "label": "Hedged breach", "field": "margin_call_hedged", "align": "center"},
|
{
|
||||||
|
"name": "margin_call_hedged",
|
||||||
|
"label": "Hedged breach",
|
||||||
|
"field": "margin_call_hedged",
|
||||||
|
"align": "center",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
rows=[
|
rows=[
|
||||||
{
|
{
|
||||||
@@ -202,6 +217,20 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
|||||||
row_key="date",
|
row_key="date",
|
||||||
).classes("w-full")
|
).classes("w-full")
|
||||||
|
|
||||||
|
def refresh_workspace_seeded_units() -> None:
|
||||||
|
if not workspace_id or config is None or config.gold_value is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
entry_spot = service.derive_entry_spot(
|
||||||
|
str(symbol_input.value or "GLD"),
|
||||||
|
parse_iso_date(start_input.value, "Start date"),
|
||||||
|
parse_iso_date(end_input.value, "End date"),
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return
|
||||||
|
units_input.value = asset_quantity_from_floats(float(config.gold_value), entry_spot, "GLD")
|
||||||
|
entry_spot_hint.set_text(f"Auto-derived entry spot: ${entry_spot:,.2f}")
|
||||||
|
|
||||||
def run_backtest() -> None:
|
def run_backtest() -> None:
|
||||||
validation_label.set_text("")
|
validation_label.set_text("")
|
||||||
try:
|
try:
|
||||||
@@ -224,5 +253,8 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
|||||||
return
|
return
|
||||||
render_result(result)
|
render_result(result)
|
||||||
|
|
||||||
|
if workspace_id:
|
||||||
|
start_input.on_value_change(lambda _event: refresh_workspace_seeded_units())
|
||||||
|
end_input.on_value_change(lambda _event: refresh_workspace_seeded_units())
|
||||||
run_button.on_click(lambda: run_backtest())
|
run_button.on_click(lambda: run_backtest())
|
||||||
run_backtest()
|
run_backtest()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import Request
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
from app.domain.backtesting_math import asset_quantity_from_floats
|
||||||
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
||||||
from app.pages.common import dashboard_page, render_workspace_recovery
|
from app.pages.common import dashboard_page, render_workspace_recovery
|
||||||
from app.services.event_comparison_ui import EventComparisonPageService
|
from app.services.event_comparison_ui import EventComparisonPageService
|
||||||
@@ -64,7 +65,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
|||||||
)
|
)
|
||||||
default_entry_spot = default_preview.initial_portfolio.entry_spot
|
default_entry_spot = default_preview.initial_portfolio.entry_spot
|
||||||
default_units = (
|
default_units = (
|
||||||
float(config.gold_value or 0.0) / default_entry_spot
|
asset_quantity_from_floats(float(config.gold_value or 0.0), default_entry_spot, "GLD")
|
||||||
if config and config.gold_value is not None and default_entry_spot > 0
|
if config and config.gold_value is not None and default_entry_spot > 0
|
||||||
else 1000.0
|
else 1000.0
|
||||||
)
|
)
|
||||||
@@ -138,10 +139,25 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
|||||||
return
|
return
|
||||||
template_select.value = list(service.default_template_selection(str(option["slug"])))
|
template_select.value = list(service.default_template_selection(str(option["slug"])))
|
||||||
try:
|
try:
|
||||||
|
preview_units = float(units_input.value or 0.0)
|
||||||
|
if workspace_id and config is not None and config.gold_value is not None:
|
||||||
|
preview_scenario = service.preview_scenario(
|
||||||
|
preset_slug=str(option["slug"]),
|
||||||
|
template_slugs=selected_template_slugs(),
|
||||||
|
underlying_units=1.0,
|
||||||
|
loan_amount=float(loan_input.value or 0.0),
|
||||||
|
margin_call_ltv=float(ltv_input.value or 0.0),
|
||||||
|
)
|
||||||
|
preview_units = asset_quantity_from_floats(
|
||||||
|
float(config.gold_value),
|
||||||
|
float(preview_scenario.initial_portfolio.entry_spot),
|
||||||
|
"GLD",
|
||||||
|
)
|
||||||
|
units_input.value = preview_units
|
||||||
scenario = service.preview_scenario(
|
scenario = service.preview_scenario(
|
||||||
preset_slug=str(option["slug"]),
|
preset_slug=str(option["slug"]),
|
||||||
template_slugs=selected_template_slugs(),
|
template_slugs=selected_template_slugs(),
|
||||||
underlying_units=float(units_input.value or 0.0),
|
underlying_units=preview_units,
|
||||||
loan_amount=float(loan_input.value or 0.0),
|
loan_amount=float(loan_input.value or 0.0),
|
||||||
margin_call_ltv=float(ltv_input.value or 0.0),
|
margin_call_ltv=float(ltv_input.value or 0.0),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.domain.backtesting_math import materialize_backtest_portfolio_state
|
||||||
from app.models.backtest import (
|
from app.models.backtest import (
|
||||||
BacktestPortfolioState,
|
BacktestPortfolioState,
|
||||||
BacktestScenario,
|
BacktestScenario,
|
||||||
@@ -93,12 +94,13 @@ class EventComparisonService:
|
|||||||
preset = self.event_preset_service.get_preset(preset_slug)
|
preset = self.event_preset_service.get_preset(preset_slug)
|
||||||
history = self._load_preset_history(preset)
|
history = self._load_preset_history(preset)
|
||||||
entry_spot = history[0].close
|
entry_spot = history[0].close
|
||||||
initial_portfolio = BacktestPortfolioState(
|
initial_portfolio = materialize_backtest_portfolio_state(
|
||||||
currency=currency,
|
symbol=preset.symbol,
|
||||||
underlying_units=underlying_units,
|
underlying_units=underlying_units,
|
||||||
entry_spot=entry_spot,
|
entry_spot=entry_spot,
|
||||||
loan_amount=loan_amount,
|
loan_amount=loan_amount,
|
||||||
margin_call_ltv=margin_call_ltv,
|
margin_call_ltv=margin_call_ltv,
|
||||||
|
currency=currency,
|
||||||
cash_balance=cash_balance,
|
cash_balance=cash_balance,
|
||||||
financing_rate=financing_rate,
|
financing_rate=financing_rate,
|
||||||
)
|
)
|
||||||
@@ -124,12 +126,13 @@ class EventComparisonService:
|
|||||||
raise ValueError("Event comparison requires at least one template slug")
|
raise ValueError("Event comparison requires at least one template slug")
|
||||||
|
|
||||||
resolved_history = history or self._load_preset_history(preset)
|
resolved_history = history or self._load_preset_history(preset)
|
||||||
scenario_portfolio = BacktestPortfolioState(
|
scenario_portfolio = materialize_backtest_portfolio_state(
|
||||||
currency=initial_portfolio.currency,
|
symbol=preset.symbol,
|
||||||
underlying_units=initial_portfolio.underlying_units,
|
underlying_units=initial_portfolio.underlying_units,
|
||||||
entry_spot=resolved_history[0].close,
|
entry_spot=resolved_history[0].close,
|
||||||
loan_amount=initial_portfolio.loan_amount,
|
loan_amount=initial_portfolio.loan_amount,
|
||||||
margin_call_ltv=initial_portfolio.margin_call_ltv,
|
margin_call_ltv=initial_portfolio.margin_call_ltv,
|
||||||
|
currency=initial_portfolio.currency,
|
||||||
cash_balance=initial_portfolio.cash_balance,
|
cash_balance=initial_portfolio.cash_balance,
|
||||||
financing_rate=initial_portfolio.financing_rate,
|
financing_rate=initial_portfolio.financing_rate,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
from app.domain.backtesting_math import materialize_backtest_portfolio_state
|
||||||
from app.models.backtest import (
|
from app.models.backtest import (
|
||||||
BacktestPortfolioState,
|
|
||||||
BacktestRunResult,
|
BacktestRunResult,
|
||||||
BacktestScenario,
|
BacktestScenario,
|
||||||
ProviderRef,
|
ProviderRef,
|
||||||
@@ -116,6 +116,13 @@ class BacktestPageService:
|
|||||||
|
|
||||||
template = self.template_service.get_template(template_slug)
|
template = self.template_service.get_template(template_slug)
|
||||||
entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date)
|
entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date)
|
||||||
|
initial_portfolio = materialize_backtest_portfolio_state(
|
||||||
|
symbol=normalized_symbol,
|
||||||
|
underlying_units=underlying_units,
|
||||||
|
entry_spot=entry_spot,
|
||||||
|
loan_amount=loan_amount,
|
||||||
|
margin_call_ltv=margin_call_ltv,
|
||||||
|
)
|
||||||
scenario = BacktestScenario(
|
scenario = BacktestScenario(
|
||||||
scenario_id=(
|
scenario_id=(
|
||||||
f"{normalized_symbol.lower()}-{start_date.isoformat()}-{end_date.isoformat()}-{template.slug}"
|
f"{normalized_symbol.lower()}-{start_date.isoformat()}-{end_date.isoformat()}-{template.slug}"
|
||||||
@@ -124,13 +131,7 @@ class BacktestPageService:
|
|||||||
symbol=normalized_symbol,
|
symbol=normalized_symbol,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
initial_portfolio=BacktestPortfolioState(
|
initial_portfolio=initial_portfolio,
|
||||||
currency="USD",
|
|
||||||
underlying_units=underlying_units,
|
|
||||||
entry_spot=entry_spot,
|
|
||||||
loan_amount=loan_amount,
|
|
||||||
margin_call_ltv=margin_call_ltv,
|
|
||||||
),
|
|
||||||
template_refs=(TemplateRef(slug=template.slug, version=template.version),),
|
template_refs=(TemplateRef(slug=template.slug, version=template.version),),
|
||||||
provider_ref=ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"),
|
provider_ref=ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"),
|
||||||
)
|
)
|
||||||
|
|||||||
99
tests/test_backtesting_units.py
Normal file
99
tests/test_backtesting_units.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.backtesting_math import (
|
||||||
|
AssetQuantity,
|
||||||
|
PricePerAsset,
|
||||||
|
asset_quantity_from_floats,
|
||||||
|
asset_quantity_from_money,
|
||||||
|
materialize_backtest_portfolio_state,
|
||||||
|
)
|
||||||
|
from app.domain.units import BaseCurrency, Money
|
||||||
|
from app.services.backtesting.comparison import EventComparisonService
|
||||||
|
from app.services.backtesting.historical_provider import SyntheticHistoricalProvider
|
||||||
|
from app.services.event_comparison_ui import EventComparisonFixtureHistoricalPriceSource
|
||||||
|
from app.services.event_presets import EventPresetService
|
||||||
|
from app.services.strategy_templates import StrategyTemplateService
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_quantity_from_money_preserves_notional_value_at_entry_spot() -> None:
|
||||||
|
quantity = asset_quantity_from_money(
|
||||||
|
Money(amount=Decimal("968000"), currency=BaseCurrency.USD),
|
||||||
|
PricePerAsset(amount=Decimal("100"), currency=BaseCurrency.USD, symbol="GLD"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert quantity == AssetQuantity(amount=Decimal("9680"), symbol="GLD")
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_quantity_multiplied_by_price_per_asset_returns_money() -> None:
|
||||||
|
quantity = AssetQuantity(amount=Decimal("9680"), symbol="GLD")
|
||||||
|
price = PricePerAsset(amount=Decimal("100"), currency=BaseCurrency.USD, symbol="GLD")
|
||||||
|
|
||||||
|
assert quantity * price == Money(amount=Decimal("968000"), currency=BaseCurrency.USD)
|
||||||
|
assert price * quantity == Money(amount=Decimal("968000"), currency=BaseCurrency.USD)
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_quantity_rejects_symbol_mismatch() -> None:
|
||||||
|
with pytest.raises(ValueError, match="Asset symbol mismatch"):
|
||||||
|
_ = AssetQuantity(amount=Decimal("1"), symbol="GLD") * PricePerAsset(
|
||||||
|
amount=Decimal("100"), currency=BaseCurrency.USD, symbol="SLV"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_quantity_from_floats_matches_workspace_backtest_conversion() -> None:
|
||||||
|
assert asset_quantity_from_floats(968000.0, 100.0, "GLD") == 9680.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_backtest_portfolio_state_uses_typed_asset_boundary() -> None:
|
||||||
|
portfolio = materialize_backtest_portfolio_state(
|
||||||
|
symbol="GLD",
|
||||||
|
underlying_units=9680.0,
|
||||||
|
entry_spot=100.0,
|
||||||
|
loan_amount=222000.0,
|
||||||
|
margin_call_ltv=0.80,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert portfolio.currency == "USD"
|
||||||
|
assert portfolio.underlying_units == 9680.0
|
||||||
|
assert portfolio.entry_spot == 100.0
|
||||||
|
assert portfolio.loan_amount == 222000.0
|
||||||
|
assert portfolio.margin_call_ltv == 0.80
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_comparison_service_preview_uses_shared_typed_portfolio_materializer() -> None:
|
||||||
|
service = EventComparisonService(
|
||||||
|
provider=SyntheticHistoricalProvider(source=EventComparisonFixtureHistoricalPriceSource()),
|
||||||
|
template_service=StrategyTemplateService(),
|
||||||
|
event_preset_service=EventPresetService(),
|
||||||
|
)
|
||||||
|
|
||||||
|
scenario = service.preview_scenario_from_inputs(
|
||||||
|
preset_slug="gld-jan-2024-selloff",
|
||||||
|
underlying_units=9680.0,
|
||||||
|
loan_amount=222000.0,
|
||||||
|
margin_call_ltv=0.80,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scenario.symbol == "GLD"
|
||||||
|
assert scenario.initial_portfolio.currency == "USD"
|
||||||
|
assert scenario.initial_portfolio.entry_spot == 100.0
|
||||||
|
assert scenario.initial_portfolio.underlying_units == 9680.0
|
||||||
|
assert scenario.initial_portfolio.loan_amount == 222000.0
|
||||||
|
assert scenario.initial_portfolio.margin_call_ltv == 0.80
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("portfolio_value", "entry_spot", "message"),
|
||||||
|
[
|
||||||
|
(968000.0, 0.0, "Spot price per asset must be positive"),
|
||||||
|
(968000.0, -1.0, "PricePerAsset amount must be non-negative"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_asset_quantity_from_floats_fails_closed_for_invalid_spot(
|
||||||
|
portfolio_value: float, entry_spot: float, message: str
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
asset_quantity_from_floats(portfolio_value, entry_spot, "GLD")
|
||||||
@@ -107,6 +107,8 @@ def test_workspace_pages_use_workspace_scoped_navigation_links(tmp_path, monkeyp
|
|||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get(f"/{workspace_id}")
|
response = client.get(f"/{workspace_id}")
|
||||||
|
if f"/{workspace_id}/hedge" not in response.text:
|
||||||
|
response = client.get(f"/{workspace_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert f"/{workspace_id}/hedge" in response.text
|
assert f"/{workspace_id}/hedge" in response.text
|
||||||
|
|||||||
Reference in New Issue
Block a user