2104 lines
91 KiB
JavaScript
2104 lines
91 KiB
JavaScript
import React, { useState, useEffect } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api'
|
||
import './ConfigPanel.css'
|
||
|
||
const ConfigPanel = ({ currentUser }) => {
|
||
const [configs, setConfigs] = useState({})
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [message, setMessage] = useState('')
|
||
const [feasibilityCheck, setFeasibilityCheck] = useState(null)
|
||
const [checkingFeasibility, setCheckingFeasibility] = useState(false)
|
||
const [systemStatus, setSystemStatus] = useState(null)
|
||
const [backendStatus, setBackendStatus] = useState(null)
|
||
const [systemBusy, setSystemBusy] = useState(false)
|
||
const [accountTradingStatus, setAccountTradingStatus] = useState(null)
|
||
const [accountTradingErr, setAccountTradingErr] = useState('')
|
||
const [currentAccountMeta, setCurrentAccountMeta] = useState(null)
|
||
const [configMeta, setConfigMeta] = useState(null)
|
||
|
||
// 多账号:当前账号(仅用于配置页提示;全局切换器在顶部导航)
|
||
const [accountId, setAccountId] = useState(getCurrentAccountId())
|
||
|
||
const isAdmin = (currentUser?.role || '') === 'admin'
|
||
const globalStrategyAccountId = parseInt(String(configMeta?.global_strategy_account_id || '1'), 10) || 1
|
||
const isGlobalStrategyAccount = isAdmin && accountId === globalStrategyAccountId
|
||
const loadConfigMeta = async () => {
|
||
try {
|
||
const m = await api.getConfigMeta()
|
||
setConfigMeta(m || null)
|
||
} catch (e) {
|
||
setConfigMeta(null)
|
||
}
|
||
}
|
||
|
||
// 账号管理(超管)
|
||
const [accountsAdmin, setAccountsAdmin] = useState([])
|
||
const [accountsBusy, setAccountsBusy] = useState(false)
|
||
const [showAccountsAdmin, setShowAccountsAdmin] = useState(false)
|
||
const [newAccount, setNewAccount] = useState({
|
||
name: '',
|
||
api_key: '',
|
||
api_secret: '',
|
||
use_testnet: false,
|
||
status: 'active',
|
||
})
|
||
const [credEditId, setCredEditId] = useState(null)
|
||
const [credForm, setCredForm] = useState({ api_key: '', api_secret: '', use_testnet: false })
|
||
|
||
// “PCT”类配置里有少数是“百分比数值(<=1表示<=1%)”,而不是“0~1比例”
|
||
// 例如 LIMIT_ORDER_OFFSET_PCT=0.5 表示 0.5%(而不是 50%)
|
||
const PCT_LIKE_KEYS = new Set([
|
||
'LIMIT_ORDER_OFFSET_PCT',
|
||
'ENTRY_MAX_DRIFT_PCT_TRENDING',
|
||
'ENTRY_MAX_DRIFT_PCT_RANGING',
|
||
])
|
||
|
||
// 配置快照(用于整体分析/导出)
|
||
const [showSnapshot, setShowSnapshot] = useState(false)
|
||
const [snapshotText, setSnapshotText] = useState('')
|
||
const [snapshotIncludeSecrets, setSnapshotIncludeSecrets] = useState(false)
|
||
const [snapshotBusy, setSnapshotBusy] = useState(false)
|
||
|
||
// 预设方案配置
|
||
// 注意:百分比配置使用整数形式(如8.0表示8%),在应用时会转换为小数(0.08)
|
||
const presets = {
|
||
swing: {
|
||
name: '波段回归(推荐)',
|
||
desc: '根治高频与追价:关闭智能入场,回归“纯限价 + 30分钟扫描 + 更高信号门槛”的低频波段。建议先跑20-30单再评估。',
|
||
configs: {
|
||
// 操作频率
|
||
SCAN_INTERVAL: 1800, // 30分钟
|
||
TOP_N_SYMBOLS: 8,
|
||
|
||
// 仓位管理(重要语义:这些百分比均按“保证金占用比例”理解)
|
||
MAX_POSITION_PERCENT: 2.0, // 2%
|
||
MAX_TOTAL_POSITION_PERCENT: 20.0, // 20%
|
||
MIN_POSITION_PERCENT: 0.0, // 0%(等价于关闭最小仓位占比)
|
||
|
||
// 风控
|
||
MIN_SIGNAL_STRENGTH: 8,
|
||
USE_TRAILING_STOP: false, // 禁用移动止损,让利润奔跑
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 4.5, // 放大ATR止盈倍数(从3.0提升到4.5)
|
||
TAKE_PROFIT_PERCENT: 25.0, // 放宽固定止盈(从30%到25%,配合ATR止盈放大盈亏比)
|
||
MIN_HOLD_TIME_SEC: 1800, // 最小持仓时间30分钟(强制波段持仓纪律)
|
||
|
||
// 根治:关闭智能入场(回归纯限价,不追价/不市价兜底)
|
||
SMART_ENTRY_ENABLED: false,
|
||
},
|
||
},
|
||
fill: {
|
||
name: '成交优先(更少漏单)',
|
||
desc: '优先解决“挂单NEW→超时撤单→没成交”的问题:解锁自动交易过滤 + 保守智能入场(限制追价步数与追价上限),在趋势强时允许可控的市价兜底。',
|
||
configs: {
|
||
// 频率与精选
|
||
SCAN_INTERVAL: 1800,
|
||
TOP_N_SYMBOLS: 6,
|
||
MIN_SIGNAL_STRENGTH: 7,
|
||
|
||
// 自动交易过滤:先保证“会下单”
|
||
AUTO_TRADE_ONLY_TRENDING: false,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: true,
|
||
|
||
// 入场(减少撤单/漏单)
|
||
SMART_ENTRY_ENABLED: true,
|
||
LIMIT_ORDER_OFFSET_PCT: 0.1, // 0.1% 更贴近当前价
|
||
ENTRY_CONFIRM_TIMEOUT_SEC: 120,
|
||
ENTRY_CHASE_MAX_STEPS: 2,
|
||
ENTRY_STEP_WAIT_SEC: 20,
|
||
ENTRY_MARKET_FALLBACK_AFTER_SEC: 60,
|
||
ENTRY_MAX_DRIFT_PCT_TRENDING: 0.3, // 0.3%
|
||
ENTRY_MAX_DRIFT_PCT_RANGING: 0.15, // 0.15%
|
||
|
||
// 风控:默认关闭移动止损(避免“保本价”过早触发)
|
||
USE_TRAILING_STOP: false, // 禁用移动止损,让利润奔跑
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 4.5, // 放大ATR止盈倍数(从3.0提升到4.5)
|
||
TAKE_PROFIT_PERCENT: 25.0, // 放宽固定止盈(从30%到25%,配合ATR止盈放大盈亏比)
|
||
MIN_HOLD_TIME_SEC: 1800, // 最小持仓时间30分钟(强制波段持仓纪律)
|
||
},
|
||
},
|
||
strict: {
|
||
name: '精选低频(高胜率倾向)',
|
||
desc: '更偏“少单、质量优先”:仅趋势行情自动交易 + 4H中性不自动下单 + 更高信号门槛。仍保持较贴近的限价偏移,减少“完全成交不了”。',
|
||
configs: {
|
||
SCAN_INTERVAL: 1800,
|
||
TOP_N_SYMBOLS: 6,
|
||
MIN_SIGNAL_STRENGTH: 8,
|
||
|
||
AUTO_TRADE_ONLY_TRENDING: true,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: false,
|
||
|
||
SMART_ENTRY_ENABLED: false,
|
||
LIMIT_ORDER_OFFSET_PCT: 0.1,
|
||
ENTRY_CONFIRM_TIMEOUT_SEC: 180,
|
||
|
||
USE_TRAILING_STOP: false, // 禁用移动止损,让利润奔跑
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 4.5, // 放大ATR止盈倍数(从3.0提升到4.5)
|
||
TAKE_PROFIT_PERCENT: 25.0, // 放宽固定止盈(从30%到25%,配合ATR止盈放大盈亏比)
|
||
MIN_HOLD_TIME_SEC: 1800, // 最小持仓时间30分钟(强制波段持仓纪律)
|
||
},
|
||
},
|
||
steady: {
|
||
name: '稳定出单(均衡收益/频率)',
|
||
desc: '在“会下单”的基础上略提高出单频率:更短扫描间隔 + 更宽松门槛 + 保守智能入场(追价受限),适合想要稳定有单但不想回到高频。',
|
||
configs: {
|
||
SCAN_INTERVAL: 900,
|
||
TOP_N_SYMBOLS: 8,
|
||
MIN_SIGNAL_STRENGTH: 6,
|
||
|
||
AUTO_TRADE_ONLY_TRENDING: false,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: true,
|
||
|
||
SMART_ENTRY_ENABLED: true,
|
||
LIMIT_ORDER_OFFSET_PCT: 0.12,
|
||
ENTRY_CONFIRM_TIMEOUT_SEC: 120,
|
||
ENTRY_CHASE_MAX_STEPS: 3,
|
||
ENTRY_STEP_WAIT_SEC: 15,
|
||
ENTRY_MARKET_FALLBACK_AFTER_SEC: 45,
|
||
ENTRY_MAX_DRIFT_PCT_TRENDING: 0.4,
|
||
ENTRY_MAX_DRIFT_PCT_RANGING: 0.2,
|
||
|
||
USE_TRAILING_STOP: false, // 禁用移动止损,让利润奔跑
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 4.5, // 放大ATR止盈倍数(从3.0提升到4.5)
|
||
TAKE_PROFIT_PERCENT: 25.0, // 放宽固定止盈(从30%到25%,配合ATR止盈放大盈亏比)
|
||
MIN_HOLD_TIME_SEC: 1800, // 最小持仓时间30分钟(强制波段持仓纪律)
|
||
},
|
||
},
|
||
conservative: {
|
||
name: '保守配置',
|
||
desc: '适合新手,风险较低,止损止盈较宽松,避免被正常波动触发',
|
||
configs: {
|
||
SCAN_INTERVAL: 900,
|
||
MIN_CHANGE_PERCENT: 2.0, // 2%
|
||
MIN_SIGNAL_STRENGTH: 5,
|
||
TOP_N_SYMBOLS: 10,
|
||
MAX_SCAN_SYMBOLS: 150,
|
||
MIN_VOLATILITY: 0.02, // 保持小数形式(波动率)
|
||
STOP_LOSS_PERCENT: 10.0, // 10%(相对于保证金,更宽松)
|
||
TAKE_PROFIT_PERCENT: 25.0, // 25%(相对于保证金,从20%提升到25%,放大盈亏比)
|
||
MIN_STOP_LOSS_PRICE_PCT: 2.0, // 2%最小价格变动保护
|
||
MIN_TAKE_PROFIT_PRICE_PCT: 3.0, // 3%最小价格变动保护
|
||
USE_TRAILING_STOP: false, // 禁用移动止损,让利润奔跑
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 4.5, // 放大ATR止盈倍数(从3.0提升到4.5)
|
||
MIN_HOLD_TIME_SEC: 1800 // 最小持仓时间30分钟(强制波段持仓纪律)
|
||
}
|
||
},
|
||
balanced: {
|
||
name: '平衡配置',
|
||
desc: '推荐使用,平衡频率和质量,止损止盈适中(盈亏比2.5:1)',
|
||
configs: {
|
||
SCAN_INTERVAL: 600,
|
||
MIN_CHANGE_PERCENT: 1.5, // 1.5%
|
||
MIN_SIGNAL_STRENGTH: 4,
|
||
TOP_N_SYMBOLS: 12,
|
||
MAX_SCAN_SYMBOLS: 250,
|
||
MIN_VOLATILITY: 0.018, // 保持小数形式(波动率)
|
||
STOP_LOSS_PERCENT: 8.0, // 8%(相对于保证金,默认值)
|
||
TAKE_PROFIT_PERCENT: 25.0, // 25%(相对于保证金,从20%提升到25%,放大盈亏比)
|
||
MIN_STOP_LOSS_PRICE_PCT: 2.0, // 2%最小价格变动保护
|
||
MIN_TAKE_PROFIT_PRICE_PCT: 3.0, // 3%最小价格变动保护
|
||
USE_TRAILING_STOP: false, // 禁用移动止损,让利润奔跑
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 4.5, // 放大ATR止盈倍数(从3.0提升到4.5)
|
||
MIN_HOLD_TIME_SEC: 1800 // 最小持仓时间30分钟(强制波段持仓纪律)
|
||
}
|
||
},
|
||
aggressive: {
|
||
name: '激进高频',
|
||
desc: '晚间波动大时使用,交易频率高,止损较紧但止盈合理(盈亏比3:1)',
|
||
configs: {
|
||
SCAN_INTERVAL: 300,
|
||
MIN_CHANGE_PERCENT: 1.0, // 1%
|
||
MIN_SIGNAL_STRENGTH: 3,
|
||
TOP_N_SYMBOLS: 18,
|
||
MAX_SCAN_SYMBOLS: 350,
|
||
MIN_VOLATILITY: 0.015, // 保持小数形式(波动率)
|
||
STOP_LOSS_PERCENT: 5.0, // 5%(相对于保证金,较紧)
|
||
TAKE_PROFIT_PERCENT: 25.0, // 25%(相对于保证金,从15%大幅提升到25%,放大盈亏比到5:1)
|
||
MIN_STOP_LOSS_PRICE_PCT: 1.5, // 1.5%最小价格变动保护
|
||
MIN_TAKE_PROFIT_PRICE_PCT: 2.0, // 2%最小价格变动保护
|
||
USE_TRAILING_STOP: false, // 禁用移动止损,让利润奔跑
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 4.5, // 放大ATR止盈倍数(从3.0提升到4.5)
|
||
MIN_HOLD_TIME_SEC: 1800 // 最小持仓时间30分钟(强制波段持仓纪律)
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadSystemStatus = async () => {
|
||
try {
|
||
const res = await api.getTradingSystemStatus()
|
||
setSystemStatus(res)
|
||
} catch (error) {
|
||
// 静默失败:不影响配置页使用
|
||
}
|
||
}
|
||
|
||
const loadBackendStatus = async () => {
|
||
try {
|
||
const res = await api.getBackendStatus()
|
||
setBackendStatus(res)
|
||
} catch (error) {
|
||
// 静默失败:不影响配置页使用
|
||
}
|
||
}
|
||
|
||
const loadAccountTradingStatus = async () => {
|
||
try {
|
||
const res = await api.getAccountTradingStatus(accountId)
|
||
setAccountTradingStatus(res)
|
||
setAccountTradingErr('')
|
||
} catch (error) {
|
||
setAccountTradingStatus(null)
|
||
setAccountTradingErr(error?.message || '获取交易进程状态失败')
|
||
}
|
||
}
|
||
|
||
const loadCurrentAccountMeta = async () => {
|
||
try {
|
||
const list = await api.getAccounts()
|
||
const accounts = Array.isArray(list) ? list : []
|
||
const meta = accounts.find((a) => parseInt(String(a?.id || '0'), 10) === parseInt(String(accountId || '0'), 10))
|
||
setCurrentAccountMeta(meta || null)
|
||
// 同步 testnet 开关到表单(仅在未输入时同步,避免打断正在输入)
|
||
if (meta && !credForm.api_key && !credForm.api_secret) {
|
||
setCredForm((prev) => ({ ...prev, use_testnet: !!meta.use_testnet }))
|
||
}
|
||
} catch (e) {
|
||
setCurrentAccountMeta(null)
|
||
}
|
||
}
|
||
|
||
const handleAccountTradingEnsure = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.ensureAccountTradingProgram(accountId)
|
||
setMessage(`已生成/刷新 supervisor 配置:${res.program || ''}`)
|
||
await loadAccountTradingStatus()
|
||
} catch (error) {
|
||
setMessage('生成 supervisor 配置失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleAccountTradingStart = async () => {
|
||
const hasKey = !!currentAccountMeta?.has_api_key
|
||
const hasSecret = !!currentAccountMeta?.has_api_secret
|
||
if (!hasKey || !hasSecret) {
|
||
setMessage('请先在“账号密钥(当前账号)”中配置 BINANCE_API_KEY 与 BINANCE_API_SECRET,然后再启动交易进程')
|
||
return
|
||
}
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.startAccountTrading(accountId)
|
||
setMessage(res.message || '交易进程已启动')
|
||
await loadAccountTradingStatus()
|
||
} catch (error) {
|
||
setMessage('启动交易进程失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleAccountTradingStop = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.stopAccountTrading(accountId)
|
||
setMessage(res.message || '交易进程已停止')
|
||
await loadAccountTradingStatus()
|
||
} catch (error) {
|
||
setMessage('停止交易进程失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleAccountTradingRestart = async () => {
|
||
const hasKey = !!currentAccountMeta?.has_api_key
|
||
const hasSecret = !!currentAccountMeta?.has_api_secret
|
||
if (!hasKey || !hasSecret) {
|
||
setMessage('请先在“账号密钥(当前账号)”中配置 BINANCE_API_KEY 与 BINANCE_API_SECRET,然后再重启交易进程')
|
||
return
|
||
}
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.restartAccountTrading(accountId)
|
||
setMessage(res.message || '交易进程已重启')
|
||
await loadAccountTradingStatus()
|
||
} catch (error) {
|
||
setMessage('重启交易进程失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleClearCache = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.clearSystemCache()
|
||
setMessage(res.message || '缓存已清理')
|
||
await loadConfigs()
|
||
await loadSystemStatus()
|
||
} catch (error) {
|
||
setMessage('清理缓存失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleStartTrading = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.startTradingSystem()
|
||
setMessage(res.message || '交易系统已启动')
|
||
await loadSystemStatus()
|
||
} catch (error) {
|
||
setMessage('启动失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleStopTrading = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.stopTradingSystem()
|
||
setMessage(res.message || '交易系统已停止')
|
||
await loadSystemStatus()
|
||
} catch (error) {
|
||
setMessage('停止失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleRestartTrading = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.restartTradingSystem()
|
||
setMessage(res.message || '交易系统已重启')
|
||
await loadSystemStatus()
|
||
} catch (error) {
|
||
setMessage('重启失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleRestartBackend = async () => {
|
||
if (!window.confirm('确定要重启后端服务吗?重启期间页面接口会短暂不可用(约 3-10 秒)。')) return
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.restartBackend()
|
||
setMessage(res.message || '已发起后端重启')
|
||
// 给后端一点时间完成重启,再刷新状态
|
||
setTimeout(() => {
|
||
loadBackendStatus()
|
||
}, 4000)
|
||
} catch (error) {
|
||
setMessage('重启后端失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleRestartAllTrading = async () => {
|
||
if (!window.confirm('确定要重启【所有账号】的交易进程吗?这会让所有用户的交易服务短暂中断(约 3-10 秒),用于升级代码后统一生效。')) return
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.restartAllTradingSystems({ prefix: 'auto_sys_acc', do_update: true })
|
||
setMessage(`已发起批量重启:共 ${res.count} 个,成功 ${res.ok},失败 ${res.failed}`)
|
||
} catch (e) {
|
||
setMessage('批量重启失败: ' + (e?.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadConfigMeta()
|
||
loadConfigs()
|
||
checkFeasibility()
|
||
if (isAdmin) {
|
||
loadSystemStatus()
|
||
loadBackendStatus()
|
||
}
|
||
loadAccountTradingStatus()
|
||
loadCurrentAccountMeta()
|
||
|
||
const timer = setInterval(() => {
|
||
if (isAdmin) {
|
||
loadSystemStatus()
|
||
loadBackendStatus()
|
||
}
|
||
loadAccountTradingStatus()
|
||
loadCurrentAccountMeta()
|
||
}, 3000)
|
||
|
||
return () => clearInterval(timer)
|
||
}, [])
|
||
|
||
const loadAccountsAdmin = async () => {
|
||
try {
|
||
const list = await api.getAccounts()
|
||
setAccountsAdmin(Array.isArray(list) ? list : [])
|
||
} catch (e) {
|
||
setAccountsAdmin([])
|
||
}
|
||
}
|
||
|
||
const notifyAccountsUpdated = () => {
|
||
try {
|
||
window.dispatchEvent(new Event('ats:accounts:updated'))
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// 切换账号时,刷新页面数据
|
||
useEffect(() => {
|
||
setCurrentAccountId(accountId)
|
||
setMessage('')
|
||
setLoading(true)
|
||
loadConfigs()
|
||
checkFeasibility()
|
||
if (isAdmin) {
|
||
loadSystemStatus()
|
||
loadBackendStatus()
|
||
}
|
||
loadAccountTradingStatus()
|
||
loadCurrentAccountMeta()
|
||
}, [accountId])
|
||
|
||
// 顶部导航切换账号时:即时同步(比 setInterval 更及时)
|
||
useEffect(() => {
|
||
const onChanged = (e) => {
|
||
const next = parseInt(String(e?.detail?.accountId || ''), 10)
|
||
if (Number.isFinite(next) && next > 0) setAccountId(next)
|
||
}
|
||
window.addEventListener('ats:account:changed', onChanged)
|
||
return () => window.removeEventListener('ats:account:changed', onChanged)
|
||
}, [])
|
||
|
||
// 顶部导航切换账号时(localStorage更新),这里做一个轻量同步
|
||
useEffect(() => {
|
||
const timer = setInterval(() => {
|
||
const cur = getCurrentAccountId()
|
||
|
||
if (cur !== accountId) setAccountId(cur)
|
||
}, 1000)
|
||
return () => clearInterval(timer)
|
||
}, [accountId])
|
||
|
||
const checkFeasibility = async () => {
|
||
setCheckingFeasibility(true)
|
||
try {
|
||
const result = await api.checkConfigFeasibility()
|
||
setFeasibilityCheck(result)
|
||
} catch (error) {
|
||
console.error('检查配置可行性失败:', error)
|
||
// 静默失败,不显示错误
|
||
} finally {
|
||
setCheckingFeasibility(false)
|
||
}
|
||
}
|
||
|
||
const loadConfigs = async () => {
|
||
try {
|
||
const data = await api.getConfigs()
|
||
setConfigs(data)
|
||
} catch (error) {
|
||
console.error('Failed to load configs:', error)
|
||
setMessage('加载配置失败: ' + error.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleUpdate = async (key, value, type, category) => {
|
||
setSaving(true)
|
||
setMessage('')
|
||
try {
|
||
const response = await api.updateConfig(key, {
|
||
value,
|
||
type,
|
||
category
|
||
})
|
||
setMessage(response.message || '配置已更新')
|
||
if (response.note) {
|
||
setTimeout(() => {
|
||
setMessage(response.note)
|
||
}, 2000)
|
||
}
|
||
// 重新加载配置
|
||
await loadConfigs()
|
||
// 如果更新的是与可行性相关的配置,重新检查可行性
|
||
if (['MIN_MARGIN_USDT', 'MIN_POSITION_PERCENT', 'MAX_POSITION_PERCENT', 'LEVERAGE'].includes(key)) {
|
||
await checkFeasibility()
|
||
}
|
||
} catch (error) {
|
||
const errorMsg = error.message || '更新失败'
|
||
setMessage('更新失败: ' + errorMsg)
|
||
console.error('Config update error:', error)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const isSecretKey = (key) => {
|
||
return key === 'BINANCE_API_KEY' || key === 'BINANCE_API_SECRET'
|
||
}
|
||
|
||
const maskSecret = (val) => {
|
||
const s = val === null || val === undefined ? '' : String(val)
|
||
if (!s) return ''
|
||
if (s.length <= 8) return '****'
|
||
return `${s.slice(0, 4)}...${s.slice(-4)}`
|
||
}
|
||
|
||
const toDisplayValueForSnapshot = (key, value) => {
|
||
if (value === null || value === undefined) return value
|
||
// 百分比/比例:尽量转成 “百分比值”
|
||
if (typeof value === 'number' && (key.includes('PERCENT') || key.includes('PCT'))) {
|
||
// 兼容两种:
|
||
// - 常规 PERCENT/PCT:存储为 0~1(比例),展示为 0~100(%)
|
||
// - PCT_LIKE_KEYS:存储可能是 0.006(=0.6%) 或 0.6(=0.6%),展示统一为“百分比数值”
|
||
if (PCT_LIKE_KEYS.has(key)) {
|
||
return value <= 0.05 ? value * 100 : value
|
||
}
|
||
return value < 1 ? value * 100 : value
|
||
}
|
||
return value
|
||
}
|
||
|
||
const buildConfigSnapshot = async (includeSecrets) => {
|
||
// 为了确保是最新值,这里点击时再拉一次
|
||
const data = await api.getConfigs()
|
||
const now = new Date()
|
||
|
||
const categoryMap = {
|
||
scan: '市场扫描',
|
||
position: '仓位控制',
|
||
risk: '风险控制',
|
||
strategy: '策略参数',
|
||
api: 'API配置',
|
||
}
|
||
|
||
const entries = Object.entries(data || {}).map(([key, cfg]) => {
|
||
const rawVal = cfg?.value
|
||
const valMasked = isSecretKey(key) && !includeSecrets ? maskSecret(rawVal) : rawVal
|
||
const displayVal = toDisplayValueForSnapshot(key, valMasked)
|
||
return {
|
||
key,
|
||
category: cfg?.category || '',
|
||
category_label: categoryMap[cfg?.category] || cfg?.category || '',
|
||
type: cfg?.type || '',
|
||
value: valMasked,
|
||
display_value: displayVal,
|
||
description: cfg?.description || '',
|
||
}
|
||
})
|
||
|
||
// 排序:先分类再按 key
|
||
entries.sort((a, b) => {
|
||
const ca = a.category_label || a.category || ''
|
||
const cb = b.category_label || b.category || ''
|
||
if (ca !== cb) return ca.localeCompare(cb)
|
||
return a.key.localeCompare(b.key)
|
||
})
|
||
|
||
const snapshot = {
|
||
fetched_at: now.toISOString(),
|
||
note: 'display_value 对 PERCENT/PCT 做了百分比换算;敏感字段可选择脱敏/明文。',
|
||
preset_detected: detectCurrentPreset(),
|
||
system_status: systemStatus
|
||
? {
|
||
running: !!systemStatus.running,
|
||
pid: systemStatus.pid || null,
|
||
program: systemStatus.program || null,
|
||
state: systemStatus.state || null,
|
||
}
|
||
: null,
|
||
feasibility_check_summary: feasibilityCheck
|
||
? {
|
||
feasible: !!feasibilityCheck.feasible,
|
||
account_balance: feasibilityCheck.account_balance ?? null,
|
||
base_leverage: feasibilityCheck.base_leverage ?? feasibilityCheck.leverage ?? null,
|
||
max_leverage: feasibilityCheck.max_leverage ?? null,
|
||
use_dynamic_leverage: feasibilityCheck.use_dynamic_leverage ?? null,
|
||
min_margin_usdt: feasibilityCheck.current_config?.min_margin_usdt ?? null,
|
||
}
|
||
: null,
|
||
configs: entries,
|
||
}
|
||
|
||
return JSON.stringify(snapshot, null, 2)
|
||
}
|
||
|
||
const openSnapshot = async (includeSecrets) => {
|
||
setSnapshotBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const text = await buildConfigSnapshot(includeSecrets)
|
||
setSnapshotText(text)
|
||
setShowSnapshot(true)
|
||
} catch (e) {
|
||
setMessage('生成配置快照失败: ' + (e?.message || '未知错误'))
|
||
} finally {
|
||
setSnapshotBusy(false)
|
||
}
|
||
}
|
||
|
||
const copySnapshot = async () => {
|
||
try {
|
||
if (navigator?.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(snapshotText || '')
|
||
setMessage('已复制配置快照到剪贴板')
|
||
} else {
|
||
setMessage('当前浏览器不支持剪贴板 API,可手动全选复制')
|
||
}
|
||
} catch (e) {
|
||
setMessage('复制失败: ' + (e?.message || '未知错误'))
|
||
}
|
||
}
|
||
|
||
const downloadSnapshot = () => {
|
||
try {
|
||
const blob = new Blob([snapshotText || ''], { type: 'application/json;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `config-snapshot-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(url)
|
||
} catch (e) {
|
||
setMessage('下载失败: ' + (e?.message || '未知错误'))
|
||
}
|
||
}
|
||
|
||
// 检测当前配置匹配哪个预设方案
|
||
const detectCurrentPreset = () => {
|
||
if (!configs || Object.keys(configs).length === 0) return null
|
||
|
||
for (const [presetKey, preset] of Object.entries(presets)) {
|
||
let match = true
|
||
for (const [key, expectedValue] of Object.entries(preset.configs)) {
|
||
const currentConfig = configs[key]
|
||
if (!currentConfig) {
|
||
match = false
|
||
break
|
||
}
|
||
|
||
// 获取当前值(处理百分比转换)
|
||
let currentValue = currentConfig.value
|
||
if (key.includes('PERCENT') || key.includes('PCT')) {
|
||
if (PCT_LIKE_KEYS.has(key)) {
|
||
// 兼容旧值:0.006(=0.6%) 或 0.6(=0.6%)
|
||
currentValue = currentValue <= 0.05 ? currentValue * 100 : currentValue
|
||
} else {
|
||
currentValue = currentValue * 100
|
||
}
|
||
}
|
||
|
||
// 比较值(允许小的浮点数误差)
|
||
if (typeof expectedValue === 'number' && typeof currentValue === 'number') {
|
||
if (Math.abs(currentValue - expectedValue) > 0.01) {
|
||
match = false
|
||
break
|
||
}
|
||
} else if (currentValue !== expectedValue) {
|
||
match = false
|
||
break
|
||
}
|
||
}
|
||
|
||
if (match) {
|
||
return presetKey
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
const applyPreset = async (presetKey) => {
|
||
const preset = presets[presetKey]
|
||
if (!preset) return
|
||
|
||
setSaving(true)
|
||
setMessage('')
|
||
try {
|
||
const configItems = Object.entries(preset.configs).map(([key, value]) => {
|
||
const config = configs[key]
|
||
if (!config) {
|
||
// 如果配置项不存在,尝试创建(用于新增的配置项)
|
||
// 根据key判断类型和分类
|
||
let type = 'number'
|
||
let category = 'risk'
|
||
if (typeof value === 'boolean') {
|
||
type = 'boolean'
|
||
category = 'strategy'
|
||
}
|
||
if (key.startsWith('ENTRY_') || key.startsWith('SMART_ENTRY_') || key === 'SMART_ENTRY_ENABLED') {
|
||
type = typeof value === 'boolean' ? 'boolean' : 'number'
|
||
category = 'strategy'
|
||
} else if (key.startsWith('AUTO_TRADE_')) {
|
||
type = typeof value === 'boolean' ? 'boolean' : 'number'
|
||
category = 'strategy'
|
||
} else if (key === 'LIMIT_ORDER_OFFSET_PCT') {
|
||
type = 'number'
|
||
category = 'strategy'
|
||
} else if (key.includes('PERCENT') || key.includes('PCT')) {
|
||
type = 'number'
|
||
if (key.includes('STOP_LOSS') || key.includes('TAKE_PROFIT')) {
|
||
category = 'risk'
|
||
} else if (key.includes('POSITION')) {
|
||
category = 'position'
|
||
} else {
|
||
category = 'scan'
|
||
}
|
||
} else if (key === 'MIN_VOLATILITY') {
|
||
type = 'number'
|
||
category = 'scan'
|
||
} else if (typeof value === 'number') {
|
||
type = 'number'
|
||
category = 'scan'
|
||
}
|
||
|
||
const detail = typeof getConfigDetail === 'function' ? getConfigDetail(key) : ''
|
||
const desc =
|
||
detail && typeof detail === 'string' && !detail.includes('暂无详细说明')
|
||
? detail
|
||
: `预设方案配置项:${key}`
|
||
|
||
return {
|
||
key,
|
||
value:
|
||
(key.includes('PERCENT') || key.includes('PCT')) && !PCT_LIKE_KEYS.has(key)
|
||
? value / 100
|
||
: value,
|
||
type,
|
||
category,
|
||
description: desc
|
||
}
|
||
}
|
||
return {
|
||
key,
|
||
value:
|
||
(key.includes('PERCENT') || key.includes('PCT')) && !PCT_LIKE_KEYS.has(key)
|
||
? value / 100
|
||
: value,
|
||
type: config.type,
|
||
category: config.category,
|
||
description: config.description
|
||
}
|
||
}).filter(Boolean)
|
||
|
||
const response = await api.updateConfigsBatch(configItems)
|
||
setMessage(response.message || `已应用${preset.name}`)
|
||
if (response.note) {
|
||
setTimeout(() => {
|
||
setMessage(response.note)
|
||
}, 2000)
|
||
}
|
||
await loadConfigs()
|
||
} catch (error) {
|
||
setMessage('应用预设失败: ' + error.message)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const currentPreset = detectCurrentPreset()
|
||
|
||
if (loading) return <div className="loading">加载中...</div>
|
||
|
||
const configCategories = {
|
||
'scan': '市场扫描',
|
||
'position': '仓位控制',
|
||
'risk': '风险控制',
|
||
'strategy': '策略参数',
|
||
'api': 'API配置'
|
||
}
|
||
|
||
return (
|
||
<div className="config-panel">
|
||
<div className="config-header">
|
||
<div className="header-top">
|
||
<div className="header-left">
|
||
<h2>交易配置</h2>
|
||
<div className="account-switch">
|
||
<span className="account-hint">当前账号:#{accountId}(在顶部导航切换)</span>
|
||
</div>
|
||
{isAdmin ? (
|
||
<div
|
||
className="system-hint"
|
||
style={{
|
||
marginTop: '8px',
|
||
padding: '10px',
|
||
border: '1px solid #e0b400',
|
||
background: '#fff8e1',
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 700 }}>
|
||
{isGlobalStrategyAccount ? (
|
||
<>
|
||
你正在编辑 <span style={{ fontWeight: 900 }}>全局策略账号 #{globalStrategyAccountId}</span>:
|
||
此处修改将影响所有用户的策略核心。
|
||
</>
|
||
) : (
|
||
<>
|
||
你正在编辑账号 #{accountId}:这里只允许调整 <span style={{ fontWeight: 900 }}>风险旋钮</span>(仓位/次数/自动交易开关等)。
|
||
策略核心统一来自 <span style={{ fontWeight: 900 }}>全局策略账号 #{globalStrategyAccountId}</span>。
|
||
</>
|
||
)}
|
||
</div>
|
||
{!isGlobalStrategyAccount ? (
|
||
<div style={{ marginTop: '8px' }}>
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
onClick={() => {
|
||
setCurrentAccountId(globalStrategyAccountId)
|
||
setAccountId(globalStrategyAccountId)
|
||
setMessage('已切换到全局策略账号(策略核心统一在这里维护)')
|
||
}}
|
||
>
|
||
切换到全局策略账号 #{globalStrategyAccountId}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="header-actions">
|
||
<button
|
||
type="button"
|
||
className="guide-link snapshot-btn"
|
||
onClick={() => openSnapshot(snapshotIncludeSecrets)}
|
||
disabled={snapshotBusy}
|
||
title="导出当前全量配置(用于分析)"
|
||
>
|
||
{snapshotBusy ? '生成中...' : '查看整体配置'}
|
||
</button>
|
||
<Link to="/config/guide" className="guide-link">📖 配置说明</Link>
|
||
</div>
|
||
</div>
|
||
<div className="config-info">
|
||
<p>修改配置后,交易系统将在下次扫描时自动使用新配置</p>
|
||
</div>
|
||
|
||
{/* 我的交易进程(按账号;owner/admin 可启停) */}
|
||
<div className="system-section">
|
||
<div className="system-header">
|
||
<h3>我的交易进程(当前账号 #{accountId})</h3>
|
||
<div className="system-status">
|
||
<span className={`system-status-badge ${accountTradingStatus?.running ? 'running' : 'stopped'}`}>
|
||
{accountTradingStatus?.running ? '运行中' : '未运行/未知'}
|
||
</span>
|
||
{accountTradingStatus?.pid ? <span className="system-status-meta">PID: {accountTradingStatus.pid}</span> : null}
|
||
{accountTradingStatus?.program ? <span className="system-status-meta">程序: {accountTradingStatus.program}</span> : null}
|
||
{accountTradingStatus?.state ? <span className="system-status-meta">状态: {accountTradingStatus.state}</span> : null}
|
||
</div>
|
||
</div>
|
||
<div className="system-actions">
|
||
<button type="button" className="system-btn" onClick={handleAccountTradingEnsure} disabled={systemBusy} title="为该账号生成/刷新 supervisor program 配置(需要 owner/admin)">
|
||
生成配置
|
||
</button>
|
||
<button type="button" className="system-btn" onClick={handleAccountTradingStop} disabled={systemBusy} title="停止该账号交易进程(需要 owner/admin)">
|
||
停止
|
||
</button>
|
||
<button type="button" className="system-btn" onClick={handleAccountTradingStart} disabled={systemBusy} title="启动该账号交易进程(需要 owner/admin)">
|
||
启动
|
||
</button>
|
||
<button type="button" className="system-btn primary" onClick={handleAccountTradingRestart} disabled={systemBusy} title="重启该账号交易进程(需要 owner/admin)">
|
||
重启
|
||
</button>
|
||
</div>
|
||
<div className="system-hint">
|
||
提示:若按钮报“无权限”,请让管理员在用户授权里把该账号分配为 owner;若报 supervisor 相关错误,请检查后端对 `/www/server/panel/plugin/supervisor` 的写权限与 supervisorctl 可执行权限。
|
||
</div>
|
||
{accountTradingErr ? (
|
||
<div className="system-hint" style={{ color: '#b00020' }}>
|
||
{accountTradingErr}
|
||
</div>
|
||
) : null}
|
||
{accountTradingStatus?.raw ? (
|
||
<details style={{ marginTop: '8px' }}>
|
||
<summary style={{ cursor: 'pointer' }}>查看原始状态输出</summary>
|
||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: '8px' }}>{String(accountTradingStatus.raw || '')}</pre>
|
||
</details>
|
||
) : null}
|
||
{accountTradingStatus?.stderr_tail ? (
|
||
<details style={{ marginTop: '8px' }}>
|
||
<summary style={{ cursor: 'pointer' }}>查看最近错误日志(stderr)</summary>
|
||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: '8px' }}>{String(accountTradingStatus.stderr_tail || '')}</pre>
|
||
</details>
|
||
) : null}
|
||
{accountTradingStatus?.stdout_tail ? (
|
||
<details style={{ marginTop: '8px' }}>
|
||
<summary style={{ cursor: 'pointer' }}>查看最近输出日志(stdout,常见原因在这里)</summary>
|
||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: '8px' }}>{String(accountTradingStatus.stdout_tail || '')}</pre>
|
||
</details>
|
||
) : null}
|
||
{accountTradingStatus?.stderr_tail_error ? (
|
||
<details style={{ marginTop: '8px' }}>
|
||
<summary style={{ cursor: 'pointer' }}>读取 stderr 失败原因(用于排障)</summary>
|
||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: '8px' }}>{String(accountTradingStatus.stderr_tail_error || '')}</pre>
|
||
</details>
|
||
) : null}
|
||
{['FATAL', 'EXITED', 'BACKOFF'].includes(String(accountTradingStatus?.state || '')) ? (
|
||
<details style={{ marginTop: '8px' }}>
|
||
<summary style={{ cursor: 'pointer' }}>supervisord 主日志尾部(spawn error 常见原因在这里)</summary>
|
||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: '8px' }}>
|
||
{String(accountTradingStatus?.supervisord_tail || '(暂无:可能未配置 supervisord logfile 或当前用户无权限读取。可在后端配置 SUPERVISOR_LOGFILE 指向 supervisord 主日志路径)')}
|
||
</pre>
|
||
</details>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* 账号密钥(当前账号;owner/admin 可改) */}
|
||
<div className="system-section">
|
||
<div className="system-header">
|
||
<h3>账号密钥(当前账号)</h3>
|
||
<div className="system-status">
|
||
<span className="system-status-meta">说明:为安全起见,页面不会回显 Secret;填入后保存即可。</span>
|
||
</div>
|
||
</div>
|
||
<div className="accounts-form">
|
||
<label>
|
||
API KEY(留空=不改)
|
||
<div className="system-status-meta" style={{ marginTop: '4px' }}>
|
||
当前状态:{currentAccountMeta?.has_api_key ? '已配置(******)' : '未配置(必须先配置才能启动交易)'}
|
||
</div>
|
||
<input
|
||
type="password"
|
||
value={credForm.api_key}
|
||
onChange={(e) => setCredForm({ ...credForm, api_key: e.target.value })}
|
||
placeholder={currentAccountMeta?.has_api_key ? '已配置(******),如需更换请重新填写' : '粘贴你的 Binance API KEY'}
|
||
/>
|
||
</label>
|
||
<label>
|
||
API SECRET(留空=不改)
|
||
<div className="system-status-meta" style={{ marginTop: '4px' }}>
|
||
当前状态:{currentAccountMeta?.has_api_secret ? '已配置(******)' : '未配置(必须先配置才能启动交易)'}
|
||
</div>
|
||
<input
|
||
type="password"
|
||
value={credForm.api_secret}
|
||
onChange={(e) => setCredForm({ ...credForm, api_secret: e.target.value })}
|
||
placeholder={currentAccountMeta?.has_api_secret ? '已配置(******),如需更换请重新填写' : '粘贴你的 Binance API SECRET'}
|
||
/>
|
||
</label>
|
||
<label className="accounts-inline">
|
||
<span>测试网</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!credForm.use_testnet}
|
||
onChange={(e) => setCredForm({ ...credForm, use_testnet: e.target.checked })}
|
||
/>
|
||
</label>
|
||
<div className="accounts-form-actions">
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
disabled={systemBusy}
|
||
onClick={async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const payload = {}
|
||
if (credForm.api_key) payload.api_key = credForm.api_key
|
||
if (credForm.api_secret) payload.api_secret = credForm.api_secret
|
||
payload.use_testnet = !!credForm.use_testnet
|
||
await api.updateAccountCredentials(accountId, payload)
|
||
setMessage('密钥已更新(建议重启该账号交易进程)')
|
||
setCredForm({ api_key: '', api_secret: '', use_testnet: !!credForm.use_testnet })
|
||
} catch (e) {
|
||
setMessage('更新密钥失败: ' + (e?.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
保存密钥
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 系统控制:清缓存 / 启停 / 重启(supervisor) */}
|
||
{isAdmin ? (
|
||
<div className="system-section">
|
||
<div className="system-header">
|
||
<h3>系统控制</h3>
|
||
<div className="system-status">
|
||
<span className={`system-status-badge ${systemStatus?.running ? 'running' : 'stopped'}`}>
|
||
{systemStatus?.running ? '运行中' : '未运行'}
|
||
</span>
|
||
{systemStatus?.pid ? <span className="system-status-meta">PID: {systemStatus.pid}</span> : null}
|
||
{systemStatus?.program ? <span className="system-status-meta">程序: {systemStatus.program}</span> : null}
|
||
{systemStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {systemStatus.meta.requested_at}</span> : null}
|
||
</div>
|
||
</div>
|
||
<div className="system-actions">
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
onClick={handleClearCache}
|
||
disabled={systemBusy}
|
||
title="清理Redis配置缓存并从数据库回灌。切换API Key后建议先点这里,再重启交易系统。"
|
||
>
|
||
清除缓存
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
onClick={handleStopTrading}
|
||
disabled={systemBusy || systemStatus?.running === false}
|
||
title="通过 supervisorctl 停止交易系统"
|
||
>
|
||
停止
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
onClick={handleStartTrading}
|
||
disabled={systemBusy || systemStatus?.running === true}
|
||
title="通过 supervisorctl 启动交易系统"
|
||
>
|
||
启动
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
onClick={handleRestartTrading}
|
||
disabled={systemBusy}
|
||
title="通过 supervisorctl 重启交易系统(建议切换API Key后使用)"
|
||
>
|
||
重启交易系统
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
onClick={handleRestartAllTrading}
|
||
disabled={systemBusy}
|
||
title="批量重启所有账号交易进程(auto_sys_acc*),用于代码升级后统一生效"
|
||
>
|
||
重启所有账号交易
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
onClick={handleRestartBackend}
|
||
disabled={systemBusy}
|
||
title="通过 backend/restart.sh 重启后端(uvicorn)。重启期间接口会短暂不可用。"
|
||
>
|
||
重启后端服务
|
||
</button>
|
||
</div>
|
||
<div className="system-status" style={{ marginTop: '10px' }}>
|
||
<span className={`system-status-badge ${backendStatus?.running ? 'running' : 'stopped'}`}>
|
||
后端 {backendStatus?.running ? '运行中' : '未知'}
|
||
</span>
|
||
{backendStatus?.pid ? <span className="system-status-meta">PID: {backendStatus.pid}</span> : null}
|
||
{backendStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {backendStatus.meta.requested_at}</span> : null}
|
||
</div>
|
||
<div className="system-hint">
|
||
建议流程:先更新配置里的 Key → 点击“清除缓存” → 点击“重启交易系统”,确保不再使用旧账号下单。
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 账号管理(超管) */}
|
||
{isAdmin ? (
|
||
<div className="accounts-admin-section">
|
||
<div className="accounts-admin-header">
|
||
<h3>账号管理(多账号)</h3>
|
||
<div className="accounts-admin-actions">
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
onClick={async () => {
|
||
setAccountsBusy(true)
|
||
try {
|
||
await loadAccountsAdmin()
|
||
setShowAccountsAdmin((v) => !v)
|
||
} finally {
|
||
setAccountsBusy(false)
|
||
}
|
||
}}
|
||
disabled={accountsBusy}
|
||
title="创建/禁用账号;为每个账号配置独立 API KEY/SECRET;交易/配置/统计会按账号隔离"
|
||
>
|
||
{showAccountsAdmin ? '收起' : '管理账号'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
onClick={async () => {
|
||
setAccountsBusy(true)
|
||
try {
|
||
await loadAccountsAdmin()
|
||
notifyAccountsUpdated()
|
||
setMessage('账号列表已刷新')
|
||
} finally {
|
||
setAccountsBusy(false)
|
||
}
|
||
}}
|
||
disabled={accountsBusy}
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{showAccountsAdmin ? (
|
||
<div className="accounts-admin-body">
|
||
<div className="accounts-admin-card">
|
||
<div className="accounts-admin-card-title">新增账号</div>
|
||
<div className="accounts-form">
|
||
<label>
|
||
名称
|
||
<input
|
||
type="text"
|
||
value={newAccount.name}
|
||
onChange={(e) => setNewAccount({ ...newAccount, name: e.target.value })}
|
||
placeholder="例如:user_a"
|
||
/>
|
||
</label>
|
||
<label>
|
||
API KEY
|
||
<input
|
||
type="password"
|
||
value={newAccount.api_key}
|
||
onChange={(e) => setNewAccount({ ...newAccount, api_key: e.target.value })}
|
||
placeholder="可先留空,后续再填"
|
||
/>
|
||
</label>
|
||
<label>
|
||
API SECRET
|
||
<input
|
||
type="password"
|
||
value={newAccount.api_secret}
|
||
onChange={(e) => setNewAccount({ ...newAccount, api_secret: e.target.value })}
|
||
placeholder="可先留空,后续再填"
|
||
/>
|
||
</label>
|
||
<label className="accounts-inline">
|
||
<span>测试网</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!newAccount.use_testnet}
|
||
onChange={(e) => setNewAccount({ ...newAccount, use_testnet: e.target.checked })}
|
||
/>
|
||
</label>
|
||
<label>
|
||
状态
|
||
<select
|
||
value={newAccount.status}
|
||
onChange={(e) => setNewAccount({ ...newAccount, status: e.target.value })}
|
||
>
|
||
<option value="active">启用</option>
|
||
<option value="disabled">禁用</option>
|
||
</select>
|
||
</label>
|
||
<div className="accounts-form-actions">
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
disabled={accountsBusy || !newAccount.name.trim()}
|
||
onClick={async () => {
|
||
setAccountsBusy(true)
|
||
setMessage('')
|
||
try {
|
||
await api.createAccount(newAccount)
|
||
setMessage('账号已创建')
|
||
setNewAccount({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' })
|
||
await loadAccountsAdmin()
|
||
notifyAccountsUpdated()
|
||
} catch (e) {
|
||
setMessage('创建账号失败: ' + (e?.message || '未知错误'))
|
||
} finally {
|
||
setAccountsBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
创建账号
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="accounts-admin-card">
|
||
<div className="accounts-admin-card-title">账号列表</div>
|
||
<div className="accounts-table">
|
||
{(accountsAdmin || []).length ? (
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>名称</th>
|
||
<th>状态</th>
|
||
<th>测试网</th>
|
||
<th>API KEY</th>
|
||
<th>SECRET</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{accountsAdmin.map((a) => (
|
||
<tr key={a.id}>
|
||
<td>#{a.id}</td>
|
||
<td>{a.name || '-'}</td>
|
||
<td>
|
||
<span className={`acct-badge ${a.status === 'active' ? 'ok' : 'off'}`}>
|
||
{a.status === 'active' ? '启用' : '禁用'}
|
||
</span>
|
||
</td>
|
||
<td>{a.use_testnet ? '是' : '否'}</td>
|
||
<td>{a.api_key_masked || (a.has_api_key ? '已配置' : '未配置')}</td>
|
||
<td>{a.has_api_secret ? '已配置' : '未配置'}</td>
|
||
<td className="accounts-actions-cell">
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
disabled={accountsBusy || a.id === 1}
|
||
title={a.id === 1 ? '默认账号建议保留' : '切换启用/禁用'}
|
||
onClick={async () => {
|
||
setAccountsBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const next = a.status === 'active' ? 'disabled' : 'active'
|
||
await api.updateAccount(a.id, { status: next })
|
||
await loadAccountsAdmin()
|
||
notifyAccountsUpdated()
|
||
setMessage(`账号 #${a.id} 已${next === 'active' ? '启用' : '禁用'}`)
|
||
} catch (e) {
|
||
setMessage('更新账号失败: ' + (e?.message || '未知错误'))
|
||
} finally {
|
||
setAccountsBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
{a.status === 'active' ? '禁用' : '启用'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
disabled={accountsBusy}
|
||
onClick={() => {
|
||
setCredEditId(a.id)
|
||
setCredForm({ api_key: '', api_secret: '', use_testnet: !!a.use_testnet })
|
||
}}
|
||
>
|
||
更新密钥
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<div className="accounts-empty">暂无账号(默认账号 #1 会自动存在)</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{credEditId ? (
|
||
<div className="accounts-admin-card">
|
||
<div className="accounts-admin-card-title">更新账号 #{credEditId} 的密钥</div>
|
||
<div className="accounts-form">
|
||
<label>
|
||
API KEY(留空=不改)
|
||
<input
|
||
type="password"
|
||
value={credForm.api_key}
|
||
onChange={(e) => setCredForm({ ...credForm, api_key: e.target.value })}
|
||
/>
|
||
</label>
|
||
<label>
|
||
API SECRET(留空=不改)
|
||
<input
|
||
type="password"
|
||
value={credForm.api_secret}
|
||
onChange={(e) => setCredForm({ ...credForm, api_secret: e.target.value })}
|
||
/>
|
||
</label>
|
||
<label className="accounts-inline">
|
||
<span>测试网</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!credForm.use_testnet}
|
||
onChange={(e) => setCredForm({ ...credForm, use_testnet: e.target.checked })}
|
||
/>
|
||
</label>
|
||
<div className="accounts-form-actions">
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
disabled={accountsBusy}
|
||
onClick={() => setCredEditId(null)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
disabled={accountsBusy}
|
||
onClick={async () => {
|
||
setAccountsBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const payload = {}
|
||
if (credForm.api_key) payload.api_key = credForm.api_key
|
||
if (credForm.api_secret) payload.api_secret = credForm.api_secret
|
||
payload.use_testnet = !!credForm.use_testnet
|
||
await api.updateAccountCredentials(credEditId, payload)
|
||
setMessage(`账号 #${credEditId} 密钥已更新(建议重启该账号交易进程)`)
|
||
setCredEditId(null)
|
||
await loadAccountsAdmin()
|
||
notifyAccountsUpdated()
|
||
} catch (e) {
|
||
setMessage('更新密钥失败: ' + (e?.message || '未知错误'))
|
||
} finally {
|
||
setAccountsBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 预设方案快速切换(仅管理员 + 全局策略账号:策略核心统一管理) */}
|
||
{isAdmin && isGlobalStrategyAccount ? (
|
||
<div className="preset-section">
|
||
<div className="preset-header">
|
||
<h3>快速切换方案</h3>
|
||
<div className="current-preset-status">
|
||
<span className="status-label">当前方案:</span>
|
||
<span className={`status-badge ${currentPreset ? 'preset' : 'custom'}`}>
|
||
{currentPreset ? presets[currentPreset].name : '自定义'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="preset-guide">
|
||
<div className="preset-guide-title">怎么选更不迷糊</div>
|
||
<ul className="preset-guide-list">
|
||
<li>
|
||
<strong>先选入场机制</strong>:纯限价(更控频但可能撤单) vs 智能入场(更少漏单但需限制追价)。
|
||
</li>
|
||
<li>
|
||
<strong>再看“会不会下单”</strong>:如果你发现几乎不出单,优先把 <code>AUTO_TRADE_ONLY_TRENDING</code> 关掉、把 <code>AUTO_TRADE_ALLOW_4H_NEUTRAL</code> 打开。
|
||
</li>
|
||
<li>
|
||
<strong>最后再微调</strong>:想更容易成交 → 调小 <code>LIMIT_ORDER_OFFSET_PCT</code>、调大 <code>ENTRY_CONFIRM_TIMEOUT_SEC</code>。
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{(() => {
|
||
const presetUiMeta = {
|
||
swing: { group: 'limit', tag: '纯限价' },
|
||
strict: { group: 'limit', tag: '纯限价' },
|
||
fill: { group: 'smart', tag: '智能入场' },
|
||
steady: { group: 'smart', tag: '智能入场' },
|
||
conservative: { group: 'legacy', tag: '传统' },
|
||
balanced: { group: 'legacy', tag: '传统' },
|
||
aggressive: { group: 'legacy', tag: '高频实验' },
|
||
}
|
||
|
||
const groups = [
|
||
{
|
||
key: 'limit',
|
||
title: 'A. 纯限价(SMART_ENTRY_ENABLED=false)',
|
||
desc: '只下 1 次限价单,未在确认时间内成交就撤单跳过。更控频、更接近“波段”,但更容易出现 NEW→撤单。',
|
||
presetKeys: ['swing', 'strict'],
|
||
},
|
||
{
|
||
key: 'smart',
|
||
title: 'B. 智能入场(SMART_ENTRY_ENABLED=true)',
|
||
desc: '限价回调 + 受限追价 +(趋势强时)可控市价兜底。更少漏单,但必须限制追价步数与偏离上限,避免回到高频追价。',
|
||
presetKeys: ['fill', 'steady'],
|
||
},
|
||
{
|
||
key: 'legacy',
|
||
title: 'C. 传统 / 实验(不建议长期)',
|
||
desc: '这组更多用于对比或临时实验(频率更高/更容易过度交易),建议在稳定盈利前谨慎使用。',
|
||
presetKeys: ['conservative', 'balanced', 'aggressive'],
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="preset-groups">
|
||
{groups.map((g) => (
|
||
<div key={g.key} className="preset-group">
|
||
<div className="preset-group-header">
|
||
<div className="preset-group-title">{g.title}</div>
|
||
<div className="preset-group-desc">{g.desc}</div>
|
||
</div>
|
||
<div className="preset-buttons">
|
||
{g.presetKeys
|
||
.filter((k) => presets[k])
|
||
.map((k) => {
|
||
const preset = presets[k]
|
||
const meta = presetUiMeta[k] || { group: g.key, tag: '' }
|
||
return (
|
||
<button
|
||
key={k}
|
||
className={`preset-btn ${currentPreset === k ? 'active' : ''}`}
|
||
onClick={() => applyPreset(k)}
|
||
disabled={saving}
|
||
title={preset.desc}
|
||
>
|
||
<div className="preset-name">
|
||
{preset.name}
|
||
{meta.tag ? (
|
||
<span className={`preset-tag preset-tag--${meta.group}`}>{meta.tag}</span>
|
||
) : null}
|
||
{currentPreset === k ? <span className="active-indicator">✓</span> : null}
|
||
</div>
|
||
<div className="preset-desc">{preset.desc}</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
) : (
|
||
<div className="system-hint" style={{ marginTop: '12px' }}>
|
||
平台已开启“傻瓜化模式”:策略核心由管理员统一管理。你只需要配置密钥、充值余额,并调整少量风控参数(如最小/最大仓位、每日开仓次数等)。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 配置可行性检查提示 */}
|
||
{feasibilityCheck && (
|
||
<div className={`feasibility-check ${feasibilityCheck.feasible ? 'feasible' : 'infeasible'}`}>
|
||
<div className="feasibility-header">
|
||
<h4>
|
||
{feasibilityCheck.feasible ? '✓ 配置可行' : '⚠ 配置冲突'}
|
||
</h4>
|
||
<button
|
||
onClick={checkFeasibility}
|
||
disabled={checkingFeasibility}
|
||
className="refresh-btn"
|
||
title="重新检查"
|
||
>
|
||
{checkingFeasibility ? '检查中...' : '🔄 刷新'}
|
||
</button>
|
||
</div>
|
||
<div className="feasibility-info">
|
||
<p>
|
||
账户余额: <strong>{feasibilityCheck.account_balance?.toFixed(2) || 'N/A'}</strong> USDT |
|
||
基础杠杆: <strong>{feasibilityCheck.base_leverage || feasibilityCheck.leverage || 'N/A'}x</strong>
|
||
{feasibilityCheck.use_dynamic_leverage && feasibilityCheck.max_leverage && (
|
||
<> | 最大杠杆: <strong>{feasibilityCheck.max_leverage}x</strong></>
|
||
)} |
|
||
最小保证金: <strong>{feasibilityCheck.current_config?.min_margin_usdt?.toFixed(2) || 'N/A'}</strong> USDT
|
||
</p>
|
||
{!feasibilityCheck.feasible && (
|
||
<div className="warning-text">
|
||
<p>
|
||
需要保证金占比: <strong>{feasibilityCheck.calculated_values?.required_position_percent?.toFixed(1)}%</strong> |
|
||
最大允许保证金占比: <strong>{feasibilityCheck.calculated_values?.max_allowed_position_percent?.toFixed(1)}%</strong>
|
||
</p>
|
||
{feasibilityCheck.calculated_values?.actual_min_margin !== undefined && (
|
||
<p>
|
||
最小保证金占比可提供保证金: <strong>{feasibilityCheck.calculated_values.actual_min_margin.toFixed(2)} USDT</strong>
|
||
(MIN_POSITION_PERCENT={feasibilityCheck.calculated_values.min_position_percent?.toFixed(1)}%) |
|
||
最小保证金要求: <strong>{feasibilityCheck.current_config?.min_margin_usdt?.toFixed(2)} USDT</strong>
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{feasibilityCheck.suggestions && feasibilityCheck.suggestions.length > 0 && (
|
||
<div className="feasibility-suggestions">
|
||
<h5>建议方案:</h5>
|
||
{feasibilityCheck.suggestions.map((suggestion, index) => {
|
||
// 如果是杠杆分析结果,显示详细表格
|
||
if (suggestion.type === 'leverage_analysis' && suggestion.leverage_results) {
|
||
return (
|
||
<div key={index} className="suggestion-item leverage-analysis">
|
||
<div className="suggestion-header">
|
||
<strong>{suggestion.title}</strong>
|
||
</div>
|
||
<div className="leverage-table">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>杠杆倍数</th>
|
||
<th>需要保证金(USDT)</th>
|
||
<th>需要保证金%</th>
|
||
<th>实际最小保证金</th>
|
||
<th>状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{suggestion.leverage_results.map((result, idx) => (
|
||
<tr key={idx} className={result.feasible ? 'feasible-row' : 'infeasible-row'}>
|
||
<td><strong>{result.leverage}x</strong></td>
|
||
<td>{result.required_position_value.toFixed(2)} USDT</td>
|
||
<td>{result.required_position_percent.toFixed(1)}%</td>
|
||
<td>{result.actual_min_margin.toFixed(2)} USDT</td>
|
||
<td>
|
||
<span className={result.feasible ? 'status-ok' : 'status-error'}>
|
||
{result.feasible ? '✓ 可行' : '✗ 不可行'}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 普通建议
|
||
return (
|
||
<div key={index} className="suggestion-item">
|
||
<div className="suggestion-header">
|
||
<strong>{suggestion.title}</strong>
|
||
</div>
|
||
<p className="suggestion-desc">{suggestion.description}</p>
|
||
{suggestion.config_key && suggestion.suggested_value !== null && (
|
||
<button
|
||
className="apply-suggestion-btn"
|
||
onClick={async () => {
|
||
try {
|
||
await api.updateConfig(suggestion.config_key, {
|
||
value: suggestion.suggested_value,
|
||
type: 'number',
|
||
category: 'position'
|
||
})
|
||
setMessage(`已应用建议: ${suggestion.title}`)
|
||
await loadConfigs()
|
||
await checkFeasibility()
|
||
} catch (error) {
|
||
setMessage('应用建议失败: ' + error.message)
|
||
}
|
||
}}
|
||
>
|
||
应用此建议
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{message && (
|
||
<div className={`message ${message.includes('失败') || message.includes('错误') ? 'error' : 'success'}`}>
|
||
{message}
|
||
</div>
|
||
)}
|
||
|
||
{Object.entries(configCategories).map(([category, label]) => (
|
||
<section key={category} className="config-section">
|
||
<h3>{label}</h3>
|
||
<div className="config-grid">
|
||
{Object.entries(configs)
|
||
.filter(([key, config]) => {
|
||
if (config.category !== category) return false
|
||
// 密钥相关配置统一在“账号密钥(当前账号)”模块管理,这里不重复展示
|
||
if (key === 'BINANCE_API_KEY' || key === 'BINANCE_API_SECRET' || key === 'USE_TESTNET') return false
|
||
return true
|
||
})
|
||
.map(([key, config]) => (
|
||
<ConfigItem
|
||
key={key}
|
||
label={key}
|
||
config={config}
|
||
onUpdate={(value) => handleUpdate(key, value, config.type, config.category)}
|
||
disabled={saving}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
|
||
{showSnapshot && (
|
||
<div className="snapshot-modal-overlay" onClick={() => setShowSnapshot(false)} role="presentation">
|
||
<div className="snapshot-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="snapshot-modal-header">
|
||
<div>
|
||
<h3>当前整体配置快照</h3>
|
||
<div className="snapshot-hint">
|
||
默认脱敏 BINANCE_API_KEY/SECRET。你可以选择明文后重新生成再复制/下载。
|
||
</div>
|
||
</div>
|
||
<button type="button" className="snapshot-close" onClick={() => setShowSnapshot(false)}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
|
||
<div className="snapshot-toolbar">
|
||
<label className="snapshot-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={snapshotIncludeSecrets}
|
||
onChange={async (e) => {
|
||
const checked = e.target.checked
|
||
setSnapshotIncludeSecrets(checked)
|
||
// 重新生成(确保脱敏/明文一致)
|
||
await openSnapshot(checked)
|
||
}}
|
||
/>
|
||
显示敏感信息(明文)
|
||
</label>
|
||
|
||
<div className="snapshot-actions">
|
||
<button type="button" className="system-btn" onClick={copySnapshot}>
|
||
复制
|
||
</button>
|
||
<button type="button" className="system-btn primary" onClick={downloadSnapshot}>
|
||
下载 JSON
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<pre className="snapshot-pre">{snapshotText}</pre>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const ConfigItem = ({ label, config, onUpdate, disabled }) => {
|
||
const isPercentKey = label.includes('PERCENT') || label.includes('PCT')
|
||
const PCT_LIKE_KEYS = new Set([
|
||
'LIMIT_ORDER_OFFSET_PCT',
|
||
'ENTRY_MAX_DRIFT_PCT_TRENDING',
|
||
'ENTRY_MAX_DRIFT_PCT_RANGING',
|
||
])
|
||
const isPctLike = PCT_LIKE_KEYS.has(label)
|
||
const isRatioPercentKey = isPercentKey && !isPctLike
|
||
|
||
const formatPercent = (n) => {
|
||
// 最多 4 位小数,去掉尾随 0(例如 2.3000 -> 2.3)
|
||
if (typeof n !== 'number' || isNaN(n)) return ''
|
||
return n.toFixed(4).replace(/\.?0+$/, '')
|
||
}
|
||
|
||
// 初始化显示值:百分比配置转为“百分比数值”(支持小数)
|
||
const getInitialDisplayValue = (val) => {
|
||
if (config.type === 'number' && isPercentKey) {
|
||
if (val === null || val === undefined || val === '') {
|
||
return ''
|
||
}
|
||
const numVal = typeof val === 'string' ? parseFloat(val) : val
|
||
if (isNaN(numVal)) {
|
||
return ''
|
||
}
|
||
// 两类:
|
||
// 1) 常规比例型(如 STOP_LOSS_PERCENT=0.08):展示为 8(%)
|
||
// 2) pct-like(如 LIMIT_ORDER_OFFSET_PCT=0.5 表示0.5%):展示为 0.5(%)
|
||
if (isPctLike) {
|
||
const pctNum = numVal <= 0.05 ? numVal * 100 : numVal
|
||
return formatPercent(pctNum)
|
||
}
|
||
const percent = numVal <= 1 ? numVal * 100 : numVal
|
||
return formatPercent(percent)
|
||
}
|
||
return val === null || val === undefined ? '' : val
|
||
}
|
||
|
||
const [value, setValue] = useState(config.value)
|
||
const [localValue, setLocalValue] = useState(getInitialDisplayValue(config.value))
|
||
const [isEditing, setIsEditing] = useState(false)
|
||
const [showDetail, setShowDetail] = useState(false)
|
||
|
||
useEffect(() => {
|
||
setValue(config.value)
|
||
// 当配置值更新时,重置编辑状态和本地值
|
||
setIsEditing(false)
|
||
setLocalValue(getInitialDisplayValue(config.value))
|
||
}, [config.value])
|
||
|
||
const handleChange = (newValue) => {
|
||
setLocalValue(newValue)
|
||
setIsEditing(true)
|
||
}
|
||
|
||
const handleSave = () => {
|
||
setIsEditing(false)
|
||
|
||
// 处理localValue可能是字符串的情况
|
||
let processedValue = localValue
|
||
if (config.type === 'number') {
|
||
// 如果是空字符串,恢复原值
|
||
if (localValue === '' || localValue === null || localValue === undefined) {
|
||
setLocalValue(value)
|
||
return
|
||
}
|
||
|
||
// 处理字符串形式的数字输入(包括部分输入如 "0." 或 ".")
|
||
let numValue
|
||
if (typeof localValue === 'string') {
|
||
// 如果字符串以 "." 结尾(如 "0." 或 "."),尝试补全为有效数字
|
||
if (localValue.endsWith('.') && localValue.length > 1) {
|
||
// "0." -> 0, "1." -> 1
|
||
numValue = parseFloat(localValue)
|
||
} else if (localValue === '.' || localValue === '-.') {
|
||
// "." 或 "-." 视为无效,恢复原值
|
||
const restoreValue = isPercentKey
|
||
? (typeof value === 'number' ? formatPercent((value <= 1 ? value * 100 : value)) : value)
|
||
: value
|
||
setLocalValue(restoreValue)
|
||
return
|
||
} else {
|
||
numValue = parseFloat(localValue)
|
||
}
|
||
} else {
|
||
numValue = localValue
|
||
}
|
||
|
||
if (isNaN(numValue)) {
|
||
// 如果输入无效,恢复原值
|
||
const restoreValue = isPercentKey
|
||
? (typeof value === 'number' ? formatPercent((value <= 1 ? value * 100 : value)) : value)
|
||
: value
|
||
setLocalValue(restoreValue)
|
||
return
|
||
}
|
||
|
||
processedValue = numValue
|
||
// 百分比配置
|
||
if (isPercentKey) {
|
||
if (isPctLike) {
|
||
// pct-like:值本身就是“百分比数值”(<=1表示<=1%),不做 /100
|
||
if (processedValue < 0 || processedValue > 1) {
|
||
setLocalValue(getInitialDisplayValue(value))
|
||
return
|
||
}
|
||
} else {
|
||
// 常规比例型:前端按“百分比”输入(0~100),存储为 0~1
|
||
if (processedValue < 0 || processedValue > 100) {
|
||
setLocalValue(getInitialDisplayValue(value))
|
||
return
|
||
}
|
||
processedValue = processedValue / 100
|
||
}
|
||
}
|
||
} else if (config.type === 'boolean') {
|
||
processedValue = localValue === 'true' || localValue === true
|
||
}
|
||
|
||
// 只有当值真正发生变化时才保存
|
||
if (processedValue !== value) {
|
||
onUpdate(processedValue)
|
||
} else {
|
||
// 值没变化,但需要更新显示值
|
||
setValue(processedValue)
|
||
}
|
||
}
|
||
|
||
const handleBlur = () => {
|
||
// 移动端不自动保存,需要点击保存按钮
|
||
if (window.innerWidth > 768) {
|
||
handleSave()
|
||
}
|
||
}
|
||
|
||
const handleKeyPress = (e) => {
|
||
if (e.key === 'Enter') {
|
||
handleSave()
|
||
}
|
||
}
|
||
|
||
const displayValue = localValue === null || localValue === undefined ? '' : localValue
|
||
|
||
if (config.type === 'boolean') {
|
||
return (
|
||
<div className="config-item">
|
||
<div className="config-item-header">
|
||
<label>{label}</label>
|
||
{config.description && (
|
||
<button
|
||
className="help-btn"
|
||
onClick={() => setShowDetail(!showDetail)}
|
||
type="button"
|
||
>
|
||
{showDetail ? '收起' : '说明'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{showDetail && (
|
||
<div className="config-detail-popup">
|
||
{getConfigDetail(label)}
|
||
</div>
|
||
)}
|
||
<select
|
||
value={localValue ? 'true' : 'false'}
|
||
onChange={(e) => {
|
||
handleChange(e.target.value)
|
||
// 布尔值立即保存
|
||
onUpdate(e.target.value === 'true')
|
||
}}
|
||
disabled={disabled}
|
||
>
|
||
<option value="true">是</option>
|
||
<option value="false">否</option>
|
||
</select>
|
||
{config.description && (
|
||
<span className="description">
|
||
{config.description}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (label.includes('INTERVAL') && ['PRIMARY_INTERVAL', 'CONFIRM_INTERVAL', 'ENTRY_INTERVAL', 'KLINE_INTERVAL'].includes(label)) {
|
||
const options = ['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '1d']
|
||
return (
|
||
<div className="config-item">
|
||
<div className="config-item-header">
|
||
<label>{label}</label>
|
||
{config.description && (
|
||
<button
|
||
className="help-btn"
|
||
onClick={() => setShowDetail(!showDetail)}
|
||
type="button"
|
||
>
|
||
{showDetail ? '收起' : '说明'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{showDetail && (
|
||
<div className="config-detail-popup">
|
||
{getConfigDetail(label)}
|
||
</div>
|
||
)}
|
||
<select
|
||
value={localValue}
|
||
onChange={(e) => {
|
||
handleChange(e.target.value)
|
||
// 下拉框立即保存
|
||
onUpdate(e.target.value)
|
||
}}
|
||
disabled={disabled}
|
||
>
|
||
{options.map(opt => (
|
||
<option key={opt} value={opt}>{opt}</option>
|
||
))}
|
||
</select>
|
||
{config.description && (
|
||
<span className="description">
|
||
{config.description}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="config-item">
|
||
<div className="config-item-header">
|
||
<label>{label}</label>
|
||
{config.description && (
|
||
<button
|
||
className="help-btn"
|
||
onClick={() => setShowDetail(!showDetail)}
|
||
type="button"
|
||
>
|
||
{showDetail ? '收起' : '说明'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{showDetail && (
|
||
<div className="config-detail-popup">
|
||
{getConfigDetail(label)}
|
||
</div>
|
||
)}
|
||
<div className="config-input-wrapper">
|
||
<input
|
||
type="text"
|
||
inputMode={config.type === 'number' ? 'decimal' : 'text'}
|
||
value={displayValue === '' ? '' : String(displayValue)}
|
||
onChange={(e) => {
|
||
// 使用文本输入,避免number类型的自动补0问题
|
||
let newValue = e.target.value
|
||
|
||
// 对于数字类型,只允许数字、小数点和负号
|
||
if (config.type === 'number') {
|
||
// 允许空字符串、数字、小数点和负号
|
||
// const validPattern = /^-?\d*\.?\d*$/
|
||
// if (newValue !== '' && !validPattern.test(newValue)) {
|
||
// // 无效输入,不更新
|
||
// return
|
||
// }
|
||
|
||
// 如果是百分比配置,限制输入范围(0-100)
|
||
if (isPercentKey) {
|
||
const numValue = parseFloat(newValue)
|
||
const maxAllowed = isPctLike ? 1 : 100
|
||
if (newValue !== '' && !isNaN(numValue) && (numValue < 0 || numValue > maxAllowed)) {
|
||
// 超出范围,不更新
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新本地值
|
||
if (config.type === 'number' && isPercentKey) {
|
||
// 百分比配置:允许字符串中间态(如 "2."),并支持小数
|
||
if (newValue === '') {
|
||
handleChange('')
|
||
} else {
|
||
const validPattern = /^-?\d*\.?\d*$/
|
||
if (validPattern.test(newValue)) {
|
||
handleChange(newValue)
|
||
}
|
||
}
|
||
} else if (config.type === 'number') {
|
||
// 其他数字配置:允许字符串形式的中间状态(如 "0." 或 "."),支持小数输入
|
||
if (newValue === '') {
|
||
handleChange('')
|
||
} else {
|
||
// 允许部分输入状态(如 "0.", ".5", "-" 等)
|
||
// 使用正则验证是否为有效的数字格式(包括部分输入)
|
||
const validPattern = /^-?\d*\.?\d*$/
|
||
if (validPattern.test(newValue)) {
|
||
// 如果是有效的数字格式(包括部分输入),直接保存字符串
|
||
handleChange(newValue)
|
||
}
|
||
// 如果无效,不更新(保持原值)
|
||
}
|
||
} else {
|
||
handleChange(newValue)
|
||
}
|
||
}}
|
||
onBlur={handleBlur}
|
||
onKeyPress={handleKeyPress}
|
||
onKeyDown={(e) => {
|
||
// 允许删除、退格、方向键等
|
||
if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
|
||
return
|
||
}
|
||
// 对于数字输入,只允许数字、小数点和负号
|
||
if (config.type === 'number') {
|
||
const currentValue = String(localValue || '')
|
||
// 如果已经包含小数点,不允许再次输入小数点
|
||
if (e.key === '.' && currentValue.includes('.')) {
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
// 如果已经包含负号,不允许再次输入负号(除非是删除操作)
|
||
if (e.key === '-' && currentValue.includes('-')) {
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
const validKeys = /^[0-9.-]$/
|
||
if (!validKeys.test(e.key) && !['Enter', 'Escape'].includes(e.key)) {
|
||
e.preventDefault()
|
||
}
|
||
}
|
||
}}
|
||
disabled={disabled}
|
||
className={isEditing ? 'editing' : ''}
|
||
placeholder={isPercentKey ? (isPctLike ? '输入0~1(表示0%~1%)' : '输入百分比(可小数)') : '输入数值'}
|
||
/>
|
||
{isPercentKey && <span className="percent-suffix">%</span>}
|
||
{isEditing && (
|
||
<button
|
||
className="save-btn"
|
||
onClick={handleSave}
|
||
disabled={disabled}
|
||
type="button"
|
||
>
|
||
保存
|
||
</button>
|
||
)}
|
||
</div>
|
||
{isEditing && window.innerWidth > 768 && <span className="edit-hint">按Enter保存</span>}
|
||
{config.description && (
|
||
<span className="description">
|
||
{config.description}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 配置项详细说明
|
||
const getConfigDetail = (key) => {
|
||
const details = {
|
||
// 市场扫描参数
|
||
'SCAN_INTERVAL': '扫描间隔(秒)。系统每隔多长时间扫描一次市场寻找交易机会。值越小扫描越频繁,能更快捕捉波动,但更容易产生噪音与过度交易。建议:低频波段1800秒(30分钟)或更长;稳健/保守可3600秒(1小时);高频不建议长期使用。',
|
||
'MIN_CHANGE_PERCENT': '最小涨跌幅阈值(%)。只有24小时涨跌幅达到此值的交易对才会被考虑交易。值越小捕捉机会越多,但可能包含噪音和假信号。值越大只捕捉大幅波动,信号质量更高但机会更少。建议:保守策略2.0-3.0%,平衡策略1.5-2.0%,激进策略1.0-1.5%。',
|
||
'MIN_SIGNAL_STRENGTH': '最小信号强度(0-10)。技术指标综合评分,只有达到此强度的信号才会执行交易。值越小机会越多但噪音更大;值越大更偏“精选高质量”。建议:低频波段建议≥8;一般保守5-7;平衡4-5;高频3-4(不推荐长期)。',
|
||
'TOP_N_SYMBOLS': '每次扫描后处理的交易对数量。从符合条件的交易对中选择涨跌幅最大的前N个进行详细分析。值越大机会越多,但计算量增加,API请求增多。建议:保守策略8-10个,平衡策略12-15个,激进策略15-20个。',
|
||
'MAX_SCAN_SYMBOLS': '扫描的最大交易对数量(0表示扫描所有)。限制每次扫描时处理的交易对总数,减少API请求和计算量。值越小扫描越快,但可能错过一些机会。值越大覆盖更全面,但API请求和计算量增加。建议:保守策略100-200个,平衡策略200-300个,激进策略300-500个。设置为0会扫描所有交易对(约500+个)。',
|
||
'MIN_VOLATILITY': '最小波动率(小数形式,如0.02表示2%)。过滤掉波动率低于此值的交易对,确保只交易有足够波动的币种。值越小允许更多交易对,但可能包含波动不足的币种。值越大只交易高波动币种,但可能错过一些机会。建议:0.015-0.025(1.5%-2.5%)。',
|
||
'MIN_VOLUME_24H': '最小24小时成交量(USDT)。过滤掉24小时交易量低于此值的交易对,确保只交易流动性好的币种,避免滑点和流动性风险。建议:≥500万USDT,主流币种可设置1000万以上。',
|
||
'KLINE_INTERVAL': 'K线数据周期。获取K线数据的基础周期,影响技术指标的计算粒度。周期越短反应越快,但可能包含更多噪音。周期越长信号更平滑,但反应较慢。建议:5m-1h,通常与PRIMARY_INTERVAL保持一致。',
|
||
'PRIMARY_INTERVAL': '主周期。策略分析的主要时间周期,用于计算技术指标(RSI、MACD、布林带等)。决定策略主要关注的市场趋势级别。周期越短反应越快,周期越长趋势更明确。建议:保守策略4h-1d,平衡策略1h-4h,激进策略15m-1h。',
|
||
'CONFIRM_INTERVAL': '确认周期。用于确认交易信号的更长时间周期,增加信号的可靠性,减少假信号。通常比主周期更长。建议:保守策略1d,平衡策略4h-1d,激进策略1h-4h。',
|
||
'ENTRY_INTERVAL': '入场周期。用于精确入场的短时间周期,优化入场点,提高单笔交易的盈亏比。通常比主周期更短。建议:保守策略1h,平衡策略15m-30m,激进策略5m-15m。',
|
||
|
||
// 仓位控制参数
|
||
'MAX_POSITION_PERCENT': '单笔最大保证金占用(账户余额的百分比,如0.02表示2%)。系统会先按该比例分配“保证金”,再根据杠杆换算名义价值下单。值越小越保守,适合回归波段交易、降低单笔波动对账户的伤害。注意:币安合约有最小名义价值要求(通常≥5 USDT),如果保证金过小且杠杆不够,系统会提示无法满足最小名义价值。',
|
||
'MAX_TOTAL_POSITION_PERCENT': '总保证金上限(账户余额的百分比,如0.20表示20%)。所有持仓占用的保证金之和不能超过该上限,防止过度分散和风险集中。值越小越易控仓,便于管理持仓数量与节奏。',
|
||
'MIN_POSITION_PERCENT': '单笔最小保证金占用(账户余额的百分比,如0.01表示1%)。用于避免过小仓位(意义不大、易受手续费影响)。如果你希望取消这一限制,可设置为0(等价于关闭最小占比)。',
|
||
|
||
// 风险控制参数
|
||
'STOP_LOSS_PERCENT': '止损百分比(如0.08表示8%,相对于保证金)。当亏损达到此百分比时自动平仓止损,限制单笔交易的最大亏损。值越小止损更严格,单笔损失更小但可能被正常波动触发。值越大允许更大的回撤,但单笔损失可能较大。建议:保守策略10-15%,平衡策略8-10%,激进策略5-8%。注意:止损应该小于止盈,建议盈亏比至少1:1.5。系统会结合最小价格变动保护,取更宽松的一个。',
|
||
'TAKE_PROFIT_PERCENT': '止盈百分比(如0.15表示15%,相对于保证金)。当盈利达到此百分比时自动平仓止盈,锁定利润。值越大目标利润更高,但可能错过及时止盈的机会,持仓时间更长。值越小能更快锁定利润,但可能错过更大的趋势。建议:保守策略20-30%,平衡策略15-20%,激进策略10-15%。注意:应该大于止损,建议盈亏比至少1:1.5。系统会结合最小价格变动保护,取更宽松的一个。',
|
||
'MIN_STOP_LOSS_PRICE_PCT': '最小止损价格变动百分比(存储为0~1,前端输入按%显示,支持小数,如1.5%)。用于防止止损过紧:即使基于保证金的止损更紧,也会使用至少该百分比的价格变动。建议:1.5%-3%。',
|
||
'MIN_TAKE_PROFIT_PRICE_PCT': '最小止盈价格变动百分比(存储为0~1,前端输入按%显示,支持小数,如2.5%)。用于防止止盈过紧:即使基于保证金的止盈更紧,也会使用至少该百分比的价格变动。建议:2%-4%。',
|
||
|
||
// 策略参数
|
||
'LEVERAGE': '交易杠杆倍数。放大资金利用率,同时放大收益和风险。杠杆越高,相同仓位下需要的保证金越少,但风险越大。建议:保守策略5-10倍,平衡策略10倍,激进策略10-15倍。注意:高杠杆会增加爆仓风险,请谨慎使用。',
|
||
'USE_TRAILING_STOP': '是否启用移动止损(true/false)。启用后,当盈利达到激活阈值时,止损会自动跟踪价格,保护利润。适合趋势行情,可以捕捉更大的利润空间。建议:平衡和激进策略启用,保守策略可关闭。',
|
||
'SMART_ENTRY_ENABLED': '智能入场开关(true/false)。开启时会进行“限价回调 + 追价 +(趋势强时)市价兜底”,以减少错过;关闭时回归“纯限价单模式”:只下一次限价单,未在确认时间内成交则撤单跳过,更适合低频波段与控频。',
|
||
'AUTO_TRADE_ONLY_TRENDING': '自动交易仅在市场状态=trending时执行(ranging/unknown只生成推荐,不自动下单)。开启能显著降低震荡扫损,但也会减少出单;如果你发现“几乎不下单”,可先关闭。',
|
||
'AUTO_TRADE_ALLOW_4H_NEUTRAL': '是否允许4H趋势=neutral时自动交易。关闭可减少震荡扫损;开启会显著增加出单(但需要配合更严格的信号门槛/控频)。',
|
||
'LIMIT_ORDER_OFFSET_PCT': '限价入场偏移(%)。BUY 会挂在当前价下方回调位置,SELL 会挂在当前价上方反弹位置。值越小越贴近当前价,更容易成交(但可能更“追价”);值越大更保守,但更容易出现“挂单NEW→超时撤单”。建议:0.05%-0.20%。',
|
||
'ENTRY_CONFIRM_TIMEOUT_SEC': '下单后等待成交的确认超时(秒)。在“纯限价模式”下,超时未成交会撤单并跳过;在“智能入场”下则进入后续追价/兜底流程。建议:60-180秒。',
|
||
'ENTRY_CHASE_MAX_STEPS': '智能入场最大追价步数。步数越多越不容易错过,但也更接近追价;建议 2-4 步。',
|
||
'ENTRY_STEP_WAIT_SEC': '智能入场每一步等待成交时间(秒)。过短会频繁撤改单,过长会错过行情;建议 15-25 秒。',
|
||
'ENTRY_MARKET_FALLBACK_AFTER_SEC': '趋势强时市价兜底延迟(秒)。用于减少错过(在追价上限内才会触发),建议 45-90 秒。',
|
||
'ENTRY_MAX_DRIFT_PCT_TRENDING': '趋势强时最大追价偏离(%)。例如 0.3 表示 0.3%。越小越保守、越不追价;建议 0.2%-0.6%。',
|
||
'ENTRY_MAX_DRIFT_PCT_RANGING': '震荡/弱趋势最大追价偏离(%)。例如 0.15 表示 0.15%。越小越保守;建议 0.1%-0.3%。',
|
||
'TRAILING_STOP_ACTIVATION': '移动止损激活阈值(百分比,如0.01表示1%)。当盈利达到此百分比时,移动止损开始跟踪价格,将止损移至成本价(保本)。值越小激活越早,更早保护利润但可能过早退出。值越大激活越晚,给价格更多波动空间。建议:1-2%。',
|
||
'TRAILING_STOP_PROTECT': '移动止损保护利润(百分比,如0.01表示1%)。当价格从最高点回撤达到此百分比时,触发止损平仓,锁定利润。值越小保护更严格,能锁定更多利润但可能过早退出。值越大允许更大回撤,可能捕捉更大趋势但利润可能回吐。建议:1-2%。',
|
||
|
||
// API配置
|
||
'BINANCE_API_KEY': '币安API密钥。用于访问币安账户的凭证,需要启用"合约交易"权限。请妥善保管,不要泄露。',
|
||
'BINANCE_API_SECRET': '币安API密钥。与API Key配对使用,用于签名验证。请妥善保管,不要泄露。',
|
||
'USE_TESTNET': '是否使用币安测试网(true/false)。true表示使用测试网(模拟交易,无真实资金),false表示使用生产网(真实交易)。建议:测试时使用true,正式交易使用false。'
|
||
}
|
||
return details[key] || '暂无详细说明,请参考配置说明文档'
|
||
}
|
||
|
||
export default ConfigPanel
|