Skip to content

Commit be014fa

Browse files
authored
Merge pull request #211 from QuantStrategyLab/codex/binance-r5-atomic-claim-20260905
fix: stop retries after uncertain earn submissions
2 parents 69763e5 + 7640bee commit be014fa

5 files changed

Lines changed: 409 additions & 15 deletions

File tree

infra/binance_runtime.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
from runtime_support import ExecutionIntegrityError
6+
57

68
def resolve_runtime_btc_snapshot(
79
runtime,
@@ -98,6 +100,8 @@ def ensure_asset_available_runtime(
98100
if not runtime.dry_run:
99101
sleep_fn(3)
100102
return True
103+
except ExecutionIntegrityError:
104+
raise
101105
except Exception:
102106
runtime_notify_fn(
103107
runtime,
@@ -175,6 +179,8 @@ def manage_usdt_earn_buffer_runtime(
175179
effect_type="earn_redeem",
176180
)
177181
append_log_fn(log_buffer, translate_fn("cash_manager_redeeming_to_spot", amount=redeem_amt))
182+
except ExecutionIntegrityError:
183+
raise
178184
except Exception:
179185
append_log_fn(
180186
log_buffer,

runtime_support.py

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@
2929
_ORDER_SUBMISSION_UNKNOWN = "SUBMISSION_UNKNOWN"
3030
_ORDER_SUBMISSION_TERMINAL = "TERMINAL"
3131
_ORDER_CLIENT_ID_PREFIX = "QSL_"
32+
_EARN_METHOD_BY_EFFECT_TYPE = {
33+
"earn_redeem": "redeem_simple_earn_flexible_product",
34+
"earn_subscribe": "subscribe_simple_earn_flexible_product",
35+
}
36+
_EARN_SUCCESS_ID_BY_METHOD = {
37+
"redeem_simple_earn_flexible_product": "redeemId",
38+
"subscribe_simple_earn_flexible_product": "purchaseId",
39+
}
3240
_LAST_API_CALL_TS: float = 0.0
3341
RUNTIME_EVIDENCE_CONTRACT_VERSION = "qsl.runtime_evidence_aggregate.v1"
3442
RECONCILIATION_STATUSES = frozenset({"MISSING", "MATCHED", "MISMATCHED"})
@@ -610,11 +618,15 @@ def _load_order_submission_state(runtime):
610618
if set(record) != {"state"}:
611619
raise StatePersistenceError("submission_state_invalid") from None
612620
elif status == _ORDER_SUBMISSION_UNKNOWN:
613-
if set(record) != {"state", "identity_sha256", "symbol"}:
614-
raise StatePersistenceError("submission_state_invalid") from None
615621
if not _is_sha256(record.get("identity_sha256")):
616622
raise StatePersistenceError("submission_state_invalid") from None
617-
if not re.fullmatch(r"[A-Z0-9]{3,30}", str(record.get("symbol") or "")):
623+
if set(record) == {"state", "identity_sha256", "symbol"}:
624+
if not re.fullmatch(r"[A-Z0-9]{3,30}", str(record.get("symbol") or "")):
625+
raise StatePersistenceError("submission_state_invalid") from None
626+
elif set(record) == {"state", "identity_sha256", "method_name"}:
627+
if str(record.get("method_name") or "") not in _EARN_SUCCESS_ID_BY_METHOD:
628+
raise StatePersistenceError("submission_state_invalid") from None
629+
else:
618630
raise StatePersistenceError("submission_state_invalid") from None
619631
else:
620632
raise StatePersistenceError("submission_state_invalid") from None
@@ -665,6 +677,14 @@ def _is_order_transport_uncertainty(exc):
665677
)
666678

667679

680+
def _is_confirmed_earn_success(method_name, response):
681+
response_id_field = _EARN_SUCCESS_ID_BY_METHOD.get(str(method_name))
682+
if not response_id_field or not isinstance(response, Mapping) or response.get("success") is not True:
683+
return False
684+
response_id = response.get(response_id_field)
685+
return isinstance(response_id, int) and not isinstance(response_id, bool) and response_id > 0
686+
687+
668688
def _reconcile_uncertain_order(client, symbol, identity_sha256):
669689
if not symbol or not identity_sha256:
670690
raise OrderReconciliationError("order_reconciliation_uncertain") from None
@@ -705,13 +725,20 @@ def runtime_call_client(runtime, report, *, method_name, payload, effect_type,
705725
raise RuntimeError("runtime.client is not configured")
706726

707727
is_order_call = str(effect_type or "").startswith("order_")
728+
earn_method = _EARN_METHOD_BY_EFFECT_TYPE.get(str(effect_type or ""))
729+
is_earn_call = earn_method == str(method_name)
730+
if str(effect_type or "").startswith("earn_") and not is_earn_call:
731+
raise StatePersistenceError("submission_state_invalid") from None
732+
is_funding_mutation = is_order_call or is_earn_call
708733
client_payload = dict(payload)
709734
trade_state = None
710735
identity_sha256 = None
711-
if is_order_call:
736+
if is_funding_mutation:
712737
trade_state, submission_record = _load_order_submission_state(runtime)
713738
submission_status = submission_record["state"]
714739
if submission_status == _ORDER_SUBMISSION_UNKNOWN:
740+
if "method_name" in submission_record or is_earn_call:
741+
raise OrderReconciliationError("order_reconciliation_uncertain") from None
715742
current_payload, current_identity_sha256 = _ensure_order_logical_identity(runtime, method_name, payload)
716743
if current_identity_sha256 != submission_record["identity_sha256"]:
717744
raise OrderReconciliationError("order_reconciliation_intent_mismatch") from None
@@ -730,19 +757,64 @@ def runtime_call_client(runtime, report, *, method_name, payload, effect_type,
730757
trade_state,
731758
{"state": _ORDER_SUBMISSION_RESERVED},
732759
)
733-
client_payload, identity_sha256 = _ensure_order_logical_identity(runtime, method_name, payload)
734-
association = _build_order_request_association(method_name, client_payload)
735-
symbol = association["symbol"]
736-
_persist_order_submission_state(
737-
runtime,
738-
trade_state,
739-
{
760+
if is_order_call:
761+
client_payload, identity_sha256 = _ensure_order_logical_identity(runtime, method_name, payload)
762+
association = _build_order_request_association(method_name, client_payload)
763+
symbol = association["symbol"]
764+
unknown_record = {
740765
"state": _ORDER_SUBMISSION_UNKNOWN,
741766
"identity_sha256": identity_sha256,
742767
"symbol": symbol,
743-
},
768+
}
769+
else:
770+
_unused_order_payload, identity_sha256 = _ensure_order_logical_identity(runtime, method_name, payload)
771+
unknown_record = {
772+
"state": _ORDER_SUBMISSION_UNKNOWN,
773+
"identity_sha256": identity_sha256,
774+
"method_name": str(method_name),
775+
}
776+
_persist_order_submission_state(
777+
runtime,
778+
trade_state,
779+
unknown_record,
744780
)
745781
record_order_submission_attempt(report)
782+
if is_earn_call:
783+
_rate_limit_pause()
784+
try:
785+
response = getattr(runtime.client, method_name)(**client_payload)
786+
except Exception as exc:
787+
if _is_order_transport_uncertainty(exc):
788+
record_order_transport_uncertainty(report)
789+
record_side_effect(
790+
runtime,
791+
report,
792+
effect_type=f"{effect_type}_failed",
793+
target=method_name,
794+
payload={"payload": dict(client_payload), "reason": "order_reconciliation_uncertain", "retries": 0},
795+
executed=False,
796+
)
797+
raise OrderReconciliationError("order_reconciliation_uncertain") from None
798+
if not _is_confirmed_earn_success(method_name, response):
799+
record_side_effect(
800+
runtime,
801+
report,
802+
effect_type=f"{effect_type}_failed",
803+
target=method_name,
804+
payload={"payload": dict(client_payload), "reason": "order_reconciliation_uncertain", "retries": 0},
805+
executed=False,
806+
)
807+
raise OrderReconciliationError("order_reconciliation_uncertain") from None
808+
record_side_effect(
809+
runtime,
810+
report,
811+
effect_type=effect_type,
812+
target=method_name,
813+
payload=dict(client_payload),
814+
executed=True,
815+
)
816+
_persist_order_submission_state(runtime, trade_state, {"state": _ORDER_SUBMISSION_TERMINAL})
817+
return response
746818
_rate_limit_pause()
747819
retries_used = max_retries
748820
for attempt in range(max_retries + 1):

tests/test_binance_runtime_infra.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
resolve_runtime_btc_snapshot,
99
resolve_runtime_trend_indicators,
1010
)
11+
from runtime_support import ExecutionIntegrityError
1112

1213

1314
class BinanceRuntimeInfraTests(unittest.TestCase):
@@ -153,6 +154,51 @@ def get_simple_earn_flexible_product_list(self, *, asset):
153154
self.assertEqual(observed["calls"][0][0], "subscribe_simple_earn_flexible_product")
154155
self.assertEqual(len(observed["logs"]), 1)
155156

157+
def test_earn_integrity_errors_are_not_swallowed(self):
158+
failure = ExecutionIntegrityError("order_reconciliation_uncertain")
159+
observed = {"notifications": [], "logs": [], "sleeps": []}
160+
161+
class RedemptionClient:
162+
def get_asset_balance(self, *, asset):
163+
return {"free": "2.0"}
164+
165+
def get_simple_earn_flexible_product_position(self, *, asset):
166+
return {"rows": [{"productId": "earn-1", "totalAmount": "5.0"}]}
167+
168+
with self.assertRaises(ExecutionIntegrityError):
169+
ensure_asset_available_runtime(
170+
SimpleNamespace(client=RedemptionClient(), dry_run=False),
171+
{"redemption_subscription_intents": []},
172+
"ETH",
173+
3.0,
174+
[],
175+
runtime_call_client_fn=lambda *_args, **_kwargs: (_ for _ in ()).throw(failure),
176+
append_log_fn=lambda _buffer, message: observed["logs"].append(message),
177+
runtime_notify_fn=lambda _runtime, _report, text: observed["notifications"].append(text),
178+
translate_fn=lambda key, **_kwargs: key,
179+
sleep_fn=lambda seconds: observed["sleeps"].append(seconds),
180+
)
181+
182+
class SubscriptionClient:
183+
def get_asset_balance(self, *, asset):
184+
return {"free": "150.0"}
185+
186+
def get_simple_earn_flexible_product_list(self, *, asset):
187+
return {"rows": [{"productId": "earn-1"}]}
188+
189+
with self.assertRaises(ExecutionIntegrityError):
190+
manage_usdt_earn_buffer_runtime(
191+
SimpleNamespace(client=SubscriptionClient()),
192+
{"redemption_subscription_intents": []},
193+
100.0,
194+
[],
195+
runtime_call_client_fn=lambda *_args, **_kwargs: (_ for _ in ()).throw(failure),
196+
append_log_fn=lambda _buffer, message: observed["logs"].append(message),
197+
translate_fn=lambda key, **_kwargs: key,
198+
)
199+
200+
self.assertEqual(observed, {"notifications": [], "logs": [], "sleeps": []})
201+
156202
def test_ensure_runtime_client_marks_report_aborted_after_retries(self):
157203
runtime = SimpleNamespace(client=None, api_key="key", api_secret="secret")
158204
report = {"status": "ok"}

tests/test_cycle_service.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from application.execution_service import execute_trend_buys
1010
from infra.binance_runtime import ensure_runtime_client
1111
from runtime_support import (
12+
ExecutionIntegrityError,
1213
ExecutionRuntime,
1314
append_report_error,
1415
build_execution_report,
@@ -18,11 +19,29 @@
1819

1920

2021
class CycleServiceTests(unittest.TestCase):
21-
def _run_funds_cycle(self, execution_permitted, *, post_execution_permitted=None, snapshot_error=False, state=None):
22+
def _run_funds_cycle(
23+
self,
24+
execution_permitted,
25+
*,
26+
post_execution_permitted=None,
27+
snapshot_error=False,
28+
state=None,
29+
earn_failure=False,
30+
):
2231
events = []
2332
state = {} if state is None else state
2433
post_execution_permitted = execution_permitted if post_execution_permitted is None else post_execution_permitted
2534
allocation_permissions = [execution_permitted, post_execution_permitted]
35+
36+
def manage_earn(*_args, **_kwargs):
37+
events.append("earn")
38+
if earn_failure:
39+
raise ExecutionIntegrityError("order_reconciliation_uncertain")
40+
41+
def send_periodic_status(*_args, **_kwargs):
42+
if earn_failure:
43+
events.append("periodic")
44+
2645
runtime = SimpleNamespace(
2746
dry_run=False,
2847
now_utc=SimpleNamespace(strftime=lambda fmt: "20260905" if "%d" in fmt else "2026-09-05"),
@@ -82,8 +101,8 @@ def _run_funds_cycle(self, execution_permitted, *, post_execution_permitted=None
82101
run_daily_circuit_breaker=lambda *_args: events.append("circuit_breaker") or False,
83102
execute_trend_rotation=lambda *_args, **_kwargs: events.append("trend") or 185.0,
84103
execute_btc_dca_cycle=lambda *_args: events.append("dca") or 185.0,
85-
manage_usdt_earn_buffer_runtime=lambda *_args, **_kwargs: events.append("earn"),
86-
maybe_send_periodic_btc_status_report=lambda *_args, **_kwargs: None,
104+
manage_usdt_earn_buffer_runtime=manage_earn,
105+
maybe_send_periodic_btc_status_report=send_periodic_status,
87106
runtime_set_trade_state=lambda *_args, **_kwargs: events.append("state_write"),
88107
append_report_error=lambda *_args, **_kwargs: None,
89108
runtime_notify=lambda *_args, **_kwargs: None,
@@ -127,6 +146,14 @@ def test_post_trade_risk_veto_blocks_dca_and_earn_actions(self):
127146
self.assertNotIn("dca", events)
128147
self.assertNotIn("earn", events)
129148

149+
def test_earn_integrity_error_stops_final_state_write_and_later_cycle_actions(self):
150+
report, events = self._run_funds_cycle(True, earn_failure=True)
151+
152+
self.assertEqual(report["status"], "error")
153+
self.assertIn("earn", events)
154+
self.assertNotIn("periodic", events)
155+
self.assertNotIn("state_write", events)
156+
130157
def test_research_cycle_settings_require_dry_run(self):
131158
runtime = SimpleNamespace(
132159
dry_run=False,

0 commit comments

Comments
 (0)