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,22 @@
|
|||||||
|
# Super Alpha 研究、管理及通用回测接入
|
||||||
|
|
||||||
|
Status: ready-for-agent
|
||||||
|
Outcome: implemented
|
||||||
|
|
||||||
|
## 工作项
|
||||||
|
|
||||||
|
- [x] 公共契约、组件快照和增量迁移
|
||||||
|
- [x] 通用回测及结果证据支持 SUPER
|
||||||
|
- [x] 方案、Selection 任务和 MCP
|
||||||
|
- [x] 研究及管理页面、路由和范围隔离
|
||||||
|
- [x] 模拟平台测试、构建、浏览器及迁移验证
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
2026-09-13:开始实施用户已确认计划。只执行本地模拟验证,保护已有 Pyramid MCP 改动。
|
||||||
|
|
||||||
|
2026-09-13:完成两个菜单、共享 SUPER 回测契约、方案版本与构造记录、独立组件证据、8 个 MCP 工具及通用工具扩展。使用文档已更新至 docs/mcp-research.md。
|
||||||
|
|
||||||
|
验证记录:后端全量 540 项通过;后续元数据、来源和幂等调整后的针对性回归分别 44 项、30 项通过。前端类型检查与构建通过;Super Alpha、原回测、管理、MCP Key、研究导航、侧栏和原工作空间浏览器场景均通过。新列表验证了 40px 行高,检查 1440/850/390px 视口。迁移 0021 在专用临时 PostgreSQL 中完成回退/重升级、历史 REGULAR 回测及已有 SUPER/备注保留、SUPER 完整闭环与并发幂等验证。
|
||||||
|
|
||||||
|
范围:只使用模拟平台和可丢弃的本地测试数据库;未迁移用户运行库,未执行真实 SUPER 模拟、正式 Alpha 提交、部署或 git 提交。升级运行环境时需应用 0021;真实 SUPER 模拟须按用户计划另行授权验收。构建保留既有的大包及 lottie-web eval 提示。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Super Alpha 研究与管理
|
||||||
|
|
||||||
|
按用户 2026-09-13 确认的功能规划实施。
|
||||||
|
|
||||||
|
- 新增 Super Alpha 研究(方案、版本、Selection 预览、参数展开、固定候选及对照)和 Super Alpha 管理菜单。
|
||||||
|
- 管理菜单以 SUPER / 非 SUPER 分流,共用 Alpha 记录、同步、备注和检查。网页 AI 只识别新页面和对象。
|
||||||
|
- 复用通用回测、研究资产和不可变版本。SUPER 候选包含 selection/combo/完整设置,每个 SUPER 单独发平台请求,共用配额与恢复。
|
||||||
|
- 组件预览与实际组件证据独立,保存请求指纹、组件指纹、时间和完整性。未知不等于通过。
|
||||||
|
- 增加方案查询/读取/保存、Selection 预览/读取、候选构造及 SUPER 成果查询/读取 MCP 工具,回测仍使用通用工具。
|
||||||
|
- 不实现自动研究循环、独立调度器或正式 Alpha 提交;不调用真实模拟或收费模型。
|
||||||
|
|
||||||
|
验收:网页及 MCP 完整闭环、范围隔离、版本及幂等、组件异常、SUPER 回测恢复与历史 REGULAR 兼容;后端测试、前端构建、浏览器和独立迁移验证。
|
||||||
|
|
||||||
|
已有 Pyramid MCP 未提交改动必须保留。
|
||||||
@@ -45,6 +45,8 @@ class PageContext(Contract):
|
|||||||
page: Literal[
|
page: Literal[
|
||||||
"home",
|
"home",
|
||||||
"alphas",
|
"alphas",
|
||||||
|
"superalphas",
|
||||||
|
"superalpha-research",
|
||||||
"account",
|
"account",
|
||||||
"datasets",
|
"datasets",
|
||||||
"fields",
|
"fields",
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ async def upsert_alpha(db, raw: dict):
|
|||||||
|
|
||||||
def list_statement(filters):
|
def list_statement(filters):
|
||||||
query = select(Alpha, Research).join(Research, Research.alpha_id == Alpha.id)
|
query = select(Alpha, Research).join(Research, Research.alpha_id == Alpha.id)
|
||||||
|
if filters.management_scope == "super":
|
||||||
|
query = query.where(Alpha.alpha_type == "SUPER")
|
||||||
|
elif filters.management_scope == "non_super":
|
||||||
|
query = query.where(or_(Alpha.alpha_type != "SUPER", Alpha.alpha_type.is_(None)))
|
||||||
if filters.submission:
|
if filters.submission:
|
||||||
query = query.where(submission_condition(filters.submission))
|
query = query.where(submission_condition(filters.submission))
|
||||||
if filters.submission_blocked is not None:
|
if filters.submission_blocked is not None:
|
||||||
@@ -285,6 +289,11 @@ def summary(item: Alpha, research: Research):
|
|||||||
result = {k: getattr(item, k) for k in keys}
|
result = {k: getattr(item, k) for k in keys}
|
||||||
result["failed_checks"] = failed_checks(item.checks)
|
result["failed_checks"] = failed_checks(item.checks)
|
||||||
result["expression_preview"] = (item.expression or item.selection or "")[:240]
|
result["expression_preview"] = (item.expression or item.selection or "")[:240]
|
||||||
|
result["selection_preview"], result["combo_preview"] = (item.selection or "")[:240], (item.combo or "")[:240]
|
||||||
|
if item.alpha_type == "SUPER":
|
||||||
|
from .superalpha.evidence import parse_components
|
||||||
|
components = parse_components(item.raw.get("components", item.raw.get("selectedAlphas")))
|
||||||
|
result["component_count"] = len(components["components"]) if components["complete"] else None
|
||||||
result["research"] = {
|
result["research"] = {
|
||||||
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version")
|
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,25 +27,47 @@ class SimulationSettings(Contract):
|
|||||||
maxPosition: Literal["ON", "OFF"] = "OFF"
|
maxPosition: Literal["ON", "OFF"] = "OFF"
|
||||||
|
|
||||||
|
|
||||||
|
class SuperSimulationSettings(SimulationSettings):
|
||||||
|
"""SUPER-only selection settings; platform metadata still determines availability."""
|
||||||
|
|
||||||
|
selectionHandling: Literal["POSITIVE", "NON_ZERO", "NON_NAN"]
|
||||||
|
selectionLimit: int = Field(ge=1, le=100000, strict=True)
|
||||||
|
componentActivation: Literal["IS", "OS"]
|
||||||
|
|
||||||
|
|
||||||
class Candidate(Contract):
|
class Candidate(Contract):
|
||||||
client_item_id: str = Field(min_length=1, max_length=100)
|
client_item_id: str = Field(min_length=1, max_length=100)
|
||||||
expression: str = Field(min_length=1, max_length=20000)
|
expression: str = Field(default="", max_length=20000)
|
||||||
settings: SimulationSettings
|
selection: str | None = Field(default=None, max_length=20000)
|
||||||
alpha_type: Literal["REGULAR"] = "REGULAR"
|
combo: str | None = Field(default=None, max_length=20000)
|
||||||
|
settings: SuperSimulationSettings | SimulationSettings
|
||||||
|
alpha_type: Literal["REGULAR", "SUPER"] = "REGULAR"
|
||||||
|
|
||||||
@field_validator("expression")
|
@field_validator("expression", "selection", "combo")
|
||||||
@classmethod
|
@classmethod
|
||||||
def nonempty(cls, value):
|
def nonempty(cls, value):
|
||||||
value = value.strip()
|
return value.strip() if value is not None else None
|
||||||
if not value:
|
|
||||||
raise ValueError("表达式不能为空")
|
@model_validator(mode="after")
|
||||||
return value
|
def typed_input(self):
|
||||||
|
if self.alpha_type == "SUPER":
|
||||||
|
if self.expression or not self.selection or not self.combo:
|
||||||
|
raise ValueError("SUPER 必须提供非空 selection/combo,不能提供 regular expression")
|
||||||
|
if not isinstance(self.settings, SuperSimulationSettings):
|
||||||
|
raise ValueError("SUPER 必须提供 selectionHandling、selectionLimit、componentActivation")
|
||||||
|
elif not self.expression or self.selection is not None or self.combo is not None or isinstance(self.settings, SuperSimulationSettings):
|
||||||
|
raise ValueError("REGULAR 必须提供非空 expression,不能包含 SUPER 表达式或设置")
|
||||||
|
return self
|
||||||
|
|
||||||
def platform_input(self):
|
def platform_input(self):
|
||||||
|
if self.alpha_type == "SUPER":
|
||||||
|
return {"type": "SUPER", "selection": self.selection, "combo": self.combo,
|
||||||
|
"settings": self.settings.model_dump()}
|
||||||
return {"type": self.alpha_type, "regular": self.expression, "settings": self.settings.model_dump()}
|
return {"type": self.alpha_type, "regular": self.expression, "settings": self.settings.model_dump()}
|
||||||
|
|
||||||
|
|
||||||
class Source(Contract):
|
class Source(Contract):
|
||||||
|
research_kind: str | None = Field(default=None, max_length=50)
|
||||||
kind: str = Field(default="manual", min_length=1, max_length=100)
|
kind: str = Field(default="manual", min_length=1, max_length=100)
|
||||||
reference: str | None = Field(default=None, max_length=200)
|
reference: str | None = Field(default=None, max_length=200)
|
||||||
batch_id: str | None = Field(default=None, max_length=200)
|
batch_id: str | None = Field(default=None, max_length=200)
|
||||||
@@ -54,6 +76,9 @@ class Source(Contract):
|
|||||||
research_id: str | None = Field(default=None, max_length=200)
|
research_id: str | None = Field(default=None, max_length=200)
|
||||||
parent_run_id: str | None = Field(default=None, max_length=36)
|
parent_run_id: str | None = Field(default=None, max_length=36)
|
||||||
hypothesis: str | None = Field(default=None, max_length=2000)
|
hypothesis: str | None = Field(default=None, max_length=2000)
|
||||||
|
superalpha_plan_id: str | None = Field(default=None, max_length=36)
|
||||||
|
superalpha_plan_version: int | None = Field(default=None, ge=1)
|
||||||
|
selection_snapshot_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class SourceOutput(Source):
|
class SourceOutput(Source):
|
||||||
@@ -129,7 +154,7 @@ def fingerprint(payload: dict) -> str:
|
|||||||
|
|
||||||
def group_key(candidate: dict):
|
def group_key(candidate: dict):
|
||||||
settings = candidate["settings"]
|
settings = candidate["settings"]
|
||||||
return tuple(settings[k] for k in ("region", "delay", "language", "instrumentType"))
|
return (candidate.get("alpha_type", "REGULAR"), *tuple(settings[k] for k in ("region", "delay", "language", "instrumentType")))
|
||||||
|
|
||||||
|
|
||||||
class ReferenceInput(Contract):
|
class ReferenceInput(Contract):
|
||||||
@@ -203,7 +228,10 @@ class ItemOutput(Contract):
|
|||||||
id: str
|
id: str
|
||||||
client_item_id: str
|
client_item_id: str
|
||||||
expression: str
|
expression: str
|
||||||
settings: SimulationSettings
|
alpha_type: Literal["REGULAR", "SUPER"] = "REGULAR"
|
||||||
|
selection: str | None = None
|
||||||
|
combo: str | None = None
|
||||||
|
settings: SuperSimulationSettings | SimulationSettings
|
||||||
attempt_id: str
|
attempt_id: str
|
||||||
platform_status: str
|
platform_status: str
|
||||||
collection_status: str
|
collection_status: str
|
||||||
|
|||||||
@@ -117,13 +117,14 @@ async def runs(
|
|||||||
source: str | None = Query(None, max_length=100),
|
source: str | None = Query(None, max_length=100),
|
||||||
reference: str | None = Query(None, max_length=200),
|
reference: str | None = Query(None, max_length=200),
|
||||||
research_id: str | None = Query(None, max_length=200),
|
research_id: str | None = Query(None, max_length=200),
|
||||||
|
alpha_type: Literal["REGULAR", "SUPER"] | None = None,
|
||||||
q: str = Query("", max_length=200),
|
q: str = Query("", max_length=200),
|
||||||
sort: Literal["name", "created_at"] = "created_at",
|
sort: Literal["name", "created_at"] = "created_at",
|
||||||
direction: Literal["asc", "desc"] = "desc",
|
direction: Literal["asc", "desc"] = "desc",
|
||||||
):
|
):
|
||||||
async with request.app.state.sessions() as db:
|
async with request.app.state.sessions() as db:
|
||||||
return await Business(db).backtests.runs(
|
return await Business(db).backtests.runs(
|
||||||
limit, offset, source, reference, research_id, q, sort, direction
|
limit, offset, source, reference, research_id, q, sort, direction, alpha_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,3 +196,17 @@ async def attach_reference(attempt_id: str, body: ReferenceInput, request: Reque
|
|||||||
async def subset(preview_id: str, body: SubsetInput, request: Request):
|
async def subset(preview_id: str, body: SubsetInput, request: Request):
|
||||||
async with request.app.state.sessions.begin() as db:
|
async with request.app.state.sessions.begin() as db:
|
||||||
return await Business(db).backtests.subset(preview_id, body)
|
return await Business(db).backtests.subset(preview_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}/artifact")
|
||||||
|
async def artifact(item_id: str, request: Request, kind: Literal["snapshot", "components", "pnl"], limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from ..research_access.contracts import Artifact
|
||||||
|
from ..research_access.queries import EvidenceQueries
|
||||||
|
from ..research_access.service import ResearchError
|
||||||
|
async with request.app.state.sessions() as db:
|
||||||
|
try:
|
||||||
|
return await EvidenceQueries(db).artifact(Artifact(item_id=item_id, kind=kind, limit=limit, offset=offset))
|
||||||
|
except ResearchError as exc:
|
||||||
|
raise HTTPException(404, str(exc)) from None
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ class BacktestLane:
|
|||||||
|
|
||||||
def safe_progress(self, value):
|
def safe_progress(self, value):
|
||||||
# Store useful protocol evidence, never arbitrary upstream diagnostics or credentials.
|
# Store useful protocol evidence, never arbitrary upstream diagnostics or credentials.
|
||||||
result = {k: value[k] for k in ("status", "alpha", "regular", "settings", "location") if k in value}
|
result = {k: value[k] for k in ("status", "type", "alpha", "regular", "selection", "combo", "settings", "location", "warnings") if k in value}
|
||||||
message = value.get("error") or value.get("message")
|
message = value.get("error") or value.get("message")
|
||||||
if isinstance(message, str):
|
if isinstance(message, str):
|
||||||
for secret in list(self.client.credentials or ()) + list(self.client.client.cookies.values()):
|
for secret in list(self.client.credentials or ()) + list(self.client.client.cookies.values()):
|
||||||
@@ -485,11 +485,13 @@ class BacktestLane:
|
|||||||
matched = [
|
matched = [
|
||||||
i
|
i
|
||||||
for i in items
|
for i in items
|
||||||
if i.expression == expression
|
if ((i.alpha_type == "REGULAR" and evidence.get("type", "REGULAR") == "REGULAR" and i.expression == expression)
|
||||||
|
or (i.alpha_type == "SUPER" and evidence.get("type") == "SUPER"
|
||||||
|
and i.selection == code(evidence.get("selection")) and i.combo == code(evidence.get("combo"))))
|
||||||
and isinstance(settings, dict)
|
and isinstance(settings, dict)
|
||||||
and all(k in settings and settings[k] == v for k, v in i.settings.items())
|
and all(k in settings and settings[k] == v for k, v in i.settings.items())
|
||||||
]
|
]
|
||||||
if count == 1:
|
if count == 1 and items[0].alpha_type == "REGULAR":
|
||||||
matched = (
|
matched = (
|
||||||
items
|
items
|
||||||
if (expression == items[0].expression or (not expression and detail is None))
|
if (expression == items[0].expression or (not expression and detail is None))
|
||||||
@@ -499,6 +501,9 @@ class BacktestLane:
|
|||||||
)
|
)
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
|
if count == 1 and items[0].alpha_type == "SUPER" and detail is None:
|
||||||
|
# A known receipt can record progress, but saving SUPER requires full type/input evidence.
|
||||||
|
matched = items if not any(k in evidence for k in ("type", "selection", "combo", "settings")) else matched
|
||||||
# Identical inputs within a multi-submit are intentionally not position-matched.
|
# Identical inputs within a multi-submit are intentionally not position-matched.
|
||||||
if len(matched) != 1 or (matched[0].simulation_id not in (None, child)):
|
if len(matched) != 1 or (matched[0].simulation_id not in (None, child)):
|
||||||
return
|
return
|
||||||
@@ -513,6 +518,9 @@ class BacktestLane:
|
|||||||
if not await db.get(BacktestResult, item.id):
|
if not await db.get(BacktestResult, item.id):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
if item.alpha_type == "SUPER":
|
||||||
|
from ..superalpha.evidence import save_actual_components
|
||||||
|
await save_actual_components(db, item, detail, receipt["observed_at"])
|
||||||
db.add(
|
db.add(
|
||||||
BacktestResult(
|
BacktestResult(
|
||||||
item_id=item.id,
|
item_id=item.id,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class Backtests:
|
|||||||
|
|
||||||
async def capabilities(self):
|
async def capabilities(self):
|
||||||
return {
|
return {
|
||||||
"alpha_types": ["REGULAR"],
|
"alpha_types": ["REGULAR", "SUPER"],
|
||||||
"languages": ["FASTEXPR"],
|
"languages": ["FASTEXPR"],
|
||||||
"instrument_types": ["EQUITY"],
|
"instrument_types": ["EQUITY"],
|
||||||
"settings_schema": Candidate.model_json_schema(),
|
"settings_schema": Candidate.model_json_schema(),
|
||||||
@@ -111,6 +111,8 @@ class Backtests:
|
|||||||
from ..research.expressions import analyze
|
from ..research.expressions import analyze
|
||||||
if not body.preparation_refs and not body.input_ids:
|
if not body.preparation_refs and not body.input_ids:
|
||||||
return
|
return
|
||||||
|
if any(c.alpha_type == "SUPER" for c in body.candidates):
|
||||||
|
raise HTTPException(422, "SUPER 组件快照不能使用字段数据准备集合")
|
||||||
await Preparations(self.db).bind(body)
|
await Preparations(self.db).bind(body)
|
||||||
snapshots = [await Preparations(self.db).snapshot(i) for i in body.input_ids]
|
snapshots = [await Preparations(self.db).snapshot(i) for i in body.input_ids]
|
||||||
for candidate in body.candidates:
|
for candidate in body.candidates:
|
||||||
@@ -221,6 +223,8 @@ class Backtests:
|
|||||||
if len(candidates) != len(selection):
|
if len(candidates) != len(selection):
|
||||||
raise HTTPException(422, "选择包含不属于当前草稿的候选")
|
raise HTTPException(422, "选择包含不属于当前草稿的候选")
|
||||||
data = {"name": draft.name, "source": draft.source, "candidates": candidates}
|
data = {"name": draft.name, "source": draft.source, "candidates": candidates}
|
||||||
|
from ..superalpha.service import validate_source
|
||||||
|
await validate_source(self.db, data["source"], data["candidates"])
|
||||||
candidates = DraftInput.model_validate(data).model_dump(mode="json")["candidates"]
|
candidates = DraftInput.model_validate(data).model_dump(mode="json")["candidates"]
|
||||||
config = await self.db.get(BacktestConfig, 1)
|
config = await self.db.get(BacktestConfig, 1)
|
||||||
groups = defaultdict(list)
|
groups = defaultdict(list)
|
||||||
@@ -253,6 +257,9 @@ class Backtests:
|
|||||||
for indices in groups.values():
|
for indices in groups.values():
|
||||||
local_batches = []
|
local_batches = []
|
||||||
for index in indices:
|
for index in indices:
|
||||||
|
if candidates[index]["alpha_type"] == "SUPER":
|
||||||
|
batches.append([index])
|
||||||
|
continue
|
||||||
batch = next(
|
batch = next(
|
||||||
(
|
(
|
||||||
b
|
b
|
||||||
@@ -352,6 +359,7 @@ class Backtests:
|
|||||||
ordinal=i,
|
ordinal=i,
|
||||||
client_item_id=c.client_item_id,
|
client_item_id=c.client_item_id,
|
||||||
expression=c.expression,
|
expression=c.expression,
|
||||||
|
alpha_type=c.alpha_type, selection=c.selection, combo=c.combo,
|
||||||
settings=c.settings.model_dump(),
|
settings=c.settings.model_dump(),
|
||||||
fingerprint=fingerprint(c.platform_input()),
|
fingerprint=fingerprint(c.platform_input()),
|
||||||
)
|
)
|
||||||
@@ -360,8 +368,10 @@ class Backtests:
|
|||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return await self.run(run.id)
|
return await self.run(run.id)
|
||||||
|
|
||||||
async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None, q="", sort="created_at", direction="desc"):
|
async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None, q="", sort="created_at", direction="desc", alpha_type=None):
|
||||||
query = select(BacktestRun)
|
query = select(BacktestRun)
|
||||||
|
if alpha_type:
|
||||||
|
query = query.where(BacktestRun.id.in_(select(BacktestItem.run_id).where(BacktestItem.alpha_type == alpha_type)))
|
||||||
if q:
|
if q:
|
||||||
query = query.where(BacktestRun.name.contains(q, autoescape=True))
|
query = query.where(BacktestRun.name.contains(q, autoescape=True))
|
||||||
column = {"name": BacktestRun.name, "created_at": BacktestRun.created_at}[sort]
|
column = {"name": BacktestRun.name, "created_at": BacktestRun.created_at}[sort]
|
||||||
@@ -460,7 +470,7 @@ class Backtests:
|
|||||||
for k in (
|
for k in (
|
||||||
"id",
|
"id",
|
||||||
"client_item_id",
|
"client_item_id",
|
||||||
"expression",
|
"expression", "alpha_type", "selection", "combo",
|
||||||
"settings",
|
"settings",
|
||||||
"attempt_id",
|
"attempt_id",
|
||||||
"platform_status",
|
"platform_status",
|
||||||
@@ -610,7 +620,8 @@ class Backtests:
|
|||||||
source=Source.model_validate({**run.source, "parent_run_id": run.id}),
|
source=Source.model_validate({**run.source, "parent_run_id": run.id}),
|
||||||
candidates=[
|
candidates=[
|
||||||
Candidate(
|
Candidate(
|
||||||
client_item_id=r.client_item_id, expression=r.expression, settings=r.settings
|
client_item_id=r.client_item_id, expression=r.expression, settings=r.settings,
|
||||||
|
alpha_type=r.alpha_type, selection=r.selection, combo=r.combo
|
||||||
)
|
)
|
||||||
for r in selected
|
for r in selected
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -91,27 +91,29 @@ class Business:
|
|||||||
else None,
|
else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_alpha_facets(self):
|
async def get_alpha_facets(self, management_scope=None):
|
||||||
|
from .schemas import AlphaFilters
|
||||||
|
ids = list_statement(AlphaFilters(management_scope=management_scope)).with_only_columns(Alpha.id)
|
||||||
result = {}
|
result = {}
|
||||||
for key in ("region", "universe", "alpha_type", "language", "status", "stage"):
|
for key in ("region", "universe", "alpha_type", "language", "status", "stage"):
|
||||||
column = getattr(Alpha, key)
|
column = getattr(Alpha, key)
|
||||||
result[key] = list(
|
result[key] = list(
|
||||||
(
|
(
|
||||||
await self.db.scalars(
|
await self.db.scalars(
|
||||||
select(column).where(column.is_not(None)).distinct().order_by(column)
|
select(column).where(column.is_not(None), Alpha.id.in_(ids)).distinct().order_by(column)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
result["tags"] = list(
|
result["tags"] = list(
|
||||||
(await self.db.scalars(select(ResearchTag.tag).distinct().order_by(ResearchTag.tag))).all()
|
(await self.db.scalars(select(ResearchTag.tag).where(ResearchTag.alpha_id.in_(ids)).distinct().order_by(ResearchTag.tag))).all()
|
||||||
)
|
)
|
||||||
result["total"] = await self.db.scalar(select(func.count()).select_from(Alpha))
|
result["total"] = await self.db.scalar(select(func.count()).select_from(Alpha).where(Alpha.id.in_(ids)))
|
||||||
result["favorites"] = await self.db.scalar(
|
result["favorites"] = await self.db.scalar(
|
||||||
select(func.count()).select_from(Research).where(Research.favorite.is_(True))
|
select(func.count()).select_from(Research).where(Research.favorite.is_(True), Research.alpha_id.in_(ids))
|
||||||
)
|
)
|
||||||
result["last_sync"] = await self.db.scalar(select(func.max(Alpha.synced_at)))
|
result["last_sync"] = await self.db.scalar(select(func.max(Alpha.synced_at)).where(Alpha.id.in_(ids)))
|
||||||
result["source"] = sorted(
|
result["source"] = sorted(
|
||||||
{kind for kinds in (await source_kinds(self.db)).values() for kind in kinds}
|
{kind for kinds in (await source_kinds(self.db, list(await self.db.scalars(ids)))).values() for kind in kinds}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ def setting_rows(data):
|
|||||||
for key in (
|
for key in (
|
||||||
"decay", "truncation", "pasteurization", "unitHandling",
|
"decay", "truncation", "pasteurization", "unitHandling",
|
||||||
"nanHandling", "language", "visualization", "maxTrade", "maxPosition",
|
"nanHandling", "language", "visualization", "maxTrade", "maxPosition",
|
||||||
|
"selectionHandling", "selectionLimit", "componentActivation",
|
||||||
):
|
):
|
||||||
definition = children.get(key)
|
definition = children.get(key)
|
||||||
if not isinstance(definition, dict):
|
if not isinstance(definition, dict):
|
||||||
@@ -200,11 +201,15 @@ class ResearchMetadata:
|
|||||||
raise HTTPException(502, "算子分页提前结束")
|
raise HTTPException(502, "算子分页提前结束")
|
||||||
raise HTTPException(502, "算子分页超过本地限制,未发布新快照")
|
raise HTTPException(502, "算子分页超过本地限制,未发布新快照")
|
||||||
|
|
||||||
async def operators(self, q="", category=None, favorite=False, limit=25, offset=0):
|
async def operators(self, q="", category=None, favorite=False, limit=25, offset=0, stage=None):
|
||||||
snapshot = await self.get("operators")
|
snapshot = await self.get("operators")
|
||||||
notes = {r.name: r for r in await self.db.scalars(select(OperatorNote))}
|
notes = {r.name: r for r in await self.db.scalars(select(OperatorNote))}
|
||||||
rows = []
|
rows = []
|
||||||
for item in snapshot["content"].get("items", []):
|
for item in snapshot["content"].get("items", []):
|
||||||
|
scopes = item.get("scope") or []
|
||||||
|
scopes = scopes if isinstance(scopes, list) else [scopes]
|
||||||
|
if stage and stage.upper() not in [str(s).upper() for s in scopes]:
|
||||||
|
continue
|
||||||
note = notes.get(item["name"])
|
note = notes.get(item["name"])
|
||||||
if q.lower() not in json.dumps(item, ensure_ascii=False).lower() or (
|
if q.lower() not in json.dumps(item, ensure_ascii=False).lower() or (
|
||||||
category and item["category"] != category
|
category and item["category"] != category
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ async def operators(
|
|||||||
q: str = "",
|
q: str = "",
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
favorite: bool = False,
|
favorite: bool = False,
|
||||||
|
stage: str | None = None,
|
||||||
limit: int = Query(25, ge=1, le=100),
|
limit: int = Query(25, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
):
|
):
|
||||||
async with request.app.state.sessions() as db:
|
async with request.app.state.sessions() as db:
|
||||||
return await ResearchMetadata(db).operators(q, category, favorite, limit, offset)
|
return await ResearchMetadata(db).operators(q, category, favorite, limit, offset, stage)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/operators/refresh")
|
@router.post("/operators/refresh")
|
||||||
|
|||||||
+5
-1
@@ -265,6 +265,10 @@ class Runner:
|
|||||||
await sync_catalog(self, job_id, payload)
|
await sync_catalog(self, job_id, payload)
|
||||||
elif kind in ("full_sync", "daily_sync"):
|
elif kind in ("full_sync", "daily_sync"):
|
||||||
await self.sync_all(job_id)
|
await self.sync_all(job_id)
|
||||||
|
elif kind == "super_selection_preview":
|
||||||
|
from .superalpha.jobs import run_selection
|
||||||
|
|
||||||
|
await run_selection(self, job_id, payload)
|
||||||
elif kind == "submission_check":
|
elif kind == "submission_check":
|
||||||
from .submission import run_check
|
from .submission import run_check
|
||||||
|
|
||||||
@@ -302,7 +306,7 @@ class Runner:
|
|||||||
await self.checkpoint(
|
await self.checkpoint(
|
||||||
job_id,
|
job_id,
|
||||||
{
|
{
|
||||||
"status": "waiting_connection" if waiting or (kind == "catalog_full_sync" and exc.code == "network_error") else "failed",
|
"status": "waiting_connection" if waiting or (kind in ("catalog_full_sync", "super_selection_preview") and exc.code == "network_error") else "failed",
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
"next_retry_at": None,
|
"next_retry_at": None,
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-3
@@ -6,7 +6,7 @@ import io
|
|||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from typing import Annotated
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
@@ -56,6 +56,7 @@ from .schemas import (
|
|||||||
)
|
)
|
||||||
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
|
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
|
||||||
from .submission import router as submission_router
|
from .submission import router as submission_router
|
||||||
|
from .superalpha.routes import router as superalpha_router
|
||||||
|
|
||||||
|
|
||||||
def account_output(account, client, settings):
|
def account_output(account, client, settings):
|
||||||
@@ -344,9 +345,9 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
|||||||
return await Business(db).search_alphas(filters)
|
return await Business(db).search_alphas(filters)
|
||||||
|
|
||||||
@api.get("/alphas/facets", response_model=FacetsOutput, tags=["alphas"])
|
@api.get("/alphas/facets", response_model=FacetsOutput, tags=["alphas"])
|
||||||
async def facets():
|
async def facets(management_scope: Literal["super", "non_super"] | None = None):
|
||||||
async with sessions() as db:
|
async with sessions() as db:
|
||||||
return await Business(db).get_alpha_facets()
|
return await Business(db).get_alpha_facets(management_scope)
|
||||||
|
|
||||||
@api.get(
|
@api.get(
|
||||||
"/alphas/export",
|
"/alphas/export",
|
||||||
@@ -487,6 +488,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
|||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(home_information_router)
|
app.include_router(home_information_router)
|
||||||
app.include_router(backtest_router)
|
app.include_router(backtest_router)
|
||||||
|
app.include_router(superalpha_router)
|
||||||
app.include_router(api)
|
app.include_router(api)
|
||||||
app.include_router(catalog_router)
|
app.include_router(catalog_router)
|
||||||
app.include_router(preparations_router)
|
app.include_router(preparations_router)
|
||||||
|
|||||||
@@ -18,9 +18,18 @@ from ..models import MCPAudit, now
|
|||||||
from ..research.serialization import encode_snapshot
|
from ..research.serialization import encode_snapshot
|
||||||
from ..research_access import contracts as c
|
from ..research_access import contracts as c
|
||||||
from ..research_access.service import ResearchAccess, ResearchError
|
from ..research_access.service import ResearchAccess, ResearchError
|
||||||
|
from ..superalpha import contracts as sc
|
||||||
|
|
||||||
# Name, schema, business method, required scope, description. No generic arbitrary HTTP tool.
|
# Name, schema, business method, required scope, description. No generic arbitrary HTTP tool.
|
||||||
TOOLS = {
|
TOOLS = {
|
||||||
|
"search_superalpha_plans": (sc.PlanSearch, "super_plans", "research:read", "分页查找 Super Alpha 研究方案。"),
|
||||||
|
"get_superalpha_plan": (sc.PlanReference, "super_plan", "research:read", "读取指定方案版本或固定构造记录;不发起回测。"),
|
||||||
|
"save_superalpha_plan": (sc.PlanSave, "save_super_plan", "research:write", "保存调用方构造的 Selection/Combo 参数方案;更新须携带版本,支持幂等。不调用模型或回测。"),
|
||||||
|
"preview_superalpha_selection": (sc.SelectionPreview, "preview_super_selection", "research:refresh", "主动预览展开后的 Selection;异步返回 job_id,用 get_refresh_job 查进度、get_superalpha_selection 查组件。预览不是实际回测组件。"),
|
||||||
|
"get_superalpha_selection": (sc.SelectionReference, "super_selection", "research:read", "分页读取组件预览及完整性、时间、警告;缺失不自动刷新。"),
|
||||||
|
"build_superalpha_candidates": (sc.BuildCandidates, "build_super_candidates", "research:write", "按方案版本或内联方案进行全量展开/固定种子采样;保存固定候选及来源,不执行回测。将 candidates 与 submit_source 交给 submit_backtests;超过100项按分页读取固定记录。"),
|
||||||
|
"search_superalphas": (sc.SuperAlphaSearch, "super_alphas", "research:read", "分页查询本地已导入的 SUPER 成果,固定 SUPER 范围;不自动同步。"),
|
||||||
|
"get_superalpha": (sc.AlphaReference, "super_alpha", "research:read", "读取已导入 SUPER 的 Selection/Combo、指标、组件证据、Description 和研究来源。"),
|
||||||
"get_pyramid_distribution": (c.PyramidQuery, "pyramid_distribution", "research:read", "实时读取指定 region(如 USA、GLB)和 delay(0/1)的个人 Pyramid Alpha 分布;必传 current_date(YYYY-MM-DD),自动按自然年四季度取完整起止日(如2026-09-13对应2026-07-01至2026-09-30),传给平台 startDate/endDate,不使用默认周期。按用户约定 alphaCount>=3 为 lit(已点亮),1–2 为 in_progress,0 为 unlit;每项含 category、alpha_count、距3条的 remaining。复用平台认证,未连接时先调用 authenticate_worldquant;缺失数据不当作0。不回测、不提交。"),
|
"get_pyramid_distribution": (c.PyramidQuery, "pyramid_distribution", "research:read", "实时读取指定 region(如 USA、GLB)和 delay(0/1)的个人 Pyramid Alpha 分布;必传 current_date(YYYY-MM-DD),自动按自然年四季度取完整起止日(如2026-09-13对应2026-07-01至2026-09-30),传给平台 startDate/endDate,不使用默认周期。按用户约定 alphaCount>=3 为 lit(已点亮),1–2 为 in_progress,0 为 unlit;每项含 category、alpha_count、距3条的 remaining。复用平台认证,未连接时先调用 authenticate_worldquant;缺失数据不当作0。不回测、不提交。"),
|
||||||
"search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"),
|
"search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"),
|
||||||
"get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"),
|
"get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"),
|
||||||
@@ -37,7 +46,7 @@ TOOLS = {
|
|||||||
"check_self_correlation": (c.SelfCorrelationCheck, "check_self_correlation", "research:refresh", "对 1–100 个已导入 Alpha 发起本地自相关检查,返回 job_id。与本地同地区已提交 Alpha 比较,排除自身;优先用缓存,缺失 PnL 自动补取。需先同步已提交 Alpha;不调用平台提交检查。用 get_refresh_job 查进度、get_self_correlation 读结果。"),
|
"check_self_correlation": (c.SelfCorrelationCheck, "check_self_correlation", "research:refresh", "对 1–100 个已导入 Alpha 发起本地自相关检查,返回 job_id。与本地同地区已提交 Alpha 比较,排除自身;优先用缓存,缺失 PnL 自动补取。需先同步已提交 Alpha;不调用平台提交检查。用 get_refresh_job 查进度、get_self_correlation 读结果。"),
|
||||||
"get_self_correlation": (c.SelfCorrelationReference, "self_correlation", "research:read", "读取指定 Alpha 最新的本地自相关缓存,包括最大相关系数、样本覆盖与 stale 状态;无缓存不自动检查,结果不等同于平台提交资格。"),
|
"get_self_correlation": (c.SelfCorrelationReference, "self_correlation", "research:read", "读取指定 Alpha 最新的本地自相关缓存,包括最大相关系数、样本覆盖与 stale 状态;无缓存不自动检查,结果不等同于平台提交资格。"),
|
||||||
"search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"),
|
"search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"),
|
||||||
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的固定批次,自动留痕并立即返回运行 ID。可携带 preparation_refs 选择集合,版本变化须重新读取;每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"),
|
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的 REGULAR/SUPER 固定批次,自动留痕并立即返回运行 ID;SUPER 使用 selection/combo 和专属设置,逐条模拟。可携带 preparation_refs 选择集合,版本变化须重新读取;每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"),
|
||||||
"get_backtest": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"),
|
"get_backtest": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"),
|
||||||
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、Alpha 非通过检查及三层状态;REGULAR_SUBMISSION 单列 submission_limits,不计入 Alpha 失败统计。缺失指标不补零。"),
|
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、Alpha 非通过检查及三层状态;REGULAR_SUBMISSION 单列 submission_limits,不计入 Alpha 失败统计。缺失指标不补零。"),
|
||||||
"get_backtest_artifact": (c.Artifact, "artifact", "research:read", "分页读取候选脱敏快照的顶层键值或独立采集的 PnL;缺缓存不自动刷新。"),
|
"get_backtest_artifact": (c.Artifact, "artifact", "research:read", "分页读取候选脱敏快照的顶层键值或独立采集的 PnL;缺缓存不自动刷新。"),
|
||||||
@@ -73,8 +82,8 @@ class MCPResearchServer:
|
|||||||
return types.ListToolsResult(tools=[types.Tool(name=name, description=description,
|
return types.ListToolsResult(tools=[types.Tool(name=name, description=description,
|
||||||
inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations(
|
inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations(
|
||||||
readOnlyHint=scope == "research:read", destructiveHint=method == "control",
|
readOnlyHint=scope == "research:read", destructiveHint=method == "control",
|
||||||
idempotentHint=method in {"submit", "control", "create_template"} or scope == "research:read",
|
idempotentHint=method in {"submit", "control", "create_template", "save_super_plan", "build_super_candidates"} or scope == "research:read",
|
||||||
openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation", "check_submission", "authenticate", "pyramid_distribution"}))
|
openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation", "check_submission", "authenticate", "pyramid_distribution", "preview_super_selection"}))
|
||||||
for name, (schema, method, scope, description) in TOOLS.items()
|
for name, (schema, method, scope, description) in TOOLS.items()
|
||||||
if scope in principal.scopes and "research:read" in principal.scopes])
|
if scope in principal.scopes and "research:read" in principal.scopes])
|
||||||
|
|
||||||
@@ -127,7 +136,7 @@ class MCPResearchServer:
|
|||||||
data = {"error": ResearchError(code, "研究操作失败;可使用原幂等键重试或查询历史", retryable=True).data}
|
data = {"error": ResearchError(code, "研究操作失败;可使用原幂等键重试或查询历史", retryable=True).data}
|
||||||
db.add(MCPAudit(id=str(uuid4()), token_id=principal.token_id, tool=name,
|
db.add(MCPAudit(id=str(uuid4()), token_id=principal.token_id, tool=name,
|
||||||
request_id=fingerprint({"request_id": request_id}), input_digest=digest,
|
request_id=fingerprint({"request_id": request_id}), input_digest=digest,
|
||||||
business_id=data.get("backtest_run_id", data.get("job_id", data.get("template_id"))),
|
business_id=data.get("backtest_run_id", data.get("job_id", data.get("template_id", data.get("id")))),
|
||||||
result_code=code, elapsed_ms=int((time.monotonic()-started)*1000)))
|
result_code=code, elapsed_ms=int((time.monotonic()-started)*1000)))
|
||||||
if not error:
|
if not error:
|
||||||
if access.wake == "backtests":
|
if access.wake == "backtests":
|
||||||
|
|||||||
@@ -301,6 +301,9 @@ class BacktestItem(Base):
|
|||||||
client_item_id: Mapped[str] = mapped_column(String(100))
|
client_item_id: Mapped[str] = mapped_column(String(100))
|
||||||
ordinal: Mapped[int] = mapped_column(Integer)
|
ordinal: Mapped[int] = mapped_column(Integer)
|
||||||
expression: Mapped[str] = mapped_column(Text)
|
expression: Mapped[str] = mapped_column(Text)
|
||||||
|
alpha_type: Mapped[str] = mapped_column(String(20), default="REGULAR", server_default="REGULAR", index=True)
|
||||||
|
selection: Mapped[str | None] = mapped_column(Text)
|
||||||
|
combo: Mapped[str | None] = mapped_column(Text)
|
||||||
settings: Mapped[dict] = mapped_column(JSON)
|
settings: Mapped[dict] = mapped_column(JSON)
|
||||||
fingerprint: Mapped[str] = mapped_column(String(64), index=True)
|
fingerprint: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
platform_status: Mapped[str] = mapped_column(String(30), default="pending")
|
platform_status: Mapped[str] = mapped_column(String(30), default="pending")
|
||||||
@@ -322,6 +325,25 @@ class BacktestResult(Base):
|
|||||||
complete: Mapped[bool] = mapped_column(Boolean, default=True)
|
complete: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SuperSelectionSnapshot(Base):
|
||||||
|
"""Immutable platform component evidence; previews never replace actual components."""
|
||||||
|
|
||||||
|
__tablename__ = "super_selection_snapshots"
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
job_id: Mapped[str | None] = mapped_column(ForeignKey("sync_jobs.id"), unique=True)
|
||||||
|
item_id: Mapped[str | None] = mapped_column(ForeignKey("backtest_items.id"), unique=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(20))
|
||||||
|
request: Mapped[dict] = mapped_column(JSON)
|
||||||
|
request_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
component_hash: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
|
components: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
raw: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
total: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
warnings: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|
||||||
|
|
||||||
class BacktestEvent(Base):
|
class BacktestEvent(Base):
|
||||||
__tablename__ = "backtest_events"
|
__tablename__ = "backtest_events"
|
||||||
run_id: Mapped[str] = mapped_column(ForeignKey("backtest_runs.id"), primary_key=True)
|
run_id: Mapped[str] = mapped_column(ForeignKey("backtest_runs.id"), primary_key=True)
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ class Assets:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def save(self, body, asset_id=None, provenance=None):
|
async def save(self, body, asset_id=None, provenance=None):
|
||||||
|
from ..superalpha.contracts import PlanSpec
|
||||||
|
|
||||||
schema = {
|
schema = {
|
||||||
|
"superalpha_plan": PlanSpec,
|
||||||
"template": TemplateSpec,
|
"template": TemplateSpec,
|
||||||
"feature": FeatureSpec,
|
"feature": FeatureSpec,
|
||||||
"view": ViewSpec,
|
"view": ViewSpec,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from ..preparations.contracts import PreparationReference
|
|||||||
from ..schemas import Contract
|
from ..schemas import Contract
|
||||||
from .expressions import IDENTIFIER, PLACEHOLDER, normalize_template
|
from .expressions import IDENTIFIER, PLACEHOLDER, normalize_template
|
||||||
|
|
||||||
AssetKind = Literal["template", "feature", "view", "workflow"]
|
AssetKind = Literal["template", "feature", "view", "workflow", "superalpha_plan"]
|
||||||
|
|
||||||
|
|
||||||
class Variable(Contract):
|
class Variable(Contract):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Annotated, Literal
|
|||||||
|
|
||||||
from pydantic import Field, model_validator
|
from pydantic import Field, model_validator
|
||||||
|
|
||||||
from ..backtests.contracts import Candidate, SimulationSettings
|
from ..backtests.contracts import Candidate, SimulationSettings, SuperSimulationSettings
|
||||||
from ..catalog.contracts import CatalogFilters, Scope
|
from ..catalog.contracts import CatalogFilters, Scope
|
||||||
from ..preparations.contracts import PreparationReference
|
from ..preparations.contracts import PreparationReference
|
||||||
from ..research.workspace_contracts import TemplateSpec
|
from ..research.workspace_contracts import TemplateSpec
|
||||||
@@ -50,11 +50,26 @@ class CompleteSettings(SimulationSettings):
|
|||||||
model_config = {"json_schema_extra": {"required": list(SimulationSettings.model_fields)}}
|
model_config = {"json_schema_extra": {"required": list(SimulationSettings.model_fields)}}
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteSuperSettings(SuperSimulationSettings):
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def complete(cls, value):
|
||||||
|
if isinstance(value, dict) and set(cls.model_fields) - value.keys():
|
||||||
|
raise ValueError("必须提供每项完整 SUPER 设置;先读取 get_research_capabilities")
|
||||||
|
return value
|
||||||
|
|
||||||
|
model_config = {"json_schema_extra": {"required": list(SuperSimulationSettings.model_fields)}}
|
||||||
|
|
||||||
|
|
||||||
class DirectCandidate(Candidate):
|
class DirectCandidate(Candidate):
|
||||||
settings: CompleteSettings
|
settings: CompleteSuperSettings | CompleteSettings
|
||||||
|
|
||||||
|
|
||||||
class Provenance(Contract):
|
class Provenance(Contract):
|
||||||
|
research_id: RunId | None = None
|
||||||
|
superalpha_plan_id: RunId | None = None
|
||||||
|
superalpha_plan_version: int | None = Field(default=None, ge=1)
|
||||||
|
selection_snapshot_ids: list[RunId] = Field(default_factory=list, max_length=100)
|
||||||
reference: str | None = Field(default=None, max_length=200)
|
reference: str | None = Field(default=None, max_length=200)
|
||||||
batch_id: str | None = Field(default=None, max_length=200)
|
batch_id: str | None = Field(default=None, max_length=200)
|
||||||
hypothesis: str | None = Field(default=None, max_length=2000)
|
hypothesis: str | None = Field(default=None, max_length=2000)
|
||||||
@@ -129,12 +144,18 @@ class Scopes(Contract):
|
|||||||
|
|
||||||
class SettingOptions(Page):
|
class SettingOptions(Page):
|
||||||
kind: Literal["settings"]
|
kind: Literal["settings"]
|
||||||
|
alpha_type: Literal["REGULAR", "SUPER"] = "REGULAR"
|
||||||
|
|
||||||
|
|
||||||
class Operators(Page):
|
class Operators(Page):
|
||||||
kind: Literal["operators"]
|
kind: Literal["operators"]
|
||||||
q: str = Field(default="", max_length=300)
|
q: str = Field(default="", max_length=300)
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
|
stage: Literal["REGULAR", "SELECTION", "COMBO"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SuperMetadata(Contract):
|
||||||
|
kind: Literal["superalpha"]
|
||||||
|
|
||||||
|
|
||||||
class Availability(Contract):
|
class Availability(Contract):
|
||||||
@@ -144,7 +165,7 @@ class Availability(Contract):
|
|||||||
|
|
||||||
|
|
||||||
class Metadata(Contract):
|
class Metadata(Contract):
|
||||||
query: Annotated[Scopes | SettingOptions | Operators | Availability, Field(discriminator="kind")]
|
query: Annotated[Scopes | SettingOptions | Operators | Availability | SuperMetadata, Field(discriminator="kind")]
|
||||||
|
|
||||||
|
|
||||||
class CatalogRefresh(Contract):
|
class CatalogRefresh(Contract):
|
||||||
@@ -192,6 +213,8 @@ class SubmissionCheck(Contract):
|
|||||||
|
|
||||||
|
|
||||||
class History(Page):
|
class History(Page):
|
||||||
|
research_id: str | None = Field(default=None, max_length=36)
|
||||||
|
alpha_type: Literal["REGULAR", "SUPER"] | None = None
|
||||||
source: str | None = Field(default=None, max_length=100)
|
source: str | None = Field(default=None, max_length=100)
|
||||||
reference: str | None = Field(default=None, max_length=200)
|
reference: str | None = Field(default=None, max_length=200)
|
||||||
status: str | None = Field(default=None, max_length=30)
|
status: str | None = Field(default=None, max_length=30)
|
||||||
@@ -224,13 +247,13 @@ class Results(Page):
|
|||||||
|
|
||||||
class Artifact(Page):
|
class Artifact(Page):
|
||||||
item_id: RunId
|
item_id: RunId
|
||||||
kind: Literal["snapshot", "pnl"]
|
kind: Literal["snapshot", "pnl", "components"]
|
||||||
date_from: date | None = None
|
date_from: date | None = None
|
||||||
date_to: date | None = None
|
date_to: date | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def dates(self):
|
def dates(self):
|
||||||
if self.kind == "snapshot" and (self.date_from or self.date_to):
|
if self.kind != "pnl" and (self.date_from or self.date_to):
|
||||||
raise ValueError("日期筛选仅用于 PnL")
|
raise ValueError("日期筛选仅用于 PnL")
|
||||||
if self.date_from and self.date_to and self.date_from > self.date_to:
|
if self.date_from and self.date_to and self.date_from > self.date_to:
|
||||||
raise ValueError("起始日期不能晚于结束日期")
|
raise ValueError("起始日期不能晚于结束日期")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
|
|
||||||
from ..alphas import number, sanitize
|
from ..alphas import number, sanitize
|
||||||
from ..backtests.contracts import fingerprint
|
from ..backtests.contracts import fingerprint
|
||||||
@@ -54,7 +54,7 @@ def item_summary(item, result):
|
|||||||
("sharpe", "fitness", "returns", "turnover", "margin", "drawdown")}
|
("sharpe", "fitness", "returns", "turnover", "margin", "drawdown")}
|
||||||
return encode_snapshot({
|
return encode_snapshot({
|
||||||
**{k: getattr(item, k) for k in (
|
**{k: getattr(item, k) for k in (
|
||||||
"id", "run_id", "client_item_id", "expression", "settings", "attempt_id",
|
"id", "run_id", "client_item_id", "expression", "selection", "combo", "alpha_type", "settings", "attempt_id",
|
||||||
"platform_status", "collection_status", "persistence_status", "simulation_id", "alpha_id",
|
"platform_status", "collection_status", "persistence_status", "simulation_id", "alpha_id",
|
||||||
)},
|
)},
|
||||||
"error": sanitize(item.error), "metrics": metrics,
|
"error": sanitize(item.error), "metrics": metrics,
|
||||||
@@ -73,10 +73,12 @@ class EvidenceQueries:
|
|||||||
query = select(BacktestItem, BacktestResult, BacktestRun).join(
|
query = select(BacktestItem, BacktestResult, BacktestRun).join(
|
||||||
BacktestRun, BacktestRun.id == BacktestItem.run_id
|
BacktestRun, BacktestRun.id == BacktestItem.run_id
|
||||||
).outerjoin(BacktestResult, BacktestResult.item_id == BacktestItem.id)
|
).outerjoin(BacktestResult, BacktestResult.item_id == BacktestItem.id)
|
||||||
for key in ("source", "reference"):
|
for key in ("source", "reference", "research_id"):
|
||||||
value = getattr(args, key)
|
value = getattr(args, key)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
query = query.where(BacktestRun.source["kind" if key == "source" else key].as_string() == value)
|
query = query.where(BacktestRun.source["kind" if key == "source" else key].as_string() == value)
|
||||||
|
if args.alpha_type:
|
||||||
|
query = query.where(BacktestItem.alpha_type == args.alpha_type)
|
||||||
if args.status:
|
if args.status:
|
||||||
query = query.where(BacktestRun.status == args.status)
|
query = query.where(BacktestRun.status == args.status)
|
||||||
if args.created_from:
|
if args.created_from:
|
||||||
@@ -89,7 +91,7 @@ class EvidenceQueries:
|
|||||||
query = query.where(BacktestItem.settings["delay"].as_integer() == args.scope.delay)
|
query = query.where(BacktestItem.settings["delay"].as_integer() == args.scope.delay)
|
||||||
if args.q:
|
if args.q:
|
||||||
escaped = args.q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
escaped = args.q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
query = query.where(BacktestItem.expression.ilike(f"%{escaped}%", escape="\\"))
|
query = query.where(or_(*(getattr(BacktestItem, key).ilike(f"%{escaped}%", escape="\\") for key in ("expression", "selection", "combo"))))
|
||||||
matches = {}
|
matches = {}
|
||||||
if args.candidates:
|
if args.candidates:
|
||||||
for c in args.candidates:
|
for c in args.candidates:
|
||||||
@@ -120,6 +122,9 @@ class EvidenceQueries:
|
|||||||
if not item:
|
if not item:
|
||||||
raise ResearchError("NOT_FOUND", "候选不存在")
|
raise ResearchError("NOT_FOUND", "候选不存在")
|
||||||
result = await self.db.get(BacktestResult, item.id)
|
result = await self.db.get(BacktestResult, item.id)
|
||||||
|
if args.kind == "components":
|
||||||
|
from ..superalpha.evidence import actual_components
|
||||||
|
return await actual_components(self.db, item.id, args.limit, args.offset)
|
||||||
if args.kind == "snapshot":
|
if args.kind == "snapshot":
|
||||||
# Top-level entries retain complete nested values; no hidden string/list truncation.
|
# Top-level entries retain complete nested values; no hidden string/list truncation.
|
||||||
entries = [{"key": k, "value": v} for k, v in sanitize(result.snapshot).items()] if result else []
|
entries = [{"key": k, "value": v} for k, v in sanitize(result.snapshot).items()] if result else []
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from ..research.workspace_contracts import FieldAvailabilityInput
|
|||||||
from ..schemas import JobInput
|
from ..schemas import JobInput
|
||||||
from ..submission import CheckInput, correlation_allows_check, create_check_job, local_alpha, source
|
from ..submission import CheckInput, correlation_allows_check, create_check_job, local_alpha, source
|
||||||
from ..submission import fingerprint as submission_fingerprint
|
from ..submission import fingerprint as submission_fingerprint
|
||||||
|
from ..superalpha.access import SuperResearchAccess
|
||||||
from ..worldquant import WqError
|
from ..worldquant import WqError
|
||||||
from .contracts import DirectCandidate, History
|
from .contracts import DirectCandidate, History
|
||||||
from .queries import EvidenceQueries, page
|
from .queries import EvidenceQueries, page
|
||||||
@@ -37,7 +38,7 @@ class ResearchError(Exception):
|
|||||||
"retry_after": retry_after, "affected_items": affected_items or []}
|
"retry_after": retry_after, "affected_items": affected_items or []}
|
||||||
|
|
||||||
|
|
||||||
class ResearchAccess:
|
class ResearchAccess(SuperResearchAccess):
|
||||||
def __init__(self, db, principal, client, public_origin):
|
def __init__(self, db, principal, client, public_origin):
|
||||||
self.db, self.principal, self.client = db, principal, client
|
self.db, self.principal, self.client = db, principal, client
|
||||||
self.public_origin = public_origin.rstrip("/")
|
self.public_origin = public_origin.rstrip("/")
|
||||||
@@ -71,6 +72,10 @@ class ResearchAccess:
|
|||||||
async def capabilities(self, args):
|
async def capabilities(self, args):
|
||||||
return {**await self.backtests.capabilities(), "max_candidates": 100,
|
return {**await self.backtests.capabilities(), "max_candidates": 100,
|
||||||
"settings_schema": DirectCandidate.model_json_schema(),
|
"settings_schema": DirectCandidate.model_json_schema(),
|
||||||
|
"superalpha": {"plan_with": "save_superalpha_plan", "build_with": "build_superalpha_candidates",
|
||||||
|
"preview_with": "preview_superalpha_selection", "read_selection_with": "get_superalpha_selection",
|
||||||
|
"job_with": "get_refresh_job", "backtest_with": "submit_backtests", "platform_batch_size": 1,
|
||||||
|
"component_evidence": "预览与实际组件分别记录,未知不能认定为同池"},
|
||||||
"confirmation": "调用者须已获本批执行授权;直接提交后返回稳定运行 ID",
|
"confirmation": "调用者须已获本批执行授权;直接提交后返回稳定运行 ID",
|
||||||
"duplicate_policies": ["reject", "rerun"], "permissions": sorted(self.principal.scopes),
|
"duplicate_policies": ["reject", "rerun"], "permissions": sorted(self.principal.scopes),
|
||||||
"metadata_only": True, "actual_platform_allowance": None,
|
"metadata_only": True, "actual_platform_allowance": None,
|
||||||
@@ -159,15 +164,23 @@ class ResearchAccess:
|
|||||||
async def metadata(self, args):
|
async def metadata(self, args):
|
||||||
q = args.query
|
q = args.query
|
||||||
metadata = ResearchMetadata(self.db)
|
metadata = ResearchMetadata(self.db)
|
||||||
|
if q.kind == "superalpha":
|
||||||
|
from ..superalpha.metadata import metadata as super_metadata
|
||||||
|
return await super_metadata(self.db)
|
||||||
if q.kind == "scopes":
|
if q.kind == "scopes":
|
||||||
return {"source": "worldquant_platform", **await platform_options(self.client)}
|
return {"source": "worldquant_platform", **await platform_options(self.client)}
|
||||||
if q.kind == "operators":
|
if q.kind == "operators":
|
||||||
data = await metadata.operators(q.q, q.category, limit=q.limit, offset=q.offset)
|
data = await metadata.operators(q.q, q.category, limit=q.limit, offset=q.offset, stage=q.stage)
|
||||||
return {**data, "status": "available" if data["fetched_at"] else "not_cached",
|
return {**data, "status": "available" if data["fetched_at"] else "not_cached",
|
||||||
"has_more": q.offset + len(data["items"]) < data["total"]}
|
"has_more": q.offset + len(data["items"]) < data["total"]}
|
||||||
if q.kind == "settings":
|
if q.kind == "settings":
|
||||||
data = await metadata.get("settings")
|
data = await metadata.get("settings")
|
||||||
items = data["content"].get("items", [])
|
items = data["content"].get("items", [])
|
||||||
|
special = {"selectionHandling", "selectionLimit", "componentActivation"}
|
||||||
|
if q.alpha_type == "REGULAR":
|
||||||
|
items = [{**r, "fields": {k: v for k, v in r.get("fields", {}).items() if k not in special}} for r in items]
|
||||||
|
else:
|
||||||
|
items = [{**r, "super_settings_completeness": "cached" if special <= r.get("fields", {}).keys() else "unknown"} for r in items]
|
||||||
return {"status": "available" if data["fetched_at"] else "not_cached",
|
return {"status": "available" if data["fetched_at"] else "not_cached",
|
||||||
"fetched_at": data["fetched_at"], **page(items[q.offset:q.offset+q.limit], len(items), q.limit, q.offset)}
|
"fetched_at": data["fetched_at"], **page(items[q.offset:q.offset+q.limit], len(items), q.limit, q.offset)}
|
||||||
data = await metadata.get(availability_key(q.field_id, q.scope))
|
data = await metadata.get(availability_key(q.field_id, q.scope))
|
||||||
@@ -201,14 +214,16 @@ class ResearchAccess:
|
|||||||
|
|
||||||
async def refresh_job(self, args):
|
async def refresh_job(self, args):
|
||||||
job = await self.db.get(Job, args.job_id)
|
job = await self.db.get(Job, args.job_id)
|
||||||
if not job or job.kind not in {"catalog_sync", "field_sync", "pnl_refresh", "self_correlation", "submission_check"}:
|
if not job or job.kind not in {"catalog_sync", "field_sync", "pnl_refresh", "self_correlation", "submission_check", "super_selection_preview"}:
|
||||||
raise ResearchError("NOT_FOUND", "研究刷新任务不存在")
|
raise ResearchError("NOT_FOUND", "研究刷新任务不存在")
|
||||||
result = await self.business.get_job_status(args.job_id)
|
result = await self.business.get_job_status(args.job_id)
|
||||||
query = select(JobItem).where(JobItem.job_id == job.id, JobItem.error.is_not(None))
|
query = select(JobItem).where(JobItem.job_id == job.id, JobItem.error.is_not(None))
|
||||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
errors = list(await self.db.scalars(query.order_by(JobItem.alpha_id).limit(args.limit).offset(args.offset)))
|
errors = list(await self.db.scalars(query.order_by(JobItem.alpha_id).limit(args.limit).offset(args.offset)))
|
||||||
result.pop("errors", None)
|
result.pop("errors", None)
|
||||||
return {**result, "job_id": job.id, "artifact_reference": job.payload,
|
artifact = ({"job_id": job.id, "snapshot_id": job.checkpoint.get("snapshot_id"), "read_with": "get_superalpha_selection"}
|
||||||
|
if job.kind == "super_selection_preview" else job.payload)
|
||||||
|
return {**result, "job_id": job.id, "artifact_reference": artifact,
|
||||||
"errors": page([{"alpha_id": e.alpha_id, "error": e.error} for e in errors], total, args.limit, args.offset)}
|
"errors": page([{"alpha_id": e.alpha_id, "error": e.error} for e in errors], total, args.limit, args.offset)}
|
||||||
|
|
||||||
async def check_self_correlation(self, args):
|
async def check_self_correlation(self, args):
|
||||||
@@ -306,7 +321,11 @@ class ResearchAccess:
|
|||||||
invalid.append(c.client_item_id)
|
invalid.append(c.client_item_id)
|
||||||
if invalid:
|
if invalid:
|
||||||
raise ResearchError("UNSUPPORTED_SETTINGS", "已缓存平台设置不支持这些组合;可显式刷新后重试", affected_items=invalid)
|
raise ResearchError("UNSUPPORTED_SETTINGS", "已缓存平台设置不支持这些组合;可显式刷新后重试", affected_items=invalid)
|
||||||
return {"settings_validation": "cached", "fetched_at": snapshot["fetched_at"], "field_validation": "unknown"}
|
result = {"settings_validation": "cached", "fetched_at": snapshot["fetched_at"], "field_validation": "unknown"}
|
||||||
|
if any(c.alpha_type == "SUPER" for c in candidates):
|
||||||
|
from ..superalpha.settings import validate_settings
|
||||||
|
result["super_settings_validation"] = await validate_settings(self.db, [c.settings for c in candidates if c.alpha_type == "SUPER"])
|
||||||
|
return result
|
||||||
|
|
||||||
async def submit(self, args):
|
async def submit(self, args):
|
||||||
previous, digest = await self.previous("submit_backtests", args)
|
previous, digest = await self.previous("submit_backtests", args)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class PreferencesInput(Contract):
|
|||||||
|
|
||||||
|
|
||||||
class AlphaFilters(Contract):
|
class AlphaFilters(Contract):
|
||||||
|
management_scope: Literal["super", "non_super"] | None = None
|
||||||
local_correlation_status: Literal["not_cached", "stale", "low", "high", "partial", "insufficient_data"] | None = None
|
local_correlation_status: Literal["not_cached", "stale", "low", "high", "partial", "insufficient_data"] | None = None
|
||||||
local_correlation_min: float | None = Field(default=None, ge=-1, le=1)
|
local_correlation_min: float | None = Field(default=None, ge=-1, le=1)
|
||||||
local_correlation_max: float | None = Field(default=None, ge=-1, le=1)
|
local_correlation_max: float | None = Field(default=None, ge=-1, le=1)
|
||||||
@@ -237,6 +238,9 @@ class AlphaSummary(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str | None
|
name: str | None
|
||||||
expression_preview: str
|
expression_preview: str
|
||||||
|
selection_preview: str = ""
|
||||||
|
combo_preview: str = ""
|
||||||
|
component_count: int | None = None
|
||||||
alpha_type: str | None
|
alpha_type: str | None
|
||||||
language: str | None
|
language: str | None
|
||||||
stage: str | None
|
stage: str | None
|
||||||
|
|||||||
@@ -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 "仅按缓存校验,仍需平台执行验证"}
|
||||||
@@ -468,6 +468,14 @@ class WqClient:
|
|||||||
"universe": scope.universe, "delay": scope.delay,
|
"universe": scope.universe, "delay": scope.delay,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async def run_super_selection(self, query):
|
||||||
|
"""Read cnhk super-selection contract with bounded async retries and shared authentication."""
|
||||||
|
allowed = {"selection", "instrumentType", "region", "delay", "selectionLimit", "selectionHandling"}
|
||||||
|
if set(query) != allowed:
|
||||||
|
raise WqError("Selection 参数不完整或包含未知键", "invalid_selection")
|
||||||
|
return await self._read_json("GET", "/simulations/super-selection", params=query, allow_list=True,
|
||||||
|
wait_for_retry_header=True)
|
||||||
|
|
||||||
async def research_setting_options(self):
|
async def research_setting_options(self):
|
||||||
"""Snapshot full setting choices for constrained research, including neutralization."""
|
"""Snapshot full setting choices for constrained research, including neutralization."""
|
||||||
return await self._read_json("OPTIONS", "/simulations")
|
return await self._read_json("OPTIONS", "/simulations")
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""SUPER candidates and immutable component evidence; retain all existing Alpha rows."""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0021"
|
||||||
|
down_revision = "0020"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("backtest_items", sa.Column("alpha_type", sa.String(20), nullable=False, server_default="REGULAR"))
|
||||||
|
op.add_column("backtest_items", sa.Column("selection", sa.Text(), nullable=True))
|
||||||
|
op.add_column("backtest_items", sa.Column("combo", sa.Text(), nullable=True))
|
||||||
|
op.create_index("ix_backtest_items_alpha_type", "backtest_items", ["alpha_type"])
|
||||||
|
op.create_table("super_selection_snapshots",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("job_id", sa.String(36), sa.ForeignKey("sync_jobs.id"), unique=True),
|
||||||
|
sa.Column("item_id", sa.String(36), sa.ForeignKey("backtest_items.id"), unique=True),
|
||||||
|
sa.Column("source", sa.String(20), nullable=False),
|
||||||
|
sa.Column("request", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("request_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("component_hash", sa.String(64)),
|
||||||
|
sa.Column("components", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("raw", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("complete", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("total", sa.Integer()),
|
||||||
|
sa.Column("warnings", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False))
|
||||||
|
for key in ("request_hash", "component_hash"):
|
||||||
|
op.create_index(f"ix_super_selection_snapshots_{key}", "super_selection_snapshots", [key])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("super_selection_snapshots")
|
||||||
|
op.drop_index("ix_backtest_items_alpha_type", table_name="backtest_items")
|
||||||
|
for key in ("combo", "selection", "alpha_type"):
|
||||||
|
op.drop_column("backtest_items", key)
|
||||||
@@ -16,10 +16,16 @@ class Platform:
|
|||||||
self.detail_fail = False
|
self.detail_fail = False
|
||||||
self.fail_child = None
|
self.fail_child = None
|
||||||
self.missing = False
|
self.missing = False
|
||||||
|
self.selection_reads = []
|
||||||
|
self.selection_result = {"count": 2, "results": [{"id": "component1", "value": 0.3}, {"id": "component2", "value": 0.7}]}
|
||||||
|
self.actual_components = {"count": 2, "results": [{"id": "component1"}, {"id": "component2"}]}
|
||||||
self.secret = "synthetic-platform-secret"
|
self.secret = "synthetic-platform-secret"
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
if path == "/simulations/super-selection":
|
||||||
|
self.selection_reads.append(dict(request.url.params))
|
||||||
|
return httpx.Response(200, json=self.selection_result)
|
||||||
if path == "/authentication":
|
if path == "/authentication":
|
||||||
return httpx.Response(201, json={})
|
return httpx.Response(201, json={})
|
||||||
if path == "/simulations" and request.method == "POST":
|
if path == "/simulations" and request.method == "POST":
|
||||||
@@ -39,29 +45,35 @@ class Platform:
|
|||||||
ids = []
|
ids = []
|
||||||
for i, item in enumerate(data):
|
for i, item in enumerate(data):
|
||||||
child = parent if len(data) == 1 else f"{parent}c{i}"
|
child = parent if len(data) == 1 else f"{parent}c{i}"
|
||||||
aid = self.existing_alpha_ids[i] if self.existing_alpha_ids else f"alpha{parent}{i}"
|
aid = self.existing_alpha_ids[i] if self.existing_alpha_ids and item["type"] != "SUPER" else f"alpha{parent}{i}"
|
||||||
progress = {
|
progress = {
|
||||||
"status": "COMPLETE",
|
"status": "COMPLETE",
|
||||||
"alpha": aid,
|
"alpha": aid,
|
||||||
"regular": item["regular"],
|
"regular": item.get("regular", ""),
|
||||||
"settings": item["settings"],
|
"settings": item["settings"],
|
||||||
}
|
}
|
||||||
if i == self.fail_child:
|
if i == self.fail_child:
|
||||||
progress = {
|
progress = {
|
||||||
"status": "FAILED",
|
"status": "FAILED",
|
||||||
"regular": item["regular"],
|
"regular": item.get("regular", ""),
|
||||||
"settings": item["settings"],
|
"settings": item["settings"],
|
||||||
"message": "invalid expression",
|
"message": "invalid expression",
|
||||||
}
|
}
|
||||||
self.simulations[child] = progress
|
self.simulations[child] = progress
|
||||||
self.alphas[aid] = {
|
self.alphas[aid] = {
|
||||||
"id": aid,
|
"id": aid,
|
||||||
"regular": {"code": item["regular"]},
|
"regular": {"code": item.get("regular", "")},
|
||||||
"type": "REGULAR",
|
"type": "REGULAR",
|
||||||
"settings": item["settings"],
|
"settings": item["settings"],
|
||||||
"is": {"sharpe": None, "fitness": 0.8},
|
"is": {"sharpe": None, "fitness": 0.8},
|
||||||
"status": "UNSUBMITTED",
|
"status": "UNSUBMITTED",
|
||||||
}
|
}
|
||||||
|
if item["type"] == "SUPER":
|
||||||
|
assert len(data) == 1, "SUPER must be submitted singly"
|
||||||
|
self.simulations[child].update(type="SUPER", selection=item["selection"], combo=item["combo"])
|
||||||
|
self.simulations[child].pop("regular", None)
|
||||||
|
self.alphas[aid].update(type="SUPER", selection={"code": item["selection"], "description": "Selection rationale"}, combo={"code": item["combo"], "description": "Combo rationale"}, components=self.actual_components)
|
||||||
|
self.alphas[aid].pop("regular", None)
|
||||||
ids.append(child)
|
ids.append(child)
|
||||||
if len(data) > 1:
|
if len(data) > 1:
|
||||||
self.simulations[parent] = {
|
self.simulations[parent] = {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Disposable PostgreSQL compatibility/concurrency acceptance, no external platform calls.
|
||||||
|
|
||||||
|
SUPER_TEST_DATABASE_URL must point to the local wq_superalpha_test database.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.alphas import upsert_alpha
|
||||||
|
from app.config import Settings
|
||||||
|
from app.main import create_app
|
||||||
|
from app.models import BacktestItem, Research
|
||||||
|
from app.superalpha.contracts import PlanSave
|
||||||
|
from app.superalpha.service import SuperResearch
|
||||||
|
from app.worldquant import WqClient
|
||||||
|
from tests.backtest_fake import Platform
|
||||||
|
from tests.test_backtests import execute, preview, setup, start
|
||||||
|
from tests.test_superalpha import plan, test_plan_selection_build_versions_and_generic_run
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(settings):
|
||||||
|
app = create_app(settings, WqClient(settings, transport=httpx.MockTransport(Platform())))
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
_, lane = await setup(app)
|
||||||
|
async with app.state.sessions.begin() as db:
|
||||||
|
await upsert_alpha(db, {"id": "legacy-super", "type": "SUPER", "selection": {"code": "turnover < 0.2"}, "combo": {"code": "1"}})
|
||||||
|
(await db.get(Research, "legacy-super")).note = "keep historical note"
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers={"X-WQ-Request": "1"}) as client:
|
||||||
|
await client.post("/api/v1/auth/login", json={"username": "admin", "password": "synthetic-admin-only"})
|
||||||
|
run = await start(client, await preview(client), "legacy")
|
||||||
|
await execute(app, lane, run["backtest_run_id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def verify(settings):
|
||||||
|
app = create_app(settings, WqClient(settings, transport=httpx.MockTransport(Platform())))
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
assert (await db.get(Research, "legacy-super")).note == "keep historical note"
|
||||||
|
item = await db.scalar(select(BacktestItem))
|
||||||
|
assert item.alpha_type == "REGULAR" and item.selection is None and item.combo is None
|
||||||
|
assert item.persistence_status == "saved"
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers={"X-WQ-Request": "1"}) as client:
|
||||||
|
await client.post("/api/v1/auth/login", json={"username": "admin", "password": "synthetic-admin-only"})
|
||||||
|
await test_plan_selection_build_versions_and_generic_run(app, client)
|
||||||
|
imported = (await client.get("/api/v1/alphas?management_scope=super&q=legacy-super")).json()
|
||||||
|
assert imported["total"] == 1
|
||||||
|
async def save_same():
|
||||||
|
async with app.state.sessions.begin() as db:
|
||||||
|
return await SuperResearch(db).save(PlanSave(plan=plan(), idempotency_key="concurrent-save"))
|
||||||
|
first, second = await asyncio.gather(save_same(), save_same())
|
||||||
|
assert first == second
|
||||||
|
print("PostgreSQL: preserved REGULAR backtest and existing SUPER/notes; SUPER lifecycle and concurrent save replay passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
url = os.environ["SUPER_TEST_DATABASE_URL"]
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
if parsed.hostname not in {"127.0.0.1", "localhost"} or parsed.path != "/wq_superalpha_test":
|
||||||
|
raise SystemExit("Refusing non-local/non-disposable database")
|
||||||
|
key = Fernet.generate_key().decode()
|
||||||
|
os.environ.update(DATABASE_URL=url, ADMIN_PASSWORD="synthetic-admin-only", ENCRYPTION_KEY=key, WQ_EMAIL="", WQ_PASSWORD="")
|
||||||
|
settings = Settings(_env_file=None, database_url=url, admin_password="synthetic-admin-only", encryption_key=key, enable_runner=False, public_origin="http://testserver")
|
||||||
|
config = Config("alembic.ini")
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
asyncio.run(seed(settings))
|
||||||
|
command.downgrade(config, "0020")
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
asyncio.run(verify(settings))
|
||||||
@@ -162,7 +162,7 @@ async def test_official_sdk_client_and_error_contract(mcp_app):
|
|||||||
async with ClientSession(streams[0], streams[1]) as client:
|
async with ClientSession(streams[0], streams[1]) as client:
|
||||||
await client.initialize()
|
await client.initialize()
|
||||||
listed = await client.list_tools()
|
listed = await client.list_tools()
|
||||||
assert len(listed.tools) == 21
|
assert len(listed.tools) == 29
|
||||||
assert any(tool.name == "get_pyramid_distribution" for tool in listed.tools)
|
assert any(tool.name == "get_pyramid_distribution" for tool in listed.tools)
|
||||||
assert {"search_data_preparations", "get_data_preparation"} <= {t.name for t in listed.tools}
|
assert {"search_data_preparations", "get_data_preparation"} <= {t.name for t in listed.tools}
|
||||||
caps = await client.call_tool("get_research_capabilities", {})
|
caps = await client.call_tool("get_research_capabilities", {})
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""SUPER public HTTP/MCP acceptance using isolated persistence and synthetic upstream."""
|
||||||
|
import asyncio
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.backtests.contracts import Candidate
|
||||||
|
from app.models import SimulationAttempt, SuperSelectionSnapshot
|
||||||
|
from app.superalpha.evidence import parse_components
|
||||||
|
from tests.test_backtests import PARAMS, candidate, execute, preview, setup, start
|
||||||
|
from tests.test_mcp import credentials, invoke, mcp_app # noqa: F401
|
||||||
|
|
||||||
|
SETTINGS = PARAMS | {"selectionHandling": "POSITIVE", "selectionLimit": 100, "componentActivation": "IS"}
|
||||||
|
|
||||||
|
|
||||||
|
def super_candidate(index=0, **changes):
|
||||||
|
return {"client_item_id": f"super-{index}", "alpha_type": "SUPER", "selection": f"turnover < {0.1 + index / 10}", "combo": "alpha", "settings": SETTINGS} | changes
|
||||||
|
|
||||||
|
|
||||||
|
def plan(**changes):
|
||||||
|
return {"name": "Super 研究", "hypothesis": "降低组件换手", "selection": "turnover < {threshold}", "combo": "alpha", "variables": {"threshold": {"kind": "number", "values": [0.1, 0.2]}}, "settings": SETTINGS, "include_baseline": True} | changes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_plan_selection_build_versions_and_generic_run(app, logged_in):
|
||||||
|
platform, lane = await setup(app)
|
||||||
|
body = {"plan": plan(), "idempotency_key": "save1"}
|
||||||
|
saved = (await logged_in.post("/api/v1/superalpha/plans", json=body)).json()
|
||||||
|
assert saved["version"] == 1, saved
|
||||||
|
assert (await logged_in.post("/api/v1/superalpha/plans", json=body)).json() == saved
|
||||||
|
selection = {"selection": "turnover < 0.1", "settings": SETTINGS, "plan_id": saved["id"], "version": 1}
|
||||||
|
job = (await logged_in.post("/api/v1/superalpha/selections", json=selection)).json()
|
||||||
|
assert (await logged_in.post("/api/v1/superalpha/selections", json=selection)).json()["job_id"] == job["job_id"]
|
||||||
|
await app.state.runner.run_next()
|
||||||
|
snap = (await logged_in.get(f"/api/v1/superalpha/selections?job_id={job['job_id']}&limit=1")).json()
|
||||||
|
assert snap["complete"] and snap["has_more"] and snap["total"] == 2, snap
|
||||||
|
assert len(platform.selection_reads) == 1 and not platform.posts
|
||||||
|
assert set(platform.selection_reads[0]) == {"selection", "instrumentType", "region", "delay", "selectionLimit", "selectionHandling"}
|
||||||
|
build = {"plan_id": saved["id"], "version": 1, "selection_snapshot_ids": [snap["snapshot_id"]], "idempotency_key": "build1"}
|
||||||
|
exp = (await logged_in.post("/api/v1/superalpha/candidates", json=build)).json()
|
||||||
|
assert exp["total"] == 4, exp
|
||||||
|
assert (await logged_in.post("/api/v1/superalpha/candidates", json=build)).json() == exp
|
||||||
|
# A subset keeps the original experiment's evidence without claiming it applies to every candidate.
|
||||||
|
p = await logged_in.post(f"/api/v1/superalpha/experiments/{exp['id']}/preview", json={"candidate_ids": ["super-2", "super-2-baseline"]})
|
||||||
|
assert p.status_code == 200, p.text
|
||||||
|
assert p.json()["batch_count"] == 2 and not platform.posts
|
||||||
|
rid = (await start(logged_in, p.json()))["backtest_run_id"]
|
||||||
|
await execute(app, lane, rid)
|
||||||
|
result = (await logged_in.get(f"/api/v1/backtests/runs/{rid}/results")).json()
|
||||||
|
assert all(i["persistence_status"] == "saved" and i["alpha_type"] == "SUPER" for i in result["items"]), result
|
||||||
|
assert len(platform.posts) == 2 and all(len(p) == 1 for p in platform.posts)
|
||||||
|
item = result["items"][0]
|
||||||
|
actual = (await logged_in.get(f"/api/v1/backtests/items/{item['id']}/artifact?kind=components")).json()
|
||||||
|
assert actual["complete"] and actual["source"] == "actual" and actual["snapshot_id"] != snap["snapshot_id"]
|
||||||
|
assert actual["component_hash"] == snap["component_hash"]
|
||||||
|
alpha = (await logged_in.get(f"/api/v1/superalpha/alphas/{item['alpha_id']}")).json()
|
||||||
|
assert alpha["descriptions"]["combo"] == "Combo rationale"
|
||||||
|
assert alpha["sources"]["items"][0]["source"]["superalpha_plan_version"] == 1
|
||||||
|
changed = body | {"plan": plan(name="修订"), "plan_id": saved["id"], "version": 1, "idempotency_key": "save2"}
|
||||||
|
assert (await logged_in.post("/api/v1/superalpha/plans", json=changed)).json()["version"] == 2
|
||||||
|
assert (await logged_in.post("/api/v1/superalpha/plans", json=changed | {"idempotency_key": "save3"})).status_code == 409
|
||||||
|
assert (await logged_in.delete(f"/api/v1/superalpha/plans/{saved['id']}?version=2")).status_code == 200
|
||||||
|
assert (await logged_in.get(f"/api/v1/superalpha/plans/{saved['id']}?version=1")).json()["content"]["name"] == "Super 研究"
|
||||||
|
rows = (await logged_in.get(f"/api/v1/superalpha/experiments/{exp['id']}/results")).json()
|
||||||
|
assert rows["total"] == 2 and rows["items"][0]["pnl_fetched_at"] is None
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
assert await db.scalar(select(func.count()).select_from(SuperSelectionSnapshot)) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mixed_run_isolation_facets_export_and_strict_match(app, logged_in):
|
||||||
|
platform, lane = await setup(app)
|
||||||
|
p = await preview(logged_in, [candidate(0), candidate(1), super_candidate(), super_candidate(1)])
|
||||||
|
assert p["batch_count"] == 3
|
||||||
|
rid = (await start(logged_in, p))["backtest_run_id"]
|
||||||
|
await execute(app, lane, rid)
|
||||||
|
assert len(platform.posts) == 3
|
||||||
|
results = (await logged_in.get(f"/api/v1/backtests/runs/{rid}/results")).json()["items"]
|
||||||
|
assert all(i["persistence_status"] == "saved" for i in results), results
|
||||||
|
for scope, kind in (("super", "SUPER"), ("non_super", "REGULAR")):
|
||||||
|
page = (await logged_in.get(f"/api/v1/alphas?management_scope={scope}")).json()
|
||||||
|
assert page["total"] == 2 and all(i["alpha_type"] == kind for i in page["items"])
|
||||||
|
facets = (await logged_in.get(f"/api/v1/alphas/facets?management_scope={scope}")).json()
|
||||||
|
assert facets["alpha_type"] == [kind], facets
|
||||||
|
export = await logged_in.get(f"/api/v1/alphas/export?management_scope={scope}")
|
||||||
|
assert export.status_code == 200
|
||||||
|
assert all(i["alpha_id"] in export.text for i in results if i["alpha_type"] == kind)
|
||||||
|
assert all(i["alpha_id"] not in export.text for i in results if i["alpha_type"] != kind)
|
||||||
|
rid2 = (await start(logged_in, await preview(logged_in, [super_candidate(9)]), "mismatch"))["backtest_run_id"]
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
aid = await db.scalar(select(SimulationAttempt.id).where(SimulationAttempt.run_id == rid2))
|
||||||
|
await lane.step(aid)
|
||||||
|
next(reversed(platform.alphas.values()))["combo"]["code"] = "WRONG"
|
||||||
|
await lane.step(aid)
|
||||||
|
row = (await logged_in.get(f"/api/v1/backtests/runs/{rid2}/results")).json()["items"][0]
|
||||||
|
assert row["persistence_status"] != "saved"
|
||||||
|
assert (await logged_in.get("/api/v1/backtests/runs?alpha_type=SUPER")).json()["total"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reject", ["unknown", "missing_location", "rate"])
|
||||||
|
async def test_super_reliability_no_unknown_resubmission(app, logged_in, reject):
|
||||||
|
platform, lane = await setup(app)
|
||||||
|
platform.reject = reject
|
||||||
|
rid = (await start(logged_in, await preview(logged_in, [super_candidate()])))["backtest_run_id"]
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
aid = await db.scalar(select(SimulationAttempt.id).where(SimulationAttempt.run_id == rid))
|
||||||
|
for _ in range(app.state.settings.retry_attempts + 1):
|
||||||
|
await lane.step(aid)
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
if reject == "rate":
|
||||||
|
assert len(platform.posts) == app.state.settings.retry_attempts
|
||||||
|
else:
|
||||||
|
await lane.start()
|
||||||
|
await lane.stop()
|
||||||
|
await logged_in.post(f"/api/v1/backtests/runs/{rid}/control", json={"action": "recover", "version": 1})
|
||||||
|
await lane.step(aid)
|
||||||
|
assert len(platform.posts) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_components_and_recovery(app, logged_in):
|
||||||
|
platform, lane = await setup(app)
|
||||||
|
platform.actual_components = None
|
||||||
|
platform.detail_fail = True
|
||||||
|
rid = (await start(logged_in, await preview(logged_in, [super_candidate()])))["backtest_run_id"]
|
||||||
|
ids = await execute(app, lane, rid)
|
||||||
|
platform.detail_fail = False
|
||||||
|
await logged_in.post(f"/api/v1/backtests/runs/{rid}/control", json={"action": "recover", "version": 1})
|
||||||
|
await lane.step(ids[0])
|
||||||
|
item = (await logged_in.get(f"/api/v1/backtests/runs/{rid}/results")).json()["items"][0]
|
||||||
|
assert item["persistence_status"] == "saved" and len(platform.posts) == 1
|
||||||
|
components = (await logged_in.get(f"/api/v1/backtests/items/{item['id']}/artifact?kind=components")).json()
|
||||||
|
assert components["complete"] is False and components["component_hash"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sampling_validation_and_component_evidence(app, logged_in):
|
||||||
|
await setup(app)
|
||||||
|
for invalid in (super_candidate(selection=" "), super_candidate(combo=""), super_candidate(expression="close"), super_candidate(settings=PARAMS)):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Candidate.model_validate(invalid)
|
||||||
|
assert (await logged_in.get("/api/v1/superalpha/selections")).status_code == 422
|
||||||
|
assert parse_components({"count": 0, "results": []})["complete"]
|
||||||
|
for raw in (["a"], {"count": 2, "results": ["a"]}, {"count": 2, "results": ["a", "a"]}, {"count": 1, "results": ["a"], "next": "next"}):
|
||||||
|
assert not parse_components(raw)["complete"]
|
||||||
|
complete = parse_components({"count": 1, "results": ["a"], "warnings": ["synthetic warning"]})
|
||||||
|
assert complete["complete"] and complete["warnings"] == ["synthetic warning"]
|
||||||
|
args = {"plan": plan(variables={"threshold": {"kind": "number", "values": list(range(100))}}), "mode": "random", "limit": 5, "seed": 42, "idempotency_key": "random1"}
|
||||||
|
a = (await logged_in.post("/api/v1/superalpha/candidates", json=args)).json()
|
||||||
|
b = (await logged_in.post("/api/v1/superalpha/candidates", json=args | {"idempotency_key": "random2"})).json()
|
||||||
|
assert a["candidates"] == b["candidates"] and a["total"] == 10
|
||||||
|
bad = deepcopy(args)
|
||||||
|
bad["plan"]["setting_variants"] = {"selectionLimit": [0]}
|
||||||
|
assert (await logged_in.post("/api/v1/superalpha/candidates", json=bad)).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_interop_permissions_inline_and_sources(mcp_app): # noqa: F811
|
||||||
|
app = mcp_app
|
||||||
|
principal, _ = await credentials(app, {"research:read", "research:write", "research:refresh", "backtests:execute"})
|
||||||
|
saved = await invoke(app, principal, "save_superalpha_plan", {"plan": plan(), "idempotency_key": "mcp-plan"})
|
||||||
|
built = await invoke(app, principal, "build_superalpha_candidates", {"plan_id": saved["id"], "version": 1, "idempotency_key": "mcp-build"})
|
||||||
|
assert not built["starts_backtests"] and built["total"] == 4
|
||||||
|
assert (await invoke(app, principal, "get_superalpha_plan", {"experiment_id": built["id"], "limit": 1}))["has_more"]
|
||||||
|
submit = {"name": "MCP SUPER", "candidates": built["candidates"][:1], "source": {"research_id": built["id"], "superalpha_plan_id": saved["id"], "superalpha_plan_version": 1}, "duplicate_policy": "rerun", "idempotency_key": "mcp-run"}
|
||||||
|
result = await invoke(app, principal, "submit_backtests", submit)
|
||||||
|
assert result == await invoke(app, principal, "submit_backtests", submit)
|
||||||
|
rid = result["backtest_run_id"]
|
||||||
|
await execute(app, app.state.runner.backtests, rid)
|
||||||
|
results = await invoke(app, principal, "get_backtest_results", {"run_id": rid})
|
||||||
|
item = results["items"][0]
|
||||||
|
assert item["persistence_status"] == "saved", results
|
||||||
|
alpha = await invoke(app, principal, "get_superalpha", {"alpha_id": item["alpha_id"]})
|
||||||
|
assert alpha["alpha_type"] == "SUPER" and "#superalphas?" in alpha["web_url"]
|
||||||
|
forged = deepcopy(submit)
|
||||||
|
forged["idempotency_key"] = "forged"
|
||||||
|
forged["candidates"][0]["combo"] = "WRONG"
|
||||||
|
assert (await app.state.mcp.invoke(principal, "submit_backtests", forged)).is_error
|
||||||
|
direct = submit | {"source": {}, "candidates": [super_candidate(9)], "idempotency_key": "direct"}
|
||||||
|
assert (await invoke(app, principal, "submit_backtests", direct))["source"]["superalpha_plan_id"] is None
|
||||||
|
readonly, _ = await credentials(app, {"research:read"})
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await app.state.mcp.invoke(readonly, "save_superalpha_plan", {"plan": plan(), "idempotency_key": "denied"})
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metadata_stage_constraints_selection_recovery_and_cancel(app, logged_in):
|
||||||
|
from app.catalog.research_metadata import ResearchMetadata
|
||||||
|
from app.models import Job
|
||||||
|
from app.superalpha.jobs import run_selection
|
||||||
|
platform, _ = await setup(app)
|
||||||
|
async with app.state.sessions.begin() as db:
|
||||||
|
await ResearchMetadata(db).publish("settings", "settings", {"items": [{"instrument_type": "EQUITY", "region": "USA", "delay": 1, "universe": "TOP3000", "neutralizations": ["INDUSTRY"], "fields": {"selectionLimit": {"maximum": 50}}}]})
|
||||||
|
await ResearchMetadata(db).publish("operators", "operators", {"items": [
|
||||||
|
{"name": "combo_a", "category": "Combo", "scope": ["COMBO"]},
|
||||||
|
{"name": "not_combo", "category": "Other", "scope": ["UNKNOWN_COMBO"]},
|
||||||
|
{"name": "unspecified", "category": "Other", "scope": None}]})
|
||||||
|
assert (await logged_in.post("/api/v1/superalpha/plans", json={"plan": plan(), "idempotency_key": "bad-settings"})).status_code == 422
|
||||||
|
p = await logged_in.post("/api/v1/backtests/previews", json={"inline": {"name": "bad", "candidates": [super_candidate()]}})
|
||||||
|
assert p.status_code == 422
|
||||||
|
assert [o["name"] for o in (await logged_in.get("/api/v1/catalog/operators?stage=COMBO")).json()["items"]] == ["combo_a"]
|
||||||
|
payload = {"selection": "turnover", "settings": SETTINGS | {"selectionLimit": 50}}
|
||||||
|
job = (await logged_in.post("/api/v1/superalpha/selections", json=payload)).json()
|
||||||
|
await app.state.runner.run_next()
|
||||||
|
await run_selection(app.state.runner, job["job_id"], payload) # Restart after commit preserves evidence.
|
||||||
|
assert len(platform.selection_reads) == 1
|
||||||
|
cancelled = (await logged_in.post("/api/v1/superalpha/selections", json=payload)).json()
|
||||||
|
async with app.state.sessions.begin() as db:
|
||||||
|
(await db.get(Job, cancelled["job_id"])).cancel_requested = True
|
||||||
|
await app.state.runner.run_next()
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
assert (await db.get(Job, cancelled["job_id"])).status == "cancelled"
|
||||||
|
assert await db.scalar(select(func.count()).select_from(SuperSelectionSnapshot)) == 1
|
||||||
+52
-2
@@ -54,7 +54,7 @@ python -m app.cli mcp-token-revoke TOKEN_ID
|
|||||||
| search_data_preparations | `{q?,scope_key?,limit?,offset?}`;查询可编辑集合及当前版本 |
|
| search_data_preparations | `{q?,scope_key?,limit?,offset?}`;查询可编辑集合及当前版本 |
|
||||||
| get_data_preparation | `{id,version,q?,limit?,offset?}`;按版本分页预览字段与数据集归属,版本冲突重新选择 |
|
| get_data_preparation | `{id,version,q?,limit?,offset?}`;按版本分页预览字段与数据集归属,版本冲突重新选择 |
|
||||||
| search_catalog | `{filters:{region,universe,delay,...},dataset_id?}`;省略 dataset_id 查数据集,提供则查字段 |
|
| search_catalog | `{filters:{region,universe,delay,...},dataset_id?}`;省略 dataset_id 查数据集,提供则查字段 |
|
||||||
| get_research_metadata | `{query:{kind,...}}`;kind 为 scopes/settings/operators/field_availability |
|
| get_research_metadata | `{query:{kind,...}}`;kind 为 scopes/settings/operators/field_availability/superalpha |
|
||||||
| refresh_research_data | `{query:{kind,...}}`;kind 为 catalog/operators/settings/field_availability/pnl |
|
| refresh_research_data | `{query:{kind,...}}`;kind 为 catalog/operators/settings/field_availability/pnl |
|
||||||
| get_submission_check | `{alpha_id}`;读取检查上下文、snapshot 和缓存结果,不发起检查 |
|
| get_submission_check | `{alpha_id}`;读取检查上下文、snapshot 和缓存结果,不发起检查 |
|
||||||
| check_submission | `{alpha_id,snapshot,descriptions}`;写回已确认描述并异步检查,绝不正式提交;要求 research:refresh |
|
| check_submission | `{alpha_id,snapshot,descriptions}`;写回已确认描述并异步检查,绝不正式提交;要求 research:refresh |
|
||||||
@@ -65,7 +65,7 @@ python -m app.cli mcp-token-revoke TOKEN_ID
|
|||||||
| submit_backtests | `{name,candidates,idempotency_key,preparation_refs?,duplicate_policy?,source?}` |
|
| submit_backtests | `{name,candidates,idempotency_key,preparation_refs?,duplicate_policy?,source?}` |
|
||||||
| get_backtest | `{run_id,after?,event_limit?}`,after 为事件游标 |
|
| get_backtest | `{run_id,after?,event_limit?}`,after 为事件游标 |
|
||||||
| get_backtest_results | `{run_id,item_ids?,limit?,offset?}` |
|
| get_backtest_results | `{run_id,item_ids?,limit?,offset?}` |
|
||||||
| get_backtest_artifact | `{item_id,kind,limit?,offset?,date_from?,date_to?}`,kind 为 snapshot/pnl |
|
| get_backtest_artifact | `{item_id,kind,limit?,offset?,date_from?,date_to?}`,kind 为 snapshot/pnl/components |
|
||||||
| control_backtest | `{run_id,action,expected_version,idempotency_key}` |
|
| control_backtest | `{run_id,action,expected_version,idempotency_key}` |
|
||||||
|
|
||||||
metadata 的 operators 支持 q/category 和分页;settings 支持分页;field_availability 要求 field_id 和 scope。refresh 的 catalog 要求 scope,可选 dataset_id;pnl 要求 alpha_ids;availability 与读取使用相同范围字段。目录和 PnL 刷新返回 job_id,查询不会隐式刷新;另外三种刷新最多等待 30 秒,成功只返回快照引用,完整内容用读取工具获取。失败不发布半成品。
|
metadata 的 operators 支持 q/category 和分页;settings 支持分页;field_availability 要求 field_id 和 scope。refresh 的 catalog 要求 scope,可选 dataset_id;pnl 要求 alpha_ids;availability 与读取使用相同范围字段。目录和 PnL 刷新返回 job_id,查询不会隐式刷新;另外三种刷新最多等待 30 秒,成功只返回快照引用,完整内容用读取工具获取。失败不发布半成品。
|
||||||
@@ -192,3 +192,53 @@ MCP_TEST_DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@127.0.0.1:PORT/wq_mcp_t
|
|||||||
该脚本执行迁移、并发提交/控制、重启重放、回退及重升级,不用于个人库或生产库。生产启用、真实平台兼容性、真实额度和客户端实际凭据配置仍需另行授权验证。本功能不会恢复任何定时研究。
|
该脚本执行迁移、并发提交/控制、重启重放、回退及重升级,不用于个人库或生产库。生产启用、真实平台兼容性、真实额度和客户端实际凭据配置仍需另行授权验证。本功能不会恢复任何定时研究。
|
||||||
|
|
||||||
使用数据准备集合时,`preparation_refs` 为最多 20 个 `{id,version}`。提交时核对版本、范围和字段,固定独立快照并保存到回测来源;空集合或版本冲突不会创建运行。后续编辑或删除集合不影响回测。无需旧输入草稿接口。
|
使用数据准备集合时,`preparation_refs` 为最多 20 个 `{id,version}`。提交时核对版本、范围和字段,固定独立快照并保存到回测来源;空集合或版本冲突不会创建运行。后续编辑或删除集合不影响回测。无需旧输入草稿接口。
|
||||||
|
|
||||||
|
|
||||||
|
## Super Alpha 研究
|
||||||
|
|
||||||
|
迁移 `0021` 增加回测项类型、Selection/Combo 和独立组件快照表。历史 REGULAR 回测保留原输入及结果;已有 SUPER 记录直接进入 **研究成果 → Super Alpha 管理**。原 Alpha 管理固定为非 SUPER,两个页面的列表、统计、筛选、导出、保存视图和列偏好区分类型,共用同一 Alpha 记录。
|
||||||
|
|
||||||
|
**研究实验 → Super Alpha 研究** 支持方案版本、复制/归档、参数候选、设置候选、等权基线、Selection 预览和固定候选。候选勾选后进入通用回测预览及启动窗口。参数使用 `{name}`;变量不接受普通 Alpha 的 `field` 类型。全量展开默认上限 100,可调至 10000;随机模式使用固定种子。基线另列候选,包含基线后最多 10000 项。保存与构造不调用模型或启动回测。
|
||||||
|
|
||||||
|
| 工具 | 权限与输入 |
|
||||||
|
| --- | --- |
|
||||||
|
| `search_superalpha_plans` | read;q/limit/offset |
|
||||||
|
| `get_superalpha_plan` | read;plan_id/version 或 experiment_id/limit/offset |
|
||||||
|
| `save_superalpha_plan` | write;plan/idempotency_key;更新同时提供 plan_id/version |
|
||||||
|
| `preview_superalpha_selection` | refresh;展开后的 selection、完整 SUPER settings,可选 plan_id/version;立即返回 job_id |
|
||||||
|
| `get_superalpha_selection` | read;snapshot_id 或 job_id,支持 q/limit/offset |
|
||||||
|
| `build_superalpha_candidates` | write;plan_id/version 或内联 plan;mode、limit、seed、selection_snapshot_ids、idempotency_key |
|
||||||
|
| `search_superalphas` | read;filters,服务端固定 SUPER 范围 |
|
||||||
|
| `get_superalpha` | read;alpha_id,读取设置、指标、两个 Description、实际组件及研究来源 |
|
||||||
|
|
||||||
|
权限名称分别为 `research:read`、`research:write`、`research:refresh`。`get_research_metadata({query:{kind:"superalpha"}})` 返回专属设置契约、文档属性和阶段信息;settings 查询可带 alpha_type,operators 可带 stage=SELECTION/COMBO。账户未返回的设置范围保持未知,文档属性不等于实时账户授权。
|
||||||
|
|
||||||
|
外部模型自行构造方案后调用 save/build。构造返回 `candidates` 和 `submit_source`,分别传入 `submit_backtests.candidates` 和 `.source`;超过 100 项按构造记录分页读取,再按批次提交。每批使用独立幂等键,保留同一个 research_id。也可直接提交完整 SUPER 候选,不先保存方案:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "SUPER 等权对照",
|
||||||
|
"idempotency_key": "super-round-001",
|
||||||
|
"candidates": [{
|
||||||
|
"client_item_id": "equal-weight",
|
||||||
|
"alpha_type": "SUPER",
|
||||||
|
"selection": "turnover < 0.2",
|
||||||
|
"combo": "1",
|
||||||
|
"settings": {
|
||||||
|
"instrumentType": "EQUITY", "region": "USA", "universe": "TOP3000", "delay": 1,
|
||||||
|
"decay": 0, "neutralization": "INDUSTRY", "truncation": 0.08,
|
||||||
|
"pasteurization": "ON", "unitHandling": "VERIFY", "nanHandling": "OFF",
|
||||||
|
"language": "FASTEXPR", "visualization": false, "maxTrade": "OFF", "maxPosition": "OFF",
|
||||||
|
"selectionHandling": "POSITIVE", "selectionLimit": 100, "componentActivation": "IS"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这只是输入形态示例,地区和参数须依据实际元数据选择。回测沿用 `submit_backtests`、`get_backtest`、`get_backtest_results`、`get_backtest_artifact` 和 `control_backtest`。SUPER 每项单独 POST,共用账户并发及恢复机制;REGULAR 继续批量方式。提交回执未知时不会自动重发。`search_backtests` 可按 alpha_type、research_id 筛选;精确匹配覆盖类型、Selection、Combo 和完整设置。
|
||||||
|
|
||||||
|
Selection 预览在通用后台任务中执行,进度用 `get_refresh_job` 查询,组件用 `get_superalpha_selection` 分页读取。预览请求遵循现有平台资料的六个查询字段:selection/instrumentType/region/delay/selectionLimit/selectionHandling。Universe 和 Combo 不会被假装应用到该只读查询;完整设置仍保存在观察记录中。
|
||||||
|
|
||||||
|
预览和实际组件分开存储。`get_backtest_artifact(kind="components")` 仅返回当次实际组件证据;平台未提供完整成员列表时,complete=false、component_hash=null。未核实列表不能作为同池证据,即使预览曾返回完整组件。请求指纹覆盖实际模拟的完整输入,组件指纹覆盖排序后的完整 ID 集合;时间和来源单独记录。指标取当次固定快照,PnL 为单独采集的缓存,以 fetched_at 为准。
|
||||||
|
|
||||||
|
验证入口:`uv run pytest -q tests/test_superalpha.py`、前端 `pnpm exec playwright test tests/superalpha.spec.ts`。专用本地 PostgreSQL 可执行 `SUPER_TEST_DATABASE_URL=.../wq_superalpha_test uv run python -m tests.superalpha_postgres`,验证迁移、历史记录兼容、完整闭环及并发保存幂等。脚本拒绝其他数据库名称或非本机地址。所有自动化验收均使用模拟平台;真实 SUPER 模拟须另行授权。
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SuperAlphaResearchPage } from "./superalpha/SuperAlphaResearchPage";
|
||||||
import { FieldDirectory } from "./preparations/FieldDirectory";
|
import { FieldDirectory } from "./preparations/FieldDirectory";
|
||||||
import { DataPreparationPage } from "./preparations/DataPreparationPage";
|
import { DataPreparationPage } from "./preparations/DataPreparationPage";
|
||||||
import { SnapshotDialog } from "./preparations/SnapshotDialog";
|
import { SnapshotDialog } from "./preparations/SnapshotDialog";
|
||||||
@@ -66,6 +67,7 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
|
"superalpha-research",
|
||||||
"operators",
|
"operators",
|
||||||
"templates",
|
"templates",
|
||||||
"variants",
|
"variants",
|
||||||
@@ -101,6 +103,9 @@ export default function App() {
|
|||||||
const [alphaContext, setAlphaContext] = useState<PageContext>({
|
const [alphaContext, setAlphaContext] = useState<PageContext>({
|
||||||
page: "alphas",
|
page: "alphas",
|
||||||
});
|
});
|
||||||
|
const [superAlphaContext, setSuperAlphaContext] = useState<PageContext>({
|
||||||
|
page: "superalphas",
|
||||||
|
});
|
||||||
const [backtestContext, setBacktestContext] = useState<PageContext>({
|
const [backtestContext, setBacktestContext] = useState<PageContext>({
|
||||||
page: "backtests",
|
page: "backtests",
|
||||||
});
|
});
|
||||||
@@ -252,7 +257,23 @@ export default function App() {
|
|||||||
location.hash = next;
|
location.hash = next;
|
||||||
setPage(next);
|
setPage(next);
|
||||||
};
|
};
|
||||||
|
const navigationRequest = useRef(0);
|
||||||
const handleAction = (action: UIAction) => {
|
const handleAction = (action: UIAction) => {
|
||||||
|
const request = ++navigationRequest.current;
|
||||||
|
if (action.type === "open_alpha" && !action.alpha_type) {
|
||||||
|
void api<{ alpha_type: string }>(
|
||||||
|
`/alphas/${encodeURIComponent(action.alpha_id)}`,
|
||||||
|
)
|
||||||
|
.then((alpha) => {
|
||||||
|
if (navigationRequest.current === request)
|
||||||
|
handleAction({
|
||||||
|
...action,
|
||||||
|
alpha_type: alpha.alpha_type || "REGULAR",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: Error) => Toast.error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const destination = actionDestination(action);
|
const destination = actionDestination(action);
|
||||||
if (!destination) return;
|
if (!destination) return;
|
||||||
if (destination.chat === "open") setChatOpen(true);
|
if (destination.chat === "open") setChatOpen(true);
|
||||||
@@ -261,6 +282,21 @@ export default function App() {
|
|||||||
if (destination.page) changePage(destination.page);
|
if (destination.page) changePage(destination.page);
|
||||||
setAIAction(action);
|
setAIAction(action);
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated) return;
|
||||||
|
const locate = () => {
|
||||||
|
const target = pageFromHash(location.hash);
|
||||||
|
if (!["alphas", "superalphas"].includes(target)) return;
|
||||||
|
const id = new URLSearchParams(location.hash.split("?")[1]).get(
|
||||||
|
"alpha_id",
|
||||||
|
);
|
||||||
|
if (id)
|
||||||
|
handleAction({ type: "open_alpha", alpha_id: id, nonce: Date.now() });
|
||||||
|
};
|
||||||
|
locate();
|
||||||
|
window.addEventListener("hashchange", locate);
|
||||||
|
return () => window.removeEventListener("hashchange", locate);
|
||||||
|
}, [authenticated]);
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
await post("/auth/logout");
|
await post("/auth/logout");
|
||||||
@@ -439,6 +475,44 @@ export default function App() {
|
|||||||
onOverlay={focusBusiness}
|
onOverlay={focusBusiness}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="alpha-page-view" hidden={page !== "superalphas"}>
|
||||||
|
<AlphaPage
|
||||||
|
managementScope="super"
|
||||||
|
onAction={handleAction}
|
||||||
|
taskPanelOpen={showJobs}
|
||||||
|
account={account}
|
||||||
|
version={`${refreshKey}:${resourceVersions.alphas}:${completedVersion}`}
|
||||||
|
onTask={taskCreated}
|
||||||
|
onAccount={() => changePage("account")}
|
||||||
|
active={page === "superalphas"}
|
||||||
|
overlaySuspended={
|
||||||
|
page !== "superalphas" || (viewport < 1440 && chatOpen)
|
||||||
|
}
|
||||||
|
chatOffset={chatOffset}
|
||||||
|
onContext={setSuperAlphaContext}
|
||||||
|
action={
|
||||||
|
aiAction?.type === "open_alpha" ||
|
||||||
|
aiAction?.type === "apply_filters"
|
||||||
|
? aiAction
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onOverlay={focusBusiness}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{visitedResearch.includes("superalpha-research") && (
|
||||||
|
<div
|
||||||
|
className="alpha-page-view"
|
||||||
|
hidden={page !== "superalpha-research"}
|
||||||
|
>
|
||||||
|
<SuperAlphaResearchPage
|
||||||
|
active={page === "superalpha-research"}
|
||||||
|
action={aiAction}
|
||||||
|
onAction={handleAction}
|
||||||
|
onContext={setResearchContext}
|
||||||
|
timezone={account?.timezone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="backtest-page-view" hidden={page !== "backtests"}>
|
<div className="backtest-page-view" hidden={page !== "backtests"}>
|
||||||
{visitedBacktests && (
|
{visitedBacktests && (
|
||||||
<BacktestPage
|
<BacktestPage
|
||||||
@@ -572,6 +646,11 @@ export default function App() {
|
|||||||
? researchContext
|
? researchContext
|
||||||
: { page: "variants" as const },
|
: { page: "variants" as const },
|
||||||
alphas: alphaContext,
|
alphas: alphaContext,
|
||||||
|
superalphas: superAlphaContext,
|
||||||
|
"superalpha-research":
|
||||||
|
researchContext.page === "superalpha-research"
|
||||||
|
? researchContext
|
||||||
|
: { page: "superalpha-research" as const },
|
||||||
datasets: datasetContext,
|
datasets: datasetContext,
|
||||||
fields: { page: "fields" as const },
|
fields: { page: "fields" as const },
|
||||||
preparations: { page: "preparations" as const },
|
preparations: { page: "preparations" as const },
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type PageContext = {
|
|||||||
page:
|
page:
|
||||||
| "home"
|
| "home"
|
||||||
| "alphas"
|
| "alphas"
|
||||||
|
| "superalphas"
|
||||||
|
| "superalpha-research"
|
||||||
| "account"
|
| "account"
|
||||||
| "datasets"
|
| "datasets"
|
||||||
| "fields"
|
| "fields"
|
||||||
@@ -48,9 +50,17 @@ export type PageContext = {
|
|||||||
filters?: Record<string, unknown>;
|
filters?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
export type AlphaUIAction =
|
export type AlphaUIAction =
|
||||||
| { type: "open_alpha"; alpha_id: string; nonce: number }
|
| { type: "open_alpha"; alpha_id: string; alpha_type?: string; nonce: number }
|
||||||
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
|
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
|
||||||
export type UIAction =
|
export type UIAction =
|
||||||
|
| {
|
||||||
|
type: "open_superalpha_research";
|
||||||
|
plan_id?: string;
|
||||||
|
version?: number;
|
||||||
|
experiment_id?: string;
|
||||||
|
alpha_id?: string;
|
||||||
|
nonce: number;
|
||||||
|
}
|
||||||
| { type: "open_feature"; asset_id: string; version?: number; nonce: number }
|
| { type: "open_feature"; asset_id: string; version?: number; nonce: number }
|
||||||
| { type: "open_template"; asset_id: string; version?: number; nonce: number }
|
| { type: "open_template"; asset_id: string; version?: number; nonce: number }
|
||||||
| { type: "open_experiment"; experiment_id: string; nonce: number }
|
| { type: "open_experiment"; experiment_id: string; nonce: number }
|
||||||
@@ -59,7 +69,7 @@ export type UIAction =
|
|||||||
| { type: "open_research_input"; input_id: string; nonce: number }
|
| { type: "open_research_input"; input_id: string; nonce: number }
|
||||||
| { type: "open_backtest"; run_id: string; nonce: number }
|
| { type: "open_backtest"; run_id: string; nonce: number }
|
||||||
| { type: "open_backtest_preview"; preview_id: string; nonce: number }
|
| { type: "open_backtest_preview"; preview_id: string; nonce: number }
|
||||||
| { type: "open_alpha"; alpha_id: string; nonce: number }
|
| { type: "open_alpha"; alpha_id: string; alpha_type?: string; nonce: number }
|
||||||
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
|
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
|
||||||
export type ToolCard = {
|
export type ToolCard = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ const contextLabels: Record<
|
|||||||
home: () => "上下文:首页看板",
|
home: () => "上下文:首页看板",
|
||||||
alphas: (context) =>
|
alphas: (context) =>
|
||||||
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
|
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
|
||||||
|
superalphas: (c) =>
|
||||||
|
`上下文:Super Alpha 管理${c.alpha_id ? ` · ${c.alpha_id}` : ""}`,
|
||||||
|
"superalpha-research": (c) =>
|
||||||
|
`上下文:Super Alpha 研究${c.research_asset_id ? ` · 方案 ${c.research_asset_id}` : ""}${c.research_experiment_id ? ` · 候选 ${c.research_experiment_id}` : ""}`,
|
||||||
operators: () => "上下文:算子库",
|
operators: () => "上下文:算子库",
|
||||||
templates: (context) =>
|
templates: (context) =>
|
||||||
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
|
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
|
||||||
@@ -100,6 +104,7 @@ type Destination = {
|
|||||||
};
|
};
|
||||||
// Exhaustive action destinations prevent a new action silently falling into Alpha.
|
// Exhaustive action destinations prevent a new action silently falling into Alpha.
|
||||||
const destinations: Record<UIAction["type"], Destination> = {
|
const destinations: Record<UIAction["type"], Destination> = {
|
||||||
|
open_superalpha_research: { page: "superalpha-research", chat: "responsive" },
|
||||||
open_feature: { page: "features", chat: "responsive" },
|
open_feature: { page: "features", chat: "responsive" },
|
||||||
open_template: { page: "templates", chat: "responsive" },
|
open_template: { page: "templates", chat: "responsive" },
|
||||||
open_experiment: { page: "templates", chat: "responsive" },
|
open_experiment: { page: "templates", chat: "responsive" },
|
||||||
@@ -113,6 +118,14 @@ const destinations: Record<UIAction["type"], Destination> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function actionDestination(action: UIAction): Destination | undefined {
|
export function actionDestination(action: UIAction): Destination | undefined {
|
||||||
|
if (action.type === "open_alpha" && action.alpha_type === "SUPER")
|
||||||
|
return { page: "superalphas", chat: "responsive" };
|
||||||
|
if (
|
||||||
|
action.type === "apply_filters" &&
|
||||||
|
(action.filters.management_scope === "super" ||
|
||||||
|
action.filters.alpha_type === "SUPER")
|
||||||
|
)
|
||||||
|
return { page: "superalphas", chat: "responsive" };
|
||||||
return Object.hasOwn(destinations, action.type)
|
return Object.hasOwn(destinations, action.type)
|
||||||
? destinations[action.type]
|
? destinations[action.type]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const jobLabels: Record<string, string> = {
|
|||||||
catalog_full_sync: "全量同步数据目录与字段",
|
catalog_full_sync: "全量同步数据目录与字段",
|
||||||
catalog_sync: "同步数据集目录",
|
catalog_sync: "同步数据集目录",
|
||||||
field_sync: "同步数据字段",
|
field_sync: "同步数据字段",
|
||||||
|
super_selection_preview: "Super Alpha 组件预览",
|
||||||
full_sync: "全量同步 Alpha",
|
full_sync: "全量同步 Alpha",
|
||||||
daily_sync: "按天同步 Alpha",
|
daily_sync: "按天同步 Alpha",
|
||||||
self_correlation: "本地自相关检测",
|
self_correlation: "本地自相关检测",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ComponentsPanel } from "../superalpha/ComponentsPanel";
|
||||||
import {
|
import {
|
||||||
CatalogTableToolbar,
|
CatalogTableToolbar,
|
||||||
CatalogIconAction,
|
CatalogIconAction,
|
||||||
@@ -90,6 +91,7 @@ export function BacktestPage({
|
|||||||
}) {
|
}) {
|
||||||
const [view, setView] = useState("runs");
|
const [view, setView] = useState("runs");
|
||||||
const [sourceFilter, setSourceFilter] = useState("");
|
const [sourceFilter, setSourceFilter] = useState("");
|
||||||
|
const [alphaType, setAlphaType] = useState("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [filterDraft, setFilterDraft] = useState({ q: "", source: "" });
|
const [filterDraft, setFilterDraft] = useState({ q: "", source: "" });
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
@@ -180,7 +182,9 @@ export function BacktestPage({
|
|||||||
offset: (page - 1) * pageSize,
|
offset: (page - 1) * pageSize,
|
||||||
sort: sort.value,
|
sort: sort.value,
|
||||||
direction: sort.direction,
|
direction: sort.direction,
|
||||||
...(view === "runs" ? { source: sourceFilter } : {}),
|
...(view === "runs"
|
||||||
|
? { source: sourceFilter, alpha_type: alphaType }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
const [data, c, kinds] = await Promise.all([
|
const [data, c, kinds] = await Promise.all([
|
||||||
api<Page<Run> | Page<DraftSummary>>(`/backtests/${view}?${params}`),
|
api<Page<Run> | Page<DraftSummary>>(`/backtests/${view}?${params}`),
|
||||||
@@ -199,7 +203,16 @@ export function BacktestPage({
|
|||||||
} finally {
|
} finally {
|
||||||
if (sequence === requestSequence.current) setLoading(false);
|
if (sequence === requestSequence.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, pageSize, sourceFilter, q, view, sort.value, sort.direction]);
|
}, [
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sourceFilter,
|
||||||
|
alphaType,
|
||||||
|
q,
|
||||||
|
view,
|
||||||
|
sort.value,
|
||||||
|
sort.direction,
|
||||||
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -472,6 +485,24 @@ export function BacktestPage({
|
|||||||
const drawerVisible = active && !suspended;
|
const drawerVisible = active && !suspended;
|
||||||
return (
|
return (
|
||||||
<section className="backtest-page">
|
<section className="backtest-page">
|
||||||
|
{view === "runs" && (
|
||||||
|
<div className="backtest-toolbar">
|
||||||
|
<span>候选类型</span>
|
||||||
|
<Select
|
||||||
|
aria-label="回测候选类型"
|
||||||
|
value={alphaType}
|
||||||
|
optionList={[
|
||||||
|
{ value: "", label: "全部类型" },
|
||||||
|
{ value: "REGULAR", label: "REGULAR" },
|
||||||
|
{ value: "SUPER", label: "SUPER" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => {
|
||||||
|
setAlphaType(String(v));
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CatalogTableToolbar
|
<CatalogTableToolbar
|
||||||
label="回测研究"
|
label="回测研究"
|
||||||
active={active && !suspended && !editor && !runId && !settingsDraft}
|
active={active && !suspended && !editor && !runId && !settingsDraft}
|
||||||
@@ -723,8 +754,21 @@ export function BacktestPage({
|
|||||||
{
|
{
|
||||||
title: "表达式",
|
title: "表达式",
|
||||||
dataIndex: "expression",
|
dataIndex: "expression",
|
||||||
width: 260,
|
width: 320,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
|
render: (_, c) => (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
c!.alpha_type === "SUPER"
|
||||||
|
? `Selection: ${c!.selection}\nCombo: ${c!.combo}`
|
||||||
|
: c!.expression
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{c!.alpha_type === "SUPER"
|
||||||
|
? `SUPER · Selection: ${c!.selection} · Combo: ${c!.combo}`
|
||||||
|
: c!.expression}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "最终参数",
|
title: "最终参数",
|
||||||
@@ -1002,12 +1046,14 @@ export function BacktestPage({
|
|||||||
dataIndex: "expression",
|
dataIndex: "expression",
|
||||||
width: 260,
|
width: 260,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (v, i) => (
|
render: (_, i) => (
|
||||||
<Button
|
<Button
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={() => setItemDetail(i!)}
|
onClick={() => setItemDetail(i!)}
|
||||||
>
|
>
|
||||||
{String(v)}
|
{i!.alpha_type === "SUPER"
|
||||||
|
? `SUPER · ${i!.selection} · ${i!.combo}`
|
||||||
|
: i!.expression}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -1055,6 +1101,7 @@ export function BacktestPage({
|
|||||||
onAction({
|
onAction({
|
||||||
type: "open_alpha",
|
type: "open_alpha",
|
||||||
alpha_id: i!.alpha_id!,
|
alpha_id: i!.alpha_id!,
|
||||||
|
alpha_type: i!.alpha_type,
|
||||||
nonce: Date.now(),
|
nonce: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1081,7 +1128,21 @@ export function BacktestPage({
|
|||||||
收起候选详情
|
收起候选详情
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p>{itemDetail.expression}</p>
|
{itemDetail.alpha_type === "SUPER" ? (
|
||||||
|
<>
|
||||||
|
<h4>Selection</h4>
|
||||||
|
<pre>{itemDetail.selection}</pre>
|
||||||
|
<h4>Combo</h4>
|
||||||
|
<pre>{itemDetail.combo}</pre>
|
||||||
|
<ComponentsPanel
|
||||||
|
key={itemDetail.id}
|
||||||
|
url={`/backtests/items/${itemDetail.id}/artifact?kind=components`}
|
||||||
|
timezone={timezone}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>{itemDetail.expression}</p>
|
||||||
|
)}
|
||||||
{itemDetail.error && (
|
{itemDetail.error && (
|
||||||
<Banner type="warning" description={itemDetail.error} />
|
<Banner type="warning" description={itemDetail.error} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export type SimulationSettings = {
|
|||||||
language: "FASTEXPR";
|
language: "FASTEXPR";
|
||||||
visualization: boolean;
|
visualization: boolean;
|
||||||
maxTrade: "ON" | "OFF";
|
maxTrade: "ON" | "OFF";
|
||||||
|
selectionHandling?: "POSITIVE" | "NON_ZERO" | "NON_NAN";
|
||||||
|
selectionLimit?: number;
|
||||||
|
componentActivation?: "IS" | "OS";
|
||||||
maxPosition?: "ON" | "OFF";
|
maxPosition?: "ON" | "OFF";
|
||||||
};
|
};
|
||||||
export const initialSettings: SimulationSettings = {
|
export const initialSettings: SimulationSettings = {
|
||||||
@@ -34,9 +37,15 @@ export type Candidate = {
|
|||||||
client_item_id: string;
|
client_item_id: string;
|
||||||
expression: string;
|
expression: string;
|
||||||
settings: SimulationSettings;
|
settings: SimulationSettings;
|
||||||
alpha_type?: "REGULAR";
|
alpha_type?: "REGULAR" | "SUPER";
|
||||||
|
selection?: string;
|
||||||
|
combo?: string;
|
||||||
};
|
};
|
||||||
export type Source = {
|
export type Source = {
|
||||||
|
research_kind?: string | null;
|
||||||
|
superalpha_plan_id?: string | null;
|
||||||
|
superalpha_plan_version?: number | null;
|
||||||
|
selection_snapshot_ids?: string[];
|
||||||
kind: string;
|
kind: string;
|
||||||
reference?: string | null;
|
reference?: string | null;
|
||||||
batch_id?: string | null;
|
batch_id?: string | null;
|
||||||
@@ -102,6 +111,9 @@ export type Run = {
|
|||||||
scheduler: Scheduler;
|
scheduler: Scheduler;
|
||||||
};
|
};
|
||||||
export type Item = {
|
export type Item = {
|
||||||
|
alpha_type: "REGULAR" | "SUPER";
|
||||||
|
selection?: string;
|
||||||
|
combo?: string;
|
||||||
id: string;
|
id: string;
|
||||||
client_item_id: string;
|
client_item_id: string;
|
||||||
expression: string;
|
expression: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ComponentsPanel } from "../superalpha/ComponentsPanel";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { formatAlphaMetric } from "../alphaMetrics";
|
import { formatAlphaMetric } from "../alphaMetrics";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +66,7 @@ export function AlphaDetail({
|
|||||||
chatOffset?: number;
|
chatOffset?: number;
|
||||||
}) {
|
}) {
|
||||||
const [detail, setDetail] = useState<Detail | null>(null);
|
const [detail, setDetail] = useState<Detail | null>(null);
|
||||||
|
const [descriptions, setDescriptions] = useState<Record<string, string>>({});
|
||||||
const [pnl, setPnl] = useState<Pnl | null>(null);
|
const [pnl, setPnl] = useState<Pnl | null>(null);
|
||||||
const [research, setResearch] = useState<Research | null>(null);
|
const [research, setResearch] = useState<Research | null>(null);
|
||||||
const [tagText, setTagText] = useState("");
|
const [tagText, setTagText] = useState("");
|
||||||
@@ -77,6 +79,7 @@ export function AlphaDetail({
|
|||||||
dirty.current = false;
|
dirty.current = false;
|
||||||
setConflict(false);
|
setConflict(false);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
|
setDescriptions({});
|
||||||
setResearch(null);
|
setResearch(null);
|
||||||
setPnl(null);
|
setPnl(null);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -122,6 +125,21 @@ export function AlphaDetail({
|
|||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [id, tab, version]);
|
}, [id, tab, version]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || detail?.alpha_type !== "SUPER") return;
|
||||||
|
const c = new AbortController();
|
||||||
|
void api<{ descriptions: Record<string, string> }>(
|
||||||
|
`/superalpha/alphas/${id}`,
|
||||||
|
{ signal: c.signal },
|
||||||
|
)
|
||||||
|
.then((value) => {
|
||||||
|
if (!c.signal.aborted) setDescriptions(value.descriptions);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (!c.signal.aborted) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => c.abort();
|
||||||
|
}, [id, detail?.alpha_type, version]);
|
||||||
async function fetchPnl() {
|
async function fetchPnl() {
|
||||||
try {
|
try {
|
||||||
await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] });
|
await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] });
|
||||||
@@ -222,6 +240,19 @@ export function AlphaDetail({
|
|||||||
<Button>在 BRAIN 中打开 ↗</Button>
|
<Button>在 BRAIN 中打开 ↗</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{detail.alpha_type === "SUPER" && (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
onAction({
|
||||||
|
type: "open_superalpha_research",
|
||||||
|
alpha_id: detail.id,
|
||||||
|
nonce: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
复制为研究方案
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
className="alpha-detail-tabs"
|
className="alpha-detail-tabs"
|
||||||
activeKey={tab}
|
activeKey={tab}
|
||||||
@@ -256,6 +287,18 @@ export function AlphaDetail({
|
|||||||
{detail.combo !== null && (
|
{detail.combo !== null && (
|
||||||
<CodeBlock title="Combo" value={detail.combo} />
|
<CodeBlock title="Combo" value={detail.combo} />
|
||||||
)}
|
)}
|
||||||
|
{detail.alpha_type === "SUPER" && (
|
||||||
|
<>
|
||||||
|
<CodeBlock
|
||||||
|
title="Selection Description"
|
||||||
|
value={descriptions.selection || "未提供"}
|
||||||
|
/>
|
||||||
|
<CodeBlock
|
||||||
|
title="Combo Description"
|
||||||
|
value={descriptions.combo || "未提供"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
<h3>回测设置</h3>
|
<h3>回测设置</h3>
|
||||||
<DetailFieldGrid
|
<DetailFieldGrid
|
||||||
@@ -266,6 +309,17 @@ export function AlphaDetail({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
|
{detail.alpha_type === "SUPER" && (
|
||||||
|
<TabPane tab="组件证据" itemKey="components">
|
||||||
|
{tab === "components" && (
|
||||||
|
<ComponentsPanel
|
||||||
|
key={detail.id}
|
||||||
|
url={`/superalpha/alphas/${detail.id}/components`}
|
||||||
|
timezone={timezone}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabPane>
|
||||||
|
)}
|
||||||
<TabPane tab="指标" itemKey="metrics">
|
<TabPane tab="指标" itemKey="metrics">
|
||||||
<MetricTable title="IS 指标" values={detail.is_metrics} />
|
<MetricTable title="IS 指标" values={detail.is_metrics} />
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -34,10 +34,22 @@ const navigation = [
|
|||||||
},
|
},
|
||||||
{ id: "features", label: "特征工程", icon: IconBeaker, group: "研究实验" },
|
{ id: "features", label: "特征工程", icon: IconBeaker, group: "研究实验" },
|
||||||
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
|
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
|
||||||
|
{
|
||||||
|
id: "superalpha-research",
|
||||||
|
label: "Super Alpha 研究",
|
||||||
|
icon: IconBeaker,
|
||||||
|
group: "研究实验",
|
||||||
|
},
|
||||||
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
|
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
|
||||||
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
|
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
|
||||||
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
|
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
|
||||||
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
|
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
|
||||||
|
{
|
||||||
|
id: "superalphas",
|
||||||
|
label: "Super Alpha 管理",
|
||||||
|
icon: IconGridView,
|
||||||
|
group: "研究成果",
|
||||||
|
},
|
||||||
{ id: "mcp-keys", label: "MCP Key", icon: IconCommand, group: "系统管理" },
|
{ id: "mcp-keys", label: "MCP Key", icon: IconCommand, group: "系统管理" },
|
||||||
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
|
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -285,7 +297,7 @@ export function AppSidebar({
|
|||||||
>
|
>
|
||||||
<div className="command-items">
|
<div className="command-items">
|
||||||
{navigation.map(({ id, label, icon: Icon }) => (
|
{navigation.map(({ id, label, icon: Icon }) => (
|
||||||
<button key={id} onClick={() => navigate(id)}>
|
<button key={id} aria-label={label} onClick={() => navigate(id)}>
|
||||||
<Icon />
|
<Icon />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
Popover,
|
Popover,
|
||||||
Pagination,
|
Pagination,
|
||||||
Select,
|
Select,
|
||||||
Table,
|
|
||||||
Tag,
|
Tag,
|
||||||
TextArea,
|
TextArea,
|
||||||
Toast,
|
Toast,
|
||||||
@@ -118,6 +118,9 @@ const initialColumns = [
|
|||||||
"source",
|
"source",
|
||||||
];
|
];
|
||||||
const columnLabels: Record<string, string> = {
|
const columnLabels: Record<string, string> = {
|
||||||
|
selection: "Selection",
|
||||||
|
combo: "Combo",
|
||||||
|
component_count: "组件数(已核实)",
|
||||||
source: "研究来源",
|
source: "研究来源",
|
||||||
name: "Alpha",
|
name: "Alpha",
|
||||||
expression: "表达式",
|
expression: "表达式",
|
||||||
@@ -147,6 +150,7 @@ const columnLabels: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AlphaPage({
|
export function AlphaPage({
|
||||||
|
managementScope = "non_super",
|
||||||
account,
|
account,
|
||||||
taskPanelOpen,
|
taskPanelOpen,
|
||||||
version,
|
version,
|
||||||
@@ -160,6 +164,7 @@ export function AlphaPage({
|
|||||||
onOverlay,
|
onOverlay,
|
||||||
onAction,
|
onAction,
|
||||||
}: {
|
}: {
|
||||||
|
managementScope?: "super" | "non_super";
|
||||||
account: Account | null;
|
account: Account | null;
|
||||||
taskPanelOpen: boolean;
|
taskPanelOpen: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -173,6 +178,21 @@ export function AlphaPage({
|
|||||||
onOverlay: () => void;
|
onOverlay: () => void;
|
||||||
onAction: (action: WorkspaceAction) => void;
|
onAction: (action: WorkspaceAction) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const columnKey =
|
||||||
|
managementScope === "super" ? "superalpha-columns" : "alpha-columns";
|
||||||
|
const defaultColumns =
|
||||||
|
managementScope === "super"
|
||||||
|
? initialColumns.flatMap((c) =>
|
||||||
|
c === "expression" ? ["selection", "combo", "component_count"] : [c],
|
||||||
|
)
|
||||||
|
: initialColumns;
|
||||||
|
const menuColumns = Object.fromEntries(
|
||||||
|
Object.entries(columnLabels).filter(([k]) =>
|
||||||
|
managementScope === "super"
|
||||||
|
? k !== "expression"
|
||||||
|
: !["selection", "combo", "component_count"].includes(k),
|
||||||
|
),
|
||||||
|
);
|
||||||
const [data, setData] = useState<Page>({
|
const [data, setData] = useState<Page>({
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -197,7 +217,7 @@ export function AlphaPage({
|
|||||||
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
|
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
|
||||||
try {
|
try {
|
||||||
const stored: unknown = JSON.parse(
|
const stored: unknown = JSON.parse(
|
||||||
localStorage.getItem("alpha-columns") || "null",
|
localStorage.getItem(columnKey) || "null",
|
||||||
);
|
);
|
||||||
return Array.isArray(stored)
|
return Array.isArray(stored)
|
||||||
? [
|
? [
|
||||||
@@ -208,9 +228,9 @@ export function AlphaPage({
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
: initialColumns;
|
: defaultColumns;
|
||||||
} catch {
|
} catch {
|
||||||
return initialColumns;
|
return defaultColumns;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
@@ -223,6 +243,7 @@ export function AlphaPage({
|
|||||||
version: number;
|
version: number;
|
||||||
text: string;
|
text: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const selectionVersions = useRef<Record<string, number>>({});
|
||||||
const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
|
const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
|
||||||
const actionNonce = useRef(0);
|
const actionNonce = useRef(0);
|
||||||
const [bulk, setBulk] = useState({
|
const [bulk, setBulk] = useState({
|
||||||
@@ -250,6 +271,7 @@ export function AlphaPage({
|
|||||||
const params = useMemo(
|
const params = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...filters,
|
...filters,
|
||||||
|
management_scope: managementScope,
|
||||||
submission,
|
submission,
|
||||||
submission_blocked: submissionBlocked || undefined,
|
submission_blocked: submissionBlocked || undefined,
|
||||||
sort,
|
sort,
|
||||||
@@ -257,15 +279,24 @@ export function AlphaPage({
|
|||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset: (page - 1) * pageSize,
|
offset: (page - 1) * pageSize,
|
||||||
}),
|
}),
|
||||||
[filters, submission, submissionBlocked, sort, direction, pageSize, page],
|
[
|
||||||
|
filters,
|
||||||
|
submission,
|
||||||
|
submissionBlocked,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
pageSize,
|
||||||
|
page,
|
||||||
|
managementScope,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const query = queryString(params);
|
const query = queryString(params);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
onContext({
|
onContext({
|
||||||
page: "alphas",
|
page: managementScope === "super" ? "superalphas" : "alphas",
|
||||||
alpha_id: detailId,
|
alpha_id: detailId,
|
||||||
selected_ids: selected,
|
selected_ids: selected.slice(0, 100),
|
||||||
filters: Object.fromEntries(
|
filters: Object.fromEntries(
|
||||||
Object.entries(params).filter(
|
Object.entries(params).filter(
|
||||||
([, value]) => value !== "" && value !== undefined && value !== null,
|
([, value]) => value !== "" && value !== undefined && value !== null,
|
||||||
@@ -274,7 +305,7 @@ export function AlphaPage({
|
|||||||
});
|
});
|
||||||
}, [active, detailId, selected, params, onContext]);
|
}, [active, detailId, selected, params, onContext]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!action || actionNonce.current === action.nonce) return;
|
if (!active || !action || actionNonce.current === action.nonce) return;
|
||||||
actionNonce.current = action.nonce;
|
actionNonce.current = action.nonce;
|
||||||
if (action.type === "open_alpha") {
|
if (action.type === "open_alpha") {
|
||||||
setDetailId(action.alpha_id);
|
setDetailId(action.alpha_id);
|
||||||
@@ -306,7 +337,7 @@ export function AlphaPage({
|
|||||||
if (nextSort) setSort(String(nextSort));
|
if (nextSort) setSort(String(nextSort));
|
||||||
if (nextDirection) setDirection(String(nextDirection));
|
if (nextDirection) setDirection(String(nextDirection));
|
||||||
}
|
}
|
||||||
}, [action, onOverlay]);
|
}, [action, onOverlay, active]);
|
||||||
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
|
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -315,7 +346,9 @@ export function AlphaPage({
|
|||||||
setError("");
|
setError("");
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api<Page>(`/alphas?${query}`, { signal: controller.signal }),
|
api<Page>(`/alphas?${query}`, { signal: controller.signal }),
|
||||||
api<Facets>("/alphas/facets", { signal: controller.signal }),
|
api<Facets>(`/alphas/facets?management_scope=${managementScope}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
.then(([results, nextFacets]) => {
|
.then(([results, nextFacets]) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@@ -333,6 +366,9 @@ export function AlphaPage({
|
|||||||
}, [active, query, version, localVersion]);
|
}, [active, query, version, localVersion]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
|
selectionVersions.current = {};
|
||||||
|
}, [filters, submission, submissionBlocked, managementScope]);
|
||||||
|
useEffect(() => {
|
||||||
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
|
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
|
||||||
}, [query]);
|
}, [query]);
|
||||||
function updateDraft(key: string, value: unknown) {
|
function updateDraft(key: string, value: unknown) {
|
||||||
@@ -464,6 +500,24 @@ export function AlphaPage({
|
|||||||
</code>
|
</code>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
...(["selection", "combo"] as const).map(
|
||||||
|
(key): ColumnProps<Alpha> => ({
|
||||||
|
key,
|
||||||
|
title: columnLabels[key],
|
||||||
|
width: 260,
|
||||||
|
render: (_, row) => (
|
||||||
|
<code title={row![`${key}_preview`]}>
|
||||||
|
{row![`${key}_preview`] || "未提供"}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
key: "component_count",
|
||||||
|
title: "组件数(已核实)",
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) => row!.component_count ?? "未核实",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "source",
|
key: "source",
|
||||||
title: "研究来源",
|
title: "研究来源",
|
||||||
@@ -953,6 +1007,7 @@ export function AlphaPage({
|
|||||||
{error && <Banner type="danger" description={error} />}
|
{error && <Banner type="danger" description={error} />}
|
||||||
<section className="library-panel" ref={tablePanel}>
|
<section className="library-panel" ref={tablePanel}>
|
||||||
<SavedViews
|
<SavedViews
|
||||||
|
managementScope={managementScope}
|
||||||
submission={submission}
|
submission={submission}
|
||||||
submissionBlocked={submissionBlocked}
|
submissionBlocked={submissionBlocked}
|
||||||
selectedId={selectedViewId}
|
selectedId={selectedViewId}
|
||||||
@@ -1005,7 +1060,7 @@ export function AlphaPage({
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
setVisibleColumns(next);
|
setVisibleColumns(next);
|
||||||
localStorage.setItem("alpha-columns", JSON.stringify(next));
|
localStorage.setItem(columnKey, JSON.stringify(next));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -1058,7 +1113,7 @@ export function AlphaPage({
|
|||||||
<CheckboxGroup
|
<CheckboxGroup
|
||||||
direction="vertical"
|
direction="vertical"
|
||||||
value={displayedColumns}
|
value={displayedColumns}
|
||||||
options={Object.entries(columnLabels).map(
|
options={Object.entries(menuColumns).map(
|
||||||
([value, label]) => ({
|
([value, label]) => ({
|
||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
@@ -1071,10 +1126,7 @@ export function AlphaPage({
|
|||||||
onChange={(values) => {
|
onChange={(values) => {
|
||||||
const next = values.map(String);
|
const next = values.map(String);
|
||||||
setVisibleColumns(next);
|
setVisibleColumns(next);
|
||||||
localStorage.setItem(
|
localStorage.setItem(columnKey, JSON.stringify(next));
|
||||||
"alpha-columns",
|
|
||||||
JSON.stringify(next),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1221,9 +1273,7 @@ export function AlphaPage({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBulkVersions(
|
setBulkVersions(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
data.items
|
selected.map((id) => [id, selectionVersions.current[id]]),
|
||||||
.filter((row) => selected.includes(row.id))
|
|
||||||
.map((row) => [row.id, row.research.version]),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setBulkOpen(true);
|
setBulkOpen(true);
|
||||||
@@ -1252,7 +1302,8 @@ export function AlphaPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Table<Alpha>
|
<WorkspaceTable<Alpha>
|
||||||
|
fill
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data.items}
|
dataSource={data.items}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
@@ -1265,7 +1316,19 @@ export function AlphaPage({
|
|||||||
}}
|
}}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selected,
|
selectedRowKeys: selected,
|
||||||
onChange: (keys) => setSelected((keys ?? []).map(String)),
|
onChange: (keys) => {
|
||||||
|
const pageIds = new Set(data.items.map((r) => r.id));
|
||||||
|
const nextIds = (keys ?? [])
|
||||||
|
.map(String)
|
||||||
|
.filter((id) => pageIds.has(id));
|
||||||
|
for (const row of data.items)
|
||||||
|
if (nextIds.includes(row.id))
|
||||||
|
selectionVersions.current[row.id] = row.research.version;
|
||||||
|
setSelected((old) => [
|
||||||
|
...old.filter((id) => !pageIds.has(id)),
|
||||||
|
...nextIds,
|
||||||
|
]);
|
||||||
|
},
|
||||||
fixed: !compact,
|
fixed: !compact,
|
||||||
}}
|
}}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ const scopes = [
|
|||||||
[
|
[
|
||||||
"research:refresh",
|
"research:refresh",
|
||||||
"刷新研究数据",
|
"刷新研究数据",
|
||||||
"更新缓存、检查自相关及恢复 WorldQuant 认证",
|
"更新缓存、预览 SUPER 组件、检查自相关及恢复 WorldQuant 认证",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"research:write",
|
"research:write",
|
||||||
"保存研究模板",
|
"保存研究模板与方案",
|
||||||
"保存大模型总结的模板及来源,供后续批量回测",
|
"保存模板、Super Alpha 方案及构造候选,不执行回测",
|
||||||
],
|
],
|
||||||
["backtests:execute", "执行回测", "提交新的回测批次"],
|
["backtests:execute", "执行回测", "提交新的回测批次"],
|
||||||
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
|
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function filterSignature(filters: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SavedViews({
|
export function SavedViews({
|
||||||
|
managementScope = "non_super",
|
||||||
filters,
|
filters,
|
||||||
columns,
|
columns,
|
||||||
submission,
|
submission,
|
||||||
@@ -55,6 +56,7 @@ export function SavedViews({
|
|||||||
suspended,
|
suspended,
|
||||||
onOverlay,
|
onOverlay,
|
||||||
}: {
|
}: {
|
||||||
|
managementScope?: "super" | "non_super";
|
||||||
filters: Record<string, unknown>;
|
filters: Record<string, unknown>;
|
||||||
columns: string[];
|
columns: string[];
|
||||||
submission: Submission;
|
submission: Submission;
|
||||||
@@ -90,13 +92,22 @@ export function SavedViews({
|
|||||||
views.push(...result.items);
|
views.push(...result.items);
|
||||||
if (views.length >= result.total || result.items.length === 0) break;
|
if (views.length >= result.total || result.items.length === 0) break;
|
||||||
}
|
}
|
||||||
if (!controller.signal.aborted) setItems(views);
|
if (!controller.signal.aborted)
|
||||||
|
setItems(
|
||||||
|
views.filter(
|
||||||
|
(v) =>
|
||||||
|
(v.content.filters.management_scope ??
|
||||||
|
(v.content.filters.alpha_type === "SUPER"
|
||||||
|
? "super"
|
||||||
|
: "non_super")) === managementScope,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
void load().catch((error: Error) => {
|
void load().catch((error: Error) => {
|
||||||
if (!controller.signal.aborted) Toast.error(error.message);
|
if (!controller.signal.aborted) Toast.error(error.message);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [revision]);
|
}, [revision, managementScope]);
|
||||||
|
|
||||||
function openEditor(mode: Editor["mode"], view?: View) {
|
function openEditor(mode: Editor["mode"], view?: View) {
|
||||||
const content = view?.content ?? { name: "", filters, columns };
|
const content = view?.content ?? { name: "", filters, columns };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "./style.css";
|
|||||||
|
|
||||||
export const sourceLabel = (kind: string) =>
|
export const sourceLabel = (kind: string) =>
|
||||||
({
|
({
|
||||||
|
superalpha: "Super Alpha 研究",
|
||||||
chatbox: "Chatbox 研究",
|
chatbox: "Chatbox 研究",
|
||||||
manual: "手工研究",
|
manual: "手工研究",
|
||||||
mcp: "MCP 研究",
|
mcp: "MCP 研究",
|
||||||
@@ -33,6 +34,24 @@ export function SourceDetails({
|
|||||||
<p>来源引用:{source.reference}</p>
|
<p>来源引用:{source.reference}</p>
|
||||||
)}
|
)}
|
||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
|
{(source.superalpha_plan_id ||
|
||||||
|
((source.kind === "superalpha" ||
|
||||||
|
source.research_kind === "superalpha") &&
|
||||||
|
source.research_id)) && (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
onAction({
|
||||||
|
type: "open_superalpha_research",
|
||||||
|
plan_id: source.superalpha_plan_id ?? undefined,
|
||||||
|
version: source.superalpha_plan_version ?? undefined,
|
||||||
|
experiment_id: source.research_id ?? undefined,
|
||||||
|
nonce: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
查看 Super Alpha 研究
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{source.research_id &&
|
{source.research_id &&
|
||||||
["template", "variant", "feature", "pipeline", "quantflow"].includes(
|
["template", "variant", "feature", "pipeline", "quantflow"].includes(
|
||||||
source.kind,
|
source.kind,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
|
|
||||||
/** One typed settings value for all producers; fixed inputs constrain the scope, never the numeric parameters. */
|
/** One typed settings value for all producers; fixed inputs constrain the scope, never the numeric parameters. */
|
||||||
export function SimulationSettingsEditor({
|
export function SimulationSettingsEditor({
|
||||||
|
alphaType = "REGULAR",
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
rows,
|
rows,
|
||||||
@@ -28,6 +29,7 @@ export function SimulationSettingsEditor({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
}: {
|
}: {
|
||||||
|
alphaType?: "REGULAR" | "SUPER";
|
||||||
value: SimulationSettings;
|
value: SimulationSettings;
|
||||||
onChange: (value: SimulationSettings) => void;
|
onChange: (value: SimulationSettings) => void;
|
||||||
rows: SettingsRow[];
|
rows: SettingsRow[];
|
||||||
@@ -98,8 +100,10 @@ export function SimulationSettingsEditor({
|
|||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
min={options.minimum}
|
min={options.minimum}
|
||||||
max={options.maximum}
|
max={options.maximum}
|
||||||
precision={key === "decay" ? 0 : undefined}
|
precision={
|
||||||
step={key === "decay" ? 1 : 0.01}
|
["decay", "selectionLimit"].includes(key) ? 0 : undefined
|
||||||
|
}
|
||||||
|
step={["decay", "selectionLimit"].includes(key) ? 1 : 0.01}
|
||||||
validateStatus={message ? "error" : "default"}
|
validateStatus={message ? "error" : "default"}
|
||||||
onChange={(next) =>
|
onChange={(next) =>
|
||||||
onChange({
|
onChange({
|
||||||
@@ -161,6 +165,23 @@ export function SimulationSettingsEditor({
|
|||||||
<div className="settings-grid">
|
<div className="settings-grid">
|
||||||
{["neutralization", "decay", "truncation"].map(field)}
|
{["neutralization", "decay", "truncation"].map(field)}
|
||||||
</div>
|
</div>
|
||||||
|
{alphaType === "SUPER" && (
|
||||||
|
<>
|
||||||
|
<div className="settings-grid">
|
||||||
|
{["selectionHandling", "selectionLimit", "componentActivation"].map(
|
||||||
|
field,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{["selectionHandling", "selectionLimit", "componentActivation"].some(
|
||||||
|
(k) => !row?.fields?.[k],
|
||||||
|
) && (
|
||||||
|
<small className="settings-hint">
|
||||||
|
平台元数据未提供全部 SUPER
|
||||||
|
设置范围;当前显示本地契约,账户适用性未核实。
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<details>
|
<details>
|
||||||
<summary>更多参数</summary>
|
<summary>更多参数</summary>
|
||||||
<div className="settings-grid">
|
<div className="settings-grid">
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ export function asScopeOptions(rows: SettingsRow[]): ScopeOption[] {
|
|||||||
return rows.map((row) => ({ ...row, universes: [row.universe] }));
|
return rows.map((row) => ({ ...row, universes: [row.universe] }));
|
||||||
}
|
}
|
||||||
export const parameterLabels: Record<string, string> = {
|
export const parameterLabels: Record<string, string> = {
|
||||||
|
selectionHandling: "Selection Handling",
|
||||||
|
selectionLimit: "Selection Limit",
|
||||||
|
componentActivation: "Component Activation",
|
||||||
neutralization: "Neutralization",
|
neutralization: "Neutralization",
|
||||||
decay: "Decay",
|
decay: "Decay",
|
||||||
truncation: "Truncation",
|
truncation: "Truncation",
|
||||||
@@ -99,6 +102,9 @@ export const parameterLabels: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
// These are application contract limits, not a substitute for account-specific market choices.
|
// These are application contract limits, not a substitute for account-specific market choices.
|
||||||
const contractFields: Record<string, FieldOption> = {
|
const contractFields: Record<string, FieldOption> = {
|
||||||
|
selectionHandling: { choices: ["POSITIVE", "NON_ZERO", "NON_NAN"] },
|
||||||
|
componentActivation: { choices: ["IS", "OS"] },
|
||||||
|
selectionLimit: { minimum: 1, maximum: 100000 },
|
||||||
decay: { minimum: 0, maximum: 10000 },
|
decay: { minimum: 0, maximum: 10000 },
|
||||||
truncation: { minimum: 0, maximum: 1 },
|
truncation: { minimum: 0, maximum: 1 },
|
||||||
pasteurization: { choices: ["ON", "OFF"] },
|
pasteurization: { choices: ["ON", "OFF"] },
|
||||||
@@ -143,7 +149,10 @@ export function fieldOptions(
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
if (result.choices && (key === "decay" || key === "truncation")) {
|
if (
|
||||||
|
result.choices &&
|
||||||
|
["decay", "truncation", "selectionLimit"].includes(key)
|
||||||
|
) {
|
||||||
result.choices = result.choices.filter(
|
result.choices = result.choices.filter(
|
||||||
(v) =>
|
(v) =>
|
||||||
typeof v === "number" &&
|
typeof v === "number" &&
|
||||||
@@ -171,6 +180,13 @@ export function settingsErrors(
|
|||||||
)
|
)
|
||||||
errors.push("所选数据准备必须具有相同组合,并与回测组合一致");
|
errors.push("所选数据准备必须具有相同组合,并与回测组合一致");
|
||||||
for (const key of Object.keys(parameterLabels)) {
|
for (const key of Object.keys(parameterLabels)) {
|
||||||
|
if (
|
||||||
|
["selectionHandling", "selectionLimit", "componentActivation"].includes(
|
||||||
|
key,
|
||||||
|
) &&
|
||||||
|
value.selectionHandling === undefined
|
||||||
|
)
|
||||||
|
continue;
|
||||||
const current =
|
const current =
|
||||||
value[key as keyof SimulationSettings] ??
|
value[key as keyof SimulationSettings] ??
|
||||||
(key === "maxPosition" ? "OFF" : undefined);
|
(key === "maxPosition" ? "OFF" : undefined);
|
||||||
@@ -183,12 +199,13 @@ export function settingsErrors(
|
|||||||
`${parameterLabels[key]} 当前值 ${String(current ?? "未提供")} 不可用,请重新选择`,
|
`${parameterLabels[key]} 当前值 ${String(current ?? "未提供")} 不可用,请重新选择`,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
(key === "decay" || key === "truncation") &&
|
["decay", "truncation", "selectionLimit"].includes(key) &&
|
||||||
(typeof current !== "number" ||
|
(typeof current !== "number" ||
|
||||||
!Number.isFinite(current) ||
|
!Number.isFinite(current) ||
|
||||||
current < options.minimum! ||
|
current < options.minimum! ||
|
||||||
current > options.maximum! ||
|
current > options.maximum! ||
|
||||||
(key === "decay" && !Number.isInteger(current)))
|
(["decay", "selectionLimit"].includes(key) &&
|
||||||
|
!Number.isInteger(current)))
|
||||||
)
|
)
|
||||||
errors.push(
|
errors.push(
|
||||||
`${parameterLabels[key]} 必须为 ${options.minimum}–${options.maximum} 的${key === "decay" ? "整数" : "数值"}`,
|
`${parameterLabels[key]} 必须为 ${options.minimum}–${options.maximum} 的${key === "decay" ? "整数" : "数值"}`,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Banner, Pagination, Spin } from "@douyinfe/semi-ui-19";
|
||||||
|
import { api, displayValue, formatTime } from "../api";
|
||||||
|
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||||
|
import type { Components } from "./types";
|
||||||
|
|
||||||
|
/** Component evidence is always read from its own immutable observation. */
|
||||||
|
export function ComponentsPanel({
|
||||||
|
url,
|
||||||
|
timezone,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
timezone?: string;
|
||||||
|
}) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [data, setData] = useState<Components>();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setData(undefined);
|
||||||
|
}, [url]);
|
||||||
|
useEffect(() => {
|
||||||
|
const c = new AbortController();
|
||||||
|
setError("");
|
||||||
|
void api<Components>(
|
||||||
|
`${url}${url.includes("?") ? "&" : "?"}limit=25&offset=${(page - 1) * 25}`,
|
||||||
|
{ signal: c.signal },
|
||||||
|
)
|
||||||
|
.then((value) => {
|
||||||
|
if (!c.signal.aborted) setData(value);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (!c.signal.aborted) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => c.abort();
|
||||||
|
}, [url, page]);
|
||||||
|
if (error) return <Banner type="danger" description={error} />;
|
||||||
|
if (!data) return <Spin />;
|
||||||
|
return (
|
||||||
|
<section className="super-components">
|
||||||
|
<p>
|
||||||
|
{data.source === "preview" ? "Selection 预览组件" : "回测实际组件"} ·{" "}
|
||||||
|
{data.complete
|
||||||
|
? `已核实 ${data.reported_total ?? data.total} 个`
|
||||||
|
: "完整性未核实"}{" "}
|
||||||
|
· 观察时间 {formatTime(data.observed_at, timezone)}
|
||||||
|
</p>
|
||||||
|
{data.complete && data.total === 0 && (
|
||||||
|
<Banner
|
||||||
|
type="warning"
|
||||||
|
description="组件池为空,回测可能无法产生结果。请调整 Selection。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(data.warnings ?? []).map((w, i) => (
|
||||||
|
<Banner key={i} type="warning" description={displayValue(w)} />
|
||||||
|
))}
|
||||||
|
<p className="muted">组件指纹:{data.component_hash ?? "未核实"}</p>
|
||||||
|
<WorkspaceTable
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={data.items}
|
||||||
|
scroll={{ x: 1100, y: 320 }}
|
||||||
|
columns={[
|
||||||
|
{ title: "组件 ID", dataIndex: "id", width: 190 },
|
||||||
|
{
|
||||||
|
title: "平台选择值",
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) =>
|
||||||
|
displayValue(r?.value ?? r?.selectionValue ?? r?.selection),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "可用指标与平台返回字段",
|
||||||
|
width: 730,
|
||||||
|
render: (_, r) => JSON.stringify(r),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
empty="未提供组件"
|
||||||
|
/>
|
||||||
|
<footer className="workspace-table-footer super-toolbar">
|
||||||
|
<span>已保存 {data.total} 条</span>
|
||||||
|
<Pagination
|
||||||
|
total={data.total}
|
||||||
|
currentPage={page}
|
||||||
|
pageSize={25}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
.super-research {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--semi-color-bg-0);
|
||||||
|
}
|
||||||
|
.super-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.super-toolbar > .semi-input-wrapper {
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.super-toolbar .super-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.super-editor {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.super-editor label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.super-editor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.super-editor .semi-input-textarea {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.super-pane {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.super-pane > .semi-banner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.super-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.super-research pre,
|
||||||
|
.super-evidence pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.super-components {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.super-evidence {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.super-evidence > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.super-editor-grid,
|
||||||
|
.super-evidence {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.super-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
initialSettings,
|
||||||
|
type Candidate,
|
||||||
|
type SimulationSettings,
|
||||||
|
type Source,
|
||||||
|
} from "../backtests/types";
|
||||||
|
export type PlanSpec = {
|
||||||
|
name: string;
|
||||||
|
hypothesis: string;
|
||||||
|
reference: string;
|
||||||
|
selection: string;
|
||||||
|
combo: string;
|
||||||
|
settings: SimulationSettings;
|
||||||
|
variables: Record<string, { kind: string; values: (string | number)[] }>;
|
||||||
|
setting_variants: Record<string, (string | number | boolean)[]>;
|
||||||
|
include_baseline: boolean;
|
||||||
|
parent_plan_id?: string | null;
|
||||||
|
parent_plan_version?: number | null;
|
||||||
|
parent_alpha_id?: string | null;
|
||||||
|
parent_experiment_id?: string | null;
|
||||||
|
};
|
||||||
|
export const newPlan = (): PlanSpec => ({
|
||||||
|
name: "",
|
||||||
|
hypothesis: "",
|
||||||
|
reference: "",
|
||||||
|
selection: "",
|
||||||
|
combo: "1",
|
||||||
|
variables: {},
|
||||||
|
setting_variants: {},
|
||||||
|
include_baseline: true,
|
||||||
|
settings: {
|
||||||
|
...initialSettings,
|
||||||
|
selectionHandling: "POSITIVE",
|
||||||
|
selectionLimit: 100,
|
||||||
|
componentActivation: "IS",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export type Plan = {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
latest_version: number;
|
||||||
|
name: string;
|
||||||
|
archived: boolean;
|
||||||
|
content: PlanSpec;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
export type Components = {
|
||||||
|
snapshot_id?: string;
|
||||||
|
job_id?: string;
|
||||||
|
item_id?: string;
|
||||||
|
source: string;
|
||||||
|
status: string;
|
||||||
|
complete: boolean;
|
||||||
|
reported_total?: number | null;
|
||||||
|
total: number;
|
||||||
|
observed_at: string | null;
|
||||||
|
component_hash?: string | null;
|
||||||
|
request_hash?: string;
|
||||||
|
warnings?: unknown[];
|
||||||
|
error?: string;
|
||||||
|
request?: { selection: string; settings: SimulationSettings };
|
||||||
|
items: { id: string; [key: string]: unknown }[];
|
||||||
|
};
|
||||||
|
export type Experiment = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
candidates: Candidate[];
|
||||||
|
source: Source;
|
||||||
|
created_at: string;
|
||||||
|
evidence: {
|
||||||
|
plan: PlanSpec;
|
||||||
|
plan_reference: { id?: string; version?: number };
|
||||||
|
selection_snapshot_ids: string[];
|
||||||
|
combination_count: string;
|
||||||
|
mode: string;
|
||||||
|
seed: number;
|
||||||
|
annotations: Record<
|
||||||
|
string,
|
||||||
|
{ baseline: boolean; duplicate_of: string | null; request_hash: string }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type Result = {
|
||||||
|
id: string;
|
||||||
|
run_id: string;
|
||||||
|
client_item_id: string;
|
||||||
|
alpha_id?: string | null;
|
||||||
|
selection: string;
|
||||||
|
combo: string;
|
||||||
|
settings: SimulationSettings;
|
||||||
|
error?: string | null;
|
||||||
|
platform_status: string;
|
||||||
|
collection_status: string;
|
||||||
|
persistence_status: string;
|
||||||
|
metrics: {
|
||||||
|
is: Record<string, number | null>;
|
||||||
|
os: Record<string, number | null>;
|
||||||
|
};
|
||||||
|
checks: {
|
||||||
|
status: string;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
total: number;
|
||||||
|
non_pass: unknown[];
|
||||||
|
};
|
||||||
|
result: { observed_at: string; complete: boolean } | null;
|
||||||
|
components: Components;
|
||||||
|
pnl_fetched_at: string | null;
|
||||||
|
};
|
||||||
@@ -39,6 +39,9 @@ export type Alpha = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
expression_preview: string;
|
expression_preview: string;
|
||||||
|
selection_preview?: string;
|
||||||
|
combo_preview?: string;
|
||||||
|
component_count?: number | null;
|
||||||
alpha_type: string | null;
|
alpha_type: string | null;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
stage: string | null;
|
stage: string | null;
|
||||||
|
|||||||
@@ -63,17 +63,26 @@ test("sidebar geometry, recent navigation, user menu and compact tools", async (
|
|||||||
await page.getByRole("button", { name: "同步任务", exact: true }).click();
|
await page.getByRole("button", { name: "同步任务", exact: true }).click();
|
||||||
await expect(page.locator(".job-panel")).toBeVisible();
|
await expect(page.locator(".job-panel")).toBeVisible();
|
||||||
// The drawer must receive pointer events where the global tools sit behind it.
|
// The drawer must receive pointer events where the global tools sit behind it.
|
||||||
await expect.poll(() =>
|
await expect
|
||||||
page.getByRole("toolbar").getByRole("button").evaluateAll((buttons) =>
|
.poll(() =>
|
||||||
buttons.every((button) => {
|
page
|
||||||
const rect = button.getBoundingClientRect();
|
.getByRole("toolbar")
|
||||||
return Boolean(document.elementFromPoint(
|
.getByRole("button")
|
||||||
rect.x + rect.width / 2,
|
.evaluateAll((buttons) =>
|
||||||
rect.y + rect.height / 2,
|
buttons.every((button) => {
|
||||||
)?.closest(".job-panel"));
|
const rect = button.getBoundingClientRect();
|
||||||
}),
|
return Boolean(
|
||||||
),
|
document
|
||||||
).toBe(true);
|
.elementFromPoint(
|
||||||
|
rect.x + rect.width / 2,
|
||||||
|
rect.y + rect.height / 2,
|
||||||
|
)
|
||||||
|
?.closest(".job-panel"),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
await page.locator(".job-panel .semi-sidesheet-close").click();
|
await page.locator(".job-panel .semi-sidesheet-close").click();
|
||||||
await expect(page.locator(".job-panel")).not.toBeVisible();
|
await expect(page.locator(".job-panel")).not.toBeVisible();
|
||||||
await page.getByRole("button", { name: "切换研究助手" }).click();
|
await page.getByRole("button", { name: "切换研究助手" }).click();
|
||||||
@@ -85,7 +94,7 @@ test("sidebar geometry, recent navigation, user menu and compact tools", async (
|
|||||||
await page.keyboard.press("Control+k");
|
await page.keyboard.press("Control+k");
|
||||||
await page
|
await page
|
||||||
.getByRole("dialog")
|
.getByRole("dialog")
|
||||||
.getByRole("button", { name: "Alpha 管理" })
|
.getByRole("button", { name: "Alpha 管理", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("tab", { name: "待提交", exact: true }),
|
page.getByRole("tab", { name: "待提交", exact: true }),
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
const headers = { "X-WQ-Request": "1" };
|
||||||
|
|
||||||
|
test("Super Alpha web lifecycle, generic simulation, evidence and type routing", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (e) => errors.push(e.message));
|
||||||
|
await page.goto("/#superalpha-research");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||||
|
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "新建 Super Alpha 方案", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.request.put("/api/v1/account/credentials", {
|
||||||
|
headers,
|
||||||
|
data: { email: "test@example.com", password: "synthetic-password" },
|
||||||
|
});
|
||||||
|
await page.request.post("/api/v1/account/connect", { headers });
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get("/api/v1/account")).json())
|
||||||
|
.connection_status,
|
||||||
|
)
|
||||||
|
.toBe("connected");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "新建 Super Alpha 方案", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel("方案名称", { exact: true }).fill("浏览器 SUPER 验收");
|
||||||
|
await page
|
||||||
|
.getByLabel("研究假设", { exact: true })
|
||||||
|
.fill("比较低换手组件与等权基线");
|
||||||
|
await page
|
||||||
|
.getByLabel("Selection", { exact: true })
|
||||||
|
.fill("turnover < {threshold}");
|
||||||
|
await page.getByLabel("Combo", { exact: true }).fill("alpha");
|
||||||
|
await page
|
||||||
|
.getByLabel("参数候选 JSON", { exact: true })
|
||||||
|
.fill('{"threshold":{"kind":"number","values":[0.1,0.2]}}');
|
||||||
|
await page.getByRole("button", { name: "同步合法设置", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "选择组合", exact: true }).click();
|
||||||
|
await page.getByRole("combobox", { name: "Region", exact: true }).click();
|
||||||
|
await page.getByRole("option").filter({ hasText: /^USA$/ }).click();
|
||||||
|
await page.getByRole("combobox", { name: "Universe", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("option")
|
||||||
|
.filter({ hasText: /^TOP3000$/ })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "应用组合", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "保存方案", exact: true }).click();
|
||||||
|
await expect(page.getByText("已保存版本 1", { exact: true })).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "预览 Selection(首组参数)", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator(".super-components")).toContainText("已核实 2 个");
|
||||||
|
await page.getByRole("tab", { name: "固定候选", exact: true }).click();
|
||||||
|
const building = page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.url().endsWith("/superalpha/candidates") &&
|
||||||
|
r.request().method() === "POST",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "展开固定候选", exact: true }).click();
|
||||||
|
const experiment = await (await building).json();
|
||||||
|
expect(experiment.total).toBe(4);
|
||||||
|
const table = page.locator(
|
||||||
|
".super-research .semi-tabs-pane-active .workspace-table",
|
||||||
|
);
|
||||||
|
await expect(table.locator("tbody tr.semi-table-row")).toHaveCount(4);
|
||||||
|
expect(
|
||||||
|
Math.round(
|
||||||
|
(await table.locator("tbody tr.semi-table-row").first().boundingBox())!
|
||||||
|
.height,
|
||||||
|
),
|
||||||
|
).toBe(40);
|
||||||
|
await table.locator("thead .semi-checkbox").click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "预览回测(4)", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText(/浏览器 SUPER 验收 · 4 条候选 · 4 个平台批次/),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("SUPER · Selection: turnover < 0.1 · Combo: alpha", {
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "确认启动回测", exact: true }).click();
|
||||||
|
await expect(page.getByText("4 / 4 已保存", { exact: true })).toBeVisible({
|
||||||
|
timeout: 25000,
|
||||||
|
});
|
||||||
|
const result = await (
|
||||||
|
await page.request.get(
|
||||||
|
`/api/v1/superalpha/experiments/${experiment.id}/results`,
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
expect(
|
||||||
|
result.items.every(
|
||||||
|
(i: { persistence_status: string }) => i.persistence_status === "saved",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
const alpha = result.items[0].alpha_id;
|
||||||
|
await page.goto(`/#alphas?alpha_id=${alpha}`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Super Alpha 管理", exact: true }),
|
||||||
|
).toHaveAttribute("aria-current", "page");
|
||||||
|
await expect(page.locator(".alpha-detail")).toContainText(
|
||||||
|
"Selection rationale",
|
||||||
|
);
|
||||||
|
await page.getByRole("tab", { name: "组件证据", exact: true }).click();
|
||||||
|
await expect(page.locator(".alpha-detail .super-components")).toContainText(
|
||||||
|
"已核实 2 个",
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "复制为研究方案", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByLabel("Selection", { exact: true })).toHaveValue(
|
||||||
|
result.items[0].selection,
|
||||||
|
);
|
||||||
|
await page.goto(`/#superalpha-research?experiment_id=${experiment.id}`);
|
||||||
|
await page.getByRole("tab", { name: "回测对照", exact: true }).click();
|
||||||
|
await expect(page.getByText("4 个回测项", { exact: true })).toBeVisible();
|
||||||
|
const checks = page.locator(
|
||||||
|
".super-research .semi-tabs-pane-active tbody input[type=checkbox]",
|
||||||
|
);
|
||||||
|
await checks.nth(0).focus();
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
await checks.nth(1).focus();
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
await page.getByRole("button", { name: "对照两项(2)" }).click();
|
||||||
|
await expect(page.getByRole("dialog")).toContainText("同池对照");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||||
|
for (const width of [1440, 850, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 950 });
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `../output/playwright/superalpha-${width}.png`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Super management scopes, saved views and cross-page selection", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/#superalphas");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||||
|
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("tab", { name: "待提交", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.request.put("/api/v1/account/credentials", {
|
||||||
|
headers,
|
||||||
|
data: { email: "test@example.com", password: "synthetic-password" },
|
||||||
|
});
|
||||||
|
await page.request.post("/api/v1/account/connect", { headers });
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get("/api/v1/account")).json())
|
||||||
|
.connection_status,
|
||||||
|
)
|
||||||
|
.toBe("connected");
|
||||||
|
const job = await (
|
||||||
|
await page.request.post("/api/v1/sync-jobs", {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
kind: "daily_sync",
|
||||||
|
submission: "UNSUBMITTED",
|
||||||
|
date_from: "2025-01-01",
|
||||||
|
date_to: "2025-01-01",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get(`/api/v1/sync-jobs/${job.id}`)).json())
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
.toBe("completed");
|
||||||
|
await page.reload();
|
||||||
|
const panel = page.locator(".alpha-page-view:not([hidden])");
|
||||||
|
await expect(panel.locator(".alpha-link").first()).toBeVisible();
|
||||||
|
const data = await (
|
||||||
|
await page.request.get(
|
||||||
|
"/api/v1/alphas?management_scope=super&submission=UNSUBMITTED",
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
expect(
|
||||||
|
data.items.every((a: { alpha_type: string }) => a.alpha_type === "SUPER"),
|
||||||
|
).toBe(true);
|
||||||
|
await panel.locator("tbody .semi-checkbox").first().click();
|
||||||
|
await panel.locator(".semi-page-item").filter({ hasText: /^2$/ }).click();
|
||||||
|
await expect(panel.getByText("已选择 1 条", { exact: false })).toBeVisible();
|
||||||
|
await panel.getByRole("button", { name: "保存为新视图" }).click();
|
||||||
|
await page.getByLabel("视图名称").fill("SUPER 隔离视图");
|
||||||
|
await page.getByRole("button", { name: "保存视图", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
panel.getByRole("tab", { name: /SUPER 隔离视图/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("tab", { name: /SUPER 隔离视图/ }),
|
||||||
|
).not.toBeVisible();
|
||||||
|
const regular = await (
|
||||||
|
await page.request.get("/api/v1/alphas?management_scope=non_super")
|
||||||
|
).json();
|
||||||
|
expect(
|
||||||
|
regular.items.every(
|
||||||
|
(a: { alpha_type: string }) => a.alpha_type !== "SUPER",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
@@ -65,7 +65,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
|
|||||||
async () =>
|
async () =>
|
||||||
(
|
(
|
||||||
await (
|
await (
|
||||||
await page.request.get("/api/v1/alphas?submission=UNSUBMITTED")
|
await page.request.get("/api/v1/alphas?submission=UNSUBMITTED&q=TEST")
|
||||||
).json()
|
).json()
|
||||||
).total,
|
).total,
|
||||||
)
|
)
|
||||||
@@ -77,28 +77,26 @@ test("account → scoped sync → research → resync → PnL → filtered expor
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () =>
|
async () =>
|
||||||
(await (await page.request.get("/api/v1/alphas")).json()).total,
|
(await (await page.request.get("/api/v1/alphas?q=TEST")).json()).total,
|
||||||
{ timeout: 20000 },
|
{ timeout: 20000 },
|
||||||
)
|
)
|
||||||
.toBe(620);
|
.toBe(620);
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(page.locator(".job-panel")).not.toBeVisible();
|
await expect(page.locator(".job-panel")).not.toBeVisible();
|
||||||
await page.getByRole("tab", { name: "待提交", exact: true }).click();
|
await page.getByRole("tab", { name: "待提交", exact: true }).click();
|
||||||
await expect(page.locator(".alpha-table-meta .count-pill")).toHaveText("413");
|
// Alpha 管理 now excludes SUPER; the upstream sync still imports both types.
|
||||||
|
await expect(page.locator(".alpha-page-view:not([hidden]) .alpha-table-meta .count-pill")).toHaveText("354");
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: "../output/playwright/alpha-library.png",
|
path: "../output/playwright/alpha-library.png",
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
});
|
});
|
||||||
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
|
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
|
||||||
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0007");
|
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0008");
|
||||||
await page.getByRole("button", { name: "查询", exact: true }).click();
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
await expect(page.locator(".alpha-link")).toHaveCount(1);
|
await expect(page.locator(".alpha-link")).toHaveCount(1);
|
||||||
await page.locator(".alpha-link").click();
|
await page.locator(".alpha-link").click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "Selection", exact: true }),
|
page.getByRole("heading", { name: "表达式", exact: true }),
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole("heading", { name: "Combo", exact: true }),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.getByRole("tab", { name: "研究信息" }).click();
|
await page.getByRole("tab", { name: "研究信息" }).click();
|
||||||
await page
|
await page
|
||||||
@@ -115,7 +113,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () =>
|
async () =>
|
||||||
(await (await page.request.get("/api/v1/alphas/TEST0007/pnl")).json())
|
(await (await page.request.get("/api/v1/alphas/TEST0008/pnl")).json())
|
||||||
.cached,
|
.cached,
|
||||||
)
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
@@ -131,7 +129,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
|
|||||||
const job = await (
|
const job = await (
|
||||||
await page.request.post("/api/v1/sync-jobs", {
|
await page.request.post("/api/v1/sync-jobs", {
|
||||||
headers: { "X-WQ-Request": "1" },
|
headers: { "X-WQ-Request": "1" },
|
||||||
data: { kind: "alpha_refresh", alpha_ids: ["TEST0007"] },
|
data: { kind: "alpha_refresh", alpha_ids: ["TEST0008"] },
|
||||||
})
|
})
|
||||||
).json();
|
).json();
|
||||||
await expect
|
await expect
|
||||||
@@ -143,7 +141,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
|
|||||||
.toBe("completed");
|
.toBe("completed");
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
|
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
|
||||||
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0007");
|
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0008");
|
||||||
await page.getByRole("button", { name: "查询", exact: true }).click();
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
await expect(page.locator(".alpha-link")).toHaveCount(1);
|
await expect(page.locator(".alpha-link")).toHaveCount(1);
|
||||||
await page.locator(".alpha-link").click();
|
await page.locator(".alpha-link").click();
|
||||||
@@ -165,13 +163,14 @@ test("account → scoped sync → research → resync → PnL → filtered expor
|
|||||||
expect(contents).toContain("TEST0619");
|
expect(contents).toContain("TEST0619");
|
||||||
expect(contents).toContain("TEST0001");
|
expect(contents).toContain("TEST0001");
|
||||||
expect(contents).not.toContain("TEST0000");
|
expect(contents).not.toContain("TEST0000");
|
||||||
expect((contents.match(/TEST\d{4}/g) ?? []).length).toBe(413);
|
expect(contents).not.toContain("TEST0007");
|
||||||
|
expect((contents.match(/TEST\d{4}/g) ?? []).length).toBe(354);
|
||||||
await page.getByRole("button", { name: "用户菜单" }).click();
|
await page.getByRole("button", { name: "用户菜单" }).click();
|
||||||
await page.getByRole("button", { name: "退出登录" }).click();
|
await page.getByRole("button", { name: "退出登录" }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "登录研究工作空间" }),
|
page.getByRole("heading", { name: "登录研究工作空间" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect((await page.request.get("/api/v1/alphas")).status()).toBe(401);
|
expect((await page.request.get("/api/v1/alphas?q=TEST")).status()).toBe(401);
|
||||||
expect(failures).toEqual([]);
|
expect(failures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +184,7 @@ test("batch tags, column visibility, server pagination and saved preferences", a
|
|||||||
page.getByRole("tab", { name: "待提交", exact: true }),
|
page.getByRole("tab", { name: "待提交", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
// Each test can run alone against the isolated browser acceptance server.
|
// Each test can run alone against the isolated browser acceptance server.
|
||||||
if ((await (await page.request.get("/api/v1/alphas")).json()).total !== 620) {
|
if ((await (await page.request.get("/api/v1/alphas?q=TEST")).json()).total !== 620) {
|
||||||
const headers = { "X-WQ-Request": "1" };
|
const headers = { "X-WQ-Request": "1" };
|
||||||
await page.request.put("/api/v1/account/credentials", {
|
await page.request.put("/api/v1/account/credentials", {
|
||||||
headers,
|
headers,
|
||||||
@@ -215,7 +214,7 @@ test("batch tags, column visibility, server pagination and saved preferences", a
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () =>
|
async () =>
|
||||||
(await (await page.request.get("/api/v1/alphas")).json()).total,
|
(await (await page.request.get("/api/v1/alphas?q=TEST")).json()).total,
|
||||||
{ timeout: 20000 },
|
{ timeout: 20000 },
|
||||||
)
|
)
|
||||||
.toBe(620);
|
.toBe(620);
|
||||||
|
|||||||
Reference in New Issue
Block a user