Coverage for src / monte_neo / core / acceleration / indicators.py: 52%
77 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-28 16:27 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-28 16:27 +0200
1"""
2MLX-accelerated indicators.
3"""
5from typing import Any
7import mlx.core as mx
8import numpy as np
11class MLXIndicator:
12 """Base class for MLX indicators."""
13 def compute(self, close: mx.array) -> mx.array:
14 """
15 Compute indicator.
16 Args:
17 close: (N, T) matrix of close prices.
18 Returns:
19 (N, T) matrix of indicator values.
20 """
21 raise NotImplementedError
23class MLXSMA(MLXIndicator):
24 def __init__(self, period: int):
25 self.period = period
26 # Create kernel: (Out=1, Kernel=P, In=1)
27 # Simple average = sum / P
28 self.weight = mx.full((1, period, 1), 1.0 / period)
30 def compute(self, close: mx.array) -> mx.array:
31 """Compute SMA using 1D convolution."""
32 if close.dtype != mx.float32 and close.dtype != mx.float16:
33 close = close.astype(mx.float32)
34 # Reshape for conv1d: [batch, length, channels]
35 # x is [scenarios, time] -> [scenarios, time, 1]
36 x = close[..., None]
38 # Pad beginning with first value to keep same length
39 pad_size = self.period - 1
40 padding = [(0, 0), (pad_size, 0), (0, 0)]
41 x_padded = mx.pad(x, padding, constant_values=0)
42 # Fix: instead of 0, use first value to avoid spikes
43 # Actually, MLX pad constant_values can be an array/scalar.
44 # But we want to pad with the first value of EACH scenario.
45 # For now, let's use a simpler approach:
46 x_padded = mx.concatenate([mx.repeat(x[:, :1, :], pad_size, axis=1), x], axis=1)
48 out = mx.conv1d(x_padded, self.weight, stride=1, padding=0)
49 return out.squeeze(-1)
51class MLXRSI(MLXIndicator):
52 """RSI indicator on MLX."""
53 def __init__(self, period: int = 14):
54 self.period = period
56 def compute(self, close: mx.array) -> mx.array:
57 # Diff
58 diff = close[:, 1:] - close[:, :-1]
59 # First diff is 0
60 diff = mx.concatenate([mx.zeros((close.shape[0], 1)), diff], axis=1)
62 gain = mx.where(diff > 0, diff, 0.0)
63 loss = mx.where(diff < 0, -diff, 0.0)
65 # SMA of gains/losses (Simplified version of Wilders)
66 # Real RSI uses SMMA/EMA, but SMA is often used as approx or in some variants.
67 # Let's use SMA for simplicity in MLX for now.
68 sma_gain = MLXSMA(self.period).compute(gain)
69 sma_loss = MLXSMA(self.period).compute(loss)
71 rs = mx.where(sma_loss > 0, sma_gain / sma_loss, 100.0)
72 rsi = 100.0 - (100.0 / (1.0 + rs))
73 return rsi
75class MLXRollingMax(MLXIndicator):
76 def __init__(self, period: int):
77 self.period = period
79 def compute(self, close: mx.array) -> mx.array:
80 # MLX doesn't have a direct rolling_max with window,
81 # but we can use a trick with reshape or just use a loop for small windows.
82 # For large windows, we might need a more efficient implementation.
83 # For now, let's use a simple implementation.
84 n, t = close.shape
85 res = mx.zeros_like(close)
86 for i in range(t):
87 start = max(0, i - self.period + 1)
88 res[:, i] = mx.max(close[:, start:i+1], axis=1)
89 return res
91class MLXDynamicStrategy:
92 """Fallback strategy that runs Python/Numba logic on MLX data."""
93 def __init__(self, indicator: Any):
94 self.indicator = indicator
96 def generate_signals(self, close: mx.array) -> mx.array:
97 # Convert to numpy
98 close_np = np.array(close).astype(np.float32)
100 if close_np.ndim == 1:
101 # Single scenario
102 signals_np = self.indicator.generate_signals_fast(close_np)
103 return mx.array(signals_np)
105 # Multiple scenarios - run in loop for now
106 n_scenarios = close_np.shape[0]
107 n_steps = close_np.shape[1]
108 all_signals = np.zeros((n_scenarios, n_steps), dtype=np.float32)
110 for i in range(n_scenarios):
111 all_signals[i] = self.indicator.generate_signals_fast(close_np[i])
113 return mx.array(all_signals)
115class MLXCrossStrategy:
116 """Simple Crossover Strategy on GPU."""
118 def __init__(self, indicator: MLXIndicator, mode: str = "greater"):
119 self.indicator = indicator
120 self.mode = mode
122 def generate_signals(self, close: mx.array) -> mx.array:
123 ind_vals = self.indicator.compute(close)
124 if self.mode == "greater":
125 state = mx.where(close > ind_vals, 1, -1)
126 else:
127 state = mx.where(close < ind_vals, 1, -1)
129 prev_state = mx.concatenate([state[:, :1], state[:, :-1]], axis=1)
130 return mx.where(state != prev_state, state, 0)
132class MLXSMACrossStrategy:
133 """SMA Crossover Strategy on GPU (Fast vs Slow)."""
135 def __init__(self, fast_period: int, slow_period: int):
136 self.fast_sma = MLXSMA(fast_period)
137 self.slow_sma = MLXSMA(slow_period)
139 def generate_signals(self, close: mx.array) -> mx.array:
140 fast = self.fast_sma.compute(close)
141 slow = self.slow_sma.compute(close)
142 state = mx.where(fast > slow, 1, -1)
143 prev_state = mx.concatenate([state[:, :1], state[:, :-1]], axis=1)
144 return mx.where(state != prev_state, state, 0)