summaryrefslogtreecommitdiff
path: root/services/strategy-engine/strategies/ema_crossover_strategy.py
blob: 9c181f302a09b43da86b4adf81e2ea7264f29d47 (plain)
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
from collections import deque
from decimal import Decimal

import pandas as pd

from shared.models import Candle, OrderSide, Signal
from strategies.base import BaseStrategy


class EmaCrossoverStrategy(BaseStrategy):
    name: str = "ema_crossover"

    def __init__(self) -> None:
        super().__init__()
        self._closes: deque[float] = deque(maxlen=500)
        self._short_period: int = 9
        self._long_period: int = 21
        self._quantity: Decimal = Decimal("0.01")
        self._prev_short_above: bool | None = None
        self._pending_signal: str | None = None  # "BUY" or "SELL" if waiting for pullback
        self._pullback_enabled: bool = True
        self._pullback_tolerance: float = 0.002  # 0.2% tolerance around short EMA

    @property
    def warmup_period(self) -> int:
        return self._long_period

    def configure(self, params: dict) -> None:
        self._short_period = int(params.get("short_period", 9))
        self._long_period = int(params.get("long_period", 21))
        self._quantity = Decimal(str(params.get("quantity", "0.01")))

        self._pullback_enabled = bool(params.get("pullback_enabled", True))
        self._pullback_tolerance = float(params.get("pullback_tolerance", 0.002))

        if self._short_period >= self._long_period:
            raise ValueError(
                f"EMA short_period must be < long_period, "
                f"got short={self._short_period}, long={self._long_period}"
            )
        if self._short_period < 2:
            raise ValueError(f"EMA short_period must be >= 2, got {self._short_period}")
        if self._long_period < 2:
            raise ValueError(f"EMA long_period must be >= 2, got {self._long_period}")
        if self._quantity <= 0:
            raise ValueError(f"Quantity must be positive, got {self._quantity}")

        self._init_filters(
            require_trend=True,
            adx_threshold=float(params.get("adx_threshold", 25.0)),
            min_volume_ratio=float(params.get("min_volume_ratio", 0.5)),
            atr_stop_multiplier=float(params.get("atr_stop_multiplier", 2.0)),
            atr_tp_multiplier=float(params.get("atr_tp_multiplier", 3.0)),
        )

    def reset(self) -> None:
        super().reset()
        self._closes.clear()
        self._prev_short_above = None
        self._pending_signal = None

    def _ema_conviction(self, short_ema: float, long_ema: float, price: float) -> float:
        """Map EMA gap to conviction (0.1-1.0). Larger gap = stronger crossover."""
        if price == 0:
            return 0.5
        gap_pct = abs(short_ema - long_ema) / price
        # Scale: 0% gap -> 0.1, 1%+ gap -> ~1.0
        return min(1.0, max(0.1, gap_pct * 100))

    def on_candle(self, candle: Candle) -> Signal | None:
        self._update_filter_data(candle)
        self._closes.append(float(candle.close))

        if len(self._closes) < self._long_period:
            return None

        series = pd.Series(list(self._closes))
        short_ema = series.ewm(span=self._short_period, adjust=False).mean().iloc[-1]
        long_ema = series.ewm(span=self._long_period, adjust=False).mean().iloc[-1]

        close = float(candle.close)
        short_above = short_ema > long_ema

        signal = None
        if self._prev_short_above is not None:
            prev = self._prev_short_above
            conviction = self._ema_conviction(short_ema, long_ema, close)

            # Golden Cross detected
            if not prev and short_above:
                if self._pullback_enabled:
                    self._pending_signal = "BUY"
                    # Don't signal yet — wait for pullback
                else:
                    signal = Signal(
                        strategy=self.name,
                        symbol=candle.symbol,
                        side=OrderSide.BUY,
                        price=candle.close,
                        quantity=self._quantity,
                        conviction=conviction,
                        reason=f"Golden Cross: short EMA ({short_ema:.2f}) crossed above long EMA ({long_ema:.2f})",
                    )

            # Death Cross detected
            elif prev and not short_above:
                if self._pullback_enabled:
                    self._pending_signal = "SELL"
                else:
                    signal = Signal(
                        strategy=self.name,
                        symbol=candle.symbol,
                        side=OrderSide.SELL,
                        price=candle.close,
                        quantity=self._quantity,
                        conviction=conviction,
                        reason=f"Death Cross: short EMA ({short_ema:.2f}) crossed below long EMA ({long_ema:.2f})",
                    )

        self._prev_short_above = short_above

        if signal is not None:
            return self._apply_filters(signal)

        # Check for pullback entry
        if self._pending_signal == "BUY":
            distance = abs(close - short_ema) / short_ema if short_ema > 0 else 999
            if distance <= self._pullback_tolerance:
                self._pending_signal = None
                conv = min(0.5 + (1.0 - distance / self._pullback_tolerance) * 0.5, 1.0)
                signal = Signal(
                    strategy=self.name,
                    symbol=candle.symbol,
                    side=OrderSide.BUY,
                    price=candle.close,
                    quantity=self._quantity,
                    conviction=conv,
                    reason=f"EMA Golden Cross pullback entry (distance={distance:.4f})",
                )
                return self._apply_filters(signal)
            # Cancel if crossover reverses
            if not short_above:
                self._pending_signal = None

        if self._pending_signal == "SELL":
            distance = abs(close - short_ema) / short_ema if short_ema > 0 else 999
            if distance <= self._pullback_tolerance:
                self._pending_signal = None
                conv = min(0.5 + (1.0 - distance / self._pullback_tolerance) * 0.5, 1.0)
                signal = Signal(
                    strategy=self.name,
                    symbol=candle.symbol,
                    side=OrderSide.SELL,
                    price=candle.close,
                    quantity=self._quantity,
                    conviction=conv,
                    reason=f"EMA Death Cross pullback entry (distance={distance:.4f})",
                )
                return self._apply_filters(signal)
            # Cancel if crossover reverses
            if short_above:
                self._pending_signal = None

        return None