Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3eb239e1a | |||
| c18960946b | |||
| 45eb4c3a17 | |||
| 5eb6008ef7 | |||
| ef24ace748 |
@@ -0,0 +1,17 @@
|
||||
# 分离 Alpha 检查和提交限制
|
||||
Type: task
|
||||
Status: ready-for-agent
|
||||
|
||||
用户已批准实现。保留 REGULAR_SUBMISSION 原始证据,将其从 Alpha 失败统计及待定判定中分离;HTTP/MCP 提供分类摘要,页面单独展示缓存限制;迁移仅重算历史 check_type。未知检查保持保守,不推断实时额度、恢复时间或正式提交资格。
|
||||
|
||||
验证:分类边界、模拟平台检查到持久化和 MCP/HTTP 返回、研究筛选、历史迁移、后端检查及前端构建。
|
||||
|
||||
## 完成记录
|
||||
|
||||
已实现并完成本地验证:后端 471 项全量测试通过;最终调整后 23 项专项测试通过;Ruff 与 git diff --check 通过;前端构建通过;Alpha 管理和工作空间 3 项浏览器回归通过。迁移 0018 在隔离 SQLite 中验证 503 条记录的升级、降级、再升级和原始证据保留。尚未部署或对业务数据库执行迁移;Docker 后端部署启动时按既有流程执行 alembic upgrade head。
|
||||
|
||||
## Comments
|
||||
|
||||
用户后续确认恢复旧项目阶段语义:同步非空有效检查无 FAIL 为 PRE_CHECK,主动 /check 完成无 FAIL 为 PASS,PENDING/WARNING 不算失败;异常、空结果保留待定。同步刷新采用新的同步快照重新分类,不沿用旧检查阶段。MCP/HTTP 使用数据库派生状态;迁移 0019 使用晚于 synced_at 的已保存 checked 检查点识别历史主动检查,其余归同步阶段。保留 0018 历史迁移和所有原始 checks。
|
||||
|
||||
阶段逻辑调整已完成:后端 499 项测试通过,含同步→主动检查→再同步、MCP PENDING/WARNING 返回和 503 条历史记录迁移;Ruff、git diff --check、前端构建通过。预检通过使用蓝色 Tag。未部署,未迁移实际业务数据库。
|
||||
+21
-8
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from sqlalchemy import or_, select, update
|
||||
|
||||
from .models import Alpha, Research, ResearchTag, SelfCorrelation, now
|
||||
from .platform_checks import check_result, split_checks, submission_limits
|
||||
from .research.provenance import source_alpha_ids
|
||||
|
||||
METRIC_FIELDS = (
|
||||
@@ -16,32 +17,34 @@ METRIC_FIELDS = (
|
||||
|
||||
|
||||
def failed_checks(checks):
|
||||
"""Return failed platform check names; local correlation never changes this list."""
|
||||
"""Return failed Alpha check names, excluding submission limits and local correlation."""
|
||||
return [
|
||||
check.get("name") if isinstance(check.get("name"), str) else "未命名检查"
|
||||
for check in checks if isinstance(check, dict) and check.get("result") == "FAIL"
|
||||
for check in split_checks(checks)[0] if isinstance(check, dict) and check_result(check) == "FAIL"
|
||||
] if isinstance(checks, list) else []
|
||||
|
||||
|
||||
def snapshot_columns(settings, metrics, checks):
|
||||
def snapshot_columns(settings, metrics, checks, *, checked=False):
|
||||
"""Derive list fields from a platform snapshot, preserving missing metrics as null.
|
||||
|
||||
Only explicit FAIL results count. Empty, malformed and unfinished checks are
|
||||
pending; all known checks passing without PROD_CORRELATION is only a pre-check.
|
||||
Submission limits are excluded. Only explicit Alpha FAIL results count.
|
||||
Sync snapshots with no failures are PRE_CHECK; a completed explicit /check
|
||||
with no failures is PASS. WARNING/PENDING do not count as failures, matching
|
||||
the legacy workflow. Empty, malformed or unknown results remain PENDING.
|
||||
No submission eligibility or activity eligibility is inferred here.
|
||||
"""
|
||||
settings = settings if isinstance(settings, dict) else {}
|
||||
metrics = metrics if isinstance(metrics, dict) else {}
|
||||
checks = checks if isinstance(checks, list) else []
|
||||
checks, _ = split_checks(checks)
|
||||
valid = [check for check in checks if isinstance(check, dict)]
|
||||
failures = len(failed_checks(checks))
|
||||
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
|
||||
if failures:
|
||||
check_type = "FAIL_1" if failures == 1 else "FAIL_2"
|
||||
elif not checks or len(valid) != len(checks) or any(check.get("result") != "PASS" for check in valid):
|
||||
elif not checks or len(valid) != len(checks) or any(check_result(check) not in ("PASS", "WARNING", "PENDING") for check in valid):
|
||||
check_type = "PENDING"
|
||||
else:
|
||||
check_type = "PASS" if "PROD_CORRELATION" in by_name else "PRE_CHECK"
|
||||
check_type = "PASS" if checked else "PRE_CHECK"
|
||||
# /check values are freshest; submitted snapshots also expose a scalar in IS.
|
||||
prod_correlation = number(by_name.get("PROD_CORRELATION", {}).get("value"))
|
||||
if prod_correlation is None:
|
||||
@@ -64,6 +67,16 @@ def snapshot_columns(settings, metrics, checks):
|
||||
}
|
||||
|
||||
|
||||
def check_summary(checks, *, check_type):
|
||||
"""Separate cached Alpha findings from submission limits; infer no live eligibility."""
|
||||
return {
|
||||
"check_type": check_type,
|
||||
"failed_checks": failed_checks(checks),
|
||||
"submission_limits": submission_limits(checks),
|
||||
"meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交",
|
||||
}
|
||||
|
||||
|
||||
def submission_condition(submission):
|
||||
"""Match the platform list contract; a missing status is never assumed submitted."""
|
||||
return Alpha.status == "UNSUBMITTED" if submission == "UNSUBMITTED" else Alpha.status != "UNSUBMITTED"
|
||||
|
||||
@@ -24,7 +24,7 @@ TOOLS = {
|
||||
"search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"),
|
||||
"get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"),
|
||||
"create_research_template": (c.CreateTemplate, "create_template", "research:write", "将调用方大模型研究后自行总结的参数化模板保存到模板工坊,供用户后续批量回测。先用 get_backtest_results 阅读实际指标和检查,选择 1–20 个已完成采集的 source_item_ids,并说明 hypothesis;不要把 completed 当作检查通过。template 使用 {name} 占位符及逐一对应的 variables,字段变量须声明 MATRIX/VECTOR/GROUP,VECTOR 聚合须明确写入表达式。提供唯一名称和 idempotency_key,可附 reference。返回模板 ID、版本和理论组合数;仅核验结构及来源,不验证所有参数组合,不再次调用模型、不执行回测、不覆盖已有模板。"),
|
||||
"get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;不发起检查。先核对或生成三段 Description,再调用 check_submission。"),
|
||||
"get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;check_summary 分离 Alpha 检查和 REGULAR_SUBMISSION 提交限制,原始 checks 保留;限制不代表当前额度。不发起检查。先核对或生成三段 Description,再调用 check_submission。"),
|
||||
"check_submission": (c.SubmissionCheck, "check_submission", "research:refresh", "对单个待提交 Alpha 写回已获用户授权的 Description 并调用平台 GET /check,返回 job_id。须先用 get_submission_check 获取 snapshot;保留本地自相关门槛和冲突保护。通过 get_refresh_job 查进度、get_submission_check 读结果。无论检查结果如何,都不会调用 /submit 或正式提交 Alpha。"),
|
||||
"get_worldquant_connection": (c.ConnectionReference, "connection", "research:read", "读取 WorldQuant 连接状态及可选认证 job_id 的进度,不发起认证;人工验证在网页完成。"),
|
||||
"authenticate_worldquant": (c.Authentication, "authenticate", "research:refresh", "使用服务端已保存凭据连接或重新认证 WorldQuant,返回 job_id;action=connect(默认)或人工验证后 verify。用 get_worldquant_connection 查询,不接收密码,不修改账户配置。"),
|
||||
@@ -38,7 +38,7 @@ TOOLS = {
|
||||
"search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"),
|
||||
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的固定批次,自动留痕并立即返回运行 ID。可携带 preparation_refs 选择集合,版本变化须重新读取;每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"),
|
||||
"get_backtest": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"),
|
||||
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、全部非通过检查及三层状态;缺失指标不补零。"),
|
||||
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、Alpha 非通过检查及三层状态;REGULAR_SUBMISSION 单列 submission_limits,不计入 Alpha 失败统计。缺失指标不补零。"),
|
||||
"get_backtest_artifact": (c.Artifact, "artifact", "research:read", "分页读取候选脱敏快照的顶层键值或独立采集的 PnL;缺缓存不自动刷新。"),
|
||||
"control_backtest": (c.Control, "control", "backtests:control", "对已授权运行暂停、继续、停止或恢复采集;不远程取消、不重提未知模拟。需要版本和幂等键。"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Classify platform evidence without discarding unknown checks or inferring eligibility."""
|
||||
|
||||
|
||||
def check_result(check):
|
||||
"""Normalize known upstream result casing without rewriting the raw evidence."""
|
||||
value = check.get("result") if isinstance(check, dict) else None
|
||||
return value.upper() if isinstance(value, str) else None
|
||||
|
||||
|
||||
def is_submission_limit(check):
|
||||
"""Recognize only the confirmed account-limit check; unknown names remain Alpha checks."""
|
||||
return isinstance(check, dict) and check.get("name") == "REGULAR_SUBMISSION"
|
||||
|
||||
|
||||
def split_checks(checks):
|
||||
"""Return Alpha checks and submission limits, retaining malformed Alpha evidence."""
|
||||
items = checks if isinstance(checks, list) else []
|
||||
return ([c for c in items if not is_submission_limit(c)],
|
||||
[c for c in items if is_submission_limit(c)])
|
||||
|
||||
|
||||
def submission_limits(checks):
|
||||
"""Summarize the observed limit, never the account's current allowance or reset time."""
|
||||
_, limits = split_checks(checks)
|
||||
status = "blocked" if any(check_result(c) == "FAIL" for c in limits) else (
|
||||
"not_blocked" if limits and all(check_result(c) == "PASS" for c in limits) else "unknown"
|
||||
)
|
||||
return {"status": status, "checks": limits,
|
||||
"meaning": "仅反映缓存观测时的提交限制,不代表当前额度或正式提交资格"}
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import select
|
||||
|
||||
from ..backtests.service import Backtests, uid
|
||||
from ..models import Alpha, ResearchEvaluation, ResearchExperiment, SelfCorrelation
|
||||
from ..platform_checks import split_checks, submission_limits
|
||||
from .experiments import Experiments
|
||||
from .serialization import encode_snapshot as jsonable_encoder
|
||||
|
||||
@@ -30,13 +31,14 @@ def assess(snapshot, rules):
|
||||
evidence.append(
|
||||
{"metric": key, "value": value, "bound": bound, "direction": direction, "status": status}
|
||||
)
|
||||
checks = metrics.get("checks") or []
|
||||
raw_checks = metrics.get("checks") or []
|
||||
checks, _ = split_checks(raw_checks)
|
||||
if not checks:
|
||||
missing.append("platform_checks")
|
||||
for check in checks:
|
||||
if check.get("result") == "FAIL":
|
||||
if isinstance(check, dict) and check.get("result") == "FAIL":
|
||||
failed.append(f"platform:{check.get('name', 'unknown')}")
|
||||
unknown_checks = [check for check in checks if check.get("result") not in ("PASS", "FAIL")]
|
||||
unknown_checks = [check for check in checks if not isinstance(check, dict) or check.get("result") not in ("PASS", "FAIL")]
|
||||
if unknown_checks:
|
||||
missing.append("unresolved_platform_checks")
|
||||
return {
|
||||
@@ -44,7 +46,8 @@ def assess(snapshot, rules):
|
||||
"evidence": evidence,
|
||||
"failed": failed,
|
||||
"missing": missing,
|
||||
"existing_platform_checks": checks,
|
||||
"existing_platform_checks": raw_checks,
|
||||
"submission_limits": submission_limits(raw_checks),
|
||||
"meaning": "本地研究筛选结果,不是官方提交资格",
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import func, select
|
||||
from ..alphas import number, sanitize
|
||||
from ..backtests.contracts import fingerprint
|
||||
from ..models import BacktestItem, BacktestResult, BacktestRun, Pnl
|
||||
from ..platform_checks import is_submission_limit
|
||||
from ..research.serialization import encode_snapshot
|
||||
|
||||
|
||||
@@ -26,6 +27,8 @@ def checks_summary(snapshot):
|
||||
if "checks" in snapshot:
|
||||
raw = snapshot["checks"]
|
||||
checks.extend({"section": "root", "raw": c} for c in (raw if isinstance(raw, list) else [raw]))
|
||||
submission_checks = [c for c in checks if is_submission_limit(c["raw"])]
|
||||
checks = [c for c in checks if not is_submission_limit(c["raw"])]
|
||||
counts = Counter({key: 0 for key in ("PASS", "FAIL", "PENDING", "WARNING", "UNKNOWN")})
|
||||
non_pass = []
|
||||
for check in checks:
|
||||
@@ -36,7 +39,9 @@ def checks_summary(snapshot):
|
||||
if state != "PASS":
|
||||
non_pass.append({**check, "status": state})
|
||||
return {"status": "unknown" if not checks else "reported", "counts": dict(counts),
|
||||
"total": len(checks), "non_pass": non_pass}
|
||||
"total": len(checks), "non_pass": non_pass,
|
||||
"submission_limits": submission_checks,
|
||||
"meaning": "Alpha 检查统计不含提交限制;限制为快照观测,不代表实时提交资格"}
|
||||
|
||||
|
||||
def item_summary(item, result):
|
||||
|
||||
@@ -9,6 +9,7 @@ from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..alphas import check_summary
|
||||
from ..backtests.contracts import ControlInput, DraftInput, PreviewInput, Source, StartInput, fingerprint
|
||||
from ..backtests.service import Backtests
|
||||
from ..business import Business
|
||||
@@ -221,7 +222,7 @@ class ResearchAccess:
|
||||
return {"alpha_id": args.alpha_id, "snapshot": submission_fingerprint(context),
|
||||
**context, "descriptions": {key: item["description"] for key, item in context["sections"].items()},
|
||||
"can_check": alpha.status == "UNSUBMITTED" and await correlation_allows_check(self.db, args.alpha_id),
|
||||
"checks": alpha.checks, "source": "local_cache", "production_submission": False,
|
||||
"checks": alpha.checks, "check_summary": check_summary(alpha.checks, check_type=alpha.check_type), "source": "local_cache", "production_submission": False,
|
||||
"job_id": job.id if job else None, "job_status": job.status if job else None,
|
||||
"checked_at": job.checkpoint.get("checked_at") if job else None}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from pydantic_ai.usage import UsageLimits
|
||||
from sqlalchemy import select
|
||||
|
||||
from .ai.provider import public_error
|
||||
from .alphas import code, sanitize, snapshot_columns, submission_condition
|
||||
from .alphas import check_summary, code, sanitize, snapshot_columns, submission_condition
|
||||
from .jobs import ACTIVE
|
||||
from .models import Account, AISettings, Alpha, Job, JobItem, SelfCorrelation, now
|
||||
from .schemas import Contract, JobOutput, valid_ids
|
||||
@@ -206,6 +206,8 @@ def router(runner, ai):
|
||||
)
|
||||
return {
|
||||
"snapshot": fingerprint(context),
|
||||
"checks": alpha.checks,
|
||||
"check_summary": check_summary(alpha.checks, check_type=alpha.check_type),
|
||||
"sections": context["sections"],
|
||||
"descriptions": {key: item["description"] for key, item in context["sections"].items()},
|
||||
"model": config.description_model,
|
||||
@@ -353,8 +355,11 @@ async def run_check(runner, job_id, payload):
|
||||
alpha.checks = sanitize(checks)
|
||||
alpha.is_metrics = {**alpha.is_metrics, "checks": alpha.checks}
|
||||
alpha.raw = {**alpha.raw, "is": {**(alpha.raw.get("is") or {}), "checks": alpha.checks}}
|
||||
for key, value in snapshot_columns(alpha.settings, alpha.is_metrics, alpha.checks).items():
|
||||
for key, value in snapshot_columns(alpha.settings, alpha.is_metrics, alpha.checks, checked=True).items():
|
||||
setattr(alpha, key, value)
|
||||
db.add(JobItem(job_id=job_id, alpha_id=alpha_id))
|
||||
job.processed = 1
|
||||
job.checkpoint = {"alpha_id": alpha_id, "phase": "checked", "checked_at": now().isoformat()}
|
||||
job.checkpoint = {
|
||||
"alpha_id": alpha_id, "phase": "checked", "checked_at": now().isoformat(),
|
||||
"review_snapshot": fingerprint(source(alpha.raw)),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Reclassify cached Alpha checks without changing upstream evidence."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0018"
|
||||
down_revision = "0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def check_type(checks, separate_limits):
|
||||
"""Frozen classification for reversible data migration; never import mutable app code."""
|
||||
checks = checks if isinstance(checks, list) else []
|
||||
if separate_limits:
|
||||
checks = [c for c in checks if not (isinstance(c, dict) and c.get("name") == "REGULAR_SUBMISSION")]
|
||||
valid = [c for c in checks if isinstance(c, dict)]
|
||||
failures = sum(c.get("result") == "FAIL" for c in valid)
|
||||
if failures:
|
||||
return "FAIL_1" if failures == 1 else "FAIL_2"
|
||||
if not checks or len(valid) != len(checks) or any(c.get("result") != "PASS" for c in valid):
|
||||
return "PENDING"
|
||||
return "PASS" if any(c.get("name") == "PROD_CORRELATION" for c in valid) else "PRE_CHECK"
|
||||
|
||||
|
||||
def reclassify(separate_limits):
|
||||
"""Update only affected derived columns, in bounded batches; raw snapshots stay intact."""
|
||||
table = sa.table("alphas", sa.column("id", sa.String()), sa.column("checks", sa.JSON()),
|
||||
sa.column("check_type", sa.String()))
|
||||
connection = op.get_bind()
|
||||
last_id = None
|
||||
while True:
|
||||
query = sa.select(table.c.id, table.c.checks).order_by(table.c.id).limit(500)
|
||||
if last_id is not None:
|
||||
query = query.where(table.c.id > last_id)
|
||||
rows = connection.execute(query).mappings().all()
|
||||
if not rows:
|
||||
break
|
||||
updates = [
|
||||
{"snapshot_id": row["id"], "classification": check_type(row["checks"], separate_limits)}
|
||||
for row in rows if isinstance(row["checks"], list) and any(
|
||||
isinstance(c, dict) and c.get("name") == "REGULAR_SUBMISSION" for c in row["checks"])
|
||||
]
|
||||
if updates:
|
||||
connection.execute(table.update().where(table.c.id == sa.bindparam("snapshot_id"))
|
||||
.values(check_type=sa.bindparam("classification")), updates)
|
||||
last_id = rows[-1]["id"]
|
||||
|
||||
|
||||
def upgrade():
|
||||
reclassify(True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
reclassify(False)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Restore stage-based check classification, preserving raw platform snapshots."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0019"
|
||||
down_revision = "0018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def timestamp(value):
|
||||
"""Read historical timestamps; missing or invalid evidence cannot prove a /check."""
|
||||
try:
|
||||
value = datetime.fromisoformat(value) if isinstance(value, str) else value
|
||||
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def classify(checks, checked, legacy):
|
||||
"""Frozen migration rules; legacy means the pre-0019 correlation-presence rule."""
|
||||
checks = checks if isinstance(checks, list) else []
|
||||
checks = [c for c in checks if not (isinstance(c, dict) and c.get("name") == "REGULAR_SUBMISSION")]
|
||||
valid = [c for c in checks if isinstance(c, dict)]
|
||||
results = [c.get("result") for c in valid]
|
||||
if not legacy:
|
||||
results = [v.upper() if isinstance(v, str) else None for v in results]
|
||||
failures = sum(v == "FAIL" for v in results)
|
||||
if failures:
|
||||
return "FAIL_1" if failures == 1 else "FAIL_2"
|
||||
allowed = ("PASS",) if legacy else ("PASS", "PENDING", "WARNING")
|
||||
if not checks or len(valid) != len(checks) or any(v not in allowed for v in results):
|
||||
return "PENDING"
|
||||
passed = any(c.get("name") == "PROD_CORRELATION" for c in valid) if legacy else checked
|
||||
return "PASS" if passed else "PRE_CHECK"
|
||||
|
||||
|
||||
def reclassify(legacy=False):
|
||||
"""Recompute in batches; only a check checkpoint newer than the sync proves its stage.
|
||||
|
||||
A prior PASS is not evidence because the old rule inferred it from a check name.
|
||||
A subsequent sync replaces the snapshot and is classified as pre-check again.
|
||||
"""
|
||||
alphas = sa.table("alphas", sa.column("id", sa.String()), sa.column("checks", sa.JSON()),
|
||||
sa.column("synced_at", sa.DateTime(timezone=True)), sa.column("check_type", sa.String()))
|
||||
jobs = sa.table("sync_jobs", sa.column("kind", sa.String()), sa.column("payload", sa.JSON()),
|
||||
sa.column("checkpoint", sa.JSON()))
|
||||
connection = op.get_bind()
|
||||
last_id = None
|
||||
while True:
|
||||
query = sa.select(alphas.c.id, alphas.c.checks, alphas.c.synced_at).order_by(alphas.c.id).limit(500)
|
||||
if last_id is not None:
|
||||
query = query.where(alphas.c.id > last_id)
|
||||
rows = connection.execute(query).mappings().all()
|
||||
if not rows:
|
||||
break
|
||||
checked_at = {}
|
||||
if not legacy:
|
||||
observations = connection.execute(sa.select(jobs.c.checkpoint).where(
|
||||
jobs.c.kind == "submission_check",
|
||||
jobs.c.payload["alpha_ids"][0].as_string().in_([r["id"] for r in rows]),
|
||||
)).scalars()
|
||||
for checkpoint in observations:
|
||||
if not isinstance(checkpoint, dict) or checkpoint.get("phase") != "checked":
|
||||
continue
|
||||
alpha_id = checkpoint.get("alpha_id")
|
||||
observed = timestamp(checkpoint.get("checked_at"))
|
||||
if isinstance(alpha_id, str) and observed and (
|
||||
alpha_id not in checked_at or observed > checked_at[alpha_id]
|
||||
):
|
||||
checked_at[alpha_id] = observed
|
||||
updates = []
|
||||
for row in rows:
|
||||
synced = timestamp(row["synced_at"])
|
||||
observed = checked_at.get(row["id"])
|
||||
updates.append({"snapshot_id": row["id"], "classification": classify(
|
||||
row["checks"], bool(synced and observed and observed > synced), legacy)})
|
||||
connection.execute(alphas.update().where(alphas.c.id == sa.bindparam("snapshot_id"))
|
||||
.values(check_type=sa.bindparam("classification")), updates)
|
||||
last_id = rows[-1]["id"]
|
||||
|
||||
|
||||
def upgrade():
|
||||
reclassify()
|
||||
|
||||
|
||||
def downgrade():
|
||||
reclassify(legacy=True)
|
||||
@@ -37,10 +37,10 @@ def checks(failures):
|
||||
(None, "PENDING"),
|
||||
([None], "PENDING"),
|
||||
([{}], "PENDING"),
|
||||
([{"name": "LOW_SHARPE", "result": "WARNING"}], "PENDING"),
|
||||
([{"name": "LOW_SHARPE", "result": "WARNING"}], "PRE_CHECK"),
|
||||
([{"name": "LOW_SHARPE", "result": "PASS"}], "PRE_CHECK"),
|
||||
([{"name": "PROD_CORRELATION", "result": "PENDING"}], "PENDING"),
|
||||
(checks(0), "PASS"),
|
||||
([{"name": "PROD_CORRELATION", "result": "PENDING"}], "PRE_CHECK"),
|
||||
(checks(0), "PRE_CHECK"),
|
||||
(checks(1), "FAIL_1"),
|
||||
(checks(2), "FAIL_2"),
|
||||
(checks(3), "FAIL_2"),
|
||||
@@ -58,7 +58,7 @@ async def test_checks_filter_before_pagination_and_share_export_scope(app, logge
|
||||
for check_type, expected in [
|
||||
("FAIL_1", ["failed1"]),
|
||||
("FAIL_2", ["failed2", "failed3"]),
|
||||
("PASS", ["failed0"]),
|
||||
("PRE_CHECK", ["failed0"]),
|
||||
("PENDING", ["unknown"]),
|
||||
]:
|
||||
response = await logged_in.get(
|
||||
@@ -218,7 +218,7 @@ def test_migration_backfills_multiple_batches_and_preserves_research(tmp_path, m
|
||||
rows = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
|
||||
assert len(rows) == 503
|
||||
for i, row in enumerate(rows):
|
||||
expected = snapshot_columns(row["settings"], row["is_metrics"], checks(i % 4))
|
||||
expected = snapshot_columns(row["settings"], row["is_metrics"], checks(i % 4), checked=True)
|
||||
assert {key: row[key] for key in expected} == expected
|
||||
record = db.execute(sa.select(research)).mappings().one()
|
||||
assert record["tags"] == ["PPAC"] and record["note"] == "keep" and record["version"] == 7
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Historical stage recovery requires a persisted /check newer than the latest sync."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
def test_stage_backfill_preserves_evidence_and_uses_checkpoints(tmp_path, monkeypatch):
|
||||
path = tmp_path / "stages.db"
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{path}")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
monkeypatch.setenv("WQ_EMAIL", "")
|
||||
monkeypatch.setenv("WQ_PASSWORD", "")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
config = Config(str(root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(root / "migrations"))
|
||||
command.upgrade(config, "0018")
|
||||
engine = sa.create_engine(f"sqlite:///{path}")
|
||||
alphas = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
|
||||
jobs = sa.Table("sync_jobs", sa.MetaData(), autoload_with=engine)
|
||||
synced = datetime(2026, 9, 12, 0, tzinfo=timezone.utc)
|
||||
pending = [{"name": "PROD_CORRELATION", "result": "PENDING"},
|
||||
{"name": "REGULAR_SUBMISSION", "result": "FAIL"}]
|
||||
passed = [{"name": "PROD_CORRELATION", "result": "PASS"}]
|
||||
patterns = [
|
||||
(pending, "PENDING", "PRE_CHECK", None),
|
||||
(pending, "PENDING", "PASS", {"phase": "checked", "checked_at": "2026-09-12T01:00:00+00:00"}),
|
||||
(pending, "PENDING", "PRE_CHECK", {"phase": "checked", "checked_at": "2026-09-11T23:00:00+00:00"}),
|
||||
(pending, "PENDING", "PRE_CHECK", {"phase": "check"}),
|
||||
(passed, "PASS", "PRE_CHECK", None),
|
||||
([{"name": "LOW_SHARPE", "result": "FAIL"}], "FAIL_1", "FAIL_1", None),
|
||||
([{}], "PENDING", "PENDING", {"phase": "checked", "checked_at": "2026-09-12T01:00:00Z"}),
|
||||
]
|
||||
with engine.begin() as db:
|
||||
db.execute(alphas.insert(), [
|
||||
{"id": f"stage{i:04}", "hidden": False, "settings": {}, "os_metrics": {},
|
||||
"is_metrics": {"checks": patterns[i % 7][0]}, "checks": patterns[i % 7][0],
|
||||
"check_type": patterns[i % 7][1], "synced_at": synced,
|
||||
"raw": {"is": {"checks": patterns[i % 7][0]}}}
|
||||
for i in range(503)
|
||||
])
|
||||
db.execute(jobs.insert(), [
|
||||
{"id": f"job{i}", "kind": "submission_check", "status": "completed",
|
||||
"payload": {"alpha_ids": [f"stage{i:04}"]},
|
||||
"checkpoint": {**patterns[i % 7][3], "alpha_id": f"stage{i:04}"},
|
||||
"processed": 1, "failed": 0, "total": 1, "cancel_requested": False,
|
||||
"created_at": synced, "updated_at": synced}
|
||||
for i in range(503) if patterns[i % 7][3]
|
||||
])
|
||||
for target, expected_index in [("0019", 2), ("0018", 1), ("0019", 2)]:
|
||||
(command.upgrade if target == "0019" else command.downgrade)(config, target)
|
||||
with engine.connect() as db:
|
||||
rows = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
|
||||
assert len(rows) == 503
|
||||
for i, row in enumerate(rows):
|
||||
assert row["check_type"] == patterns[i % 7][expected_index]
|
||||
assert row["checks"] == row["raw"]["is"]["checks"] == row["is_metrics"]["checks"] == patterns[i % 7][0]
|
||||
command.check(config)
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""The same checks have different meanings at sync and explicit /check stages."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.alphas import snapshot_columns
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checks", [
|
||||
[{"name": "LOW_SHARPE", "result": "PASS"}],
|
||||
[{"name": "PROD_CORRELATION", "result": "PASS"}],
|
||||
[{"name": "PROD_CORRELATION", "result": "PENDING"}],
|
||||
[{"name": "MATCHES_THEMES", "result": "WARNING"}],
|
||||
])
|
||||
def test_stage_not_correlation_presence_decides_pass(checks):
|
||||
assert snapshot_columns({}, {}, checks)["check_type"] == "PRE_CHECK"
|
||||
assert snapshot_columns({}, {}, checks, checked=True)["check_type"] == "PASS"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checked", [False, True])
|
||||
@pytest.mark.parametrize("checks,expected", [
|
||||
([], "PENDING"),
|
||||
([None], "PENDING"),
|
||||
([{}], "PENDING"),
|
||||
([{"name": "UNKNOWN", "result": "OTHER"}], "PENDING"),
|
||||
([{"name": "REGULAR_SUBMISSION", "result": "FAIL"}], "PENDING"),
|
||||
([{"name": "LOW_SHARPE", "result": "fail"}, {"name": "REGULAR_SUBMISSION", "result": "FAIL"}], "FAIL_1"),
|
||||
([{"name": "LOW_SHARPE", "result": "FAIL"}, {"name": "LOW_FITNESS", "result": "Fail"}], "FAIL_2"),
|
||||
])
|
||||
def test_stage_preserves_failures_and_missing_evidence(checked, checks, expected):
|
||||
assert snapshot_columns({}, {}, checks, checked=checked)["check_type"] == expected
|
||||
|
||||
|
||||
async def test_sync_check_and_resync_use_distinct_stages(app, logged_in, monkeypatch):
|
||||
from app.alphas import upsert_alpha
|
||||
from tests import test_submission
|
||||
from tests.conftest import alpha
|
||||
|
||||
checks = [{"name": "LOW_SHARPE", "result": "PASS"},
|
||||
{"name": "PROD_CORRELATION", "result": "PENDING"},
|
||||
{"name": "MATCHES_THEMES", "result": "WARNING"},
|
||||
{"name": "REGULAR_SUBMISSION", "result": "FAIL"}]
|
||||
monkeypatch.setattr(test_submission, "CHECKS", checks)
|
||||
await test_submission.setup(app, alpha(**{"is": {"checks": checks}}))
|
||||
endpoint = "/api/v1/alphas/alpha1/submission"
|
||||
assert (await logged_in.get(endpoint)).json()["check_summary"]["check_type"] == "PRE_CHECK"
|
||||
response = await test_submission.enqueue(logged_in)
|
||||
assert response.status_code == 202
|
||||
await app.state.runner.execute(response.json()["id"])
|
||||
state = (await logged_in.get(endpoint)).json()
|
||||
assert state["job"]["status"] == "completed"
|
||||
assert state["check_summary"]["check_type"] == "PASS"
|
||||
assert state["check_summary"]["submission_limits"]["status"] == "blocked"
|
||||
assert (await logged_in.get("/api/v1/alphas/alpha1")).json()["check_type"] == "PASS"
|
||||
async with app.state.sessions.begin() as db:
|
||||
await upsert_alpha(db, alpha(**{"is": {"checks": checks}}))
|
||||
assert (await logged_in.get(endpoint)).json()["check_summary"]["check_type"] == "PRE_CHECK"
|
||||
@@ -11,11 +11,14 @@ from tests.test_submission import FIELDS, Description, setup
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["REGULAR", "SUPER"])
|
||||
@pytest.mark.parametrize("result", ["PASS", "FAIL"])
|
||||
async def test_mcp_check_never_submits(mcp_app, kind, result, monkeypatch):
|
||||
@pytest.mark.parametrize("result", ["PASS", "FAIL", "PENDING", "WARNING"])
|
||||
@pytest.mark.parametrize("limited", [False, True])
|
||||
async def test_mcp_check_never_submits(mcp_app, kind, result, limited, monkeypatch):
|
||||
from tests import test_submission
|
||||
|
||||
checks = [{"name": "PROD_CORRELATION", "result": result}]
|
||||
if limited:
|
||||
checks.append({"name": "REGULAR_SUBMISSION", "result": "FAIL"})
|
||||
monkeypatch.setattr(test_submission, "CHECKS", checks)
|
||||
sections = ["regular"] if kind == "REGULAR" else ["selection", "combo"]
|
||||
platform = await setup(mcp_app, alpha(type=kind, **{s: {"code": "rank(close)"} for s in sections}))
|
||||
@@ -35,6 +38,8 @@ async def test_mcp_check_never_submits(mcp_app, kind, result, monkeypatch):
|
||||
assert job["status"] == "completed", job
|
||||
data = await invoke(mcp_app, principal, "get_submission_check", {"alpha_id": "alpha1"})
|
||||
assert data["checks"] == checks and data["checked_at"]
|
||||
assert data["check_summary"]["check_type"] == ("FAIL_1" if result == "FAIL" else "PASS")
|
||||
assert data["check_summary"]["submission_limits"]["status"] == ("blocked" if limited else "unknown")
|
||||
assert data["production_submission"] is False
|
||||
assert platform.patches == [{s: {"description": args["descriptions"][s]} for s in sections}]
|
||||
assert platform.calls.count(("GET", "/alphas/alpha1/check")) == 2
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Upgrade cached classifications across batches while preserving platform evidence."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.models import now
|
||||
|
||||
|
||||
def test_limit_migration_is_reversible_and_preserves_snapshots(tmp_path, monkeypatch):
|
||||
path = tmp_path / "limits.db"
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{path}")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
monkeypatch.setenv("WQ_EMAIL", "")
|
||||
monkeypatch.setenv("WQ_PASSWORD", "")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
config = Config(str(root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(root / "migrations"))
|
||||
command.upgrade(config, "0017")
|
||||
engine = sa.create_engine(f"sqlite:///{path}")
|
||||
table = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
|
||||
limit = {"name": "REGULAR_SUBMISSION", "result": "FAIL"}
|
||||
patterns = [
|
||||
([{"name": "PROD_CORRELATION", "result": "PASS"}, limit], "FAIL_1", "PASS"),
|
||||
([{"name": "LOW_SHARPE", "result": "FAIL"}, limit], "FAIL_2", "FAIL_1"),
|
||||
([limit], "FAIL_1", "PENDING"),
|
||||
([{"name": "LOW_SHARPE", "result": "PASS"}, limit], "FAIL_1", "PRE_CHECK"),
|
||||
([{"name": "UNKNOWN", "result": "FAIL"}], "FAIL_1", "FAIL_1"),
|
||||
]
|
||||
with engine.begin() as db:
|
||||
db.execute(table.insert(), [
|
||||
{"id": f"old{i:04}", "hidden": False, "settings": {}, "os_metrics": {},
|
||||
"is_metrics": {"checks": patterns[i % 5][0]}, "checks": patterns[i % 5][0],
|
||||
"check_type": patterns[i % 5][1], "synced_at": now(),
|
||||
"raw": {"is": {"checks": patterns[i % 5][0]}}}
|
||||
for i in range(503)
|
||||
])
|
||||
for version, position in [("0018", 2), ("0017", 1), ("0018", 2)]:
|
||||
(command.upgrade if version == "0018" else command.downgrade)(config, version)
|
||||
with engine.connect() as db:
|
||||
rows = db.execute(sa.select(table).order_by(table.c.id)).mappings().all()
|
||||
assert len(rows) == 503
|
||||
for i, row in enumerate(rows):
|
||||
assert row["check_type"] == patterns[i % 5][position]
|
||||
assert row["checks"] == row["is_metrics"]["checks"] == row["raw"]["is"]["checks"] == patterns[i % 5][0]
|
||||
command.upgrade(config, "head")
|
||||
command.check(config)
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Submission limits must not disqualify an otherwise passing Alpha."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.alphas import failed_checks, snapshot_columns
|
||||
|
||||
LIMIT = {"name": "REGULAR_SUBMISSION", "result": "FAIL", "value": 4, "limit": 4}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checks,expected", [
|
||||
([{"name": "PROD_CORRELATION", "result": "PASS"}], "PRE_CHECK"),
|
||||
([{"name": "LOW_SHARPE", "result": "PASS"}], "PRE_CHECK"),
|
||||
([{"name": "LOW_SHARPE", "result": "FAIL"}], "FAIL_1"),
|
||||
([], "PENDING"),
|
||||
([None], "PENDING"),
|
||||
([{"name": "UNKNOWN_CHECK", "result": "FAIL"}], "FAIL_1"),
|
||||
([{"name": "PROD_CORRELATION", "result": "PENDING"}], "PRE_CHECK"),
|
||||
])
|
||||
def test_limit_does_not_change_alpha_verdict(checks, expected):
|
||||
original = [*checks, LIMIT]
|
||||
assert snapshot_columns({}, {}, original)["check_type"] == expected
|
||||
assert "REGULAR_SUBMISSION" not in failed_checks(original)
|
||||
assert original[-1] == LIMIT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", ["PASS", "PENDING", "WARNING", "UNRECOGNIZED"])
|
||||
def test_all_limit_states_are_separate(result):
|
||||
from app.alphas import check_summary
|
||||
|
||||
checks = [{"name": "PROD_CORRELATION", "result": "PASS"}, {**LIMIT, "result": result}]
|
||||
summary = check_summary(checks, check_type="PASS")
|
||||
assert summary["check_type"] == "PASS"
|
||||
assert summary["submission_limits"]["status"] == ("not_blocked" if result == "PASS" else "unknown")
|
||||
|
||||
|
||||
def test_research_and_mcp_evidence_keep_limits_separate():
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.research.evaluations import assess
|
||||
from app.research_access.queries import checks_summary
|
||||
|
||||
snapshot = {"is": {"sharpe": 2, "fitness": 2, "turnover": 0.1,
|
||||
"checks": [{"name": "PROD_CORRELATION", "result": "PASS"}, LIMIT]}}
|
||||
rules = SimpleNamespace(sharpe_min=1, fitness_min=1, turnover_max=0.5)
|
||||
finding = assess(snapshot, rules)
|
||||
assert finding["verdict"] == "pass" and finding["failed"] == []
|
||||
assert finding["existing_platform_checks"] == snapshot["is"]["checks"]
|
||||
assert finding["submission_limits"]["status"] == "blocked"
|
||||
summary = checks_summary(snapshot)
|
||||
assert summary["counts"]["FAIL"] == 0 and summary["total"] == 1
|
||||
assert summary["non_pass"] == []
|
||||
assert summary["submission_limits"] == [{"section": "is", "raw": LIMIT}]
|
||||
snapshot["is"]["checks"] = [LIMIT]
|
||||
assert assess(snapshot, rules)["verdict"] == "review"
|
||||
assert checks_summary(snapshot)["status"] == "unknown"
|
||||
|
||||
|
||||
async def test_http_check_persists_limit_without_failing_alpha(app, logged_in, monkeypatch):
|
||||
from app.models import Alpha
|
||||
from tests import test_submission
|
||||
|
||||
checks = [{"name": "PROD_CORRELATION", "result": "PASS"}, LIMIT]
|
||||
monkeypatch.setattr(test_submission, "CHECKS", checks)
|
||||
await test_submission.setup(app)
|
||||
response = await test_submission.enqueue(logged_in)
|
||||
assert response.status_code == 202
|
||||
await app.state.runner.execute(response.json()["id"])
|
||||
async with app.state.sessions() as db:
|
||||
item = await db.get(Alpha, "alpha1")
|
||||
assert item.check_type == "PASS"
|
||||
assert item.checks == item.is_metrics["checks"] == item.raw["is"]["checks"] == checks
|
||||
state = (await logged_in.get("/api/v1/alphas/alpha1/submission")).json()
|
||||
assert state["job"]["status"] == "completed"
|
||||
assert state["check_summary"]["submission_limits"]["status"] == "blocked"
|
||||
rows = (await logged_in.get("/api/v1/alphas?check_type=PASS")).json()["items"]
|
||||
assert rows[0]["id"] == "alpha1" and rows[0]["failed_checks"] == []
|
||||
@@ -0,0 +1,23 @@
|
||||
"""A completed check can be repeated using its resulting review snapshot."""
|
||||
|
||||
from tests.test_submission import FIELDS, Description, setup
|
||||
|
||||
|
||||
async def test_repeat_check_uses_resulting_snapshot_without_repatch(app, logged_in):
|
||||
platform = await setup(app)
|
||||
url = "/api/v1/alphas/alpha1/submission"
|
||||
state = (await logged_in.get(url)).json()
|
||||
job_ids = []
|
||||
for _ in range(2):
|
||||
response = await logged_in.post("/api/v1/alphas/alpha1/submission-check", json={
|
||||
"snapshot": state["snapshot"], "descriptions": {"regular": Description(**FIELDS).text()},
|
||||
})
|
||||
assert response.status_code == 202
|
||||
job_ids.append(response.json()["id"])
|
||||
await app.state.runner.execute(job_ids[-1])
|
||||
state = (await logged_in.get(url)).json()
|
||||
assert state["job"]["status"] == "completed"
|
||||
assert state["job"]["checkpoint"]["review_snapshot"] == state["snapshot"]
|
||||
assert job_ids[0] != job_ids[1]
|
||||
assert len(platform.patches) == 1
|
||||
assert platform.calls.count(("GET", "/alphas/alpha1/check")) == 2
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Format the five headline Alpha metrics for display; stored/filter values stay raw.
|
||||
* Unsupported fields return undefined so callers can retain their existing format.
|
||||
*/
|
||||
export function formatAlphaMetric(
|
||||
key: string,
|
||||
value: unknown,
|
||||
): string | undefined {
|
||||
const percent = key === "turnover" || key === "returns" || key === "drawdown";
|
||||
if (!percent && key !== "sharpe" && key !== "fitness") return undefined;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
return percent ? `${(value * 100).toFixed(2)}%` : value.toFixed(2);
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
.backtest-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
gap: 6px;
|
||||
}
|
||||
.backtest-form-grid .semi-input-number {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatAlphaMetric } from "../alphaMetrics";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -464,7 +465,10 @@ function MetricTable({
|
||||
<h3>{title}</h3>
|
||||
{entries.length ? (
|
||||
<DetailFieldGrid
|
||||
data={entries.map(([key, value]) => ({ key, value }))}
|
||||
data={entries.map(([key, value]) => ({
|
||||
key,
|
||||
value: formatAlphaMetric(key, value) ?? value,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<p className="muted">未提供</p>
|
||||
@@ -474,16 +478,46 @@ function MetricTable({
|
||||
}
|
||||
|
||||
/** IS and OS checks are separate observations; missing values never imply a pass. */
|
||||
function PlatformChecks({ title, checks }: { title: string; checks: unknown }) {
|
||||
function PlatformChecks({
|
||||
title,
|
||||
checks,
|
||||
submissionLimit = false,
|
||||
}: {
|
||||
title: string;
|
||||
checks: unknown;
|
||||
submissionLimit?: boolean;
|
||||
}) {
|
||||
const rows = Array.isArray(checks)
|
||||
? checks.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
item !== null && typeof item === "object" && !Array.isArray(item),
|
||||
)
|
||||
: [];
|
||||
const limits = rows.filter((check) => check.name === "REGULAR_SUBMISSION");
|
||||
const alphaChecks = rows.filter(
|
||||
(check) => check.name !== "REGULAR_SUBMISSION",
|
||||
);
|
||||
if (!submissionLimit && limits.length) {
|
||||
return (
|
||||
<>
|
||||
<PlatformChecks title={title} checks={alphaChecks} />
|
||||
<PlatformChecks
|
||||
title={`${title} · 提交限制`}
|
||||
checks={limits}
|
||||
submissionLimit
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section aria-label={title}>
|
||||
<h4>{title}</h4>
|
||||
{submissionLimit && (
|
||||
<p className="muted">
|
||||
以下为缓存观测时的提交限制,不计入 Alpha
|
||||
失败项;当前额度需重新检查确认。
|
||||
</p>
|
||||
)}
|
||||
{rows.length ? (
|
||||
rows.map((check, i) => (
|
||||
<div className="check-row" key={i}>
|
||||
@@ -496,17 +530,23 @@ function PlatformChecks({ title, checks }: { title: string; checks: unknown }) {
|
||||
check.result === "PASS"
|
||||
? "green"
|
||||
: check.result === "FAIL"
|
||||
? "red"
|
||||
? submissionLimit
|
||||
? "orange"
|
||||
: "red"
|
||||
: check.result === "WARNING"
|
||||
? "orange"
|
||||
: "grey"
|
||||
}
|
||||
>
|
||||
{check.result === "WARNING"
|
||||
? "警告(WARNING)"
|
||||
: check.result === "PENDING"
|
||||
? "待定(PENDING)"
|
||||
: displayValue(check.result)}
|
||||
{submissionLimit && check.result === "FAIL"
|
||||
? "提交受限(FAIL)"
|
||||
: submissionLimit && check.result === "PASS"
|
||||
? "当时未受限(PASS)"
|
||||
: check.result === "WARNING"
|
||||
? "警告(WARNING)"
|
||||
: check.result === "PENDING"
|
||||
? "待定(PENDING)"
|
||||
: displayValue(check.result)}
|
||||
</Tag>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -34,6 +34,7 @@ export function SubmissionPanel({
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const dirty = useRef(false);
|
||||
const submittedJob = useRef<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
@@ -47,6 +48,16 @@ export function SubmissionPanel({
|
||||
.then((value) => {
|
||||
if (!active) return;
|
||||
setData(value);
|
||||
// Rebase only our completed writeback and its exact resulting snapshot.
|
||||
// A different expression/settings/description must still require review.
|
||||
if (
|
||||
value.job?.id === submittedJob.current &&
|
||||
value.job?.status === "completed" &&
|
||||
value.job.checkpoint.review_snapshot === value.snapshot
|
||||
) {
|
||||
setSnapshot(value.snapshot);
|
||||
submittedJob.current = null;
|
||||
}
|
||||
if (!dirty.current) {
|
||||
setDraft(value.descriptions);
|
||||
setSnapshot(value.snapshot);
|
||||
@@ -89,6 +100,7 @@ export function SubmissionPanel({
|
||||
if (!mounted.current) return;
|
||||
// Keep the reviewed draft visible while the durable task runs.
|
||||
dirty.current = true;
|
||||
submittedJob.current = job.id;
|
||||
setData((current) => (current ? { ...current, job } : current));
|
||||
onTask();
|
||||
} catch (e) {
|
||||
@@ -105,14 +117,14 @@ export function SubmissionPanel({
|
||||
<div className="detail-section">
|
||||
<h3>Description 与提交检查</h3>
|
||||
<p className="muted">
|
||||
AI 一次生成完整 Description,可在下方统一修改。写回并检查会更新 BRAIN 的
|
||||
AI 一次生成完整 Description,可在下方统一修改。平台检查会更新 BRAIN 的
|
||||
Description,随后获取平台提交检查结果,不会正式提交 Alpha。
|
||||
</p>
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
{!data.can_check && (
|
||||
<Banner
|
||||
type="info"
|
||||
description="写回并检查需要待提交 Alpha。有本地比较基准时,需取得有效、完整且低于阈值的自相关结果;没有本地基准时可直接继续平台检查。"
|
||||
description="平台检查需要待提交 Alpha。有本地比较基准时,需取得有效、完整且低于阈值的自相关结果;没有本地基准时可直接继续平台检查。"
|
||||
/>
|
||||
)}
|
||||
{!data.can_generate && (
|
||||
@@ -121,6 +133,17 @@ export function SubmissionPanel({
|
||||
</p>
|
||||
)}
|
||||
<div className="inline-actions">
|
||||
<Button
|
||||
disabled={!!busy || running}
|
||||
onClick={() => {
|
||||
dirty.current = false;
|
||||
setDraft(data.descriptions);
|
||||
setSnapshot(data.snapshot);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
载入最新描述
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void generate()}
|
||||
loading={busy === "generate"}
|
||||
@@ -181,18 +204,7 @@ export function SubmissionPanel({
|
||||
disabled={!!busy || running || conflict || !data.can_check}
|
||||
onClick={() => void check()}
|
||||
>
|
||||
写回 Description 并检查
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!!busy || running}
|
||||
onClick={() => {
|
||||
dirty.current = false;
|
||||
setDraft(data.descriptions);
|
||||
setSnapshot(data.snapshot);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
载入最新描述
|
||||
平台检查
|
||||
</Button>
|
||||
</div>
|
||||
{data.job && (
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
.catalog-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
}
|
||||
.catalog-filter-grid > label {
|
||||
display: flex;
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
.alpha-filter-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
}
|
||||
.alpha-filter-fields > label {
|
||||
margin: 0;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
IconRefresh,
|
||||
} from "@douyinfe/semi-icons";
|
||||
import "./AlphaPage.css";
|
||||
import { formatAlphaMetric } from "../alphaMetrics";
|
||||
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
||||
import {
|
||||
api,
|
||||
@@ -84,8 +85,10 @@ function metricColor(key: string, value: unknown): string | undefined {
|
||||
return "var(--semi-color-danger)";
|
||||
}
|
||||
|
||||
/** Convert Margin only for display; filtering and sorting still use raw values. */
|
||||
/** Format metrics only for display; filtering and sorting still use raw values. */
|
||||
function formatMetric(key: string, value: unknown): string {
|
||||
const headline = formatAlphaMetric(key, value);
|
||||
if (headline !== undefined) return headline;
|
||||
if (key !== "margin") return formatNumber(value, 3);
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? `${formatNumber(value * 10000, 2)}bps`
|
||||
@@ -523,7 +526,9 @@ export function AlphaPage({
|
||||
? "red"
|
||||
: row!.check_type === "PASS"
|
||||
? "green"
|
||||
: "grey"
|
||||
: row!.check_type === "PRE_CHECK"
|
||||
? "blue"
|
||||
: "grey"
|
||||
}
|
||||
>
|
||||
{checkLabels[row!.check_type] || "待检查"}
|
||||
@@ -646,8 +651,13 @@ export function AlphaPage({
|
||||
render: (_, row) => formatTime(row!.date_created, account?.timezone),
|
||||
},
|
||||
];
|
||||
// Submitted alphas always expose the platform production correlation, including saved views.
|
||||
const displayedColumns =
|
||||
submission === "SUBMITTED"
|
||||
? [...new Set([...visibleColumns, "prod_correlation"])]
|
||||
: visibleColumns;
|
||||
const columns = columnDefinitions
|
||||
.filter((column) => visibleColumns.includes(String(column.key)))
|
||||
.filter((column) => displayedColumns.includes(String(column.key)))
|
||||
.map((column) => (compact ? { ...column, fixed: undefined } : column));
|
||||
const activeFilterCount = Object.values(filters).filter(Boolean).length;
|
||||
const options = (key: string) =>
|
||||
@@ -909,7 +919,7 @@ export function AlphaPage({
|
||||
suspended={overlaySuspended || !active}
|
||||
onOverlay={onOverlay}
|
||||
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
|
||||
columns={visibleColumns}
|
||||
columns={displayedColumns}
|
||||
onRestore={(view) => {
|
||||
setFilterOpen(false);
|
||||
const {
|
||||
@@ -984,12 +994,15 @@ export function AlphaPage({
|
||||
<strong>显示列</strong>
|
||||
<CheckboxGroup
|
||||
direction="vertical"
|
||||
value={visibleColumns}
|
||||
value={displayedColumns}
|
||||
options={Object.entries(columnLabels).map(
|
||||
([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === "name",
|
||||
disabled:
|
||||
value === "name" ||
|
||||
(submission === "SUBMITTED" &&
|
||||
value === "prod_correlation"),
|
||||
}),
|
||||
)}
|
||||
onChange={(values) => {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
.home-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.home-metric.semi-card,
|
||||
@@ -142,7 +142,7 @@
|
||||
.home-panel-error {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
min-height: 180px;
|
||||
}
|
||||
.home-calendar-summary {
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
.mcp-key-permissions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
}
|
||||
.mcp-key-permissions label {
|
||||
display: flex;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
.research-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
}
|
||||
.research-toolbar,
|
||||
.research-methods,
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
}
|
||||
.settings-field {
|
||||
display: flex;
|
||||
|
||||
@@ -138,6 +138,7 @@ export type Job = {
|
||||
alpha_ids?: string[];
|
||||
};
|
||||
checkpoint: {
|
||||
review_snapshot?: string;
|
||||
date?: string;
|
||||
dataset_id?: string;
|
||||
datasets_completed?: number;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Job } from "../src/types";
|
||||
|
||||
test("platform check can repeat after its own description writeback", async ({
|
||||
page,
|
||||
}) => {
|
||||
let snapshot = "a".repeat(64);
|
||||
let descriptions = { regular: "" };
|
||||
let job: Job | null = null;
|
||||
const submitted: string[] = [];
|
||||
await page.route(/\/api\/v1\/sync-jobs$/, (route) =>
|
||||
route.fulfill({ json: job ? [job] : [] }),
|
||||
);
|
||||
await page.route(/\/api\/v1\/alphas\/[^/]+\/submission$/, (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
snapshot,
|
||||
descriptions,
|
||||
sections: {
|
||||
regular: { code: "rank(close)", description: descriptions.regular },
|
||||
},
|
||||
model: "test-model",
|
||||
can_generate: true,
|
||||
can_check: true,
|
||||
job,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
/\/api\/v1\/alphas\/[^/]+\/submission-check$/,
|
||||
async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
expect(body.snapshot).toBe(snapshot);
|
||||
submitted.push(body.snapshot);
|
||||
descriptions = body.descriptions;
|
||||
snapshot = String(submitted.length).repeat(64);
|
||||
job = {
|
||||
id: `repeat-${submitted.length}`,
|
||||
kind: "submission_check",
|
||||
status: "completed",
|
||||
processed: 1,
|
||||
failed: 0,
|
||||
total: 1,
|
||||
error: null,
|
||||
next_retry_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
payload: {},
|
||||
checkpoint: { phase: "checked", review_snapshot: snapshot },
|
||||
};
|
||||
await route.fulfill({ status: 202, json: { ...job, status: "queued" } });
|
||||
},
|
||||
);
|
||||
await page.goto("/#alphas");
|
||||
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||
await expect(
|
||||
page.getByRole("tab", { name: "待提交", exact: true }),
|
||||
).toBeVisible();
|
||||
const headers = { "X-WQ-Request": "1" };
|
||||
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 imported = await (
|
||||
await page.request.post("/api/v1/sync-jobs", {
|
||||
headers,
|
||||
data: { kind: "alpha_refresh", alpha_ids: ["TEST0004"] },
|
||||
})
|
||||
).json();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await (
|
||||
await page.request.get(`/api/v1/sync-jobs/${imported.id}`)
|
||||
).json()
|
||||
).status,
|
||||
)
|
||||
.toBe("completed");
|
||||
await page.reload();
|
||||
await page.locator(".alpha-link").first().click();
|
||||
const sheet = page.locator(".alpha-detail");
|
||||
await sheet.getByRole("tab", { name: "相关性检查", exact: true }).click();
|
||||
const load = sheet.getByRole("button", { name: "载入最新描述", exact: true });
|
||||
const generate = sheet.getByRole("button", {
|
||||
name: "AI 生成 Description",
|
||||
exact: true,
|
||||
});
|
||||
await expect(load).toBeVisible();
|
||||
expect((await load.boundingBox())!.x).toBeLessThan(
|
||||
(await generate.boundingBox())!.x,
|
||||
);
|
||||
const draft = sheet.getByRole("textbox", {
|
||||
name: "regular Description",
|
||||
exact: true,
|
||||
});
|
||||
await draft.fill("A reviewed description for repeat checking.");
|
||||
const check = sheet.getByRole("button", { name: "平台检查", exact: true });
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
await expect(check).toBeEnabled();
|
||||
await check.click();
|
||||
await expect.poll(() => submitted.length).toBe(i);
|
||||
await expect(page.locator(".job-panel")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator(".job-panel")).not.toBeVisible();
|
||||
await expect(check).toBeEnabled();
|
||||
await expect(draft).toHaveValue(
|
||||
"A reviewed description for repeat checking.",
|
||||
);
|
||||
}
|
||||
expect(submitted).toEqual(["a".repeat(64), "1".repeat(64)]);
|
||||
});
|
||||
Reference in New Issue
Block a user