arbitrage-engine/frontend/components/StrategyForm.tsx

535 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import { ArrowLeft, Save, Info } from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface StrategyFormData {
display_name: string;
symbol: string;
direction: string;
initial_balance: number;
cvd_fast_window: string;
cvd_slow_window: string;
weight_direction: number;
weight_env: number;
weight_aux: number;
weight_momentum: number;
entry_score: number;
// 门1 波动率
gate_vol_enabled: boolean;
vol_atr_pct_min: number;
// 门2 CVD共振
gate_cvd_enabled: boolean;
// 门3 鲸鱼否决
gate_whale_enabled: boolean;
whale_usd_threshold: number;
whale_flow_pct: number;
// 门4 OBI否决
gate_obi_enabled: boolean;
obi_threshold: number;
// 门5 期现背离
gate_spot_perp_enabled: boolean;
spot_perp_threshold: number;
sl_atr_multiplier: number;
tp1_ratio: number;
tp2_ratio: number;
timeout_minutes: number;
flip_threshold: number;
description: string;
}
export const DEFAULT_FORM: StrategyFormData = {
display_name: "",
symbol: "BTCUSDT",
direction: "both",
initial_balance: 10000,
cvd_fast_window: "30m",
cvd_slow_window: "4h",
weight_direction: 55,
weight_env: 25,
weight_aux: 15,
weight_momentum: 5,
entry_score: 75,
gate_vol_enabled: true,
vol_atr_pct_min: 0.002,
gate_cvd_enabled: true,
gate_whale_enabled: true,
whale_usd_threshold: 50000,
whale_flow_pct: 0.5,
gate_obi_enabled: true,
obi_threshold: 0.35,
gate_spot_perp_enabled: false,
spot_perp_threshold: 0.005,
sl_atr_multiplier: 1.5,
tp1_ratio: 0.75,
tp2_ratio: 1.5,
timeout_minutes: 240,
flip_threshold: 80,
description: "",
};
// ─── Per-symbol 推荐值 ────────────────────────────────────────────────────────
// 来自 v53.json symbol_gates与 signal_engine.py 默认值保持一致
export const SYMBOL_RECOMMENDED: Record<string, Partial<StrategyFormData>> = {
BTCUSDT: {
vol_atr_pct_min: 0.002, // ATR需>价格0.2%
whale_usd_threshold: 100000, // 鲸鱼单>10万USD
whale_flow_pct: 0.5, // BTC鲸鱼流量>50%才否决
obi_threshold: 0.30, // OBI阈值宽松BTC流动性好
spot_perp_threshold: 0.003, // 期现溢价<0.3%
},
ETHUSDT: {
vol_atr_pct_min: 0.003, // ETH波动需更大
whale_usd_threshold: 50000,
whale_flow_pct: 0.5,
obi_threshold: 0.35,
spot_perp_threshold: 0.005,
},
SOLUSDT: {
vol_atr_pct_min: 0.004, // SOL波动更剧烈需更高阈值
whale_usd_threshold: 20000,
whale_flow_pct: 0.5,
obi_threshold: 0.45, // SOL OBI噪音多需更严
spot_perp_threshold: 0.008,
},
XRPUSDT: {
vol_atr_pct_min: 0.0025,
whale_usd_threshold: 30000,
whale_flow_pct: 0.5,
obi_threshold: 0.40,
spot_perp_threshold: 0.006,
},
};
export function applySymbolDefaults(form: StrategyFormData, symbol: string): StrategyFormData {
const rec = SYMBOL_RECOMMENDED[symbol] || SYMBOL_RECOMMENDED["BTCUSDT"];
return { ...form, symbol, ...rec };
}
// ─── Helper Components ────────────────────────────────────────────────────────
function FieldLabel({ label, hint }: { label: string; hint?: string }) {
return (
<div className="flex items-center gap-1 mb-1">
<span className="text-xs font-medium text-slate-600">{label}</span>
{hint && (
<span className="group relative">
<Info size={11} className="text-slate-400 cursor-help" />
<span className="hidden group-hover:block absolute left-4 top-0 z-10 w-48 text-[10px] bg-slate-800 text-white rounded px-2 py-1">
{hint}
</span>
</span>
)}
</div>
);
}
function NumberInput({
value, onChange, min, max, step = 1, disabled = false
}: {
value: number; onChange: (v: number) => void;
min: number; max: number; step?: number; disabled?: boolean;
}) {
return (
<input
type="number"
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!isNaN(v)) onChange(Math.min(max, Math.max(min, v)));
}}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300 disabled:bg-slate-50 disabled:text-slate-400"
/>
);
}
function SelectInput({
value, onChange, options
}: {
value: string; onChange: (v: string) => void;
options: { label: string; value: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300 bg-white"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
function GateRow({
label, hint, enabled, onToggle, children
}: {
label: string; hint: string; enabled: boolean; onToggle: () => void; children?: React.ReactNode;
}) {
return (
<div className={`border rounded-lg p-3 transition-colors ${enabled ? "border-blue-200 bg-blue-50/30" : "border-slate-200 bg-slate-50/50"}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-slate-700">{label}</span>
<span className="group relative">
<Info size={11} className="text-slate-400 cursor-help" />
<span className="hidden group-hover:block absolute left-4 top-0 z-10 w-52 text-[10px] bg-slate-800 text-white rounded px-2 py-1">
{hint}
</span>
</span>
</div>
<button
type="button"
onClick={onToggle}
className={`relative inline-flex items-center w-10 h-5 rounded-full transition-colors flex-shrink-0 ${enabled ? "bg-blue-500" : "bg-slate-300"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${enabled ? "translate-x-5" : "translate-x-0"}`} />
</button>
</div>
{enabled && <div className="mt-2">{children}</div>}
</div>
);
}
// ─── Main Form Component ──────────────────────────────────────────────────────
interface StrategyFormProps {
mode: "create" | "edit";
initialData: StrategyFormData;
strategyId?: string;
onSuccess?: (id: string) => void;
isBalanceEditable?: boolean;
}
export default function StrategyForm({ mode, initialData, strategyId, onSuccess, isBalanceEditable = true }: StrategyFormProps) {
useAuth();
const router = useRouter();
const [form, setForm] = useState<StrategyFormData>(initialData);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const set = <K extends keyof StrategyFormData>(key: K, value: StrategyFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const weightsTotal = form.weight_direction + form.weight_env + form.weight_aux + form.weight_momentum;
const weightsOk = weightsTotal === 100;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!weightsOk) {
setError(`权重合计必须等于 100当前为 ${weightsTotal}`);
return;
}
if (!form.display_name.trim()) {
setError("策略名称不能为空");
return;
}
setError("");
setSubmitting(true);
try {
const payload: Record<string, unknown> = { ...form };
if (!payload.description) delete payload.description;
let res: Response;
if (mode === "create") {
res = await authFetch("/api/strategies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
// Edit: only send changed fields (excluding symbol & initial_balance)
const { symbol: _s, initial_balance: _b, ...editPayload } = payload;
void _s; void _b;
res = await authFetch(`/api/strategies/${strategyId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editPayload),
});
}
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail?.[0]?.msg || err.detail || "请求失败");
}
const data = await res.json();
const newId = data.strategy?.strategy_id || strategyId || "";
if (onSuccess) onSuccess(newId);
else router.push(`/strategy-plaza/${newId}`);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "未知错误");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* ── 基础信息 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<FieldLabel label="策略名称" hint="自由命名最多50字符" />
<input
type="text"
value={form.display_name}
onChange={(e) => set("display_name", e.target.value)}
placeholder="例如我的BTC激进策略"
maxLength={50}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
</div>
<div>
<FieldLabel label="交易对" />
<SelectInput
value={form.symbol}
onChange={(v) => {
if (mode === "create") {
// 新建时切换币种自动填入推荐值
setForm((prev) => applySymbolDefaults(prev, v));
} else {
set("symbol", v);
}
}}
options={[
{ label: "BTC/USDT", value: "BTCUSDT" },
{ label: "ETH/USDT", value: "ETHUSDT" },
{ label: "SOL/USDT", value: "SOLUSDT" },
{ label: "XRP/USDT", value: "XRPUSDT" },
]}
/>
</div>
<div>
<FieldLabel label="交易方向" />
<SelectInput
value={form.direction}
onChange={(v) => set("direction", v)}
options={[
{ label: "多空双向", value: "both" },
{ label: "只做多", value: "long_only" },
{ label: "只做空", value: "short_only" },
]}
/>
</div>
<div>
<FieldLabel label="初始资金 (USDT)" hint="最少 1,000 USDT" />
<NumberInput
value={form.initial_balance}
onChange={(v) => set("initial_balance", v)}
min={1000}
max={1000000}
step={1000}
disabled={mode === "edit" && !isBalanceEditable}
/>
</div>
<div className="md:col-span-2">
<FieldLabel label="策略描述(可选)" />
<input
type="text"
value={form.description}
onChange={(e) => set("description", e.target.value)}
placeholder="简短描述这个策略的思路"
maxLength={200}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
</div>
</div>
</div>
{/* ── CVD 窗口 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">CVD </h3>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="快线 CVD" hint="短周期CVD捕捉近期买卖力量" />
<SelectInput
value={form.cvd_fast_window}
onChange={(v) => set("cvd_fast_window", v)}
options={[
{ label: "5m超短线", value: "5m" },
{ label: "15m短线", value: "15m" },
{ label: "30m中短线", value: "30m" },
]}
/>
</div>
<div>
<FieldLabel label="慢线 CVD" hint="长周期CVD反映趋势方向" />
<SelectInput
value={form.cvd_slow_window}
onChange={(v) => set("cvd_slow_window", v)}
options={[
{ label: "30m", value: "30m" },
{ label: "1h推荐", value: "1h" },
{ label: "4h趋势", value: "4h" },
]}
/>
</div>
</div>
</div>
{/* ── 四层权重 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-slate-700"></h3>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${weightsOk ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
: {weightsTotal}/100
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="方向得分权重" hint="CVD方向信号的权重建议50-65" />
<NumberInput value={form.weight_direction} onChange={(v) => set("weight_direction", v)} min={10} max={80} />
</div>
<div>
<FieldLabel label="环境得分权重" hint="市场环境OI/FR/资金费率)的权重" />
<NumberInput value={form.weight_env} onChange={(v) => set("weight_env", v)} min={5} max={60} />
</div>
<div>
<FieldLabel label="辅助因子权重" hint="清算/现货溢价等辅助信号的权重" />
<NumberInput value={form.weight_aux} onChange={(v) => set("weight_aux", v)} min={0} max={40} />
</div>
<div>
<FieldLabel label="动量权重" hint="短期价格动量信号的权重" />
<NumberInput value={form.weight_momentum} onChange={(v) => set("weight_momentum", v)} min={0} max={20} />
</div>
</div>
</div>
{/* ── 入场阈值 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="入场最低总分" hint="信号总分超过此值才开仓默认75越高越严格" />
<NumberInput value={form.entry_score} onChange={(v) => set("entry_score", v)} min={60} max={95} />
</div>
</div>
</div>
{/* ── 五道 Gate ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">Gate</h3>
<div className="space-y-3">
{/* 推荐值提示 */}
{(() => {
const rec = SYMBOL_RECOMMENDED[form.symbol];
if (!rec) return null;
return (
<div className="text-xs text-slate-500 bg-slate-50 rounded-lg px-3 py-2">
📌 {form.symbol.replace("USDT","")} ATR%{((rec.vol_atr_pct_min??0)*100).toFixed(2)}%${((rec.whale_usd_threshold??50000)/1000).toFixed(0)}kOBI阈值{rec.obi_threshold}
</div>
);
})()}
<GateRow
label="门1波动率门 (ATR%价格)"
hint="ATR占价格比例低于阈值时不开仓过滤低波动时段"
enabled={form.gate_vol_enabled}
onToggle={() => set("gate_vol_enabled", !form.gate_vol_enabled)}
>
<FieldLabel label="ATR% 最低阈值" hint={`推荐BTC=0.002, ETH=0.003, SOL=0.004, XRP=0.0025(当前${form.symbol.replace("USDT","")}推荐${SYMBOL_RECOMMENDED[form.symbol]?.vol_atr_pct_min??0.002}`} />
<NumberInput value={form.vol_atr_pct_min} onChange={(v) => set("vol_atr_pct_min", v)} min={0.0001} max={0.02} step={0.0005} />
</GateRow>
<GateRow
label="门2CVD共振方向门"
hint="要求快慢两条CVD同向双线共振否则视为无方向不开仓"
enabled={form.gate_cvd_enabled}
onToggle={() => set("gate_cvd_enabled", !form.gate_cvd_enabled)}
/>
<GateRow
label="门3鲸鱼否决门"
hint="检测大单方向ALT用USD金额阈值BTC用鲸鱼CVD流量比例"
enabled={form.gate_whale_enabled}
onToggle={() => set("gate_whale_enabled", !form.gate_whale_enabled)}
>
<FieldLabel label="大单USD阈值 (ALT)" hint={`推荐BTC=10万, ETH=5万, SOL=2万, XRP=3万当前推荐$${((SYMBOL_RECOMMENDED[form.symbol]?.whale_usd_threshold??50000)/1000).toFixed(0)}k`} />
<NumberInput value={form.whale_usd_threshold} onChange={(v) => set("whale_usd_threshold", v)} min={1000} max={1000000} step={5000} />
<FieldLabel label="鲸鱼CVD流量阈值 (BTC)" hint="0~1BTC鲸鱼净方向比例超过此值才否决推荐0.5" />
<NumberInput value={form.whale_flow_pct} onChange={(v) => set("whale_flow_pct", v)} min={0} max={1} step={0.05} />
</GateRow>
<GateRow
label="门4订单簿失衡门 (OBI)"
hint="要求订单簿方向与信号一致OBI绝对值超过阈值才否决反向信号"
enabled={form.gate_obi_enabled}
onToggle={() => set("gate_obi_enabled", !form.gate_obi_enabled)}
>
<FieldLabel label="OBI 否决阈值" hint={`推荐BTC=0.30(宽松), ETH=0.35, SOL=0.45(严格)(当前推荐${SYMBOL_RECOMMENDED[form.symbol]?.obi_threshold??0.35}`} />
<NumberInput value={form.obi_threshold} onChange={(v) => set("obi_threshold", v)} min={0.1} max={0.9} step={0.05} />
</GateRow>
<GateRow
label="门5期现背离门"
hint="要求现货与永续溢价低于阈值,过滤套利异常时段(默认关闭)"
enabled={form.gate_spot_perp_enabled}
onToggle={() => set("gate_spot_perp_enabled", !form.gate_spot_perp_enabled)}
>
<FieldLabel label="溢价率阈值" hint={`推荐BTC=0.003, ETH=0.005, SOL=0.008(当前推荐${SYMBOL_RECOMMENDED[form.symbol]?.spot_perp_threshold??0.005}`} />
<NumberInput value={form.spot_perp_threshold} onChange={(v) => set("spot_perp_threshold", v)} min={0.0005} max={0.01} step={0.0005} />
</GateRow>
</div>
</div>
{/* ── 风控参数 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="SL 宽度 (× ATR)" hint="止损距离 = SL倍数 × ATR默认1.5" />
<NumberInput value={form.sl_atr_multiplier} onChange={(v) => set("sl_atr_multiplier", v)} min={0.5} max={3.0} step={0.1} />
</div>
<div>
<FieldLabel label="TP1 目标 (× RD)" hint="第一止盈 = TP1倍数 × 风险距离默认0.75" />
<NumberInput value={form.tp1_ratio} onChange={(v) => set("tp1_ratio", v)} min={0.3} max={2.0} step={0.05} />
</div>
<div>
<FieldLabel label="TP2 目标 (× RD)" hint="第二止盈 = TP2倍数 × 风险距离默认1.5" />
<NumberInput value={form.tp2_ratio} onChange={(v) => set("tp2_ratio", v)} min={0.5} max={4.0} step={0.1} />
</div>
<div>
<FieldLabel label="超时时间 (分钟)" hint="持仓超过此时间自动平仓默认240min" />
<NumberInput value={form.timeout_minutes} onChange={(v) => set("timeout_minutes", v)} min={30} max={1440} step={30} />
</div>
<div>
<FieldLabel label="反转平仓阈值 (分)" hint="对手方向信号分≥此值时平仓默认80" />
<NumberInput value={form.flip_threshold} onChange={(v) => set("flip_threshold", v)} min={60} max={95} />
</div>
</div>
</div>
{/* ── Error & Submit ── */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-2 text-sm text-red-600">
{error}
</div>
)}
<div className="flex gap-3">
<Link
href="/strategy-plaza"
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-slate-200 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
>
<ArrowLeft size={15} />
</Link>
<button
type="submit"
disabled={submitting || !weightsOk}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Save size={15} />
{submitting ? "保存中..." : mode === "create" ? "创建并启动" : "保存参数"}
</button>
</div>
</form>
);
}