399 lines
14 KiB
Python
399 lines
14 KiB
Python
"""多时间框架对齐度评分 — Timeframe Alignment Score。
|
||
|
||
核心逻辑:
|
||
- 分别判断 D1 / 4H / 1H 三个级别的方向倾向
|
||
- 统计方向一致的级别数量(对齐度 0-3)
|
||
- 对齐度 3 = 三重共振,最高信心
|
||
- 对齐度 2 = 双重确认,正常交易
|
||
- 对齐度 1 = 单级别信号,降级为观察
|
||
- 对齐度 0 = 方向矛盾,不建议入场
|
||
|
||
方向判断依据(不用滞后指标,纯 PA):
|
||
- 最近 N 根 K 线的高低点趋势(higher highs/higher lows vs lower highs/lower lows)
|
||
- 最近动K方向
|
||
- 当前价格相对近期高低点的位置
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Optional
|
||
|
||
import pandas as pd
|
||
import numpy as np
|
||
|
||
from app.config.config_loader import _get_section as _get_cfg_section
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _alignment_config() -> dict:
|
||
"""Load config from rules.yaml -> timeframe_alignment section."""
|
||
try:
|
||
cfg = _get_cfg_section("timeframe_alignment") or {}
|
||
except Exception:
|
||
cfg = {}
|
||
return {
|
||
# How many candles to look back for trend detection
|
||
"d1_lookback": int(cfg.get("d1_lookback", 10)),
|
||
"h4_lookback": int(cfg.get("h4_lookback", 15)),
|
||
"h1_lookback": int(cfg.get("h1_lookback", 20)),
|
||
# Minimum swing size as % of price to count as valid swing
|
||
"min_swing_pct": float(cfg.get("min_swing_pct", 1.0)),
|
||
# Alignment score thresholds
|
||
"min_alignment_buy_now": int(cfg.get("min_alignment_buy_now", 2)),
|
||
"min_alignment_wait": int(cfg.get("min_alignment_wait", 2)),
|
||
"min_alignment_observe": int(cfg.get("min_alignment_observe", 1)),
|
||
# Factor weights
|
||
"weight_full_alignment": float(cfg.get("weight_full_alignment", 4.0)),
|
||
"weight_double_alignment": float(cfg.get("weight_double_alignment", 2.0)),
|
||
"weight_no_alignment_penalty": float(cfg.get("weight_no_alignment_penalty", -3.0)),
|
||
"weight_single_penalty": float(cfg.get("weight_single_penalty", -1.5)),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Single timeframe direction detection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _detect_swing_points(df: pd.DataFrame, min_swing_pct: float = 1.0) -> list[dict]:
|
||
"""Detect significant swing highs and lows in a price series.
|
||
|
||
Returns list of {"type": "high"/"low", "price": float, "index": int}
|
||
sorted by index (oldest first).
|
||
"""
|
||
if df is None or len(df) < 5:
|
||
return []
|
||
|
||
highs = df["high"].values
|
||
lows = df["low"].values
|
||
closes = df["close"].values
|
||
n = len(df)
|
||
|
||
swings = []
|
||
min_move = closes[-1] * min_swing_pct / 100 if closes[-1] > 0 else 0
|
||
|
||
# Simple swing detection: local max/min with at least 2 bars on each side
|
||
for i in range(2, n - 2):
|
||
# Swing high
|
||
if (
|
||
highs[i] >= highs[i - 1]
|
||
and highs[i] >= highs[i - 2]
|
||
and highs[i] >= highs[i + 1]
|
||
and highs[i] >= highs[i + 2]
|
||
):
|
||
swings.append({"type": "high", "price": float(highs[i]), "index": i})
|
||
# Swing low
|
||
if (
|
||
lows[i] <= lows[i - 1]
|
||
and lows[i] <= lows[i - 2]
|
||
and lows[i] <= lows[i + 1]
|
||
and lows[i] <= lows[i + 2]
|
||
):
|
||
swings.append({"type": "low", "price": float(lows[i]), "index": i})
|
||
|
||
# Filter out insignificant swings
|
||
if min_move > 0 and len(swings) >= 2:
|
||
filtered = [swings[0]]
|
||
for s in swings[1:]:
|
||
if abs(s["price"] - filtered[-1]["price"]) >= min_move:
|
||
filtered.append(s)
|
||
swings = filtered
|
||
|
||
return sorted(swings, key=lambda x: x["index"])
|
||
|
||
|
||
def detect_timeframe_direction(df: pd.DataFrame, lookback: int = 15, min_swing_pct: float = 1.0) -> dict:
|
||
"""Detect the directional bias of a single timeframe.
|
||
|
||
Returns:
|
||
{
|
||
"direction": 1 (bullish) / -1 (bearish) / 0 (neutral/unclear),
|
||
"confidence": float 0-1,
|
||
"reason": str,
|
||
"higher_highs": bool,
|
||
"higher_lows": bool,
|
||
"lower_highs": bool,
|
||
"lower_lows": bool,
|
||
"price_position": float (0-1, where in recent range),
|
||
}
|
||
"""
|
||
if df is None or len(df) < lookback:
|
||
return {
|
||
"direction": 0,
|
||
"confidence": 0.0,
|
||
"reason": "数据不足",
|
||
"higher_highs": False,
|
||
"higher_lows": False,
|
||
"lower_highs": False,
|
||
"lower_lows": False,
|
||
"price_position": 0.5,
|
||
}
|
||
|
||
recent = df.tail(lookback).copy()
|
||
swings = _detect_swing_points(recent, min_swing_pct)
|
||
|
||
# Separate swing highs and lows
|
||
swing_highs = [s for s in swings if s["type"] == "high"]
|
||
swing_lows = [s for s in swings if s["type"] == "low"]
|
||
|
||
# Check for higher highs / higher lows (bullish structure)
|
||
higher_highs = False
|
||
higher_lows = False
|
||
lower_highs = False
|
||
lower_lows = False
|
||
|
||
if len(swing_highs) >= 2:
|
||
last_two_highs = swing_highs[-2:]
|
||
higher_highs = last_two_highs[1]["price"] > last_two_highs[0]["price"]
|
||
lower_highs = last_two_highs[1]["price"] < last_two_highs[0]["price"]
|
||
|
||
if len(swing_lows) >= 2:
|
||
last_two_lows = swing_lows[-2:]
|
||
higher_lows = last_two_lows[1]["price"] > last_two_lows[0]["price"]
|
||
lower_lows = last_two_lows[1]["price"] < last_two_lows[0]["price"]
|
||
|
||
# Price position within recent range (0 = at low, 1 = at high)
|
||
range_high = float(recent["high"].max())
|
||
range_low = float(recent["low"].min())
|
||
current_close = float(recent["close"].iloc[-1])
|
||
price_range = range_high - range_low
|
||
price_position = (current_close - range_low) / price_range if price_range > 0 else 0.5
|
||
|
||
# Recent candle momentum (last 3 candles net direction)
|
||
last_3 = recent.tail(3)
|
||
net_change = float(last_3["close"].iloc[-1]) - float(last_3["open"].iloc[0])
|
||
recent_bullish = net_change > 0
|
||
|
||
# Linear-regression slope of closes (works even without clean swing points,
|
||
# e.g. strong monotonic trends where pullback-based swings never form).
|
||
closes = recent["close"].values.astype(float)
|
||
slope_dir = 0
|
||
slope_pct = 0.0
|
||
if len(closes) >= 3:
|
||
x = np.arange(len(closes))
|
||
try:
|
||
slope = np.polyfit(x, closes, 1)[0]
|
||
mean_close = float(np.mean(closes))
|
||
# Normalize slope to % move per bar relative to mean price
|
||
slope_pct = (slope / mean_close * 100) if mean_close > 0 else 0.0
|
||
if slope_pct > 0.15:
|
||
slope_dir = 1
|
||
elif slope_pct < -0.15:
|
||
slope_dir = -1
|
||
except Exception:
|
||
slope_dir = 0
|
||
|
||
# Determine direction
|
||
bull_score = 0
|
||
bear_score = 0
|
||
|
||
if higher_highs:
|
||
bull_score += 1
|
||
if higher_lows:
|
||
bull_score += 1.5 # Higher lows are more important for trend
|
||
if lower_highs:
|
||
bear_score += 1
|
||
if lower_lows:
|
||
bear_score += 1.5
|
||
if slope_dir == 1:
|
||
bull_score += 1.5 # regression trend confirms direction
|
||
elif slope_dir == -1:
|
||
bear_score += 1.5
|
||
if price_position > 0.65:
|
||
bull_score += 0.5
|
||
elif price_position < 0.35:
|
||
bear_score += 0.5
|
||
if recent_bullish:
|
||
bull_score += 0.5
|
||
else:
|
||
bear_score += 0.5
|
||
|
||
# Decision
|
||
if bull_score >= 2.5 and bull_score > bear_score + 1:
|
||
direction = 1
|
||
confidence = min(1.0, bull_score / 4.0)
|
||
if higher_highs and higher_lows:
|
||
reason = "高点抬高+低点抬高"
|
||
elif slope_dir == 1:
|
||
reason = f"趋势斜率向上({slope_pct:+.2f}%/bar)"
|
||
else:
|
||
reason = "结构偏多"
|
||
elif bear_score >= 2.5 and bear_score > bull_score + 1:
|
||
direction = -1
|
||
confidence = min(1.0, bear_score / 4.0)
|
||
if lower_highs and lower_lows:
|
||
reason = "高点降低+低点降低"
|
||
elif slope_dir == -1:
|
||
reason = f"趋势斜率向下({slope_pct:+.2f}%/bar)"
|
||
else:
|
||
reason = "结构偏空"
|
||
else:
|
||
direction = 0
|
||
confidence = 0.3
|
||
reason = "方向不明确"
|
||
|
||
return {
|
||
"direction": direction,
|
||
"confidence": round(confidence, 2),
|
||
"reason": reason,
|
||
"higher_highs": higher_highs,
|
||
"higher_lows": higher_lows,
|
||
"lower_highs": lower_highs,
|
||
"lower_lows": lower_lows,
|
||
"slope_dir": slope_dir,
|
||
"slope_pct": round(slope_pct, 3),
|
||
"price_position": round(price_position, 3),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Multi-timeframe alignment
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def compute_timeframe_alignment(
|
||
df_d1: Optional[pd.DataFrame] = None,
|
||
df_4h: Optional[pd.DataFrame] = None,
|
||
df_1h: Optional[pd.DataFrame] = None,
|
||
trade_direction: str = "long",
|
||
) -> dict:
|
||
"""Compute alignment score across D1/4H/1H timeframes.
|
||
|
||
Args:
|
||
df_d1: Daily kline DataFrame
|
||
df_4h: 4-hour kline DataFrame
|
||
df_1h: 1-hour kline DataFrame
|
||
trade_direction: "long" or "short" — the intended trade direction
|
||
|
||
Returns:
|
||
{
|
||
"alignment_score": int (0-3),
|
||
"alignment_label": str,
|
||
"d1_direction": dict,
|
||
"h4_direction": dict,
|
||
"h1_direction": dict,
|
||
"aligned_timeframes": list[str],
|
||
"conflicting_timeframes": list[str],
|
||
"entry_allowed": bool,
|
||
"max_entry_action": str, # "可即刻买入" / "等回踩" / "观察"
|
||
}
|
||
"""
|
||
cfg = _alignment_config()
|
||
target_dir = 1 if trade_direction == "long" else -1
|
||
|
||
# Detect direction for each timeframe
|
||
d1_dir = detect_timeframe_direction(df_d1, cfg["d1_lookback"], cfg["min_swing_pct"]) if df_d1 is not None else None
|
||
h4_dir = detect_timeframe_direction(df_4h, cfg["h4_lookback"], cfg["min_swing_pct"]) if df_4h is not None else None
|
||
h1_dir = detect_timeframe_direction(df_1h, cfg["h1_lookback"], cfg["min_swing_pct"]) if df_1h is not None else None
|
||
|
||
# Count alignment
|
||
aligned = []
|
||
conflicting = []
|
||
timeframes = [("D1", d1_dir), ("4H", h4_dir), ("1H", h1_dir)]
|
||
|
||
for tf_name, tf_result in timeframes:
|
||
if tf_result is None:
|
||
continue
|
||
if tf_result["direction"] == target_dir:
|
||
aligned.append(tf_name)
|
||
elif tf_result["direction"] == -target_dir:
|
||
conflicting.append(tf_name)
|
||
# direction == 0 (neutral) doesn't count as aligned or conflicting
|
||
|
||
alignment_score = len(aligned)
|
||
|
||
# Determine entry permission based on alignment
|
||
if alignment_score >= 3:
|
||
alignment_label = "三重对齐"
|
||
max_entry_action = "可即刻买入"
|
||
entry_allowed = True
|
||
elif alignment_score >= cfg["min_alignment_buy_now"]:
|
||
alignment_label = "双重确认"
|
||
max_entry_action = "可即刻买入"
|
||
entry_allowed = True
|
||
elif alignment_score >= cfg["min_alignment_observe"]:
|
||
alignment_label = "单级别信号"
|
||
max_entry_action = "观察"
|
||
entry_allowed = True
|
||
else:
|
||
alignment_label = "方向矛盾"
|
||
max_entry_action = "观察"
|
||
entry_allowed = False
|
||
|
||
# If higher timeframe (D1) is conflicting, be more conservative
|
||
if "D1" in conflicting:
|
||
if max_entry_action == "可即刻买入":
|
||
max_entry_action = "等回踩"
|
||
alignment_label += "(日线逆向)"
|
||
|
||
return {
|
||
"alignment_score": alignment_score,
|
||
"alignment_label": alignment_label,
|
||
"d1_direction": d1_dir or {"direction": 0, "confidence": 0, "reason": "无数据"},
|
||
"h4_direction": h4_dir or {"direction": 0, "confidence": 0, "reason": "无数据"},
|
||
"h1_direction": h1_dir or {"direction": 0, "confidence": 0, "reason": "无数据"},
|
||
"aligned_timeframes": aligned,
|
||
"conflicting_timeframes": conflicting,
|
||
"entry_allowed": entry_allowed,
|
||
"max_entry_action": max_entry_action,
|
||
"trade_direction": trade_direction,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Factor scoring interface
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def alignment_factor_score(alignment_data: dict) -> tuple[float, str]:
|
||
"""Convert alignment data into a factor score.
|
||
|
||
Returns:
|
||
(score_delta, signal_label)
|
||
"""
|
||
cfg = _alignment_config()
|
||
score_val = alignment_data.get("alignment_score", 0)
|
||
label = alignment_data.get("alignment_label", "")
|
||
|
||
if score_val >= 3:
|
||
return cfg["weight_full_alignment"], f"多周期三重对齐({'/'.join(alignment_data.get('aligned_timeframes', []))})"
|
||
elif score_val == 2:
|
||
return cfg["weight_double_alignment"], f"多周期双重确认({'/'.join(alignment_data.get('aligned_timeframes', []))})"
|
||
elif score_val == 1:
|
||
return cfg["weight_single_penalty"], f"仅单周期支持({'/'.join(alignment_data.get('aligned_timeframes', []))})"
|
||
else:
|
||
conflicting = alignment_data.get("conflicting_timeframes", [])
|
||
return cfg["weight_no_alignment_penalty"], f"多周期方向矛盾({'/'.join(conflicting)}冲突)"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry action gate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def gate_entry_by_alignment(
|
||
current_entry_action: str,
|
||
alignment_data: dict,
|
||
) -> tuple[str, str]:
|
||
"""Downgrade entry action if alignment is insufficient.
|
||
|
||
Args:
|
||
current_entry_action: the entry action determined by other logic
|
||
alignment_data: result from compute_timeframe_alignment
|
||
|
||
Returns:
|
||
(final_entry_action, reason_if_downgraded)
|
||
"""
|
||
max_allowed = alignment_data.get("max_entry_action", "观察")
|
||
|
||
# Define action hierarchy
|
||
action_rank = {"可即刻买入": 3, "即刻买入": 3, "等回踩": 2, "观察": 1}
|
||
current_rank = action_rank.get(current_entry_action, 1)
|
||
max_rank = action_rank.get(max_allowed, 1)
|
||
|
||
if current_rank <= max_rank:
|
||
return current_entry_action, ""
|
||
|
||
# Downgrade
|
||
reason = f"多周期对齐度不足({alignment_data.get('alignment_label', '')}), 降级为{max_allowed}"
|
||
return max_allowed, reason
|