feat: add scoped alpha sync and local self-correlation

This commit is contained in:
yuxuanhui
2026-09-08 10:40:49 +08:00
parent 404a4d8a04
commit d2ccd94721
28 changed files with 1656 additions and 69 deletions
+15 -1
View File
@@ -579,7 +579,21 @@ function BusinessCard({
{Array.isArray(call.preview.operation.alpha_ids) &&
call.preview.operation.alpha_ids.length
? call.preview.operation.alpha_ids.join("、")
: "全部 Alpha"}
: call.preview.operation.submission === "UNSUBMITTED"
? "待提交 Alpha"
: call.preview.operation.submission === "SUBMITTED"
? "已提交 Alpha"
: "全部 Alpha"}
{call.preview.operation.kind === "daily_sync" && (
<>
{" · "}
{call.preview.operation.submission === "UNSUBMITTED"
? "创建日期"
: "提交日期"}{" "}
{String(call.preview.operation.date_from)} 至{" "}
{String(call.preview.operation.date_to)}(UTC)
</>
)}
</p>
)}
{call.preview.job && (
+8
View File
@@ -83,12 +83,20 @@ export const stateOptions = Object.entries(stateLabels).map(
);
export const jobLabels: Record<string, string> = {
full_sync: "全量同步 Alpha",
daily_sync: "按天同步 Alpha",
self_correlation: "本地自相关检测",
alpha_refresh: "导入 / 刷新 Alpha",
pnl_refresh: "获取 PnL",
connect: "连接 WorldQuant",
verify: "继续人工验证",
profile: "刷新个人资料",
};
export const correlationLabels = {
high: "相关性偏高",
low: "低于阈值",
partial: "样本不完整",
insufficient_data: "数据不足",
};
export const jobStateLabels: Record<string, string> = {
queued: "排队中",
running: "执行中",
+12
View File
@@ -31,6 +31,7 @@ import type {
ResearchState,
} from "../types";
import { PnlChart } from "./PnlChart";
import { SelfCorrelationPanel } from "./SelfCorrelationPanel";
export function AlphaDetail({
id,
@@ -304,6 +305,17 @@ export function AlphaDetail({
<Empty description="点击获取 PnL,从平台读取并缓存曲线数据" />
)}
</TabPane>
<TabPane tab="本地自相关" itemKey="correlation">
{tab === "correlation" && id && (
<SelfCorrelationPanel
key={id}
id={id}
version={version}
timezone={timezone}
onTask={onTask}
/>
)}
</TabPane>
<TabPane tab="研究记录" itemKey="research">
<div className="detail-section">
<p className="muted">
@@ -0,0 +1,98 @@
import { useState } from "react";
import { Input, Modal, Toast } from "@douyinfe/semi-ui-19";
import { post } from "../api";
import type { Submission } from "../types";
export function AlphaSyncDialog({
submission,
suspended,
onClose,
onTask,
}: {
submission: Submission;
suspended: boolean;
onClose: () => void;
onTask: () => void;
}) {
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const label = submission === "UNSUBMITTED" ? "待提交" : "已提交";
const dateLabel = submission === "UNSUBMITTED" ? "创建日期" : "提交日期";
const today = new Date().toISOString().slice(0, 10);
async function start() {
if (!from || !to || from > to || to > today) {
setError("请选择有效的起止日期,结束日期不能晚于今天(UTC)。");
return;
}
setBusy(true);
try {
await post("/sync-jobs", {
kind: "daily_sync",
submission,
date_from: from,
date_to: to,
});
onClose();
onTask();
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Modal
title={`按天同步${label} Alpha`}
visible={!suspended}
onCancel={onClose}
okText="开始同步"
okButtonProps={{ "aria-label": "开始同步" }}
confirmLoading={busy}
onOk={() => void start()}
>
<p className="muted">
按{dateLabel}(UTC)逐天获取,包含隐藏记录。相同起止日期表示只同步当天。
</p>
<div className="sync-date-range">
<label>
开始日期
<Input
aria-label="同步开始日期"
type="date"
max={today}
value={from}
onChange={(value) => {
setFrom(value);
if (!to) setTo(value);
setError("");
}}
/>
</label>
<label>
结束日期
<Input
aria-label="同步结束日期"
type="date"
min={from || undefined}
max={today}
value={to}
onChange={(value) => {
setTo(value);
setError("");
}}
/>
</label>
</div>
{error && (
<p className="error-text" role="alert">
{error}
</p>
)}
<p className="muted">
任务面板显示当前日期和进度,中断后可继续未完成部分。
</p>
</Modal>
);
}
+22
View File
@@ -72,6 +72,28 @@ export function JobPanel({
</Tag>
</div>
<p className="muted">{formatTime(job.created_at, timezone)}</p>
{job.payload?.submission && (
<p className="muted">
{job.payload.submission === "SUBMITTED" ? "已提交" : "待提交"}
{job.payload.date_from
? ` · ${job.payload.date_from} 至 ${job.payload.date_to}(UTC)`
: " · 全量"}
</p>
)}
{job.checkpoint?.date && (
<p className="muted">
同步日期:{job.checkpoint.date} · 已完成{" "}
{job.checkpoint.dates_completed} / {job.checkpoint.dates_total} 天
</p>
)}
{job.kind === "self_correlation" && job.checkpoint?.alpha_id && (
<p className="muted">
{job.checkpoint.alpha_id} ·{" "}
{job.checkpoint.phase === "calculating"
? "计算相关性"
: `准备 PnL:${job.checkpoint.references_loaded ?? 0} / ${job.checkpoint.references_total ?? 0} 个基准`}
</p>
)}
<div className="job-numbers">
<span>
已处理 <b>{job.processed}</b>
@@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Descriptions,
Empty,
Spin,
Table,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, correlationLabels, formatNumber, formatTime, post } from "../api";
import type { CorrelationResult } from "../types";
export function SelfCorrelationPanel({
id,
version,
timezone,
onTask,
}: {
id: string;
version: string;
timezone?: string;
onTask: () => void;
}) {
const [result, setResult] = useState<CorrelationResult | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
setLoading(true);
api<{ result: CorrelationResult | null }>(
`/alphas/${id}/self-correlation`,
{ signal: controller.signal },
)
.then((value) => {
if (!controller.signal.aborted) {
setResult(value.result);
setError("");
}
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [id, version]);
async function check() {
setBusy(true);
try {
await post("/sync-jobs", { kind: "self_correlation", alpha_ids: [id] });
onTask();
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="correlation-panel">
<div className="section-toolbar">
<h3>本地自相关</h3>
<Button onClick={() => void check()} loading={busy}>
{result ? "重新检测自相关" : "检测自相关"}
</Button>
</div>
<p className="muted">
与本地已同步的同地区已提交 Alpha 比较,排除自身。使用 PnL
缓存,缺失时自动获取;建议先全量同步已提交 Alpha。
</p>
<p className="muted">
近四年 PnL 日变化的 Pearson 相关系数,至少 30 个共同样本;本地告警线为
0.7,检测结果与平台检查分别记录。
</p>
{error && <Banner type="danger" description={error} />}
{loading && !result ? (
<Spin />
) : !result ? (
<Empty description="尚未检测自相关" />
) : (
<>
{result.stale && (
<Banner
type="warning"
description="PnL 或比较样本已更新,以下为上次结果,请重新检测。"
/>
)}
<div className="inline-actions">
<Tag
color={
result.stale
? "grey"
: result.status === "high"
? "orange"
: result.status === "low"
? "green"
: "grey"
}
>
{correlationLabels[result.status]}
</Tag>
<span className="muted">
计算于 {formatTime(result.calculated_at, timezone)}
</span>
</div>
<Descriptions
data={[
{
key: "最大相关系数",
value: formatNumber(result.max_correlation, 4),
},
{
key: "有效比较",
value: `${result.compared_count} / ${result.candidate_count}`,
},
{ key: "跳过样本", value: result.skipped_count },
{
key: "比较窗口",
value: result.window_from
? `${result.window_from} 至 ${result.window_to}`
: "未提供",
},
{
key: "目标 PnL 更新",
value: result.target_pnl_fetched_at
? formatTime(result.target_pnl_fetched_at, timezone)
: "未提供",
},
]}
/>
{result.reason && <p>{result.reason}</p>}
{result.status === "partial" && (
<p>已有样本低于阈值,但部分基准无法比较,不能据此判断全部样本。</p>
)}
{result.matches.length > 0 && (
<>
<h3>相关系数最高的 Alpha(最多 10 条)</h3>
<Table
dataSource={result.matches}
rowKey="alpha_id"
size="small"
pagination={false}
columns={[
{
title: "Alpha",
dataIndex: "alpha_id",
render: (value) => (
<a
href={`https://platform.worldquantbrain.com/alpha/${encodeURIComponent(value)}`}
target="_blank"
rel="noreferrer"
>
{value}
</a>
),
},
{
title: "相关系数",
dataIndex: "correlation",
render: (value) => formatNumber(value, 4),
},
{ title: "共同样本", dataIndex: "sample_count" },
{
title: "PnL 更新",
dataIndex: "pnl_fetched_at",
render: (value) => formatTime(value, timezone),
},
]}
/>
</>
)}
{result.skipped.length > 0 && (
<details>
<summary>
跳过原因({result.skipped_count} 条,最多展示 100 条)
</summary>
{result.skipped.map((item) => (
<p key={item.alpha_id}>
<code>{item.alpha_id}</code>:{item.reason}
</p>
))}
</details>
)}
</>
)}
</div>
);
}
+100 -7
View File
@@ -11,6 +11,7 @@ import {
Pagination,
Select,
Table,
Tabs,
Tag,
TextArea,
Toast,
@@ -18,6 +19,7 @@ import {
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import {
api,
correlationLabels,
formatNumber,
formatTime,
patch,
@@ -26,8 +28,15 @@ import {
stateLabels,
stateOptions,
} from "../api";
import type { Account, Alpha, AlphaPage as Page, Facets } from "../types";
import type {
Account,
Alpha,
AlphaPage as Page,
Facets,
Submission,
} from "../types";
import { AlphaDetail } from "../components/AlphaDetail";
import { AlphaSyncDialog } from "../components/AlphaSyncDialog";
import type { PageContext, UIAction } from "../ai/types";
const metricLabels = {
@@ -46,6 +55,7 @@ const initialColumns = [
"sharpe",
"fitness",
"turnover",
"correlation",
"research",
];
const columnLabels: Record<string, string> = {
@@ -63,6 +73,8 @@ const columnLabels: Record<string, string> = {
tags: "本地标签",
language: "语言",
created: "创建时间",
submitted: "提交时间",
correlation: "本地自相关",
};
export function AlphaPage({
@@ -99,6 +111,8 @@ export function AlphaPage({
const [facets, setFacets] = useState<Facets>({});
const [draft, setDraft] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string>>({});
const [submission, setSubmission] = useState<Submission>("UNSUBMITTED");
const [syncingScope, setSyncingScope] = useState<Submission | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState("date_created");
@@ -157,12 +171,13 @@ export function AlphaPage({
const params = useMemo(
() => ({
...filters,
submission,
sort,
direction,
limit: pageSize,
offset: (page - 1) * pageSize,
}),
[filters, sort, direction, pageSize, page],
[filters, submission, sort, direction, pageSize, page],
);
const query = queryString(params);
useEffect(() => {
@@ -190,6 +205,7 @@ export function AlphaPage({
direction: nextDirection,
limit: _limit,
offset: _offset,
submission: nextSubmission,
...values
} = action.filters;
const next = Object.fromEntries(
@@ -201,6 +217,8 @@ export function AlphaPage({
setFilters(next);
setPage(1);
setSelected([]);
if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED")
setSubmission(nextSubmission);
if (nextSort) setSort(String(nextSort));
if (nextDirection) setDirection(String(nextDirection));
}
@@ -247,10 +265,22 @@ export function AlphaPage({
setFilters({});
setPage(1);
}
function changeSubmission(value: string) {
setSubmission(value as Submission);
setDraft({});
setFilters({});
setPage(1);
setSelected([]);
setSort(value === "SUBMITTED" ? "date_submitted" : "date_created");
}
async function newTask(kind: string, ids: string[] = []) {
setBusy(kind);
try {
await post("/sync-jobs", { kind, alpha_ids: ids });
await post("/sync-jobs", {
kind,
alpha_ids: ids,
...(kind === "full_sync" ? { submission: "SUBMITTED" } : {}),
});
setImporting(false);
setIdText("");
onTask();
@@ -389,6 +419,31 @@ export function AlphaPage({
),
},
{ key: "language", title: "语言", width: 125, dataIndex: "language" },
{
key: "correlation",
title: "本地自相关",
width: 160,
render: (_, row) => {
const value = row!.local_correlation;
return value ? (
<span
title={`计算于 ${formatTime(value.calculated_at, account?.timezone)}`}
>
{value.stale ? "待重算" : correlationLabels[value.status]}
{value.max_correlation !== null &&
` · ${formatNumber(value.max_correlation, 3)}`}
</span>
) : (
<span className="muted">未检测</span>
);
},
},
{
key: "submitted",
title: "提交时间",
width: 165,
render: (_, row) => formatTime(row!.date_submitted, account?.timezone),
},
{
key: "created",
title: "创建时间",
@@ -419,12 +474,23 @@ export function AlphaPage({
</Button>
<Button
theme="solid"
loading={busy === "full_sync"}
disabled={!connected}
onClick={() => void newTask("full_sync")}
onClick={() => {
setSyncingScope(submission);
onOverlay();
}}
>
同步平台数据
按天同步
</Button>
{submission === "SUBMITTED" && (
<Button
loading={busy === "full_sync"}
disabled={!connected}
onClick={() => void newTask("full_sync")}
>
全量同步已提交
</Button>
)}
</div>
</div>
<div className="library-stats">
@@ -467,6 +533,15 @@ export function AlphaPage({
)}
{error && <Banner type="danger" description={error} />}
<section className="library-panel" ref={tablePanel}>
<Tabs
className="alpha-submission-tabs"
activeKey={submission}
onChange={changeSubmission}
tabList={[
{ itemKey: "UNSUBMITTED", tab: "待提交" },
{ itemKey: "SUBMITTED", tab: "已提交" },
]}
/>
<form
className="filter-panel"
onSubmit={(e) => {
@@ -619,7 +694,9 @@ export function AlphaPage({
</form>
<div className="table-toolbar">
<div>
<strong>全部 Alpha</strong>
<strong>
{submission === "UNSUBMITTED" ? "待提交" : "已提交"} Alpha
</strong>
<span className="count-pill">{data.total.toLocaleString()}</span>
{selected.length > 0 && (
<span className="selection-text">
@@ -630,6 +707,14 @@ export function AlphaPage({
<div className="inline-actions">
{selected.length > 0 && (
<>
<Button
type="tertiary"
size="small"
loading={busy === "self_correlation"}
onClick={() => void newTask("self_correlation", selected)}
>
检测自相关
</Button>
<Button
type="tertiary"
size="small"
@@ -844,6 +929,14 @@ export function AlphaPage({
/>
</label>
</Modal>
{syncingScope && (
<AlphaSyncDialog
submission={syncingScope}
suspended={overlaySuspended}
onClose={() => setSyncingScope(null)}
onTask={onTask}
/>
)}
<AlphaDetail
suspended={overlaySuspended}
chatOffset={chatOffset}
+28
View File
@@ -317,6 +317,34 @@ label:not(.semi-checkbox):not(.semi-radio) {
max-height: 44%;
overflow: auto;
}
.alpha-submission-tabs {
flex-shrink: 0;
padding: 0 var(--space-3);
}
.alpha-submission-tabs .semi-tabs-content {
display: none;
}
.sync-date-range {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
margin-top: var(--space-4);
}
.correlation-panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3) 0;
}
.correlation-panel .section-toolbar h3 {
margin: 0;
}
.correlation-panel .semi-table {
overflow-x: auto;
}
.correlation-panel summary {
cursor: pointer;
}
.primary-filters {
display: flex;
gap: var(--space-2);
+44
View File
@@ -1,4 +1,32 @@
export type ResearchState = "inbox" | "candidate" | "optimizing" | "archived";
export type Submission = "UNSUBMITTED" | "SUBMITTED";
export type CorrelationSummary = {
status: "high" | "low" | "partial" | "insufficient_data";
max_correlation: number | null;
compared_count: number;
skipped_count: number;
stale: boolean;
calculated_at: string;
};
export type CorrelationResult = CorrelationSummary & {
threshold: number;
min_samples: number;
window_years: number;
window_from: string | null;
window_to: string | null;
candidate_count: number;
reason: string | null;
target_pnl_fetched_at?: string;
matches: {
alpha_id: string;
correlation: number;
sample_count: number;
date_from: string;
date_to: string;
pnl_fetched_at: string;
}[];
skipped: { alpha_id: string; reason: string }[];
};
export type Research = {
version: number;
note: string;
@@ -28,6 +56,7 @@ export type Alpha = {
date_submitted: string | null;
synced_at: string;
research: Research;
local_correlation: CorrelationSummary | null;
};
export type AlphaDetail = Alpha & {
expression: string | null;
@@ -85,6 +114,21 @@ export type Job = {
next_retry_at: string | null;
created_at: string;
updated_at: string;
payload: {
submission?: Submission;
date_from?: string;
date_to?: string;
alpha_ids?: string[];
};
checkpoint: {
date?: string;
dates_completed?: number;
dates_total?: number;
alpha_id?: string;
phase?: string;
references_loaded?: number;
references_total?: number;
};
};
export type AlphaPage = {
items: Alpha[];
+9
View File
@@ -39,6 +39,15 @@ async function configure(page: Page) {
headers,
data: { kind: "full_sync" },
});
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",
},
});
await expect
.poll(
async () =>
+145
View File
@@ -0,0 +1,145 @@
import { expect, test } from "@playwright/test";
test("submission tabs, day selection, local correlation and reload", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await expect(
page.getByRole("heading", { name: "Alpha 管理", exact: true }),
).toBeVisible();
const headers = { "X-WQ-Request": "1" };
const credentials = await page.request.put("/api/v1/account/credentials", {
headers,
data: { email: "test@example.com", password: "synthetic-password" },
});
expect(credentials.ok()).toBe(true);
expect(
(await page.request.post("/api/v1/account/connect", { headers })).ok(),
).toBe(true);
await expect
.poll(
async () =>
(await (await page.request.get("/api/v1/account")).json())
.connection_status,
)
.toBe("connected");
await page.reload();
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("tab", { name: "已提交", exact: true }).click();
await page.getByRole("button", { name: "按天同步", exact: true }).click();
await expect(page.getByRole("dialog")).toContainText("提交日期");
await page.getByRole("button", { name: "开始同步", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("请选择有效的起止日期");
// Separate validation from submission; Semi ignores repeated OK clicks within 100 ms.
await page
.getByRole("dialog")
.getByRole("button", { name: "cancel", exact: true })
.click();
await page.getByRole("button", { name: "按天同步", exact: true }).click();
await page.getByLabel("同步开始日期").fill("2025-02-02");
await page.getByLabel("同步结束日期").fill("2025-02-02");
const request = page.waitForResponse(
(response) =>
response.url().endsWith("/api/v1/sync-jobs") &&
response.request().method() === "POST",
);
await page.getByRole("button", { name: "开始同步", exact: true }).click();
const job = await (await request).json();
expect(job.payload).toMatchObject({
submission: "SUBMITTED",
date_from: "2025-02-02",
date_to: "2025-02-02",
});
await expect
.poll(
async () =>
(await (await page.request.get(`/api/v1/sync-jobs/${job.id}`)).json())
.status,
)
.toBe("completed");
const complete = await (
await page.request.get(`/api/v1/sync-jobs/${job.id}`)
).json();
expect(complete.processed).toBe(103);
await expect(page.locator(".job-panel")).toContainText("2025-02-02");
await page.keyboard.press("Escape");
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0003");
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(1);
await page.getByRole("tab", { name: "待提交", exact: true }).click();
await expect(page.getByRole("textbox", { name: "搜索 Alpha" })).toHaveValue(
"",
);
await page.getByRole("button", { name: "按天同步", exact: true }).click();
await expect(page.getByRole("dialog")).toContainText("创建日期");
await page.getByLabel("同步开始日期").fill("2025-01-01");
await page.getByLabel("同步结束日期").fill("2025-01-01");
await page.getByRole("button", { name: "开始同步", exact: true }).click();
await expect
.poll(
async () =>
(
await (
await page.request.get("/api/v1/alphas?submission=UNSUBMITTED")
).json()
).total,
)
.toBe(413);
await page.keyboard.press("Escape");
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0004");
await page.getByRole("button", { name: "查询", exact: true }).click();
await page.locator(".alpha-link").click();
await page.getByRole("tab", { name: "本地自相关", exact: true }).click();
await page.getByRole("button", { name: "检测自相关", exact: true }).click();
await expect
.poll(
async () =>
(
await (
await page.request.get("/api/v1/alphas/TEST0004/self-correlation")
).json()
).result?.status,
{ timeout: 20000 },
)
.toBe("high");
await page.keyboard.press("Escape");
await expect(page.locator(".job-panel")).not.toBeVisible();
await expect(page.locator(".correlation-panel")).toContainText("相关性偏高");
await expect(page.locator(".correlation-panel")).toContainText("1.0000");
await page.screenshot({
path: "../output/playwright/local-correlation.png",
animations: "disabled",
});
await page.keyboard.press("Escape");
await page.reload();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0004");
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-table")).toContainText("相关性偏高");
await page.getByRole("tab", { name: "已提交", exact: true }).click();
await expect(
page.getByRole("button", { name: "全量同步已提交" }),
).toBeVisible();
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
await expect(
page.getByRole("tab", { name: "已提交", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "按天同步", exact: true }),
).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
await page.screenshot({
path: `../output/playwright/alpha-tabs-${width}.png`,
animations: "disabled",
});
}
expect(errors).toEqual([]);
});
+39 -6
View File
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { readFile } from "node:fs/promises";
test("account → full sync → research → resync → PnL → filtered export → logout", async ({
test("account → scoped sync → research → resync → PnL → filtered export → logout", async ({
page,
}) => {
const failures: string[] = [];
@@ -48,7 +48,29 @@ test("account → full sync → research → resync → PnL → filtered export
await expect(simulation).toContainText("10,000(本地设定)");
await expect(simulation).toContainText("9,997");
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("button", { name: "同步平台数据" }).click();
await expect(
page.getByRole("tab", { name: "待提交", exact: true }),
).toHaveAttribute("aria-selected", "true");
await expect(
page.getByRole("button", { name: "全量同步已提交" }),
).toHaveCount(0);
await page.getByRole("button", { name: "按天同步", exact: true }).click();
await page.getByLabel("同步开始日期").fill("2025-01-01");
await page.getByLabel("同步结束日期").fill("2025-01-01");
await page.getByRole("button", { name: "开始同步", exact: true }).click();
await expect
.poll(
async () =>
(
await (
await page.request.get("/api/v1/alphas?submission=UNSUBMITTED")
).json()
).total,
)
.toBe(413);
await page.keyboard.press("Escape");
await page.getByRole("tab", { name: "已提交", exact: true }).click();
await page.getByRole("button", { name: "全量同步已提交" }).click();
await expect
.poll(
async () =>
@@ -58,6 +80,7 @@ test("account → full sync → research → resync → PnL → filtered export
.toBe(620);
await page.keyboard.press("Escape");
await expect(page.locator(".job-panel")).not.toBeVisible();
await page.getByRole("tab", { name: "待提交", exact: true }).click();
await expect(page.locator(".library-stats strong").first()).toHaveText("620");
await page.screenshot({
path: "../output/playwright/alpha-library.png",
@@ -134,8 +157,9 @@ test("account → full sync → research → resync → PnL → filtered export
const download = await downloading;
const contents = await readFile((await download.path())!, "utf-8");
expect(contents).toContain("TEST0619");
expect(contents).toContain("TEST0000");
expect((contents.match(/TEST\d{4}/g) ?? []).length).toBe(620);
expect(contents).toContain("TEST0001");
expect(contents).not.toContain("TEST0000");
expect((contents.match(/TEST\d{4}/g) ?? []).length).toBe(413);
await page.getByRole("button", { name: "退出登录" }).click();
await expect(
page.getByRole("heading", { name: "登录研究工作空间" }),
@@ -172,6 +196,15 @@ test("batch tags, column visibility, server pagination and saved preferences", a
headers,
data: { kind: "full_sync" },
});
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",
},
});
await expect
.poll(
async () =>
@@ -182,7 +215,7 @@ test("batch tags, column visibility, server pagination and saved preferences", a
}
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST006");
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(10);
await expect(page.locator(".alpha-link")).toHaveCount(6);
const selectAll = page.getByRole("checkbox", { name: "Select all rows" });
await selectAll.focus();
await selectAll.press("Space");
@@ -204,7 +237,7 @@ test("batch tags, column visibility, server pagination and saved preferences", a
).json()
).total,
)
.toBe(10);
.toBe(6);
await page.getByRole("button", { name: "显示列设置" }).click();
await page
.locator(".columns-picker")