From 599d636c41e37dc3f4c47a7548c2e553cf43079b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:55:52 +0800 Subject: [PATCH] feat: require typed authority before crypto execution Co-Authored-By: Codex --- application/cycle_service.py | 59 ++++--- application/execution_service.py | 18 ++ decision_mapper.py | 227 +++++++++++++++++++++++--- main.py | 46 +++++- market_snapshot_support.py | 88 ++++++---- pyproject.toml | 4 +- qsl.toml | 2 +- strategy_runtime.py | 31 +++- tests/test_cycle_replay_runtime.py | 28 ++-- tests/test_cycle_service.py | 84 +++++++++- tests/test_decision_mapper.py | 150 ++++++++++++++++- tests/test_execution_service.py | 106 +++++++++++- tests/test_market_snapshot_support.py | 70 ++++++-- tests/test_notify_i18n.py | 10 +- tests/test_strategy_runtime.py | 117 +++++++++++++ uv.lock | 6 +- 16 files changed, 911 insertions(+), 135 deletions(-) diff --git a/application/cycle_service.py b/application/cycle_service.py index 4f567137..feae9ef6 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 is_execution_authority_valid from runtime_logging import RuntimeLogContext, emit_runtime_log from runtime_support import finalize_notification_delivery @@ -20,7 +21,8 @@ def execute_strategy_cycle( load_cycle_state, append_trend_pool_source_logs, capture_market_snapshot, - compute_portfolio_allocation, + execute_bnb_fuel_top_up, + resolve_strategy_plan, build_balance_snapshot, maybe_reset_daily_state, maybe_rebase_daily_state_for_balance_change, @@ -77,22 +79,38 @@ def execute_strategy_cycle( btc_snapshot = market_snapshot["btc_snapshot"] trend_indicators = market_snapshot["trend_indicators"] - allocation = compute_portfolio_allocation( + strategy_plan = resolve_strategy_plan( runtime, + state, runtime_trend_universe, - balances, + trend_indicators, + btc_snapshot, prices, + balances, u_total, fuel_val, - state, - trend_indicators, - btc_snapshot, + allow_new_trend_entries=allow_new_trend_entries, + allow_pool_refresh=not trend_pool_resolution["degraded"], ) + allocation = strategy_plan["allocation"] + execution_authority = strategy_plan.get("execution_authority") total_equity = allocation["total_equity"] trend_val_equity = allocation["trend_val"] report["total_equity_usdt"] = total_equity report["trend_equity_usdt"] = trend_val_equity + if not is_execution_authority_valid(execution_authority): + report["status"] = "aborted" + report.setdefault("gating_summary", {})["missing_execution_authority"] = 1 + return report + + u_total, fuel_val = execute_bnb_fuel_top_up( + runtime, + report, + market_snapshot, + log_buffer, + execution_authority=execution_authority, + ) now_utc = runtime.now_utc today_utc = now_utc.strftime("%Y-%m-%d") @@ -127,6 +145,7 @@ def execute_strategy_cycle( trend_daily_pnl, circuit_breaker_pct, log_buffer, + execution_authority=execution_authority, ): return report @@ -145,29 +164,14 @@ def execute_strategy_cycle( today_id_str, allow_new_trend_entries, allow_pool_refresh=not trend_pool_resolution["degraded"], + strategy_plan=strategy_plan, + execution_authority=execution_authority, ) - post_trade_allocation = compute_portfolio_allocation( - runtime, - runtime_trend_universe, - balances, - prices, - u_total, - fuel_val, - state, - trend_indicators, - btc_snapshot, - ) - total_equity = post_trade_allocation["total_equity"] - trend_val_equity = post_trade_allocation["trend_val"] - - report["total_equity_usdt"] = total_equity - report["trend_equity_usdt"] = trend_val_equity - - btc_target_ratio = post_trade_allocation["btc_target_ratio"] - dca_usdt_pool = post_trade_allocation["dca_usdt_pool"] - dca_val = post_trade_allocation["dca_val"] - btc_base_order_usdt = post_trade_allocation["btc_base_order_usdt"] + btc_target_ratio = allocation["btc_target_ratio"] + dca_usdt_pool = allocation["dca_usdt_pool"] + dca_val = allocation["dca_val"] + btc_base_order_usdt = allocation["btc_base_order_usdt"] _, trend_daily_pnl = compute_daily_pnls(state, total_equity, trend_val_equity) u_total = execute_btc_dca_cycle( @@ -185,6 +189,7 @@ def execute_strategy_cycle( btc_base_order_usdt, today_id_str, log_buffer, + execution_authority=execution_authority, ) manage_usdt_earn_buffer_runtime( diff --git a/application/execution_service.py b/application/execution_service.py index 62087b31..00caa4cf 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +from decision_mapper import is_execution_authority_valid from runtime_support import record_gating_event @@ -106,6 +107,7 @@ def run_daily_circuit_breaker( circuit_breaker_pct, log_buffer, *, + execution_authority=None, format_qty_fn, runtime_notify_fn, ensure_asset_available_fn, @@ -115,6 +117,8 @@ def run_daily_circuit_breaker( build_balance_snapshot_fn, translate_fn, ): + if not is_execution_authority_valid(execution_authority): + return False if trend_daily_pnl > circuit_breaker_pct: return False @@ -199,6 +203,7 @@ def execute_trend_sells( log_buffer, today_id_str, *, + execution_authority=None, should_skip_duplicate_trend_action_fn, append_log_fn, translate_fn, @@ -211,6 +216,8 @@ def execute_trend_sells( runtime_set_trade_state_fn, runtime_notify_fn, ): + if not is_execution_authority_valid(execution_authority): + return u_total for symbol, config in runtime_trend_universe.items(): curr_price = prices[symbol] sell_reason = str(sell_reasons.get(symbol, "")).strip() @@ -296,6 +303,7 @@ def execute_trend_buys( log_buffer, today_id_str, *, + execution_authority=None, should_skip_duplicate_trend_action_fn, append_log_fn, translate_fn, @@ -308,6 +316,8 @@ def execute_trend_buys( runtime_set_trade_state_fn, runtime_notify_fn, ): + if not is_execution_authority_valid(execution_authority): + return u_total for symbol in eligible_buy_symbols: curr_price = prices[symbol] candidate_meta = selected_candidates[symbol] @@ -421,6 +431,7 @@ def execute_trend_rotation( allow_new_trend_entries, allow_pool_refresh, *, + execution_authority=None, resolve_strategy_plan, append_rotation_summary, execute_trend_sells, @@ -428,6 +439,8 @@ def execute_trend_rotation( append_trend_symbol_status, official_trend_pool_symbols, ): + if not is_execution_authority_valid(execution_authority): + return u_total strategy_plan = resolve_strategy_plan( state, runtime_trend_universe, @@ -481,6 +494,7 @@ def execute_trend_rotation( u_total, log_buffer, today_id_str, + execution_authority=execution_authority, ) post_sell_plan = resolve_strategy_plan( @@ -520,6 +534,7 @@ def execute_trend_rotation( u_total, log_buffer, today_id_str, + execution_authority=execution_authority, ) append_trend_symbol_status( log_buffer, @@ -567,6 +582,7 @@ def execute_btc_dca_cycle( today_id_str, log_buffer, *, + execution_authority=None, append_log_fn, translate_fn, format_qty_fn, @@ -576,6 +592,8 @@ def execute_btc_dca_cycle( runtime_notify_fn, runtime_set_trade_state_fn, ): + if not is_execution_authority_valid(execution_authority): + 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..a9fdadc5 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -1,11 +1,166 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import math from typing import Any +from quant_platform_kit import PortfolioSnapshot +from quant_platform_kit.risk.contracts import CandidateRiskIdentity, RiskGateAssessment +from quant_platform_kit.risk.gate import assess_with_evidence from quant_platform_kit.strategy_contracts import StrategyDecision +_MAX_AUTHORITY_AGE_SECONDS = 300.0 +_MAX_ASSESSMENT_SKEW_SECONDS = 5.0 + + +@dataclass(frozen=True) +class ExecutionAuthority: + decision: StrategyDecision + portfolio_snapshot: PortfolioSnapshot + candidate_identity: CandidateRiskIdentity + member_assessment: RiskGateAssessment + account_assessment: RiskGateAssessment + + +def _validated_assessment(value: Any) -> RiskGateAssessment | None: + if type(value) is not RiskGateAssessment: + return None + try: + payload = asdict(value) + supplied_digest = payload.pop("assessment_sha256") + rebuilt = RiskGateAssessment(**payload) + except (TypeError, ValueError): + return None + if rebuilt.assessment_sha256 != supplied_digest: + return None + return rebuilt + + +def _assessment_time(value: str) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(timezone.utc) + + +def is_execution_authority_valid( + authority: Any, + *, + decision: StrategyDecision | None = None, +) -> bool: + if type(authority) is not ExecutionAuthority: + return False + if type(authority.decision) is not StrategyDecision: + return False + if decision is not None and authority.decision is not decision: + return False + if type(authority.portfolio_snapshot) is not PortfolioSnapshot: + return False + if type(authority.candidate_identity) is not CandidateRiskIdentity: + return False + + member = _validated_assessment(authority.member_assessment) + account = _validated_assessment(authority.account_assessment) + if member is None or account is None: + return False + if member.scope != "MEMBER" or account.scope != "ACCOUNT": + return False + if member.outcome != "APPROVE" or account.outcome != "APPROVE": + return False + if member.reason_codes or account.reason_codes: + return False + candidate_digest = authority.candidate_identity.candidate_sha256 + if member.candidate_identity_sha256 != candidate_digest: + return False + if account.candidate_identity_sha256 != candidate_digest: + return False + if member.decision_digest_sha256 != account.decision_digest_sha256: + return False + if member.portfolio_snapshot_digest_sha256 != account.portfolio_snapshot_digest_sha256: + return False + if member.contract_version != account.contract_version: + return False + if (member.policy_id, member.policy_version) != (account.policy_id, account.policy_version): + return False + if ( + member.qpk_source_revision, + member.mandate_id, + member.mandate_version, + member.mandate_authority_receipt_sha256, + member.mandate_scope, + ) != ( + account.qpk_source_revision, + account.mandate_id, + account.mandate_version, + account.mandate_authority_receipt_sha256, + account.mandate_scope, + ): + return False + if member.mandate_authority_receipt_sha256 != authority.candidate_identity.authority_receipt_sha256: + return False + + member_time = _assessment_time(member.evaluated_at) + account_time = _assessment_time(account.evaluated_at) + if member_time is None or account_time is None: + return False + now = datetime.now(timezone.utc) + for evaluated_at in (member_time, account_time): + age_seconds = (now - evaluated_at).total_seconds() + if age_seconds < -_MAX_ASSESSMENT_SKEW_SECONDS or age_seconds > _MAX_AUTHORITY_AGE_SECONDS: + return False + return abs((member_time - account_time).total_seconds()) <= _MAX_ASSESSMENT_SKEW_SECONDS + + +def build_execution_authority( + decision: StrategyDecision, + *, + portfolio_snapshot: PortfolioSnapshot, + mandate_provenance: Mapping[str, Any] | None, + candidate_identity: CandidateRiskIdentity | None, + market_data: Mapping[str, Any], +) -> ExecutionAuthority | None: + if type(decision) is not StrategyDecision: + return None + if type(portfolio_snapshot) is not PortfolioSnapshot: + return None + if not isinstance(mandate_provenance, Mapping): + return None + if type(candidate_identity) is not CandidateRiskIdentity: + return None + member = assess_with_evidence( + decision, + portfolio_snapshot, + scope="MEMBER", + mandate_provenance=mandate_provenance, + market_data=market_data, + candidate_identity=candidate_identity, + ).assessment + account = assess_with_evidence( + decision, + portfolio_snapshot, + scope="ACCOUNT", + mandate_provenance=mandate_provenance, + market_data=market_data, + candidate_identity=candidate_identity, + ).assessment + authority = ExecutionAuthority( + decision=decision, + portfolio_snapshot=portfolio_snapshot, + candidate_identity=candidate_identity, + member_assessment=member, + account_assessment=account, + ) + return authority if is_execution_authority_valid(authority, decision=decision) else None + + def _budget_map(decision: StrategyDecision) -> dict[str, float]: values: dict[str, float] = {} for budget in decision.budgets: @@ -26,53 +181,85 @@ def map_strategy_decision_to_allocation( decision: StrategyDecision, *, account_metrics: Mapping[str, Any], + execution_authority: ExecutionAuthority | None = None, ) -> 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 = is_execution_authority_valid(execution_authority, decision=decision) + diagnostics = dict(decision.diagnostics) if authorized else {} + budgets = _budget_map(decision) if authorized else {} + positions = _position_weight_map(decision) if authorized else {} + trend_target_ratio = sum(weight for symbol, weight in positions.items() if symbol != "BTCUSDT") + btc_base_order = float(diagnostics.get("btc_base_order_usdt", 0.0) or 0.0) + if not math.isfinite(btc_base_order) or btc_base_order < 0.0: + btc_base_order = 0.0 + btc_base_order = min(btc_base_order, budgets.get("btc_core_dca_pool", 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(positions.get("BTCUSDT", 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": btc_base_order, } -def map_strategy_decision_to_rotation_plan(decision: StrategyDecision) -> dict[str, Any]: +def map_strategy_decision_to_rotation_plan( + decision: StrategyDecision, + *, + execution_authority: ExecutionAuthority | None = None, +) -> dict[str, Any]: + if not is_execution_authority_valid(execution_authority, decision=decision): + return { + "active_trend_pool": [], + "selected_candidates": {}, + "eligible_buy_symbols": [], + "planned_trend_buys": {}, + "sell_reasons": {}, + "rotation_pool_source_version": None, + "rotation_pool_source_as_of_date": None, + "rotation_pool_last_month": None, + "artifact_contract": {}, + "risk_flags": tuple(str(flag) for flag in decision.risk_flags), + "combo_diagnostics": {}, + } diagnostics = dict(decision.diagnostics) metadata = diagnostics.get("metadata") if isinstance(diagnostics.get("metadata"), Mapping) else {} combo_meta = metadata.get("combo") if isinstance(metadata.get("combo"), Mapping) else {} + target_weights = _position_weight_map(decision) + target_weights.pop("BTCUSDT", None) selected_candidates = { str(symbol): { - "weight": float(payload.get("weight", 0.0)), + "weight": target_weights[str(symbol)], "relative_score": float(payload.get("relative_score", 0.0)), "abs_momentum": float(payload.get("abs_momentum", 0.0)), } for symbol, payload in dict(diagnostics.get("rotation_candidates", {})).items() + if str(symbol) in target_weights } - planned_trend_buys = { - str(symbol): float(amount) - for symbol, amount in dict(diagnostics.get("planned_trend_buys", {})).items() + current_values = { + str(position.symbol): float(position.market_value or 0.0) + for position in (() if execution_authority is None else execution_authority.portfolio_snapshot.positions) } + total_equity = 0.0 if execution_authority is None else float(execution_authority.portfolio_snapshot.total_equity) + remaining_budget = _budget_map(decision).get("trend_rotation_pool", 0.0) + planned_trend_buys: dict[str, float] = {} + for symbol, target_weight in target_weights.items(): + buy_amount = max(0.0, target_weight * total_equity - current_values.get(symbol, 0.0)) + buy_amount = min(buy_amount, remaining_budget) + if buy_amount > 0.0: + planned_trend_buys[symbol] = buy_amount + remaining_budget -= buy_amount + eligible_buy_symbols = list(planned_trend_buys) sell_reasons = { - str(symbol): str(reason) - for symbol, reason in dict(diagnostics.get("sell_reasons", {})).items() - if str(reason) + symbol: str(dict(diagnostics.get("sell_reasons", {})).get(symbol) or "strategy_target_exit") + for symbol, current_value in current_values.items() + if symbol != "BTCUSDT" and current_value > 0.0 and target_weights.get(symbol, 0.0) <= 0.0 } 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": eligible_buy_symbols, "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..01bd418f 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,7 @@ ) from market_snapshot_support import ( capture_market_snapshot as ms_capture_market_snapshot, + execute_bnb_fuel_top_up as ms_execute_bnb_fuel_top_up, ) from runtime_support import ( ExecutionRuntime as _ExecutionRuntime, @@ -788,6 +789,20 @@ def _capture_market_snapshot(runtime, report, runtime_trend_universe, log_buffer ) +def _execute_bnb_fuel_top_up(runtime, report, market_snapshot, log_buffer, *, execution_authority): + return ms_execute_bnb_fuel_top_up( + runtime, + report, + market_snapshot, + log_buffer, + execution_authority=execution_authority, + ensure_asset_available_fn=ensure_asset_available_runtime, + runtime_call_client_fn=runtime_call_client, + runtime_notify_fn=runtime_notify, + append_log_fn=append_log, + ) + + def _resolve_strategy_evaluation( runtime, state, @@ -823,6 +838,8 @@ def _resolve_strategy_evaluation( allow_rotation_refresh=allow_pool_refresh, get_symbol_trade_state_fn=get_symbol_trade_state, set_symbol_trade_state_fn=set_symbol_trade_state, + mandate_provenance=getattr(runtime, "mandate_provenance", None), + candidate_identity=getattr(runtime, "candidate_risk_identity", None), ) @@ -853,12 +870,17 @@ def _resolve_strategy_plan( allow_new_trend_entries=allow_new_trend_entries, allow_pool_refresh=allow_pool_refresh, ) - strategy_plan = map_decision_to_rotation_plan(evaluation.decision) + strategy_plan = map_decision_to_rotation_plan( + evaluation.decision, + execution_authority=evaluation.execution_authority, + ) strategy_plan["allocation"] = map_decision_to_allocation( evaluation.decision, account_metrics=evaluation.account_metrics, + execution_authority=evaluation.execution_authority, ) strategy_plan["decision"] = evaluation.decision + strategy_plan["execution_authority"] = evaluation.execution_authority return strategy_plan @@ -877,6 +899,7 @@ def _compute_portfolio_allocation(runtime, runtime_trend_universe, balances, pri return map_decision_to_allocation( evaluation.decision, account_metrics=evaluation.account_metrics, + execution_authority=evaluation.execution_authority, ) @@ -949,6 +972,8 @@ def _run_daily_circuit_breaker( trend_daily_pnl, circuit_breaker_pct, log_buffer, + *, + execution_authority, ): return app_run_daily_circuit_breaker( runtime, @@ -961,6 +986,7 @@ def _run_daily_circuit_breaker( trend_daily_pnl, circuit_breaker_pct, log_buffer, + execution_authority=execution_authority, format_qty_fn=format_qty, runtime_notify_fn=runtime_notify, ensure_asset_available_fn=ensure_asset_available_runtime, @@ -994,6 +1020,8 @@ def _execute_trend_sells( u_total, log_buffer, today_id_str, + *, + execution_authority, ): return app_execute_trend_sells( runtime, @@ -1006,6 +1034,7 @@ def _execute_trend_sells( u_total, log_buffer, today_id_str, + execution_authority=execution_authority, should_skip_duplicate_trend_action_fn=should_skip_duplicate_trend_action, append_log_fn=append_log, translate_fn=t, @@ -1032,6 +1061,8 @@ def _execute_trend_buys( u_total, log_buffer, today_id_str, + *, + execution_authority, ): return app_execute_trend_buys( runtime, @@ -1045,6 +1076,7 @@ def _execute_trend_buys( u_total, log_buffer, today_id_str, + execution_authority=execution_authority, should_skip_duplicate_trend_action_fn=should_skip_duplicate_trend_action, append_log_fn=append_log, translate_fn=t, @@ -1088,6 +1120,9 @@ def _execute_trend_rotation( today_id_str, allow_new_trend_entries, allow_pool_refresh, + *, + strategy_plan, + execution_authority, ): return app_execute_trend_rotation( runtime, @@ -1104,7 +1139,8 @@ def _execute_trend_rotation( today_id_str, allow_new_trend_entries, allow_pool_refresh, - resolve_strategy_plan=lambda *args, **kwargs: _resolve_strategy_plan(runtime, *args, **kwargs), + execution_authority=execution_authority, + resolve_strategy_plan=lambda *_args, **_kwargs: strategy_plan, append_rotation_summary=_append_rotation_summary, execute_trend_sells=_execute_trend_sells, execute_trend_buys=_execute_trend_buys, @@ -1128,6 +1164,8 @@ def _execute_btc_dca_cycle( btc_base_order_usdt, today_id_str, log_buffer, + *, + execution_authority, ): return app_execute_btc_dca_cycle( runtime, @@ -1144,6 +1182,7 @@ def _execute_btc_dca_cycle( btc_base_order_usdt, today_id_str, log_buffer, + execution_authority=execution_authority, append_log_fn=append_log, translate_fn=t, format_qty_fn=format_qty, @@ -1168,7 +1207,8 @@ def execute_cycle(runtime): load_cycle_state=_load_cycle_state, append_trend_pool_source_logs=_append_trend_pool_source_logs, capture_market_snapshot=_capture_market_snapshot, - compute_portfolio_allocation=_compute_portfolio_allocation, + execute_bnb_fuel_top_up=_execute_bnb_fuel_top_up, + resolve_strategy_plan=_resolve_strategy_plan, build_balance_snapshot=_build_balance_snapshot, maybe_reset_daily_state=_maybe_reset_daily_state, maybe_rebase_daily_state_for_balance_change=_maybe_rebase_daily_state_for_balance_change, diff --git a/market_snapshot_support.py b/market_snapshot_support.py index c425d8a0..4774e9d0 100644 --- a/market_snapshot_support.py +++ b/market_snapshot_support.py @@ -2,6 +2,7 @@ from typing import Any, Callable, Mapping +from decision_mapper import is_execution_authority_valid from notify_i18n_support import translate as t @@ -28,35 +29,6 @@ def capture_market_snapshot( bnb_price = float(runtime.client.get_avg_price(symbol=bnb_fuel_symbol)["price"]) dynamic_usdt_buffer = max(50.0, min(u_total * 0.05, 300.0)) - if bnb_total * bnb_price < min_bnb_value and u_total >= buy_bnb_amount: - report["buy_sell_intents"].append( - { - "category": "fuel", - "action": "buy", - "symbol": bnb_fuel_symbol, - "quote_order_qty": buy_bnb_amount, - } - ) - try: - if not ensure_asset_available_fn(runtime, report, "USDT", buy_bnb_amount, log_buffer): - raise RuntimeError(t("usdt_spot_buffer_unavailable_for_bnb_top_up")) - runtime_call_client_fn( - runtime, - report, - method_name="order_market_buy", - payload={"symbol": bnb_fuel_symbol, "quoteOrderQty": buy_bnb_amount}, - effect_type="order_buy", - ) - u_total -= buy_bnb_amount - bnb_total += (buy_bnb_amount * 0.995) / bnb_price - append_log_fn(log_buffer, t("bnb_top_up_completed")) - except Exception as exc: - runtime_notify_fn( - runtime, - report, - f"{t('bnb_top_up_failed')}\n{t('error_label')}: {exc}", - ) - prices = {} balances = {} for symbol, config in runtime_trend_universe.items(): @@ -73,6 +45,11 @@ def capture_market_snapshot( return { "u_total": u_total, + "bnb_total": bnb_total, + "bnb_price": bnb_price, + "bnb_top_up_required": bnb_total * bnb_price < min_bnb_value and u_total >= buy_bnb_amount, + "bnb_top_up_amount": buy_bnb_amount, + "bnb_fuel_symbol": bnb_fuel_symbol, "fuel_val": bnb_total * bnb_price, "dynamic_usdt_buffer": dynamic_usdt_buffer, "prices": prices, @@ -80,3 +57,56 @@ def capture_market_snapshot( "btc_snapshot": btc_snapshot, "trend_indicators": resolve_trend_indicators_fn(runtime), } + + +def execute_bnb_fuel_top_up( + runtime, + report: dict[str, Any], + market_snapshot: Mapping[str, Any], + log_buffer, + *, + execution_authority, + ensure_asset_available_fn: Callable[..., bool], + runtime_call_client_fn: Callable[..., Any], + runtime_notify_fn: Callable[..., Any], + append_log_fn: Callable[..., Any], +) -> tuple[float, float]: + u_total = float(market_snapshot["u_total"]) + fuel_val = float(market_snapshot["fuel_val"]) + if not is_execution_authority_valid(execution_authority): + return u_total, fuel_val + if not market_snapshot.get("bnb_top_up_required"): + return u_total, fuel_val + + buy_bnb_amount = float(market_snapshot["bnb_top_up_amount"]) + bnb_price = float(market_snapshot["bnb_price"]) + bnb_total = float(market_snapshot["bnb_total"]) + bnb_fuel_symbol = str(market_snapshot["bnb_fuel_symbol"]) + report["buy_sell_intents"].append( + { + "category": "fuel", + "action": "buy", + "symbol": bnb_fuel_symbol, + "quote_order_qty": buy_bnb_amount, + } + ) + try: + if not ensure_asset_available_fn(runtime, report, "USDT", buy_bnb_amount, log_buffer): + raise RuntimeError(t("usdt_spot_buffer_unavailable_for_bnb_top_up")) + runtime_call_client_fn( + runtime, + report, + method_name="order_market_buy", + payload={"symbol": bnb_fuel_symbol, "quoteOrderQty": buy_bnb_amount}, + effect_type="order_buy", + ) + u_total -= buy_bnb_amount + bnb_total += (buy_bnb_amount * 0.995) / bnb_price + append_log_fn(log_buffer, t("bnb_top_up_completed")) + except Exception as exc: + runtime_notify_fn( + runtime, + report, + f"{t('bnb_top_up_failed')}\n{t('error_label')}: {exc}", + ) + return u_total, bnb_total * bnb_price diff --git a/pyproject.toml b/pyproject.toml index c45cc399..5cb59e47 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@730ad9f3983bd90cd75adecb67fcf483ffb96736", "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@730ad9f3983bd90cd75adecb67fcf483ffb96736", ] [tool.ruff] diff --git a/qsl.toml b/qsl.toml index 2a80a5c9..42e36231 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 = "730ad9f3983bd90cd75adecb67fcf483ffb96736" crypto_strategies = "ef78312d7653095f585c4f75d45bf765bedc2751" [qsl.compat] diff --git a/strategy_runtime.py b/strategy_runtime.py index f3351996..98a93352 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -7,6 +7,7 @@ from typing import Any, Callable, Mapping from quant_platform_kit import PortfolioSnapshot, Position, build_strategy_evaluation_inputs +from quant_platform_kit.risk.contracts import CandidateRiskIdentity from quant_platform_kit.strategy_contracts import ( StrategyContext, StrategyDecision, @@ -17,6 +18,7 @@ ) from crypto_strategies import get_platform_runtime_adapter +from decision_mapper import ExecutionAuthority, build_execution_authority from strategy_loader import load_strategy_entrypoint_for_profile from strategy_registry import BINANCE_PLATFORM, resolve_strategy_metadata from trend_pool_support import get_default_live_pool_candidates as tp_get_default_live_pool_candidates @@ -101,6 +103,8 @@ class StrategyEvaluationResult: decision: StrategyDecision account_metrics: Mapping[str, Any] = field(default_factory=dict) metadata: Mapping[str, Any] = field(default_factory=dict) + portfolio_snapshot: PortfolioSnapshot | None = None + execution_authority: ExecutionAuthority | None = None @dataclass(frozen=True) @@ -184,9 +188,15 @@ def build_portfolio_snapshot( market_value=market_value, ) ) + total_equity = float(account_metrics["total_equity"]) + observed_effective_exposure = ( + sum(float(position.market_value or 0.0) for position in positions) / total_equity + if total_equity > 0.0 + else float("nan") + ) return PortfolioSnapshot( as_of=as_of, - total_equity=float(account_metrics["total_equity"]), + total_equity=total_equity, buying_power=float(account_metrics["cash_usdt"]), cash_balance=float(account_metrics["cash_usdt"]), positions=tuple(positions), @@ -195,6 +205,7 @@ def build_portfolio_snapshot( "cash_available_for_trading": float(account_metrics["cash_usdt"]), "trend_value": float(account_metrics["trend_value"]), "dca_value": float(account_metrics["dca_value"]), + "observed_effective_exposure": observed_effective_exposure, }, ) @@ -214,6 +225,8 @@ def evaluate( allow_rotation_refresh: bool = True, get_symbol_trade_state_fn: Callable[..., Any] | None = None, set_symbol_trade_state_fn: Callable[..., Any] | None = None, + mandate_provenance: Mapping[str, Any] | None = None, + candidate_identity: CandidateRiskIdentity | None = None, ) -> StrategyEvaluationResult: runtime_config = dict(self.runtime_overrides) runtime_config.update( @@ -263,6 +276,11 @@ def evaluate( runtime_config=runtime_config, capabilities={"platform": BINANCE_PLATFORM}, ) + artifacts: dict[str, Any] = {"trend_pool_contract": self.artifact_contract} + if isinstance(mandate_provenance, Mapping): + artifacts["mandate_provenance"] = mandate_provenance + if type(candidate_identity) is CandidateRiskIdentity: + artifacts["candidate_risk_identity"] = candidate_identity ctx = StrategyContext( as_of=ctx.as_of, market_data=ctx.market_data, @@ -270,12 +288,21 @@ def evaluate( state=ctx.state, runtime_config=ctx.runtime_config, capabilities=ctx.capabilities, - artifacts={"trend_pool_contract": self.artifact_contract}, + artifacts=artifacts, ) decision = self.entrypoint.evaluate(ctx) + execution_authority = build_execution_authority( + decision, + portfolio_snapshot=portfolio_snapshot, + mandate_provenance=mandate_provenance, + candidate_identity=candidate_identity, + market_data=ctx.market_data, + ) return StrategyEvaluationResult( decision=decision, account_metrics=dict(account_metrics), + portfolio_snapshot=portfolio_snapshot, + execution_authority=execution_authority, metadata={ "strategy_profile": self.profile, "strategy_display_name": resolve_strategy_metadata( diff --git a/tests/test_cycle_replay_runtime.py b/tests/test_cycle_replay_runtime.py index 192261e7..0355f76a 100644 --- a/tests/test_cycle_replay_runtime.py +++ b/tests/test_cycle_replay_runtime.py @@ -97,33 +97,27 @@ def test_dry_run_produces_no_real_side_effects(self): result = self.run_cycle(run_id="dry-run-regression") report = result["report"] - self.assertEqual(report["status"], "ok") + self.assertEqual(report["status"], "aborted") self.assertTrue(report["dry_run"]) self.assertEqual(result["client"].side_effect_calls, []) 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.assertGreaterEqual(len(report["redemption_subscription_intents"]), 1) + self.assertEqual(report["buy_sell_intents"], []) + self.assertEqual(report["btc_dca_intents"], []) + self.assertEqual(report["redemption_subscription_intents"], []) def test_fixed_input_produces_deterministic_execution_report(self): first = self.run_cycle(run_id="deterministic-report") second = self.run_cycle(run_id="deterministic-report") self.assertEqual(first["report"], second["report"]) - self.assertEqual( - 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"]["redemption_subscription_intents"][0]["action"], "subscribe") - self.assertAlmostEqual(first["report"]["redemption_subscription_intents"][0]["amount"], 71.5) + self.assertEqual(first["report"]["status"], "aborted") + self.assertEqual(first["report"]["selected_symbols"]["active_trend_pool"], []) + self.assertEqual(first["report"]["selected_symbols"]["selected_candidates"], []) + self.assertEqual(first["report"]["buy_sell_intents"], []) + self.assertEqual(first["report"]["btc_dca_intents"], []) + self.assertEqual(first["report"]["redemption_subscription_intents"], []) + self.assertEqual(first["client"].side_effect_calls, []) def test_state_load_failure_aborts_execution_safely(self): runtime, client, state_store, _ = run_cycle_replay.build_replay_runtime( diff --git a/tests/test_cycle_service.py b/tests/test_cycle_service.py index ab5c0c16..1fac348d 100644 --- a/tests/test_cycle_service.py +++ b/tests/test_cycle_service.py @@ -2,6 +2,7 @@ import os import tempfile import unittest +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import patch @@ -199,7 +200,8 @@ def test_execute_strategy_cycle_returns_aborted_report_when_client_unavailable(s load_cycle_state=lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not load state")), append_trend_pool_source_logs=lambda *_args, **_kwargs: None, capture_market_snapshot=lambda *_args, **_kwargs: None, - compute_portfolio_allocation=lambda *_args, **_kwargs: None, + execute_bnb_fuel_top_up=lambda *_args, **_kwargs: None, + resolve_strategy_plan=lambda *_args, **_kwargs: None, build_balance_snapshot=lambda *_args, **_kwargs: {}, maybe_reset_daily_state=lambda *_args, **_kwargs: None, maybe_rebase_daily_state_for_balance_change=lambda *_args, **_kwargs: False, @@ -238,7 +240,8 @@ def test_execute_strategy_cycle_captures_unhandled_exception(self): load_cycle_state=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")), append_trend_pool_source_logs=lambda *_args, **_kwargs: None, capture_market_snapshot=lambda *_args, **_kwargs: None, - compute_portfolio_allocation=lambda *_args, **_kwargs: None, + execute_bnb_fuel_top_up=lambda *_args, **_kwargs: None, + resolve_strategy_plan=lambda *_args, **_kwargs: None, build_balance_snapshot=lambda *_args, **_kwargs: {}, maybe_reset_daily_state=lambda *_args, **_kwargs: None, maybe_rebase_daily_state_for_balance_change=lambda *_args, **_kwargs: False, @@ -258,6 +261,83 @@ def test_execute_strategy_cycle_captures_unhandled_exception(self): self.assertEqual(report["status"], "error") self.assertEqual(observed["errors"], [("execute_cycle", "boom")]) + def test_execute_strategy_cycle_stops_all_execution_callbacks_without_authority(self): + runtime = SimpleNamespace( + dry_run=True, + print_traceback=False, + now_utc=datetime(2026, 3, 29, tzinfo=timezone.utc), + tg_token="", + tg_chat_id="", + strategy_profile="crypto_live_pool_rotation", + ) + observed = {"breaker": 0, "trend": 0, "dca": 0, "earn": 0} + + report = execute_strategy_cycle( + runtime, + build_execution_report=lambda _runtime: {"status": "ok", "log_lines": []}, + ensure_runtime_client=lambda *_args, **_kwargs: 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, **_kwargs: ( + {}, + {"degraded": False}, + {"ETHUSDT": {"base_asset": "ETH"}}, + True, + ), + append_trend_pool_source_logs=lambda *_args, **_kwargs: None, + capture_market_snapshot=lambda *_args, **_kwargs: { + "u_total": 500.0, + "fuel_val": 20.0, + "dynamic_usdt_buffer": 50.0, + "prices": {"BTCUSDT": 50_000.0, "ETHUSDT": 100.0}, + "balances": {"BTCUSDT": 0.01, "ETHUSDT": 1.0}, + "btc_snapshot": {"ahr999": 0.7, "zscore": 0.0, "sell_trigger": 3.5}, + "trend_indicators": {"ETHUSDT": {}}, + }, + execute_bnb_fuel_top_up=lambda *_args, **_kwargs: (500.0, 20.0), + resolve_strategy_plan=lambda *_args, **_kwargs: { + "allocation": { + "total_equity": 1_120.0, + "trend_val": 100.0, + "dca_val": 500.0, + "btc_target_ratio": 0.25, + "trend_target_ratio": 0.25, + "trend_usdt_pool": 100.0, + "dca_usdt_pool": 100.0, + "btc_base_order_usdt": 50.0, + }, + "execution_authority": None, + }, + build_balance_snapshot=lambda *_args, **_kwargs: {}, + maybe_reset_daily_state=lambda *_args, **_kwargs: None, + maybe_rebase_daily_state_for_balance_change=lambda *_args, **_kwargs: False, + compute_daily_pnls=lambda *_args, **_kwargs: (0.0, 0.0), + append_portfolio_report=lambda *_args, **_kwargs: None, + run_daily_circuit_breaker=lambda *_args, **_kwargs: observed.__setitem__( + "breaker", observed["breaker"] + 1 + ) or False, + execute_trend_rotation=lambda *_args, **_kwargs: observed.__setitem__( + "trend", observed["trend"] + 1 + ) or 500.0, + execute_btc_dca_cycle=lambda *_args, **_kwargs: observed.__setitem__( + "dca", observed["dca"] + 1 + ) or 500.0, + manage_usdt_earn_buffer_runtime=lambda *_args, **_kwargs: observed.__setitem__( + "earn", observed["earn"] + 1 + ), + 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, **_kwargs: None, + translate_fn=lambda key, **_kwargs: key, + traceback_module=SimpleNamespace(print_exc=lambda: None), + ) + + self.assertEqual(report["status"], "aborted") + self.assertEqual(observed, {"breaker": 0, "trend": 0, "dca": 0, "earn": 0}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index cdec5978..ef4ccf0c 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -1,5 +1,7 @@ import sys import unittest +from dataclasses import replace +from datetime import datetime, timedelta, timezone from pathlib import Path @@ -10,10 +12,75 @@ if str(QPK_SRC) not in sys.path: sys.path.insert(0, str(QPK_SRC)) -from decision_mapper import map_strategy_decision_to_allocation, map_strategy_decision_to_rotation_plan +from decision_mapper import ( + build_execution_authority, + is_execution_authority_valid, + map_strategy_decision_to_allocation, + map_strategy_decision_to_rotation_plan, +) +from quant_platform_kit import PortfolioSnapshot, Position +from quant_platform_kit.risk.contracts import CandidateRiskIdentity from quant_platform_kit.strategy_contracts import BudgetIntent, PositionTarget, StrategyDecision +def _candidate_identity(**overrides): + values = { + "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, + } + values.update(overrides) + return CandidateRiskIdentity(**values) + + +def _approved_authority(decision, *, expired=False): + now = datetime.now(timezone.utc) + candidate = _candidate_identity() + expires_at = now - timedelta(minutes=1) if expired else now + timedelta(minutes=5) + mandate = { + "mandate_id": "binance_crypto_research_only_v1", + "mandate_version": "v1", + "authority_receipt_sha256": candidate.authority_receipt_sha256, + "authority_scope": "RESEARCH_ONLY", + "strategy_profile": candidate.strategy_profile, + "account_mode": candidate.account_mode, + "strategy_revision": candidate.strategy_revision, + "runner_revision": candidate.runner_revision, + "config_sha256": candidate.config_sha256, + "input_manifest_sha256": candidate.input_manifest_sha256, + "candidate_identity_sha256": candidate.candidate_sha256, + "effective_at": (now - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"), + "expires_at": expires_at.isoformat().replace("+00:00", "Z"), + "max_snapshot_age_seconds": 300, + "effective_exposure_cap": 1.0, + "loss_budget": 10_000.0, + "product_caps": 1.0, + "nominal_caps": 1.0, + "product_leverage_factors": {"BTCUSDT": 1, "ETHUSDT": 1, "SOLUSDT": 1}, + "allowed_nonzero_assets": ["BTCUSDT", "ETHUSDT", "SOLUSDT"], + "source_revision": "6" * 40, + } + snapshot = PortfolioSnapshot( + as_of=now, + total_equity=1_000.0, + buying_power=900.0, + cash_balance=900.0, + positions=(Position(symbol="SOLUSDT", quantity=1.0, market_value=100.0),), + metadata={"observed_effective_exposure": 0.1}, + ) + return build_execution_authority( + decision, + portfolio_snapshot=snapshot, + mandate_provenance=mandate, + candidate_identity=candidate, + market_data={}, + ) + + class DecisionMapperTests(unittest.TestCase): def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): decision = StrategyDecision( @@ -39,6 +106,7 @@ def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): "trend_value": 3500.0, "dca_value": 1800.0, }, + execution_authority=_approved_authority(decision), ) self.assertEqual(allocation["total_equity"], 10000.0) @@ -46,10 +114,12 @@ def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): self.assertEqual(allocation["dca_usdt_pool"], 250.0) self.assertEqual(allocation["btc_base_order_usdt"], 50.0) self.assertEqual(allocation["btc_target_ratio"], 0.3) - self.assertEqual(allocation["trend_target_ratio"], 0.7) + self.assertEqual(allocation["trend_target_ratio"], 0.4) def test_map_strategy_decision_to_rotation_plan_uses_unified_diagnostics(self): decision = StrategyDecision( + positions=(PositionTarget(symbol="ETHUSDT", target_weight=0.4),), + budgets=(BudgetIntent(name="trend_rotation_pool", amount=400.0),), diagnostics={ "trend_pool": ("ETHUSDT", "SOLUSDT"), "metadata": { @@ -77,11 +147,14 @@ def test_map_strategy_decision_to_rotation_plan_uses_unified_diagnostics(self): risk_flags=("regime_off",), ) - plan = map_strategy_decision_to_rotation_plan(decision) + plan = map_strategy_decision_to_rotation_plan( + decision, + execution_authority=_approved_authority(decision), + ) self.assertEqual(plan["active_trend_pool"], ["ETHUSDT", "SOLUSDT"]) self.assertEqual(plan["eligible_buy_symbols"], ["ETHUSDT"]) - self.assertEqual(plan["planned_trend_buys"], {"ETHUSDT": 320.0}) + self.assertEqual(plan["planned_trend_buys"], {"ETHUSDT": 400.0}) self.assertEqual(plan["sell_reasons"], {"SOLUSDT": "trend_sell_reason_rotated_out"}) self.assertEqual(plan["artifact_contract"], {"version": "v1"}) self.assertEqual(plan["risk_flags"], ("regime_off",)) @@ -101,6 +174,75 @@ def test_map_strategy_decision_to_rotation_plan_uses_unified_diagnostics(self): }, ) + def test_rejected_diagnostics_do_not_create_an_executable_plan(self): + decision = StrategyDecision( + diagnostics={ + "member_risk_assessment": {"outcome": "REJECT"}, + "eligible_buy_symbols": ("ETHUSDT",), + "planned_trend_buys": {"ETHUSDT": 320.0}, + "sell_reasons": {"SOLUSDT": "stale_diagnostic"}, + } + ) + + plan = map_strategy_decision_to_rotation_plan(decision) + + self.assertEqual(plan["eligible_buy_symbols"], []) + self.assertEqual(plan["planned_trend_buys"], {}) + self.assertEqual(plan["sell_reasons"], {}) + + def test_authority_rejects_tampered_scope_identity_digests_payload_and_freshness(self): + decision = StrategyDecision(positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.1),)) + authority = _approved_authority(decision) + self.assertIsNotNone(authority) + self.assertTrue(is_execution_authority_valid(authority, decision=decision)) + + member = authority.member_assessment + mutations = ( + replace(authority, member_assessment=replace(member, outcome="REJECT", reason_codes=("rejected",))), + replace(authority, member_assessment=replace(member, scope="ACCOUNT")), + replace(authority, member_assessment=replace(member, decision_digest_sha256="a" * 64)), + replace(authority, member_assessment=replace(member, portfolio_snapshot_digest_sha256="b" * 64)), + replace(authority, member_assessment=replace(member, evaluated_at="2020-01-01T00:00:00Z")), + replace(authority, candidate_identity=_candidate_identity(config_sha256="7" * 64)), + ) + for mutation in mutations: + with self.subTest(mutation=mutation): + self.assertFalse(is_execution_authority_valid(mutation, decision=decision)) + + tampered_payload = replace(member) + object.__setattr__(tampered_payload, "assessment_sha256", "0" * 64) + self.assertFalse( + is_execution_authority_valid( + replace(authority, member_assessment=tampered_payload), + decision=decision, + ) + ) + non_finite = replace(member) + object.__setattr__(non_finite, "effective_exposure_cap", float("nan")) + self.assertFalse( + is_execution_authority_valid( + replace(authority, member_assessment=non_finite), + decision=decision, + ) + ) + + def test_missing_or_expired_mandate_cannot_build_authority(self): + decision = StrategyDecision(positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.1),)) + self.assertIsNone(_approved_authority(decision, expired=True)) + self.assertIsNone( + build_execution_authority( + decision, + portfolio_snapshot=PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=1_000.0, + metadata={"observed_effective_exposure": 0.0}, + ), + mandate_provenance=None, + candidate_identity=None, + market_data={}, + ) + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 5d07379c..bad5ad2c 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -1,5 +1,6 @@ import unittest from types import SimpleNamespace +from unittest.mock import patch from application.execution_service import ( execute_btc_dca_cycle, @@ -9,8 +10,18 @@ run_daily_circuit_breaker, ) +APPROVED_AUTHORITY = object() + class ExecutionServiceTests(unittest.TestCase): + def setUp(self): + patcher = patch( + "application.execution_service.is_execution_authority_valid", + side_effect=lambda authority: authority is APPROVED_AUTHORITY, + ) + patcher.start() + self.addCleanup(patcher.stop) + def test_run_daily_circuit_breaker_liquidates_and_latches_state(self): runtime = SimpleNamespace(client=object()) report = {"buy_sell_intents": []} @@ -30,6 +41,7 @@ def test_run_daily_circuit_breaker_liquidates_and_latches_state(self): -0.10, -0.05, [], + execution_authority=APPROVED_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, @@ -81,6 +93,7 @@ def test_execute_trend_sells_executes_sell_and_updates_runtime_state(self): 50.0, [], "20260329", + execution_authority=APPROVED_AUTHORITY, should_skip_duplicate_trend_action_fn=lambda *_args: False, append_log_fn=lambda _buffer, message: observed["logs"].append(message), translate_fn=lambda key, **kwargs: f"{key}:{kwargs}" if kwargs else key, @@ -134,6 +147,7 @@ def test_execute_trend_buys_executes_buy_and_updates_runtime_state(self): 500.0, [], "20260329", + execution_authority=APPROVED_AUTHORITY, should_skip_duplicate_trend_action_fn=lambda *_args: False, append_log_fn=lambda _buffer, message: observed["logs"].append(message), translate_fn=lambda key, **kwargs: f"{key}:{kwargs}" if kwargs else key, @@ -175,6 +189,7 @@ def test_execute_trend_buys_records_gate_when_budget_below_threshold(self): 500.0, [], "20260329", + execution_authority=APPROVED_AUTHORITY, should_skip_duplicate_trend_action_fn=lambda *_args: False, append_log_fn=lambda *_args: None, translate_fn=lambda key, **_kwargs: key, @@ -227,11 +242,11 @@ def fake_resolve_strategy_plan(*args, **kwargs): observed["plan_calls"].append((args, kwargs)) return plans[len(observed["plan_calls"]) - 1] - def fake_execute_trend_sells(*_args): + def fake_execute_trend_sells(*_args, **_kwargs): observed["sell_called"] = True return 1150.0 - def fake_execute_trend_buys(*_args): + def fake_execute_trend_buys(*_args, **_kwargs): observed["buy_plan"] = dict(_args[5]) return 980.0 @@ -250,6 +265,7 @@ def fake_execute_trend_buys(*_args): "20260329", True, False, + execution_authority=APPROVED_AUTHORITY, resolve_strategy_plan=fake_resolve_strategy_plan, append_rotation_summary=lambda *_args: observed.__setitem__("summary_called", True), execute_trend_sells=fake_execute_trend_sells, @@ -329,10 +345,11 @@ def fake_resolve_strategy_plan(*_args, **_kwargs): "20260329", True, True, + execution_authority=APPROVED_AUTHORITY, resolve_strategy_plan=fake_resolve_strategy_plan, append_rotation_summary=lambda *_args: None, - execute_trend_sells=lambda *_args: 1000.0, - execute_trend_buys=lambda *_args: 1000.0, + execute_trend_sells=lambda *_args, **_kwargs: 1000.0, + execute_trend_buys=lambda *_args, **_kwargs: 1000.0, append_trend_symbol_status=lambda *_args: None, official_trend_pool_symbols=["ETHUSDT"], ) @@ -368,6 +385,7 @@ def test_execute_btc_dca_cycle_executes_buy_branch(self): 50.0, "20260329", log_buffer, + execution_authority=APPROVED_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 +431,7 @@ def test_execute_btc_dca_cycle_executes_trim_branch(self): 50.0, "20260329", log_buffer, + execution_authority=APPROVED_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 +472,7 @@ def test_execute_btc_dca_cycle_records_gate_when_pool_too_small(self): 50.0, "20260329", [], + execution_authority=APPROVED_AUTHORITY, append_log_fn=lambda *_args: None, translate_fn=lambda key, **_kwargs: key, format_qty_fn=lambda *_args: 0.0, @@ -467,6 +487,84 @@ def test_execute_btc_dca_cycle_records_gate_when_pool_too_small(self): self.assertEqual(report["btc_dca_intents"], []) self.assertEqual(report["gating_summary"]["btc_dca_pool_too_small"], 1) + def test_order_paths_fail_closed_without_typed_authority(self): + runtime = SimpleNamespace(client=object()) + client_calls = [] + + def common_call(_runtime, _report, method_name, payload, effect_type): + client_calls.append((method_name, payload, effect_type)) + + run_daily_circuit_breaker( + runtime, + {"buy_sell_intents": []}, + {}, + {"ETHUSDT": {"base_asset": "ETH"}}, + {"ETHUSDT": 2.0}, + 50.0, + {"ETHUSDT": 100.0}, + -0.10, + -0.05, + [], + format_qty_fn=lambda *_args: 1.0, + runtime_notify_fn=lambda *_args, **_kwargs: None, + ensure_asset_available_fn=lambda *_args, **_kwargs: True, + runtime_call_client_fn=common_call, + set_symbol_trade_state_fn=lambda *_args, **_kwargs: None, + runtime_set_trade_state_fn=lambda *_args, **_kwargs: None, + build_balance_snapshot_fn=lambda *_args, **_kwargs: {}, + translate_fn=lambda key, **_kwargs: key, + ) + execute_trend_buys( + runtime, + {"buy_sell_intents": []}, + {}, + {"ETHUSDT": {"weight": 0.2, "relative_score": 1.0}}, + ["ETHUSDT"], + {"ETHUSDT": 100.0}, + {"ETHUSDT": 100.0}, + {"ETHUSDT": 0.0}, + 500.0, + [], + "20260329", + should_skip_duplicate_trend_action_fn=lambda *_args: False, + 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, **_kwargs: True, + runtime_call_client_fn=common_call, + next_order_id_fn=lambda *_args: "trend-buy", + set_symbol_trade_state_fn=lambda *_args, **_kwargs: None, + record_trend_action_fn=lambda *_args, **_kwargs: None, + runtime_set_trade_state_fn=lambda *_args, **_kwargs: None, + runtime_notify_fn=lambda *_args, **_kwargs: None, + ) + execute_btc_dca_cycle( + runtime, + {"btc_dca_intents": [], "gating_summary": {}, "gating_events": []}, + {}, + {"BTCUSDT": 0.0}, + {"BTCUSDT": 50_000.0}, + 500.0, + 1_000.0, + 100.0, + 0.0, + {"ahr999": 0.7, "zscore": 0.0, "sell_trigger": 3.5}, + 0.25, + 50.0, + "20260329", + [], + append_log_fn=lambda *_args: None, + translate_fn=lambda key, **_kwargs: key, + format_qty_fn=lambda *_args: 0.001, + ensure_asset_available_fn=lambda *_args, **_kwargs: True, + runtime_call_client_fn=common_call, + next_order_id_fn=lambda *_args: "btc-buy", + runtime_notify_fn=lambda *_args, **_kwargs: None, + runtime_set_trade_state_fn=lambda *_args, **_kwargs: None, + ) + + self.assertEqual(client_calls, []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_market_snapshot_support.py b/tests/test_market_snapshot_support.py index 99913dee..ea65a840 100644 --- a/tests/test_market_snapshot_support.py +++ b/tests/test_market_snapshot_support.py @@ -1,7 +1,8 @@ import unittest from types import SimpleNamespace +from unittest.mock import patch -from market_snapshot_support import capture_market_snapshot +from market_snapshot_support import capture_market_snapshot, execute_bnb_fuel_top_up class FakeClient: @@ -13,7 +14,7 @@ def get_avg_price(self, *, symbol): class MarketSnapshotSupportTests(unittest.TestCase): - def test_capture_market_snapshot_handles_bnb_top_up_and_collects_balances(self): + def test_capture_market_snapshot_never_orders_bnb_before_strategy_authority(self): runtime = SimpleNamespace( client=FakeClient( { @@ -54,25 +55,15 @@ def test_capture_market_snapshot_handles_bnb_top_up_and_collects_balances(self): resolve_trend_indicators_fn=lambda runtime: {"ETHUSDT": {"score": 1.0}, "SOLUSDT": {"score": 0.5}}, ) - self.assertEqual( - report["buy_sell_intents"], - [ - { - "category": "fuel", - "action": "buy", - "symbol": "BNBUSDT", - "quote_order_qty": 30.0, - } - ], - ) - self.assertEqual(side_effect_calls[0]["method_name"], "order_market_buy") - self.assertAlmostEqual(snapshot["u_total"], 170.0) - self.assertAlmostEqual(snapshot["fuel_val"], 44.85, places=2) + self.assertEqual(report["buy_sell_intents"], []) + self.assertEqual(side_effect_calls, []) + self.assertAlmostEqual(snapshot["u_total"], 200.0) + self.assertAlmostEqual(snapshot["fuel_val"], 15.0, places=2) self.assertEqual(snapshot["prices"]["ETHUSDT"], 2500.0) self.assertEqual(snapshot["balances"]["SOLUSDT"], 2.0) self.assertEqual(snapshot["balances"]["BTCUSDT"], 0.01) self.assertEqual(snapshot["trend_indicators"]["ETHUSDT"]["score"], 1.0) - self.assertIn("BNB top-up completed", "".join(log_buffer)) + self.assertNotIn("BNB top-up completed", "".join(log_buffer)) def test_capture_market_snapshot_raises_when_btc_snapshot_is_missing(self): runtime = SimpleNamespace( @@ -108,6 +99,51 @@ def test_capture_market_snapshot_raises_when_btc_snapshot_is_missing(self): resolve_trend_indicators_fn=lambda runtime: {}, ) + def test_bnb_top_up_requires_validated_authority(self): + runtime = SimpleNamespace(client=object()) + report = {"buy_sell_intents": []} + calls = [] + snapshot = { + "u_total": 200.0, + "fuel_val": 15.0, + "bnb_total": 0.05, + "bnb_price": 300.0, + "bnb_top_up_required": True, + "bnb_top_up_amount": 30.0, + "bnb_fuel_symbol": "BNBUSDT", + } + kwargs = { + "ensure_asset_available_fn": lambda *_args, **_kwargs: True, + "runtime_call_client_fn": lambda _runtime, _report, **payload: calls.append(payload), + "runtime_notify_fn": lambda *_args, **_kwargs: None, + "append_log_fn": lambda *_args, **_kwargs: None, + } + + no_authority = execute_bnb_fuel_top_up( + runtime, + report, + snapshot, + [], + execution_authority=None, + **kwargs, + ) + self.assertEqual(no_authority, (200.0, 15.0)) + self.assertEqual(calls, []) + + with patch("market_snapshot_support.is_execution_authority_valid", return_value=True): + authorized = execute_bnb_fuel_top_up( + runtime, + report, + snapshot, + [], + execution_authority=object(), + **kwargs, + ) + + self.assertAlmostEqual(authorized[0], 170.0) + self.assertAlmostEqual(authorized[1], 44.85, places=2) + self.assertEqual(calls[0]["method_name"], "order_market_buy") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_notify_i18n.py b/tests/test_notify_i18n.py index b4af75f5..9616cd5f 100644 --- a/tests/test_notify_i18n.py +++ b/tests/test_notify_i18n.py @@ -197,7 +197,7 @@ def test_trend_pool_source_logs_use_chinese_when_notify_lang_is_zh(self): self.assertIn("趋势池来源", log_lines[0]) self.assertTrue(any("暂停新的趋势买入" in line for line in log_lines)) - def test_capture_market_snapshot_uses_chinese_bnb_log_when_notify_lang_is_zh(self): + def test_capture_market_snapshot_defers_bnb_top_up_until_authorized(self): runtime = SimpleNamespace( client=FakeClient( { @@ -212,7 +212,7 @@ def test_capture_market_snapshot_uses_chinese_bnb_log_when_notify_lang_is_zh(sel side_effect_calls = [] with patch.dict(os.environ, {"NOTIFY_LANG": "zh"}, clear=False): - capture_market_snapshot( + snapshot = capture_market_snapshot( runtime, report, {"ETHUSDT": {"base_asset": "ETH"}}, @@ -233,8 +233,10 @@ def test_capture_market_snapshot_uses_chinese_bnb_log_when_notify_lang_is_zh(sel resolve_trend_indicators_fn=lambda runtime: {"ETHUSDT": {"score": 1.0}}, ) - self.assertEqual(side_effect_calls[0]["method_name"], "order_market_buy") - self.assertIn("BNB 补仓已完成", "".join(log_buffer)) + self.assertTrue(snapshot["bnb_top_up_required"]) + self.assertEqual(snapshot["bnb_top_up_amount"], 30.0) + self.assertEqual(side_effect_calls, []) + self.assertNotIn("BNB 补仓已完成", "".join(log_buffer)) if __name__ == "__main__": diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index f305b846..d40f1068 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -24,6 +24,12 @@ class StrategyRuntimeTests(unittest.TestCase): + def test_pinned_qpk_exposes_typed_execution_authority_contracts(self): + from quant_platform_kit.risk.contracts import CandidateRiskIdentity, RiskGateAssessment + + self.assertIn("candidate_sha256", CandidateRiskIdentity.__dataclass_fields__) + self.assertIn("assessment_sha256", RiskGateAssessment.__dataclass_fields__) + def test_load_strategy_runtime_exposes_explicit_artifact_contract(self): try: from strategy_runtime import load_strategy_runtime @@ -135,6 +141,7 @@ def test_strategy_runtime_evaluate_returns_decision_with_buy_sell_diagnostics(se self.assertIn("sell_reasons", diagnostics) self.assertIn("btc_base_order_usdt", diagnostics) self.assertGreaterEqual(diagnostics["btc_base_order_usdt"], 15.0) + self.assertIsNone(evaluation.execution_authority) def test_load_strategy_runtime_uses_entrypoint_only(self): try: @@ -246,8 +253,118 @@ def evaluate(self, ctx): self.assertEqual(ctx.market_data["market_prices"]["ETHUSDT"], 3000.0) self.assertEqual(ctx.market_data["universe_snapshot"], ("ETHUSDT",)) self.assertEqual(ctx.portfolio.metadata["account_metrics"]["cash_usdt"], 2000.0) + self.assertAlmostEqual(ctx.portfolio.metadata["observed_effective_exposure"], 0.8) self.assertEqual(evaluation.metadata["strategy_display_name"], "Crypto Live Pool Rotation") + def test_evaluate_builds_member_and_account_authority_from_typed_qpk_inputs(self): + from datetime import timedelta + + import strategy_runtime as strategy_runtime_module + from decision_mapper import is_execution_authority_valid + from quant_platform_kit.risk.contracts import CandidateRiskIdentity + from quant_platform_kit.strategy_contracts import PositionTarget, StrategyDecision + + class FakeEntrypoint: + manifest = StrategyManifest( + profile="crypto_live_pool_rotation", + domain="crypto", + display_name="Crypto Live Pool Rotation", + description="test", + required_inputs=frozenset( + { + "market_prices", + "derived_indicators", + "benchmark_snapshot", + "portfolio_snapshot", + "universe_snapshot", + } + ), + default_config={}, + ) + + def evaluate(self, ctx): + self.ctx = ctx + return StrategyDecision( + positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.1),), + ) + + now = datetime.now(timezone.utc) + candidate = 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, + ) + mandate = { + "mandate_id": "binance_crypto_research_only_v1", + "mandate_version": "v1", + "authority_receipt_sha256": candidate.authority_receipt_sha256, + "authority_scope": "RESEARCH_ONLY", + "strategy_profile": candidate.strategy_profile, + "account_mode": candidate.account_mode, + "strategy_revision": candidate.strategy_revision, + "runner_revision": candidate.runner_revision, + "config_sha256": candidate.config_sha256, + "input_manifest_sha256": candidate.input_manifest_sha256, + "candidate_identity_sha256": candidate.candidate_sha256, + "effective_at": (now - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"), + "expires_at": (now + timedelta(minutes=5)).isoformat().replace("+00:00", "Z"), + "max_snapshot_age_seconds": 300, + "effective_exposure_cap": 1.0, + "loss_budget": 1_000.0, + "product_caps": 1.0, + "nominal_caps": 1.0, + "product_leverage_factors": {"BTCUSDT": 1}, + "allowed_nonzero_assets": ["BTCUSDT"], + "source_revision": "6" * 40, + } + entrypoint = FakeEntrypoint() + runtime = strategy_runtime_module.LoadedStrategyRuntime( + entrypoint=entrypoint, + runtime_adapter=StrategyRuntimeAdapter( + available_inputs=frozenset(entrypoint.manifest.required_inputs), + portfolio_input_name="portfolio_snapshot", + ), + merged_runtime_config={}, + ) + + with patch.object( + strategy_runtime_module, + "resolve_strategy_metadata", + return_value=SimpleNamespace(display_name="Crypto Live Pool Rotation"), + ): + evaluation = runtime.evaluate( + prices={"BTCUSDT": 50_000.0}, + trend_indicators={}, + btc_snapshot={"regime_on": True}, + account_metrics={ + "total_equity": 1_000.0, + "cash_usdt": 900.0, + "trend_value": 0.0, + "dca_value": 100.0, + }, + trend_universe_symbols=(), + balances={"BTCUSDT": 0.002}, + state={}, + translator=lambda key, **_kwargs: key, + now_utc=now, + mandate_provenance=mandate, + candidate_identity=candidate, + ) + + self.assertTrue( + is_execution_authority_valid( + evaluation.execution_authority, + decision=evaluation.decision, + ) + ) + self.assertEqual(evaluation.execution_authority.member_assessment.scope, "MEMBER") + self.assertEqual(evaluation.execution_authority.account_assessment.scope, "ACCOUNT") + self.assertIs(entrypoint.ctx.artifacts["candidate_risk_identity"], candidate) + def test_evaluate_stamps_consecutive_losses(self): import strategy_runtime as strategy_runtime_module from quant_platform_kit.strategy_contracts import StrategyDecision diff --git a/uv.lock b/uv.lock index 64a628c6..7f3f251b 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=730ad9f3983bd90cd75adecb67fcf483ffb96736" }] [[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=730ad9f3983bd90cd75adecb67fcf483ffb96736" }, { 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=730ad9f3983bd90cd75adecb67fcf483ffb96736#730ad9f3983bd90cd75adecb67fcf483ffb96736" } [[package]] name = "regex"