diff --git a/application/cycle_service.py b/application/cycle_service.py index 4f567137..183ce2f0 100644 --- a/application/cycle_service.py +++ b/application/cycle_service.py @@ -4,11 +4,12 @@ import json import os +from datetime import datetime, timezone 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 runtime_logging import RuntimeLogContext, emit_runtime_log -from runtime_support import finalize_notification_delivery +from runtime_support import build_runtime_evidence_aggregate_v2, finalize_notification_delivery def execute_strategy_cycle( @@ -21,6 +22,7 @@ def execute_strategy_cycle( append_trend_pool_source_logs, capture_market_snapshot, compute_portfolio_allocation, + refresh_action_authorization=None, build_balance_snapshot, maybe_reset_daily_state, maybe_rebase_daily_state_for_balance_change, @@ -37,7 +39,7 @@ def execute_strategy_cycle( translate_fn, traceback_module, ): - circuit_breaker_pct = -0.05 + circuit_breaker_pct = 0.0 min_bnb_value, buy_bnb_amount = 10.0, 15.0 cycle_settings = load_cycle_execution_settings() btc_status_report_interval_hours = cycle_settings.btc_status_report_interval_hours @@ -57,6 +59,11 @@ def execute_strategy_cycle( state, trend_pool_resolution, runtime_trend_universe, allow_new_trend_entries = cycle_state append_trend_pool_source_logs(log_buffer, trend_pool_resolution, allow_new_trend_entries) + report["release_identity"] = dict(trend_pool_resolution.get("runtime_evidence_identity", {})) + report["release_identity_sha256"] = str(trend_pool_resolution.get("release_identity_sha256", "")) + runtime.release_identity = dict(report["release_identity"]) + runtime.release_identity_sha256 = report["release_identity_sha256"] + report["upstream_pool_symbols"] = list(runtime_trend_universe.keys()) if trend_pool_resolution["degraded"]: report["degraded_mode_level"] = trend_pool_resolution.get("source_kind", "unknown") @@ -88,12 +95,37 @@ def execute_strategy_cycle( trend_indicators, btc_snapshot, ) + risk_evidence = allocation.pop("_risk_evidence", {}) + for field_name in ( + "member_risk_assessment", + "account_risk_assessment", + "cap_assessment", + "strategy_stop_evaluation", + "order_authorization", + ): + report[field_name] = dict(risk_evidence.get(field_name, {})) 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 refresh_action_authorization is not None: + runtime.action_authorizer = lambda **action: refresh_action_authorization( + runtime, + report, + state, + runtime_trend_universe, + trend_indicators, + btc_snapshot, + prices, + balances, + fuel_val, + allow_new_trend_entries=allow_new_trend_entries, + allow_pool_refresh=not trend_pool_resolution["degraded"], + **action, + ) + now_utc = runtime.now_utc today_utc = now_utc.strftime("%Y-%m-%d") today_id_str = now_utc.strftime("%Y%m%d") @@ -112,10 +144,6 @@ def execute_strategy_cycle( daily_pnl, trend_daily_pnl = compute_daily_pnls(state, total_equity, trend_val_equity) append_portfolio_report(log_buffer, allocation, fuel_val, daily_pnl, trend_daily_pnl, btc_snapshot) - if state.get("is_circuit_broken"): - log_buffer.insert(0, translate_fn("circuit_breaker_latched_line", total_equity=total_equity)) - return report - if run_daily_circuit_breaker( runtime, report, @@ -124,10 +152,12 @@ def execute_strategy_cycle( balances, u_total, prices, - trend_daily_pnl, + daily_pnl, circuit_breaker_pct, log_buffer, ): + if state.get("is_circuit_broken"): + log_buffer.insert(0, translate_fn("circuit_breaker_latched_line", total_equity=total_equity)) return report u_total = execute_trend_rotation( @@ -187,6 +217,7 @@ def execute_strategy_cycle( log_buffer, ) + runtime.action_cash_usdt = float(u_total) manage_usdt_earn_buffer_runtime( runtime, report, @@ -224,6 +255,7 @@ def execute_strategy_cycle( except Exception: pass finally: + runtime.action_authorizer = None report["log_lines"] = list(log_buffer) finalize_notification_delivery(report) try_record_platform_execution( @@ -296,6 +328,33 @@ def run_live_cycle( ) report = execute_cycle(runtime) output_printer("\n".join(report.get("log_lines", []))) + produced_at_value = getattr(runtime, "now_utc", None) or datetime.now(timezone.utc) + produced_at = produced_at_value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + static_degraded_without_identity = ( + report.get("degraded_mode_level") == "static" + and report.get("release_identity") == {} + ) + if not static_degraded_without_identity: + try: + report["runtime_evidence_aggregate"] = build_runtime_evidence_aggregate_v2( + produced_at=produced_at, + run_id=str(report.get("run_id") or getattr(runtime, "run_id", "")), + producer_revision=str(getattr(runtime, "producer_revision", "")), + release_identity=report.get("release_identity", {}), + member_risk_assessment=report.get("member_risk_assessment", {}), + account_risk_assessment=report.get("account_risk_assessment", {}), + cap_assessment=report.get("cap_assessment", {}), + order_authorization=report.get("order_authorization", {}), + strategy_stop_evaluation=report.get("strategy_stop_evaluation", {}), + account_breaker_evaluation=report.get("account_breaker_evaluation", {}), + execution_gate_outcome=str(report.get("order_authorization", {}).get("outcome", "REJECT")), + reconciliation={"status": "MISSING"}, + ) + except Exception as aggregate_exc: + report["status"] = "error" + report.setdefault("error_summary", {}).setdefault("errors", []).append( + {"stage": "runtime_evidence_aggregate", "message": str(aggregate_exc)} + ) report_path = report_writer(report) persisted_local_path = report_path persisted_cloud_uri = None @@ -309,6 +368,11 @@ def run_live_cycle( persisted_local_path = persisted.local_path or report_path persisted_cloud_uri = persisted.cloud_uri except Exception as persist_exc: + report["status"] = "error" + report.setdefault("error_summary", {}).setdefault("errors", []).append( + {"stage": "runtime_report_persist", "message": str(persist_exc)} + ) + report_path = report_writer(report) output_printer(f"failed to persist archived execution report: {persist_exc}") report_status = str(report.get("status", "unknown")) status_event = { diff --git a/application/execution_service.py b/application/execution_service.py index 62087b31..0cd83400 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -2,7 +2,43 @@ from __future__ import annotations -from runtime_support import record_gating_event +import hashlib +import json +from datetime import timezone + +from runtime_support import authorize_runtime_action, record_gating_event + + +_ACCOUNT_BREAKER_POLICY_ID = "binance.account_daily_loss_breaker" +_ACCOUNT_BREAKER_POLICY_VERSION = "binance_crypto_research_only_v1.2026-08-04.1" + + +def _canonical_sha256(value): + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + ).hexdigest() + + +def _breaker_evaluation(runtime, *, account_daily_pnl, threshold, outcome, action_result): + evaluated_at = runtime.now_utc.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + return { + "evaluated": True, + "policy_id": _ACCOUNT_BREAKER_POLICY_ID, + "policy_version": _ACCOUNT_BREAKER_POLICY_VERSION, + "evaluated_at": evaluated_at, + "observed_metric": "account_daily_pnl", + "observed_loss_ratio": float(account_daily_pnl), + "snapshot_digest_sha256": _canonical_sha256({"account_daily_pnl": float(account_daily_pnl)}), + "policy_digest_sha256": _canonical_sha256( + { + "policy_id": _ACCOUNT_BREAKER_POLICY_ID, + "policy_version": _ACCOUNT_BREAKER_POLICY_VERSION, + "threshold": float(threshold), + } + ), + "outcome": outcome, + "action_result": action_result, + } def _safe_float(value, default=0.0): @@ -102,7 +138,7 @@ def run_daily_circuit_breaker( balances, u_total, prices, - trend_daily_pnl, + account_daily_pnl, circuit_breaker_pct, log_buffer, *, @@ -115,9 +151,44 @@ def run_daily_circuit_breaker( build_balance_snapshot_fn, translate_fn, ): - if trend_daily_pnl > circuit_breaker_pct: + latched = bool(state.get("is_circuit_broken")) + if not latched and account_daily_pnl >= circuit_breaker_pct: + report["account_breaker_evaluation"] = _breaker_evaluation( + runtime, + account_daily_pnl=account_daily_pnl, + threshold=circuit_breaker_pct, + outcome="CLEAR", + action_result={ + "status": "NOT_REQUIRED", + "attempted_count": 0, + "succeeded_count": 0, + "failed_count": 0, + "actions_digest_sha256": _canonical_sha256([]), + }, + ) return False + action_results = [] + attempted_count = 0 + succeeded_count = 0 + failed_count = 0 + if latched: + report["account_breaker_evaluation"] = _breaker_evaluation( + runtime, + account_daily_pnl=account_daily_pnl, + threshold=circuit_breaker_pct, + outcome="TRIGGERED", + action_result={ + "status": "BLOCKED", + "attempted_count": 0, + "succeeded_count": 0, + "failed_count": 0, + "actions_digest_sha256": _canonical_sha256([]), + }, + ) + report["circuit_breaker_triggered"] = True + return True + for symbol, config in runtime_trend_universe.items(): tradable_qty = balances[symbol] if tradable_qty * prices[symbol] <= 10: @@ -130,17 +201,11 @@ def run_daily_circuit_breaker( ) continue qty = format_qty_fn(runtime.client, symbol, tradable_qty) - report["buy_sell_intents"].append( - { - "category": "trend", - "action": "sell", - "symbol": symbol, - "reason": "daily_circuit_breaker", - "quantity": float(qty), - } - ) + attempted_count += 1 try: if qty <= 0: + failed_count += 1 + action_results.append({"symbol": symbol, "status": "failed"}) runtime_notify_fn( runtime, report, @@ -148,25 +213,49 @@ def run_daily_circuit_breaker( f"{translate_fn('qty_zero_msg')}", ) continue + runtime.action_cash_usdt = float(u_total) if not ensure_asset_available_fn(runtime, report, config["base_asset"], qty, log_buffer): raise RuntimeError( translate_fn("asset_unavailable_for_circuit_breaker_sell", asset=config["base_asset"]) ) + payload = {"symbol": symbol, "quantity": qty} + authorize_runtime_action( + runtime, + report, + action_class="account_breaker_sell", + method_name="order_market_sell", + payload=payload, + effect_type="order_sell", + u_total=u_total, + ) runtime_call_client_fn( runtime, report, method_name="order_market_sell", - payload={"symbol": symbol, "quantity": qty}, + payload=payload, effect_type="order_sell", ) + report["buy_sell_intents"].append( + { + "category": "trend", + "action": "sell", + "symbol": symbol, + "reason": "daily_circuit_breaker", + "quantity": float(qty), + } + ) balances[symbol] = max(0.0, balances[symbol] - qty) u_total += qty * prices[symbol] + succeeded_count += 1 + action_results.append({"symbol": symbol, "status": "succeeded"}) set_symbol_trade_state_fn( state, symbol, {"is_holding": False, "entry_price": 0.0, "highest_price": 0.0}, ) except Exception as exc: + failed_count += 1 + action_results.append({"symbol": symbol, "status": "failed"}) runtime_notify_fn( runtime, report, @@ -177,12 +266,35 @@ def run_daily_circuit_breaker( state.update({"is_circuit_broken": True}) state["last_balance_snapshot"] = build_balance_snapshot_fn(runtime_trend_universe, balances, u_total) report["circuit_breaker_triggered"] = True + action_status = "COMPLETED" + if failed_count and succeeded_count: + action_status = "PARTIAL" + elif failed_count: + action_status = "FAILED" + report["account_breaker_evaluation"] = _breaker_evaluation( + runtime, + account_daily_pnl=account_daily_pnl, + threshold=circuit_breaker_pct, + outcome="TRIGGERED", + action_result={ + "status": action_status, + "attempted_count": attempted_count, + "succeeded_count": succeeded_count, + "failed_count": failed_count, + "actions_digest_sha256": _canonical_sha256(action_results), + }, + ) + if failed_count: + report["status"] = "error" + report.setdefault("error_summary", {}).setdefault("errors", []).append( + {"stage": "account_breaker", "message": "Protective action failed."} + ) runtime_set_trade_state_fn(runtime, report, state, reason="daily_circuit_breaker") runtime_notify_fn( runtime, report, f"{translate_fn('circuit_breaker')}\n" - f"{translate_fn('circuit_msg', pnl=f'{trend_daily_pnl:.2%}')}", + f"account_daily_pnl={account_daily_pnl:.2%}", ) return True @@ -242,17 +354,28 @@ def execute_trend_sells( f"{translate_fn('qty_zero_msg')}", ) continue + runtime.action_cash_usdt = float(u_total) if not ensure_asset_available_fn(runtime, report, config["base_asset"], qty, log_buffer): raise RuntimeError(translate_fn("asset_unavailable_for_trend_sell", asset=config["base_asset"])) + payload = { + "symbol": symbol, + "quantity": qty, + "newClientOrderId": next_order_id_fn(runtime, "T_SELL", symbol), + } + authorize_runtime_action( + runtime, + report, + action_class="trend_sell", + method_name="order_market_sell", + payload=payload, + effect_type="order_sell", + u_total=u_total, + ) runtime_call_client_fn( runtime, report, method_name="order_market_sell", - payload={ - "symbol": symbol, - "quantity": qty, - "newClientOrderId": next_order_id_fn(runtime, "T_SELL", symbol), - }, + payload=payload, effect_type="order_sell", ) balances[symbol] = max(0.0, balances[symbol] - qty) @@ -362,17 +485,28 @@ def execute_trend_buys( f"{translate_fn('qty_zero_msg')}", ) continue + runtime.action_cash_usdt = float(u_total) if not ensure_asset_available_fn(runtime, report, "USDT", usdt_cost, log_buffer): raise RuntimeError(translate_fn("usdt_unavailable_for_trend_buy")) + payload = { + "symbol": symbol, + "quantity": qty, + "newClientOrderId": next_order_id_fn(runtime, "T_BUY", symbol), + } + authorize_runtime_action( + runtime, + report, + action_class="trend_buy", + method_name="order_market_buy", + payload=payload, + effect_type="order_buy", + u_total=u_total, + ) runtime_call_client_fn( runtime, report, method_name="order_market_buy", - payload={ - "symbol": symbol, - "quantity": qty, - "newClientOrderId": next_order_id_fn(runtime, "T_BUY", symbol), - }, + payload=payload, effect_type="order_buy", ) set_symbol_trade_state_fn( @@ -648,17 +782,28 @@ def execute_btc_dca_cycle( f"{translate_fn('qty_zero_msg')}", ) else: + runtime.action_cash_usdt = float(u_total) if not ensure_asset_available_fn(runtime, report, "USDT", buy_cost, log_buffer): raise RuntimeError(translate_fn("usdt_unavailable_for_btc_dca_buy")) + payload = { + "symbol": "BTCUSDT", + "quantity": qty, + "newClientOrderId": next_order_id_fn(runtime, "D_BUY", "BTCUSDT"), + } + authorize_runtime_action( + runtime, + report, + action_class="btc_dca_buy", + method_name="order_market_buy", + payload=payload, + effect_type="order_buy", + u_total=u_total, + ) runtime_call_client_fn( runtime, report, method_name="order_market_buy", - payload={ - "symbol": "BTCUSDT", - "quantity": qty, - "newClientOrderId": next_order_id_fn(runtime, "D_BUY", "BTCUSDT"), - }, + payload=payload, effect_type="order_buy", ) balances["BTCUSDT"] += qty @@ -717,17 +862,28 @@ def execute_btc_dca_cycle( f"{translate_fn('qty_zero_msg')}", ) else: + runtime.action_cash_usdt = float(u_total) if not ensure_asset_available_fn(runtime, report, "BTC", qty, log_buffer): raise RuntimeError(translate_fn("btc_unavailable_for_dca_sell")) + payload = { + "symbol": "BTCUSDT", + "quantity": qty, + "newClientOrderId": next_order_id_fn(runtime, "D_SELL", "BTCUSDT"), + } + authorize_runtime_action( + runtime, + report, + action_class="btc_dca_sell", + method_name="order_market_sell", + payload=payload, + effect_type="order_sell", + u_total=u_total, + ) runtime_call_client_fn( runtime, report, method_name="order_market_sell", - payload={ - "symbol": "BTCUSDT", - "quantity": qty, - "newClientOrderId": next_order_id_fn(runtime, "D_SELL", "BTCUSDT"), - }, + payload=payload, effect_type="order_sell", ) balances["BTCUSDT"] = max(0.0, balances["BTCUSDT"] - qty) diff --git a/decision_mapper.py b/decision_mapper.py index af098f62..8ffab942 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -6,7 +6,24 @@ from quant_platform_kit.strategy_contracts import StrategyDecision +def _decision_is_order_authorized(decision: StrategyDecision) -> bool: + diagnostics = decision.diagnostics if isinstance(decision.diagnostics, Mapping) else {} + member = diagnostics.get("member_risk_assessment", {}) + account = diagnostics.get("account_risk_assessment", {}) + authorization = diagnostics.get("order_authorization", {}) + return ( + isinstance(member, Mapping) + and member.get("outcome") == "APPROVE" + and isinstance(account, Mapping) + and account.get("outcome") == "APPROVE" + and isinstance(authorization, Mapping) + and authorization.get("outcome") == "APPROVE" + ) + + def _budget_map(decision: StrategyDecision) -> dict[str, float]: + if not _decision_is_order_authorized(decision): + return {} values: dict[str, float] = {} for budget in decision.budgets: if budget.amount is not None: @@ -15,6 +32,8 @@ def _budget_map(decision: StrategyDecision) -> dict[str, float]: def _position_weight_map(decision: StrategyDecision) -> dict[str, float]: + if not _decision_is_order_authorized(decision): + return {} values: dict[str, float] = {} for position in decision.positions: if position.target_weight is not None: @@ -63,7 +82,7 @@ def map_strategy_decision_to_rotation_plan(decision: StrategyDecision) -> dict[s planned_trend_buys = { str(symbol): float(amount) for symbol, amount in dict(diagnostics.get("planned_trend_buys", {})).items() - } + } if _decision_is_order_authorized(decision) else {} sell_reasons = { str(symbol): str(reason) for symbol, reason in dict(diagnostics.get("sell_reasons", {})).items() diff --git a/infra/binance_runtime.py b/infra/binance_runtime.py index 95e69d19..75342dea 100644 --- a/infra/binance_runtime.py +++ b/infra/binance_runtime.py @@ -2,6 +2,8 @@ from __future__ import annotations +from runtime_support import authorize_runtime_action + def resolve_runtime_btc_snapshot( runtime, @@ -69,6 +71,8 @@ def ensure_asset_available_runtime( return True shortfall = required_amount - spot_free + if not callable(getattr(runtime, "action_authorizer", None)): + return False earn_positions = runtime.client.get_simple_earn_flexible_product_position(asset=asset) if earn_positions and "rows" in earn_positions and len(earn_positions["rows"]) > 0: row = earn_positions["rows"][0] @@ -83,14 +87,24 @@ def ensure_asset_available_runtime( "amount": float(redeem_amt), "reason": "asset_availability", } - report["redemption_subscription_intents"].append(intent) + payload = {"productId": product_id, "amount": redeem_amt} + authorize_runtime_action( + runtime, + report, + action_class="earn_asset_availability_redeem", + method_name="redeem_simple_earn_flexible_product", + payload=payload, + effect_type="earn_redeem", + u_total=getattr(runtime, "action_cash_usdt", 0.0), + ) runtime_call_client_fn( runtime, report, method_name="redeem_simple_earn_flexible_product", - payload={"productId": product_id, "amount": redeem_amt}, + payload=payload, effect_type="earn_redeem", ) + report["redemption_subscription_intents"].append(intent) append_log_fn( log_buffer, translate_fn("execution_spot_short_redeeming_from_earn", asset=asset, amount=redeem_amt), @@ -120,6 +134,8 @@ def manage_usdt_earn_buffer_runtime( spot_free_override=None, ): try: + if not callable(getattr(runtime, "action_authorizer", None)): + return asset = "USDT" if spot_free_override is None: spot_free = float(runtime.client.get_asset_balance(asset=asset)["free"]) @@ -134,6 +150,23 @@ def manage_usdt_earn_buffer_runtime( if spot_free > target_buffer + 5.0: excess = round(spot_free - target_buffer, 4) if excess >= 0.1: + payload = {"productId": product_id, "amount": excess} + authorize_runtime_action( + runtime, + report, + action_class="earn_buffer_subscribe", + method_name="subscribe_simple_earn_flexible_product", + payload=payload, + effect_type="earn_subscribe", + u_total=getattr(runtime, "action_cash_usdt", 0.0), + ) + runtime_call_client_fn( + runtime, + report, + method_name="subscribe_simple_earn_flexible_product", + payload=payload, + effect_type="earn_subscribe", + ) report["redemption_subscription_intents"].append( { "asset": asset, @@ -143,13 +176,6 @@ def manage_usdt_earn_buffer_runtime( "reason": "maintain_usdt_buffer", } ) - runtime_call_client_fn( - runtime, - report, - method_name="subscribe_simple_earn_flexible_product", - payload={"productId": product_id, "amount": excess}, - effect_type="earn_subscribe", - ) append_log_fn(log_buffer, translate_fn("cash_manager_subscribed_to_earn", amount=excess)) elif spot_free < target_buffer - 5.0: shortfall = round(target_buffer - spot_free, 4) @@ -158,6 +184,23 @@ def manage_usdt_earn_buffer_runtime( earn_free = float(earn_positions["rows"][0]["totalAmount"]) if earn_free > 0: redeem_amt = round(min(shortfall, earn_free), 8) + payload = {"productId": product_id, "amount": redeem_amt} + authorize_runtime_action( + runtime, + report, + action_class="earn_buffer_redeem", + method_name="redeem_simple_earn_flexible_product", + payload=payload, + effect_type="earn_redeem", + u_total=getattr(runtime, "action_cash_usdt", 0.0), + ) + runtime_call_client_fn( + runtime, + report, + method_name="redeem_simple_earn_flexible_product", + payload=payload, + effect_type="earn_redeem", + ) report["redemption_subscription_intents"].append( { "asset": asset, @@ -167,13 +210,6 @@ def manage_usdt_earn_buffer_runtime( "reason": "maintain_usdt_buffer", } ) - runtime_call_client_fn( - runtime, - report, - method_name="redeem_simple_earn_flexible_product", - payload={"productId": product_id, "amount": redeem_amt}, - effect_type="earn_redeem", - ) append_log_fn(log_buffer, translate_fn("cash_manager_redeeming_to_spot", amount=redeem_amt)) except Exception as exc: append_log_fn(log_buffer, translate_fn("usdt_earn_buffer_maintenance_failed", error=exc)) diff --git a/main.py b/main.py index e3db73d1..540c7dae 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,8 @@ import json import os +import re +import subprocess import sys import time import traceback @@ -717,12 +719,24 @@ def get_tradable_qty(symbol, total_qty, prices, min_bnb_value): # 3. Core strategy # ========================================== def build_live_runtime(now_utc=None): - return rc_build_live_runtime( + runtime = rc_build_live_runtime( now_utc=now_utc, state_loader=get_trade_state, state_writer=set_trade_state, notifier=lambda **kwargs: send_tg_msg(kwargs["token"], kwargs["chat_id"], kwargs["text"]), ) + try: + revision = subprocess.run( + ["git", "rev-parse", "HEAD^{commit}"], + cwd=Path(__file__).resolve().parent, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + revision = "" + runtime.producer_revision = revision if re.fullmatch(r"[0-9a-f]{40}", revision) else "" + return runtime def _set_runtime_trend_universe(resolved_trend_universe): @@ -801,6 +815,7 @@ def _resolve_strategy_evaluation( *, allow_new_trend_entries=True, allow_pool_refresh=True, + action_context=None, ): account_metrics = STRATEGY_RUNTIME.compute_account_metrics( runtime_trend_universe, @@ -823,9 +838,65 @@ 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, + release_identity=getattr(runtime, "release_identity", {}), + release_identity_sha256=getattr(runtime, "release_identity_sha256", ""), + run_id=str(runtime.run_id), + action_context=action_context, ) +def _refresh_action_authorization( + runtime, + report, + state, + runtime_trend_universe, + trend_indicators, + btc_snapshot, + prices, + balances, + fuel_val, + *, + allow_new_trend_entries, + allow_pool_refresh, + action_class, + method_name, + payload, + effect_type, + u_total, +): + runtime.authorization_sequence += 1 + evaluation = _resolve_strategy_evaluation( + runtime, + state, + runtime_trend_universe, + trend_indicators, + btc_snapshot, + prices, + balances, + u_total, + fuel_val, + allow_new_trend_entries=allow_new_trend_entries, + allow_pool_refresh=allow_pool_refresh, + action_context={ + "action_sequence": runtime.authorization_sequence, + "action_class": action_class, + "method_name": method_name, + "effect_type": effect_type, + "payload": dict(payload), + }, + ) + diagnostics = dict(evaluation.decision.diagnostics or {}) + for field_name in ( + "member_risk_assessment", + "account_risk_assessment", + "cap_assessment", + "strategy_stop_evaluation", + "order_authorization", + ): + report[field_name] = dict(diagnostics.get(field_name, {})) + return evaluation.decision + + def _resolve_strategy_plan( runtime, state, @@ -874,10 +945,19 @@ 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, ) + diagnostics = dict(evaluation.decision.diagnostics or {}) + allocation["_risk_evidence"] = { + "member_risk_assessment": dict(diagnostics.get("member_risk_assessment", {})), + "account_risk_assessment": dict(diagnostics.get("account_risk_assessment", {})), + "cap_assessment": dict(diagnostics.get("cap_assessment", {})), + "strategy_stop_evaluation": dict(diagnostics.get("strategy_stop_evaluation", {})), + "order_authorization": dict(diagnostics.get("order_authorization", {})), + } + return allocation def _build_balance_snapshot(runtime_trend_universe, balances, u_total): @@ -946,7 +1026,7 @@ def _run_daily_circuit_breaker( balances, u_total, prices, - trend_daily_pnl, + account_daily_pnl, circuit_breaker_pct, log_buffer, ): @@ -958,7 +1038,7 @@ def _run_daily_circuit_breaker( balances, u_total, prices, - trend_daily_pnl, + account_daily_pnl, circuit_breaker_pct, log_buffer, format_qty_fn=format_qty, @@ -1169,6 +1249,7 @@ def execute_cycle(runtime): append_trend_pool_source_logs=_append_trend_pool_source_logs, capture_market_snapshot=_capture_market_snapshot, compute_portfolio_allocation=_compute_portfolio_allocation, + refresh_action_authorization=_refresh_action_authorization, 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..12eac3c3 100644 --- a/market_snapshot_support.py +++ b/market_snapshot_support.py @@ -28,15 +28,12 @@ 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, - } - ) + authorization = report.get("order_authorization", {}) + if ( + bnb_total * bnb_price < min_bnb_value + and u_total >= buy_bnb_amount + and authorization.get("outcome") == "APPROVE" + ): 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")) @@ -47,6 +44,14 @@ def capture_market_snapshot( payload={"symbol": bnb_fuel_symbol, "quoteOrderQty": buy_bnb_amount}, effect_type="order_buy", ) + report["buy_sell_intents"].append( + { + "category": "fuel", + "action": "buy", + "symbol": bnb_fuel_symbol, + "quote_order_qty": buy_bnb_amount, + } + ) u_total -= buy_bnb_amount bnb_total += (buy_bnb_amount * 0.995) / bnb_price append_log_fn(log_buffer, t("bnb_top_up_completed")) diff --git a/pyproject.toml b/pyproject.toml index c45cc399..1d57674c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ 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", - "crypto-strategies @ git+https://github.com/QuantStrategyLab/CryptoStrategies.git@ef78312d7653095f585c4f75d45bf765bedc2751", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b371322b948e4298920a7d8613b155245dcd5f8d", + "crypto-strategies @ git+https://github.com/QuantStrategyLab/CryptoStrategies.git@5ef4d4ae840704c850b4dc63a63b7a0e084d3d88", "python-binance", "pandas", "numpy", @@ -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@b371322b948e4298920a7d8613b155245dcd5f8d", ] [tool.ruff] diff --git a/qsl.toml b/qsl.toml index 2a80a5c9..49241e76 100644 --- a/qsl.toml +++ b/qsl.toml @@ -9,8 +9,8 @@ expires_at = "2026-09-30" next_action = "keep uv.lock current and maintain QPK/CryptoStrategies pin consistency" [qsl.requires] -quant_platform_kit = "61783fdaee869bfeedd4289ae4b7f27104513759" -crypto_strategies = "ef78312d7653095f585c4f75d45bf765bedc2751" +quant_platform_kit = "b371322b948e4298920a7d8613b155245dcd5f8d" +crypto_strategies = "5ef4d4ae840704c850b4dc63a63b7a0e084d3d88" [qsl.compat] bundle = "2026.07.4" diff --git a/runtime_support.py b/runtime_support.py index 93c131ec..940241e3 100644 --- a/runtime_support.py +++ b/runtime_support.py @@ -1,4 +1,6 @@ import hashlib +import json +import math import os import re import time @@ -13,18 +15,32 @@ _BINANCE_ORDER_RATE_LIMIT_INTERVAL_SEC = 0.25 # max ~4 orders/sec _LAST_API_CALL_TS: float = 0.0 RUNTIME_EVIDENCE_CONTRACT_VERSION = "qsl.runtime_evidence_aggregate.v1" +RUNTIME_EVIDENCE_V2_CONTRACT_VERSION = "qsl.runtime_evidence_aggregate.v2" RECONCILIATION_STATUSES = frozenset({"MISSING", "MATCHED", "MISMATCHED"}) _RUNTIME_EVIDENCE_FORBIDDEN_FIELDS = frozenset( { "api_key", "api_secret", + "account_id", "authorization", + "balance", "balances", + "cookie", "credentials", + "fill", + "fill_id", + "fills", "headers", + "jwt", + "notional", + "order", + "order_id", "orders", + "position", "positions", "provider_rows", + "quantity", + "raw_series", "secret", "token", } @@ -164,7 +180,6 @@ def validate_runtime_evidence_aggregate(aggregate: Any) -> dict[str, Any]: if aggregate.get("contract_version") != RUNTIME_EVIDENCE_CONTRACT_VERSION: errors.append(f"{label} contract_version must be {RUNTIME_EVIDENCE_CONTRACT_VERSION}") _validate_release_identity(aggregate.get("release_identity"), errors) - risk_engine = aggregate.get("risk_engine") if not isinstance(risk_engine, Mapping): errors.append(f"{label} risk_engine must be an object") @@ -234,6 +249,463 @@ def build_runtime_evidence_aggregate( return aggregate +def _canonical_sha256(value: Mapping[str, Any]) -> str: + encoded = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +_RISK_ASSESSMENT_V1_FIELDS = frozenset( + { + "contract_version", + "scope", + "evaluated_at", + "policy_id", + "policy_version", + "qpk_source_revision", + "mandate_id", + "mandate_version", + "mandate_authority_receipt_sha256", + "mandate_scope", + "decision_digest_sha256", + "portfolio_snapshot_digest_sha256", + "effective_exposure_cap", + "observed_effective_exposure", + "proposed_effective_exposure", + "outcome", + "reason_codes", + "assessment_sha256", + } +) +_CAP_ASSESSMENT_FIELDS = frozenset( + { + "outcome", + "mandate_id", + "mandate_version", + "mandate_authority_receipt_sha256", + "mandate_scope", + "effective_exposure_cap", + "decision_digest_sha256", + "release_identity_sha256", + "account_snapshot_sha256", + "account_assessment_sha256", + "qpk_source_revision", + "order_authorization_sha256", + } +) +_ORDER_AUTHORIZATION_V2_FIELDS = frozenset( + { + "contract_version", + "outcome", + "run_id", + "authorization_kind", + "action_sequence", + "action_class", + "method_name", + "effect_type", + "canonical_payload_sha256", + "decision_digest_sha256", + "release_identity_sha256", + "account_snapshot_sha256", + "member_assessment_sha256", + "account_assessment_sha256", + "mandate_authority_receipt_sha256", + "mandate_scope", + "authorization_sha256", + } +) +_RUNTIME_EVIDENCE_V2_FIELDS = frozenset( + { + "contract_version", + "produced_at", + "run_id", + "producer_revision", + "release_identity", + "member_risk_assessment", + "account_risk_assessment", + "cap_assessment", + "order_authorization", + "strategy_stop_evaluation", + "account_breaker_evaluation", + "execution_gate_outcome", + "reconciliation", + "verified_active", + "fills_verified", + "capital_use_verified", + } +) + + +def _append_exact_field_errors( + payload: Mapping[str, Any], + expected_fields: frozenset[str], + errors: list[str], + label: str, +) -> None: + actual_fields = set(payload) + for field_name in sorted(expected_fields - actual_fields, key=str): + errors.append(f"{label} missing field: {field_name}") + for field_name in sorted(actual_fields - expected_fields, key=str): + errors.append(f"{label} unexpected field: {field_name}") + + +def _is_finite_cap(value: Any) -> bool: + return ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and math.isfinite(float(value)) + and 0.0 <= float(value) <= 1.0 + ) + + +def _is_nonnegative_finite_number(value: Any) -> bool: + return ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and math.isfinite(float(value)) + and float(value) >= 0.0 + ) + + +def _validate_risk_assessment_v1( + assessment: Any, + *, + expected_scope: str, + errors: list[str], + label: str, +) -> Mapping[str, Any] | None: + if not isinstance(assessment, Mapping): + errors.append(f"{label} must be an object") + return None + _append_exact_field_errors(assessment, _RISK_ASSESSMENT_V1_FIELDS, errors, label) + if assessment.get("contract_version") != "qsl.risk_gate_assessment.v1": + errors.append(f"{label}.contract_version is invalid") + if assessment.get("scope") != expected_scope: + errors.append(f"{label}.scope must be {expected_scope}") + if assessment.get("outcome") not in {"APPROVE", "REJECT"}: + errors.append(f"{label}.outcome is invalid") + if not _is_sha256(assessment.get("assessment_sha256")): + errors.append(f"{label}.assessment_sha256 is invalid") + for digest_field in ( + "mandate_authority_receipt_sha256", + "decision_digest_sha256", + "portfolio_snapshot_digest_sha256", + ): + if not _is_sha256(assessment.get(digest_field)): + errors.append(f"{label}.{digest_field} is invalid") + if not _is_git_revision(assessment.get("qpk_source_revision")): + errors.append(f"{label}.qpk_source_revision is invalid") + if not _is_utc_timestamp(assessment.get("evaluated_at")): + errors.append(f"{label}.evaluated_at is invalid") + for field_name in ("policy_id", "policy_version", "mandate_id", "mandate_version"): + if not isinstance(assessment.get(field_name), str) or not assessment[field_name].strip(): + errors.append(f"{label}.{field_name} is invalid") + if assessment.get("mandate_scope") not in {"RESEARCH_ONLY", "PAPER", "LIVE"}: + errors.append(f"{label}.mandate_scope is invalid") + if not _is_finite_cap(assessment.get("effective_exposure_cap")): + errors.append(f"{label}.effective_exposure_cap is invalid") + for field_name in ("observed_effective_exposure", "proposed_effective_exposure"): + if not _is_nonnegative_finite_number(assessment.get(field_name)): + errors.append(f"{label}.{field_name} is invalid") + reason_codes = assessment.get("reason_codes") + if not isinstance(reason_codes, (list, tuple)) or not all(isinstance(code, str) for code in reason_codes): + errors.append(f"{label}.reason_codes is invalid") + digest_payload = {key: value for key, value in assessment.items() if key != "assessment_sha256"} + try: + expected_digest = _canonical_sha256(digest_payload) + except (TypeError, ValueError): + errors.append(f"{label} must be canonical JSON") + else: + if assessment.get("assessment_sha256") != expected_digest: + errors.append(f"{label}.assessment_sha256 mismatch") + return assessment + + +def _validate_runtime_evidence_aggregate_v2_payload( + aggregate: Any, + *, + require_aggregate_sha256: bool, +) -> list[str]: + errors: list[str] = [] + label = "runtime_evidence_aggregate_v2" + if not isinstance(aggregate, Mapping): + return [f"{label} must be an object"] + _append_forbidden_field_errors(aggregate, errors) + expected_aggregate_fields = _RUNTIME_EVIDENCE_V2_FIELDS | ( + {"aggregate_sha256"} if require_aggregate_sha256 else set() + ) + _append_exact_field_errors(aggregate, frozenset(expected_aggregate_fields), errors, label) + if aggregate.get("contract_version") != RUNTIME_EVIDENCE_V2_CONTRACT_VERSION: + errors.append(f"{label} contract_version must be {RUNTIME_EVIDENCE_V2_CONTRACT_VERSION}") + if not _is_utc_timestamp(aggregate.get("produced_at")): + errors.append(f"{label} produced_at must be a UTC timestamp") + if not isinstance(aggregate.get("run_id"), str) or not aggregate["run_id"].strip(): + errors.append(f"{label} run_id must be non-empty") + if not _is_git_revision(aggregate.get("producer_revision")): + errors.append(f"{label} producer_revision must be a git revision") + _validate_release_identity(aggregate.get("release_identity"), errors) + release_artifacts = aggregate.get("release_identity", {}).get("artifacts", {}) if isinstance( + aggregate.get("release_identity"), Mapping + ) else {} + if set(release_artifacts) != {"live_pool", "live_pool_legacy", "latest_ranking", "latest_universe"}: + errors.append(f"{label} release_identity must bind the exact four artifacts") + member = _validate_risk_assessment_v1( + aggregate.get("member_risk_assessment"), + expected_scope="MEMBER", + errors=errors, + label=f"{label} member_risk_assessment", + ) + account = _validate_risk_assessment_v1( + aggregate.get("account_risk_assessment"), + expected_scope="ACCOUNT", + errors=errors, + label=f"{label} account_risk_assessment", + ) + if member is not None and account is not None: + for field_name in ( + "policy_id", + "policy_version", + "qpk_source_revision", + "mandate_id", + "mandate_version", + "mandate_authority_receipt_sha256", + "mandate_scope", + "decision_digest_sha256", + "portfolio_snapshot_digest_sha256", + "effective_exposure_cap", + ): + if member.get(field_name) != account.get(field_name): + errors.append(f"{label} risk assessments disagree on {field_name}") + release_identity = aggregate.get("release_identity") + release_identity_sha256 = "" + if isinstance(release_identity, Mapping): + try: + release_identity_sha256 = _canonical_sha256(release_identity) + except (TypeError, ValueError): + errors.append(f"{label} release_identity must be canonical JSON") + cap = aggregate.get("cap_assessment") + if not isinstance(cap, Mapping) or cap.get("outcome") not in {"APPROVE", "REJECT"}: + errors.append(f"{label} cap_assessment is invalid") + else: + _append_exact_field_errors(cap, _CAP_ASSESSMENT_FIELDS, errors, f"{label} cap_assessment") + for field_name in ( + "decision_digest_sha256", + "release_identity_sha256", + "account_snapshot_sha256", + "account_assessment_sha256", + "mandate_authority_receipt_sha256", + "order_authorization_sha256", + ): + if not _is_sha256(cap.get(field_name)): + errors.append(f"{label} cap_assessment.{field_name} is invalid") + if not _is_git_revision(cap.get("qpk_source_revision")): + errors.append(f"{label} cap_assessment.qpk_source_revision is invalid") + if not _is_finite_cap(cap.get("effective_exposure_cap")): + errors.append(f"{label} cap_assessment.effective_exposure_cap is invalid") + for field_name in ("mandate_id", "mandate_version"): + if not isinstance(cap.get(field_name), str) or not cap[field_name].strip(): + errors.append(f"{label} cap_assessment.{field_name} is invalid") + if cap.get("mandate_scope") not in {"RESEARCH_ONLY", "PAPER", "LIVE"}: + errors.append(f"{label} cap_assessment.mandate_scope is invalid") + if account is not None: + cap_bindings = { + "decision_digest_sha256": account.get("decision_digest_sha256"), + "release_identity_sha256": release_identity_sha256, + "account_snapshot_sha256": account.get("portfolio_snapshot_digest_sha256"), + "account_assessment_sha256": account.get("assessment_sha256"), + "mandate_id": account.get("mandate_id"), + "mandate_version": account.get("mandate_version"), + "mandate_authority_receipt_sha256": account.get("mandate_authority_receipt_sha256"), + "mandate_scope": account.get("mandate_scope"), + "effective_exposure_cap": account.get("effective_exposure_cap"), + "qpk_source_revision": account.get("qpk_source_revision"), + } + for field_name, expected_value in cap_bindings.items(): + if cap.get(field_name) != expected_value: + errors.append(f"{label} cap_assessment.{field_name} binding mismatch") + if cap.get("outcome") == "APPROVE" and ( + member is None + or account is None + or member.get("outcome") != "APPROVE" + or account.get("outcome") != "APPROVE" + or cap.get("mandate_scope") not in {"PAPER", "LIVE"} + or not _is_finite_cap(cap.get("effective_exposure_cap")) + or float(cap.get("effective_exposure_cap", 0.0)) <= 0.0 + ): + errors.append(f"{label} cap APPROVE requires MEMBER and ACCOUNT APPROVE") + authorization = aggregate.get("order_authorization") + if not isinstance(authorization, Mapping): + errors.append(f"{label} order_authorization must be an object") + else: + _append_exact_field_errors( + authorization, + _ORDER_AUTHORIZATION_V2_FIELDS, + errors, + f"{label} order_authorization", + ) + if authorization.get("contract_version") != "qsl.binance_order_authorization.v2": + errors.append(f"{label} order_authorization.contract_version is invalid") + if authorization.get("outcome") not in {"APPROVE", "REJECT"}: + errors.append(f"{label} order_authorization.outcome is invalid") + if authorization.get("authorization_kind") not in {"PRELIMINARY", "ACTION"}: + errors.append(f"{label} order_authorization.authorization_kind is invalid") + if not isinstance(authorization.get("run_id"), str) or not authorization["run_id"].strip(): + errors.append(f"{label} order_authorization.run_id is invalid") + if authorization.get("mandate_scope") not in {"RESEARCH_ONLY", "PAPER", "LIVE"}: + errors.append(f"{label} order_authorization.mandate_scope is invalid") + for field_name in ( + "canonical_payload_sha256", + "decision_digest_sha256", + "release_identity_sha256", + "account_snapshot_sha256", + "member_assessment_sha256", + "account_assessment_sha256", + "mandate_authority_receipt_sha256", + "authorization_sha256", + ): + if field_name == "canonical_payload_sha256" and authorization.get("authorization_kind") == "PRELIMINARY": + continue + if not _is_sha256(authorization.get(field_name)): + errors.append(f"{label} order_authorization.{field_name} is invalid") + if authorization.get("authorization_kind") == "ACTION": + if ( + isinstance(authorization.get("action_sequence"), bool) + or not isinstance(authorization.get("action_sequence"), int) + or authorization.get("action_sequence") <= 0 + ): + errors.append(f"{label} order_authorization.action_sequence is invalid") + for field_name in ("action_class", "method_name", "effect_type"): + if not isinstance(authorization.get(field_name), str) or not authorization[field_name].strip(): + errors.append(f"{label} order_authorization.{field_name} is invalid") + elif authorization.get("action_sequence") != 0 or any( + authorization.get(field_name) != "" + for field_name in ("action_class", "method_name", "effect_type", "canonical_payload_sha256") + ): + errors.append(f"{label} PRELIMINARY order_authorization must not bind an action") + authorization_payload = { + key: value for key, value in authorization.items() if key != "authorization_sha256" + } + try: + expected_authorization_sha256 = _canonical_sha256(authorization_payload) + except (TypeError, ValueError): + errors.append(f"{label} order_authorization must be canonical JSON") + else: + if authorization.get("authorization_sha256") != expected_authorization_sha256: + errors.append(f"{label} order_authorization.authorization_sha256 mismatch") + if member is not None and account is not None: + authorization_bindings = { + "run_id": aggregate.get("run_id"), + "decision_digest_sha256": account.get("decision_digest_sha256"), + "release_identity_sha256": release_identity_sha256, + "account_snapshot_sha256": account.get("portfolio_snapshot_digest_sha256"), + "member_assessment_sha256": member.get("assessment_sha256"), + "account_assessment_sha256": account.get("assessment_sha256"), + "mandate_authority_receipt_sha256": account.get("mandate_authority_receipt_sha256"), + "mandate_scope": account.get("mandate_scope"), + } + for field_name, expected_value in authorization_bindings.items(): + if authorization.get(field_name) != expected_value: + errors.append(f"{label} order_authorization.{field_name} binding mismatch") + if aggregate.get("execution_gate_outcome") != authorization.get("outcome"): + errors.append(f"{label} execution_gate_outcome does not match order_authorization") + if isinstance(cap, Mapping) and cap.get("order_authorization_sha256") != authorization.get( + "authorization_sha256" + ): + errors.append(f"{label} cap_assessment.order_authorization_sha256 binding mismatch") + for field_name in ("strategy_stop_evaluation", "account_breaker_evaluation"): + evaluation = aggregate.get(field_name) + if not isinstance(evaluation, Mapping) or evaluation.get("evaluated") is not True: + errors.append(f"{label} {field_name} must be evaluated") + elif evaluation.get("outcome") not in {"CLEAR", "TRIGGERED"}: + errors.append(f"{label} {field_name}.outcome is invalid") + if aggregate.get("execution_gate_outcome") not in {"APPROVE", "REJECT"}: + errors.append(f"{label} execution_gate_outcome is invalid") + elif aggregate.get("execution_gate_outcome") == "APPROVE" and ( + not isinstance(cap, Mapping) + or cap.get("outcome") != "APPROVE" + or member is None + or account is None + or member.get("outcome") != "APPROVE" + or account.get("outcome") != "APPROVE" + ): + errors.append(f"{label} execution APPROVE requires cap, MEMBER and ACCOUNT APPROVE") + reconciliation = aggregate.get("reconciliation") + if not isinstance(reconciliation, Mapping) or dict(reconciliation) != {"status": "MISSING"}: + errors.append(f"{label} platform reconciliation must be MISSING") + for field_name in ("verified_active", "fills_verified", "capital_use_verified"): + if aggregate.get(field_name) is not False: + errors.append(f"{label} {field_name} must be false") + if require_aggregate_sha256 and not errors: + claimed_digest = aggregate.get("aggregate_sha256") + digest_payload = {key: value for key, value in aggregate.items() if key != "aggregate_sha256"} + try: + expected_digest = _canonical_sha256(digest_payload) + except (TypeError, ValueError): + errors.append(f"{label} must be canonical JSON") + else: + if claimed_digest != expected_digest: + errors.append(f"{label} aggregate_sha256 mismatch") + return errors + + +def validate_runtime_evidence_aggregate_v2(aggregate: Any) -> dict[str, Any]: + errors = _validate_runtime_evidence_aggregate_v2_payload( + aggregate, + require_aggregate_sha256=True, + ) + return {"ok": not errors, "errors": errors} + + +def build_runtime_evidence_aggregate_v2( + *, + produced_at: str, + run_id: str, + producer_revision: str, + release_identity: Mapping[str, Any], + member_risk_assessment: Mapping[str, Any], + account_risk_assessment: Mapping[str, Any], + cap_assessment: Mapping[str, Any], + order_authorization: Mapping[str, Any], + strategy_stop_evaluation: Mapping[str, Any], + account_breaker_evaluation: Mapping[str, Any], + execution_gate_outcome: str, + reconciliation: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + aggregate = { + "contract_version": RUNTIME_EVIDENCE_V2_CONTRACT_VERSION, + "produced_at": str(produced_at), + "run_id": str(run_id), + "producer_revision": str(producer_revision), + "release_identity": dict(release_identity), + "member_risk_assessment": dict(member_risk_assessment), + "account_risk_assessment": dict(account_risk_assessment), + "cap_assessment": dict(cap_assessment), + "order_authorization": dict(order_authorization), + "strategy_stop_evaluation": dict(strategy_stop_evaluation), + "account_breaker_evaluation": dict(account_breaker_evaluation), + "execution_gate_outcome": str(execution_gate_outcome), + "reconciliation": dict(reconciliation or {"status": "MISSING"}), + "verified_active": False, + "fills_verified": False, + "capital_use_verified": False, + } + errors = _validate_runtime_evidence_aggregate_v2_payload( + aggregate, + require_aggregate_sha256=False, + ) + if errors: + raise ValueError("Runtime evidence v2 validation failed: " + "; ".join(errors)) + aggregate["aggregate_sha256"] = _canonical_sha256(aggregate) + validation = validate_runtime_evidence_aggregate_v2(aggregate) + if not validation["ok"]: + raise ValueError("Runtime evidence v2 validation failed: " + "; ".join(validation["errors"])) + return aggregate + + @dataclass class ExecutionRuntime: dry_run: bool = False @@ -257,7 +729,12 @@ class ExecutionRuntime: trend_indicator_snapshots: Optional[dict[str, Any]] = None print_traceback: bool = True order_sequence: int = 0 + authorization_sequence: int = 0 + consumed_order_authorizations: set[str] = field(default_factory=set) + action_authorizer: Optional[Callable[..., Any]] = None + action_cash_usdt: float = 0.0 side_effect_log: list[dict[str, Any]] = field(default_factory=list) + producer_revision: str = "" def __post_init__(self): if self.now_utc is None: @@ -313,6 +790,14 @@ def build_execution_report(runtime): "circuit_breaker_triggered": False, "degraded_mode_level": None, "upstream_pool_symbols": [], + "release_identity": {}, + "release_identity_sha256": "", + "member_risk_assessment": {}, + "account_risk_assessment": {}, + "cap_assessment": {}, + "strategy_stop_evaluation": {}, + "account_breaker_evaluation": {}, + "order_authorization": {}, "summary": { "strategy_display_name": str(runtime.strategy_display_name or ""), "strategy_display_name_localized": str(runtime.strategy_display_name_localized or ""), @@ -471,8 +956,95 @@ def runtime_set_trade_state(runtime, report, state, *, reason): record_side_effect(runtime, report, effect_type="state_write", target="firestore", payload=payload, executed=True) +def authorize_runtime_action(runtime, report, *, action_class, method_name, payload, effect_type, u_total): + authorizer = getattr(runtime, "action_authorizer", None) + if not callable(authorizer): + return None + return authorizer( + action_class=action_class, + method_name=method_name, + payload=payload, + effect_type=effect_type, + u_total=float(u_total), + ) + + +def validate_current_order_authorization(runtime, report, *, method_name, payload, effect_type) -> dict[str, Any]: + authorization = report.get("order_authorization") + member = report.get("member_risk_assessment") + account = report.get("account_risk_assessment") + release_digest = report.get("release_identity_sha256") + if not all(isinstance(value, Mapping) for value in (authorization, member, account)): + return {"ok": False, "reason": "missing_order_authorization_binding"} + required_digests = ( + "decision_digest_sha256", + "release_identity_sha256", + "account_snapshot_sha256", + "member_assessment_sha256", + "account_assessment_sha256", + "mandate_authority_receipt_sha256", + "canonical_payload_sha256", + "authorization_sha256", + ) + try: + canonical_payload_sha256 = _canonical_sha256(payload) if isinstance(payload, Mapping) else "" + except (TypeError, ValueError): + canonical_payload_sha256 = "" + if ( + authorization.get("contract_version") != "qsl.binance_order_authorization.v2" + or authorization.get("outcome") != "APPROVE" + or authorization.get("run_id") != str(runtime.run_id) + or authorization.get("authorization_kind") != "ACTION" + or not isinstance(authorization.get("action_sequence"), int) + or isinstance(authorization.get("action_sequence"), bool) + or authorization.get("action_sequence") <= 0 + or authorization.get("action_sequence") != runtime.authorization_sequence + or not isinstance(authorization.get("action_class"), str) + or not authorization.get("action_class") + or authorization.get("method_name") != str(method_name) + or authorization.get("effect_type") != str(effect_type) + or authorization.get("canonical_payload_sha256") != canonical_payload_sha256 + or authorization.get("mandate_scope") not in {"PAPER", "LIVE"} + or member.get("scope") != "MEMBER" + or member.get("outcome") != "APPROVE" + or account.get("scope") != "ACCOUNT" + or account.get("outcome") != "APPROVE" + or authorization.get("decision_digest_sha256") != member.get("decision_digest_sha256") + or authorization.get("decision_digest_sha256") != account.get("decision_digest_sha256") + or authorization.get("release_identity_sha256") != release_digest + or authorization.get("account_snapshot_sha256") != account.get("portfolio_snapshot_digest_sha256") + or authorization.get("member_assessment_sha256") != member.get("assessment_sha256") + or authorization.get("account_assessment_sha256") != account.get("assessment_sha256") + or any(not _is_sha256(authorization.get(field_name)) for field_name in required_digests) + or authorization.get("authorization_sha256") in runtime.consumed_order_authorizations + ): + return {"ok": False, "reason": "mismatched_order_authorization_binding"} + claimed_digest = authorization["authorization_sha256"] + digest_payload = {key: value for key, value in authorization.items() if key != "authorization_sha256"} + if claimed_digest != _canonical_sha256(digest_payload): + return {"ok": False, "reason": "invalid_order_authorization_digest"} + return {"ok": True, "reason": "approved"} + + def runtime_call_client(runtime, report, *, method_name, payload, effect_type, max_retries: int = 3, retry_base_sec: float = 1.0): + authorization = validate_current_order_authorization( + runtime, + report, + method_name=method_name, + payload=payload, + effect_type=effect_type, + ) + if not authorization["ok"]: + record_gating_event( + report, + gate="account_order_authorization", + category="execution", + detail={"outcome": "REJECT", "reason": authorization["reason"]}, + ) + raise RuntimeError("client mutation blocked by account order authorization") + authorization_sha256 = str(report["order_authorization"]["authorization_sha256"]) + runtime.consumed_order_authorizations.add(authorization_sha256) if runtime.dry_run: record_side_effect( runtime, report, effect_type=effect_type, diff --git a/strategy_runtime.py b/strategy_runtime.py index f3351996..9276b23c 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -1,12 +1,17 @@ from __future__ import annotations import os +import hashlib +import json +import re +from dataclasses import asdict, is_dataclass from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Mapping from quant_platform_kit import PortfolioSnapshot, Position, build_strategy_evaluation_inputs +from quant_platform_kit.risk.gate import assess_with_evidence as qpk_assess_with_evidence from quant_platform_kit.strategy_contracts import ( StrategyContext, StrategyDecision, @@ -26,6 +31,8 @@ # Ensure artifacts directory exists so local-fallback path never fails with FileNotFoundError DEFAULT_LOCAL_TREND_POOL_ARTIFACT.parent.mkdir(parents=True, exist_ok=True) DEFAULT_TREND_POOL_SIZE = 5 +BINANCE_RESEARCH_MANDATE_RECEIPT_SHA256 = "246c39b8023b25f913bf1e67dc175005955a7102f3727dfc1bd8e981cf8128ee" +QPK_RISK_SOURCE_REVISION = "b371322b948e4298920a7d8613b155245dcd5f8d" COMBO_RUNTIME_ENV_OVERRIDES: tuple[tuple[str, str, str], ...] = ( ("BTC_WEIGHT", "btc_weight", "ratio"), ("TREND_WEIGHT", "trend_weight", "ratio"), @@ -103,6 +110,166 @@ class StrategyEvaluationResult: metadata: Mapping[str, Any] = field(default_factory=dict) +@dataclass(frozen=True) +class AccountGateResult: + decision: StrategyDecision + member_risk_assessment: Mapping[str, Any] + account_risk_assessment: Mapping[str, Any] + cap_assessment: Mapping[str, Any] + order_authorization: Mapping[str, Any] + + +def _canonical_sha256(value: Mapping[str, Any]) -> str: + encoded = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def build_binance_research_mandate() -> dict[str, Any]: + """Return the immutable zero-cap authority object; it can never authorize an order.""" + return { + "mandate_id": "binance_crypto_research_only_v1", + "mandate_version": "2026-08-04.1", + "authority_receipt_sha256": BINANCE_RESEARCH_MANDATE_RECEIPT_SHA256, + "authority_scope": "RESEARCH_ONLY", + "strategy_profile": "crypto_live_pool_rotation", + "account_mode": "single_strategy_account_v1", + "effective_at": "2026-08-04T04:27:55Z", + "expires_at": "2026-09-03T15:59:59Z", + "max_snapshot_age_seconds": 300, + "effective_exposure_cap": 0.0, + "loss_budget": 0.0, + "product_caps": 0.0, + "nominal_caps": 0.0, + "product_leverage_factors": {}, + "allowed_nonzero_assets": [], + "source_revision": QPK_RISK_SOURCE_REVISION, + } + + +def _assessment_payload(value: Any) -> dict[str, Any]: + if is_dataclass(value): + return asdict(value) + if isinstance(value, Mapping): + return dict(value) + return dict(vars(value)) + + +def apply_account_risk_gate( + decision: StrategyDecision, + *, + portfolio_snapshot: Any, + release_identity_sha256: str, + run_id: str, + mandate_provenance: Mapping[str, Any], + market_data: Mapping[str, Any], + action_context: Mapping[str, Any] | None = None, +) -> AccountGateResult: + """Call the QPK ACCOUNT gate and bind its receipt to the current release/run.""" + member = dict(decision.diagnostics.get("member_risk_assessment", {})) + result = qpk_assess_with_evidence( + decision, + portfolio_snapshot, + scope="ACCOUNT", + mandate_provenance=mandate_provenance, + market_data=market_data, + ) + account = _assessment_payload(result.assessment) + decision_digest = str(account.get("decision_digest_sha256", "")) + snapshot_digest = str(account.get("portfolio_snapshot_digest_sha256", "")) + release_digest_valid = bool(re.fullmatch(r"[0-9a-f]{64}", str(release_identity_sha256))) + exact_binding = ( + member.get("scope") == "MEMBER" + and member.get("outcome") == "APPROVE" + and account.get("scope") == "ACCOUNT" + and account.get("outcome") == "APPROVE" + and member.get("decision_digest_sha256") == decision_digest + and release_digest_valid + and bool(snapshot_digest) + ) + capital_authority = ( + mandate_provenance.get("authority_scope") in {"PAPER", "LIVE"} + and float(mandate_provenance.get("effective_exposure_cap", 0.0) or 0.0) > 0.0 + and bool(mandate_provenance.get("allowed_nonzero_assets")) + ) + action = dict(action_context or {}) + action_sequence = action.get("action_sequence") + action_class = action.get("action_class") + method_name = action.get("method_name") + effect_type = action.get("effect_type") + payload = action.get("payload") + valid_action = ( + isinstance(action_sequence, int) + and not isinstance(action_sequence, bool) + and action_sequence > 0 + and all(isinstance(value, str) and value.strip() for value in (action_class, method_name, effect_type)) + and isinstance(payload, Mapping) + ) + try: + canonical_payload_sha256 = _canonical_sha256(payload) if valid_action else "" + except (TypeError, ValueError): + canonical_payload_sha256 = "" + valid_action = False + cap_outcome = "APPROVE" if exact_binding and capital_authority else "REJECT" + outcome = cap_outcome if action_context is None or valid_action else "REJECT" + cap_assessment = { + "outcome": cap_outcome, + "mandate_id": str(mandate_provenance.get("mandate_id", "")), + "mandate_version": str(mandate_provenance.get("mandate_version", "")), + "mandate_authority_receipt_sha256": str(mandate_provenance.get("authority_receipt_sha256", "")), + "mandate_scope": str(mandate_provenance.get("authority_scope", "")), + "effective_exposure_cap": float(mandate_provenance.get("effective_exposure_cap", 0.0) or 0.0), + "decision_digest_sha256": decision_digest, + "release_identity_sha256": str(release_identity_sha256), + "account_snapshot_sha256": snapshot_digest, + "account_assessment_sha256": str(account.get("assessment_sha256", "")), + } + authorization = { + "contract_version": "qsl.binance_order_authorization.v2", + "outcome": outcome, + "run_id": str(run_id), + "authorization_kind": "ACTION" if valid_action else "PRELIMINARY", + "action_sequence": action_sequence if valid_action else 0, + "action_class": str(action_class or ""), + "method_name": str(method_name or ""), + "effect_type": str(effect_type or ""), + "canonical_payload_sha256": canonical_payload_sha256, + "decision_digest_sha256": decision_digest, + "release_identity_sha256": str(release_identity_sha256), + "account_snapshot_sha256": snapshot_digest, + "member_assessment_sha256": str(member.get("assessment_sha256", "")), + "account_assessment_sha256": str(account.get("assessment_sha256", "")), + "mandate_authority_receipt_sha256": str(mandate_provenance.get("authority_receipt_sha256", "")), + "mandate_scope": str(mandate_provenance.get("authority_scope", "")), + } + authorization["authorization_sha256"] = _canonical_sha256(authorization) + cap_assessment["qpk_source_revision"] = str(mandate_provenance.get("source_revision", "")) + cap_assessment["order_authorization_sha256"] = authorization["authorization_sha256"] + gated_decision = StrategyDecision( + positions=result.decision.positions if outcome == "APPROVE" else (), + budgets=result.decision.budgets if outcome == "APPROVE" else (), + risk_flags=tuple(result.decision.risk_flags or ()) + (("rejected:account_gate",) if outcome == "REJECT" else ()), + diagnostics={ + **dict(result.decision.diagnostics or {}), + "member_risk_assessment": member, + "account_risk_assessment": account, + "cap_assessment": cap_assessment, + "order_authorization": authorization, + }, + ) + return AccountGateResult( + decision=gated_decision, + member_risk_assessment=member, + account_risk_assessment=account, + cap_assessment=cap_assessment, + order_authorization=authorization, + ) + + @dataclass(frozen=True) class LoadedStrategyRuntime: entrypoint: StrategyEntrypoint @@ -195,6 +362,12 @@ 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": ( + (float(account_metrics["trend_value"]) + float(account_metrics["dca_value"])) + / float(account_metrics["total_equity"]) + if float(account_metrics["total_equity"]) > 0.0 + else 0.0 + ), }, ) @@ -214,6 +387,10 @@ 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, + release_identity: Mapping[str, Any] | None = None, + release_identity_sha256: str = "", + run_id: str = "", + action_context: Mapping[str, Any] | None = None, ) -> StrategyEvaluationResult: runtime_config = dict(self.runtime_overrides) runtime_config.update( @@ -263,6 +440,7 @@ def evaluate( runtime_config=runtime_config, capabilities={"platform": BINANCE_PLATFORM}, ) + mandate_provenance = build_binance_research_mandate() ctx = StrategyContext( as_of=ctx.as_of, market_data=ctx.market_data, @@ -270,11 +448,25 @@ def evaluate( state=ctx.state, runtime_config=ctx.runtime_config, capabilities=ctx.capabilities, - artifacts={"trend_pool_contract": self.artifact_contract}, + artifacts={ + "trend_pool_contract": self.artifact_contract, + "runtime_evidence_identity": dict(release_identity or {}), + "release_identity_sha256": str(release_identity_sha256), + "mandate_provenance": mandate_provenance, + }, ) decision = self.entrypoint.evaluate(ctx) + account_gate = apply_account_risk_gate( + decision, + portfolio_snapshot=portfolio_snapshot, + release_identity_sha256=release_identity_sha256, + run_id=run_id, + mandate_provenance=mandate_provenance, + market_data=dict(ctx.market_data or {}), + action_context=action_context, + ) return StrategyEvaluationResult( - decision=decision, + decision=account_gate.decision, account_metrics=dict(account_metrics), metadata={ "strategy_profile": self.profile, @@ -282,6 +474,10 @@ def evaluate( self.profile, platform_id=BINANCE_PLATFORM, ).display_name, + "member_risk_assessment": dict(account_gate.member_risk_assessment), + "account_risk_assessment": dict(account_gate.account_risk_assessment), + "cap_assessment": dict(account_gate.cap_assessment), + "order_authorization": dict(account_gate.order_authorization), }, ) diff --git a/tests/test_binance_runtime_infra.py b/tests/test_binance_runtime_infra.py index 1f1a83ec..830c66e2 100644 --- a/tests/test_binance_runtime_infra.py +++ b/tests/test_binance_runtime_infra.py @@ -1,5 +1,6 @@ import unittest from types import SimpleNamespace +from unittest.mock import Mock from infra.binance_runtime import ( ensure_asset_available_runtime, @@ -11,6 +12,35 @@ class BinanceRuntimeInfraTests(unittest.TestCase): + def test_earn_mutations_are_not_attempted_without_account_binding(self): + runtime_calls = Mock() + runtime = SimpleNamespace( + client=SimpleNamespace( + get_asset_balance=lambda **_kwargs: {"free": "0"}, + get_simple_earn_flexible_product_position=lambda **_kwargs: { + "rows": [{"productId": "synthetic", "totalAmount": "10"}] + }, + ), + dry_run=True, + ) + report = {"redemption_subscription_intents": []} + + result = ensure_asset_available_runtime( + runtime, + report, + "USDT", + 5.0, + [], + runtime_call_client_fn=runtime_calls, + append_log_fn=Mock(), + runtime_notify_fn=Mock(), + translate_fn=lambda key, **kwargs: key, + sleep_fn=Mock(), + ) + + self.assertFalse(result) + runtime_calls.assert_not_called() + self.assertEqual(report["redemption_subscription_intents"], []) def test_resolve_runtime_btc_snapshot_prefers_injected_snapshot(self): runtime = SimpleNamespace(client=object(), btc_market_snapshot={"ahr999": 0.8}) @@ -98,8 +128,14 @@ def get_asset_balance(self, *, asset): def get_simple_earn_flexible_product_position(self, *, asset): return {"rows": [{"productId": "earn-1", "totalAmount": "5.0"}]} - runtime = SimpleNamespace(client=Client(), dry_run=False) - report = {"redemption_subscription_intents": []} + action_authorizations = [] + runtime = SimpleNamespace( + client=Client(), + dry_run=False, + action_cash_usdt=25.0, + action_authorizer=lambda **action: action_authorizations.append(action), + ) + report = {"redemption_subscription_intents": [], "order_authorization": {"outcome": "APPROVE"}} observed = {"calls": [], "logs": [], "notifications": [], "sleep": []} available = ensure_asset_available_runtime( @@ -120,6 +156,9 @@ def get_simple_earn_flexible_product_position(self, *, asset): self.assertTrue(available) self.assertEqual(report["redemption_subscription_intents"][0]["action"], "redeem") self.assertEqual(observed["calls"][0][0], "redeem_simple_earn_flexible_product") + self.assertEqual(action_authorizations[0]["action_class"], "earn_asset_availability_redeem") + self.assertEqual(action_authorizations[0]["payload"], observed["calls"][0][1]) + self.assertEqual(action_authorizations[0]["u_total"], 25.0) self.assertEqual(observed["sleep"], [3]) self.assertEqual(observed["notifications"], []) self.assertEqual(len(observed["logs"]), 1) @@ -132,8 +171,13 @@ def get_asset_balance(self, *, asset): def get_simple_earn_flexible_product_list(self, *, asset): return {"rows": [{"productId": "earn-1"}]} - runtime = SimpleNamespace(client=Client()) - report = {"redemption_subscription_intents": []} + action_authorizations = [] + runtime = SimpleNamespace( + client=Client(), + action_cash_usdt=150.0, + action_authorizer=lambda **action: action_authorizations.append(action), + ) + report = {"redemption_subscription_intents": [], "order_authorization": {"outcome": "APPROVE"}} observed = {"calls": [], "logs": []} manage_usdt_earn_buffer_runtime( @@ -151,6 +195,8 @@ def get_simple_earn_flexible_product_list(self, *, asset): self.assertEqual(report["redemption_subscription_intents"][0]["action"], "subscribe") self.assertEqual(report["redemption_subscription_intents"][0]["amount"], 50.0) self.assertEqual(observed["calls"][0][0], "subscribe_simple_earn_flexible_product") + self.assertEqual(action_authorizations[0]["action_class"], "earn_buffer_subscribe") + self.assertEqual(action_authorizations[0]["payload"], observed["calls"][0][1]) self.assertEqual(len(observed["logs"]), 1) def test_ensure_runtime_client_marks_report_aborted_after_retries(self): diff --git a/tests/test_cycle_replay_runtime.py b/tests/test_cycle_replay_runtime.py index 192261e7..6fa53369 100644 --- a/tests/test_cycle_replay_runtime.py +++ b/tests/test_cycle_replay_runtime.py @@ -1,10 +1,13 @@ import contextlib +import hashlib import io +import json import sys import types import unittest from datetime import datetime, timezone from pathlib import Path +from unittest.mock import patch def install_test_stubs(): @@ -78,52 +81,193 @@ def set(self, *args, **kwargs): import main import run_cycle_replay +from application.cycle_service import run_live_cycle +from trend_pool_support import build_static_trend_pool_resolution FIXTURE_TIME = datetime(2026, 3, 15, 0, 0, tzinfo=timezone.utc) +RISK_EVALUATION_TIME = datetime(2026, 8, 4, 12, 0, tzinfo=timezone.utc) + + +def canonical_digest(value): + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def valid_release_identity(): + return { + "strategy_profile": "crypto_live_pool_rotation", + "mode": "core_major", + "source_revision": "a" * 40, + "input_timestamp": "2026-03-10T00:00:00Z", + "artifact_contract": "qsl.crypto_live_pool.artifact_manifest.v1", + "artifact_version": "2026-03-10-core_major", + "artifacts": { + name: {"sha256": character * 64} + for name, character in zip( + ("live_pool", "live_pool_legacy", "latest_ranking", "latest_universe"), + "1234", + ) + }, + } + + +def runtime_evidence_identity(report): + aggregate = report.get("runtime_evidence_aggregate") + return { + "release_identity": report.get("release_identity", {}), + "account_risk_assessment": report.get("account_risk_assessment", {}), + "order_authorization": report.get("order_authorization", {}), + "strategy_stop_evaluation": report.get("strategy_stop_evaluation", {}), + "account_breaker_evaluation": report.get("account_breaker_evaluation", {}), + "durable_v2": aggregate, + "reconciliation": ( + aggregate.get("reconciliation", {}) + if isinstance(aggregate, dict) + else {"status": "MISSING"} + ), + } class CycleReplayRuntimeTests(unittest.TestCase): - def run_cycle(self, *, run_id): + def run_cycle(self, *, run_id, include_release_identity=False, static_fallback=False): + runtime, client, state_store, notifier = run_cycle_replay.build_replay_runtime( + run_id=run_id, + dry_run=True, + now_utc=FIXTURE_TIME, + ) + if include_release_identity: + identity = valid_release_identity() + symbol_map = runtime.trend_pool_payload["symbol_map"] + legacy_payload = { + **runtime.trend_pool_payload, + "symbols": symbol_map, + "symbol_map": symbol_map, + } + exact_text = json.dumps(legacy_payload, separators=(", ", ": ")) + identity["artifacts"]["live_pool_legacy"]["sha256"] = hashlib.sha256( + exact_text.encode("utf-8") + ).hexdigest() + runtime.trend_pool_payload["runtime_evidence_identity"] = identity + runtime.trend_pool_payload["live_pool_legacy_exact_bytes"] = { + "contract_version": "qsl.crypto_live_pool_legacy_exact_bytes.v1", + "encoding": "utf-8", + "utf8_text": exact_text, + } + trend_pool_patch = contextlib.nullcontext() + if static_fallback: + static_universe = dict(runtime.trend_pool_payload["symbol_map"]) + runtime.trend_pool_payload = None + resolution = build_static_trend_pool_resolution( + now_utc=runtime.now_utc, + messages=["forced static fallback"], + static_trend_universe=static_universe, + ) + trend_pool_patch = patch( + "main.load_trend_universe_from_live_pool", + return_value=(resolution["symbol_map"], resolution), + ) output_buffer = io.StringIO() - with contextlib.redirect_stdout(output_buffer): - return run_cycle_replay.run_replay_cycle( - run_id=run_id, - dry_run=True, - now_utc=FIXTURE_TIME, + with ( + patch("quant_platform_kit.risk.gate._utc_now", return_value=RISK_EVALUATION_TIME), + trend_pool_patch, + contextlib.redirect_stdout(output_buffer), + ): + report = main.execute_cycle(runtime) + return { + "report": report, + "runtime": runtime, + "client": client, + "state_store": state_store, + "notifier": notifier, + } + + def test_static_degraded_fallback_persists_without_v2_aggregate_or_orders(self): + result = self.run_cycle(run_id="static-degraded", static_fallback=True) + written = [] + + with patch( + "application.cycle_service.persist_runtime_report", + return_value=types.SimpleNamespace(local_path="/tmp/static-report.json", cloud_uri=None), + ): + report, _ = run_live_cycle( + runtime_builder=lambda: result["runtime"], + execute_cycle=lambda _runtime: result["report"], + output_printer=lambda _line: None, + report_writer=lambda current: written.append(dict(current)) or "/tmp/static-report.json", ) + self.assertEqual(report["status"], "ok") + self.assertEqual(report["degraded_mode_level"], "static") + self.assertEqual(report["release_identity"], {}) + self.assertNotIn("runtime_evidence_aggregate", report) + self.assertEqual(runtime_evidence_identity(report)["reconciliation"], {"status": "MISSING"}) + self.assertEqual(report["buy_sell_intents"], []) + self.assertEqual(report["btc_dca_intents"], []) + self.assertEqual(report["redemption_subscription_intents"], []) + self.assertEqual(result["client"].side_effect_calls, []) + self.assertEqual(result["state_store"].write_calls, []) + self.assertNotIn("runtime_evidence_aggregate", written[0]) + 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.assertNotEqual(report["status"], "ok") self.assertTrue(report["dry_run"]) self.assertEqual(result["client"].side_effect_calls, []) self.assertEqual(result["state_store"].write_calls, []) + self.assertEqual(result["notifier"].messages, []) 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.get("positions", []), []) + self.assertEqual(report.get("budgets", []), []) + self.assertEqual(report["buy_sell_intents"], []) + self.assertEqual(report["btc_dca_intents"], []) + self.assertEqual(report["redemption_subscription_intents"], []) + self.assertEqual(report["selected_symbols"]["active_trend_pool"], []) + self.assertEqual( + runtime_evidence_identity(report), + { + "release_identity": {}, + "account_risk_assessment": {}, + "order_authorization": {}, + "strategy_stop_evaluation": {}, + "account_breaker_evaluation": {}, + "durable_v2": None, + "reconciliation": {"status": "MISSING"}, + }, + ) 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") + first = self.run_cycle(run_id="deterministic-report", include_release_identity=True) + second = self.run_cycle(run_id="deterministic-report", include_release_identity=True) self.assertEqual(first["report"], second["report"]) + self.assertEqual(canonical_digest(first["report"]), canonical_digest(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"]["selected_symbols"]["selected_candidates"], ["ETHUSDT", "SOLUSDT"]) + self.assertEqual(first["report"]["selected_symbols"], second["report"]["selected_symbols"]) + 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, []) + self.assertEqual(first["state_store"].write_calls, []) + self.assertEqual(first["report"]["side_effect_summary"]["executed_call_count"], 0) + first_identity = runtime_evidence_identity(first["report"]) + self.assertEqual(first_identity, runtime_evidence_identity(second["report"])) + self.assertEqual(first_identity["account_risk_assessment"]["scope"], "ACCOUNT") + self.assertEqual(first_identity["account_risk_assessment"]["outcome"], "REJECT") + self.assertEqual(first_identity["account_risk_assessment"]["effective_exposure_cap"], 0.0) + self.assertEqual(first_identity["order_authorization"]["outcome"], "REJECT") + self.assertEqual(first_identity["order_authorization"]["mandate_scope"], "RESEARCH_ONLY") + self.assertTrue(first_identity["strategy_stop_evaluation"]["evaluated"]) + self.assertTrue(first_identity["account_breaker_evaluation"]["evaluated"]) + self.assertIsNone(first_identity["durable_v2"]) + self.assertEqual(first_identity["reconciliation"], {"status": "MISSING"}) 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..6af1b759 100644 --- a/tests/test_cycle_service.py +++ b/tests/test_cycle_service.py @@ -3,12 +3,160 @@ import tempfile import unittest from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import Mock, patch from application.cycle_service import execute_strategy_cycle, run_live_cycle, write_execution_report class CycleServiceTests(unittest.TestCase): + def test_execute_strategy_cycle_passes_total_account_daily_pnl_to_breaker(self): + observed_pnls = [] + + for daily_pnl, trend_daily_pnl in ((-0.01, 0.02), (0.0, -0.10)): + runtime = SimpleNamespace( + dry_run=True, + print_traceback=False, + now_utc=SimpleNamespace(strftime=lambda _fmt: "20260804"), + ) + execute_strategy_cycle( + runtime, + build_execution_report=lambda _runtime: { + "status": "ok", + "log_lines": [], + "error_summary": {"errors": []}, + }, + 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: ( + {}, + { + "runtime_evidence_identity": {}, + "release_identity_sha256": "", + "degraded": False, + }, + {}, + False, + ), + append_trend_pool_source_logs=lambda *_args, **_kwargs: None, + capture_market_snapshot=lambda *_args, **_kwargs: { + "u_total": 100.0, + "fuel_val": 0.0, + "dynamic_usdt_buffer": 0.0, + "prices": {}, + "balances": {}, + "btc_snapshot": {}, + "trend_indicators": {}, + }, + compute_portfolio_allocation=lambda *_args, **_kwargs: { + "_risk_evidence": {}, + "total_equity": 100.0, + "trend_val": 20.0, + }, + 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: (daily_pnl, trend_daily_pnl), + append_portfolio_report=lambda *_args, **_kwargs: None, + run_daily_circuit_breaker=lambda *args, **_kwargs: observed_pnls.append(args[7]) or True, + execute_trend_rotation=lambda *_args, **_kwargs: None, + execute_btc_dca_cycle=lambda *_args, **_kwargs: None, + 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, **_kwargs: None, + translate_fn=lambda key, **kwargs: key, + traceback_module=SimpleNamespace(print_exc=lambda: None), + ) + + self.assertEqual(observed_pnls, [-0.01, 0.0]) + + def test_run_live_cycle_preserves_static_degraded_fallback_without_aggregate(self): + runtime = SimpleNamespace( + runtime_target=None, + strategy_profile="crypto_live_pool_rotation", + strategy_display_name="", + strategy_display_name_localized="", + run_id="static-fallback", + dry_run=True, + producer_revision="a" * 40, + ) + report = { + "status": "ok", + "run_id": runtime.run_id, + "log_lines": [], + "error_summary": {"errors": []}, + "degraded_mode_level": "static", + "release_identity": {}, + "buy_sell_intents": [], + "btc_dca_intents": [], + "redemption_subscription_intents": [], + } + written = [] + exit_fn = Mock() + + with patch("application.cycle_service.build_runtime_evidence_aggregate_v2") as builder, patch( + "application.cycle_service.persist_runtime_report", + return_value=SimpleNamespace(local_path="/tmp/report.json", cloud_uri=None), + ) as persist: + result, _ = run_live_cycle( + runtime_builder=lambda: runtime, + execute_cycle=lambda _runtime: report, + output_printer=lambda _line: None, + report_writer=lambda current: written.append(dict(current)) or "/tmp/report.json", + exit_fn=exit_fn, + ) + + builder.assert_not_called() + self.assertEqual(result["status"], "ok") + self.assertEqual(result["degraded_mode_level"], "static") + self.assertNotIn("runtime_evidence_aggregate", result) + self.assertFalse(result["error_summary"]["errors"]) + self.assertEqual(result["buy_sell_intents"], []) + self.assertEqual(result["btc_dca_intents"], []) + self.assertEqual(result["redemption_subscription_intents"], []) + self.assertNotIn("runtime_evidence_aggregate", written[0]) + self.assertNotIn("runtime_evidence_aggregate", persist.call_args.args[0]) + exit_fn.assert_not_called() + + def test_run_live_cycle_attaches_v2_aggregate_before_durable_persist(self): + runtime = SimpleNamespace( + runtime_target=None, + strategy_profile="crypto_live_pool_rotation", + strategy_display_name="", + strategy_display_name_localized="", + run_id="synthetic-run", + dry_run=True, + producer_revision="a" * 40, + ) + report = { + "status": "ok", + "run_id": runtime.run_id, + "log_lines": [], + "error_summary": {"errors": []}, + } + written = [] + + with patch( + "application.cycle_service.build_runtime_evidence_aggregate_v2", + return_value={"contract_version": "qsl.runtime_evidence_aggregate.v2"}, + ) as builder, patch( + "application.cycle_service.persist_runtime_report", + return_value=SimpleNamespace(local_path="/tmp/report.json", cloud_uri=None), + ) as persist: + run_live_cycle( + runtime_builder=lambda: runtime, + execute_cycle=lambda _runtime: report, + output_printer=lambda _line: None, + report_writer=lambda current: written.append(dict(current)) or "/tmp/report.json", + ) + + builder.assert_called_once() + self.assertEqual(written[0]["runtime_evidence_aggregate"]["contract_version"], "qsl.runtime_evidence_aggregate.v2") + self.assertIn("runtime_evidence_aggregate", persist.call_args.args[0]) def test_write_execution_report_persists_json(self): report = {"status": "ok", "log_lines": ["hello"], "value": 1} with tempfile.TemporaryDirectory() as tmp_dir: @@ -55,7 +203,8 @@ def fake_execute_cycle(runtime): self.assertEqual(observed["built"], 1) self.assertEqual(len(observed["printed"]), 3) self.assertEqual(observed["printed"][1], "line-1\nline-2") - self.assertEqual(report["status"], "ok") + self.assertEqual(report["status"], "error") + self.assertEqual(report["error_summary"]["errors"][0]["stage"], "runtime_evidence_aggregate") self.assertEqual(payload["log_lines"], ["line-1", "line-2"]) def test_run_live_cycle_emits_structured_runtime_events(self): @@ -96,7 +245,7 @@ def test_run_live_cycle_emits_structured_runtime_events(self): ), ) - self.assertEqual(report["status"], "ok") + self.assertEqual(report["status"], "error") self.assertEqual(len(observed["printed"]), 3) start_log = json.loads(observed["printed"][0]) end_log = json.loads(observed["printed"][2]) @@ -105,8 +254,8 @@ def test_run_live_cycle_emits_structured_runtime_events(self): self.assertEqual(start_log["strategy_display_name"], "Crypto Live Pool Rotation") self.assertEqual(start_log["strategy_display_name_localized"], "加密领涨轮动") self.assertEqual(start_log["run_id"], "run-001") - self.assertEqual(end_log["event"], "strategy_cycle_completed") - self.assertEqual(end_log["status"], "ok") + self.assertEqual(end_log["event"], "strategy_cycle_failed") + self.assertEqual(end_log["status"], "error") def test_run_live_cycle_uses_shared_runtime_report_archive(self): observed = {} @@ -151,9 +300,9 @@ def test_run_live_cycle_uses_shared_runtime_report_archive(self): ), ) - self.assertEqual(report["status"], "ok") + self.assertEqual(report["status"], "error") self.assertEqual(persisted_path, output_path) - self.assertEqual(observed["status"], "ok") + self.assertEqual(observed["status"], "error") self.assertEqual(observed["kwargs"]["output_path"], output_path) self.assertEqual(observed["kwargs"]["cloud_prefix_uri"], "gs://demo-bucket/runtime-reports") self.assertEqual(observed["kwargs"]["project_id"], "demo-project") diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index cdec5978..9b14f901 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -15,6 +15,30 @@ class DecisionMapperTests(unittest.TestCase): + @staticmethod + def approved_diagnostics(values): + return { + "member_risk_assessment": {"outcome": "APPROVE"}, + "account_risk_assessment": {"outcome": "APPROVE"}, + "order_authorization": {"outcome": "APPROVE"}, + **values, + } + + def test_rejected_member_budgets_do_not_map_to_intents(self): + decision = StrategyDecision( + positions=(), + budgets=(BudgetIntent(name="trend_rotation_pool", amount=100.0),), + diagnostics={"member_risk_assessment": {"outcome": "REJECT"}}, + ) + + allocation = map_strategy_decision_to_allocation( + decision, + account_metrics={"total_equity": 1000.0, "trend_value": 0.0, "dca_value": 0.0}, + ) + plan = map_strategy_decision_to_rotation_plan(decision) + + self.assertEqual(allocation["trend_usdt_pool"], 0.0) + self.assertEqual(plan["planned_trend_buys"], {}) def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): decision = StrategyDecision( positions=( @@ -25,11 +49,11 @@ def test_map_strategy_decision_to_allocation_uses_budgets_and_diagnostics(self): BudgetIntent(name="btc_core_dca_pool", symbol="BTCUSDT", amount=250.0), BudgetIntent(name="trend_rotation_pool", amount=400.0), ), - diagnostics={ + diagnostics=self.approved_diagnostics({ "btc_target_ratio": 0.3, "trend_target_ratio": 0.7, "btc_base_order_usdt": 50.0, - }, + }), ) allocation = map_strategy_decision_to_allocation( @@ -50,7 +74,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={ + diagnostics=self.approved_diagnostics({ "trend_pool": ("ETHUSDT", "SOLUSDT"), "metadata": { "combo": { @@ -73,7 +97,7 @@ def test_map_strategy_decision_to_rotation_plan_uses_unified_diagnostics(self): "planned_trend_buys": {"ETHUSDT": 320.0}, "sell_reasons": {"SOLUSDT": "trend_sell_reason_rotated_out"}, "artifact_contract": {"version": "v1"}, - }, + }), risk_flags=("regime_off",), ) diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 5d07379c..cc22ccc1 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -1,5 +1,8 @@ +import hashlib import unittest +from datetime import datetime, timezone from types import SimpleNamespace +from unittest.mock import Mock from application.execution_service import ( execute_btc_dca_cycle, @@ -11,9 +14,170 @@ class ExecutionServiceTests(unittest.TestCase): + def test_run_daily_circuit_breaker_keeps_break_even_clear_at_zero_threshold(self): + report = {"buy_sell_intents": [], "error_summary": {"errors": []}, "status": "ok"} + state = {} + runtime_notify_fn = Mock() + runtime_call_client_fn = Mock() + runtime_set_trade_state_fn = Mock() + + triggered = run_daily_circuit_breaker( + SimpleNamespace(client=object(), now_utc=datetime(2026, 8, 4, tzinfo=timezone.utc)), + report, + state, + {}, + {}, + 100.0, + {}, + 0.0, + 0.0, + [], + format_qty_fn=Mock(), + runtime_notify_fn=runtime_notify_fn, + ensure_asset_available_fn=Mock(), + runtime_call_client_fn=runtime_call_client_fn, + set_symbol_trade_state_fn=Mock(), + runtime_set_trade_state_fn=runtime_set_trade_state_fn, + build_balance_snapshot_fn=Mock(), + translate_fn=lambda key, **kwargs: key, + ) + + self.assertFalse(triggered) + self.assertEqual(state, {}) + self.assertEqual(report["account_breaker_evaluation"]["outcome"], "CLEAR") + self.assertEqual(report["account_breaker_evaluation"]["action_result"]["status"], "NOT_REQUIRED") + self.assertEqual(report["account_breaker_evaluation"]["observed_loss_ratio"], 0.0) + self.assertEqual( + report["account_breaker_evaluation"]["snapshot_digest_sha256"], + hashlib.sha256(b'{"account_daily_pnl":0.0}').hexdigest(), + ) + runtime_notify_fn.assert_not_called() + runtime_call_client_fn.assert_not_called() + runtime_set_trade_state_fn.assert_not_called() + + def test_run_daily_circuit_breaker_triggers_on_loss_and_preserves_latch(self): + runtime = SimpleNamespace(client=object(), now_utc=datetime(2026, 8, 4, tzinfo=timezone.utc)) + report = {"buy_sell_intents": [], "error_summary": {"errors": []}, "status": "ok"} + state = {} + + triggered = run_daily_circuit_breaker( + runtime, + report, + state, + {}, + {}, + 100.0, + {}, + -0.01, + 0.0, + [], + format_qty_fn=Mock(), + runtime_notify_fn=Mock(), + ensure_asset_available_fn=Mock(), + runtime_call_client_fn=Mock(), + set_symbol_trade_state_fn=Mock(), + runtime_set_trade_state_fn=Mock(), + build_balance_snapshot_fn=Mock(return_value={}), + translate_fn=lambda key, **kwargs: key, + ) + + self.assertTrue(triggered) + self.assertTrue(state["is_circuit_broken"]) + self.assertEqual(report["account_breaker_evaluation"]["outcome"], "TRIGGERED") + self.assertEqual(report["account_breaker_evaluation"]["observed_metric"], "account_daily_pnl") + self.assertEqual(report["account_breaker_evaluation"]["observed_loss_ratio"], -0.01) + self.assertEqual( + report["account_breaker_evaluation"]["snapshot_digest_sha256"], + hashlib.sha256(b'{"account_daily_pnl":-0.01}').hexdigest(), + ) + + latched_report = {"buy_sell_intents": [], "error_summary": {"errors": []}, "status": "ok"} + latched = run_daily_circuit_breaker( + runtime, + latched_report, + state, + {}, + {}, + 100.0, + {}, + 0.0, + 0.0, + [], + format_qty_fn=Mock(), + runtime_notify_fn=Mock(), + ensure_asset_available_fn=Mock(), + runtime_call_client_fn=Mock(), + set_symbol_trade_state_fn=Mock(), + runtime_set_trade_state_fn=Mock(), + build_balance_snapshot_fn=Mock(), + translate_fn=lambda key, **kwargs: key, + ) + + self.assertTrue(latched) + self.assertEqual(latched_report["account_breaker_evaluation"]["outcome"], "TRIGGERED") + self.assertEqual(latched_report["account_breaker_evaluation"]["action_result"]["status"], "BLOCKED") + + def test_run_daily_circuit_breaker_emits_clear_evaluation_receipt(self): + report = {"buy_sell_intents": [], "error_summary": {"errors": []}, "status": "ok"} + + triggered = run_daily_circuit_breaker( + SimpleNamespace(client=object(), now_utc=datetime(2026, 8, 4, tzinfo=timezone.utc)), + report, + {}, + {}, + {}, + 100.0, + {}, + 0.0, + -0.05, + [], + format_qty_fn=Mock(), + runtime_notify_fn=Mock(), + ensure_asset_available_fn=Mock(), + runtime_call_client_fn=Mock(), + set_symbol_trade_state_fn=Mock(), + runtime_set_trade_state_fn=Mock(), + build_balance_snapshot_fn=Mock(), + translate_fn=lambda key, **kwargs: key, + ) + + self.assertFalse(triggered) + self.assertEqual(report["account_breaker_evaluation"]["outcome"], "CLEAR") + self.assertEqual(report["account_breaker_evaluation"]["action_result"]["attempted_count"], 0) + + def test_run_daily_circuit_breaker_treats_zero_quantity_as_failed_action(self): + report = {"buy_sell_intents": [], "error_summary": {"errors": []}, "status": "ok"} + + run_daily_circuit_breaker( + SimpleNamespace(client=object(), now_utc=datetime(2026, 8, 4, tzinfo=timezone.utc)), + report, + {}, + {"ETHUSDT": {"base_asset": "ETH"}}, + {"ETHUSDT": 1.0}, + 0.0, + {"ETHUSDT": 100.0}, + -0.10, + -0.05, + [], + format_qty_fn=lambda *_args: 0.0, + runtime_notify_fn=Mock(), + ensure_asset_available_fn=Mock(), + runtime_call_client_fn=Mock(), + set_symbol_trade_state_fn=Mock(), + runtime_set_trade_state_fn=Mock(), + build_balance_snapshot_fn=Mock(return_value={}), + translate_fn=lambda key, **kwargs: key, + ) + + action_result = report["account_breaker_evaluation"]["action_result"] + self.assertEqual(action_result["status"], "FAILED") + self.assertEqual(action_result["attempted_count"], 1) + self.assertEqual(action_result["failed_count"], 1) + self.assertEqual(report["status"], "error") + def test_run_daily_circuit_breaker_liquidates_and_latches_state(self): - runtime = SimpleNamespace(client=object()) - report = {"buy_sell_intents": []} + runtime = SimpleNamespace(client=object(), now_utc=datetime(2026, 8, 4, tzinfo=timezone.utc)) + report = {"buy_sell_intents": [], "error_summary": {"errors": []}, "status": "ok"} state = {} balances = {"ETHUSDT": 2.0} prices = {"ETHUSDT": 100.0} @@ -55,7 +219,11 @@ def test_run_daily_circuit_breaker_liquidates_and_latches_state(self): self.assertGreaterEqual(len(observed["notifications"]), 1) def test_execute_trend_sells_executes_sell_and_updates_runtime_state(self): - runtime = SimpleNamespace(client=object()) + action_authorizations = [] + runtime = SimpleNamespace( + client=object(), + action_authorizer=lambda **action: action_authorizations.append(action), + ) report = {"buy_sell_intents": []} state = {} balances = {"ETHUSDT": 2.0} @@ -102,12 +270,18 @@ def test_execute_trend_sells_executes_sell_and_updates_runtime_state(self): self.assertEqual(report["buy_sell_intents"][0]["reason"], "rotated_out") self.assertEqual(observed["asset_checks"][0][0], "ETH") self.assertEqual(observed["client_calls"][0][0], "order_market_sell") + self.assertEqual(action_authorizations[0]["action_class"], "trend_sell") + self.assertEqual(action_authorizations[0]["payload"], observed["client_calls"][0][1]) self.assertEqual(observed["actions"], [("ETHUSDT", "sell", "20260329")]) self.assertEqual(observed["persist_reasons"], ["trend_sell:ETHUSDT"]) self.assertGreaterEqual(len(observed["notifications"]), 1) def test_execute_trend_buys_executes_buy_and_updates_runtime_state(self): - runtime = SimpleNamespace(client=object()) + action_authorizations = [] + runtime = SimpleNamespace( + client=object(), + action_authorizer=lambda **action: action_authorizations.append(action), + ) report = {"buy_sell_intents": [], "gating_summary": {}, "gating_events": []} state = {} balances = {"ETHUSDT": 0.0} @@ -155,6 +329,10 @@ def test_execute_trend_buys_executes_buy_and_updates_runtime_state(self): self.assertEqual(report["buy_sell_intents"][0]["budget"], 200.0) self.assertEqual(observed["asset_checks"][0][0], "USDT") self.assertEqual(observed["client_calls"][0][0], "order_market_buy") + self.assertEqual(action_authorizations[0]["action_class"], "trend_buy") + self.assertEqual(action_authorizations[0]["method_name"], "order_market_buy") + self.assertEqual(action_authorizations[0]["payload"], observed["client_calls"][0][1]) + self.assertEqual(action_authorizations[0]["u_total"], 500.0) self.assertEqual(observed["actions"], [("ETHUSDT", "buy", "20260329")]) self.assertEqual(observed["persist_reasons"], ["trend_buy:ETHUSDT"]) self.assertGreaterEqual(len(observed["notifications"]), 1) diff --git a/tests/test_market_snapshot_support.py b/tests/test_market_snapshot_support.py index 99913dee..8f39b338 100644 --- a/tests/test_market_snapshot_support.py +++ b/tests/test_market_snapshot_support.py @@ -1,5 +1,6 @@ import unittest from types import SimpleNamespace +from unittest.mock import Mock from market_snapshot_support import capture_market_snapshot @@ -13,6 +14,28 @@ def get_avg_price(self, *, symbol): class MarketSnapshotSupportTests(unittest.TestCase): + def test_capture_market_snapshot_does_not_attempt_bnb_mutation_without_binding(self): + runtime_calls = Mock() + report = {"buy_sell_intents": []} + + capture_market_snapshot( + SimpleNamespace(client=FakeClient({"BNBUSDT": 300.0, "BTCUSDT": 50000.0}), dry_run=True), + report, + {}, + [], + 10.0, + 15.0, + get_total_balance_fn=lambda _client, asset, **_kwargs: {"USDT": 100.0, "BNB": 0.0, "BTC": 0.0}[asset], + ensure_asset_available_fn=Mock(return_value=True), + runtime_call_client_fn=runtime_calls, + runtime_notify_fn=Mock(), + append_log_fn=Mock(), + resolve_btc_snapshot_fn=Mock(return_value={}), + resolve_trend_indicators_fn=Mock(return_value={}), + ) + + runtime_calls.assert_not_called() + self.assertEqual(report["buy_sell_intents"], []) def test_capture_market_snapshot_handles_bnb_top_up_and_collects_balances(self): runtime = SimpleNamespace( client=FakeClient( @@ -24,7 +47,7 @@ def test_capture_market_snapshot_handles_bnb_top_up_and_collects_balances(self): } ) ) - report = {"buy_sell_intents": []} + report = {"buy_sell_intents": [], "order_authorization": {"outcome": "APPROVE"}} log_buffer = [] side_effect_calls = [] balance_map = { diff --git a/tests/test_notify_i18n.py b/tests/test_notify_i18n.py index b4af75f5..c0fc4a5b 100644 --- a/tests/test_notify_i18n.py +++ b/tests/test_notify_i18n.py @@ -210,8 +210,14 @@ def test_capture_market_snapshot_uses_chinese_bnb_log_when_notify_lang_is_zh(sel report = {"buy_sell_intents": []} log_buffer = [] side_effect_calls = [] + notifications = [] with patch.dict(os.environ, {"NOTIFY_LANG": "zh"}, clear=False): + translator = build_translator(os.environ["NOTIFY_LANG"]) + report["order_authorization"] = { + "outcome": "REJECT", + "diagnostic": f"{translator('bnb_top_up_failed')}:已阻止,缺少账户/订单授权", + } capture_market_snapshot( runtime, report, @@ -227,14 +233,19 @@ def test_capture_market_snapshot_uses_chinese_bnb_log_when_notify_lang_is_zh(sel }[asset], ensure_asset_available_fn=lambda runtime, report, asset, amount, log_buffer: True, runtime_call_client_fn=lambda runtime, report, **kwargs: side_effect_calls.append(kwargs), - runtime_notify_fn=lambda runtime, report, message: self.fail(f"unexpected notification: {message}"), + runtime_notify_fn=lambda runtime, report, message: notifications.append(message), append_log_fn=lambda buffer, message: buffer.append(message), resolve_btc_snapshot_fn=lambda runtime, btc_price, log_buffer: {"ahr999": 0.8, "zscore": 1.2}, 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.assertEqual(side_effect_calls, []) + self.assertEqual(report["buy_sell_intents"], []) + self.assertEqual(notifications, []) + self.assertEqual(log_buffer, []) + self.assertIn("BNB 补仓失败", report["order_authorization"]["diagnostic"]) + self.assertIn("已阻止", report["order_authorization"]["diagnostic"]) + self.assertIn("账户/订单授权", report["order_authorization"]["diagnostic"]) if __name__ == "__main__": diff --git a/tests/test_runtime_support.py b/tests/test_runtime_support.py index 3f96a9bf..cb65bbd9 100644 --- a/tests/test_runtime_support.py +++ b/tests/test_runtime_support.py @@ -1,6 +1,9 @@ +import hashlib +import json import os import sys import unittest +from unittest.mock import Mock from pathlib import Path from unittest.mock import patch @@ -15,17 +18,490 @@ from runtime_support import ( ExecutionRuntime, build_runtime_evidence_aggregate, + build_runtime_evidence_aggregate_v2, build_execution_report, finalize_notification_delivery, record_gating_event, runtime_notify, validate_runtime_evidence_aggregate, + validate_runtime_evidence_aggregate_v2, + runtime_call_client, ) from quant_platform_kit.common.runtime_target import build_runtime_target class TestBuildExecutionReport(unittest.TestCase): @staticmethod + def action_authorization_report(runtime, *, payload, method_name="order_market_buy", effect_type="order_buy"): + runtime.authorization_sequence = 1 + report = build_execution_report(runtime) + report.update({ + "release_identity_sha256": "5" * 64, + "member_risk_assessment": { + "scope": "MEMBER", + "outcome": "APPROVE", + "decision_digest_sha256": "c" * 64, + "assessment_sha256": "1" * 64, + }, + "account_risk_assessment": { + "scope": "ACCOUNT", + "outcome": "APPROVE", + "decision_digest_sha256": "c" * 64, + "portfolio_snapshot_digest_sha256": "d" * 64, + "assessment_sha256": "2" * 64, + }, + }) + authorization = { + "contract_version": "qsl.binance_order_authorization.v2", + "outcome": "APPROVE", + "run_id": str(runtime.run_id), + "authorization_kind": "ACTION", + "action_sequence": 1, + "action_class": "btc_dca_buy", + "method_name": method_name, + "effect_type": effect_type, + "canonical_payload_sha256": hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + ).hexdigest(), + "decision_digest_sha256": "c" * 64, + "release_identity_sha256": "5" * 64, + "account_snapshot_sha256": "d" * 64, + "member_assessment_sha256": "1" * 64, + "account_assessment_sha256": "2" * 64, + "mandate_authority_receipt_sha256": "b" * 64, + "mandate_scope": "PAPER", + } + authorization["authorization_sha256"] = hashlib.sha256( + json.dumps(authorization, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + ).hexdigest() + report["order_authorization"] = authorization + return report + + def test_runtime_call_client_consumes_exact_action_authorization_once(self): + payload = {"symbol": "BTCUSDT", "quantity": 0.001} + runtime = ExecutionRuntime(dry_run=True, run_id="synthetic-run") + report = self.action_authorization_report(runtime, payload=payload) + + result = runtime_call_client( + runtime, + report, + method_name="order_market_buy", + payload=payload, + effect_type="order_buy", + ) + + self.assertEqual(result["status"], "suppressed") + self.assertEqual(report["order_authorization"]["authorization_kind"], "ACTION") + with self.assertRaises(RuntimeError): + runtime_call_client( + runtime, + report, + method_name="order_market_buy", + payload=payload, + effect_type="order_buy", + ) + + def test_runtime_call_client_rejects_action_binding_mismatches(self): + payload = {"symbol": "BTCUSDT", "quantity": 0.001} + variants = ( + ("method", "order_market_sell", payload, "order_buy"), + ("effect", "order_market_buy", payload, "order_sell"), + ("payload", "order_market_buy", {"symbol": "BTCUSDT", "quantity": 0.002}, "order_buy"), + ) + for label, method_name, call_payload, effect_type in variants: + with self.subTest(label=label): + runtime = ExecutionRuntime(dry_run=True, run_id=f"synthetic-{label}") + report = self.action_authorization_report(runtime, payload=payload) + with self.assertRaises(RuntimeError): + runtime_call_client( + runtime, + report, + method_name=method_name, + payload=call_payload, + effect_type=effect_type, + ) + + def test_runtime_call_client_rejects_stale_decision_and_snapshot_bindings(self): + payload = {"symbol": "BTCUSDT", "quantity": 0.001} + variants = ( + ("stale_sequence", lambda runtime, _report: setattr(runtime, "authorization_sequence", 2)), + ("decision", lambda _runtime, report: report["account_risk_assessment"].update( + decision_digest_sha256="e" * 64 + )), + ("snapshot", lambda _runtime, report: report["account_risk_assessment"].update( + portfolio_snapshot_digest_sha256="e" * 64 + )), + ) + for label, mutate in variants: + with self.subTest(label=label): + runtime = ExecutionRuntime(dry_run=True, run_id=f"synthetic-{label}") + report = self.action_authorization_report(runtime, payload=payload) + mutate(runtime, report) + with self.assertRaises(RuntimeError): + runtime_call_client( + runtime, + report, + method_name="order_market_buy", + payload=payload, + effect_type="order_buy", + ) + + @staticmethod + def canonical_sha256(value): + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + ).hexdigest() + + @classmethod + def risk_assessment( + cls, + scope, + *, + outcome="REJECT", + mandate_scope="RESEARCH_ONLY", + effective_exposure_cap=0.0, + decision_digest_sha256="c" * 64, + portfolio_snapshot_digest_sha256="d" * 64, + ): + assessment = { + "contract_version": "qsl.risk_gate_assessment.v1", + "scope": scope, + "evaluated_at": "2026-08-04T00:00:00Z", + "policy_id": "qpk.risk_gate", + "policy_version": "v1", + "qpk_source_revision": "a" * 40, + "mandate_id": "binance_crypto_research_only_v1", + "mandate_version": "2026-08-04.1", + "mandate_authority_receipt_sha256": "b" * 64, + "mandate_scope": mandate_scope, + "decision_digest_sha256": decision_digest_sha256, + "portfolio_snapshot_digest_sha256": portfolio_snapshot_digest_sha256, + "effective_exposure_cap": effective_exposure_cap, + "observed_effective_exposure": 0.0, + "proposed_effective_exposure": 0.25 if outcome == "APPROVE" else 0.0, + "outcome": outcome, + "reason_codes": () if outcome == "APPROVE" else ("budget_authority_exceeded",), + } + assessment["assessment_sha256"] = cls.canonical_sha256(assessment) + return assessment + + @staticmethod + def v2_release_identity(): + return { + "strategy_profile": "crypto_live_pool_rotation", + "mode": "core_major", + "source_revision": "e" * 40, + "input_timestamp": "2026-08-04T00:00:00Z", + "artifact_contract": "qsl.crypto_live_pool.artifact_manifest.v1", + "artifact_version": "2026-08-04-core_major", + "artifacts": { + name: {"sha256": character * 64} + for name, character in zip( + ("live_pool", "live_pool_legacy", "latest_ranking", "latest_universe"), + "1234", + ) + }, + } + + @classmethod + def v2_chain_inputs(cls, *, outcome="REJECT"): + mandate_scope = "PAPER" if outcome == "APPROVE" else "RESEARCH_ONLY" + effective_exposure_cap = 0.5 if outcome == "APPROVE" else 0.0 + member = cls.risk_assessment( + "MEMBER", + outcome=outcome, + mandate_scope=mandate_scope, + effective_exposure_cap=effective_exposure_cap, + ) + account = cls.risk_assessment( + "ACCOUNT", + outcome=outcome, + mandate_scope=mandate_scope, + effective_exposure_cap=effective_exposure_cap, + ) + release_identity = cls.v2_release_identity() + authorization = { + "contract_version": "qsl.binance_order_authorization.v2", + "outcome": outcome, + "run_id": "synthetic", + "authorization_kind": "ACTION", + "action_sequence": 1, + "action_class": "btc_dca_buy", + "method_name": "order_market_buy", + "effect_type": "order_buy", + "canonical_payload_sha256": "9" * 64, + "decision_digest_sha256": account["decision_digest_sha256"], + "release_identity_sha256": cls.canonical_sha256(release_identity), + "account_snapshot_sha256": account["portfolio_snapshot_digest_sha256"], + "member_assessment_sha256": member["assessment_sha256"], + "account_assessment_sha256": account["assessment_sha256"], + "mandate_authority_receipt_sha256": account["mandate_authority_receipt_sha256"], + "mandate_scope": account["mandate_scope"], + } + authorization["authorization_sha256"] = cls.canonical_sha256(authorization) + cap = { + "outcome": outcome, + "mandate_id": account["mandate_id"], + "mandate_version": account["mandate_version"], + "mandate_authority_receipt_sha256": account["mandate_authority_receipt_sha256"], + "mandate_scope": account["mandate_scope"], + "effective_exposure_cap": account["effective_exposure_cap"], + "decision_digest_sha256": account["decision_digest_sha256"], + "release_identity_sha256": cls.canonical_sha256(release_identity), + "account_snapshot_sha256": account["portfolio_snapshot_digest_sha256"], + "account_assessment_sha256": account["assessment_sha256"], + "qpk_source_revision": account["qpk_source_revision"], + "order_authorization_sha256": authorization["authorization_sha256"], + } + return { + "produced_at": "2026-08-04T00:00:00Z", + "run_id": "synthetic", + "producer_revision": "f" * 40, + "release_identity": release_identity, + "member_risk_assessment": member, + "account_risk_assessment": account, + "cap_assessment": cap, + "order_authorization": authorization, + "strategy_stop_evaluation": {"evaluated": True, "outcome": "CLEAR"}, + "account_breaker_evaluation": {"evaluated": True, "outcome": "CLEAR"}, + "execution_gate_outcome": outcome, + "reconciliation": {"status": "MISSING"}, + } + + @classmethod + def resign_aggregate(cls, aggregate): + aggregate["aggregate_sha256"] = cls.canonical_sha256( + {key: value for key, value in aggregate.items() if key != "aggregate_sha256"} + ) + + @classmethod + def resign_assessment(cls, assessment): + assessment["assessment_sha256"] = cls.canonical_sha256( + {key: value for key, value in assessment.items() if key != "assessment_sha256"} + ) + + @classmethod + def resign_authorization(cls, aggregate): + authorization = aggregate["order_authorization"] + authorization["authorization_sha256"] = cls.canonical_sha256( + {key: value for key, value in authorization.items() if key != "authorization_sha256"} + ) + aggregate["cap_assessment"]["order_authorization_sha256"] = authorization["authorization_sha256"] + + def test_v2_aggregate_builds_deterministic_redacted_missing_receipt(self): + kwargs = self.v2_chain_inputs() + + first = build_runtime_evidence_aggregate_v2(**kwargs) + second = build_runtime_evidence_aggregate_v2(**kwargs) + + self.assertEqual(first, second) + self.assertTrue(validate_runtime_evidence_aggregate_v2(first)["ok"]) + self.assertEqual(first["reconciliation"], {"status": "MISSING"}) + self.assertEqual(first["order_authorization"], kwargs["order_authorization"]) + self.assertNotIn("positions", str(first)) + + def test_v2_aggregate_validates_synthetic_future_authority_approve_chain(self): + aggregate = build_runtime_evidence_aggregate_v2(**self.v2_chain_inputs(outcome="APPROVE")) + + self.assertTrue(validate_runtime_evidence_aggregate_v2(aggregate)["ok"]) + self.assertEqual(aggregate["execution_gate_outcome"], "APPROVE") + + def test_v2_aggregate_validates_preliminary_reject_and_fail_closed_action_reject(self): + preliminary = self.v2_chain_inputs() + preliminary_authorization = preliminary["order_authorization"] + preliminary_authorization.update( + authorization_kind="PRELIMINARY", + action_sequence=0, + action_class="", + method_name="", + effect_type="", + canonical_payload_sha256="", + ) + preliminary_authorization["authorization_sha256"] = self.canonical_sha256( + { + key: value + for key, value in preliminary_authorization.items() + if key != "authorization_sha256" + } + ) + preliminary["cap_assessment"]["order_authorization_sha256"] = preliminary_authorization[ + "authorization_sha256" + ] + + approved_risk = self.v2_chain_inputs(outcome="APPROVE") + approved_risk["order_authorization"]["outcome"] = "REJECT" + approved_risk["order_authorization"]["authorization_sha256"] = self.canonical_sha256( + { + key: value + for key, value in approved_risk["order_authorization"].items() + if key != "authorization_sha256" + } + ) + approved_risk["cap_assessment"]["order_authorization_sha256"] = approved_risk["order_authorization"][ + "authorization_sha256" + ] + approved_risk["execution_gate_outcome"] = "REJECT" + + self.assertTrue(validate_runtime_evidence_aggregate_v2(build_runtime_evidence_aggregate_v2(**preliminary))["ok"]) + self.assertTrue(validate_runtime_evidence_aggregate_v2(build_runtime_evidence_aggregate_v2(**approved_risk))["ok"]) + + def test_v2_aggregate_rejects_contradictory_execution_gate_before_signing(self): + kwargs = self.v2_chain_inputs() + kwargs["execution_gate_outcome"] = "APPROVE" + + with self.assertRaises(ValueError): + build_runtime_evidence_aggregate_v2(**kwargs) + + def test_v2_aggregate_rejects_stale_risk_assessment_digest(self): + aggregate = build_runtime_evidence_aggregate_v2(**self.v2_chain_inputs()) + aggregate["member_risk_assessment"]["decision_digest_sha256"] = "e" * 64 + self.resign_aggregate(aggregate) + + self.assertFalse(validate_runtime_evidence_aggregate_v2(aggregate)["ok"]) + + def test_v2_aggregate_rejects_member_account_cross_assessment_splice(self): + shared_fields = ( + "policy_id", + "policy_version", + "qpk_source_revision", + "mandate_id", + "mandate_version", + "mandate_authority_receipt_sha256", + "mandate_scope", + "decision_digest_sha256", + "portfolio_snapshot_digest_sha256", + "effective_exposure_cap", + ) + for field_name in shared_fields: + with self.subTest(field_name=field_name): + aggregate = build_runtime_evidence_aggregate_v2(**self.v2_chain_inputs()) + member = aggregate["member_risk_assessment"] + member[field_name] = 0.25 if field_name == "effective_exposure_cap" else ( + "e" * 64 if field_name.endswith("sha256") else "changed" + ) + self.resign_assessment(member) + aggregate["order_authorization"]["member_assessment_sha256"] = member["assessment_sha256"] + self.resign_authorization(aggregate) + self.resign_aggregate(aggregate) + + self.assertFalse(validate_runtime_evidence_aggregate_v2(aggregate)["ok"]) + + def test_v2_aggregate_rejects_cap_chain_mismatches(self): + cap_fields = ( + "decision_digest_sha256", + "release_identity_sha256", + "account_snapshot_sha256", + "account_assessment_sha256", + "mandate_id", + "mandate_version", + "mandate_authority_receipt_sha256", + "mandate_scope", + "effective_exposure_cap", + "qpk_source_revision", + "order_authorization_sha256", + ) + for field_name in cap_fields: + with self.subTest(field_name=field_name): + aggregate = build_runtime_evidence_aggregate_v2(**self.v2_chain_inputs()) + aggregate["cap_assessment"][field_name] = ( + 0.25 if field_name == "effective_exposure_cap" else ( + "e" * 64 if field_name.endswith("sha256") else "changed" + ) + ) + self.resign_aggregate(aggregate) + + self.assertFalse(validate_runtime_evidence_aggregate_v2(aggregate)["ok"]) + + def test_v2_aggregate_rejects_authorization_chain_mismatches(self): + authorization_fields = ( + "run_id", + "decision_digest_sha256", + "release_identity_sha256", + "account_snapshot_sha256", + "member_assessment_sha256", + "account_assessment_sha256", + "mandate_authority_receipt_sha256", + "mandate_scope", + ) + for field_name in authorization_fields: + with self.subTest(field_name=field_name): + aggregate = build_runtime_evidence_aggregate_v2(**self.v2_chain_inputs()) + aggregate["order_authorization"][field_name] = ( + "e" * 64 if field_name.endswith("sha256") else "changed" + ) + self.resign_authorization(aggregate) + self.resign_aggregate(aggregate) + + self.assertFalse(validate_runtime_evidence_aggregate_v2(aggregate)["ok"]) + + def test_v2_aggregate_rejects_stale_action_binding_digest(self): + action_fields = ( + "action_sequence", + "action_class", + "method_name", + "effect_type", + "canonical_payload_sha256", + ) + for field_name in action_fields: + with self.subTest(field_name=field_name): + aggregate = build_runtime_evidence_aggregate_v2(**self.v2_chain_inputs()) + aggregate["order_authorization"][field_name] = ( + 2 if field_name == "action_sequence" else ( + "e" * 64 if field_name.endswith("sha256") else "changed" + ) + ) + self.resign_aggregate(aggregate) + + self.assertFalse(validate_runtime_evidence_aggregate_v2(aggregate)["ok"]) + + def test_v2_aggregate_rejects_missing_or_aliased_singletons(self): + variants = ( + ("missing_member", lambda value: value.pop("member_risk_assessment")), + ("missing_authorization", lambda value: value.pop("order_authorization")), + ("member_alias", lambda value: value.update(member_risk_assessments=[])), + ("authorization_alias", lambda value: value.update(authorization=value["order_authorization"])), + ) + for label, mutate in variants: + with self.subTest(label=label): + aggregate = build_runtime_evidence_aggregate_v2(**self.v2_chain_inputs()) + mutate(aggregate) + self.resign_aggregate(aggregate) + + self.assertFalse(validate_runtime_evidence_aggregate_v2(aggregate)["ok"]) + + def test_runtime_call_client_blocks_missing_current_account_binding(self): + client = Mock() + runtime = ExecutionRuntime(client=client, dry_run=False) + report = build_execution_report(runtime) + + with self.assertRaises(RuntimeError): + runtime_call_client( + runtime, + report, + method_name="order_market_buy", + payload={"symbol": "BTCUSDT", "quoteOrderQty": 1.0}, + effect_type="order_buy", + ) + + client.order_market_buy.assert_not_called() + + def test_v2_aggregate_is_redacted_missing_only_and_rejects_matched(self): + with self.assertRaises(ValueError): + build_runtime_evidence_aggregate_v2( + produced_at="2026-08-04T00:00:00Z", + run_id="synthetic", + producer_revision="a" * 40, + release_identity=self.v2_release_identity(), + member_risk_assessment={"scope": "MEMBER", "outcome": "REJECT", "assessment_sha256": "1" * 64}, + account_risk_assessment={"scope": "ACCOUNT", "outcome": "REJECT", "assessment_sha256": "2" * 64}, + cap_assessment={"outcome": "REJECT", "positions": []}, + order_authorization={}, + strategy_stop_evaluation={"evaluated": True, "outcome": "CLEAR"}, + account_breaker_evaluation={"evaluated": True, "outcome": "CLEAR"}, + execution_gate_outcome="REJECT", + reconciliation={"status": "MATCHED"}, + ) + @staticmethod def runtime_evidence_inputs(): return { "release_identity": { diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index f305b846..e1a4ba20 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -1,6 +1,8 @@ import sys import types import unittest +import hashlib +import json from types import SimpleNamespace from unittest.mock import patch from datetime import datetime, timezone @@ -20,10 +22,123 @@ sys.modules["requests"] = requests_module from quant_platform_kit import PortfolioSnapshot -from quant_platform_kit.strategy_contracts import StrategyManifest, StrategyRuntimeAdapter +from quant_platform_kit.strategy_contracts import StrategyDecision, StrategyManifest, StrategyRuntimeAdapter class StrategyRuntimeTests(unittest.TestCase): + def test_account_gate_binds_exact_action_and_requires_action_context(self): + from strategy_runtime import apply_account_risk_gate, build_binance_research_mandate + + decision = StrategyDecision(positions=(), budgets=(), diagnostics={ + "member_risk_assessment": { + "scope": "MEMBER", + "outcome": "APPROVE", + "decision_digest_sha256": "1" * 64, + "assessment_sha256": "2" * 64, + } + }) + assessment = SimpleNamespace( + scope="ACCOUNT", + outcome="APPROVE", + decision_digest_sha256="1" * 64, + portfolio_snapshot_digest_sha256="3" * 64, + assessment_sha256="4" * 64, + effective_exposure_cap=0.5, + mandate_scope="PAPER", + ) + mandate = build_binance_research_mandate() + mandate.update({ + "authority_scope": "PAPER", + "effective_exposure_cap": 0.5, + "loss_budget": 1.0, + "product_caps": 0.5, + "nominal_caps": 0.5, + "allowed_nonzero_assets": ["BTCUSDT"], + "product_leverage_factors": {"BTCUSDT": 1}, + }) + payload = {"symbol": "BTCUSDT", "quantity": 0.001} + action_context = { + "action_sequence": 7, + "action_class": "btc_dca_buy", + "method_name": "order_market_buy", + "effect_type": "order_buy", + "payload": payload, + } + + with patch( + "strategy_runtime.qpk_assess_with_evidence", + return_value=SimpleNamespace(decision=decision, assessment=assessment), + ) as assess: + bound = apply_account_risk_gate( + decision, + portfolio_snapshot={"as_of": "2026-08-04T00:00:00Z"}, + release_identity_sha256="5" * 64, + run_id="synthetic-run", + mandate_provenance=mandate, + market_data={}, + action_context=action_context, + ) + preliminary = apply_account_risk_gate( + decision, + portfolio_snapshot={"as_of": "2026-08-04T00:00:00Z"}, + release_identity_sha256="5" * 64, + run_id="synthetic-run", + mandate_provenance=mandate, + market_data={}, + ) + + authorization = bound.order_authorization + self.assertEqual(assess.call_count, 2) + self.assertEqual(authorization["contract_version"], "qsl.binance_order_authorization.v2") + self.assertEqual(authorization["outcome"], "APPROVE") + self.assertEqual(authorization["action_sequence"], 7) + self.assertEqual(authorization["action_class"], "btc_dca_buy") + self.assertEqual(authorization["method_name"], "order_market_buy") + self.assertEqual(authorization["effect_type"], "order_buy") + self.assertEqual( + authorization["canonical_payload_sha256"], + hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + ).hexdigest(), + ) + self.assertEqual(preliminary.order_authorization["outcome"], "APPROVE") + self.assertEqual(preliminary.order_authorization["authorization_kind"], "PRELIMINARY") + self.assertEqual(preliminary.order_authorization["action_sequence"], 0) + + def test_account_gate_uses_qpk_and_never_authorizes_research_only_mandate(self): + from strategy_runtime import apply_account_risk_gate, build_binance_research_mandate + + decision = StrategyDecision(positions=(), budgets=(), diagnostics={ + "member_risk_assessment": { + "scope": "MEMBER", + "outcome": "APPROVE", + "decision_digest_sha256": "1" * 64, + "assessment_sha256": "2" * 64, + } + }) + assessment = SimpleNamespace( + scope="ACCOUNT", + outcome="APPROVE", + decision_digest_sha256="1" * 64, + portfolio_snapshot_digest_sha256="3" * 64, + assessment_sha256="4" * 64, + effective_exposure_cap=0.0, + mandate_scope="RESEARCH_ONLY", + ) + qpk_result = SimpleNamespace(decision=decision, assessment=assessment) + + with patch("strategy_runtime.qpk_assess_with_evidence", return_value=qpk_result) as assess: + result = apply_account_risk_gate( + decision, + portfolio_snapshot={"as_of": "2026-08-04T00:00:00Z"}, + release_identity_sha256="5" * 64, + run_id="synthetic-run", + mandate_provenance=build_binance_research_mandate(), + market_data={}, + ) + + self.assertEqual(assess.call_args.kwargs["scope"], "ACCOUNT") + self.assertEqual(result.order_authorization["outcome"], "REJECT") def test_load_strategy_runtime_exposes_explicit_artifact_contract(self): try: from strategy_runtime import load_strategy_runtime diff --git a/tests/test_trend_pool_loading.py b/tests/test_trend_pool_loading.py index 7c758cbd..f795a63e 100644 --- a/tests/test_trend_pool_loading.py +++ b/tests/test_trend_pool_loading.py @@ -2,6 +2,8 @@ import types import unittest import os +import hashlib +import json from datetime import datetime, timezone from pathlib import Path from unittest.mock import Mock, patch @@ -95,6 +97,34 @@ def set(self, *args, **kwargs): from crypto_strategies.strategies.crypto_live_pool_rotation.rotation import refresh_rotation_pool +EXACT_BYTES_CONTRACT_VERSION = "qsl.crypto_live_pool_legacy_exact_bytes.v1" + + +def bind_exact_legacy_artifact(payload, *, exact_text=None): + if exact_text is None: + symbol_map = payload["symbol_map"] + exact_payload = { + "as_of_date": payload["as_of_date"], + "version": payload["version"], + "mode": payload["mode"], + "pool_size": payload["pool_size"], + "symbols": {symbol: symbol_map[symbol] for symbol in payload["symbols"]}, + "symbol_map": {symbol: symbol_map[symbol] for symbol in payload["symbols"]}, + "source_project": payload["source_project"], + } + exact_text = "\n" + json.dumps(exact_payload, separators=(", ", ": ")) + "\n" + payload["live_pool_legacy_exact_bytes"] = { + "contract_version": EXACT_BYTES_CONTRACT_VERSION, + "encoding": "utf-8", + "utf8_text": exact_text, + } + exact_bytes = exact_text.encode("utf-8") + payload["runtime_evidence_identity"]["artifacts"]["live_pool_legacy"]["sha256"] = ( + hashlib.sha256(exact_bytes).hexdigest() + ) + return payload + + def build_payload(as_of_date="2026-03-10", *, mode="core_major"): symbol_map = { "ETHUSDT": {"base_asset": "ETH"}, @@ -103,7 +133,7 @@ def build_payload(as_of_date="2026-03-10", *, mode="core_major"): "LTCUSDT": {"base_asset": "LTC"}, "BCHUSDT": {"base_asset": "BCH"}, } - return { + payload = { "as_of_date": as_of_date, "version": f"{as_of_date}-{mode}", "mode": mode, @@ -112,9 +142,154 @@ def build_payload(as_of_date="2026-03-10", *, mode="core_major"): "symbol_map": symbol_map, "source_project": "crypto-live-pool-pipelines", } + payload["runtime_evidence_identity"] = { + "strategy_profile": "crypto_live_pool_rotation", + "mode": mode, + "source_revision": "a" * 40, + "input_timestamp": f"{as_of_date}T00:00:00Z", + "artifact_contract": "qsl.crypto_live_pool.artifact_manifest.v1", + "artifact_version": payload["version"], + "artifacts": { + name: {"sha256": character * 64} + for name, character in zip( + ("live_pool", "live_pool_legacy", "latest_ranking", "latest_universe"), + "1234", + ) + }, + } + return bind_exact_legacy_artifact(payload) class TrendPoolLoadingTests(unittest.TestCase): + def test_validate_trend_pool_payload_preserves_and_digests_release_identity(self): + payload = build_payload() + + result = main.validate_trend_pool_payload( + payload, + source_label="test", + now_utc=datetime(2026, 3, 14, tzinfo=timezone.utc), + max_age_days=30, + acceptable_modes=["core_major"], + expected_pool_size=5, + enforce_freshness=True, + ) + + expected = hashlib.sha256( + json.dumps( + payload["runtime_evidence_identity"], + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + self.assertTrue(result["ok"]) + self.assertEqual(result["payload"]["runtime_evidence_identity"], payload["runtime_evidence_identity"]) + self.assertEqual(result["payload"]["release_identity_sha256"], expected) + + def test_validate_trend_pool_payload_rejects_missing_exact_legacy_artifact(self): + payload = build_payload() + payload.pop("live_pool_legacy_exact_bytes") + + result = main.validate_trend_pool_payload( + payload, + source_label="test", + now_utc=datetime(2026, 3, 14, tzinfo=timezone.utc), + max_age_days=30, + acceptable_modes=["core_major"], + expected_pool_size=5, + enforce_freshness=True, + ) + + self.assertFalse(result["ok"]) + self.assertIn("exact bytes", " ".join(result["errors"])) + + def test_validate_trend_pool_payload_rejects_exact_byte_mutation(self): + payload = build_payload() + payload["live_pool_legacy_exact_bytes"]["utf8_text"] += "\n" + + result = main.validate_trend_pool_payload( + payload, + source_label="test", + now_utc=datetime(2026, 3, 14, tzinfo=timezone.utc), + max_age_days=30, + acceptable_modes=["core_major"], + expected_pool_size=5, + enforce_freshness=True, + ) + + self.assertFalse(result["ok"]) + self.assertIn("digest", " ".join(result["errors"])) + + def test_validate_trend_pool_payload_rejects_invalid_exact_legacy_artifact(self): + cases = { + "invalid_utf8": b"\xff", + "invalid_json": "{", + "nan": '{"value": NaN}', + "infinity": '{"value": Infinity}', + "negative_infinity": '{"value": -Infinity}', + "non_object": "[]", + "empty_object": "{}", + } + for label, invalid_value in cases.items(): + with self.subTest(label=label): + payload = build_payload() + raw_bytes = ( + invalid_value + if isinstance(invalid_value, bytes) + else invalid_value.encode("utf-8") + ) + payload["live_pool_legacy_exact_bytes"]["utf8_text"] = invalid_value + payload["runtime_evidence_identity"]["artifacts"]["live_pool_legacy"][ + "sha256" + ] = hashlib.sha256(raw_bytes).hexdigest() + + result = main.validate_trend_pool_payload( + payload, + source_label="test", + now_utc=datetime(2026, 3, 14, tzinfo=timezone.utc), + max_age_days=30, + acceptable_modes=["core_major"], + expected_pool_size=5, + enforce_freshness=True, + ) + + self.assertFalse(result["ok"]) + self.assertIn("exact bytes", " ".join(result["errors"])) + + def test_validate_trend_pool_payload_rejects_top_level_pool_mutation(self): + payload = build_payload() + payload["symbols"][0] = "ADAUSDT" + payload["symbol_map"].pop("ETHUSDT") + payload["symbol_map"]["ADAUSDT"] = {"base_asset": "ADA"} + + result = main.validate_trend_pool_payload( + payload, + source_label="test", + now_utc=datetime(2026, 3, 14, tzinfo=timezone.utc), + max_age_days=30, + acceptable_modes=["core_major"], + expected_pool_size=5, + enforce_freshness=True, + ) + + self.assertFalse(result["ok"]) + self.assertIn("convenience", " ".join(result["errors"])) + + def test_validate_trend_pool_payload_rejects_missing_release_identity(self): + payload = build_payload() + payload.pop("runtime_evidence_identity") + + result = main.validate_trend_pool_payload( + payload, + source_label="test", + now_utc=datetime(2026, 3, 14, tzinfo=timezone.utc), + max_age_days=30, + acceptable_modes=["core_major"], + expected_pool_size=5, + enforce_freshness=True, + ) + + self.assertFalse(result["ok"]) + self.assertIn("runtime_evidence_identity", " ".join(result["errors"])) def test_validate_trend_pool_payload_rejects_stale_payload(self): payload = build_payload(as_of_date="2026-01-01") @@ -134,6 +309,7 @@ def test_validate_trend_pool_payload_rejects_stale_payload(self): def test_validate_trend_pool_payload_preserves_ordered_symbols_in_symbol_map(self): payload = build_payload() payload["symbols"] = ["BCHUSDT", "ETHUSDT", "LTCUSDT", "SOLUSDT", "XRPUSDT"] + bind_exact_legacy_artifact(payload) result = main.validate_trend_pool_payload( payload, diff --git a/tests/test_watchdog_workflow.py b/tests/test_watchdog_workflow.py index 5a045421..eaa18962 100644 --- a/tests/test_watchdog_workflow.py +++ b/tests/test_watchdog_workflow.py @@ -245,9 +245,12 @@ def test_qsl_qpk_pin_matches_manifest(self) -> None: def test_crypto_strategies_pin_matches_qpk_health_dependency(self) -> None: requirement = _dependency("crypto-strategies @ ") lock = LOCK.read_text(encoding="utf-8") + qsl = tomllib.loads(QSL.read_text(encoding="utf-8")) + revision = "5ef4d4ae840704c850b4dc63a63b7a0e084d3d88" - self.assertIn("CryptoStrategies.git?rev=ef78312d7653095f585c4f75d45bf765bedc2751", lock) - self.assertIn("@ef78312d7653095f585c4f75d45bf765bedc2751", requirement) + self.assertIn(f"CryptoStrategies.git?rev={revision}#{revision}", lock) + self.assertEqual(requirement.rsplit("@", maxsplit=1)[1], revision) + self.assertEqual(qsl["qsl"]["requires"]["crypto_strategies"], revision) self.assertNotIn("@eb7bf665c5199f7f075af61ef5c86171eea1f057", lock) diff --git a/trend_pool_support.py b/trend_pool_support.py index a17bac04..af8a99d7 100644 --- a/trend_pool_support.py +++ b/trend_pool_support.py @@ -1,5 +1,7 @@ import json +import hashlib import os +import re from datetime import datetime, timezone from pathlib import Path @@ -12,6 +14,140 @@ ) +_RUNTIME_IDENTITY_ARTIFACTS = frozenset( + {"live_pool", "live_pool_legacy", "latest_ranking", "latest_universe"} +) +_RUNTIME_IDENTITY_PROFILE = "crypto_live_pool_rotation" +_LEGACY_EXACT_BYTES_CONTRACT_VERSION = "qsl.crypto_live_pool_legacy_exact_bytes.v1" + + +def _canonical_sha256(value): + encoded = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _reject_non_standard_json_constant(value): + raise ValueError(f"non-standard JSON constant is not allowed: {value}") + + +def _validate_exact_legacy_artifact(payload, identity): + errors = [] + handoff = payload.get("live_pool_legacy_exact_bytes") if isinstance(payload, dict) else None + if not isinstance(handoff, dict): + return {}, ["live_pool_legacy exact bytes handoff must be an object"] + if handoff.get("contract_version") != _LEGACY_EXACT_BYTES_CONTRACT_VERSION: + errors.append("live_pool_legacy exact bytes contract version mismatch") + if handoff.get("encoding") != "utf-8": + errors.append("live_pool_legacy exact bytes encoding must be utf-8") + + exact_text = handoff.get("utf8_text") + exact_bytes = None + if not isinstance(exact_text, str): + errors.append("live_pool_legacy exact bytes must contain UTF-8 text") + else: + try: + exact_bytes = exact_text.encode("utf-8") + except UnicodeEncodeError: + errors.append("live_pool_legacy exact bytes must contain valid UTF-8 text") + + artifacts = identity.get("artifacts") if isinstance(identity, dict) else None + legacy_identity = artifacts.get("live_pool_legacy") if isinstance(artifacts, dict) else None + expected_digest = legacy_identity.get("sha256") if isinstance(legacy_identity, dict) else None + if exact_bytes is not None and hashlib.sha256(exact_bytes).hexdigest() != expected_digest: + errors.append("live_pool_legacy exact bytes digest mismatch") + + exact_payload = None + if exact_bytes is not None: + try: + parsed = json.loads( + exact_text, + parse_constant=_reject_non_standard_json_constant, + ) + except (TypeError, ValueError): + errors.append("live_pool_legacy exact bytes must contain valid JSON") + else: + if not isinstance(parsed, dict): + errors.append("live_pool_legacy exact bytes must contain a JSON object") + else: + exact_payload = parsed + + if exact_payload is not None: + exact_symbols = exact_payload.get("symbols") + exact_symbol_map = exact_payload.get("symbol_map") + if not isinstance(exact_symbols, dict) or not exact_symbols: + errors.append("live_pool_legacy exact bytes symbols must be a non-empty object") + if not isinstance(exact_symbol_map, dict) or exact_symbol_map != exact_symbols: + errors.append("live_pool_legacy exact bytes symbol_map mismatch") + if isinstance(exact_symbols, dict): + if payload.get("symbols") != list(exact_symbols): + errors.append("live_pool_legacy exact bytes symbols convenience mismatch") + if payload.get("symbol_map") != exact_symbols: + errors.append("live_pool_legacy exact bytes symbol_map convenience mismatch") + for field in ("as_of_date", "version", "mode", "pool_size", "source_project"): + if payload.get(field) != exact_payload.get(field): + errors.append(f"live_pool_legacy exact bytes {field} convenience mismatch") + + return exact_payload or {}, errors + + +def validate_runtime_evidence_identity(identity, *, payload): + errors = [] + if not isinstance(identity, dict): + return {}, "", ["runtime_evidence_identity must be an object"] + required = ( + "strategy_profile", + "mode", + "source_revision", + "input_timestamp", + "artifact_contract", + "artifact_version", + "artifacts", + ) + for field in required: + if field not in identity: + errors.append(f"runtime_evidence_identity missing field: {field}") + if errors: + return {}, "", errors + + if identity.get("strategy_profile") != _RUNTIME_IDENTITY_PROFILE: + errors.append("runtime_evidence_identity strategy_profile mismatch") + if identity.get("mode") != payload.get("mode"): + errors.append("runtime_evidence_identity mode mismatch") + source_revision = identity.get("source_revision") + if not isinstance(source_revision, str) or not re.fullmatch(r"[0-9a-f]{40}", source_revision): + errors.append("runtime_evidence_identity source_revision must be a lowercase git SHA") + expected_timestamp = f"{payload.get('as_of_date')}T00:00:00Z" + if identity.get("input_timestamp") != expected_timestamp: + errors.append("runtime_evidence_identity input_timestamp mismatch") + artifact_contract = identity.get("artifact_contract") + if not isinstance(artifact_contract, str) or not artifact_contract.strip(): + errors.append("runtime_evidence_identity artifact_contract must be non-empty") + declared_contract = payload.get("artifact_contract_version") + if declared_contract and artifact_contract != declared_contract: + errors.append("runtime_evidence_identity artifact_contract mismatch") + if identity.get("artifact_version") != payload.get("version"): + errors.append("runtime_evidence_identity artifact_version mismatch") + + artifacts = identity.get("artifacts") + if not isinstance(artifacts, dict) or set(artifacts) != _RUNTIME_IDENTITY_ARTIFACTS: + errors.append("runtime_evidence_identity artifacts must contain the exact four release artifacts") + else: + for name, artifact in artifacts.items(): + if not isinstance(artifact, dict) or not isinstance(artifact.get("sha256"), str) or not re.fullmatch( + r"[0-9a-f]{64}", artifact["sha256"] + ): + errors.append(f"runtime_evidence_identity artifacts.{name}.sha256 is invalid") + if errors: + return {}, "", errors + normalized = json.loads(json.dumps(identity, sort_keys=True, allow_nan=False)) + return normalized, _canonical_sha256(normalized), [] + + def infer_base_asset(symbol): return symbol[:-4] if isinstance(symbol, str) and symbol.endswith("USDT") else symbol @@ -204,6 +340,25 @@ def validate_trend_pool_payload( source_project = "unknown" warnings.append(t("source_project_missing_unknown")) + runtime_evidence_identity, release_identity_sha256, identity_errors = validate_runtime_evidence_identity( + (payload or {}).get("runtime_evidence_identity"), + payload={ + "as_of_date": as_of_date.isoformat() if as_of_date is not None else "", + "version": version, + "mode": mode, + "artifact_contract_version": (payload or {}).get("artifact_contract_version"), + }, + ) + errors.extend(identity_errors) + exact_payload, exact_payload_errors = _validate_exact_legacy_artifact( + payload or {}, + runtime_evidence_identity, + ) + errors.extend(exact_payload_errors) + if exact_payload: + symbol_map = parse_trend_universe_mapping(exact_payload) + symbols = extract_trend_pool_symbols(exact_payload, symbol_map) + ordered_symbol_map = { symbol: symbol_map[symbol] for symbol in symbols @@ -218,6 +373,17 @@ def validate_trend_pool_payload( "symbols": symbols, "symbol_map": ordered_symbol_map, "source_project": source_project, + "runtime_evidence_identity": runtime_evidence_identity, + "release_identity_sha256": release_identity_sha256, + "live_pool_legacy_exact_bytes": { + "contract_version": _LEGACY_EXACT_BYTES_CONTRACT_VERSION, + "encoding": "utf-8", + "utf8_text": ( + (payload or {}).get("live_pool_legacy_exact_bytes", {}).get("utf8_text", "") + if isinstance((payload or {}).get("live_pool_legacy_exact_bytes"), dict) + else "" + ), + }, } return { @@ -361,6 +527,8 @@ def build_trend_pool_resolution(validated_payload, *, source_kind, degraded, now "version": payload["version"], "mode": payload["mode"], "source_project": payload["source_project"], + "runtime_evidence_identity": payload["runtime_evidence_identity"], + "release_identity_sha256": payload["release_identity_sha256"], } @@ -390,6 +558,8 @@ def build_static_trend_pool_resolution(*, now_utc=None, messages=None, static_tr "symbols": list(static_trend_universe.keys()), "symbol_map": {symbol: meta.copy() for symbol, meta in static_trend_universe.items()}, "source_project": "BinancePlatform", + "runtime_evidence_identity": {}, + "release_identity_sha256": "", } return { "source_kind": "static", @@ -407,4 +577,6 @@ def build_static_trend_pool_resolution(*, now_utc=None, messages=None, static_tr "version": payload["version"], "mode": payload["mode"], "source_project": payload["source_project"], + "runtime_evidence_identity": {}, + "release_identity_sha256": "", } diff --git a/uv.lock b/uv.lock index 64a628c6..d3fb2202 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=b371322b948e4298920a7d8613b155245dcd5f8d" }] [[package]] name = "aiohappyeyeballs" @@ -206,7 +206,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "crypto-strategies", git = "https://github.com/QuantStrategyLab/CryptoStrategies.git?rev=ef78312d7653095f585c4f75d45bf765bedc2751" }, + { name = "crypto-strategies", git = "https://github.com/QuantStrategyLab/CryptoStrategies.git?rev=5ef4d4ae840704c850b4dc63a63b7a0e084d3d88" }, { name = "functions-framework" }, { name = "google-cloud-firestore" }, { name = "google-cloud-storage" }, @@ -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=b371322b948e4298920a7d8613b155245dcd5f8d" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'", specifier = ">=0.12" }, ] @@ -446,7 +446,7 @@ wheels = [ [[package]] name = "crypto-strategies" version = "0.4.11" -source = { git = "https://github.com/QuantStrategyLab/CryptoStrategies.git?rev=ef78312d7653095f585c4f75d45bf765bedc2751#ef78312d7653095f585c4f75d45bf765bedc2751" } +source = { git = "https://github.com/QuantStrategyLab/CryptoStrategies.git?rev=5ef4d4ae840704c850b4dc63a63b7a0e084d3d88#5ef4d4ae840704c850b4dc63a63b7a0e084d3d88" } dependencies = [ { name = "quant-platform-kit" }, ] @@ -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=b371322b948e4298920a7d8613b155245dcd5f8d#b371322b948e4298920a7d8613b155245dcd5f8d" } [[package]] name = "regex"