feat: 添加前端界面和市场分析模块
- 新增 Vue 3 + Vuetify 前端界面 - 新增市场分析模块 (market/) - 更新主服务器和路由 - 更新 MT5 EA 文件 - 添加 .gitignore 排除临时文件
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
行情分析模块
|
||||
"""
|
||||
|
||||
from .store import MarketStore
|
||||
from .pivot_detector import PivotDetector
|
||||
from .monitor import PivotMonitor
|
||||
from .trend_analyzer import TrendAnalyzer
|
||||
|
||||
__all__ = ['MarketStore', 'PivotDetector', 'PivotMonitor', 'TrendAnalyzer']
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
K线合并模块
|
||||
处理增量K线数据的合并逻辑
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from .store import KlineData
|
||||
|
||||
|
||||
class KlineMerger:
|
||||
"""K线合并器"""
|
||||
|
||||
@staticmethod
|
||||
def merge_klines(existing: List[KlineData], new_klines: List[KlineData]) -> List[KlineData]:
|
||||
"""
|
||||
合并K线数据
|
||||
|
||||
Args:
|
||||
existing: 现有K线数据
|
||||
new_klines: 新增K线数据
|
||||
|
||||
Returns:
|
||||
合并后的K线数据
|
||||
"""
|
||||
if not new_klines:
|
||||
return existing
|
||||
|
||||
if not existing:
|
||||
return new_klines
|
||||
|
||||
# 使用字典来去重,以时间戳为key
|
||||
kline_dict = {}
|
||||
|
||||
# 添加现有数据
|
||||
for k in existing:
|
||||
ts = KlineMerger._normalize_timestamp(k.timestamp)
|
||||
kline_dict[ts] = k
|
||||
|
||||
# 添加或更新新数据
|
||||
for k in new_klines:
|
||||
ts = KlineMerger._normalize_timestamp(k.timestamp)
|
||||
kline_dict[ts] = k
|
||||
|
||||
# 按时间排序
|
||||
merged = sorted(kline_dict.values(), key=lambda x: KlineMerger._normalize_timestamp(x.timestamp))
|
||||
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _normalize_timestamp(ts) -> str:
|
||||
"""标准化时间戳"""
|
||||
if isinstance(ts, datetime):
|
||||
return ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(ts)
|
||||
|
||||
@staticmethod
|
||||
def detect_gaps(klines: List[KlineData], period: str) -> List[Dict]:
|
||||
"""
|
||||
检测K线数据缺口
|
||||
|
||||
Args:
|
||||
klines: K线数据
|
||||
period: 周期
|
||||
|
||||
Returns:
|
||||
缺口列表
|
||||
"""
|
||||
if len(klines) < 2:
|
||||
return []
|
||||
|
||||
# 各周期对应的分钟数
|
||||
period_minutes = {
|
||||
'H4': 240,
|
||||
'H1': 60,
|
||||
'M15': 15,
|
||||
'M5': 5,
|
||||
'M1': 1
|
||||
}
|
||||
|
||||
interval = period_minutes.get(period, 1)
|
||||
gaps = []
|
||||
|
||||
for i in range(1, len(klines)):
|
||||
prev_ts = KlineMerger._parse_timestamp(klines[i-1].timestamp)
|
||||
curr_ts = KlineMerger._parse_timestamp(klines[i].timestamp)
|
||||
|
||||
if prev_ts and curr_ts:
|
||||
expected_diff = interval * 60 # 秒
|
||||
actual_diff = (curr_ts - prev_ts).total_seconds()
|
||||
|
||||
# 如果实际差值大于预期的1.5倍,认为有缺口
|
||||
if actual_diff > expected_diff * 1.5:
|
||||
gaps.append({
|
||||
"start": klines[i-1].timestamp,
|
||||
"end": klines[i].timestamp,
|
||||
"missing_bars": int(actual_diff / expected_diff) - 1
|
||||
})
|
||||
|
||||
return gaps
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(ts):
|
||||
"""解析时间戳"""
|
||||
if isinstance(ts, datetime):
|
||||
return ts
|
||||
|
||||
if isinstance(ts, str):
|
||||
try:
|
||||
return datetime.strptime(ts, "%Y-%m-%d %H:%M:%S")
|
||||
except:
|
||||
try:
|
||||
return datetime.strptime(ts, "%Y-%m-%d %H:%M")
|
||||
except:
|
||||
return None
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
转折点监控模块
|
||||
实时监控价格与转折点的接近程度,并通过WebSocket推送提醒
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Set
|
||||
from datetime import datetime
|
||||
import threading
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from .store import MarketStore, normalize_symbol
|
||||
from .pivot_detector import PivotDetector
|
||||
from .pending_orders import PendingOrderManager
|
||||
|
||||
|
||||
# 交易配置
|
||||
class TradeConfig:
|
||||
"""交易配置"""
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = True # 是否启用自动生成
|
||||
|
||||
# 默认配置
|
||||
self.default_volume = 0.01 # 默认手数
|
||||
self.default_sl_offset = 0.05 # 默认止损偏移(固定点数)
|
||||
|
||||
# 按品种配置: {symbol: {"volume": 0.01, "sl_offset": 0.05}}
|
||||
self.symbol_config = {
|
||||
"GOLD#": {"volume": 0.01, "sl_offset": 0.5},
|
||||
"OILCASH#": {"volume": 0.01, "sl_offset": 0.05},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def get_symbol_config(self, symbol: str) -> Dict:
|
||||
"""获取品种配置,如果未配置则返回默认值"""
|
||||
symbol = symbol.upper()
|
||||
if symbol in self.symbol_config:
|
||||
config = self.symbol_config[symbol]
|
||||
return {
|
||||
"volume": config.get("volume", self.default_volume),
|
||||
"sl_offset": config.get("sl_offset", self.default_sl_offset)
|
||||
}
|
||||
return {
|
||||
"volume": self.default_volume,
|
||||
"sl_offset": self.default_sl_offset
|
||||
}
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"default_volume": self.default_volume,
|
||||
"default_sl_offset": self.default_sl_offset,
|
||||
"symbol_config": self.symbol_config
|
||||
}
|
||||
|
||||
def update(self, data: Dict):
|
||||
if "enabled" in data:
|
||||
self.enabled = bool(data["enabled"])
|
||||
if "default_volume" in data:
|
||||
self.default_volume = float(data["default_volume"])
|
||||
if "default_sl_offset" in data:
|
||||
self.default_sl_offset = float(data["default_sl_offset"])
|
||||
if "symbol_config" in data:
|
||||
self.symbol_config = data["symbol_config"]
|
||||
|
||||
|
||||
class PivotMonitor:
|
||||
"""转折点监控器"""
|
||||
|
||||
def __init__(self, store: MarketStore, detector: PivotDetector,
|
||||
pending_orders: PendingOrderManager = None):
|
||||
self.store = store
|
||||
self.detector = detector
|
||||
self.pending_orders = pending_orders
|
||||
self.trade_config = TradeConfig.get_instance()
|
||||
|
||||
# WebSocket连接管理
|
||||
self._ws_clients: Set = set()
|
||||
self._ws_lock = threading.Lock()
|
||||
|
||||
# 已提醒的转折点(避免重复提醒)
|
||||
# 结构: {(symbol, period, timestamp, price): datetime}
|
||||
self._alerted_pivots: Dict[tuple, datetime] = {}
|
||||
self._alert_lock = threading.Lock()
|
||||
|
||||
# 提醒冷却时间(秒)
|
||||
self.alert_cooldown = 300 # 5分钟内同一转折点不重复提醒
|
||||
|
||||
print("[PivotMonitor] 转折点监控器已初始化")
|
||||
|
||||
def check_and_alert(self, symbol: str, current_price: float) -> List[Dict]:
|
||||
"""
|
||||
检查价格是否接近转折点,并发送提醒
|
||||
|
||||
Args:
|
||||
symbol: 交易品种
|
||||
current_price: 当前价格
|
||||
|
||||
Returns:
|
||||
接近的转折点列表
|
||||
"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
|
||||
# 检查是否接近转折点
|
||||
near_pivots = self.detector.check_near_pivot(symbol, current_price)
|
||||
|
||||
if not near_pivots:
|
||||
return []
|
||||
|
||||
# 过滤已提醒过的转折点
|
||||
new_alerts = []
|
||||
current_time = datetime.now()
|
||||
|
||||
with self._alert_lock:
|
||||
for pivot in near_pivots:
|
||||
key = (
|
||||
pivot['symbol'],
|
||||
pivot['period'],
|
||||
pivot['timestamp'],
|
||||
pivot['price']
|
||||
)
|
||||
|
||||
# 检查是否已提醒过
|
||||
if key in self._alerted_pivots:
|
||||
last_alert = self._alerted_pivots[key]
|
||||
elapsed = (current_time - last_alert).total_seconds()
|
||||
|
||||
# 如果在冷却时间内,跳过
|
||||
if elapsed < self.alert_cooldown:
|
||||
continue
|
||||
|
||||
# 记录提醒时间
|
||||
self._alerted_pivots[key] = current_time
|
||||
|
||||
# 构建提醒消息
|
||||
is_breakthrough = pivot.get('is_breakthrough', False)
|
||||
alert_type = pivot.get('alert_type', '')
|
||||
period = pivot['period']
|
||||
|
||||
# 根据类型生成不同的消息
|
||||
if is_breakthrough:
|
||||
if 'high' in alert_type:
|
||||
message = f"{pivot['symbol']} {period} 已突破高点 {pivot['price']}, 当前价格 {pivot['current_price']}"
|
||||
else:
|
||||
message = f"{pivot['symbol']} {period} 已突破低点 {pivot['price']}, 当前价格 {pivot['current_price']}"
|
||||
else:
|
||||
if 'high' in alert_type:
|
||||
message = f"{pivot['symbol']} {period} 接近高点 {pivot['price']}, 当前价格 {pivot['current_price']}, 距离 {pivot['distance_pct']}%"
|
||||
else:
|
||||
message = f"{pivot['symbol']} {period} 接近低点 {pivot['price']}, 当前价格 {pivot['current_price']}, 距离 {pivot['distance_pct']}%"
|
||||
|
||||
alert = {
|
||||
"type": "pivot_alert",
|
||||
"symbol": pivot['symbol'],
|
||||
"period": period,
|
||||
"direction": pivot['direction'],
|
||||
"pivot_price": pivot['price'],
|
||||
"current_price": pivot['current_price'],
|
||||
"distance_pct": pivot['distance_pct'],
|
||||
"threshold_pct": pivot['threshold_pct'],
|
||||
"timestamp": current_time.isoformat(),
|
||||
"alert_type": alert_type,
|
||||
"is_breakthrough": is_breakthrough,
|
||||
"message": message
|
||||
}
|
||||
|
||||
# M1和M5周期接近转折点时,自动生成交易指令
|
||||
pending_order = None
|
||||
if period in ['M1', 'M5'] and not is_breakthrough:
|
||||
pending_order = self._auto_generate_order(pivot, current_time)
|
||||
|
||||
# 如果生成了订单,加入通知中
|
||||
if pending_order:
|
||||
alert["pending_order"] = pending_order
|
||||
|
||||
new_alerts.append(alert)
|
||||
|
||||
# 异步推送WebSocket消息
|
||||
self._broadcast_alert(alert)
|
||||
|
||||
# 清理过期的提醒记录
|
||||
self._cleanup_alerted()
|
||||
|
||||
return new_alerts
|
||||
|
||||
def _auto_generate_order(self, pivot: Dict, current_time: datetime) -> Optional[Dict]:
|
||||
"""
|
||||
M1周期接近转折点时,自动生成交易指令
|
||||
|
||||
Args:
|
||||
pivot: 转折点信息
|
||||
current_time: 当前时间
|
||||
|
||||
Returns:
|
||||
生成的订单信息,包含order_id
|
||||
"""
|
||||
if not self.pending_orders:
|
||||
return None
|
||||
|
||||
if not self.trade_config.enabled:
|
||||
return None
|
||||
|
||||
symbol = pivot['symbol']
|
||||
current_price = pivot['current_price']
|
||||
pivot_price = pivot['price']
|
||||
direction = pivot['direction']
|
||||
alert_type = pivot['alert_type']
|
||||
|
||||
# 只处理"接近"类型(near_high, near_low)
|
||||
if not alert_type.startswith('near_'):
|
||||
return None
|
||||
|
||||
# 获取品种配置
|
||||
config = self.trade_config.get_symbol_config(symbol)
|
||||
volume = config["volume"]
|
||||
sl_offset = config["sl_offset"] # 固定点数偏移
|
||||
|
||||
order = None
|
||||
|
||||
if alert_type == 'near_low':
|
||||
# 接近低点 → 买入
|
||||
# 止损 = 低点 - 配置的偏移
|
||||
sl = pivot_price - sl_offset
|
||||
# 止盈 = 最近的高点
|
||||
tp = self._find_nearest_pivot_price(symbol, 'high', current_price)
|
||||
|
||||
if tp and tp > current_price:
|
||||
order = {
|
||||
"symbol": symbol,
|
||||
"action": "b", # 买入
|
||||
"price": current_price,
|
||||
"mount": volume,
|
||||
"sl": round(sl, 2),
|
||||
"tp": round(tp, 2),
|
||||
"reason": f"M1接近低点{pivot_price:.2f},建议买入,止损{sl:.2f},止盈{tp:.2f}",
|
||||
"source": "auto_pivot_m1",
|
||||
"pivot_price": pivot_price,
|
||||
"generated_at": current_time.isoformat()
|
||||
}
|
||||
|
||||
elif alert_type == 'near_high':
|
||||
# 接近高点 → 卖出
|
||||
# 止损 = 高点 + 配置的偏移
|
||||
sl = pivot_price + sl_offset
|
||||
# 止盈 = 最近的低点
|
||||
tp = self._find_nearest_pivot_price(symbol, 'low', current_price)
|
||||
|
||||
if tp and tp < current_price:
|
||||
order = {
|
||||
"symbol": symbol,
|
||||
"action": "s", # 卖出
|
||||
"price": current_price,
|
||||
"mount": volume,
|
||||
"sl": round(sl, 2),
|
||||
"tp": round(tp, 2),
|
||||
"reason": f"M1接近高点{pivot_price:.2f},建议卖出,止损{sl:.2f},止盈{tp:.2f}",
|
||||
"source": "auto_pivot_m1",
|
||||
"pivot_price": pivot_price,
|
||||
"generated_at": current_time.isoformat()
|
||||
}
|
||||
|
||||
if order:
|
||||
order_id = self.pending_orders.add_order(order)
|
||||
print(f"[PivotMonitor] 自动生成交易指令: {order_id} - {order['action']} {symbol} @ {current_price}")
|
||||
# 返回订单信息(包含order_id)
|
||||
order["order_id"] = order_id
|
||||
return order
|
||||
|
||||
return None
|
||||
|
||||
def _find_nearest_pivot_price(self, symbol: str, direction: str,
|
||||
current_price: float) -> Optional[float]:
|
||||
"""
|
||||
找到离当前价格最近的转折点价格
|
||||
|
||||
Args:
|
||||
symbol: 交易品种
|
||||
direction: 'high' 或 'low'
|
||||
current_price: 当前价格
|
||||
|
||||
Returns:
|
||||
最近的转折点价格,如果没有返回None
|
||||
"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
nearest_price = None
|
||||
min_distance = float('inf')
|
||||
|
||||
with self.detector._lock:
|
||||
for period in self.detector._pivots[symbol]:
|
||||
pivots = self.detector._pivots[symbol][period]
|
||||
|
||||
for pivot in pivots:
|
||||
if pivot.direction != direction:
|
||||
continue
|
||||
|
||||
# 对于高点,只考虑价格高于当前价的
|
||||
# 对于低点,只考虑价格低于当前价的
|
||||
if direction == 'high' and pivot.price <= current_price:
|
||||
continue
|
||||
if direction == 'low' and pivot.price >= current_price:
|
||||
continue
|
||||
|
||||
distance = abs(pivot.price - current_price)
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
nearest_price = pivot.price
|
||||
|
||||
return nearest_price
|
||||
|
||||
def _broadcast_new_order(self, order_id: str, order: Dict) -> None:
|
||||
"""广播新订单通知"""
|
||||
message = json.dumps({
|
||||
"type": "new_order",
|
||||
"order_id": order_id,
|
||||
"order": order
|
||||
})
|
||||
|
||||
with self._ws_lock:
|
||||
clients = list(self._ws_clients)
|
||||
|
||||
for client in clients:
|
||||
try:
|
||||
asyncio.create_task(self._send_to_client(client, message))
|
||||
except Exception as e:
|
||||
print(f"[PivotMonitor] 发送新订单通知失败: {e}")
|
||||
|
||||
def _cleanup_alerted(self):
|
||||
"""清理过期的提醒记录"""
|
||||
current_time = datetime.now()
|
||||
|
||||
with self._alert_lock:
|
||||
keys_to_remove = []
|
||||
for key, alert_time in self._alerted_pivots.items():
|
||||
elapsed = (current_time - alert_time).total_seconds()
|
||||
if elapsed > self.alert_cooldown * 2:
|
||||
keys_to_remove.append(key)
|
||||
|
||||
for key in keys_to_remove:
|
||||
del self._alerted_pivots[key]
|
||||
|
||||
def _broadcast_alert(self, alert: Dict):
|
||||
"""广播提醒到所有WebSocket客户端"""
|
||||
message = json.dumps(alert)
|
||||
|
||||
with self._ws_lock:
|
||||
clients = list(self._ws_clients)
|
||||
|
||||
# 在事件循环中发送消息
|
||||
for client in clients:
|
||||
try:
|
||||
asyncio.create_task(self._send_to_client(client, message))
|
||||
except Exception as e:
|
||||
print(f"[PivotMonitor] 发送WebSocket消息失败: {e}")
|
||||
|
||||
async def _send_to_client(self, client, message: str):
|
||||
"""发送消息到客户端"""
|
||||
try:
|
||||
await client.send_text(message)
|
||||
except Exception as e:
|
||||
print(f"[PivotMonitor] 发送消息到客户端失败: {e}")
|
||||
# 移除失效的客户端
|
||||
with self._ws_lock:
|
||||
self._ws_clients.discard(client)
|
||||
|
||||
def add_ws_client(self, client):
|
||||
"""添加WebSocket客户端"""
|
||||
with self._ws_lock:
|
||||
self._ws_clients.add(client)
|
||||
print(f"[PivotMonitor] WebSocket客户端已连接, 当前连接数: {len(self._ws_clients)}")
|
||||
|
||||
def remove_ws_client(self, client):
|
||||
"""移除WebSocket客户端"""
|
||||
with self._ws_lock:
|
||||
self._ws_clients.discard(client)
|
||||
print(f"[PivotMonitor] WebSocket客户端已断开, 当前连接数: {len(self._ws_clients)}")
|
||||
|
||||
def get_ws_client_count(self) -> int:
|
||||
"""获取WebSocket客户端数量"""
|
||||
with self._ws_lock:
|
||||
return len(self._ws_clients)
|
||||
|
||||
def clear_symbol(self, symbol: str):
|
||||
"""清除某个Symbol的提醒记录"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
|
||||
with self._alert_lock:
|
||||
keys_to_remove = [k for k in self._alerted_pivots if k[0] == symbol]
|
||||
for key in keys_to_remove:
|
||||
del self._alerted_pivots[key]
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""获取监控状态"""
|
||||
with self._alert_lock:
|
||||
alerted_count = len(self._alerted_pivots)
|
||||
|
||||
return {
|
||||
"ws_clients": self.get_ws_client_count(),
|
||||
"alerted_pivots": alerted_count,
|
||||
"alert_cooldown": self.alert_cooldown
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
待确认订单管理模块
|
||||
存储交易员待确认的交易指令
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import List, Dict, Optional, Callable
|
||||
from datetime import datetime, timedelta
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
|
||||
class PendingOrderManager:
|
||||
"""待确认订单管理器"""
|
||||
|
||||
# 订单超时时间(秒)
|
||||
ORDER_TIMEOUT = 180 # 3分钟
|
||||
|
||||
def __init__(self):
|
||||
# 待确认订单: {SYMBOL: [Order, ...]}
|
||||
self._pending_orders = defaultdict(list)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# 订单ID到订单的映射
|
||||
self._order_by_id = {}
|
||||
|
||||
# 订单确认回调(确认后将订单加入交易队列)
|
||||
self._confirm_callback: Optional[Callable] = None
|
||||
|
||||
# 启动超时清理线程
|
||||
self._start_cleanup_thread()
|
||||
|
||||
print("[PendingOrderManager] 待确认订单管理器已初始化")
|
||||
|
||||
def set_confirm_callback(self, callback: Callable):
|
||||
"""设置订单确认回调函数"""
|
||||
self._confirm_callback = callback
|
||||
|
||||
def _start_cleanup_thread(self):
|
||||
"""启动超时清理线程"""
|
||||
def cleanup_loop():
|
||||
while True:
|
||||
try:
|
||||
self._cleanup_expired_orders()
|
||||
except Exception as e:
|
||||
print(f"[PendingOrderManager] 清理线程异常: {e}")
|
||||
threading.Event().wait(10) # 每10秒检查一次
|
||||
|
||||
thread = threading.Thread(target=cleanup_loop, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _cleanup_expired_orders(self):
|
||||
"""清理超时订单"""
|
||||
current_time = datetime.now()
|
||||
expired_orders = []
|
||||
|
||||
with self._lock:
|
||||
for order_id, order in list(self._order_by_id.items()):
|
||||
created_at = datetime.fromisoformat(order['created_at'])
|
||||
elapsed = (current_time - created_at).total_seconds()
|
||||
|
||||
if elapsed > self.ORDER_TIMEOUT:
|
||||
expired_orders.append(order_id)
|
||||
|
||||
for order_id in expired_orders:
|
||||
order = self._order_by_id[order_id]
|
||||
symbol = order.get('symbol', 'UNKNOWN')
|
||||
|
||||
self._pending_orders[symbol] = [
|
||||
o for o in self._pending_orders[symbol] if o['order_id'] != order_id
|
||||
]
|
||||
del self._order_by_id[order_id]
|
||||
|
||||
print(f"[PendingOrderManager] 订单超时自动移除: {order_id}")
|
||||
|
||||
def add_order(self, order: Dict) -> str:
|
||||
"""
|
||||
添加待确认订单
|
||||
|
||||
Args:
|
||||
order: 订单信息
|
||||
|
||||
Returns:
|
||||
订单ID
|
||||
"""
|
||||
# 生成订单ID
|
||||
order_id = str(uuid.uuid4())[:8]
|
||||
|
||||
order_with_id = {
|
||||
**order,
|
||||
"order_id": order_id,
|
||||
"status": "pending",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"expires_at": (datetime.now() + timedelta(seconds=self.ORDER_TIMEOUT)).isoformat()
|
||||
}
|
||||
|
||||
symbol = order.get('symbol', 'UNKNOWN')
|
||||
|
||||
with self._lock:
|
||||
self._pending_orders[symbol].append(order_with_id)
|
||||
self._order_by_id[order_id] = order_with_id
|
||||
|
||||
print(f"[PendingOrderManager] 添加待确认订单: {order_id} {symbol} {order.get('action')}")
|
||||
|
||||
return order_id
|
||||
|
||||
def confirm_order(self, order_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
确认订单(交易员确认后调用)
|
||||
|
||||
Returns:
|
||||
确认后的订单,用于加入正式交易队列
|
||||
"""
|
||||
with self._lock:
|
||||
if order_id not in self._order_by_id:
|
||||
return None
|
||||
|
||||
order = self._order_by_id[order_id]
|
||||
symbol = order.get('symbol', 'UNKNOWN')
|
||||
|
||||
# 从待确认列表中移除
|
||||
self._pending_orders[symbol] = [
|
||||
o for o in self._pending_orders[symbol] if o['order_id'] != order_id
|
||||
]
|
||||
del self._order_by_id[order_id]
|
||||
|
||||
# 标记为已确认
|
||||
order['status'] = 'confirmed'
|
||||
order['confirmed_at'] = datetime.now().isoformat()
|
||||
|
||||
print(f"[PendingOrderManager] 订单已确认: {order_id}")
|
||||
|
||||
# 调用确认回调(将订单加入交易队列)
|
||||
if self._confirm_callback:
|
||||
try:
|
||||
self._confirm_callback(order)
|
||||
print(f"[PendingOrderManager] 订单已加入交易队列: {order_id}")
|
||||
except Exception as e:
|
||||
print(f"[PendingOrderManager] 加入交易队列失败: {e}")
|
||||
|
||||
return order
|
||||
|
||||
def reject_order(self, order_id: str) -> bool:
|
||||
"""
|
||||
拒绝订单(交易员点击放弃)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
with self._lock:
|
||||
if order_id not in self._order_by_id:
|
||||
return False
|
||||
|
||||
order = self._order_by_id[order_id]
|
||||
symbol = order.get('symbol', 'UNKNOWN')
|
||||
|
||||
# 从待确认列表中移除
|
||||
self._pending_orders[symbol] = [
|
||||
o for o in self._pending_orders[symbol] if o['order_id'] != order_id
|
||||
]
|
||||
del self._order_by_id[order_id]
|
||||
|
||||
print(f"[PendingOrderManager] 订单已拒绝: {order_id}")
|
||||
|
||||
return True
|
||||
|
||||
def get_pending_orders(self, symbol: str = None) -> List[Dict]:
|
||||
"""获取待确认订单列表"""
|
||||
with self._lock:
|
||||
if symbol:
|
||||
return list(self._pending_orders.get(symbol, []))
|
||||
else:
|
||||
# 返回所有
|
||||
orders = []
|
||||
for sym, order_list in self._pending_orders.items():
|
||||
orders.extend(order_list)
|
||||
return sorted(orders, key=lambda x: x['created_at'], reverse=True)
|
||||
|
||||
def get_order_by_id(self, order_id: str) -> Optional[Dict]:
|
||||
"""根据ID获取订单"""
|
||||
with self._lock:
|
||||
return self._order_by_id.get(order_id)
|
||||
|
||||
def get_pending_count(self, symbol: str = None) -> int:
|
||||
"""获取待确认订单数量"""
|
||||
with self._lock:
|
||||
if symbol:
|
||||
return len(self._pending_orders.get(symbol, []))
|
||||
return len(self._order_by_id)
|
||||
|
||||
def clear_all(self) -> int:
|
||||
"""清空所有待确认订单"""
|
||||
with self._lock:
|
||||
count = len(self._order_by_id)
|
||||
self._pending_orders.clear()
|
||||
self._order_by_id.clear()
|
||||
return count
|
||||
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
转折点检测模块
|
||||
识别K线的高点和低点(分型识别)
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
import threading
|
||||
|
||||
from .store import KlineData, normalize_symbol
|
||||
|
||||
|
||||
class PivotPoint:
|
||||
"""转折点数据结构"""
|
||||
|
||||
def __init__(self, symbol: str, period: str, timestamp, price: float,
|
||||
direction: str, strength: int = 3):
|
||||
self.symbol = normalize_symbol(symbol)
|
||||
self.period = period
|
||||
self.timestamp = timestamp
|
||||
self.price = price
|
||||
self.direction = direction # "high" 或 "low"
|
||||
self.strength = strength # 转折强度(左右各N根K线)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""转换为字典"""
|
||||
ts = self.timestamp
|
||||
if isinstance(ts, datetime):
|
||||
ts_str = ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
ts_str = str(ts)
|
||||
|
||||
return {
|
||||
"symbol": self.symbol,
|
||||
"period": self.period,
|
||||
"timestamp": ts_str,
|
||||
"price": self.price,
|
||||
"direction": self.direction,
|
||||
"strength": self.strength
|
||||
}
|
||||
|
||||
|
||||
class PivotDetector:
|
||||
"""转折点检测器"""
|
||||
|
||||
# 各周期接近阈值(千分比)
|
||||
THRESHOLDS = {
|
||||
'H4': 0.0015, # 千分之1.5
|
||||
'H1': 0.0015, # 千分之1.5
|
||||
'M15': 0.0015, # 千分之1.5
|
||||
'M5': 0.0005, # 千分之0.5
|
||||
'M1': 0.0002 # 千分之0.2
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# 存储转折点: {SYMBOL: {PERIOD: [PivotPoint, ...]}}
|
||||
self._pivots = defaultdict(lambda: defaultdict(list))
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# 默认转折强度(左右各N根K线)
|
||||
self.default_strength = 3
|
||||
|
||||
print("[PivotDetector] 转折点检测器已初始化")
|
||||
|
||||
def detect_pivots(self, symbol: str, period: str, klines: List[KlineData],
|
||||
strength: int = None) -> List[PivotPoint]:
|
||||
"""
|
||||
检测转折点
|
||||
|
||||
Args:
|
||||
symbol: 交易品种
|
||||
period: 周期
|
||||
klines: K线数据列表
|
||||
strength: 转折强度(左右各N根K线)
|
||||
|
||||
Returns:
|
||||
检测到的转折点列表
|
||||
"""
|
||||
if strength is None:
|
||||
strength = self.default_strength
|
||||
|
||||
if len(klines) < 2 * strength + 1:
|
||||
return []
|
||||
|
||||
pivots = []
|
||||
|
||||
# 遍历K线,检测分型
|
||||
for i in range(strength, len(klines) - strength):
|
||||
current = klines[i]
|
||||
|
||||
# 检查是否为高点(顶分型)
|
||||
is_high = True
|
||||
for j in range(1, strength + 1):
|
||||
if klines[i - j].high >= current.high or klines[i + j].high >= current.high:
|
||||
is_high = False
|
||||
break
|
||||
|
||||
if is_high:
|
||||
pivot = PivotPoint(
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
timestamp=current.timestamp,
|
||||
price=current.high,
|
||||
direction="high",
|
||||
strength=strength
|
||||
)
|
||||
pivots.append(pivot)
|
||||
|
||||
# 检查是否为低点(底分型)
|
||||
is_low = True
|
||||
for j in range(1, strength + 1):
|
||||
if klines[i - j].low <= current.low or klines[i + j].low <= current.low:
|
||||
is_low = False
|
||||
break
|
||||
|
||||
if is_low:
|
||||
pivot = PivotPoint(
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
timestamp=current.timestamp,
|
||||
price=current.low,
|
||||
direction="low",
|
||||
strength=strength
|
||||
)
|
||||
pivots.append(pivot)
|
||||
|
||||
return pivots
|
||||
|
||||
def _merge_pivots(self, pivots: List[PivotPoint], klines: List[KlineData]) -> List[PivotPoint]:
|
||||
"""
|
||||
合并相近的转折点
|
||||
|
||||
合并规则:
|
||||
- K线距离小于26根
|
||||
- 价格相差在万分之三范围内
|
||||
- 高点合并:取较高的价格
|
||||
- 低点合并:取较低的价格
|
||||
|
||||
Args:
|
||||
pivots: 原始转折点列表
|
||||
klines: K线数据(用于计算K线索引)
|
||||
|
||||
Returns:
|
||||
合并后的转折点列表
|
||||
"""
|
||||
if len(pivots) < 2:
|
||||
return pivots
|
||||
|
||||
# 建立K线时间戳到索引的映射
|
||||
kline_index = {str(k.timestamp): i for i, k in enumerate(klines)}
|
||||
|
||||
# 按时间排序
|
||||
pivots = sorted(pivots, key=lambda p: str(p.timestamp))
|
||||
|
||||
# 分开处理高点和低点
|
||||
high_pivots = [p for p in pivots if p.direction == "high"]
|
||||
low_pivots = [p for p in pivots if p.direction == "low"]
|
||||
|
||||
# 合并高点
|
||||
merged_highs = self._merge_same_direction(
|
||||
high_pivots, kline_index, "high"
|
||||
)
|
||||
|
||||
# 合并低点
|
||||
merged_lows = self._merge_same_direction(
|
||||
low_pivots, kline_index, "low"
|
||||
)
|
||||
|
||||
# 合并结果
|
||||
result = merged_highs + merged_lows
|
||||
return result
|
||||
|
||||
def _merge_same_direction(self, pivots: List[PivotPoint],
|
||||
kline_index: Dict[str, int],
|
||||
direction: str) -> List[PivotPoint]:
|
||||
"""
|
||||
合并同方向的转折点
|
||||
"""
|
||||
if len(pivots) < 2:
|
||||
return pivots
|
||||
|
||||
merged = []
|
||||
i = 0
|
||||
|
||||
while i < len(pivots):
|
||||
current = pivots[i]
|
||||
current_idx = kline_index.get(str(current.timestamp), -1)
|
||||
|
||||
if current_idx < 0:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 查找需要合并的转折点
|
||||
group = [current]
|
||||
|
||||
j = i + 1
|
||||
while j < len(pivots):
|
||||
next_pivot = pivots[j]
|
||||
next_idx = kline_index.get(str(next_pivot.timestamp), -1)
|
||||
|
||||
if next_idx < 0:
|
||||
j += 1
|
||||
continue
|
||||
|
||||
# 检查K线距离
|
||||
kline_distance = abs(next_idx - current_idx)
|
||||
|
||||
if kline_distance >= 26:
|
||||
break
|
||||
|
||||
# 检查价格差距(万分之三)
|
||||
if current.price > 0:
|
||||
price_diff_pct = abs(next_pivot.price - current.price) / current.price
|
||||
if price_diff_pct <= 0.0003: # 万分之三
|
||||
group.append(next_pivot)
|
||||
j += 1
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
# 从组中选择代表性转折点
|
||||
if direction == "high":
|
||||
# 高点:取价格最高的
|
||||
best = max(group, key=lambda p: p.price)
|
||||
else:
|
||||
# 低点:取价格最低的
|
||||
best = min(group, key=lambda p: p.price)
|
||||
|
||||
merged.append(best)
|
||||
i = j
|
||||
|
||||
return merged
|
||||
|
||||
def update_pivots(self, symbol: str, period: str, klines: List[KlineData],
|
||||
strength: int = None) -> int:
|
||||
"""
|
||||
更新转折点数据
|
||||
|
||||
Returns:
|
||||
更新后的转折点数量
|
||||
"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
|
||||
pivots = self.detect_pivots(symbol, period, klines, strength)
|
||||
|
||||
# 合并相近的转折点
|
||||
merged_pivots = self._merge_pivots(pivots, klines)
|
||||
|
||||
with self._lock:
|
||||
self._pivots[symbol][period] = merged_pivots
|
||||
count = len(merged_pivots)
|
||||
|
||||
original_count = len(pivots)
|
||||
if original_count != count:
|
||||
print(f"[PivotDetector] {symbol} {period} 检测到 {original_count} 个转折点,合并后 {count} 个")
|
||||
else:
|
||||
print(f"[PivotDetector] {symbol} {period} 检测到 {count} 个转折点")
|
||||
return count
|
||||
|
||||
def get_pivots(self, symbol: str, period: str, direction: str = None,
|
||||
count: int = 50) -> List[Dict]:
|
||||
"""
|
||||
获取转折点数据
|
||||
|
||||
Args:
|
||||
symbol: 交易品种
|
||||
period: 周期
|
||||
direction: "high" 或 "low",None表示全部
|
||||
count: 返回数量
|
||||
|
||||
Returns:
|
||||
转折点列表
|
||||
"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
|
||||
with self._lock:
|
||||
pivots = self._pivots[symbol][period]
|
||||
|
||||
if direction:
|
||||
pivots = [p for p in pivots if p.direction == direction]
|
||||
|
||||
# 按时间排序,返回最新的
|
||||
pivots = sorted(pivots, key=lambda x: str(x.timestamp), reverse=True)[:count]
|
||||
|
||||
return [p.to_dict() for p in pivots]
|
||||
|
||||
def get_recent_pivots(self, symbol: str, period: str, count: int = 10) -> List[Dict]:
|
||||
"""获取最近的转折点(按时间倒序)"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
|
||||
with self._lock:
|
||||
pivots = self._pivots[symbol][period]
|
||||
pivots = sorted(pivots, key=lambda x: str(x.timestamp), reverse=True)[:count]
|
||||
return [p.to_dict() for p in pivots]
|
||||
|
||||
def check_near_pivot(self, symbol: str, current_price: float) -> List[Dict]:
|
||||
"""
|
||||
检查当前价格是否接近某个转折点
|
||||
|
||||
Args:
|
||||
symbol: 交易品种
|
||||
current_price: 当前价格
|
||||
|
||||
Returns:
|
||||
接近的转折点列表,包含距离信息
|
||||
|
||||
预警逻辑:
|
||||
- 接近高点:当前价格 < 高点价格 且 距离在阈值范围内
|
||||
- 接近低点:当前价格 > 低点价格 且 距离在阈值范围内
|
||||
- 突破高点:当前价格超过高点价格的万分之一点二(基于实时价格)
|
||||
- 突破低点:当前价格低于低点价格的万分之一点二(基于实时价格)
|
||||
- 超过千分之一不再提示
|
||||
"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
near_pivots = []
|
||||
|
||||
# 突破阈值:万分之一点二
|
||||
BREAKTHROUGH_THRESHOLD = 0.00012
|
||||
# 最大提示范围:千分之一
|
||||
MAX_ALERT_THRESHOLD = 0.001
|
||||
|
||||
with self._lock:
|
||||
for period in self._pivots[symbol]:
|
||||
pivots = self._pivots[symbol][period]
|
||||
threshold = self.THRESHOLDS.get(period, 0.001)
|
||||
|
||||
for pivot in pivots:
|
||||
if pivot.price == 0 or current_price == 0:
|
||||
continue
|
||||
|
||||
# 基于实时价格计算阈值
|
||||
breakthrough_value = current_price * BREAKTHROUGH_THRESHOLD # 万分之一点二
|
||||
max_alert_value = current_price * MAX_ALERT_THRESHOLD # 千分之一
|
||||
|
||||
# 判断是接近还是突破
|
||||
is_near = False
|
||||
is_breakthrough = False
|
||||
alert_type = ""
|
||||
|
||||
if pivot.direction == "high":
|
||||
# 高点转折
|
||||
if current_price > pivot.price:
|
||||
# 当前价格高于高点,判断是否突破
|
||||
# 突破:超过高点的距离在万分之一点二到千分之一之间
|
||||
distance = current_price - pivot.price
|
||||
if distance >= breakthrough_value and distance < max_alert_value:
|
||||
is_breakthrough = True
|
||||
alert_type = "breakthrough_high"
|
||||
# 超过千分之一不再提示
|
||||
else:
|
||||
# 当前价格低于高点
|
||||
distance_pct = (pivot.price - current_price) / current_price
|
||||
if distance_pct <= threshold:
|
||||
is_near = True
|
||||
alert_type = "near_high"
|
||||
|
||||
elif pivot.direction == "low":
|
||||
# 低点转折
|
||||
if current_price < pivot.price:
|
||||
# 当前价格低于低点,判断是否突破
|
||||
# 突破:低于低点的距离在万分之一点二到千分之一之间
|
||||
distance = pivot.price - current_price
|
||||
if distance >= breakthrough_value and distance < max_alert_value:
|
||||
is_breakthrough = True
|
||||
alert_type = "breakthrough_low"
|
||||
# 超过千分之一不再提示
|
||||
else:
|
||||
# 当前价格高于低点
|
||||
distance_pct = (current_price - pivot.price) / current_price
|
||||
if distance_pct <= threshold:
|
||||
is_near = True
|
||||
alert_type = "near_low"
|
||||
|
||||
if is_near or is_breakthrough:
|
||||
distance_pct = abs(current_price - pivot.price) / current_price
|
||||
near_pivots.append({
|
||||
**pivot.to_dict(),
|
||||
"current_price": current_price,
|
||||
"distance_pct": round(distance_pct * 100, 4),
|
||||
"threshold_pct": round(threshold * 100, 4),
|
||||
"distance": round(current_price - pivot.price, 2),
|
||||
"alert_type": alert_type,
|
||||
"is_breakthrough": is_breakthrough
|
||||
})
|
||||
|
||||
# 按距离排序,最近的优先
|
||||
near_pivots.sort(key=lambda x: x['distance_pct'])
|
||||
|
||||
return near_pivots
|
||||
|
||||
def get_threshold(self, period: str) -> float:
|
||||
"""获取某个周期的接近阈值"""
|
||||
return self.THRESHOLDS.get(period, 0.001)
|
||||
|
||||
def clear_symbol(self, symbol: str):
|
||||
"""清除某个Symbol的转折点数据"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
with self._lock:
|
||||
if symbol in self._pivots:
|
||||
del self._pivots[symbol]
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""获取状态"""
|
||||
with self._lock:
|
||||
status = {}
|
||||
for symbol in self._pivots:
|
||||
status[symbol] = {}
|
||||
for period in self._pivots[symbol]:
|
||||
count = len(self._pivots[symbol][period])
|
||||
status[symbol][period] = {"pivot_count": count}
|
||||
return status
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
K线数据存储模块
|
||||
按周期和Symbol存储K线数据
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional
|
||||
import threading
|
||||
|
||||
|
||||
def normalize_symbol(symbol: str) -> str:
|
||||
"""
|
||||
标准化品种名称(保持原样)
|
||||
"""
|
||||
return symbol if symbol else ""
|
||||
|
||||
|
||||
class KlineData:
|
||||
"""K线数据结构"""
|
||||
|
||||
def __init__(self, symbol: str, period: str, timestamp, open_price: float,
|
||||
high: float, low: float, close: float, volume: float = 0):
|
||||
self.symbol = normalize_symbol(symbol)
|
||||
self.period = period # H4, H1, M15, M5, M1
|
||||
self.timestamp = timestamp
|
||||
self.open = open_price
|
||||
self.high = high
|
||||
self.low = low
|
||||
self.close = close
|
||||
self.volume = volume
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""转换为字典"""
|
||||
ts = self.timestamp
|
||||
if isinstance(ts, datetime):
|
||||
ts_str = ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
ts_str = str(ts)
|
||||
|
||||
return {
|
||||
"symbol": self.symbol,
|
||||
"period": self.period,
|
||||
"timestamp": ts_str,
|
||||
"open": self.open,
|
||||
"high": self.high,
|
||||
"low": self.low,
|
||||
"close": self.close,
|
||||
"volume": self.volume
|
||||
}
|
||||
|
||||
|
||||
class MarketStore:
|
||||
"""K线数据存储"""
|
||||
|
||||
# 支持的周期
|
||||
PERIODS = ['H4', 'H1', 'M15', 'M5', 'M1']
|
||||
|
||||
# 各周期最大存储条数
|
||||
MAX_KLINES = {
|
||||
'H4': 1500, # 4小时,6个月约1100根,留余量
|
||||
'H1': 1000, # 1小时,1个月约720根
|
||||
'M15': 500, # 15分钟,3天约288根
|
||||
'M5': 400, # 5分钟,24小时288根
|
||||
'M1': 100 # 1分钟,1小时60根
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# 存储结构: {SYMBOL: {PERIOD: [KlineData, ...]}}
|
||||
self._klines = defaultdict(lambda: defaultdict(list))
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# 标记每个symbol每个周期是否已收到全量数据
|
||||
# 结构: {SYMBOL: {PERIOD: True/False}}
|
||||
self._initialized = defaultdict(lambda: defaultdict(bool))
|
||||
|
||||
print("[MarketStore] K线存储已初始化")
|
||||
|
||||
def save_klines(self, symbol: str, period: str, klines: List[Dict],
|
||||
is_full: bool = False) -> Dict:
|
||||
"""
|
||||
保存K线数据
|
||||
|
||||
Args:
|
||||
symbol: 交易品种
|
||||
period: 周期 (H4/H1/M15/M5/M1)
|
||||
klines: K线数据列表
|
||||
is_full: 是否为全量数据
|
||||
|
||||
Returns:
|
||||
{"status": "ok", "count": N, "is_full": bool}
|
||||
"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
period = period.upper()
|
||||
|
||||
if period not in self.PERIODS:
|
||||
return {"status": "error", "message": f"不支持的周期: {period}"}
|
||||
|
||||
with self._lock:
|
||||
if is_full:
|
||||
# 全量数据,直接覆盖
|
||||
self._klines[symbol][period] = []
|
||||
|
||||
# 解析并存储K线数据
|
||||
new_count = 0
|
||||
for k in klines:
|
||||
kline = KlineData(
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
timestamp=k.get('timestamp') or k.get('time'),
|
||||
open_price=float(k.get('open', 0)),
|
||||
high=float(k.get('high', 0)),
|
||||
low=float(k.get('low', 0)),
|
||||
close=float(k.get('close', 0)),
|
||||
volume=float(k.get('volume', 0))
|
||||
)
|
||||
|
||||
# 检查是否已存在相同时间戳的数据
|
||||
existing = self._klines[symbol][period]
|
||||
ts = kline.timestamp
|
||||
|
||||
# 查找是否已存在
|
||||
found_idx = -1
|
||||
for i, existing_kline in enumerate(existing):
|
||||
if self._normalize_timestamp(existing_kline.timestamp) == self._normalize_timestamp(ts):
|
||||
found_idx = i
|
||||
break
|
||||
|
||||
if found_idx >= 0:
|
||||
# 更新已有数据
|
||||
existing[found_idx] = kline
|
||||
else:
|
||||
# 添加新数据
|
||||
existing.append(kline)
|
||||
new_count += 1
|
||||
|
||||
# 按时间排序
|
||||
self._klines[symbol][period].sort(
|
||||
key=lambda x: self._normalize_timestamp(x.timestamp)
|
||||
)
|
||||
|
||||
# 限制最大条数,保留最新的
|
||||
max_count = self.MAX_KLINES.get(period, 500)
|
||||
if len(self._klines[symbol][period]) > max_count:
|
||||
self._klines[symbol][period] = self._klines[symbol][period][-max_count:]
|
||||
|
||||
# 标记已初始化
|
||||
self._initialized[symbol][period] = True
|
||||
|
||||
total = len(self._klines[symbol][period])
|
||||
print(f"[MarketStore] {symbol} {period} 保存了 {new_count} 条新数据, 当前共 {total} 条")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"count": new_count,
|
||||
"total": total,
|
||||
"is_full": is_full
|
||||
}
|
||||
|
||||
def get_klines(self, symbol: str, period: str, count: int = 100) -> List[Dict]:
|
||||
"""获取K线数据"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
period = period.upper()
|
||||
|
||||
with self._lock:
|
||||
klines = self._klines[symbol][period][-count:]
|
||||
return [k.to_dict() for k in klines]
|
||||
|
||||
def get_all_klines(self, symbol: str, period: str) -> List[Dict]:
|
||||
"""获取所有K线数据"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
period = period.upper()
|
||||
|
||||
with self._lock:
|
||||
return [k.to_dict() for k in self._klines[symbol][period]]
|
||||
|
||||
def get_latest_price(self, symbol: str) -> Optional[float]:
|
||||
"""获取最新价格(从K线的最新close,优先M1,依次尝试其他周期)"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
|
||||
with self._lock:
|
||||
# 尝试找到匹配的symbol(支持带#后缀的symbol)
|
||||
actual_symbol = None
|
||||
if symbol in self._klines:
|
||||
actual_symbol = symbol
|
||||
else:
|
||||
# 尝试添加#后缀
|
||||
for s in self._klines:
|
||||
if s.upper().startswith(symbol.upper()):
|
||||
actual_symbol = s
|
||||
break
|
||||
|
||||
if not actual_symbol:
|
||||
return None
|
||||
|
||||
# 按优先级尝试各周期(M1优先,然后更短周期)
|
||||
for period in ['M1', 'M5', 'M15', 'H1', 'H4']:
|
||||
klines = self._klines[actual_symbol][period]
|
||||
if klines:
|
||||
return klines[-1].close
|
||||
return None
|
||||
|
||||
def is_initialized(self, symbol: str, period: str) -> bool:
|
||||
"""检查某个周期的数据是否已初始化"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
period = period.upper()
|
||||
return self._initialized[symbol][period]
|
||||
|
||||
def check_all_initialized(self, symbol: str) -> bool:
|
||||
"""检查所有周期是否都已初始化"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
return all(self._initialized[symbol][p] for p in self.PERIODS)
|
||||
|
||||
def clear_symbol(self, symbol: str):
|
||||
"""清除某个Symbol的数据"""
|
||||
symbol = normalize_symbol(symbol)
|
||||
with self._lock:
|
||||
if symbol in self._klines:
|
||||
del self._klines[symbol]
|
||||
if symbol in self._initialized:
|
||||
del self._initialized[symbol]
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""获取存储状态"""
|
||||
with self._lock:
|
||||
status = {}
|
||||
for symbol in self._klines:
|
||||
status[symbol] = {}
|
||||
for period in self.PERIODS:
|
||||
count = len(self._klines[symbol][period])
|
||||
initialized = self._initialized[symbol][period]
|
||||
status[symbol][period] = {
|
||||
"count": count,
|
||||
"initialized": initialized
|
||||
}
|
||||
return status
|
||||
|
||||
def get_symbols(self) -> List[str]:
|
||||
"""获取所有有实际数据的symbol列表"""
|
||||
with self._lock:
|
||||
symbols = []
|
||||
for symbol in self._klines:
|
||||
# 检查是否有实际数据(任一周期有K线数据)
|
||||
has_data = False
|
||||
for period in self.PERIODS:
|
||||
if len(self._klines[symbol][period]) > 0:
|
||||
has_data = True
|
||||
break
|
||||
if has_data:
|
||||
symbols.append(symbol)
|
||||
return symbols
|
||||
|
||||
def _normalize_timestamp(self, ts) -> str:
|
||||
"""标准化时间戳为字符串"""
|
||||
if isinstance(ts, datetime):
|
||||
return ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(ts)
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
趋势分析模块
|
||||
基于均线和ADX判断趋势方向和强度
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
import threading
|
||||
|
||||
from .store import KlineData, normalize_symbol
|
||||
|
||||
|
||||
class TrendAnalyzer:
|
||||
"""趋势分析器"""
|
||||
|
||||
# 支持的周期
|
||||
PERIODS = ['H4', 'H1', 'M15', 'M5', 'M1']
|
||||
|
||||
# ADX阈值
|
||||
ADX_TREND_THRESHOLD = 25 # ADX > 25 表示有趋势
|
||||
ADX_STRONG_THRESHOLD = 40 # ADX > 40 表示强趋势
|
||||
|
||||
# 均线周期
|
||||
MA_FAST = 10 # 快线周期
|
||||
MA_SLOW = 20 # 慢线周期
|
||||
|
||||
def __init__(self):
|
||||
# 存储各周期趋势状态: {SYMBOL: {PERIOD: TrendState}}
|
||||
self._trend_states = defaultdict(lambda: defaultdict(dict))
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# 趋势转换历史
|
||||
self._trend_changes = defaultdict(list)
|
||||
|
||||
print("[TrendAnalyzer] 趋势分析器已初始化")
|
||||
|
||||
def analyze_trend(self, symbol: str, period: str, klines: List[KlineData]) -> Dict:
|
||||
"""
|
||||
分析单个周期的趋势
|
||||
|
||||
Args:
|
||||
symbol: 交易品种
|
||||
period: 周期
|
||||
klines: K线数据
|
||||
|
||||
Returns:
|
||||
{
|
||||
"trend": "up" / "down" / "sideways",
|
||||
"strength": 0-100,
|
||||
"adx": float,
|
||||
"ma_fast": float,
|
||||
"ma_slow": float,
|
||||
"price": float,
|
||||
"change_signal": bool, # 是否发生趋势转换
|
||||
"timestamp": str
|
||||
}
|
||||
"""
|
||||
if len(klines) < 30: # 至少需要30根K线
|
||||
return {
|
||||
"trend": "unknown",
|
||||
"strength": 0,
|
||||
"adx": 0,
|
||||
"ma_fast": 0,
|
||||
"ma_slow": 0,
|
||||
"price": 0,
|
||||
"change_signal": False,
|
||||
"reason": "K线数据不足(需≥30根)",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 计算均线
|
||||
closes = [k.close for k in klines]
|
||||
ma_fast = self._calculate_ma(closes, self.MA_FAST)
|
||||
ma_slow = self._calculate_ma(closes, self.MA_SLOW)
|
||||
current_price = closes[-1]
|
||||
|
||||
# 计算ADX
|
||||
adx = self._calculate_adx(klines)
|
||||
|
||||
# 判断趋势方向和原因
|
||||
reason_parts = []
|
||||
|
||||
if adx < self.ADX_TREND_THRESHOLD:
|
||||
# ADX较低,震荡行情
|
||||
trend = "sideways"
|
||||
reason_parts.append(f"ADX={adx:.1f}<25 无明显趋势")
|
||||
else:
|
||||
# 根据均线和价格判断方向
|
||||
if ma_fast > ma_slow and current_price > ma_fast:
|
||||
trend = "up"
|
||||
reason_parts.append(f"MA{self.MA_FAST}({ma_fast:.2f}) > MA{self.MA_SLOW}({ma_slow:.2f})")
|
||||
reason_parts.append(f"价格({current_price:.2f}) > MA{self.MA_FAST}")
|
||||
reason_parts.append(f"ADX={adx:.1f}≥25 确认趋势")
|
||||
elif ma_fast < ma_slow and current_price < ma_fast:
|
||||
trend = "down"
|
||||
reason_parts.append(f"MA{self.MA_FAST}({ma_fast:.2f}) < MA{self.MA_SLOW}({ma_slow:.2f})")
|
||||
reason_parts.append(f"价格({current_price:.2f}) < MA{self.MA_FAST}")
|
||||
reason_parts.append(f"ADX={adx:.1f}≥25 确认趋势")
|
||||
else:
|
||||
trend = "sideways"
|
||||
if ma_fast > ma_slow:
|
||||
reason_parts.append(f"MA{self.MA_FAST}({ma_fast:.2f}) > MA{self.MA_SLOW}({ma_slow:.2f})")
|
||||
reason_parts.append(f"但价格({current_price:.2f})低于MA{self.MA_FAST}")
|
||||
else:
|
||||
reason_parts.append(f"MA{self.MA_FAST}({ma_fast:.2f}) < MA{self.MA_SLOW}({ma_slow:.2f})")
|
||||
reason_parts.append(f"且价格({current_price:.2f})高于MA{self.MA_FAST}")
|
||||
reason_parts.append("信号矛盾,判定震荡")
|
||||
|
||||
reason = ";".join(reason_parts)
|
||||
|
||||
# 计算趋势强度 (基于ADX)
|
||||
if adx >= self.ADX_STRONG_THRESHOLD:
|
||||
strength = min(100, int(adx + 20))
|
||||
elif adx >= self.ADX_TREND_THRESHOLD:
|
||||
strength = int(adx + 10)
|
||||
else:
|
||||
strength = int(adx)
|
||||
|
||||
# 检查趋势转换
|
||||
symbol_key = normalize_symbol(symbol)
|
||||
change_signal = False
|
||||
previous_trend = None
|
||||
|
||||
with self._lock:
|
||||
if period in self._trend_states[symbol_key]:
|
||||
previous_trend = self._trend_states[symbol_key][period].get('trend')
|
||||
if previous_trend and previous_trend != trend and previous_trend != "unknown":
|
||||
change_signal = True
|
||||
# 记录转换历史
|
||||
self._trend_changes[symbol_key].append({
|
||||
"period": period,
|
||||
"from_trend": previous_trend,
|
||||
"to_trend": trend,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"price": current_price
|
||||
})
|
||||
# 只保留最近20条
|
||||
if len(self._trend_changes[symbol_key]) > 20:
|
||||
self._trend_changes[symbol_key] = self._trend_changes[symbol_key][-20:]
|
||||
|
||||
# 更新状态
|
||||
self._trend_states[symbol_key][period] = {
|
||||
"trend": trend,
|
||||
"strength": strength,
|
||||
"adx": round(adx, 2),
|
||||
"ma_fast": round(ma_fast, 4),
|
||||
"ma_slow": round(ma_slow, 4),
|
||||
"price": current_price,
|
||||
"change_signal": change_signal,
|
||||
"previous_trend": previous_trend,
|
||||
"reason": reason,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self._trend_states[symbol_key][period]
|
||||
|
||||
def analyze_resonance(self, symbol: str) -> Dict:
|
||||
"""
|
||||
分析多周期共振
|
||||
|
||||
Returns:
|
||||
{
|
||||
"resonance": "up" / "down" / "none",
|
||||
"strength": 0-100,
|
||||
"periods": {period: trend_state},
|
||||
"aligned_count": int,
|
||||
"signal": str
|
||||
}
|
||||
"""
|
||||
symbol_key = normalize_symbol(symbol)
|
||||
|
||||
with self._lock:
|
||||
states = dict(self._trend_states[symbol_key])
|
||||
|
||||
if not states:
|
||||
return {
|
||||
"resonance": "none",
|
||||
"strength": 0,
|
||||
"periods": {},
|
||||
"aligned_count": 0,
|
||||
"signal": "等待数据"
|
||||
}
|
||||
|
||||
# 统计各趋势数量
|
||||
up_count = sum(1 for s in states.values() if s.get('trend') == 'up')
|
||||
down_count = sum(1 for s in states.values() if s.get('trend') == 'down')
|
||||
sideways_count = sum(1 for s in states.values() if s.get('trend') == 'sideways')
|
||||
|
||||
# 计算平均强度
|
||||
strengths = [s.get('strength', 0) for s in states.values() if s.get('trend') != 'sideways']
|
||||
avg_strength = sum(strengths) / len(strengths) if strengths else 0
|
||||
|
||||
# 判断共振
|
||||
total = len(states)
|
||||
if up_count >= total * 0.6: # 60%以上周期趋势一致
|
||||
resonance = "up"
|
||||
aligned_count = up_count
|
||||
signal = f"多周期向上共振 ({up_count}/{total})"
|
||||
elif down_count >= total * 0.6:
|
||||
resonance = "down"
|
||||
aligned_count = down_count
|
||||
signal = f"多周期向下共振 ({down_count}/{total})"
|
||||
else:
|
||||
resonance = "none"
|
||||
aligned_count = max(up_count, down_count)
|
||||
signal = f"趋势分歧 (↑{up_count} ↓{down_count} →{sideways_count})"
|
||||
|
||||
return {
|
||||
"resonance": resonance,
|
||||
"strength": int(avg_strength),
|
||||
"periods": states,
|
||||
"aligned_count": aligned_count,
|
||||
"up_count": up_count,
|
||||
"down_count": down_count,
|
||||
"sideways_count": sideways_count,
|
||||
"signal": signal
|
||||
}
|
||||
|
||||
def get_trend_state(self, symbol: str, period: str = None) -> Dict:
|
||||
"""获取趋势状态"""
|
||||
symbol_key = normalize_symbol(symbol)
|
||||
|
||||
with self._lock:
|
||||
if period:
|
||||
return self._trend_states[symbol_key].get(period, {})
|
||||
return dict(self._trend_states[symbol_key])
|
||||
|
||||
def get_trend_changes(self, symbol: str, count: int = 10) -> List[Dict]:
|
||||
"""获取趋势转换历史"""
|
||||
symbol_key = normalize_symbol(symbol)
|
||||
|
||||
with self._lock:
|
||||
return self._trend_changes[symbol_key][-count:]
|
||||
|
||||
def _calculate_ma(self, data: List[float], period: int) -> float:
|
||||
"""计算移动平均线"""
|
||||
if len(data) < period:
|
||||
return data[-1] if data else 0
|
||||
return sum(data[-period:]) / period
|
||||
|
||||
def _calculate_adx(self, klines: List[KlineData], period: int = 14) -> float:
|
||||
"""
|
||||
计算ADX (Average Directional Index)
|
||||
|
||||
ADX > 25: 有趋势
|
||||
ADX > 40: 强趋势
|
||||
ADX < 20: 无明显趋势
|
||||
"""
|
||||
if len(klines) < period + 1:
|
||||
return 0
|
||||
|
||||
# 计算 +DM 和 -DM
|
||||
plus_dm = []
|
||||
minus_dm = []
|
||||
tr_list = []
|
||||
|
||||
for i in range(1, len(klines)):
|
||||
high = klines[i].high
|
||||
low = klines[i].low
|
||||
prev_high = klines[i-1].high
|
||||
prev_low = klines[i-1].low
|
||||
prev_close = klines[i-1].close
|
||||
|
||||
# +DM
|
||||
up_move = high - prev_high
|
||||
down_move = prev_low - low
|
||||
|
||||
if up_move > down_move and up_move > 0:
|
||||
plus_dm.append(up_move)
|
||||
else:
|
||||
plus_dm.append(0)
|
||||
|
||||
# -DM
|
||||
if down_move > up_move and down_move > 0:
|
||||
minus_dm.append(down_move)
|
||||
else:
|
||||
minus_dm.append(0)
|
||||
|
||||
# True Range
|
||||
tr = max(
|
||||
high - low,
|
||||
abs(high - prev_close),
|
||||
abs(low - prev_close)
|
||||
)
|
||||
tr_list.append(tr)
|
||||
|
||||
if len(tr_list) < period:
|
||||
return 0
|
||||
|
||||
# 计算平滑值
|
||||
atr = sum(tr_list[-period:]) / period
|
||||
smoothed_plus_dm = sum(plus_dm[-period:]) / period
|
||||
smoothed_minus_dm = sum(minus_dm[-period:]) / period
|
||||
|
||||
# 计算 +DI 和 -DI
|
||||
if atr == 0:
|
||||
return 0
|
||||
|
||||
plus_di = (smoothed_plus_dm / atr) * 100
|
||||
minus_di = (smoothed_minus_dm / atr) * 100
|
||||
|
||||
# 计算 DX
|
||||
di_sum = plus_di + minus_di
|
||||
if di_sum == 0:
|
||||
return 0
|
||||
|
||||
dx = abs(plus_di - minus_di) / di_sum * 100
|
||||
|
||||
return dx
|
||||
|
||||
def generate_trade_suggestion(self, symbol: str, pivots: List[Dict],
|
||||
current_price: float) -> Optional[Dict]:
|
||||
"""
|
||||
基于趋势和转折点生成交易建议
|
||||
|
||||
Args:
|
||||
symbol: 交易品种
|
||||
pivots: 转折点数据
|
||||
current_price: 当前价格
|
||||
|
||||
Returns:
|
||||
交易建议 或 None
|
||||
"""
|
||||
symbol_key = normalize_symbol(symbol)
|
||||
|
||||
# 获取趋势状态
|
||||
resonance = self.analyze_resonance(symbol)
|
||||
|
||||
if resonance['resonance'] == 'none':
|
||||
return None
|
||||
|
||||
if resonance['strength'] < 30:
|
||||
return None
|
||||
|
||||
trend = resonance['resonance']
|
||||
|
||||
# 根据趋势找最近的转折点作为止损止盈
|
||||
recent_pivots = sorted(pivots, key=lambda x: x['timestamp'], reverse=True)[:10]
|
||||
|
||||
sl = None
|
||||
tp = None
|
||||
action = None
|
||||
reason = ""
|
||||
|
||||
if trend == "up":
|
||||
# 上升趋势,找最近的低点作为止损
|
||||
action = "b"
|
||||
low_pivots = [p for p in recent_pivots if p['direction'] == 'low']
|
||||
if low_pivots:
|
||||
# 找最近的低点作为止损
|
||||
sl = low_pivots[0]['price']
|
||||
# 止盈设为止损的1.5-2倍距离
|
||||
if sl and current_price > sl:
|
||||
distance = current_price - sl
|
||||
tp = current_price + distance * 1.5
|
||||
reason = f"多周期向上共振,建议买入,止损参考最近低点 {sl}"
|
||||
else:
|
||||
return None
|
||||
|
||||
elif trend == "down":
|
||||
# 下降趋势,找最近的高点作为止损
|
||||
action = "s"
|
||||
high_pivots = [p for p in recent_pivots if p['direction'] == 'high']
|
||||
if high_pivots:
|
||||
sl = high_pivots[0]['price']
|
||||
if sl and current_price < sl:
|
||||
distance = sl - current_price
|
||||
tp = current_price - distance * 1.5
|
||||
reason = f"多周期向下共振,建议卖出,止损参考最近高点 {sl}"
|
||||
else:
|
||||
return None
|
||||
|
||||
if not all([action, sl, tp]):
|
||||
return None
|
||||
|
||||
return {
|
||||
"symbol": symbol_key,
|
||||
"action": action,
|
||||
"price": current_price,
|
||||
"sl": round(sl, 4),
|
||||
"tp": round(tp, 4),
|
||||
"reason": reason,
|
||||
"trend_strength": resonance['strength'],
|
||||
"resonance_periods": resonance['aligned_count'],
|
||||
"generated_at": datetime.now().isoformat()
|
||||
}
|
||||
Reference in New Issue
Block a user