""" 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, # 独立初始资金 'max_price_deviation': 0.001, # 0.1% - 短周期要求精准入场 }, TimeFrame.MEDIUM: { 'name': '中周期', 'name_en': 'Medium-term', 'signal_keys': ['medium_term_4h_1d', 'swing'], 'leverage': 10, 'initial_balance': 10000.0, # 独立初始资金 'max_price_deviation': 0.003, # 0.3% - 中周期适中容错 }, TimeFrame.LONG: { 'name': '长周期', 'name_en': 'Long-term', 'signal_keys': ['long_term_1d_1w'], 'leverage': 10, 'initial_balance': 10000.0, # 独立初始资金 'max_price_deviation': 0.005, # 0.5% - 长周期追求大趋势 }, } # 金字塔加仓配置:每次加仓的仓位比例(总计100%) PYRAMID_LEVELS = [0.4, 0.3, 0.2, 0.1] # 首仓40%,加仓30%、20%、10% @dataclass class PositionEntry: """单次入场记录""" price: float size: float # BTC 数量 margin: float # 本次占用保证金 timestamp: str level: int # 金字塔层级 0=首仓, 1=加仓1, ... def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, data: dict) -> 'PositionEntry': return cls(**data) @dataclass class Position: """持仓信息(支持金字塔加仓)""" side: str # LONG, SHORT, FLAT entries: List['PositionEntry'] = field(default_factory=list) # 入场记录 stop_loss: float = 0.0 take_profit: float = 0.0 created_at: str = "" signal_reasoning: str = "" @property def entry_price(self) -> float: """加权平均入场价""" if not self.entries: return 0.0 total_value = sum(e.price * e.size for e in self.entries) total_size = sum(e.size for e in self.entries) return total_value / total_size if total_size > 0 else 0.0 @property def size(self) -> float: """总持仓数量""" return sum(e.size for e in self.entries) @property def margin(self) -> float: """总占用保证金""" return sum(e.margin for e in self.entries) @property def pyramid_level(self) -> int: """当前金字塔层级""" return len(self.entries) def to_dict(self) -> dict: return { 'side': self.side, 'entry_price': self.entry_price, 'size': self.size, 'margin': self.margin, 'pyramid_level': self.pyramid_level, 'entries': [e.to_dict() for e in self.entries], 'stop_loss': self.stop_loss, 'take_profit': self.take_profit, 'created_at': self.created_at, 'signal_reasoning': self.signal_reasoning, } @classmethod def from_dict(cls, data: dict) -> 'Position': entries = [PositionEntry.from_dict(e) for e in data.get('entries', [])] # 兼容旧数据格式 if not entries and data.get('entry_price') and data.get('size'): entries = [PositionEntry( price=data['entry_price'], size=data['size'], margin=data.get('margin', 0), timestamp=data.get('created_at', ''), level=0, )] return cls( side=data['side'], entries=entries, stop_loss=data.get('stop_loss', 0), take_profit=data.get('take_profit', 0), created_at=data.get('created_at', ''), signal_reasoning=data.get('signal_reasoning', ''), ) @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: """单个周期的账户 资金结构: - initial_balance: 初始本金 - realized_pnl: 已实现盈亏(平仓后累计) - position.margin: 当前持仓占用保证金 - unrealized_pnl: 未实现盈亏(需实时计算) 账户权益 = initial_balance + realized_pnl + unrealized_pnl 可用余额 = initial_balance + realized_pnl - position.margin """ timeframe: str initial_balance: float leverage: int realized_pnl: float = 0.0 # 已实现盈亏 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 get_used_margin(self) -> float: """获取已占用保证金""" if self.position and self.position.side != 'FLAT': return self.position.margin return 0.0 def get_available_balance(self) -> float: """获取可用余额(可用于开新仓)""" return self.initial_balance + self.realized_pnl - self.get_used_margin() def get_equity(self, unrealized_pnl: float = 0.0) -> float: """获取账户权益(包含未实现盈亏)""" return self.initial_balance + self.realized_pnl + unrealized_pnl def to_dict(self) -> dict: return { 'timeframe': self.timeframe, 'initial_balance': self.initial_balance, 'realized_pnl': self.realized_pnl, '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': # 兼容旧数据格式 realized_pnl = data.get('realized_pnl', 0.0) # 如果是旧格式,从 balance 推算 realized_pnl if 'realized_pnl' not in data and 'balance' in data: realized_pnl = data['balance'] - data['initial_balance'] account = cls( timeframe=data['timeframe'], initial_balance=data['initial_balance'], leverage=data['leverage'], realized_pnl=realized_pnl, 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 self.accounts[tf] = TimeFrameAccount( timeframe=tf.value, initial_balance=config['initial_balance'], leverage=config['leverage'], realized_pnl=0.0, ) 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) signal_entry_price = tf_signal.get('entry_price', 0) # 验证止盈止损 if signal_stop_loss <= 0 or signal_take_profit <= 0: result['action'] = 'NO_SIGNAL' result['details'] = {'reason': '缺少有效止盈止损'} return result # 检查价格偏差:当前价格与建议入场价偏差超过阈值则不开仓 max_deviation = config.get('max_price_deviation', 0.002) if signal_entry_price > 0: price_deviation = abs(current_price - signal_entry_price) / signal_entry_price if price_deviation > max_deviation: result['action'] = 'PRICE_DEVIATION' result['details'] = { 'reason': f'价格偏差过大: {price_deviation*100:.2f}% > {max_deviation*100:.1f}%', 'signal_entry': signal_entry_price, 'current_price': current_price, 'deviation_pct': price_deviation * 100, 'max_deviation_pct': max_deviation * 100, } logger.info( f"[{config['name']}] 跳过开仓: 价格偏差 {price_deviation*100:.2f}% > {max_deviation*100:.1f}% " f"(信号价: ${signal_entry_price:.2f}, 当前价: ${current_price:.2f})" ) 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'] = 'CLOSE' result['details'] = close_result logger.info( f"[{config['name']}] 反向信号平仓,等待下一周期新信号" ) return result else: # 同方向信号:尝试金字塔加仓 add_result = self._add_position( tf, current_price, signal_stop_loss, signal_take_profit, tf_signal.get('reasoning', '')[:100] ) if add_result: result['action'] = 'ADD' result['details'] = add_result else: # 已达到最大仓位,保持持仓 result['action'] = 'HOLD' result['details'] = { 'position': account.position.to_dict(), 'unrealized_pnl': self._calc_unrealized_pnl(tf, current_price), 'reason': '已达最大仓位层级', } 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 _get_max_position_value(self, tf: TimeFrame) -> float: """获取最大仓位价值(本金 × 杠杆)""" account = self.accounts[tf] return account.initial_balance * account.leverage def _get_current_position_value(self, tf: TimeFrame, current_price: float) -> float: """获取当前仓位价值""" account = self.accounts[tf] if not account.position or account.position.side == 'FLAT': return 0.0 return account.position.size * current_price 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] # 计算首仓仓位:最大仓位 × 首仓比例 max_position_value = self._get_max_position_value(tf) first_level_ratio = PYRAMID_LEVELS[0] # 40% position_value = max_position_value * first_level_ratio margin = position_value / account.leverage size = position_value / price # 检查可用余额是否足够 available_balance = account.get_available_balance() if available_balance < margin: logger.warning(f"[{config['name']}] 可用余额不足: ${available_balance:.2f} < ${margin:.2f}") return None if size <= 0: return None # 创建首仓入场记录 entry = PositionEntry( price=price, size=size, margin=margin, timestamp=datetime.now().isoformat(), level=0, ) account.position = Position( side=direction, entries=[entry], stop_loss=stop_loss, take_profit=take_profit, created_at=datetime.now().isoformat(), signal_reasoning=reasoning, ) logger.info( f"[{config['name']}] OPEN {direction} [L1/{len(PYRAMID_LEVELS)}]: price=${price:.2f}, " f"size={size:.6f} BTC, margin=${margin:.2f}, value=${position_value:.2f}, " f"SL=${stop_loss:.2f}, TP=${take_profit:.2f}" ) return { 'timeframe': tf.value, 'side': direction, 'entry_price': price, 'size': size, 'margin': margin, 'position_value': position_value, 'pyramid_level': 1, 'max_levels': len(PYRAMID_LEVELS), 'stop_loss': stop_loss, 'take_profit': take_profit, } def _add_position( self, tf: TimeFrame, price: float, stop_loss: float, take_profit: float, reasoning: str ) -> Optional[Dict]: """金字塔加仓""" account = self.accounts[tf] config = TIMEFRAME_CONFIG[tf] pos = account.position if not pos or pos.side == 'FLAT': return None # 检查是否已达最大层级 current_level = pos.pyramid_level if current_level >= len(PYRAMID_LEVELS): logger.info(f"[{config['name']}] 已达最大仓位层级 {current_level}/{len(PYRAMID_LEVELS)}") return None # 计算加仓仓位 max_position_value = self._get_max_position_value(tf) level_ratio = PYRAMID_LEVELS[current_level] add_position_value = max_position_value * level_ratio add_margin = add_position_value / account.leverage add_size = add_position_value / price # 检查可用余额 available_balance = account.get_available_balance() if available_balance < add_margin: logger.warning( f"[{config['name']}] 加仓余额不足: ${available_balance:.2f} < ${add_margin:.2f}" ) return None # 添加入场记录 entry = PositionEntry( price=price, size=add_size, margin=add_margin, timestamp=datetime.now().isoformat(), level=current_level, ) pos.entries.append(entry) # 更新止盈止损 pos.stop_loss = stop_loss pos.take_profit = take_profit new_level = pos.pyramid_level logger.info( f"[{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"total_size={pos.size:.6f} BTC, total_margin=${pos.margin:.2f}, " f"avg_price=${pos.entry_price:.2f}" ) return { 'timeframe': tf.value, 'side': pos.side, 'add_price': price, 'add_size': add_size, 'add_margin': add_margin, 'add_position_value': add_position_value, 'total_size': pos.size, 'total_margin': pos.margin, 'avg_entry_price': pos.entry_price, 'pyramid_level': new_level, 'max_levels': len(PYRAMID_LEVELS), '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 = (price - pos.entry_price) * pos.size else: # 做空:(买入价 - 卖出价) * 数量 pnl = (pos.entry_price - price) * pos.size # 收益率 = 盈亏 / 保证金 * 100 pnl_pct = (pnl / pos.margin * 100) if pos.margin > 0 else 0 # 更新已实现盈亏(保证金释放 + 盈亏结算) account.realized_pnl += 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) # 计算新的账户权益 new_equity = account.get_equity() result = { 'timeframe': tf.value, 'side': pos.side, 'entry_price': pos.entry_price, 'exit_price': price, 'size': pos.size, 'margin': pos.margin, 'pnl': pnl, 'pnl_pct': pnl_pct, 'reason': reason, 'new_equity': new_equity, 'realized_pnl': account.realized_pnl, } 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}, " f"equity=${new_equity:.2f}" ) 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 = (current_price - pos.entry_price) * pos.size else: pnl = (pos.entry_price - current_price) * pos.size # 收益率 = 盈亏 / 保证金 * 100 pnl_pct = (pnl / pos.margin * 100) if pos.margin > 0 else 0 return {'pnl': pnl, 'pnl_pct': pnl_pct} def _update_equity_curve(self, tf: TimeFrame, current_price: float): """更新权益曲线""" account = self.accounts[tf] unrealized = self._calc_unrealized_pnl(tf, current_price) equity = account.get_equity(unrealized['pnl']) account.equity_curve.append({ 'timestamp': datetime.now().isoformat(), 'equity': equity, 'initial_balance': account.initial_balance, 'realized_pnl': account.realized_pnl, 'unrealized_pnl': unrealized['pnl'], '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']) # 更新峰值和回撤(基于账户权益) equity = account.get_equity() if equity > stats['peak_balance']: stats['peak_balance'] = equity drawdown = (stats['peak_balance'] - equity) / 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_equity = 0 total_initial = 0 total_realized_pnl = 0 total_unrealized_pnl = 0 # 先计算各周期数据 timeframes_data = {} for tf in TimeFrame: account = self.accounts[tf] config = TIMEFRAME_CONFIG[tf] # 计算未实现盈亏 unrealized = self._calc_unrealized_pnl(tf, current_price) if current_price else {'pnl': 0, 'pnl_pct': 0} equity = account.get_equity(unrealized['pnl']) total_initial += account.initial_balance total_realized_pnl += account.realized_pnl total_unrealized_pnl += unrealized['pnl'] total_equity += equity # 收益率 = (权益 - 初始本金) / 初始本金 return_pct = (equity - account.initial_balance) / account.initial_balance * 100 if account.initial_balance > 0 else 0 tf_status = { 'name': config['name'], 'name_en': config['name_en'], 'initial_balance': account.initial_balance, 'realized_pnl': account.realized_pnl, 'unrealized_pnl': unrealized['pnl'], 'equity': equity, 'available_balance': account.get_available_balance(), 'used_margin': account.get_used_margin(), 'return_pct': return_pct, '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: 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 timeframes_data[tf.value] = tf_status # 总收益率 total_return = (total_equity - total_initial) / total_initial * 100 if total_initial > 0 else 0 status = { 'timestamp': datetime.now().isoformat(), 'total_initial_balance': total_initial, 'total_realized_pnl': total_realized_pnl, 'total_unrealized_pnl': total_unrealized_pnl, 'total_equity': total_equity, 'total_return': total_return, 'timeframes': timeframes_data, } return status def reset(self): """重置所有账户""" self._init_all_accounts() self._save_state() logger.info("All accounts reset") # 兼容旧的 PaperTrader 接口 PaperTrader = MultiTimeframePaperTrader