463 lines
18 KiB
TypeScript
463 lines
18 KiB
TypeScript
"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 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) => 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>
|
||
);
|
||
}
|