From 7e551868b5dd232ad6d1c61d542e3e6fb1805276 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:48:12 +0800 Subject: [PATCH 1/2] fix: bind Binance execution authority to QPK 9618 Co-Authored-By: Codex --- application/cycle_service.py | 2 + application/execution_service.py | 41 ++++++++ decision_mapper.py | 96 +++++++++++++++--- main.py | 10 +- pyproject.toml | 4 +- qsl.toml | 2 +- tests/test_cycle_replay_runtime.py | 16 ++- tests/test_decision_mapper.py | 100 +++++++++++++++++++ tests/test_execution_service.py | 155 +++++++++++++++++++++++++++++ uv.lock | 6 +- 10 files changed, 404 insertions(+), 28 deletions(-) diff --git a/application/cycle_service.py b/application/cycle_service.py index 4f567137..38fa43d0 100644 --- a/application/cycle_service.py +++ b/application/cycle_service.py @@ -127,6 +127,7 @@ def execute_strategy_cycle( trend_daily_pnl, circuit_breaker_pct, log_buffer, + decision=allocation.get("execution_decision"), ): return report @@ -185,6 +186,7 @@ def execute_strategy_cycle( btc_base_order_usdt, today_id_str, log_buffer, + decision=post_trade_allocation.get("execution_decision"), ) manage_usdt_earn_buffer_runtime( diff --git a/application/execution_service.py b/application/execution_service.py index 62087b31..bbc9d471 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +from decision_mapper import has_execution_authority from runtime_support import record_gating_event @@ -21,6 +22,17 @@ def _is_missing(value) -> bool: return False +def _block_unapproved_execution(report, decision, *, category) -> bool: + if has_execution_authority(decision): + return False + record_gating_event( + report, + gate="execution_authority_not_approved", + category=category, + ) + return True + + def build_trend_candidate_filter_diagnostics( active_trend_pool, trend_indicators, @@ -106,6 +118,7 @@ def run_daily_circuit_breaker( circuit_breaker_pct, log_buffer, *, + decision=None, format_qty_fn, runtime_notify_fn, ensure_asset_available_fn, @@ -117,6 +130,8 @@ def run_daily_circuit_breaker( ): if trend_daily_pnl > circuit_breaker_pct: return False + if _block_unapproved_execution(report, decision, category="daily_circuit_breaker"): + return False for symbol, config in runtime_trend_universe.items(): tradable_qty = balances[symbol] @@ -448,6 +463,23 @@ def execute_trend_rotation( report.setdefault("diagnostics", {})["combo"] = combo_diagnostics report["selected_symbols"]["active_trend_pool"] = list(active_trend_pool) report["selected_symbols"]["selected_candidates"] = list(selected_candidates.keys()) + if _block_unapproved_execution(report, strategy_plan.get("decision"), category="trend_rotation"): + report["selected_symbols"]["selected_candidates"] = [] + append_rotation_summary( + log_buffer, + official_trend_pool_symbols, + active_trend_pool, + {}, + ) + append_trend_symbol_status( + log_buffer, + runtime_trend_universe, + prices, + trend_indicators, + state, + btc_snapshot, + ) + return u_total if not selected_candidates: record_gating_event( report, @@ -498,6 +530,12 @@ def execute_trend_rotation( selected_candidates = dict(post_sell_plan["selected_candidates"]) eligible_buy_symbols = list(post_sell_plan["eligible_buy_symbols"]) planned_trend_buys = dict(post_sell_plan["planned_trend_buys"]) + if _block_unapproved_execution( + report, + post_sell_plan.get("decision"), + category="trend_rotation", + ): + return u_total if selected_candidates and not eligible_buy_symbols: record_gating_event( report, @@ -567,6 +605,7 @@ def execute_btc_dca_cycle( today_id_str, log_buffer, *, + decision=None, append_log_fn, translate_fn, format_qty_fn, @@ -576,6 +615,8 @@ def execute_btc_dca_cycle( runtime_notify_fn, runtime_set_trade_state_fn, ): + if _block_unapproved_execution(report, decision, category="btc_dca"): + return u_total if dca_usdt_pool <= 10 and dca_val <= 10: record_gating_event( report, diff --git a/decision_mapper.py b/decision_mapper.py index af098f62..efbe99a4 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -1,11 +1,67 @@ from __future__ import annotations +import re from collections.abc import Mapping from typing import Any from quant_platform_kit.strategy_contracts import StrategyDecision +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") + + +def _approved_scoped_assessment(value: Any, *, scope: str) -> Mapping[str, Any] | None: + """Accept only serialized QPK approval evidence; never infer or recompute risk authority.""" + if not isinstance(value, Mapping): + return None + reason_codes = value.get("reason_codes") + if ( + value.get("scope") != scope + or value.get("outcome") != "APPROVE" + or not isinstance(reason_codes, (list, tuple)) + or reason_codes + ): + return None + for field in ( + "mandate_authority_receipt_sha256", + "candidate_identity_sha256", + "decision_digest_sha256", + "portfolio_snapshot_digest_sha256", + "assessment_sha256", + ): + if not isinstance(value.get(field), str) or not _SHA256_PATTERN.fullmatch(value[field]): + return None + for field in ("contract_version", "evaluated_at", "policy_id", "policy_version"): + if not isinstance(value.get(field), str) or not value[field].strip(): + return None + return value + + +def has_execution_authority(decision: StrategyDecision | None) -> bool: + """Require matching QPK RiskEngine, MEMBER, and ACCOUNT approval evidence.""" + if not isinstance(decision, StrategyDecision): + return False + diagnostics = decision.diagnostics + if not isinstance(diagnostics, Mapping) or diagnostics.get("risk_gate") != "APPROVE": + return False + if any(str(flag).startswith("rejected:") for flag in decision.risk_flags): + return False + member = _approved_scoped_assessment( + diagnostics.get("member_risk_assessment"), + scope="MEMBER", + ) + account = _approved_scoped_assessment( + diagnostics.get("account_risk_assessment"), + scope="ACCOUNT", + ) + if member is None or account is None: + return False + return all( + member[field] == account[field] + for field in ("candidate_identity_sha256", "decision_digest_sha256") + ) + + def _budget_map(decision: StrategyDecision) -> dict[str, float]: values: dict[str, float] = {} for budget in decision.budgets: @@ -28,28 +84,40 @@ def map_strategy_decision_to_allocation( account_metrics: Mapping[str, Any], ) -> dict[str, float]: diagnostics = dict(decision.diagnostics) - budgets = _budget_map(decision) - positions = _position_weight_map(decision) - trend_target_ratio = float( - diagnostics.get( - "trend_target_ratio", - sum(weight for symbol, weight in positions.items() if symbol != "BTCUSDT"), + authorized = has_execution_authority(decision) + budgets = _budget_map(decision) if authorized else {} + positions = _position_weight_map(decision) if authorized else {} + trend_target_ratio = ( + float( + diagnostics.get( + "trend_target_ratio", + sum(weight for symbol, weight in positions.items() if symbol != "BTCUSDT"), + ) ) + if authorized + else 0.0 ) return { "total_equity": float(account_metrics["total_equity"]), "trend_val": float(account_metrics["trend_value"]), "dca_val": float(account_metrics["dca_value"]), - "btc_target_ratio": float(diagnostics.get("btc_target_ratio", positions.get("BTCUSDT", 0.0))), + "btc_target_ratio": ( + float(diagnostics.get("btc_target_ratio", positions.get("BTCUSDT", 0.0))) + if authorized + else 0.0 + ), "trend_target_ratio": trend_target_ratio, "trend_usdt_pool": float(budgets.get("trend_rotation_pool", 0.0)), "dca_usdt_pool": float(budgets.get("btc_core_dca_pool", 0.0)), - "btc_base_order_usdt": float(diagnostics.get("btc_base_order_usdt", 0.0)), + "btc_base_order_usdt": ( + float(diagnostics.get("btc_base_order_usdt", 0.0)) if authorized else 0.0 + ), } def map_strategy_decision_to_rotation_plan(decision: StrategyDecision) -> dict[str, Any]: diagnostics = dict(decision.diagnostics) + authorized = has_execution_authority(decision) metadata = diagnostics.get("metadata") if isinstance(diagnostics.get("metadata"), Mapping) else {} combo_meta = metadata.get("combo") if isinstance(metadata.get("combo"), Mapping) else {} selected_candidates = { @@ -59,20 +127,24 @@ def map_strategy_decision_to_rotation_plan(decision: StrategyDecision) -> dict[s "abs_momentum": float(payload.get("abs_momentum", 0.0)), } for symbol, payload in dict(diagnostics.get("rotation_candidates", {})).items() - } + } if authorized else {} planned_trend_buys = { str(symbol): float(amount) for symbol, amount in dict(diagnostics.get("planned_trend_buys", {})).items() - } + } if authorized else {} sell_reasons = { str(symbol): str(reason) for symbol, reason in dict(diagnostics.get("sell_reasons", {})).items() if str(reason) - } + } if authorized else {} return { "active_trend_pool": list(diagnostics.get("trend_pool", ())), "selected_candidates": selected_candidates, - "eligible_buy_symbols": [str(symbol) for symbol in diagnostics.get("eligible_buy_symbols", ())], + "eligible_buy_symbols": ( + [str(symbol) for symbol in diagnostics.get("eligible_buy_symbols", ())] + if authorized + else [] + ), "planned_trend_buys": planned_trend_buys, "sell_reasons": sell_reasons, "rotation_pool_source_version": diagnostics.get("rotation_pool_source_version"), diff --git a/main.py b/main.py index e3db73d1..fa5f673e 100644 --- a/main.py +++ b/main.py @@ -874,10 +874,12 @@ def _compute_portfolio_allocation(runtime, runtime_trend_universe, balances, pri u_total, fuel_val, ) - return map_decision_to_allocation( + allocation = map_decision_to_allocation( evaluation.decision, account_metrics=evaluation.account_metrics, ) + allocation["execution_decision"] = evaluation.decision + return allocation def _build_balance_snapshot(runtime_trend_universe, balances, u_total): @@ -949,6 +951,8 @@ def _run_daily_circuit_breaker( trend_daily_pnl, circuit_breaker_pct, log_buffer, + *, + decision=None, ): return app_run_daily_circuit_breaker( runtime, @@ -961,6 +965,7 @@ def _run_daily_circuit_breaker( trend_daily_pnl, circuit_breaker_pct, log_buffer, + decision=decision, format_qty_fn=format_qty, runtime_notify_fn=runtime_notify, ensure_asset_available_fn=ensure_asset_available_runtime, @@ -1128,6 +1133,8 @@ def _execute_btc_dca_cycle( btc_base_order_usdt, today_id_str, log_buffer, + *, + decision=None, ): return app_execute_btc_dca_cycle( runtime, @@ -1144,6 +1151,7 @@ def _execute_btc_dca_cycle( btc_base_order_usdt, today_id_str, log_buffer, + decision=decision, append_log_fn=append_log, translate_fn=t, format_qty_fn=format_qty, diff --git a/pyproject.toml b/pyproject.toml index c45cc399..95c261ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "QuantStrategyLab platform layer for Binance exchange." requires-python = ">=3.11" dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@61783fdaee869bfeedd4289ae4b7f27104513759", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9618b4bd8e179760ac174914713598762cab15d7", "crypto-strategies @ git+https://github.com/QuantStrategyLab/CryptoStrategies.git@ef78312d7653095f585c4f75d45bf765bedc2751", "python-binance", "pandas", @@ -23,7 +23,7 @@ test = [ [tool.uv] override-dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@61783fdaee869bfeedd4289ae4b7f27104513759", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9618b4bd8e179760ac174914713598762cab15d7", ] [tool.ruff] diff --git a/qsl.toml b/qsl.toml index 2a80a5c9..61dcabcb 100644 --- a/qsl.toml +++ b/qsl.toml @@ -9,7 +9,7 @@ expires_at = "2026-09-30" next_action = "keep uv.lock current and maintain QPK/CryptoStrategies pin consistency" [qsl.requires] -quant_platform_kit = "61783fdaee869bfeedd4289ae4b7f27104513759" +quant_platform_kit = "9618b4bd8e179760ac174914713598762cab15d7" crypto_strategies = "ef78312d7653095f585c4f75d45bf765bedc2751" [qsl.compat] diff --git a/tests/test_cycle_replay_runtime.py b/tests/test_cycle_replay_runtime.py index 192261e7..5a7cdbec 100644 --- a/tests/test_cycle_replay_runtime.py +++ b/tests/test_cycle_replay_runtime.py @@ -103,7 +103,9 @@ def test_dry_run_produces_no_real_side_effects(self): self.assertEqual(result["state_store"].write_calls, []) self.assertEqual(report["side_effect_summary"]["executed_call_count"], 0) self.assertGreater(report["side_effect_summary"]["suppressed_call_count"], 0) - self.assertGreaterEqual(len(report["buy_sell_intents"]), 2) + self.assertEqual(report["buy_sell_intents"], []) + self.assertEqual(report["btc_dca_intents"], []) + self.assertEqual(report["gating_summary"]["execution_authority_not_approved"], 2) self.assertGreaterEqual(len(report["redemption_subscription_intents"]), 1) def test_fixed_input_produces_deterministic_execution_report(self): @@ -115,15 +117,11 @@ def test_fixed_input_produces_deterministic_execution_report(self): first["report"]["selected_symbols"]["active_trend_pool"], ["ETHUSDT", "SOLUSDT", "XRPUSDT", "LTCUSDT", "BCHUSDT"], ) - trend_buy_symbols = [ - intent["symbol"] - for intent in first["report"]["buy_sell_intents"] - if intent["category"] == "trend" and intent["action"] == "buy" - ] - self.assertEqual(trend_buy_symbols, ["ETHUSDT", "SOLUSDT"]) - self.assertEqual(first["report"]["btc_dca_intents"][0]["action"], "buy") + self.assertEqual(first["report"]["buy_sell_intents"], []) + self.assertEqual(first["report"]["btc_dca_intents"], []) + self.assertEqual(first["report"]["gating_summary"]["execution_authority_not_approved"], 2) self.assertEqual(first["report"]["redemption_subscription_intents"][0]["action"], "subscribe") - self.assertAlmostEqual(first["report"]["redemption_subscription_intents"][0]["amount"], 71.5) + self.assertAlmostEqual(first["report"]["redemption_subscription_intents"][0]["amount"], 950.0) def test_state_load_failure_aborts_execution_safely(self): runtime, client, state_store, _ = run_cycle_replay.build_replay_runtime( diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index cdec5978..9c2246a6 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -14,6 +14,31 @@ from quant_platform_kit.strategy_contracts import BudgetIntent, PositionTarget, StrategyDecision +def _risk_assessment(scope, *, outcome="APPROVE", candidate_sha="a" * 64, decision_sha="b" * 64): + return { + "contract_version": "qsl.risk_gate_assessment.v1", + "scope": scope, + "evaluated_at": "2026-08-07T00:00:00Z", + "policy_id": "qsl.risk_gate", + "policy_version": "v1", + "mandate_authority_receipt_sha256": "c" * 64, + "candidate_identity_sha256": candidate_sha, + "decision_digest_sha256": decision_sha, + "portfolio_snapshot_digest_sha256": "d" * 64, + "outcome": outcome, + "reason_codes": () if outcome == "APPROVE" else ("rejected",), + "assessment_sha256": "e" * 64, + } + + +def _approved_authority_diagnostics(): + return { + "risk_gate": "APPROVE", + "member_risk_assessment": _risk_assessment("MEMBER"), + "account_risk_assessment": _risk_assessment("ACCOUNT"), + } + + class DecisionMapperTests(unittest.TestCase): def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): decision = StrategyDecision( @@ -26,6 +51,7 @@ def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): BudgetIntent(name="trend_rotation_pool", amount=400.0), ), diagnostics={ + **_approved_authority_diagnostics(), "btc_target_ratio": 0.3, "trend_target_ratio": 0.7, "btc_base_order_usdt": 50.0, @@ -51,6 +77,7 @@ def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): def test_map_strategy_decision_to_rotation_plan_uses_unified_diagnostics(self): decision = StrategyDecision( diagnostics={ + **_approved_authority_diagnostics(), "trend_pool": ("ETHUSDT", "SOLUSDT"), "metadata": { "combo": { @@ -101,6 +128,79 @@ def test_map_strategy_decision_to_rotation_plan_uses_unified_diagnostics(self): }, ) + def test_execution_intents_fail_closed_without_matching_scoped_approvals(self): + base_diagnostics = { + **_approved_authority_diagnostics(), + "btc_target_ratio": 0.3, + "trend_target_ratio": 0.7, + "btc_base_order_usdt": 50.0, + "trend_pool": ("ETHUSDT", "SOLUSDT"), + "rotation_candidates": { + "ETHUSDT": {"weight": 0.6, "relative_score": 1.2, "abs_momentum": 0.3}, + }, + "eligible_buy_symbols": ("ETHUSDT",), + "planned_trend_buys": {"ETHUSDT": 320.0}, + "sell_reasons": {"SOLUSDT": "stale_rotated_out"}, + } + cases = { + "risk_engine_reject_with_stale_diagnostics": { + **base_diagnostics, + "risk_gate": "REJECT", + }, + "risk_engine_missing": { + key: value for key, value in base_diagnostics.items() if key != "risk_gate" + }, + "member_reject": { + **base_diagnostics, + "member_risk_assessment": _risk_assessment("MEMBER", outcome="REJECT"), + }, + "member_missing": { + key: value for key, value in base_diagnostics.items() if key != "member_risk_assessment" + }, + "account_reject": { + **base_diagnostics, + "account_risk_assessment": _risk_assessment("ACCOUNT", outcome="REJECT"), + }, + "account_missing": { + key: value for key, value in base_diagnostics.items() if key != "account_risk_assessment" + }, + "candidate_identity_mismatch": { + **base_diagnostics, + "account_risk_assessment": _risk_assessment("ACCOUNT", candidate_sha="f" * 64), + }, + "decision_identity_mismatch": { + **base_diagnostics, + "account_risk_assessment": _risk_assessment("ACCOUNT", decision_sha="f" * 64), + }, + } + + for name, diagnostics in cases.items(): + with self.subTest(name=name): + decision = StrategyDecision( + positions=(PositionTarget(symbol="ETHUSDT", target_weight=0.4),), + budgets=(BudgetIntent(name="trend_rotation_pool", amount=400.0),), + diagnostics=diagnostics, + ) + allocation = map_strategy_decision_to_allocation( + decision, + account_metrics={ + "total_equity": 10000.0, + "trend_value": 3500.0, + "dca_value": 1800.0, + }, + ) + plan = map_strategy_decision_to_rotation_plan(decision) + + self.assertEqual(allocation["btc_target_ratio"], 0.0) + self.assertEqual(allocation["trend_target_ratio"], 0.0) + self.assertEqual(allocation["trend_usdt_pool"], 0.0) + self.assertEqual(allocation["dca_usdt_pool"], 0.0) + self.assertEqual(allocation["btc_base_order_usdt"], 0.0) + self.assertEqual(plan["selected_candidates"], {}) + self.assertEqual(plan["eligible_buy_symbols"], []) + self.assertEqual(plan["planned_trend_buys"], {}) + self.assertEqual(plan["sell_reasons"], {}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 5d07379c..d861d13f 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -8,9 +8,156 @@ execute_trend_sells, run_daily_circuit_breaker, ) +from decision_mapper import map_strategy_decision_to_rotation_plan +from quant_platform_kit.strategy_contracts import StrategyDecision + + +def _risk_assessment(scope, *, outcome="APPROVE", candidate_sha="a" * 64, decision_sha="b" * 64): + return { + "contract_version": "qsl.risk_gate_assessment.v1", + "scope": scope, + "evaluated_at": "2026-08-07T00:00:00Z", + "policy_id": "qsl.risk_gate", + "policy_version": "v1", + "mandate_authority_receipt_sha256": "c" * 64, + "candidate_identity_sha256": candidate_sha, + "decision_digest_sha256": decision_sha, + "portfolio_snapshot_digest_sha256": "d" * 64, + "outcome": outcome, + "reason_codes": () if outcome == "APPROVE" else ("rejected",), + "assessment_sha256": "e" * 64, + } + + +def _decision_with_authority( + *, + risk_gate="APPROVE", + member="APPROVE", + account="APPROVE", + account_candidate_sha="a" * 64, +): + return StrategyDecision( + diagnostics={ + "risk_gate": risk_gate, + "member_risk_assessment": _risk_assessment("MEMBER", outcome=member), + "account_risk_assessment": _risk_assessment( + "ACCOUNT", + outcome=account, + candidate_sha=account_candidate_sha, + ), + "trend_pool": ("ETHUSDT",), + "rotation_candidates": { + "ETHUSDT": {"weight": 1.0, "relative_score": 1.5, "abs_momentum": 0.4}, + }, + "eligible_buy_symbols": ("ETHUSDT",), + "planned_trend_buys": {"ETHUSDT": 320.0}, + "sell_reasons": {"ETHUSDT": "stale_rotated_out"}, + } + ) class ExecutionServiceTests(unittest.TestCase): + def test_trend_rotation_reject_never_reaches_order_helpers(self): + runtime = SimpleNamespace(now_utc="2026-08-07T00:00:00Z") + report = { + "selected_symbols": {"active_trend_pool": [], "selected_candidates": []}, + "gating_summary": {}, + "gating_events": [], + } + rejected = _decision_with_authority(risk_gate="REJECT") + plan = {**map_strategy_decision_to_rotation_plan(rejected), "decision": rejected} + observed_order_client_calls = [] + + result = execute_trend_rotation( + runtime, + report, + {}, + {"ETHUSDT": {"base_asset": "ETH"}}, + {"ETHUSDT": {}}, + {}, + {"ETHUSDT": 100.0}, + {"ETHUSDT": 1.0}, + 1000.0, + 0.0, + [], + "20260807", + True, + True, + resolve_strategy_plan=lambda *_args, **_kwargs: plan, + append_rotation_summary=lambda *_args: None, + execute_trend_sells=lambda *_args: observed_order_client_calls.append("sell") or 1000.0, + execute_trend_buys=lambda *_args: observed_order_client_calls.append("buy") or 1000.0, + append_trend_symbol_status=lambda *_args: None, + official_trend_pool_symbols=["ETHUSDT"], + ) + + self.assertEqual(result, 1000.0) + self.assertEqual(observed_order_client_calls, []) + + def test_daily_breaker_account_reject_makes_zero_order_client_calls(self): + report = {"buy_sell_intents": [], "gating_summary": {}, "gating_events": []} + observed_client_calls = [] + + result = run_daily_circuit_breaker( + SimpleNamespace(client=object()), + report, + {}, + {"ETHUSDT": {"base_asset": "ETH"}}, + {"ETHUSDT": 2.0}, + 50.0, + {"ETHUSDT": 100.0}, + -0.10, + -0.05, + [], + decision=_decision_with_authority(account="REJECT"), + format_qty_fn=lambda *_args: 1.5, + runtime_notify_fn=lambda *_args: None, + ensure_asset_available_fn=lambda *_args: True, + runtime_call_client_fn=lambda *_args, **_kwargs: observed_client_calls.append("sell"), + set_symbol_trade_state_fn=lambda *_args: None, + runtime_set_trade_state_fn=lambda *_args, **_kwargs: None, + build_balance_snapshot_fn=lambda *_args: {}, + translate_fn=lambda key, **_kwargs: key, + ) + + self.assertFalse(result) + self.assertEqual(report["buy_sell_intents"], []) + self.assertEqual(observed_client_calls, []) + + def test_btc_dca_identity_mismatch_makes_zero_order_client_calls(self): + report = {"btc_dca_intents": [], "gating_summary": {}, "gating_events": []} + observed_client_calls = [] + + result = execute_btc_dca_cycle( + SimpleNamespace(client=object()), + report, + {}, + {"BTCUSDT": 0.1}, + {"BTCUSDT": 50_000.0}, + 1000.0, + 20_000.0, + 300.0, + 5000.0, + {"ahr999": 0.4, "zscore": 0.0, "sell_trigger": 3.5}, + 0.25, + 50.0, + "20260807", + [], + decision=_decision_with_authority(account_candidate_sha="f" * 64), + append_log_fn=lambda *_args: None, + translate_fn=lambda key, **_kwargs: key, + format_qty_fn=lambda *_args: 1.0, + ensure_asset_available_fn=lambda *_args: True, + runtime_call_client_fn=lambda *_args, **_kwargs: observed_client_calls.append("buy"), + next_order_id_fn=lambda *_args: "unused", + runtime_notify_fn=lambda *_args: None, + runtime_set_trade_state_fn=lambda *_args, **_kwargs: None, + ) + + self.assertEqual(result, 1000.0) + self.assertEqual(report["btc_dca_intents"], []) + self.assertEqual(observed_client_calls, []) + def test_run_daily_circuit_breaker_liquidates_and_latches_state(self): runtime = SimpleNamespace(client=object()) report = {"buy_sell_intents": []} @@ -30,6 +177,7 @@ def test_run_daily_circuit_breaker_liquidates_and_latches_state(self): -0.10, -0.05, [], + decision=_decision_with_authority(), format_qty_fn=lambda _client, _symbol, qty: round(qty - 0.5, 4), runtime_notify_fn=lambda _runtime, _report, text: observed["notifications"].append(text), ensure_asset_available_fn=lambda _runtime, _report, asset, amount, _log_buffer: observed["asset_checks"].append((asset, amount)) or True, @@ -207,6 +355,7 @@ def test_execute_trend_rotation_delegates_sell_buy_and_status_flow(self): plans = [ { + "decision": _decision_with_authority(), "active_trend_pool": ["ETHUSDT"], "selected_candidates": {"ETHUSDT": {"weight": 1.0, "relative_score": 1.5}}, "combo_diagnostics": {"regime_tier": "hard", "effective_btc_weight": 0.25}, @@ -215,6 +364,7 @@ def test_execute_trend_rotation_delegates_sell_buy_and_status_flow(self): "sell_reasons": {"ETHUSDT": "rotated_out"}, }, { + "decision": _decision_with_authority(), "active_trend_pool": ["ETHUSDT"], "selected_candidates": {"ETHUSDT": {"weight": 1.0, "relative_score": 1.5}}, "eligible_buy_symbols": ["ETHUSDT"], @@ -293,6 +443,7 @@ def test_execute_trend_rotation_records_candidate_filter_reasons(self): btc_snapshot = {"regime_on": True, "btc_roc20": 0.10, "btc_roc60": 0.08, "btc_roc120": 0.06} plans = [ { + "decision": _decision_with_authority(), "active_trend_pool": ["ETHUSDT"], "selected_candidates": {}, "eligible_buy_symbols": [], @@ -300,6 +451,7 @@ def test_execute_trend_rotation_records_candidate_filter_reasons(self): "sell_reasons": {}, }, { + "decision": _decision_with_authority(), "active_trend_pool": ["ETHUSDT"], "selected_candidates": {}, "eligible_buy_symbols": [], @@ -368,6 +520,7 @@ def test_execute_btc_dca_cycle_executes_buy_branch(self): 50.0, "20260329", log_buffer, + decision=_decision_with_authority(), append_log_fn=lambda buffer, message: buffer.append(message), translate_fn=lambda key, **_kwargs: key, format_qty_fn=lambda _client, _symbol, qty: round(qty, 6), @@ -413,6 +566,7 @@ def test_execute_btc_dca_cycle_executes_trim_branch(self): 50.0, "20260329", log_buffer, + decision=_decision_with_authority(), append_log_fn=lambda buffer, message: buffer.append(message), translate_fn=lambda key, **_kwargs: key, format_qty_fn=lambda _client, _symbol, qty: round(qty, 6), @@ -453,6 +607,7 @@ def test_execute_btc_dca_cycle_records_gate_when_pool_too_small(self): 50.0, "20260329", [], + decision=_decision_with_authority(), append_log_fn=lambda *_args: None, translate_fn=lambda key, **_kwargs: key, format_qty_fn=lambda *_args: 0.0, diff --git a/uv.lock b/uv.lock index 64a628c6..e4faa440 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=61783fdaee869bfeedd4289ae4b7f27104513759" }] +overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=9618b4bd8e179760ac174914713598762cab15d7" }] [[package]] name = "aiohappyeyeballs" @@ -214,7 +214,7 @@ requires-dist = [ { name = "pandas" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, { name = "python-binance" }, - { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=61783fdaee869bfeedd4289ae4b7f27104513759" }, + { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=9618b4bd8e179760ac174914713598762cab15d7" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'", specifier = ">=0.12" }, ] @@ -1617,7 +1617,7 @@ wheels = [ [[package]] name = "quant-platform-kit" version = "0.10.0" -source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=61783fdaee869bfeedd4289ae4b7f27104513759#61783fdaee869bfeedd4289ae4b7f27104513759" } +source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=9618b4bd8e179760ac174914713598762cab15d7#9618b4bd8e179760ac174914713598762cab15d7" } [[package]] name = "regex" From 98a3697cd91d45a632adad43c8c4dc5f1e365daa Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:13:34 +0800 Subject: [PATCH 2/2] fix: close Binance execution authority gaps Co-Authored-By: Codex --- application/cycle_service.py | 19 ++- application/execution_service.py | 2 +- decision_mapper.py | 42 ++++++- tests/test_decision_mapper.py | 203 +++++++++++++++++++++++++------ tests/test_execution_service.py | 168 ++++++++++++++++++++++--- 5 files changed, 376 insertions(+), 58 deletions(-) diff --git a/application/cycle_service.py b/application/cycle_service.py index 38fa43d0..827fd0fa 100644 --- a/application/cycle_service.py +++ b/application/cycle_service.py @@ -7,6 +7,7 @@ from quant_platform_kit.common.runtime_reports import persist_runtime_report from quant_platform_kit.strategy_lifecycle.performance_monitor import try_record_platform_execution +from decision_mapper import has_execution_authority from runtime_logging import RuntimeLogContext, emit_runtime_log from runtime_support import finalize_notification_delivery @@ -66,7 +67,7 @@ def execute_strategy_cycle( report, runtime_trend_universe, log_buffer, - min_bnb_value, + float("-inf"), buy_bnb_amount, ) u_total = market_snapshot["u_total"] @@ -88,6 +89,22 @@ def execute_strategy_cycle( trend_indicators, btc_snapshot, ) + if has_execution_authority(allocation.get("execution_decision")): + market_snapshot = capture_market_snapshot( + runtime, + report, + runtime_trend_universe, + log_buffer, + min_bnb_value, + buy_bnb_amount, + ) + u_total = market_snapshot["u_total"] + fuel_val = market_snapshot["fuel_val"] + dynamic_usdt_buffer = market_snapshot["dynamic_usdt_buffer"] + prices = market_snapshot["prices"] + balances = market_snapshot["balances"] + btc_snapshot = market_snapshot["btc_snapshot"] + trend_indicators = market_snapshot["trend_indicators"] total_equity = allocation["total_equity"] trend_val_equity = allocation["trend_val"] diff --git a/application/execution_service.py b/application/execution_service.py index bbc9d471..54f7691e 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -131,7 +131,7 @@ def run_daily_circuit_breaker( if trend_daily_pnl > circuit_breaker_pct: return False if _block_unapproved_execution(report, decision, category="daily_circuit_breaker"): - return False + return True for symbol, config in runtime_trend_universe.items(): tradable_qty = balances[symbol] diff --git a/decision_mapper.py b/decision_mapper.py index efbe99a4..f5298bd0 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -2,15 +2,28 @@ import re from collections.abc import Mapping +from datetime import datetime, timezone from typing import Any +from quant_platform_kit.risk.contracts import CandidateRiskIdentity +from quant_platform_kit.risk.gate import ( + _FALLBACK_MAX_SNAPSHOT_AGE_SECONDS_V1, + _canonical_digest, + _decision_metrics, + _parse_utc_timestamp, +) from quant_platform_kit.strategy_contracts import StrategyDecision _SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") -def _approved_scoped_assessment(value: Any, *, scope: str) -> Mapping[str, Any] | None: +def _approved_scoped_assessment( + value: Any, + *, + scope: str, + now: datetime, +) -> Mapping[str, Any] | None: """Accept only serialized QPK approval evidence; never infer or recompute risk authority.""" if not isinstance(value, Mapping): return None @@ -34,6 +47,12 @@ def _approved_scoped_assessment(value: Any, *, scope: str) -> Mapping[str, Any] for field in ("contract_version", "evaluated_at", "policy_id", "policy_version"): if not isinstance(value.get(field), str) or not value[field].strip(): return None + evaluated_at = _parse_utc_timestamp(value["evaluated_at"]) + if evaluated_at is None: + return None + age_seconds = (now - evaluated_at).total_seconds() + if not 0.0 <= age_seconds <= _FALLBACK_MAX_SNAPSHOT_AGE_SECONDS_V1: + return None return value @@ -46,19 +65,34 @@ def has_execution_authority(decision: StrategyDecision | None) -> bool: return False if any(str(flag).startswith("rejected:") for flag in decision.risk_flags): return False + now = datetime.now(timezone.utc) member = _approved_scoped_assessment( diagnostics.get("member_risk_assessment"), scope="MEMBER", + now=now, ) account = _approved_scoped_assessment( diagnostics.get("account_risk_assessment"), scope="ACCOUNT", + now=now, ) if member is None or account is None: return False - return all( - member[field] == account[field] - for field in ("candidate_identity_sha256", "decision_digest_sha256") + candidate_identity = diagnostics.get("candidate_risk_identity") + if not isinstance(candidate_identity, CandidateRiskIdentity): + return False + try: + decision_payload, _, _ = _decision_metrics(decision, total_equity=None) + decision_digest = _canonical_digest(decision_payload) + except (TypeError, ValueError): + return False + return ( + member["candidate_identity_sha256"] + == account["candidate_identity_sha256"] + == candidate_identity.candidate_sha256 + and member["decision_digest_sha256"] + == account["decision_digest_sha256"] + == decision_digest ) diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index 9c2246a6..de780cda 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -1,5 +1,6 @@ import sys import unittest +from datetime import datetime, timedelta, timezone from pathlib import Path @@ -11,37 +12,97 @@ sys.path.insert(0, str(QPK_SRC)) from decision_mapper import map_strategy_decision_to_allocation, map_strategy_decision_to_rotation_plan +from quant_platform_kit.risk.contracts import CandidateRiskIdentity +from quant_platform_kit.risk.gate import _canonical_digest, _decision_metrics from quant_platform_kit.strategy_contracts import BudgetIntent, PositionTarget, StrategyDecision -def _risk_assessment(scope, *, outcome="APPROVE", candidate_sha="a" * 64, decision_sha="b" * 64): +_CANDIDATE_IDENTITY = CandidateRiskIdentity( + strategy_profile="crypto_live_pool_rotation", + account_mode="single_strategy_account_v1", + strategy_revision="1" * 40, + runner_revision="2" * 40, + config_sha256="3" * 64, + input_manifest_sha256="4" * 64, + authority_receipt_sha256="5" * 64, +) + + +def _utc_text(value): + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _risk_assessment( + scope, + *, + outcome="APPROVE", + candidate_sha=None, + decision_sha, + evaluated_at=None, +): return { "contract_version": "qsl.risk_gate_assessment.v1", "scope": scope, - "evaluated_at": "2026-08-07T00:00:00Z", - "policy_id": "qsl.risk_gate", + "evaluated_at": evaluated_at or _utc_text(datetime.now(timezone.utc)), + "policy_id": "qpk.risk_gate", "policy_version": "v1", - "mandate_authority_receipt_sha256": "c" * 64, - "candidate_identity_sha256": candidate_sha, + "mandate_authority_receipt_sha256": "7" * 64, + "candidate_identity_sha256": candidate_sha or _CANDIDATE_IDENTITY.candidate_sha256, "decision_digest_sha256": decision_sha, - "portfolio_snapshot_digest_sha256": "d" * 64, + "portfolio_snapshot_digest_sha256": "8" * 64, "outcome": outcome, "reason_codes": () if outcome == "APPROVE" else ("rejected",), - "assessment_sha256": "e" * 64, + "assessment_sha256": "9" * 64, } -def _approved_authority_diagnostics(): - return { - "risk_gate": "APPROVE", - "member_risk_assessment": _risk_assessment("MEMBER"), - "account_risk_assessment": _risk_assessment("ACCOUNT"), - } +def _decision_digest(decision): + payload, _, _ = _decision_metrics(decision, total_equity=None) + return _canonical_digest(payload) + + +def _with_authority( + decision, + *, + risk_gate="APPROVE", + member="APPROVE", + account="APPROVE", + member_candidate_sha=None, + account_candidate_sha=None, + member_decision_sha=None, + account_decision_sha=None, + evaluated_at=None, +): + digest = _decision_digest(decision) + return StrategyDecision( + positions=decision.positions, + budgets=decision.budgets, + risk_flags=decision.risk_flags, + diagnostics={ + **dict(decision.diagnostics), + "risk_gate": risk_gate, + "candidate_risk_identity": _CANDIDATE_IDENTITY, + "member_risk_assessment": _risk_assessment( + "MEMBER", + outcome=member, + candidate_sha=member_candidate_sha, + decision_sha=member_decision_sha or digest, + evaluated_at=evaluated_at, + ), + "account_risk_assessment": _risk_assessment( + "ACCOUNT", + outcome=account, + candidate_sha=account_candidate_sha, + decision_sha=account_decision_sha or digest, + evaluated_at=evaluated_at, + ), + }, + ) class DecisionMapperTests(unittest.TestCase): def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): - decision = StrategyDecision( + decision = _with_authority(StrategyDecision( positions=( PositionTarget(symbol="BTCUSDT", target_weight=0.3), PositionTarget(symbol="ETHUSDT", target_weight=0.4), @@ -51,12 +112,11 @@ def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): BudgetIntent(name="trend_rotation_pool", amount=400.0), ), diagnostics={ - **_approved_authority_diagnostics(), "btc_target_ratio": 0.3, "trend_target_ratio": 0.7, "btc_base_order_usdt": 50.0, }, - ) + )) allocation = map_strategy_decision_to_allocation( decision, @@ -75,9 +135,8 @@ def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): self.assertEqual(allocation["trend_target_ratio"], 0.7) def test_map_strategy_decision_to_rotation_plan_uses_unified_diagnostics(self): - decision = StrategyDecision( + decision = _with_authority(StrategyDecision( diagnostics={ - **_approved_authority_diagnostics(), "trend_pool": ("ETHUSDT", "SOLUSDT"), "metadata": { "combo": { @@ -102,7 +161,7 @@ def test_map_strategy_decision_to_rotation_plan_uses_unified_diagnostics(self): "artifact_contract": {"version": "v1"}, }, risk_flags=("regime_off",), - ) + )) plan = map_strategy_decision_to_rotation_plan(decision) @@ -129,19 +188,26 @@ def test_map_strategy_decision_to_rotation_plan_uses_unified_diagnostics(self): ) def test_execution_intents_fail_closed_without_matching_scoped_approvals(self): - base_diagnostics = { - **_approved_authority_diagnostics(), - "btc_target_ratio": 0.3, - "trend_target_ratio": 0.7, - "btc_base_order_usdt": 50.0, - "trend_pool": ("ETHUSDT", "SOLUSDT"), - "rotation_candidates": { - "ETHUSDT": {"weight": 0.6, "relative_score": 1.2, "abs_momentum": 0.3}, - }, - "eligible_buy_symbols": ("ETHUSDT",), - "planned_trend_buys": {"ETHUSDT": 320.0}, - "sell_reasons": {"SOLUSDT": "stale_rotated_out"}, - } + approved = _with_authority( + StrategyDecision( + positions=(PositionTarget(symbol="ETHUSDT", target_weight=0.4),), + budgets=(BudgetIntent(name="trend_rotation_pool", amount=400.0),), + diagnostics={ + "btc_target_ratio": 0.3, + "trend_target_ratio": 0.7, + "btc_base_order_usdt": 50.0, + "trend_pool": ("ETHUSDT", "SOLUSDT"), + "rotation_candidates": { + "ETHUSDT": {"weight": 0.6, "relative_score": 1.2, "abs_momentum": 0.3}, + }, + "eligible_buy_symbols": ("ETHUSDT",), + "planned_trend_buys": {"ETHUSDT": 320.0}, + "sell_reasons": {"SOLUSDT": "stale_rotated_out"}, + }, + ) + ) + base_diagnostics = dict(approved.diagnostics) + decision_sha = base_diagnostics["member_risk_assessment"]["decision_digest_sha256"] cases = { "risk_engine_reject_with_stale_diagnostics": { **base_diagnostics, @@ -152,26 +218,46 @@ def test_execution_intents_fail_closed_without_matching_scoped_approvals(self): }, "member_reject": { **base_diagnostics, - "member_risk_assessment": _risk_assessment("MEMBER", outcome="REJECT"), + "member_risk_assessment": _risk_assessment( + "MEMBER", outcome="REJECT", decision_sha=decision_sha + ), }, "member_missing": { key: value for key, value in base_diagnostics.items() if key != "member_risk_assessment" }, "account_reject": { **base_diagnostics, - "account_risk_assessment": _risk_assessment("ACCOUNT", outcome="REJECT"), + "account_risk_assessment": _risk_assessment( + "ACCOUNT", outcome="REJECT", decision_sha=decision_sha + ), }, "account_missing": { key: value for key, value in base_diagnostics.items() if key != "account_risk_assessment" }, "candidate_identity_mismatch": { **base_diagnostics, - "account_risk_assessment": _risk_assessment("ACCOUNT", candidate_sha="f" * 64), + "account_risk_assessment": _risk_assessment( + "ACCOUNT", candidate_sha="f" * 64, decision_sha=decision_sha + ), }, "decision_identity_mismatch": { **base_diagnostics, "account_risk_assessment": _risk_assessment("ACCOUNT", decision_sha="f" * 64), }, + "matching_foreign_candidate_identity": { + **base_diagnostics, + "member_risk_assessment": _risk_assessment( + "MEMBER", candidate_sha="f" * 64, decision_sha=decision_sha + ), + "account_risk_assessment": _risk_assessment( + "ACCOUNT", candidate_sha="f" * 64, decision_sha=decision_sha + ), + }, + "matching_foreign_decision_identity": { + **base_diagnostics, + "member_risk_assessment": _risk_assessment("MEMBER", decision_sha="f" * 64), + "account_risk_assessment": _risk_assessment("ACCOUNT", decision_sha="f" * 64), + }, } for name, diagnostics in cases.items(): @@ -201,6 +287,53 @@ def test_execution_intents_fail_closed_without_matching_scoped_approvals(self): self.assertEqual(plan["planned_trend_buys"], {}) self.assertEqual(plan["sell_reasons"], {}) + def test_execution_intents_fail_closed_for_invalid_stale_or_future_assessments(self): + now = datetime.now(timezone.utc) + evaluated_at_cases = { + "invalid": "not-a-timestamp", + "timezone_missing": now.replace(tzinfo=None).isoformat(), + "stale": _utc_text(now - timedelta(seconds=301)), + "future": _utc_text(now + timedelta(seconds=60)), + } + + for name, evaluated_at in evaluated_at_cases.items(): + with self.subTest(name=name): + decision = _with_authority( + StrategyDecision( + positions=(PositionTarget(symbol="ETHUSDT", target_weight=0.4),), + budgets=(BudgetIntent(name="trend_rotation_pool", amount=400.0),), + diagnostics={ + "trend_target_ratio": 0.4, + "rotation_candidates": { + "ETHUSDT": { + "weight": 1.0, + "relative_score": 1.2, + "abs_momentum": 0.3, + }, + }, + "eligible_buy_symbols": ("ETHUSDT",), + "planned_trend_buys": {"ETHUSDT": 320.0}, + }, + ), + evaluated_at=evaluated_at, + ) + + allocation = map_strategy_decision_to_allocation( + decision, + account_metrics={ + "total_equity": 10000.0, + "trend_value": 3500.0, + "dca_value": 1800.0, + }, + ) + plan = map_strategy_decision_to_rotation_plan(decision) + + self.assertEqual(allocation["trend_target_ratio"], 0.0) + self.assertEqual(allocation["trend_usdt_pool"], 0.0) + self.assertEqual(plan["selected_candidates"], {}) + self.assertEqual(plan["eligible_buy_symbols"], []) + self.assertEqual(plan["planned_trend_buys"], {}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index d861d13f..81f085ac 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -1,6 +1,9 @@ import unittest +from datetime import datetime, timezone from types import SimpleNamespace +from unittest.mock import patch +from application.cycle_service import execute_strategy_cycle from application.execution_service import ( execute_btc_dca_cycle, execute_trend_buys, @@ -9,23 +12,41 @@ run_daily_circuit_breaker, ) from decision_mapper import map_strategy_decision_to_rotation_plan +from market_snapshot_support import capture_market_snapshot +from quant_platform_kit.risk.contracts import CandidateRiskIdentity +from quant_platform_kit.risk.gate import _canonical_digest, _decision_metrics from quant_platform_kit.strategy_contracts import StrategyDecision -def _risk_assessment(scope, *, outcome="APPROVE", candidate_sha="a" * 64, decision_sha="b" * 64): +_CANDIDATE_IDENTITY = CandidateRiskIdentity( + strategy_profile="crypto_live_pool_rotation", + account_mode="single_strategy_account_v1", + strategy_revision="1" * 40, + runner_revision="2" * 40, + config_sha256="3" * 64, + input_manifest_sha256="4" * 64, + authority_receipt_sha256="5" * 64, +) + + +def _utc_now_text(): + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _risk_assessment(scope, *, outcome="APPROVE", candidate_sha=None, decision_sha): return { "contract_version": "qsl.risk_gate_assessment.v1", "scope": scope, - "evaluated_at": "2026-08-07T00:00:00Z", - "policy_id": "qsl.risk_gate", + "evaluated_at": _utc_now_text(), + "policy_id": "qpk.risk_gate", "policy_version": "v1", - "mandate_authority_receipt_sha256": "c" * 64, - "candidate_identity_sha256": candidate_sha, + "mandate_authority_receipt_sha256": "7" * 64, + "candidate_identity_sha256": candidate_sha or _CANDIDATE_IDENTITY.candidate_sha256, "decision_digest_sha256": decision_sha, - "portfolio_snapshot_digest_sha256": "d" * 64, + "portfolio_snapshot_digest_sha256": "8" * 64, "outcome": outcome, "reason_codes": () if outcome == "APPROVE" else ("rejected",), - "assessment_sha256": "e" * 64, + "assessment_sha256": "9" * 64, } @@ -34,17 +55,10 @@ def _decision_with_authority( risk_gate="APPROVE", member="APPROVE", account="APPROVE", - account_candidate_sha="a" * 64, + account_candidate_sha=None, ): - return StrategyDecision( + decision = StrategyDecision( diagnostics={ - "risk_gate": risk_gate, - "member_risk_assessment": _risk_assessment("MEMBER", outcome=member), - "account_risk_assessment": _risk_assessment( - "ACCOUNT", - outcome=account, - candidate_sha=account_candidate_sha, - ), "trend_pool": ("ETHUSDT",), "rotation_candidates": { "ETHUSDT": {"weight": 1.0, "relative_score": 1.5, "abs_momentum": 0.4}, @@ -54,6 +68,27 @@ def _decision_with_authority( "sell_reasons": {"ETHUSDT": "stale_rotated_out"}, } ) + payload, _, _ = _decision_metrics(decision, total_equity=None) + decision_sha = _canonical_digest(payload) + return StrategyDecision( + positions=decision.positions, + budgets=decision.budgets, + risk_flags=decision.risk_flags, + diagnostics={ + **dict(decision.diagnostics), + "risk_gate": risk_gate, + "candidate_risk_identity": _CANDIDATE_IDENTITY, + "member_risk_assessment": _risk_assessment( + "MEMBER", outcome=member, decision_sha=decision_sha + ), + "account_risk_assessment": _risk_assessment( + "ACCOUNT", + outcome=account, + candidate_sha=account_candidate_sha, + decision_sha=decision_sha, + ), + }, + ) class ExecutionServiceTests(unittest.TestCase): @@ -120,10 +155,109 @@ def test_daily_breaker_account_reject_makes_zero_order_client_calls(self): translate_fn=lambda key, **_kwargs: key, ) - self.assertFalse(result) + self.assertTrue(result) self.assertEqual(report["buy_sell_intents"], []) self.assertEqual(observed_client_calls, []) + def test_cycle_gates_bnb_top_up_until_execution_authority_is_approved(self): + cases = { + "missing": None, + "rejected": _decision_with_authority(account="REJECT"), + "mismatched": _decision_with_authority(account_candidate_sha="f" * 64), + } + + for name, decision in cases.items(): + with self.subTest(name=name): + capture_calls, order_client_calls = self._run_fuel_gate_cycle(decision) + + self.assertEqual(capture_calls, [(float("-inf"), 15.0)]) + self.assertEqual(order_client_calls, []) + + def test_cycle_allows_bnb_top_up_only_after_approved_execution_authority(self): + capture_calls, order_client_calls = self._run_fuel_gate_cycle(_decision_with_authority()) + + self.assertEqual(capture_calls, [(float("-inf"), 15.0), (10.0, 15.0)]) + self.assertEqual(order_client_calls, ["order_market_buy"]) + + def _run_fuel_gate_cycle(self, decision): + capture_calls = [] + order_client_calls = [] + + def capture_snapshot(current_runtime, report, universe, logs, min_bnb_value, buy_bnb_amount): + capture_calls.append((min_bnb_value, buy_bnb_amount)) + return capture_market_snapshot( + current_runtime, + report, + universe, + logs, + min_bnb_value, + buy_bnb_amount, + get_total_balance_fn=lambda _client, asset, **_kwargs: 1000.0 + if asset == "USDT" + else 0.0, + ensure_asset_available_fn=lambda *_args: True, + runtime_call_client_fn=lambda _runtime, _report, method_name, **_kwargs: ( + order_client_calls.append(method_name) + ), + runtime_notify_fn=lambda *_args: None, + append_log_fn=lambda *_args: None, + resolve_btc_snapshot_fn=lambda *_args: {}, + resolve_trend_indicators_fn=lambda *_args: {}, + ) + + runtime = SimpleNamespace( + client=SimpleNamespace(get_avg_price=lambda **_kwargs: {"price": "100.0"}), + dry_run=True, + now_utc=datetime.now(timezone.utc), + print_traceback=False, + tg_token="", + tg_chat_id="", + ) + with patch("application.cycle_service.try_record_platform_execution"): + execute_strategy_cycle( + runtime, + build_execution_report=lambda _runtime: { + "status": "ok", + "log_lines": [], + "buy_sell_intents": [], + }, + ensure_runtime_client=lambda *_args: True, + load_cycle_execution_settings=lambda: SimpleNamespace( + btc_status_report_interval_hours=24, + allow_new_trend_entries_on_degraded=False, + ), + load_cycle_state=lambda *_args: ( + {"is_circuit_broken": True}, + {"degraded": False}, + {"ETHUSDT": {"base_asset": "ETH"}}, + True, + ), + append_trend_pool_source_logs=lambda *_args: None, + capture_market_snapshot=capture_snapshot, + compute_portfolio_allocation=lambda *_args: { + "total_equity": 1000.0, + "trend_val": 0.0, + "execution_decision": decision, + }, + build_balance_snapshot=lambda *_args: {}, + maybe_reset_daily_state=lambda *_args: None, + maybe_rebase_daily_state_for_balance_change=lambda *_args: False, + compute_daily_pnls=lambda *_args: (0.0, 0.0), + append_portfolio_report=lambda *_args: None, + run_daily_circuit_breaker=lambda *_args, **_kwargs: False, + execute_trend_rotation=lambda *_args, **_kwargs: 1000.0, + execute_btc_dca_cycle=lambda *_args, **_kwargs: 1000.0, + manage_usdt_earn_buffer_runtime=lambda *_args, **_kwargs: None, + maybe_send_periodic_btc_status_report=lambda *_args, **_kwargs: None, + runtime_set_trade_state=lambda *_args, **_kwargs: None, + append_report_error=lambda *_args, **_kwargs: None, + runtime_notify=lambda *_args: None, + translate_fn=lambda key, **_kwargs: key, + traceback_module=SimpleNamespace(print_exc=lambda: None), + ) + + return capture_calls, order_client_calls + def test_btc_dca_identity_mismatch_makes_zero_order_client_calls(self): report = {"btc_dca_intents": [], "gating_summary": {}, "gating_events": []} observed_client_calls = []