Add risk-based position sizing (以损定仓) with per-open k resize.

Manual vs risk modes are exclusive; each open floors k to 1 decimal so estimated premium+fees stay within the loss budget.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-29 23:32:42 +08:00
parent 0b51aa15ff
commit 3a4c8d639c
9 changed files with 803 additions and 16 deletions
+7
View File
@@ -323,6 +323,13 @@ export type StrategySettings = {
perp_qty_eth?: number;
option_qty_eth?: number;
show_manual_trade_buttons?: boolean;
sizing_mode?: "manual" | "risk_based";
risk_loss_mode?: "percent" | "absolute";
risk_loss_pct?: number;
risk_loss_usdt?: number;
risk_capital_source?: "trading_account" | "manual";
risk_manual_capital_usdt?: number;
risk_sizing_preview?: Record<string, unknown>;
exchange?: string;
};
+182 -8
View File
@@ -84,6 +84,19 @@ export default function SettingsPage() {
const [perpQty, setPerpQty] = useState(1);
const [optQty, setOptQty] = useState(2);
const [showManualTrade, setShowManualTrade] = useState(false);
const [sizingMode, setSizingMode] = useState<"manual" | "risk_based">("manual");
const [riskLossMode, setRiskLossMode] = useState<"percent" | "absolute">(
"percent",
);
const [riskLossPct, setRiskLossPct] = useState(1);
const [riskLossUsdt, setRiskLossUsdt] = useState(15);
const [riskCapitalSource, setRiskCapitalSource] = useState<
"trading_account" | "manual"
>("trading_account");
const [riskManualCapital, setRiskManualCapital] = useState(10000);
const [riskPreview, setRiskPreview] = useState<Record<string, unknown> | null>(
null,
);
const [initialEquity, setInitialEquity] = useState(10000);
const [exchange, setExchange] = useState<"okx" | "binance">("okx");
const [stratOk, setStratOk] = useState("");
@@ -173,6 +186,19 @@ export default function SettingsPage() {
setPerpQty(s.perp_qty_eth ?? 1);
setOptQty(s.option_qty_eth ?? 2);
setShowManualTrade(s.show_manual_trade_buttons === true);
setSizingMode(s.sizing_mode === "risk_based" ? "risk_based" : "manual");
setRiskLossMode(s.risk_loss_mode === "absolute" ? "absolute" : "percent");
setRiskLossPct(s.risk_loss_pct ?? 1);
setRiskLossUsdt(s.risk_loss_usdt ?? 15);
setRiskCapitalSource(
s.risk_capital_source === "manual" ? "manual" : "trading_account",
);
setRiskManualCapital(s.risk_manual_capital_usdt ?? 10000);
setRiskPreview(
s.risk_sizing_preview && typeof s.risk_sizing_preview === "object"
? s.risk_sizing_preview
: null,
);
setInitialEquity(s.initial_equity ?? 10000);
setExchange(s.exchange === "binance" ? "binance" : "okx");
})
@@ -302,8 +328,7 @@ export default function SettingsPage() {
try {
const body: Record<string, unknown> = {
fee_rate: fee,
exit_mode: exitMode,
net_profit_target: netTarget,
exit_mode: sizingMode === "risk_based" ? "fixed_usdt" : exitMode,
premium_exit_multiple: premMult,
rest_seconds: rest,
live_order_interval_sec: orderInterval,
@@ -317,19 +342,40 @@ export default function SettingsPage() {
fixed_direction_enabled: fixedDirOn,
fixed_perp_side: fixedPerpSide,
close_bid_mark_max_pct: closeDevPct,
perp_qty_eth: perpQty,
option_qty_eth: optQty,
show_manual_trade_buttons: showManualTrade,
sizing_mode: sizingMode,
risk_loss_mode: riskLossMode,
risk_loss_pct: riskLossPct,
risk_loss_usdt: riskLossUsdt,
risk_capital_source: riskCapitalSource,
risk_manual_capital_usdt: riskManualCapital,
exchange,
};
// 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422
if (sizingMode === "manual") {
body.net_profit_target = netTarget;
body.perp_qty_eth = perpQty;
body.option_qty_eth = optQty;
}
// LIVE 不改模拟资金,避免误重置本地账本
if (!isLive) {
body.initial_equity = initialEquity;
}
await apiFetch("/api/settings/strategy", {
const saved = await apiFetch<StrategySettings>("/api/settings/strategy", {
method: "PUT",
body: JSON.stringify(body),
});
if (saved?.risk_sizing_preview) {
setRiskPreview(saved.risk_sizing_preview);
}
if (saved?.perp_qty_eth != null) setPerpQty(saved.perp_qty_eth);
if (saved?.option_qty_eth != null) setOptQty(saved.option_qty_eth);
if (saved?.net_profit_target != null) setNetTarget(saved.net_profit_target);
if (saved?.exit_mode) {
setExitMode(
saved.exit_mode === "premium_multiple" ? "premium_multiple" : "fixed_usdt",
);
}
setStratOk(
isLive
? "策略参数已保存(实盘模式不改模拟资金)"
@@ -583,6 +629,125 @@ export default function SettingsPage() {
<option value="isolated"></option>
</select>
</div>
<div className="field">
<label htmlFor="sizingMode"></label>
<select
id="sizingMode"
className="mono"
value={sizingMode}
onChange={(e) =>
setSizingMode(
e.target.value === "risk_based"
? "risk_based"
: "manual",
)
}
>
<option value="manual"> ETH </option>
<option value="risk_based"></option>
</select>
</div>
{sizingMode === "risk_based" ? (
<>
<div className="field">
<label htmlFor="riskLossMode"></label>
<select
id="riskLossMode"
className="mono"
value={riskLossMode}
onChange={(e) =>
setRiskLossMode(
e.target.value === "absolute"
? "absolute"
: "percent",
)
}
>
<option value="percent"> %</option>
<option value="absolute">USDT</option>
</select>
</div>
{riskLossMode === "percent" ? (
<>
<div className="field">
<label htmlFor="riskCapSrc"></label>
<select
id="riskCapSrc"
className="mono"
value={riskCapitalSource}
onChange={(e) =>
setRiskCapitalSource(
e.target.value === "manual"
? "manual"
: "trading_account",
)
}
>
<option value="trading_account">
</option>
<option value="manual"></option>
</select>
</div>
{riskCapitalSource === "manual" ? (
<div className="field">
<label htmlFor="riskManCap">USDT</label>
<input
id="riskManCap"
className="mono"
type="number"
step="100"
min="1"
value={riskManualCapital}
onChange={(e) =>
setRiskManualCapital(Number(e.target.value))
}
/>
</div>
) : null}
<div className="field">
<label htmlFor="riskPct">%</label>
<input
id="riskPct"
className="mono"
type="number"
step="0.01"
min="0.01"
value={riskLossPct}
onChange={(e) =>
setRiskLossPct(Number(e.target.value))
}
/>
</div>
</>
) : (
<div className="field">
<label htmlFor="riskUsdt">USDT</label>
<input
id="riskUsdt"
className="mono"
type="number"
step="0.1"
min="0.1"
value={riskLossUsdt}
onChange={(e) =>
setRiskLossUsdt(Number(e.target.value))
}
/>
</div>
)}
<div className="field" style={{ gridColumn: "1 / -1" }}>
<label></label>
<div className="mono" style={{ fontSize: 12, opacity: 0.9 }}>
{riskPreview == null
? "—"
: riskPreview.ok === false
? String(riskPreview.detail || "预览不可用")
: `k=${String(riskPreview.k ?? "—")} · 预算=${String(riskPreview.budget ?? "—")}U · 估亏=${String(riskPreview.max_loss ?? "—")}U · 永续=${String(riskPreview.perp_qty_eth ?? "—")} · 期权=${String(riskPreview.option_qty_eth ?? "—")} · 出场=${String(riskPreview.net_profit_target ?? "—")}`}
</div>
</div>
</>
) : null}
<div className="field">
<label htmlFor="perp"> ETH </label>
<input
@@ -591,6 +756,7 @@ export default function SettingsPage() {
type="number"
step="0.01"
min="0.01"
disabled={sizingMode === "risk_based"}
value={perpQty}
onChange={(e) => setPerpQty(Number(e.target.value))}
/>
@@ -603,6 +769,7 @@ export default function SettingsPage() {
type="number"
step="0.01"
min="0.01"
disabled={sizingMode === "risk_based"}
value={optQty}
onChange={(e) => setOptQty(Number(e.target.value))}
/>
@@ -709,7 +876,10 @@ export default function SettingsPage() {
<select
id="exitMode"
className="mono"
value={exitMode}
disabled={sizingMode === "risk_based"}
value={
sizingMode === "risk_based" ? "fixed_usdt" : exitMode
}
onChange={(e) =>
setExitMode(
e.target.value === "premium_multiple"
@@ -722,15 +892,19 @@ export default function SettingsPage() {
<option value="premium_multiple"></option>
</select>
</div>
{exitMode === "fixed_usdt" ? (
{sizingMode === "risk_based" || exitMode === "fixed_usdt" ? (
<div className="field">
<label htmlFor="netTarget">USDT</label>
<label htmlFor="netTarget">
USDT
{sizingMode === "risk_based" ? " · 以损定仓=15×k" : ""}
</label>
<input
id="netTarget"
className="mono"
type="number"
step="0.1"
min="0.1"
disabled={sizingMode === "risk_based"}
value={netTarget}
onChange={(e) => setNetTarget(Number(e.target.value))}
/>