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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ It is not an automated money maker, promises no profitability, and does not prov
```mermaid
flowchart LR
Op["Operator / External Agent<br/>Web · ht CLI · TUI · Desktop"] --> API["FastAPI Control Plane<br/>Mission · TokenManager · Probes (/livez, /readyz, /healthz)"]
API --> DB[("PostgreSQL + pgvector<br/>49 migrations · Mission projections · HET tree · Memory")]
API --> DB[("PostgreSQL + pgvector<br/>50 migrations · Mission projections · HET tree · Memory")]
Worker["Worker Loops (13 Loops)<br/>Mission · AVO research · evolution · auto-review · meta-tuning"] --> DB
Worker --> Research["ARC Autonomous Research Loop (AVO + RD-Agent)<br/>HET tree → Co-STEER synthesis → AST gatekeeper → sandbox → baseline"]
Worker --> Evolution["Evolution Engine (default on)<br/>decay scan → budget admission → re-research → race duel → ledger"]
Expand Down Expand Up @@ -213,7 +213,7 @@ Single command executes all quality gates: Frontend lint, Vitest, and production
| **Control Plane** | FastAPI 0.122+ / Uvicorn, scoped security tokens, `/livez`, `/readyz`, `/healthz` container probes, cursor SSE |
| **Credential Security**| `TokenRotationService` (SHA-256 single-way hashing, zero plaintext storage, 24h grace period hot rotation, 7-day expiration audit) |
| **Domain & Runtime** | Python 3.12+, Pydantic v2 strict models, modular monolith + ports-and-adapters |
| **Storage** | SQLAlchemy 2.0 + Alembic (49 migrations), PostgreSQL 14+ with `pgvector` |
| **Storage** | SQLAlchemy 2.0 + Alembic (50 migrations), PostgreSQL 14+ with `pgvector` |
| **Workers** | asyncio + PostgreSQL SQL lease locking (13 concurrent background loops) |
| **Research Core** | ARC + RD-Agent Hypothesis Evolution Tree (HET) + Co-STEER synthesizer + `ASTGatekeeper` |
| **Target Adapters** | `market_target.v1` and `market-evolution.v1` MCP contracts; BitPro (Crypto) + QuantLab (A-Shares) |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ It is not an automated money maker, promises no profitability, and does not prov
```mermaid
flowchart LR
Op["Operator / External Agent<br/>Web · ht CLI · TUI · Desktop"] --> API["FastAPI Control Plane<br/>Mission · TokenManager · Probes (/livez, /readyz, /healthz)"]
API --> DB[("PostgreSQL + pgvector<br/>49 migrations · Mission projections · HET tree · Memory")]
API --> DB[("PostgreSQL + pgvector<br/>50 migrations · Mission projections · HET tree · Memory")]
Worker["Worker Loops (13 Loops)<br/>Mission · AVO research · evolution · auto-review · meta-tuning"] --> DB
Worker --> Research["ARC Autonomous Research Loop (AVO + RD-Agent)<br/>HET tree → Co-STEER synthesis → AST gatekeeper → sandbox → baseline"]
Worker --> Evolution["Evolution Engine (default on)<br/>decay scan → budget admission → re-research → race duel → ledger"]
Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
```mermaid
flowchart LR
Op["操作员 / 外部 Agent<br/>Web · ht CLI · TUI · Desktop"] --> API["FastAPI 控制面<br/>Mission · TokenManager · Probes (/livez, /readyz, /healthz)"]
API --> DB[("PostgreSQL + pgvector<br/>49 项迁移 · Mission 投影 · 演进树 · 记忆库")]
API --> DB[("PostgreSQL + pgvector<br/>50 项迁移 · Mission 投影 · 演进树 · 记忆库")]
Worker["Worker 循环 (13 个 Loop)<br/>Mission · AVO 研究 · 进化扫描 · 自动评审 · 元学习"] --> DB
Worker --> Research["ARC 自主研究循环 (AVO + RD-Agent)<br/>HET 假设树 → Co-STEER 合成 → AST 门禁 → 沙箱自测 → 基线对比"]
Worker --> Evolution["自主进化引擎(默认开启)<br/>退化扫描 → 预算准入 → 再研究 → 赛马对决 → 效果账本"]
Expand Down Expand Up @@ -213,7 +213,7 @@ curl http://localhost:3334/api/portfolio/relay/handovers
| **控制面** | FastAPI 0.122+ / Uvicorn,作用域安全令牌,`/livez`, `/readyz`, `/healthz` 容器探活矩阵,游标可续传 SSE |
| **凭据安全** | `TokenRotationService`(SHA-256 单向散列,零明文存储,24h 宽限期热轮换,7天临期审计) |
| **运行时与领域** | Python 3.12+、Pydantic v2 严格合同,模块化单体 + ports-and-adapters |
| **存储** | SQLAlchemy 2.0 + Alembic(49 个迁移),PostgreSQL 14+ 带 `pgvector` |
| **存储** | SQLAlchemy 2.0 + Alembic(50 个迁移),PostgreSQL 14+ 带 `pgvector` |
| **Worker 集群** | asyncio + PostgreSQL SQL lease 租约(13 个并发后台循环) |
| **研究与演进中枢** | ARC + RD-Agent 假设演进树 (HET) + Co-STEER 代码合成器 + `ASTGatekeeper` 语法守卫 |
| **多市场目标适配** | `market_target.v1` 与 `market-evolution.v1` MCP 契约;BitPro (Crypto) + QuantLab (A股) |
Expand Down
94 changes: 94 additions & 0 deletions backend/alembic/versions/0050_arc_forward_outcomes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Persist finished BitPro race lane outcomes for research memory."""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "0050_arc_forward_outcomes"
down_revision: str | None = "0049_arc_cognitive_memory"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def _timestamps() -> tuple[sa.Column[object], sa.Column[object]]:
return (
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)


def upgrade() -> None:
op.create_table(
"arc_forward_outcomes",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("target_id", sa.String(32), nullable=False),
sa.Column("parent_strategy_id", sa.String(64), nullable=False),
sa.Column("challenger_strategy_id", sa.String(64), nullable=False),
sa.Column("slot_no", sa.Integer(), nullable=True),
sa.Column("generation", sa.Integer(), nullable=False, server_default="1"),
sa.Column("verdict", sa.String(32), nullable=False),
sa.Column("verdict_source", sa.String(32), nullable=False, server_default="race_judge"),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metrics_json", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("evidence_sha256", sa.String(64), nullable=False, server_default=""),
sa.Column("lineage_status", sa.String(16), nullable=False, server_default="pending"),
sa.Column("mission_id", sa.String(64), nullable=True),
sa.Column("candidate_id", sa.String(128), nullable=True),
sa.Column("attempt_id", sa.String(128), nullable=True),
sa.Column("changes_json", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("scope_json", sa.JSON(), nullable=False, server_default="{}"),
*_timestamps(),
sa.UniqueConstraint(
"target_id",
"parent_strategy_id",
"challenger_strategy_id",
name="uq_arc_forward_outcome_pair",
),
)
op.create_index(
"ix_arc_forward_outcomes_parent_strategy_id",
"arc_forward_outcomes",
["parent_strategy_id"],
)
op.create_index(
"ix_arc_forward_outcomes_challenger_strategy_id",
"arc_forward_outcomes",
["challenger_strategy_id"],
)
op.create_index("ix_arc_forward_outcomes_verdict", "arc_forward_outcomes", ["verdict"])
op.create_index(
"ix_arc_forward_outcomes_lineage_status",
"arc_forward_outcomes",
["lineage_status"],
)
op.create_index(
"ix_arc_forward_outcomes_mission_id",
"arc_forward_outcomes",
["mission_id"],
)


def downgrade() -> None:
op.drop_index("ix_arc_forward_outcomes_mission_id", table_name="arc_forward_outcomes")
op.drop_index("ix_arc_forward_outcomes_lineage_status", table_name="arc_forward_outcomes")
op.drop_index("ix_arc_forward_outcomes_verdict", table_name="arc_forward_outcomes")
op.drop_index(
"ix_arc_forward_outcomes_challenger_strategy_id",
table_name="arc_forward_outcomes",
)
op.drop_index(
"ix_arc_forward_outcomes_parent_strategy_id",
table_name="arc_forward_outcomes",
)
op.drop_table("arc_forward_outcomes")
12 changes: 12 additions & 0 deletions backend/src/hypertrade/arc/avo.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,18 @@
Use both successful and failed experiments; match instrument, timeframe and window.
For an identical archived experiment, provide repeat_reason explaining new evidence or
why a controlled replication is needed; unchanged repetitions do not establish improvement.
Evolution memory may start with forward_outcome entries: real BitPro Paper races in this
strategy's lineage. relation says how each relates to the strategy you are evolving:
made_current is the race this strategy itself won to become the champion (its changes are
a direction that worked); variant_of_current is an earlier variant of this strategy;
made_ancestor and variant_of_ancestor are races of the champions it replaced
(generations_back counts how far back). Their changes are relative to that race's parent,
not necessarily to this strategy. verdict: won = adopted after the forward gate;
lost_futility = eliminated for lagging far behind; lost_exam = failed the one-time final
exam. Cite them by memory_id (outcome:...). Do not repeat the direction of a lost variant
of this strategy without new evidence and a repeat_reason; exploring near a winning
direction is preferred. A single race is a noisy observation, not a causal conclusion,
and never replaces development evidence.
source_variant_policy.relay.tried_variants lists the changes BitPro already raced, queued or
eliminated for this parent; a proposal identical to one of them is rejected because BitPro never
races the same changes twice, so propose a different direction. relay.slots shows how many of the
Expand Down
19 changes: 19 additions & 0 deletions backend/src/hypertrade/arc/evidence_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,32 @@ def build_evidence_view(projection: ARCMissionProjection) -> dict[str, Any]:
research["research"]["evolution"]["order_sample"] = {
k: v for k, v in context.get("orders", {}).items() if k != "fills"
}
# Forward races are not development references: they carry no backtest receipt,
# so mixing them in would read as development evidence without a backtest id.
research["research"]["evolution"]["memory_references"] = [
{
"mission_id": item.get("mission_id"),
"candidate_id": item.get("candidate_id"),
"backtest_id": item.get("development", {}).get("backtest_id"),
}
for item in context.get("memory", [])
if item.get("type") != "forward_outcome"
]
research["research"]["evolution"]["forward_outcome_references"] = [
{
key: item.get(key)
for key in (
"memory_id",
"verdict",
"relation",
"generations_back",
"parent_strategy_id",
"challenger_strategy_id",
"ended_at",
)
}
for item in context.get("memory", [])
if item.get("type") == "forward_outcome"
]
if projection.goal and projection.goal.paper_review_required:
from hypertrade.arc.paper_review import build_paper_review
Expand Down
88 changes: 87 additions & 1 deletion backend/src/hypertrade/arc/evolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
)
from hypertrade.arc.evolution_models import EvolutionControl, EvolutionCycle
from hypertrade.arc.feedback import 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
from hypertrade.arc.short_horizon import (
Expand Down Expand Up @@ -272,6 +273,17 @@ def status(self) -> dict[str, Any]:
"continuations": ContinuationLedger(self.db).view(),
}

def forward_outcomes_summary(self) -> dict[str, Any]:
"""Ledger aggregate for the evolution page; failures stay on the page, not raised.

Kept out of ``status()`` because the research scan calls that several times per
cycle and never reads this telemetry.
"""
try:
return ForwardOutcomeLedger(self.db).summary()
except Exception as exc: # Read-only telemetry must never take the page down.
return {"error": failure_text(exc)[:200]}

def configure(self, config: EvolutionConfig, *, revision: int, actor: str) -> dict[str, Any]:
try:
profile = get_market_target(config.target_id).profile
Expand Down Expand Up @@ -1100,4 +1112,78 @@ def _memory(self, context: dict[str, Any], now: datetime) -> list[dict[str, Any]
)
context["memory_manifest"] = manifest
context["research_windows"] = windows.model_dump(mode="json")
return memory

def forward_outcome_memory(row: dict[str, Any]) -> dict[str, Any]:
# One race is evidence about a direction, never a causal conclusion; the
# prompt tells the model to treat these as noisy and cite them by memory_id.
return {
"memory_id": f"outcome:{row['id']}",
"type": "forward_outcome",
"evidence_status": "forward_paper_outcome",
"outcome_id": row["id"],
"verdict": row["verdict"],
"relation": row["relation"],
"generations_back": row["generations_back"],
"parent_strategy_id": row["parent_strategy_id"],
"challenger_strategy_id": row["challenger_strategy_id"],
"mission_id": row["mission_id"],
"candidate_id": row["candidate_id"],
"changes": row["changes"],
"metrics": {
key: row["metrics"].get(key)
for key in (
"excess_return_pct",
"parent_net_return_pct",
"challenger_net_return_pct",
"parent_max_drawdown_pct",
"challenger_max_drawdown_pct",
"positive_days",
"paired_non_tie_days",
"observed_hours",
"cancellation_reason",
)
},
"ended_at": row["ended_at"],
"rule": (
"One forward Paper race; changes are relative to that race's parent. "
"A noisy observation, not a causal conclusion."
),
}

# Forward results live under the champion-to-champion chain, so they are only
# visible after linking and only from the lineage walk. Prepend them: the model
# sees the first entries of memory, and the newest races are the most relevant.
outcomes: list[dict[str, Any]] = []
try:
ledger = ForwardOutcomeLedger(self.db)
ledger.link_pending(limit=10)
rows = ledger.evidence_for_lineage(
target_id=context["target_id"],
strategy_id=context["source_strategy_id"],
ancestors=3,
limit=8,
)
except Exception as exc: # Research continues on development evidence alone.
record_runtime_error(
"evolution.forward_outcomes",
exc,
context={
"source_strategy_id": str(context["source_strategy_id"]),
"target_id": context["target_id"],
},
db=self.db,
)
manifest["forward_outcomes"] = {
"policy": "forward_outcome_v1",
"selected": 0,
"error": failure_text(exc)[:200],
}
else:
outcomes = [forward_outcome_memory(row) for row in rows]
manifest["forward_outcomes"] = {
"policy": "forward_outcome_v1",
"selected": len(outcomes),
"won": sum(1 for row in rows if row["verdict"] == "won"),
"lost": sum(1 for row in rows if row["verdict"] in ("lost_futility", "lost_exam")),
}
return [*outcomes, *memory]
33 changes: 32 additions & 1 deletion backend/src/hypertrade/arc/evolution_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime
from typing import Any

from sqlalchemy import JSON, DateTime, Integer, String, Text
from sqlalchemy import JSON, DateTime, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column

from hypertrade.db import Base, TimestampMixin
Expand Down Expand Up @@ -88,3 +88,34 @@ class ArcModelExchange(Base, TimestampMixin):
tool_calls_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
usage_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
truncated_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)


class ArcForwardOutcome(Base, TimestampMixin):
"""One finished BitPro race lane: what happened to a challenger against its parent."""

__tablename__ = "arc_forward_outcomes"
__table_args__ = (
UniqueConstraint(
"target_id",
"parent_strategy_id",
"challenger_strategy_id",
name="uq_arc_forward_outcome_pair",
),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
target_id: Mapped[str] = mapped_column(String(32))
parent_strategy_id: Mapped[str] = mapped_column(String(64), index=True)
challenger_strategy_id: Mapped[str] = mapped_column(String(64), index=True)
slot_no: Mapped[int | None] = mapped_column(Integer, nullable=True)
generation: Mapped[int] = mapped_column(Integer, default=1)
verdict: Mapped[str] = mapped_column(String(32), index=True)
verdict_source: Mapped[str] = mapped_column(String(32), default="race_judge")
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
metrics_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
evidence_sha256: Mapped[str] = mapped_column(String(64), default="")
lineage_status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
mission_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
candidate_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
attempt_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
changes_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
scope_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
9 changes: 8 additions & 1 deletion backend/src/hypertrade/arc/evolution_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ class EvolutionUpdate(BaseModel):

@router.get("/evolution", dependencies=[Depends(require_scope(ARCScope.READ))])
async def evolution_status(request: Request) -> dict[str, Any]:
return await run_in_threadpool(EvolutionService(request.app.state.db).status)
def _status() -> dict[str, Any]:
service = EvolutionService(request.app.state.db)
state = service.status()
# Page-level telemetry only; the research scan keeps calling status() directly.
state["forward_outcomes"] = service.forward_outcomes_summary()
return state

return await run_in_threadpool(_status)


@router.put("/evolution", dependencies=[Depends(require_scope(ARCScope.POLICY))])
Expand Down
Loading
Loading