feat: add initial frontend setup with styles, types, and testing framework
- Created a global CSS file for styling the frontend with responsive design. - Introduced TypeScript types for various entities including Research, Alpha, and Account. - Implemented Playwright tests for account management and data synchronization workflows. - Configured TypeScript with strict settings and included necessary libraries. - Set up Vite as the build tool with React plugin and API proxy configuration. - Added a Python script to initialize environment variables securely.
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Input,
|
||||
Select,
|
||||
SideSheet,
|
||||
Spin,
|
||||
TabPane,
|
||||
Tabs,
|
||||
Tag,
|
||||
TextArea,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
api,
|
||||
displayValue,
|
||||
formatTime,
|
||||
patch,
|
||||
post,
|
||||
stateOptions,
|
||||
} from "../api";
|
||||
import type {
|
||||
AlphaDetail as Detail,
|
||||
Pnl,
|
||||
Research,
|
||||
ResearchState,
|
||||
} from "../types";
|
||||
import { PnlChart } from "./PnlChart";
|
||||
|
||||
export function AlphaDetail({
|
||||
id,
|
||||
version,
|
||||
timezone,
|
||||
taskPanelOpen,
|
||||
onClose,
|
||||
onSaved,
|
||||
onTask,
|
||||
}: {
|
||||
id: string | null;
|
||||
version: string;
|
||||
timezone?: string;
|
||||
taskPanelOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onTask: () => void;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [pnl, setPnl] = useState<Pnl | null>(null);
|
||||
const [research, setResearch] = useState<Research | null>(null);
|
||||
const [tagText, setTagText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tab, setTab] = useState("overview");
|
||||
const dirty = useRef(false);
|
||||
useEffect(() => {
|
||||
dirty.current = false;
|
||||
setDetail(null);
|
||||
setResearch(null);
|
||||
setPnl(null);
|
||||
setError("");
|
||||
setTab("overview");
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (id)
|
||||
api<Detail>(`/alphas/${id}`)
|
||||
.then((value) => {
|
||||
if (active) {
|
||||
setDetail(value);
|
||||
setError("");
|
||||
if (!dirty.current) {
|
||||
setResearch(value.research);
|
||||
setTagText(value.research.tags.join(", "));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id, version]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (id && tab === "pnl")
|
||||
api<Pnl>(`/alphas/${id}/pnl`)
|
||||
.then((p) => {
|
||||
if (active) setPnl(p);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) Toast.error(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id, tab, version]);
|
||||
async function fetchPnl() {
|
||||
try {
|
||||
await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] });
|
||||
onTask();
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!research) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const { updated_at: _, ...editable } = research;
|
||||
await patch(`/alphas/${id}/research`, {
|
||||
...editable,
|
||||
tags: tagText
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
dirty.current = false;
|
||||
Toast.success("研究记录已保存");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<SideSheet
|
||||
closeOnEsc={!taskPanelOpen}
|
||||
className="alpha-detail"
|
||||
visible={Boolean(id)}
|
||||
width={800}
|
||||
title={
|
||||
<span>
|
||||
Alpha 详情 <code className="muted">{id}</code>
|
||||
</span>
|
||||
}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{error ? (
|
||||
<Banner type="danger" description={error} />
|
||||
) : !detail || !research ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<>
|
||||
<div className="detail-title">
|
||||
<div>
|
||||
<h2>{detail.name || detail.id}</h2>
|
||||
<div className="inline-actions">
|
||||
<Tag>{detail.alpha_type ?? "类型未提供"}</Tag>
|
||||
<Tag>{detail.language ?? "语言未提供"}</Tag>
|
||||
<Tag>{detail.status ?? "状态未提供"}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`https://platform.worldquantbrain.com/alpha/${encodeURIComponent(detail.id)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button>在 BRAIN 中打开 ↗</Button>
|
||||
</a>
|
||||
</div>
|
||||
<Tabs activeKey={tab} onChange={setTab}>
|
||||
<TabPane tab="概览与表达式" itemKey="overview">
|
||||
<div className="detail-section">
|
||||
<Descriptions
|
||||
data={["region", "universe", "stage"].map((key) => ({
|
||||
key,
|
||||
value: displayValue(detail[key as keyof Detail]),
|
||||
}))}
|
||||
/>
|
||||
<p className="muted">
|
||||
平台创建:{formatTime(detail.date_created, timezone)} ·
|
||||
最近同步:{formatTime(detail.synced_at, timezone)}
|
||||
</p>
|
||||
{detail.expression !== null && (
|
||||
<CodeBlock
|
||||
title={
|
||||
detail.language === "PYTHON" ? "Python 代码" : "表达式"
|
||||
}
|
||||
value={detail.expression}
|
||||
/>
|
||||
)}
|
||||
{detail.selection !== null && (
|
||||
<CodeBlock title="Selection" value={detail.selection} />
|
||||
)}
|
||||
{detail.combo !== null && (
|
||||
<CodeBlock title="Combo" value={detail.combo} />
|
||||
)}
|
||||
<h3>回测设置</h3>
|
||||
<Descriptions
|
||||
align="left"
|
||||
data={Object.entries(detail.settings).map(([key, value]) => ({
|
||||
key,
|
||||
value: displayValue(value),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tab="指标与检查" itemKey="metrics">
|
||||
<MetricTable title="IS 指标" values={detail.is_metrics} />
|
||||
<MetricTable title="OS 指标" values={detail.os_metrics} />
|
||||
<h3>平台已有检查结果</h3>
|
||||
<p className="muted">
|
||||
展示已同步结果,查看此页不会触发新的平台检查。
|
||||
</p>
|
||||
{detail.checks.length ? (
|
||||
detail.checks.map((check, i) => (
|
||||
<div className="check-row" key={i}>
|
||||
<span>{displayValue(check.name)}</span>
|
||||
<span>
|
||||
{displayValue(check.value)} / {displayValue(check.limit)}
|
||||
</span>
|
||||
<Tag
|
||||
color={
|
||||
check.result === "PASS"
|
||||
? "green"
|
||||
: check.result === "FAIL"
|
||||
? "red"
|
||||
: "grey"
|
||||
}
|
||||
>
|
||||
{displayValue(check.result)}
|
||||
</Tag>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Empty description="平台尚未返回检查结果" />
|
||||
)}
|
||||
</TabPane>
|
||||
<TabPane tab="PnL 曲线" itemKey="pnl">
|
||||
<div className="section-toolbar">
|
||||
<div>
|
||||
<h3>累计 PnL</h3>
|
||||
<p className="muted">
|
||||
{pnl?.fetched_at
|
||||
? `缓存更新于 ${formatTime(pnl.fetched_at, timezone)}`
|
||||
: "尚未获取 PnL"}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => void fetchPnl()}>
|
||||
{pnl?.cached ? "刷新 PnL" : "获取 PnL"}
|
||||
</Button>
|
||||
</div>
|
||||
{pnl?.cached ? (
|
||||
<>
|
||||
<PnlChart points={pnl.points} />
|
||||
<p className="muted">
|
||||
{pnl.points.length} 条记录 · 缺失数据保留为断点
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<Empty description="点击获取 PnL,从平台读取并缓存曲线数据" />
|
||||
)}
|
||||
</TabPane>
|
||||
<TabPane tab="研究记录" itemKey="research">
|
||||
<div className="detail-section">
|
||||
<p className="muted">
|
||||
这些记录只保存在你的工作空间,平台同步不会覆盖。
|
||||
</p>
|
||||
<label>
|
||||
研究状态
|
||||
<Select
|
||||
aria-label="研究状态"
|
||||
optionList={stateOptions}
|
||||
value={research.state}
|
||||
onChange={(state) => {
|
||||
dirty.current = true;
|
||||
setResearch({
|
||||
...research,
|
||||
state: state as ResearchState,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
标签
|
||||
<Input
|
||||
aria-label="研究标签"
|
||||
value={tagText}
|
||||
onChange={(value) => {
|
||||
dirty.current = true;
|
||||
setTagText(value);
|
||||
}}
|
||||
placeholder="以逗号分隔,例如 低换手, 分析师预期"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
研究备注
|
||||
<TextArea
|
||||
aria-label="研究备注"
|
||||
value={research.note}
|
||||
onChange={(note) => {
|
||||
dirty.current = true;
|
||||
setResearch({ ...research, note });
|
||||
}}
|
||||
rows={8}
|
||||
maxCount={20000}
|
||||
placeholder="记录假设、观察、下一步实验……"
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={research.favorite}
|
||||
onChange={(e) => {
|
||||
dirty.current = true;
|
||||
setResearch({
|
||||
...research,
|
||||
favorite: Boolean(e.target.checked),
|
||||
});
|
||||
}}
|
||||
>
|
||||
收藏此 Alpha
|
||||
</Checkbox>
|
||||
<div className="detail-save">
|
||||
<Button
|
||||
theme="solid"
|
||||
loading={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
保存研究记录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</SideSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ title, value }: { title: string; value: string }) {
|
||||
return (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<pre className="code-block">
|
||||
<code>{value}</code>
|
||||
</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function MetricTable({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, unknown>;
|
||||
}) {
|
||||
const entries = Object.entries(values).filter(([key]) => key !== "checks");
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h3>{title}</h3>
|
||||
{entries.length ? (
|
||||
<Descriptions
|
||||
data={entries.map(([key, value]) => ({
|
||||
key,
|
||||
value: displayValue(value),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<p className="muted">未提供</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Progress,
|
||||
SideSheet,
|
||||
Tag,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { api, formatTime, jobLabels, jobStateLabels, post } from "../api";
|
||||
import type { Job } from "../types";
|
||||
|
||||
export function JobPanel({
|
||||
visible,
|
||||
jobs,
|
||||
timezone,
|
||||
onClose,
|
||||
onChange,
|
||||
onAccount,
|
||||
}: {
|
||||
visible: boolean;
|
||||
jobs: Job[];
|
||||
timezone?: string;
|
||||
onClose: () => void;
|
||||
onChange: () => void;
|
||||
onAccount: () => void;
|
||||
}) {
|
||||
const [errors, setErrors] = useState<
|
||||
Record<string, { alpha_id: string; error: string }[]>
|
||||
>({});
|
||||
const [busy, setBusy] = useState("");
|
||||
async function action(job: Job, name: string) {
|
||||
setBusy(job.id);
|
||||
try {
|
||||
await post(`/sync-jobs/${job.id}/${name}`);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<SideSheet
|
||||
closeOnEsc
|
||||
className="job-panel"
|
||||
title="同步任务"
|
||||
visible={visible}
|
||||
width={520}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<p className="muted">同步进度保存在服务端,关闭此面板不会中断任务。</p>
|
||||
{!jobs.length && <Empty description="暂时没有同步任务" />}
|
||||
{jobs.map((job) => (
|
||||
<article className="job-card" key={job.id}>
|
||||
<div className="job-header">
|
||||
<strong>{jobLabels[job.kind] ?? job.kind}</strong>
|
||||
<Tag
|
||||
color={
|
||||
job.status === "completed"
|
||||
? "green"
|
||||
: ["failed", "completed_with_errors"].includes(job.status)
|
||||
? "red"
|
||||
: "blue"
|
||||
}
|
||||
>
|
||||
{jobStateLabels[job.status] ?? job.status}
|
||||
</Tag>
|
||||
</div>
|
||||
<p className="muted">{formatTime(job.created_at, timezone)}</p>
|
||||
<div className="job-numbers">
|
||||
<span>
|
||||
已处理 <b>{job.processed}</b>
|
||||
</span>
|
||||
<span>
|
||||
失败 <b>{job.failed}</b>
|
||||
</span>
|
||||
<span>
|
||||
总数 <b>{job.total ?? "计算中"}</b>
|
||||
</span>
|
||||
</div>
|
||||
{job.total !== null && job.total > 0 && (
|
||||
<Progress
|
||||
percent={Math.min(
|
||||
100,
|
||||
Math.round(((job.processed + job.failed) / job.total) * 100),
|
||||
)}
|
||||
showInfo={false}
|
||||
/>
|
||||
)}
|
||||
{job.error && <p className="error-text">{job.error}</p>}
|
||||
{job.next_retry_at && (
|
||||
<p className="muted">
|
||||
下次重试:{formatTime(job.next_retry_at, timezone)}
|
||||
</p>
|
||||
)}
|
||||
<div className="inline-actions">
|
||||
{[
|
||||
"queued",
|
||||
"running",
|
||||
"waiting_auth",
|
||||
"waiting_connection",
|
||||
].includes(job.status) && (
|
||||
<Button
|
||||
size="small"
|
||||
loading={busy === job.id}
|
||||
onClick={() => void action(job, "cancel")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
{["failed", "cancelled", "completed_with_errors"].includes(
|
||||
job.status,
|
||||
) && (
|
||||
<Button
|
||||
size="small"
|
||||
loading={busy === job.id}
|
||||
onClick={() => void action(job, "retry")}
|
||||
>
|
||||
重试未完成部分
|
||||
</Button>
|
||||
)}
|
||||
{["waiting_auth", "waiting_connection"].includes(job.status) && (
|
||||
<Button size="small" onClick={onAccount}>
|
||||
处理账户连接
|
||||
</Button>
|
||||
)}
|
||||
{job.failed > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
api<{ alpha_id: string; error: string }[]>(
|
||||
`/sync-jobs/${job.id}/errors`,
|
||||
)
|
||||
.then((rows) => setErrors({ ...errors, [job.id]: rows }))
|
||||
.catch((e) => Toast.error(e.message))
|
||||
}
|
||||
>
|
||||
查看失败记录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{errors[job.id]?.map((e) => (
|
||||
<p className="error-text" key={e.alpha_id}>
|
||||
{e.alpha_id}:{e.error}
|
||||
</p>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</SideSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Pnl } from "../types";
|
||||
|
||||
export function PnlChart({ points }: { points: Pnl["points"] }) {
|
||||
const values = points
|
||||
.map((p) => p.value)
|
||||
.filter((v): v is number => v !== null && Number.isFinite(v));
|
||||
if (!values.length)
|
||||
return <div className="chart-empty">暂无可绘制的 PnL 数据</div>;
|
||||
const low = Math.min(...values),
|
||||
high = Math.max(...values),
|
||||
range = high - low || Math.max(1, Math.abs(high) * 0.1);
|
||||
const x = (index: number) =>
|
||||
62 + (index / Math.max(1, points.length - 1)) * 610;
|
||||
const y = (v: number) => 24 + ((high - v) / range) * 180;
|
||||
let segments = "";
|
||||
let connected = false;
|
||||
points.forEach((point, i) => {
|
||||
if (point.value === null) {
|
||||
connected = false;
|
||||
return;
|
||||
}
|
||||
segments += `${connected ? " L" : " M"}${x(i)} ${y(point.value)}`;
|
||||
connected = true;
|
||||
});
|
||||
return (
|
||||
<svg
|
||||
className="pnl-chart"
|
||||
viewBox="0 0 700 240"
|
||||
role="img"
|
||||
aria-label={`PnL 曲线,共 ${points.length} 条记录`}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1="62"
|
||||
x2="672"
|
||||
y1={24 + i * 60}
|
||||
y2={24 + i * 60}
|
||||
stroke="var(--line)"
|
||||
/>
|
||||
<text x="52" y={28 + i * 60} textAnchor="end">
|
||||
{(high - (i * range) / 3).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<path d={segments} fill="none" stroke="var(--accent)" strokeWidth="2" />
|
||||
{points.length === 1 && points[0].value !== null && (
|
||||
<circle cx={x(0)} cy={y(points[0].value)} r="3" fill="var(--accent)" />
|
||||
)}
|
||||
<text x="62" y="230">
|
||||
{points[0]?.date.slice(0, 10)}
|
||||
</text>
|
||||
<text x="672" y="230" textAnchor="end">
|
||||
{points.at(-1)?.date.slice(0, 10)}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user