File size: 6,137 Bytes
fa77b99
2b13db0
 
7dcad13
 
2b13db0
 
7dcad13
2b13db0
7dcad13
2b13db0
7dcad13
 
 
2b13db0
7dcad13
2b13db0
 
 
 
7dcad13
 
 
fa77b99
2b13db0
 
 
 
fa77b99
 
2b13db0
 
 
fa77b99
2b13db0
fa77b99
7dcad13
2b13db0
7dcad13
fa77b99
2b13db0
fa77b99
2b13db0
7dcad13
 
2b13db0
 
7dcad13
2b13db0
 
7dcad13
fa77b99
2b13db0
 
 
 
 
 
 
 
 
fa77b99
 
7dcad13
 
fa77b99
 
7dcad13
 
 
 
2b13db0
 
 
fa77b99
7dcad13
2b13db0
fa77b99
2b13db0
 
 
 
fa77b99
7dcad13
2b13db0
 
 
 
 
fa77b99
7dcad13
 
 
 
fa77b99
7dcad13
2b13db0
fa77b99
7dcad13
 
 
 
 
 
fa77b99
2b13db0
fa77b99
7dcad13
2b13db0
fa77b99
7dcad13
2b13db0
fa77b99
 
2b13db0
7dcad13
2b13db0
7dcad13
 
 
 
 
 
2b13db0
 
 
fa77b99
 
7dcad13
 
fa77b99
 
 
 
7dcad13
2b13db0
 
7dcad13
 
 
2b13db0
7dcad13
2b13db0
 
fa77b99
7dcad13
2b13db0
 
7dcad13
2b13db0
 
7dcad13
2b13db0
7dcad13
2b13db0
fa77b99
7dcad13
2b13db0
 
7dcad13
fa77b99
 
 
2b13db0
7dcad13
2b13db0
7dcad13
 
2b13db0
 
7dcad13
2b13db0
 
7dcad13
fa77b99
 
 
7dcad13
 
 
 
 
fa77b99
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""
FinSight β€” Technical Analysis Engine
========================================
Pure, side-effect-free functions that add technical indicators to OHLCV
DataFrames and derive human-readable signals from the most recent bar.

All functions:
- Return new objects; inputs are never mutated.
- Are fully type-annotated.
- Raise ``ValueError`` on invalid input rather than silently returning NaN.

Indicators:
    Trend     : SMA(20, 50, 200), EMA(12, 26)
    Momentum  : MACD + Signal + Histogram, RSI(14)
    Volatility: Bollinger Bands(20, 2Οƒ), ATR(14)
    Volume    : Volume SMA(20)

Usage::

    from core.analysis.technical import add_indicators, get_signals, signal_summary
    df      = add_indicators(raw_ohlcv_df)
    signals = get_signals(df)
    bias    = signal_summary(signals)   # "BULLISH" | "BEARISH" | "MIXED" | "NEUTRAL"
"""
from __future__ import annotations

import logging

import pandas as pd
import ta
import ta.momentum
import ta.trend
import ta.volatility

log = logging.getLogger(__name__)

# ── Type alias ────────────────────────────────────────────────
SignalMap = dict[str, str]
"""``{indicator_name: "BULLISH" | "BEARISH" | "NEUTRAL"}``"""

_REQUIRED_COLS = frozenset({"Open", "High", "Low", "Close", "Volume"})


def _validate(df: pd.DataFrame) -> None:
    """Assert *df* has required OHLCV columns and is non-empty.

    Args:
        df: Input DataFrame to validate.

    Raises:
        ValueError: If *df* is empty or missing required columns.
    """
    if df.empty:
        raise ValueError("OHLCV DataFrame is empty β€” cannot compute indicators.")
    missing = _REQUIRED_COLS - set(df.columns)
    if missing:
        raise ValueError(f"DataFrame missing required columns: {missing}")


def add_indicators(df: pd.DataFrame) -> pd.DataFrame:
    """Compute technical indicators and append them as new columns.

    Args:
        df: OHLCV DataFrame with columns ``[Open, High, Low, Close, Volume]``
            and a DatetimeIndex.  The DataFrame is **not** mutated.

    Returns:
        A copy of *df* with columns: ``sma_20``, ``sma_50``, ``sma_200``,
        ``ema_12``, ``ema_26``, ``macd``, ``macd_signal``, ``macd_hist``,
        ``rsi_14``, ``bb_upper``, ``bb_middle``, ``bb_lower``, ``bb_pct``,
        ``atr_14``, ``vol_sma_20``.

    Raises:
        ValueError: If *df* is empty or missing required columns.
    """
    _validate(df)
    out = df.copy()

    close = out["Close"].squeeze()
    high  = out["High"].squeeze()
    low   = out["Low"].squeeze()
    vol   = out["Volume"].squeeze()

    # Trend
    out["sma_20"]  = ta.trend.sma_indicator(close, window=20)
    out["sma_50"]  = ta.trend.sma_indicator(close, window=50)
    out["sma_200"] = ta.trend.sma_indicator(close, window=200)
    out["ema_12"]  = ta.trend.ema_indicator(close, window=12)
    out["ema_26"]  = ta.trend.ema_indicator(close, window=26)

    _macd = ta.trend.MACD(close)
    out["macd"]        = _macd.macd()
    out["macd_signal"] = _macd.macd_signal()
    out["macd_hist"]   = _macd.macd_diff()

    # Momentum
    out["rsi_14"] = ta.momentum.rsi(close, window=14)

    # Volatility
    _bb = ta.volatility.BollingerBands(close, window=20, window_dev=2)
    out["bb_upper"]  = _bb.bollinger_hband()
    out["bb_middle"] = _bb.bollinger_mavg()
    out["bb_lower"]  = _bb.bollinger_lband()
    out["bb_pct"]    = _bb.bollinger_pband()

    out["atr_14"] = ta.volatility.average_true_range(high, low, close, window=14)

    # Volume
    out["vol_sma_20"] = ta.trend.sma_indicator(vol.astype(float), window=20)

    log.debug("indicators_added rows=%d cols=%d", len(out), len(out.columns))
    return out


def get_signals(df: pd.DataFrame) -> SignalMap:
    """Derive actionable signals from the most recent bar.

    Signal rules:
    - **RSI**    : < 30 β†’ BULLISH (oversold); > 70 β†’ BEARISH (overbought).
    - **MACD**   : histogram > 0 β†’ BULLISH; < 0 β†’ BEARISH.
    - **SMA_50** : close > SMA50  β†’ BULLISH; else BEARISH.
    - **SMA_200**: close > SMA200 β†’ BULLISH; else BEARISH.
    - **BB**     : BB%B < 0.2 β†’ BULLISH; > 0.8 β†’ BEARISH.

    Args:
        df: DataFrame returned by :func:`add_indicators`.

    Returns:
        :data:`SignalMap` mapping indicator name to signal string.
        Returns ``{}`` if *df* is empty.
    """
    if df.empty:
        return {}

    last = df.iloc[-1]
    signals: SignalMap = {}

    def _f(col: str) -> float | None:
        """Safely extract a scalar float from the last row."""
        v = last.get(col)
        try:
            return float(v) if v is not None and not pd.isna(v) else None
        except (TypeError, ValueError):
            return None

    if (rsi := _f("rsi_14")) is not None:
        signals["RSI"] = "BULLISH" if rsi < 30 else ("BEARISH" if rsi > 70 else "NEUTRAL")

    if (hist := _f("macd_hist")) is not None:
        signals["MACD"] = "BULLISH" if hist > 0 else "BEARISH"

    close = _f("Close")
    for label, col in [("SMA_50", "sma_50"), ("SMA_200", "sma_200")]:
        if close is not None and (sma := _f(col)) is not None:
            signals[label] = "BULLISH" if close > sma else "BEARISH"

    if (pct := _f("bb_pct")) is not None:
        signals["BB"] = "BULLISH" if pct < 0.2 else ("BEARISH" if pct > 0.8 else "NEUTRAL")

    log.debug("signals n=%d %s", len(signals), signals)
    return signals


def signal_summary(signals: SignalMap) -> str:
    """Aggregate individual signals into a single overall bias.

    Uses majority-vote: more BULLISH β†’ ``"BULLISH"``,
    more BEARISH β†’ ``"BEARISH"``, tie β†’ ``"MIXED"``, empty β†’ ``"NEUTRAL"``.

    Args:
        signals: :data:`SignalMap` from :func:`get_signals`.

    Returns:
        One of ``"BULLISH"``, ``"BEARISH"``, ``"MIXED"``, ``"NEUTRAL"``.
    """
    if not signals:
        return "NEUTRAL"
    b = sum(1 for v in signals.values() if v == "BULLISH")
    r = sum(1 for v in signals.values() if v == "BEARISH")
    if b > r:   return "BULLISH"
    if r > b:   return "BEARISH"
    if b == 0:  return "NEUTRAL"
    return "MIXED"