feat: add Super Alpha research, management and MCP workflows
Deploy production / deploy (push) Successful in 56s
Deploy production / deploy (push) Successful in 56s
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Super Alpha construction and immutable component evidence over shared execution."""
|
||||
@@ -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}"}
|
||||
@@ -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)
|
||||
@@ -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": ["平台实际组件尚未核实"]}
|
||||
@@ -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"]}
|
||||
@@ -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": "结构校验与平台执行分开;缺少适用范围的算子保持未知"}
|
||||
@@ -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"]
|
||||
@@ -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)}
|
||||
@@ -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 "仅按缓存校验,仍需平台执行验证"}
|
||||
Reference in New Issue
Block a user