""" Paper Trading Module - 多周期独立仓位管理 支持三个独立周期的模拟交易: - 短周期 (5m/15m/1h): short_term_5m_15m_1h / intraday - 中周期 (4h/1d): medium_term_4h_1d / swing - 长周期 (1d/1w): long_term_1d_1w 每个周期独立管理: - 独立仓位 - 独立止盈止损 - 独立统计数据 - 独立权益曲线 """ import json import logging from datetime import datetime, timedelta from typing import Dict, Any, Optional, List from pathlib import Path from dataclasses import dataclass, asdict, field from enum import Enum logger = logging.getLogger(__name__) class TimeFrame(Enum): """交易周期""" SHORT = "short" # 短周期 5m/15m/1h MEDIUM = "medium" # 中周期 4h/1d LONG = "long" # 长周期 1d/1w TIMEFRAME_CONFIG = { TimeFrame.SHORT: { 'name': '短周期', 'name_en': 'Short-term', 'signal_keys': ['short_term_5m_15m_1h', 'intraday'], 'leverage': 10, 'initial_balance': 10000.0, # 独立初始资金 }, TimeFrame.MEDIUM: { 'name': '中周期', 'name_en': 'Medium-term', 'signal_keys': ['medium_term_4h_1d', 'swing'], 'leverage': 10, 'initial_balance': 10000.0, # 独立初始资金 }, TimeFrame.LONG: { 'name': '长周期', 'name_en': 'Long-term', 'signal_keys': ['long_term_1d_1w'], 'leverage': 10, 'initial_balance': 10000.0, # 独立初始资金 }, } @dataclass class Position: """持仓信息""" side: str # LONG, SHORT, FLAT entry_price: float = 0.0 size: float = 0.0 # BTC 数量 stop_loss: float = 0.0 take_profit: float = 0.0 created_at: str = "" signal_reasoning: str = "" def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, data: dict) -> 'Position': return cls(**data) @dataclass class Trade: """交易记录""" id: str timeframe: str side: str entry_price: float entry_time: str exit_price: float exit_time: str size: float pnl: float pnl_pct: float exit_reason: str def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, data: dict) -> 'Trade': return cls(**data) @dataclass class TimeFrameAccount: """单个周期的账户""" timeframe: str balance: float initial_balance: float leverage: int position: Optional[Position] = None trades: List[Trade] = field(default_factory=list) stats: Dict = field(default_factory=dict) equity_curve: List[Dict] = field(default_factory=list) def __post_init__(self): if not self.stats: self.stats = self._init_stats() def _init_stats(self) -> dict: return { 'total_trades': 0, 'winning_trades': 0, 'losing_trades': 0, 'total_pnl': 0.0, 'max_drawdown': 0.0, 'peak_balance': self.initial_balance, 'win_rate': 0.0, 'avg_win': 0.0, 'avg_loss': 0.0, 'profit_factor': 0.0, } def to_dict(self) -> dict: return { 'timeframe': self.timeframe, 'balance': self.balance, 'initial_balance': self.initial_balance, 'leverage': self.leverage, 'position': self.position.to_dict() if self.position else None, 'trades': [t.to_dict() for t in self.trades[-100:]], 'stats': self.stats, 'equity_curve': self.equity_curve[-500:], } @classmethod def from_dict(cls, data: dict) -> 'TimeFrameAccount': account = cls( timeframe=data['timeframe'], balance=data['balance'], initial_balance=data['initial_balance'], leverage=data['leverage'], stats=data.get('stats', {}), equity_curve=data.get('equity_curve', []), ) if data.get('position'): account.position = Position.from_dict(data['position']) account.trades = [Trade.from_dict(t) for t in data.get('trades', [])] return account class MultiTimeframePaperTrader: """多周期模拟盘交易器""" def __init__( self, initial_balance: float = 10000.0, state_file: str = None ): self.initial_balance = initial_balance # 状态文件 if state_file: self.state_file = Path(state_file) else: self.state_file = Path(__file__).parent.parent / 'output' / 'paper_trading_state.json' # 初始化三个周期账户 self.accounts: Dict[TimeFrame, TimeFrameAccount] = {} # 加载或初始化状态 self._load_state() logger.info(f"Multi-timeframe Paper Trader initialized: total_balance=${initial_balance:.2f}") def _load_state(self): """加载持久化状态""" if self.state_file.exists(): try: with open(self.state_file, 'r') as f: state = json.load(f) # 加载各周期账户 for tf in TimeFrame: tf_data = state.get('accounts', {}).get(tf.value) if tf_data: self.accounts[tf] = TimeFrameAccount.from_dict(tf_data) else: self._init_account(tf) logger.info(f"Loaded state from {self.state_file}") except Exception as e: logger.error(f"Failed to load state: {e}") self._init_all_accounts() else: self._init_all_accounts() def _init_all_accounts(self): """初始化所有账户""" for tf in TimeFrame: self._init_account(tf) def _init_account(self, tf: TimeFrame): """初始化单个周期账户""" config = TIMEFRAME_CONFIG[tf] # 每个周期独立初始资金 10000 USD,10倍杠杆,最大仓位价值 100000 USD account_balance = config['initial_balance'] self.accounts[tf] = TimeFrameAccount( timeframe=tf.value, balance=account_balance, initial_balance=account_balance, leverage=config['leverage'], ) def _save_state(self): """保存状态到文件""" self.state_file.parent.mkdir(parents=True, exist_ok=True) state = { 'accounts': {tf.value: acc.to_dict() for tf, acc in self.accounts.items()}, 'last_updated': datetime.now().isoformat(), } with open(self.state_file, 'w') as f: json.dump(state, f, indent=2, ensure_ascii=False) def process_signal(self, signal: Dict[str, Any], current_price: float) -> Dict[str, Any]: """处理交易信号 - 检查所有周期""" results = { 'timestamp': datetime.now().isoformat(), 'current_price': current_price, 'timeframes': {}, } for tf in TimeFrame: result = self._process_timeframe_signal(tf, signal, current_price) results['timeframes'][tf.value] = result self._save_state() return results def _process_timeframe_signal( self, tf: TimeFrame, signal: Dict[str, Any], current_price: float ) -> Dict[str, Any]: """处理单个周期的信号""" account = self.accounts[tf] config = TIMEFRAME_CONFIG[tf] result = { 'action': 'NONE', 'details': None, } # 更新权益曲线 self._update_equity_curve(tf, current_price) # 1. 检查止盈止损 if account.position and account.position.side != 'FLAT': close_result = self._check_close_position(tf, current_price) if close_result: result['action'] = 'CLOSE' result['details'] = close_result return result # 2. 提取该周期的信号 tf_signal = self._extract_timeframe_signal(signal, config['signal_keys']) if not tf_signal or not tf_signal.get('exists'): result['action'] = 'NO_SIGNAL' return result direction = tf_signal.get('direction') if not direction: result['action'] = 'NO_SIGNAL' return result signal_stop_loss = tf_signal.get('stop_loss', 0) signal_take_profit = tf_signal.get('take_profit', 0) # 验证止盈止损 if signal_stop_loss <= 0 or signal_take_profit <= 0: result['action'] = 'NO_SIGNAL' result['details'] = {'reason': '缺少有效止盈止损'} return result # 3. 如果有反向持仓,先平仓 if account.position and account.position.side != 'FLAT': if (account.position.side == 'LONG' and direction == 'SHORT') or \ (account.position.side == 'SHORT' and direction == 'LONG'): close_result = self._close_position(tf, current_price, 'SIGNAL_REVERSE') result['action'] = 'REVERSE' result['details'] = {'close': close_result} # 开反向仓 open_result = self._open_position( tf, direction, current_price, signal_stop_loss, signal_take_profit, tf_signal.get('reasoning', '')[:100] ) if open_result: result['details']['open'] = open_result return result else: # 同方向,保持持仓 result['action'] = 'HOLD' result['details'] = { 'position': account.position.to_dict(), 'unrealized_pnl': self._calc_unrealized_pnl(tf, current_price), } return result # 4. 无持仓,开新仓 open_result = self._open_position( tf, direction, current_price, signal_stop_loss, signal_take_profit, tf_signal.get('reasoning', '')[:100] ) if open_result: result['action'] = 'OPEN' result['details'] = open_result else: result['action'] = 'WAIT' return result def _extract_timeframe_signal( self, signal: Dict[str, Any], signal_keys: List[str] ) -> Optional[Dict[str, Any]]: """提取特定周期的信号""" try: # 从 llm_signal.opportunities 中提取 llm_signal = signal.get('llm_signal') or signal.get('aggregated_signal', {}).get('llm_signal') if llm_signal and isinstance(llm_signal, dict): opportunities = llm_signal.get('opportunities', {}) for key in signal_keys: if key in opportunities and opportunities[key]: return opportunities[key] # 备选路径 agg = signal.get('aggregated_signal', {}) if agg: llm = agg.get('llm_signal', {}) if llm: opps = llm.get('opportunities', {}) for key in signal_keys: if key in opps and opps[key]: return opps[key] return None except Exception as e: logger.error(f"Error extracting signal: {e}") return None def _open_position( self, tf: TimeFrame, direction: str, price: float, stop_loss: float, take_profit: float, reasoning: str ) -> Optional[Dict]: """开仓""" account = self.accounts[tf] config = TIMEFRAME_CONFIG[tf] # 计算仓位大小: 全部余额 * 杠杆倍数 (10000 * 10 = 100000 USD 仓位价值) position_value = account.balance * account.leverage size = position_value / price if size <= 0: return None account.position = Position( side=direction, entry_price=price, size=size, stop_loss=stop_loss, take_profit=take_profit, created_at=datetime.now().isoformat(), signal_reasoning=reasoning, ) logger.info( f"[{config['name']}] OPEN {direction}: price=${price:.2f}, " f"size={size:.6f} BTC, SL=${stop_loss:.2f}, TP=${take_profit:.2f}" ) return { 'timeframe': tf.value, 'side': direction, 'entry_price': price, 'size': size, 'stop_loss': stop_loss, 'take_profit': take_profit, } def _check_close_position(self, tf: TimeFrame, current_price: float) -> Optional[Dict]: """检查是否触发止盈止损""" account = self.accounts[tf] pos = account.position if not pos or pos.side == 'FLAT': return None if pos.side == 'LONG': if current_price >= pos.take_profit: return self._close_position(tf, current_price, 'TAKE_PROFIT') elif current_price <= pos.stop_loss: return self._close_position(tf, current_price, 'STOP_LOSS') else: # SHORT if current_price <= pos.take_profit: return self._close_position(tf, current_price, 'TAKE_PROFIT') elif current_price >= pos.stop_loss: return self._close_position(tf, current_price, 'STOP_LOSS') return None def _close_position(self, tf: TimeFrame, price: float, reason: str) -> Dict: """平仓""" account = self.accounts[tf] config = TIMEFRAME_CONFIG[tf] pos = account.position if not pos or pos.side == 'FLAT': return {'error': 'No position'} # 计算盈亏 if pos.side == 'LONG': pnl_pct = (price - pos.entry_price) / pos.entry_price * 100 * account.leverage else: pnl_pct = (pos.entry_price - price) / pos.entry_price * 100 * account.leverage position_value = pos.size * pos.entry_price pnl = position_value * (pnl_pct / 100) account.balance += pnl # 记录交易 trade = Trade( id=f"{tf.value[0].upper()}{len(account.trades)+1:04d}", timeframe=tf.value, side=pos.side, entry_price=pos.entry_price, entry_time=pos.created_at, exit_price=price, exit_time=datetime.now().isoformat(), size=pos.size, pnl=pnl, pnl_pct=pnl_pct, exit_reason=reason, ) account.trades.append(trade) self._update_stats(tf, trade) result = { 'timeframe': tf.value, 'side': pos.side, 'entry_price': pos.entry_price, 'exit_price': price, 'size': pos.size, 'pnl': pnl, 'pnl_pct': pnl_pct, 'reason': reason, 'new_balance': account.balance, } logger.info( f"[{config['name']}] CLOSE {pos.side}: entry=${pos.entry_price:.2f}, " f"exit=${price:.2f}, PnL=${pnl:.2f} ({pnl_pct:.2f}%), reason={reason}" ) account.position = None return result def _calc_unrealized_pnl(self, tf: TimeFrame, current_price: float) -> Dict[str, float]: """计算未实现盈亏""" account = self.accounts[tf] pos = account.position if not pos or pos.side == 'FLAT': return {'pnl': 0, 'pnl_pct': 0} if pos.side == 'LONG': pnl_pct = (current_price - pos.entry_price) / pos.entry_price * 100 * account.leverage else: pnl_pct = (pos.entry_price - current_price) / pos.entry_price * 100 * account.leverage position_value = pos.size * pos.entry_price pnl = position_value * (pnl_pct / 100) return {'pnl': pnl, 'pnl_pct': pnl_pct} def _update_equity_curve(self, tf: TimeFrame, current_price: float): """更新权益曲线""" account = self.accounts[tf] equity = account.balance if account.position and account.position.side != 'FLAT': unrealized = self._calc_unrealized_pnl(tf, current_price) equity += unrealized['pnl'] account.equity_curve.append({ 'timestamp': datetime.now().isoformat(), 'equity': equity, 'balance': account.balance, 'price': current_price, }) def _update_stats(self, tf: TimeFrame, trade: Trade): """更新统计数据""" account = self.accounts[tf] stats = account.stats stats['total_trades'] += 1 stats['total_pnl'] += trade.pnl if trade.pnl > 0: stats['winning_trades'] += 1 else: stats['losing_trades'] += 1 if stats['total_trades'] > 0: stats['win_rate'] = stats['winning_trades'] / stats['total_trades'] * 100 wins = [t for t in account.trades if t.pnl > 0] losses = [t for t in account.trades if t.pnl <= 0] if wins: stats['avg_win'] = sum(t.pnl for t in wins) / len(wins) if losses: stats['avg_loss'] = sum(t.pnl for t in losses) / len(losses) if stats['avg_loss'] != 0: stats['profit_factor'] = abs(stats['avg_win'] / stats['avg_loss']) if account.balance > stats['peak_balance']: stats['peak_balance'] = account.balance drawdown = (stats['peak_balance'] - account.balance) / stats['peak_balance'] * 100 if drawdown > stats['max_drawdown']: stats['max_drawdown'] = drawdown def get_status(self, current_price: float = None) -> Dict[str, Any]: """获取所有周期状态""" total_balance = sum(acc.balance for acc in self.accounts.values()) total_initial = sum(acc.initial_balance for acc in self.accounts.values()) status = { 'timestamp': datetime.now().isoformat(), 'total_balance': total_balance, 'total_initial_balance': total_initial, 'total_return': (total_balance - total_initial) / total_initial * 100, 'timeframes': {}, } for tf in TimeFrame: account = self.accounts[tf] config = TIMEFRAME_CONFIG[tf] tf_status = { 'name': config['name'], 'name_en': config['name_en'], 'balance': account.balance, 'initial_balance': account.initial_balance, 'return_pct': (account.balance - account.initial_balance) / account.initial_balance * 100, 'leverage': account.leverage, 'position': None, 'stats': account.stats, 'recent_trades': [t.to_dict() for t in account.trades[-10:]], 'equity_curve': account.equity_curve[-100:], } if account.position and account.position.side != 'FLAT': pos_dict = account.position.to_dict() if current_price: unrealized = self._calc_unrealized_pnl(tf, current_price) pos_dict['current_price'] = current_price pos_dict['unrealized_pnl'] = unrealized['pnl'] pos_dict['unrealized_pnl_pct'] = unrealized['pnl_pct'] tf_status['position'] = pos_dict status['timeframes'][tf.value] = tf_status return status def reset(self): """重置所有账户""" self._init_all_accounts() self._save_state() logger.info("All accounts reset") # 兼容旧的 PaperTrader 接口 PaperTrader = MultiTimeframePaperTrader