arbitrage-engine/frontend/components/StrategyForm.tsx

463 lines
18 KiB
TypeScript
Raw 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;
gate_obi_enabled: boolean;
obi_threshold: number;
gate_whale_enabled: boolean;
whale_cvd_threshold: number;
gate_vol_enabled: boolean;
atr_percentile_min: number;
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_obi_enabled: true,
obi_threshold: 0.3,
gate_whale_enabled: true,
whale_cvd_threshold: 0.0,
gate_vol_enabled: true,
atr_percentile_min: 20,
gate_spot_perp_enabled: false,
spot_perp_threshold: 0.002,
sl_atr_multiplier: 1.5,
tp1_ratio: 0.75,
tp2_ratio: 1.5,
timeout_minutes: 240,
flip_threshold: 80,
description: "",
};
// ─── 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 w-10 h-5 rounded-full transition-colors ${enabled ? "bg-blue-500" : "bg-slate-300"}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${enabled ? "translate-x-5" : "translate-x-0.5"}`} />
</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) => 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">
<GateRow
label="Gate 1订单簿失衡 (OBI)"
hint="要求订单簿方向与信号一致OBI越高越严格"
enabled={form.gate_obi_enabled}
onToggle={() => set("gate_obi_enabled", !form.gate_obi_enabled)}
>
<FieldLabel label="OBI 最低阈值" hint="0.1(宽松) ~ 0.9(严格)默认0.3" />
<NumberInput value={form.obi_threshold} onChange={(v) => set("obi_threshold", v)} min={0.1} max={0.9} step={0.05} />
</GateRow>
<GateRow
label="Gate 2大单 CVD 门"
hint="要求大单累积净买入方向与信号一致"
enabled={form.gate_whale_enabled}
onToggle={() => set("gate_whale_enabled", !form.gate_whale_enabled)}
>
<FieldLabel label="Whale CVD 阈值" hint="-1到1多头信号时大单CVD需>此值默认0" />
<NumberInput value={form.whale_cvd_threshold} onChange={(v) => set("whale_cvd_threshold", v)} min={-1} max={1} step={0.1} />
</GateRow>
<GateRow
label="Gate 3波动率门 (ATR%)"
hint="要求当前ATR百分位高于最低值过滤低波动时段"
enabled={form.gate_vol_enabled}
onToggle={() => set("gate_vol_enabled", !form.gate_vol_enabled)}
>
<FieldLabel label="ATR 最低百分位" hint="0-80默认20低于此值不开仓" />
<NumberInput value={form.atr_percentile_min} onChange={(v) => set("atr_percentile_min", v)} min={5} max={80} />
</GateRow>
<GateRow
label="Gate 4现货/永续溢价门"
hint="要求现货与永续价格溢价低于阈值,过滤套利异常时段"
enabled={form.gate_spot_perp_enabled}
onToggle={() => set("gate_spot_perp_enabled", !form.gate_spot_perp_enabled)}
>
<FieldLabel label="溢价率阈值" hint="0.0005~0.01,溢价超过此值不开仓" />
<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>
);
}