alphax/app/core/timeframe_alignment.py
2026-06-01 00:16:01 +08:00

399 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""多时间框架对齐度评分 — 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