306 lines
10 KiB
Python
306 lines
10 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
自适应三分位趋势策略 - 核心逻辑
|
|||
|
|
趋势过滤、动态阈值、信号确认、市场状态
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from typing import List, Dict, Optional, Tuple
|
|||
|
|
import sys
|
|||
|
|
import os
|
|||
|
|
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||
|
|
if ROOT_DIR not in sys.path:
|
|||
|
|
sys.path.insert(0, ROOT_DIR)
|
|||
|
|
|
|||
|
|
from adaptive_third_strategy.config import (
|
|||
|
|
MIN_BODY_ATR_RATIO,
|
|||
|
|
MIN_VOLATILITY_PERCENT,
|
|||
|
|
EMA_SHORT,
|
|||
|
|
EMA_LONG_FAST,
|
|||
|
|
EMA_LONG_SLOW,
|
|||
|
|
EMA_MID_FAST,
|
|||
|
|
EMA_MID_SLOW,
|
|||
|
|
ATR_PERIOD,
|
|||
|
|
VOLATILITY_COEF_CLAMP,
|
|||
|
|
BASE_COEF,
|
|||
|
|
TREND_FAVOR_COEF,
|
|||
|
|
TREND_AGAINST_COEF,
|
|||
|
|
TREND_MODE,
|
|||
|
|
CONFIRM_REQUIRED,
|
|||
|
|
VOLUME_MA_PERIOD,
|
|||
|
|
VOLUME_RATIO_THRESHOLD,
|
|||
|
|
REVERSE_BREAK_MULT,
|
|||
|
|
MIN_BARS_SINCE_ENTRY,
|
|||
|
|
FORBIDDEN_PERIODS,
|
|||
|
|
ATR_PAUSE_MULT,
|
|||
|
|
STRONG_TREND_COEF,
|
|||
|
|
RANGE_COEF,
|
|||
|
|
HIGH_VOL_EXTRA_CONFIRM,
|
|||
|
|
)
|
|||
|
|
from adaptive_third_strategy.indicators import (
|
|||
|
|
get_ema_atr_from_klines,
|
|||
|
|
align_higher_tf_ema,
|
|||
|
|
ema,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_body_size(candle: Dict) -> float:
|
|||
|
|
return abs(float(candle["open"]) - float(candle["close"]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_bullish(candle: Dict) -> bool:
|
|||
|
|
return float(candle["close"]) > float(candle["open"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_min_body_threshold(price: float, atr_value: Optional[float]) -> float:
|
|||
|
|
"""有效K线最小实体 = max(ATR*0.1, 价格*0.05%)"""
|
|||
|
|
min_vol = price * MIN_VOLATILITY_PERCENT
|
|||
|
|
if atr_value is not None and atr_value > 0:
|
|||
|
|
min_vol = max(min_vol, atr_value * MIN_BODY_ATR_RATIO)
|
|||
|
|
return min_vol
|
|||
|
|
|
|||
|
|
|
|||
|
|
def find_valid_prev_bar(
|
|||
|
|
all_data: List[Dict],
|
|||
|
|
current_idx: int,
|
|||
|
|
atr_series: List[Optional[float]],
|
|||
|
|
min_body_override: Optional[float] = None,
|
|||
|
|
) -> Tuple[Optional[int], Optional[Dict]]:
|
|||
|
|
"""从当前索引往前找实体>=阈值的K线。阈值 = max(ATR*0.1, 价格*0.05%)"""
|
|||
|
|
if current_idx <= 0:
|
|||
|
|
return None, None
|
|||
|
|
for i in range(current_idx - 1, -1, -1):
|
|||
|
|
prev = all_data[i]
|
|||
|
|
body = get_body_size(prev)
|
|||
|
|
price = float(prev["close"])
|
|||
|
|
atr_val = atr_series[i] if i < len(atr_series) else None
|
|||
|
|
th = min_body_override if min_body_override is not None else get_min_body_threshold(price, atr_val)
|
|||
|
|
if body >= th:
|
|||
|
|
return i, prev
|
|||
|
|
return None, None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_trend(
|
|||
|
|
klines_5m: List[Dict],
|
|||
|
|
idx_5m: int,
|
|||
|
|
ema_5m: List[Optional[float]],
|
|||
|
|
ema_15m_align: List[Dict],
|
|||
|
|
ema_60m_align: List[Dict],
|
|||
|
|
) -> str:
|
|||
|
|
"""
|
|||
|
|
多时间框架趋势。返回 "long" / "short" / "neutral"。
|
|||
|
|
长期:1h EMA50 vs EMA200;中期:15m EMA20 vs EMA50;短期:5m close vs EMA9。
|
|||
|
|
ema_*_align: 与 5m 对齐的列表,每项 {"ema_fast", "ema_slow"}。
|
|||
|
|
"""
|
|||
|
|
if idx_5m >= len(klines_5m):
|
|||
|
|
return "neutral"
|
|||
|
|
curr = klines_5m[idx_5m]
|
|||
|
|
close_5 = float(curr["close"])
|
|||
|
|
# 短期
|
|||
|
|
ema9 = ema_5m[idx_5m] if idx_5m < len(ema_5m) else None
|
|||
|
|
short_bull = (ema9 is not None and close_5 > ema9)
|
|||
|
|
# 中期
|
|||
|
|
mid = ema_15m_align[idx_5m] if idx_5m < len(ema_15m_align) else {}
|
|||
|
|
mid_bull = (mid.get("ema_fast") is not None and mid.get("ema_slow") is not None
|
|||
|
|
and mid["ema_fast"] > mid["ema_slow"])
|
|||
|
|
# 长期
|
|||
|
|
long_d = ema_60m_align[idx_5m] if idx_5m < len(ema_60m_align) else {}
|
|||
|
|
long_bull = (long_d.get("ema_fast") is not None and long_d.get("ema_slow") is not None
|
|||
|
|
and long_d["ema_fast"] > long_d["ema_slow"])
|
|||
|
|
|
|||
|
|
if TREND_MODE == "aggressive":
|
|||
|
|
return "long" if short_bull else "short"
|
|||
|
|
if TREND_MODE == "conservative":
|
|||
|
|
if mid_bull and short_bull:
|
|||
|
|
return "long"
|
|||
|
|
if not mid_bull and not short_bull:
|
|||
|
|
return "short"
|
|||
|
|
return "neutral"
|
|||
|
|
# strict
|
|||
|
|
if long_bull and mid_bull and short_bull:
|
|||
|
|
return "long"
|
|||
|
|
if not long_bull and not mid_bull and not short_bull:
|
|||
|
|
return "short"
|
|||
|
|
return "neutral"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_dynamic_trigger_levels(
|
|||
|
|
prev: Dict,
|
|||
|
|
atr_value: float,
|
|||
|
|
trend: str,
|
|||
|
|
market_state: str = "normal",
|
|||
|
|
) -> Tuple[Optional[float], Optional[float]]:
|
|||
|
|
"""
|
|||
|
|
动态三分位触发价。
|
|||
|
|
波动率系数 = clamp(实体/ATR, 0.3, 3.0),调整系数 = 0.33 * 波动率系数。
|
|||
|
|
顺势方向 ×0.8,逆势 ×1.2。
|
|||
|
|
"""
|
|||
|
|
body = get_body_size(prev)
|
|||
|
|
if body < 1e-6 or atr_value <= 0:
|
|||
|
|
return None, None
|
|||
|
|
vol_coef = body / atr_value
|
|||
|
|
vol_coef = max(VOLATILITY_COEF_CLAMP[0], min(VOLATILITY_COEF_CLAMP[1], vol_coef))
|
|||
|
|
adj = BASE_COEF * vol_coef
|
|||
|
|
if market_state == "strong_trend":
|
|||
|
|
adj = STRONG_TREND_COEF
|
|||
|
|
elif market_state == "range":
|
|||
|
|
adj = RANGE_COEF
|
|||
|
|
p_close = float(prev["close"])
|
|||
|
|
if trend == "long":
|
|||
|
|
long_adj = adj * TREND_FAVOR_COEF
|
|||
|
|
short_adj = adj * TREND_AGAINST_COEF
|
|||
|
|
elif trend == "short":
|
|||
|
|
long_adj = adj * TREND_AGAINST_COEF
|
|||
|
|
short_adj = adj * TREND_FAVOR_COEF
|
|||
|
|
else:
|
|||
|
|
long_adj = short_adj = adj
|
|||
|
|
long_trigger = p_close + body * long_adj
|
|||
|
|
short_trigger = p_close - body * short_adj
|
|||
|
|
return long_trigger, short_trigger
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check_signal_confirm(
|
|||
|
|
curr: Dict,
|
|||
|
|
direction: str,
|
|||
|
|
trigger_price: float,
|
|||
|
|
all_data: List[Dict],
|
|||
|
|
current_idx: int,
|
|||
|
|
volume_ma: Optional[float],
|
|||
|
|
required: int = CONFIRM_REQUIRED,
|
|||
|
|
) -> int:
|
|||
|
|
"""
|
|||
|
|
确认条件计数:收盘价确认、成交量确认、动量确认。
|
|||
|
|
返回满足的个数。
|
|||
|
|
"""
|
|||
|
|
count = 0
|
|||
|
|
c_close = float(curr["close"])
|
|||
|
|
c_volume = float(curr.get("volume", 0))
|
|||
|
|
# 1. 收盘价确认
|
|||
|
|
if direction == "long" and c_close >= trigger_price:
|
|||
|
|
count += 1
|
|||
|
|
elif direction == "short" and c_close <= trigger_price:
|
|||
|
|
count += 1
|
|||
|
|
# 2. 成交量确认
|
|||
|
|
if volume_ma is not None and volume_ma > 0 and c_volume >= volume_ma * VOLUME_RATIO_THRESHOLD:
|
|||
|
|
count += 1
|
|||
|
|
# 3. 动量确认:当前K线实体方向与信号一致
|
|||
|
|
if direction == "long" and is_bullish(curr):
|
|||
|
|
count += 1
|
|||
|
|
elif direction == "short" and not is_bullish(curr):
|
|||
|
|
count += 1
|
|||
|
|
return count
|
|||
|
|
|
|||
|
|
|
|||
|
|
def in_forbidden_period(ts_sec: int) -> bool:
|
|||
|
|
"""是否在禁止交易时段(按 UTC+8 小时:分)"""
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
try:
|
|||
|
|
dt = datetime.fromtimestamp(ts_sec, tz=timezone.utc)
|
|||
|
|
except Exception:
|
|||
|
|
dt = datetime.utcfromtimestamp(ts_sec)
|
|||
|
|
# 转 UTC+8
|
|||
|
|
hour = (dt.hour + 8) % 24
|
|||
|
|
minute = dt.minute
|
|||
|
|
for h1, m1, h2, m2 in FORBIDDEN_PERIODS:
|
|||
|
|
t1 = h1 * 60 + m1
|
|||
|
|
t2 = h2 * 60 + m2
|
|||
|
|
t = hour * 60 + minute
|
|||
|
|
if t1 <= t < t2:
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_market_state(
|
|||
|
|
atr_value: float,
|
|||
|
|
atr_avg: Optional[float],
|
|||
|
|
trend: str,
|
|||
|
|
) -> str:
|
|||
|
|
"""normal / strong_trend / range / high_vol"""
|
|||
|
|
if atr_avg is not None and atr_avg > 0 and atr_value >= atr_avg * ATR_PAUSE_MULT:
|
|||
|
|
return "high_vol"
|
|||
|
|
if trend in ("long", "short") and atr_avg is not None and atr_value > atr_avg * 1.2:
|
|||
|
|
return "strong_trend"
|
|||
|
|
if trend == "neutral":
|
|||
|
|
return "range"
|
|||
|
|
return "normal"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check_trigger(
|
|||
|
|
all_data: List[Dict],
|
|||
|
|
current_idx: int,
|
|||
|
|
atr_series: List[Optional[float]],
|
|||
|
|
ema_5m: List[Optional[float]],
|
|||
|
|
ema_15m_align: List[Dict],
|
|||
|
|
ema_60m_align: List[Dict],
|
|||
|
|
volume_ma_list: Optional[List[Optional[float]]] = None,
|
|||
|
|
use_confirm: bool = True,
|
|||
|
|
) -> Tuple[Optional[str], Optional[float], Optional[int], Optional[Dict]]:
|
|||
|
|
"""
|
|||
|
|
检查当前K线是否产生有效信号(含趋势过滤与确认)。
|
|||
|
|
返回 (方向, 触发价, 有效前一根索引, 有效前一根K线) 或 (None, None, None, None)。
|
|||
|
|
"""
|
|||
|
|
if current_idx <= 0 or current_idx >= len(all_data):
|
|||
|
|
return None, None, None, None
|
|||
|
|
curr = all_data[current_idx]
|
|||
|
|
valid_prev_idx, prev = find_valid_prev_bar(all_data, current_idx, atr_series)
|
|||
|
|
if prev is None:
|
|||
|
|
return None, None, None, None
|
|||
|
|
atr_val = atr_series[current_idx] if current_idx < len(atr_series) else None
|
|||
|
|
if atr_val is None or atr_val <= 0:
|
|||
|
|
return None, None, None, None
|
|||
|
|
trend = get_trend(
|
|||
|
|
all_data, current_idx, ema_5m, ema_15m_align, ema_60m_align,
|
|||
|
|
)
|
|||
|
|
atr_avg = None
|
|||
|
|
if atr_series:
|
|||
|
|
valid_atr = [x for x in atr_series[: current_idx + 1] if x is not None and x > 0]
|
|||
|
|
if len(valid_atr) >= ATR_PERIOD:
|
|||
|
|
atr_avg = sum(valid_atr) / len(valid_atr)
|
|||
|
|
market_state = get_market_state(atr_val, atr_avg, trend)
|
|||
|
|
if market_state == "high_vol":
|
|||
|
|
return None, None, None, None
|
|||
|
|
long_trigger, short_trigger = get_dynamic_trigger_levels(prev, atr_val, trend, market_state)
|
|||
|
|
if long_trigger is None:
|
|||
|
|
return None, None, None, None
|
|||
|
|
c_high = float(curr["high"])
|
|||
|
|
c_low = float(curr["low"])
|
|||
|
|
long_triggered = c_high >= long_trigger
|
|||
|
|
short_triggered = c_low <= short_trigger
|
|||
|
|
direction = None
|
|||
|
|
trigger_price = None
|
|||
|
|
if long_triggered and short_triggered:
|
|||
|
|
c_open = float(curr["open"])
|
|||
|
|
if abs(c_open - short_trigger) <= abs(c_open - long_trigger):
|
|||
|
|
direction, trigger_price = "short", short_trigger
|
|||
|
|
else:
|
|||
|
|
direction, trigger_price = "long", long_trigger
|
|||
|
|
elif short_triggered:
|
|||
|
|
direction, trigger_price = "short", short_trigger
|
|||
|
|
elif long_triggered:
|
|||
|
|
direction, trigger_price = "long", long_trigger
|
|||
|
|
if direction is None:
|
|||
|
|
return None, None, None, None
|
|||
|
|
# 趋势过滤:逆势不交易(可选,这里做过滤)
|
|||
|
|
if trend == "long" and direction == "short":
|
|||
|
|
return None, None, None, None
|
|||
|
|
if trend == "short" and direction == "long":
|
|||
|
|
return None, None, None, None
|
|||
|
|
# 禁止时段
|
|||
|
|
if in_forbidden_period(curr["id"]):
|
|||
|
|
return None, None, None, None
|
|||
|
|
# 信号确认
|
|||
|
|
if use_confirm and CONFIRM_REQUIRED > 0:
|
|||
|
|
vol_ma = volume_ma_list[current_idx] if volume_ma_list and current_idx < len(volume_ma_list) else None
|
|||
|
|
n = check_signal_confirm(curr, direction, trigger_price, all_data, current_idx, vol_ma, CONFIRM_REQUIRED)
|
|||
|
|
if n < CONFIRM_REQUIRED:
|
|||
|
|
return None, None, None, None
|
|||
|
|
return direction, trigger_price, valid_prev_idx, prev
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_volume_ma(klines: List[Dict], period: int = VOLUME_MA_PERIOD) -> List[Optional[float]]:
|
|||
|
|
"""前 period-1 为 None,之后为 volume 的 SMA"""
|
|||
|
|
vol = [float(k.get("volume", 0)) for k in klines]
|
|||
|
|
out: List[Optional[float]] = [None] * (period - 1)
|
|||
|
|
for i in range(period - 1, len(vol)):
|
|||
|
|
out.append(sum(vol[i - period + 1 : i + 1]) / period)
|
|||
|
|
return out
|