From 53863709dcadc0a651a3bd11c49b971a00494756 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Mon, 30 Mar 2026 01:08:29 +0800 Subject: [PATCH] 11 --- backend/app/api/paper_trading.py | 20 --- backend/app/crypto_agent/crypto_agent.py | 121 +++++++------ backend/app/models/paper_trading.py | 6 +- backend/app/services/paper_trading_service.py | 98 +++++----- backend/app/services/position_manager.py | 4 +- backend/app/services/position_sizing.py | 168 ++++++++++++++++++ .../tests/test_position_sizing_regression.py | 150 ++++++++++++++++ frontend/trading.html | 14 +- 8 files changed, 433 insertions(+), 148 deletions(-) create mode 100644 backend/app/services/position_sizing.py create mode 100644 backend/tests/test_position_sizing_regression.py diff --git a/backend/app/api/paper_trading.py b/backend/app/api/paper_trading.py index cddc02c..8be8086 100644 --- a/backend/app/api/paper_trading.py +++ b/backend/app/api/paper_trading.py @@ -421,26 +421,6 @@ async def get_monitor_status(): raise HTTPException(status_code=500, detail=str(e)) -@router.post("/reset") -async def reset_paper_trading(): - """ - 重置所有模拟交易数据 - - 警告:此操作将删除所有订单记录,不可恢复! - """ - try: - service = get_paper_trading_service() - result = service.reset_all_data() - - return { - "success": True, - "message": f"交易数据已重置,删除 {result['deleted_count']} 条订单", - "result": result - } - except Exception as e: - logger.error(f"重置交易数据失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - @router.post("/recalculate-statistics") async def recalculate_statistics(): """ diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index 9fd03ef..bdbb67a 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -15,6 +15,12 @@ from app.services.telegram_service import get_telegram_service from app.services.dingtalk_service import get_dingtalk_service from app.services.paper_trading_service import get_paper_trading_service from app.services.signal_database_service import get_signal_db_service +from app.services.position_sizing import ( + DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME, + DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS, + calculate_margin_and_position_value, + resolve_target_margin_pct, +) from app.crypto_agent.market_signal_analyzer import MarketSignalAnalyzer from app.utils.system_status import get_system_monitor, AgentStatus @@ -70,15 +76,11 @@ class CryptoAgent: } SIGNAL_POSITION_SIZE_DEFAULTS = { - 'short_term': 'light', - 'medium_term': 'medium', - 'long_term': 'medium', + **DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME, } SIGNAL_MARGIN_MULTIPLIERS = { - 'short_term': 0.85, - 'medium_term': 1.0, - 'long_term': 1.0, + **DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS, } SIGNAL_MIN_STOP_LOSS_PCT = { @@ -948,6 +950,7 @@ class CryptoAgent: 'used_margin': hl_state["total_margin_used"], 'available_balance': hl_state["available_balance"], 'available': hl_state["available_balance"], # 决策器期望的键名 + 'order_leverage': min(getattr(self.hyperliquid, 'max_total_leverage', 10), 10), 'total_position_value': sum(abs(float(p.get("position", {}).get("szi", 0)) * float(p.get("position", {}).get("entryPx", 0))) for p in hl_state["positions"]), @@ -1343,19 +1346,9 @@ class CryptoAgent: grade = 'D' grade_icon = '' - # 仓位(基于信心度和杠杆空间)- 与新的等级阈值对齐 - if confidence >= 80: # A级信号 - position_size = 'heavy' - position_icon = '🔥' - position_text = '重仓' - elif confidence >= 60: # B级信号 - position_size = 'medium' - position_icon = '📊' - position_text = '中仓' - else: # C级或D级信号 - position_size = 'light' - position_icon = '🌱' - position_text = '轻仓' + position_size = best_signal.get('position_size') or self.SIGNAL_POSITION_SIZE_DEFAULTS.get(timeframe, 'light') + position_icon = {'heavy': '🔥', 'medium': '📊', 'light': '🌱', 'micro': '🌿'}.get(position_size, '🌱') + position_text = {'heavy': '重仓', 'medium': '中仓', 'light': '轻仓', 'micro': '微仓'}.get(position_size, '轻仓') # 计算止损止盈百分比(价格已经是 float) try: @@ -1641,12 +1634,24 @@ class CryptoAgent: """执行模拟交易""" try: symbol = decision.get('symbol') - action = decision.get('action', '') + action = decision.get('signal_action') or decision.get('action', '') position_size = decision.get('position_size', 'light') + raw_signal_type = decision.get('timeframe') or decision.get('type') or 'medium_term' - # 使用新的动态仓位计算方法 - logger.info(f" 计算动态仓位: {position_size}") - margin, position_value = self.paper_trading._calculate_dynamic_position(position_size, symbol) + quantity = decision.get('margin', decision.get('quantity', 0)) + if quantity and quantity > 0: + margin = float(quantity) + position_value = round(margin * self.paper_trading.leverage, 2) + logger.info(f" 使用统一决策保证金: ${margin:.2f}") + else: + logger.info(f" 回退计算动态仓位: {position_size}") + margin, position_value = self.paper_trading._calculate_dynamic_position( + position_size=position_size, + symbol=symbol, + signal_type=raw_signal_type, + confidence=decision.get('confidence'), + grade=decision.get('grade'), + ) if margin <= 0: logger.warning(f" ⚠️ 计算的保证金无效: {margin},无法开仓") @@ -1676,8 +1681,10 @@ class CryptoAgent: 'stop_loss': decision.get('stop_loss'), 'take_profit': decision.get('take_profit'), 'confidence': decision.get('confidence', 50), - 'signal_grade': 'B', # 默认B级 + 'signal_grade': decision.get('grade', 'B'), 'position_size': position_size, + 'signal_type': raw_signal_type, + 'type': raw_signal_type, 'quantity': quantity # 使用计算后的保证金金额 } @@ -2137,6 +2144,7 @@ class CryptoAgent: 'used_margin': bg_state["total_margin_used"], 'available_balance': bg_state["available_balance"], 'available': bg_state["available_balance"], # 决策器期望的键名 + 'order_leverage': 10, 'total_position_value': total_position_value, 'max_total_leverage': self.bitget.max_total_leverage, } @@ -2169,26 +2177,15 @@ class CryptoAgent: account: Dict[str, Any], platform_name: str) -> tuple: """ - 根据可用保证金和信号强度计算仓位大小 + 根据统一的权益百分比模型计算仓位大小 Returns: (margin, reason) - 保证金金额和原因 """ signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term' - - # 基础保证金比例(超激进配置 - 最大化资金利用率) confidence = signal.get('confidence', 50) - if confidence >= 90: - base_margin_pct = 0.20 # A级: 20% (重仓出击) - grade = 'A' - elif confidence >= 70: - base_margin_pct = 0.15 # B级: 15% (中仓跟进) - grade = 'B' - else: - base_margin_pct = 0.08 # C级: 8% (轻仓试探) - grade = 'C' - - base_margin_pct *= self.SIGNAL_MARGIN_MULTIPLIERS.get(signal_type, 1.0) + grade = signal.get('grade') + position_size = signal.get('position_size') # 可用保证金 available = account.get('available', account.get('available_balance', 0)) @@ -2197,42 +2194,44 @@ class CryptoAgent: if available <= 0 or balance <= 0: return 0, "账户余额无效" - # 计算保证金 - margin = available * base_margin_pct - # 应用平台规则 rules = self.PLATFORM_RULES.get(platform_name, {}) min_margin_rules = rules.get('min_margin', {}) max_margin_pct = rules.get('max_margin_pct', 0.1) - # 应用最小保证金限制 symbol = signal.get('symbol', '').replace('USDT', '').upper() min_margin = min_margin_rules.get(symbol, 0) - if min_margin > 0 and margin < min_margin: - margin = min_margin - - # 应用最大保证金限制 - max_margin = balance * max_margin_pct - if margin > max_margin: - margin = max_margin - - # 应用杠杆限制 current_leverage = account.get('current_total_leverage', 0) max_leverage = account.get('max_total_leverage', 10) - remaining_leverage = max_leverage - current_leverage + order_leverage = account.get('order_leverage', 10) - if remaining_leverage <= 0: - return 0, f"已达最大杠杆 {current_leverage:.1f}x/{max_leverage}x" + target_margin_pct, sizing_reason, _, _ = resolve_target_margin_pct( + position_size=position_size, + signal_type=signal_type, + confidence=confidence, + grade=grade, + timeframe_multipliers=self.SIGNAL_MARGIN_MULTIPLIERS, + default_positions=self.SIGNAL_POSITION_SIZE_DEFAULTS, + ) - max_margin_by_leverage = balance * remaining_leverage - if margin > max_margin_by_leverage: - margin = max_margin_by_leverage + margin, _, budget_reason = calculate_margin_and_position_value( + balance=balance, + available_margin=available, + current_total_leverage=current_leverage, + max_total_leverage=max_leverage, + order_leverage=order_leverage, + target_margin_pct=target_margin_pct, + max_margin_pct=max_margin_pct, + min_margin=min_margin, + ) - # 确保不超过可用余额 - if margin > available: - margin = available * 0.95 # 留 5% 余量 + if margin <= 0: + return 0, budget_reason - return round(margin, 2), f"{signal_type} 信号{grade}级({confidence}%) → {base_margin_pct*100:.1f}%保证金" + return margin, ( + f"{sizing_reason} | 平台: {platform_name} | " + f"限制后保证金 ${margin:.2f} ({budget_reason})" + ) def _handle_same_direction(self, signal: Dict[str, Any], positions: List[Dict], diff --git a/backend/app/models/paper_trading.py b/backend/app/models/paper_trading.py index 3e3c012..b5c6f17 100644 --- a/backend/app/models/paper_trading.py +++ b/backend/app/models/paper_trading.py @@ -63,7 +63,7 @@ class PaperOrder(Base): # 仓位信息 quantity = Column(Float, default=1000) # 持仓价值 (USDT) margin = Column(Float, default=50) # 保证金 (USDT) - leverage = Column(Integer, default=20) # 杠杆倍数 + leverage = Column(Integer, default=10) # 杠杆倍数 # 信号信息 signal_grade = Column(SQLEnum(SignalGrade), default=SignalGrade.D) @@ -117,8 +117,8 @@ class PaperOrder(Base): 'filled_price': self.filled_price, 'exit_price': self.exit_price, 'quantity': self.quantity, # 持仓价值 - 'margin': getattr(self, 'margin', self.quantity / 20), # 保证金(回退值:20倍杠杆) - 'leverage': getattr(self, 'leverage', 20), # 杠杆倍数(回退值:20倍) + 'margin': getattr(self, 'margin', self.quantity / 10), # 保证金(回退值:10倍杠杆) + 'leverage': getattr(self, 'leverage', 10), # 杠杆倍数(回退值:10倍) 'signal_grade': self.signal_grade.value if self.signal_grade else None, 'signal_type': self.signal_type, 'confidence': self.confidence, diff --git a/backend/app/services/paper_trading_service.py b/backend/app/services/paper_trading_service.py index 388f266..9e852b0 100644 --- a/backend/app/services/paper_trading_service.py +++ b/backend/app/services/paper_trading_service.py @@ -7,6 +7,7 @@ from typing import Dict, Any, List, Optional from app.models.paper_trading import PaperOrder, OrderStatus, OrderSide, SignalGrade, EntryType from app.services.db_service import db_service +from app.services.position_sizing import calculate_margin_and_position_value, resolve_target_margin_pct from app.config import get_settings from app.utils.logger import logger from app.utils.datetime_utils import get_beijing_time @@ -259,7 +260,14 @@ class PaperTradingService: else: # 回退到动态仓位计算 position_size = signal.get('position_size', 'light') - margin, position_value = self._calculate_dynamic_position(position_size, symbol) + signal_type = signal.get('signal_type') or signal.get('type') or 'medium_term' + margin, position_value = self._calculate_dynamic_position( + position_size=position_size, + symbol=symbol, + signal_type=signal_type, + confidence=signal.get('confidence'), + grade=grade, + ) if margin <= 0: msg = f"保证金不足或已达杠杆上限(当前杠杆已达 {self.leverage}x)" @@ -388,15 +396,18 @@ class PaperTradingService: finally: db.close() - def _calculate_dynamic_position(self, position_size: str, symbol: str) -> tuple: + def _calculate_dynamic_position( + self, + position_size: str, + symbol: str, + signal_type: str = 'medium_term', + confidence: float = None, + grade: str = None, + ) -> tuple: """ - 根据 LLM 建议的仓位大小计算实际保证金和持仓价值 + 回退仓位计算。 - 计算逻辑: - - 根据可用保证金的倍数确定持仓价值 - - micro: 0.5x, light: 1.0x, medium: 1.5x, heavy: 2.0x - - 累计持仓价值不超过可用保证金的 15 倍 - - 保证金 = 持仓价值 / 杠杆 + 规则统一为“按账户权益百分比控制保证金”,不再按可用保证金倍数放大名义仓位。 Args: position_size: 'heavy' / 'medium' / 'light' / 'micro' @@ -409,58 +420,35 @@ class PaperTradingService: account = self.get_account_status() balance = account['current_balance'] used_margin = account['used_margin'] # 已用保证金(持仓+挂单) - total_position_value = account['total_position_value'] # 累计持仓价值 - - # 计算可用保证金 available_margin = max(0, balance - used_margin) + target_margin_pct, sizing_reason, normalized_position_size, normalized_grade = resolve_target_margin_pct( + position_size=position_size, + signal_type=signal_type, + confidence=confidence, + grade=grade, + ) - # 根据 position_size 确定倍数(相对于可用保证金) - position_multiplier = { - 'micro': 0.5, - 'light': 1.0, - 'medium': 1.5, - 'heavy': 2.0 - }.get(position_size, 1.0) + margin, position_value, budget_reason = calculate_margin_and_position_value( + balance=balance, + available_margin=available_margin, + current_total_leverage=account.get('current_total_leverage', 0), + max_total_leverage=account.get('max_total_leverage', self.max_total_leverage), + order_leverage=self.leverage, + target_margin_pct=target_margin_pct, + max_margin_pct=0.25, + ) - # 最大累计持仓价值倍数 - max_total_multiplier = 15.0 - - # 计算目标持仓价值 = 可用保证金 × 倍数 - target_position_value = available_margin * position_multiplier - - # 计算最大允许的累计持仓价值 = 可用保证金 × 15 - max_total_position_value = available_margin * max_total_multiplier - - # 可用的剩余持仓价值额度 - available_position_value = max(0, max_total_position_value - total_position_value) - - # 检查是否超过可用额度 - if target_position_value > available_position_value: - logger.warning(f"目标持仓价值 ${target_position_value:.2f} 超过可用额度 ${available_position_value:.2f},调整为可用额度") - target_position_value = available_position_value - - if target_position_value < 50: - logger.warning(f"可用持仓价值不足(${available_position_value:.2f}),无法开仓") + if margin <= 0: + logger.warning( + f"动态仓位计算失败: {symbol} | {signal_type} | {normalized_position_size} | " + f"{normalized_grade} | {budget_reason}" + ) return 0, 0 - # 计算保证金 = 持仓价值 / 杠杆 - margin = target_position_value / self.leverage - - # 确保不超过可用保证金(理论上不会超过,因为 position_value = available_margin × multiplier) - if margin > available_margin: - logger.warning(f"计算保证金 ${margin:.2f} 超过可用保证金 ${available_margin:.2f},调整为可用保证金") - margin = available_margin - # 重新计算持仓价值 - target_position_value = margin * self.leverage - - # 修正浮点数精度问题,保留 2 位小数 - margin = round(margin, 2) - position_value = round(target_position_value, 2) - - logger.info(f"动态仓位计算: {position_size} | 可用保证金: ${available_margin:.2f} | " - f"累计持仓: ${total_position_value:.2f}/${max_total_position_value:.2f} | " - f"目标保证金: ${margin:.2f} | 持仓价值: ${position_value:.2f} ({position_multiplier}x, {self.leverage}x杠杆)") - + logger.info( + f"动态仓位计算: {symbol} | {sizing_reason} | " + f"保证金 ${margin:.2f} | 持仓价值 ${position_value:.2f} | {budget_reason}" + ) return margin, position_value def get_position_info(self) -> Dict[str, Any]: diff --git a/backend/app/services/position_manager.py b/backend/app/services/position_manager.py index 8087b40..1f4f85a 100644 --- a/backend/app/services/position_manager.py +++ b/backend/app/services/position_manager.py @@ -101,7 +101,7 @@ class PositionManager: class PaperPositionCalculator(PositionCalculator): """模拟盘仓位计算器""" - def __init__(self, account_status_getter, max_leverage: int = 20): + def __init__(self, account_status_getter, max_leverage: int = 10): """ 初始化模拟盘计算器 @@ -150,7 +150,7 @@ def calculate_paper_position( account_status_getter, position_size: str, symbol: str, - max_leverage: int = 20 + max_leverage: int = 10 ) -> Tuple[float, float]: """ 计算模拟盘仓位(快捷方法) diff --git a/backend/app/services/position_sizing.py b/backend/app/services/position_sizing.py new file mode 100644 index 0000000..e8c1ef6 --- /dev/null +++ b/backend/app/services/position_sizing.py @@ -0,0 +1,168 @@ +""" +统一仓位 sizing 规则。 + +目标: +- 单笔仓位按账户权益百分比控制保证金 +- 总杠杆限制按“名义仓位空间”换算成“可加保证金” +- 给模拟盘和执行决策层复用,避免多套逻辑漂移 +""" +from typing import Dict, Optional, Tuple + +from app.utils.logger import logger + + +DEFAULT_POSITION_SIZE_MARGIN_PCTS: Dict[str, float] = { + "micro": 0.01, + "light": 0.03, + "medium": 0.05, + "heavy": 0.08, +} + +DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME: Dict[str, str] = { + "short_term": "light", + "medium_term": "light", + "long_term": "medium", +} + +DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS: Dict[str, float] = { + "short_term": 0.90, + "medium_term": 1.00, + "long_term": 1.10, +} + +DEFAULT_GRADE_MARGIN_MULTIPLIERS: Dict[str, float] = { + "A": 1.10, + "B": 1.00, + "C": 0.80, + "D": 0.00, +} + + +def normalize_signal_type(signal_type: Optional[str]) -> str: + normalized = (signal_type or "medium_term").strip().lower() + if normalized not in {"short_term", "medium_term", "long_term"}: + return "medium_term" + return normalized + + +def infer_signal_grade(confidence: Optional[float], explicit_grade: Optional[str] = None) -> str: + if explicit_grade: + grade = str(explicit_grade).strip().upper() + if grade in {"A", "B", "C", "D"}: + return grade + + confidence_value = float(confidence or 0) + if confidence_value >= 80: + return "A" + if confidence_value >= 60: + return "B" + if confidence_value >= 40: + return "C" + return "D" + + +def normalize_position_size( + position_size: Optional[str], + signal_type: Optional[str], + defaults: Optional[Dict[str, str]] = None, +) -> str: + normalized_type = normalize_signal_type(signal_type) + normalized_size = (position_size or "").strip().lower() + if normalized_size in DEFAULT_POSITION_SIZE_MARGIN_PCTS: + return normalized_size + + fallback_defaults = defaults or DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME + return fallback_defaults.get(normalized_type, "light") + + +def resolve_target_margin_pct( + position_size: Optional[str], + signal_type: Optional[str], + confidence: Optional[float] = None, + grade: Optional[str] = None, + size_margin_pcts: Optional[Dict[str, float]] = None, + timeframe_multipliers: Optional[Dict[str, float]] = None, + grade_multipliers: Optional[Dict[str, float]] = None, + default_positions: Optional[Dict[str, str]] = None, +) -> Tuple[float, str, str, str]: + normalized_type = normalize_signal_type(signal_type) + normalized_size = normalize_position_size(position_size, normalized_type, default_positions) + normalized_grade = infer_signal_grade(confidence, grade) + + base_margin_pcts = size_margin_pcts or DEFAULT_POSITION_SIZE_MARGIN_PCTS + type_multipliers = timeframe_multipliers or DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS + effective_grade_multipliers = grade_multipliers or DEFAULT_GRADE_MARGIN_MULTIPLIERS + + base_pct = base_margin_pcts.get(normalized_size, base_margin_pcts["light"]) + timeframe_multiplier = type_multipliers.get(normalized_type, 1.0) + grade_multiplier = effective_grade_multipliers.get(normalized_grade, 1.0) + target_pct = base_pct * timeframe_multiplier * grade_multiplier + + reason = ( + f"{normalized_type} {normalized_grade}级 {normalized_size}仓位" + f" -> {target_pct * 100:.1f}%权益保证金" + ) + return target_pct, reason, normalized_size, normalized_grade + + +def calculate_margin_and_position_value( + *, + balance: float, + available_margin: float, + current_total_leverage: float, + max_total_leverage: float, + order_leverage: float, + target_margin_pct: float, + max_margin_pct: float, + min_margin: float = 0.0, + reserve_ratio: float = 0.05, +) -> Tuple[float, float, str]: + if balance <= 0: + return 0.0, 0.0, "账户余额无效" + if available_margin <= 0: + return 0.0, 0.0, "可用保证金不足" + if order_leverage <= 0: + return 0.0, 0.0, "订单杠杆无效" + if target_margin_pct <= 0: + return 0.0, 0.0, "目标保证金比例无效" + + reserve_ratio = min(max(reserve_ratio, 0.0), 0.5) + buffer_available_margin = max(0.0, available_margin * (1 - reserve_ratio)) + if buffer_available_margin <= 0: + return 0.0, 0.0, "可用保证金不足" + + max_margin_by_platform = balance * max_margin_pct if max_margin_pct > 0 else buffer_available_margin + leverage_headroom = max(0.0, max_total_leverage - current_total_leverage) + if leverage_headroom <= 0: + return 0.0, 0.0, f"已达最大总杠杆 {current_total_leverage:.2f}x/{max_total_leverage:.2f}x" + + # 总杠杆限制是“还能开的名义仓位”,这里换算成“还能加的保证金”。 + max_margin_by_total_leverage = (balance * leverage_headroom) / order_leverage + hard_cap = min(buffer_available_margin, max_margin_by_platform, max_margin_by_total_leverage) + if hard_cap <= 0: + return 0.0, 0.0, "可开保证金额度不足" + + target_margin = balance * target_margin_pct + margin = min(target_margin, hard_cap) + + if min_margin > 0 and margin < min_margin: + if min_margin <= hard_cap: + margin = min_margin + else: + return 0.0, 0.0, ( + f"最小保证金 ${min_margin:.2f} 超过当前可用额度 " + f"${hard_cap:.2f}" + ) + + position_value = margin * order_leverage + if position_value < 50: + return 0.0, 0.0, "可开仓位不足 $50" + + margin = round(margin, 2) + position_value = round(position_value, 2) + detail = ( + f"目标保证金 ${target_margin:.2f}, 实际保证金 ${margin:.2f}, " + f"名义仓位 ${position_value:.2f}, 单笔杠杆 {order_leverage:.1f}x" + ) + logger.info(f"仓位预算计算: {detail}") + return margin, position_value, detail diff --git a/backend/tests/test_position_sizing_regression.py b/backend/tests/test_position_sizing_regression.py new file mode 100644 index 0000000..d14b349 --- /dev/null +++ b/backend/tests/test_position_sizing_regression.py @@ -0,0 +1,150 @@ +""" +仓位 sizing 回归测试 + +覆盖重点: + - 中线信号默认仓位降到 light + - 总杠杆限制按“名义仓位空间 -> 保证金”正确换算 + - 模拟盘回退仓位计算不再按可用保证金倍数放大 +""" +import importlib.util +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + + +def load_position_sizing_module(): + module_path = Path(__file__).resolve().parents[1] / "app" / "services" / "position_sizing.py" + + if "app" not in sys.modules: + app_pkg = types.ModuleType("app") + app_pkg.__path__ = [str(module_path.parents[2] / "app")] + sys.modules["app"] = app_pkg + + if "app.services" not in sys.modules: + services_pkg = types.ModuleType("app.services") + services_pkg.__path__ = [str(module_path.parent)] + sys.modules["app.services"] = services_pkg + + if "app.utils" not in sys.modules: + utils_pkg = types.ModuleType("app.utils") + utils_pkg.__path__ = [str(module_path.parents[1] / "utils")] + sys.modules["app.utils"] = utils_pkg + + logger_module = types.ModuleType("app.utils.logger") + logger_module.logger = MagicMock() + sys.modules["app.utils.logger"] = logger_module + + module_name = "app.services.position_sizing_test" + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def load_paper_trading_service_class(): + service_path = Path(__file__).resolve().parents[1] / "app" / "services" / "paper_trading_service.py" + position_sizing_module = load_position_sizing_module() + sys.modules["app.services.position_sizing"] = position_sizing_module + + if "app.models" not in sys.modules: + models_pkg = types.ModuleType("app.models") + models_pkg.__path__ = [str(service_path.parents[1] / "models")] + sys.modules["app.models"] = models_pkg + + config_module = types.ModuleType("app.config") + config_module.get_settings = MagicMock(return_value=MagicMock()) + sys.modules["app.config"] = config_module + + datetime_module = types.ModuleType("app.utils.datetime_utils") + datetime_module.get_beijing_time = MagicMock() + sys.modules["app.utils.datetime_utils"] = datetime_module + + db_module = types.ModuleType("app.services.db_service") + db_module.db_service = MagicMock() + sys.modules["app.services.db_service"] = db_module + + paper_models_module = types.ModuleType("app.models.paper_trading") + paper_models_module.PaperOrder = object + paper_models_module.OrderStatus = types.SimpleNamespace( + PENDING="pending", + OPEN="open", + CLOSED_TP="closed_tp", + CLOSED_SL="closed_sl", + CLOSED_BE="closed_be", + CLOSED_TS="closed_ts", + CLOSED_MANUAL="closed_manual", + CANCELLED="cancelled", + ) + paper_models_module.OrderSide = types.SimpleNamespace(LONG="long", SHORT="short") + paper_models_module.SignalGrade = lambda value: value + paper_models_module.EntryType = types.SimpleNamespace(MARKET="market", LIMIT="limit") + sys.modules["app.models.paper_trading"] = paper_models_module + + module_name = "app.services.paper_trading_service_test" + spec = importlib.util.spec_from_file_location(module_name, service_path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module.PaperTradingService + + +def test_medium_term_defaults_to_light_margin_budget(): + module = load_position_sizing_module() + + target_pct, _, position_size, grade = module.resolve_target_margin_pct( + position_size=None, + signal_type="medium_term", + confidence=75, + ) + + assert position_size == "light" + assert grade == "B" + assert target_pct == pytest.approx(0.03) + + +def test_total_leverage_cap_is_converted_to_margin_cap(): + module = load_position_sizing_module() + + margin, position_value, _ = module.calculate_margin_and_position_value( + balance=20000, + available_margin=12000, + current_total_leverage=9.5, + max_total_leverage=10, + order_leverage=10, + target_margin_pct=0.08, + max_margin_pct=0.25, + ) + + assert margin == pytest.approx(1000.0) + assert position_value == pytest.approx(10000.0) + + +def test_paper_dynamic_position_uses_equity_pct_instead_of_margin_multiple(): + PaperTradingService = load_paper_trading_service_class() + + service = PaperTradingService.__new__(PaperTradingService) + service.leverage = 10 + service.max_total_leverage = 10 + service.get_account_status = MagicMock( + return_value={ + "current_balance": 20000, + "used_margin": 0, + "current_total_leverage": 0, + "max_total_leverage": 10, + } + ) + + margin, position_value = service._calculate_dynamic_position( + position_size="medium", + symbol="ETHUSDT", + signal_type="medium_term", + confidence=75, + grade="B", + ) + + assert margin == pytest.approx(1000.0) + assert position_value == pytest.approx(10000.0) diff --git a/frontend/trading.html b/frontend/trading.html index 0102f6e..5ead61e 100644 --- a/frontend/trading.html +++ b/frontend/trading.html @@ -396,7 +396,7 @@