mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
feat: add position history API and frontend integration
- Add /positions/history API endpoint - Add position history types and API client - Add translations for position history page - Integrate PositionHistory component in App
This commit is contained in:
@@ -191,6 +191,7 @@ func (s *Server) setupRoutes() {
|
||||
protected.GET("/status", s.handleStatus)
|
||||
protected.GET("/account", s.handleAccount)
|
||||
protected.GET("/positions", s.handlePositions)
|
||||
protected.GET("/positions/history", s.handlePositionHistory)
|
||||
protected.GET("/trades", s.handleTrades)
|
||||
protected.GET("/orders", s.handleOrders) // Order list (all orders)
|
||||
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
|
||||
@@ -2123,6 +2124,60 @@ func (s *Server) handlePositions(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, positions)
|
||||
}
|
||||
|
||||
// handlePositionHistory Historical closed positions with statistics
|
||||
func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get optional query parameters
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit := 100
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
// Get store
|
||||
store := trader.GetStore()
|
||||
if store == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get closed positions
|
||||
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get position history: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
stats, _ := store.Position().GetFullStats(trader.GetID())
|
||||
|
||||
// Get symbol stats
|
||||
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
|
||||
|
||||
// Get direction stats
|
||||
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"positions": positions,
|
||||
"stats": stats,
|
||||
"symbol_stats": symbolStats,
|
||||
"direction_stats": directionStats,
|
||||
})
|
||||
}
|
||||
|
||||
// handleTrades Historical trades list
|
||||
func (s *Server) handleTrades(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
|
||||
@@ -19,6 +19,7 @@ import { t, type Language } from './i18n/translations'
|
||||
import { confirmToast, notify } from './lib/notify'
|
||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||
import { DecisionCard } from './components/DecisionCard'
|
||||
import { PositionHistory } from './components/PositionHistory'
|
||||
import { PunkAvatar, getTraderAvatar } from './components/PunkAvatar'
|
||||
import { OFFICIAL_LINKS } from './constants/branding'
|
||||
import { BacktestPage } from './components/BacktestPage'
|
||||
@@ -1514,6 +1515,25 @@ function TraderDetailsPage({
|
||||
</div>
|
||||
{/* 右侧结束 */}
|
||||
</div>
|
||||
|
||||
{/* Position History Section */}
|
||||
{selectedTraderId && (
|
||||
<div
|
||||
className="binance-card p-6 animate-slide-in"
|
||||
style={{ animationDelay: '0.25s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2
|
||||
className="text-xl font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
<span className="text-2xl">📜</span>
|
||||
{t('positionHistory.title', language)}
|
||||
</h2>
|
||||
</div>
|
||||
<PositionHistory traderId={selectedTraderId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1091,6 +1091,55 @@ export const translations = {
|
||||
privatekeyObfuscationFailed: 'Clipboard obfuscation failed',
|
||||
},
|
||||
|
||||
// Position History
|
||||
positionHistory: {
|
||||
title: 'Position History',
|
||||
loading: 'Loading position history...',
|
||||
noHistory: 'No Position History',
|
||||
noHistoryDesc: 'Closed positions will appear here after trading.',
|
||||
showingPositions: 'Showing {count} of {total} positions',
|
||||
totalPnL: 'Total P&L',
|
||||
// Stats
|
||||
totalTrades: 'Total Trades',
|
||||
winLoss: 'Win: {win} / Loss: {loss}',
|
||||
winRate: 'Win Rate',
|
||||
profitFactor: 'Profit Factor',
|
||||
profitFactorDesc: 'Total Profit / Total Loss',
|
||||
plRatio: 'P/L Ratio',
|
||||
plRatioDesc: 'Avg Win / Avg Loss',
|
||||
sharpeRatio: 'Sharpe Ratio',
|
||||
sharpeRatioDesc: 'Risk-adjusted Return',
|
||||
maxDrawdown: 'Max Drawdown',
|
||||
avgWin: 'Avg Win',
|
||||
avgLoss: 'Avg Loss',
|
||||
netPnL: 'Net P&L',
|
||||
netPnLDesc: 'After Fees',
|
||||
fee: 'Fee',
|
||||
// Direction Stats
|
||||
trades: 'Trades',
|
||||
avgPnL: 'Avg P&L',
|
||||
// Symbol Performance
|
||||
symbolPerformance: 'Symbol Performance',
|
||||
// Filters
|
||||
symbol: 'Symbol',
|
||||
allSymbols: 'All Symbols',
|
||||
side: 'Side',
|
||||
all: 'All',
|
||||
sort: 'Sort',
|
||||
latestFirst: 'Latest First',
|
||||
oldestFirst: 'Oldest First',
|
||||
highestPnL: 'Highest P&L',
|
||||
lowestPnL: 'Lowest P&L',
|
||||
// Table Headers
|
||||
entry: 'Entry',
|
||||
exit: 'Exit',
|
||||
qty: 'Qty',
|
||||
lev: 'Lev',
|
||||
pnl: 'P&L',
|
||||
duration: 'Duration',
|
||||
closedAt: 'Closed At',
|
||||
},
|
||||
|
||||
// Debate Arena Page
|
||||
debatePage: {
|
||||
title: 'Market Debate Arena',
|
||||
@@ -2188,6 +2237,55 @@ export const translations = {
|
||||
privatekeyObfuscationFailed: '剪贴板混淆失败',
|
||||
},
|
||||
|
||||
// Position History
|
||||
positionHistory: {
|
||||
title: '历史仓位',
|
||||
loading: '加载历史仓位...',
|
||||
noHistory: '暂无历史仓位',
|
||||
noHistoryDesc: '平仓后的仓位记录将显示在此处',
|
||||
showingPositions: '显示 {count} / {total} 条记录',
|
||||
totalPnL: '总盈亏',
|
||||
// Stats
|
||||
totalTrades: '总交易次数',
|
||||
winLoss: '盈利: {win} / 亏损: {loss}',
|
||||
winRate: '胜率',
|
||||
profitFactor: '盈利因子',
|
||||
profitFactorDesc: '总盈利 / 总亏损',
|
||||
plRatio: '盈亏比',
|
||||
plRatioDesc: '平均盈利 / 平均亏损',
|
||||
sharpeRatio: '夏普比率',
|
||||
sharpeRatioDesc: '风险调整收益',
|
||||
maxDrawdown: '最大回撤',
|
||||
avgWin: '平均盈利',
|
||||
avgLoss: '平均亏损',
|
||||
netPnL: '净盈亏',
|
||||
netPnLDesc: '扣除手续费后',
|
||||
fee: '手续费',
|
||||
// Direction Stats
|
||||
trades: '交易次数',
|
||||
avgPnL: '平均盈亏',
|
||||
// Symbol Performance
|
||||
symbolPerformance: '品种表现',
|
||||
// Filters
|
||||
symbol: '交易对',
|
||||
allSymbols: '全部交易对',
|
||||
side: '方向',
|
||||
all: '全部',
|
||||
sort: '排序',
|
||||
latestFirst: '最新优先',
|
||||
oldestFirst: '最早优先',
|
||||
highestPnL: '盈利最高',
|
||||
lowestPnL: '亏损最多',
|
||||
// Table Headers
|
||||
entry: '开仓价',
|
||||
exit: '平仓价',
|
||||
qty: '数量',
|
||||
lev: '杠杆',
|
||||
pnl: '盈亏',
|
||||
duration: '持仓时长',
|
||||
closedAt: '平仓时间',
|
||||
},
|
||||
|
||||
// Debate Arena Page
|
||||
debatePage: {
|
||||
title: '行情辩论大赛',
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
DebateMessage,
|
||||
DebateVote,
|
||||
DebatePersonalityInfo,
|
||||
PositionHistoryResponse,
|
||||
} from '../types'
|
||||
import { CryptoService } from './crypto'
|
||||
import { httpClient } from './httpClient'
|
||||
@@ -775,4 +776,13 @@ export const api = {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
return new EventSource(`${API_BASE}/debates/${debateId}/stream?token=${token}`)
|
||||
},
|
||||
|
||||
// Position History API
|
||||
async getPositionHistory(traderId: string, limit: number = 100): Promise<PositionHistoryResponse> {
|
||||
const result = await httpClient.get<PositionHistoryResponse>(
|
||||
`${API_BASE}/positions/history?trader_id=${traderId}&limit=${limit}`
|
||||
)
|
||||
if (!result.success) throw new Error('获取历史仓位失败')
|
||||
return result.data!
|
||||
},
|
||||
}
|
||||
|
||||
@@ -645,3 +645,70 @@ export interface DebatePersonalityInfo {
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Position History Types
|
||||
export interface HistoricalPosition {
|
||||
id: number;
|
||||
trader_id: string;
|
||||
exchange_id: string;
|
||||
exchange_type: string;
|
||||
symbol: string;
|
||||
side: string;
|
||||
quantity: number;
|
||||
entry_quantity: number;
|
||||
entry_price: number;
|
||||
entry_order_id: string;
|
||||
entry_time: string;
|
||||
exit_price: number;
|
||||
exit_order_id: string;
|
||||
exit_time: string;
|
||||
realized_pnl: number;
|
||||
fee: number;
|
||||
leverage: number;
|
||||
status: string;
|
||||
close_reason: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Matches Go TraderStats struct exactly
|
||||
export interface TraderStats {
|
||||
total_trades: number;
|
||||
win_trades: number;
|
||||
loss_trades: number;
|
||||
win_rate: number;
|
||||
profit_factor: number;
|
||||
sharpe_ratio: number;
|
||||
total_pnl: number;
|
||||
total_fee: number;
|
||||
avg_win: number;
|
||||
avg_loss: number;
|
||||
max_drawdown_pct: number;
|
||||
}
|
||||
|
||||
// Matches Go SymbolStats struct exactly
|
||||
export interface SymbolStats {
|
||||
symbol: string;
|
||||
total_trades: number;
|
||||
win_trades: number;
|
||||
win_rate: number;
|
||||
total_pnl: number;
|
||||
avg_pnl: number;
|
||||
avg_hold_mins: number;
|
||||
}
|
||||
|
||||
// Matches Go DirectionStats struct exactly
|
||||
export interface DirectionStats {
|
||||
side: string;
|
||||
trade_count: number;
|
||||
win_rate: number;
|
||||
total_pnl: number;
|
||||
avg_pnl: number;
|
||||
}
|
||||
|
||||
export interface PositionHistoryResponse {
|
||||
positions: HistoricalPosition[];
|
||||
stats: TraderStats | null;
|
||||
symbol_stats: SymbolStats[];
|
||||
direction_stats: DirectionStats[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user