feat: use day's low price for margin call evaluation

- Extend DailyClosePoint to include low, high, open (optional)
- Update Databento source to extract OHLC data from ohlcv-1d schema
- Update YFinance source to extract Low, High, Open from history
- Modify backtest engine to use worst-case (low) price for margin call detection

This ensures margin calls are evaluated at the day's worst price,
not just the closing price, providing more realistic risk assessment.
This commit is contained in:
Bu5hm4nn
2026-04-04 23:06:15 +02:00
parent 1e567775f9
commit a8e710f790
3 changed files with 144 additions and 119 deletions

View File

@@ -20,10 +20,19 @@ from app.models.strategy_template import TemplateLeg
class DailyClosePoint:
date: date
close: float
low: float | None = None # Day's low for margin call evaluation
high: float | None = None # Day's high
open: float | None = None # Day's open
def __post_init__(self) -> None:
if self.close <= 0:
raise ValueError("close must be positive")
if self.low is not None and self.low <= 0:
raise ValueError("low must be positive")
if self.high is not None and self.high <= 0:
raise ValueError("high must be positive")
if self.open is not None and self.open <= 0:
raise ValueError("open must be positive")
@dataclass(frozen=True)
@@ -181,7 +190,9 @@ class BacktestHistoricalProvider(Protocol):
class YFinanceHistoricalPriceSource:
@staticmethod
def _normalize_daily_close_row(*, row_date: object, close: object) -> DailyClosePoint | None:
def _normalize_daily_close_row(
*, row_date: object, close: object, low: object = None, high: object = None, open_price: object = None
) -> DailyClosePoint | None:
if close is None:
return None
if not hasattr(row_date, "date"):
@@ -192,7 +203,23 @@ class YFinanceHistoricalPriceSource:
raise TypeError(f"close must be numeric, got {type(close)!r}")
if not isfinite(normalized_close):
raise ValueError("historical close must be finite")
return DailyClosePoint(date=row_date.date(), close=normalized_close)
# Parse optional OHLC fields
def parse_optional(val: object) -> float | None:
if val is None:
return None
if isinstance(val, (int, float)):
result = float(val)
return result if isfinite(result) and result > 0 else None
return None
return DailyClosePoint(
date=row_date.date(),
close=normalized_close,
low=parse_optional(low),
high=parse_optional(high),
open=parse_optional(open_price),
)
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
if yf is None:
@@ -202,7 +229,13 @@ class YFinanceHistoricalPriceSource:
history = ticker.history(start=start_date.isoformat(), end=inclusive_end_date.isoformat(), interval="1d")
rows: list[DailyClosePoint] = []
for index, row in history.iterrows():
point = self._normalize_daily_close_row(row_date=index, close=row.get("Close"))
point = self._normalize_daily_close_row(
row_date=index,
close=row.get("Close"),
low=row.get("Low"),
high=row.get("High"),
open_price=row.get("Open"),
)
if point is not None:
rows.append(point)
return rows