feat(web): 回测页统一作业反馈条(点了立刻有字 / 真实阶段 / 可取消)

用户反馈:「点击回测没有任何反馈,不清楚是不是已经开始回测」。

- 新增全站统一的 `useJobRunner`(`lib/jobs.ts`)与 `JobProgress` 组件:
  提交瞬间即显示「排队中 + 作业号 + 发起时间」,已用时间**每秒自增**(不依赖后端
  是否报新阶段),阶段来自后端真实 `stage`,排队/运行中可**取消任务**、失败直接显示
  后端原文、成功给「打开归档 / 去对比」。反馈条出现时自动滚入视野(按钮在长表单底部,
  不滚过去就等于没显示);`role="status" aria-live="polite"`。
- `/backtest` 改用它:删掉原先手写的阶段条与 stage/elapsed/jobId 三个 state,
  取消走 `POST /jobs/{id}/cancel`,取消后如实提示「已取消,没有归档」而不是报错。
- 顺带修掉「未命名组合点运行 → 撞 422」:运行是**临时组合不落库**,未填名字时按
  「未命名组合(仅本次运行)」提交并明确提示,不再把后端 422 原文甩给用户。
- 修掉反馈条里 `**加粗**` 字面残留(hint 不走 Markdown)。

验证(真实浏览器 CDP,实测数字):
- 勾选策略 → 点「运行回测组合」→ **+0.11s** 反馈条已显示 `排队中` + `JOB-8508F84D`
  + 发起于 18:05:28 + 已用 0s + 四个真实阶段 + 「取消任务」按钮;+4.5s 进入
  `加载行情与因子数据`;点取消 → 「正在取消…」→「已取消」并提示没有归档。
- 反馈条截图逐字核对(阶段、作业号、时间、按钮),并据此修掉 markdown 残留。
This commit is contained in:
Simon
2026-10-01 18:08:58 +08:00
parent 7e369d9680
commit e8fa67ad40
4 changed files with 465 additions and 34 deletions
+47 -34
View File
@@ -18,11 +18,12 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "rea
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
import { STAGE_LABEL, runSavedCombo, submitComboJob, waitJob } from "@/lib/jobs"; import { runSavedCombo, submitComboJob, useJobRunner } from "@/lib/jobs";
import { recentRange } from "@/lib/dates"; import { recentRange } from "@/lib/dates";
import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types"; import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types";
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui"; import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui";
import { BacktestResultView } from "@/components/BacktestResultView"; import { BacktestResultView } from "@/components/BacktestResultView";
import { JobProgress } from "@/components/JobProgress";
import { adjustLabel } from "@/lib/labels"; import { adjustLabel } from "@/lib/labels";
export default function BacktestPage() { export default function BacktestPage() {
@@ -61,9 +62,18 @@ function emptyDraft(range: { start: string; end: string }): Draft {
}; };
} }
function draftToCombo(d: Draft): BacktestCombo { /**
* 草稿 → 组合对象。
*
* `nameFallback`:**运行**(不保存)时组合名可以为空,但 `/combos/run` 的契约里
* `name` 必填 —— 老版本直接提交空名字,用户看到的是 422 原文
* (`String should have at least 1 character`),点了「运行」却像什么都没发生。
* 运行是临时组合、不会落库,所以这里给一个**如实说明「未命名/仅本次运行」**的名字,
* 而不是编一个像真的组合名。要正式命名请用「保存为回测组合」。
*/
function draftToCombo(d: Draft, nameFallback?: string): BacktestCombo {
return { return {
name: d.name.trim(), name: d.name.trim() || nameFallback || "",
description: d.description.trim(), description: d.description.trim(),
strategy_ids: d.strategyIds, strategy_ids: d.strategyIds,
initial_capital: d.initialCapital, initial_capital: d.initialCapital,
@@ -132,9 +142,12 @@ function BacktestInner() {
const [resultArchiveId, setResultArchiveId] = useState<string | null>(null); const [resultArchiveId, setResultArchiveId] = useState<string | null>(null);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [runningComboId, setRunningComboId] = useState<string | null>(null); const [runningComboId, setRunningComboId] = useState<string | null>(null);
const [jobId, setJobId] = useState(""); /**
const [stage, setStage] = useState(""); * 作业反馈统一交给 `useJobRunner`(全站同一套):提交瞬间就有「正在提交作业…」,
const [elapsed, setElapsed] = useState(0); * 之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消 —— 用户反馈的
* 「点了回测没有任何反馈,不知道有没有开始」就是这里的缺口。
*/
const job = useJobRunner<BacktestResult>("回测组合");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -225,7 +238,13 @@ function BacktestInner() {
}); });
return; return;
} }
await runVia(() => submitComboJob(draftToCombo(draft)), null); // 未命名时用「未命名组合(仅本次运行)」:临时组合不落库,只是给作业一个可读标签;
// 不让用户在点「运行」后撞上 422(那正是「点了没反应」的观感来源之一)。
const fallback = draft.name.trim() ? undefined : "未命名组合(仅本次运行)";
if (fallback) {
setNotice("本次运行未命名组合名 —— 按「未命名组合(仅本次运行)」提交(不会保存到组合库)。");
}
await runVia(() => submitComboJob(draftToCombo(draft, fallback)), null);
} }
/** /**
@@ -240,22 +259,24 @@ function BacktestInner() {
setRunningComboId(comboId); setRunningComboId(comboId);
setError(""); setError("");
setNotice(""); setNotice("");
setJobId("");
setResult(null); setResult(null);
setResultArchiveId(null); setResultArchiveId(null);
try { try {
const { job_id } = await submit(); const out = await job.run(submit, {
setJobId(job_id); label: draft.name ? `回测组合「${draft.name}」` : "回测组合",
setStage("queued");
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
setStage(info.stage);
setElapsed(info.elapsedMs);
}); });
if (out.status === "success" && out.result) { if (out.status === "success" && out.result) {
setResult(out.result); setResult(out.result as BacktestResult);
const exp = out.experimentId ?? null; const exp = out.experimentId ?? null;
setResultArchiveId(exp); setResultArchiveId(exp);
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。"); setNotice(
exp
? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。`
: "回测完成,已自动归档。",
);
} else if (out.status === "cancelled") {
// 主动取消不是错误:如实说明,并提示可重新运行
setNotice("已取消该回测任务(未产生结果)。可改完参数后重新运行。");
} else { } else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
} }
@@ -264,7 +285,6 @@ function BacktestInner() {
} finally { } finally {
setRunning(false); setRunning(false);
setRunningComboId(null); setRunningComboId(null);
setJobId("");
} }
} }
@@ -626,23 +646,16 @@ function BacktestInner() {
</div> </div>
</div> </div>
{running ? ( {/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消 */}
<div className="stages" style={{ marginTop: 12 }}> <JobProgress
{["data_loading", "backtesting", "analysis"].map((st) => { state={job.state}
const order = ["queued", "data_loading", "backtesting", "analysis", "done"]; onCancel={job.cancel}
const cur = order.indexOf(stage); onDismiss={job.reset}
const mine = order.indexOf(st); anchorId="backtest-job-progress"
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage"; />
return ( {job.state.phase === "idle" && running ? (
<span className={cls} key={st}> <div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
{mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st} 正在提交作业…(提交成功后会显示作业号与真实阶段,可随时取消)
</span>
);
})}
<span className="hint">
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用 {Math.round(elapsed / 1000)}s
(全市场多年区间约 3~5 分钟,可离开本页,结果会归档)
</span>
</div> </div>
) : null} ) : null}
</Card> </Card>
+201
View File
@@ -0,0 +1,201 @@
"use client";
/**
* 作业反馈条(全站统一)。
*
* 解决的问题(用户反馈原话):「点了回测没有任何反馈,不清楚是不是已经开始回测」。
* 因此这里有三条硬要求:
* 1. **点了立刻有字**:`submitting` 阶段就显示「正在提交作业…」,不等到后端返回;
* 2. **看得出在动**:作业号 + 真实阶段 + **每秒自增**的已用时间(阶段没变也在走);
* 3. **出事了能自救**:排队中可「取消」,失败显示后端原文,成功给「打开归档 / 去对比」。
*
* 反馈条出现时会自动滚到视野中央 —— 按钮常在长表单底部,反馈条在页面顶部,
* 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。
*/
import { useEffect, useRef } from "react";
import Link from "next/link";
import { Btn, Pill } from "@/components/ui";
import { Icon } from "@/components/icons";
import { STAGE_LABEL, STAGE_ORDER, type JobRunState } from "@/lib/jobs";
/** 已用时间:< 60s 显示秒,否则 mm:ss(跑几分钟时更好读) */
function fmtElapsed(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
}
function fmtClock(d: Date | null): string {
if (!d) return "";
return d.toLocaleTimeString("zh-CN", { hour12: false });
}
export function JobProgress<T>({
state,
onCancel,
onDismiss,
/** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */
anchorId = "job-progress",
}: {
state: JobRunState<T>;
onCancel?: () => void;
onDismiss?: () => void;
anchorId?: string;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const scrolled = useRef(false);
const active = state.phase === "submitting" || state.phase === "queued" || state.phase === "running";
// 提交瞬间(含同一页面第二次提交)把反馈条滚进视野:按钮在长表单底部时,
// 顶部出现的反馈条不滚过去就等于没显示。
useEffect(() => {
if (state.phase === "idle") {
scrolled.current = false;
return;
}
if ((state.phase === "submitting" || state.phase === "queued") && !scrolled.current) {
scrolled.current = true;
ref.current?.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [state.phase]);
if (state.phase === "idle") return null;
const stageText = state.stage
? state.stage === "cancelling"
? "正在取消…"
: (STAGE_LABEL[state.stage] ?? state.stage)
: "等待后端回报阶段";
const cur = STAGE_ORDER.indexOf(state.stage);
const archiveHref = state.experimentId
? `/experiments?exp=${encodeURIComponent(state.experimentId)}`
: null;
const tone =
state.phase === "success" ? "job-progress--ok" : state.phase === "failed" || state.phase === "timeout" ? "job-progress--bad" : "job-progress--run";
return (
<div
ref={ref}
id={anchorId}
className={`job-progress ${tone}`}
role="status"
aria-live="polite"
data-phase={state.phase}
>
<div className="job-progress__head">
{active ? (
<Pill tone="accent" icon="spinner">
{state.phase === "submitting" ? "正在提交作业…" : stageText}
</Pill>
) : state.phase === "success" ? (
<Pill tone="pos" icon="check">
已完成
</Pill>
) : state.phase === "cancelled" ? (
<Pill tone="warn" icon="x">
已取消
</Pill>
) : (
<Pill tone="neg" icon="alert">
{state.phase === "timeout" ? "等待超时" : "执行失败"}
</Pill>
)}
<span className="job-progress__title">
{state.label || "研究任务"}
{state.progress ? (
<span className="hint">
{" "}
(第 {state.progress.index}/{state.progress.total} 个)
</span>
) : null}
</span>
<span className="job-progress__meta">
{state.jobId ? (
<>
作业 <span className="mono">{state.jobId}</span>
</>
) : (
<span className="hint">正在换取作业号…</span>
)}
{state.startedAt ? <span className="hint">发起于 {fmtClock(state.startedAt)}</span> : null}
{active ? <span className="mono">已用 {fmtElapsed(state.elapsedMs)}</span> : null}
</span>
<span className="job-progress__actions">
{active && onCancel && state.jobId ? (
<Btn size="sm" icon="x" loading={state.cancelling} disabled={state.cancelling} onClick={onCancel}>
取消任务
</Btn>
) : null}
{!active && archiveHref ? (
<Link className="btn btn--sm" href={archiveHref}>
<span>去对比</span>
</Link>
) : null}
{!active && state.experimentId ? (
<Link
className="btn btn--sm"
href={`/experiments/${encodeURIComponent(state.experimentId)}`}
target="_blank"
rel="noreferrer"
>
<span>打开归档</span>
<Icon name="arrowUpRight" size={13} />
</Link>
) : null}
{!active && onDismiss ? (
<Btn size="sm" onClick={onDismiss}>
收起
</Btn>
) : null}
</span>
</div>
{active ? (
<div className="stages" style={{ marginTop: 8 }}>
{["data_loading", "selection", "backtesting", "analysis"].map((st) => {
const mine = STAGE_ORDER.indexOf(st);
const cls = cur >= 0 && mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
return (
<span className={cls} key={st}>
{cur >= 0 && mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
</span>
);
})}
</div>
) : null}
{active ? (
<div className="hint" style={{ marginTop: 6 }}>
后台执行中:可以离开本页(刷新 / 关页都不影响),跑完会自动归档到「实验对比」。
全市场多年区间通常 3~5 分钟;阶段与已用时间来自后端真实状态,不是假进度条。
</div>
) : null}
{state.phase === "failed" || state.phase === "timeout" ? (
<div className="job-progress__err" role="alert">
{state.error || "任务失败,后端没有给出原因。"}
</div>
) : null}
{state.phase === "cancelled" ? (
<div className="hint" style={{ marginTop: 6 }}>
任务已取消,没有归档(取消的任务不产生实验记录)。可以改完参数再跑一次。
</div>
) : null}
{state.phase === "success" ? (
<div className="hint" style={{ marginTop: 6 }}>
{state.experimentId
? `已归档为实验 ${state.experimentId},可在「实验对比」里与其它版本叠曲线、比参数。`
: "已完成(本次没有归档,可能是同步任务)。"}
</div>
) : null}
</div>
);
}
+216
View File
@@ -3,6 +3,8 @@
* 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行, * 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行,
* 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。 * 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。
*/ */
import { useCallback, useEffect, useRef, useState } from "react";
import { apiGet, apiPost } from "./api"; import { apiGet, apiPost } from "./api";
import type { ResearchSpec } from "./types"; import type { ResearchSpec } from "./types";
@@ -75,6 +77,19 @@ export const STAGE_LABEL: Record<string, string> = {
done: "完成", done: "完成",
}; };
/** 执行阶段时间线(顺序即推进顺序,用于「阶段小圆点」) */
export const STAGE_ORDER = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"];
/** 取消排队中/执行中的作业(POST /api/jobs/{id}/cancel;已结束的会返回 cancelled=false)。 */
export function cancelJob(
jobId: string,
): Promise<{ job_id: string; status: string; cancelled: boolean }> {
return apiPost<{ job_id: string; status: string; cancelled: boolean }>(
`/jobs/${encodeURIComponent(jobId)}/cancel`,
{},
);
}
/** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */ /** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
export async function submitComboJob(combo: unknown): Promise<JobSubmit> { export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
@@ -90,3 +105,204 @@ export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
export async function runSavedCombo(comboId: string): Promise<JobSubmit> { export async function runSavedCombo(comboId: string): Promise<JobSubmit> {
return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {}); return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {});
} }
/* ------------------------------------------------------------------ *
* 统一的「提交 → 轮询 → 终态」状态机(useJobRunner)
*
* 为什么要有它:以前每个页面各写一遍 running/jobId/error,结果参差不齐 ——
* 有的页面点了「运行」只在按钮上转圈,用户不知道到底提交没提交、跑到哪一步;
* 有的页面「已用秒数」只在后端阶段变化时才更新,看起来像卡死了(0s 一动不动)。
* 这里把状态、秒表、取消、终态收敛到一处,页面只管渲染。
* ------------------------------------------------------------------ */
export type JobPhase =
| "idle"
| "submitting"
| "queued"
| "running"
| "success"
| "failed"
| "cancelled"
| "timeout";
export interface JobRunState<T> {
phase: JobPhase;
/** 作业号(提交成功后立即拿到;这是「到底提交没提交」的凭据) */
jobId: string;
/** 后端上报的执行阶段(queued/data_loading/selection/backtesting/analysis/done) */
stage: string;
/** 已用毫秒:**每秒自增**,不依赖后端阶段变化,所以界面不会看起来卡住 */
elapsedMs: number;
/** 这次跑的是什么(如「因子测试 · momentum_60」),显示在反馈条上 */
label: string;
/** 批量场景的进度(第 index / total 个),单跑时为 null */
progress: { index: number; total: number } | null;
error: string;
result: T | null;
experimentId: string | null;
/** 提交时刻(本地时间,用于「发起于 hh:mm:ss」) */
startedAt: Date | null;
/** 正在请求取消 */
cancelling: boolean;
}
function emptyState<T>(label = ""): JobRunState<T> {
return {
phase: "idle",
jobId: "",
stage: "",
elapsedMs: 0,
label,
progress: null,
error: "",
result: null,
experimentId: null,
startedAt: null,
cancelling: false,
};
}
export interface JobRunOptions {
/** 反馈条上显示的任务名(如「因子测试 · momentum_60」) */
label?: string;
/** 批量进度(第 index / total 个) */
progress?: { index: number; total: number } | null;
timeoutMs?: number;
}
export interface JobRunner<T> {
state: JobRunState<T>;
/** 提交并轮询到终态;返回终态结果(供页面继续处理,如渲染报告) */
run: (
submit: () => Promise<{ job_id: string }>,
opts?: JobRunOptions,
) => Promise<JobOutcome<T>>;
cancel: () => Promise<void>;
reset: () => void;
}
/**
* 作业运行状态机。
*
* 生命周期:idle → submitting(已点击,正在换 job_id)→ queued/running(轮询中,秒表走)
* → success | failed | cancelled | timeout。**submitting 也是状态**:点了按钮立刻就有
* 反馈文字,不会出现「点了没反应」的空白期。
*/
export function useJobRunner<T>(defaultLabel = ""): JobRunner<T> {
const [state, setState] = useState<JobRunState<T>>(() => emptyState<T>(defaultLabel));
const t0 = useRef(0);
const timer = useRef<number | null>(null);
const alive = useRef(true);
const currentJob = useRef("");
// 后端最近上报的阶段放在 ref 里:终态那一帧要写「最后一个阶段」,但不想让
// `run` 依赖 state(否则每次 state 变化都换一个新函数,页面 effect 会连环触发)。
const stageRef = useRef("");
const stopTick = useCallback(() => {
if (timer.current !== null) {
window.clearInterval(timer.current);
timer.current = null;
}
}, []);
useEffect(
() => () => {
alive.current = false;
if (timer.current !== null) window.clearInterval(timer.current);
},
[],
);
const patch = useCallback((next: Partial<JobRunState<T>>) => {
if (!alive.current) return;
setState((prev) => ({ ...prev, ...next }));
}, []);
const reset = useCallback(() => {
stopTick();
currentJob.current = "";
if (alive.current) setState(emptyState<T>(defaultLabel));
}, [defaultLabel, stopTick]);
const run = useCallback(
async (
submit: () => Promise<{ job_id: string }>,
opts: JobRunOptions = {},
): Promise<JobOutcome<T>> => {
stopTick();
currentJob.current = "";
stageRef.current = "";
t0.current = Date.now();
setState({
...emptyState<T>(opts.label ?? defaultLabel),
phase: "submitting",
label: opts.label ?? defaultLabel,
progress: opts.progress ?? null,
startedAt: new Date(),
});
// 秒表独立于后端阶段:即使后端几秒内不报新阶段,「已用 Xs」也在动
timer.current = window.setInterval(() => {
if (!alive.current) return;
setState((prev) =>
prev.phase === "submitting" || prev.phase === "queued" || prev.phase === "running"
? { ...prev, elapsedMs: Date.now() - t0.current }
: prev,
);
}, 500);
try {
const { job_id } = await submit();
currentJob.current = job_id;
stageRef.current = "queued";
patch({ jobId: job_id, phase: "queued", stage: "queued" });
const out = await waitJob<T>(job_id, opts.timeoutMs ?? 900_000, (info) => {
stageRef.current = info.stage;
patch({
stage: info.stage,
elapsedMs: info.elapsedMs,
phase: info.stage && info.stage !== "queued" ? "running" : "queued",
});
});
const finalPhase: JobPhase =
out.status === "success"
? "success"
: out.status === "cancelled"
? "cancelled"
: out.status === "timeout"
? "timeout"
: "failed";
patch({
phase: finalPhase,
stage: out.status === "success" ? "done" : stageRef.current,
elapsedMs: Date.now() - t0.current,
result: out.result,
experimentId: out.experimentId ?? null,
error: out.error ?? "",
});
return out;
} catch (e) {
const msg = (e as Error).message;
patch({ phase: "failed", error: msg, elapsedMs: Date.now() - t0.current });
return { status: "failed", result: null, error: msg };
} finally {
stopTick();
}
},
[defaultLabel, patch, stopTick],
);
const cancel = useCallback(async () => {
const jobId = currentJob.current;
if (!jobId) return;
patch({ cancelling: true });
try {
await cancelJob(jobId);
// 取消后轮询会在 1.5s 内看到 cancelled 终态;这里先把阶段文字改掉,
// 让用户马上知道「取消请求已发出」而不是等下一个轮询周期。
patch({ stage: "cancelling" });
} finally {
patch({ cancelling: false });
}
}, [patch]);
return { state, run, cancel, reset };
}
+1
View File
@@ -234,6 +234,7 @@ export const REASON_LABELS: Record<string, string> = {
buy_skip_min_commission: "不足最低佣金", buy_skip_min_commission: "不足最低佣金",
sell_drop_topn: "跌出 TopN", sell_drop_topn: "跌出 TopN",
sell_force_tmax: "持有超 Tmax", sell_force_tmax: "持有超 Tmax",
sell_rebalance_full: "调仓换仓卖出",
sell_defer_tmin: "Tmin 保护暂留", sell_defer_tmin: "Tmin 保护暂留",
sell_defer_halted: "停牌未卖", sell_defer_halted: "停牌未卖",
sell_defer_limit_down: "跌停未卖", sell_defer_limit_down: "跌停未卖",