diff --git a/packages/loopx-finance-execution/src/loopx_finance_execution/transient_retry.py b/packages/loopx-finance-execution/src/loopx_finance_execution/transient_retry.py new file mode 100644 index 0000000000..5d79f1f631 --- /dev/null +++ b/packages/loopx-finance-execution/src/loopx_finance_execution/transient_retry.py @@ -0,0 +1,108 @@ +"""Bounded retry policy for transient venue refusals. + +A venue refuses an order for many reasons. Some are transient (rate limits, +request timeouts) and worth one bounded wait-and-retry. Others are terminal +(insufficient balance, invalid precision) and must stop immediately. Retrying a +submission is only safe when the executor first proves that the earlier attempt +produced no effect, so every decision helper here takes that observation. + +The module is deliberately provider-neutral: it classifies a bounded set of +public refusal signals, computes the next backoff, and refuses to retry when the +operation deadline passed, the attempt budget is spent or a venue effect is +already visible. It performs no IO and grants no execution authority. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import re + + +RETRY_POLICY_SCHEMA_VERSION = "finance_transient_retry_policy_v0" + +#: Fixed patterns for transient venue refusals. Anything not matched here is +#: treated as terminal: an unknown refusal is never retried. +RETRYABLE_PATTERNS = ( + r"请求过于频繁", + r"rate.?limit", + r"too many requests", + r"too frequent", + r"timeout|timed out", + r"超时", + r"稍后重试", + r"try again", +) + +RETRYABLE = "retryable" +TERMINAL = "terminal" + + +@dataclass(frozen=True) +class RetryPolicy: + """Attempt budget, backoff schedule and wall-clock ceiling.""" + + max_attempts: int = 4 + waits_seconds: tuple[float, ...] = (12.0, 25.0, 35.0) + max_total_seconds: float = 150.0 + + def __post_init__(self) -> None: + if self.max_attempts < 1: + raise ValueError("max_attempts must be positive") + if not self.waits_seconds or any(wait < 0 for wait in self.waits_seconds): + raise ValueError("waits_seconds must be non-negative") + if self.max_total_seconds <= 0: + raise ValueError("max_total_seconds must be positive") + + +def classify_venue_refusal(message: object) -> str: + """Return RETRYABLE or TERMINAL for one venue refusal message.""" + + text = str(message or "").strip() + if not text: + return TERMINAL + for pattern in RETRYABLE_PATTERNS: + if re.search(pattern, text, re.IGNORECASE): + return RETRYABLE + return TERMINAL + + +def next_wait_seconds(policy: RetryPolicy, attempt: int) -> float: + """Backoff before ``attempt`` (1-based). The last entry repeats.""" + + if attempt < 2: + return 0.0 + index = min(attempt - 2, len(policy.waits_seconds) - 1) + return float(policy.waits_seconds[index]) + + +def may_retry( + *, + policy: RetryPolicy, + attempt: int, + elapsed_seconds: float, + venue_effect_observed: bool, + refusal: object, + deadline: datetime | None = None, + now: datetime | None = None, +) -> tuple[bool, str]: + """Decide whether one more submission attempt is admissible. + + Returns ``(allowed, reason)``. ``reason`` is a stable public token, never a + venue payload. + """ + + if venue_effect_observed: + return False, "venue_effect_observed" + if attempt >= policy.max_attempts: + return False, "attempt_budget_spent" + if elapsed_seconds + next_wait_seconds(policy, attempt + 1) > policy.max_total_seconds: + return False, "time_budget_spent" + if classify_venue_refusal(refusal) != RETRYABLE: + return False, "refusal_not_retryable" + if deadline is not None and now is not None: + clock = now.astimezone(timezone.utc) + expiry = deadline if deadline.tzinfo else deadline.replace(tzinfo=timezone.utc) + if clock >= expiry.astimezone(timezone.utc): + return False, "operation_expired" + return True, "retry_admissible" diff --git a/tests/extensions/test_finance_transient_retry.py b/tests/extensions/test_finance_transient_retry.py new file mode 100644 index 0000000000..8853a1a857 --- /dev/null +++ b/tests/extensions/test_finance_transient_retry.py @@ -0,0 +1,94 @@ +"""Synthetic coverage for the bounded venue-refusal retry policy.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "packages" / "loopx-finance-execution" / "src")) + +from loopx_finance_execution.transient_retry import ( # noqa: E402 + RETRYABLE, + TERMINAL, + RetryPolicy, + classify_venue_refusal, + may_retry, + next_wait_seconds, +) + + +NOW = datetime(2026, 10, 17, 4, 0, tzinfo=timezone.utc) +POLICY = RetryPolicy() + + +def _decide(**overrides): + arguments = { + "policy": POLICY, + "attempt": 1, + "elapsed_seconds": 0.0, + "venue_effect_observed": False, + "refusal": "Hyperliquid 请求过于频繁,请等待 10 秒后重试。", + } + arguments.update(overrides) + return may_retry(**arguments) + + +def test_rate_limit_is_retryable() -> None: + assert classify_venue_refusal("Hyperliquid 请求过于频繁,请等待 10 秒后重试。") == RETRYABLE + assert classify_venue_refusal("rate limit exceeded") == RETRYABLE + assert classify_venue_refusal("Request timeout after 30000ms") == RETRYABLE + + +def test_terminal_refusals_are_not_retried() -> None: + for message in ("余额不足", "invalid price precision", "", None, "order rejected"): + assert classify_venue_refusal(message) == TERMINAL + allowed, reason = _decide(refusal=message) + assert allowed is False and reason == "refusal_not_retryable" + + +def test_visible_effect_never_retries() -> None: + allowed, reason = _decide(venue_effect_observed=True) + assert allowed is False and reason == "venue_effect_observed" + + +def test_attempt_budget_is_enforced() -> None: + allowed, reason = _decide(attempt=POLICY.max_attempts) + assert allowed is False and reason == "attempt_budget_spent" + + +def test_time_budget_is_enforced() -> None: + allowed, reason = _decide(elapsed_seconds=POLICY.max_total_seconds - 1) + assert allowed is False and reason == "time_budget_spent" + + +def test_deadline_stops_retries() -> None: + allowed, reason = _decide(deadline=NOW, now=NOW) + assert allowed is False and reason == "operation_expired" + allowed, reason = _decide(deadline=NOW + timedelta(minutes=1), now=NOW) + assert allowed is True and reason == "retry_admissible" + + +def test_backoff_schedule() -> None: + assert next_wait_seconds(POLICY, 1) == 0.0 + assert next_wait_seconds(POLICY, 2) == 12.0 + assert next_wait_seconds(POLICY, 3) == 25.0 + assert next_wait_seconds(POLICY, 4) == 35.0 + # The last entry repeats instead of wrapping to zero. + assert next_wait_seconds(POLICY, 9) == 35.0 + + +def test_invalid_policy_is_rejected() -> None: + for kwargs in ( + {"max_attempts": 0}, + {"waits_seconds": ()}, + {"waits_seconds": (-1.0,)}, + {"max_total_seconds": 0}, + ): + try: + RetryPolicy(**kwargs) + except ValueError: + continue + raise AssertionError(f"expected RetryPolicy to reject {kwargs}")