Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion application/cycle_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from quant_platform_kit.common.runtime_reports import persist_runtime_report
from quant_platform_kit.strategy_lifecycle.performance_monitor import try_record_platform_execution
from decision_mapper import has_execution_authority
from runtime_logging import RuntimeLogContext, emit_runtime_log
from runtime_support import finalize_notification_delivery

Expand Down Expand Up @@ -66,7 +67,7 @@ def execute_strategy_cycle(
report,
runtime_trend_universe,
log_buffer,
min_bnb_value,
float("-inf"),
buy_bnb_amount,
)
u_total = market_snapshot["u_total"]
Expand All @@ -88,6 +89,22 @@ def execute_strategy_cycle(
trend_indicators,
btc_snapshot,
)
if has_execution_authority(allocation.get("execution_decision")):
market_snapshot = capture_market_snapshot(
Comment on lines +92 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recompute allocation after recapturing the market snapshot

Whenever execution authority is approved, this second capture replaces prices, balances, and u_total, but allocation, total_equity, and trend_val_equity still come from the first snapshot. If prices move while the two snapshots and strategy evaluation run—or the BNB top-up changes balances—the portfolio report, daily PnL, state rebasing, and circuit-breaker decision use the old valuation while subsequent execution uses the new market data. Recompute the allocation from the replacement snapshot, or isolate the fuel top-up without replacing the inputs used downstream.

Useful? React with 👍 / 👎.

runtime,
report,
runtime_trend_universe,
log_buffer,
min_bnb_value,
buy_bnb_amount,
)
u_total = market_snapshot["u_total"]
fuel_val = market_snapshot["fuel_val"]
dynamic_usdt_buffer = market_snapshot["dynamic_usdt_buffer"]
prices = market_snapshot["prices"]
balances = market_snapshot["balances"]
btc_snapshot = market_snapshot["btc_snapshot"]
trend_indicators = market_snapshot["trend_indicators"]
total_equity = allocation["total_equity"]
trend_val_equity = allocation["trend_val"]

Expand Down Expand Up @@ -127,6 +144,7 @@ def execute_strategy_cycle(
trend_daily_pnl,
circuit_breaker_pct,
log_buffer,
decision=allocation.get("execution_decision"),
Comment thread
Pigbibi marked this conversation as resolved.
):
return report

Expand Down Expand Up @@ -185,6 +203,7 @@ def execute_strategy_cycle(
btc_base_order_usdt,
today_id_str,
log_buffer,
decision=post_trade_allocation.get("execution_decision"),
)

manage_usdt_earn_buffer_runtime(
Expand Down
41 changes: 41 additions & 0 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from decision_mapper import has_execution_authority
from runtime_support import record_gating_event


Expand All @@ -21,6 +22,17 @@ def _is_missing(value) -> bool:
return False


def _block_unapproved_execution(report, decision, *, category) -> bool:
if has_execution_authority(decision):
return False
record_gating_event(
report,
gate="execution_authority_not_approved",
category=category,
)
return True


def build_trend_candidate_filter_diagnostics(
active_trend_pool,
trend_indicators,
Expand Down Expand Up @@ -106,6 +118,7 @@ def run_daily_circuit_breaker(
circuit_breaker_pct,
log_buffer,
*,
decision=None,
format_qty_fn,
runtime_notify_fn,
ensure_asset_available_fn,
Expand All @@ -117,6 +130,8 @@ def run_daily_circuit_breaker(
):
if trend_daily_pnl > circuit_breaker_pct:
return False
if _block_unapproved_execution(report, decision, category="daily_circuit_breaker"):
return True

for symbol, config in runtime_trend_universe.items():
tradable_qty = balances[symbol]
Expand Down Expand Up @@ -448,6 +463,23 @@ def execute_trend_rotation(
report.setdefault("diagnostics", {})["combo"] = combo_diagnostics
report["selected_symbols"]["active_trend_pool"] = list(active_trend_pool)
report["selected_symbols"]["selected_candidates"] = list(selected_candidates.keys())
if _block_unapproved_execution(report, strategy_plan.get("decision"), category="trend_rotation"):
report["selected_symbols"]["selected_candidates"] = []
append_rotation_summary(
log_buffer,
official_trend_pool_symbols,
active_trend_pool,
{},
)
append_trend_symbol_status(
log_buffer,
runtime_trend_universe,
prices,
trend_indicators,
state,
btc_snapshot,
)
return u_total
if not selected_candidates:
record_gating_event(
report,
Expand Down Expand Up @@ -498,6 +530,12 @@ def execute_trend_rotation(
selected_candidates = dict(post_sell_plan["selected_candidates"])
eligible_buy_symbols = list(post_sell_plan["eligible_buy_symbols"])
planned_trend_buys = dict(post_sell_plan["planned_trend_buys"])
if _block_unapproved_execution(
report,
post_sell_plan.get("decision"),
category="trend_rotation",
):
return u_total
if selected_candidates and not eligible_buy_symbols:
record_gating_event(
report,
Expand Down Expand Up @@ -567,6 +605,7 @@ def execute_btc_dca_cycle(
today_id_str,
log_buffer,
*,
decision=None,
append_log_fn,
translate_fn,
format_qty_fn,
Expand All @@ -576,6 +615,8 @@ def execute_btc_dca_cycle(
runtime_notify_fn,
runtime_set_trade_state_fn,
):
if _block_unapproved_execution(report, decision, category="btc_dca"):
return u_total
if dca_usdt_pool <= 10 and dca_val <= 10:
record_gating_event(
report,
Expand Down
130 changes: 118 additions & 12 deletions decision_mapper.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,101 @@
from __future__ import annotations

import re
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any

from quant_platform_kit.risk.contracts import CandidateRiskIdentity
from quant_platform_kit.risk.gate import (
_FALLBACK_MAX_SNAPSHOT_AGE_SECONDS_V1,
_canonical_digest,
_decision_metrics,
_parse_utc_timestamp,
)
from quant_platform_kit.strategy_contracts import StrategyDecision


_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")


def _approved_scoped_assessment(
value: Any,
*,
scope: str,
now: datetime,
) -> Mapping[str, Any] | None:
"""Accept only serialized QPK approval evidence; never infer or recompute risk authority."""
if not isinstance(value, Mapping):
return None
reason_codes = value.get("reason_codes")
if (
value.get("scope") != scope
or value.get("outcome") != "APPROVE"
or not isinstance(reason_codes, (list, tuple))
or reason_codes
):
return None
for field in (
"mandate_authority_receipt_sha256",
"candidate_identity_sha256",
"decision_digest_sha256",
"portfolio_snapshot_digest_sha256",
"assessment_sha256",
):
if not isinstance(value.get(field), str) or not _SHA256_PATTERN.fullmatch(value[field]):
Comment on lines +38 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify each serialized assessment digest before granting authority

When serialized assessment evidence is altered after QPK produced it, this check accepts any 64-character assessment_sha256 without verifying that it is the canonical digest of the assessment. For example, changing a rejected assessment's outcome to APPROVE and clearing reason_codes leaves its candidate and decision digests valid, so has_execution_authority can authorize live orders even though the supplied assessment hash no longer authenticates those fields. Recompute and compare the QPK assessment digest, or use QPK's typed assessment verifier, before trusting the evidence.

Useful? React with 👍 / 👎.

return None
for field in ("contract_version", "evaluated_at", "policy_id", "policy_version"):
if not isinstance(value.get(field), str) or not value[field].strip():
Comment thread
Pigbibi marked this conversation as resolved.
return None
evaluated_at = _parse_utc_timestamp(value["evaluated_at"])
if evaluated_at is None:
return None
age_seconds = (now - evaluated_at).total_seconds()
if not 0.0 <= age_seconds <= _FALLBACK_MAX_SNAPSHOT_AGE_SECONDS_V1:
return None
return value


def has_execution_authority(decision: StrategyDecision | None) -> bool:
"""Require matching QPK RiskEngine, MEMBER, and ACCOUNT approval evidence."""
if not isinstance(decision, StrategyDecision):
return False
diagnostics = decision.diagnostics
if not isinstance(diagnostics, Mapping) or diagnostics.get("risk_gate") != "APPROVE":
return False
if any(str(flag).startswith("rejected:") for flag in decision.risk_flags):
return False
now = datetime.now(timezone.utc)
member = _approved_scoped_assessment(
diagnostics.get("member_risk_assessment"),
scope="MEMBER",
now=now,
)
account = _approved_scoped_assessment(
diagnostics.get("account_risk_assessment"),
scope="ACCOUNT",
now=now,
)
if member is None or account is None:
return False
candidate_identity = diagnostics.get("candidate_risk_identity")
if not isinstance(candidate_identity, CandidateRiskIdentity):
return False
try:
decision_payload, _, _ = _decision_metrics(decision, total_equity=None)
decision_digest = _canonical_digest(decision_payload)
except (TypeError, ValueError):
return False
return (
member["candidate_identity_sha256"]
== account["candidate_identity_sha256"]
== candidate_identity.candidate_sha256
and member["decision_digest_sha256"]
== account["decision_digest_sha256"]
== decision_digest
Comment on lines +90 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require scoped approvals to reference the same portfolio snapshot

When MEMBER and ACCOUNT approvals were produced from different portfolio snapshots within the five-minute freshness window, this comparison still grants authority as long as the candidate and decision digests match. Because portfolio_snapshot_digest_sha256 is only shape-checked and never compared, approvals that were never jointly issued for one account state can be combined to authorize live orders; require the two scoped assessments to carry the same portfolio snapshot digest before accepting them.

Useful? React with 👍 / 👎.

)


def _budget_map(decision: StrategyDecision) -> dict[str, float]:
values: dict[str, float] = {}
for budget in decision.budgets:
Expand All @@ -28,28 +118,40 @@ def map_strategy_decision_to_allocation(
account_metrics: Mapping[str, Any],
) -> dict[str, float]:
diagnostics = dict(decision.diagnostics)
budgets = _budget_map(decision)
positions = _position_weight_map(decision)
trend_target_ratio = float(
diagnostics.get(
"trend_target_ratio",
sum(weight for symbol, weight in positions.items() if symbol != "BTCUSDT"),
authorized = has_execution_authority(decision)
budgets = _budget_map(decision) if authorized else {}
positions = _position_weight_map(decision) if authorized else {}
trend_target_ratio = (
float(
diagnostics.get(
"trend_target_ratio",
sum(weight for symbol, weight in positions.items() if symbol != "BTCUSDT"),
)
)
if authorized
else 0.0
)
return {
"total_equity": float(account_metrics["total_equity"]),
"trend_val": float(account_metrics["trend_value"]),
"dca_val": float(account_metrics["dca_value"]),
"btc_target_ratio": float(diagnostics.get("btc_target_ratio", positions.get("BTCUSDT", 0.0))),
"btc_target_ratio": (
float(diagnostics.get("btc_target_ratio", positions.get("BTCUSDT", 0.0)))
if authorized
else 0.0
),
"trend_target_ratio": trend_target_ratio,
"trend_usdt_pool": float(budgets.get("trend_rotation_pool", 0.0)),
"dca_usdt_pool": float(budgets.get("btc_core_dca_pool", 0.0)),
"btc_base_order_usdt": float(diagnostics.get("btc_base_order_usdt", 0.0)),
"btc_base_order_usdt": (
float(diagnostics.get("btc_base_order_usdt", 0.0)) if authorized else 0.0
),
}


def map_strategy_decision_to_rotation_plan(decision: StrategyDecision) -> dict[str, Any]:
diagnostics = dict(decision.diagnostics)
authorized = has_execution_authority(decision)
metadata = diagnostics.get("metadata") if isinstance(diagnostics.get("metadata"), Mapping) else {}
combo_meta = metadata.get("combo") if isinstance(metadata.get("combo"), Mapping) else {}
selected_candidates = {
Expand All @@ -59,20 +161,24 @@ def map_strategy_decision_to_rotation_plan(decision: StrategyDecision) -> dict[s
"abs_momentum": float(payload.get("abs_momentum", 0.0)),
}
for symbol, payload in dict(diagnostics.get("rotation_candidates", {})).items()
}
} if authorized else {}
planned_trend_buys = {
str(symbol): float(amount)
for symbol, amount in dict(diagnostics.get("planned_trend_buys", {})).items()
}
} if authorized else {}
sell_reasons = {
str(symbol): str(reason)
for symbol, reason in dict(diagnostics.get("sell_reasons", {})).items()
if str(reason)
}
} if authorized else {}
return {
"active_trend_pool": list(diagnostics.get("trend_pool", ())),
"selected_candidates": selected_candidates,
"eligible_buy_symbols": [str(symbol) for symbol in diagnostics.get("eligible_buy_symbols", ())],
"eligible_buy_symbols": (
[str(symbol) for symbol in diagnostics.get("eligible_buy_symbols", ())]
if authorized
else []
),
"planned_trend_buys": planned_trend_buys,
"sell_reasons": sell_reasons,
"rotation_pool_source_version": diagnostics.get("rotation_pool_source_version"),
Expand Down
10 changes: 9 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,10 +874,12 @@ def _compute_portfolio_allocation(runtime, runtime_trend_universe, balances, pri
u_total,
fuel_val,
)
return map_decision_to_allocation(
allocation = map_decision_to_allocation(
evaluation.decision,
account_metrics=evaluation.account_metrics,
)
allocation["execution_decision"] = evaluation.decision
return allocation


def _build_balance_snapshot(runtime_trend_universe, balances, u_total):
Expand Down Expand Up @@ -949,6 +951,8 @@ def _run_daily_circuit_breaker(
trend_daily_pnl,
circuit_breaker_pct,
log_buffer,
*,
decision=None,
):
return app_run_daily_circuit_breaker(
runtime,
Expand All @@ -961,6 +965,7 @@ def _run_daily_circuit_breaker(
trend_daily_pnl,
circuit_breaker_pct,
log_buffer,
decision=decision,
format_qty_fn=format_qty,
runtime_notify_fn=runtime_notify,
ensure_asset_available_fn=ensure_asset_available_runtime,
Expand Down Expand Up @@ -1128,6 +1133,8 @@ def _execute_btc_dca_cycle(
btc_base_order_usdt,
today_id_str,
log_buffer,
*,
decision=None,
):
return app_execute_btc_dca_cycle(
runtime,
Expand All @@ -1144,6 +1151,7 @@ def _execute_btc_dca_cycle(
btc_base_order_usdt,
today_id_str,
log_buffer,
decision=decision,
append_log_fn=append_log,
translate_fn=t,
format_qty_fn=format_qty,
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
description = "QuantStrategyLab platform layer for Binance exchange."
requires-python = ">=3.11"
dependencies = [
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@61783fdaee869bfeedd4289ae4b7f27104513759",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9618b4bd8e179760ac174914713598762cab15d7",
"crypto-strategies @ git+https://github.com/QuantStrategyLab/CryptoStrategies.git@ef78312d7653095f585c4f75d45bf765bedc2751",
"python-binance",
"pandas",
Expand All @@ -23,7 +23,7 @@ test = [

[tool.uv]
override-dependencies = [
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@61783fdaee869bfeedd4289ae4b7f27104513759",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9618b4bd8e179760ac174914713598762cab15d7",
]

[tool.ruff]
Expand Down
Loading