feat: improve strategy studio and fix trader deletion bug

- Add strategy export/import functionality to Strategy Studio
- Fix trader deletion not removing from memory (competition page ghost data)
- Simplify TraderConfigViewModal: remove unused fields, show strategy name
- Improve quant data formatting: OI/Netflow multi-timeframe display
- Add configurable OI/Netflow toggles in indicator settings
- Clean up unused frontend components and dead code
This commit is contained in:
tinkle-community
2025-12-09 16:46:58 +08:00
parent 48792907b3
commit 9fa2432705
20 changed files with 258 additions and 2348 deletions
+3
View File
@@ -777,6 +777,9 @@ func (s *Server) handleDeleteTrader(c *gin.Context) {
}
}
// Remove trader from memory
s.traderManager.RemoveTrader(traderID)
logger.Infof("✓ Trader deleted: %s", traderID)
c.JSON(http.StatusOK, gin.H{"message": "Trader deleted"})
}
+67 -46
View File
@@ -179,11 +179,11 @@ func (e *StrategyEngine) FetchExternalData() (map[string]interface{}, error) {
// QuantData quantitative data structure (fund flow, position changes, price changes)
type QuantData struct {
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Netflow *NetflowData `json:"netflow,omitempty"`
OI map[string]*OIData `json:"oi,omitempty"`
PriceChange map[string]float64 `json:"price_change,omitempty"`
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Netflow *NetflowData `json:"netflow,omitempty"`
OI map[string]*OIData `json:"oi,omitempty"`
PriceChange map[string]float64 `json:"price_change,omitempty"`
}
type NetflowData struct {
@@ -197,9 +197,9 @@ type FlowTypeData struct {
}
type OIData struct {
CurrentOI float64 `json:"current_oi"`
NetLong float64 `json:"net_long"`
NetShort float64 `json:"net_short"`
CurrentOI float64 `json:"current_oi"`
NetLong float64 `json:"net_long"`
NetShort float64 `json:"net_short"`
Delta map[string]*OIDeltaData `json:"delta,omitempty"`
}
@@ -242,7 +242,7 @@ func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
// Parse response
var apiResp struct {
Code int `json:"code"`
Code int `json:"code"`
Data *QuantData `json:"data"`
}
@@ -285,84 +285,85 @@ func (e *StrategyEngine) formatQuantData(data *QuantData) string {
return ""
}
indicators := e.config.Indicators
// If both OI and Netflow are disabled, return empty
if !indicators.EnableQuantOI && !indicators.EnableQuantNetflow {
return ""
}
var sb strings.Builder
sb.WriteString("📊 Quantitative Data:\n")
// Price changes
// Price changes (API returns decimals, multiply by 100 for percentage)
if len(data.PriceChange) > 0 {
sb.WriteString("Price Change: ")
timeframes := []string{"5m", "15m", "1h", "4h", "24h"}
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
parts := []string{}
for _, tf := range timeframes {
if v, ok := data.PriceChange[tf]; ok {
parts = append(parts, fmt.Sprintf("%s: %+.2f%%", tf, v))
parts = append(parts, fmt.Sprintf("%s: %+.4f%%", tf, v*100))
}
}
sb.WriteString(strings.Join(parts, " | "))
sb.WriteString("\n")
}
// Fund flow
if data.Netflow != nil {
sb.WriteString("Fund Flow (USDT):\n")
// Fund flow (Netflow) - only show if enabled
if indicators.EnableQuantNetflow && data.Netflow != nil {
sb.WriteString("Fund Flow (Netflow):\n")
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
// Institutional funds
if data.Netflow.Institution != nil {
if data.Netflow.Institution.Future != nil {
sb.WriteString(" Institutional Futures: ")
parts := []string{}
for _, tf := range []string{"1h", "4h", "24h"} {
if data.Netflow.Institution.Future != nil && len(data.Netflow.Institution.Future) > 0 {
sb.WriteString(" Institutional Futures:\n")
for _, tf := range timeframes {
if v, ok := data.Netflow.Institution.Future[tf]; ok {
parts = append(parts, fmt.Sprintf("%s: %+.0f", tf, v))
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
}
}
sb.WriteString(strings.Join(parts, " | "))
sb.WriteString("\n")
}
if data.Netflow.Institution.Spot != nil {
sb.WriteString(" Institutional Spot: ")
parts := []string{}
for _, tf := range []string{"1h", "4h", "24h"} {
if data.Netflow.Institution.Spot != nil && len(data.Netflow.Institution.Spot) > 0 {
sb.WriteString(" Institutional Spot:\n")
for _, tf := range timeframes {
if v, ok := data.Netflow.Institution.Spot[tf]; ok {
parts = append(parts, fmt.Sprintf("%s: %+.0f", tf, v))
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
}
}
sb.WriteString(strings.Join(parts, " | "))
sb.WriteString("\n")
}
}
// Retail funds
if data.Netflow.Personal != nil {
if data.Netflow.Personal.Future != nil {
sb.WriteString(" Retail Futures: ")
parts := []string{}
for _, tf := range []string{"1h", "4h", "24h"} {
if data.Netflow.Personal.Future != nil && len(data.Netflow.Personal.Future) > 0 {
sb.WriteString(" Retail Futures:\n")
for _, tf := range timeframes {
if v, ok := data.Netflow.Personal.Future[tf]; ok {
parts = append(parts, fmt.Sprintf("%s: %+.0f", tf, v))
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
}
}
}
if data.Netflow.Personal.Spot != nil && len(data.Netflow.Personal.Spot) > 0 {
sb.WriteString(" Retail Spot:\n")
for _, tf := range timeframes {
if v, ok := data.Netflow.Personal.Spot[tf]; ok {
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
}
}
sb.WriteString(strings.Join(parts, " | "))
sb.WriteString("\n")
}
}
}
// Position data
if len(data.OI) > 0 {
// Open Interest (OI) - only show if enabled
if indicators.EnableQuantOI && len(data.OI) > 0 {
for exchange, oiData := range data.OI {
sb.WriteString(fmt.Sprintf("Open Interest (%s): Current %.2f | Long %.2f Short %.2f\n",
exchange, oiData.CurrentOI, oiData.NetLong, oiData.NetShort))
if len(oiData.Delta) > 0 {
sb.WriteString(" OI Change: ")
parts := []string{}
for _, tf := range []string{"1h", "4h", "24h"} {
sb.WriteString(fmt.Sprintf("Open Interest (%s):\n", exchange))
for _, tf := range []string{"5m", "15m", "1h", "4h", "12h", "24h"} {
if d, ok := oiData.Delta[tf]; ok {
parts = append(parts, fmt.Sprintf("%s: %+.2f%%", tf, d.OIDeltaPercent))
sb.WriteString(fmt.Sprintf(" %s: %+.4f%% (%s)\n", tf, d.OIDeltaPercent, formatFlowValue(d.OIDeltaValue)))
}
}
sb.WriteString(strings.Join(parts, " | "))
sb.WriteString("\n")
}
}
}
@@ -760,6 +761,26 @@ func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *ma
sb.WriteString("\n")
}
// formatFlowValue formats flow value with M/K units
func formatFlowValue(v float64) string {
sign := ""
if v >= 0 {
sign = "+"
}
absV := v
if absV < 0 {
absV = -absV
}
if absV >= 1e9 {
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
} else if absV >= 1e6 {
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
} else if absV >= 1e3 {
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
}
return fmt.Sprintf("%s%.2f", sign, v)
}
// formatFloatSlice formats float slice
func formatFloatSlice(values []float64) string {
strValues := make([]string, len(values))
+8 -4
View File
@@ -94,8 +94,10 @@ type IndicatorConfig struct {
// external data sources
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
// quantitative data sources (capital flow, position changes, price changes)
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
QuantDataAPIURL string `json:"quant_data_api_url,omitempty"` // quantitative data API address
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
QuantDataAPIURL string `json:"quant_data_api_url,omitempty"` // quantitative data API address
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
}
// KlineConfig K-line configuration
@@ -216,8 +218,10 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
EMAPeriods: []int{20, 50},
RSIPeriods: []int{7, 14},
ATRPeriods: []int{14},
EnableQuantData: true,
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
EnableQuantData: true,
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
EnableQuantOI: true,
EnableQuantNetflow: true,
},
RiskControl: RiskControlConfig{
MaxPositions: 3,
+15 -107
View File
@@ -15,10 +15,6 @@ import { getExchangeIcon } from './ExchangeIcons'
import { getModelIcon } from './ModelIcons'
import { TraderConfigModal } from './TraderConfigModal'
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
import {
TwoStageKeyModal,
type TwoStageKeyModalResult,
} from './TwoStageKeyModal'
import {
WebCryptoEnvironmentCheck,
type WebCryptoCheckStatus,
@@ -1603,11 +1599,6 @@ function ExchangeConfigModal({
const [lighterPrivateKey, setLighterPrivateKey] = useState('')
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
// 安全输入状态
const [secureInputTarget, setSecureInputTarget] = useState<
null | 'hyperliquid' | 'aster' | 'lighter'
>(null)
// 获取当前编辑的交易所信息
const selectedExchange = allExchanges?.find(
(e) => e.id === selectedExchangeId
@@ -1705,44 +1696,6 @@ function ExchangeConfigModal({
}
}
// 安全输入处理函数
const secureInputContextLabel =
secureInputTarget === 'aster'
? t('asterExchangeName', language)
: secureInputTarget === 'hyperliquid'
? t('hyperliquidExchangeName', language)
: undefined
const handleSecureInputCancel = () => {
setSecureInputTarget(null)
}
const handleSecureInputComplete = ({
value,
obfuscationLog,
}: TwoStageKeyModalResult) => {
const trimmed = value.trim()
if (secureInputTarget === 'hyperliquid') {
setApiKey(trimmed)
}
if (secureInputTarget === 'aster') {
setAsterPrivateKey(trimmed)
}
console.log('Secure input obfuscation log:', obfuscationLog)
setSecureInputTarget(null)
}
// 掩盖敏感数据显示
const maskSecret = (secret: string) => {
if (!secret || secret.length === 0) return ''
if (secret.length <= 8) return '*'.repeat(secret.length)
return (
secret.slice(0, 4) +
'*'.repeat(Math.max(secret.length - 8, 4)) +
secret.slice(-4)
)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedExchangeId) return
@@ -2328,58 +2281,22 @@ function ExchangeConfigModal({
>
{t('hyperliquidAgentPrivateKey', language)}
</label>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<input
type="text"
value={maskSecret(apiKey)}
readOnly
placeholder={t(
'enterHyperliquidAgentPrivateKey',
language
)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<button
type="button"
onClick={() => setSecureInputTarget('hyperliquid')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#F0B90B',
color: '#000',
whiteSpace: 'nowrap',
}}
>
{apiKey
? t('secureInputReenter', language)
: t('secureInputButton', language)}
</button>
{apiKey && (
<button
type="button"
onClick={() => setApiKey('')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#1B1F2B',
color: '#848E9C',
whiteSpace: 'nowrap',
}}
>
{t('secureInputClear', language)}
</button>
)}
</div>
{apiKey && (
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('secureInputHint', language)}
</div>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t(
'enterHyperliquidAgentPrivateKey',
language
)}
</div>
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
<div
className="text-xs mt-1"
style={{ color: '#848E9C' }}
@@ -2605,15 +2522,6 @@ function ExchangeConfigModal({
</div>
)}
{/* Two Stage Key Modal */}
<TwoStageKeyModal
isOpen={secureInputTarget !== null}
language={language}
contextLabel={secureInputContextLabel}
expectedLength={64}
onCancel={handleSecureInputCancel}
onComplete={handleSecureInputComplete}
/>
</div>
)
}
-136
View File
@@ -1,136 +0,0 @@
import * as React from 'react'
import { motion } from 'framer-motion'
import { Check } from 'lucide-react'
import { cn } from '../lib/utils'
interface CryptoFeatureCardProps {
icon: React.ReactNode
title: string
description: string
features: string[]
className?: string
delay?: number
}
export const CryptoFeatureCard = React.forwardRef<
HTMLDivElement,
CryptoFeatureCardProps
>(({ icon, title, description, features, className, delay = 0 }, ref) => {
const [isHovered, setIsHovered] = React.useState(false)
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
className="relative h-full"
>
<div
className={cn(
'relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl',
'bg-gradient-to-br from-[#000000] to-[#0A0A0A]',
'border-[#1A1A1A] hover:border-[#F0B90B]/50',
isHovered && 'shadow-[0_0_20px_rgba(240,185,11,0.2)]',
className
)}
>
{/* Animated glow border effect */}
<motion.div
className="absolute inset-0 opacity-0 pointer-events-none"
animate={{
opacity: isHovered ? 1 : 0,
}}
transition={{ duration: 0.3 }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
</motion.div>
{/* Background pattern */}
<div className="absolute inset-0 opacity-5">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
backgroundSize: '32px 32px',
}}
/>
</div>
<div className="relative z-10 p-8 flex flex-col h-full">
{/* Icon container */}
<motion.div
className="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl"
style={{
background:
'linear-gradient(135deg, rgba(240, 185, 11, 0.2) 0%, rgba(240, 185, 11, 0.05) 100%)',
border: '1px solid rgba(240, 185, 11, 0.3)',
}}
animate={{
scale: isHovered ? 1.1 : 1,
boxShadow: isHovered
? '0 0 20px rgba(240, 185, 11, 0.4)'
: '0 0 0px rgba(240, 185, 11, 0)',
}}
transition={{ duration: 0.3 }}
>
<div style={{ color: 'var(--brand-yellow)' }}>{icon}</div>
</motion.div>
{/* Title */}
<h3
className="text-2xl font-bold mb-3"
style={{ color: 'var(--brand-light-gray)' }}
>
{title}
</h3>
{/* Description */}
<p
className="mb-6 flex-grow leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{description}
</p>
{/* Features list */}
<div className="space-y-3 mb-6">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: delay + index * 0.1 }}
className="flex items-start gap-3"
>
<div className="mt-0.5 flex-shrink-0">
<div
className="w-5 h-5 rounded-full flex items-center justify-center"
style={{ background: 'rgba(240, 185, 11, 0.2)' }}
>
<Check
className="w-3 h-3"
style={{ color: 'var(--brand-yellow)' }}
/>
</div>
</div>
<span
className="text-sm"
style={{ color: 'var(--brand-light-gray)' }}
>
{feature}
</span>
</motion.div>
))}
</div>
</div>
</div>
</motion.div>
)
})
CryptoFeatureCard.displayName = 'CryptoFeatureCard'
-116
View File
@@ -1,116 +0,0 @@
/// <reference types="vite/client" />
import { useState } from 'react'
import { confirmToast, notify } from '../lib/notify'
const toastOptions = [
'message',
'success',
'info',
'warning',
'error',
'custom',
] as const
type ToastType = (typeof toastOptions)[number]
const customRenderer = () => (
<div className="dev-custom-toast">
<p className="dev-custom-title">Sonner </p>
<p className="dev-custom-body">
`notify.custom` Toast
</p>
</div>
)
export function DevToastController() {
const [type, setType] = useState<ToastType>('success')
const [message, setMessage] = useState('来自 Dev 控制器的测试通知')
const [duration, setDuration] = useState(2200)
if (!import.meta.env.DEV) {
return null
}
const triggerToast = async () => {
switch (type) {
case 'message':
notify.message(message, { duration })
break
case 'success':
notify.success(message, { duration })
break
case 'info':
notify.info(message, { duration })
break
case 'warning':
notify.warning(message, { duration })
break
case 'error':
notify.error(message, { duration })
break
case 'custom':
notify.custom(() => customRenderer(), { duration })
break
}
}
const triggerConfirm = async () => {
const confirmed = await confirmToast(message, {
okText: '继续',
cancelText: '取消',
})
if (confirmed) {
notify.success('确认按钮已点击', { duration: 2000 })
} else {
notify.message('已取消确认逻辑', { duration: 2000 })
}
}
return (
<div className="dev-toast-controller">
<div className="dev-toast-controller__header">
<span>Dev Sonner </span>
<small> dev </small>
</div>
<div className="dev-toast-controller__content">
<label className="dev-toast-controller__label">
<select
value={type}
onChange={(event) => setType(event.target.value as ToastType)}
>
{toastOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="dev-toast-controller__label">
<input
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="输入通知/确认文案"
/>
</label>
<label className="dev-toast-controller__label">
(ms)
<input
type="number"
min={600}
value={duration}
onChange={(event) => setDuration(Number(event.target.value))}
/>
</label>
<div className="dev-toast-controller__actions">
<button onClick={triggerToast}></button>
<button onClick={triggerConfirm}></button>
</div>
</div>
</div>
)
}
export default DevToastController
+17 -150
View File
@@ -1,5 +1,3 @@
import { useState } from 'react'
import { toast } from 'sonner'
import type { TraderConfigData } from '../types'
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
@@ -20,66 +18,20 @@ export function TraderConfigViewModal({
onClose,
traderData,
}: TraderConfigViewModalProps) {
const [copiedField, setCopiedField] = useState<string | null>(null)
if (!isOpen || !traderData) return null
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedField(fieldName)
setTimeout(() => setCopiedField(null), 2000)
toast.success('已复制到剪贴板')
} catch (error) {
console.error('Failed to copy:', error)
toast.error('复制失败,请手动复制')
}
}
const CopyButton = ({
text,
fieldName,
}: {
text: string
fieldName: string
}) => (
<button
onClick={() => copyToClipboard(text, fieldName)}
className="ml-2 px-2 py-1 text-xs rounded transition-all duration-200 hover:scale-105"
style={{
background:
copiedField === fieldName
? 'rgba(14, 203, 129, 0.1)'
: 'rgba(240, 185, 11, 0.1)',
color: copiedField === fieldName ? '#0ECB81' : '#F0B90B',
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`,
}}
>
{copiedField === fieldName ? '✓ 已复制' : '📋 复制'}
</button>
)
const InfoRow = ({
label,
value,
copyable = false,
fieldName = '',
}: {
label: string
value: string | number | boolean
copyable?: boolean
fieldName?: string
}) => (
<div className="flex justify-between items-start py-2 border-b border-[#2B3139] last:border-b-0">
<span className="text-sm text-[#848E9C] font-medium">{label}</span>
<div className="flex items-center text-right">
<span className="text-sm text-[#EAECEF] font-mono">
{typeof value === 'boolean' ? (value ? '是' : '否') : value}
</span>
{copyable && typeof value === 'string' && value && (
<CopyButton text={value} fieldName={fieldName} />
)}
</div>
<span className="text-sm text-[#EAECEF] font-mono text-right">
{typeof value === 'boolean' ? (value ? '是' : '否') : value}
</span>
</div>
)
@@ -134,17 +86,9 @@ export function TraderConfigViewModal({
🤖
</h3>
<div className="space-y-3">
<InfoRow
label="交易员ID"
value={traderData.trader_id || ''}
copyable
fieldName="trader_id"
/>
<InfoRow
label="交易员名称"
value={traderData.trader_name}
copyable
fieldName="trader_name"
/>
<InfoRow
label="AI模型"
@@ -158,118 +102,41 @@ export function TraderConfigViewModal({
label="初始余额"
value={`$${traderData.initial_balance.toLocaleString()}`}
/>
</div>
</div>
{/* Trading Configuration */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
</h3>
<div className="space-y-3">
<InfoRow
label="保证金模式"
value={traderData.is_cross_margin ? '全仓' : '逐仓'}
/>
<InfoRow
label="BTC/ETH 杠杆"
value={`${traderData.btc_eth_leverage}x`}
/>
<InfoRow
label="山寨币杠杆"
value={`${traderData.altcoin_leverage}x`}
/>
<InfoRow
label="交易币种"
value={traderData.trading_symbols || '使用默认币种'}
copyable
fieldName="trading_symbols"
label="扫描间隔"
value={`${traderData.scan_interval_minutes || 3} 分钟`}
/>
</div>
</div>
{/* Signal Sources */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
📡
</h3>
<div className="space-y-3">
<InfoRow
label="Coin Pool 信号"
value={traderData.use_coin_pool}
/>
<InfoRow label="OI Top 信号" value={traderData.use_oi_top} />
</div>
</div>
{/* Custom Prompt */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[#EAECEF] flex items-center gap-2">
💬
{/* Strategy Info - only show if strategy is bound */}
{traderData.strategy_id && (
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
📋 使
</h3>
{traderData.custom_prompt && (
<CopyButton
text={traderData.custom_prompt}
fieldName="custom_prompt"
<div className="space-y-3">
<InfoRow
label="策略名称"
value={traderData.strategy_name || traderData.strategy_id}
/>
)}
</div>
</div>
<div className="space-y-3">
<InfoRow
label="覆盖默认提示词"
value={traderData.override_base_prompt}
/>
{traderData.custom_prompt ? (
<div>
<div className="text-sm text-[#848E9C] mb-2">
{traderData.override_base_prompt
? '自定义提示词'
: '附加提示词'}
</div>
<div
className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
whiteSpace: 'pre-wrap',
}}
>
{traderData.custom_prompt}
</div>
</div>
) : (
<div
className="text-sm text-[#848E9C] italic p-3 rounded border"
style={{ border: '1px solid #2B3139' }}
>
使
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<div className="flex justify-end p-6 border-t border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<button
onClick={onClose}
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"
>
</button>
<button
onClick={() =>
copyToClipboard(
JSON.stringify(traderData, null, 2),
'full_config'
)
}
className="px-6 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 font-medium shadow-lg"
>
{copiedField === 'full_config' ? '✓ 已复制配置' : '📋 复制完整配置'}
</button>
</div>
</div>
</div>
-86
View File
@@ -1,86 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
interface TypewriterProps {
lines: string[]
typingSpeed?: number // 毫秒/字符
lineDelay?: number // 每行结束的额外等待
className?: string
style?: React.CSSProperties
}
export default function Typewriter({
lines,
typingSpeed = 50,
lineDelay = 600,
className,
style,
}: TypewriterProps) {
const [typedLines, setTypedLines] = useState<string[]>([''])
const [showCursor, setShowCursor] = useState(true)
const lineIndexRef = useRef(0)
const charIndexRef = useRef(0)
const timerRef = useRef<number | null>(null)
const blinkRef = useRef<number | null>(null)
const sanitizedLines = useMemo(
() => lines.map((l) => String(l ?? '')),
[lines]
)
useEffect(() => {
// 重置状态
lineIndexRef.current = 0
charIndexRef.current = 0
setTypedLines([''])
function typeNext() {
const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
if (charIndexRef.current < currentLine.length) {
const ch = currentLine.charAt(charIndexRef.current)
setTypedLines((prev) => {
const next = [...prev]
const lastIndex = next.length - 1
next[lastIndex] = (next[lastIndex] ?? '') + ch
return next
})
charIndexRef.current += 1
timerRef.current = window.setTimeout(typeNext, typingSpeed)
} else {
// 行结束
if (lineIndexRef.current < sanitizedLines.length - 1) {
lineIndexRef.current += 1
charIndexRef.current = 0
setTypedLines((prev) => [...prev, ''])
timerRef.current = window.setTimeout(typeNext, lineDelay)
} else {
// 最后一行输入完毕
timerRef.current = null
}
}
}
// 延迟一帧开始打字,确保状态已重置
timerRef.current = window.setTimeout(typeNext, 0)
// 光标闪烁
blinkRef.current = window.setInterval(() => {
setShowCursor((v) => !v)
}, 500)
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current)
if (blinkRef.current) window.clearInterval(blinkRef.current)
}
}, [sanitizedLines, typingSpeed, lineDelay])
const displayText = useMemo(
() => typedLines.join('\n').replace(/undefined/g, ''),
[typedLines]
)
return (
<pre className={className} style={{ whiteSpace: 'pre-wrap', ...style }}>
{displayText}
<span style={{ opacity: showCursor ? 1 : 0 }}> </span>
</pre>
)
}
@@ -428,6 +428,30 @@ export function IndicatorEditor({
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('symbolPlaceholder')}</p>
{/* OI and Netflow toggles */}
<div className="flex gap-4 mt-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={config.enable_quant_oi !== false}
onChange={(e) => !disabled && onChange({ ...config, enable_quant_oi: e.target.checked })}
disabled={disabled}
className="w-3.5 h-3.5 rounded accent-blue-500"
/>
<span className="text-xs" style={{ color: '#EAECEF' }}>OI</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={config.enable_quant_netflow !== false}
onChange={(e) => !disabled && onChange({ ...config, enable_quant_netflow: e.target.checked })}
disabled={disabled}
className="w-3.5 h-3.5 rounded accent-blue-500"
/>
<span className="text-xs" style={{ color: '#EAECEF' }}>Netflow</span>
</label>
</div>
</div>
)}
</div>
+1 -2
View File
@@ -1339,8 +1339,7 @@ export const translations = {
'使用代理钱包安全交易:代理钱包用于签名(餘額~0),主钱包持有资金(永不暴露私钥)',
hyperliquidAgentPrivateKey: '代理私钥',
enterHyperliquidAgentPrivateKey: '输入代理钱包私钥',
hyperliquidAgentPrivateKeyDesc:
'代理钱包私钥,用于签名交易(为了安全应保持余额接近0)',
hyperliquidAgentPrivateKeyDesc: '代理钱包仅有交易权限,无法提现',
hyperliquidMainWalletAddress: '主钱包地址',
enterHyperliquidMainWalletAddress: '输入主钱包地址',
hyperliquidMainWalletAddressDesc:
-56
View File
@@ -1,56 +0,0 @@
import { ReactNode } from 'react'
import { Outlet, Link } from 'react-router-dom'
import { Container } from '../components/Container'
import { useLanguage } from '../contexts/LanguageContext'
interface AuthLayoutProps {
children?: ReactNode
}
export default function AuthLayout({ children }: AuthLayoutProps) {
const { language, setLanguage } = useLanguage()
return (
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
{/* Simple Header with Logo and Language Selector */}
<nav
className="fixed top-0 w-full z-50"
style={{
background: 'rgba(11, 14, 17, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
<Container className="flex items-center justify-between h-16">
{/* Logo */}
<Link
to="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
<span className="text-xl font-bold" style={{ color: '#F0B90B' }}>
NOFX
</span>
</Link>
{/* Language Selector */}
<div className="flex items-center gap-2">
<button
onClick={() => setLanguage(language === 'zh' ? 'en' : 'zh')}
className="px-3 py-1.5 rounded text-sm font-medium transition-colors"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
>
{language === 'zh' ? 'English' : '中文'}
</button>
</div>
</Container>
</nav>
{/* Content with top padding to avoid overlap with fixed header */}
<div className="pt-16">{children || <Outlet />}</div>
</div>
)
}
-163
View File
@@ -1,163 +0,0 @@
import { ReactNode } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import HeaderBar from '../components/HeaderBar'
import { Container } from '../components/Container'
import { useLanguage } from '../contexts/LanguageContext'
import { useAuth } from '../contexts/AuthContext'
import { t } from '../i18n/translations'
import { OFFICIAL_LINKS } from '../constants/branding'
interface MainLayoutProps {
children?: ReactNode
}
export default function MainLayout({ children }: MainLayoutProps) {
const { language, setLanguage } = useLanguage()
const { user, logout } = useAuth()
const location = useLocation()
// 根据路径自动判断当前页面
const getCurrentPage = (): 'competition' | 'traders' | 'trader' | 'faq' => {
if (location.pathname === '/faq') return 'faq'
if (location.pathname === '/traders') return 'traders'
if (location.pathname === '/dashboard') return 'trader'
if (location.pathname === '/competition') return 'competition'
return 'competition' // 默认
}
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage={getCurrentPage()}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onPageChange={() => {
// React Router handles navigation now
}}
/>
{/* Main Content */}
<Container as="main" className="py-6 pt-24">
{children || <Outlet />}
</Container>
{/* Footer */}
<footer
className="mt-16"
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
>
<Container
className="py-6 text-center text-sm"
style={{ color: '#5E6673' }}
>
<p>{t('footerTitle', language)}</p>
<p className="mt-1">{t('footerWarning', language)}</p>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
{/* GitHub */}
<a
href={OFFICIAL_LINKS.github}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#F0B90B'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="18"
height="18"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub
</a>
{/* Twitter/X */}
<a
href={OFFICIAL_LINKS.twitter}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#1DA1F2'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
Twitter
</a>
{/* Telegram */}
<a
href={OFFICIAL_LINKS.telegram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#0088cc'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
Telegram
</a>
</div>
</Container>
</footer>
</div>
)
}
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
-214
View File
@@ -1,214 +0,0 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import useSWR from 'swr'
import { api } from '../lib/api'
import { useLanguage } from '../contexts/LanguageContext'
import { useAuth } from '../contexts/AuthContext'
import { useTradersConfigStore, useTradersModalStore } from '../stores'
import { useTraderActions } from '../hooks/useTraderActions'
import { TraderConfigModal } from '../components/TraderConfigModal'
import {
ModelConfigModal,
ExchangeConfigModal,
} from '../components/traders'
import { PageHeader } from '../components/traders/sections/PageHeader'
import { AIModelsSection } from '../components/traders/sections/AIModelsSection'
import { ExchangesSection } from '../components/traders/sections/ExchangesSection'
import { TradersGrid } from '../components/traders/sections/TradersGrid'
interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void
}
export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const { language } = useLanguage()
const { user, token } = useAuth()
const navigate = useNavigate()
// Zustand stores
const {
allModels,
allExchanges,
supportedModels,
supportedExchanges,
configuredModels,
configuredExchanges,
loadConfigs,
setAllModels,
setAllExchanges,
} = useTradersConfigStore()
const {
showCreateModal,
showEditModal,
showModelModal,
showExchangeModal,
editingModel,
editingExchange,
editingTrader,
setShowCreateModal,
setShowEditModal,
setShowModelModal,
setShowExchangeModal,
setEditingModel,
setEditingExchange,
setEditingTrader,
} = useTradersModalStore()
// SWR for traders data
const { data: traders, mutate: mutateTraders } = useSWR(
user && token ? 'traders' : null,
api.getTraders,
{ refreshInterval: 5000 }
)
// Load configurations
useEffect(() => {
loadConfigs(user, token)
}, [user, token, loadConfigs])
// Business logic hook
const {
isModelInUse,
isExchangeInUse,
handleCreateTrader,
handleEditTrader,
handleSaveEditTrader,
handleDeleteTrader,
handleToggleTrader,
handleAddModel,
handleAddExchange,
handleModelClick,
handleExchangeClick,
handleSaveModel,
handleDeleteModel,
handleSaveExchange,
handleDeleteExchange,
} = useTraderActions({
traders,
allModels,
allExchanges,
supportedModels,
supportedExchanges,
language,
mutateTraders,
setAllModels,
setAllExchanges,
setShowCreateModal,
setShowEditModal,
setShowModelModal,
setShowExchangeModal,
setEditingModel,
setEditingExchange,
editingTrader,
setEditingTrader,
})
// 计算派生状态
const enabledModels = allModels?.filter((m) => m.enabled) || []
const enabledExchanges =
allExchanges?.filter((e) => {
if (!e.enabled) return false
if (e.id === 'aster') {
return e.asterUser?.trim() && e.asterSigner?.trim()
}
if (e.id === 'hyperliquid') {
return e.hyperliquidWalletAddr?.trim()
}
return true
}) || []
// 处理交易员查看
const handleTraderSelect = (traderId: string) => {
if (onTraderSelect) {
onTraderSelect(traderId)
} else {
navigate(`/dashboard?trader=${traderId}`)
}
}
return (
<div className="space-y-4 md:space-y-6 animate-fade-in">
{/* Header */}
<PageHeader
language={language}
tradersCount={traders?.length || 0}
configuredModelsCount={configuredModels.length}
configuredExchangesCount={configuredExchanges.length}
onAddModel={handleAddModel}
onAddExchange={handleAddExchange}
onCreateTrader={() => setShowCreateModal(true)}
/>
{/* Configuration Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<AIModelsSection
language={language}
configuredModels={configuredModels}
isModelInUse={isModelInUse}
onModelClick={handleModelClick}
/>
<ExchangesSection
language={language}
configuredExchanges={configuredExchanges}
isExchangeInUse={isExchangeInUse}
onExchangeClick={handleExchangeClick}
/>
</div>
{/* Traders Grid */}
<TradersGrid
language={language}
traders={traders}
onTraderSelect={handleTraderSelect}
onEditTrader={handleEditTrader}
onDeleteTrader={handleDeleteTrader}
onToggleTrader={handleToggleTrader}
/>
{/* Modals */}
<TraderConfigModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
isEditMode={false}
availableModels={enabledModels}
availableExchanges={enabledExchanges}
onSave={handleCreateTrader}
/>
<TraderConfigModal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
isEditMode={true}
traderData={editingTrader}
availableModels={enabledModels}
availableExchanges={enabledExchanges}
onSave={handleSaveEditTrader}
/>
{showModelModal && (
<ModelConfigModal
allModels={supportedModels}
configuredModels={allModels}
editingModelId={editingModel}
onSave={handleSaveModel}
onDelete={handleDeleteModel}
onClose={() => setShowModelModal(false)}
language={language}
/>
)}
{showExchangeModal && (
<ExchangeConfigModal
allExchanges={supportedExchanges}
editingExchangeId={editingExchange}
onSave={handleSaveExchange}
onDelete={handleDeleteExchange}
onClose={() => setShowExchangeModal(false)}
language={language}
/>
)}
</div>
)
}
+110 -23
View File
@@ -26,6 +26,8 @@ import {
Terminal,
Code,
Send,
Download,
Upload,
} from 'lucide-react'
import type { Strategy, StrategyConfig, AIModel } from '../types'
import { confirmToast, notify } from '../lib/notify'
@@ -263,6 +265,67 @@ export function StrategyStudioPage() {
}
}
// Export strategy as JSON file
const handleExportStrategy = (strategy: Strategy) => {
const exportData = {
name: strategy.name,
description: strategy.description,
config: strategy.config,
exported_at: new Date().toISOString(),
version: '1.0',
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `strategy_${strategy.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
notify.success(language === 'zh' ? '策略已导出' : 'Strategy exported')
}
// Import strategy from JSON file
const handleImportStrategy = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !token) return
try {
const text = await file.text()
const importData = JSON.parse(text)
// Validate imported data
if (!importData.config || !importData.name) {
throw new Error(language === 'zh' ? '无效的策略文件' : 'Invalid strategy file')
}
// Create new strategy with imported config
const response = await fetch(`${API_BASE}/api/strategies`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: `${importData.name} (${language === 'zh' ? '导入' : 'Imported'})`,
description: importData.description || '',
config: importData.config,
}),
})
if (!response.ok) throw new Error('Failed to import strategy')
notify.success(language === 'zh' ? '策略已导入' : 'Strategy imported')
await fetchStrategies()
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
notify.error(errorMsg)
} finally {
// Reset file input
event.target.value = ''
}
}
// Save strategy
const handleSaveStrategy = async () => {
if (!token || !selectedStrategy || !editingConfig) return
@@ -526,13 +589,26 @@ export function StrategyStudioPage() {
<div className="p-2">
<div className="flex items-center justify-between mb-2 px-2">
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('strategies')}</span>
<button
onClick={handleCreateStrategy}
className="p-1 rounded hover:bg-white/10 transition-colors"
style={{ color: '#F0B90B' }}
>
<Plus className="w-4 h-4" />
</button>
<div className="flex items-center gap-1">
{/* Import button with hidden file input */}
<label className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer" style={{ color: '#848E9C' }} title={language === 'zh' ? '导入策略' : 'Import Strategy'}>
<Upload className="w-4 h-4" />
<input
type="file"
accept=".json"
onChange={handleImportStrategy}
className="hidden"
/>
</label>
<button
onClick={handleCreateStrategy}
className="p-1 rounded hover:bg-white/10 transition-colors"
style={{ color: '#F0B90B' }}
title={language === 'zh' ? '新建策略' : 'New Strategy'}
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-1">
{strategies.map((strategy) => (
@@ -554,22 +630,33 @@ export function StrategyStudioPage() {
>
<div className="flex items-center justify-between">
<span className="text-sm truncate" style={{ color: '#EAECEF' }}>{strategy.name}</span>
{!strategy.is_default && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
className="p-1 rounded hover:bg-white/10"
>
<Copy className="w-3 h-3" style={{ color: '#848E9C' }} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
className="p-1 rounded hover:bg-red-500/20"
>
<Trash2 className="w-3 h-3" style={{ color: '#F6465D' }} />
</button>
</div>
)}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
className="p-1 rounded hover:bg-white/10"
title={language === 'zh' ? '导出' : 'Export'}
>
<Download className="w-3 h-3" style={{ color: '#848E9C' }} />
</button>
{!strategy.is_default && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
className="p-1 rounded hover:bg-white/10"
title={language === 'zh' ? '复制' : 'Duplicate'}
>
<Copy className="w-3 h-3" style={{ color: '#848E9C' }} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
className="p-1 rounded hover:bg-red-500/20"
title={language === 'zh' ? '删除' : 'Delete'}
>
<Trash2 className="w-3 h-3" style={{ color: '#F6465D' }} />
</button>
</>
)}
</div>
</div>
<div className="flex items-center gap-1 mt-1">
{strategy.is_active && (
File diff suppressed because it is too large Load Diff
-62
View File
@@ -1,62 +0,0 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import MainLayout from '../layouts/MainLayout'
import AuthLayout from '../layouts/AuthLayout'
import { LandingPage } from '../pages/LandingPage'
import { FAQPage } from '../pages/FAQPage'
import { LoginPage } from '../components/LoginPage'
import { RegisterPage } from '../components/RegisterPage'
import { ResetPasswordPage } from '../components/ResetPasswordPage'
import { CompetitionPage } from '../components/CompetitionPage'
import { AITradersPage } from '../pages/AITradersPage'
import TraderDashboard from '../pages/TraderDashboard'
export const router = createBrowserRouter([
{
path: '/',
element: <LandingPage />,
},
// Auth routes - using AuthLayout
{
element: <AuthLayout />,
children: [
{
path: '/login',
element: <LoginPage />,
},
{
path: '/register',
element: <RegisterPage />,
},
{
path: '/reset-password',
element: <ResetPasswordPage />,
},
],
},
// Main app routes - using MainLayout with nested routes
{
element: <MainLayout />,
children: [
{
path: '/faq',
element: <FAQPage />,
},
{
path: '/competition',
element: <CompetitionPage />,
},
{
path: '/traders',
element: <AITradersPage />,
},
{
path: '/dashboard',
element: <TraderDashboard />,
},
],
},
{
path: '*',
element: <Navigate to="/" replace />,
},
])
+12 -9
View File
@@ -205,20 +205,21 @@ export interface TraderConfigData {
trader_name: string
ai_model: string
exchange_id: string
strategy_id?: string // 策略ID(新版)
strategy_id?: string // 策略ID
strategy_name?: string // 策略名称
is_cross_margin: boolean
scan_interval_minutes: number
initial_balance: number
is_running: boolean
// 以下为旧版字段(向后兼容)
btc_eth_leverage: number
altcoin_leverage: number
trading_symbols: string
custom_prompt: string
override_base_prompt: boolean
system_prompt_template: string
use_coin_pool: boolean
use_oi_top: boolean
btc_eth_leverage?: number
altcoin_leverage?: number
trading_symbols?: string
custom_prompt?: string
override_base_prompt?: boolean
system_prompt_template?: string
use_coin_pool?: boolean
use_oi_top?: boolean
}
// Backtest types
@@ -411,6 +412,8 @@ export interface IndicatorConfig {
// 量化数据源(资金流向、持仓变化、价格变化)
enable_quant_data?: boolean;
quant_data_api_url?: string;
enable_quant_oi?: boolean;
enable_quant_netflow?: boolean;
}
export interface KlineConfig {
-93
View File
@@ -1,93 +0,0 @@
// 系统状态
export interface SystemStatus {
is_running: boolean
start_time: string
runtime_minutes: number
call_count: number
initial_balance: number
scan_interval: string
stop_until: string
last_reset_time: string
ai_provider: string
}
// 账户信息
export interface AccountInfo {
total_equity: number
available_balance: number
total_pnl: number
total_pnl_pct: number
total_unrealized_pnl: number
margin_used: number
margin_used_pct: number
position_count: number
initial_balance: number
daily_pnl: number
}
// 持仓信息
export interface Position {
symbol: string
side: string
entry_price: number
mark_price: number
quantity: number
leverage: number
unrealized_pnl: number
unrealized_pnl_pct: number
liquidation_price: number
margin_used: number
}
// 决策动作
export interface DecisionAction {
action: string
symbol: string
quantity: number
leverage: number
price: number
order_id: number
timestamp: string
success: boolean
error: string
}
// 决策记录
export interface DecisionRecord {
timestamp: string
cycle_number: number
input_prompt: string
cot_trace: string
decision_json: string
account_state: {
total_balance: number
available_balance: number
total_unrealized_profit: number
position_count: number
margin_used_pct: number
}
positions: Array<{
symbol: string
side: string
position_amt: number
entry_price: number
mark_price: number
unrealized_profit: number
leverage: number
liquidation_price: number
}>
candidate_coins: string[]
decisions: DecisionAction[]
execution_log: string[]
success: boolean
error_message: string
}
// 统计信息
export interface Statistics {
total_cycles: number
successful_cycles: number
failed_cycles: number
total_open_positions: number
total_close_positions: number
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />