From b74f90bdd538c044d7a7327a282c4eeb466f7066 Mon Sep 17 00:00:00 2001 From: shadowell Date: Fri, 23 Oct 2026 12:32:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(arc):=20=E5=9F=BA=E6=9C=AC=E5=85=A5?= =?UTF-8?q?=E5=9C=BA=E2=80=94=E2=80=94=E4=B8=A5=E6=A0=BC=E7=BB=88=E9=AA=8C?= =?UTF-8?q?=E6=9C=AA=E8=BF=87=E4=BD=86=E4=B8=8D=E6=98=8E=E6=98=BE=E5=B7=AE?= =?UTF-8?q?=E4=BA=8E=E6=AF=8D=E4=BD=93=E7=9A=84=E5=80=99=E9=80=89=E5=8F=AF?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=20Paper=20=E8=B5=9B=E9=A9=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 paper_admission(默认 strict,配置与任务目标各一份,任务创建时冻结)。 设为 basic 时,终验未过严格门槛(成功标准、去偏夏普、基线改善)的候选, 若在同一终验窗口对母体满足基本检查(至少一笔且不少于母体三成笔数; 收益落后不超过 max(2 个百分点, 母体回撤的一半);回撤不超过母体 +5 个百分点 且不超过 30%),以内容绑定的 val_basic_ 编号进入既有 Paper 评审路径。 严格结果如实记录(passed=false 与原因保留),接班规则在 BitPro 不变。 评审包只在基本入场时增加 admission 段,严格任务的包与哈希不变; 自动评审对基本入场按已存数字重算,不信任标记;崩溃恢复同样识别基本入场。 13 处守卫变异检验全部被用例抓住。 Co-Authored-By: Claude Opus 5.5 --- backend/src/hypertrade/arc/admission.py | 185 +++++++ backend/src/hypertrade/arc/auto_review.py | 67 ++- backend/src/hypertrade/arc/avo.py | 31 +- backend/src/hypertrade/arc/contracts.py | 5 + backend/src/hypertrade/arc/evolution.py | 4 + backend/src/hypertrade/arc/paper_review.py | 19 +- tests/test_arc_basic_admission.py | 587 +++++++++++++++++++++ 7 files changed, 868 insertions(+), 30 deletions(-) create mode 100644 backend/src/hypertrade/arc/admission.py create mode 100644 tests/test_arc_basic_admission.py diff --git a/backend/src/hypertrade/arc/admission.py b/backend/src/hypertrade/arc/admission.py new file mode 100644 index 0000000..06a75f1 --- /dev/null +++ b/backend/src/hypertrade/arc/admission.py @@ -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) diff --git a/backend/src/hypertrade/arc/auto_review.py b/backend/src/hypertrade/arc/auto_review.py index 0eb173b..730de66 100644 --- a/backend/src/hypertrade/arc/auto_review.py +++ b/backend/src/hypertrade/arc/auto_review.py @@ -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 @@ -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 {} @@ -91,7 +101,7 @@ 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") @@ -99,10 +109,10 @@ def evaluate_auto_review(controller: ARCController, config: EvolutionConfig) -> 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" @@ -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: diff --git a/backend/src/hypertrade/arc/avo.py b/backend/src/hypertrade/arc/avo.py index 36fd0f6..6fd83d8 100644 --- a/backend/src/hypertrade/arc/avo.py +++ b/backend/src/hypertrade/arc/avo.py @@ -16,6 +16,11 @@ from jsonschema import ValidationError, validate from hypertrade.agent.compaction import ContextBlocked, compact_request, sanitize_context +from hypertrade.arc.admission import ( + basic_admission, + basic_admitted, + basic_validation_id, +) from hypertrade.arc.adversarial import BlueTeamQuant from hypertrade.arc.backtest_recovery import BacktestActionRunner, BacktestDeferred, recoverable from hypertrade.arc.contracts import SIDE_EFFECT_FREE_TOOLS, ARCCandidateAttemptV1 @@ -862,9 +867,33 @@ def charged(kind: str, attempt_id: str) -> bool: "avo_development_result", {"attempt_id": candidate.attempt_id, "result": payload} ) return payload + if final and not result.passed and goal.paper_admission == "basic" and comparison_context: + admission = basic_admission( + result.metrics, + controller.projection.avo.get("baseline_final") or {}, + ) + payload["admission"] = admission + if admission["passed"]: + payload["admitted"] = True + # The id is bound after admission and admitted are in the receipt. + payload["validation_id"] = basic_validation_id(payload) controller.apply_event("bitpro_self_tested", payload) if result.passed: request_paper_review(controller) + elif payload.get("admitted") is True: + # Basic admission: strict gate failed (recorded above); the parent comparison + # did not show it clearly worse, so the Paper race decides. Succession is unchanged. + controller.apply_event( + "candidate_validated", + { + "attempt_id": candidate.attempt_id, + "validation_id": payload["validation_id"], + "bitpro_strategy_id": payload.get("bitpro_strategy_id"), + "backtest_id": payload.get("backtest_id"), + "admission": "basic", + }, + ) + request_paper_review(controller) else: controller.apply_event("operator_needed", {"reason": "avo_final_validation_failed"}) # Final numbers go to human evidence, never to another model research turn. @@ -1395,7 +1424,7 @@ def _recover_receipt(controller: ARCController) -> None: return pending = controller.projection.avo["pending"] if pending["name"] == "finish": - if result.get("passed"): + if result.get("passed") or basic_admitted(controller.projection.goal, result): if controller.projection.state != "paper_review_ready": controller.apply_event("candidate_validated", result) request_paper_review(controller) diff --git a/backend/src/hypertrade/arc/contracts.py b/backend/src/hypertrade/arc/contracts.py index 396dfe2..28e1e94 100644 --- a/backend/src/hypertrade/arc/contracts.py +++ b/backend/src/hypertrade/arc/contracts.py @@ -148,6 +148,11 @@ class ARCGoalV1(BaseModel): # always enables version-bound review for new work. paper_review_required: bool = False paper_review_mode: Literal["human", "agent"] = "human" + # How a final candidate reaches Paper review. "strict" (default; every historical + # mission) requires the full final gate. "basic" also admits a candidate that fails it + # but passes arc.admission.basic_admission against its same-window parent. Frozen at + # creation, so a later config change never re-judges a running mission. + paper_admission: Literal["strict", "basic"] = "strict" research_id: str | None = None paper_initial_equity: Decimal = Field(default=Decimal("100"), gt=0, le=10000) # Evidence-window provenance consent. The gate refuses to spend candidate budget on diff --git a/backend/src/hypertrade/arc/evolution.py b/backend/src/hypertrade/arc/evolution.py index 7b47b8d..03ab270 100644 --- a/backend/src/hypertrade/arc/evolution.py +++ b/backend/src/hypertrade/arc/evolution.py @@ -71,6 +71,9 @@ class EvolutionConfig(BaseModel): max_research_per_day: int = Field(default=4, ge=1, le=100) max_research_total: int | None = Field(default=None, ge=1) paper_review_mode: Literal["human", "agent"] = "human" + # "basic" lets a candidate that fails the strict final gate reach Paper review when + # it is not clearly worse than its same-window parent; "strict" keeps the full gate. + paper_admission: Literal["strict", "basic"] = "strict" # Provider for loop-created research; tasks freeze it at creation. research_provider: ChatProviderName = "codex" paper_criteria: ARCSuccessCriteriaV1 = Field( @@ -446,6 +449,7 @@ def tick(self, now: datetime | None = None) -> dict[str, Any]: provider_name=config.research_provider, paper_review_required=True, paper_review_mode=config.paper_review_mode, + paper_admission=config.paper_admission, paper_initial_equity=research_capital, evolution_context=context, research_windows=ResearchWindowsV1.model_validate(context["research_windows"]), diff --git a/backend/src/hypertrade/arc/paper_review.py b/backend/src/hypertrade/arc/paper_review.py index 8cba5f0..513d9b4 100644 --- a/backend/src/hypertrade/arc/paper_review.py +++ b/backend/src/hypertrade/arc/paper_review.py @@ -6,6 +6,7 @@ import json from typing import TYPE_CHECKING, Any +from hypertrade.arc.admission import basic_admitted from hypertrade.arc.contracts import PaperPreauthorizationV1 from hypertrade.arc.incubation import ARCPaperIncubationResolver from hypertrade.arc.universe import candidate_symbols @@ -78,6 +79,8 @@ def build_paper_review( if goal is not None and goal.research_windows is not None: binding["research_windows"] = goal.research_windows.model_dump(mode="json") if goal is not None and goal.evolution_context: + admitted = False + receipt: dict[str, Any] = {} if selected is not None: receipt = next( ( @@ -87,8 +90,9 @@ def build_paper_review( ), {}, ) + admitted = basic_admitted(goal, receipt) if ( - receipt.get("passed") is not True + (receipt.get("passed") is not True and not admitted) or receipt.get("purpose") != "final" or receipt.get("terminal_failure") or receipt.get("backtest_id") != selected.bitpro_backtest_id @@ -116,8 +120,19 @@ def build_paper_review( binding["evolution_source"]["context_sha256"] = hashlib.sha256( json.dumps(context, sort_keys=True, ensure_ascii=False).encode() ).hexdigest() + if admitted: + # Strict missions never add this key: their review package hash is unchanged. + binding["admission"] = { + "policy": "basic", + "strict_passed": False, + "strict_reasons": list(receipt.get("reasons") or []), + **receipt["admission"], + } comparison = (selected.observed_metrics if selected else {}).get("baseline_comparison", {}) - if comparison.get("passed") is not True or not comparison.get("backtest_id"): + if admitted: + if not comparison.get("backtest_id") or comparison.get("reason"): + unknowns.append("missing_or_failed_baseline_comparison") + elif comparison.get("passed") is not True or not comparison.get("backtest_id"): unknowns.append("missing_or_failed_baseline_comparison") if goal is not None and goal.feedback_parent: binding["feedback_parent"] = goal.feedback_parent diff --git a/tests/test_arc_basic_admission.py b/tests/test_arc_basic_admission.py new file mode 100644 index 0000000..b7d909a --- /dev/null +++ b/tests/test_arc_basic_admission.py @@ -0,0 +1,587 @@ +from __future__ import annotations + +import copy +import hashlib +import json +from typing import Any + +import pytest +from hypertrade.arc.admission import ( + BASIC_ADMISSION_VERSION, + basic_admission, + basic_admitted, + basic_validation_id, +) +from hypertrade.arc.auto_review import evaluate_auto_review +from hypertrade.arc.avo import _perform, _recover_receipt +from hypertrade.arc.contracts import ( + ARCBudgetV1, + ARCCandidateAttemptV1, + ARCGoalV1, + ARCSuccessCriteriaV1, +) +from hypertrade.arc.controller import ARCController +from hypertrade.arc.evolution import EvolutionConfig +from hypertrade.arc.paper_review import build_paper_review, request_paper_review +from hypertrade.arc.self_test import ARCSelfTestService +from hypertrade.arc.store import reset_store, save_mission +from test_source_variant_avo import HYPOTHESIS, MockSelfTestClient + +WINDOW = { + "purpose": "final", + "start_date": "2026-01-01", + "end_date": "2026-03-01", + "research_id": "baseline-research", +} + + +def _metrics(**overrides: Any) -> dict[str, Any]: + values: dict[str, Any] = { + "evaluation_window": dict(WINDOW), + "net_return": 0.10, + "max_drawdown": 0.05, + "trades": 40, + } + values.update(overrides) + return values + + +def _baseline(**overrides: Any) -> dict[str, Any]: + return {"backtest_id": "bt-base", "metrics": _metrics(**overrides)} + + +def _build_mission() -> ARCController: + variant_policy = { + "contract_version": "strategy_research_variant_policy.v1", + "parent_strategy_id": 107, + "parent_manifest_sha256": "a" * 64, + "parent_execution_identity_sha256": "e" * 64, + "variant_creation_supported": True, + "authorized_parameters": [ + { + "name": "fast_window", + "type": "int", + "minimum": 5, + "maximum": 50, + "current_value": 10, + }, + ], + } + baseline_attempt = ARCCandidateAttemptV1( + attempt_id="baseline_107", + candidate_id="baseline_107", + hypothesis="原策略基线", + strategy_code="class NativeStrategy107: pass\n", + strategy_spec={ + "symbol": "BTC-USDT", + "timeframe": "1H", + "is_source_variant": True, + "parent_strategy_id": 107, + "parent_manifest_sha256": "a" * 64, + "parent_execution_identity_sha256": "e" * 64, + "tunable_parameters": {"fast_window": 10}, + "baseline_config": {"fast_window": 10}, + }, + ) + ctrl = ARCController( + goal=ARCGoalV1( + objective="对107号策略进行参数调优", + symbols=["BTC-USDT"], + timeframes=["1H"], + paper_review_required=True, + research_mode="avo", + budget=ARCBudgetV1(max_candidates=5, max_backtests=10), + success_criteria=ARCSuccessCriteriaV1(required_validation_policy="arc_windowed_v1"), + evolution_context={ + "source_strategy_id": 107, + "paper_feedback": {"status": "observed"}, + "variant_policy": variant_policy, + "baseline": baseline_attempt.model_dump(mode="json"), + }, + ) + ) + save_mission(ctrl) + return ctrl + + +@pytest.fixture +def mission(): + reset_store() + ctrl = _build_mission() + yield ctrl + reset_store() + + +def _window(ctrl: ARCController) -> dict[str, Any]: + goal = ctrl.projection.goal + assert goal is not None and goal.research_windows is not None + start, end = goal.research_windows.window("final") + return { + "purpose": "final", + "start_date": start.isoformat(), + "end_date": end.isoformat(), + "research_id": goal.research_id, + } + + +class CandidateClient(MockSelfTestClient): + """Baseline and candidate differ only in their final metrics.""" + + def __init__(self, candidate: dict[str, Any], baseline: dict[str, Any]) -> None: + super().__init__() + self._candidate = candidate + self._baseline = baseline + self._baseline_ids: set[int] = set() + + def strategy_research_variant_create(self, *, purpose: str = "candidate", **kwargs: Any): + created = super().strategy_research_variant_create(purpose=purpose, **kwargs) + if purpose == "baseline": + self._baseline_ids.add(int(created["candidate_strategy_id"])) + return created + + def backtest_start_job(self, **kwargs: Any) -> dict[str, Any]: + result = super().backtest_start_job(**kwargs) + strategy_id = int(kwargs["strategy_id"]) + result["metrics"] = dict( + self._baseline if strategy_id in self._baseline_ids else self._candidate + ) + result["job"] = { + "status": "completed", + "strategy_input_snapshot": { + "runtime_sha256": "a" * 64, + "cost_policy_hash": "b" * 64, + "initial_capital": kwargs["initial_capital"], + "symbols": ["BTC-USDT"], + "timeframes": [kwargs["timeframe"]], + "start_date": kwargs["start_date"], + "end_date": kwargs["end_date"], + }, + "verified_data_binding": { + "version": "verified_backtest_data.v3", + "entries": [{"verified_snapshot_id": "vbs", "manifest_sha256": "d" * 64}], + }, + } + return result + + +def _candidate_metrics(**overrides: Any) -> dict[str, Any]: + values = {"net_return": 0.185, "sharpe": 1.8, "max_drawdown": 0.05, "trades": 40} + values.update(overrides) + return values + + +def _baseline_metrics(**overrides: Any) -> dict[str, Any]: + values = {"net_return": 0.20, "sharpe": 1.8, "max_drawdown": 0.05, "trades": 40} + values.update(overrides) + return values + + +def _propose(ctrl: ARCController, service: ARCSelfTestService) -> str: + return str( + _perform( + ctrl, + "propose", + { + "hypothesis": "提高快线周期至15以过滤假突破", + "evolution_hypothesis": HYPOTHESIS, + "parameter_changes": {"fast_window": 15}, + }, + service, + )["attempt_id"] + ) + + +def _run_to_finish( + ctrl: ARCController, + *, + candidate: dict[str, Any] | None = None, + baseline: dict[str, Any] | None = None, +) -> tuple[str, ARCSelfTestService]: + client = CandidateClient(candidate or _candidate_metrics(), baseline or _baseline_metrics()) + service = ARCSelfTestService(client=client) + if "started_at" not in ctrl.projection.avo: + ctrl.apply_event("avo_initialized", {"messages": []}) + attempt_id = _propose(ctrl, service) + _perform(ctrl, "develop", {"attempt_id": attempt_id}, service) + _perform(ctrl, "finish", {"attempt_id": attempt_id}, service) + return attempt_id, service + + +def _attempt(ctrl: ARCController, attempt_id: str) -> ARCCandidateAttemptV1: + return next(a for a in ctrl.projection.attempts if a.attempt_id == attempt_id) + + +# --------------------------------------------------------------------------- +# basic_admission unit tests +# --------------------------------------------------------------------------- + + +def test_basic_admission_admits_a_candidate_within_two_points(): + result = basic_admission(_metrics(net_return=0.09), _baseline()) + assert result["version"] == BASIC_ADMISSION_VERSION + assert result["passed"] is True + assert result["reasons"] == [] + assert result["checks"]["return_tolerance"] == "0.025" + + +def test_basic_admission_rejects_a_shortfall_beyond_tolerance(): + result = basic_admission(_metrics(net_return=0.05), _baseline()) + assert result["passed"] is False + assert result["reasons"] == ["return_shortfall"] + + +def test_basic_admission_tolerance_grows_with_half_the_parent_drawdown(): + baseline = _baseline(max_drawdown=0.10) + within = basic_admission(_metrics(net_return=0.06, max_drawdown=0.10), baseline) + beyond = basic_admission(_metrics(net_return=0.04, max_drawdown=0.10), baseline) + assert within["passed"] is True + assert within["checks"]["return_tolerance"] == "0.05" + assert beyond["passed"] is False + assert beyond["reasons"] == ["return_shortfall"] + + +def test_basic_admission_rejects_drawdown_more_than_five_points_worse(): + result = basic_admission(_metrics(max_drawdown=0.16), _baseline(max_drawdown=0.10)) + assert result["passed"] is False + assert result["reasons"] == ["drawdown_too_large"] + assert result["checks"]["drawdown_limit"] == "0.15" + + +def test_basic_admission_caps_drawdown_at_thirty_percent_even_for_a_worse_parent(): + result = basic_admission(_metrics(max_drawdown=0.31), _baseline(max_drawdown=0.35)) + assert result["passed"] is False + assert result["reasons"] == ["drawdown_too_large"] + assert result["checks"]["drawdown_limit"] == "0.30" + + +def test_basic_admission_rejects_zero_or_too_few_trades(): + zero = basic_admission(_metrics(trades=0), _baseline(trades=40)) + few = basic_admission(_metrics(trades=11), _baseline(trades=40)) + assert zero["reasons"] == ["too_few_trades"] + assert few["reasons"] == ["too_few_trades"] + assert few["checks"]["required_trades"] == "12" + + +def test_basic_admission_reads_percentage_spellings(): + candidate = { + "evaluation_window": dict(WINDOW), + "total_return_pct": 9.0, + "max_drawdown_pct": 5.0, + "trade_count": 40, + } + baseline = { + "backtest_id": "bt-base", + "metrics": { + "evaluation_window": dict(WINDOW), + "total_return_pct": 10.0, + "max_drawdown_pct": 5.0, + "trade_count": 40, + }, + } + result = basic_admission(candidate, baseline) + assert result["passed"] is True + assert result["checks"]["candidate_return"] == "0.09" + + +def test_basic_admission_reports_a_missing_metric(): + candidate = _metrics() + del candidate["trades"] + result = basic_admission(candidate, _baseline()) + assert result["passed"] is False + assert result["reasons"] == ["metric_missing:candidate_trades"] + + parent = _baseline() + del parent["metrics"]["trades"] + result = basic_admission(_metrics(), parent) + assert result["reasons"] == ["metric_missing:parent_trades"] + + +def test_basic_admission_rejects_incomparable_windows(): + candidate = _metrics(evaluation_window={**WINDOW, "end_date": "2026-04-01"}) + result = basic_admission(candidate, _baseline()) + assert result["passed"] is False + assert result["reasons"] == ["baseline_evidence_invalid"] + + +def test_basic_admission_is_deterministic(): + candidate, baseline = _metrics(net_return=0.06, max_drawdown=0.10), _baseline( + max_drawdown=0.10 + ) + assert basic_admission(candidate, baseline) == basic_admission(candidate, baseline) + + +def test_basic_validation_id_is_content_bound(): + receipt = { + "attempt_id": "att_1", + "backtest_id": "bt_1", + "code_sha256": "a" * 64, + "bitpro_strategy_id": "9003", + "admission": {"version": BASIC_ADMISSION_VERSION, "passed": True}, + } + assert basic_validation_id(receipt).startswith("val_basic_") + assert basic_validation_id(receipt) != basic_validation_id({**receipt, "backtest_id": "bt_2"}) + + +# --------------------------------------------------------------------------- +# AVO flow +# --------------------------------------------------------------------------- + + +def test_basic_admission_lets_a_slightly_worse_candidate_reach_paper_review(mission): + mission.projection.goal.paper_admission = "basic" + attempt_id, _ = _run_to_finish(mission) + + record = mission.projection.self_test_records[-1] + assert record["attempt_id"] == attempt_id + assert record["passed"] is False + assert "baseline_not_improved" in record["reasons"] + assert record["admitted"] is True + assert record["admission"]["passed"] is True + assert record["validation_id"].startswith("val_basic_") + assert basic_admitted(mission.projection.goal, record) is True + assert _attempt(mission, attempt_id).state == "validated" + assert mission.projection.state == "paper_review_ready" + package = build_paper_review(mission.projection) + assert package["unknowns"] == [] + assert package["admission"]["policy"] == "basic" + + +def test_strict_admission_keeps_the_failed_candidate_with_the_operator(mission): + assert mission.projection.goal.paper_admission == "strict" + attempt_id, _ = _run_to_finish(mission) + + record = mission.projection.self_test_records[-1] + assert record["passed"] is False + assert "admitted" not in record + assert "admission" not in record + assert _attempt(mission, attempt_id).state != "validated" + assert mission.projection.state == "needs_operator" + assert mission.projection.events[-1].payload["reason"] == "avo_final_validation_failed" + assert "admission" not in build_paper_review(mission.projection) + + +def test_basic_admission_still_rejects_a_clearly_worse_candidate(mission): + mission.projection.goal.paper_admission = "basic" + attempt_id, _ = _run_to_finish( + mission, + candidate=_candidate_metrics(net_return=0.10), + baseline=_baseline_metrics(net_return=0.20), + ) + + record = mission.projection.self_test_records[-1] + assert record["passed"] is False + assert record["admission"]["passed"] is False + assert "return_shortfall" in record["admission"]["reasons"] + assert "admitted" not in record + assert _attempt(mission, attempt_id).state != "validated" + assert mission.projection.state == "needs_operator" + + +def test_strict_review_package_keys_are_unchanged(mission): + attempt_id = _propose(mission, ARCSelfTestService(client=CandidateClient({}, {}))) + attempt = _attempt(mission, attempt_id) + window = _window(mission) + metrics = { + "evaluation_window": window, + "net_return": 0.20, + "max_drawdown": 0.05, + "trades": 40, + "selection_bias": {"status": "observed"}, + "baseline_comparison": {"passed": True, "backtest_id": "bt-base"}, + } + mission.apply_event( + "bitpro_self_tested", + { + "attempt_id": attempt_id, + "purpose": "final", + "passed": True, + "reasons": [], + "bitpro_strategy_id": "9003", + "backtest_id": "bt-candidate", + "validation_id": "val_arc_strict", + "code_sha256": hashlib.sha256(attempt.strategy_code.encode()).hexdigest(), + "metrics": metrics, + }, + ) + request_paper_review(mission) + package = build_paper_review(mission.projection) + assert package["status"] == "ready" + assert "admission" not in package + assert set(package) == { + "schema_version", + "mission_id", + "attempt_id", + "strategy", + "backtest", + "criteria", + "feedback_policy", + "paper_configuration", + "research_windows", + "evolution_source", + "package_hash", + "status", + "unknowns", + "kind", + "recommendation", + } + binding = { + key: value + for key, value in package.items() + if key not in {"package_hash", "status", "unknowns", "kind", "recommendation"} + } + assert package["package_hash"] == hashlib.sha256( + json.dumps(binding, sort_keys=True, ensure_ascii=False, allow_nan=False).encode() + ).hexdigest() + + +def test_auto_review_approves_a_basic_admitted_mission_and_rechecks_the_numbers(mission): + mission.projection.goal.paper_admission = "basic" + _run_to_finish(mission) + + evaluation = evaluate_auto_review( + mission, EvolutionConfig(enabled=True, paper_review_mode="agent") + ) + assert evaluation["decision"] == "approve" + assert evaluation["admission"] == "basic" + assert evaluation["reasons"] == [] + + # The stored strict result is still False; tampering its numbers must fail the recheck. + mission.projection.self_test_records[-1]["metrics"]["net_return"] = -0.5 + tampered = evaluate_auto_review( + mission, EvolutionConfig(enabled=True, paper_review_mode="agent") + ) + assert tampered["decision"] == "reject" + assert tampered["admission"] == "basic" + assert any( + reason.startswith("basic:") or reason == "basic_admission_receipt_mismatch" + for reason in tampered["reasons"] + ) + + +def test_recovery_finishes_an_admitted_receipt_without_repeating_the_backtest(mission): + mission.projection.goal.paper_admission = "basic" + service = ARCSelfTestService(client=CandidateClient(_candidate_metrics(), _baseline_metrics())) + attempt_id = _propose(mission, service) + attempt = _attempt(mission, attempt_id) + mission.apply_event( + "avo_final_requested", {"attempt_id": attempt_id, "tool_call_id": "call-recover"} + ) + + window = _window(mission) + candidate_metrics = { + "evaluation_window": window, + "net_return": 0.185, + "max_drawdown": 0.05, + "trades": 40, + "baseline_comparison": {"passed": False, "backtest_id": "bt-base"}, + } + baseline_receipt = { + "backtest_id": "bt-base", + "metrics": { + "evaluation_window": window, + "net_return": 0.20, + "max_drawdown": 0.05, + "trades": 40, + }, + } + admission = basic_admission(candidate_metrics, baseline_receipt) + assert admission["passed"] is True + receipt = { + "attempt_id": attempt_id, + "tool_call_id": "call-recover", + "purpose": "final", + "passed": False, + "reasons": ["baseline_not_improved"], + "bitpro_strategy_id": "9003", + "backtest_id": "bt-candidate", + "code_sha256": hashlib.sha256(attempt.strategy_code.encode()).hexdigest(), + "metrics": candidate_metrics, + "admission": admission, + "admitted": True, + } + receipt["validation_id"] = basic_validation_id(receipt) + mission.apply_event("bitpro_self_tested", receipt) + mission.projection.avo["baseline_final"] = baseline_receipt + mission.projection.avo["pending"] = { + "kind": "tool", + "id": "call-recover", + "name": "finish", + "arguments": {"attempt_id": attempt_id}, + } + + _recover_receipt(mission) + + assert _attempt(mission, attempt_id).state == "validated" + assert mission.projection.avo["pending"] is None + assert mission.projection.state == "paper_review_ready" + assert build_paper_review(mission.projection)["admission"]["policy"] == "basic" + + +def test_basic_admitted_rejects_a_tampered_final_receipt(mission): + mission.projection.goal.paper_admission = "basic" + _run_to_finish(mission) + goal = mission.projection.goal + record = copy.deepcopy(mission.projection.self_test_records[-1]) + assert record["admitted"] is True + assert basic_admitted(goal, record) is True + + # (a) One number inside the admission evidence moves. + tampered_checks = copy.deepcopy(record) + tampered_checks["admission"]["checks"]["candidate_return"] = "0.0001" + assert tampered_checks["admission"]["checks"] != record["admission"]["checks"] + assert basic_admitted(goal, tampered_checks) is False + + # (b) A well-formed but different val_basic_... id. + tampered_id = copy.deepcopy(record) + tampered_id["validation_id"] = basic_validation_id( + {**record, "backtest_id": "bt-somewhere-else"} + ) + assert tampered_id["validation_id"].startswith("val_basic_") + assert tampered_id["validation_id"] != record["validation_id"] + assert basic_admitted(goal, tampered_id) is False + + # The review package must notice either tamper on the stored record. + for tampered in (tampered_checks, tampered_id): + mission.projection.self_test_records[-1] = copy.deepcopy(tampered) + package = build_paper_review(mission.projection) + assert "missing_matching_final_validation_receipt" in package["unknowns"] + assert package["status"] != "ready" + + +def test_basic_admitted_requires_the_basic_goal_policy(mission): + mission.projection.goal.paper_admission = "basic" + _run_to_finish(mission) + admitted = copy.deepcopy(mission.projection.self_test_records[-1]) + assert basic_admitted(mission.projection.goal, admitted) is True + + strict_goal = mission.projection.goal.model_copy(update={"paper_admission": "strict"}) + assert strict_goal.paper_admission == "strict" + assert basic_admitted(strict_goal, admitted) is False + + # Replaying that receipt into a strict mission must not produce a ready package. + assert _attempt(mission, admitted["attempt_id"]).state == "validated" + mission.projection.self_test_records[-1] = copy.deepcopy(admitted) + mission.projection.goal = strict_goal + package = build_paper_review(mission.projection) + assert package["status"] != "ready" + assert "admission" not in package + assert { + "missing_matching_final_validation_receipt", + "missing_or_failed_baseline_comparison", + } & set(package["unknowns"]) + + +def test_basic_review_package_omits_admission_when_basic_admission_fails(mission): + mission.projection.goal.paper_admission = "basic" + _run_to_finish( + mission, + candidate=_candidate_metrics(net_return=0.10), + baseline=_baseline_metrics(net_return=0.20), + ) + + record = mission.projection.self_test_records[-1] + assert record["admission"]["passed"] is False + assert "admitted" not in record + + package = build_paper_review(mission.projection) + assert "admission" not in package From f237e10b5e3761b99e5c86185acf74e12e349be8 Mon Sep 17 00:00:00 2001 From: shadowell Date: Fri, 23 Oct 2026 15:36:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(arc):=20=E6=AF=8D=E4=BD=93=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF=E6=BB=A1=20N=20=E5=A4=A9=E5=8D=B3=E5=8F=AF=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=8F=98=E4=BD=93=EF=BC=8C=E6=8E=A2=E7=B4=A2=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=8F=AF=E7=94=A8=E5=8F=82=E6=95=B0=E5=BE=AE=E8=B0=83?= =?UTF-8?q?=E7=A0=94=E7=A9=B6=E5=91=98=EF=BC=8C=E5=A4=A7=E7=AF=AE=E5=AD=90?= =?UTF-8?q?=E7=A0=94=E7=A9=B6=E7=AA=97=E5=8F=A3=E9=80=82=E9=85=8D=E5=9B=9E?= =?UTF-8?q?=E6=B5=8B=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launch_enabled/launch_days(默认关、7 天):完整 7+7 证据(成交门槛、14 天、成本与采样 回执)缺失时,运行满 N 个完整 UTC 日且支持来源变体的 BitPro 母体以 launch 触发研究, 不声称退化;零成交母体也可研究;其他市场目标保持原样。readiness 对 launch 机会不再挂 成交与 14 天阻断;预算准入识别 launch(未开启时 launch_disabled)。 - exploration_provider(默认 research):launch/proactive 探索任务可交给 rule_tweak; 退化与紧急任务始终用 research_provider。周期记录 provider_name。 - exploration_cooldown_hours(默认 24):来源最近一次任务是 rule_tweak 时用较短冷却, 其余仍用 cooldown_hours;扫描排序、预算状态与准入一致。 - research_windows_for:按标的数与周期把开发/终验窗口压进 BitPro 封存回测上限 (单快照 100 万根、单标的 10 万根、固定 500 根预热);120 个标的 15m 的开发窗口 119→81 天,修复 443 每天 snapshot_range_exceeds_bound。 14 处守卫变异检验全部被用例抓住。 Co-Authored-By: Claude Opus 5.5 --- backend/src/hypertrade/arc/contracts.py | 3 + backend/src/hypertrade/arc/evolution.py | 214 +++++++-- .../hypertrade/arc/evolution_continuation.py | 8 +- backend/src/hypertrade/arc/research_budget.py | 19 +- tests/test_evolution_launch.py | 451 ++++++++++++++++++ 5 files changed, 640 insertions(+), 55 deletions(-) create mode 100644 tests/test_evolution_launch.py diff --git a/backend/src/hypertrade/arc/contracts.py b/backend/src/hypertrade/arc/contracts.py index 28e1e94..ae70d5a 100644 --- a/backend/src/hypertrade/arc/contracts.py +++ b/backend/src/hypertrade/arc/contracts.py @@ -12,6 +12,9 @@ # the model, and a pending call left by a crash is not an unresolved side effect. SIDE_EFFECT_FREE_TOOLS = frozenset({"inspect", "propose", "stop"}) ChatProviderName = Literal["deepseek", "openai", "codex", "qwen", "openrouter", "vide_coding"] +# Deterministic researcher for exploration missions: one-step parameter neighbours of the +# source, no model call. Not a ChatProviderName: it never reaches ProviderRuntime. +RULE_TWEAK_PROVIDER = "rule_tweak" class ARCSuccessCriteriaV1(BaseModel): diff --git a/backend/src/hypertrade/arc/evolution.py b/backend/src/hypertrade/arc/evolution.py index 03ab270..7d6ef41 100644 --- a/backend/src/hypertrade/arc/evolution.py +++ b/backend/src/hypertrade/arc/evolution.py @@ -7,7 +7,7 @@ import logging import re from collections.abc import Callable -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from decimal import Decimal, InvalidOperation from typing import Any, Literal from uuid import uuid4 @@ -17,6 +17,7 @@ from hypertrade.arc.attribution import attribution_report, collect_attribution from hypertrade.arc.contracts import ( + RULE_TWEAK_PROVIDER, ARCBudgetV1, ARCCandidateAttemptV1, ARCGoalV1, @@ -33,7 +34,7 @@ upstream_read_unavailable, ) from hypertrade.arc.evolution_models import EvolutionControl, EvolutionCycle -from hypertrade.arc.feedback import collect_windows +from hypertrade.arc.feedback import _timeframe_seconds, collect_windows from hypertrade.arc.forward_outcomes import ForwardOutcomeLedger from hypertrade.arc.relay_seats import relay_seats from hypertrade.arc.runtime_journal import failure_text, record_runtime_error @@ -60,6 +61,13 @@ class EvolutionConfig(BaseModel): model_config = ConfigDict(extra="forbid") enabled: bool = True proactive_enabled: bool = False + # Launch exploration: a parent with launch_days complete days may receive variants even + # when the full 7+7 evidence is missing. Off by default, so scans behave as before. + launch_enabled: bool = False + launch_days: int = Field(default=7, ge=3, le=14) + # Cheap deterministic researcher for launch/proactive exploration missions. + exploration_provider: Literal["research", "rule_tweak"] = "research" + exploration_cooldown_hours: int = Field(default=24, ge=1, le=720) emergency_enabled: bool = True observed_gap_fallback_enabled: bool = True emergency_days: int = Field(default=3, ge=3, le=5) @@ -118,6 +126,69 @@ def reject_boolean_strategy_ids(cls, value: Any) -> Any: auto_approve_paper: bool = False +LAUNCH_MEASUREMENT = "launch_exploration" + +# BitPro's verified-backtest contract: at most 1,000,000 bars per snapshot, 100,000 per +# symbol, plus a fixed 500-bar warmup. +BITPRO_MAX_TOTAL_BARS = 1_000_000 +BITPRO_MAX_BARS_PER_SYMBOL = 100_000 +BITPRO_WARMUP_BARS = 500 +MIN_RESEARCH_WINDOW_DAYS = 14 + + +def launch_observation( + config: EvolutionConfig, snapshot: dict[str, Any], now: datetime +) -> dict[str, Any] | None: + """Exploration evidence for a long-running parent whose full window is unavailable.""" + if not config.launch_enabled: + return None + session = snapshot.get("session") + started_at = session.get("started_at") if isinstance(session, dict) else None + if not isinstance(started_at, str) or not started_at.strip(): + return None + try: + started = datetime.fromisoformat(started_at.replace("Z", "+00:00")) + except ValueError: + return None + if started.tzinfo is None: + started = started.replace(tzinfo=UTC) + day_start = now.astimezone(UTC).replace(hour=0, minute=0, second=0, microsecond=0) + if day_start < started + timedelta(days=config.launch_days): + return None + return { + "triggered": False, + "measurement": LAUNCH_MEASUREMENT, + "launch_days": config.launch_days, + "session_started_at": started.isoformat(), + "session_complete_days": (day_start - started).days, + "trade_count": snapshot.get("trade_count"), + "receipts": [], + } + + +def research_windows_for( + symbol_count: int, timeframe: str, *, as_of: date +) -> ResearchWindowsV1: + """Fit research windows inside BitPro's sealed-backtest bounds.""" + default = ResearchWindowsV1(as_of=as_of) + seconds = _timeframe_seconds(timeframe) + if seconds is None or seconds >= 86400: + return default + bars_per_day = 86400 // seconds + per_symbol = min(BITPRO_MAX_BARS_PER_SYMBOL, BITPRO_MAX_TOTAL_BARS // max(1, symbol_count)) + # One extra bar covers the inclusive window boundary. + max_days = (per_symbol - BITPRO_WARMUP_BARS - 1) // bars_per_day + if max_days >= default.development_days and max_days >= default.validation_days: + return default + if max_days < MIN_RESEARCH_WINDOW_DAYS: + raise ValueError("research_window_exceeds_backtest_bounds") + return ResearchWindowsV1( + as_of=as_of, + development_days=min(default.development_days, max_days), + validation_days=min(default.validation_days, max_days), + ) + + def digest(value: Any) -> str: return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest() @@ -437,6 +508,15 @@ def tick(self, now: datetime | None = None) -> dict[str, Any]: research_capital = Decimal( str(context.get("research_capital") or config.paper_capital) ) + exploration = context["trigger_source"] in ( + "launch", + "proactive", + ) and not context.get("short_term_emergency") + provider_name = ( + RULE_TWEAK_PROVIDER + if exploration and config.exploration_provider == "rule_tweak" + else config.research_provider + ) goal = ARCGoalV1( objective=( "依据有来源的原Paper观察、历史成交样本和开发实验提出可证伪优化方向;" @@ -446,7 +526,7 @@ def tick(self, now: datetime | None = None) -> dict[str, Any]: symbols=declared_symbols(context["baseline"]["strategy_spec"]), timeframes=[context["baseline"]["strategy_spec"]["timeframe"]], research_mode="avo", - provider_name=config.research_provider, + provider_name=provider_name, paper_review_required=True, paper_review_mode=config.paper_review_mode, paper_admission=config.paper_admission, @@ -540,6 +620,7 @@ def tick(self, now: datetime | None = None) -> dict[str, Any]: db=self.db, ) payload["trigger_source"] = context["trigger_source"] + payload["provider_name"] = provider_name if not payload["budget"]["accepted"]: return self._save_cycle(cycle_id, "deferred", payload) payload["mission_id"] = mission_id @@ -628,7 +709,11 @@ def _scan( } ) chosen = None - from hypertrade.arc.research_budget import budget_status, source_key + from hypertrade.arc.research_budget import ( + budget_status, + source_cooldown_hours, + source_key, + ) budget: dict[str, Any] | None = None @@ -646,7 +731,7 @@ def rank(context: dict[str, Any]) -> tuple[Any, ...]: ) until = datetime.fromisoformat( source.get("cooldown_at", "1970-01-01T00:00:00+00:00") - ) + timedelta(hours=config.cooldown_hours) + ) + timedelta(hours=source_cooldown_hours(source, config)) blocked = source.get("busy", False) or now < until preferred = "degradation" # BitPro parents can race several challengers: among equally urgent sources, the one @@ -732,51 +817,62 @@ def rank(context: dict[str, Any]) -> tuple[Any, ...]: if emergency: feedback = short else: - trade_count = int(snapshot.get("trade_count") or 0) - timeframe = str(row.timeframe or snapshot.get("timeframe") or "1D").upper() - # 自适应不同 K 线周期的成交门槛 (低频日线/周线策略成交频率较低) - effective_min_trades = config.min_trades - if timeframe in ("1D", "1W", "1MONTH") and config.min_trades > 10: - effective_min_trades = max(5, config.min_trades // 3) - - if trade_count < effective_min_trades: - raise ValueError("成交样本不足") - benchmark_symbols = ( - target_symbols(snapshot, bitpro=profile.transport == "bitpro_mcp_v1") - or None - ) try: - feedback = collect_windows( - ports, - str(snapshot["instance_id"]), - str(sid), - end, - PaperFeedbackPolicyV1( - enabled=True, - threshold_pp=config.threshold_pp, - benchmark_relative=config.degradation_basis == "benchmark_relative", - ), - benchmark_symbols=benchmark_symbols, - timeframe=row.timeframe or None, - calendar=profile.calendar, + trade_count = int(snapshot.get("trade_count") or 0) + timeframe = str(row.timeframe or snapshot.get("timeframe") or "1D").upper() + # 自适应不同 K 线周期的成交门槛 (低频日线/周线策略成交频率较低) + effective_min_trades = config.min_trades + if timeframe in ("1D", "1W", "1MONTH") and config.min_trades > 10: + effective_min_trades = max(5, config.min_trades // 3) + + if trade_count < effective_min_trades: + raise ValueError("成交样本不足") + benchmark_symbols = ( + target_symbols(snapshot, bitpro=profile.transport == "bitpro_mcp_v1") + or None ) - except Exception: - if ( - not config.observed_gap_fallback_enabled - or profile.transport != "bitpro_mcp_v1" - ): + try: + feedback = collect_windows( + ports, + str(snapshot["instance_id"]), + str(sid), + end, + PaperFeedbackPolicyV1( + enabled=True, + threshold_pp=config.threshold_pp, + benchmark_relative=( + config.degradation_basis == "benchmark_relative" + ), + ), + benchmark_symbols=benchmark_symbols, + timeframe=row.timeframe or None, + calendar=profile.calendar, + ) + except Exception: + if ( + not config.observed_gap_fallback_enabled + or profile.transport != "bitpro_mcp_v1" + ): + raise + observed = collect_observed_long_window( + ports, + snapshot, + end, + min_trades=effective_min_trades, + threshold_pp=float(config.threshold_pp), + ) + if observed.get("status") != "observed": + raise + feedback = observed + diagnostic["long_horizon"] = observed + except Exception as exc: + if profile.transport != "bitpro_mcp_v1": raise - observed = collect_observed_long_window( - ports, - snapshot, - end, - min_trades=effective_min_trades, - threshold_pp=float(config.threshold_pp), - ) - if observed.get("status") != "observed": + launch: Any = launch_observation(config, snapshot, now) + if launch is None: raise - feedback = observed - diagnostic["long_horizon"] = observed + feedback = launch + diagnostic["launch_evidence_gap"] = failure_text(exc) diagnostic["window_receipt_hash"] = digest( { "baseline": feedback.get("baseline_receipt"), @@ -796,9 +892,16 @@ def rank(context: dict[str, Any]) -> tuple[Any, ...]: else {k: v for k, v in feedback.items() if k != "receipts"} ), ) - if not feedback["triggered"] and not config.proactive_enabled: + launch = feedback.get("measurement") == LAUNCH_MEASUREMENT + if not feedback["triggered"] and not launch and not config.proactive_enabled: continue - trigger = "degradation" if feedback["triggered"] else "proactive" + trigger = ( + "degradation" + if feedback["triggered"] + else "launch" + if launch + else "proactive" + ) diagnostic.update(status="opportunity", trigger_source=trigger) symbols = target_symbols(snapshot, bitpro=profile.transport == "bitpro_mcp_v1") if not symbols: @@ -831,7 +934,7 @@ def rank(context: dict[str, Any]) -> tuple[Any, ...]: "pnl": trade.pnl, } ) - if not fills: + if not fills and not launch: raise ValueError("当前模拟会话没有可读取的历史成交样本") timeframe = str(source_record.timeframe or row.timeframe or "") if not timeframe: @@ -896,6 +999,8 @@ def rank(context: dict[str, Any]) -> tuple[Any, ...]: raise ValueError("parent execution identity missing") if emergency and not is_source_variant: raise ValueError("short_emergency_requires_bound_variant_policy") + if launch and not is_source_variant: + raise ValueError("launch_requires_source_variant_policy") # BitPro binds a variant to its parent's capital; any other research # capital makes every bound backtest fail its capital receipt. research_capital = ( @@ -951,6 +1056,11 @@ def rank(context: dict[str, Any]) -> tuple[Any, ...]: if emergency else "收益下降或回撤扩大达到阈值;" if feedback["triggered"] + else ( + f"母体模拟盘已运行满{config.launch_days}天," + "按空出的变体位主动生成同窗比较候选,不声称退化;" + ) + if launch else "完整观察未达到退化阈值,主动探索可证伪方向;" ) + "用成交与研究记忆判断信号、退出和成本方面的改进方向。" @@ -1106,7 +1216,11 @@ def _memory(self, context: dict[str, Any], now: datetime) -> list[dict[str, Any] "development": receipt, } ) - windows = ResearchWindowsV1(as_of=now.astimezone(UTC).date() - timedelta(days=1)) + windows = research_windows_for( + len(scope_symbols), + context["baseline"]["strategy_spec"]["timeframe"], + as_of=now.astimezone(UTC).date() - timedelta(days=1), + ) memory, manifest = MemoryService(self.db).project_research( memory, symbol=sorted(scope_symbols)[0] if scope_symbols else "", diff --git a/backend/src/hypertrade/arc/evolution_continuation.py b/backend/src/hypertrade/arc/evolution_continuation.py index 4811d39..8d9c80f 100644 --- a/backend/src/hypertrade/arc/evolution_continuation.py +++ b/backend/src/hypertrade/arc/evolution_continuation.py @@ -229,6 +229,10 @@ def readiness( and diagnostic.get("candidate_id") is not None ) ) + launch_ready = ( + diagnostic.get("status") == "opportunity" + and diagnostic.get("trigger_source") == "launch" + ) is_organic = bool( snapshot.get("is_organic") or snapshot.get("source_type") in ("seed", "manual", "organic") @@ -245,7 +249,7 @@ def readiness( "condition": "original Paper must be running; do not restart or reconfigure", } ) - if snapshot and not short_ready and not self_healed: + if snapshot and not short_ready and not self_healed and not launch_ready: trades = snapshot.get("trade_count") try: observed = int(trades) if trades is not None and not isinstance(trades, bool) else None @@ -260,7 +264,7 @@ def readiness( "condition": "same-session trade_count >= required", } ) - if snapshot_read and not short_ready and not self_healed: + if snapshot_read and not short_ready and not self_healed and not launch_ready: try: if not start: raise ValueError("missing start") diff --git a/backend/src/hypertrade/arc/research_budget.py b/backend/src/hypertrade/arc/research_budget.py index afe8b43..d07a067 100644 --- a/backend/src/hypertrade/arc/research_budget.py +++ b/backend/src/hypertrade/arc/research_budget.py @@ -16,6 +16,7 @@ from sqlalchemy import select from hypertrade.arc import store +from hypertrade.arc.contracts import RULE_TWEAK_PROVIDER from hypertrade.arc.controller import ARCController from hypertrade.arc.evolution_models import EvolutionControl, EvolutionCycle from hypertrade.arc.feedback import _feedback_child_active, _pending_effect @@ -93,10 +94,17 @@ def _source_identity(goal: Any) -> tuple[str | None, str | None, int | str | Non and len(strategy_value) <= 128 ): strategy_id = strategy_value - trigger = trigger_value if trigger_value in {"degradation", "proactive"} else None + trigger = trigger_value if trigger_value in {"degradation", "proactive", "launch"} else None return target_id, source, strategy_id, trigger +def source_cooldown_hours(source: dict[str, Any], config: EvolutionConfig) -> int: + """A cheap rule_tweak mission may be followed sooner; anything else keeps cooldown_hours.""" + if source.get("last_provider") == RULE_TWEAK_PROVIDER: + return config.exploration_cooldown_hours + return config.cooldown_hours + + def usage(session: Session, config: EvolutionConfig, now: datetime) -> dict[str, Any]: start = now.astimezone(UTC).replace(hour=0, minute=0, second=0, microsecond=0) entries = { @@ -149,8 +157,11 @@ def usage(session: Session, config: EvolutionConfig, now: datetime) -> dict[str, "last_admitted_at": when.isoformat(), "busy": False, "cooldown_at": cooldown_at.isoformat(), + "last_provider": goal.provider_name, }, ) + if cooldown_at.isoformat() >= prior["cooldown_at"]: + prior["last_provider"] = goal.provider_name prior["cooldown_at"] = max(prior["cooldown_at"], cooldown_at.isoformat()) prior["last_admitted_at"] = max(prior["last_admitted_at"], when.isoformat()) prior["busy"] = prior["busy"] or busy @@ -194,7 +205,7 @@ def budget_status(db: Database, config: EvolutionConfig, now: datetime) -> dict[ state.update(reason=reason, next_run_at=next_run) for source in state["sources"].values(): until = datetime.fromisoformat(source["cooldown_at"]) + timedelta( - hours=config.cooldown_hours + hours=source_cooldown_hours(source, config) ) source["reason"] = ( "source_active" if source["busy"] else ("source_cooldown" if now < until else None) @@ -277,6 +288,8 @@ def admit( reason = "invalid_trigger_source" elif trigger == "proactive" and not config.proactive_enabled: reason = "proactive_disabled" + elif trigger == "launch" and not config.launch_enabled: + reason = "launch_disabled" elif config.strategy_ids and source_strategy_id not in config.strategy_ids: reason = "outside_strategy_scope" elif ( @@ -291,7 +304,7 @@ def admit( elif identity_key in state["sources"]: previous = state["sources"][identity_key] until = datetime.fromisoformat(previous["cooldown_at"]) + timedelta( - hours=config.cooldown_hours + hours=source_cooldown_hours(previous, config) ) if previous["busy"]: reason = "source_active" diff --git a/tests/test_evolution_launch.py b/tests/test_evolution_launch.py new file mode 100644 index 0000000..0188a16 --- /dev/null +++ b/tests/test_evolution_launch.py @@ -0,0 +1,451 @@ +from datetime import UTC, date, datetime, timedelta + +import pytest +from hypertrade.arc.contracts import ARCGoalV1 +from hypertrade.arc.controller import ARCController +from hypertrade.arc.evolution import EvolutionConfig, EvolutionService, research_windows_for +from hypertrade.arc.research_budget import admit, budget_status, source_cooldown_hours +from hypertrade.arc.store import ( + configure_store, + get_controller, + list_mission_ids, + reset_store, + save_mission, +) +from hypertrade.db import ArcMission, Database +from hypertrade.targets.bitpro import BITPRO_TARGET_PROFILE +from hypertrade.targets.registry import MarketTargetBinding +from test_arc_evolution import Paper, VariantPaper, prepare + + +@pytest.fixture +def service(): + db = Database("sqlite:///:memory:") + db.create_all() + configure_store(db) + yield EvolutionService(db) + reset_store() + +NOW = datetime(2026, 9, 12, 12, tzinfo=UTC) + + +class LaunchPaper(VariantPaper): + """Variant-capable parent whose session is old enough (or not) to launch.""" + + started_at = "2026-09-04T00:00:00Z" + trade_count = 2 + fills_enabled = True + + def paper_snapshot(self, **kwargs): + body = super().paper_snapshot(**kwargs) + body["session"] = {"started_at": self.started_at} + body["trade_count"] = self.trade_count + return body + + def strategy_trades(self, **kwargs): + return super().strategy_trades(**kwargs) if self.fills_enabled else [] + + +class LaunchPlainPaper(Paper): + """A parent without bound-variant support but with an old session.""" + + started_at = "2026-09-04T00:00:00Z" + trade_count = 2 + + def paper_snapshot(self, **kwargs): + body = super().paper_snapshot(**kwargs) + body["session"] = {"started_at": self.started_at} + body["trade_count"] = self.trade_count + return body + + +def evidence_unavailable(*args, **kwargs): + raise RuntimeError("evidence unavailable") + + +def launch_service(service, monkeypatch, *, client=None, **overrides): + service.client = client if client is not None else LaunchPaper() + service.client.capital = 100 + monkeypatch.setattr( + "hypertrade.arc.evolution.collect_windows", evidence_unavailable + ) + monkeypatch.setattr( + "hypertrade.arc.evolution.collect_observed_long_window", evidence_unavailable + ) + state = service.status() + service.configure( + EvolutionConfig.model_validate( + {**state["config"], "paper_capital": "10000", **overrides} + ), + revision=state["revision"], + actor="test", + ) + return NOW + + +def mission(service, now, *, provider, trigger="degradation", instance="paper-source"): + ctrl = ARCController( + goal=ARCGoalV1( + objective="cooldown probe", + provider_name=provider, + evolution_context={ + "source_instance_id": instance, + "source_strategy_id": 44, + "trigger_source": trigger, + }, + ) + ) + result = admit(ctrl, now=now, db=service.db) + assert result["accepted"], result + return ctrl + + +def end_mission(service, ctrl, ended): + live = get_controller(ctrl.mission_id) + live.projection.state = "failed" + save_mission(live) + with service.db.session() as session: + session.get(ArcMission, ctrl.mission_id).updated_at = ended + + +def test_launch_creates_research_when_evidence_is_missing(service, monkeypatch): + now = launch_service(service, monkeypatch, launch_enabled=True) + result = service.tick(now) + assert result["status"] == "research_created", result + diagnostic = result["payload"]["diagnostics"][0] + assert diagnostic["status"] == "opportunity" + assert diagnostic["trigger_source"] == "launch" + assert diagnostic["launch_evidence_gap"] + goal = get_controller(result["payload"]["mission_id"]).projection.goal + assert goal.evolution_context["trigger_source"] == "launch" + assert goal.evolution_context["paper_feedback"]["measurement"] == "launch_exploration" + codes = {blocker["code"] for blocker in diagnostic["continuation"]["blockers"]} + assert "minimum_trades" not in codes + assert "completed_utc_window" not in codes + + +def test_launch_is_off_by_default(service, monkeypatch): + now = launch_service(service, monkeypatch, launch_enabled=False) + result = service.tick(now) + assert result["status"] == "no_action" + assert result["payload"]["diagnostics"][0]["status"] == "unavailable" + assert list_mission_ids() == [] + + +def test_parent_younger_than_launch_days_is_not_launched(service, monkeypatch): + paper = LaunchPaper() + paper.started_at = "2026-09-07T00:00:00Z" + now = launch_service(service, monkeypatch, client=paper, launch_enabled=True) + result = service.tick(now) + assert result["status"] == "no_action" + assert result["payload"]["diagnostics"][0]["status"] == "unavailable" + assert list_mission_ids() == [] + + +def test_launch_requires_bound_source_variant_policy(service, monkeypatch): + now = launch_service( + service, monkeypatch, client=LaunchPlainPaper(), launch_enabled=True + ) + result = service.tick(now) + assert result["status"] == "no_action" + diagnostic = result["payload"]["diagnostics"][0] + assert diagnostic["status"] == "unavailable" + assert "launch_requires_source_variant_policy" in diagnostic["reason"] + assert list_mission_ids() == [] + + +def test_launch_parent_without_fills_is_still_researched(service, monkeypatch): + paper = LaunchPaper() + paper.fills_enabled = False + now = launch_service(service, monkeypatch, client=paper, launch_enabled=True) + result = service.tick(now) + assert result["status"] == "research_created", result + goal = get_controller(result["payload"]["mission_id"]).projection.goal + assert goal.evolution_context["orders"]["sample_count"] == 0 + + +def test_launch_uses_rule_tweak_when_configured(service, monkeypatch): + now = launch_service( + service, + monkeypatch, + launch_enabled=True, + exploration_provider="rule_tweak", + ) + result = service.tick(now) + assert result["status"] == "research_created", result + goal = get_controller(result["payload"]["mission_id"]).projection.goal + assert goal.provider_name == "rule_tweak" + assert result["payload"]["provider_name"] == "rule_tweak" + + +def test_proactive_exploration_uses_rule_tweak(service, monkeypatch): + now = prepare(service, monkeypatch) + monkeypatch.setattr( + "hypertrade.arc.evolution.collect_windows", + lambda *a, **k: { + "triggered": False, + "reasons": [], + "end_at": "2026-09-12T00:00:00Z", + }, + ) + state = service.status() + service.configure( + EvolutionConfig.model_validate( + {**state["config"], "proactive_enabled": True, "exploration_provider": "rule_tweak"} + ), + revision=state["revision"], + actor="test", + ) + result = service.tick(now) + assert result["status"] == "research_created", result + goal = get_controller(result["payload"]["mission_id"]).projection.goal + assert goal.evolution_context["trigger_source"] == "proactive" + assert goal.provider_name == "rule_tweak" + + +def test_degradation_keeps_the_configured_research_provider(service, monkeypatch): + now = prepare(service, monkeypatch) + state = service.status() + service.configure( + EvolutionConfig.model_validate( + {**state["config"], "exploration_provider": "rule_tweak"} + ), + revision=state["revision"], + actor="test", + ) + result = service.tick(now) + assert result["status"] == "research_created", result + goal = get_controller(result["payload"]["mission_id"]).projection.goal + assert goal.evolution_context["trigger_source"] == "degradation" + assert goal.provider_name == EvolutionConfig().research_provider + + +def test_launch_keeps_research_provider_when_exploration_is_not_rule_tweak( + service, monkeypatch +): + now = launch_service( + service, + monkeypatch, + launch_enabled=True, + exploration_provider="research", + ) + result = service.tick(now) + assert result["status"] == "research_created", result + goal = get_controller(result["payload"]["mission_id"]).projection.goal + assert goal.provider_name == EvolutionConfig().research_provider + + +def test_admit_refuses_launch_when_launch_is_disabled(service): + service.configure(EvolutionConfig(enabled=True), revision=0, actor="test") + ctrl = ARCController( + goal=ARCGoalV1( + objective="launch probe", + evolution_context={ + "source_instance_id": "paper-source", + "source_strategy_id": 44, + "trigger_source": "launch", + }, + ) + ) + result = admit(ctrl, now=NOW, db=service.db) + assert result["accepted"] is False + assert result["reason"] == "launch_disabled" + + +def test_rule_tweak_mission_uses_the_short_exploration_cooldown(service): + service.configure(EvolutionConfig(enabled=True), revision=0, actor="test") + first = mission(service, NOW - timedelta(hours=10), provider="rule_tweak") + end_mission(service, first, NOW - timedelta(hours=3)) + state = service.status() + service.configure( + EvolutionConfig.model_validate( + {**state["config"], "exploration_cooldown_hours": 2} + ), + revision=state["revision"], + actor="test", + ) + result = admit( + ARCController( + goal=ARCGoalV1( + objective="next", + provider_name="rule_tweak", + evolution_context={ + "source_instance_id": "paper-source", + "source_strategy_id": 44, + "trigger_source": "degradation", + }, + ) + ), + now=NOW, + db=service.db, + ) + assert result["accepted"] is True + + +def test_rule_tweak_mission_waits_for_the_default_cooldown(service): + service.configure(EvolutionConfig(enabled=True), revision=0, actor="test") + first = mission(service, NOW - timedelta(hours=10), provider="rule_tweak") + end_mission(service, first, NOW - timedelta(hours=3)) + result = admit( + ARCController( + goal=ARCGoalV1( + objective="next", + provider_name="rule_tweak", + evolution_context={ + "source_instance_id": "paper-source", + "source_strategy_id": 44, + "trigger_source": "degradation", + }, + ) + ), + now=NOW, + db=service.db, + ) + assert result["accepted"] is False + assert result["reason"] == "source_cooldown" + + +def test_other_providers_keep_the_configured_cooldown(service): + service.configure(EvolutionConfig(enabled=True), revision=0, actor="test") + first = mission(service, NOW - timedelta(hours=10), provider="codex") + end_mission(service, first, NOW - timedelta(hours=3)) + state = service.status() + service.configure( + EvolutionConfig.model_validate( + {**state["config"], "exploration_cooldown_hours": 2} + ), + revision=state["revision"], + actor="test", + ) + result = admit( + ARCController( + goal=ARCGoalV1( + objective="next", + provider_name="codex", + evolution_context={ + "source_instance_id": "paper-source", + "source_strategy_id": 44, + "trigger_source": "degradation", + }, + ) + ), + now=NOW, + db=service.db, + ) + assert result["accepted"] is False + assert result["reason"] == "source_cooldown" + + +def test_source_cooldown_hours_reads_the_latest_provider(): + config = EvolutionConfig(exploration_cooldown_hours=2) + assert source_cooldown_hours({"last_provider": "rule_tweak"}, config) == 2 + assert source_cooldown_hours({"last_provider": "codex"}, config) == config.cooldown_hours + assert source_cooldown_hours({}, config) == config.cooldown_hours + + +def test_research_windows_fit_bitpro_backtest_bounds(): + as_of = date(2026, 9, 11) + windows = research_windows_for(120, "15m", as_of=as_of) + assert windows.development_days == 81 + assert windows.validation_days == 60 + assert (81 * 96 + 501) * 120 <= 1_000_000 + + defaults = research_windows_for(1, "1h", as_of=as_of) + assert (defaults.development_days, defaults.validation_days) == (119, 60) + + daily = research_windows_for(1, "1D", as_of=as_of) + assert (daily.development_days, daily.validation_days) == (119, 60) + + unknown = research_windows_for(1, "3x", as_of=as_of) + assert (unknown.development_days, unknown.validation_days) == (119, 60) + + with pytest.raises(ValueError, match="research_window_exceeds_backtest_bounds"): + research_windows_for(2000, "1m", as_of=as_of) + + +def test_default_config_keeps_degradation_research(service, monkeypatch): + now = prepare(service, monkeypatch) + result = service.tick(now) + assert result["status"] == "research_created", result + goal = get_controller(result["payload"]["mission_id"]).projection.goal + assert goal.evolution_context["trigger_source"] == "degradation" + assert goal.provider_name == EvolutionConfig().research_provider + + +def test_launch_fallback_is_limited_to_the_bitpro_transport(service, monkeypatch): + """A non-BitPro target must surface the evidence failure instead of launching.""" + other_transport = BITPRO_TARGET_PROFILE.model_copy(update={"transport": "mcp_contract_v1"}) + monkeypatch.setattr( + "hypertrade.arc.evolution.get_market_target", + lambda target_id: MarketTargetBinding(profile=other_transport), + ) + paper = LaunchPaper() + # Enough trades to reach the evidence collector, so launch is the only fallback. + paper.trade_count = 40 + now = launch_service(service, monkeypatch, client=paper, launch_enabled=True) + result = service.tick(now) + assert result["status"] == "no_action", result + diagnostic = result["payload"]["diagnostics"][0] + assert diagnostic["status"] == "unavailable" + assert "launch_evidence_gap" not in diagnostic + assert list_mission_ids() == [] + + +def _launch_ctrl(): + return ARCController( + goal=ARCGoalV1( + objective="launch probe", + evolution_context={ + "source_instance_id": "paper-source", + "source_strategy_id": 44, + "trigger_source": "launch", + }, + ) + ) + + +def _two_finished_missions(service, *, older_provider, newer_provider): + """The same source finished twice; the newer mission is always the last one.""" + service.configure( + EvolutionConfig( + enabled=True, + launch_enabled=True, + exploration_cooldown_hours=1, + ), + revision=0, + actor="test", + ) + older = mission(service, NOW - timedelta(hours=30), provider=older_provider) + end_mission(service, older, NOW - timedelta(hours=27)) + newer = mission(service, NOW - timedelta(hours=3), provider=newer_provider) + end_mission(service, newer, NOW - timedelta(hours=3)) + state = service.status() + service.configure( + EvolutionConfig.model_validate( + {**state["config"], "launch_enabled": True, "exploration_cooldown_hours": 2} + ), + revision=state["revision"], + actor="test", + ) + return EvolutionConfig.model_validate(service.status()["config"]) + + +def test_source_cooldown_follows_the_latest_mission_provider(service): + config = _two_finished_missions( + service, older_provider="rule_tweak", newer_provider="deepseek" + ) + sources = budget_status(service.db, config, NOW)["sources"] + assert [source["last_provider"] for source in sources.values()] == ["deepseek"] + result = admit(_launch_ctrl(), now=NOW, db=service.db) + assert result["accepted"] is False + assert result["reason"] == "source_cooldown" + + +def test_rule_tweak_short_cooldown_applies_when_it_is_the_latest_provider(service): + config = _two_finished_missions( + service, older_provider="deepseek", newer_provider="rule_tweak" + ) + sources = budget_status(service.db, config, NOW)["sources"] + assert [source["last_provider"] for source in sources.values()] == ["rule_tweak"] + result = admit(_launch_ctrl(), now=NOW, db=service.db) + assert result["accepted"] is True, result From abb0db1969c07e2464c88121b99b782f6222d5b7 Mon Sep 17 00:00:00 2001 From: shadowell Date: Fri, 23 Oct 2026 15:52:56 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(arc):=20=E7=A1=AE=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=BE=AE=E8=B0=83=E7=A0=94=E7=A9=B6=E5=91=98?= =?UTF-8?q?=20rule=5Ftweak=20=E9=A9=B1=E5=8A=A8=E6=8E=A2=E7=B4=A2=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goal.provider_name == rule_tweak 的任务由 RuleTweakProvider 在 AVO 循环内驱动, 不调用模型:只提单参数约 ±20% 的一步邻居(整数保持 int、按授权范围截断), 完整镜像 BitPro validate_parameter_changes 的参数规则(8 对快慢周期严格递增、 动量出场阈值不超过入场阈值),因为变体创建时被 BitPro 拒绝会被记为未知效果并卡住任务; 跳过 BitPro 已赛过或排队的组合与本任务已提过的组合,按任务编号轮换起点; 开发后对有成交的候选中净收益最高者终验,否则以 rule_tweak: 开头的原因结束。 所有动作仍经同一套工具、预算、记账、终验与评审。11 处守卫变异检验全部被用例抓住。 Co-Authored-By: Claude Opus 5.5 --- backend/src/hypertrade/arc/avo.py | 12 +- backend/src/hypertrade/arc/rule_tweak.py | 303 ++++++++++++++++ tests/test_rule_tweak.py | 424 +++++++++++++++++++++++ 3 files changed, 737 insertions(+), 2 deletions(-) create mode 100644 backend/src/hypertrade/arc/rule_tweak.py create mode 100644 tests/test_rule_tweak.py diff --git a/backend/src/hypertrade/arc/avo.py b/backend/src/hypertrade/arc/avo.py index 6fd83d8..4a3c244 100644 --- a/backend/src/hypertrade/arc/avo.py +++ b/backend/src/hypertrade/arc/avo.py @@ -23,7 +23,11 @@ ) from hypertrade.arc.adversarial import BlueTeamQuant from hypertrade.arc.backtest_recovery import BacktestActionRunner, BacktestDeferred, recoverable -from hypertrade.arc.contracts import SIDE_EFFECT_FREE_TOOLS, ARCCandidateAttemptV1 +from hypertrade.arc.contracts import ( + RULE_TWEAK_PROVIDER, + SIDE_EFFECT_FREE_TOOLS, + ARCCandidateAttemptV1, +) from hypertrade.arc.controller import ARCController, ARCEventV1, ARCMissionProjection from hypertrade.arc.evolution_memory import ( assess_development, @@ -967,7 +971,7 @@ def run_avo_research( # shared with output, leaving >=100K output headroom and keeping the recovery # snapshot under its 2MB limit. Other providers keep the conservative default. _DEFAULT_CONTEXT_BUDGET = 64_000 -_PROVIDER_CONTEXT_BUDGET = {"deepseek": 900_000} +_PROVIDER_CONTEXT_BUDGET = {"deepseek": 900_000, RULE_TWEAK_PROVIDER: 900_000} def _context_budget(provider: Any) -> int: @@ -1119,6 +1123,10 @@ def _run( controller.apply_event("avo_model_abandoned", {"reason": "unacknowledged_model_call"}) if goal.model_name and goal.provider_name not in {"codex", "vide_coding"}: raise ResearchStopped("avo_provider_unavailable") + if provider is None and goal.provider_name == RULE_TWEAK_PROVIDER: + from hypertrade.arc.rule_tweak import RuleTweakProvider + + provider = RuleTweakProvider(controller) if provider is None: try: provider = ProviderRuntime(get_settings()).get_chat_provider( diff --git a/backend/src/hypertrade/arc/rule_tweak.py b/backend/src/hypertrade/arc/rule_tweak.py new file mode 100644 index 0000000..bfd32d3 --- /dev/null +++ b/backend/src/hypertrade/arc/rule_tweak.py @@ -0,0 +1,303 @@ +"""Deterministic one-step parameter neighbours for rule_tweak missions. + +A mission whose goal names `RULE_TWEAK_PROVIDER` is driven by this provider instead of a +model: it proposes single-parameter neighbours of the running source, develops them and +finishes the best one. It returns tool calls only, so every effect still runs through the +audited AVO loop and stays budgeted, journalled and reviewed like model research. +""" + +from __future__ import annotations + +import hashlib +import math +from typing import TYPE_CHECKING, Any + +from hypertrade.arc.contracts import RULE_TWEAK_PROVIDER +from hypertrade.arc.relay_seats import tried_variant +from hypertrade.arc.self_test import _fraction, _number +from hypertrade.providers.chat import ChatResponse, ToolCallRequest + +if TYPE_CHECKING: + from hypertrade.arc.controller import ARCController + +RULE_TWEAK_MODEL = "parameter-neighbours-v1" +STEP_FRACTION = 0.2 +ZERO_STEP_FRACTION = 0.1 +FINAL_RESERVE = 2 + +_NESTED_PREFIX = "research_parameters." +# Mirrors BitPro's `validate_parameter_changes` (contract strategy_research_variant_policy.v1). +# BitPro validates the *effective* value of every pair, so a neighbour that would break one of +# these rules is rejected at variant-creation time; the AVO loop would then record an unknown +# effect and stall the mission, so such a neighbour must never be proposed. +_ORDERING_PAIRS = ( + ("fast_window", "slow_window"), + ("fast_window", "mid_window"), + ("mid_window", "slow_window"), + ("trend_fast_period", "trend_slow_period"), + ("trend_fast_ema", "trend_slow_ema"), + ("mom_fast_window_bars", "mom_slow_window_bars"), + ("ret_fast_window_bars", "ret_slow_window_bars"), + ("research_parameters.fast_window", "research_parameters.slow_window"), +) +_MOMENTUM_EXIT_KEY = "momentum_exit_min_abs_pct" +_MOMENTUM_ENTER_KEY = "momentum_enter_min_abs_pct" + + +def _current_value(baseline_config: dict[str, Any], key: str) -> Any: + if key.startswith(_NESTED_PREFIX): + nested = baseline_config.get("research_parameters") + if not isinstance(nested, dict): + return None + return nested.get(key[len(_NESTED_PREFIX) :]) + return baseline_config.get(key) + + +def _finite_number(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def _effective( + baseline_config: dict[str, Any], changes: dict[str, int | float], key: str +) -> float | None: + if key in changes: + return _finite_number(changes[key]) + return _finite_number(_current_value(baseline_config, key)) + + +def _violates_bitpro_rules( + baseline_config: dict[str, Any], changes: dict[str, int | float] +) -> bool: + for first, second in _ORDERING_PAIRS: + first_value = _effective(baseline_config, changes, first) + second_value = _effective(baseline_config, changes, second) + if first_value is None or second_value is None: + continue + if first_value >= second_value: + return True + exit_threshold = _effective(baseline_config, changes, _MOMENTUM_EXIT_KEY) + enter_threshold = _effective(baseline_config, changes, _MOMENTUM_ENTER_KEY) + return ( + exit_threshold is not None + and enter_threshold is not None + and exit_threshold > enter_threshold + ) + + +def parameter_neighbours( + policy: dict[str, Any], baseline_config: dict[str, Any], *, seed: str +) -> list[dict[str, int | float]]: + """One-step parameter neighbours of the baseline, rotated by a deterministic seed.""" + parameters = policy.get("authorized_parameters") if isinstance(policy, dict) else None + if not isinstance(parameters, list) or not isinstance(baseline_config, dict): + return [] + ordered: list[dict[str, int | float]] = [] + entries = sorted( + (entry for entry in parameters if isinstance(entry, dict)), + key=lambda entry: str(entry.get("key") or entry.get("name") or ""), + ) + for entry in entries: + key = str(entry.get("key") or entry.get("name") or "") + if not key: + continue + current = _finite_number(_current_value(baseline_config, key)) + if current is None: + continue + minimum = _finite_number(entry.get("min", entry.get("minimum"))) + maximum = _finite_number(entry.get("max", entry.get("maximum"))) + integral = str(entry.get("type") or "").lower() in {"int", "integer"} + if integral: + step = float(max(1, round(abs(current) * STEP_FRACTION))) + elif current == 0: + if minimum is None or maximum is None: + continue + step = (maximum - minimum) * ZERO_STEP_FRACTION + else: + step = abs(current) * STEP_FRACTION + candidates: list[int | float] = [] + for offset in (current + step, current - step): + if minimum is not None: + offset = max(offset, minimum) + if maximum is not None: + offset = min(offset, maximum) + value: int | float = int(offset) if integral else round(offset, 10) + if value == current or value in candidates: + continue + candidates.append(value) + for value in candidates: + changes: dict[str, int | float] = {key: value} + if _violates_bitpro_rules(baseline_config, changes): + continue + if tried_variant(policy.get("relay"), changes, {}) is not None: + continue + ordered.append(changes) + if not ordered: + return [] + offset = int(hashlib.sha256(seed.encode()).hexdigest(), 16) % len(ordered) + return ordered[offset:] + ordered[:offset] + + +def _evidence_refs(context: dict[str, Any]) -> list[str]: + refs: list[str] = [] + report = context.get("attribution_report") + if isinstance(report, dict): + dimensions = report.get("dimensions") + report_id = report.get("report_id") + if isinstance(dimensions, dict) and dimensions and isinstance(report_id, str) and report_id: + refs.append(f"attribution:{report_id}:{sorted(dimensions)[0]}") + if context.get("paper_feedback"): + refs.append("paper_feedback") + else: + orders = context.get("orders") + sample_count = orders.get("sample_count") if isinstance(orders, dict) else None + if ( + isinstance(sample_count, (int, float)) + and not isinstance(sample_count, bool) + and sample_count > 0 + ): + refs.append("order_sample") + return refs + + +class RuleTweakProvider: + """Deterministic ChatProvider that only ever returns one AVO tool call.""" + + name = RULE_TWEAK_PROVIDER + model = RULE_TWEAK_MODEL + + def __init__(self, controller: ARCController) -> None: + self.controller = controller + + @staticmethod + def _call(projection: Any, name: str, arguments: dict[str, Any]) -> ChatResponse: + return ChatResponse( + content="", + tool_calls=[ + ToolCallRequest(f"rt_{len(projection.events)}_0", name, arguments), + ], + ) + + def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + ) -> ChatResponse: + # Never read `messages`: the decision is a function of committed mission state. + projection = self.controller.projection + goal = projection.goal + if goal is None: + return self._call(projection, "stop", {"reason": "rule_tweak: mission goal is missing"}) + budget = goal.budget + avo = projection.avo + development = avo.get("development") or {} + dev_left = budget.max_backtests - budget.backtests_used - FINAL_RESERVE + requested = [ + (event.payload.get("arguments") or {}).get("parameter_changes") + for event in projection.events + if event.event_type == "avo_tool_requested" + and event.payload.get("name") == "propose" + ] + finish_requested = any( + event.event_type == "avo_tool_requested" and event.payload.get("name") == "finish" + for event in projection.events + ) + context = goal.evolution_context or {} + policy = context.get("variant_policy") or {} + baseline_spec = (context.get("baseline") or {}).get("strategy_spec") or {} + baseline_config = baseline_spec.get("baseline_config") or {} + untried = [ + changes + for changes in parameter_neighbours( + policy, baseline_config, seed=projection.mission_id + ) + if changes not in requested + ] + + for attempt in projection.attempts: + if attempt.attempt_id not in development and dev_left >= 1: + return self._call(projection, "develop", {"attempt_id": attempt.attempt_id}) + + if budget.candidates_used < budget.max_candidates and dev_left >= 1 and untried: + changes = untried[0] + refs = _evidence_refs(context) + if not refs: + return self._call( + projection, + "stop", + {"reason": "rule_tweak: no citable evidence for a hypothesis"}, + ) + key, value = next(iter(changes.items())) + current = _current_value(baseline_config, key) + return self._call( + projection, + "propose", + { + "hypothesis": ( + f"规则微调:{key} 从 {current} 调到 {value}" + "(单参数一步,约20%),检验同窗净收益是否改善" + ), + "parameter_changes": changes, + "evolution_hypothesis": { + "evidence_refs": refs, + "expected_metric": "net_return", + "expected_direction": "increase", + "falsification": ( + "Refuted if the development and final backtests do not raise net " + "return versus the unchanged source on the same window." + ), + }, + }, + ) + + best = self._best_developed(projection, development) + if best is not None and not finish_requested: + return self._call(projection, "finish", {"attempt_id": best.attempt_id}) + if not untried: + reason = "rule_tweak: no untried one-step parameter neighbour is available" + else: + reason = "rule_tweak: no developed neighbour traded on the development window" + return self._call(projection, "stop", {"reason": reason}) + + @staticmethod + def _best_developed(projection: Any, development: dict[str, Any]) -> Any | None: + ranked: list[tuple[float, float, str]] = [] + for attempt in projection.attempts: + receipt = development.get(attempt.attempt_id) + if not isinstance(receipt, dict): + continue + if not receipt.get("backtest_id") or receipt.get("terminal_failure"): + continue + metrics = receipt.get("metrics") + if not isinstance(metrics, dict): + continue + trades = _number(metrics, "trades", "out_of_sample_trades", "trade_count") + if trades is None or trades <= 0: + continue + net_return = _fraction( + metrics, + fractions=("net_return", "out_of_sample_return", "total_return"), + percentages=("total_return_pct",), + ) + drawdown = _fraction( + metrics, + fractions=("max_drawdown", "out_of_sample_max_drawdown", "drawdown"), + percentages=("max_drawdown_pct",), + ) + ranked.append( + ( + -(net_return if net_return is not None else -math.inf), + abs(drawdown) if drawdown is not None else math.inf, + attempt.attempt_id, + ) + ) + if not ranked: + return None + ranked.sort() + best_id = ranked[0][2] + return next(attempt for attempt in projection.attempts if attempt.attempt_id == best_id) diff --git a/tests/test_rule_tweak.py b/tests/test_rule_tweak.py new file mode 100644 index 0000000..1a25870 --- /dev/null +++ b/tests/test_rule_tweak.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import pytest +from hypertrade.arc.contracts import RULE_TWEAK_PROVIDER +from hypertrade.arc.evolution_memory import bind_hypothesis, experiment_key +from hypertrade.arc.rule_tweak import RuleTweakProvider, parameter_neighbours +from hypertrade.arc.self_test import ARCSelfTestService +from test_source_variant_avo import MockSelfTestClient, source_variant_mission # noqa: F401 + + +def _policy(*parameters: dict[str, Any]) -> dict[str, Any]: + return {"authorized_parameters": list(parameters)} + + +def _param(key: str, kind: str, minimum: float, maximum: float) -> dict[str, Any]: + return {"key": key, "type": kind, "min": minimum, "max": maximum} + + +def _values(neighbours: list[dict[str, int | float]], key: str) -> list[int | float]: + return sorted(item[key] for item in neighbours) + + +def test_integer_parameter_moves_one_step_each_way() -> None: + neighbours = parameter_neighbours( + _policy(_param("fast_window", "integer", 0, 100)), + {"fast_window": 10}, + seed="seed", + ) + assert _values(neighbours, "fast_window") == [8, 12] + assert all(len(item) == 1 for item in neighbours) + + +def test_integer_step_is_at_least_one() -> None: + neighbours = parameter_neighbours( + _policy(_param("fast_window", "integer", 0, 10)), + {"fast_window": 2}, + seed="seed", + ) + assert _values(neighbours, "fast_window") == [1, 3] + + +def test_values_are_clamped_to_declared_bounds() -> None: + upper = parameter_neighbours( + _policy(_param("fast_window", "integer", 0, 50)), + {"fast_window": 50}, + seed="seed", + ) + assert _values(upper, "fast_window") == [40] + lower = parameter_neighbours( + _policy(_param("fast_window", "integer", 5, 50)), + {"fast_window": 5}, + seed="seed", + ) + assert _values(lower, "fast_window") == [6] + + +def test_number_parameter_uses_a_fraction_of_its_value() -> None: + neighbours = parameter_neighbours( + _policy(_param("threshold", "number", 0.0, 2.0)), + {"threshold": 0.5}, + seed="seed", + ) + assert _values(neighbours, "threshold") == [0.4, 0.6] + + +def test_zero_number_uses_a_fraction_of_the_allowed_range() -> None: + neighbours = parameter_neighbours( + _policy(_param("threshold", "number", -1.0, 1.0)), + {"threshold": 0}, + seed="seed", + ) + assert _values(neighbours, "threshold") == [-0.2, 0.2] + + +def test_nested_research_parameters_are_read() -> None: + neighbours = parameter_neighbours( + _policy(_param("research_parameters.threshold", "number", 0.0, 1.0)), + {"research_parameters": {"threshold": 0.02}}, + seed="seed", + ) + assert _values(neighbours, "research_parameters.threshold") == [0.016, 0.024] + + +def test_neighbour_that_breaks_window_order_is_dropped() -> None: + neighbours = parameter_neighbours( + _policy( + _param("fast_window", "integer", 1, 100), + _param("slow_window", "integer", 1, 100), + ), + {"fast_window": 18, "slow_window": 20}, + seed="seed", + ) + assert {"fast_window": 22} not in neighbours + assert {"slow_window": 16} not in neighbours + assert {"fast_window": 14} in neighbours + assert {"slow_window": 24} in neighbours + + +def test_neighbour_that_breaks_trend_period_order_is_dropped() -> None: + neighbours = parameter_neighbours( + _policy( + _param("trend_fast_period", "integer", 1, 100), + _param("trend_slow_period", "integer", 1, 100), + ), + {"trend_fast_period": 18, "trend_slow_period": 20}, + seed="seed", + ) + assert {"trend_fast_period": 22} not in neighbours + assert {"trend_slow_period": 16} not in neighbours + assert {"trend_fast_period": 14} in neighbours + assert {"trend_slow_period": 24} in neighbours + + +def test_neighbour_that_breaks_nested_window_order_is_dropped() -> None: + neighbours = parameter_neighbours( + _policy( + _param("research_parameters.fast_window", "integer", 1, 100), + _param("research_parameters.slow_window", "integer", 1, 100), + ), + {"research_parameters": {"fast_window": 18, "slow_window": 20}}, + seed="seed", + ) + assert {"research_parameters.fast_window": 22} not in neighbours + assert {"research_parameters.slow_window": 16} not in neighbours + assert {"research_parameters.fast_window": 14} in neighbours + assert {"research_parameters.slow_window": 24} in neighbours + + +def test_momentum_exit_must_not_exceed_entry_threshold() -> None: + neighbours = parameter_neighbours( + _policy( + _param("momentum_enter_min_abs_pct", "number", 0.0, 10.0), + _param("momentum_exit_min_abs_pct", "number", 0.0, 10.0), + ), + {"momentum_enter_min_abs_pct": 0.4, "momentum_exit_min_abs_pct": 0.38}, + seed="seed", + ) + # 0.38 -> 0.456 would exceed the unchanged 0.4 entry threshold. + assert {"momentum_exit_min_abs_pct": 0.456} not in neighbours + assert {"momentum_exit_min_abs_pct": 0.304} in neighbours + # Lowering entry to 0.32 would leave it below the unchanged 0.38 exit threshold. + assert {"momentum_enter_min_abs_pct": 0.32} not in neighbours + assert {"momentum_enter_min_abs_pct": 0.48} in neighbours + + +def test_variant_bitpro_already_tried_is_dropped() -> None: + policy = _policy(_param("fast_window", "integer", 0, 100)) + policy["relay"] = { + "tried_variants": [ + { + "candidate_strategy_id": 9001, + "state": "racing", + "parameter_changes": {"fast_window": 12}, + "structural_changes": {}, + } + ] + } + neighbours = parameter_neighbours(policy, {"fast_window": 10}, seed="seed") + assert _values(neighbours, "fast_window") == [8] + + +def test_rotation_is_deterministic_per_seed() -> None: + policy = _policy( + _param("fast_window", "integer", 0, 100), + _param("slow_window", "integer", 0, 200), + _param("threshold", "number", 0.0, 1.0), + ) + config = {"fast_window": 10, "slow_window": 30, "threshold": 0.02} + first = parameter_neighbours(policy, config, seed="seed-a") + assert parameter_neighbours(policy, config, seed="seed-a") == first + for seed in ("seed-b", "seed-c", "seed-d", "seed-e"): + if parameter_neighbours(policy, config, seed=seed) != first: + break + else: # pragma: no cover - hashing is stable, so a different seed must rotate + pytest.fail("different seeds never changed the rotation") + + +def test_missing_or_boolean_current_values_are_skipped() -> None: + neighbours = parameter_neighbours( + _policy( + _param("enabled", "integer", 0, 10), + _param("ghost", "integer", 0, 10), + _param("fast_window", "integer", 0, 100), + ), + {"enabled": True, "fast_window": 10}, + seed="seed", + ) + assert {key for item in neighbours for key in item} == {"fast_window"} + assert _values(neighbours, "fast_window") == [8, 12] + + +def _bitpro_policy(controller: Any) -> None: + controller.projection.goal.evolution_context["variant_policy"]["authorized_parameters"] = [ + {"key": "fast_window", "type": "integer", "min": 5, "max": 50}, + {"key": "slow_window", "type": "integer", "min": 20, "max": 200}, + {"key": "threshold", "type": "number", "min": 0.01, "max": 0.1}, + ] + + +def _tool_requests(controller: Any, name: str) -> list[dict[str, Any]]: + return [ + event.payload + for event in controller.projection.events + if event.event_type == "avo_tool_requested" and event.payload.get("name") == name + ] + + +def test_rule_tweak_mission_runs_end_to_end_without_a_provider( + source_variant_mission: Any, # noqa: F811 + monkeypatch: pytest.MonkeyPatch, +) -> None: + controller = source_variant_mission + goal = controller.projection.goal + goal.provider_name = RULE_TWEAK_PROVIDER + goal.model_name = None + goal.budget.max_candidates = 4 + _bitpro_policy(controller) + + class ExplodingRuntime: + def __init__(self, *args: Any, **kwargs: Any) -> None: + raise AssertionError("rule_tweak must never reach ProviderRuntime") + + monkeypatch.setattr("hypertrade.arc.avo.ProviderRuntime", ExplodingRuntime) + client = MockSelfTestClient() + from hypertrade.arc.avo import run_avo_research + + run_avo_research(controller.mission_id, experiments=ARCSelfTestService(client=client)) + + attempts = controller.projection.attempts + assert len(attempts) == 4 + for attempt in attempts: + assert len(attempt.strategy_spec["parameter_changes"]) == 1 + assert len(_tool_requests(controller, "finish")) == 1 + assert client.backtests_started + model_requests = [ + event + for event in controller.projection.events + if event.event_type == "avo_model_requested" + ] + assert model_requests + assert all(event.payload["provider"] == RULE_TWEAK_PROVIDER for event in model_requests) + + +def test_rejected_proposals_are_not_retried_and_the_mission_stops( + source_variant_mission: Any, # noqa: F811 +) -> None: + from hypertrade.arc.avo import run_avo_research + + controller = source_variant_mission + goal = controller.projection.goal + goal.provider_name = RULE_TWEAK_PROVIDER + goal.model_name = None + _bitpro_policy(controller) + context = goal.evolution_context + policy = context["variant_policy"] + baseline = context["baseline"] + baseline_config = baseline["strategy_spec"]["baseline_config"] + neighbours = parameter_neighbours(policy, baseline_config, seed=controller.mission_id) + assert neighbours + code_sha256 = hashlib.sha256(baseline["strategy_code"].encode()).hexdigest() + context["memory"] = [ + { + "memory_id": f"mem_archived_{index}", + "experiment_key": experiment_key( + code_sha256, + {**baseline["strategy_spec"], "parameter_changes": changes}, + goal.paper_initial_equity, + goal.research_windows, + ), + } + for index, changes in enumerate(neighbours) + ] + + run_avo_research( + controller.mission_id, experiments=ARCSelfTestService(client=MockSelfTestClient()) + ) + + proposals = _tool_requests(controller, "propose") + assert len(proposals) == len(neighbours) + seen = { + json.dumps(request["arguments"]["parameter_changes"], sort_keys=True) + for request in proposals + } + assert len(seen) == len(proposals) + assert controller.projection.attempts == [] + stopped = [ + event.payload + for event in controller.projection.events + if event.event_type == "operator_needed" + ] + assert stopped + assert stopped[-1]["reason"] == "avo_no_candidate" + assert stopped[-1]["message"].startswith("rule_tweak:") + + +def test_proposal_binds_an_attribution_report( + source_variant_mission: Any, # noqa: F811 +) -> None: + controller = source_variant_mission + context = controller.projection.goal.evolution_context + context.pop("paper_feedback") + context.pop("orders", None) + context["attribution_report"] = { + "report_id": "attr01", + "dimensions": { + "slippage": {"state": "observed"}, + "alpha": {"state": "observed"}, + }, + } + response = RuleTweakProvider(controller).chat([], None) + call = response.tool_calls[0] + assert call.name == "propose" + hypothesis = call.arguments["evolution_hypothesis"] + assert hypothesis["evidence_refs"] == ["attribution:attr01:alpha"] + assert bind_hypothesis(call.arguments, context, {}) + + +def test_proposal_binds_paper_feedback( + source_variant_mission: Any, # noqa: F811 +) -> None: + controller = source_variant_mission + context = controller.projection.goal.evolution_context + assert context["paper_feedback"] + response = RuleTweakProvider(controller).chat([], None) + call = response.tool_calls[0] + assert call.name == "propose" + assert call.arguments["evolution_hypothesis"]["evidence_refs"] == ["paper_feedback"] + assert bind_hypothesis(call.arguments, context, {}) + + +def test_integer_neighbours_are_python_ints_and_numbers_are_floats() -> None: + # BitPro rejects an exact-integral float such as 12.0 for an int parameter, so the + # emitted value must be a Python int, including when a step is clamped to the bound. + integral = parameter_neighbours( + _policy(_param("fast_window", "integer", 0, 11)), + {"fast_window": 10}, + seed="seed", + ) + integer_values = _values(integral, "fast_window") + assert integer_values == [8, 11] + assert all(type(value) is int for value in integer_values) + decimal = parameter_neighbours( + _policy(_param("threshold", "number", 0.0, 2.0)), + {"threshold": 0.5}, + seed="seed", + ) + assert all(type(value) is float for value in _values(decimal, "threshold")) + + +def _developed_attempts( + controller: Any, receipts: list[tuple[float, int]] +) -> list[str]: + """Create real variant attempts through `_perform`, then pin development receipts.""" + from hypertrade.arc.avo import _perform + + goal = controller.projection.goal + goal.provider_name = RULE_TWEAK_PROVIDER + goal.model_name = None + _bitpro_policy(controller) + experiments = ARCSelfTestService(client=MockSelfTestClient()) + attempt_ids: list[str] = [] + for offset in range(2, 2 + 2 * len(receipts), 2): + result = _perform( + controller, + "propose", + { + "hypothesis": f"规则微调:fast_window 从 10 调到 {10 + offset}", + "parameter_changes": {"fast_window": 10 + offset}, + "evolution_hypothesis": { + "evidence_refs": ["paper_feedback"], + "expected_metric": "net_return", + "expected_direction": "increase", + "falsification": ( + "Refuted if the development and final backtests do not raise net " + "return versus the unchanged source on the same window." + ), + }, + }, + experiments, + ) + attempt_ids.append(result["attempt_id"]) + # Exhaust the candidate budget so `chat` cannot propose or develop any more attempts. + goal.budget.max_candidates = len(attempt_ids) + development = controller.projection.avo.setdefault("development", {}) + for attempt_id, (net_return, trades) in zip(attempt_ids, receipts, strict=True): + development[attempt_id] = { + "attempt_id": attempt_id, + "backtest_id": f"bt_{attempt_id}", + "metrics": {"net_return": net_return, "trades": trades}, + } + return attempt_ids + + +def test_finish_goes_to_the_highest_development_net_return( + source_variant_mission: Any, # noqa: F811 +) -> None: + attempt_ids = _developed_attempts(source_variant_mission, [(0.02, 12), (0.08, 12)]) + call = RuleTweakProvider(source_variant_mission).chat([], None).tool_calls[0] + assert call.name == "finish" + assert call.arguments["attempt_id"] == attempt_ids[1] + + +def test_untraded_candidate_is_never_finished( + source_variant_mission: Any, # noqa: F811 +) -> None: + attempt_ids = _developed_attempts(source_variant_mission, [(0.02, 12), (0.08, 0)]) + call = RuleTweakProvider(source_variant_mission).chat([], None).tool_calls[0] + assert call.name == "finish" + assert call.arguments["attempt_id"] == attempt_ids[0] + + +def test_all_untraded_candidates_stop_with_the_no_trade_reason( + source_variant_mission: Any, # noqa: F811 +) -> None: + _developed_attempts(source_variant_mission, [(0.02, 0), (0.08, 0)]) + call = RuleTweakProvider(source_variant_mission).chat([], None).tool_calls[0] + assert call.name == "stop" + assert call.arguments["reason"] == ( + "rule_tweak: no developed neighbour traded on the development window" + ) From 9d735bc401962d9b1289fa113c1dd6c9fe44e549 Mon Sep 17 00:00:00 2001 From: shadowell Date: Fri, 23 Oct 2026 16:00:07 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF=E6=8E=A2=E7=B4=A2=E3=80=81=E5=8F=82=E6=95=B0=E5=BE=AE?= =?UTF-8?q?=E8=B0=83=E7=A0=94=E7=A9=B6=E5=91=98=E4=B8=8E=E5=9F=BA=E6=9C=AC?= =?UTF-8?q?=E5=85=A5=E5=9C=BA=EF=BC=88=E8=BF=9B=E5=BA=A6=E3=80=81=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=E4=B8=8E=E4=B8=89=E8=AF=AD=20README=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.en.md | 3 ++- README.md | 3 ++- README.zh-CN.md | 3 ++- docs/progress.md | 13 +++++++++++++ docs/spec.md | 9 +++++++++ 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/README.en.md b/README.en.md index 7127a5d..1a186e7 100644 --- a/README.en.md +++ b/README.en.md @@ -58,7 +58,7 @@ flowchart LR API --> DB[("PostgreSQL + pgvector
50 migrations · Mission projections · HET tree · Memory")] Worker["Worker Loops (13 Loops)
Mission · AVO research · evolution · auto-review · meta-tuning"] --> DB Worker --> Research["ARC Autonomous Research Loop (AVO + RD-Agent)
HET tree → Co-STEER synthesis → AST gatekeeper → sandbox → baseline"] - Worker --> Evolution["Evolution Engine (default on)
decay scan → budget admission → re-research → race duel → ledger"] + Worker --> Evolution["Evolution Engine (default on)
decay / launch scan → budget admission → re-research or rule tweak → admission → race duel → ledger"] Research --> Sandbox["Isolated Strategy Sandbox
UDS · non-root · no network · digest-bound"] Research --> Targets["Multi-Market Target Layer
market_target.v1 · market-evolution.v1"] Evolution --> Relay["Position Netting Relay Handover
retain min(P,C) · >80% turnover saved · K slices"] @@ -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) diff --git a/README.md b/README.md index 27248a1..f105785 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ flowchart LR API --> DB[("PostgreSQL + pgvector
50 migrations · Mission projections · HET tree · Memory")] Worker["Worker Loops (13 Loops)
Mission · AVO research · evolution · auto-review · meta-tuning"] --> DB Worker --> Research["ARC Autonomous Research Loop (AVO + RD-Agent)
HET tree → Co-STEER synthesis → AST gatekeeper → sandbox → baseline"] - Worker --> Evolution["Evolution Engine (default on)
decay scan → budget admission → re-research → race duel → ledger"] + Worker --> Evolution["Evolution Engine (default on)
decay / launch scan → budget admission → re-research or rule tweak → admission → race duel → ledger"] Research --> Sandbox["Isolated Strategy Sandbox
UDS · non-root · no network · digest-bound"] Research --> Targets["Multi-Market Target Layer
market_target.v1 · market-evolution.v1"] Evolution --> Relay["Position Netting Relay Handover
retain min(P,C) · >80% turnover saved · K slices"] @@ -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) diff --git a/README.zh-CN.md b/README.zh-CN.md index 20d2a2b..64d7599 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -58,7 +58,7 @@ flowchart LR API --> DB[("PostgreSQL + pgvector
50 项迁移 · Mission 投影 · 演进树 · 记忆库")] Worker["Worker 循环 (13 个 Loop)
Mission · AVO 研究 · 进化扫描 · 自动评审 · 元学习"] --> DB Worker --> Research["ARC 自主研究循环 (AVO + RD-Agent)
HET 假设树 → Co-STEER 合成 → AST 门禁 → 沙箱自测 → 基线对比"] - Worker --> Evolution["自主进化引擎(默认开启)
退化扫描 → 预算准入 → 再研究 → 赛马对决 → 效果账本"] + Worker --> Evolution["自主进化引擎(默认开启)
退化 / 上线扫描 → 预算准入 → 再研究或参数微调 → 入场检查 → 赛马对决 → 效果账本"] Research --> Sandbox["隔离策略沙箱
UDS · 非 root · 无网络 · digest 绑定"] Research --> Targets["多市场目标适配层
market_target.v1 · market-evolution.v1"] Evolution --> Relay["持仓净额平滑换仓 (Relay Netting)
交集保留 min(P,C) · 换手节约 >80% · K 期切片"] @@ -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) diff --git a/docs/progress.md b/docs/progress.md index 3e85e9b..0d78e84 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,3 +1,16 @@ +## 2026-10-23:母体上线满 7 天就生成变体、参数微调研究员与基本入场 + +- **背景**:BitPro 每个母体有 3 个变体位(BitPro #1286),但生产上这些位长期空着(系统统计 12 个位、占用 0)。只读核对生产:34 个在役模拟盘里只有 4 个满足研究报名条件,其余 30 个卡在同一会话成交满 30 笔、满 14 天以及成本元数据与权益采样核验上;这 4 个里 333 被挂起副作用占着,443 每天在开发回测报 `snapshot_range_exceeds_bound`(120 个标的 15m 的 119 天开发窗口超过 BitPro 封存回测 100 万根上限),509、513 的候选在终验同窗都比母体差。10-11 起 25 个候选 0 个通过严格终验(60 天窗口上要求去偏夏普概率 ≥0.95,约等于年化夏普 4 以上,且必须优于母体)。用户要求所有策略上线后或表现差时都能自动产生变体,并逐项拍板:母体满 7 天、满 20 笔后生成,7 天不到 20 笔的低频策略满 7 天也生成;变体上模拟盘前的考试改为基本检查,接班标准不降;变体来源为参数微调加 AI 研究;全站同时赛马上限保持 60。 +- **交付**(三项都由配置开关控制,默认关闭;代码合入本身不改变线上行为): + 1. 基本入场 `paper_admission`(strict|basic,任务创建时冻结):严格终验未过的候选,若在同一终验窗口对母体满足基本检查——成交至少 1 笔且不少于母体三成;收益落后不超过 max(2 个百分点, 母体最大回撤的一半);最大回撤不超过母体 +5 个百分点且不超过 30%——以内容绑定的 `val_basic_` 编号走原有 `candidate_validated` 与 Paper 评审路径。严格结果如实保留(`passed=false` 与原因),评审包只在基本入场时增加 `admission` 段(严格任务的包与哈希不变),自动评审按存档数字重算、不信任标记;崩溃恢复同样识别。状态机未改。 + 2. 上线探索 `launch_enabled`/`launch_days`(默认 7):7+7 证据缺失但会话已满 N 个完整 UTC 日、且支持来源变体的 BitPro 母体以 `launch` 触发研究,不声称退化,零成交母体也可研究;readiness 不再对其挂成交与 14 天阻断;预算准入识别 `launch`(未开启时 `launch_disabled`)。 + 3. 参数微调研究员 `exploration_provider=rule_tweak`:launch/proactive 探索任务由确定性的 `RuleTweakProvider` 驱动——只提单参数约 ±20% 的一步邻居,按授权范围、BitPro 全部参数规则(8 对快慢周期严格递增、动量出场阈值不超过入场阈值、整数类型)过滤,跳过 BitPro 已赛过或排队的组合,按任务编号轮换起点;开发后对成交过的候选中净收益最高者做终验,不调用模型,走与模型研究相同的工具、预算、记账与评审。退化与紧急任务始终用 `research_provider`。来源最近一次任务是 rule_tweak 时冷却用 `exploration_cooldown_hours`。 + 4. 研究窗口适配:开发与终验窗口按标的数与周期压进 BitPro 封存回测上限(单快照 100 万根、单标的 10 万根、固定 500 根预热);120 个标的 15m 的开发窗口 119→81 天,修复 443。 +- **验证**:新增 `tests/test_arc_basic_admission.py`、`tests/test_evolution_launch.py`、`tests/test_rule_tweak.py`。变异检验共 38 处守卫(基本入场 13、上线探索与冷却 14、参数微调 11),去掉任一守卫都有用例失败(首轮未被抓住的 8 处已补用例)。完整 `./scripts/check.sh` 通过(前端 lint/15 项测试/构建、ruff、mypy 320 个源文件、pytest 2369 项)。合并前未在生产实测。 +- **影响分析与风险**:GitNexus 把 `build_paper_review` 与 `ARCController._reduce` 评为 CRITICAL,`_scan`、`readiness`、`usage`、`admit` 评为 HIGH,`_perform`、`evaluate_auto_review` 评为 LOW。应对:状态机不改(复用已有 `candidate_validated` 事件);评审包只在任务明确为 basic 时多一段;所有新路径由默认关闭的配置开关控制。另:BitPro 在变体创建时拒绝参数会被记为未知效果并卡住任务,所以参数微调研究员完整镜像了 BitPro 的参数规则。 +- **生产启用(另行执行,按用户已拍板的规则)**:`launch_enabled=true`、`launch_days=7`、`min_trades` 30→20、`exploration_provider=rule_tweak`、`exploration_cooldown_hours=2`、`paper_admission=basic`;全站赛马上限(BitPro `PAPER_RELAY_MAX_RACING_TOTAL=60`)不动。333 的挂起任务已用 `scripts/reconcile_avo_effects.py` 审计对账关闭(用户同意,备份在容器 `/app/data/backups/`)。 +- **已知限制**:9 月遗留、仍在运行的旧研究副本(#529、#531 为对照副本,#532、#533 为候选)满 7 天后也会被当作母体研究,旧规则下 10-25 本来也会;是否停掉它们由用户决定。变体数量上来后,BitPro 修改策略共用文件会让已审核变体重启后被拒绝加载(源码漂移),影响面随之变大。 + ## 2026-10-22:终验被去偏夏普否决的研究任务不再永久占用来源名额 - **背景**:生产研究预算上调后(每天 32 轮、检查间隔 30 分钟),连续多轮扫描仍全部 `deferred(source_active)`。只读核对线上任务投影:母体 509、513 各有一个 `needs_operator` 研究任务(终止原因 `avo_final_validation_failed`,终验窗口已用掉)占着来源名额五天以上;它们的数字门槛全过(夏普 0.21/0.57、回撤 3.9%/2.9%、成交 32/142 笔),被去偏夏普概率不足(0.65/0.85,门槛 0.95)和基线对比未改善否决,是明确的负向定论。 diff --git a/docs/spec.md b/docs/spec.md index 0fd767d..d64d588 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1179,6 +1179,15 @@ agent模式受enabled、策略范围、候选资金上限约束;独立评审 `proactive_enabled` 允许 worker 对未退化但证据完整的 Paper 发起可证伪研究;旧配置默认为关闭,原总开关与门槛不迁移。7+7 退化与主动探索及任务自身反馈共用持久 `research_budget.v1` 准入账本,按 UTC 日/可选累计任务限额、全局并发及来源冷却控制。任务与扣额原子提交,重启与配置修订不清账;旧自动任务计入消耗。退化优先,同级按最久未研究来源和策略ID选择。冷却计入诚实终态时间,未知效果继续阻断。GET evolution 和 CLI `research evolution` 展示预算、触发来源、拒绝原因与下一可运行时间;预览不扣额。完整数据门槛不降低,不产生 Live 或原 Paper 修改。 +### 上线探索、参数微调研究员与基本入场(2026-10-23) + +三项均由 `EvolutionConfig` 开关控制,默认关闭,开关关闭时扫描、预算与任务目标与此前一致。 + +- **上线探索**:`launch_enabled` 开启后,会话已满 `launch_days`(默认 7,范围 3~14)个完整 UTC 日、但 7+7 证据缺失(成交门槛、14 天窗口、成本或采样回执)的 BitPro 母体以 `launch` 触发研究;仅限支持来源变体的母体,不声称退化,零成交母体也可研究;readiness 不再对其挂成交与 14 天阻断;预算准入在开关关闭时以 `launch_disabled` 拒绝;其他市场目标保持原样。 +- **参数微调研究员**:`exploration_provider=rule_tweak` 时,launch/proactive 探索任务交给确定性研究员:只提单参数约 ±20% 的一步邻居(按授权范围、快慢窗口顺序过滤,跳过 BitPro 已赛过或已排队的组合,按任务编号轮换起点),开发回测后终验净收益最高的一个,不调用模型;它走与模型研究相同的工具、预算、记账与评审路径。退化与紧急任务始终使用 `research_provider`。来源最近一次任务是 rule_tweak 时,冷却改用 `exploration_cooldown_hours`,其余任务仍用 `cooldown_hours`。 +- **基本入场**:`paper_admission=basic`(随任务目标冻结)时,严格终验未过的候选若在同一终验窗口对母体满足基本检查——成交至少 1 笔且不少于母体三成;收益落后不超过 max(2 个百分点, 母体最大回撤的一半);最大回撤不超过母体 +5 个百分点且不超过 30%——以内容绑定的 `val_basic_` 编号进入原有 Paper 评审。严格结果如实保留(`passed=false` 与原因),评审包增加 `admission` 段,自动评审按存档数字重算而不信任标记;严格任务的评审包与哈希不变。接班仍由 BitPro 前向赛马决定(14 天里至少赢 12 天),本项不改变接班标准。 +- **研究窗口适配**:开发与终验窗口按标的数与周期压进 BitPro 封存回测上限(单快照 100 万根、单标的 10 万根、固定 500 根预热);可用天数不足 14 天时拒绝研究。120 个标的的 15m 母体开发窗口由 119 天降为 81 天。 + ### 不可变组合研究基线(任务 F,2026-09-13) 组合研究通过受管理员保护的 `/api/portfolio/research` 冻结 `portfolio_manifest.v1` 并比较等权持有基线。成员为同层、同窗、同币种、同成本的单资产策略净权益;最多20成员/500点,权重、现金、方向声明、版本/来源哈希、资金与收盘后再平衡均冻结。回放采用自筹费用的 `sleeve_index.v1`,仅代表按比例缩放历史净权益的研究假设,不代表改变资金后策略执行可复现。