File size: 15,701 Bytes
1ccf652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
"""

Bot Shape Classifier



Uses incoming market price streams and orderbook analyses to compute short-time

spectrograms, extract spectral features, and classify observed bot/whale shapes

(e.g., grid, spiral, oscillator, taper, accumulation/distribution).



Publishes:

- topic: `whale.sonar.spectrogram` payload: {symbol, detected_at, peaks, centroid, bandwidth}

- topic: `whale.shape.detected` payload: {symbol, detected_at, shape: {subtype, score, reason}, spectrogram_summary}

"""
from __future__ import annotations
from aureon.core.aureon_baton_link import link_system as _baton_link; _baton_link(__name__)

import logging
import math
import time
from collections import defaultdict, deque
from dataclasses import asdict
from typing import Any, Dict, List, Optional

import numpy as np

from aureon.core.aureon_thought_bus import get_thought_bus, Thought

try:
    from aureon.bots.whale_metrics import whale_shape_detected_total
    METRICS_AVAILABLE = True
except ImportError:
    METRICS_AVAILABLE = False
    whale_shape_detected_total = None

# ════════════════════════════════════════════════════════════════════════════════
# 🐦 CHIRP BUS INTEGRATION - Emit strong bot patterns to Orca!
# ════════════════════════════════════════════════════════════════════════════════
CHIRP_BUS_AVAILABLE = False
get_chirp_bus = None
try:
    from aureon.core.aureon_chirp_bus import get_chirp_bus
    CHIRP_BUS_AVAILABLE = True
except ImportError:
    CHIRP_BUS_AVAILABLE = False

# ════════════════════════════════════════════════════════════════════════════════
# 🌐 GLOBAL MARKET INTEGRATION
# ════════════════════════════════════════════════════════════════════════════════
try:
    from aureon.exchanges.kraken_client import KrakenClient
    KRAKEN_AVAILABLE = True
except ImportError:
    KRAKEN_AVAILABLE = False
    KrakenClient = None

try:
    from aureon.exchanges.binance_ws_client import BinanceWebSocketClient
    BINANCE_WS_AVAILABLE = True
except ImportError:
    BINANCE_WS_AVAILABLE = False
    BinanceWebSocketClient = None

try:
    from aureon.exchanges.capital_client import CapitalClient
    CAPITAL_AVAILABLE = True
except ImportError:
    CAPITAL_AVAILABLE = False
    CapitalClient = None

logger = logging.getLogger(__name__)


class BotShapeClassifier:
    def __init__(self, sample_window: int = 128, hop: int = 32):
        self.thought_bus = get_thought_bus()
        # price history per symbol: deque of (ts, price)
        self.price_hist: Dict[str, deque] = defaultdict(lambda: deque(maxlen=1024))
        self.sample_window = int(sample_window)
        self.hop = int(hop)
        
        # 🌐 GLOBAL MARKET CLIENTS
        self.kraken_client: Optional[Any] = None
        self.binance_ws: Optional[Any] = None
        self.capital_client: Optional[Any] = None
        
        # Initialize market connections
        self._init_market_connections()

        # 🐦 CHIRP BUS - Emit strong bot patterns to Orca
        self.chirp_bus = None
        if CHIRP_BUS_AVAILABLE and get_chirp_bus:
            try:
                self.chirp_bus = get_chirp_bus()
                logger.info("🐦 Bot Shape Classifier β†’ Orca CHIRP BUS connected")
            except Exception as e:
                logger.debug(f"Chirp bus init failed: {e}")

        # subscribe to market price updates and orderbook analyses
        try:
            self.thought_bus.subscribe('market.*', self._on_market)
        except Exception:
            logger.debug('BotShapeClassifier: failed to subscribe to market.*')
        try:
            self.thought_bus.subscribe('whale.orderbook.analyzed', self._on_orderbook)
        except Exception:
            logger.debug('BotShapeClassifier: failed to subscribe to whale.orderbook.analyzed')

    def _init_market_connections(self) -> None:
        """Initialize optional exchange client connections for live data."""
        if KRAKEN_AVAILABLE and KrakenClient is not None:
            try:
                self.kraken_client = KrakenClient()
                logger.info("BotShapeClassifier: Kraken client connected")
            except Exception as e:
                logger.debug(f"BotShapeClassifier: Kraken init failed: {e}")

        if BINANCE_WS_AVAILABLE and BinanceWebSocketClient is not None:
            try:
                self.binance_ws = BinanceWebSocketClient()
                logger.info("BotShapeClassifier: Binance WS connected")
            except Exception as e:
                logger.debug(f"BotShapeClassifier: Binance WS init failed: {e}")

        if CAPITAL_AVAILABLE and CapitalClient is not None:
            try:
                self.capital_client = CapitalClient()
                logger.info("BotShapeClassifier: Capital.com client connected")
            except Exception as e:
                logger.debug(f"BotShapeClassifier: Capital.com init failed: {e}")

    def _on_market(self, thought: Thought) -> None:
        payload = thought.payload or {}
        symbol = payload.get('symbol') or payload.get('s')
        price = payload.get('price') or payload.get('c') or payload.get('close')
        ts = payload.get('ts') or thought.ts or time.time()
        try:
            if not symbol or price is None:
                return
            price = float(price)
            self.price_hist[symbol].append((ts, price))
        except Exception:
            return

    def _on_orderbook(self, thought: Thought) -> None:
        payload = thought.payload or {}
        symbol = payload.get('symbol')
        exchange = payload.get('exchange') or payload.get('exch')
        if not symbol:
            return
        # If we have enough price history, compute spectrogram and classify
        ph = list(self.price_hist.get(symbol, []))
        if len(ph) < self.sample_window:
            # not enough data -> try to fetch from external cache (best-effort)
            logger.debug('Not enough price history for %s (have=%d need=%d)', symbol, len(ph), self.sample_window)
            return

        timestamps, prices = zip(*ph[-self.sample_window:])
        prices = np.array(prices)

        # Integrate harmonic analyzer (if available) for frequency/coherence/phase features
        harmonic_info = {}
        try:
            from aureon.bridges.aureon_probability_nexus import HarmonicAnalyzer
            ha = HarmonicAnalyzer()
            # HarmonicAnalyzer expects a price list; pass the last 100 prices if available
            freq, coh, ph = ha.analyze(list(prices[-100:]))
            harmonic_info = {'frequency': float(freq), 'coherence': float(coh), 'phase': float(ph)}
        except Exception:
            harmonic_info = {}

        # Work with log returns to emphasize micro-movements
        returns = np.diff(np.log(prices + 1e-12))
        if len(returns) < 8:
            return

        # Build spectrogram via short-time FFT
        S = self._stft(returns, n_fft=self.sample_window//2, hop=self.hop)
        # magnitude spectrogram
        mag = np.abs(S)
        # Normalize per-column
        mag_norm = mag / (np.max(mag, axis=0, keepdims=True) + 1e-9)

        # Spectral features
        centroid = self._spectral_centroid(mag)
        bandwidth = self._spectral_bandwidth(mag, centroid)
        peaks = self._dominant_peaks(mag, top_n=3)
        flatness = self._spectral_flatness(mag)
        energy = float(np.sum(mag))

        spectro_summary = {
            'shape_frames': mag.shape[1],
            'centroid': float(centroid),
            'bandwidth': float(bandwidth),
            'flatness': float(flatness),
            'energy': float(energy),
            'peaks': peaks,
            'harmonic': harmonic_info,
        }

        # Heuristic classification
        layering = float(payload.get('layering_score', 0.0) or 0.0)
        bids_depth = float(payload.get('bids_depth', 0.0) or 0.0)
        asks_depth = float(payload.get('asks_depth', 0.0) or 0.0)

        subtype, score, reason = self._classify_shape(spectro_summary, layering, bids_depth, asks_depth)

        detected = {
            'symbol': symbol,
            'exchange': exchange,
            'detected_at': time.time(),
            'shape': {'subtype': subtype, 'score': float(score), 'reason': reason},
            'spectrogram': spectro_summary,
        }
        
        # Emit strong patterns to Orca for trading signals
        self.emit_shape_to_orca(symbol, detected['shape'], exchange, spectro_summary)

        # Publish both a spectrogram thought (compact) and a shape classification
        s_th = Thought(source='bot_shape_classifier', topic='whale.sonar.spectrogram', payload={'symbol': symbol, 'spectrogram': spectro_summary})
        try:
            self.thought_bus.publish(s_th)
        except Exception:
            logger.debug('Failed to publish whale.sonar.spectrogram')

        # A-Z-Z-A echo signature (simple mirrored peaks) to model echolocation-like ping
        try:
            peak_bins = [p['bin'] for p in peaks]
            echo_sig = peak_bins + peak_bins[::-1]
            echo_payload = {'symbol': symbol, 'echo_signature': echo_sig, 'detected_at': time.time()}
            e_th = Thought(source='bot_shape_classifier', topic='whale.sonar.echo', payload=echo_payload)
            self.thought_bus.publish(e_th)
        except Exception:
            logger.debug('Failed to publish whale.sonar.echo')

        th = Thought(source='bot_shape_classifier', topic='whale.shape.detected', payload=detected)
        try:
            self.thought_bus.publish(th)
            # Emit metrics
            if METRICS_AVAILABLE and whale_shape_detected_total:
                whale_shape_detected_total.inc(subtype=subtype, symbol=symbol, exchange=exchange or 'unknown')
        except Exception:
            logger.debug('Failed to publish whale.shape.detected')

    # ----------------- signal processing helpers -----------------
    def _stft(self, x: np.ndarray, n_fft: int = 32, hop: int = 8) -> np.ndarray:
        n = len(x)
        if n < n_fft:
            n_fft = max(8, n//2)
        frames = []
        for start in range(0, n - n_fft + 1, hop):
            win = x[start:start+n_fft] * np.hanning(n_fft)
            fft = np.fft.rfft(win)
            frames.append(fft)
        if not frames:
            return np.zeros((n_fft//2+1, 0), dtype=np.complex64)
        return np.column_stack(frames)

    def _spectral_centroid(self, mag: np.ndarray) -> float:
        freqs = np.arange(mag.shape[0])
        power = np.mean(mag, axis=1)
        num = np.sum(freqs * power)
        den = np.sum(power) + 1e-12
        return float(num / den)

    def _spectral_bandwidth(self, mag: np.ndarray, centroid: float) -> float:
        freqs = np.arange(mag.shape[0])
        power = np.mean(mag, axis=1)
        num = np.sum(((freqs - centroid) ** 2) * power)
        den = np.sum(power) + 1e-12
        return float(math.sqrt(num / den))

    def _dominant_peaks(self, mag: np.ndarray, top_n: int = 3) -> List[Dict[str, float]]:
        mean_spec = np.mean(mag, axis=1)
        idx = np.argsort(mean_spec)[::-1][:top_n]
        return [{'bin': int(i), 'power': float(mean_spec[i])} for i in idx]

    def _spectral_flatness(self, mag: np.ndarray) -> float:
        mean_spec = np.mean(mag, axis=1) + 1e-12
        geo = float(np.exp(np.mean(np.log(mean_spec))))
        arith = float(np.mean(mean_spec))
        return float(geo / (arith + 1e-12))

    # ----------------- classification heuristics -----------------
    def _classify_shape(self, spectro: Dict[str, Any], layering: float, bids_depth: float, asks_depth: float):
        centroid = spectro['centroid']
        flatness = spectro['flatness']
        energy = spectro['energy']
        peaks = spectro['peaks']

        # Heuristic rules:
        # - Grid: high layering or low flatness with multiple evenly strong peaks
        # - Oscillator: dominant single peak, low bandwidth
        # - Spiral: shifting centroid across frames (not trivially detectable here) -> use bandwidth>threshold
        # - Taper: energy low and flatness increasing
        # - Accumulation/Distribution: inferred from wall side/imbalance (bids_depth vs asks_depth)

        # grid
        if layering > 0.6 or (len(peaks) >= 2 and flatness < 0.5 and peaks[1]['power'] > 0.4 * peaks[0]['power']):
            return 'grid', min(0.99, 0.4 + 0.6 * layering + (1.0 - flatness) * 0.2), 'layering_or_multi_peak'

        # oscillator
        if spectro['bandwidth'] < 2.0 and peaks and peaks[0]['power'] > 0.5 * max(0.01, sum(p['power'] for p in peaks)):
            return 'oscillator', min(0.99, 0.5 + (1.0 - spectro['bandwidth']/10.0)), 'dominant_peak_low_bandwidth'

        # spiral (bandwidth high but centroid moderate)
        if spectro['bandwidth'] > 4.0 and spectro['centroid'] > 2.0:
            return 'spiral', min(0.95, 0.4 + 0.12 * spectro['bandwidth']), 'wideband_centroid'

        # taper
        if energy < 1e-3 or spectro['flatness'] > 0.85:
            return 'taper', 0.6, 'low_energy_or_flat'

        # accumulation / distribution
        if bids_depth > asks_depth * 1.5:
            return 'accumulation', min(0.99, 0.5 + min(0.5, bids_depth / (asks_depth + 1e-9) / 10.0)), 'bid_depth_imbalance'
        if asks_depth > bids_depth * 1.5:
            return 'distribution', min(0.99, 0.5 + min(0.5, asks_depth / (bids_depth + 1e-9) / 10.0)), 'ask_depth_imbalance'

        # default neutral
        return 'neutral', 0.4, 'no_strong_signal'


# Default singleton
default_bot_shape_classifier: Optional[BotShapeClassifier] = None
try:
    default_bot_shape_classifier = BotShapeClassifier()
except Exception:
    default_bot_shape_classifier = None


if __name__ == '__main__':
    # quick CLI test: ingest sample generated price data and a fake orderbook
    import argparse
    import math

    ap = argparse.ArgumentParser()
    ap.add_argument('symbol', nargs='?', default='TESTCOIN/USD')
    args = ap.parse_args()

    from aureon.core.aureon_thought_bus import get_thought_bus, Thought
    tb = get_thought_bus()

    # publish synthetic market ticks (sine + noise)
    symbol = args.symbol
    for t in range(256):
        price = 100.0 + math.sin(t/4.0) * 0.5 + (0.01 * (t % 5))
        th = Thought(source='cli', topic='market.snapshot', payload={'symbol': symbol, 'price': price, 'ts': time.time()})
        tb.publish(th)
        time.sleep(0.01)

    # publish a fake orderbook analyzed event
    analysis = {'symbol': symbol, 'detected_at': time.time(), 'walls': [{'price':100.0,'size':2000.0,'notional_usd':200000.0,'side':'bid'}], 'layering_score':0.2, 'bids_depth':200000.0, 'asks_depth':50000.0}
    tb.publish(Thought(source='cli', topic='whale.orderbook.analyzed', payload=analysis))