This commit is contained in:
aaron 2026-03-30 01:08:29 +08:00
parent 6a067fd39e
commit 53863709dc
8 changed files with 433 additions and 148 deletions

View File

@ -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():
"""

View File

@ -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],

View File

@ -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,

View File

@ -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]:

View File

@ -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]:
"""
计算模拟盘仓位快捷方法

View 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

View 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)

View File

@ -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: {