111
This commit is contained in:
parent
54e33e7fff
commit
143628e793
68
output/paper_trading_state.json
Normal file
68
output/paper_trading_state.json
Normal file
@ -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"
|
||||
}
|
||||
@ -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
|
||||
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']
|
||||
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
|
||||
|
||||
|
||||
50
web/api.py
50
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'),
|
||||
|
||||
@ -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 = `
|
||||
<div class="flex justify-between text-xs mb-1">
|
||||
<span class="text-slate-500">Entry</span>
|
||||
<span class="text-slate-500">Entry <span class="text-slate-600">(L${pyramidLevel}/4)</span></span>
|
||||
<span class="text-white font-mono">$${(position.entry_price || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs mb-1">
|
||||
<span class="text-slate-500">SL / TP</span>
|
||||
<span class="font-mono"><span class="text-danger">$${(position.stop_loss || 0).toFixed(0)}</span> / <span class="text-success">$${(position.take_profit || 0).toFixed(0)}</span></span>
|
||||
<span class="text-slate-500">Stop Loss</span>
|
||||
<span class="text-danger font-mono">$${(position.stop_loss || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs">
|
||||
<div class="flex justify-between text-xs mb-1">
|
||||
<span class="text-slate-500">Take Profit</span>
|
||||
<span class="text-success font-mono">$${(position.take_profit || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs pt-1 border-t border-slate-700/50">
|
||||
<span class="text-slate-500">PnL</span>
|
||||
<span class="${pnlColor} font-mono font-medium">${unrealized >= 0 ? '+' : ''}$${Math.abs(unrealized).toFixed(2)} (${unrealizedPct >= 0 ? '+' : ''}${unrealizedPct.toFixed(1)}%)</span>
|
||||
</div>
|
||||
@ -360,8 +378,15 @@
|
||||
} else {
|
||||
badge.className = 'badge badge-flat tf-position-badge';
|
||||
badge.textContent = 'FLAT';
|
||||
posInfo.innerHTML = '<div class="text-slate-500 text-sm text-center">No position</div>';
|
||||
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 = `
|
||||
<div class="text-center">
|
||||
<div class="text-slate-500 text-xs mb-1">Realized PnL</div>
|
||||
<div class="${realizedColor} font-mono text-sm">${realizedPnl >= 0 ? '+' : ''}$${realizedPnl.toFixed(2)}</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-slate-400 text-xs uppercase">${tfName}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
${confidence ? `<span class="text-xs text-slate-500">Conf: ${(confidence * 100).toFixed(0)}%</span>` : ''}
|
||||
<span class="badge ${isLong ? 'badge-long' : 'badge-short'}">${opp.direction}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div><span class="text-slate-500">Entry</span><div class="text-white font-mono">$${(opp.entry_price || 0).toFixed(0)}</div></div>
|
||||
<div><span class="text-slate-500">SL</span><div class="text-danger font-mono">$${(opp.stop_loss || 0).toFixed(0)}</div></div>
|
||||
<div><span class="text-slate-500">TP</span><div class="text-success font-mono">$${(opp.take_profit || 0).toFixed(0)}</div></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs mb-3">
|
||||
<div><span class="text-slate-500">Entry</span><div class="text-white font-mono">$${(opp.entry_price || 0).toLocaleString()}</div></div>
|
||||
<div><span class="text-slate-500">SL</span><div class="text-danger font-mono">$${(opp.stop_loss || 0).toLocaleString()}</div></div>
|
||||
<div><span class="text-slate-500">TP</span><div class="text-success font-mono">$${(opp.take_profit || 0).toLocaleString()}</div></div>
|
||||
</div>
|
||||
${reasoning ? `<div class="text-xs text-slate-400 border-t border-slate-700/50 pt-2 mt-2 leading-relaxed">${reasoning}</div>` : ''}
|
||||
`;
|
||||
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 = `
|
||||
<div class="text-slate-400 text-xs uppercase mb-2">${tfName}</div>
|
||||
<div class="text-slate-500 text-sm">${reason.length > 60 ? reason.substring(0, 60) + '...' : reason}</div>
|
||||
<div class="text-slate-500 text-sm leading-relaxed">${reason}</div>
|
||||
`;
|
||||
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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user