1
This commit is contained in:
parent
ce035fdcbb
commit
020808f69f
@ -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"
|
||||||
|
|||||||
147
scheduler.py
147
scheduler.py
@ -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):
|
||||||
"""启动调度器主循环"""
|
"""启动调度器主循环"""
|
||||||
|
|||||||
@ -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 USD,10倍杠杆,最大仓位价值 100000 USD
|
# 每个币种每个周期独立初始资金 10000 USD,10倍杠杆,最大仓位价值 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 接口
|
||||||
|
|||||||
423
web/api.py
423
web/api.py
@ -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',
|
||||||
|
|||||||
@ -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] = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user