mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
feat: add metric formula tooltips with KaTeX rendering
This commit is contained in:
2522
docs/research/AI-Trader-Analysis-Report.md
Normal file
2522
docs/research/AI-Trader-Analysis-Report.md
Normal file
File diff suppressed because it is too large
Load Diff
26
web/package-lock.json
generated
26
web/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
"katex": "^0.16.27",
|
||||||
"lightweight-charts": "^5.1.0",
|
"lightweight-charts": "^5.1.0",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -5793,6 +5794,31 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/katex": {
|
||||||
|
"version": "0.16.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz",
|
||||||
|
"integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==",
|
||||||
|
"funding": [
|
||||||
|
"https://opencollective.com/katex",
|
||||||
|
"https://github.com/sponsors/katex"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^8.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"katex": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/katex/node_modules/commander": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
"katex": "^0.16.27",
|
||||||
"lightweight-charts": "^5.1.0",
|
"lightweight-charts": "^5.1.0",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { useLanguage } from '../contexts/LanguageContext'
|
|||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
import { confirmToast } from '../lib/notify'
|
import { confirmToast } from '../lib/notify'
|
||||||
import { DecisionCard } from './DecisionCard'
|
import { DecisionCard } from './DecisionCard'
|
||||||
|
import { MetricTooltip } from './MetricTooltip'
|
||||||
import type {
|
import type {
|
||||||
BacktestStatusPayload,
|
BacktestStatusPayload,
|
||||||
BacktestPositionStatus,
|
BacktestPositionStatus,
|
||||||
@@ -79,6 +80,8 @@ function StatCard({
|
|||||||
suffix,
|
suffix,
|
||||||
trend,
|
trend,
|
||||||
color = '#EAECEF',
|
color = '#EAECEF',
|
||||||
|
metricKey,
|
||||||
|
language = 'en',
|
||||||
}: {
|
}: {
|
||||||
icon: typeof TrendingUp
|
icon: typeof TrendingUp
|
||||||
label: string
|
label: string
|
||||||
@@ -86,6 +89,8 @@ function StatCard({
|
|||||||
suffix?: string
|
suffix?: string
|
||||||
trend?: 'up' | 'down' | 'neutral'
|
trend?: 'up' | 'down' | 'neutral'
|
||||||
color?: string
|
color?: string
|
||||||
|
metricKey?: string
|
||||||
|
language?: string
|
||||||
}) {
|
}) {
|
||||||
const trendColors = {
|
const trendColors = {
|
||||||
up: '#0ECB81',
|
up: '#0ECB81',
|
||||||
@@ -103,6 +108,9 @@ function StatCard({
|
|||||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
{metricKey && (
|
||||||
|
<MetricTooltip metricKey={metricKey} language={language} size={12} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-xl font-bold" style={{ color }}>
|
<span className="text-xl font-bold" style={{ color }}>
|
||||||
@@ -1779,6 +1787,7 @@ export function BacktestPage() {
|
|||||||
label={language === 'zh' ? '当前净值' : 'Equity'}
|
label={language === 'zh' ? '当前净值' : 'Equity'}
|
||||||
value={(status?.equity ?? 0).toFixed(2)}
|
value={(status?.equity ?? 0).toFixed(2)}
|
||||||
suffix="USDT"
|
suffix="USDT"
|
||||||
|
language={language}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
@@ -1786,17 +1795,23 @@ export function BacktestPage() {
|
|||||||
value={`${(metrics?.total_return_pct ?? 0).toFixed(2)}%`}
|
value={`${(metrics?.total_return_pct ?? 0).toFixed(2)}%`}
|
||||||
trend={(metrics?.total_return_pct ?? 0) >= 0 ? 'up' : 'down'}
|
trend={(metrics?.total_return_pct ?? 0) >= 0 ? 'up' : 'down'}
|
||||||
color={(metrics?.total_return_pct ?? 0) >= 0 ? '#0ECB81' : '#F6465D'}
|
color={(metrics?.total_return_pct ?? 0) >= 0 ? '#0ECB81' : '#F6465D'}
|
||||||
|
metricKey="total_return"
|
||||||
|
language={language}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
label={language === 'zh' ? '最大回撤' : 'Max DD'}
|
label={language === 'zh' ? '最大回撤' : 'Max DD'}
|
||||||
value={`${(metrics?.max_drawdown_pct ?? 0).toFixed(2)}%`}
|
value={`${(metrics?.max_drawdown_pct ?? 0).toFixed(2)}%`}
|
||||||
color="#F6465D"
|
color="#F6465D"
|
||||||
|
metricKey="max_drawdown"
|
||||||
|
language={language}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
label={language === 'zh' ? '夏普比率' : 'Sharpe'}
|
label={language === 'zh' ? '夏普比率' : 'Sharpe'}
|
||||||
value={(metrics?.sharpe_ratio ?? 0).toFixed(2)}
|
value={(metrics?.sharpe_ratio ?? 0).toFixed(2)}
|
||||||
|
metricKey="sharpe_ratio"
|
||||||
|
language={language}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1856,16 +1871,18 @@ export function BacktestPage() {
|
|||||||
{metrics && (
|
{metrics && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
||||||
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
|
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
|
||||||
{language === 'zh' ? '胜率' : 'Win Rate'}
|
{language === 'zh' ? '胜率' : 'Win Rate'}
|
||||||
|
<MetricTooltip metricKey="win_rate" language={language} size={11} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||||
{(metrics.win_rate ?? 0).toFixed(1)}%
|
{(metrics.win_rate ?? 0).toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
|
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
|
||||||
{language === 'zh' ? '盈亏因子' : 'Profit Factor'}
|
{language === 'zh' ? '盈亏因子' : 'Profit Factor'}
|
||||||
|
<MetricTooltip metricKey="profit_factor" language={language} size={11} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||||
{(metrics.profit_factor ?? 0).toFixed(2)}
|
{(metrics.profit_factor ?? 0).toFixed(2)}
|
||||||
|
|||||||
364
web/src/components/MetricTooltip.tsx
Normal file
364
web/src/components/MetricTooltip.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { HelpCircle } from 'lucide-react'
|
||||||
|
import katex from 'katex'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
|
||||||
|
export interface MetricDefinition {
|
||||||
|
key: string
|
||||||
|
nameEn: string
|
||||||
|
nameZh: string
|
||||||
|
formula: string // LaTeX formula
|
||||||
|
descriptionEn: string
|
||||||
|
descriptionZh: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metric definitions with formulas
|
||||||
|
export const METRIC_DEFINITIONS: Record<string, MetricDefinition> = {
|
||||||
|
total_return: {
|
||||||
|
key: 'total_return',
|
||||||
|
nameEn: 'Total Return',
|
||||||
|
nameZh: '总收益率',
|
||||||
|
formula: 'R_{total} = \\frac{V_{end} - V_{start}}{V_{start}} \\times 100\\%',
|
||||||
|
descriptionEn: 'Measures overall portfolio performance from start to end',
|
||||||
|
descriptionZh: '衡量投资组合从开始到结束的整体收益表现',
|
||||||
|
},
|
||||||
|
annualized_return: {
|
||||||
|
key: 'annualized_return',
|
||||||
|
nameEn: 'Annualized Return',
|
||||||
|
nameZh: '年化收益率',
|
||||||
|
formula: 'R_{ann} = \\left(1 + R_{total}\\right)^{\\frac{252}{n}} - 1',
|
||||||
|
descriptionEn: 'Standardized yearly return rate (252 trading days)',
|
||||||
|
descriptionZh: '标准化年度收益率(按252个交易日计算)',
|
||||||
|
},
|
||||||
|
max_drawdown: {
|
||||||
|
key: 'max_drawdown',
|
||||||
|
nameEn: 'Maximum Drawdown',
|
||||||
|
nameZh: '最大回撤',
|
||||||
|
formula: 'MDD = \\max_{t} \\left( \\frac{Peak_t - Trough_t}{Peak_t} \\right)',
|
||||||
|
descriptionEn: 'Largest peak-to-trough decline during the period',
|
||||||
|
descriptionZh: '期间内从峰值到谷底的最大跌幅',
|
||||||
|
},
|
||||||
|
sharpe_ratio: {
|
||||||
|
key: 'sharpe_ratio',
|
||||||
|
nameEn: 'Sharpe Ratio',
|
||||||
|
nameZh: '夏普比率',
|
||||||
|
formula: 'SR = \\frac{\\bar{r} - r_f}{\\sigma}',
|
||||||
|
descriptionEn: 'Risk-adjusted return per unit of volatility (r̄=avg return, rf=risk-free rate, σ=std dev)',
|
||||||
|
descriptionZh: '单位波动风险下的超额收益(r̄=平均收益,rf=无风险利率,σ=标准差)',
|
||||||
|
},
|
||||||
|
sortino_ratio: {
|
||||||
|
key: 'sortino_ratio',
|
||||||
|
nameEn: 'Sortino Ratio',
|
||||||
|
nameZh: '索提诺比率',
|
||||||
|
formula: 'Sortino = \\frac{\\bar{r} - r_f}{\\sigma_d}',
|
||||||
|
descriptionEn: 'Return per unit of downside risk (σd=downside deviation)',
|
||||||
|
descriptionZh: '单位下行风险的收益(σd=下行标准差)',
|
||||||
|
},
|
||||||
|
calmar_ratio: {
|
||||||
|
key: 'calmar_ratio',
|
||||||
|
nameEn: 'Calmar Ratio',
|
||||||
|
nameZh: '卡玛比率',
|
||||||
|
formula: 'Calmar = \\frac{R_{ann}}{|MDD|}',
|
||||||
|
descriptionEn: 'Annualized return divided by maximum drawdown',
|
||||||
|
descriptionZh: '年化收益率与最大回撤的比值',
|
||||||
|
},
|
||||||
|
win_rate: {
|
||||||
|
key: 'win_rate',
|
||||||
|
nameEn: 'Win Rate',
|
||||||
|
nameZh: '胜率',
|
||||||
|
formula: 'WinRate = \\frac{N_{win}}{N_{total}} \\times 100\\%',
|
||||||
|
descriptionEn: 'Percentage of profitable trades',
|
||||||
|
descriptionZh: '盈利交易占总交易数的百分比',
|
||||||
|
},
|
||||||
|
profit_factor: {
|
||||||
|
key: 'profit_factor',
|
||||||
|
nameEn: 'Profit Factor',
|
||||||
|
nameZh: '盈亏比',
|
||||||
|
formula: 'PF = \\frac{\\sum Profits}{|\\sum Losses|}',
|
||||||
|
descriptionEn: 'Ratio of gross profit to gross loss',
|
||||||
|
descriptionZh: '总盈利与总亏损的比值',
|
||||||
|
},
|
||||||
|
volatility: {
|
||||||
|
key: 'volatility',
|
||||||
|
nameEn: 'Volatility',
|
||||||
|
nameZh: '波动率',
|
||||||
|
formula: '\\sigma = \\sqrt{\\frac{1}{n}\\sum_{i=1}^{n}(r_i - \\bar{r})^2}',
|
||||||
|
descriptionEn: 'Standard deviation of returns',
|
||||||
|
descriptionZh: '收益率的标准差',
|
||||||
|
},
|
||||||
|
var_95: {
|
||||||
|
key: 'var_95',
|
||||||
|
nameEn: 'VaR (95%)',
|
||||||
|
nameZh: '风险价值',
|
||||||
|
formula: 'P(R < VaR_{95\\%}) = 5\\%',
|
||||||
|
descriptionEn: '95% confidence level maximum expected loss',
|
||||||
|
descriptionZh: '95%置信水平下的最大预期损失',
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
key: 'alpha',
|
||||||
|
nameEn: 'Alpha',
|
||||||
|
nameZh: '超额收益',
|
||||||
|
formula: '\\alpha = R_{portfolio} - R_{benchmark}',
|
||||||
|
descriptionEn: 'Excess return over benchmark',
|
||||||
|
descriptionZh: '相对于基准的超额收益',
|
||||||
|
},
|
||||||
|
beta: {
|
||||||
|
key: 'beta',
|
||||||
|
nameEn: 'Beta',
|
||||||
|
nameZh: '贝塔系数',
|
||||||
|
formula: '\\beta = \\frac{Cov(R_p, R_m)}{Var(R_m)}',
|
||||||
|
descriptionEn: 'Portfolio sensitivity to market movements',
|
||||||
|
descriptionZh: '投资组合对市场波动的敏感度',
|
||||||
|
},
|
||||||
|
information_ratio: {
|
||||||
|
key: 'information_ratio',
|
||||||
|
nameEn: 'Information Ratio',
|
||||||
|
nameZh: '信息比率',
|
||||||
|
formula: 'IR = \\frac{\\alpha}{\\sigma_{tracking}}',
|
||||||
|
descriptionEn: 'Alpha per unit of tracking error',
|
||||||
|
descriptionZh: '单位跟踪误差的超额收益',
|
||||||
|
},
|
||||||
|
avg_trade_pnl: {
|
||||||
|
key: 'avg_trade_pnl',
|
||||||
|
nameEn: 'Avg Trade PnL',
|
||||||
|
nameZh: '平均盈亏',
|
||||||
|
formula: '\\bar{PnL} = \\frac{\\sum PnL_i}{N}',
|
||||||
|
descriptionEn: 'Average profit/loss per trade',
|
||||||
|
descriptionZh: '每笔交易的平均盈亏',
|
||||||
|
},
|
||||||
|
expectancy: {
|
||||||
|
key: 'expectancy',
|
||||||
|
nameEn: 'Expectancy',
|
||||||
|
nameZh: '期望收益',
|
||||||
|
formula: 'E = (WinRate \\times \\bar{W}) - (LossRate \\times \\bar{L})',
|
||||||
|
descriptionEn: 'Expected return per trade',
|
||||||
|
descriptionZh: '每笔交易的期望收益',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormulaRendererProps {
|
||||||
|
formula: string
|
||||||
|
displayMode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormulaRenderer({ formula, displayMode = true }: FormulaRendererProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
try {
|
||||||
|
katex.render(formula, containerRef.current, {
|
||||||
|
throwOnError: false,
|
||||||
|
displayMode,
|
||||||
|
output: 'html',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('KaTeX render error:', e)
|
||||||
|
containerRef.current.textContent = formula
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formula, displayMode])
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="formula-container" />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipPosition {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
placement: 'top' | 'bottom'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricTooltipProps {
|
||||||
|
metricKey: string
|
||||||
|
language?: string
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricTooltip({
|
||||||
|
metricKey,
|
||||||
|
language = 'en',
|
||||||
|
size = 14,
|
||||||
|
className = '',
|
||||||
|
}: MetricTooltipProps) {
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
const [position, setPosition] = useState<TooltipPosition>({ top: 100, left: 100, placement: 'bottom' })
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const tooltipWidth = 340
|
||||||
|
const tooltipHeight = 220
|
||||||
|
|
||||||
|
const metric = METRIC_DEFINITIONS[metricKey]
|
||||||
|
|
||||||
|
const calculatePosition = useCallback(() => {
|
||||||
|
if (!buttonRef.current) return
|
||||||
|
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect()
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
|
||||||
|
// Calculate center position (fixed positioning uses viewport coordinates)
|
||||||
|
let left = rect.left + rect.width / 2 - tooltipWidth / 2
|
||||||
|
|
||||||
|
// Clamp to viewport bounds with padding
|
||||||
|
const padding = 16
|
||||||
|
left = Math.max(padding, Math.min(left, viewportWidth - tooltipWidth - padding))
|
||||||
|
|
||||||
|
// Decide placement: prefer bottom for reliability
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom
|
||||||
|
|
||||||
|
let placement: 'top' | 'bottom' = 'bottom'
|
||||||
|
let top: number
|
||||||
|
|
||||||
|
if (spaceBelow >= tooltipHeight + 20) {
|
||||||
|
// Enough space below
|
||||||
|
placement = 'bottom'
|
||||||
|
top = rect.bottom + 8
|
||||||
|
} else {
|
||||||
|
// Show above
|
||||||
|
placement = 'top'
|
||||||
|
top = Math.max(8, rect.top - tooltipHeight - 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure top is never negative
|
||||||
|
top = Math.max(8, top)
|
||||||
|
|
||||||
|
setPosition({ top, left, placement })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
calculatePosition()
|
||||||
|
setShow(true)
|
||||||
|
}, [calculatePosition])
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
setShow(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!metric) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = language === 'zh' ? metric.nameZh : metric.nameEn
|
||||||
|
const description = language === 'zh' ? metric.descriptionZh : metric.descriptionEn
|
||||||
|
|
||||||
|
const tooltipContent = (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setShow(true)}
|
||||||
|
onMouseLeave={() => setShow(false)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${position.top}px`,
|
||||||
|
left: `${position.left}px`,
|
||||||
|
width: `${tooltipWidth}px`,
|
||||||
|
zIndex: 99999,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(145deg, #1E2329 0%, #2B3139 100%)',
|
||||||
|
border: '1px solid #3B4149',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
borderBottom: '1px solid #3B4149'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#F0B90B'
|
||||||
|
}} />
|
||||||
|
<span style={{ fontWeight: 'bold', fontSize: '14px', color: '#EAECEF' }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formula */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(0,0,0,0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#848E9C', marginBottom: '8px' }}>
|
||||||
|
{language === 'zh' ? '计算公式' : 'Formula'}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 4px',
|
||||||
|
color: '#EAECEF',
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
maxWidth: '100%',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
}}>
|
||||||
|
<FormulaRenderer formula={metric.formula} displayMode={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p style={{ fontSize: '12px', lineHeight: '1.5', color: '#B7BDC6', margin: 0 }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!show) {
|
||||||
|
calculatePosition()
|
||||||
|
}
|
||||||
|
setShow(!show)
|
||||||
|
}}
|
||||||
|
className={`p-0.5 rounded-full transition-colors hover:bg-white/10 ${className}`}
|
||||||
|
style={{ color: '#848E9C' }}
|
||||||
|
aria-label={`Info about ${name}`}
|
||||||
|
>
|
||||||
|
<HelpCircle size={size} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{show && createPortal(tooltipContent, document.body)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience component for inline metric label with tooltip
|
||||||
|
interface MetricLabelProps {
|
||||||
|
metricKey: string
|
||||||
|
label?: string
|
||||||
|
language?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricLabel({ metricKey, label, language = 'en', className = '' }: MetricLabelProps) {
|
||||||
|
const metric = METRIC_DEFINITIONS[metricKey]
|
||||||
|
const displayLabel = label || (language === 'zh' ? metric?.nameZh : metric?.nameEn) || metricKey
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 ${className}`}>
|
||||||
|
{displayLabel}
|
||||||
|
<MetricTooltip metricKey={metricKey} language={language} size={12} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react'
|
|||||||
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 { MetricTooltip } from './MetricTooltip'
|
||||||
import type {
|
import type {
|
||||||
HistoricalPosition,
|
HistoricalPosition,
|
||||||
TraderStats,
|
TraderStats,
|
||||||
@@ -61,7 +62,8 @@ function StatCard({
|
|||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
subtitle,
|
subtitle,
|
||||||
formula,
|
metricKey,
|
||||||
|
language = 'en',
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
value: string | number
|
value: string | number
|
||||||
@@ -69,10 +71,9 @@ function StatCard({
|
|||||||
color?: string
|
color?: string
|
||||||
icon: string
|
icon: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
formula?: string
|
metricKey?: string
|
||||||
|
language?: string
|
||||||
}) {
|
}) {
|
||||||
const [showTooltip, setShowTooltip] = useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg p-4 transition-all duration-200 hover:scale-[1.02]"
|
className="rounded-lg p-4 transition-all duration-200 hover:scale-[1.02]"
|
||||||
@@ -87,30 +88,8 @@ function StatCard({
|
|||||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{formula && (
|
{metricKey && (
|
||||||
<div className="relative">
|
<MetricTooltip metricKey={metricKey} language={language} size={12} />
|
||||||
<span
|
|
||||||
className="cursor-help text-xs px-1 rounded"
|
|
||||||
style={{ color: '#848E9C', background: '#2B3139' }}
|
|
||||||
onMouseEnter={() => setShowTooltip(true)}
|
|
||||||
onMouseLeave={() => setShowTooltip(false)}
|
|
||||||
>
|
|
||||||
?
|
|
||||||
</span>
|
|
||||||
{showTooltip && (
|
|
||||||
<div
|
|
||||||
className="absolute z-50 left-0 top-6 p-2 rounded text-xs whitespace-pre-wrap min-w-[200px] max-w-[300px]"
|
|
||||||
style={{
|
|
||||||
background: '#1E2329',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
color: '#EAECEF',
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formula}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
@@ -519,9 +498,7 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
title={t('positionHistory.totalTrades', language)}
|
title={t('positionHistory.totalTrades', language)}
|
||||||
value={stats.total_trades || 0}
|
value={stats.total_trades || 0}
|
||||||
subtitle={t('positionHistory.winLoss', language, { win: stats.win_trades || 0, loss: stats.loss_trades || 0 })}
|
subtitle={t('positionHistory.winLoss', language, { win: stats.win_trades || 0, loss: stats.loss_trades || 0 })}
|
||||||
formula={language === 'zh'
|
language={language}
|
||||||
? '总交易次数 = 所有已平仓位数量'
|
|
||||||
: 'Total Trades = Count of all closed positions'}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="🎯"
|
icon="🎯"
|
||||||
@@ -535,9 +512,8 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
? '#F0B90B'
|
? '#F0B90B'
|
||||||
: '#F6465D'
|
: '#F6465D'
|
||||||
}
|
}
|
||||||
formula={language === 'zh'
|
metricKey="win_rate"
|
||||||
? `胜率 = 盈利交易数 / 总交易数 × 100%\n= ${stats.win_trades || 0} / ${stats.total_trades || 0} × 100%`
|
language={language}
|
||||||
: `Win Rate = Winning Trades / Total Trades × 100%\n= ${stats.win_trades || 0} / ${stats.total_trades || 0} × 100%`}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="💰"
|
icon="💰"
|
||||||
@@ -545,9 +521,8 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
value={((stats.total_pnl || 0) >= 0 ? '+' : '') + formatNumber(stats.total_pnl || 0)}
|
value={((stats.total_pnl || 0) >= 0 ? '+' : '') + formatNumber(stats.total_pnl || 0)}
|
||||||
color={(stats.total_pnl || 0) >= 0 ? '#0ECB81' : '#F6465D'}
|
color={(stats.total_pnl || 0) >= 0 ? '#0ECB81' : '#F6465D'}
|
||||||
subtitle={`${t('positionHistory.fee', language)}: -${formatNumber(stats.total_fee || 0)}`}
|
subtitle={`${t('positionHistory.fee', language)}: -${formatNumber(stats.total_fee || 0)}`}
|
||||||
formula={language === 'zh'
|
metricKey="total_return"
|
||||||
? '总盈亏 = Σ(每笔已平仓位的 realized_pnl)\n不含手续费'
|
language={language}
|
||||||
: 'Total P&L = Σ(realized_pnl of each closed position)\nExcluding fees'}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="📈"
|
icon="📈"
|
||||||
@@ -555,9 +530,8 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
value={(stats.profit_factor || 0).toFixed(2)}
|
value={(stats.profit_factor || 0).toFixed(2)}
|
||||||
color={(stats.profit_factor || 0) >= 1.5 ? '#0ECB81' : (stats.profit_factor || 0) >= 1 ? '#F0B90B' : '#F6465D'}
|
color={(stats.profit_factor || 0) >= 1.5 ? '#0ECB81' : (stats.profit_factor || 0) >= 1 ? '#F0B90B' : '#F6465D'}
|
||||||
subtitle={t('positionHistory.profitFactorDesc', language)}
|
subtitle={t('positionHistory.profitFactorDesc', language)}
|
||||||
formula={language === 'zh'
|
metricKey="profit_factor"
|
||||||
? '盈利因子 = 总盈利 / 总亏损\n>1.5 优秀, >1 盈利, <1 亏损'
|
language={language}
|
||||||
: 'Profit Factor = Total Profit / Total Loss\n>1.5 Excellent, >1 Profitable, <1 Loss'}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="⚖️"
|
icon="⚖️"
|
||||||
@@ -565,9 +539,8 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
value={profitLossRatio === Infinity ? '∞' : profitLossRatio.toFixed(2)}
|
value={profitLossRatio === Infinity ? '∞' : profitLossRatio.toFixed(2)}
|
||||||
color={profitLossRatio >= 1.5 ? '#0ECB81' : profitLossRatio >= 1 ? '#F0B90B' : '#F6465D'}
|
color={profitLossRatio >= 1.5 ? '#0ECB81' : profitLossRatio >= 1 ? '#F0B90B' : '#F6465D'}
|
||||||
subtitle={t('positionHistory.plRatioDesc', language)}
|
subtitle={t('positionHistory.plRatioDesc', language)}
|
||||||
formula={language === 'zh'
|
metricKey="expectancy"
|
||||||
? `盈亏比 = 平均盈利 / 平均亏损\n= ${formatNumber(stats.avg_win || 0)} / ${formatNumber(stats.avg_loss || 0)}`
|
language={language}
|
||||||
: `P/L Ratio = Avg Win / Avg Loss\n= ${formatNumber(stats.avg_win || 0)} / ${formatNumber(stats.avg_loss || 0)}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -581,9 +554,8 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
value={(stats.sharpe_ratio || 0).toFixed(2)}
|
value={(stats.sharpe_ratio || 0).toFixed(2)}
|
||||||
color={(stats.sharpe_ratio || 0) >= 1 ? '#0ECB81' : (stats.sharpe_ratio || 0) >= 0 ? '#F0B90B' : '#F6465D'}
|
color={(stats.sharpe_ratio || 0) >= 1 ? '#0ECB81' : (stats.sharpe_ratio || 0) >= 0 ? '#F0B90B' : '#F6465D'}
|
||||||
subtitle={t('positionHistory.sharpeRatioDesc', language)}
|
subtitle={t('positionHistory.sharpeRatioDesc', language)}
|
||||||
formula={language === 'zh'
|
metricKey="sharpe_ratio"
|
||||||
? '夏普比率 = 平均收益 / 收益标准差\n衡量风险调整后的收益\n>1 良好, >2 优秀'
|
language={language}
|
||||||
: 'Sharpe Ratio = Mean Return / Std Dev\nMeasures risk-adjusted return\n>1 Good, >2 Excellent'}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="🔻"
|
icon="🔻"
|
||||||
@@ -591,27 +563,23 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
value={(stats.max_drawdown_pct || 0).toFixed(1)}
|
value={(stats.max_drawdown_pct || 0).toFixed(1)}
|
||||||
suffix="%"
|
suffix="%"
|
||||||
color={(stats.max_drawdown_pct || 0) <= 10 ? '#0ECB81' : (stats.max_drawdown_pct || 0) <= 20 ? '#F0B90B' : '#F6465D'}
|
color={(stats.max_drawdown_pct || 0) <= 10 ? '#0ECB81' : (stats.max_drawdown_pct || 0) <= 20 ? '#F0B90B' : '#F6465D'}
|
||||||
formula={language === 'zh'
|
metricKey="max_drawdown"
|
||||||
? '最大回撤 = (峰值 - 谷值) / 峰值 × 100%\n基于虚拟起始资金10000计算\n衡量最大亏损幅度'
|
language={language}
|
||||||
: 'Max Drawdown = (Peak - Trough) / Peak × 100%\nBased on virtual starting equity of 10000\nMeasures largest loss from peak'}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="🏆"
|
icon="🏆"
|
||||||
title={t('positionHistory.avgWin', language)}
|
title={t('positionHistory.avgWin', language)}
|
||||||
value={'+' + formatNumber(stats.avg_win || 0)}
|
value={'+' + formatNumber(stats.avg_win || 0)}
|
||||||
color="#0ECB81"
|
color="#0ECB81"
|
||||||
formula={language === 'zh'
|
metricKey="avg_trade_pnl"
|
||||||
? `平均盈利 = 总盈利 / 盈利交易数\n盈利交易数: ${stats.win_trades || 0}`
|
language={language}
|
||||||
: `Avg Win = Total Profit / Winning Trades\nWinning Trades: ${stats.win_trades || 0}`}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="💸"
|
icon="💸"
|
||||||
title={t('positionHistory.avgLoss', language)}
|
title={t('positionHistory.avgLoss', language)}
|
||||||
value={'-' + formatNumber(stats.avg_loss || 0)}
|
value={'-' + formatNumber(stats.avg_loss || 0)}
|
||||||
color="#F6465D"
|
color="#F6465D"
|
||||||
formula={language === 'zh'
|
language={language}
|
||||||
? `平均亏损 = 总亏损 / 亏损交易数\n亏损交易数: ${stats.loss_trades || 0}`
|
|
||||||
: `Avg Loss = Total Loss / Losing Trades\nLosing Trades: ${stats.loss_trades || 0}`}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="💵"
|
icon="💵"
|
||||||
@@ -619,9 +587,7 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
value={((stats.total_pnl || 0) - (stats.total_fee || 0) >= 0 ? '+' : '') + formatNumber((stats.total_pnl || 0) - (stats.total_fee || 0))}
|
value={((stats.total_pnl || 0) - (stats.total_fee || 0) >= 0 ? '+' : '') + formatNumber((stats.total_pnl || 0) - (stats.total_fee || 0))}
|
||||||
color={(stats.total_pnl || 0) - (stats.total_fee || 0) >= 0 ? '#0ECB81' : '#F6465D'}
|
color={(stats.total_pnl || 0) - (stats.total_fee || 0) >= 0 ? '#0ECB81' : '#F6465D'}
|
||||||
subtitle={t('positionHistory.netPnLDesc', language)}
|
subtitle={t('positionHistory.netPnLDesc', language)}
|
||||||
formula={language === 'zh'
|
language={language}
|
||||||
? `净盈亏 = 总盈亏 - 手续费\n= ${formatNumber(stats.total_pnl || 0)} - ${formatNumber(stats.total_fee || 0)}`
|
|
||||||
: `Net P&L = Total P&L - Fees\n= ${formatNumber(stats.total_pnl || 0)} - ${formatNumber(stats.total_fee || 0)}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user