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:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user