mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 17:34:39 +08:00
fix: improve frontend UX and fix OKX close position
Frontend improvements: - Replace window.location.reload() with SWR mutate() for data refresh - Replace native alert/confirm with toast notifications (confirmToast, notify) - Add loading skeletons to AITradersPage and EquityChart - Fix flash of empty state during initial load OKX fixes: - Fix proxy issue in Docker by using explicit no-proxy function - Fix CloseShort sz parameter error - ensure quantity is always positive - Fix GetPositions to return absolute value for positionAmt
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
neturl "net/url"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -95,11 +96,17 @@ func genOkxClOrdID() string {
|
|||||||
return orderID
|
return orderID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noProxyFunc 返回一个始终返回 nil 的代理函数,用于禁用代理
|
||||||
|
func noProxyFunc(req *http.Request) (*neturl.URL, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewOKXTrader 创建OKX交易器
|
// NewOKXTrader 创建OKX交易器
|
||||||
func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
|
func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
|
||||||
// 创建禁用代理的 HTTP 客户端
|
// 创建完全禁用代理的 HTTP 客户端
|
||||||
|
// 这对于 Docker 容器环境很重要,因为容器可能继承宿主机的代理环境变量
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
Proxy: nil, // 显式禁用代理
|
Proxy: noProxyFunc,
|
||||||
}
|
}
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -341,11 +348,14 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
|
|||||||
// 转换symbol格式
|
// 转换symbol格式
|
||||||
symbol := t.convertSymbolBack(pos.InstId)
|
symbol := t.convertSymbolBack(pos.InstId)
|
||||||
|
|
||||||
// 确定方向
|
// 确定方向,并确保 posAmt 是正数
|
||||||
side := "long"
|
side := "long"
|
||||||
if pos.PosSide == "short" || posAmt < 0 {
|
if pos.PosSide == "short" {
|
||||||
side = "short"
|
side = "short"
|
||||||
posAmt = -posAmt // 取绝对值
|
}
|
||||||
|
// OKX 空仓的 pos 是负数,需要取绝对值
|
||||||
|
if posAmt < 0 {
|
||||||
|
posAmt = -posAmt
|
||||||
}
|
}
|
||||||
|
|
||||||
posMap := map[string]interface{}{
|
posMap := map[string]interface{}{
|
||||||
@@ -726,9 +736,13 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
logger.Infof("🔍 OKX CloseShort 查找持仓: symbol=%s, 当前持仓数=%d", symbol, len(positions))
|
||||||
for _, pos := range positions {
|
for _, pos := range positions {
|
||||||
|
logger.Infof("🔍 OKX 持仓: symbol=%v, side=%v, positionAmt=%v",
|
||||||
|
pos["symbol"], pos["side"], pos["positionAmt"])
|
||||||
if pos["symbol"] == symbol && pos["side"] == "short" {
|
if pos["symbol"] == symbol && pos["side"] == "short" {
|
||||||
quantity = pos["positionAmt"].(float64) // 这已经是张数
|
quantity = pos["positionAmt"].(float64)
|
||||||
|
logger.Infof("🔍 OKX 找到空仓: quantity=%f", quantity)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -737,12 +751,20 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确保 quantity 是正数(OKX sz 参数必须为正)
|
||||||
|
if quantity < 0 {
|
||||||
|
quantity = -quantity
|
||||||
|
}
|
||||||
|
|
||||||
// 获取合约信息用于格式化张数
|
// 获取合约信息用于格式化张数
|
||||||
inst, err := t.getInstrument(symbol)
|
inst, err := t.getInstrument(symbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("获取合约信息失败: %w", err)
|
return nil, fmt.Errorf("获取合约信息失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("🔍 OKX 合约信息: instId=%s, lotSz=%f, minSz=%f, ctVal=%f",
|
||||||
|
inst.InstID, inst.LotSz, inst.MinSz, inst.CtVal)
|
||||||
|
|
||||||
// quantity 已经是张数,直接格式化
|
// quantity 已经是张数,直接格式化
|
||||||
szStr := t.formatSize(quantity, inst)
|
szStr := t.formatSize(quantity, inst)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import useSWR from 'swr'
|
import useSWR, { mutate } from 'swr'
|
||||||
import { api } from './lib/api'
|
import { api } from './lib/api'
|
||||||
import { ChartTabs } from './components/ChartTabs'
|
import { ChartTabs } from './components/ChartTabs'
|
||||||
import { AITradersPage } from './components/AITradersPage'
|
import { AITradersPage } from './components/AITradersPage'
|
||||||
@@ -15,6 +15,7 @@ import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
|||||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
import { ConfirmDialogProvider } from './components/ConfirmDialog'
|
import { ConfirmDialogProvider } from './components/ConfirmDialog'
|
||||||
import { t, type Language } from './i18n/translations'
|
import { t, type Language } from './i18n/translations'
|
||||||
|
import { confirmToast, notify } from './lib/notify'
|
||||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||||
import { DecisionCard } from './components/DecisionCard'
|
import { DecisionCard } from './components/DecisionCard'
|
||||||
import { BacktestPage } from './components/BacktestPage'
|
import { BacktestPage } from './components/BacktestPage'
|
||||||
@@ -535,17 +536,26 @@ function TraderDetailsPage({
|
|||||||
? `确定要平仓 ${symbol} ${side === 'LONG' ? '多仓' : '空仓'} 吗?`
|
? `确定要平仓 ${symbol} ${side === 'LONG' ? '多仓' : '空仓'} 吗?`
|
||||||
: `Are you sure you want to close ${symbol} ${side === 'LONG' ? 'LONG' : 'SHORT'} position?`
|
: `Are you sure you want to close ${symbol} ${side === 'LONG' ? 'LONG' : 'SHORT'} position?`
|
||||||
|
|
||||||
if (!confirm(confirmMsg)) return
|
const confirmed = await confirmToast(confirmMsg, {
|
||||||
|
title: language === 'zh' ? '确认平仓' : 'Confirm Close',
|
||||||
|
okText: language === 'zh' ? '确认' : 'Confirm',
|
||||||
|
cancelText: language === 'zh' ? '取消' : 'Cancel',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setClosingPosition(symbol)
|
setClosingPosition(symbol)
|
||||||
try {
|
try {
|
||||||
await api.closePosition(selectedTraderId, symbol, side)
|
await api.closePosition(selectedTraderId, symbol, side)
|
||||||
const successMsg = language === 'zh' ? '平仓成功' : 'Position closed successfully'
|
notify.success(language === 'zh' ? '平仓成功' : 'Position closed successfully')
|
||||||
alert(successMsg)
|
// 使用 SWR mutate 刷新数据而非重新加载页面
|
||||||
window.location.reload()
|
await Promise.all([
|
||||||
|
mutate(`positions-${selectedTraderId}`),
|
||||||
|
mutate(`account-${selectedTraderId}`),
|
||||||
|
])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMsg = err instanceof Error ? err.message : (language === 'zh' ? '平仓失败' : 'Failed to close position')
|
const errorMsg = err instanceof Error ? err.message : (language === 'zh' ? '平仓失败' : 'Failed to close position')
|
||||||
alert(errorMsg)
|
notify.error(errorMsg)
|
||||||
} finally {
|
} finally {
|
||||||
setClosingPosition(null)
|
setClosingPosition(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
oiTopUrl: '',
|
oiTopUrl: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: traders, mutate: mutateTraders } = useSWR<TraderInfo[]>(
|
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
|
||||||
user && token ? 'traders' : null,
|
user && token ? 'traders' : null,
|
||||||
api.getTraders,
|
api.getTraders,
|
||||||
{ refreshInterval: 5000 }
|
{ refreshInterval: 5000 }
|
||||||
@@ -1047,7 +1047,31 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{traders && traders.length > 0 ? (
|
{isTradersLoading ? (
|
||||||
|
/* Loading Skeleton */
|
||||||
|
<div className="space-y-3 md:space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex flex-col md:flex-row md:items-center justify-between p-3 md:p-4 rounded gap-3 md:gap-4 animate-pulse"
|
||||||
|
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 md:gap-4">
|
||||||
|
<div className="w-10 h-10 md:w-12 md:h-12 rounded-full skeleton"></div>
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<div className="skeleton h-5 w-32"></div>
|
||||||
|
<div className="skeleton h-3 w-24"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 md:gap-4">
|
||||||
|
<div className="skeleton h-6 w-16"></div>
|
||||||
|
<div className="skeleton h-6 w-16"></div>
|
||||||
|
<div className="skeleton h-8 w-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : traders && traders.length > 0 ? (
|
||||||
<div className="space-y-3 md:space-y-4">
|
<div className="space-y-3 md:space-y-4">
|
||||||
{traders.map((trader) => (
|
{traders.map((trader) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
|
import { confirmToast } from '../lib/notify'
|
||||||
import { DecisionCard } from './DecisionCard'
|
import { DecisionCard } from './DecisionCard'
|
||||||
import type {
|
import type {
|
||||||
BacktestStatusPayload,
|
BacktestStatusPayload,
|
||||||
@@ -308,12 +309,17 @@ export function BacktestPage() {
|
|||||||
|
|
||||||
const handleDeleteRun = async () => {
|
const handleDeleteRun = async () => {
|
||||||
if (!selectedRunId) return
|
if (!selectedRunId) return
|
||||||
if (
|
|
||||||
typeof window !== 'undefined' &&
|
const confirmed = await confirmToast(
|
||||||
!window.confirm(tr('toasts.confirmDelete', { id: selectedRunId }))
|
tr('toasts.confirmDelete', { id: selectedRunId }),
|
||||||
) {
|
{
|
||||||
return
|
title: language === 'zh' ? '确认删除' : 'Confirm Delete',
|
||||||
}
|
okText: language === 'zh' ? '删除' : 'Delete',
|
||||||
|
cancelText: language === 'zh' ? '取消' : 'Cancel',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteBacktestRun(selectedRunId)
|
await api.deleteBacktestRun(selectedRunId)
|
||||||
setToast({ text: tr('toasts.deleteSuccess'), tone: 'success' })
|
setToast({ text: tr('toasts.deleteSuccess'), tone: 'success' })
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
|||||||
const { user, token } = useAuth()
|
const { user, token } = useAuth()
|
||||||
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
|
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
|
||||||
|
|
||||||
const { data: history, error } = useSWR<EquityPoint[]>(
|
const { data: history, error, isLoading } = useSWR<EquityPoint[]>(
|
||||||
user && token && traderId ? `equity-history-${traderId}` : null,
|
user && token && traderId ? `equity-history-${traderId}` : null,
|
||||||
() => api.getEquityHistory(traderId),
|
() => api.getEquityHistory(traderId),
|
||||||
{
|
{
|
||||||
@@ -61,6 +61,22 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Loading state - show skeleton
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={embedded ? 'p-6' : 'binance-card p-6'}>
|
||||||
|
{!embedded && (
|
||||||
|
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>
|
||||||
|
{t('accountEquityCurve', language)}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="skeleton h-64 w-full rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={embedded ? 'p-6' : 'binance-card p-6'}>
|
<div className={embedded ? 'p-6' : 'binance-card p-6'}>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||||
|
import { confirmToast, notify } from '../lib/notify'
|
||||||
import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
|
import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
|
||||||
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
|
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
|
||||||
import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
||||||
@@ -175,16 +176,30 @@ export function StrategyStudioPage() {
|
|||||||
|
|
||||||
// Delete strategy
|
// Delete strategy
|
||||||
const handleDeleteStrategy = async (id: string) => {
|
const handleDeleteStrategy = async (id: string) => {
|
||||||
if (!token || !confirm(language === 'zh' ? '确定删除此策略?' : 'Delete this strategy?')) return
|
if (!token) return
|
||||||
|
|
||||||
|
const confirmed = await confirmToast(
|
||||||
|
language === 'zh' ? '确定删除此策略?' : 'Delete this strategy?',
|
||||||
|
{
|
||||||
|
title: language === 'zh' ? '确认删除' : 'Confirm Delete',
|
||||||
|
okText: language === 'zh' ? '删除' : 'Delete',
|
||||||
|
cancelText: language === 'zh' ? '取消' : 'Cancel',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/strategies/${id}`, {
|
const response = await fetch(`${API_BASE}/api/strategies/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error('Failed to delete strategy')
|
if (!response.ok) throw new Error('Failed to delete strategy')
|
||||||
|
notify.success(language === 'zh' ? '策略已删除' : 'Strategy deleted')
|
||||||
await fetchStrategies()
|
await fetchStrategies()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
setError(errorMsg)
|
||||||
|
notify.error(errorMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import useSWR from 'swr'
|
import useSWR, { mutate } from 'swr'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { ChartTabs } from '../components/ChartTabs'
|
import { ChartTabs } from '../components/ChartTabs'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { stripLeadingIcons } from '../lib/text'
|
import { stripLeadingIcons } from '../lib/text'
|
||||||
|
import { confirmToast, notify } from '../lib/notify'
|
||||||
import type {
|
import type {
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
AccountInfo,
|
AccountInfo,
|
||||||
@@ -88,18 +89,26 @@ export default function TraderDashboard() {
|
|||||||
? `确定要平仓 ${symbol} ${side === 'LONG' ? '多仓' : '空仓'} 吗?`
|
? `确定要平仓 ${symbol} ${side === 'LONG' ? '多仓' : '空仓'} 吗?`
|
||||||
: `Are you sure you want to close ${symbol} ${side === 'LONG' ? 'LONG' : 'SHORT'} position?`
|
: `Are you sure you want to close ${symbol} ${side === 'LONG' ? 'LONG' : 'SHORT'} position?`
|
||||||
|
|
||||||
if (!confirm(confirmMsg)) return
|
const confirmed = await confirmToast(confirmMsg, {
|
||||||
|
title: language === 'zh' ? '确认平仓' : 'Confirm Close',
|
||||||
|
okText: language === 'zh' ? '确认' : 'Confirm',
|
||||||
|
cancelText: language === 'zh' ? '取消' : 'Cancel',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setClosingPosition(symbol)
|
setClosingPosition(symbol)
|
||||||
try {
|
try {
|
||||||
await api.closePosition(selectedTraderId, symbol, side)
|
await api.closePosition(selectedTraderId, symbol, side)
|
||||||
const successMsg = language === 'zh' ? '平仓成功' : 'Position closed successfully'
|
notify.success(language === 'zh' ? '平仓成功' : 'Position closed successfully')
|
||||||
alert(successMsg)
|
// 使用 SWR mutate 刷新数据而非重新加载页面
|
||||||
// 刷新持仓数据
|
await Promise.all([
|
||||||
window.location.reload()
|
mutate(`positions-${selectedTraderId}`),
|
||||||
|
mutate(`account-${selectedTraderId}`),
|
||||||
|
])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMsg = err instanceof Error ? err.message : (language === 'zh' ? '平仓失败' : 'Failed to close position')
|
const errorMsg = err instanceof Error ? err.message : (language === 'zh' ? '平仓失败' : 'Failed to close position')
|
||||||
alert(errorMsg)
|
notify.error(errorMsg)
|
||||||
} finally {
|
} finally {
|
||||||
setClosingPosition(null)
|
setClosingPosition(null)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user