11
This commit is contained in:
parent
6a067fd39e
commit
53863709dc
@ -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():
|
||||
"""
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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]:
|
||||
"""
|
||||
计算模拟盘仓位(快捷方法)
|
||||
|
||||
168
backend/app/services/position_sizing.py
Normal file
168
backend/app/services/position_sizing.py
Normal file
@ -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
|
||||
150
backend/tests/test_position_sizing_regression.py
Normal file
150
backend/tests/test_position_sizing_regression.py
Normal file
@ -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)
|
||||
@ -396,7 +396,7 @@
|
||||
<th>保证金</th>
|
||||
<th>未实现盈亏</th>
|
||||
<th>盈亏比例</th>
|
||||
<th>操作</th>
|
||||
<th v-if="adminMode">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -420,7 +420,7 @@
|
||||
<td :class="order.pnl_percent >= 0 ? 'text-success' : 'text-error'">
|
||||
{{ formatSignedPercent(order.pnl_percent) }}
|
||||
</td>
|
||||
<td>
|
||||
<td v-if="adminMode">
|
||||
<button class="btn btn-danger btn-small" @click="closeOrder(order)">平仓</button>
|
||||
<button v-if="adminMode" class="btn btn-secondary btn-small" @click="deleteOrder(order)" style="margin-left: 4px;">删除</button>
|
||||
</td>
|
||||
@ -678,13 +678,13 @@
|
||||
});
|
||||
},
|
||||
pendingOrders() {
|
||||
return this.orders.filter(o => o.status === 'pending');
|
||||
return this.orders.filter(order => order.status === 'pending');
|
||||
},
|
||||
orderHistory() {
|
||||
// 包含所有已关闭的订单:closed_tp, closed_sl, closed_be, closed_ts, closed_manual, cancelled
|
||||
return this.orders.filter(o =>
|
||||
o.status.startsWith('closed') || o.status === 'cancelled'
|
||||
);
|
||||
return this.orders.filter(order => {
|
||||
const status = typeof order.status === 'string' ? order.status : '';
|
||||
return status.startsWith('closed') || status === 'cancelled';
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user