Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ flowchart LR
API --> DB[("PostgreSQL + pgvector<br/>50 migrations · Mission projections · HET tree · Memory")]
Worker["Worker Loops (13 Loops)<br/>Mission · AVO research · evolution · auto-review · meta-tuning"] --> DB
Worker --> Research["ARC Autonomous Research Loop (AVO + RD-Agent)<br/>HET tree → Co-STEER synthesis → AST gatekeeper → sandbox → baseline"]
Worker --> Evolution["Evolution Engine (default on)<br/>decay scan → budget admission → re-research → race duel → ledger"]
Worker --> Evolution["Evolution Engine (default on)<br/>decay / launch scan → budget admission → re-research or rule tweak → admission → race duel → ledger"]
Research --> Sandbox["Isolated Strategy Sandbox<br/>UDS · non-root · no network · digest-bound"]
Research --> Targets["Multi-Market Target Layer<br/>market_target.v1 · market-evolution.v1"]
Evolution --> Relay["Position Netting Relay Handover<br/>retain min(P,C) · >80% turnover saved · K slices"]
Expand Down Expand Up @@ -100,6 +100,7 @@ Scans active incubating paper strategies hourly:
- **Benchmark-Relative Decay Detection**: Strategy performance change over 14 days minus benchmark buy-and-hold change prevents broader market drops from falsely flagging strategy decay.
- **Position Netting Relay Service**: During strategy turnover, intersecting holdings $\min(P_i, C_i)$ are preserved in place. Only net differences $\Delta = C - P$ are sliced and executed over $K$ periods with 100-share constraints, slashing turnover friction by >80%.
- **Effectiveness Ledger & Feishu Alerts**: Full ledger accounting with automatic Feishu webhook card alerts for stalled data gaps and error streaks.
- **Variant Supply for Every Parent (opt-in)**: once a paper parent has run 7 complete days it can receive variants before full decay evidence exists; exploration missions can use a deterministic one-step parameter-neighbour researcher, and a candidate that fails the strict final gate may still race if it is not clearly worse than its parent on the same window. Succession still requires winning the forward race.

Design docs: [63 Evolution Hardening](docs/architecture/63-evolution-effectiveness-alerts-and-benchmark.md) · [66 Smooth Relay Handover](docs/architecture/66-quantlab-adapter-a-share-microstructure-and-relay-handover.md)

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ flowchart LR
API --> DB[("PostgreSQL + pgvector<br/>50 migrations · Mission projections · HET tree · Memory")]
Worker["Worker Loops (13 Loops)<br/>Mission · AVO research · evolution · auto-review · meta-tuning"] --> DB
Worker --> Research["ARC Autonomous Research Loop (AVO + RD-Agent)<br/>HET tree → Co-STEER synthesis → AST gatekeeper → sandbox → baseline"]
Worker --> Evolution["Evolution Engine (default on)<br/>decay scan → budget admission → re-research → race duel → ledger"]
Worker --> Evolution["Evolution Engine (default on)<br/>decay / launch scan → budget admission → re-research or rule tweak → admission → race duel → ledger"]
Research --> Sandbox["Isolated Strategy Sandbox<br/>UDS · non-root · no network · digest-bound"]
Research --> Targets["Multi-Market Target Layer<br/>market_target.v1 · market-evolution.v1"]
Evolution --> Relay["Position Netting Relay Handover<br/>retain min(P,C) · >80% turnover saved · K slices"]
Expand Down Expand Up @@ -100,6 +100,7 @@ Scans active incubating paper strategies hourly:
- **Benchmark-Relative Decay Detection**: Strategy performance change over 14 days minus benchmark buy-and-hold change prevents broader market drops from falsely flagging strategy decay.
- **Position Netting Relay Service**: During strategy turnover, intersecting holdings $\min(P_i, C_i)$ are preserved in place. Only net differences $\Delta = C - P$ are sliced and executed over $K$ periods with 100-share constraints, slashing turnover friction by >80%.
- **Effectiveness Ledger & Feishu Alerts**: Full ledger accounting with automatic Feishu webhook card alerts for stalled data gaps and error streaks.
- **Variant Supply for Every Parent (opt-in)**: once a paper parent has run 7 complete days it can receive variants before full decay evidence exists; exploration missions can use a deterministic one-step parameter-neighbour researcher, and a candidate that fails the strict final gate may still race if it is not clearly worse than its parent on the same window. Succession still requires winning the forward race.

Design docs: [63 Evolution Hardening](docs/architecture/63-evolution-effectiveness-alerts-and-benchmark.md) · [66 Smooth Relay Handover](docs/architecture/66-quantlab-adapter-a-share-microstructure-and-relay-handover.md)

Expand Down
3 changes: 2 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ flowchart LR
API --> DB[("PostgreSQL + pgvector<br/>50 项迁移 · Mission 投影 · 演进树 · 记忆库")]
Worker["Worker 循环 (13 个 Loop)<br/>Mission · AVO 研究 · 进化扫描 · 自动评审 · 元学习"] --> DB
Worker --> Research["ARC 自主研究循环 (AVO + RD-Agent)<br/>HET 假设树 → Co-STEER 合成 → AST 门禁 → 沙箱自测 → 基线对比"]
Worker --> Evolution["自主进化引擎(默认开启)<br/>退化扫描 → 预算准入 → 再研究 → 赛马对决 → 效果账本"]
Worker --> Evolution["自主进化引擎(默认开启)<br/>退化 / 上线扫描 → 预算准入 → 再研究或参数微调 → 入场检查 → 赛马对决 → 效果账本"]
Research --> Sandbox["隔离策略沙箱<br/>UDS · 非 root · 无网络 · digest 绑定"]
Research --> Targets["多市场目标适配层<br/>market_target.v1 · market-evolution.v1"]
Evolution --> Relay["持仓净额平滑换仓 (Relay Netting)<br/>交集保留 min(P,C) · 换手节约 >80% · K 期切片"]
Expand Down Expand Up @@ -100,6 +100,7 @@ flowchart LR
- **基准相对退化判定**:前后各 7 天策略表现扣除标的同期买入持有变化,大盘普跌不冒充策略退化;
- **持仓净额对冲平滑接力 (Position Netting Relay)**:赛马优胜换仓时,`PositionNettingRelayService` 计算母子策略持仓交集并原地保留共有份额 $\min(P_i, C_i)$,仅对净额差量 $\Delta = C - P$ 切分为 $K$ 期平滑切片执行,重合资产换手节约率超 80%,极大减少摩擦滑点;
- **效果账本与告警**:汇总周期、任务、证据与胜出率;数据缺口停摆与严重报错自动触发告警,直连飞书 Webhook 交互卡片。
- **每个母体都有变体(可选开启)**:模拟盘母体运行满 7 天,即使退化证据不全也能生成变体;探索任务可交给确定性的一步参数微调研究员;严格终验未过、但同窗不明显差于母体的候选也能进入赛马。接班仍须在前向赛马中胜出。

设计文档:[63 进化加固](docs/architecture/63-evolution-effectiveness-alerts-and-benchmark.md) · [66 赛马平滑换仓](docs/architecture/66-quantlab-adapter-a-share-microstructure-and-relay-handover.md)

Expand Down
185 changes: 185 additions & 0 deletions backend/src/hypertrade/arc/admission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Basic admission for evolution candidates.

The forward Paper race is the real exam; this gate only keeps clearly broken or clearly
worse candidates out of it. Thresholds are fixed in code so a config edit cannot quietly
loosen them. The strict final result is still recorded truthfully; admission is a separate,
explicit decision bound to that receipt.
"""

from __future__ import annotations

import hashlib
import json
from decimal import ROUND_CEILING, Decimal
from typing import Any

from hypertrade.arc.contracts import ARCGoalV1
from hypertrade.arc.feedback import compare_backtests
from hypertrade.arc.self_test import _fraction, _number

BASIC_ADMISSION_VERSION = "basic_admission.v1"
MIN_RETURN_TOLERANCE = Decimal("0.02") # candidate may trail the parent by 2 pp ...
DRAWDOWN_SHARE_TOLERANCE = Decimal("0.5") # ... or by half of the parent's own max drawdown
MAX_EXTRA_DRAWDOWN = Decimal("0.05") # drawdown at most 5 pp worse than the parent
MAX_DRAWDOWN_CAP = Decimal("0.30") # and never above 30%
MIN_TRADE_SHARE = Decimal("0.3") # at least 30% of the parent's trade count
MIN_TRADES = 1 # and at least one trade


def basic_admission(
metrics: dict[str, Any], baseline_receipt: dict[str, Any]
) -> dict[str, Any]:
"""Judge a failed final candidate against its same-window parent; pure and deterministic."""
comparison = compare_backtests(metrics, baseline_receipt)
baseline_backtest_id = comparison.get("backtest_id")
checks: dict[str, Any] = {
"baseline_backtest_id": None
if baseline_backtest_id is None
else str(baseline_backtest_id)
}
if "reason" in comparison or not comparison.get("backtest_id"):
return {
"version": BASIC_ADMISSION_VERSION,
"passed": False,
"reasons": ["baseline_evidence_invalid"],
"checks": checks,
}

parent = baseline_receipt.get("metrics")
parent_metrics = parent if isinstance(parent, dict) else {}
candidate_return = _fraction(
metrics,
fractions=("net_return", "out_of_sample_return", "total_return"),
percentages=("total_return_pct",),
)
parent_return = _fraction(
parent_metrics,
fractions=("net_return", "out_of_sample_return", "total_return"),
percentages=("total_return_pct",),
)
candidate_drawdown = _fraction(
metrics,
fractions=("max_drawdown", "out_of_sample_max_drawdown", "drawdown"),
percentages=("max_drawdown_pct",),
)
parent_drawdown = _fraction(
parent_metrics,
fractions=("max_drawdown", "out_of_sample_max_drawdown", "drawdown"),
percentages=("max_drawdown_pct",),
)
candidate_trades = _number(metrics, "trades", "out_of_sample_trades", "trade_count")
parent_trades = _number(parent_metrics, "trades", "out_of_sample_trades", "trade_count")

# Absolute drawdowns; the sign convention upstream must never change the verdict.
if candidate_drawdown is not None:
candidate_drawdown = abs(candidate_drawdown)
if parent_drawdown is not None:
parent_drawdown = abs(parent_drawdown)

values = {
"candidate_return": candidate_return,
"parent_return": parent_return,
"candidate_drawdown": candidate_drawdown,
"parent_drawdown": parent_drawdown,
"candidate_trades": candidate_trades,
"parent_trades": parent_trades,
}
for name, value in values.items():
if value is not None:
checks[name] = str(value)

reasons: list[str] = []
if candidate_return is None:
reasons.append("metric_missing:candidate_return")
if parent_return is None:
reasons.append("metric_missing:parent_return")
if candidate_drawdown is None:
reasons.append("metric_missing:candidate_drawdown")
if parent_drawdown is None:
reasons.append("metric_missing:parent_drawdown")
if candidate_trades is None:
reasons.append("metric_missing:candidate_trades")
if parent_trades is None:
reasons.append("metric_missing:parent_trades")
if reasons:
return {
"version": BASIC_ADMISSION_VERSION,
"passed": False,
"reasons": reasons,
"checks": checks,
}

assert candidate_return is not None
assert parent_return is not None
assert candidate_drawdown is not None
assert parent_drawdown is not None
assert candidate_trades is not None
assert parent_trades is not None

candidate_return_d = Decimal(str(candidate_return))
parent_return_d = Decimal(str(parent_return))
candidate_drawdown_d = Decimal(str(candidate_drawdown))
parent_drawdown_d = Decimal(str(parent_drawdown))
candidate_trades_d = Decimal(str(candidate_trades))
parent_trades_d = Decimal(str(parent_trades))

required_trades = max(
MIN_TRADES,
int((MIN_TRADE_SHARE * parent_trades_d).to_integral_value(rounding=ROUND_CEILING)),
)
return_tolerance = max(MIN_RETURN_TOLERANCE, DRAWDOWN_SHARE_TOLERANCE * parent_drawdown_d)
drawdown_limit = min(MAX_DRAWDOWN_CAP, parent_drawdown_d + MAX_EXTRA_DRAWDOWN)

checks["return_tolerance"] = str(return_tolerance)
checks["drawdown_limit"] = str(drawdown_limit)
checks["required_trades"] = str(required_trades)

if candidate_trades_d < required_trades:
reasons.append("too_few_trades")
if candidate_return_d < parent_return_d - return_tolerance:
reasons.append("return_shortfall")
if candidate_drawdown_d > drawdown_limit:
reasons.append("drawdown_too_large")

return {
"version": BASIC_ADMISSION_VERSION,
"passed": not reasons,
"reasons": reasons,
"checks": checks,
}


def basic_validation_id(receipt: dict[str, Any]) -> str:
"""Content-bound id for a basic-admitted final receipt."""
payload = {
"version": BASIC_ADMISSION_VERSION,
"attempt_id": receipt.get("attempt_id"),
"backtest_id": receipt.get("backtest_id"),
"code_sha256": receipt.get("code_sha256"),
"bitpro_strategy_id": receipt.get("bitpro_strategy_id"),
"admission": receipt.get("admission"),
}
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, default=str).encode()
).hexdigest()
return "val_basic_" + digest[:24]


def basic_admitted(goal: ARCGoalV1 | None, receipt: dict[str, Any]) -> bool:
"""True only for a receipt this task itself admitted under the basic policy."""
if goal is None or goal.paper_admission != "basic":
return False
if receipt.get("purpose") != "final":
return False
if receipt.get("admitted") is not True:
return False
if receipt.get("passed") is True:
return False
admission = receipt.get("admission")
if not isinstance(admission, dict):
return False
if admission.get("version") != BASIC_ADMISSION_VERSION:
return False
if admission.get("passed") is not True:
return False
return receipt.get("validation_id") == basic_validation_id(receipt)
67 changes: 40 additions & 27 deletions backend/src/hypertrade/arc/auto_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from decimal import Decimal, InvalidOperation
from typing import Any

from hypertrade.arc.admission import basic_admission, basic_admitted
from hypertrade.arc.controller import ARCController
from hypertrade.arc.evolution import EvolutionConfig
from hypertrade.arc.evolution_models import EvolutionControl
Expand Down Expand Up @@ -49,38 +50,47 @@ def evaluate_auto_review(controller: ARCController, config: EvolutionConfig) ->
),
{},
)
admitted = basic_admitted(goal, proof)
if (
not proof
or proof.get("passed") is not True
or (proof.get("passed") is not True and not admitted)
or proof.get("validation_id") != attempt.validation_id
or proof.get("code_sha256") != hashlib.sha256(attempt.strategy_code.encode()).hexdigest()
or str(proof.get("backtest_id")) != str(attempt.bitpro_backtest_id)
):
reasons.append("missing_version_bound_final_receipt")
metrics = proof.get("metrics") or {}
checked = enforce_selection_gate(
controller,
attempt,
SelfTestResult(
True,
attempt.validation_id,
attempt.bitpro_strategy_id,
attempt.bitpro_backtest_id,
metrics=metrics,
),
)
reasons.extend(checked.reasons)
metrics = checked.metrics
passed, failed = apply_success_criteria(
metrics, goal.success_criteria, require_selection_evidence=True
)
if not passed:
reasons.extend(failed)
policy_passed, policy_failed = apply_success_criteria(
metrics, config.paper_criteria, require_selection_evidence=True
)
if not policy_passed:
reasons.extend("policy: " + reason for reason in policy_failed)
if admitted:
# Re-derive admission from the stored numbers rather than trusting the receipt's flag.
recheck = basic_admission(metrics, projection.avo.get("baseline_final", {}))
if not recheck["passed"]:
reasons.extend("basic: " + reason for reason in recheck["reasons"])
if recheck != proof.get("admission"):
reasons.append("basic_admission_receipt_mismatch")
else:
checked = enforce_selection_gate(
controller,
attempt,
SelfTestResult(
True,
attempt.validation_id,
attempt.bitpro_strategy_id,
attempt.bitpro_backtest_id,
metrics=metrics,
),
)
reasons.extend(checked.reasons)
metrics = checked.metrics
passed, failed = apply_success_criteria(
metrics, goal.success_criteria, require_selection_evidence=True
)
if not passed:
reasons.extend(failed)
policy_passed, policy_failed = apply_success_criteria(
metrics, config.paper_criteria, require_selection_evidence=True
)
if not policy_passed:
reasons.extend("policy: " + reason for reason in policy_failed)
if config.paper_criteria.required_validation_policy != "arc_windowed_v1":
reasons.append("unsupported_automatic_validation_policy")
window = metrics.get("evaluation_window") or {}
Expand All @@ -91,18 +101,18 @@ def evaluate_auto_review(controller: ARCController, config: EvolutionConfig) ->
or [window.get("start_date"), window.get("end_date")] != [str(d) for d in dates]
):
reasons.append("final_window_unverified")
if goal.evolution_context or goal.feedback_parent:
if (goal.evolution_context or goal.feedback_parent) and not admitted:
comparison = compare_backtests(metrics, projection.avo.get("baseline_final", {}))
if not comparison["passed"] or not comparison.get("backtest_id"):
reasons.append("baseline_comparison_failed")
if Decimal(package["paper_configuration"]["initial_equity"]) > config.paper_capital:
reasons.append("automatic_paper_capital_limit")
if goal.live_allowed:
reasons.append("paper_only_policy")
return {
result: dict[str, Any] = {
"decision": (
"block"
if metrics["selection_bias"]["status"] == "unknown"
if (not admitted and metrics["selection_bias"]["status"] == "unknown")
else "reject"
if reasons
else "approve"
Expand All @@ -112,6 +122,9 @@ def evaluate_auto_review(controller: ARCController, config: EvolutionConfig) ->
"policy": "autonomous_paper_referee_v1",
"live_authorized": False,
}
if admitted:
result["admission"] = "basic"
return result


def configured_review_mode(db: Database) -> Any:
Expand Down
Loading