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
+17
View File
@@ -268,6 +268,16 @@ export type PlanState = {
max_atm_open_offset?: number;
fixed_direction_enabled?: boolean;
fixed_perp_side?: "long" | "short";
semi_auto_enabled?: boolean;
semi_armed?: boolean;
semi_view_side?: "long" | "short";
semi_option_side?: "call" | "put";
semi_perp_side?: "long" | "short";
semi_option_move_points?: number;
semi_perp_exit_unit?: number;
semi_min_option_hours?: number;
semi_min_option_leverage?: number;
semi_net_exit_target?: number;
can_open: boolean;
open_capacity?: {
leverage?: number;
@@ -404,6 +414,13 @@ export type StrategySettings = {
max_atm_open_offset?: number;
fixed_direction_enabled?: boolean;
fixed_perp_side?: "long" | "short";
semi_auto_enabled?: boolean;
semi_armed?: boolean;
semi_view_side?: "long" | "short";
semi_option_move_points?: number;
semi_perp_exit_unit?: number;
semi_min_option_hours?: number;
semi_min_option_leverage?: number;
close_bid_mark_max_pct?: number;
residual_min_premium_pct?: number;
residual_close_check_sec?: number;
+2
View File
@@ -28,6 +28,8 @@ const CLOSE_REASON_ZH: Record<string, string> = {
premium_multiple: "权利金倍数达标·双腿全平",
target_perp_only: "净盈利达标·只平永续(期权归档)",
residual_premium_close: "残留期权·权利金回收中途平",
semi_target_points: "半自动·标的到点且组合净利>0·双腿全平",
semi_perp_exit: "半自动·净利基数达标·双腿全平",
expiry: "到期结算",
emergency: "紧急全平",
manual: "手动平仓",
+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">
+22
View File
@@ -89,6 +89,7 @@ export default function SettingsPage() {
const [perpQty, setPerpQty] = useState(1);
const [optQty, setOptQty] = useState(2);
const [showManualTrade, setShowManualTrade] = useState(false);
const [semiAutoOn, setSemiAutoOn] = useState(false);
const [sizingMode, setSizingMode] = useState<"manual" | "risk_based">("manual");
const [riskLeverageBasis, setRiskLeverageBasis] = useState<
"actual" | "selection"
@@ -214,6 +215,7 @@ export default function SettingsPage() {
setPerpQty(s.perp_qty_eth ?? 1);
setOptQty(s.option_qty_eth ?? 2);
setShowManualTrade(s.show_manual_trade_buttons === true);
setSemiAutoOn(s.semi_auto_enabled === true);
setSizingMode(s.sizing_mode === "risk_based" ? "risk_based" : "manual");
setRiskLeverageBasis(
s.risk_leverage_basis === "actual" ? "actual" : "selection",
@@ -393,6 +395,7 @@ export default function SettingsPage() {
close_bid_mark_max_pct: closeDevPct,
residual_min_premium_pct: residualMinPremPct,
show_manual_trade_buttons: showManualTrade,
semi_auto_enabled: oo ? false : semiAutoOn,
sizing_mode: oo ? "risk_based" : sizingMode,
risk_leverage_basis: riskLeverageBasis,
risk_loss_mode: oo ? "percent" : riskLossMode,
@@ -1582,6 +1585,22 @@ export default function SettingsPage() {
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
{!isOo ? (
<div className="field">
<label htmlFor="semiAuto"></label>
<select
id="semiAuto"
className="mono"
value={semiAutoOn ? "on" : "off"}
onChange={(e) =>
setSemiAutoOn(e.target.value === "on")
}
>
<option value="off"></option>
<option value="on"></option>
</select>
</div>
) : null}
<div className="field">
<label htmlFor="rest"></label>
<input
@@ -1770,6 +1789,9 @@ export default function SettingsPage() {
) : null}
{stratSub === "pace" ? (
<>
<li>
/
</li>
<li>
LIVE / 1s
0.230
+9
View File
@@ -459,6 +459,15 @@ input {
margin-bottom: 12px;
}
.plan-semi-card {
margin-bottom: 12px;
border-color: rgba(94, 200, 255, 0.35);
}
.plan-semi-card .plan-panel-title {
color: #5ec8ff;
}
@media (max-width: 900px) {
.app-container {
padding: 0 12px calc(72px + env(safe-area-inset-bottom, 0px));