feat: add Super Alpha research, management and MCP workflows
Deploy production / deploy (push) Successful in 56s

This commit is contained in:
yuxuanhui
2026-09-13 12:32:16 +08:00
parent 7c8188df9c
commit e256d6fef1
59 changed files with 3766 additions and 125 deletions
+1
View File
@@ -0,0 +1 @@
"""Super Alpha construction and immutable component evidence over shared execution."""
+43
View File
@@ -0,0 +1,43 @@
"""MCP adapter using the same SUPER operations and records as the web editor."""
from ..research.assets import Assets
from .evidence import read_selection
from .service import SuperResearch
class SuperResearchAccess:
async def super_plans(self, args):
return await Assets(self.db).list("superalpha_plan", args.q, args.limit, args.offset)
async def super_plan(self, args):
if args.experiment_id:
return await SuperResearch(self.db).experiment(args.experiment_id, args.limit, args.offset)
return await Assets(self.db).get(args.plan_id, args.version, "superalpha_plan")
async def save_super_plan(self, args):
result = await SuperResearch(self.db).save(args)
return {**result, "web_url": f"{self.public_origin}/#superalpha-research?plan_id={result['id']}"}
async def preview_super_selection(self, args):
result = await SuperResearch(self.db).selection_job(args)
self.wake = "jobs"
return result
async def super_selection(self, args):
return await read_selection(self.db, args)
async def build_super_candidates(self, args):
result = await SuperResearch(self.db).build(args)
return {**result, "web_url": f"{self.public_origin}/#superalpha-research?experiment_id={result['id']}",
"submit_with": "submit_backtests", "starts_backtests": False,
"submit_source": {k: result["source"][k] for k in ("research_id", "superalpha_plan_id", "superalpha_plan_version", "selection_snapshot_ids", "reference", "hypothesis")},
"paging": "完整候选可用 get_superalpha_plan 的 experiment_id 读取"}
async def super_alphas(self, args):
args.filters.management_scope = "super"
args.filters.alpha_type = "SUPER"
return await self.business.search_alphas(args.filters)
async def super_alpha(self, args):
result = await SuperResearch(self.db).alpha(args.alpha_id)
return {**result, "web_url": f"{self.public_origin}/#superalphas?alpha_id={args.alpha_id}"}
+140
View File
@@ -0,0 +1,140 @@
"""Bounded SUPER authoring inputs, independent from regular data-field preparation."""
from typing import Literal
from pydantic import Field, field_validator, model_validator
from ..backtests.contracts import SuperSimulationSettings
from ..research.expressions import IDENTIFIER, PLACEHOLDER
from ..research.workspace_contracts import Variable
from ..schemas import AlphaFilters, Contract
class PlanSpec(Contract):
name: str = Field(min_length=1, max_length=200)
hypothesis: str = Field(min_length=1, max_length=2000)
selection: str = Field(min_length=1, max_length=20000)
combo: str = Field(min_length=1, max_length=20000)
variables: dict[str, Variable] = Field(default_factory=dict, max_length=50)
settings: SuperSimulationSettings
setting_variants: dict[str, list[str | int | float | bool]] = Field(default_factory=dict, max_length=20)
include_baseline: bool = False
reference: str = Field(default="", max_length=200)
parent_plan_id: str | None = Field(default=None, max_length=36)
parent_plan_version: int | None = Field(default=None, ge=1)
parent_alpha_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
parent_experiment_id: str | None = Field(default=None, max_length=36)
@field_validator("name", "hypothesis", "selection", "combo")
@classmethod
def text(cls, value):
if not value.strip():
raise ValueError("内容不能为空")
return value.strip()
@model_validator(mode="after")
def bindings(self):
text = self.selection + "\n" + self.combo
if set(PLACEHOLDER.findall(text)) != set(self.variables):
raise ValueError("Selection/Combo 占位符必须与变量逐一对应")
if any(not IDENTIFIER.fullmatch(k) or v.kind == "field" for k, v in self.variables.items()):
raise ValueError("SUPER 变量须使用合法名称,不能使用 REGULAR 数据字段绑定")
if "{" in PLACEHOLDER.sub("", text) or "}" in PLACEHOLDER.sub("", text):
raise ValueError("占位符格式错误")
for key, values in self.setting_variants.items():
if key not in SuperSimulationSettings.model_fields or not 1 <= len(values) <= 100:
raise ValueError("设置变量必须为已支持设置,每项 1–100 个候选值")
for value in values:
SuperSimulationSettings.model_validate({**self.settings.model_dump(), key: value})
if bool(self.parent_plan_id) != bool(self.parent_plan_version):
raise ValueError("父方案必须同时指定 ID 和版本")
return self
class PlanSearch(Contract):
q: str = Field(default="", max_length=200)
limit: int = Field(default=25, ge=1, le=100)
offset: int = Field(default=0, ge=0)
class PlanReference(PlanSearch):
plan_id: str | None = Field(default=None, min_length=1, max_length=36)
experiment_id: str | None = Field(default=None, min_length=1, max_length=36)
version: int | None = Field(default=None, ge=1)
@model_validator(mode="after")
def one_reference(self):
if bool(self.plan_id) == bool(self.experiment_id) or (self.version and not self.plan_id):
raise ValueError("提供 plan_id 或 experiment_id 之一;version 仅用于方案")
return self
class PlanSave(Contract):
plan: PlanSpec
plan_id: str | None = Field(default=None, max_length=36)
version: int | None = Field(default=None, ge=1)
idempotency_key: str = Field(min_length=1, max_length=100)
@model_validator(mode="after")
def reference(self):
if bool(self.plan_id) != bool(self.version):
raise ValueError("更新须同时提供方案 ID 与当前版本")
return self
class SelectionPreview(Contract):
plan_id: str | None = Field(default=None, max_length=36)
version: int | None = Field(default=None, ge=1)
selection: str = Field(min_length=1, max_length=20000)
settings: SuperSimulationSettings
@field_validator("selection")
@classmethod
def concrete(cls, value):
if not value.strip() or "{" in value or "}" in value:
raise ValueError("预览须提供展开后的非空 Selection")
return value.strip()
def platform_query(self):
return {"selection": self.selection, **self.settings.model_dump(include={
"instrumentType", "region", "delay", "selectionLimit", "selectionHandling"})}
class SelectionReference(PlanSearch):
snapshot_id: str | None = Field(default=None, max_length=36)
job_id: str | None = Field(default=None, max_length=36)
@model_validator(mode="after")
def one(self):
if bool(self.snapshot_id) == bool(self.job_id):
raise ValueError("提供 snapshot_id 或 job_id 之一")
return self
class BuildCandidates(Contract):
plan: PlanSpec | None = None
plan_id: str | None = Field(default=None, max_length=36)
version: int | None = Field(default=None, ge=1)
mode: Literal["all", "random"] = "all"
limit: int = Field(default=100, ge=1, le=10000)
seed: int = Field(default=0, ge=0, le=2147483647)
selection_snapshot_ids: list[str] = Field(default_factory=list, max_length=100)
idempotency_key: str = Field(min_length=1, max_length=100)
@model_validator(mode="after")
def one(self):
if bool(self.plan) == bool(self.plan_id) or bool(self.plan_id) != bool(self.version):
raise ValueError("提供内联方案或方案 ID/版本之一")
return self
class SuperAlphaSearch(Contract):
filters: AlphaFilters = Field(default_factory=AlphaFilters)
class AlphaReference(Contract):
alpha_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
class ExperimentPreview(Contract):
candidate_ids: list[str] = Field(min_length=1, max_length=10000)
+97
View File
@@ -0,0 +1,97 @@
"""Parse only explicit component evidence; never infer actual members from a preview."""
import re
from datetime import datetime
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import select
from ..alphas import sanitize
from ..backtests.contracts import fingerprint
from ..models import SuperSelectionSnapshot
from ..research.serialization import encode_snapshot
def parse_components(raw):
"""Return normalized rows and completeness; count/duplicate/next ambiguity stays unknown."""
warnings = []
if isinstance(raw, dict):
supplied = raw.get("warnings", [])
warnings.extend(supplied if isinstance(supplied, list) else [supplied])
rows = raw.get("results", raw.get("alphas", raw.get("components")))
total = raw.get("count", raw.get("total"))
complete_hint = raw.get("complete") is True
next_page = raw.get("next")
else:
rows, total, complete_hint, next_page = raw, None, False, None
invalid_total = total is not None and (type(total) is not int or total < 0)
total = total if type(total) is int and total >= 0 else None
valid_shape = isinstance(rows, list)
items, seen, malformed = [], set(), False
for row in rows if valid_shape else []:
entry = {"id": row} if isinstance(row, str) else row
if not isinstance(entry, dict):
malformed = True
continue
alpha_id = entry.get("id", entry.get("alpha", entry.get("alphaId")))
if not isinstance(alpha_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", alpha_id) or alpha_id in seen:
malformed = True
continue
seen.add(alpha_id)
items.append({**sanitize(entry), "id": alpha_id})
complete = valid_shape and not malformed and not invalid_total and not next_page and (
(total is not None and total == len(items)) or (total is None and complete_hint))
if not complete:
warnings.append("组件列表未核实完整性;不生成完整组件指纹,不用于同池结论")
return {"components": items, "total": total, "complete": complete,
"component_hash": fingerprint({"alpha_ids": sorted(seen)}) if complete else None,
"warnings": sanitize(warnings)}
def snapshot_output(row, limit=25, offset=0, q=""):
items = [item for item in row.components if not q or q.lower() in str(item).lower()]
return encode_snapshot({"snapshot_id": row.id, "job_id": row.job_id, "item_id": row.item_id,
"source": row.source, "request": row.request, "request_hash": row.request_hash,
"component_hash": row.component_hash, "complete": row.complete, "reported_total": row.total,
"observed_at": row.observed_at, "warnings": row.warnings,
"status": "available" if row.complete else "unknown", "total": len(items),
"limit": limit, "offset": offset, "has_more": offset + limit < len(items),
"items": items[offset:offset + limit]})
async def read_selection(db, args):
query = select(SuperSelectionSnapshot)
query = query.where(SuperSelectionSnapshot.id == args.snapshot_id) if args.snapshot_id else query.where(
SuperSelectionSnapshot.job_id == args.job_id)
row = await db.scalar(query)
if not row:
if args.job_id:
from ..models import Job
job = await db.get(Job, args.job_id)
if not job or job.kind != "super_selection_preview":
raise HTTPException(404, "组件预览任务不存在")
return {"status": job.status, "snapshot_id": None, "job_id": job.id, "items": [],
"total": 0, "complete": False, "error": job.error, "observed_at": None}
raise HTTPException(404, "组件快照不存在")
return snapshot_output(row, args.limit, args.offset, args.q)
async def save_actual_components(db, item, detail, observed_at):
raw = detail.get("components", detail.get("selectedAlphas"))
if raw is None and isinstance(detail.get("selection"), dict):
selection = detail["selection"]
if isinstance(selection.get("alphas"), list):
raw = {"alphas": selection["alphas"], "count": selection.get("count")}
request = {"type": "SUPER", "selection": item.selection, "combo": item.combo, "settings": item.settings}
parsed = parse_components(raw)
db.add(SuperSelectionSnapshot(id=str(uuid4()), item_id=item.id, source="actual", request=request,
request_hash=fingerprint(request), raw=sanitize(raw) if isinstance(raw, (dict, list)) else {},
observed_at=datetime.fromisoformat(observed_at), **parsed))
async def actual_components(db, item_id, limit=25, offset=0):
row = await db.scalar(select(SuperSelectionSnapshot).where(SuperSelectionSnapshot.item_id == item_id))
return snapshot_output(row, limit, offset) if row else {
"status": "unknown", "complete": False, "source": "actual", "items": [], "total": 0,
"component_hash": None, "observed_at": None, "warnings": ["平台实际组件尚未核实"]}
+31
View File
@@ -0,0 +1,31 @@
"""Selection previews run on the existing durable job runner, outside request transactions."""
import asyncio
from uuid import uuid4
from sqlalchemy import select
from ..alphas import sanitize
from ..backtests.contracts import fingerprint
from ..models import Job, SuperSelectionSnapshot, now
from .contracts import SelectionPreview
from .evidence import parse_components
async def run_selection(runner, job_id, payload):
async with runner.sessions() as db:
existing = await db.scalar(select(SuperSelectionSnapshot).where(SuperSelectionSnapshot.job_id == job_id))
if existing:
return # Restart after snapshot commit must not replace the original observation.
request = SelectionPreview.model_validate(payload)
raw = await runner.client.run_super_selection(request.platform_query())
parsed = parse_components(raw)
async with runner.sessions.begin() as db:
job = await db.get(Job, job_id)
if job.cancel_requested:
raise asyncio.CancelledError()
snapshot = SuperSelectionSnapshot(id=str(uuid4()), job_id=job_id, source="preview", request=payload,
request_hash=fingerprint(request.platform_query()), raw=sanitize(raw), **parsed)
db.add(snapshot)
job.processed, job.total, job.updated_at = 1, 1, now()
job.checkpoint = {"snapshot_id": snapshot.id, "complete": parsed["complete"]}
+27
View File
@@ -0,0 +1,27 @@
"""Separate Alpha selection properties from stock data fields; availability remains evidence based."""
from ..backtests.contracts import SuperSimulationSettings
from ..catalog.research_metadata import ResearchMetadata
async def metadata(db):
settings = await ResearchMetadata(db).get("settings")
return {"settings_schema": SuperSimulationSettings.model_json_schema(), "settings_snapshot": settings,
"selection_properties": [{"name": name, "description": description} for name, description in (
("category", "用户设置的 Alpha 类别"), ("color", "用户设置的颜色"),
("datasets", "组件使用的数据集集合,可配合 in()"), ("datafields", "组件使用的数据字段集合"),
("datacategories", "组件使用的数据类别集合"), ("dataset_count", "不同数据集数量"),
("datafield_count", "不同数据字段数量"), ("datacategory_count", "不同数据类别数量"),
("decay", "组件的衰减设置"), ("favorite", "平台收藏状态"), ("name", "组件名称,按完整名称匹配"),
("neutralization", "组件的中性化设置"), ("operator_count", "组件表达式算子数量"),
("long_count", "IS 平均多头股票数量"), ("short_count", "IS 平均空头股票数量"),
("tags", "组件的自定义标签集合"), ("truncation", "组件截断设置"),
("turnover", "组件 IS 换手率"), ("universe", "组件股票池名称"),
("self_correlation", "组件自相关属性"), ("prod_correlation", "组件生产相关性属性"),
("os_start_date", "组件样本外起始日期,YYYY-MM-DD 字符串"),
("classifications", "组件分类集合"), ("competitions", "组件关联比赛集合"))],
"property_source": "BRAIN Selection Expression 文档快照(2025-10-16);属性列表非账户实时授权清单,具体可用性以平台响应为准",
"combo_input": "alpha 表示选中的组件;Combo 返回每日每个组件的权重,常量 1 可作为等权基线",
"selection_object": "平台可供选择的已提交 ACTIVE Alpha;本地列表不等同于平台完整组件池",
"operator_query": {"kind": "operators", "stage": "SELECTION"},
"validation": "结构校验与平台执行分开;缺少适用范围的算子保持未知"}
+135
View File
@@ -0,0 +1,135 @@
"""Authenticated SUPER authoring endpoints; construction never starts a simulation."""
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import ValidationError
from sqlalchemy import func, select
from ..models import SuperSelectionSnapshot
from ..research.assets import Assets
from ..security import require_auth
from .contracts import BuildCandidates, ExperimentPreview, PlanSave, SelectionPreview, SelectionReference
from .evidence import read_selection, snapshot_output
from .metadata import metadata
from .service import SuperResearch
router = APIRouter(prefix="/api/v1/superalpha", tags=["superalpha"], dependencies=[Depends(require_auth)])
@router.get("/metadata")
async def get_metadata(request: Request):
async with request.app.state.sessions() as db:
return await metadata(db)
@router.get("/plans")
async def plans(request: Request, q: str = "", limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
async with request.app.state.sessions() as db:
return await Assets(db).list("superalpha_plan", q, limit, offset)
@router.post("/plans")
async def save_plan(body: PlanSave, request: Request):
async with request.app.state.sessions.begin() as db:
return await SuperResearch(db).save(body)
@router.get("/plans/{plan_id}")
async def plan(plan_id: str, request: Request, version: int | None = Query(None, ge=1)):
async with request.app.state.sessions() as db:
return await Assets(db).get(plan_id, version, "superalpha_plan")
@router.get("/plans/{plan_id}/versions")
async def versions(plan_id: str, request: Request):
async with request.app.state.sessions() as db:
await Assets(db).get(plan_id, expected_kind="superalpha_plan")
return await Assets(db).versions(plan_id)
@router.delete("/plans/{plan_id}")
async def archive(plan_id: str, request: Request, version: int = Query(..., ge=1)):
async with request.app.state.sessions.begin() as db:
await Assets(db).get(plan_id, expected_kind="superalpha_plan")
return await Assets(db).archive(plan_id, version)
@router.post("/selections", status_code=202)
async def preview_selection(body: SelectionPreview, request: Request):
async with request.app.state.sessions.begin() as db:
result = await SuperResearch(db).selection_job(body)
request.app.state.runner.wake.set()
return result
@router.get("/selections")
async def selection(request: Request, snapshot_id: str | None = None, job_id: str | None = None,
q: str = "", limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
try:
args = SelectionReference(snapshot_id=snapshot_id, job_id=job_id, q=q, limit=limit, offset=offset)
except ValidationError as exc:
raise HTTPException(422, str(exc)) from None
async with request.app.state.sessions() as db:
return await read_selection(db, args)
@router.post("/candidates", status_code=201)
async def build(body: BuildCandidates, request: Request):
async with request.app.state.sessions.begin() as db:
return await SuperResearch(db).build(body)
@router.get("/experiments")
async def experiments(request: Request, plan_id: str | None = None,
limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
async with request.app.state.sessions() as db:
return await SuperResearch(db).experiments(plan_id, limit, offset)
@router.get("/experiments/{experiment_id}")
async def experiment(experiment_id: str, request: Request, limit: int = Query(100, ge=1, le=100), offset: int = Query(0, ge=0)):
async with request.app.state.sessions() as db:
return await SuperResearch(db).experiment(experiment_id, limit, offset)
@router.post("/experiments/{experiment_id}/preview")
async def preview(experiment_id: str, body: ExperimentPreview, request: Request):
async with request.app.state.sessions.begin() as db:
return await SuperResearch(db).preview(experiment_id, body.candidate_ids)
@router.get("/alphas/{alpha_id}")
async def alpha(alpha_id: str, request: Request):
async with request.app.state.sessions() as db:
return await SuperResearch(db).alpha(alpha_id)
@router.get("/selection-history")
async def selection_history(request: Request, plan_id: str, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
async with request.app.state.sessions() as db:
query = select(SuperSelectionSnapshot).where(SuperSelectionSnapshot.source == "preview", SuperSelectionSnapshot.request["plan_id"].as_string() == plan_id)
total = await db.scalar(select(func.count()).select_from(query.subquery()))
rows = await db.scalars(query.order_by(SuperSelectionSnapshot.observed_at.desc(), SuperSelectionSnapshot.id).limit(limit).offset(offset))
return {"items": [snapshot_output(row, 0) for row in rows], "total": total, "limit": limit, "offset": offset}
@router.get("/experiments/{experiment_id}/results")
async def experiment_results(experiment_id: str, request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
from ..models import Pnl
from ..research.serialization import encode_snapshot
from ..research_access.contracts import History
from ..research_access.queries import EvidenceQueries
from .evidence import actual_components
async with request.app.state.sessions() as db:
await SuperResearch(db).experiment(experiment_id, 1)
result = await EvidenceQueries(db).history(History(research_id=experiment_id, alpha_type="SUPER", limit=limit, offset=offset))
for item in result["items"]:
item["components"] = await actual_components(db, item["id"], 0)
pnl = await db.get(Pnl, item["alpha_id"]) if item["alpha_id"] else None
item["pnl_fetched_at"] = pnl.fetched_at if pnl else None
return encode_snapshot(result)
@router.get("/alphas/{alpha_id}/components")
async def alpha_components(alpha_id: str, request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
async with request.app.state.sessions() as db:
return (await SuperResearch(db).alpha(alpha_id, limit, offset))["components"]
+227
View File
@@ -0,0 +1,227 @@
"""Versioned SUPER plans and deterministic candidate construction; never executes simulations."""
import math
import random
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import func, select
from ..alphas import sanitize
from ..backtests.contracts import Candidate, DraftInput, PreviewInput, Source, fingerprint
from ..backtests.service import Backtests
from ..models import Account, Alpha, Job, ResearchExperiment, ResearchRequest, SuperSelectionSnapshot, now
from ..research.assets import Assets
from ..research.expressions import PLACEHOLDER
from ..research.serialization import encode_snapshot
from ..research.workspace_contracts import AssetWrite
from .contracts import PlanSpec, SelectionPreview
from .evidence import actual_components, parse_components
from .settings import validate_settings
async def validate_source(db, source, candidates):
"""Verify server-owned provenance references without making assets mandatory for direct execution."""
if (source.get("superalpha_plan_id") or source.get("selection_snapshot_ids") or source.get("kind") == "superalpha") and any(c.get("alpha_type", "REGULAR") != "SUPER" for c in candidates):
raise HTTPException(422, "Super Alpha 方案或组件来源只能关联 SUPER 候选")
await validate_settings(db, [c["settings"] for c in candidates if c.get("alpha_type") == "SUPER"])
experiment = None
if bool(source.get("superalpha_plan_id")) != bool(source.get("superalpha_plan_version")):
raise HTTPException(422, "方案引用须同时指定 ID 和版本")
if source.get("superalpha_plan_id"):
if not source.get("superalpha_plan_version"):
raise HTTPException(422, "方案引用须指定版本")
await Assets(db).get(source["superalpha_plan_id"], source["superalpha_plan_version"], "superalpha_plan")
if source.get("research_id") and (source.get("kind") == "superalpha" or source.get("superalpha_plan_id") or any(c.get("alpha_type") == "SUPER" for c in candidates)):
experiment = await db.get(ResearchExperiment, source["research_id"])
if not experiment or experiment.kind != "superalpha":
raise HTTPException(404, "SUPER 候选构造记录不存在")
source["research_kind"] = "superalpha"
expected = {c["client_item_id"]: fingerprint(Candidate.model_validate(c).platform_input()) for c in experiment.candidates}
for value in candidates:
c = Candidate.model_validate(value)
if expected.get(c.client_item_id) != fingerprint(c.platform_input()):
raise HTTPException(409, "候选与引用的固定构造记录不一致")
ref = experiment.evidence.get("plan_reference", {})
if source.get("superalpha_plan_id") and ref != {
"id": source["superalpha_plan_id"], "version": source["superalpha_plan_version"]}:
raise HTTPException(409, "方案版本与构造来源不一致")
for snapshot_id in source.get("selection_snapshot_ids", []):
row = await db.get(SuperSelectionSnapshot, snapshot_id)
if not row or row.source != "preview":
raise HTTPException(404, "Selection 预览快照不存在")
query = SelectionPreview.model_validate(row.request).platform_query()
if not any(Candidate.model_validate(c).alpha_type == "SUPER" and SelectionPreview(
selection=c["selection"], settings=c["settings"]).platform_query() == query for c in (experiment.candidates if experiment else candidates)):
raise HTTPException(409, "组件预览与候选 Selection/范围不匹配")
class SuperResearch:
def __init__(self, db):
self.db = db
self.assets = Assets(db)
async def previous(self, operation, args):
account = await self.db.scalar(select(Account).where(Account.id == 1).with_for_update())
if not account:
raise HTTPException(409, "工作空间未初始化")
digest = fingerprint(args.model_dump(mode="json", exclude={"idempotency_key"}))
previous = await self.db.scalar(select(ResearchRequest).where(ResearchRequest.account_id == 1,
ResearchRequest.operation == operation, ResearchRequest.idempotency_key == args.idempotency_key))
if previous and previous.digest != digest:
raise HTTPException(409, "幂等键已用于不同内容")
return previous, digest
async def remember(self, operation, args, digest, result, business_id):
result = encode_snapshot(result)
result["_meta"] = {"schema_version": 1, "observed_at": now().isoformat(), "source": "system"}
self.db.add(ResearchRequest(id=str(uuid4()), account_id=1, operation=operation,
idempotency_key=args.idempotency_key, digest=digest, business_id=business_id, response=result))
await self.db.flush()
return result
async def provenance(self, plan):
result = {"reference": plan.reference}
if plan.parent_plan_id:
parent = await self.assets.get(plan.parent_plan_id, plan.parent_plan_version, "superalpha_plan")
result["parent_plan"] = {k: parent[k] for k in ("id", "version", "name")}
if plan.parent_alpha_id:
alpha = await self.db.get(Alpha, plan.parent_alpha_id)
if not alpha or alpha.alpha_type != "SUPER":
raise HTTPException(404, "父 SUPER Alpha 尚未导入")
result["parent_alpha"] = {"id": alpha.id, "snapshot": sanitize(alpha.raw), "observed_at": alpha.synced_at}
if plan.parent_experiment_id:
parent = await self.experiment(plan.parent_experiment_id)
result["parent_experiment"] = {"id": parent["id"], "created_at": parent["created_at"]}
return encode_snapshot(result)
async def save(self, args):
previous, digest = await self.previous("save_superalpha_plan", args)
if previous:
return previous.response
await validate_settings(self.db, [args.plan.settings])
result = await self.assets.save(AssetWrite(kind="superalpha_plan", content=args.plan.model_dump(mode="json"),
version=args.version), args.plan_id, await self.provenance(args.plan))
return await self.remember("save_superalpha_plan", args, digest, result, result["id"])
async def build(self, args):
previous, digest = await self.previous("build_superalpha_candidates", args)
if previous:
return previous.response
asset = await self.assets.get(args.plan_id, args.version, "superalpha_plan") if args.plan_id else None
plan = PlanSpec.model_validate(asset["content"]) if asset else args.plan
provenance = await self.provenance(plan)
names = list(plan.variables)
settings_names = list(plan.setting_variants)
axes = [plan.variables[k].values for k in names] + [plan.setting_variants[k] for k in settings_names]
count = math.prod(len(a) for a in axes)
if count > 10**12 or (args.mode == "all" and count > args.limit):
raise HTTPException(422, f"理论组合数 {count} 超出展开上限;缩小参数或采用随机采样")
indices = range(count) if args.mode == "all" else sorted(random.Random(args.seed).sample(range(count), min(count, args.limit)))
candidates, annotations, seen = [], {}, {}
for index in indices:
remaining, values = index, []
for axis in reversed(axes):
remaining, position = divmod(remaining, len(axis))
values.insert(0, axis[position])
bindings = dict(zip(names, values[:len(names)]))
def substitute(text):
return PLACEHOLDER.sub(lambda m: str(bindings[m.group(1)]), text)
selection, combo = substitute(plan.selection), substitute(plan.combo)
settings = {**plan.settings.model_dump(), **dict(zip(settings_names, values[len(names):]))}
variants = [(combo, combo == "1")] + ([("1", True)] if plan.include_baseline and combo != "1" else [])
for combo_value, baseline in variants:
candidate_id = f"super-{index + 1}{'-baseline' if baseline else ''}"
c = Candidate(client_item_id=candidate_id, alpha_type="SUPER", selection=selection,
combo=combo_value, settings=settings)
h = fingerprint(c.platform_input())
annotations[candidate_id] = {"baseline": baseline, "parameters": bindings,
"duplicate_of": seen.get(h), "request_hash": h}
seen.setdefault(h, candidate_id)
candidates.append(c.model_dump(mode="json"))
if len(candidates) > 10000:
raise HTTPException(422, "包含基线后超过 10000 项,请缩小候选数")
plan_reference = {"id": asset["id"], "version": asset["version"]} if asset else {}
source = Source(kind="superalpha", research_kind="superalpha", reference=plan.reference, hypothesis=plan.hypothesis,
superalpha_plan_id=args.plan_id, superalpha_plan_version=args.version,
selection_snapshot_ids=args.selection_snapshot_ids).model_dump(mode="json")
await validate_source(self.db, source, candidates)
experiment = ResearchExperiment(id=str(uuid4()), name=plan.name, kind="superalpha", hypothesis=plan.hypothesis,
inputs=[], parents=[], candidates=candidates, evidence={"plan": plan.model_dump(mode="json"),
"plan_reference": plan_reference, "provenance": provenance, "source": source,
"selection_snapshot_ids": args.selection_snapshot_ids, "annotations": annotations,
"combination_count": str(count), "mode": args.mode, "seed": args.seed})
self.db.add(experiment)
await self.db.flush()
result = await self.experiment(experiment.id)
return await self.remember("build_superalpha_candidates", args, digest, result, experiment.id)
async def experiment(self, experiment_id, limit=100, offset=0):
row = await self.db.get(ResearchExperiment, experiment_id)
if not row or row.kind != "superalpha":
raise HTTPException(404, "SUPER 研究记录不存在")
source = {**row.evidence["source"], "research_id": row.id}
visible = row.candidates[offset:offset + limit]
evidence = {**row.evidence, "annotations": {c["client_item_id"]: row.evidence["annotations"].get(c["client_item_id"], {}) for c in visible}}
return encode_snapshot({"id": row.id, "name": row.name, "kind": row.kind, "hypothesis": row.hypothesis,
"created_at": row.created_at, "evidence": evidence, "source": source,
"candidates": row.candidates[offset:offset + limit], "total": len(row.candidates),
"limit": limit, "offset": offset, "has_more": offset + limit < len(row.candidates)})
async def experiments(self, plan_id=None, limit=25, offset=0):
query = select(ResearchExperiment).where(ResearchExperiment.kind == "superalpha")
if plan_id:
query = query.where(ResearchExperiment.evidence["plan_reference"]["id"].as_string() == plan_id)
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
rows = await self.db.scalars(query.order_by(ResearchExperiment.created_at.desc(), ResearchExperiment.id).limit(limit).offset(offset))
return encode_snapshot({"items": [{"id": r.id, "name": r.name, "created_at": r.created_at,
"total": len(r.candidates)} for r in rows], "total": total, "limit": limit, "offset": offset})
async def preview(self, experiment_id, candidate_ids):
row = await self.db.get(ResearchExperiment, experiment_id)
await self.experiment(experiment_id)
selected = [c for c in row.candidates if c["client_item_id"] in set(candidate_ids)]
if len(selected) != len(set(candidate_ids)):
raise HTTPException(422, "候选不属于当前研究记录")
return await Backtests(self.db).preview(PreviewInput(inline=DraftInput(name=row.name,
candidates=selected, source={**row.evidence["source"], "research_id": row.id})), preserve_source=True)
async def selection_job(self, args):
if bool(args.plan_id) != bool(args.version):
raise HTTPException(422, "预览的方案来源需同时指定 ID 和版本")
if args.plan_id:
await self.assets.get(args.plan_id, args.version, "superalpha_plan")
await validate_settings(self.db, [args.settings])
account = await self.db.scalar(select(Account).where(Account.id == 1).with_for_update())
if not account or account.connection_status not in ("connected", "expired"):
raise HTTPException(409, "请先连接 WorldQuant")
payload = args.model_dump(mode="json")
jobs = await self.db.scalars(select(Job).where(Job.kind == "super_selection_preview",
Job.status.in_(("queued", "running", "waiting_auth", "waiting_connection"))))
job = next((j for j in jobs if j.payload == payload and not j.cancel_requested), None)
if not job:
job = Job(id=str(uuid4()), kind="super_selection_preview", payload=payload, total=1)
self.db.add(job)
await self.db.flush()
return {"job_id": job.id, "status": job.status, "read_with": "get_superalpha_selection"}
async def alpha(self, alpha_id, limit=25, offset=0):
from ..business import Business
from ..models import BacktestItem, BacktestResult
alpha = await self.db.get(Alpha, alpha_id)
if not alpha or alpha.alpha_type != "SUPER":
raise HTTPException(404, "SUPER Alpha 尚未导入")
item = await self.db.scalar(select(BacktestItem).join(BacktestResult, BacktestResult.item_id == BacktestItem.id)
.where(BacktestItem.alpha_id == alpha_id).order_by(BacktestResult.observed_at.desc()).limit(1))
components = await actual_components(self.db, item.id, limit, offset) if item else {
"status": "unknown", "complete": False, "source": "actual", "items": [], "total": 0}
if not item:
parsed = parse_components(alpha.raw.get("components", alpha.raw.get("selectedAlphas")))
components = {"source": "actual", "status": "available" if parsed["complete"] else "unknown",
"complete": parsed["complete"], "component_hash": parsed["component_hash"],
"reported_total": parsed["total"], "total": len(parsed["components"]), "warnings": parsed["warnings"],
"observed_at": alpha.synced_at, "items": parsed["components"][offset:offset + limit], "limit": limit, "offset": offset}
return {**await Business(self.db).get_alpha(alpha_id), "components": components,
"descriptions": {k: (alpha.raw.get(k) or {}).get("description", "")
if isinstance(alpha.raw.get(k), dict) else "" for k in ("selection", "combo")},
"sources": await Business(self.db).get_alpha_sources(alpha_id)}
+45
View File
@@ -0,0 +1,45 @@
"""Cached platform constraints shared by SUPER authoring and generic execution."""
from fastapi import HTTPException
from ..catalog.research_metadata import ResearchMetadata
async def validate_settings(db, values):
"""Reject known unsupported values; absent metadata is explicitly unknown, never approved."""
snapshot = await ResearchMetadata(db).get("settings")
rows = snapshot["content"].get("items", [])
if not snapshot["fetched_at"] or not rows:
return {"status": "unknown", "reason": "未缓存平台设置"}
incomplete = False
for settings in values:
value = settings.model_dump() if hasattr(settings, "model_dump") else settings
matches = [r for r in rows if all(r.get(k) == value.get(v) for k, v in (
("instrument_type", "instrumentType"), ("region", "region"), ("universe", "universe"), ("delay", "delay")))]
if not matches:
raise HTTPException(422, "平台设置快照不支持当前 SUPER 地区 / Universe / Delay 组合")
failures = []
valid = False
for row in matches:
failed = []
if row.get("neutralizations") and value["neutralization"] not in row["neutralizations"]:
failed.append("neutralization")
for key, field in row.get("fields", {}).items():
if key not in value:
continue
current = value[key]
if "choices" in field and current not in field["choices"]:
failed.append(key)
if type(current) in (int, float) and (
("minimum" in field and current < field["minimum"]) or
("maximum" in field and current > field["maximum"])):
failed.append(key)
if not failed:
valid = True
incomplete |= any(not row.get("fields", {}).get(key) for key in ("selectionLimit", "selectionHandling", "componentActivation"))
break
failures.extend(failed)
if not valid:
raise HTTPException(422, "平台设置快照不支持 SUPER 参数:" + "、".join(sorted(set(failures))))
return {"status": "partial" if incomplete else "cached", "fetched_at": snapshot["fetched_at"],
"reason": "部分 SUPER 设置范围未提供" if incomplete else "仅按缓存校验,仍需平台执行验证"}