This commit is contained in:
aaron 2026-03-30 00:53:36 +08:00
parent f31322a2a5
commit 6a067fd39e
7 changed files with 997 additions and 1947 deletions

View File

@ -110,7 +110,7 @@ async def get_order(order_id: str):
@router.post("/orders/{order_id}/close")
async def close_order(order_id: str, request: CloseOrderRequest):
async def close_order(order_id: str, request: Optional[CloseOrderRequest] = None):
"""
手动平仓
@ -119,7 +119,17 @@ async def close_order(order_id: str, request: CloseOrderRequest):
"""
try:
service = get_paper_trading_service()
result = service.close_order_manual(order_id, request.exit_price)
order = service.get_order_by_id(order_id)
if not order:
raise HTTPException(status_code=404, detail="订单不存在或已平仓")
exit_price = request.exit_price if request and request.exit_price > 0 else 0
if exit_price <= 0:
exit_price = service._get_current_price(order.get('symbol', ''))
if exit_price <= 0:
exit_price = order.get('filled_price') or order.get('entry_price') or 0
result = service.close_order_manual(order_id, float(exit_price))
if not result:
raise HTTPException(status_code=404, detail="订单不存在或已平仓")
@ -136,6 +146,28 @@ async def close_order(order_id: str, request: CloseOrderRequest):
raise HTTPException(status_code=500, detail=str(e))
@router.post("/orders/{order_id}/cancel")
async def cancel_order(order_id: str):
"""撤销挂单"""
try:
service = get_paper_trading_service()
result = service.cancel_order(order_id)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "撤单失败"))
return {
"success": True,
"message": result.get("message", "撤单成功"),
"result": result
}
except HTTPException:
raise
except Exception as e:
logger.error(f"撤单失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/orders/{order_id}")
async def delete_order(
order_id: str,
@ -409,7 +441,6 @@ async def reset_paper_trading():
logger.error(f"重置交易数据失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/recalculate-statistics")
async def recalculate_statistics():
"""

View File

@ -16,7 +16,6 @@ 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.crypto_agent.market_signal_analyzer import MarketSignalAnalyzer
from app.crypto_agent.trading_decision_maker import TradingDecisionMaker
from app.utils.system_status import get_system_monitor, AgentStatus
@ -82,6 +81,18 @@ class CryptoAgent:
'long_term': 1.0,
}
SIGNAL_MIN_STOP_LOSS_PCT = {
'short_term': 0.6,
'medium_term': 1.0,
'long_term': 1.2,
}
SIGNAL_MIN_TAKE_PROFIT_PCT = {
'short_term': 1.0,
'medium_term': 2.0,
'long_term': 2.5,
}
def __new__(cls, *args, **kwargs):
"""单例模式 - 确保只有一个实例"""
if cls._instance is None:
@ -102,21 +113,14 @@ class CryptoAgent:
self.telegram = get_telegram_service()
self.dingtalk = get_dingtalk_service() # 添加钉钉服务
# 新架构:市场信号分析器 + 交易决策器
# 信号层:只负责市场分析
self.market_analyzer = MarketSignalAnalyzer()
self.decision_maker = None # 延迟初始化,需要 paper_trading 的杠杆配置
self.signal_db = get_signal_db_service() # 信号数据库服务
# 模拟交易服务(始终启用)
self.paper_trading = get_paper_trading_service()
# 初始化决策器(需要杠杆配置)
self.decision_maker = TradingDecisionMaker(
leverage=self.paper_trading.leverage,
max_total_leverage=self.paper_trading.max_total_leverage
)
# Hyperliquid 实盘服务(可选)
from app.services.hyperliquid_trading_service import get_hyperliquid_service
self.hyperliquid = get_hyperliquid_service()
@ -612,12 +616,12 @@ class CryptoAgent:
async def analyze_symbol(self, symbol: str):
"""
分析单个交易对新架构市场分析 + 交易决策分离
分析单个交易对信号分析 + 平台执行规则
新架构流程
当前流程
1. 市场信号分析器分析市场不包含仓位信息
2. 交易决策器根据信号+仓位+账户状态做决策
3. 执行交易决策
2. 各平台按自身规则筛选并处理信号
3. 执行交易动作
Args:
symbol: 交易对 'BTCUSDT'
@ -696,7 +700,7 @@ class CryptoAgent:
)
# ============================================================
# 发送市场信号通知(独立于交易决策)
# 发送市场信号通知
# ============================================================
await self._send_market_signal_notification(market_signal, current_price)
@ -725,10 +729,6 @@ class CryptoAgent:
else:
logger.info(" 无可执行信号")
paper_decision = {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
# 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策)
# await self._send_trading_decision_notification(
# paper_decision, market_signal, current_price, prefix="[模拟盘]"
# )
else:
paper_decision = {"action": "IGNORE", "reason": "未启用"}
logger.info(f"⏸️ 模拟盘交易未启用")
@ -753,10 +753,6 @@ class CryptoAgent:
else:
logger.info(" 无可执行信号")
hl_decision = {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
# 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策)
# await self._send_trading_decision_notification(
# hl_decision, market_signal, current_price, prefix="[Hyperliquid]"
# )
else:
hl_decision = {"action": "IGNORE", "reason": "未启用"}
logger.info(f"⏸️ Hyperliquid 实盘交易未启用")
@ -781,16 +777,12 @@ class CryptoAgent:
else:
logger.info(" 无可执行信号")
bg_decision = {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
# 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策)
# await self._send_trading_decision_notification(
# bg_decision, market_signal, current_price, prefix="[Bitget]"
# )
else:
bg_decision = {"action": "IGNORE", "reason": "未启用"}
logger.info(f"⏸️ Bitget 实盘交易未启用")
# ============================================================
# 第三阶段:执行交易决策(各平台独立)
# 第三阶段:执行交易动作(各平台独立)
# ============================================================
await self._execute_decisions(paper_decision, hl_decision, bg_decision, market_signal, current_price)
@ -870,41 +862,6 @@ class CryptoAgent:
logger.info(f" 信心度: {confidence}%")
logger.info(f" 理由: {sig.get('reasoning', 'N/A')}")
def _log_trading_decision(self, decision: Dict[str, Any]):
"""输出交易决策结果"""
decision_type = decision.get('decision', 'HOLD')
decision_map = {
'OPEN': '🟢 开仓',
'CLOSE': '🔴 平仓',
'ADD': ' 加仓',
'REDUCE': ' 减仓',
'HOLD': '⏸️ 观望'
}
logger.info(f" 决策: {decision_map.get(decision_type, decision_type)}")
logger.info(f" 动作: {decision.get('action', 'N/A')}")
logger.info(f" 仓位: {decision.get('position_size', 'N/A')}")
# quantity 是保证金,显示持仓价值 = 保证金 × 杠杆
quantity = decision.get('quantity', 0)
if isinstance(quantity, (int, float)) and quantity > 0:
leverage = self.paper_trading.leverage # 使用实际的杠杆配置
position_value = quantity * leverage
logger.info(f" 持仓价值: ${position_value:,.2f} (保证金 ${quantity:.2f})")
else:
logger.info(f" 数量: ${decision.get('quantity', 'N/A')}")
if decision.get('stop_loss'):
logger.info(f" 止损: ${decision.get('stop_loss')}")
if decision.get('take_profit'):
logger.info(f" 止盈: ${decision.get('take_profit')}")
logger.info(f" 理由: {decision.get('reasoning', 'N/A')}")
risk = decision.get('risk_analysis', '')
if risk:
logger.info(f" 风险: {risk}")
def _get_paper_trading_state(self) -> tuple:
"""
获取模拟盘交易状态持仓和账户
@ -1173,6 +1130,9 @@ class CryptoAgent:
if decision_type == 'HOLD':
reasoning = decision.get('reasoning', decision.get('reason', '观望'))
logger.info(f"\n📊 交易决策: {reasoning}")
await self._notify_signal_not_executed(
market_signal, decision, current_price, reason=f"[模拟盘] {reasoning}", prefix="[模拟盘]"
)
return
logger.info(f"\n📊 【执行交易】")
@ -1478,144 +1438,6 @@ class CryptoAgent:
import traceback
logger.debug(traceback.format_exc())
async def _send_trading_decision_notification(self, decision: Dict[str, Any],
market_signal: Dict[str, Any],
current_price: float,
prefix: str = ""):
"""发送交易决策通知(第二阶段)"""
try:
decision_type = decision.get('decision', 'HOLD')
symbol = market_signal.get('symbol')
# 账户类型标识
account_type = f"{prefix} 📊 交易" if prefix else "📊 交易"
# 决策类型映射
decision_map = {
'OPEN': '开仓',
'CLOSE': '平仓',
'ADD': '加仓',
'REDUCE': '减仓',
'CANCEL_PENDING': '取消挂单',
'HOLD': '观望'
}
decision_text = decision_map.get(decision_type, decision_type)
# 根据决策类型设置颜色
color_map = {
'OPEN': 'green',
'ADD': 'green',
'CLOSE': 'orange',
'REDUCE': 'orange',
'CANCEL_PENDING': 'red',
'HOLD': 'gray'
}
color = color_map.get(decision_type, 'blue')
# 构建标题 - 添加 [决策] 前缀区分
title = f"[决策] {account_type} {symbol} 交易决策: {decision_text}"
# 获取最佳信号用于显示
best_signal = self._get_signal_for_decision(market_signal, decision)
signal_confidence = best_signal.get('confidence', 0) if best_signal else 0
signal_action = best_signal.get('action', '') if best_signal else ''
signal_timeframe = best_signal.get('timeframe', best_signal.get('type', 'unknown')) if best_signal else 'unknown'
timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'}
timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe)
# 方向图标
if 'buy' in signal_action.lower() or 'long' in signal_action.lower():
action_icon = '🟢'
action_text = '做多'
elif 'sell' in signal_action.lower() or 'short' in signal_action.lower():
action_icon = '🔴'
action_text = '做空'
else:
action_icon = ''
action_text = '观望'
# 构建内容
content_parts = [
f"{action_icon} **市场信号**: {action_text} | {timeframe_text} | 信心度: {signal_confidence}%",
f"",
f"🎯 **交易决策**: {decision_text}",
f"",
]
# 添加决策详情
if decision_type != 'HOLD':
reasoning = decision.get('reasoning', '')
risk_analysis = decision.get('risk_analysis', '')
position_size = decision.get('position_size', 'N/A')
# 仓位图标
position_map = {'heavy': '🔥 重仓', 'medium': '📊 中仓', 'light': '🌱 轻仓', 'micro': '🌱 微仓'}
position_display = position_map.get(position_size, position_size)
# HTML转义避免特殊字符破坏HTML格式
import html
escaped_reasoning = html.escape(reasoning) if reasoning else ''
escaped_risk = html.escape(risk_analysis) if risk_analysis else ''
content_parts.extend([
f"📊 **仓位**: {position_display}",
f"💭 **决策理由**: {escaped_reasoning}",
])
if escaped_risk:
content_parts.append(f"⚠️ **风险**: {escaped_risk}")
# 添加价格信息(如果有)
quantity = decision.get('quantity', 0)
if isinstance(quantity, (int, float)) and quantity > 0:
leverage = self.paper_trading.leverage # 使用实际的杠杆配置
position_value = quantity * leverage
content_parts.append(f"💰 **持仓价值**: ${position_value:,.2f} (保证金 ${quantity:.2f})")
stop_loss = decision.get('stop_loss')
take_profit = decision.get('take_profit')
if stop_loss:
content_parts.append(f"🛑 **止损**: ${stop_loss}")
if take_profit:
content_parts.append(f"🎯 **止盈**: ${take_profit}")
# 取消挂单时显示要取消的订单
if decision_type == 'CANCEL_PENDING':
orders_to_cancel = decision.get('orders_to_cancel', [])
if orders_to_cancel:
content_parts.append(f"🚫 **取消订单**: {len(orders_to_cancel)}")
for order_id in orders_to_cancel[:3]: # 最多显示3个
content_parts.append(f" - {order_id}")
if len(orders_to_cancel) > 3:
content_parts.append(f" - ... 还有 {len(orders_to_cancel) - 3}")
else:
# HOLD 决策
reasoning = decision.get('reasoning', '综合评估后选择观望')
content_parts.append(f"💭 **理由**: {reasoning}")
content_parts.append("")
content_parts.append(f"⏰ 当前价格: ${current_price:,.2f}")
content = "\n".join(content_parts)
# 发送通知 - [决策] 发送到 paper_trading webhooktrading
if self.settings.feishu_enabled:
await self.feishu_paper.send_card(title, content, color)
if self.settings.telegram_enabled:
# Telegram 使用文本格式
message = f"{title}\n\n{content}"
await self.telegram.send_message(message)
if self.settings.dingtalk_enabled:
await self.dingtalk.send_action_card(title, content)
logger.info(f" 📤 已发送交易决策通知: {decision_text}")
except Exception as e:
logger.warning(f"发送交易决策通知失败: {e}")
import traceback
logger.debug(traceback.format_exc())
async def _send_signal_notification(self, market_signal: Dict[str, Any],
decision: Dict[str, Any], current_price: float,
prefix: str = "", hl_order_status: str = None):
@ -2579,6 +2401,16 @@ class CryptoAgent:
reward = entry - tp
if risk > 0:
stop_distance_pct = risk / entry * 100
take_distance_pct = reward / entry * 100
min_stop_pct = self.SIGNAL_MIN_STOP_LOSS_PCT.get(signal_type, 0.6)
min_take_pct = self.SIGNAL_MIN_TAKE_PROFIT_PCT.get(signal_type, 1.0)
if stop_distance_pct < min_stop_pct:
return False, f"{signal_type} 止损距离 {stop_distance_pct:.2f}% < {min_stop_pct:.1f}%,不执行"
if take_distance_pct < min_take_pct:
return False, f"{signal_type} 止盈距离 {take_distance_pct:.2f}% < {min_take_pct:.1f}%,不执行"
risk_reward_ratio = reward / risk
if risk_reward_ratio < min_rr:
return False, f"{signal_type} 盈亏比 {risk_reward_ratio:.2f} < {min_rr:.1f},不执行"
@ -2724,6 +2556,9 @@ class CryptoAgent:
if decision_type == 'HOLD':
reasoning = decision.get('reasoning', decision.get('reason', '观望'))
logger.info(f" Bitget 决策: {reasoning}")
await self._notify_signal_not_executed(
market_signal, decision, current_price, reason=f"[Bitget] {reasoning}", prefix="[Bitget]"
)
return
# 使用执行器
@ -3063,6 +2898,9 @@ class CryptoAgent:
if decision_type == 'HOLD':
reasoning = decision.get('reasoning', decision.get('reason', '观望'))
logger.info(f" Hyperliquid 决策: {reasoning}")
await self._notify_signal_not_executed(
market_signal, decision, current_price, reason=f"[Hyperliquid] {reasoning}", prefix="[Hyperliquid]"
)
return
# 使用执行器
@ -3557,12 +3395,14 @@ class CryptoAgent:
market_signal: Dict[str, Any],
decision: Dict[str, Any],
current_price: float,
reason: str = ""
reason: str = "",
prefix: str = ""
):
"""发送有信号但未执行交易的通知"""
try:
symbol = market_signal.get('symbol')
account_type = "📊"
title_prefix = f"{prefix} " if prefix else ""
signal = self._get_signal_for_decision(market_signal, decision)
if not signal:
@ -3600,7 +3440,7 @@ class CryptoAgent:
action_text = '观望'
# 构建标题
title = f"{account_type} {symbol} 信号未执行"
title = f"{title_prefix}{account_type} {symbol} 信号未执行"
# 构建内容
content_parts = [
@ -3631,26 +3471,75 @@ class CryptoAgent:
logger.warning(f"发送信号未执行通知失败: {e}")
async def analyze_once(self, symbol: str) -> Dict[str, Any]:
"""单次分析(用于测试或手动触发)"""
"""单次分析并返回市场信号与平台执行预览"""
data = self.exchange.get_multi_timeframe_data(symbol)
if not self._validate_data(data):
return {'error': '数据不完整'}
# 使用新架构:市场分析 + 交易决策
current_price = float(data['5m'].iloc[-1]['close'])
market_signal = await self.market_analyzer.analyze(
symbol, data,
symbols=self.symbols
)
positions, account = self._get_trading_state()
decision = await self.decision_maker.make_decision(
market_signal, positions, account
)
signals = market_signal.get('signals', [])
threshold = self.settings.crypto_llm_threshold * 100
valid_signals = [
signal for signal in signals
if signal.get('action') in {'buy', 'sell'} and signal.get('confidence', 0) >= threshold
]
execution_preview: Dict[str, Any] = {}
if self.settings.paper_trading_enabled:
paper_positions, paper_account, paper_pending = self._get_paper_trading_state()
paper_signal = self._select_signal_for_platform(valid_signals, 'PaperTrading')
execution_preview['PaperTrading'] = self._normalize_execution_decision(
self.execute_signal_with_rules(
self._build_execution_signal(symbol, paper_signal, current_price),
'PaperTrading',
paper_account,
paper_positions,
paper_pending,
),
paper_positions,
paper_pending,
) if paper_signal else {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
if self.hyperliquid:
hl_positions, hl_account, hl_pending = self._get_hyperliquid_trading_state()
hl_signal = self._select_signal_for_platform(valid_signals, 'Hyperliquid')
execution_preview['Hyperliquid'] = self._normalize_execution_decision(
self.execute_signal_with_rules(
self._build_execution_signal(symbol, hl_signal, current_price),
'Hyperliquid',
hl_account,
hl_positions,
hl_pending,
),
hl_positions,
hl_pending,
) if hl_signal else {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
if self.bitget:
bg_positions, bg_account, bg_pending = self._get_bitget_trading_state()
bg_signal = self._select_signal_for_platform(valid_signals, 'Bitget')
execution_preview['Bitget'] = self._normalize_execution_decision(
self.execute_signal_with_rules(
self._build_execution_signal(symbol, bg_signal, current_price),
'Bitget',
bg_account,
bg_positions,
bg_pending,
),
bg_positions,
bg_pending,
) if bg_signal else {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
return {
'market_signal': market_signal,
'trading_decision': decision
'execution_preview': execution_preview,
}
def get_status(self) -> Dict[str, Any]:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2211,7 +2211,17 @@ class PaperTradingService:
def _get_current_price(self, symbol: str) -> float:
"""获取交易对当前价格"""
try:
from app.services.price_monitor_service import get_price_monitor_service
from app.services.bitget_service import bitget_service
cached_price = get_price_monitor_service().get_latest_price(symbol)
if cached_price:
return float(cached_price)
price = bitget_service.get_current_price(symbol)
if price:
return float(price)
ticker = bitget_service.get_ticker(symbol)
if ticker and 'lastPrice' in ticker:
return float(ticker['lastPrice'])

View File

@ -0,0 +1,177 @@
"""
MarketSignalAnalyzer 回归测试
覆盖重点
- 关键位聚合后保留优先级同时数值数组继续按价格顺序返回
- lane 级别的最小盈亏比和止盈止损距离门槛
- Fib 摘要与可交易区输出
"""
import importlib.util
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
import pandas as pd
def load_market_signal_analyzer_class():
analyzer_path = Path(__file__).resolve().parents[1] / "app" / "crypto_agent" / "market_signal_analyzer.py"
if "app" not in sys.modules:
app_pkg = types.ModuleType("app")
app_pkg.__path__ = [str(analyzer_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(analyzer_path.parents[1] / "services")]
sys.modules["app.services"] = services_pkg
if "app.crypto_agent" not in sys.modules:
crypto_pkg = types.ModuleType("app.crypto_agent")
crypto_pkg.__path__ = [str(analyzer_path.parent)]
sys.modules["app.crypto_agent"] = crypto_pkg
if "app.utils" not in sys.modules:
utils_pkg = types.ModuleType("app.utils")
utils_pkg.__path__ = [str(analyzer_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
llm_module = types.ModuleType("app.services.llm_service")
llm_module.llm_service = MagicMock()
sys.modules["app.services.llm_service"] = llm_module
news_module = types.ModuleType("app.services.news_service")
news_module.get_news_service = MagicMock(return_value=MagicMock())
sys.modules["app.services.news_service"] = news_module
bitget_module = types.ModuleType("app.services.bitget_service")
bitget_module.bitget_service = MagicMock()
sys.modules["app.services.bitget_service"] = bitget_module
module_name = "app.crypto_agent.market_signal_analyzer_test"
spec = importlib.util.spec_from_file_location(module_name, analyzer_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module.MarketSignalAnalyzer
def make_analyzer():
return load_market_signal_analyzer_class()()
def make_frame(closes, lows, highs, ema20, atr=10.0):
return pd.DataFrame(
{
"close": closes,
"low": lows,
"high": highs,
"ema20": [ema20] * len(closes),
"atr": [atr] * len(closes),
"volume": [1000 + i * 10 for i in range(len(closes))],
}
)
def test_derive_key_levels_keeps_numeric_arrays_price_sorted_and_builds_trade_zones():
analyzer = make_analyzer()
data = {
"30m": make_frame(
closes=[100, 101, 102, 103, 104, 105, 106, 104, 103, 102, 101, 100, 99, 100, 101, 102, 103, 104, 105, 106],
lows=[98, 99, 100, 101, 102, 103, 104, 102, 101, 100, 99, 98, 97, 98, 99, 100, 101, 102, 103, 104],
highs=[101, 102, 103, 104, 105, 106, 107, 105, 104, 103, 102, 101, 100, 101, 102, 103, 104, 105, 106, 107],
ema20=102,
),
"1h": make_frame(
closes=[110 + i for i in range(20)],
lows=[108 + i for i in range(20)],
highs=[111 + i for i in range(20)],
ema20=118,
),
"4h": make_frame(
closes=[120 + i for i in range(12)],
lows=[118 + i for i in range(12)],
highs=[121 + i for i in range(12)],
ema20=126,
),
}
fib_context = {
"support_details": [
{"price": 124.2, "ratio": 0.618, "distance_pct": 0.4, "kind": "retracement"},
{"price": 122.8, "ratio": 0.5, "distance_pct": 1.1, "kind": "retracement"},
],
"resistance_details": [
{"price": 131.4, "ratio": 1.272, "distance_pct": 1.0, "kind": "extension"},
{"price": 133.1, "ratio": 1.618, "distance_pct": 2.3, "kind": "extension"},
],
}
levels = analyzer._derive_key_levels(
data=data,
range_zone={"support_level": 123.6, "resistance_level": 130.2},
fib_context=fib_context,
current_price=128.0,
)
assert levels["support"] == sorted(levels["support"], reverse=True)
assert levels["resistance"] == sorted(levels["resistance"])
assert levels["best_long_zone"]["center"] == levels["support_priority"][0]["price"]
assert levels["best_short_zone"]["center"] == levels["resistance_priority"][0]["price"]
assert levels["best_long_zone"]["low"] < levels["best_long_zone"]["high"]
assert any("Fib0.618" in item["sources"] for item in levels["support_priority"])
def test_lane_specific_risk_reward_and_distance_thresholds():
analyzer = make_analyzer()
signal = {
"action": "sell",
"entry_price": 100.0,
"stop_loss": 101.0,
"take_profit": 98.0,
}
assert analyzer._meets_min_risk_reward(signal, "short_term") is True
assert analyzer._meets_min_risk_reward(signal, "medium_term") is True
assert analyzer._meets_min_price_distance(signal, "short_term") is True
assert analyzer._meets_min_price_distance(signal, "medium_term") is True
tighter_signal = {
"action": "sell",
"entry_price": 100.0,
"stop_loss": 101.0,
"take_profit": 98.4,
}
assert analyzer._meets_min_risk_reward(tighter_signal, "short_term") is True
assert analyzer._meets_min_risk_reward(tighter_signal, "medium_term") is False
assert analyzer._meets_min_price_distance(tighter_signal, "short_term") is True
assert analyzer._meets_min_price_distance(tighter_signal, "medium_term") is False
def test_fibonacci_context_marks_kind_and_trade_zone():
analyzer = make_analyzer()
df = pd.DataFrame(
{
"close": [100, 102, 105, 108, 112, 116, 120, 118, 116, 114, 112, 110, 108, 106, 104, 102, 101, 100, 99, 98],
"high": [101, 103, 106, 109, 113, 117, 121, 119, 117, 115, 113, 111, 109, 107, 105, 103, 102, 101, 100, 99],
"low": [99, 101, 104, 107, 111, 115, 119, 117, 115, 113, 111, 109, 107, 105, 103, 101, 100, 99, 98, 97],
"ema20": [108] * 20,
"atr": [2.5] * 20,
"volume": [1000, 1100, 1300, 1500, 1700, 1900, 2100, 1800, 1600, 1500, 1400, 1300, 1200, 1100, 1050, 1000, 980, 960, 940, 920],
}
)
result = analyzer._calculate_fibonacci_levels(df, current_price=104.0, lookback=20)
assert result is not None
assert result["trade_zone"]
assert any(level["kind"] in {"retracement", "extension"} for level in result["support_details"] + result["resistance_details"])
formatted = analyzer._format_fib_levels(result["support_details"] or result["resistance_details"])
assert "回撤Fib" in formatted or "扩展Fib" in formatted

View File

@ -282,9 +282,6 @@
</div>
<div class="header-actions">
<button class="btn btn-secondary" @click="refreshData">刷新</button>
<button class="btn btn-danger" @click="resetAccount" v-if="currentTab === 'positions' && openPositions.length > 0">
重置账户
</button>
<!-- 管理员菜单 -->
<div class="admin-dropdown" v-if="adminMode">
<button class="btn btn-secondary" @click="toggleAdminMenu">管理 ▾</button>
@ -393,6 +390,8 @@
<th>数量</th>
<th>入场价</th>
<th>当前价</th>
<th>止损</th>
<th>止盈</th>
<th>杠杆</th>
<th>保证金</th>
<th>未实现盈亏</th>
@ -408,16 +407,18 @@
{{ order.side === 'long' ? '做多' : '做空' }}
</span>
</td>
<td>{{ order.quantity ? order.quantity.toFixed(4) : '0.0000' }}</td>
<td>{{ order.entry_price ? '$' + order.entry_price.toFixed(2) : '$0.00' }}</td>
<td>{{ order.current_price ? '$' + order.current_price.toFixed(2) : '-' }}</td>
<td>{{ formatNumber(order.quantity, 4) }}</td>
<td>{{ formatCurrency(order.display_entry_price) }}</td>
<td>{{ formatCurrency(order.current_price) }}</td>
<td>{{ formatCurrency(order.stop_loss) }}</td>
<td>{{ formatCurrency(order.take_profit) }}</td>
<td>{{ order.leverage || 0 }}x</td>
<td>{{ order.margin ? '$' + order.margin.toFixed(2) : '$0.00' }}</td>
<td>{{ formatCurrency(order.margin) }}</td>
<td :class="order.unrealized_pnl >= 0 ? 'text-success' : 'text-error'">
{{ order.unrealized_pnl >= 0 ? '+' : '' }}${{ order.unrealized_pnl ? order.unrealized_pnl.toFixed(2) : '0.00' }}
{{ formatSignedCurrency(order.unrealized_pnl) }}
</td>
<td :class="order.pnl_percent >= 0 ? 'text-success' : 'text-error'">
{{ order.pnl_percent >= 0 ? '+' : '' }}{{ order.pnl_percent ? order.pnl_percent.toFixed(2) : '0.00' }}%
{{ formatSignedPercent(order.pnl_percent) }}
</td>
<td>
<button class="btn btn-danger btn-small" @click="closeOrder(order)">平仓</button>
@ -659,7 +660,22 @@
},
computed: {
openPositions() {
return this.orders.filter(o => o.status === 'open');
return this.orders
.filter(order => order.status === 'open')
.map(order => {
const displayEntryPrice = this.getDisplayEntryPrice(order);
const currentPrice = this.resolveOrderCurrentPrice(order);
const pnlPercent = this.calculateOpenOrderPnlPercent(order, currentPrice, displayEntryPrice);
const unrealizedPnl = this.calculateOpenOrderPnlAmount(order, pnlPercent);
return {
...order,
display_entry_price: displayEntryPrice,
current_price: currentPrice || null,
pnl_percent: pnlPercent,
unrealized_pnl: unrealizedPnl
};
});
},
pendingOrders() {
return this.orders.filter(o => o.status === 'pending');
@ -741,14 +757,8 @@
async fetchOrders() {
try {
const response = await axios.get('/api/trading/orders');
console.log('API Response:', response.data);
if (response.data.success) {
this.orders = response.data.orders || [];
console.log('Orders loaded:', this.orders.length);
console.log('Orders data:', this.orders);
console.log('Open positions:', this.orders.filter(o => o.status === 'open'));
console.log('Pending orders:', this.orders.filter(o => o.status === 'pending'));
console.log('Order history:', this.orders.filter(o => o.status === 'closed'));
}
} catch (error) {
console.error('获取订单失败:', error);
@ -759,7 +769,10 @@
if (!confirm('确定要平仓吗?')) return;
try {
const response = await axios.post(`/api/trading/orders/${order.order_id}/close`);
const exitPrice = this.resolveOrderCurrentPrice(order) || this.getDisplayEntryPrice(order);
const response = await axios.post(`/api/trading/orders/${order.order_id}/close`, {
exit_price: exitPrice
});
if (response.data.success) {
await this.refreshData();
alert('平仓成功');
@ -806,23 +819,6 @@
}
},
async resetAccount() {
if (!confirm('确定要重置账户吗?这将清除所有持仓和订单!')) return;
try {
const response = await axios.post('/api/trading/account/reset');
if (response.data.success) {
await this.refreshData();
alert('账户重置成功');
} else {
alert('重置失败: ' + (response.data.message || '未知错误'));
}
} catch (error) {
console.error('重置账户失败:', error);
alert('重置失败: ' + (error.response?.data?.detail || error.message));
}
},
async sendReport() {
this.sendingReport = true;
try {
@ -851,6 +847,73 @@
});
},
formatCurrency(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return '-';
return `$${number.toFixed(2)}`;
},
formatSignedCurrency(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '$0.00';
return `${number >= 0 ? '+' : '-'}$${Math.abs(number).toFixed(2)}`;
},
formatSignedPercent(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '0.00%';
return `${number >= 0 ? '+' : '-'}${Math.abs(number).toFixed(2)}%`;
},
formatNumber(value, digits = 2) {
const number = Number(value);
if (!Number.isFinite(number)) return (0).toFixed(digits);
return number.toFixed(digits);
},
getDisplayEntryPrice(order) {
const price = Number(order.filled_price || order.entry_price || 0);
return Number.isFinite(price) ? price : 0;
},
resolveOrderCurrentPrice(order) {
const latest = Number(this.latestPrices?.[order.symbol]);
if (Number.isFinite(latest) && latest > 0) {
return latest;
}
const current = Number(order.current_price);
if (Number.isFinite(current) && current > 0) {
return current;
}
return 0;
},
calculateOpenOrderPnlPercent(order, currentPrice, entryPrice) {
if (!currentPrice || !entryPrice) {
return Number(order.pnl_percent || 0);
}
if (order.side === 'long') {
return ((currentPrice - entryPrice) / entryPrice) * 100;
}
if (order.side === 'short') {
return ((entryPrice - currentPrice) / entryPrice) * 100;
}
return Number(order.pnl_percent || 0);
},
calculateOpenOrderPnlAmount(order, pnlPercent) {
const positionValue = Number(order.quantity || 0);
if (!Number.isFinite(positionValue) || positionValue <= 0) {
return Number(order.unrealized_pnl || 0);
}
return positionValue * pnlPercent / 100;
},
getCloseReason(reason) {
const map = {
'manual': '手动',