This commit is contained in:
aaron 2025-12-09 22:46:04 +08:00
parent ce035fdcbb
commit 020808f69f
5 changed files with 974 additions and 290 deletions

View File

@ -2,6 +2,7 @@
Configuration settings for Signal Generation System Configuration settings for Signal Generation System
Pure API mode - no Redis dependency Pure API mode - no Redis dependency
""" """
from typing import List
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@ -14,8 +15,14 @@ class Settings(BaseSettings):
extra="ignore" # Ignore extra fields from environment extra="ignore" # Ignore extra fields from environment
) )
# Symbol Configuration # Symbol Configuration - 支持多币种
SYMBOL: str = "BTCUSDT" SYMBOLS: str = "BTCUSDT,ETHUSDT" # 逗号分隔的交易对列表
SYMBOL: str = "BTCUSDT" # 向后兼容,默认主币种
@property
def symbols_list(self) -> List[str]:
"""解析币种列表"""
return [s.strip().upper() for s in self.SYMBOLS.split(',') if s.strip()]
# Binance API Configuration # Binance API Configuration
BINANCE_API_BASE_URL: str = "https://fapi.binance.com" BINANCE_API_BASE_URL: str = "https://fapi.binance.com"

View File

@ -2,13 +2,17 @@
Signal Generation Scheduler - 定时生成交易信号 Signal Generation Scheduler - 定时生成交易信号
每隔指定时间间隔自动运行量化分析和LLM决策 每隔指定时间间隔自动运行量化分析和LLM决策
支持多币种: BTC/USDT, ETH/USDT
""" """
import asyncio import asyncio
import logging import logging
import signal import signal
import sys import sys
import os
import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List
# Add parent directory to path # Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
@ -29,25 +33,31 @@ logger = logging.getLogger(__name__)
class SignalScheduler: class SignalScheduler:
"""定时信号生成调度器""" """定时信号生成调度器 - 支持多币种"""
def __init__(self, interval_minutes: int = 5): def __init__(self, interval_minutes: int = 5, symbols: List[str] = None):
""" """
Args: Args:
interval_minutes: 生成信号的时间间隔分钟 interval_minutes: 生成信号的时间间隔分钟
symbols: 交易对列表 ['BTCUSDT', 'ETHUSDT']
""" """
self.interval_minutes = interval_minutes self.interval_minutes = interval_minutes
self.is_running = False self.is_running = False
# Initialize components # 支持多币种
self.engine = MarketAnalysisEngine() self.symbols = symbols or settings.symbols_list
self.quant_generator = QuantitativeSignalGenerator() logger.info(f"支持的交易对: {', '.join(self.symbols)}")
# Initialize LLM decision maker # 为每个币种初始化分析引擎
self.engines: Dict[str, MarketAnalysisEngine] = {}
for symbol in self.symbols:
self.engines[symbol] = MarketAnalysisEngine(symbol=symbol)
# 共享组件
self.quant_generator = QuantitativeSignalGenerator()
self.llm_maker = LLMDecisionMaker(provider='openai') self.llm_maker = LLMDecisionMaker(provider='openai')
# Initialize DingTalk notifier # Initialize DingTalk notifier
import os
dingtalk_webhook = os.getenv('DINGTALK_WEBHOOK') dingtalk_webhook = os.getenv('DINGTALK_WEBHOOK')
dingtalk_secret = os.getenv('DINGTALK_SECRET') dingtalk_secret = os.getenv('DINGTALK_SECRET')
self.dingtalk = DingTalkNotifier( self.dingtalk = DingTalkNotifier(
@ -58,47 +68,52 @@ class SignalScheduler:
logger.info(f"Signal Scheduler 初始化完成 - 每{interval_minutes}分钟生成一次信号") logger.info(f"Signal Scheduler 初始化完成 - 每{interval_minutes}分钟生成一次信号")
async def generate_signal_once(self) -> dict: async def generate_signal_for_symbol(self, symbol: str) -> Dict:
"""执行一次信号生成""" """为单个币种生成信号"""
try: try:
logger.info("=" * 80) engine = self.engines.get(symbol)
logger.info(f"开始生成交易信号 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") if not engine:
logger.info("=" * 80) logger.error(f"未找到 {symbol} 的分析引擎")
# Step 1: Market analysis
analysis = self.engine.analyze_current_market(timeframe='5m')
if 'error' in analysis:
logger.warning(f"市场分析失败: {analysis['error']}")
return None return None
logger.info(f"市场分析完成 - 价格: ${analysis['current_price']:,.2f}, 趋势: {analysis['trend_analysis'].get('direction')}") logger.info(f"[{symbol}] 开始分析...")
# Step 1: Market analysis
analysis = engine.analyze_current_market(timeframe='5m')
if 'error' in analysis:
logger.warning(f"[{symbol}] 市场分析失败: {analysis['error']}")
return None
logger.info(f"[{symbol}] 价格: ${analysis['current_price']:,.2f}, 趋势: {analysis['trend_analysis'].get('direction')}")
# Step 2: Quantitative signal # Step 2: Quantitative signal
quant_signal = self.quant_generator.generate_signal(analysis) quant_signal = self.quant_generator.generate_signal(analysis)
logger.info(f"量化信号: {quant_signal['signal_type']} (得分: {quant_signal['composite_score']:.1f})") logger.info(f"[{symbol}] 量化信号: {quant_signal['signal_type']} (得分: {quant_signal['composite_score']:.1f})")
# Step 3: LLM decision # Step 3: LLM decision
llm_signal = None llm_context = engine.get_llm_context(format='full')
llm_context = self.engine.get_llm_context(format='full')
llm_signal = self.llm_maker.generate_decision(llm_context, analysis) llm_signal = self.llm_maker.generate_decision(llm_context, analysis)
if llm_signal.get('enabled', True): if llm_signal.get('enabled', True):
logger.info(f"LLM信号: {llm_signal['signal_type']} (置信度: {llm_signal.get('confidence', 0):.2%})") logger.info(f"[{symbol}] LLM信号: {llm_signal['signal_type']} (置信度: {llm_signal.get('confidence', 0):.2%})")
else: else:
logger.info("LLM未启用 (无API key)") logger.info(f"[{symbol}] LLM未启用")
# Step 4: Aggregate signals # Step 4: Aggregate signals
aggregated = SignalAggregator.aggregate_signals(quant_signal, llm_signal) aggregated = SignalAggregator.aggregate_signals(quant_signal, llm_signal)
aggregated['symbol'] = symbol # 添加币种标识
logger.info(f"最终信号: {aggregated['final_signal']} (置信度: {aggregated['final_confidence']:.2%})") logger.info(f"[{symbol}] 最终信号: {aggregated['final_signal']} (置信度: {aggregated['final_confidence']:.2%})")
# Step 5: Save to file # Step 5: Save to file (每个币种独立文件)
output_file = Path(__file__).parent / 'output' / 'latest_signal.json' output_dir = Path(__file__).parent / 'output'
output_file.parent.mkdir(exist_ok=True) output_dir.mkdir(exist_ok=True)
import json # 保存独立信号文件
symbol_file = output_dir / f'signal_{symbol.lower()}.json'
output_data = { output_data = {
'symbol': symbol,
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
'aggregated_signal': aggregated, 'aggregated_signal': aggregated,
'market_analysis': { 'market_analysis': {
@ -110,13 +125,58 @@ class SignalScheduler:
'llm_signal': llm_signal if llm_signal and llm_signal.get('enabled', True) else None, 'llm_signal': llm_signal if llm_signal and llm_signal.get('enabled', True) else None,
} }
with open(output_file, 'w') as f: with open(symbol_file, 'w') as f:
json.dump(output_data, f, indent=2, ensure_ascii=False) json.dump(output_data, f, indent=2, ensure_ascii=False)
logger.info(f"信号已保存到: {output_file}") return output_data
# Step 6: Send DingTalk notification except Exception as e:
logger.error(f"[{symbol}] 信号生成失败: {e}", exc_info=True)
return None
async def generate_signal_once(self) -> Dict:
"""为所有币种生成信号"""
logger.info("=" * 80)
logger.info(f"开始生成交易信号 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"交易对: {', '.join(self.symbols)}")
logger.info("=" * 80)
all_signals = {}
# 为每个币种生成信号
for symbol in self.symbols:
result = await self.generate_signal_for_symbol(symbol)
if result:
all_signals[symbol] = result
# 保存汇总信号文件 (latest_signal.json 保持向后兼容,使用第一个币种)
if all_signals:
# 合并所有信号到一个文件
combined_file = Path(__file__).parent / 'output' / 'latest_signals.json'
with open(combined_file, 'w') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'symbols': all_signals,
}, f, indent=2, ensure_ascii=False)
# 向后兼容: latest_signal.json 使用第一个币种
first_symbol = self.symbols[0]
if first_symbol in all_signals:
compat_file = Path(__file__).parent / 'output' / 'latest_signal.json'
with open(compat_file, 'w') as f:
json.dump(all_signals[first_symbol], f, indent=2, ensure_ascii=False)
# Step 6: Send DingTalk notification for signals with opportunities
for symbol, signal_data in all_signals.items():
await self._send_notification(symbol, signal_data)
logger.info("=" * 80)
return all_signals
async def _send_notification(self, symbol: str, signal_data: Dict):
"""发送钉钉通知"""
try: try:
aggregated = signal_data.get('aggregated_signal', {})
final_signal = aggregated.get('final_signal', 'HOLD') final_signal = aggregated.get('final_signal', 'HOLD')
should_notify = False should_notify = False
@ -124,9 +184,8 @@ class SignalScheduler:
if final_signal in ['BUY', 'SELL']: if final_signal in ['BUY', 'SELL']:
should_notify = True should_notify = True
notify_reason = f"明确{final_signal}信号" notify_reason = f"[{symbol}] 明确{final_signal}信号"
elif final_signal == 'HOLD': elif final_signal == 'HOLD':
# 检查是否有日内机会
llm_signal = aggregated.get('llm_signal') llm_signal = aggregated.get('llm_signal')
if llm_signal and isinstance(llm_signal, dict): if llm_signal and isinstance(llm_signal, dict):
opportunities = llm_signal.get('opportunities', {}) opportunities = llm_signal.get('opportunities', {})
@ -134,27 +193,19 @@ class SignalScheduler:
if short_term.get('exists', False): if short_term.get('exists', False):
should_notify = True should_notify = True
direction = short_term.get('direction', 'N/A') direction = short_term.get('direction', 'N/A')
notify_reason = f"HOLD信号存在短期{direction}机会" notify_reason = f"[{symbol}] 存在短期{direction}机会"
if should_notify: if should_notify:
logger.info(f"发送钉钉通知 - {notify_reason}") logger.info(f"发送钉钉通知 - {notify_reason}")
# 在信号中添加币种信息
aggregated['symbol'] = symbol
sent = self.dingtalk.send_signal(aggregated) sent = self.dingtalk.send_signal(aggregated)
if sent: if sent:
logger.info(f"钉钉通知发送成功") logger.info(f"[{symbol}] 钉钉通知发送成功")
else: else:
logger.warning(f"钉钉通知发送失败或未配置") logger.warning(f"[{symbol}] 钉钉通知发送失败或未配置")
else:
logger.info(f"HOLD信号且无日内机会跳过钉钉通知")
except Exception as e: except Exception as e:
logger.error(f"钉钉通知发送异常: {e}", exc_info=True) logger.error(f"[{symbol}] 钉钉通知发送异常: {e}", exc_info=True)
logger.info("=" * 80)
return aggregated
except Exception as e:
logger.error(f"信号生成失败: {e}", exc_info=True)
return None
async def run(self): async def run(self):
"""启动调度器主循环""" """启动调度器主循环"""

View File

@ -1,12 +1,12 @@
""" """
Paper Trading Module - 周期独立仓位管理 Paper Trading Module - 币种多周期独立仓位管理
支持三个独立周期的模拟交易 支持多币种 (BTC/USDT, ETH/USDT ) 三个独立周期的模拟交易
- 短周期 (5m/15m/1h): short_term_5m_15m_1h / intraday - 短周期 (5m/15m/1h): short_term_5m_15m_1h / intraday
- 中周期 (4h/1d): medium_term_4h_1d / swing - 中周期 (4h/1d): medium_term_4h_1d / swing
- 长周期 (1d/1w): long_term_1d_1w - 长周期 (1d/1w): long_term_1d_1w
每个周期独立管理 每个币种的每个周期独立管理
- 独立仓位 - 独立仓位
- 独立止盈止损 - 独立止盈止损
- 独立统计数据 - 独立统计数据
@ -20,6 +20,8 @@ from pathlib import Path
from dataclasses import dataclass, asdict, field from dataclasses import dataclass, asdict, field
from enum import Enum from enum import Enum
from config.settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -162,18 +164,22 @@ class Trade:
pnl: float pnl: float
pnl_pct: float pnl_pct: float
exit_reason: str exit_reason: str
symbol: str = "BTCUSDT" # 交易币种
def to_dict(self) -> dict: def to_dict(self) -> dict:
return asdict(self) return asdict(self)
@classmethod @classmethod
def from_dict(cls, data: dict) -> 'Trade': def from_dict(cls, data: dict) -> 'Trade':
# 兼容旧数据
if 'symbol' not in data:
data['symbol'] = 'BTCUSDT'
return cls(**data) return cls(**data)
@dataclass @dataclass
class TimeFrameAccount: class TimeFrameAccount:
"""单个周期的账户 """单个币种单个周期的账户
资金结构 资金结构
- initial_balance: 初始本金 - initial_balance: 初始本金
@ -187,6 +193,7 @@ class TimeFrameAccount:
timeframe: str timeframe: str
initial_balance: float initial_balance: float
leverage: int leverage: int
symbol: str = "BTCUSDT" # 交易币种
realized_pnl: float = 0.0 # 已实现盈亏 realized_pnl: float = 0.0 # 已实现盈亏
position: Optional[Position] = None position: Optional[Position] = None
trades: List[Trade] = field(default_factory=list) trades: List[Trade] = field(default_factory=list)
@ -228,6 +235,7 @@ class TimeFrameAccount:
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
'timeframe': self.timeframe, 'timeframe': self.timeframe,
'symbol': self.symbol,
'initial_balance': self.initial_balance, 'initial_balance': self.initial_balance,
'realized_pnl': self.realized_pnl, 'realized_pnl': self.realized_pnl,
'leverage': self.leverage, 'leverage': self.leverage,
@ -249,6 +257,7 @@ class TimeFrameAccount:
timeframe=data['timeframe'], timeframe=data['timeframe'],
initial_balance=data['initial_balance'], initial_balance=data['initial_balance'],
leverage=data['leverage'], leverage=data['leverage'],
symbol=data.get('symbol', 'BTCUSDT'), # 兼容旧数据
realized_pnl=realized_pnl, realized_pnl=realized_pnl,
stats=data.get('stats', {}), stats=data.get('stats', {}),
equity_curve=data.get('equity_curve', []), equity_curve=data.get('equity_curve', []),
@ -260,28 +269,33 @@ class TimeFrameAccount:
class MultiTimeframePaperTrader: class MultiTimeframePaperTrader:
"""周期模拟盘交易器""" """币种多周期模拟盘交易器"""
def __init__( def __init__(
self, self,
initial_balance: float = 10000.0, initial_balance: float = 10000.0,
state_file: str = None state_file: str = None,
symbols: List[str] = None
): ):
self.initial_balance = initial_balance self.initial_balance = initial_balance
# 支持的币种列表
self.symbols = symbols or settings.symbols_list
logger.info(f"支持的交易对: {', '.join(self.symbols)}")
# 状态文件 # 状态文件
if state_file: if state_file:
self.state_file = Path(state_file) self.state_file = Path(state_file)
else: else:
self.state_file = Path(__file__).parent.parent / 'output' / 'paper_trading_state.json' self.state_file = Path(__file__).parent.parent / 'output' / 'paper_trading_state.json'
# 初始化三个周期账户 # 多币种多周期账户: {symbol: {TimeFrame: TimeFrameAccount}}
self.accounts: Dict[TimeFrame, TimeFrameAccount] = {} self.accounts: Dict[str, Dict[TimeFrame, TimeFrameAccount]] = {}
# 加载或初始化状态 # 加载或初始化状态
self._load_state() self._load_state()
logger.info(f"Multi-timeframe Paper Trader initialized: total_balance=${initial_balance:.2f}") logger.info(f"Multi-symbol Multi-timeframe Paper Trader initialized: {len(self.symbols)} symbols")
def _load_state(self): def _load_state(self):
"""加载持久化状态""" """加载持久化状态"""
@ -290,13 +304,33 @@ class MultiTimeframePaperTrader:
with open(self.state_file, 'r') as f: with open(self.state_file, 'r') as f:
state = json.load(f) state = json.load(f)
# 加载各周期账户 # 检查是否是新的多币种格式
if 'symbols' in state:
# 新格式: {symbols: {BTCUSDT: {short: {...}, medium: {...}, long: {...}}, ...}}
for symbol in self.symbols:
symbol_data = state.get('symbols', {}).get(symbol, {})
self.accounts[symbol] = {}
for tf in TimeFrame:
tf_data = symbol_data.get(tf.value)
if tf_data:
self.accounts[symbol][tf] = TimeFrameAccount.from_dict(tf_data)
else:
self._init_account(symbol, tf)
else:
# 旧格式: {accounts: {short: {...}, medium: {...}, long: {...}}}
# 将旧数据迁移到第一个币种 (BTCUSDT)
first_symbol = self.symbols[0] if self.symbols else 'BTCUSDT'
self.accounts[first_symbol] = {}
for tf in TimeFrame: for tf in TimeFrame:
tf_data = state.get('accounts', {}).get(tf.value) tf_data = state.get('accounts', {}).get(tf.value)
if tf_data: if tf_data:
self.accounts[tf] = TimeFrameAccount.from_dict(tf_data) tf_data['symbol'] = first_symbol # 添加 symbol 字段
self.accounts[first_symbol][tf] = TimeFrameAccount.from_dict(tf_data)
else: else:
self._init_account(tf) self._init_account(first_symbol, tf)
# 初始化其他币种
for symbol in self.symbols[1:]:
self._init_symbol_accounts(symbol)
logger.info(f"Loaded state from {self.state_file}") logger.info(f"Loaded state from {self.state_file}")
except Exception as e: except Exception as e:
@ -306,18 +340,25 @@ class MultiTimeframePaperTrader:
self._init_all_accounts() self._init_all_accounts()
def _init_all_accounts(self): def _init_all_accounts(self):
"""初始化所有账户""" """初始化所有币种所有周期账户"""
for tf in TimeFrame: for symbol in self.symbols:
self._init_account(tf) self._init_symbol_accounts(symbol)
def _init_account(self, tf: TimeFrame): def _init_symbol_accounts(self, symbol: str):
"""初始化单个周期账户""" """初始化单个币种的所有周期账户"""
self.accounts[symbol] = {}
for tf in TimeFrame:
self._init_account(symbol, tf)
def _init_account(self, symbol: str, tf: TimeFrame):
"""初始化单个币种单个周期账户"""
config = TIMEFRAME_CONFIG[tf] config = TIMEFRAME_CONFIG[tf]
# 每个周期独立初始资金 10000 USD10倍杠杆最大仓位价值 100000 USD # 每个币种每个周期独立初始资金 10000 USD10倍杠杆最大仓位价值 100000 USD
self.accounts[tf] = TimeFrameAccount( self.accounts[symbol][tf] = TimeFrameAccount(
timeframe=tf.value, timeframe=tf.value,
initial_balance=config['initial_balance'], initial_balance=config['initial_balance'],
leverage=config['leverage'], leverage=config['leverage'],
symbol=symbol,
realized_pnl=0.0, realized_pnl=0.0,
) )
@ -325,34 +366,95 @@ class MultiTimeframePaperTrader:
"""保存状态到文件""" """保存状态到文件"""
self.state_file.parent.mkdir(parents=True, exist_ok=True) self.state_file.parent.mkdir(parents=True, exist_ok=True)
# 新格式: {symbols: {BTCUSDT: {short: {...}, ...}, ETHUSDT: {...}}, accounts: {...}}
symbols_data = {}
for symbol, tf_accounts in self.accounts.items():
symbols_data[symbol] = {
tf.value: acc.to_dict() for tf, acc in tf_accounts.items()
}
# 同时保留旧格式兼容 (使用第一个币种)
first_symbol = self.symbols[0] if self.symbols else 'BTCUSDT'
legacy_accounts = {}
if first_symbol in self.accounts:
legacy_accounts = {
tf.value: acc.to_dict() for tf, acc in self.accounts[first_symbol].items()
}
state = { state = {
'accounts': {tf.value: acc.to_dict() for tf, acc in self.accounts.items()}, 'symbols': symbols_data,
'accounts': legacy_accounts, # 向后兼容
'last_updated': datetime.now().isoformat(), 'last_updated': datetime.now().isoformat(),
} }
with open(self.state_file, 'w') as f: with open(self.state_file, 'w') as f:
json.dump(state, f, indent=2, ensure_ascii=False) json.dump(state, f, indent=2, ensure_ascii=False)
def process_signal(self, signal: Dict[str, Any], current_price: float) -> Dict[str, Any]: def process_signal(
"""处理交易信号 - 检查所有周期""" self,
signal: Dict[str, Any],
current_price: float,
symbol: str = None
) -> Dict[str, Any]:
"""处理单个币种的交易信号 - 检查所有周期
Args:
signal: 该币种的信号数据
current_price: 该币种当前价格
symbol: 交易对 'BTCUSDT'若未指定则使用第一个币种
"""
symbol = symbol or (self.symbols[0] if self.symbols else 'BTCUSDT')
# 确保该币种的账户已初始化
if symbol not in self.accounts:
self._init_symbol_accounts(symbol)
results = { results = {
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
'symbol': symbol,
'current_price': current_price, 'current_price': current_price,
'timeframes': {}, 'timeframes': {},
} }
for tf in TimeFrame: for tf in TimeFrame:
result = self._process_timeframe_signal(tf, signal, current_price) result = self._process_timeframe_signal(symbol, tf, signal, current_price)
results['timeframes'][tf.value] = result results['timeframes'][tf.value] = result
self._save_state() self._save_state()
return results return results
def _process_timeframe_signal( def process_all_signals(
self, tf: TimeFrame, signal: Dict[str, Any], current_price: float self,
signals: Dict[str, Dict[str, Any]],
prices: Dict[str, float]
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""处理单个周期的信号""" """处理所有币种的信号
account = self.accounts[tf]
Args:
signals: {symbol: signal_data} 各币种的信号
prices: {symbol: price} 各币种的当前价格
"""
results = {
'timestamp': datetime.now().isoformat(),
'symbols': {},
}
for symbol in self.symbols:
if symbol in signals and symbol in prices:
result = self.process_signal(
signal=signals[symbol],
current_price=prices[symbol],
symbol=symbol
)
results['symbols'][symbol] = result
return results
def _process_timeframe_signal(
self, symbol: str, tf: TimeFrame, signal: Dict[str, Any], current_price: float
) -> Dict[str, Any]:
"""处理单个币种单个周期的信号"""
account = self.accounts[symbol][tf]
config = TIMEFRAME_CONFIG[tf] config = TIMEFRAME_CONFIG[tf]
result = { result = {
@ -361,11 +463,11 @@ class MultiTimeframePaperTrader:
} }
# 更新权益曲线 # 更新权益曲线
self._update_equity_curve(tf, current_price) self._update_equity_curve(symbol, tf, current_price)
# 1. 检查止盈止损 # 1. 检查止盈止损
if account.position and account.position.side != 'FLAT': if account.position and account.position.side != 'FLAT':
close_result = self._check_close_position(tf, current_price) close_result = self._check_close_position(symbol, tf, current_price)
if close_result: if close_result:
result['action'] = 'CLOSE' result['action'] = 'CLOSE'
result['details'] = close_result result['details'] = close_result
@ -417,17 +519,17 @@ class MultiTimeframePaperTrader:
# 反向信号:只平仓不开反向仓 # 反向信号:只平仓不开反向仓
if (account.position.side == 'LONG' and direction == 'SHORT') or \ if (account.position.side == 'LONG' and direction == 'SHORT') or \
(account.position.side == 'SHORT' and direction == 'LONG'): (account.position.side == 'SHORT' and direction == 'LONG'):
close_result = self._close_position(tf, current_price, 'SIGNAL_REVERSE') close_result = self._close_position(symbol, tf, current_price, 'SIGNAL_REVERSE')
result['action'] = 'CLOSE' result['action'] = 'CLOSE'
result['details'] = close_result result['details'] = close_result
logger.info( logger.info(
f"[{config['name']}] 反向信号平仓,等待下一周期新信号" f"[{symbol}][{config['name']}] 反向信号平仓,等待下一周期新信号"
) )
return result return result
else: else:
# 同方向信号:尝试金字塔加仓 # 同方向信号:尝试金字塔加仓
add_result = self._add_position( add_result = self._add_position(
tf, current_price, symbol, tf, current_price,
signal_stop_loss, signal_take_profit, signal_stop_loss, signal_take_profit,
tf_signal.get('reasoning', '')[:100] tf_signal.get('reasoning', '')[:100]
) )
@ -439,14 +541,14 @@ class MultiTimeframePaperTrader:
result['action'] = 'HOLD' result['action'] = 'HOLD'
result['details'] = { result['details'] = {
'position': account.position.to_dict(), 'position': account.position.to_dict(),
'unrealized_pnl': self._calc_unrealized_pnl(tf, current_price), 'unrealized_pnl': self._calc_unrealized_pnl(symbol, tf, current_price),
'reason': '已达最大仓位层级', 'reason': '已达最大仓位层级',
} }
return result return result
# 4. 无持仓,开新仓(首仓) # 4. 无持仓,开新仓(首仓)
open_result = self._open_position( open_result = self._open_position(
tf, direction, current_price, symbol, tf, direction, current_price,
signal_stop_loss, signal_take_profit, signal_stop_loss, signal_take_profit,
tf_signal.get('reasoning', '')[:100] tf_signal.get('reasoning', '')[:100]
) )
@ -488,28 +590,28 @@ class MultiTimeframePaperTrader:
logger.error(f"Error extracting signal: {e}") logger.error(f"Error extracting signal: {e}")
return None return None
def _get_max_position_value(self, tf: TimeFrame) -> float: def _get_max_position_value(self, symbol: str, tf: TimeFrame) -> float:
"""获取最大仓位价值(本金 × 杠杆)""" """获取最大仓位价值(本金 × 杠杆)"""
account = self.accounts[tf] account = self.accounts[symbol][tf]
return account.initial_balance * account.leverage return account.initial_balance * account.leverage
def _get_current_position_value(self, tf: TimeFrame, current_price: float) -> float: def _get_current_position_value(self, symbol: str, tf: TimeFrame, current_price: float) -> float:
"""获取当前仓位价值""" """获取当前仓位价值"""
account = self.accounts[tf] account = self.accounts[symbol][tf]
if not account.position or account.position.side == 'FLAT': if not account.position or account.position.side == 'FLAT':
return 0.0 return 0.0
return account.position.size * current_price return account.position.size * current_price
def _open_position( def _open_position(
self, tf: TimeFrame, direction: str, price: float, self, symbol: str, tf: TimeFrame, direction: str, price: float,
stop_loss: float, take_profit: float, reasoning: str stop_loss: float, take_profit: float, reasoning: str
) -> Optional[Dict]: ) -> Optional[Dict]:
"""开首仓(金字塔第一层)""" """开首仓(金字塔第一层)"""
account = self.accounts[tf] account = self.accounts[symbol][tf]
config = TIMEFRAME_CONFIG[tf] config = TIMEFRAME_CONFIG[tf]
# 计算首仓仓位:最大仓位 × 首仓比例 # 计算首仓仓位:最大仓位 × 首仓比例
max_position_value = self._get_max_position_value(tf) max_position_value = self._get_max_position_value(symbol, tf)
first_level_ratio = PYRAMID_LEVELS[0] # 40% first_level_ratio = PYRAMID_LEVELS[0] # 40%
position_value = max_position_value * first_level_ratio position_value = max_position_value * first_level_ratio
margin = position_value / account.leverage margin = position_value / account.leverage
@ -518,7 +620,7 @@ class MultiTimeframePaperTrader:
# 检查可用余额是否足够 # 检查可用余额是否足够
available_balance = account.get_available_balance() available_balance = account.get_available_balance()
if available_balance < margin: if available_balance < margin:
logger.warning(f"[{config['name']}] 可用余额不足: ${available_balance:.2f} < ${margin:.2f}") logger.warning(f"[{symbol}][{config['name']}] 可用余额不足: ${available_balance:.2f} < ${margin:.2f}")
return None return None
if size <= 0: if size <= 0:
@ -542,13 +644,17 @@ class MultiTimeframePaperTrader:
signal_reasoning=reasoning, signal_reasoning=reasoning,
) )
# 确定单位名称
unit = symbol.replace('USDT', '') if symbol.endswith('USDT') else symbol
logger.info( logger.info(
f"[{config['name']}] OPEN {direction} [L1/{len(PYRAMID_LEVELS)}]: price=${price:.2f}, " f"[{symbol}][{config['name']}] OPEN {direction} [L1/{len(PYRAMID_LEVELS)}]: price=${price:.2f}, "
f"size={size:.6f} BTC, margin=${margin:.2f}, value=${position_value:.2f}, " f"size={size:.6f} {unit}, margin=${margin:.2f}, value=${position_value:.2f}, "
f"SL=${stop_loss:.2f}, TP=${take_profit:.2f}" f"SL=${stop_loss:.2f}, TP=${take_profit:.2f}"
) )
return { return {
'symbol': symbol,
'timeframe': tf.value, 'timeframe': tf.value,
'side': direction, 'side': direction,
'entry_price': price, 'entry_price': price,
@ -562,11 +668,11 @@ class MultiTimeframePaperTrader:
} }
def _add_position( def _add_position(
self, tf: TimeFrame, price: float, self, symbol: str, tf: TimeFrame, price: float,
stop_loss: float, take_profit: float, reasoning: str stop_loss: float, take_profit: float, reasoning: str
) -> Optional[Dict]: ) -> Optional[Dict]:
"""金字塔加仓""" """金字塔加仓"""
account = self.accounts[tf] account = self.accounts[symbol][tf]
config = TIMEFRAME_CONFIG[tf] config = TIMEFRAME_CONFIG[tf]
pos = account.position pos = account.position
@ -576,11 +682,11 @@ class MultiTimeframePaperTrader:
# 检查是否已达最大层级 # 检查是否已达最大层级
current_level = pos.pyramid_level current_level = pos.pyramid_level
if current_level >= len(PYRAMID_LEVELS): if current_level >= len(PYRAMID_LEVELS):
logger.info(f"[{config['name']}] 已达最大仓位层级 {current_level}/{len(PYRAMID_LEVELS)}") logger.info(f"[{symbol}][{config['name']}] 已达最大仓位层级 {current_level}/{len(PYRAMID_LEVELS)}")
return None return None
# 计算加仓仓位 # 计算加仓仓位
max_position_value = self._get_max_position_value(tf) max_position_value = self._get_max_position_value(symbol, tf)
level_ratio = PYRAMID_LEVELS[current_level] level_ratio = PYRAMID_LEVELS[current_level]
add_position_value = max_position_value * level_ratio add_position_value = max_position_value * level_ratio
add_margin = add_position_value / account.leverage add_margin = add_position_value / account.leverage
@ -590,7 +696,7 @@ class MultiTimeframePaperTrader:
available_balance = account.get_available_balance() available_balance = account.get_available_balance()
if available_balance < add_margin: if available_balance < add_margin:
logger.warning( logger.warning(
f"[{config['name']}] 加仓余额不足: ${available_balance:.2f} < ${add_margin:.2f}" f"[{symbol}][{config['name']}] 加仓余额不足: ${available_balance:.2f} < ${add_margin:.2f}"
) )
return None return None
@ -608,15 +714,19 @@ class MultiTimeframePaperTrader:
pos.stop_loss = stop_loss pos.stop_loss = stop_loss
pos.take_profit = take_profit pos.take_profit = take_profit
# 确定单位名称
unit = symbol.replace('USDT', '') if symbol.endswith('USDT') else symbol
new_level = pos.pyramid_level new_level = pos.pyramid_level
logger.info( logger.info(
f"[{config['name']}] ADD {pos.side} [L{new_level}/{len(PYRAMID_LEVELS)}]: price=${price:.2f}, " f"[{symbol}][{config['name']}] ADD {pos.side} [L{new_level}/{len(PYRAMID_LEVELS)}]: price=${price:.2f}, "
f"add_size={add_size:.6f} BTC, add_margin=${add_margin:.2f}, " f"add_size={add_size:.6f} {unit}, add_margin=${add_margin:.2f}, "
f"total_size={pos.size:.6f} BTC, total_margin=${pos.margin:.2f}, " f"total_size={pos.size:.6f} {unit}, total_margin=${pos.margin:.2f}, "
f"avg_price=${pos.entry_price:.2f}" f"avg_price=${pos.entry_price:.2f}"
) )
return { return {
'symbol': symbol,
'timeframe': tf.value, 'timeframe': tf.value,
'side': pos.side, 'side': pos.side,
'add_price': price, 'add_price': price,
@ -632,9 +742,9 @@ class MultiTimeframePaperTrader:
'take_profit': take_profit, 'take_profit': take_profit,
} }
def _check_close_position(self, tf: TimeFrame, current_price: float) -> Optional[Dict]: def _check_close_position(self, symbol: str, tf: TimeFrame, current_price: float) -> Optional[Dict]:
"""检查是否触发止盈止损""" """检查是否触发止盈止损"""
account = self.accounts[tf] account = self.accounts[symbol][tf]
pos = account.position pos = account.position
if not pos or pos.side == 'FLAT': if not pos or pos.side == 'FLAT':
@ -642,20 +752,20 @@ class MultiTimeframePaperTrader:
if pos.side == 'LONG': if pos.side == 'LONG':
if current_price >= pos.take_profit: if current_price >= pos.take_profit:
return self._close_position(tf, current_price, 'TAKE_PROFIT') return self._close_position(symbol, tf, current_price, 'TAKE_PROFIT')
elif current_price <= pos.stop_loss: elif current_price <= pos.stop_loss:
return self._close_position(tf, current_price, 'STOP_LOSS') return self._close_position(symbol, tf, current_price, 'STOP_LOSS')
else: # SHORT else: # SHORT
if current_price <= pos.take_profit: if current_price <= pos.take_profit:
return self._close_position(tf, current_price, 'TAKE_PROFIT') return self._close_position(symbol, tf, current_price, 'TAKE_PROFIT')
elif current_price >= pos.stop_loss: elif current_price >= pos.stop_loss:
return self._close_position(tf, current_price, 'STOP_LOSS') return self._close_position(symbol, tf, current_price, 'STOP_LOSS')
return None return None
def _close_position(self, tf: TimeFrame, price: float, reason: str) -> Dict: def _close_position(self, symbol: str, tf: TimeFrame, price: float, reason: str) -> Dict:
"""平仓""" """平仓"""
account = self.accounts[tf] account = self.accounts[symbol][tf]
config = TIMEFRAME_CONFIG[tf] config = TIMEFRAME_CONFIG[tf]
pos = account.position pos = account.position
@ -678,7 +788,7 @@ class MultiTimeframePaperTrader:
# 记录交易 # 记录交易
trade = Trade( trade = Trade(
id=f"{tf.value[0].upper()}{len(account.trades)+1:04d}", id=f"{symbol[0]}{tf.value[0].upper()}{len(account.trades)+1:04d}",
timeframe=tf.value, timeframe=tf.value,
side=pos.side, side=pos.side,
entry_price=pos.entry_price, entry_price=pos.entry_price,
@ -689,14 +799,16 @@ class MultiTimeframePaperTrader:
pnl=pnl, pnl=pnl,
pnl_pct=pnl_pct, pnl_pct=pnl_pct,
exit_reason=reason, exit_reason=reason,
symbol=symbol,
) )
account.trades.append(trade) account.trades.append(trade)
self._update_stats(tf, trade) self._update_stats(symbol, tf, trade)
# 计算新的账户权益 # 计算新的账户权益
new_equity = account.get_equity() new_equity = account.get_equity()
result = { result = {
'symbol': symbol,
'timeframe': tf.value, 'timeframe': tf.value,
'side': pos.side, 'side': pos.side,
'entry_price': pos.entry_price, 'entry_price': pos.entry_price,
@ -711,7 +823,7 @@ class MultiTimeframePaperTrader:
} }
logger.info( logger.info(
f"[{config['name']}] CLOSE {pos.side}: entry=${pos.entry_price:.2f}, " f"[{symbol}][{config['name']}] CLOSE {pos.side}: entry=${pos.entry_price:.2f}, "
f"exit=${price:.2f}, PnL=${pnl:.2f} ({pnl_pct:.2f}%), reason={reason}, " f"exit=${price:.2f}, PnL=${pnl:.2f} ({pnl_pct:.2f}%), reason={reason}, "
f"equity=${new_equity:.2f}" f"equity=${new_equity:.2f}"
) )
@ -719,9 +831,9 @@ class MultiTimeframePaperTrader:
account.position = None account.position = None
return result return result
def _calc_unrealized_pnl(self, tf: TimeFrame, current_price: float) -> Dict[str, float]: def _calc_unrealized_pnl(self, symbol: str, tf: TimeFrame, current_price: float) -> Dict[str, float]:
"""计算未实现盈亏""" """计算未实现盈亏"""
account = self.accounts[tf] account = self.accounts[symbol][tf]
pos = account.position pos = account.position
if not pos or pos.side == 'FLAT': if not pos or pos.side == 'FLAT':
@ -738,10 +850,10 @@ class MultiTimeframePaperTrader:
return {'pnl': pnl, 'pnl_pct': pnl_pct} return {'pnl': pnl, 'pnl_pct': pnl_pct}
def _update_equity_curve(self, tf: TimeFrame, current_price: float): def _update_equity_curve(self, symbol: str, tf: TimeFrame, current_price: float):
"""更新权益曲线""" """更新权益曲线"""
account = self.accounts[tf] account = self.accounts[symbol][tf]
unrealized = self._calc_unrealized_pnl(tf, current_price) unrealized = self._calc_unrealized_pnl(symbol, tf, current_price)
equity = account.get_equity(unrealized['pnl']) equity = account.get_equity(unrealized['pnl'])
account.equity_curve.append({ account.equity_curve.append({
@ -753,9 +865,9 @@ class MultiTimeframePaperTrader:
'price': current_price, 'price': current_price,
}) })
def _update_stats(self, tf: TimeFrame, trade: Trade): def _update_stats(self, symbol: str, tf: TimeFrame, trade: Trade):
"""更新统计数据""" """更新统计数据"""
account = self.accounts[tf] account = self.accounts[symbol][tf]
stats = account.stats stats = account.stats
stats['total_trades'] += 1 stats['total_trades'] += 1
@ -789,21 +901,43 @@ class MultiTimeframePaperTrader:
if drawdown > stats['max_drawdown']: if drawdown > stats['max_drawdown']:
stats['max_drawdown'] = drawdown stats['max_drawdown'] = drawdown
def get_status(self, current_price: float = None) -> Dict[str, Any]: def get_status(
"""获取所有周期状态""" self,
current_price: float = None,
symbol: str = None,
prices: Dict[str, float] = None
) -> Dict[str, Any]:
"""获取状态
Args:
current_price: 单币种价格向后兼容
symbol: 指定币种若为空则返回所有
prices: 多币种价格 {symbol: price}
"""
# 如果指定了单个币种
if symbol:
return self._get_symbol_status(symbol, current_price or (prices.get(symbol) if prices else None))
# 返回所有币种汇总
return self._get_all_status(prices or {self.symbols[0]: current_price} if current_price else {})
def _get_symbol_status(self, symbol: str, current_price: float = None) -> Dict[str, Any]:
"""获取单个币种所有周期状态"""
if symbol not in self.accounts:
return {'error': f'Symbol {symbol} not found'}
total_equity = 0 total_equity = 0
total_initial = 0 total_initial = 0
total_realized_pnl = 0 total_realized_pnl = 0
total_unrealized_pnl = 0 total_unrealized_pnl = 0
# 先计算各周期数据
timeframes_data = {} timeframes_data = {}
for tf in TimeFrame: for tf in TimeFrame:
account = self.accounts[tf] account = self.accounts[symbol][tf]
config = TIMEFRAME_CONFIG[tf] config = TIMEFRAME_CONFIG[tf]
# 计算未实现盈亏 # 计算未实现盈亏
unrealized = self._calc_unrealized_pnl(tf, current_price) if current_price else {'pnl': 0, 'pnl_pct': 0} unrealized = self._calc_unrealized_pnl(symbol, tf, current_price) if current_price else {'pnl': 0, 'pnl_pct': 0}
equity = account.get_equity(unrealized['pnl']) equity = account.get_equity(unrealized['pnl'])
total_initial += account.initial_balance total_initial += account.initial_balance
@ -811,12 +945,12 @@ class MultiTimeframePaperTrader:
total_unrealized_pnl += unrealized['pnl'] total_unrealized_pnl += unrealized['pnl']
total_equity += equity total_equity += equity
# 收益率 = (权益 - 初始本金) / 初始本金
return_pct = (equity - account.initial_balance) / account.initial_balance * 100 if account.initial_balance > 0 else 0 return_pct = (equity - account.initial_balance) / account.initial_balance * 100 if account.initial_balance > 0 else 0
tf_status = { tf_status = {
'name': config['name'], 'name': config['name'],
'name_en': config['name_en'], 'name_en': config['name_en'],
'symbol': symbol,
'initial_balance': account.initial_balance, 'initial_balance': account.initial_balance,
'realized_pnl': account.realized_pnl, 'realized_pnl': account.realized_pnl,
'unrealized_pnl': unrealized['pnl'], 'unrealized_pnl': unrealized['pnl'],
@ -841,11 +975,11 @@ class MultiTimeframePaperTrader:
timeframes_data[tf.value] = tf_status timeframes_data[tf.value] = tf_status
# 总收益率
total_return = (total_equity - total_initial) / total_initial * 100 if total_initial > 0 else 0 total_return = (total_equity - total_initial) / total_initial * 100 if total_initial > 0 else 0
status = { return {
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
'symbol': symbol,
'total_initial_balance': total_initial, 'total_initial_balance': total_initial,
'total_realized_pnl': total_realized_pnl, 'total_realized_pnl': total_realized_pnl,
'total_unrealized_pnl': total_unrealized_pnl, 'total_unrealized_pnl': total_unrealized_pnl,
@ -854,13 +988,67 @@ class MultiTimeframePaperTrader:
'timeframes': timeframes_data, 'timeframes': timeframes_data,
} }
return status def _get_all_status(self, prices: Dict[str, float] = None) -> Dict[str, Any]:
"""获取所有币种汇总状态"""
prices = prices or {}
def reset(self): grand_total_equity = 0
"""重置所有账户""" grand_total_initial = 0
grand_total_realized_pnl = 0
grand_total_unrealized_pnl = 0
symbols_data = {}
for symbol in self.symbols:
if symbol not in self.accounts:
continue
current_price = prices.get(symbol)
symbol_status = self._get_symbol_status(symbol, current_price)
symbols_data[symbol] = symbol_status
grand_total_initial += symbol_status.get('total_initial_balance', 0)
grand_total_realized_pnl += symbol_status.get('total_realized_pnl', 0)
grand_total_unrealized_pnl += symbol_status.get('total_unrealized_pnl', 0)
grand_total_equity += symbol_status.get('total_equity', 0)
grand_total_return = (grand_total_equity - grand_total_initial) / grand_total_initial * 100 if grand_total_initial > 0 else 0
# 向后兼容:保留 timeframes 字段(使用第一个币种)
first_symbol = self.symbols[0] if self.symbols else None
legacy_timeframes = symbols_data.get(first_symbol, {}).get('timeframes', {}) if first_symbol else {}
return {
'timestamp': datetime.now().isoformat(),
'symbols': symbols_data,
'timeframes': legacy_timeframes, # 向后兼容
'grand_total_initial_balance': grand_total_initial,
'grand_total_realized_pnl': grand_total_realized_pnl,
'grand_total_unrealized_pnl': grand_total_unrealized_pnl,
'grand_total_equity': grand_total_equity,
'grand_total_return': grand_total_return,
# 向后兼容字段
'total_initial_balance': grand_total_initial,
'total_realized_pnl': grand_total_realized_pnl,
'total_unrealized_pnl': grand_total_unrealized_pnl,
'total_equity': grand_total_equity,
'total_return': grand_total_return,
}
def reset(self, symbol: str = None):
"""重置账户
Args:
symbol: 指定币种若为空则重置所有
"""
if symbol:
if symbol in self.accounts:
self._init_symbol_accounts(symbol)
logger.info(f"{symbol} accounts reset")
else:
self._init_all_accounts() self._init_all_accounts()
self._save_state()
logger.info("All accounts reset") logger.info("All accounts reset")
self._save_state()
# 兼容旧的 PaperTrader 接口 # 兼容旧的 PaperTrader 接口

View File

@ -1,10 +1,11 @@
""" """
FastAPI Web Service - 周期交易状态展示 API FastAPI Web Service - 币种多周期交易状态展示 API
""" """
import json import json
import asyncio import asyncio
import urllib.request import urllib.request
import ssl import ssl
import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
@ -13,45 +14,74 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from config.settings import settings
# 状态文件路径 # 状态文件路径
STATE_FILE = Path(__file__).parent.parent / 'output' / 'paper_trading_state.json' STATE_FILE = Path(__file__).parent.parent / 'output' / 'paper_trading_state.json'
SIGNAL_FILE = Path(__file__).parent.parent / 'output' / 'latest_signal.json' SIGNAL_FILE = Path(__file__).parent.parent / 'output' / 'latest_signal.json'
SIGNALS_FILE = Path(__file__).parent.parent / 'output' / 'latest_signals.json'
# Binance API # 支持的币种列表
BINANCE_PRICE_URL = "https://fapi.binance.com/fapi/v1/ticker/price?symbol=BTCUSDT" SYMBOLS = settings.symbols_list
# Binance API - 多币种价格
BINANCE_PRICE_BASE_URL = "https://fapi.binance.com/fapi/v1/ticker/price"
app = FastAPI(title="Trading Dashboard", version="2.0.0") app = FastAPI(title="Trading Dashboard", version="2.0.0")
# 全局价格缓存 # 全局价格缓存 - 多币种
_current_price: float = 0.0 _current_prices: Dict[str, float] = {}
_price_update_time: datetime = None _price_update_time: datetime = None
async def fetch_binance_price() -> Optional[float]: async def fetch_binance_prices() -> Dict[str, float]:
"""从 Binance 获取实时价格(使用标准库)""" """从 Binance 获取所有币种实时价格"""
global _current_price, _price_update_time global _current_prices, _price_update_time
try: try:
# 使用线程池执行同步请求,避免阻塞事件循环
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
price = await loop.run_in_executor(None, _fetch_price_sync) prices = await loop.run_in_executor(None, _fetch_prices_sync)
if price: if prices:
_current_price = price _current_prices.update(prices)
_price_update_time = datetime.now() _price_update_time = datetime.now()
return _current_price return _current_prices
except Exception as e: except Exception as e:
print(f"Error fetching Binance price: {type(e).__name__}: {e}") print(f"Error fetching Binance prices: {type(e).__name__}: {e}")
return _current_price if _current_price > 0 else None return _current_prices
def _fetch_price_sync() -> Optional[float]: async def fetch_binance_price(symbol: str = 'BTCUSDT') -> Optional[float]:
"""同步获取价格""" """从 Binance 获取单个币种实时价格(向后兼容)"""
prices = await fetch_binance_prices()
return prices.get(symbol)
def _fetch_prices_sync() -> Dict[str, float]:
"""同步获取所有币种价格"""
prices = {}
try: try:
# 创建 SSL 上下文
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
req = urllib.request.Request( for symbol in SYMBOLS:
BINANCE_PRICE_URL, try:
headers={'User-Agent': 'Mozilla/5.0'} url = f"{BINANCE_PRICE_BASE_URL}?symbol={symbol}"
) req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=5, context=ctx) as response:
data = json.loads(response.read().decode('utf-8'))
prices[symbol] = float(data['price'])
except Exception as e:
print(f"Fetch {symbol} price error: {type(e).__name__}: {e}")
except Exception as e:
print(f"Sync fetch error: {type(e).__name__}: {e}")
return prices
def _fetch_price_sync(symbol: str = 'BTCUSDT') -> Optional[float]:
"""同步获取单个币种价格(向后兼容)"""
try:
ctx = ssl.create_default_context()
url = f"{BINANCE_PRICE_BASE_URL}?symbol={symbol}"
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=5, context=ctx) as response: with urllib.request.urlopen(req, timeout=5, context=ctx) as response:
data = json.loads(response.read().decode('utf-8')) data = json.loads(response.read().decode('utf-8'))
return float(data['price']) return float(data['price'])
@ -92,20 +122,26 @@ def load_trading_state() -> Dict[str, Any]:
except Exception as e: except Exception as e:
print(f"Error loading state: {e}") print(f"Error loading state: {e}")
# 返回默认状态 # 返回默认状态 - 多币种格式
default_symbols = {}
for symbol in SYMBOLS:
default_symbols[symbol] = {
'short': _default_account('short', 10000, symbol),
'medium': _default_account('medium', 10000, symbol),
'long': _default_account('long', 10000, symbol),
}
return { return {
'accounts': { 'symbols': default_symbols,
'short': _default_account('short', 10000), 'accounts': default_symbols.get(SYMBOLS[0], {}) if SYMBOLS else {}, # 向后兼容
'medium': _default_account('medium', 10000),
'long': _default_account('long', 10000),
},
'last_updated': None, 'last_updated': None,
} }
def _default_account(timeframe: str, initial_balance: float) -> Dict: def _default_account(timeframe: str, initial_balance: float, symbol: str = 'BTCUSDT') -> Dict:
return { return {
'timeframe': timeframe, 'timeframe': timeframe,
'symbol': symbol,
'initial_balance': initial_balance, 'initial_balance': initial_balance,
'realized_pnl': 0.0, 'realized_pnl': 0.0,
'leverage': 10, # 所有周期统一 10 倍杠杆 'leverage': 10, # 所有周期统一 10 倍杠杆
@ -124,12 +160,36 @@ def _default_account(timeframe: str, initial_balance: float) -> Dict:
} }
def load_latest_signal() -> Dict[str, Any]: def load_latest_signal(symbol: str = None) -> Dict[str, Any]:
"""加载最新信号""" """加载最新信号
Args:
symbol: 指定币种若为空则加载所有
"""
try: try:
if SIGNAL_FILE.exists(): if symbol:
# 加载单个币种信号
symbol_file = Path(__file__).parent.parent / 'output' / f'signal_{symbol.lower()}.json'
if symbol_file.exists():
with open(symbol_file, 'r') as f:
return json.load(f)
# 降级到旧文件
elif SIGNAL_FILE.exists():
with open(SIGNAL_FILE, 'r') as f: with open(SIGNAL_FILE, 'r') as f:
return json.load(f) return json.load(f)
else:
# 加载所有币种信号
if SIGNALS_FILE.exists():
with open(SIGNALS_FILE, 'r') as f:
return json.load(f)
elif SIGNAL_FILE.exists():
with open(SIGNAL_FILE, 'r') as f:
data = json.load(f)
# 转换为新格式
return {
'timestamp': data.get('timestamp'),
'symbols': {SYMBOLS[0] if SYMBOLS else 'BTCUSDT': data}
}
except Exception as e: except Exception as e:
print(f"Error loading signal: {e}") print(f"Error loading signal: {e}")
return {} return {}
@ -145,29 +205,143 @@ async def root():
@app.get("/api/status") @app.get("/api/status")
async def get_status(): async def get_status(symbol: str = None):
"""获取多周期交易状态""" """获取多币种多周期交易状态
Args:
symbol: 指定币种可选若为空则返回所有币种汇总
"""
state = load_trading_state() state = load_trading_state()
# 检查是否是新的多币种格式
if 'symbols' in state:
return _get_multi_symbol_status(state, symbol)
else:
# 旧格式兼容
return _get_legacy_status(state)
def _get_multi_symbol_status(state: Dict, symbol: str = None) -> Dict:
"""处理多币种状态"""
symbols_data = state.get('symbols', {})
if symbol:
# 返回单个币种状态
if symbol not in symbols_data:
return {"error": f"Symbol '{symbol}' not found"}
return _build_symbol_status(symbol, symbols_data[symbol], state.get('last_updated'))
# 返回所有币种汇总
grand_total_initial = 0
grand_total_realized_pnl = 0
grand_total_equity = 0
all_symbols_status = {}
for sym, accounts in symbols_data.items():
sym_status = _build_symbol_status(sym, accounts, None)
all_symbols_status[sym] = sym_status
grand_total_initial += sym_status.get('total_initial_balance', 0)
grand_total_realized_pnl += sym_status.get('total_realized_pnl', 0)
grand_total_equity += sym_status.get('total_equity', 0)
grand_total_return = (grand_total_equity - grand_total_initial) / grand_total_initial * 100 if grand_total_initial > 0 else 0
# 向后兼容:保留 timeframes 字段
first_symbol = SYMBOLS[0] if SYMBOLS else None
legacy_timeframes = all_symbols_status.get(first_symbol, {}).get('timeframes', {}) if first_symbol else {}
return {
'timestamp': datetime.now().isoformat(),
'symbols': all_symbols_status,
'supported_symbols': SYMBOLS,
'timeframes': legacy_timeframes, # 向后兼容
'grand_total_initial_balance': grand_total_initial,
'grand_total_realized_pnl': grand_total_realized_pnl,
'grand_total_equity': grand_total_equity,
'grand_total_return': grand_total_return,
# 向后兼容字段
'total_initial_balance': grand_total_initial,
'total_realized_pnl': grand_total_realized_pnl,
'total_equity': grand_total_equity,
'total_return': grand_total_return,
'last_updated': state.get('last_updated'),
}
def _build_symbol_status(symbol: str, accounts: Dict, last_updated: str = None) -> Dict:
"""构建单个币种的状态"""
total_initial = 0
total_realized_pnl = 0
total_equity = 0
timeframes = {}
for tf_key, acc in accounts.items():
initial = acc.get('initial_balance', 0)
realized_pnl = acc.get('realized_pnl', 0)
if 'realized_pnl' not in acc and 'balance' in acc:
realized_pnl = acc['balance'] - initial
equity = initial + realized_pnl
position = acc.get('position')
used_margin = position.get('margin', 0) if position else 0
available_balance = equity - used_margin
total_initial += initial
total_realized_pnl += realized_pnl
total_equity += equity
return_pct = (equity - initial) / initial * 100 if initial > 0 else 0
timeframes[tf_key] = {
'name': '短周期' if tf_key == 'short' else '中周期' if tf_key == 'medium' else '长周期',
'name_en': 'Short-term' if tf_key == 'short' else 'Medium-term' if tf_key == 'medium' else 'Long-term',
'symbol': symbol,
'initial_balance': initial,
'realized_pnl': realized_pnl,
'equity': equity,
'available_balance': available_balance,
'used_margin': used_margin,
'return_pct': return_pct,
'leverage': acc.get('leverage', 10),
'position': position,
'stats': acc.get('stats', {}),
}
total_return = (total_equity - total_initial) / total_initial * 100 if total_initial > 0 else 0
return {
'timestamp': datetime.now().isoformat(),
'symbol': symbol,
'total_initial_balance': total_initial,
'total_realized_pnl': total_realized_pnl,
'total_equity': total_equity,
'total_return': total_return,
'timeframes': timeframes,
'last_updated': last_updated,
}
def _get_legacy_status(state: Dict) -> Dict:
"""处理旧格式状态(向后兼容)"""
accounts = state.get('accounts', {}) accounts = state.get('accounts', {})
total_initial = 0 total_initial = 0
total_realized_pnl = 0 total_realized_pnl = 0
total_equity = 0 total_equity = 0
# 构建各周期状态
timeframes = {} timeframes = {}
for tf_key, acc in accounts.items(): for tf_key, acc in accounts.items():
initial = acc.get('initial_balance', 0) initial = acc.get('initial_balance', 0)
realized_pnl = acc.get('realized_pnl', 0) realized_pnl = acc.get('realized_pnl', 0)
# 兼容旧数据格式
if 'realized_pnl' not in acc and 'balance' in acc: if 'realized_pnl' not in acc and 'balance' in acc:
realized_pnl = acc['balance'] - initial realized_pnl = acc['balance'] - initial
# 计算权益(不含未实现盈亏,因为 API 没有实时价格)
equity = initial + realized_pnl equity = initial + realized_pnl
# 检查持仓的保证金
position = acc.get('position') position = acc.get('position')
used_margin = position.get('margin', 0) if position else 0 used_margin = position.get('margin', 0) if position else 0
available_balance = equity - used_margin available_balance = equity - used_margin
@ -206,12 +380,36 @@ async def get_status():
@app.get("/api/trades") @app.get("/api/trades")
async def get_trades(timeframe: str = None, limit: int = 50): async def get_trades(symbol: str = None, timeframe: str = None, limit: int = 50):
"""获取交易记录""" """获取交易记录
Args:
symbol: 指定币种可选
timeframe: 指定周期可选
limit: 返回数量限制
"""
state = load_trading_state() state = load_trading_state()
accounts = state.get('accounts', {})
all_trades = [] all_trades = []
# 检查是否是新的多币种格式
if 'symbols' in state:
symbols_data = state.get('symbols', {})
for sym, accounts in symbols_data.items():
if symbol and sym != symbol:
continue
for tf_key, acc in accounts.items():
if timeframe and tf_key != timeframe:
continue
trades = acc.get('trades', [])
# 确保每个交易都有 symbol 字段
for trade in trades:
if 'symbol' not in trade:
trade['symbol'] = sym
all_trades.extend(trades)
else:
# 旧格式
accounts = state.get('accounts', {})
for tf_key, acc in accounts.items(): for tf_key, acc in accounts.items():
if timeframe and tf_key != timeframe: if timeframe and tf_key != timeframe:
continue continue
@ -228,12 +426,34 @@ async def get_trades(timeframe: str = None, limit: int = 50):
@app.get("/api/equity") @app.get("/api/equity")
async def get_equity_curve(timeframe: str = None, limit: int = 500): async def get_equity_curve(symbol: str = None, timeframe: str = None, limit: int = 500):
"""获取权益曲线""" """获取权益曲线
Args:
symbol: 指定币种可选
timeframe: 指定周期可选
limit: 返回数量限制
"""
state = load_trading_state() state = load_trading_state()
accounts = state.get('accounts', {})
result = {} result = {}
if 'symbols' in state:
symbols_data = state.get('symbols', {})
for sym, accounts in symbols_data.items():
if symbol and sym != symbol:
continue
sym_result = {}
for tf_key, acc in accounts.items():
if timeframe and tf_key != timeframe:
continue
equity_curve = acc.get('equity_curve', [])
sym_result[tf_key] = equity_curve[-limit:] if limit > 0 else equity_curve
if sym_result:
result[sym] = sym_result
else:
# 旧格式
accounts = state.get('accounts', {})
for tf_key, acc in accounts.items(): for tf_key, acc in accounts.items():
if timeframe and tf_key != timeframe: if timeframe and tf_key != timeframe:
continue continue
@ -246,19 +466,46 @@ async def get_equity_curve(timeframe: str = None, limit: int = 500):
@app.get("/api/signal") @app.get("/api/signal")
async def get_signal(): async def get_signal(symbol: str = None):
"""获取最新信号""" """获取最新信号
signal = load_latest_signal()
Args:
symbol: 指定币种可选若为空则返回所有
"""
if symbol:
# 加载单个币种信号
signal = load_latest_signal(symbol)
return _format_signal_response(signal, symbol)
else:
# 加载所有币种信号
all_signals = load_latest_signal()
if 'symbols' in all_signals:
# 新的多币种格式
result = {
'timestamp': all_signals.get('timestamp'),
'symbols': {},
'supported_symbols': SYMBOLS,
}
for sym, sig_data in all_signals.get('symbols', {}).items():
result['symbols'][sym] = _format_signal_response(sig_data, sym)
return result
else:
# 旧格式
return _format_signal_response(all_signals, SYMBOLS[0] if SYMBOLS else 'BTCUSDT')
def _format_signal_response(signal: Dict, symbol: str) -> Dict:
"""格式化信号响应"""
agg = signal.get('aggregated_signal', {}) agg = signal.get('aggregated_signal', {})
llm = agg.get('llm_signal', {}) llm = agg.get('llm_signal', {})
market = signal.get('market_analysis', {}) market = signal.get('market_analysis', {})
# 提取各周期机会
opportunities = llm.get('opportunities', {}) opportunities = llm.get('opportunities', {})
return { return {
'timestamp': agg.get('timestamp'), 'symbol': symbol,
'timestamp': signal.get('timestamp') or agg.get('timestamp'),
'final_signal': agg.get('final_signal'), 'final_signal': agg.get('final_signal'),
'final_confidence': agg.get('final_confidence'), 'final_confidence': agg.get('final_confidence'),
'current_price': agg.get('levels', {}).get('current_price') or market.get('price'), 'current_price': agg.get('levels', {}).get('current_price') or market.get('price'),
@ -273,9 +520,22 @@ async def get_signal():
@app.get("/api/timeframe/{timeframe}") @app.get("/api/timeframe/{timeframe}")
async def get_timeframe_detail(timeframe: str): async def get_timeframe_detail(timeframe: str, symbol: str = None):
"""获取单个周期详情""" """获取单个周期详情
Args:
timeframe: 周期 (short, medium, long)
symbol: 指定币种可选默认第一个
"""
state = load_trading_state() state = load_trading_state()
symbol = symbol or (SYMBOLS[0] if SYMBOLS else 'BTCUSDT')
if 'symbols' in state:
symbols_data = state.get('symbols', {})
if symbol not in symbols_data:
return {"error": f"Symbol '{symbol}' not found"}
accounts = symbols_data[symbol]
else:
accounts = state.get('accounts', {}) accounts = state.get('accounts', {})
if timeframe not in accounts: if timeframe not in accounts:
@ -283,14 +543,17 @@ async def get_timeframe_detail(timeframe: str):
acc = accounts[timeframe] acc = accounts[timeframe]
initial = acc.get('initial_balance', 0) initial = acc.get('initial_balance', 0)
balance = acc.get('balance', 0) realized_pnl = acc.get('realized_pnl', 0)
equity = initial + realized_pnl
return { return {
'symbol': symbol,
'timeframe': timeframe, 'timeframe': timeframe,
'balance': balance, 'equity': equity,
'initial_balance': initial, 'initial_balance': initial,
'return_pct': (balance - initial) / initial * 100 if initial > 0 else 0, 'realized_pnl': realized_pnl,
'leverage': acc.get('leverage', 1), 'return_pct': (equity - initial) / initial * 100 if initial > 0 else 0,
'leverage': acc.get('leverage', 10),
'position': acc.get('position'), 'position': acc.get('position'),
'stats': acc.get('stats', {}), 'stats': acc.get('stats', {}),
'recent_trades': acc.get('trades', [])[-20:], 'recent_trades': acc.get('trades', [])[-20:],
@ -298,14 +561,25 @@ async def get_timeframe_detail(timeframe: str):
} }
@app.get("/api/prices")
async def get_prices():
"""获取所有币种实时价格"""
prices = await fetch_binance_prices()
return {
'timestamp': datetime.now().isoformat(),
'prices': prices,
'supported_symbols': SYMBOLS,
}
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
"""WebSocket 实时推送""" """WebSocket 实时推送 - 支持多币种"""
await manager.connect(websocket) await manager.connect(websocket)
try: try:
# 获取初始实时价格 # 获取所有币种初始实时价格
current_price = await fetch_binance_price() current_prices = await fetch_binance_prices()
# 发送初始状态 # 发送初始状态
state = load_trading_state() state = load_trading_state()
@ -314,32 +588,41 @@ async def websocket_endpoint(websocket: WebSocket):
'type': 'init', 'type': 'init',
'state': state, 'state': state,
'signal': signal, 'signal': signal,
'current_price': current_price, 'prices': current_prices,
'current_price': current_prices.get(SYMBOLS[0]) if SYMBOLS else None, # 向后兼容
'supported_symbols': SYMBOLS,
}) })
# 持续推送更新 # 持续推送更新
last_state_mtime = STATE_FILE.stat().st_mtime if STATE_FILE.exists() else 0 last_state_mtime = STATE_FILE.stat().st_mtime if STATE_FILE.exists() else 0
last_signal_mtime = SIGNAL_FILE.stat().st_mtime if SIGNAL_FILE.exists() else 0 last_signal_mtime = SIGNAL_FILE.stat().st_mtime if SIGNAL_FILE.exists() else 0
last_price = current_price last_signals_mtime = SIGNALS_FILE.stat().st_mtime if SIGNALS_FILE.exists() else 0
price_update_counter = 0 last_prices = current_prices.copy()
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)
price_update_counter += 1
# 每秒获取实时价格并推送 # 每秒获取所有币种实时价格并推送
current_price = await fetch_binance_price() current_prices = await fetch_binance_prices()
if current_price and current_price != last_price: price_changed = False
last_price = current_price for sym, price in current_prices.items():
if price and price != last_prices.get(sym):
price_changed = True
break
if price_changed:
last_prices = current_prices.copy()
await websocket.send_json({ await websocket.send_json({
'type': 'price_update', 'type': 'price_update',
'current_price': current_price, 'prices': current_prices,
'current_price': current_prices.get(SYMBOLS[0]) if SYMBOLS else None, # 向后兼容
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
}) })
# 检查状态文件更新 # 检查状态文件更新
current_state_mtime = STATE_FILE.stat().st_mtime if STATE_FILE.exists() else 0 current_state_mtime = STATE_FILE.stat().st_mtime if STATE_FILE.exists() else 0
current_signal_mtime = SIGNAL_FILE.stat().st_mtime if SIGNAL_FILE.exists() else 0 current_signal_mtime = SIGNAL_FILE.stat().st_mtime if SIGNAL_FILE.exists() else 0
current_signals_mtime = SIGNALS_FILE.stat().st_mtime if SIGNALS_FILE.exists() else 0
if current_state_mtime > last_state_mtime: if current_state_mtime > last_state_mtime:
last_state_mtime = current_state_mtime last_state_mtime = current_state_mtime
@ -349,8 +632,12 @@ async def websocket_endpoint(websocket: WebSocket):
'state': state, 'state': state,
}) })
if current_signal_mtime > last_signal_mtime: # 检查信号文件更新(新格式或旧格式)
signal_updated = (current_signal_mtime > last_signal_mtime or
current_signals_mtime > last_signals_mtime)
if signal_updated:
last_signal_mtime = current_signal_mtime last_signal_mtime = current_signal_mtime
last_signals_mtime = current_signals_mtime
signal = load_latest_signal() signal = load_latest_signal()
await websocket.send_json({ await websocket.send_json({
'type': 'signal_update', 'type': 'signal_update',

View File

@ -71,7 +71,7 @@
<header class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4"> <header class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<div> <div>
<h1 class="text-3xl font-bold text-gradient mb-1">AI Quant Trading</h1> <h1 class="text-3xl font-bold text-gradient mb-1">AI Quant Trading</h1>
<p class="text-slate-400 text-sm">BTC/USDT Perpetual • Multi-Timeframe • Powered by Quantitative Analysis & AI</p> <p class="text-slate-400 text-sm">Multi-Symbol • Multi-Timeframe • Powered by Quantitative Analysis & AI</p>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div id="connection-status" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-yellow-500/20 text-yellow-400 text-sm"> <div id="connection-status" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-yellow-500/20 text-yellow-400 text-sm">
@ -82,20 +82,29 @@
</div> </div>
</header> </header>
<!-- Total Summary --> <!-- Symbol Selector & Total Summary -->
<div class="glass-card p-4 mb-6"> <div class="glass-card p-4 mb-6">
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-wrap items-center justify-between gap-4">
<!-- Symbol Tabs -->
<div class="flex items-center gap-2" id="symbol-tabs">
<button class="symbol-tab px-4 py-2 rounded-lg text-sm font-medium bg-primary-500/20 text-primary-400 border border-primary-500/30" data-symbol="BTCUSDT">
BTC/USDT
</button>
<button class="symbol-tab px-4 py-2 rounded-lg text-sm font-medium bg-slate-700/50 text-slate-400 border border-slate-600/30 hover:bg-slate-700" data-symbol="ETHUSDT">
ETH/USDT
</button>
</div>
<div class="flex items-center gap-6"> <div class="flex items-center gap-6">
<div> <div>
<div class="text-slate-400 text-xs uppercase">Total Balance</div> <div class="text-slate-400 text-xs uppercase">Total Balance</div>
<div id="total-balance" class="text-2xl font-bold text-white">$30,000.00</div> <div id="total-balance" class="text-2xl font-bold text-white">$60,000.00</div>
</div> </div>
<div> <div>
<div class="text-slate-400 text-xs uppercase">Total Return</div> <div class="text-slate-400 text-xs uppercase">Total Return</div>
<div id="total-return" class="text-xl font-bold text-slate-400">+0.00%</div> <div id="total-return" class="text-xl font-bold text-slate-400">+0.00%</div>
</div> </div>
<div> <div>
<div class="text-slate-400 text-xs uppercase">Price</div> <div class="text-slate-400 text-xs uppercase" id="price-label">BTC Price</div>
<div id="current-price" class="text-xl font-bold text-white font-mono">$0.00</div> <div id="current-price" class="text-xl font-bold text-white font-mono">$0.00</div>
</div> </div>
</div> </div>
@ -229,6 +238,7 @@
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="text-slate-400 text-xs uppercase border-b border-slate-700/50"> <tr class="text-slate-400 text-xs uppercase border-b border-slate-700/50">
<th class="px-3 py-2 text-left">Symbol</th>
<th class="px-3 py-2 text-left">TF</th> <th class="px-3 py-2 text-left">TF</th>
<th class="px-3 py-2 text-left">Side</th> <th class="px-3 py-2 text-left">Side</th>
<th class="px-3 py-2 text-right">Entry</th> <th class="px-3 py-2 text-right">Entry</th>
@ -239,7 +249,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="trades-table" class="divide-y divide-slate-700/30"> <tbody id="trades-table" class="divide-y divide-slate-700/30">
<tr><td colspan="7" class="text-center py-8 text-slate-500">No trades yet</td></tr> <tr><td colspan="8" class="text-center py-8 text-slate-500">No trades yet</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -257,10 +267,14 @@
<script> <script>
let ws = null; let ws = null;
let reconnectInterval = null; let reconnectInterval = null;
let currentPrice = 0; // 保存当前价格用于实时计算 PnL let currentPrices = {}; // 多币种价格 {BTCUSDT: 12345, ETHUSDT: 1234}
let currentPrice = 0; // 当前选中币种的价格 (向后兼容)
let lastState = null; // 保存最新状态用于价格更新时重新计算 PnL let lastState = null; // 保存最新状态用于价格更新时重新计算 PnL
let supportedSymbols = ['BTCUSDT', 'ETHUSDT']; // 支持的币种列表
let selectedSymbol = 'BTCUSDT'; // 当前选中的币种
const TF_NAMES = { short: 'Short', medium: 'Medium', long: 'Long' }; const TF_NAMES = { short: 'Short', medium: 'Medium', long: 'Long' };
const SYMBOL_DISPLAY = { BTCUSDT: 'BTC/USDT', ETHUSDT: 'ETH/USDT' };
function connectWebSocket() { function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@ -283,25 +297,40 @@
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'init') { if (data.type === 'init') {
// 先更新价格,再更新信号和状态 // 更新支持的币种列表
if (data.current_price) { if (data.supported_symbols) {
supportedSymbols = data.supported_symbols;
updateSymbolTabs();
}
// 更新多币种价格
if (data.prices) {
currentPrices = data.prices;
currentPrice = currentPrices[selectedSymbol] || 0;
updatePriceDisplay();
} else if (data.current_price) {
currentPrice = data.current_price; currentPrice = data.current_price;
document.getElementById('current-price').textContent = `$${currentPrice.toLocaleString('en-US', {minimumFractionDigits: 2})}`; currentPrices[selectedSymbol] = currentPrice;
updatePriceDisplay();
} }
updateSignal(data.signal); updateSignal(data.signal);
updateState(data.state); updateState(data.state);
} }
else if (data.type === 'price_update') { else if (data.type === 'price_update') {
// 实时价格更新 // 多币种实时价格更新
if (data.current_price) { if (data.prices) {
currentPrices = data.prices;
currentPrice = currentPrices[selectedSymbol] || 0;
updatePriceDisplay();
} else if (data.current_price) {
currentPrice = data.current_price; currentPrice = data.current_price;
document.getElementById('current-price').textContent = `$${currentPrice.toLocaleString('en-US', {minimumFractionDigits: 2})}`; currentPrices[selectedSymbol] = currentPrice;
updatePriceDisplay();
}
// 重新计算 PnL // 重新计算 PnL
if (lastState) { if (lastState) {
updateState(lastState); updateState(lastState);
} }
} }
}
else if (data.type === 'state_update') { else if (data.type === 'state_update') {
updateState(data.state); updateState(data.state);
} }
@ -316,21 +345,105 @@
}; };
} }
function updateSymbolTabs() {
const container = document.getElementById('symbol-tabs');
container.innerHTML = supportedSymbols.map(sym => {
const displayName = SYMBOL_DISPLAY[sym] || sym;
const isSelected = sym === selectedSymbol;
const activeClass = isSelected
? 'bg-primary-500/20 text-primary-400 border-primary-500/30'
: 'bg-slate-700/50 text-slate-400 border-slate-600/30 hover:bg-slate-700';
return `<button class="symbol-tab px-4 py-2 rounded-lg text-sm font-medium border ${activeClass}" data-symbol="${sym}">${displayName}</button>`;
}).join('');
// 重新绑定点击事件
container.querySelectorAll('.symbol-tab').forEach(btn => {
btn.addEventListener('click', () => selectSymbol(btn.dataset.symbol));
});
}
function selectSymbol(symbol) {
if (symbol === selectedSymbol) return;
selectedSymbol = symbol;
currentPrice = currentPrices[selectedSymbol] || 0;
// 更新 tab 样式
updateSymbolTabs();
// 更新价格显示
updatePriceDisplay();
// 重新渲染状态(使用新币种的数据)
if (lastState) {
updateState(lastState);
}
}
function updatePriceDisplay() {
const price = currentPrices[selectedSymbol] || currentPrice || 0;
document.getElementById('current-price').textContent = `$${price.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
const symbolShort = selectedSymbol.replace('USDT', '');
document.getElementById('price-label').textContent = `${symbolShort} Price`;
}
function updateState(state) { function updateState(state) {
if (!state || !state.accounts) return; if (!state) return;
// 保存最新状态用于价格更新时重新计算 // 保存最新状态用于价格更新时重新计算
lastState = state; lastState = state;
const accounts = state.accounts; // 支持新的多币种格式和旧格式
let totalInitial = 0, totalEquity = 0, totalRealizedPnl = 0, totalUnrealizedPnl = 0; let accounts = null;
let grandTotalInitial = 0, grandTotalEquity = 0, grandTotalRealizedPnl = 0, grandTotalUnrealizedPnl = 0;
let allTrades = [];
if (state.symbols) {
// 新的多币种格式
// 计算所有币种的汇总
for (const [sym, symData] of Object.entries(state.symbols)) {
const symAccounts = symData.timeframes || symData;
const symPrice = currentPrices[sym] || 0;
for (const [tf, acc] of Object.entries(symAccounts)) {
const initial = acc.initial_balance || 0;
const realizedPnl = acc.realized_pnl || 0;
const position = acc.position;
let unrealizedPnl = 0;
if (position && position.side && position.side !== 'FLAT' && symPrice > 0) {
const entryPrice = position.entry_price || 0;
const size = position.size || 0;
if (position.side === 'LONG') {
unrealizedPnl = (symPrice - entryPrice) * size;
} else {
unrealizedPnl = (entryPrice - symPrice) * size;
}
}
grandTotalInitial += initial;
grandTotalRealizedPnl += realizedPnl;
grandTotalUnrealizedPnl += unrealizedPnl;
grandTotalEquity += initial + realizedPnl + unrealizedPnl;
// 收集交易记录
const trades = acc.trades || [];
trades.forEach(t => { if (!t.symbol) t.symbol = sym; });
allTrades = allTrades.concat(trades);
}
}
// 获取当前选中币种的账户用于显示
const selectedData = state.symbols[selectedSymbol];
accounts = selectedData ? (selectedData.timeframes || selectedData) : null;
} else if (state.accounts) {
// 旧格式
accounts = state.accounts;
for (const [tf, acc] of Object.entries(accounts)) { for (const [tf, acc] of Object.entries(accounts)) {
const initial = acc.initial_balance || 0; const initial = acc.initial_balance || 0;
const realizedPnl = acc.realized_pnl || 0; const realizedPnl = acc.realized_pnl || 0;
const position = acc.position; const position = acc.position;
// 使用当前价格实时计算未实现盈亏
let unrealizedPnl = 0; let unrealizedPnl = 0;
if (position && position.side && position.side !== 'FLAT' && currentPrice > 0) { if (position && position.side && position.side !== 'FLAT' && currentPrice > 0) {
const entryPrice = position.entry_price || 0; const entryPrice = position.entry_price || 0;
@ -342,25 +455,30 @@
} }
} }
totalInitial += initial; grandTotalInitial += initial;
totalRealizedPnl += realizedPnl; grandTotalRealizedPnl += realizedPnl;
totalUnrealizedPnl += unrealizedPnl; grandTotalUnrealizedPnl += unrealizedPnl;
totalEquity += initial + realizedPnl + unrealizedPnl; grandTotalEquity += initial + realizedPnl + unrealizedPnl;
updateTimeframeCard(tf, acc);
}
const totalReturn = totalInitial > 0 ? (totalEquity - totalInitial) / totalInitial * 100 : 0;
document.getElementById('total-balance').textContent = `$${totalEquity.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
const returnEl = document.getElementById('total-return');
returnEl.textContent = `${totalReturn >= 0 ? '+' : ''}${totalReturn.toFixed(2)}%`;
returnEl.className = `text-xl font-bold ${totalReturn > 0 ? 'text-success' : totalReturn < 0 ? 'text-danger' : 'text-slate-400'}`;
// Collect all trades
let allTrades = [];
for (const acc of Object.values(accounts)) {
allTrades = allTrades.concat(acc.trades || []); allTrades = allTrades.concat(acc.trades || []);
} }
}
// 更新 Total 显示(所有币种汇总)
const grandTotalReturn = grandTotalInitial > 0 ? (grandTotalEquity - grandTotalInitial) / grandTotalInitial * 100 : 0;
document.getElementById('total-balance').textContent = `$${grandTotalEquity.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
const returnEl = document.getElementById('total-return');
returnEl.textContent = `${grandTotalReturn >= 0 ? '+' : ''}${grandTotalReturn.toFixed(2)}%`;
returnEl.className = `text-xl font-bold ${grandTotalReturn > 0 ? 'text-success' : grandTotalReturn < 0 ? 'text-danger' : 'text-slate-400'}`;
// 更新当前选中币种的各周期卡片
if (accounts) {
for (const [tf, acc] of Object.entries(accounts)) {
updateTimeframeCard(tf, acc);
}
}
// 更新交易记录
allTrades.sort((a, b) => (b.exit_time || '').localeCompare(a.exit_time || '')); allTrades.sort((a, b) => (b.exit_time || '').localeCompare(a.exit_time || ''));
updateTrades(allTrades); updateTrades(allTrades);
} }
@ -554,7 +672,7 @@
document.getElementById('trade-count').textContent = `${trades.length} trades`; document.getElementById('trade-count').textContent = `${trades.length} trades`;
if (!trades || trades.length === 0) { if (!trades || trades.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-8 text-slate-500">No trades yet</td></tr>'; tbody.innerHTML = '<tr><td colspan="8" class="text-center py-8 text-slate-500">No trades yet</td></tr>';
return; return;
} }
@ -563,9 +681,11 @@
const pnlPct = trade.pnl_pct || 0; const pnlPct = trade.pnl_pct || 0;
const isWin = pnl > 0; const isWin = pnl > 0;
const tfLabel = TF_NAMES[trade.timeframe] || trade.timeframe; const tfLabel = TF_NAMES[trade.timeframe] || trade.timeframe;
const symbolDisplay = (trade.symbol || 'BTCUSDT').replace('USDT', '');
return ` return `
<tr class="table-row"> <tr class="table-row">
<td class="px-3 py-2 text-primary-400 text-xs font-medium">${symbolDisplay}</td>
<td class="px-3 py-2 text-slate-400 text-xs">${tfLabel}</td> <td class="px-3 py-2 text-slate-400 text-xs">${tfLabel}</td>
<td class="px-3 py-2"><span class="badge ${trade.side === 'LONG' ? 'badge-long' : 'badge-short'}">${trade.side}</span></td> <td class="px-3 py-2"><span class="badge ${trade.side === 'LONG' ? 'badge-long' : 'badge-short'}">${trade.side}</span></td>
<td class="px-3 py-2 text-right font-mono text-white">$${(trade.entry_price || 0).toFixed(2)}</td> <td class="px-3 py-2 text-right font-mono text-white">$${(trade.entry_price || 0).toFixed(2)}</td>
@ -588,18 +708,49 @@
async function loadInitialData() { async function loadInitialData() {
try { try {
const [statusRes, signalRes] = await Promise.all([ const [statusRes, signalRes, pricesRes] = await Promise.all([
fetch('/api/status'), fetch('/api/status'),
fetch('/api/signal'), fetch('/api/signal'),
fetch('/api/prices'),
]); ]);
const status = await statusRes.json(); const status = await statusRes.json();
const signal = await signalRes.json(); const signal = await signalRes.json();
const pricesData = await pricesRes.json();
// 先更新信号获取当前价格 // 更新支持的币种列表
if (status.supported_symbols) {
supportedSymbols = status.supported_symbols;
updateSymbolTabs();
} else if (pricesData.supported_symbols) {
supportedSymbols = pricesData.supported_symbols;
updateSymbolTabs();
}
// 更新多币种价格
if (pricesData.prices) {
currentPrices = pricesData.prices;
currentPrice = currentPrices[selectedSymbol] || 0;
updatePriceDisplay();
}
// 处理信号 (新格式或旧格式)
if (signal.symbols) {
// 新的多币种格式
const selectedSignal = signal.symbols[selectedSymbol];
if (selectedSignal) {
updateSignal({ aggregated_signal: { llm_signal: { opportunities: selectedSignal.opportunities }, levels: { current_price: selectedSignal.current_price }, timestamp: selectedSignal.timestamp } });
}
} else {
// 旧格式
updateSignal({ aggregated_signal: { llm_signal: { opportunities: signal.opportunities }, levels: { current_price: signal.current_price }, timestamp: signal.timestamp } }); updateSignal({ aggregated_signal: { llm_signal: { opportunities: signal.opportunities }, levels: { current_price: signal.current_price }, timestamp: signal.timestamp } });
}
// 再更新状态(这样 PnL 才能用当前价格计算) // 更新状态 (新格式或旧格式)
if (status.timeframes) { if (status.symbols) {
// 新的多币种格式
updateState({ symbols: status.symbols });
} else if (status.timeframes) {
// 旧格式
const state = { accounts: {} }; const state = { accounts: {} };
for (const [tf, data] of Object.entries(status.timeframes)) { for (const [tf, data] of Object.entries(status.timeframes)) {
state.accounts[tf] = { state.accounts[tf] = {