Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend_api_python/app/services/indicator_signal_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,7 @@ def _poll_seconds(self, timeframe: str) -> int:
"4h": 300,
"1d": 600,
"1w": 1800,
"1mo": 3600,
}
return mapping.get(tf, 120)

Expand Down
8 changes: 6 additions & 2 deletions backend_api_python/app/services/strategy_v2/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions backend_api_python/app/services/strategy_v2/frequencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
}


Expand All @@ -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
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions backend_api_python/app/services/strategy_v2/market_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ class _SharedFrameEntry:
"4h": 14400,
"1d": 86400,
"1w": 604800,
"1mo": 2592000,
}

PROVIDER_TIMEFRAMES = {
"1h": "1H",
"4h": "4H",
"1d": "1D",
"1w": "1W",
"1mo": "1M",
}


Expand Down
121 changes: 121 additions & 0 deletions backend_api_python/tests/test_frequencies.py
Original file line number Diff line number Diff line change
@@ -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",
)

3 changes: 3 additions & 0 deletions backend_api_python/tests/test_indicator_signal_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
Expand Down
Loading