Add 永期半自动: human arm, machine open/close, then stop.

Settings toggle, Plan panel, Fleet monitor, exit locks and armed TOCTOU gates; docs and dual audits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 11:20:11 +08:00
parent bf3441537e
commit 4585dba3c3
22 changed files with 1147 additions and 44 deletions
+216 -11
View File
@@ -82,6 +82,7 @@ function fmtHoldDuration(openAtMs: number | null | undefined, now: number) {
const PHASE_ZH: Record<string, string> = {
idle: "空闲",
wait_signal: "等待信号",
wait_human: "等待人工授权",
opening: "开仓中",
open: "持仓中",
closing: "平仓中",
@@ -111,6 +112,12 @@ export default function PlanPage() {
const [busy, setBusy] = useState("");
const [residualBusy, setResidualBusy] = useState("");
const [nowMs, setNowMs] = useState(() => Date.now());
const [semiView, setSemiView] = useState<"long" | "short">("long");
const [semiMove, setSemiMove] = useState(50);
const [semiExitU, setSemiExitU] = useState(5);
const [semiMinH, setSemiMinH] = useState(30);
const [semiMinLev, setSemiMinLev] = useState(100);
const [semiDirty, setSemiDirty] = useState(false);
async function refresh() {
try {
@@ -120,12 +127,71 @@ export default function PlanPage() {
]);
setSnap(m);
setPlan(p);
if (!semiDirty) {
setSemiView(p.semi_view_side === "short" ? "short" : "long");
setSemiMove(Number(p.semi_option_move_points ?? 50));
setSemiExitU(Number(p.semi_perp_exit_unit ?? 5));
setSemiMinH(Number(p.semi_min_option_hours ?? 30));
setSemiMinLev(Number(p.semi_min_option_leverage ?? 100));
}
setErr("");
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
}
async function saveSemiParams() {
setBusy("semi-save");
setErr("");
try {
const p = await apiFetch<PlanState>("/api/plan/semi/params", {
method: "PUT",
body: JSON.stringify({
semi_view_side: semiView,
semi_option_move_points: semiMove,
semi_perp_exit_unit: semiExitU,
semi_min_option_hours: semiMinH,
semi_min_option_leverage: semiMinLev,
}),
});
setPlan(p);
setSemiDirty(false);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy("");
}
}
async function armSemi(armed: boolean) {
setBusy(armed ? "semi-arm" : "semi-disarm");
setErr("");
try {
if (semiDirty) {
await apiFetch<PlanState>("/api/plan/semi/params", {
method: "PUT",
body: JSON.stringify({
semi_view_side: semiView,
semi_option_move_points: semiMove,
semi_perp_exit_unit: semiExitU,
semi_min_option_hours: semiMinH,
semi_min_option_leverage: semiMinLev,
}),
});
setSemiDirty(false);
}
const p = await apiFetch<PlanState>("/api/plan/semi/arm", {
method: "POST",
body: JSON.stringify({ armed }),
});
setPlan(p);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy("");
}
}
useEffect(() => {
refresh();
const t = window.setInterval(refresh, 1500);
@@ -171,26 +237,36 @@ export default function PlanPage() {
}
const bias = snap?.ask_compare?.bias;
const biasTag =
bias === "strike_below_spot" ||
bias === "call_ask_gt_put" ||
bias === "fixed_short_call" ? (
const pos = plan?.position;
const open = !!pos?.has_position;
// 持仓中展示本组成交方向,勿用监控 ATM 的实时盘口信号(会漂)
const heldOpt = String(pos?.option_side || "").toLowerCase();
const heldPerp = String(pos?.perp_side || "").toLowerCase();
const biasTag = open ? (
heldOpt === "call" || heldPerp === "short" ? (
<span className="tag up"> Call + </span>
) : bias === "strike_above_spot" ||
bias === "put_ask_gt_call" ||
bias === "fixed_long_put" ? (
) : heldOpt === "put" || heldPerp === "long" ? (
<span className="tag down"> Put + </span>
) : (
<span className="tag"> / </span>
);
<span className="tag"></span>
)
) : bias === "strike_below_spot" ||
bias === "call_ask_gt_put" ||
bias === "fixed_short_call" ? (
<span className="tag up"> Call + </span>
) : bias === "strike_above_spot" ||
bias === "put_ask_gt_call" ||
bias === "fixed_long_put" ? (
<span className="tag down"> Put + </span>
) : (
<span className="tag"> / </span>
);
const pos = plan?.position;
const isOo =
plan?.hedge_mode === "option_option" ||
snap?.hedge_mode === "option_option" ||
pos?.hedge_mode === "option_option" ||
!!pos?.option2_inst_id;
const open = !!pos?.has_position;
const exitMode = plan?.exit_mode ?? "fixed_usdt";
const riskBased =
plan?.risk_based === true || plan?.sizing_mode === "risk_based";
@@ -488,6 +564,135 @@ export default function PlanPage() {
{busy ? <span className="meta">{busy}</span> : null}
</div>
{plan?.semi_auto_enabled && !isOo ? (
<section className="card plan-semi-card" aria-label="半自动本单">
<h3 className="plan-panel-title"> · </h3>
<p className="meta" style={{ marginTop: 0 }}>
/ ±&gt;0×k
</p>
<div className="settings-fields" style={{ marginTop: 8 }}>
<div className="field">
<label htmlFor="semiView"></label>
<select
id="semiView"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiView}
onChange={(e) => {
setSemiView(e.target.value === "short" ? "short" : "long");
setSemiDirty(true);
}}
>
<option value="long"> · Call + </option>
<option value="short"> · Put + </option>
</select>
</div>
<div className="field">
<label htmlFor="semiMove"> · </label>
<input
id="semiMove"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMove}
onChange={(e) => {
setSemiMove(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiExitU">U×k</label>
<input
id="semiExitU"
className="mono"
type="number"
step="0.1"
min="0.1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiExitU}
onChange={(e) => {
setSemiExitU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinH"> · h</label>
<input
id="semiMinH"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinH}
onChange={(e) => {
setSemiMinH(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinLev"> · </label>
<input
id="semiMinLev"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinLev}
onChange={(e) => {
setSemiMinLev(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
</div>
<div className="mono meta" style={{ marginTop: 8 }}>
{(() => {
const idx = snap?.index_px;
if (idx == null || !Number.isFinite(Number(idx))) {
return `目标净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · 指数 —`;
}
const n = Number(idx);
const tgt =
semiView === "long" ? n + Number(semiMove) : n - Number(semiMove);
return `指数 ${fmtExPx("index", n)} → 到点 ${fmtExPx("index", tgt)} · 净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · ${plan.semi_armed ? "已授权盯开" : "未授权"}`;
})()}
</div>
<div className="plan-actions" style={{ marginTop: 10, paddingTop: 0 }}>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !semiDirty}
onClick={() => void saveSemiParams()}
>
</button>
<button
className="btn"
type="button"
disabled={open || !!busy || !!plan.semi_armed}
onClick={() => void armSemi(true)}
>
</button>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !plan.semi_armed}
onClick={() => void armSemi(false)}
>
</button>
</div>
</section>
) : null}
<div className="plan-shell">
<div className="plan-board">
<div className="card plan-strategy">