feat: 添加前端界面和市场分析模块

- 新增 Vue 3 + Vuetify 前端界面
- 新增市场分析模块 (market/)
- 更新主服务器和路由
- 更新 MT5 EA 文件
- 添加 .gitignore 排除临时文件
This commit is contained in:
guaiwoluo2020
2026-03-10 17:38:13 +08:00
parent 0c9cf048d2
commit 51b2f30748
40 changed files with 8576 additions and 151 deletions
+12
View File
@@ -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']
+120
View File
@@ -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
+412
View File
@@ -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
}
+199
View File
@@ -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
+414
View File
@@ -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
View File
@@ -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)
+389
View File
@@ -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()
}