"""기술적 지표 계산 (인과 신호용).""" from __future__ import annotations import pandas as pd def ema(series: pd.Series, span: int) -> pd.Series: """지수이동평균을 계산한다.""" return series.ewm(span=span, adjust=False).mean() def sma(series: pd.Series, window: int) -> pd.Series: """단순이동평균을 계산한다.""" return series.rolling(window=window, min_periods=window).mean() def bollinger_bands( close: pd.Series, window: int = 20, num_std: float = 2.0, ) -> tuple[pd.Series, pd.Series, pd.Series]: """볼린저 밴드 (중심, 상단, 하단)를 계산한다.""" mid = sma(close, window) std = close.rolling(window=window, min_periods=window).std() upper = mid + num_std * std lower = mid - num_std * std return mid, upper, lower def rsi(close: pd.Series, period: int = 14) -> pd.Series: """RSI(상대강도지수)를 계산한다.""" delta = close.diff() gain = delta.clip(lower=0.0) loss = -delta.clip(upper=0.0) avg_gain = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() avg_loss = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() rs = avg_gain / avg_loss.replace(0, pd.NA) return 100 - (100 / (1 + rs)) def macd( close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9, ) -> tuple[pd.Series, pd.Series, pd.Series]: """MACD, 시그널, 히스토그램을 계산한다.""" ema_fast = ema(close, fast) ema_slow = ema(close, slow) macd_line = ema_fast - ema_slow signal_line = ema(macd_line, signal) hist = macd_line - signal_line return macd_line, signal_line, hist def atr( high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14, ) -> pd.Series: """Average True Range (ATR)를 계산한다. Args: high: 고가 시리즈. low: 저가 시리즈. close: 종가 시리즈. period: ATR 기간. Returns: ATR 시리즈. """ prev_close = close.shift(1) tr = pd.concat( [ (high - low).abs(), (high - prev_close).abs(), (low - prev_close).abs(), ], axis=1, ).max(axis=1) return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() def stochastic( high: pd.Series, low: pd.Series, close: pd.Series, k_period: int = 14, d_period: int = 3, ) -> tuple[pd.Series, pd.Series]: """Stochastic %K, %D를 계산한다.""" import numpy as np close_f = close.astype(float) lowest = low.astype(float).rolling(window=k_period, min_periods=k_period).min() highest = high.astype(float).rolling(window=k_period, min_periods=k_period).max() range_hl = highest - lowest pct_k = pd.Series( np.where(range_hl > 0, 100.0 * (close_f - lowest) / range_hl, np.nan), index=close.index, dtype=float, ) pct_d = pct_k.rolling(window=d_period, min_periods=d_period).mean() return pct_k, pct_d def cci( high: pd.Series, low: pd.Series, close: pd.Series, period: int = 20, ) -> pd.Series: """Commodity Channel Index를 계산한다.""" tp = (high + low + close) / 3.0 sma_tp = sma(tp, period) mean_dev = (tp - sma_tp).abs().rolling(window=period, min_periods=period).mean() return (tp - sma_tp) / (0.015 * mean_dev.replace(0, pd.NA)) def roc(close: pd.Series, period: int = 12) -> pd.Series: """Rate of Change(%)를 계산한다.""" prev = close.shift(period) return (close - prev) / prev.replace(0, pd.NA) * 100.0 def adx( high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14, ) -> tuple[pd.Series, pd.Series, pd.Series]: """ADX, +DI, -DI를 계산한다.""" up_move = high.diff() down_move = -low.diff() plus_dm = up_move.where((up_move > down_move) & (up_move > 0), 0.0) minus_dm = down_move.where((down_move > up_move) & (down_move > 0), 0.0) atr_vals = atr(high, low, close, period=period) plus_di = 100 * ema(plus_dm, period) / atr_vals.replace(0, pd.NA) minus_di = 100 * ema(minus_dm, period) / atr_vals.replace(0, pd.NA) dx = (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, pd.NA) * 100 adx_line = ema(dx, period) return adx_line, plus_di, minus_di def keltner_channels( high: pd.Series, low: pd.Series, close: pd.Series, ema_span: int = 20, atr_period: int = 10, atr_mult: float = 2.0, ) -> tuple[pd.Series, pd.Series, pd.Series]: """Keltner 채널 (중심, 상단, 하단)을 계산한다.""" mid = ema(close, ema_span) atr_vals = atr(high, low, close, period=atr_period) upper = mid + atr_mult * atr_vals lower = mid - atr_mult * atr_vals return mid, upper, lower def obv(close: pd.Series, volume: pd.Series) -> pd.Series: """On-Balance Volume을 계산한다.""" direction = close.diff().apply(lambda x: 1 if x > 0 else (-1 if x < 0 else 0)) return (direction * volume.astype(float)).cumsum() def supertrend( high: pd.Series, low: pd.Series, close: pd.Series, period: int = 10, multiplier: float = 3.0, ) -> tuple[pd.Series, pd.Series]: """Supertrend 라인과 방향(1=상승, -1=하락)을 계산한다.""" atr_vals = atr(high, low, close, period=period) hl2 = (high + low) / 2.0 basic_upper = hl2 + multiplier * atr_vals basic_lower = hl2 - multiplier * atr_vals final_upper = basic_upper.copy() final_lower = basic_lower.copy() direction = pd.Series(1, index=close.index, dtype=float) st_line = pd.Series(index=close.index, dtype=float) for i in range(1, len(close)): if pd.isna(final_upper.iloc[i]) or pd.isna(final_lower.iloc[i]): continue if basic_upper.iloc[i] < final_upper.iloc[i - 1] or close.iloc[i - 1] > final_upper.iloc[i - 1]: final_upper.iloc[i] = basic_upper.iloc[i] else: final_upper.iloc[i] = final_upper.iloc[i - 1] if basic_lower.iloc[i] > final_lower.iloc[i - 1] or close.iloc[i - 1] < final_lower.iloc[i - 1]: final_lower.iloc[i] = basic_lower.iloc[i] else: final_lower.iloc[i] = final_lower.iloc[i - 1] if direction.iloc[i - 1] == 1: if close.iloc[i] < final_lower.iloc[i]: direction.iloc[i] = -1 else: direction.iloc[i] = 1 else: if close.iloc[i] > final_upper.iloc[i]: direction.iloc[i] = 1 else: direction.iloc[i] = -1 st_line.iloc[i] = final_lower.iloc[i] if direction.iloc[i] == 1 else final_upper.iloc[i] return st_line, direction def parabolic_sar( high: pd.Series, low: pd.Series, close: pd.Series, af_step: float = 0.02, af_max: float = 0.2, ) -> pd.Series: """Parabolic SAR 근사값을 계산한다.""" length = len(close) sar = pd.Series(index=close.index, dtype=float) if length < 2: return sar bull = True af = af_step ep = float(high.iloc[0]) sar.iloc[0] = float(low.iloc[0]) for i in range(1, length): prev_sar = float(sar.iloc[i - 1]) if not pd.isna(sar.iloc[i - 1]) else float(low.iloc[0]) curr_sar = prev_sar + af * (ep - prev_sar) h = float(high.iloc[i]) low_i = float(low.iloc[i]) if bull: curr_sar = min(curr_sar, float(low.iloc[i - 1]), low_i) if low_i < curr_sar: bull = False curr_sar = ep ep = low_i af = af_step else: if h > ep: ep = h af = min(af + af_step, af_max) else: curr_sar = max(curr_sar, float(high.iloc[i - 1]), h) if h > curr_sar: bull = True curr_sar = ep ep = h af = af_step else: if low_i < ep: ep = low_i af = min(af + af_step, af_max) sar.iloc[i] = curr_sar return sar def ichimoku( high: pd.Series, low: pd.Series, tenkan: int = 9, kijun: int = 26, ) -> tuple[pd.Series, pd.Series]: """일목 전환선·기준선을 계산한다 (인과 신호용 간소 버전).""" tenkan_sen = (high.rolling(tenkan).max() + low.rolling(tenkan).min()) / 2.0 kijun_sen = (high.rolling(kijun).max() + low.rolling(kijun).min()) / 2.0 return tenkan_sen, kijun_sen def rolling_pivot_points( high: pd.Series, low: pd.Series, close: pd.Series, window: int = 60, ) -> tuple[pd.Series, pd.Series, pd.Series]: """롤링 피벗 P, S1, R1을 계산한다.""" prev_high = high.shift(1).rolling(window).max() prev_low = low.shift(1).rolling(window).min() prev_close = close.shift(1) pivot = (prev_high + prev_low + prev_close) / 3.0 s1 = 2 * pivot - prev_high r1 = 2 * pivot - prev_low return pivot, s1, r1