파이프라인 산출물(data/, docs/)을 Git 추적에서 제외하고 히스토리를 단일 커밋으로 재구성해 저장소 용량을 경량화한다. Co-authored-by: Cursor <cursoragent@cursor.com>
304 lines
9.6 KiB
Python
304 lines
9.6 KiB
Python
"""RSI·MACD 다이버전스 매수·매도 타점 (Ground Truth, 사후 검증)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import pandas as pd
|
|
|
|
from deepcoin.techniques.indicators import macd, rsi
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DivergenceSignal:
|
|
"""다이버전스 신호."""
|
|
|
|
side: str # buy | sell
|
|
bar_index: int
|
|
price: float
|
|
datetime: pd.Timestamp
|
|
indicator: str # rsi | macd_hist
|
|
price_prev: float
|
|
price_curr: float
|
|
ind_prev: float
|
|
ind_curr: float
|
|
|
|
|
|
def find_divergence_signals(
|
|
df: pd.DataFrame,
|
|
local_order: int = 15,
|
|
min_bars_between: int = 100,
|
|
max_pair_lookback_bars: int = 5000,
|
|
rsi_period: int = 14,
|
|
min_rsi_diff: float = 2.0,
|
|
min_macd_hist_diff: float = 0.0,
|
|
min_price_move_pct: float = 1.5,
|
|
future_bars: int = 2000,
|
|
min_future_move_pct: float = 2.0,
|
|
) -> tuple[list[DivergenceSignal], list[DivergenceSignal]]:
|
|
"""가격·지표 다이버전스 매수·매도 후보를 찾는다.
|
|
|
|
- 상승 다이버전스(매수): 가격 LL + RSI/MACD HL
|
|
- 하락 다이버전스(매도): 가격 HH + RSI/MACD LH
|
|
- 미래 데이터로 이후 유의미한 반등·하락을 사후 검증
|
|
|
|
Args:
|
|
df: OHLCV DataFrame.
|
|
local_order: 국소 극값 반경(봉).
|
|
min_bars_between: 연속 다이버전스 최소 간격(봉).
|
|
max_pair_lookback_bars: 비교할 이전 극값 최대 거리(봉).
|
|
rsi_period: RSI 기간.
|
|
min_rsi_diff: RSI 다이버전스 최소 차이(포인트).
|
|
min_macd_hist_diff: MACD 히스토그램 최소 차이.
|
|
min_price_move_pct: 극값 간 최소 가격 변동(%).
|
|
future_bars: 사후 검증 구간(봉).
|
|
min_future_move_pct: 사후 최소 가격 변동(%).
|
|
|
|
Returns:
|
|
(매수 신호, 매도 신호) 리스트.
|
|
"""
|
|
if len(df) < local_order * 4 + rsi_period:
|
|
return [], []
|
|
|
|
close = df["close"].astype(float)
|
|
low = df["low"].astype(float)
|
|
high = df["high"].astype(float)
|
|
rsi_vals = rsi(close, period=rsi_period)
|
|
_, _, macd_hist = macd(close)
|
|
|
|
lows = _find_local_extrema(df, low, rsi_vals, macd_hist, local_order, "low")
|
|
highs = _find_local_extrema(df, high, rsi_vals, macd_hist, local_order, "high")
|
|
|
|
bull_rsi = _detect_bullish(
|
|
df, lows, rsi_vals, "rsi", min_rsi_diff, min_price_move_pct,
|
|
max_pair_lookback_bars, future_bars, min_future_move_pct, high,
|
|
)
|
|
bear_rsi = _detect_bearish(
|
|
df, highs, rsi_vals, "rsi", min_rsi_diff, min_price_move_pct,
|
|
max_pair_lookback_bars, future_bars, min_future_move_pct, low,
|
|
)
|
|
|
|
bull_macd: list[DivergenceSignal] = []
|
|
bear_macd: list[DivergenceSignal] = []
|
|
if min_macd_hist_diff > 0:
|
|
bull_macd = _detect_bullish(
|
|
df, lows, macd_hist, "macd_hist", min_macd_hist_diff, min_price_move_pct,
|
|
max_pair_lookback_bars, future_bars, min_future_move_pct, high,
|
|
)
|
|
bear_macd = _detect_bearish(
|
|
df, highs, macd_hist, "macd_hist", min_macd_hist_diff, min_price_move_pct,
|
|
max_pair_lookback_bars, future_bars, min_future_move_pct, low,
|
|
)
|
|
|
|
buys = _dedupe_signals(
|
|
_merge_same_bar(bull_rsi + bull_macd), min_bars_between, prefer_lower=True
|
|
)
|
|
sells = _dedupe_signals(
|
|
_merge_same_bar(bear_rsi + bear_macd), min_bars_between, prefer_lower=False
|
|
)
|
|
return buys, sells
|
|
|
|
|
|
@dataclass
|
|
class _ExtremePoint:
|
|
"""국소 극값."""
|
|
|
|
bar_index: int
|
|
price: float
|
|
rsi: float
|
|
macd_hist: float
|
|
datetime: pd.Timestamp
|
|
|
|
|
|
def _find_local_extrema(
|
|
df: pd.DataFrame,
|
|
series: pd.Series,
|
|
rsi_vals: pd.Series,
|
|
macd_hist: pd.Series,
|
|
order: int,
|
|
side: str,
|
|
) -> list[_ExtremePoint]:
|
|
"""국소 저점·고점을 수집한다."""
|
|
points: list[_ExtremePoint] = []
|
|
values = series.values
|
|
|
|
for i in range(order, len(df) - order):
|
|
window = values[i - order : i + order + 1]
|
|
val = float(values[i])
|
|
if side == "low" and val > window.min():
|
|
continue
|
|
if side == "high" and val < window.max():
|
|
continue
|
|
if pd.isna(rsi_vals.iloc[i]) or pd.isna(macd_hist.iloc[i]):
|
|
continue
|
|
points.append(
|
|
_ExtremePoint(
|
|
bar_index=i,
|
|
price=val,
|
|
rsi=float(rsi_vals.iloc[i]),
|
|
macd_hist=float(macd_hist.iloc[i]),
|
|
datetime=pd.Timestamp(df.iloc[i]["datetime"]),
|
|
)
|
|
)
|
|
return points
|
|
|
|
|
|
def _detect_bullish(
|
|
df: pd.DataFrame,
|
|
lows: list[_ExtremePoint],
|
|
indicator: pd.Series,
|
|
indicator_name: str,
|
|
min_ind_diff: float,
|
|
min_price_move_pct: float,
|
|
max_lookback: int,
|
|
future_bars: int,
|
|
min_future_move_pct: float,
|
|
highs,
|
|
) -> list[DivergenceSignal]:
|
|
"""상승 다이버전스 매수를 탐지한다."""
|
|
signals: list[DivergenceSignal] = []
|
|
if len(lows) < 2:
|
|
return signals
|
|
|
|
for idx in range(1, len(lows)):
|
|
curr = lows[idx]
|
|
for prev in reversed(lows[:idx]):
|
|
if curr.bar_index - prev.bar_index > max_lookback:
|
|
break
|
|
if curr.bar_index - prev.bar_index < 20:
|
|
continue
|
|
|
|
price_move = (prev.price - curr.price) / prev.price * 100.0
|
|
if price_move < min_price_move_pct:
|
|
continue
|
|
|
|
ind_prev = float(indicator.iloc[prev.bar_index])
|
|
ind_curr = float(indicator.iloc[curr.bar_index])
|
|
if pd.isna(ind_prev) or pd.isna(ind_curr):
|
|
continue
|
|
if not (curr.price < prev.price and ind_curr > ind_prev + min_ind_diff):
|
|
continue
|
|
|
|
end = min(len(df), curr.bar_index + future_bars + 1)
|
|
future_high = float(highs[curr.bar_index:end].max())
|
|
rally = (future_high - curr.price) / curr.price * 100.0
|
|
if rally < min_future_move_pct:
|
|
continue
|
|
|
|
signals.append(
|
|
DivergenceSignal(
|
|
side="buy",
|
|
bar_index=curr.bar_index,
|
|
price=round(curr.price, 2),
|
|
datetime=curr.datetime,
|
|
indicator=indicator_name,
|
|
price_prev=round(prev.price, 2),
|
|
price_curr=round(curr.price, 2),
|
|
ind_prev=round(ind_prev, 4),
|
|
ind_curr=round(ind_curr, 4),
|
|
)
|
|
)
|
|
break
|
|
|
|
return signals
|
|
|
|
|
|
def _detect_bearish(
|
|
df: pd.DataFrame,
|
|
highs: list[_ExtremePoint],
|
|
indicator: pd.Series,
|
|
indicator_name: str,
|
|
min_ind_diff: float,
|
|
min_price_move_pct: float,
|
|
max_lookback: int,
|
|
future_bars: int,
|
|
min_future_move_pct: float,
|
|
lows,
|
|
) -> list[DivergenceSignal]:
|
|
"""하락 다이버전스 매도를 탐지한다."""
|
|
signals: list[DivergenceSignal] = []
|
|
if len(highs) < 2:
|
|
return signals
|
|
|
|
for idx in range(1, len(highs)):
|
|
curr = highs[idx]
|
|
for prev in reversed(highs[:idx]):
|
|
if curr.bar_index - prev.bar_index > max_lookback:
|
|
break
|
|
if curr.bar_index - prev.bar_index < 20:
|
|
continue
|
|
|
|
price_move = (curr.price - prev.price) / prev.price * 100.0
|
|
if price_move < min_price_move_pct:
|
|
continue
|
|
|
|
ind_prev = float(indicator.iloc[prev.bar_index])
|
|
ind_curr = float(indicator.iloc[curr.bar_index])
|
|
if pd.isna(ind_prev) or pd.isna(ind_curr):
|
|
continue
|
|
if not (curr.price > prev.price and ind_curr < ind_prev - min_ind_diff):
|
|
continue
|
|
|
|
end = min(len(df), curr.bar_index + future_bars + 1)
|
|
future_low = float(lows[curr.bar_index:end].min())
|
|
drop = (curr.price - future_low) / curr.price * 100.0
|
|
if drop < min_future_move_pct:
|
|
continue
|
|
|
|
signals.append(
|
|
DivergenceSignal(
|
|
side="sell",
|
|
bar_index=curr.bar_index,
|
|
price=round(curr.price, 2),
|
|
datetime=curr.datetime,
|
|
indicator=indicator_name,
|
|
price_prev=round(prev.price, 2),
|
|
price_curr=round(curr.price, 2),
|
|
ind_prev=round(ind_prev, 4),
|
|
ind_curr=round(ind_curr, 4),
|
|
)
|
|
)
|
|
break
|
|
|
|
return signals
|
|
|
|
|
|
def _merge_same_bar(signals: list[DivergenceSignal]) -> list[DivergenceSignal]:
|
|
"""동일 봉·동일 방향 신호를 하나로 합친다."""
|
|
by_bar: dict[int, DivergenceSignal] = {}
|
|
for signal in signals:
|
|
prev = by_bar.get(signal.bar_index)
|
|
if prev is None:
|
|
by_bar[signal.bar_index] = signal
|
|
continue
|
|
prev_diff = abs(prev.ind_curr - prev.ind_prev)
|
|
curr_diff = abs(signal.ind_curr - signal.ind_prev)
|
|
if curr_diff > prev_diff:
|
|
by_bar[signal.bar_index] = signal
|
|
return sorted(by_bar.values(), key=lambda s: s.bar_index)
|
|
|
|
|
|
def _dedupe_signals(
|
|
signals: list[DivergenceSignal],
|
|
min_bars: int,
|
|
prefer_lower: bool,
|
|
) -> list[DivergenceSignal]:
|
|
"""근접 신호를 병합한다."""
|
|
if not signals:
|
|
return []
|
|
|
|
sorted_signals = sorted(signals, key=lambda s: s.bar_index)
|
|
merged: list[DivergenceSignal] = [sorted_signals[0]]
|
|
|
|
for signal in sorted_signals[1:]:
|
|
last = merged[-1]
|
|
if signal.bar_index - last.bar_index < min_bars:
|
|
if prefer_lower and signal.price < last.price:
|
|
merged[-1] = signal
|
|
elif not prefer_lower and signal.price > last.price:
|
|
merged[-1] = signal
|
|
else:
|
|
merged.append(signal)
|
|
|
|
return merged
|