From 143628e79318b1ff4ad142885836e1be9ce092ff Mon Sep 17 00:00:00 2001 From: aaron <> Date: Tue, 9 Dec 2025 13:27:38 +0800 Subject: [PATCH] 111 --- output/paper_trading_state.json | 68 +++++ trading/paper_trading.py | 423 ++++++++++++++++++++++++++------ web/api.py | 50 ++-- web/static/index.html | 80 ++++-- 4 files changed, 507 insertions(+), 114 deletions(-) create mode 100644 output/paper_trading_state.json diff --git a/output/paper_trading_state.json b/output/paper_trading_state.json new file mode 100644 index 0000000..2012fae --- /dev/null +++ b/output/paper_trading_state.json @@ -0,0 +1,68 @@ +{ + "accounts": { + "short": { + "timeframe": "short", + "initial_balance": 10000.0, + "realized_pnl": 0.0, + "leverage": 10, + "position": null, + "trades": [], + "stats": { + "total_trades": 0, + "winning_trades": 0, + "losing_trades": 0, + "total_pnl": 0.0, + "max_drawdown": 0.0, + "peak_balance": 10000.0, + "win_rate": 0.0, + "avg_win": 0.0, + "avg_loss": 0.0, + "profit_factor": 0.0 + }, + "equity_curve": [] + }, + "medium": { + "timeframe": "medium", + "initial_balance": 10000.0, + "realized_pnl": 0.0, + "leverage": 10, + "position": null, + "trades": [], + "stats": { + "total_trades": 0, + "winning_trades": 0, + "losing_trades": 0, + "total_pnl": 0.0, + "max_drawdown": 0.0, + "peak_balance": 10000.0, + "win_rate": 0.0, + "avg_win": 0.0, + "avg_loss": 0.0, + "profit_factor": 0.0 + }, + "equity_curve": [] + }, + "long": { + "timeframe": "long", + "initial_balance": 10000.0, + "realized_pnl": 0.0, + "leverage": 10, + "position": null, + "trades": [], + "stats": { + "total_trades": 0, + "winning_trades": 0, + "losing_trades": 0, + "total_pnl": 0.0, + "max_drawdown": 0.0, + "peak_balance": 10000.0, + "win_rate": 0.0, + "avg_win": 0.0, + "avg_loss": 0.0, + "profit_factor": 0.0 + }, + "equity_curve": [] + } + }, + "last_updated": "2025-12-09T13:24:30.616776" +} \ No newline at end of file diff --git a/trading/paper_trading.py b/trading/paper_trading.py index 80fbfeb..83af38a 100644 --- a/trading/paper_trading.py +++ b/trading/paper_trading.py @@ -37,6 +37,7 @@ TIMEFRAME_CONFIG = { 'signal_keys': ['short_term_5m_15m_1h', 'intraday'], 'leverage': 10, 'initial_balance': 10000.0, # 独立初始资金 + 'max_price_deviation': 0.001, # 0.1% - 短周期要求精准入场 }, TimeFrame.MEDIUM: { 'name': '中周期', @@ -44,6 +45,7 @@ TIMEFRAME_CONFIG = { 'signal_keys': ['medium_term_4h_1d', 'swing'], 'leverage': 10, 'initial_balance': 10000.0, # 独立初始资金 + 'max_price_deviation': 0.003, # 0.3% - 中周期适中容错 }, TimeFrame.LONG: { 'name': '长周期', @@ -51,29 +53,101 @@ TIMEFRAME_CONFIG = { '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 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 = "" +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) -> 'Position': + 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: """交易记录""" @@ -99,11 +173,21 @@ class Trade: @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 - balance: float 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) @@ -127,11 +211,25 @@ class TimeFrameAccount: '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, - 'balance': self.balance, '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:]], @@ -141,11 +239,17 @@ class TimeFrameAccount: @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'], - balance=data['balance'], initial_balance=data['initial_balance'], leverage=data['leverage'], + realized_pnl=realized_pnl, stats=data.get('stats', {}), equity_curve=data.get('equity_curve', []), ) @@ -210,12 +314,11 @@ class MultiTimeframePaperTrader: """初始化单个周期账户""" 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, + initial_balance=config['initial_balance'], leverage=config['leverage'], + realized_pnl=0.0, ) def _save_state(self): @@ -282,6 +385,7 @@ class MultiTimeframePaperTrader: 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: @@ -289,33 +393,58 @@ class MultiTimeframePaperTrader: result['details'] = {'reason': '缺少有效止盈止损'} return result - # 3. 如果有反向持仓,先平仓 + # 检查价格偏差:当前价格与建议入场价偏差超过阈值则不开仓 + 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'] = 'REVERSE' - result['details'] = {'close': close_result} - - # 开反向仓 - open_result = self._open_position( - tf, direction, current_price, + 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 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), - } + 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. 无持仓,开新仓 + # 4. 无持仓,开新仓(首仓) open_result = self._open_position( tf, direction, current_price, signal_stop_loss, signal_take_profit, @@ -359,25 +488,54 @@ class MultiTimeframePaperTrader: 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] - # 计算仓位大小: 全部余额 * 杠杆倍数 (10000 * 10 = 100000 USD 仓位价值) - position_value = account.balance * account.leverage + # 计算首仓仓位:最大仓位 × 首仓比例 + 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, - entry_price=price, - size=size, + entries=[entry], stop_loss=stop_loss, take_profit=take_profit, created_at=datetime.now().isoformat(), @@ -385,8 +543,9 @@ class MultiTimeframePaperTrader: ) logger.info( - f"[{config['name']}] OPEN {direction}: price=${price:.2f}, " - f"size={size:.6f} BTC, SL=${stop_loss:.2f}, TP=${take_profit:.2f}" + 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 { @@ -394,6 +553,81 @@ class MultiTimeframePaperTrader: '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, } @@ -428,16 +662,19 @@ class MultiTimeframePaperTrader: 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 + # 做多:(卖出价 - 买入价) * 数量 + pnl = (price - pos.entry_price) * pos.size else: - pnl_pct = (pos.entry_price - price) / pos.entry_price * 100 * account.leverage + # 做空:(买入价 - 卖出价) * 数量 + pnl = (pos.entry_price - price) * pos.size - position_value = pos.size * pos.entry_price - pnl = position_value * (pnl_pct / 100) + # 收益率 = 盈亏 / 保证金 * 100 + pnl_pct = (pnl / pos.margin * 100) if pos.margin > 0 else 0 - account.balance += pnl + # 更新已实现盈亏(保证金释放 + 盈亏结算) + account.realized_pnl += pnl # 记录交易 trade = Trade( @@ -456,21 +693,27 @@ class MultiTimeframePaperTrader: 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_balance': account.balance, + '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"exit=${price:.2f}, PnL=${pnl:.2f} ({pnl_pct:.2f}%), reason={reason}, " + f"equity=${new_equity:.2f}" ) account.position = None @@ -484,29 +727,29 @@ class MultiTimeframePaperTrader: 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 + pnl = (current_price - pos.entry_price) * pos.size else: - pnl_pct = (pos.entry_price - current_price) / pos.entry_price * 100 * account.leverage + pnl = (pos.entry_price - current_price) * pos.size - position_value = pos.size * pos.entry_price - pnl = position_value * (pnl_pct / 100) + # 收益率 = 盈亏 / 保证金 * 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] - equity = account.balance - - if account.position and account.position.side != 'FLAT': - unrealized = self._calc_unrealized_pnl(tf, current_price) - equity += unrealized['pnl'] + unrealized = self._calc_unrealized_pnl(tf, current_price) + equity = account.get_equity(unrealized['pnl']) account.equity_curve.append({ 'timestamp': datetime.now().isoformat(), 'equity': equity, - 'balance': account.balance, + 'initial_balance': account.initial_balance, + 'realized_pnl': account.realized_pnl, + 'unrealized_pnl': unrealized['pnl'], 'price': current_price, }) @@ -537,36 +780,50 @@ class MultiTimeframePaperTrader: 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 + # 更新峰值和回撤(基于账户权益) + equity = account.get_equity() + if equity > stats['peak_balance']: + stats['peak_balance'] = equity - drawdown = (stats['peak_balance'] - account.balance) / stats['peak_balance'] * 100 + 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_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': {}, - } + 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'], - 'balance': account.balance, 'initial_balance': account.initial_balance, - 'return_pct': (account.balance - account.initial_balance) / account.initial_balance * 100, + '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, @@ -577,13 +834,25 @@ class MultiTimeframePaperTrader: 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 + 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 diff --git a/web/api.py b/web/api.py index 0b8e7d4..864d22d 100644 --- a/web/api.py +++ b/web/api.py @@ -61,11 +61,11 @@ def load_trading_state() -> Dict[str, Any]: } -def _default_account(timeframe: str, balance: float) -> Dict: +def _default_account(timeframe: str, initial_balance: float) -> Dict: return { 'timeframe': timeframe, - 'balance': balance, - 'initial_balance': balance, + 'initial_balance': initial_balance, + 'realized_pnl': 0.0, 'leverage': 10, # 所有周期统一 10 倍杠杆 'position': None, 'trades': [], @@ -75,7 +75,7 @@ def _default_account(timeframe: str, balance: float) -> Dict: 'losing_trades': 0, 'total_pnl': 0.0, 'max_drawdown': 0.0, - 'peak_balance': balance, + 'peak_balance': initial_balance, 'win_rate': 0.0, }, 'equity_curve': [], @@ -108,33 +108,55 @@ async def get_status(): state = load_trading_state() accounts = state.get('accounts', {}) - # 计算总余额 - total_balance = sum(acc.get('balance', 0) for acc in accounts.values()) - total_initial = sum(acc.get('initial_balance', 0) for acc in accounts.values()) - total_return = (total_balance - total_initial) / total_initial * 100 if total_initial > 0 else 0 + total_initial = 0 + total_realized_pnl = 0 + total_equity = 0 # 构建各周期状态 timeframes = {} for tf_key, acc in accounts.items(): initial = acc.get('initial_balance', 0) - balance = acc.get('balance', 0) - return_pct = (balance - initial) / initial * 100 if initial > 0 else 0 + realized_pnl = acc.get('realized_pnl', 0) + + # 兼容旧数据格式 + if 'realized_pnl' not in acc and 'balance' in acc: + realized_pnl = acc['balance'] - initial + + # 计算权益(不含未实现盈亏,因为 API 没有实时价格) + 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', - 'balance': balance, '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', 1), - 'position': acc.get('position'), + '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(), - 'total_balance': total_balance, 'total_initial_balance': total_initial, + 'total_realized_pnl': total_realized_pnl, + 'total_equity': total_equity, 'total_return': total_return, 'timeframes': timeframes, 'last_updated': state.get('last_updated'), diff --git a/web/static/index.html b/web/static/index.html index 8d51866..a279442 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -288,16 +288,25 @@ if (!state || !state.accounts) return; const accounts = state.accounts; - let totalBalance = 0, totalInitial = 0; + let totalInitial = 0, totalEquity = 0, totalRealizedPnl = 0, totalUnrealizedPnl = 0; for (const [tf, acc] of Object.entries(accounts)) { - totalBalance += acc.balance || 0; - totalInitial += acc.initial_balance || 0; + const initial = acc.initial_balance || 0; + const realizedPnl = acc.realized_pnl || 0; + // 兼容旧数据 + const equity = acc.equity || (acc.balance || initial + realizedPnl); + const unrealizedPnl = acc.position?.unrealized_pnl || 0; + + totalInitial += initial; + totalRealizedPnl += realizedPnl; + totalUnrealizedPnl += unrealizedPnl; + totalEquity += equity + unrealizedPnl; + updateTimeframeCard(tf, acc); } - const totalReturn = totalInitial > 0 ? (totalBalance - totalInitial) / totalInitial * 100 : 0; - document.getElementById('total-balance').textContent = `$${totalBalance.toLocaleString('en-US', {minimumFractionDigits: 2})}`; + 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'}`; @@ -315,13 +324,17 @@ const card = document.getElementById(`tf-${tf}`); if (!card) return; - const balance = acc.balance || 0; const initial = acc.initial_balance || 0; - const returnPct = initial > 0 ? (balance - initial) / initial * 100 : 0; - const stats = acc.stats || {}; + const realizedPnl = acc.realized_pnl || 0; const position = acc.position; + const unrealizedPnl = position?.unrealized_pnl || 0; - card.querySelector('.tf-balance').textContent = `$${balance.toLocaleString('en-US', {minimumFractionDigits: 2})}`; + // 权益 = 初始本金 + 已实现盈亏 + 未实现盈亏 + const equity = initial + realizedPnl + unrealizedPnl; + const returnPct = initial > 0 ? (equity - initial) / initial * 100 : 0; + const stats = acc.stats || {}; + + card.querySelector('.tf-balance').textContent = `$${equity.toLocaleString('en-US', {minimumFractionDigits: 2})}`; const returnEl = card.querySelector('.tf-return'); returnEl.textContent = `${returnPct >= 0 ? '+' : ''}${returnPct.toFixed(2)}%`; @@ -340,18 +353,23 @@ const unrealized = position.unrealized_pnl || 0; const unrealizedPct = position.unrealized_pnl_pct || 0; + const pyramidLevel = position.pyramid_level || 1; const pnlColor = unrealized >= 0 ? 'text-success' : 'text-danger'; posInfo.innerHTML = `
- Entry + Entry (L${pyramidLevel}/4) $${(position.entry_price || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}
- SL / TP - $${(position.stop_loss || 0).toFixed(0)} / $${(position.take_profit || 0).toFixed(0)} + Stop Loss + $${(position.stop_loss || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}
-
+
+ Take Profit + $${(position.take_profit || 0).toLocaleString('en-US', {minimumFractionDigits: 2})} +
+
PnL ${unrealized >= 0 ? '+' : ''}$${Math.abs(unrealized).toFixed(2)} (${unrealizedPct >= 0 ? '+' : ''}${unrealizedPct.toFixed(1)}%)
@@ -360,8 +378,15 @@ } else { badge.className = 'badge badge-flat tf-position-badge'; badge.textContent = 'FLAT'; - posInfo.innerHTML = '
No position
'; - posInfo.className = 'tf-position-info bg-slate-800/30 rounded-lg p-3 text-center'; + // 显示已实现盈亏 + const realizedColor = realizedPnl >= 0 ? 'text-success' : 'text-danger'; + posInfo.innerHTML = ` +
+
Realized PnL
+
${realizedPnl >= 0 ? '+' : ''}$${realizedPnl.toFixed(2)}
+
+ `; + posInfo.className = 'tf-position-info bg-slate-800/30 rounded-lg p-3'; } } @@ -389,23 +414,29 @@ if (opp && opp.exists && opp.direction) { const isLong = opp.direction === 'LONG'; + const confidence = opp.confidence || opp.confidence_score || 0; + const reasoning = opp.reasoning || ''; el.innerHTML = ` -
+
${tfName} - ${opp.direction} +
+ ${confidence ? `Conf: ${(confidence * 100).toFixed(0)}%` : ''} + ${opp.direction} +
-
-
Entry
$${(opp.entry_price || 0).toFixed(0)}
-
SL
$${(opp.stop_loss || 0).toFixed(0)}
-
TP
$${(opp.take_profit || 0).toFixed(0)}
+
+
Entry
$${(opp.entry_price || 0).toLocaleString()}
+
SL
$${(opp.stop_loss || 0).toLocaleString()}
+
TP
$${(opp.take_profit || 0).toLocaleString()}
+ ${reasoning ? `
${reasoning}
` : ''} `; el.className = `bg-slate-800/50 rounded-lg p-4 border ${isLong ? 'border-success/30' : 'border-danger/30'}`; } else { const reason = opp?.reasoning || 'No opportunity'; el.innerHTML = `
${tfName}
-
${reason.length > 60 ? reason.substring(0, 60) + '...' : reason}
+
${reason}
`; el.className = 'bg-slate-800/50 rounded-lg p-4'; } @@ -463,8 +494,11 @@ const state = { accounts: {} }; for (const [tf, data] of Object.entries(status.timeframes)) { state.accounts[tf] = { - balance: data.balance, initial_balance: data.initial_balance, + realized_pnl: data.realized_pnl || 0, + equity: data.equity || data.initial_balance, + available_balance: data.available_balance, + used_margin: data.used_margin, leverage: data.leverage, position: data.position, stats: data.stats,