mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 23:32:55 +08:00
243 lines
10 KiB
Python
243 lines
10 KiB
Python
"""Backtest range policy shared by human and agent endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
import math
|
|
from typing import Any, Dict, Iterable, Optional
|
|
|
|
from app.data_sources.factory import DataSourceFactory
|
|
|
|
|
|
_TIMEFRAME_SECONDS = {
|
|
"1m": 60,
|
|
"3m": 180,
|
|
"5m": 300,
|
|
"15m": 900,
|
|
"30m": 1800,
|
|
"1H": 3600,
|
|
"4H": 14400,
|
|
"1D": 86400,
|
|
"1W": 604800,
|
|
}
|
|
|
|
_TIMEFRAME_ALIASES = {key.lower(): key for key in _TIMEFRAME_SECONDS}
|
|
|
|
|
|
class BacktestRangeLimitError(ValueError):
|
|
"""Structured range rejection that routes can return to API clients."""
|
|
|
|
def __init__(self, details: Dict[str, Any]) -> None:
|
|
super().__init__("strategyV2.backtestRangeLimit")
|
|
self.details = details
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BacktestRangePolicy:
|
|
max_days: int
|
|
label: str
|
|
reason: str
|
|
|
|
|
|
_DEFAULT_LIMITS: Dict[str, BacktestRangePolicy] = {
|
|
"1m": BacktestRangePolicy(30, "1 month", "engine workload limit"),
|
|
"3m": BacktestRangePolicy(30, "1 month", "engine workload limit"),
|
|
"5m": BacktestRangePolicy(180, "6 months", "engine workload limit"),
|
|
"15m": BacktestRangePolicy(365, "1 year", "engine workload limit"),
|
|
"30m": BacktestRangePolicy(365, "1 year", "engine workload limit"),
|
|
"1H": BacktestRangePolicy(1095, "3 years", "engine workload limit"),
|
|
"4H": BacktestRangePolicy(1095, "3 years", "engine workload limit"),
|
|
"1D": BacktestRangePolicy(1095, "3 years", "engine workload limit"),
|
|
"1W": BacktestRangePolicy(1095, "3 years", "engine workload limit"),
|
|
}
|
|
|
|
|
|
_MARKET_LIMITS: Dict[str, Dict[str, BacktestRangePolicy]] = {
|
|
# yfinance intraday endpoints are much narrower than daily/weekly history.
|
|
# Keep the cap below the upstream hard edge so indicator warmup does not
|
|
# push an apparently valid user window into an upstream 400.
|
|
"USStock": {
|
|
"1m": BacktestRangePolicy(7, "7 days", "US stock intraday data provider limit"),
|
|
"3m": BacktestRangePolicy(7, "7 days", "US stock intraday data provider limit"),
|
|
"5m": BacktestRangePolicy(60, "60 days", "US stock intraday data provider limit"),
|
|
"15m": BacktestRangePolicy(60, "60 days", "US stock intraday data provider limit"),
|
|
"30m": BacktestRangePolicy(60, "60 days", "US stock intraday data provider limit"),
|
|
"1H": BacktestRangePolicy(700, "about 23 months", "US stock hourly data provider limit"),
|
|
"4H": BacktestRangePolicy(700, "about 23 months", "US stock hourly data provider limit"),
|
|
"1D": BacktestRangePolicy(3650, "10 years", "US stock daily data provider limit"),
|
|
"1W": BacktestRangePolicy(3650, "10 years", "US stock weekly data provider limit"),
|
|
},
|
|
# Public forex fallbacks often cap output size or paid subscription depth.
|
|
# These limits avoid silently requesting more bars than the configured
|
|
# provider can return in one backtest run.
|
|
"Forex": {
|
|
"1m": BacktestRangePolicy(7, "7 days", "forex intraday data provider limit"),
|
|
"3m": BacktestRangePolicy(30, "30 days", "forex intraday data provider limit"),
|
|
"5m": BacktestRangePolicy(60, "60 days", "forex intraday data provider limit"),
|
|
"15m": BacktestRangePolicy(60, "60 days", "forex intraday data provider limit"),
|
|
"30m": BacktestRangePolicy(120, "120 days", "forex intraday data provider limit"),
|
|
"1H": BacktestRangePolicy(365, "1 year", "forex hourly data provider limit"),
|
|
"4H": BacktestRangePolicy(730, "2 years", "forex 4H data provider limit"),
|
|
"1D": BacktestRangePolicy(1095, "3 years", "forex daily data provider limit"),
|
|
"1W": BacktestRangePolicy(1095, "3 years", "forex weekly data provider limit"),
|
|
},
|
|
}
|
|
|
|
|
|
def normalize_backtest_timeframe(timeframe: str) -> str:
|
|
raw = str(timeframe or "1D").strip()
|
|
return _TIMEFRAME_ALIASES.get(raw.lower(), raw)
|
|
|
|
|
|
def backtest_range_policy(market: str, timeframe: str) -> BacktestRangePolicy:
|
|
normalized_market = DataSourceFactory.normalize_market(market or "")
|
|
tf = normalize_backtest_timeframe(timeframe)
|
|
return (
|
|
_MARKET_LIMITS.get(normalized_market, {}).get(tf)
|
|
or _DEFAULT_LIMITS.get(tf)
|
|
or _DEFAULT_LIMITS["1D"]
|
|
)
|
|
|
|
|
|
def backtest_warmup_calendar_days(timeframe: str, warmup_bars: int) -> int:
|
|
bars = max(0, int(warmup_bars or 0))
|
|
if bars == 0:
|
|
return 0
|
|
normalized = normalize_backtest_timeframe(timeframe).lower()
|
|
if normalized.endswith("m") and normalized[:-1].isdigit():
|
|
minutes = max(1, int(normalized[:-1]))
|
|
return max(1, math.ceil(bars * minutes * 1.5 / 1440.0))
|
|
if normalized.endswith("h") and normalized[:-1].isdigit():
|
|
hours = max(1, int(normalized[:-1]))
|
|
return max(1, math.ceil(bars * hours * 1.5 / 24.0))
|
|
if normalized.endswith("d"):
|
|
return max(2, math.ceil(bars * 7.0 / 5.0 * 1.35))
|
|
if normalized.endswith("w"):
|
|
return max(8, bars * 8)
|
|
return max(1, math.ceil(bars * 1.5))
|
|
|
|
|
|
def backtest_range_policy_metadata(
|
|
*,
|
|
markets: Iterable[str],
|
|
timeframe: str,
|
|
warmup_bars: int = 0,
|
|
) -> Dict[str, Any]:
|
|
"""Return the strictest client-facing policy for a compiled strategy."""
|
|
normalized_markets = list(dict.fromkeys(
|
|
DataSourceFactory.normalize_market(market or "")
|
|
for market in markets
|
|
)) or [""]
|
|
policies = [
|
|
(market, backtest_range_policy(market, timeframe))
|
|
for market in normalized_markets
|
|
]
|
|
market, policy = min(policies, key=lambda item: item[1].max_days)
|
|
normalized_timeframe = normalize_backtest_timeframe(timeframe)
|
|
timeframe_seconds = _TIMEFRAME_SECONDS.get(normalized_timeframe, 86400)
|
|
normalized_warmup_bars = max(0, int(warmup_bars or 0))
|
|
warmup_days = backtest_warmup_calendar_days(normalized_timeframe, normalized_warmup_bars)
|
|
return {
|
|
"timeframe": normalized_timeframe,
|
|
"market": market,
|
|
"maxDays": policy.max_days,
|
|
"maxSelectedDays": max(0, policy.max_days - warmup_days),
|
|
"warmupBars": normalized_warmup_bars,
|
|
"warmupDays": warmup_days,
|
|
"timeframeSeconds": timeframe_seconds,
|
|
"maxBars": max(1, (policy.max_days * 86400) // timeframe_seconds),
|
|
}
|
|
|
|
|
|
def _date_limit_start(end_date: datetime, max_days: int, warmup_seconds: int) -> datetime:
|
|
"""Return a date-only friendly start that keeps the fetch window under max_days."""
|
|
return end_date - timedelta(days=max(0, int(max_days) - 1)) + timedelta(seconds=warmup_seconds)
|
|
|
|
|
|
def _date_limit_end(fetch_start: datetime, max_days: int) -> datetime:
|
|
"""Return a date-only friendly end that keeps the fetch window under max_days."""
|
|
return fetch_start + timedelta(days=max(0, int(max_days) - 1))
|
|
|
|
|
|
def validate_backtest_range(
|
|
*,
|
|
market: str,
|
|
symbol: str,
|
|
timeframe: str,
|
|
start_date: datetime,
|
|
end_date: datetime,
|
|
warmup_bars: int = 0,
|
|
fetch_start: Optional[datetime] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Return a structured range error, or None when the request is allowed."""
|
|
policy = backtest_range_policy(market, timeframe)
|
|
normalized_timeframe = normalize_backtest_timeframe(timeframe)
|
|
tf_seconds = _TIMEFRAME_SECONDS.get(normalized_timeframe, 86400)
|
|
warmup_seconds = max(0, int(warmup_bars or 0)) * tf_seconds
|
|
if fetch_start is None:
|
|
fetch_start = start_date - timedelta(seconds=warmup_seconds)
|
|
else:
|
|
warmup_seconds = max(0, int((start_date - fetch_start).total_seconds()))
|
|
selected_days = max(0, (end_date - start_date).days)
|
|
fetch_days = max(0, (end_date - fetch_start).days)
|
|
if fetch_days <= policy.max_days:
|
|
return None
|
|
|
|
warmup_note = ""
|
|
if warmup_bars:
|
|
warmup_note = f" including {int(warmup_bars)} warmup bars"
|
|
warmup_days = int((warmup_seconds + 86399) // 86400)
|
|
recommendation_available = warmup_seconds < policy.max_days * 86400
|
|
recommended_start_str = None
|
|
recommended_end_str = None
|
|
recommendation_msg = (
|
|
"Please shorten the date range or use a higher timeframe."
|
|
)
|
|
if recommendation_available:
|
|
recommended_start = _date_limit_start(end_date, policy.max_days, warmup_seconds)
|
|
if recommended_start > end_date:
|
|
recommended_start = end_date
|
|
recommended_end = _date_limit_end(fetch_start, policy.max_days)
|
|
if recommended_end > end_date:
|
|
recommended_end = end_date
|
|
recommended_start_str = recommended_start.strftime("%Y-%m-%d")
|
|
recommended_end_str = recommended_end.strftime("%Y-%m-%d")
|
|
recommendation_msg = (
|
|
f"Please shorten the date range or use a higher timeframe. "
|
|
f"Suggested fix: use {recommended_start_str} to {end_date.strftime('%Y-%m-%d')} "
|
|
f"to keep the current end date, or keep start date {start_date.strftime('%Y-%m-%d')} "
|
|
f"and set end date to {recommended_end_str}."
|
|
)
|
|
elif warmup_bars:
|
|
recommendation_msg = (
|
|
"The indicator warmup alone exceeds this data provider limit. "
|
|
"Reduce long lookback parameters, reduce warmup requirements, or use a higher timeframe."
|
|
)
|
|
msg = (
|
|
f"Backtest range exceeds limit: {market}:{symbol} timeframe {timeframe} "
|
|
f"supports up to {policy.label} ({policy.max_days} days) because of the "
|
|
f"{policy.reason}, but this request needs {fetch_days} days{warmup_note}. "
|
|
f"{recommendation_msg}"
|
|
)
|
|
return {
|
|
"error_type": "BACKTEST_RANGE_LIMIT",
|
|
"msg": msg,
|
|
"market": DataSourceFactory.normalize_market(market or ""),
|
|
"symbol": symbol,
|
|
"timeframe": timeframe,
|
|
"max_days": policy.max_days,
|
|
"max_range": policy.label,
|
|
"reason": policy.reason,
|
|
"selected_days": selected_days,
|
|
"fetch_days": fetch_days,
|
|
"warmup_bars": int(warmup_bars or 0),
|
|
"warmup_days": warmup_days,
|
|
"fetch_start": fetch_start.strftime("%Y-%m-%d"),
|
|
"requested_start": start_date.strftime("%Y-%m-%d"),
|
|
"requested_end": end_date.strftime("%Y-%m-%d"),
|
|
"recommendation_available": recommendation_available,
|
|
"recommended_start": recommended_start_str,
|
|
"recommended_end": recommended_end_str,
|
|
}
|