From 97a397f8fc0e971304495f25b22b1318e88eebe9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 7 Jun 2026 04:09:16 +0800 Subject: [PATCH] Retry BTC market snapshot fetch --- infra/binance_runtime.py | 33 ++++++++++++++++-- main.py | 11 ++++++ notify_i18n_support.py | 2 ++ tests/test_binance_runtime_infra.py | 52 +++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/infra/binance_runtime.py b/infra/binance_runtime.py index cffb7fb7..95e69d19 100644 --- a/infra/binance_runtime.py +++ b/infra/binance_runtime.py @@ -3,10 +3,39 @@ from __future__ import annotations -def resolve_runtime_btc_snapshot(runtime, btc_price, log_buffer, *, fetch_btc_market_snapshot_fn): +def resolve_runtime_btc_snapshot( + runtime, + btc_price, + log_buffer, + *, + fetch_btc_market_snapshot_fn, + max_attempts=1, + retry_delays=(), + sleep_fn=None, + append_log_fn=None, + retry_log_message_fn=None, +): if runtime.btc_market_snapshot is not None: return dict(runtime.btc_market_snapshot) - return fetch_btc_market_snapshot_fn(runtime.client, btc_price, log_buffer=log_buffer) + + attempts = max(1, int(max_attempts)) + delays = tuple(retry_delays or ()) + for attempt in range(1, attempts + 1): + snapshot = fetch_btc_market_snapshot_fn(runtime.client, btc_price, log_buffer=log_buffer) + if snapshot is not None: + return snapshot + if attempt >= attempts: + return None + + delay_seconds = 0 + if delays: + delay_seconds = max(0, delays[min(attempt - 1, len(delays) - 1)]) + if append_log_fn is not None and retry_log_message_fn is not None: + append_log_fn(log_buffer, retry_log_message_fn(attempt + 1, attempts, delay_seconds)) + if sleep_fn is not None and delay_seconds > 0: + sleep_fn(delay_seconds) + + return None def resolve_runtime_trend_indicators(runtime, trend_universe_symbols, *, fetch_daily_indicators_fn): diff --git a/main.py b/main.py index 5964229e..750bc1f9 100644 --- a/main.py +++ b/main.py @@ -159,6 +159,7 @@ TREND_POOL_ACTION_HISTORY_KEY = "trend_action_history" DEFAULT_TREND_POOL_MAX_AGE_DAYS = int(STRATEGY_RUNTIME.artifact_contract["max_age_days"]) DEFAULT_TREND_POOL_ACCEPTABLE_MODES = tuple(STRATEGY_RUNTIME.artifact_contract["acceptable_modes"]) +BTC_MARKET_SNAPSHOT_RETRY_DELAYS = (5, 15) class BalanceFetchError(RuntimeError): @@ -594,6 +595,16 @@ def resolve_runtime_btc_snapshot(runtime, btc_price, log_buffer): btc_price, log_buffer, fetch_btc_market_snapshot_fn=fetch_btc_market_snapshot, + max_attempts=max(1, min(5, get_env_int("BTC_MARKET_SNAPSHOT_MAX_ATTEMPTS", 3))), + retry_delays=BTC_MARKET_SNAPSHOT_RETRY_DELAYS, + sleep_fn=time.sleep, + append_log_fn=append_log, + retry_log_message_fn=lambda attempt, max_attempts, delay_seconds: t( + "btc_daily_retrying", + attempt=attempt, + max_attempts=max_attempts, + delay_seconds=delay_seconds, + ), ) diff --git a/notify_i18n_support.py b/notify_i18n_support.py index b385746c..ace9bb0b 100644 --- a/notify_i18n_support.py +++ b/notify_i18n_support.py @@ -124,6 +124,7 @@ "btc_daily_fetch_failed": "⚠️ BTC daily fetch failed: {error}", "btc_daily_data_empty": "⚠️ BTC daily data empty.", "btc_data_insufficient": "⚠️ BTC data insufficient for MA200/Z-Score. len={length}, last_time={last_time}", + "btc_daily_retrying": "Retrying BTC daily fetch ({attempt}/{max_attempts}) after {delay_seconds:.0f}s.", "asset_unavailable_for_circuit_breaker_sell": "{asset} unavailable for circuit-breaker sell", "asset_unavailable_for_trend_sell": "{asset} unavailable for trend sell", "usdt_unavailable_for_trend_buy": "USDT unavailable for trend buy", @@ -249,6 +250,7 @@ "btc_daily_fetch_failed": "⚠️ 拉取 BTC 日线失败: {error}", "btc_daily_data_empty": "⚠️ BTC 日线数据为空。", "btc_data_insufficient": "⚠️ BTC 数据不足,无法计算 MA200/Z-Score。len={length}, last_time={last_time}", + "btc_daily_retrying": "正在重试拉取 BTC 日线({attempt}/{max_attempts}),等待 {delay_seconds:.0f} 秒。", "asset_unavailable_for_circuit_breaker_sell": "{asset} 不足,无法执行熔断卖出", "asset_unavailable_for_trend_sell": "{asset} 不足,无法执行趋势卖出", "usdt_unavailable_for_trend_buy": "USDT 不足,无法执行趋势买入", diff --git a/tests/test_binance_runtime_infra.py b/tests/test_binance_runtime_infra.py index dbc453d4..1f1a83ec 100644 --- a/tests/test_binance_runtime_infra.py +++ b/tests/test_binance_runtime_infra.py @@ -24,6 +24,58 @@ def test_resolve_runtime_btc_snapshot_prefers_injected_snapshot(self): self.assertEqual(snapshot, {"ahr999": 0.8}) self.assertIsNot(snapshot, runtime.btc_market_snapshot) + def test_resolve_runtime_btc_snapshot_retries_before_success(self): + runtime = SimpleNamespace(client=object(), btc_market_snapshot=None) + log_buffer = [] + observed = {"calls": 0, "sleeps": []} + + def fetch_snapshot(_client, _btc_price, log_buffer=None): + observed["calls"] += 1 + if observed["calls"] < 3: + return None + return {"ahr999": 0.8} + + snapshot = resolve_runtime_btc_snapshot( + runtime, + 50_000.0, + log_buffer, + fetch_btc_market_snapshot_fn=fetch_snapshot, + max_attempts=3, + retry_delays=(1, 2), + sleep_fn=lambda seconds: observed["sleeps"].append(seconds), + append_log_fn=lambda buffer, message: buffer.append(message), + retry_log_message_fn=lambda attempt, max_attempts, delay_seconds: ( + f"retry {attempt}/{max_attempts} after {delay_seconds}s" + ), + ) + + self.assertEqual(snapshot, {"ahr999": 0.8}) + self.assertEqual(observed["calls"], 3) + self.assertEqual(observed["sleeps"], [1, 2]) + self.assertEqual(log_buffer, ["retry 2/3 after 1s", "retry 3/3 after 2s"]) + + def test_resolve_runtime_btc_snapshot_returns_none_after_retries(self): + runtime = SimpleNamespace(client=object(), btc_market_snapshot=None) + observed = {"calls": 0, "sleeps": []} + + def fetch_missing_snapshot(*_args, **_kwargs): + observed["calls"] += 1 + return None + + snapshot = resolve_runtime_btc_snapshot( + runtime, + 50_000.0, + [], + fetch_btc_market_snapshot_fn=fetch_missing_snapshot, + max_attempts=2, + retry_delays=(1,), + sleep_fn=lambda seconds: observed["sleeps"].append(seconds), + ) + + self.assertIsNone(snapshot) + self.assertEqual(observed["calls"], 2) + self.assertEqual(observed["sleeps"], [1]) + def test_resolve_runtime_trend_indicators_fetches_when_not_injected(self): runtime = SimpleNamespace(client=object(), trend_indicator_snapshots=None) observed_symbols = []