From 8db718f19bc2b1d8f536b3225ac3598b457869a8 Mon Sep 17 00:00:00 2001 From: huuan26 Date: Mon, 19 Oct 2026 10:41:20 +0700 Subject: [PATCH] fix(strategy-v2): align monthly timeframe duration, annualization periods, and driving frequency - Add '1mo': 2_592_000 to FREQUENCY_SECONDS and TIMEFRAME_SECONDS - Map '1mo': '1M' in PROVIDER_TIMEFRAMES and add '1mo': 3600 poll interval - Define SUPPORTED_SUBSCRIPTION_FREQUENCIES to decouple subscription validation from duration lookup - Fix periods_per_year to return 12.0 for monthly timeframe instead of trading_days fallback - Correct driving_frequency comparison so 1w (604,800s) takes precedence over 1mo (2,592,000s) - Add comprehensive test suite in test_frequencies.py --- .../app/services/indicator_signal_alerts.py | 1 + .../app/services/strategy_v2/contract.py | 8 +- .../app/services/strategy_v2/frequencies.py | 20 +++ .../app/services/strategy_v2/market_data.py | 2 + backend_api_python/tests/test_frequencies.py | 121 ++++++++++++++++++ .../tests/test_indicator_signal_alerts.py | 3 + 6 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 backend_api_python/tests/test_frequencies.py diff --git a/backend_api_python/app/services/indicator_signal_alerts.py b/backend_api_python/app/services/indicator_signal_alerts.py index 1131e2f24..2b6d622ee 100644 --- a/backend_api_python/app/services/indicator_signal_alerts.py +++ b/backend_api_python/app/services/indicator_signal_alerts.py @@ -1011,6 +1011,7 @@ def _poll_seconds(self, timeframe: str) -> int: "4h": 300, "1d": 600, "1w": 1800, + "1mo": 3600, } return mapping.get(tf, 120) diff --git a/backend_api_python/app/services/strategy_v2/contract.py b/backend_api_python/app/services/strategy_v2/contract.py index f7d6de69c..fd271b02f 100644 --- a/backend_api_python/app/services/strategy_v2/contract.py +++ b/backend_api_python/app/services/strategy_v2/contract.py @@ -30,7 +30,11 @@ normalize_pool_reference, parse_instrument, ) -from .frequencies import FREQUENCY_SECONDS, unique_frequencies +from .frequencies import ( + FREQUENCY_SECONDS, + SUPPORTED_SUBSCRIPTION_FREQUENCIES, + unique_frequencies, +) from .models import ( InstrumentSpec, ScheduleSpec, @@ -306,7 +310,7 @@ def compile_strategy_v2(code: str) -> CompiledStrategyV2: unsupported = [ item for item in frequencies - if item not in FREQUENCY_SECONDS + if item not in SUPPORTED_SUBSCRIPTION_FREQUENCIES ] if unsupported: raise StrategyV2ContractError( diff --git a/backend_api_python/app/services/strategy_v2/frequencies.py b/backend_api_python/app/services/strategy_v2/frequencies.py index 426d8e865..b77f75bfe 100644 --- a/backend_api_python/app/services/strategy_v2/frequencies.py +++ b/backend_api_python/app/services/strategy_v2/frequencies.py @@ -15,8 +15,21 @@ "4h": 14_400, "1d": 86_400, "1w": 604_800, + "1mo": 2_592_000, } +SUPPORTED_SUBSCRIPTION_FREQUENCIES: tuple[str, ...] = ( + "1m", + "3m", + "5m", + "15m", + "30m", + "1h", + "4h", + "1d", + "1w", +) + _ALIASES = { "daily": "1d", "day": "1d", @@ -25,11 +38,15 @@ "weekly": "1w", "week": "1w", "w": "1w", + "1week": "1w", + "1wk": "1w", "monthly": "1mo", "month": "1mo", + "1month": "1mo", "m1": "1m", "h1": "1h", "d1": "1d", + "w1": "1w", } @@ -56,6 +73,8 @@ def periods_per_year(frequency: object, markets: Iterable[str]) -> float: normalized = normalize_frequency(frequency) is_crypto = "Crypto" in set(markets) trading_days = 365.25 if is_crypto else 252.0 + if normalized in {"1mo", "1month"}: + return 12.0 if normalized.endswith("m"): minutes = max(1, int(normalized[:-1] or 1)) session_minutes = 1440.0 if is_crypto else 390.0 @@ -71,6 +90,7 @@ def periods_per_year(frequency: object, markets: Iterable[str]) -> float: __all__ = [ "FREQUENCY_SECONDS", + "SUPPORTED_SUBSCRIPTION_FREQUENCIES", "driving_frequency", "frequency_seconds", "normalize_frequency", diff --git a/backend_api_python/app/services/strategy_v2/market_data.py b/backend_api_python/app/services/strategy_v2/market_data.py index 309a55161..ca996181d 100644 --- a/backend_api_python/app/services/strategy_v2/market_data.py +++ b/backend_api_python/app/services/strategy_v2/market_data.py @@ -47,6 +47,7 @@ class _SharedFrameEntry: "4h": 14400, "1d": 86400, "1w": 604800, + "1mo": 2592000, } PROVIDER_TIMEFRAMES = { @@ -54,6 +55,7 @@ class _SharedFrameEntry: "4h": "4H", "1d": "1D", "1w": "1W", + "1mo": "1M", } diff --git a/backend_api_python/tests/test_frequencies.py b/backend_api_python/tests/test_frequencies.py new file mode 100644 index 000000000..986491baa --- /dev/null +++ b/backend_api_python/tests/test_frequencies.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import pytest + +from app.services.strategy_v2.frequencies import ( + FREQUENCY_SECONDS, + SUPPORTED_SUBSCRIPTION_FREQUENCIES, + driving_frequency, + frequency_seconds, + normalize_frequency, + periods_per_year, + unique_frequencies, +) + + +def test_normalize_frequency_handles_canonical_and_aliases(): + assert normalize_frequency("1d") == "1d" + assert normalize_frequency("daily") == "1d" + assert normalize_frequency("Day") == "1d" + assert normalize_frequency("1day") == "1d" + + assert normalize_frequency("1w") == "1w" + assert normalize_frequency("weekly") == "1w" + assert normalize_frequency("week") == "1w" + assert normalize_frequency("1week") == "1w" + assert normalize_frequency("1wk") == "1w" + assert normalize_frequency("w1") == "1w" + + assert normalize_frequency("1mo") == "1mo" + assert normalize_frequency("monthly") == "1mo" + assert normalize_frequency("month") == "1mo" + assert normalize_frequency("1month") == "1mo" + assert normalize_frequency("1MO") == "1mo" + + assert normalize_frequency("5分钟") == "5m" + assert normalize_frequency("1小时") == "1h" + + assert normalize_frequency("", default="1h") == "1h" + assert normalize_frequency(None, default="1d") == "1d" + + +def test_frequency_seconds_duration_and_strict_monotonic_hierarchy(): + expected = { + "1m": 60, + "3m": 180, + "5m": 300, + "15m": 900, + "30m": 1800, + "1h": 3600, + "4h": 14_400, + "1d": 86_400, + "1w": 604_800, + "1mo": 2_592_000, + } + for key, seconds in expected.items(): + assert FREQUENCY_SECONDS[key] == seconds + assert frequency_seconds(key) == seconds + + assert ( + frequency_seconds("1m") + < frequency_seconds("5m") + < frequency_seconds("1h") + < frequency_seconds("1d") + < frequency_seconds("1w") + < frequency_seconds("1mo") + ) + assert frequency_seconds("monthly") == 2_592_000 + assert frequency_seconds("1month") == 2_592_000 + assert frequency_seconds("weekly") == 604_800 + assert frequency_seconds("1week") == 604_800 + + +def test_driving_frequency_selects_faster_interval_consistently(): + assert driving_frequency(["1d", "1mo"]) == "1d" + assert driving_frequency(["1mo", "1d"]) == "1d" + assert driving_frequency(["1w", "1mo"]) == "1w" + assert driving_frequency(["1mo", "1w"]) == "1w" + assert driving_frequency(["monthly", "weekly"]) == "1w" + assert driving_frequency(["5m", "1h", "1d", "1mo"]) == "5m" + + +def test_periods_per_year_annualization_factors(): + assert periods_per_year("1mo", ["USStock"]) == 12.0 + assert periods_per_year("1mo", ["Crypto"]) == 12.0 + assert periods_per_year("monthly", ["USStock"]) == 12.0 + assert periods_per_year("1month", ["Forex"]) == 12.0 + + assert periods_per_year("1w", ["USStock"]) == 52.0 + assert periods_per_year("1w", ["Crypto"]) == 52.0 + assert periods_per_year("weekly", ["USStock"]) == 52.0 + + assert periods_per_year("1d", ["USStock"]) == 252.0 + assert periods_per_year("1d", ["Crypto"]) == 365.25 + + assert periods_per_year("1h", ["USStock"]) == pytest.approx(252.0 * 6.5 / 1.0) + assert periods_per_year("1h", ["Crypto"]) == pytest.approx(365.25 * 24.0 / 1.0) + assert periods_per_year("15m", ["USStock"]) == pytest.approx(252.0 * 390.0 / 15.0) + assert periods_per_year("15m", ["Crypto"]) == pytest.approx(365.25 * 1440.0 / 15.0) + + +def test_unique_frequencies_preserves_order_and_deduplicates(): + assert unique_frequencies(["1d", "daily", "1mo", "monthly"]) == ("1d", "1mo") + assert unique_frequencies([]) == ("1d",) + + +def test_supported_subscription_frequencies_excludes_monthly(): + assert "1mo" not in SUPPORTED_SUBSCRIPTION_FREQUENCIES + assert "1w" in SUPPORTED_SUBSCRIPTION_FREQUENCIES + assert "1d" in SUPPORTED_SUBSCRIPTION_FREQUENCIES + assert SUPPORTED_SUBSCRIPTION_FREQUENCIES == ( + "1m", + "3m", + "5m", + "15m", + "30m", + "1h", + "4h", + "1d", + "1w", + ) + diff --git a/backend_api_python/tests/test_indicator_signal_alerts.py b/backend_api_python/tests/test_indicator_signal_alerts.py index 2c430c27f..ae8cfecde 100644 --- a/backend_api_python/tests/test_indicator_signal_alerts.py +++ b/backend_api_python/tests/test_indicator_signal_alerts.py @@ -213,6 +213,9 @@ def test_signal_alert_payload_uses_profile_notification_defaults(monkeypatch): ("1D", 600), ("1w", 1800), ("1W", 1800), + ("1mo", 3600), + ("1MO", 3600), + ("monthly", 3600), ("", 120), (None, 120), ],