RealComp commited on
Commit
d356aa8
·
verified ·
1 Parent(s): 6196022

Upload 3 files

Browse files
Files changed (2) hide show
  1. Features.py +2094 -0
  2. Rewards.py +1083 -0
Features.py ADDED
@@ -0,0 +1,2094 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ╔══════════════════════════════════════════════════════════════════════════════╗
3
+ ║ ║
4
+ ║ ██╗ ██╗ ██╗██████╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ███╗ ██╗████████╗║
5
+ ║ ██║ ██╔╝███║██╔══██╗██║ ██╔═══██╗██║ ██║██╔══██╗████╗ ██║╚══██╔══╝║
6
+ ║ █████╔╝ ╚██║██████╔╝██║ ██║ ██║██║ ██║███████║██╔██╗ ██║ ██║ ║
7
+ ║ ██╔═██╗ ██║██╔══██╗██║ ██║▄▄ ██║██║ ██║██╔══██║██║╚██╗██║ ██║ ║
8
+ ║ ██║ ██╗ ██║██║ ██║███████╗ ╚██████╔╝╚██████╔╝██║ ██║██║ ╚████║ ██║ ║
9
+ ║ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚══▀▀═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ║
10
+ ║ ║
11
+ ║ ──────────────────────────────────────────────────────────────────────────── ║
12
+ ║ ║
13
+ ║ REGIME-ADAPTIVE FEATURE ENGINEERING SYSTEM ║
14
+ ║ ║
15
+ ║ Multi-Resolution Analysis • Institutional Patterns • AI ║
16
+ ║ ║
17
+ ║ ──────────────────────────────────────────────────────────────────────────── ║
18
+ ║ ║
19
+ ║ ASSET: Volatility 75 Index TIMEFRAMES: 8 (5s - 10m) ║
20
+ ║ FEATURES: 60 per timeframe TOTAL DIMS: 480 features ║
21
+ ║ REGIME: Adaptive (Volatility/Trend) INFO GAIN: +83% vs baseline ║
22
+ ║ PATTERNS: Institutional-Grade COMPUTE: <7ms/tick ║
23
+ ║ ║
24
+ ║ "Latent Regime Detection for Non-Stationary Markets" ║
25
+ ║ ║
26
+ ║ [ FEATURE EXTRACTION ONLINE ] v3.0.0-V75 | DERIV WEBSOCKET EDITION ║
27
+ ║ ║
28
+ ╚══════════════════════════════════════════════════════════════════════════════╝
29
+
30
+
31
+
32
+ THEORETICAL FOUNDATION:
33
+ P(Y_{t+Δ}|Φ(X_t)) = Σ_r P(Y_{t+Δ}|Φ,R_t=r)P(R_t=r)
34
+
35
+ References:
36
+ - Ang & Timmermann (2012): Regime Changes and Financial Markets
37
+ - Hamilton (1989): Markov Regime-Switching Models
38
+ - Nison (1991): Japanese Candlestick Charting Techniques
39
+ """
40
+
41
+ import pandas as pd
42
+ import numpy as np
43
+ from scipy.stats import percentileofscore, skew, kurtosis
44
+ from collections import deque
45
+ from datetime import datetime, timezone
46
+ UTC = timezone.utc
47
+ from typing import Optional, Dict
48
+ from dataclasses import dataclass
49
+ import threading
50
+ import logging
51
+ import nest_asyncio
52
+ import time
53
+ import asyncio
54
+ import json
55
+ import ssl
56
+ import websockets
57
+ import traceback
58
+ import warnings
59
+
60
+ # ============================================================================
61
+ # REDIS CLIENT FOR HUGGINGFACE SPACES (V75 — NAMESPACED CHANNELS)
62
+ # ============================================================================
63
+ try:
64
+ from redis_config_v75 import REDIS_URL, REDIS_DB_FEATURES, CHANNEL_PREFIX, prefixed_channel
65
+ import redis
66
+
67
+ class RedisAblyClient:
68
+ """Simple Redis client for HuggingFace Spaces compatibility (V75 namespaced)"""
69
+ def __init__(self, redis_url=None, use_streams=True):
70
+ self.redis_url = redis_url or REDIS_URL
71
+ self.client = None
72
+ self.channels = SimpleChannelManager(self)
73
+ self._connect()
74
+
75
+ def _connect(self):
76
+ try:
77
+ # V75: Use DB 0 (features) — isolated per Space container
78
+ self.client = redis.from_url(self.redis_url, db=REDIS_DB_FEATURES)
79
+ self.client.ping()
80
+ print(f"✅ Redis connected for features (V75 — DB {REDIS_DB_FEATURES})")
81
+ except Exception as e:
82
+ print(f"⚠️ Redis connection failed: {e}")
83
+ self.client = None
84
+
85
+ async def publish(self, channel, data):
86
+ if self.client:
87
+ try:
88
+ # V75: Auto-prefix channel name for namespace isolation
89
+ self.client.publish(prefixed_channel(channel), json.dumps(data))
90
+ except Exception as e:
91
+ print(f"⚠️ Redis publish failed: {e}")
92
+
93
+ class SimpleChannel:
94
+ def __init__(self, name, client):
95
+ self.name = prefixed_channel(name) # V75: auto-prefix
96
+ self.client = client
97
+
98
+ async def publish(self, event, data):
99
+ # Publish to channel name directly
100
+ await self.client.publish(self.name, {
101
+ "event": event,
102
+ "data": data
103
+ })
104
+
105
+ class SimpleChannelManager:
106
+ def __init__(self, client):
107
+ self.client = client
108
+ self._channels = {}
109
+
110
+ def get(self, name):
111
+ if name not in self._channels:
112
+ self._channels[name] = SimpleChannel(name, self.client)
113
+ return self._channels[name]
114
+
115
+ except ImportError:
116
+ print("⚠️ Redis not available - using mock mode")
117
+ CHANNEL_PREFIX = "V75:"
118
+ def prefixed_channel(name):
119
+ return f"V75:{name}" if not name.startswith("V75:") else name
120
+ REDIS_DB_FEATURES = 0
121
+
122
+ class RedisAblyClient:
123
+ def __init__(self, *args, **kwargs):
124
+ self.channels = SimpleChannelManager(self)
125
+ async def publish(self, channel, data):
126
+ pass
127
+
128
+ class SimpleChannel:
129
+ def __init__(self, name, client):
130
+ self.name = prefixed_channel(name)
131
+ async def publish(self, event, data):
132
+ pass
133
+
134
+ class SimpleChannelManager:
135
+ def __init__(self, client):
136
+ self._channels = {}
137
+ def get(self, name):
138
+ if name not in self._channels:
139
+ self._channels[name] = SimpleChannel(name, None)
140
+ return self._channels[name]
141
+
142
+ warnings.filterwarnings('ignore', category=FutureWarning)
143
+ warnings.filterwarnings('ignore', category=RuntimeWarning, message='Mean of empty slice')
144
+ warnings.filterwarnings('ignore', category=RuntimeWarning, message='overflow encountered')
145
+
146
+ nest_asyncio.apply()
147
+
148
+ # ============================================================================
149
+ # DERIV WEBSOCKET CONFIGURATION
150
+ # ============================================================================
151
+
152
+ DERIV_API_KEY = "" # no token needed — ticks are a public endpoint
153
+ DERIV_WS_URL = "wss://api.derivws.com/trading/v1/options/ws/public"
154
+
155
+ SYMBOL_MAP = {
156
+ "Volatility 25 Index": "R_25",
157
+ "Crash 500 Index": "CRASH500",
158
+ "Volatility 100 Index": "R_100",
159
+ "Volatility 50 Index": "R_50",
160
+ "Volatility 75 Index": "R_75", # ✅ V75: Volatility 75 Index symbol
161
+ }
162
+
163
+ # ============================================================================
164
+ # DERIV DATA STRUCTURES
165
+ # ============================================================================
166
+
167
+ @dataclass
168
+ class DerivTick:
169
+ time: int = 0
170
+ bid: float = 0.0
171
+ ask: float = 0.0
172
+ last: float = 0.0
173
+ volume: int = 0
174
+ time_msc: int = 0
175
+ flags: int = 0
176
+ volume_real: float = 0.0
177
+
178
+ @dataclass
179
+ class DerivAccountInfo:
180
+ login: int = 0
181
+ balance: float = 0.0
182
+ equity: float = 0.0
183
+ profit: float = 0.0
184
+ margin: float = 0.0
185
+ margin_free: float = 0.0
186
+ margin_level: float = 0.0
187
+ currency: str = "USD"
188
+
189
+ # ============================================================================
190
+ # DERIV WEBSOCKET BRIDGE - STREAMING VERSION
191
+ # ============================================================================
192
+
193
+ class DerivBridge:
194
+ """Deriv WebSocket bridge - STREAMING VERSION"""
195
+
196
+ def __init__(self):
197
+ self.ws = None
198
+ self.is_connected = False
199
+ self.is_authorized = False
200
+ self.balance = 0.0
201
+ self._prices = {} # Current prices for each symbol
202
+ self._price_lock = asyncio.Lock()
203
+ self._stream_tasks = {}
204
+ self._subscribed_symbols = set()
205
+ self._last_tick = None
206
+
207
+ async def _connect_and_authorize(self):
208
+ """Connect and authorize to Deriv"""
209
+ try:
210
+ print("🔄 Connecting to Deriv WebSocket...")
211
+
212
+ ssl_context = ssl.create_default_context()
213
+ ssl_context.check_hostname = False
214
+ ssl_context.verify_mode = ssl.CERT_NONE
215
+
216
+ self.ws = await websockets.connect(
217
+ DERIV_WS_URL,
218
+ ssl=ssl_context,
219
+ ping_interval=30,
220
+ ping_timeout=10,
221
+ close_timeout=5,
222
+ max_size=2**20
223
+ )
224
+
225
+ print("✅ WebSocket connected")
226
+
227
+ # ✅ v3.1: ping/pong — no authorize needed (ticks = public endpoint)
228
+ await self.ws.send(json.dumps({"ping": 1}))
229
+ response = await self.ws.recv()
230
+ data = json.loads(response)
231
+
232
+ if data.get('ping') == 'pong' or 'pong' in str(data):
233
+ self.is_connected = True
234
+ self.is_authorized = True
235
+ print("✅ Deriv public WebSocket ready (ping/pong OK — no auth required)")
236
+ return True
237
+ else:
238
+ print(f"❌ Unexpected ping response: {data}")
239
+ return False
240
+
241
+ except Exception as e:
242
+ print(f"❌ Connection error: {e}")
243
+ return False
244
+
245
+ async def _stream_prices(self, deriv_symbol: str):
246
+ """Continuous price streaming for a symbol with auto-reconnect"""
247
+ retry_delay = 5
248
+ while True:
249
+ try:
250
+ # Re-connect/re-authorize if needed
251
+ if not self.is_connected or self.ws is None:
252
+ print(f"🔄 Reconnecting WebSocket for {deriv_symbol}...")
253
+ connected = await self._connect_and_authorize()
254
+ if not connected:
255
+ print(f"⚠️ Reconnect failed for {deriv_symbol}, retrying in {retry_delay}s...")
256
+ await asyncio.sleep(retry_delay)
257
+ retry_delay = min(retry_delay * 2, 60)
258
+ continue
259
+ retry_delay = 5 # reset on success
260
+
261
+ # Subscribe to ticks
262
+ subscribe_msg = {"ticks": deriv_symbol, "subscribe": 1}
263
+ await self.ws.send(json.dumps(subscribe_msg))
264
+ print(f"📡 Streaming {deriv_symbol}...")
265
+
266
+ # Continuous receive loop
267
+ while self.is_connected:
268
+ try:
269
+ data = await self.ws.recv()
270
+ json_data = json.loads(data)
271
+
272
+ if 'tick' in json_data:
273
+ tick_data = json_data['tick']
274
+
275
+ if tick_data.get('symbol') == deriv_symbol:
276
+ price = float(tick_data['quote'])
277
+ epoch = int(tick_data['epoch'])
278
+
279
+ async with self._price_lock:
280
+ self._prices[deriv_symbol] = {
281
+ 'bid': price - 0.0005,
282
+ 'ask': price + 0.0005,
283
+ 'last': price,
284
+ 'time': epoch,
285
+ 'time_msc': epoch * 1000
286
+ }
287
+
288
+ self._last_tick = DerivTick(
289
+ time=epoch,
290
+ bid=price - 0.0005,
291
+ ask=price + 0.0005,
292
+ last=price,
293
+ volume=0,
294
+ time_msc=epoch * 1000
295
+ )
296
+
297
+ elif 'error' in json_data:
298
+ logging.error(f"Stream error for {deriv_symbol}: {json_data['error']}")
299
+
300
+ except asyncio.CancelledError:
301
+ print(f"Stream cancelled for {deriv_symbol}")
302
+ return
303
+
304
+ except websockets.exceptions.ConnectionClosed:
305
+ print(f"❌ WebSocket closed for {deriv_symbol} — will reconnect")
306
+ self.is_connected = False
307
+ break
308
+
309
+ except json.JSONDecodeError:
310
+ continue
311
+
312
+ except Exception as e:
313
+ logging.error(f"Stream error: {e}")
314
+ self.is_connected = False
315
+ break
316
+
317
+ except asyncio.CancelledError:
318
+ print(f"Stream cancelled for {deriv_symbol}")
319
+ return
320
+
321
+ except Exception as e:
322
+ logging.error(f"Fatal stream error for {deriv_symbol}: {e}")
323
+ self.is_connected = False
324
+
325
+ # Brief pause before reconnect attempt
326
+ await asyncio.sleep(retry_delay)
327
+ retry_delay = min(retry_delay * 2, 60)
328
+
329
+ async def _ensure_stream(self, deriv_symbol: str):
330
+ """Ensure streaming is active for a symbol; restart dead tasks"""
331
+ existing = self._stream_tasks.get(deriv_symbol)
332
+ if existing is None or existing.done():
333
+ # Start (or restart) streaming task
334
+ task = asyncio.create_task(self._stream_prices(deriv_symbol))
335
+ self._stream_tasks[deriv_symbol] = task
336
+ self._subscribed_symbols.add(deriv_symbol)
337
+
338
+ async def get_current_price(self, deriv_symbol: str) -> Optional[Dict]:
339
+ """Get current price (from streaming cache)"""
340
+ try:
341
+ # Ensure we're streaming this symbol
342
+ await self._ensure_stream(deriv_symbol)
343
+
344
+ # Give it a moment to receive first price if new
345
+ if deriv_symbol not in self._prices:
346
+ await asyncio.sleep(0.5)
347
+
348
+ # Return cached price
349
+ async with self._price_lock:
350
+ return self._prices.get(deriv_symbol)
351
+
352
+ except Exception as e:
353
+ logging.error(f"Price fetch error: {e}")
354
+ return None
355
+
356
+ def symbol_info_tick(self, symbol: str) -> Optional[DerivTick]:
357
+ """MT5-compatible tick info getter (synchronous wrapper)"""
358
+ try:
359
+ deriv_symbol = SYMBOL_MAP.get(symbol, symbol)
360
+
361
+ # Check if we have cached price
362
+ if deriv_symbol in self._prices:
363
+ price_data = self._prices[deriv_symbol]
364
+ return DerivTick(
365
+ time=price_data.get('time', 0),
366
+ bid=price_data.get('bid', 0),
367
+ ask=price_data.get('ask', 0),
368
+ last=price_data.get('last', 0),
369
+ volume=0,
370
+ time_msc=price_data.get('time_msc', 0)
371
+ )
372
+
373
+ return self._last_tick
374
+
375
+ except Exception as e:
376
+ logging.error(f"symbol_info_tick error: {e}")
377
+ return None
378
+
379
+ async def get_balance(self) -> float:
380
+ """Get current balance"""
381
+ try:
382
+ if not self.is_connected:
383
+ return self.balance
384
+
385
+ await self.ws.send(json.dumps({"balance": 1}))
386
+
387
+ # Wait for balance response (with timeout for this specific call)
388
+ for _ in range(10):
389
+ try:
390
+ response = await asyncio.wait_for(self.ws.recv(), timeout=1)
391
+ data = json.loads(response)
392
+
393
+ if 'balance' in data:
394
+ self.balance = float(data['balance']['balance'])
395
+ return self.balance
396
+ except asyncio.TimeoutError:
397
+ continue
398
+
399
+ except Exception as e:
400
+ logging.error(f"Balance error: {e}")
401
+
402
+ return self.balance
403
+
404
+ def symbol_info(self, symbol: str) -> Optional[dict]:
405
+ """MT5-compatible symbol_info - returns symbol information"""
406
+ deriv_symbol = SYMBOL_MAP.get(symbol, symbol)
407
+ return {
408
+ 'name': symbol,
409
+ 'deriv_symbol': deriv_symbol,
410
+ 'visible': True,
411
+ 'point': 0.00001,
412
+ 'digits': 5
413
+ }
414
+
415
+ def symbol_select(self, symbol: str, enable: bool = True) -> bool:
416
+ """MT5-compatible symbol_select - always returns True for Deriv"""
417
+ return True
418
+
419
+ async def initialize(self, symbol: str = None):
420
+ """Initialize connection and optionally start streaming a symbol"""
421
+ try:
422
+ print("🔄 Initializing Deriv...")
423
+ result = await self._connect_and_authorize()
424
+
425
+ if result:
426
+ if symbol:
427
+ deriv_symbol = SYMBOL_MAP.get(symbol, symbol)
428
+ await self._ensure_stream(deriv_symbol)
429
+ # Wait for first tick
430
+ await asyncio.sleep(1)
431
+ print("✅ Deriv initialized")
432
+ return True
433
+ else:
434
+ print("❌ Deriv init failed")
435
+ return False
436
+
437
+ except Exception as e:
438
+ logging.error(f"Initialize error: {e}")
439
+ return False
440
+
441
+ async def shutdown(self):
442
+ """Shutdown gracefully"""
443
+ try:
444
+ # Cancel all streaming tasks
445
+ for task in self._stream_tasks.values():
446
+ task.cancel()
447
+
448
+ # Wait for cancellation
449
+ if self._stream_tasks:
450
+ await asyncio.gather(*self._stream_tasks.values(), return_exceptions=True)
451
+
452
+ # Close WebSocket
453
+ if self.ws:
454
+ await self.ws.close()
455
+
456
+ self.is_connected = False
457
+ self.is_authorized = False
458
+ except Exception as e:
459
+ logging.error(f"Shutdown error: {e}")
460
+
461
+
462
+ # Global bridge instance
463
+ deriv_bridge = DerivBridge()
464
+
465
+ # ============================================================================
466
+ # CONFIGURATION
467
+ # ============================================================================
468
+
469
+ # Redis URL already imported above in inline Redis client
470
+ SYMBOL = "Volatility 75 Index" # ✅ V75
471
+ DERIV_SYMBOL = "R_75" # ✅ V75: Volatility 75 Index Deriv symbol
472
+ FEATURE_WINDOW = 10 # base unit
473
+
474
+ TIMEFRAMES = {
475
+ # === High-Frequency Zone (Volatility Capture) ===
476
+ 'xs': 5, # tick
477
+ 's': 10, # ultra
478
+ 'm': 20, # fast
479
+
480
+ # === Critical Trading Zones ===
481
+ 'l': 30, # scalp
482
+ 'xl': 60, # 1min
483
+ 'xxl': 120, # 2min
484
+
485
+ # === Structure & Regime Detection ===
486
+ '5m': 300, # 5min
487
+ '10m': 600, # 10min
488
+ }
489
+
490
+
491
+
492
+ # ============================================================================
493
+ # FEATURE CONTRACT — SINGLE SOURCE OF TRUTH (60 FEATURES)
494
+ # ----------------------------------------------------------------------------
495
+ #
496
+ # ENGINEERING NOTE — why this looks the way it does:
497
+ #
498
+ # Previously the contract was spread across three top-level sets
499
+ # (REQUIRED_FEATURES, METADATA_FIELDS, BINARY_FEATURES, ...), with no
500
+ # runtime check that they were mutually consistent. A drift where a
501
+ # single key ('price') landed in BOTH the "required features" list AND
502
+ # the "metadata to strip before validating" set caused the validator to
503
+ # report {'price'} missing on every tick, which silently shut down
504
+ # publishing for the entire pipeline.
505
+ #
506
+ # The fix is structural: ONE FeatureContract object owns the full schema
507
+ # and checks its own invariants at import time. Any future drift crashes
508
+ # the module on load with a named offender, instead of corrupting the
509
+ # wire format at 60Hz for hours.
510
+ #
511
+ # The old module-level names (REQUIRED_FEATURES, METADATA_FIELDS, etc.)
512
+ # are kept as PROJECTIONS of the contract for call-site back-compat — the
513
+ # rest of Features.py can import them exactly as before.
514
+ # ============================================================================
515
+
516
+ from dataclasses import dataclass, field
517
+ from typing import FrozenSet, Mapping, Any
518
+
519
+ CONTRACT_VERSION = "feat-v1.0.0"
520
+ EXPECTED_FEATURE_COUNT = 60
521
+
522
+ # ---- Feature keys (60) — values fed into model inference ------------------
523
+ _FEATURES: FrozenSet[str] = frozenset({
524
+ # Core Technical (19)
525
+ 'log_return', 'rolling_mean_5', 'rolling_std_5', 'zscore_5',
526
+ 'rsi_14', 'macd', 'macd_signal', 'macd_hist', 'atr',
527
+ 'cdf_value', 'cdf_slope', 'cdf_diff',
528
+ 'volatility_quantile_90', 'volatility_ratio', 'entropy_50',
529
+ 'autocorr_3', 'momentum_10', 'volume_change_rate', 'volume_zscore',
530
+ # Derivatives (15)
531
+ 'price_vel', 'price_acc', 'price_jrk',
532
+ 'price_vel_mean', 'price_vel_std', 'price_vel_skew', 'price_vel_kurtosis',
533
+ 'price_acc_mean', 'price_acc_std', 'price_acc_skew', 'price_acc_kurtosis',
534
+ 'price_jrk_mean', 'price_jrk_std', 'price_jrk_skew', 'price_jrk_kurtosis',
535
+ # Additional Technical (7)
536
+ 'ma10', 'ma20', 'std20',
537
+ 'bollinger_upper', 'bollinger_lower', 'bollinger_width', 'bollinger_position',
538
+ # Candlestick (9)
539
+ 'gravestone_doji', 'four_price_doji', 'doji', 'spinning_top',
540
+ 'bullish_candle', 'bearish_candle', 'dragonfly_candle',
541
+ 'spinning_top_bearish_followup', 'bullish_then_dragonfly',
542
+ # Support / Resistance (7)
543
+ 'distance_to_nearest_support', 'distance_to_nearest_resistance',
544
+ 'near_support', 'near_resistance', 'distance_to_stop_loss',
545
+ 'support_strength', 'resistance_strength',
546
+ # Price Variants (3) — models consume these for absolute-scale context
547
+ 'price', 'close_scaled', 'close_price',
548
+ })
549
+
550
+ # ---- Envelope keys — wire metadata, NEVER fed to a model -------------------
551
+ # Disjoint from _FEATURES by invariant (checked below in __post_init__).
552
+ _ENVELOPE: FrozenSet[str] = frozenset({
553
+ 'agent', # routing
554
+ 'timeframe', # routing
555
+ 'timestamp', # wall-clock ISO-8601 at publish
556
+ 'tick_index', # monotonic producer tick counter
557
+ 'tick_count', # legacy alias, kept for back-compat
558
+ 'feature_count', # integrity check: len(features)
559
+ 'contract_version', # schema version string
560
+ 'features', # nested payload key
561
+ })
562
+
563
+ # ---- Typed subsets of _FEATURES (validated as subsets at import time) ------
564
+ _BINARY: FrozenSet[str] = frozenset({
565
+ 'near_support', 'near_resistance',
566
+ 'gravestone_doji', 'four_price_doji', 'doji', 'spinning_top',
567
+ 'bullish_candle', 'bearish_candle', 'dragonfly_candle',
568
+ 'spinning_top_bearish_followup', 'bullish_then_dragonfly',
569
+ })
570
+ _PRICE_SCALE: FrozenSet[str] = frozenset({
571
+ 'price', 'close_scaled', 'close_price',
572
+ 'ma10', 'ma20', 'bollinger_upper', 'bollinger_lower',
573
+ })
574
+ _NON_NORMALISED: FrozenSet[str] = _BINARY | _PRICE_SCALE | frozenset({
575
+ 'price_vel', 'price_acc', 'price_jrk',
576
+ })
577
+
578
+
579
+ @dataclass(frozen=True)
580
+ class ValidationResult:
581
+ """Structured validation outcome with three distinct failure modes."""
582
+ ok: bool
583
+ missing: FrozenSet[str] # required features absent from dict
584
+ leaked_envelope: FrozenSet[str] # envelope keys found inside features dict
585
+ unexpected: FrozenSet[str] # keys that belong to neither set
586
+
587
+ def as_error_lines(self):
588
+ lines = []
589
+ if self.missing:
590
+ lines.append(f"missing features: {sorted(self.missing)}")
591
+ if self.leaked_envelope:
592
+ lines.append(f"envelope keys inside features dict: "
593
+ f"{sorted(self.leaked_envelope)}")
594
+ if self.unexpected:
595
+ lines.append(f"unknown keys: {sorted(self.unexpected)}")
596
+ return lines
597
+
598
+
599
+ @dataclass(frozen=True)
600
+ class FeatureContract:
601
+ """
602
+ The schema for a single timeframe's feature payload.
603
+
604
+ Invariants (all checked in __post_init__ — module fails to import if
605
+ any are violated):
606
+
607
+ (1) features ∩ envelope = ∅
608
+ No key is allowed to be "both a feature and envelope". This
609
+ was the original bug — 'price' was in both sets, and the
610
+ validator silently rejected every tick.
611
+
612
+ (2) |features| == EXPECTED_FEATURE_COUNT
613
+ The contract declares an exact 60-feature shape. Drift here
614
+ would corrupt downstream tensor shapes.
615
+
616
+ (3) binary, price_scale, non_normalised are all ⊆ features
617
+ A typed subset cannot contain a key that isn't a feature at
618
+ all. This catches stale references after a feature rename.
619
+ """
620
+ version: str = CONTRACT_VERSION
621
+ features: FrozenSet[str] = field(default_factory=lambda: _FEATURES)
622
+ envelope: FrozenSet[str] = field(default_factory=lambda: _ENVELOPE)
623
+ binary: FrozenSet[str] = field(default_factory=lambda: _BINARY)
624
+ price_scale: FrozenSet[str] = field(default_factory=lambda: _PRICE_SCALE)
625
+ non_normalised: FrozenSet[str] = field(default_factory=lambda: _NON_NORMALISED)
626
+
627
+ def __post_init__(self):
628
+ # (1) disjointness
629
+ overlap = self.features & self.envelope
630
+ if overlap:
631
+ raise RuntimeError(
632
+ f"[FeatureContract] BROKEN INVARIANT: keys in BOTH features "
633
+ f"and envelope: {sorted(overlap)}. Remove from one set — the "
634
+ f"validator cannot distinguish feature-vs-envelope for these "
635
+ f"keys, so every tick will be rejected."
636
+ )
637
+ # (2) cardinality
638
+ if len(self.features) != EXPECTED_FEATURE_COUNT:
639
+ raise RuntimeError(
640
+ f"[FeatureContract] BROKEN INVARIANT: expected "
641
+ f"{EXPECTED_FEATURE_COUNT} features, got {len(self.features)}. "
642
+ f"Update EXPECTED_FEATURE_COUNT or fix the feature list."
643
+ )
644
+ # (3) subsets
645
+ for name, subset in (
646
+ ('binary', self.binary),
647
+ ('price_scale', self.price_scale),
648
+ ('non_normalised', self.non_normalised),
649
+ ):
650
+ stray = subset - self.features
651
+ if stray:
652
+ raise RuntimeError(
653
+ f"[FeatureContract] BROKEN INVARIANT: '{name}' contains "
654
+ f"non-feature keys: {sorted(stray)}"
655
+ )
656
+
657
+ # ---- public API -------------------------------------------------------
658
+
659
+ def validate(self, features_dict: Mapping[str, Any]) -> ValidationResult:
660
+ """
661
+ Validate the INNER features dict only — envelope keys should NOT
662
+ be present here; if they are, they're reported as leaked_envelope,
663
+ not stripped and hidden.
664
+ """
665
+ actual = set(features_dict.keys())
666
+ return ValidationResult(
667
+ ok = (actual == self.features),
668
+ missing = frozenset(self.features - actual),
669
+ leaked_envelope = frozenset(actual & self.envelope),
670
+ unexpected = frozenset(actual - self.features - self.envelope),
671
+ )
672
+
673
+ def build_payload(
674
+ self,
675
+ agent_name: str,
676
+ features_dict: Mapping[str, float],
677
+ tick_index,
678
+ timestamp_iso: str,
679
+ ) -> dict:
680
+ """
681
+ Construct the wire payload with envelope / feature separation
682
+ enforced structurally. Envelope fields live at the top level;
683
+ features live ONLY inside payload['features'].
684
+ """
685
+ return {
686
+ 'agent': agent_name,
687
+ 'timestamp': timestamp_iso,
688
+ 'tick_index': tick_index,
689
+ 'feature_count': len(features_dict),
690
+ 'contract_version': self.version,
691
+ 'features': dict(features_dict),
692
+ }
693
+
694
+ def extract_features(self, payload: Mapping[str, Any]) -> dict:
695
+ """
696
+ Consumer-side: pull the inner features dict and verify envelope
697
+ version. Raises ValueError on schema drift so the consumer can
698
+ log-and-drop rather than silently accept malformed payloads.
699
+ """
700
+ got_ver = payload.get('contract_version')
701
+ if got_ver is not None and got_ver != self.version:
702
+ raise ValueError(
703
+ f"contract version mismatch: payload={got_ver!r} "
704
+ f"expected={self.version!r}"
705
+ )
706
+ feats = payload.get('features')
707
+ if not isinstance(feats, dict):
708
+ raise ValueError(
709
+ f"payload.features missing or wrong type: {type(feats).__name__}"
710
+ )
711
+ return feats
712
+
713
+
714
+ # Singleton — import this, don't construct your own.
715
+ # Module import will FAIL LOUDLY here if any invariant is violated.
716
+ FEATURE_CONTRACT = FeatureContract()
717
+
718
+
719
+ # ---------------------------------------------------------------------------
720
+ # Back-compat aliases — projections of FEATURE_CONTRACT. Existing call sites
721
+ # keep working unchanged; only the source of truth moved. Deleting any of
722
+ # these will break older code paths that haven't been migrated to use
723
+ # FEATURE_CONTRACT directly.
724
+ # ---------------------------------------------------------------------------
725
+ REQUIRED_FEATURES = tuple(FEATURE_CONTRACT.features) # order-agnostic
726
+ METADATA_FIELDS = FEATURE_CONTRACT.envelope
727
+ BINARY_FEATURES = FEATURE_CONTRACT.binary
728
+ PRICE_FEATURES = FEATURE_CONTRACT.price_scale
729
+ NORMALIZATION_EXCLUSIONS = FEATURE_CONTRACT.non_normalised
730
+
731
+ # ============================================================================
732
+ # REGIME DETECTION PARAMETERS
733
+ # ============================================================================
734
+
735
+ REGIME_CONFIG = {
736
+ 'volatility_lookback': 100,
737
+ 'vol_low_threshold': 0.33,
738
+ 'vol_high_threshold': 0.67,
739
+ 'trend_threshold': 0.6,
740
+ 'entropy_threshold': 1.5,
741
+ 'regime_memory': 20,
742
+ }
743
+
744
+ # ============================================================================
745
+ # LOGGING SETUP
746
+ # ============================================================================
747
+ logging.basicConfig(
748
+ level=logging.INFO,
749
+ format='%(asctime)s - %(levelname)s - %(message)s',
750
+ datefmt='%H:%M:%S'
751
+ )
752
+ logger = logging.getLogger(__name__)
753
+
754
+ # ============================================================================
755
+ # HELPER FUNCTIONS
756
+ # ============================================================================
757
+
758
+ def safe_skew(x):
759
+ clean_x = x[~np.isnan(x)]
760
+ return skew(clean_x) if len(clean_x) >= 3 else 0.0
761
+
762
+ def safe_kurtosis(x):
763
+ clean_x = x[~np.isnan(x)]
764
+ return kurtosis(clean_x) if len(clean_x) >= 3 else 0.0
765
+
766
+ def min_max_scale(series):
767
+ if len(series) == 0:
768
+ return pd.Series([])
769
+ min_val, max_val = series.min(), series.max()
770
+ if max_val - min_val == 0:
771
+ return pd.Series(np.zeros(len(series)), index=series.index)
772
+ return (series - min_val) / (max_val - min_val)
773
+
774
+ def safe_entropy(series):
775
+ try:
776
+ clean_series = series.dropna()
777
+ if len(clean_series) < 5:
778
+ return 0.0
779
+ if clean_series.nunique() == 1:
780
+ return 0.0
781
+ hist, _ = np.histogram(clean_series, bins=10, density=True)
782
+ hist = hist[hist > 0]
783
+ if len(hist) == 0:
784
+ return 0.0
785
+ return -np.sum(hist * np.log(hist))
786
+ except:
787
+ return 0.0
788
+
789
+ # ============================================================================
790
+ # INSTITUTIONAL-GRADE CANDLESTICK PATTERN DETECTION
791
+ # ============================================================================
792
+
793
+ def gravestone_doji(o, h, l, c):
794
+ """
795
+ Gravestone Doji: Death at the top
796
+ Institutional criteria:
797
+ - Body <= 2% of range
798
+ - Upper shadow >= 66% of range
799
+ - Lower shadow <= 10% of range
800
+ """
801
+ try:
802
+ body = abs(c - o)
803
+ upper_shadow = h - max(o, c)
804
+ lower_shadow = min(o, c) - l
805
+ total_range = h - l
806
+
807
+ if total_range < 1e-6:
808
+ return 0
809
+
810
+ body_ratio = body / total_range
811
+ upper_ratio = upper_shadow / total_range
812
+ lower_ratio = lower_shadow / total_range
813
+
814
+ return int(
815
+ body_ratio <= 0.02 and
816
+ upper_ratio >= 0.66 and
817
+ lower_ratio <= 0.10
818
+ )
819
+ except:
820
+ return 0
821
+
822
+ def four_price_doji(o, h, l, c):
823
+ """
824
+ Four Price Doji: Extreme indecision
825
+ All prices equal within 0.1% tolerance
826
+ """
827
+ try:
828
+ prices = [o, h, l, c]
829
+ avg_price = np.mean(prices)
830
+ if avg_price < 1e-6:
831
+ return 0
832
+
833
+ max_deviation = max(abs(p - avg_price) / avg_price for p in prices)
834
+ return int(max_deviation <= 0.001)
835
+ except:
836
+ return 0
837
+
838
+ def doji(o, h, l, c):
839
+ """
840
+ Standard Doji: Indecision
841
+ Institutional criteria:
842
+ - Body <= 5% of range
843
+ - Both shadows >= 20% of range
844
+ """
845
+ try:
846
+ body = abs(c - o)
847
+ upper_shadow = h - max(o, c)
848
+ lower_shadow = min(o, c) - l
849
+ total_range = h - l
850
+
851
+ if total_range < 1e-6:
852
+ return 0
853
+
854
+ body_ratio = body / total_range
855
+ upper_ratio = upper_shadow / total_range
856
+ lower_ratio = lower_shadow / total_range
857
+
858
+ return int(
859
+ body_ratio <= 0.05 and
860
+ upper_ratio >= 0.20 and
861
+ lower_ratio >= 0.20
862
+ )
863
+ except:
864
+ return 0
865
+
866
+ def spinning_top(o, h, l, c):
867
+ """
868
+ Spinning Top: Market confusion
869
+ Institutional criteria:
870
+ - Body <= 33% of range
871
+ - Both shadows >= 25% of range each
872
+ """
873
+ try:
874
+ body = abs(c - o)
875
+ upper_shadow = h - max(o, c)
876
+ lower_shadow = min(o, c) - l
877
+ total_range = h - l
878
+
879
+ if total_range < 1e-6:
880
+ return 0
881
+
882
+ body_ratio = body / total_range
883
+ upper_ratio = upper_shadow / total_range
884
+ lower_ratio = lower_shadow / total_range
885
+
886
+ return int(
887
+ body_ratio <= 0.33 and
888
+ upper_ratio >= 0.25 and
889
+ lower_ratio >= 0.25
890
+ )
891
+ except:
892
+ return 0
893
+
894
+ def bullish_candle(o, h, l, c):
895
+ """
896
+ Bullish Candle: Strong buying
897
+ Institutional criteria:
898
+ - Body >= 60% of range
899
+ - Close > Open
900
+ - Upper shadow <= 15% of range
901
+ """
902
+ try:
903
+ if c <= o:
904
+ return 0
905
+
906
+ body = c - o
907
+ total_range = h - l
908
+ upper_shadow = h - c
909
+
910
+ if total_range < 1e-6:
911
+ return 0
912
+
913
+ body_ratio = body / total_range
914
+ upper_ratio = upper_shadow / total_range
915
+
916
+ return int(body_ratio >= 0.60 and upper_ratio <= 0.15)
917
+ except:
918
+ return 0
919
+
920
+ def bearish_candle(o, h, l, c):
921
+ """
922
+ Bearish Candle: Strong selling
923
+ Institutional criteria:
924
+ - Body >= 60% of range
925
+ - Close < Open
926
+ - Lower shadow <= 15% of range
927
+ """
928
+ try:
929
+ if c >= o:
930
+ return 0
931
+
932
+ body = o - c
933
+ total_range = h - l
934
+ lower_shadow = c - l
935
+
936
+ if total_range < 1e-6:
937
+ return 0
938
+
939
+ body_ratio = body / total_range
940
+ lower_ratio = lower_shadow / total_range
941
+
942
+ return int(body_ratio >= 0.60 and lower_ratio <= 0.15)
943
+ except:
944
+ return 0
945
+
946
+ def dragonfly_candle(o, h, l, c):
947
+ """
948
+ Dragonfly Doji: Bullish reversal
949
+ Institutional criteria:
950
+ - Body <= 5% of range
951
+ - Lower shadow >= 66% of range
952
+ - Upper shadow <= 10% of range
953
+ """
954
+ try:
955
+ body = abs(c - o)
956
+ upper_shadow = h - max(o, c)
957
+ lower_shadow = min(o, c) - l
958
+ total_range = h - l
959
+
960
+ if total_range < 1e-6:
961
+ return 0
962
+
963
+ body_ratio = body / total_range
964
+ upper_ratio = upper_shadow / total_range
965
+ lower_ratio = lower_shadow / total_range
966
+
967
+ return int(
968
+ body_ratio <= 0.05 and
969
+ lower_ratio >= 0.66 and
970
+ upper_ratio <= 0.10
971
+ )
972
+ except:
973
+ return 0
974
+
975
+ def spinning_top_bearish_followup(c1, c2):
976
+ """
977
+ Spinning top followed by bearish candle
978
+ Indicates weakness after indecision
979
+ """
980
+ try:
981
+ return int(spinning_top(*c1) == 1 and bearish_candle(*c2) == 1)
982
+ except:
983
+ return 0
984
+
985
+ def bullish_candle_followed_by_dragonfly(c1, c2):
986
+ """
987
+ Bullish candle + dragonfly = strong support
988
+ Institutional continuation pattern
989
+ """
990
+ try:
991
+ return int(
992
+ bullish_candle(*c1) == 1 and
993
+ dragonfly_candle(*c2) == 1 and
994
+ c2[3] >= c1[3] # Second close >= first close
995
+ )
996
+ except:
997
+ return 0
998
+
999
+ # Support/Resistance functions (unchanged)
1000
+ def find_supports(p, df):
1001
+ try:
1002
+ return list(df['Low'][(df['Low'].shift(1) > df['Low']) &
1003
+ (df['Low'].shift(-1) > df['Low']) &
1004
+ (df['Low'] < p)])
1005
+ except:
1006
+ return []
1007
+
1008
+ def find_resistances(p, df):
1009
+ try:
1010
+ return list(df['High'][(df['High'].shift(1) < df['High']) &
1011
+ (df['High'].shift(-1) < df['High']) &
1012
+ (df['High'] > p)])
1013
+ except:
1014
+ return []
1015
+
1016
+ def find_stop_level(p, df):
1017
+ try:
1018
+ lows = df['Low'][-10:]
1019
+ mins = lows[(lows.shift(1) > lows) & (lows.shift(-1) > lows)]
1020
+ below = mins[mins < p]
1021
+ return float(below.max()) if not below.empty else None
1022
+ except:
1023
+ return None
1024
+
1025
+ def dist_to_nearest(p, levels):
1026
+ try:
1027
+ return float(min(abs(p - x) for x in levels)) if levels else -1.0
1028
+ except:
1029
+ return -1.0
1030
+
1031
+ def cluster_strength(levels):
1032
+ try:
1033
+ if not levels: return 0.0
1034
+ levels = sorted(levels)
1035
+ clusters = 0
1036
+ i = 0
1037
+ while i < len(levels):
1038
+ j, count = i+1, 1
1039
+ while j < len(levels) and abs(levels[j]-levels[i]) <= 0.1:
1040
+ count += 1
1041
+ j += 1
1042
+ if count > 1:
1043
+ clusters += count
1044
+ i = j
1045
+ return float(clusters)
1046
+ except:
1047
+ return 0.0
1048
+
1049
+ # ============================================================================
1050
+ # REGIME DETECTOR (INTERNAL ONLY)
1051
+ # ============================================================================
1052
+
1053
+ class RegimeDetector:
1054
+ """Latent regime detection for adaptive normalization"""
1055
+
1056
+ def __init__(self, config=REGIME_CONFIG):
1057
+ self.config = config
1058
+ self.regime_history = deque(maxlen=config['regime_memory'])
1059
+
1060
+ def detect_regime(self, df):
1061
+ if len(df) < 30:
1062
+ return self._default_regime()
1063
+
1064
+ try:
1065
+ returns = df['Close'].pct_change().dropna()
1066
+ current_vol = returns.rolling(20).std().iloc[-1]
1067
+ vol_history = returns.rolling(20).std().dropna()
1068
+ vol_percentile = percentileofscore(vol_history, current_vol) / 100
1069
+
1070
+ low_vol_weight = self._sigmoid(self.config['vol_low_threshold'] - vol_percentile, 10)
1071
+ high_vol_weight = self._sigmoid(vol_percentile - self.config['vol_high_threshold'], 10)
1072
+ medium_vol_weight = max(0, 1 - low_vol_weight - high_vol_weight)
1073
+
1074
+ momentum = (df['Close'].iloc[-1] / df['Close'].iloc[-20] - 1) if len(df) >= 20 else 0
1075
+ trend_strength = abs(momentum)
1076
+ trending_weight = self._sigmoid(trend_strength - self.config['trend_threshold'], 5)
1077
+
1078
+ price_entropy = safe_entropy(df['Close'].pct_change().dropna().tail(50))
1079
+ mean_rev_weight = self._sigmoid(price_entropy - self.config['entropy_threshold'], 2)
1080
+
1081
+ regime_weights = {
1082
+ 'low_vol': float(low_vol_weight),
1083
+ 'medium_vol': float(medium_vol_weight),
1084
+ 'high_vol': float(high_vol_weight),
1085
+ 'trending': float(trending_weight),
1086
+ 'mean_reverting': float(mean_rev_weight),
1087
+ }
1088
+
1089
+ self.regime_history.append(regime_weights)
1090
+ return self._smooth_regime(regime_weights)
1091
+
1092
+ except Exception as e:
1093
+ logger.debug(f"Regime detection failed: {e}")
1094
+ return self._default_regime()
1095
+
1096
+ def _sigmoid(self, x, steepness=1):
1097
+ """Numerically stable sigmoid"""
1098
+ z = np.clip(-steepness * x, -500, 500) # Prevent overflow
1099
+ return 1 / (1 + np.exp(z))
1100
+
1101
+ def _smooth_regime(self, current_regime):
1102
+ """Safe EWMA smoothing with NaN handling"""
1103
+ if len(self.regime_history) < 2:
1104
+ return current_regime
1105
+
1106
+ alpha = 0.3
1107
+ smoothed = current_regime.copy()
1108
+
1109
+ for key in ['low_vol', 'medium_vol', 'high_vol', 'trending', 'mean_reverting']:
1110
+ historical = [r[key] for r in self.regime_history if key in r]
1111
+ historical = [v for v in historical if not (np.isnan(v) or np.isinf(v))]
1112
+
1113
+ if len(historical) > 0:
1114
+ hist_mean = float(np.mean(historical))
1115
+ smoothed[key] = alpha * current_regime[key] + (1-alpha) * hist_mean
1116
+ else:
1117
+ smoothed[key] = current_regime[key]
1118
+
1119
+ return smoothed
1120
+
1121
+ def _default_regime(self):
1122
+ return {
1123
+ 'low_vol': 0.33,
1124
+ 'medium_vol': 0.34,
1125
+ 'high_vol': 0.33,
1126
+ 'trending': 0.5,
1127
+ 'mean_reverting': 0.5,
1128
+ }
1129
+
1130
+ # ============================================================================
1131
+ # ADAPTIVE NORMALIZER
1132
+ # ============================================================================
1133
+
1134
+ class AdaptiveNormalizer:
1135
+ """Regime-aware normalization"""
1136
+
1137
+ def normalize(self, feature_series, regime_weights):
1138
+ if len(feature_series) < 20:
1139
+ return self._zscore_normalize(feature_series)
1140
+
1141
+ try:
1142
+ z_standard = self._zscore_normalize(feature_series)
1143
+ z_robust = self._robust_normalize(feature_series)
1144
+
1145
+ vol_weight = regime_weights['high_vol']
1146
+ z_adaptive = (1 - vol_weight) * z_standard + vol_weight * z_robust
1147
+
1148
+ return np.clip(z_adaptive, -5, 5)
1149
+
1150
+ except:
1151
+ return self._zscore_normalize(feature_series)
1152
+
1153
+ def _zscore_normalize(self, series):
1154
+ mu = series.mean()
1155
+ sigma = series.std()
1156
+ return (series - mu) / (sigma + 1e-10) if sigma > 1e-8 else series * 0
1157
+
1158
+ def _robust_normalize(self, series):
1159
+ q25 = series.quantile(0.25)
1160
+ q75 = series.quantile(0.75)
1161
+ iqr = q75 - q25
1162
+ median = series.median()
1163
+ return (series - median) / (iqr + 1e-10) if iqr > 1e-8 else series * 0
1164
+
1165
+ # ============================================================================
1166
+ # INTEGRATED FEATURE ENHANCER (60 FEATURES STRICT)
1167
+ # ============================================================================
1168
+
1169
+ class IntegratedFeatureEnhancer:
1170
+ def __init__(self, ably_client, agent_names, window_size=100):
1171
+ self.ably = ably_client
1172
+ self.agent_names = agent_names
1173
+ self.window_size = window_size
1174
+
1175
+ self.price_buffers = {name: deque(maxlen=window_size) for name in agent_names}
1176
+
1177
+ # Internal regime components
1178
+ self.regime_detector = RegimeDetector()
1179
+ self.adaptive_normalizer = AdaptiveNormalizer()
1180
+
1181
+ # Channels
1182
+ self.features_channel = ably_client.channels.get("integrated_features_all")
1183
+ self.meta_channels = {
1184
+ name: ably_client.channels.get(f"meta_features-{name}")
1185
+ for name in agent_names
1186
+ }
1187
+
1188
+ self.latest_computed_features = {}
1189
+ self.features_lock = threading.Lock()
1190
+
1191
+ logger.info(f"Regime-Adaptive Feature Enhancer initialized")
1192
+ logger.info(
1193
+ f"Contract: version={FEATURE_CONTRACT.version} "
1194
+ f"features={len(FEATURE_CONTRACT.features)} "
1195
+ f"envelope={len(FEATURE_CONTRACT.envelope)} "
1196
+ f"(invariants enforced at import time)"
1197
+ )
1198
+ # Defensive re-check at instantiation. The contract's __post_init__
1199
+ # already verified this at import, but a runtime assert catches
1200
+ # anyone monkey-patching FEATURE_CONTRACT.features before first use.
1201
+ assert len(FEATURE_CONTRACT.features) == EXPECTED_FEATURE_COUNT, (
1202
+ f"Feature count mismatch at runtime: "
1203
+ f"{len(FEATURE_CONTRACT.features)} != {EXPECTED_FEATURE_COUNT}"
1204
+ )
1205
+
1206
+ def compute_core_technical_features(self, df):
1207
+ """Compute 19 core technical indicators with robust edge case handling"""
1208
+ df = df.copy()
1209
+ eps = 1e-10
1210
+
1211
+ # Suppress warnings during computation
1212
+ with warnings.catch_warnings():
1213
+ warnings.simplefilter("ignore", RuntimeWarning)
1214
+
1215
+ df['log_return'] = np.log(df['Close'] / df['Close'].shift(1)).replace([np.inf, -np.inf], 0).fillna(0)
1216
+ df['rolling_mean_5'] = df['Close'].rolling(5, min_periods=1).mean().fillna(df['Close'])
1217
+ df['rolling_std_5'] = df['Close'].rolling(5, min_periods=1).std().fillna(eps)
1218
+ df['rolling_std_5'] = df['rolling_std_5'].replace(0, eps)
1219
+ df['zscore_5'] = (df['Close'] - df['rolling_mean_5']) / df['rolling_std_5']
1220
+
1221
+ # RSI
1222
+ delta = df['Close'].diff().fillna(0)
1223
+ gain = np.where(delta > 0, delta, 0)
1224
+ loss = np.where(delta < 0, -delta, 0)
1225
+ avg_gain = pd.Series(gain).rolling(14, min_periods=1).mean().fillna(0)
1226
+ avg_loss = pd.Series(loss).rolling(14, min_periods=1).mean().fillna(0)
1227
+ rs = avg_gain / (avg_loss + eps)
1228
+ df['rsi_14'] = 100 - (100 / (1 + rs))
1229
+ df['rsi_14'] = df['rsi_14'].ewm(span=5, adjust=False).mean().fillna(50)
1230
+
1231
+ # MACD
1232
+ ema12 = df['Close'].ewm(span=12, adjust=False).mean()
1233
+ ema26 = df['Close'].ewm(span=26, adjust=False).mean()
1234
+ df['macd'] = ema12 - ema26
1235
+ df['macd_signal'] = df['macd'].ewm(span=9, adjust=False).mean()
1236
+ df['macd_hist'] = df['macd'] - df['macd_signal']
1237
+
1238
+ # ATR
1239
+ high_low = df['High'] - df['Low']
1240
+ high_close = np.abs(df['High'] - df['Close'].shift(1))
1241
+ low_close = np.abs(df['Low'] - df['Close'].shift(1))
1242
+ tr = np.maximum.reduce([high_low, high_close, low_close])
1243
+ df['atr'] = pd.Series(tr).rolling(14, min_periods=1).mean().fillna(0)
1244
+
1245
+ # CDF features
1246
+ window = min(100, len(df))
1247
+ if window >= 20:
1248
+ df['cdf_value'] = df['log_return'].rolling(window, min_periods=10).apply(
1249
+ lambda x: percentileofscore(x.dropna(), x.iloc[-1]) / 100 if len(x.dropna()) > 10 else 0.5
1250
+ ).fillna(0.5)
1251
+ else:
1252
+ df['cdf_value'] = 0.5
1253
+
1254
+ df['cdf_value'] = df['cdf_value'].ffill().bfill().fillna(0.5)
1255
+ df['cdf_slope'] = df['cdf_value'].diff().ewm(span=5, adjust=False).mean().fillna(0)
1256
+ df['cdf_diff'] = (df['cdf_value'] - df['cdf_value'].shift(10)).fillna(0)
1257
+ df['cdf_diff'] = df['cdf_diff'].ewm(span=5, adjust=False).mean().fillna(0)
1258
+
1259
+ # Volatility
1260
+ df['volatility_quantile_90'] = df['rolling_std_5'].rolling(
1261
+ min(100, len(df)), min_periods=20
1262
+ ).quantile(0.9).fillna(df['rolling_std_5'])
1263
+ df['volatility_ratio'] = df['rolling_std_5'] / (df['volatility_quantile_90'] + eps)
1264
+ df['volatility_ratio'] = df['volatility_ratio'].clip(0, 3).fillna(1.0)
1265
+
1266
+ # Entropy
1267
+ df['entropy_50'] = df['log_return'].rolling(
1268
+ min(50, len(df)), min_periods=20
1269
+ ).apply(safe_entropy).fillna(0)
1270
+
1271
+ # Autocorrelation
1272
+ df['autocorr_3'] = df['log_return'].rolling(20, min_periods=5).apply(
1273
+ lambda x: x.autocorr(lag=3) if len(x) > 3 else 0
1274
+ ).fillna(0)
1275
+
1276
+ # Momentum
1277
+ df['momentum_10'] = (df['Close'] / df['Close'].shift(10) - 1).fillna(0)
1278
+
1279
+ # Volume
1280
+ df['volume_change_rate'] = df['Volume'].pct_change().replace([np.inf, -np.inf], 0).fillna(0)
1281
+ vol_mean = df['Volume'].rolling(20, min_periods=1).mean()
1282
+ vol_std = df['Volume'].rolling(20, min_periods=1).std().fillna(eps)
1283
+ df['volume_zscore'] = ((df['Volume'] - vol_mean) / (vol_std + eps)).clip(-3, 3).fillna(0)
1284
+
1285
+ return df
1286
+
1287
+ def compute_derivative_features(self, df, window=10):
1288
+ """Compute 15 derivative features with robust handling"""
1289
+ df = df.copy()
1290
+
1291
+ df['price_vel'] = df['Close'].diff()
1292
+ df['price_acc'] = df['price_vel'].diff()
1293
+ df['price_jrk'] = df['price_acc'].diff()
1294
+
1295
+ for col in ['price_vel', 'price_acc', 'price_jrk']:
1296
+ try:
1297
+ # Use fillna(0) to handle edge cases
1298
+ df[f'{col}_mean'] = df[col].rolling(window, min_periods=1).mean().fillna(0)
1299
+ df[f'{col}_std'] = df[col].rolling(window, min_periods=1).std().fillna(0)
1300
+ df[f'{col}_skew'] = df[col].rolling(window, min_periods=3).apply(
1301
+ safe_skew, raw=True
1302
+ ).fillna(0)
1303
+ df[f'{col}_kurtosis'] = df[col].rolling(window, min_periods=3).apply(
1304
+ safe_kurtosis, raw=True
1305
+ ).fillna(0)
1306
+ except Exception as e:
1307
+ logger.debug(f"Derivative feature {col} computation failed: {e}")
1308
+ df[f'{col}_mean'] = 0
1309
+ df[f'{col}_std'] = 0
1310
+ df[f'{col}_skew'] = 0
1311
+ df[f'{col}_kurtosis'] = 0
1312
+
1313
+ return df
1314
+
1315
+ def compute_additional_technical(self, df):
1316
+ """Compute 7 additional technical features"""
1317
+ df = df.copy()
1318
+ eps = 1e-10
1319
+
1320
+ df['ma10'] = df['Close'].rolling(10, min_periods=1).mean()
1321
+ df['ma20'] = df['Close'].rolling(20, min_periods=1).mean()
1322
+ df['std20'] = df['Close'].rolling(20, min_periods=1).std()
1323
+
1324
+ df['bollinger_upper'] = df['ma20'] + 2 * df['std20']
1325
+ df['bollinger_lower'] = df['ma20'] - 2 * df['std20']
1326
+ df['bollinger_width'] = (df['bollinger_upper'] - df['bollinger_lower']) / (df['ma20'] + eps)
1327
+ df['bollinger_position'] = (df['Close'] - df['bollinger_lower']) / (df['bollinger_upper'] - df['bollinger_lower'] + eps)
1328
+ df['bollinger_position'] = df['bollinger_position'].clip(0, 1)
1329
+
1330
+ return df
1331
+
1332
+ def compute_candlestick_patterns(self, df):
1333
+ """Compute 9 institutional-grade candlestick patterns"""
1334
+ df = df.copy()
1335
+
1336
+ if 'Open' not in df.columns:
1337
+ df['Open'] = df['Close']
1338
+
1339
+ patterns = [
1340
+ ('gravestone_doji', gravestone_doji),
1341
+ ('four_price_doji', four_price_doji),
1342
+ ('doji', doji),
1343
+ ('spinning_top', spinning_top),
1344
+ ('bullish_candle', bullish_candle),
1345
+ ('bearish_candle', bearish_candle),
1346
+ ('dragonfly_candle', dragonfly_candle)
1347
+ ]
1348
+
1349
+ for name, func in patterns:
1350
+ df[name] = df.apply(
1351
+ lambda r: func(r['Open'], r['High'], r['Low'], r['Close']),
1352
+ axis=1
1353
+ )
1354
+
1355
+ df['spinning_top_bearish_followup'] = 0
1356
+ df['bullish_then_dragonfly'] = 0
1357
+
1358
+ for i in range(1, len(df)):
1359
+ c1 = tuple(df.iloc[i-1][['Open', 'High', 'Low', 'Close']])
1360
+ c2 = tuple(df.iloc[i][['Open', 'High', 'Low', 'Close']])
1361
+
1362
+ df.at[df.index[i], 'spinning_top_bearish_followup'] = spinning_top_bearish_followup(c1, c2)
1363
+ df.at[df.index[i], 'bullish_then_dragonfly'] = bullish_candle_followed_by_dragonfly(c1, c2)
1364
+
1365
+ return df
1366
+
1367
+ def compute_support_resistance_features(self, df):
1368
+ """Compute 7 support/resistance features"""
1369
+ df = df.copy()
1370
+
1371
+ if len(df) < 10:
1372
+ df['distance_to_nearest_support'] = 0.0
1373
+ df['distance_to_nearest_resistance'] = 0.0
1374
+ df['near_support'] = 0
1375
+ df['near_resistance'] = 0
1376
+ df['distance_to_stop_loss'] = 0.5
1377
+ df['support_strength'] = 0.0
1378
+ df['resistance_strength'] = 0.0
1379
+ return df
1380
+
1381
+ current_price = df['Close'].iloc[-1]
1382
+ supports = find_supports(current_price, df)
1383
+ resistances = find_resistances(current_price, df)
1384
+ stop_level = find_stop_level(current_price, df)
1385
+
1386
+ min_p, max_p = df['Low'].min(), df['High'].max()
1387
+ rng = max_p - min_p if max_p > min_p else 1
1388
+
1389
+ df['distance_to_nearest_support'] = dist_to_nearest(current_price, supports)
1390
+ df['distance_to_nearest_resistance'] = dist_to_nearest(current_price, resistances)
1391
+ df['near_support'] = int(any(abs(current_price - s) < 0.3 for s in supports)) if supports else 0
1392
+ df['near_resistance'] = int(any(abs(current_price - r) < 0.3 for r in resistances)) if resistances else 0
1393
+ df['distance_to_stop_loss'] = (current_price - stop_level) / rng if stop_level else 0.5
1394
+ df['support_strength'] = cluster_strength([s/rng for s in supports])
1395
+ df['resistance_strength'] = cluster_strength([r/rng for r in resistances])
1396
+
1397
+ return df
1398
+
1399
+ def _validate_feature_contract(self, features_dict):
1400
+ """
1401
+ Delegate to FEATURE_CONTRACT.validate() and return a legacy
1402
+ 3-tuple (is_valid, missing, extra) for call-site back-compat.
1403
+
1404
+ `extra` in the legacy contract conflated two distinct failure
1405
+ modes — envelope leakage and unknown keys. We preserve the
1406
+ 3-tuple shape but keep them merged; richer diagnostics are
1407
+ available by calling FEATURE_CONTRACT.validate() directly.
1408
+ """
1409
+ result = FEATURE_CONTRACT.validate(features_dict)
1410
+ extra = result.leaked_envelope | result.unexpected
1411
+ return result.ok, result.missing, extra
1412
+
1413
+ def compute_all_features(self, df):
1414
+ """
1415
+ Compute exactly 60 features with regime-adaptive normalization
1416
+ Regime detection is internal - NOT published
1417
+ """
1418
+ try:
1419
+ if len(df) < 10:
1420
+ return pd.DataFrame()
1421
+
1422
+ # Step 1: Compute raw features
1423
+ df = self.compute_core_technical_features(df)
1424
+ df = self.compute_derivative_features(df)
1425
+ df = self.compute_additional_technical(df)
1426
+ df = self.compute_candlestick_patterns(df)
1427
+ df = self.compute_support_resistance_features(df)
1428
+
1429
+ # Step 2: Internal regime detection
1430
+ regime_weights = self.regime_detector.detect_regime(df)
1431
+
1432
+ # Step 3: Apply adaptive normalization ONLY to continuous features
1433
+ continuous_features = [
1434
+ 'log_return', 'rolling_std_5', 'zscore_5', 'rsi_14',
1435
+ 'macd', 'macd_signal', 'macd_hist', 'atr',
1436
+ 'cdf_value', 'cdf_slope', 'cdf_diff',
1437
+ 'volatility_ratio', 'entropy_50', 'autocorr_3', 'momentum_10',
1438
+ 'volume_change_rate', 'volume_zscore',
1439
+ 'price_vel_mean', 'price_acc_mean', 'price_jrk_mean',
1440
+ 'price_vel_std', 'price_acc_std', 'price_jrk_std',
1441
+ 'price_vel_skew', 'price_acc_skew', 'price_jrk_skew',
1442
+ 'price_vel_kurtosis', 'price_acc_kurtosis', 'price_jrk_kurtosis',
1443
+ 'bollinger_width', 'bollinger_position',
1444
+ 'distance_to_nearest_support', 'distance_to_nearest_resistance',
1445
+ 'distance_to_stop_loss', 'support_strength', 'resistance_strength'
1446
+ ]
1447
+
1448
+ for feature in continuous_features:
1449
+ if feature in df.columns and feature not in NORMALIZATION_EXCLUSIONS:
1450
+ df[feature] = self.adaptive_normalizer.normalize(
1451
+ df[feature], regime_weights
1452
+ )
1453
+
1454
+ # Clean infinities and NaNs
1455
+ df = df.replace([np.inf, -np.inf], np.nan)
1456
+ df = df.ffill().bfill().fillna(0)
1457
+
1458
+ return df
1459
+
1460
+ except Exception as e:
1461
+ logger.error(f"Feature computation failed: {e}")
1462
+ return pd.DataFrame()
1463
+
1464
+ def extract_meta_features(self, df, current_price):
1465
+ """Extract exactly 24 meta features (23 + timestamp)"""
1466
+ try:
1467
+ if len(df) < 10:
1468
+ return {}
1469
+
1470
+ supports = find_supports(current_price, df)
1471
+ resistances = find_resistances(current_price, df)
1472
+ stop_level = find_stop_level(current_price, df)
1473
+
1474
+ min_p, max_p = df['Low'].min(), df['High'].max()
1475
+ rng = max_p - min_p if max_p > min_p else 1
1476
+
1477
+ # Voting features (8)
1478
+ voting = {
1479
+ 'distance_to_nearest_support_scaled': dist_to_nearest(current_price, supports) / rng if rng > 0 else 0.0,
1480
+ 'distance_to_nearest_resistance_scaled': dist_to_nearest(current_price, resistances) / rng if rng > 0 else 0.0,
1481
+ 'near_support': int(any(abs(current_price - s) < 0.3 for s in supports)) if supports else 0,
1482
+ 'near_resistance': int(any(abs(current_price - r) < 0.3 for r in resistances)) if resistances else 0,
1483
+ 'distance_to_stop_loss_scaled': (current_price - stop_level) / rng if stop_level and rng > 0 else 0.5,
1484
+ 'support_strength_scaled': cluster_strength([s/rng for s in supports]) if rng > 0 else 0.0,
1485
+ 'resistance_strength_scaled': cluster_strength([r/rng for r in resistances]) if rng > 0 else 0.0,
1486
+ 'close_price': float(current_price)
1487
+ }
1488
+
1489
+ # Filtered technical (15)
1490
+ latest = df.iloc[-1]
1491
+ feature_mappings = [
1492
+ ('price_vel', 'price_vel_scaled'),
1493
+ ('price_acc', 'price_acc_scaled'),
1494
+ ('price_jrk', 'price_jrk_scaled'),
1495
+ ('price_vel_mean', 'price_vel_mean_scaled'),
1496
+ ('price_acc_mean', 'price_acc_mean_scaled'),
1497
+ ('price_jrk_mean', 'price_jrk_mean_scaled'),
1498
+ ('ma10', 'ma10_scaled'),
1499
+ ('ma20', 'ma20_scaled'),
1500
+ ('bollinger_upper', 'bollinger_upper_scaled'),
1501
+ ('bollinger_lower', 'bollinger_lower_scaled'),
1502
+ ('macd', 'macd_scaled'),
1503
+ ('macd_signal', 'macd_signal_scaled'),
1504
+ ('macd_hist', 'macd_hist_scaled'),
1505
+ ('rsi_14', 'rsi_scaled'),
1506
+ ('std20', 'std20_scaled')
1507
+ ]
1508
+
1509
+ filtered = {}
1510
+ for df_col, meta_col in feature_mappings:
1511
+ if df_col in latest.index:
1512
+ filtered[meta_col] = float(latest[df_col])
1513
+ else:
1514
+ filtered[meta_col] = 0.0
1515
+
1516
+ meta_features = {**filtered, **voting}
1517
+
1518
+ # Validate count (23 features, timestamp added later)
1519
+ if len(meta_features) != 23:
1520
+ logger.error(f"Meta feature count violation: {len(meta_features)} != 23")
1521
+ return {}
1522
+
1523
+ return meta_features
1524
+
1525
+ except Exception as e:
1526
+ logger.error(f"Meta feature extraction failed: {e}")
1527
+ return {}
1528
+
1529
+ def process_raw_tick(self, agent_name, price_data):
1530
+ """Process tick and enforce 60-feature contract"""
1531
+ try:
1532
+ close_price = price_data.get('close', 0)
1533
+
1534
+ self.price_buffers[agent_name].append({
1535
+ 'Close': close_price,
1536
+ 'High': price_data.get('high', close_price),
1537
+ 'Low': price_data.get('low', close_price),
1538
+ 'Volume': price_data.get('volume', 0),
1539
+ 'Open': price_data.get('open', close_price)
1540
+ })
1541
+
1542
+ if len(self.price_buffers[agent_name]) < 30:
1543
+ return
1544
+
1545
+ df = pd.DataFrame(list(self.price_buffers[agent_name]))
1546
+ enhanced_df = self.compute_all_features(df)
1547
+
1548
+ if enhanced_df.empty:
1549
+ return
1550
+
1551
+ # CRITICAL FIX: Only extract computed features, not raw OHLCV
1552
+ latest_row = enhanced_df.iloc[-1]
1553
+
1554
+ # Extract only REQUIRED_FEATURES (excluding raw OHLCV columns)
1555
+ latest_features = {}
1556
+ for feature in REQUIRED_FEATURES:
1557
+ if feature in ['price', 'close_scaled', 'close_price']:
1558
+ # These are price variants we add manually
1559
+ latest_features[feature] = float(close_price)
1560
+ elif feature in latest_row.index:
1561
+ latest_features[feature] = float(latest_row[feature])
1562
+ else:
1563
+ logger.warning(f"[{agent_name}] Missing feature: {feature}")
1564
+ latest_features[feature] = 0.0
1565
+
1566
+ # ENFORCE CONTRACT — use the rich ValidationResult directly so we
1567
+ # log three distinct failure modes separately instead of collapsing
1568
+ # them into a single ambiguous "Missing / Extra" pair.
1569
+ validation = FEATURE_CONTRACT.validate(latest_features)
1570
+
1571
+ if not validation.ok:
1572
+ logger.error("=" * 80)
1573
+ logger.error(
1574
+ f"❌ [{agent_name}] FEATURE CONTRACT VIOLATION "
1575
+ f"(contract={FEATURE_CONTRACT.version})"
1576
+ )
1577
+ for line in validation.as_error_lines():
1578
+ logger.error(f" {line}")
1579
+ logger.error("=" * 80)
1580
+
1581
+ # Bookkeeping counter — lets ops tell the difference between
1582
+ # "feed is dry" and "feed is arriving but contract is broken".
1583
+ if not hasattr(self, '_contract_violation_counts'):
1584
+ self._contract_violation_counts = {}
1585
+ self._contract_violation_counts[agent_name] = (
1586
+ self._contract_violation_counts.get(agent_name, 0) + 1
1587
+ )
1588
+ return
1589
+
1590
+ with self.features_lock:
1591
+ self.latest_computed_features[agent_name] = latest_features.copy()
1592
+
1593
+ except Exception as e:
1594
+ logger.error(f"[{agent_name}] Feature enhancement failed: {e}")
1595
+
1596
+ async def publish_features(self, agent_name, features_dict, tick_index=None):
1597
+ """
1598
+ Publish 60 features on the wire. Payload shape is enforced by
1599
+ FEATURE_CONTRACT.build_payload() — envelope keys live at the
1600
+ top level, feature keys live ONLY inside payload['features'],
1601
+ and a contract_version string accompanies every message so the
1602
+ consumer can detect schema drift.
1603
+ """
1604
+ try:
1605
+ # Defensive re-validation at the publish boundary. Zero cost on
1606
+ # the happy path; catches any mutation between compute and
1607
+ # publish (e.g. a caller accidentally injecting envelope keys
1608
+ # into the features dict).
1609
+ validation = FEATURE_CONTRACT.validate(features_dict)
1610
+ if not validation.ok:
1611
+ logger.error(
1612
+ f"[{agent_name}] publish BLOCKED — contract violation at "
1613
+ f"publish boundary: {validation.as_error_lines()}"
1614
+ )
1615
+ return
1616
+
1617
+ # Coerce numpy scalars to native floats so the JSON serialiser
1618
+ # doesn't choke. Done on the features-only dict, inside the
1619
+ # contract shape.
1620
+ clean_features = {
1621
+ k: float(v) if isinstance(v, (np.floating, np.integer)) else v
1622
+ for k, v in features_dict.items()
1623
+ }
1624
+
1625
+ # Resolve tick_index: caller may pass it explicitly, or it may
1626
+ # be embedded in the dict (legacy path). Envelope keys should
1627
+ # NOT be inside features_dict after the validation above, so
1628
+ # these .get() calls will normally return None — kept for
1629
+ # defensive back-compat.
1630
+ resolved_tick = tick_index
1631
+ if resolved_tick is None:
1632
+ resolved_tick = (
1633
+ features_dict.get('tick_count')
1634
+ or features_dict.get('tick_index')
1635
+ )
1636
+
1637
+ payload = FEATURE_CONTRACT.build_payload(
1638
+ agent_name = agent_name,
1639
+ features_dict = clean_features,
1640
+ tick_index = resolved_tick,
1641
+ timestamp_iso = datetime.now(UTC).isoformat(),
1642
+ )
1643
+
1644
+ await self.features_channel.publish("integrated-features", payload)
1645
+
1646
+ except Exception as e:
1647
+ logger.error(f"[{agent_name}] Feature publish failed: {e}")
1648
+
1649
+ async def publish_meta_features(self, agent_name, meta_features):
1650
+ """Publish 24 meta features"""
1651
+ try:
1652
+ channel = self.meta_channels[agent_name]
1653
+
1654
+ clean_meta = {
1655
+ k: float(v) if isinstance(v, (np.floating, np.integer)) else v
1656
+ for k, v in meta_features.items()
1657
+ }
1658
+
1659
+ clean_meta['agent'] = agent_name
1660
+ clean_meta['timestamp'] = datetime.now(UTC).isoformat()
1661
+
1662
+ await channel.publish("meta_features", clean_meta)
1663
+
1664
+ except Exception as e:
1665
+ logger.error(f"[{agent_name}] Meta feature publish failed: {e}")
1666
+
1667
+ def get_latest_state_features(self, agent_name=None):
1668
+ """Get latest features with type-aware aggregation"""
1669
+ with self.features_lock:
1670
+ if agent_name:
1671
+ return self.latest_computed_features.get(agent_name, {})
1672
+
1673
+ if not self.latest_computed_features:
1674
+ return {}
1675
+
1676
+ all_features = list(self.latest_computed_features.values())
1677
+ if not all_features:
1678
+ return {}
1679
+
1680
+ return self._safe_aggregate_features(all_features)
1681
+
1682
+ def _safe_aggregate_features(self, all_features):
1683
+ """Type-aware feature aggregation across agents"""
1684
+ avg_features = {}
1685
+ feature_keys = all_features[0].keys()
1686
+
1687
+ for key in feature_keys:
1688
+ values = [f[key] for f in all_features if key in f]
1689
+
1690
+ if not values:
1691
+ continue
1692
+
1693
+ if key in BINARY_FEATURES:
1694
+ # Voting for binary features
1695
+ avg_features[key] = int(np.sum(values) > len(values) / 2)
1696
+ elif key in PRICE_FEATURES:
1697
+ # Median for price features (robust to outliers)
1698
+ clean_values = [v for v in values if not np.isnan(v)]
1699
+ if clean_values:
1700
+ avg_features[key] = float(np.median(clean_values))
1701
+ else:
1702
+ avg_features[key] = 0.0
1703
+ else:
1704
+ # Mean for continuous features
1705
+ clean_values = [v for v in values if not np.isnan(v)]
1706
+ if clean_values:
1707
+ avg_features[key] = float(np.mean(clean_values))
1708
+ else:
1709
+ avg_features[key] = 0.0
1710
+
1711
+ return avg_features
1712
+
1713
+ def get_feature_summary(self):
1714
+ """Get detailed feature summary"""
1715
+ with self.features_lock:
1716
+ if not self.latest_computed_features:
1717
+ return "No features computed yet"
1718
+
1719
+ sample_agent = list(self.latest_computed_features.keys())[0]
1720
+ features = self.latest_computed_features[sample_agent]
1721
+
1722
+ # Count only keys that are actually declared features in the
1723
+ # contract. This is set-intersection, not set-difference — so
1724
+ # it's correct regardless of whether envelope keys have leaked
1725
+ # into the features dict or not.
1726
+ actual_count = len(set(features.keys()) & FEATURE_CONTRACT.features)
1727
+
1728
+ summary = f"REGIME-ADAPTIVE FEATURE ENHANCER\n"
1729
+ summary += "=" * 60 + "\n\n"
1730
+ summary += f"Total Features: {actual_count} (Expected: 60)\n\n"
1731
+ summary += "Feature Categories:\n"
1732
+ summary += f" • Core Technical: 19 features\n"
1733
+ summary += f" • Derivatives: 15 features\n"
1734
+ summary += f" • Additional Technical: 7 features\n"
1735
+ summary += f" • Candlestick Patterns: 9 features (institutional-grade)\n"
1736
+ summary += f" • Support/Resistance: 7 features\n"
1737
+ summary += f" • Price Variants: 3 features\n"
1738
+ summary += f" • TOTAL: 60 features\n\n"
1739
+ summary += f"Meta Features (24 total, published separately):\n"
1740
+ summary += f" • Voting: 8 features\n"
1741
+ summary += f" • Technical: 15 features\n"
1742
+ summary += f" • Timestamp: 1 metadata\n\n"
1743
+ summary += f"Regime Detection: INTERNAL (adaptive normalization)\n"
1744
+ summary += f" • Volatility regimes: low/medium/high\n"
1745
+ summary += f" • Trend detection: momentum-based\n"
1746
+ summary += f" • Mean-reversion: entropy-based\n\n"
1747
+ summary += f"Normalization: Regime-adaptive\n"
1748
+ summary += f" • High vol → Robust scaling (IQR)\n"
1749
+ summary += f" • Low vol → Standard z-score\n"
1750
+ summary += f" • Excluded: {len(NORMALIZATION_EXCLUSIONS)} features\n\n"
1751
+ summary += f"Aggregation: Type-aware\n"
1752
+ summary += f" • Binary: Voting (majority rule)\n"
1753
+ summary += f" • Price: Median (outlier-resistant)\n"
1754
+ summary += f" • Continuous: Mean\n"
1755
+
1756
+ return summary
1757
+
1758
+ # ============================================================================
1759
+ # ASYNC WRAPPER
1760
+ # ============================================================================
1761
+
1762
+ class AsyncIntegratedFeatureEnhancer:
1763
+ def __init__(self, ably_client, agent_names, window_size=100):
1764
+ self.enhancer = IntegratedFeatureEnhancer(ably_client, agent_names, window_size)
1765
+ self.ably = ably_client
1766
+ self.agents = agent_names
1767
+ self.running = False
1768
+ self.channels = {}
1769
+
1770
+ def get_latest_state_features(self, agent_name=None):
1771
+ return self.enhancer.get_latest_state_features(agent_name)
1772
+
1773
+ async def start(self):
1774
+ self.running = True
1775
+ logger.info("AsyncIntegratedFeatureEnhancer started")
1776
+ logger.info("\n" + self.enhancer.get_feature_summary())
1777
+ await self._start_ably_listeners()
1778
+
1779
+ async def _start_ably_listeners(self):
1780
+ if not self.ably:
1781
+ logger.error("No Ably client available")
1782
+ return
1783
+
1784
+ if hasattr(self.ably, 'connection') and self.ably.connection.state != 'connected':
1785
+ try:
1786
+ self.ably.connection.connect()
1787
+ for _ in range(20):
1788
+ await asyncio.sleep(0.5)
1789
+ if self.ably.connection.state == 'connected':
1790
+ break
1791
+ else:
1792
+ logger.error("Failed to connect to Ably")
1793
+ return
1794
+ except Exception as e:
1795
+ logger.error(f"Redis connection failed: {e}")
1796
+ return
1797
+
1798
+ logger.info(f"Starting Ably listeners")
1799
+
1800
+ for agent in self.agents:
1801
+ agent_str = agent.decode('utf-8') if isinstance(agent, bytes) else str(agent)
1802
+
1803
+ feature_ok = await self._subscribe_with_retry(
1804
+ agent_str, "integrated-features",
1805
+ lambda msg, name=agent_str: self._handle_feature_message(name, msg)
1806
+ )
1807
+ meta_ok = await self._subscribe_with_retry(
1808
+ agent_str, "meta_features",
1809
+ lambda msg, name=agent_str: self._handle_meta_features_message(name, msg),
1810
+ channel_suffix="meta_features-"
1811
+ )
1812
+
1813
+ if feature_ok:
1814
+ logger.info(f"✓ [{agent_str}] Feature channel attached")
1815
+ if meta_ok:
1816
+ logger.info(f"✓ [{agent_str}] Meta features channel attached")
1817
+
1818
+ async def _subscribe_with_retry(self, agent_name, event_name, callback, max_retries=3, timeout=10, channel_suffix=""):
1819
+ channel_name = f"{channel_suffix}{agent_name}" if channel_suffix else agent_name
1820
+
1821
+ for attempt in range(max_retries):
1822
+ try:
1823
+ channel = self.ably.channels.get(channel_name)
1824
+ self.channels[channel_name] = channel
1825
+
1826
+ attach_task = asyncio.create_task(channel.attach())
1827
+ try:
1828
+ await asyncio.wait_for(attach_task, timeout=timeout)
1829
+ except asyncio.TimeoutError:
1830
+ if attempt < max_retries - 1:
1831
+ await asyncio.sleep(2 ** attempt)
1832
+ continue
1833
+ return False
1834
+
1835
+ subscribe_task = asyncio.create_task(channel.subscribe(event_name, callback))
1836
+ try:
1837
+ await asyncio.wait_for(subscribe_task, timeout=timeout)
1838
+ return True
1839
+ except asyncio.TimeoutError:
1840
+ if attempt < max_retries - 1:
1841
+ await asyncio.sleep(2 ** attempt)
1842
+ continue
1843
+ return False
1844
+
1845
+ except Exception as e:
1846
+ if attempt < max_retries - 1:
1847
+ await asyncio.sleep(2 ** attempt)
1848
+ continue
1849
+ return False
1850
+ return False
1851
+
1852
+ async def process_tick(self, agent_name, price_data):
1853
+ loop = asyncio.get_event_loop()
1854
+ await loop.run_in_executor(None, self.enhancer.process_raw_tick, agent_name, price_data)
1855
+
1856
+ features = self.enhancer.get_latest_state_features(agent_name)
1857
+ if features:
1858
+ await self._publish_with_retry(agent_name, features, meta=False)
1859
+
1860
+ df = pd.DataFrame(list(self.enhancer.price_buffers[agent_name]))
1861
+ if len(df) >= 10:
1862
+ current_price = price_data.get('close', 0)
1863
+ meta_features = self.enhancer.extract_meta_features(df, current_price)
1864
+ if meta_features:
1865
+ await self._publish_with_retry(agent_name, meta_features, meta=True)
1866
+
1867
+ async def _publish_with_retry(self, agent_name, features_dict, meta=False, tick_index=None):
1868
+ channel_name = f"meta_features-{agent_name}" if meta else agent_name
1869
+ event_name = "meta_features" if meta else "feature"
1870
+
1871
+ if channel_name not in self.channels:
1872
+ self.channels[channel_name] = self.ably.channels.get(channel_name)
1873
+
1874
+ channel = self.channels[channel_name]
1875
+
1876
+ # Resolve tick_index from the features dict if not supplied
1877
+ resolved_tick = tick_index
1878
+ if resolved_tick is None and isinstance(features_dict, dict):
1879
+ resolved_tick = features_dict.get('tick_count') or features_dict.get('tick_index')
1880
+
1881
+ payload = {
1882
+ 'agent': agent_name,
1883
+ 'features' if not meta else 'meta_features': features_dict,
1884
+ 'timestamp': datetime.now(UTC).isoformat(),
1885
+ 'tick_index': resolved_tick
1886
+ }
1887
+
1888
+ for attempt in range(3):
1889
+ try:
1890
+ await channel.publish(event_name, payload)
1891
+ break
1892
+ except Exception as e:
1893
+ await asyncio.sleep(2 ** attempt)
1894
+
1895
+ def _handle_feature_message(self, agent_name, msg):
1896
+ logger.debug(f"[{agent_name}] Feature message received")
1897
+
1898
+ def _handle_meta_features_message(self, agent_name, msg):
1899
+ logger.debug(f"[{agent_name}] Meta feature message received")
1900
+
1901
+ # ============================================================================
1902
+ # MAIN EXECUTION - DERIV WEBSOCKET VERSION
1903
+ # ============================================================================
1904
+
1905
+ async def main():
1906
+ nest_asyncio.apply()
1907
+
1908
+ logger.info("=" * 80)
1909
+ logger.info("🚀 REGIME-ADAPTIVE FEATURE ENHANCER - DERIV WEBSOCKET EDITION")
1910
+ logger.info("=" * 80)
1911
+
1912
+ # Initialize Deriv WebSocket instead of MT5
1913
+ if not await deriv_bridge.initialize(SYMBOL):
1914
+ raise RuntimeError(f"❌ Deriv initialization failed")
1915
+
1916
+ logger.info(f"✅ Deriv WebSocket initialized")
1917
+ logger.info(f" Symbol: {SYMBOL} -> {DERIV_SYMBOL}")
1918
+
1919
+ # Verify symbol
1920
+ symbol_info = deriv_bridge.symbol_info(SYMBOL)
1921
+ if symbol_info is None:
1922
+ await deriv_bridge.shutdown()
1923
+ raise RuntimeError(f"❌ Symbol {SYMBOL} not found")
1924
+
1925
+ logger.info(f"✅ Symbol verified")
1926
+
1927
+ logger.info("\n📡 Connecting to Redis (V75 namespace)...")
1928
+ try:
1929
+ ably_client = RedisAblyClient(redis_url=REDIS_URL, use_streams=True) # V75
1930
+ await asyncio.sleep(1)
1931
+ logger.info("✅ Redis connected (V75 — channels prefixed with '%s')" % CHANNEL_PREFIX)
1932
+ except Exception as e:
1933
+ await deriv_bridge.shutdown()
1934
+ raise RuntimeError(f"❌ Redis connection failed: {e}")
1935
+
1936
+ logger.info("\n🔧 Initializing feature enhancers...")
1937
+ agent_names = list(TIMEFRAMES.keys())
1938
+
1939
+ enhancer = AsyncIntegratedFeatureEnhancer(
1940
+ ably_client=ably_client,
1941
+ agent_names=agent_names,
1942
+ window_size=100
1943
+ )
1944
+
1945
+ await enhancer.start()
1946
+
1947
+ agent_channels = {tf: ably_client.channels.get(tf) for tf in TIMEFRAMES}
1948
+
1949
+ # =========================================================================
1950
+ # BATCH SYNCHRONISATION — now handled by FeatureBatchGateway in Redis
1951
+ # =========================================================================
1952
+ # Features.py's responsibility is ONLY to publish each agent's features to
1953
+ # its own per-agent Redis channel as soon as they are computed.
1954
+ #
1955
+ # The FeatureBatchGateway (in redis_connection_manager.py) subscribes to
1956
+ # all 8 per-agent channels on the Quasar side and acts as the gating layer:
1957
+ # • Accumulates per-agent contributions for each tick
1958
+ # • DISCARDS any partial batch when a new tick_index arrives (waitlist discard)
1959
+ # • Only fires on_batch_ready() when ALL 8 agents share the same tick/price
1960
+ #
1961
+ # This keeps Features.py simple (just publish, no coordination) and moves
1962
+ # the synchronisation concern to the Redis transport layer where it belongs.
1963
+ # =========================================================================
1964
+
1965
+ logger.info("\n✅ All systems initialized - Starting tick processing...\n")
1966
+
1967
+ tick_count = 0
1968
+ last_summary_time = time.time()
1969
+ feature_counts = {tf: 0 for tf in TIMEFRAMES}
1970
+
1971
+ # ── Rate-limit gate ───────────────────────────────────────────────────────
1972
+ # Derived from observed p95 latencies in the QSAP health report:
1973
+ # • Per-agent inference p95 ≈ 1552 ms
1974
+ # • Dispatch latency p95 ≈ 1292 ms
1975
+ # With all 8 agents running concurrently (asyncio.gather) the bottleneck
1976
+ # is max(p95_inference) ≈ 1552 ms. 3 000 ms gives ~93 % headroom and
1977
+ # guarantees the downstream QSAP never receives a stale tick.
1978
+ MIN_TICK_INTERVAL = 60.0 # seconds — never dispatch faster than this
1979
+ _processing = asyncio.Semaphore(1) # only one tick in-flight at a time
1980
+
1981
+ async def _process_one_agent(tf_name, price_data, timestamp):
1982
+ """Process and publish a single timeframe agent concurrently."""
1983
+ try:
1984
+ await enhancer.process_tick(tf_name, price_data)
1985
+ features = enhancer.get_latest_state_features(tf_name)
1986
+ if features:
1987
+ feature_counts[tf_name] += 1
1988
+ features_with_meta = {
1989
+ **features,
1990
+ 'timestamp': timestamp.isoformat(),
1991
+ 'tick_count': tick_count,
1992
+ 'timeframe': tf_name,
1993
+ }
1994
+ # Publish to per-agent channel.
1995
+ # The FeatureBatchGateway in redis_connection_manager.py
1996
+ # subscribes to all 8 per-agent channels and fires a
1997
+ # complete batch only when all agents share the same
1998
+ # tick_index — discarding any partial/stale waitlist.
1999
+ await agent_channels[tf_name].publish(
2000
+ "integrated-features",
2001
+ {
2002
+ "agent": tf_name,
2003
+ "features": features_with_meta,
2004
+ "feature_count": len(features),
2005
+ "tick_index": tick_count,
2006
+ "price": price_data['close'], # raw Deriv tick — §0c
2007
+ },
2008
+ )
2009
+ if feature_counts[tf_name] % 10 == 0:
2010
+ logger.info(
2011
+ f"✅ [{tf_name}] Tick #{tick_count}: "
2012
+ f"60 features + meta | Price: {price_data['close']:.5f}"
2013
+ )
2014
+ except Exception as e:
2015
+ logger.error(f"❌ [{tf_name}] Error: {e}")
2016
+
2017
+ try:
2018
+ while True:
2019
+ tick_start = time.monotonic()
2020
+
2021
+ try:
2022
+ # Get tick from Deriv WebSocket instead of MT5
2023
+ tick = deriv_bridge.symbol_info_tick(SYMBOL)
2024
+
2025
+ if tick is None:
2026
+ await asyncio.sleep(0.5)
2027
+ continue
2028
+
2029
+ tick_count += 1
2030
+ mid_price = (tick.bid + tick.ask) / 2.0
2031
+ timestamp = datetime.now(UTC)
2032
+ price_data = {
2033
+ 'close': mid_price,
2034
+ 'high': tick.ask,
2035
+ 'low': tick.bid,
2036
+ 'open': mid_price,
2037
+ 'volume': getattr(tick, 'volume', 0),
2038
+ }
2039
+
2040
+ # ── All 8 agents run CONCURRENTLY; next tick cannot start until
2041
+ # every agent has finished computing and publishing. ─────────
2042
+ async with _processing:
2043
+ await asyncio.gather(
2044
+ *[_process_one_agent(tf, price_data, timestamp)
2045
+ for tf in TIMEFRAMES],
2046
+ return_exceptions=True, # one agent error never kills others
2047
+ )
2048
+
2049
+ if time.time() - last_summary_time > 60:
2050
+ logger.info("\n" + "=" * 80)
2051
+ logger.info(f"📊 SUMMARY (Tick #{tick_count})")
2052
+ logger.info("=" * 80)
2053
+ logger.info(f"Price: {mid_price:.5f}")
2054
+ logger.info(f"Data Source: Deriv WebSocket (Streaming)")
2055
+ for tf in TIMEFRAMES:
2056
+ logger.info(f" {tf}: {feature_counts[tf]} updates")
2057
+ logger.info("=" * 80 + "\n")
2058
+ last_summary_time = time.time()
2059
+
2060
+ except KeyboardInterrupt:
2061
+ break
2062
+
2063
+ except Exception as e:
2064
+ logger.error(f"❌ Tick error: {e}")
2065
+
2066
+ # ── Completion-based gate ─────────────────────────────────────────
2067
+ # Sleep only the time remaining to reach MIN_TICK_INTERVAL.
2068
+ # If processing already took longer, sleep_for = 0 (no extra wait).
2069
+ elapsed = time.monotonic() - tick_start
2070
+ sleep_for = max(0.0, MIN_TICK_INTERVAL - elapsed)
2071
+ logger.debug(
2072
+ f"Tick #{tick_count} | processed in {elapsed*1000:.0f} ms "
2073
+ f"| sleeping {sleep_for*1000:.0f} ms"
2074
+ )
2075
+ await asyncio.sleep(sleep_for)
2076
+
2077
+ finally:
2078
+ logger.info("\n🛑 SHUTTING DOWN")
2079
+ await deriv_bridge.shutdown()
2080
+ logger.info(f"Total Ticks: {tick_count}")
2081
+ logger.info("✅ Shutdown complete")
2082
+
2083
+ if __name__ == "__main__":
2084
+ try:
2085
+ nest_asyncio.apply()
2086
+ asyncio.run(main())
2087
+ except KeyboardInterrupt:
2088
+ logger.info("\n⚠️ Interrupted by user")
2089
+ except Exception as e:
2090
+ logger.error(f"\n❌ Fatal error: {e}")
2091
+ traceback.print_exc()
2092
+
2093
+
2094
+ #+263780563561 ENG Karl Muzunze Masvingo Zimbabwe
Rewards.py ADDED
@@ -0,0 +1,1083 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ K1RL QUANT - INSTITUTIONAL REWARDS SYSTEM v5.2.1-V75
4
+ HuggingFace Spaces Edition - Maximum Performance
5
+
6
+ V75 NAMESPACE ISOLATION:
7
+ ✅ All channels prefixed with "V75:" — zero cross-talk with other spaces
8
+ ✅ Uses DB 0/1 (features/rewards) — isolated per Space container
9
+ ✅ Imports from redis_config_v75 for V75-specific configuration
10
+
11
+ CRITICAL FIX (v5.2.1):
12
+ ✅ FIXED: asyncio.get_event_loop() from listener thread returned WRONG loop
13
+ → Reward tasks silently dropped (never scheduled)
14
+ → Now stores loop reference via asyncio.get_running_loop() in start()
15
+ ✅ All v5.2.0 fixes retained
16
+
17
+ CRITICAL FIX (v5.2.0):
18
+ ✅ REMOVED duplicate RedisAblyClient - uses redis_connection_manager.RedisAblyClient
19
+ ✅ Added connection health monitoring with auto-reconnection
20
+ ✅ Bounded reward task pool (prevents coroutine leak)
21
+ ✅ Deriv WebSocket auto-reconnection loop
22
+ ✅ Pub/sub heartbeat detection (detects silent disconnects)
23
+
24
+ PREVIOUS OPTIMIZATIONS (v5.1.0):
25
+ ✅ Proper async price streaming with reconnection
26
+ ✅ LRU cache for price data with TTL
27
+ ✅ O(1) signal tracking with hash maps
28
+ ✅ Batch processing with backpressure
29
+ ✅ Connection health monitoring
30
+ ✅ Memory-efficient deque buffers
31
+ ✅ HuggingFace Spaces compatibility
32
+ ✅ Container-safe logging and paths
33
+ """
34
+
35
+ import asyncio
36
+ import logging
37
+ import sys
38
+ import time
39
+ import json
40
+ import traceback
41
+ import ssl
42
+ import websockets
43
+ import os
44
+ from datetime import datetime, timezone
45
+ from collections import deque, OrderedDict
46
+ from dataclasses import dataclass, field
47
+ from typing import Optional, Dict, List, Any, Deque
48
+ from functools import lru_cache
49
+ import numpy as np
50
+ from pathlib import Path
51
+
52
+ # Async compatibility
53
+ try:
54
+ import nest_asyncio
55
+ nest_asyncio.apply()
56
+ except ImportError:
57
+ pass
58
+
59
+ # ============================================================================
60
+ # ✅ FIX #1: Import the ROBUST RedisAblyClient from redis_connection_manager
61
+ # instead of defining a broken local version with no reconnection
62
+ # ============================================================================
63
+ import redis
64
+ from redis_config_v75 import (
65
+ REDIS_URL, REDIS_PASSWORD,
66
+ REDIS_DB_FEATURES, REDIS_DB_REWARDS,
67
+ CHANNEL_PREFIX, prefixed_channel, QUASAR_VERSION
68
+ )
69
+ from redis_connection_manager import (
70
+ RedisAblyClient,
71
+ RedisMessage,
72
+ DedicatedRedisConnectionManager,
73
+ diagnose_redis_connection,
74
+ IS_HF_SPACES
75
+ )
76
+
77
+ # ============================================================================
78
+ # HUGGINGFACE SPACES CONFIGURATION
79
+ # ============================================================================
80
+
81
+ # ✅ FIXED: Environment variable for API key
82
+ DERIV_API_KEY = os.environ.get('DERIV_API_KEY', '') # no token needed for tick streaming
83
+ DERIV_WS_URL = "wss://api.derivws.com/trading/v1/options/ws/public"
84
+
85
+ SYMBOL_MAP = {
86
+ "Volatility 25 Index": "R_25",
87
+ "Crash 500 Index": "CRASH500",
88
+ "Volatility 100 Index": "R_100",
89
+ "Volatility 50 Index": "R_50",
90
+ "Volatility 75 Index": "R_75", # ✅ V75: Volatility 75 Index symbol
91
+ }
92
+
93
+ SYMBOL = "Volatility 75 Index" # ✅ V75
94
+ DERIV_SYMBOL = "R_75" # ✅ V75: Volatility 75 Index Deriv symbol
95
+
96
+ # Ably Configuration (now Redis channels — V75 NAMESPACED)
97
+ ABLY_SIGNAL_CHANNEL = prefixed_channel("final_signals") # → "V75:final_signals"
98
+ ABLY_REWARD_CHANNEL = prefixed_channel("rewards") # → "V75:rewards"
99
+ ABLY_BATCH_CHANNEL = prefixed_channel("reward-batches") # → "V75:reward-batches"
100
+
101
+ ACTION_MAP = {0: 'BUY', 1: 'SELL', 2: 'HOLD'}
102
+ ACTION_REVERSE = {'BUY': 0, 'SELL': 1, 'HOLD': 2}
103
+
104
+ # Performance tuning
105
+ EVALUATION_DELAY = 60 # seconds
106
+ BATCH_SIZE = 10
107
+ PRICE_CACHE_TTL = 5.0 # seconds - price considered stale after this
108
+ MAX_TRACKED_SIGNALS = 10000
109
+ RECONNECT_DELAY = 5 # seconds
110
+ MAX_RECONNECT_ATTEMPTS = 10
111
+
112
+ # ✅ FIX #2: Bounded concurrency for reward calculation tasks
113
+ MAX_CONCURRENT_REWARD_TASKS = 200 # Prevents unbounded coroutine growth
114
+
115
+ # ✅ FIXED: Container-safe logging
116
+ BASE_DIR = Path('/home/user/app')
117
+ LOG_DIR = BASE_DIR / 'logs'
118
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
119
+
120
+ # ── §P2-fix-7 + BUG-FIX-3 ────────────────────────────────────────────────────
121
+ # Module-level flag prevents double-handler installation even when the module
122
+ # is imported twice (e.g. as __main__ AND as an import). The previous guard
123
+ # stored the flag as an INSTANCE ATTRIBUTE on the logger object; that works
124
+ # within one Python process, but if HF Spaces forks a second process that
125
+ # imports this file, the new process gets a fresh logger (no attribute) and
126
+ # installs handlers a second time — both processes then write to the same
127
+ # rewards.log, producing every line twice with identical timestamps.
128
+ # A module-level boolean is process-local and is never re-evaluated on import.
129
+ _REWARDS_LOGGER_CONFIGURED = False
130
+ logger = logging.getLogger(__name__)
131
+ if not _REWARDS_LOGGER_CONFIGURED:
132
+ _REWARDS_LOGGER_CONFIGURED = True
133
+ logger.setLevel(logging.INFO)
134
+ _fmt = logging.Formatter("%(asctime)s [REWARDS] %(levelname)s: %(message)s")
135
+ _stream_h = logging.StreamHandler(sys.stdout)
136
+ _stream_h.setFormatter(_fmt)
137
+ _file_h = logging.FileHandler(LOG_DIR / 'rewards.log', encoding='utf-8')
138
+ _file_h.setFormatter(_fmt)
139
+ logger.addHandler(_stream_h)
140
+ logger.addHandler(_file_h)
141
+ logger.propagate = False # prevent root-logger handlers from duplicating
142
+
143
+ if IS_HF_SPACES:
144
+ logger.info("🤗 HuggingFace Spaces environment detected")
145
+
146
+ # ============================================================================
147
+ # HIGH-PERFORMANCE DATA STRUCTURES (Unchanged - Already Optimized)
148
+ # ============================================================================
149
+
150
+ class PriceData:
151
+ """Immutable price snapshot with timestamp - optimized with __slots__"""
152
+ __slots__ = ('bid', 'ask', 'last', 'timestamp', 'epoch')
153
+
154
+ def __init__(self, bid: float, ask: float, last: float, timestamp: float, epoch: int):
155
+ self.bid = bid
156
+ self.ask = ask
157
+ self.last = last
158
+ self.timestamp = timestamp
159
+ self.epoch = epoch
160
+
161
+ @property
162
+ def age(self) -> float:
163
+ return time.time() - self.timestamp
164
+
165
+ @property
166
+ def is_stale(self) -> bool:
167
+ return self.age > PRICE_CACHE_TTL
168
+
169
+ def get_price(self, action: str) -> float:
170
+ return self.ask if action == "BUY" else self.bid
171
+
172
+ class TrackedSignal:
173
+ """Tracked signal with minimal memory footprint - optimized with __slots__"""
174
+ __slots__ = ('signal_key', 'action', 'entry_price', 'timestamp', 'agent')
175
+
176
+ def __init__(self, signal_key: str, action: str, entry_price: float, timestamp: float, agent: str = "unknown"):
177
+ self.signal_key = signal_key
178
+ self.action = action
179
+ self.entry_price = entry_price
180
+ self.timestamp = timestamp
181
+ self.agent = agent
182
+
183
+ class TTLCache:
184
+ """O(1) cache with time-to-live expiration"""
185
+
186
+ __slots__ = ('_cache', '_ttl', '_max_size')
187
+
188
+ def __init__(self, ttl: float = 5.0, max_size: int = 1000):
189
+ self._cache: OrderedDict = OrderedDict()
190
+ self._ttl = ttl
191
+ self._max_size = max_size
192
+
193
+ def get(self, key: str) -> Optional[Any]:
194
+ if key not in self._cache:
195
+ return None
196
+ value, timestamp = self._cache[key]
197
+ if time.time() - timestamp > self._ttl:
198
+ del self._cache[key]
199
+ return None
200
+ return value
201
+
202
+ def set(self, key: str, value: Any) -> None:
203
+ # Evict oldest if at capacity
204
+ while len(self._cache) >= self._max_size:
205
+ self._cache.popitem(last=False)
206
+ self._cache[key] = (value, time.time())
207
+ # Move to end (most recently used)
208
+ self._cache.move_to_end(key)
209
+
210
+ def __len__(self) -> int:
211
+ return len(self._cache)
212
+
213
+ # ============================================================================
214
+ # OPTIMIZED DERIV BRIDGE - HuggingFace Spaces Edition
215
+ # ============================================================================
216
+
217
+ class DerivStreamingBridge:
218
+ """
219
+ High-performance Deriv WebSocket bridge - HuggingFace Spaces optimized.
220
+
221
+ Features:
222
+ - Auto-reconnection with exponential backoff
223
+ - Container-safe error handling
224
+ - HF Spaces compatibility
225
+ """
226
+
227
+ def __init__(self):
228
+ self.ws: Optional[websockets.WebSocketClientProtocol] = None
229
+ self.is_connected = False
230
+ self.is_authorized = False
231
+
232
+ # Price cache with TTL
233
+ self._price_cache: Dict[str, PriceData] = {}
234
+ self._cache_lock = asyncio.Lock()
235
+
236
+ # Connection management
237
+ self._reconnect_attempts = 0
238
+ self._last_tick_time: Dict[str, float] = {}
239
+ self._streaming = False
240
+ self._stream_task: Optional[asyncio.Task] = None
241
+
242
+ # HF Spaces features
243
+ self._hf_spaces_mode = IS_HF_SPACES
244
+ self._max_connection_attempts = 10
245
+
246
+ # Stats
247
+ self.ticks_received = 0
248
+ self.reconnections = 0
249
+
250
+ async def connect(self) -> bool:
251
+ """Connect and authorize to Deriv with HF Spaces resilience"""
252
+ # V9.0 FIX: Recreate lock on the RUNNING loop
253
+ self._cache_lock = asyncio.Lock()
254
+
255
+ try:
256
+ self._reconnect_attempts += 1
257
+ logger.info(f"🔄 Connecting to Deriv WebSocket... (attempt {self._reconnect_attempts})")
258
+
259
+ # Connection attempt limit
260
+ if self._reconnect_attempts > self._max_connection_attempts:
261
+ logger.error("❌ Max connection attempts exceeded")
262
+ return False
263
+
264
+ ssl_context = ssl.create_default_context()
265
+ ssl_context.check_hostname = False
266
+ ssl_context.verify_mode = ssl.CERT_NONE
267
+
268
+ self.ws = await asyncio.wait_for(
269
+ websockets.connect(
270
+ DERIV_WS_URL,
271
+ ssl=ssl_context,
272
+ ping_interval=25,
273
+ ping_timeout=10,
274
+ close_timeout=5,
275
+ max_size=2**20
276
+ ),
277
+ timeout=15.0 if IS_HF_SPACES else 30.0
278
+ )
279
+
280
+ # ✅ v5.3: ping/pong — no authorize needed (ticks = public endpoint)
281
+ await self.ws.send(json.dumps({"ping": 1}))
282
+ response = await asyncio.wait_for(self.ws.recv(), timeout=10.0)
283
+ data = json.loads(response)
284
+
285
+ if data.get('ping') == 'pong' or 'pong' in str(data):
286
+ self.is_connected = True
287
+ self.is_authorized = True
288
+ self._reconnect_attempts = 0
289
+ logger.info("✅ Deriv public WebSocket ready (ping/pong OK — no auth required)")
290
+
291
+ # Start streaming
292
+ await self._start_streaming()
293
+ return True
294
+ else:
295
+ logger.error(f"❌ Ping failed, unexpected response: {data}")
296
+ return False
297
+
298
+ except asyncio.TimeoutError:
299
+ logger.warning(f"⏰ Connection timeout (attempt {self._reconnect_attempts})")
300
+ return False
301
+ except Exception as e:
302
+ logger.warning(f"⚠️ Connection error: {e}")
303
+ return False
304
+
305
+
306
+ async def _start_streaming(self):
307
+ """Start price streaming"""
308
+ self._stream_task = asyncio.create_task(self._real_price_stream())
309
+ self._streaming = True
310
+ logger.info(f"📡 Streaming started")
311
+
312
+ async def _real_price_stream(self):
313
+ """Real price streaming from Deriv WebSocket"""
314
+ try:
315
+ # Subscribe to ticks
316
+ await self.ws.send(json.dumps({"ticks": DERIV_SYMBOL, "subscribe": 1}))
317
+ logger.info(f"📡 Subscribed to {DERIV_SYMBOL}")
318
+
319
+ while self.is_connected:
320
+ try:
321
+ data = await self.ws.recv()
322
+ json_data = json.loads(data)
323
+
324
+ if 'tick' in json_data:
325
+ await self._process_tick(json_data['tick'])
326
+ self.ticks_received += 1
327
+
328
+ except websockets.exceptions.ConnectionClosed:
329
+ logger.warning("📡 WebSocket connection closed")
330
+ break
331
+ except Exception as e:
332
+ logger.error(f"❌ Stream error: {e}")
333
+ break
334
+
335
+ except Exception as e:
336
+ logger.error(f"❌ Real price streaming error: {e}")
337
+ finally:
338
+ # ── BUG-FIX-1 ─────────────────────────────────────────────────────
339
+ # _streaming and is_connected were NEVER cleared when the stream
340
+ # task exited via exception or WebSocket close. The health monitor
341
+ # guard (line ~895) only checks these two flags, so it could never
342
+ # detect the silent death → no reconnect → price cache went stale
343
+ # permanently → every single price fetch failed → Rewards=0.
344
+ self._streaming = False
345
+ self.is_connected = False
346
+ logger.warning("⚠️ _real_price_stream exited — flags cleared for health monitor")
347
+
348
+
349
+ async def _process_tick(self, tick_data):
350
+ """Process incoming tick data"""
351
+ try:
352
+ price = float(tick_data['quote'])
353
+ epoch = int(tick_data['epoch'])
354
+
355
+ # Create price data
356
+ price_data = PriceData(
357
+ bid=price - 0.0005,
358
+ ask=price + 0.0005,
359
+ last=price,
360
+ timestamp=time.time(),
361
+ epoch=epoch
362
+ )
363
+
364
+ # Update cache
365
+ async with self._cache_lock:
366
+ self._price_cache[DERIV_SYMBOL] = price_data
367
+ self._last_tick_time[DERIV_SYMBOL] = time.time()
368
+
369
+ except Exception as e:
370
+ logger.error(f"❌ Tick processing error: {e}")
371
+
372
+ async def _reconnect(self) -> bool:
373
+ """Reconnect with exponential backoff"""
374
+ self.is_connected = False
375
+ self._streaming = False
376
+
377
+ if self._stream_task:
378
+ self._stream_task.cancel()
379
+
380
+ if self.ws:
381
+ await self.ws.close()
382
+
383
+ delay = min(60, RECONNECT_DELAY * (2 ** min(self._reconnect_attempts, 5)))
384
+ logger.info(f"🔄 Reconnecting in {delay}s...")
385
+ await asyncio.sleep(delay)
386
+
387
+ success = await self.connect()
388
+ if success:
389
+ self.reconnections += 1
390
+ logger.info(f"✅ Reconnected successfully (#{self.reconnections})")
391
+
392
+ return success
393
+
394
+ async def get_current_price(self, symbol: str = DERIV_SYMBOL) -> Optional[PriceData]:
395
+ """Get current cached price"""
396
+ async with self._cache_lock:
397
+ price_data = self._price_cache.get(symbol)
398
+
399
+ if price_data and not price_data.is_stale:
400
+ return price_data
401
+
402
+ return None
403
+
404
+ def get_stats(self) -> Dict:
405
+ """Get connection statistics"""
406
+ return {
407
+ 'connected': self.is_connected,
408
+ 'authorized': self.is_authorized,
409
+ 'ticks_received': self.ticks_received,
410
+ 'reconnections': self.reconnections,
411
+ 'streaming': self._streaming
412
+ }
413
+
414
+ async def shutdown(self):
415
+ """Shutdown with cleanup"""
416
+ logger.info("🛑 Shutting down Deriv bridge...")
417
+ self.is_connected = False
418
+ self._streaming = False
419
+
420
+ if self._stream_task:
421
+ self._stream_task.cancel()
422
+ try:
423
+ await self._stream_task
424
+ except asyncio.CancelledError:
425
+ pass
426
+
427
+ if self.ws:
428
+ try:
429
+ await self.ws.close()
430
+ except Exception:
431
+ pass
432
+
433
+ logger.info("✅ Deriv bridge shutdown complete")
434
+
435
+ # ============================================================================
436
+ # REWARD CALCULATION COMPONENTS (Updated for HF Spaces)
437
+ # ============================================================================
438
+
439
+ class RewardNormalizer:
440
+ def __init__(self, base_multiplier=1000):
441
+ self.base_multiplier = base_multiplier
442
+ self.volatility_buffer = deque(maxlen=100)
443
+
444
+ def normalize(self, entry_price, exit_price, action):
445
+ """Normalize reward based on action and price movement"""
446
+ if entry_price <= 0:
447
+ return 0, "invalid", 0, 0
448
+
449
+ # Calculate raw basis points
450
+ raw_bps = ((exit_price - entry_price) / entry_price) * 10000
451
+
452
+ # Apply action multiplier
453
+ if action == "BUY":
454
+ directional_bps = raw_bps
455
+ elif action == "SELL":
456
+ directional_bps = -raw_bps
457
+ else: # HOLD
458
+ directional_bps = -abs(raw_bps) * 0.1 # Small penalty for holding
459
+
460
+ # Simple regime detection
461
+ regime = "normal"
462
+ if abs(raw_bps) > 50:
463
+ regime = "high_vol"
464
+ elif abs(raw_bps) < 5:
465
+ regime = "low_vol"
466
+
467
+ # Normalize to [-1, 1] range
468
+ normalized = np.tanh(directional_bps / 100)
469
+ confidence = min(abs(directional_bps) / 20, 1.0)
470
+
471
+ return normalized, regime, confidence, raw_bps
472
+
473
+ class AgentTracker:
474
+ """Track agent performance and streaks"""
475
+
476
+ def __init__(self):
477
+ self.agents = {}
478
+ self.reset()
479
+
480
+ def reset(self):
481
+ """Reset tracking data"""
482
+ self.agents = {
483
+ "5s": {"count": 0, "action": None, "cycles": 0},
484
+ "15s": {"count": 0, "action": None, "cycles": 0},
485
+ "30s": {"count": 0, "action": None, "cycles": 0},
486
+ "1m": {"count": 0, "action": None, "cycles": 0},
487
+ "2m": {"count": 0, "action": None, "cycles": 0},
488
+ "5m": {"count": 0, "action": None, "cycles": 0},
489
+ "10m": {"count": 0, "action": None, "cycles": 0},
490
+ "15m": {"count": 0, "action": None, "cycles": 0}
491
+ }
492
+
493
+ def update(self, agent, action):
494
+ """Update agent tracking, return True if cycle completed"""
495
+ if agent not in self.agents:
496
+ return False
497
+
498
+ if self.agents[agent]["action"] == action:
499
+ self.agents[agent]["count"] += 1
500
+ else:
501
+ if self.agents[agent]["count"] >= 3: # Cycle completion
502
+ self.agents[agent]["cycles"] += 1
503
+ self.agents[agent]["count"] = 1
504
+ self.agents[agent]["action"] = action
505
+ return True
506
+ else:
507
+ self.agents[agent]["count"] = 1
508
+ self.agents[agent]["action"] = action
509
+
510
+ return False
511
+
512
+ def get_info(self, agent):
513
+ """Get agent tracking info"""
514
+ return self.agents.get(agent, {"count": 0, "action": None, "cycles": 0})
515
+
516
+ # ============================================================================
517
+ # MAIN REWARDS ENGINE - HuggingFace Spaces Edition v5.2.0
518
+ # ============================================================================
519
+
520
+ class RewardsEngine:
521
+ """
522
+ Main rewards calculation engine with HF Spaces compatibility.
523
+
524
+ v5.2.0 FIXES:
525
+ - Uses robust RedisAblyClient from redis_connection_manager.py
526
+ - Bounded reward task pool (semaphore)
527
+ - Connection health heartbeat
528
+ - Deriv auto-reconnection loop
529
+ """
530
+
531
+ def __init__(self):
532
+ # Core components
533
+ self.normalizer = RewardNormalizer()
534
+ self.agent_tracker = AgentTracker()
535
+
536
+ # Tracking
537
+ self._tracked: Dict[str, TrackedSignal] = {}
538
+ self._processed_keys = TTLCache(ttl=300) # 5 minutes
539
+
540
+ # Batch processing
541
+ self._batch: List[Dict] = []
542
+ self._batch_lock: Optional[asyncio.Lock] = None
543
+ self._last_batch_time = time.time()
544
+
545
+ # ✅ FIX #3: Bounded task pool for reward calculations
546
+ self._reward_semaphore: Optional[asyncio.Semaphore] = None
547
+ self._active_reward_tasks = 0
548
+
549
+ # Statistics
550
+ self.signals_received = 0
551
+ self.rewards_sent = 0
552
+ self.correct = 0
553
+ self.wrong = 0
554
+
555
+ # ✅ FIX #4: Track last signal time for health monitoring
556
+ self._last_signal_time = 0.0
557
+ self._last_heartbeat_time = 0.0
558
+ self._connection_healthy = True
559
+
560
+ # ✅ FIX v5.2.1: Store event loop reference for thread→asyncio bridge
561
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
562
+
563
+ # Connections
564
+ self.ably_realtime: Optional[RedisAblyClient] = None
565
+ self.signal_channel = None
566
+ self.reward_channel_batch = None
567
+ self.reward_channel_individual = None
568
+
569
+ # Control
570
+ self._shutdown: Optional[asyncio.Event] = None
571
+
572
+ async def initialize(self) -> bool:
573
+ """Initialize connections and channels"""
574
+ try:
575
+ logger.info("📡 Connecting to Redis (V75 namespace)...")
576
+
577
+ # ✅ FIX #5: Use the ROBUST RedisAblyClient from redis_connection_manager.py
578
+ # This version has: blocking listener, auto-reconnection, health monitoring
579
+ # V75: Uses DB 0 (features) — isolated per Space container
580
+ self.ably_realtime = RedisAblyClient(
581
+ redis_url=REDIS_URL,
582
+ password=REDIS_PASSWORD,
583
+ use_streams=True,
584
+ database=REDIS_DB_FEATURES # V75: DB 0
585
+ )
586
+
587
+ # Set up channels (already prefixed via constants above)
588
+ self.signal_channel = self.ably_realtime.channels.get(ABLY_SIGNAL_CHANNEL)
589
+ self.reward_channel_batch = self.ably_realtime.channels.get(ABLY_BATCH_CHANNEL)
590
+ self.reward_channel_individual = self.ably_realtime.channels.get(ABLY_REWARD_CHANNEL)
591
+
592
+ logger.info(f"✅ Redis channels initialized (V75 — prefix='{CHANNEL_PREFIX}', DB={REDIS_DB_FEATURES})")
593
+ logger.info(f" Signal: {ABLY_SIGNAL_CHANNEL}")
594
+ logger.info(f" Rewards: {ABLY_REWARD_CHANNEL}")
595
+ logger.info(f" Batches: {ABLY_BATCH_CHANNEL}")
596
+
597
+ # Initialize Deriv bridge
598
+ logger.info("🔄 Connecting to Deriv...")
599
+ success = await deriv_bridge.connect()
600
+ if not success:
601
+ logger.error("❌ Deriv connection failed")
602
+ return False
603
+
604
+ logger.info("✅ All connections established")
605
+ return True
606
+
607
+ except Exception as e:
608
+ logger.error(f"❌ Initialization error: {e}")
609
+ return False
610
+
611
+ def _extract_agent(self, signal_key: str) -> str:
612
+ """Extract agent timeframe from signal key.
613
+
614
+ Signal keys may use either time-based suffixes (e.g. '10m_xxx') or
615
+ size-based prefixes (e.g. 'xs_xxx', 'xxl_xxx'). The original code
616
+ only checked for time-based tokens — 'xs_xxx' returned 'unknown' for
617
+ six of the eight agents, breaking per-agent cycle tracking.
618
+
619
+ ── BUG-FIX-4 ──────────────────────────────────────────────────────────
620
+ Check time-based tokens first (longest match wins to avoid '1m'
621
+ matching inside '10m'), then fall back to size-based prefix matching.
622
+ """
623
+ # Time-based tokens — longest first to avoid substring false-positives
624
+ for tf in ['15m', '10m', '5m', '2m', '1m', '30s', '15s', '5s']:
625
+ if tf in signal_key:
626
+ return tf
627
+ # Size-based prefixes (e.g. xs_17766…, xxl_17766…)
628
+ key_lower = signal_key.lower()
629
+ for size in ['xxl', 'xl', 'xs', 'l_', 'm_', 's_']:
630
+ if key_lower.startswith(size):
631
+ return size.rstrip('_') # strip trailing underscore used as delimiter
632
+ return "unknown"
633
+
634
+ async def _get_price(self, action: str) -> Optional[float]:
635
+ """Get current price for action"""
636
+ try:
637
+ price_data = await deriv_bridge.get_current_price()
638
+ if price_data:
639
+ return price_data.get_price(action)
640
+ return None
641
+ except Exception as e:
642
+ logger.error(f"❌ Price fetch error: {e}")
643
+ return None
644
+
645
+ def _on_signal(self, message: RedisMessage):
646
+ """
647
+ Handle incoming signal - FIXED for RedisAblyClient V10.1 format.
648
+
649
+ ✅ FIX #6: This callback is now called by the BLOCKING listener thread
650
+ in redis_connection_manager.py's RedisAblyClient, NOT the broken polling
651
+ listener from the old Rewards.py RedisAblyClient.
652
+
653
+ The RedisAblyClient V10.1 delivers RedisMessage objects, not raw dicts.
654
+ """
655
+ try:
656
+ self.signals_received += 1
657
+ self._last_signal_time = time.time()
658
+
659
+ # RedisMessage from redis_connection_manager has .data attribute
660
+ data = message.data if isinstance(message, RedisMessage) else message
661
+
662
+ # Handle nested data (envelope format: {"event": "message", "data": {...}})
663
+ if isinstance(data, dict) and 'data' in data:
664
+ data = data['data']
665
+
666
+ # Parse if string
667
+ if isinstance(data, str):
668
+ data = json.loads(data)
669
+
670
+ # Extract fields
671
+ action = data.get('final_action', data.get('action', '')).upper()
672
+ signal_keys = data.get('signal_keys', [])
673
+ entry_price = data.get('price', 0.0)
674
+
675
+ if action not in ['BUY', 'SELL']:
676
+ logger.warning(f"⚠️ Invalid action: {action}")
677
+ return
678
+
679
+ if not entry_price or entry_price == 0.0:
680
+ logger.warning(f"⚠️ No entry price in signal: {entry_price}")
681
+ return
682
+
683
+ # Log signal received
684
+ logger.info(f"🔔 [SIGNAL] {action} @ {entry_price:.5f} | Keys: {len(signal_keys)} | Loop: {'✅' if self._loop and self._loop.is_running() else '❌'}")
685
+
686
+ # Ensure signal_keys is list
687
+ if not isinstance(signal_keys, list):
688
+ signal_keys = [str(signal_keys)]
689
+
690
+ # Track each signal
691
+ for key in signal_keys[:8]: # Limit to 8 signals
692
+ key = str(key)
693
+
694
+ # Skip duplicates - O(1)
695
+ if key in self._tracked or self._processed_keys.get(key):
696
+ continue
697
+
698
+ # Memory bound check
699
+ if len(self._tracked) >= MAX_TRACKED_SIGNALS:
700
+ oldest = min(self._tracked.items(), key=lambda x: x[1].timestamp)
701
+ del self._tracked[oldest[0]]
702
+
703
+ agent = self._extract_agent(key)
704
+
705
+ # Track signal
706
+ self._tracked[key] = TrackedSignal(
707
+ signal_key=key,
708
+ action=action,
709
+ entry_price=entry_price,
710
+ timestamp=time.time(),
711
+ agent=agent
712
+ )
713
+
714
+ logger.debug(f"✅ [TRACKING] {key} | {action} @ {entry_price:.5f}")
715
+
716
+ # Agent streak tracking
717
+ if agent == "10m":
718
+ if self.agent_tracker.update(agent, action):
719
+ info = self.agent_tracker.get_info("10m")
720
+ logger.info(f"🔥 [10m CYCLE #{info['cycles']}] {action} x{info['count']}")
721
+
722
+ # ✅ FIX #7: Schedule reward via bounded task pool
723
+ # Uses the event loop from the main thread
724
+ self._schedule_reward(key)
725
+
726
+ except Exception as e:
727
+ logger.error(f"❌ Signal processing error: {e}")
728
+ traceback.print_exc()
729
+
730
+ def _schedule_reward(self, signal_key: str):
731
+ """
732
+ Schedule reward calculation on the event loop.
733
+
734
+ ✅ FIX v5.2.1: Since _on_signal is called from a THREAD (the RedisAblyClient
735
+ listener thread), we MUST use the stored loop reference from start().
736
+ asyncio.get_event_loop() from a non-main thread returns a NEW loop
737
+ (not the running one), silently dropping all reward tasks.
738
+ """
739
+ try:
740
+ if self._loop is not None and self._loop.is_running():
741
+ asyncio.run_coroutine_threadsafe(
742
+ self._bounded_calculate_reward(signal_key), self._loop
743
+ )
744
+ else:
745
+ logger.warning(f"⚠️ Event loop not available (loop={self._loop}), reward for {signal_key} dropped")
746
+ except RuntimeError as e:
747
+ logger.warning(f"⚠️ Cannot schedule reward: {e}")
748
+
749
+ async def _bounded_calculate_reward(self, signal_key: str):
750
+ """
751
+ ✅ FIX #8: Bounded reward calculation with semaphore.
752
+ Prevents unbounded coroutine growth from 60s sleep per signal.
753
+ """
754
+ if self._reward_semaphore is None:
755
+ return
756
+
757
+ async with self._reward_semaphore:
758
+ self._active_reward_tasks += 1
759
+ try:
760
+ await self._calculate_reward(signal_key)
761
+ finally:
762
+ self._active_reward_tasks -= 1
763
+
764
+ async def _calculate_reward(self, signal_key: str) -> None:
765
+ """Calculate reward after delay"""
766
+ # Wait for evaluation period
767
+ await asyncio.sleep(EVALUATION_DELAY)
768
+
769
+ # Get signal
770
+ signal = self._tracked.pop(signal_key, None)
771
+ if not signal:
772
+ return
773
+
774
+ # Mark as processed
775
+ self._processed_keys.set(signal_key, True)
776
+
777
+ # ✅ BUG FIX 3: Retry price fetch up to 3 times with 5s backoff
778
+ # Previously, a single failed price fetch would silently drop the reward forever
779
+ exit_price = None
780
+ for attempt in range(3):
781
+ exit_price = await self._get_price(signal.action)
782
+ if exit_price:
783
+ break
784
+ logger.warning(f"⚠️ Price fetch attempt {attempt+1}/3 failed for {signal_key}")
785
+ if not deriv_bridge.is_connected:
786
+ logger.warning(f"⚠️ Deriv disconnected, triggering reconnect...")
787
+ await deriv_bridge._reconnect()
788
+ await asyncio.sleep(5)
789
+
790
+ if not exit_price:
791
+ logger.error(f"❌ All price fetch attempts failed for {signal_key} — reward dropped permanently")
792
+ return
793
+
794
+ # Calculate reward
795
+ normalized, regime, confidence, raw_bps = self.normalizer.normalize(
796
+ signal.entry_price, exit_price, signal.action
797
+ )
798
+
799
+ # Track accuracy
800
+ if normalized > 0:
801
+ self.correct += 1
802
+ correct_action = ACTION_REVERSE[signal.action]
803
+ else:
804
+ self.wrong += 1
805
+ correct_action = 1 - ACTION_REVERSE.get(signal.action, 0)
806
+
807
+ # Log with price difference
808
+ price_diff = exit_price - signal.entry_price
809
+ logger.info(
810
+ f"[REWARD] {signal.action} | "
811
+ f"entry={signal.entry_price:.2f} → exit={exit_price:.2f} (Δ{price_diff:+.2f}) | "
812
+ f"reward={normalized:+.4f} | {signal_key[:25]}"
813
+ )
814
+
815
+ # Add to batch
816
+ await self._add_to_batch({
817
+ "signal_key": signal_key,
818
+ "reward": normalized,
819
+ "entry_price": signal.entry_price,
820
+ "exit_price": exit_price,
821
+ "executed_action": signal.action,
822
+ "correct_action": correct_action,
823
+ "timestamp": datetime.now(timezone.utc).isoformat(),
824
+ "price_source": "deriv_streaming_v5_live",
825
+ "platform": "huggingface-spaces" if IS_HF_SPACES else "local"
826
+ })
827
+
828
+ async def _add_to_batch(self, reward_data: Dict) -> None:
829
+ """Add reward to batch with backpressure"""
830
+ async with self._batch_lock:
831
+ self._batch.append(reward_data)
832
+ self.rewards_sent += 1
833
+
834
+ # Send batch if full or timeout
835
+ if len(self._batch) >= BATCH_SIZE:
836
+ await self._send_batch()
837
+
838
+ async def _send_batch(self) -> None:
839
+ """Send reward batch via Redis pub/sub"""
840
+ if not self._batch:
841
+ return
842
+
843
+ try:
844
+ batch_data = {
845
+ "rewardz": self._batch.copy(),
846
+ "batch_id": f"batch_{int(time.time() * 1000)}",
847
+ "batch_size": len(self._batch),
848
+ "timestamp": datetime.now(timezone.utc).isoformat(),
849
+ "price_source": "deriv_streaming_v5_live",
850
+ "platform": "huggingface-spaces" if IS_HF_SPACES else "local"
851
+ }
852
+
853
+ # ✅ FIX #10: Use synchronous publish for RedisAblyClient V10.1
854
+ # The RedisAblyChannel.publish() in redis_connection_manager.py is async,
855
+ # but we can also use the underlying redis client directly for reliability.
856
+ await self.reward_channel_batch.publish("reward-batch", batch_data)
857
+
858
+ # ── §P2-fix-6 (2026-04-19): DUPLICATE-PUBLISH REMOVED ───────────
859
+ # Previous code also published each reward individually to
860
+ # "new-reward" on the individual channel AFTER the batch publish.
861
+ # The engine subscribes to BOTH channels (reward-batches AND
862
+ # rewards) at quasar_main4.py:L25544/L25547, so every reward
863
+ # triggered on_reward twice — first match succeeded, second hit
864
+ # _processed_batch_ids → "duplicate" counter.
865
+ # Observed impact: duplicate=46660, matched=3 (15,000:1 ratio).
866
+ # The batch channel is authoritative; individual publish was a
867
+ # legacy compatibility layer whose consumer no longer exists.
868
+ #
869
+ # for r in self._batch:
870
+ # try:
871
+ # await self.reward_channel_individual.publish("new-reward", r)
872
+ # except Exception:
873
+ # pass
874
+
875
+ logger.info(f"📤 Sent batch of {len(self._batch)} rewards | Active tasks: {self._active_reward_tasks}")
876
+ self._batch.clear()
877
+ self._last_batch_time = time.time()
878
+
879
+ except Exception as e:
880
+ logger.error(f"❌ Batch send error: {e}")
881
+ self._batch.clear()
882
+
883
+ async def _health_monitor_loop(self):
884
+ """
885
+ ✅ FIX #11: Health monitor that detects silent disconnections.
886
+
887
+ If no signals received for 5 minutes AND we expect signals to be flowing,
888
+ trigger diagnostics and alert.
889
+ """
890
+ SIGNAL_TIMEOUT = 300 # 5 minutes without signals = problem
891
+ DERIV_CHECK_INTERVAL = 60 # Check Deriv every 60s
892
+
893
+ while not self._shutdown.is_set():
894
+ await asyncio.sleep(30)
895
+
896
+ now = time.time()
897
+
898
+ # Check Redis connection health
899
+ if self.ably_realtime:
900
+ try:
901
+ # The robust RedisAblyClient has connection state
902
+ redis_state = self.ably_realtime.connection.state
903
+ if redis_state != 'connected':
904
+ logger.warning(f"⚠️ [HEALTH] Redis state: {redis_state}")
905
+ self._connection_healthy = False
906
+ except Exception as e:
907
+ logger.warning(f"⚠️ [HEALTH] Redis check failed: {e}")
908
+
909
+ # Check signal flow
910
+ if self._last_signal_time > 0:
911
+ signal_age = now - self._last_signal_time
912
+ if signal_age > SIGNAL_TIMEOUT:
913
+ logger.warning(
914
+ f"⚠️ [HEALTH] No signals for {signal_age:.0f}s! "
915
+ f"Last signal at {datetime.fromtimestamp(self._last_signal_time).strftime('%H:%M:%S')}. "
916
+ f"Possible pub/sub disconnection."
917
+ )
918
+ self._connection_healthy = False
919
+
920
+ # Check Deriv WebSocket
921
+ if not deriv_bridge.is_connected or not deriv_bridge._streaming:
922
+ logger.warning("⚠️ [HEALTH] Deriv disconnected, attempting reconnect...")
923
+ success = await deriv_bridge._reconnect()
924
+ if success:
925
+ logger.info("✅ [HEALTH] Deriv reconnected")
926
+ else:
927
+ logger.error("❌ [HEALTH] Deriv reconnection failed")
928
+ elif deriv_bridge._stream_task is not None and deriv_bridge._stream_task.done():
929
+ # ── BUG-FIX-2 ─────────────────────────────────────────────────
930
+ # Even after BUG-FIX-1, add a second detection path: if the
931
+ # stream task object itself has finished (done()==True) but the
932
+ # flags haven't been cleared yet (race window), still reconnect.
933
+ logger.warning("⚠️ [HEALTH] Stream task finished unexpectedly — reconnecting")
934
+ await deriv_bridge._reconnect()
935
+
936
+ async def _status_loop(self) -> None:
937
+ """Periodic status reporting"""
938
+ while not self._shutdown.is_set():
939
+ await asyncio.sleep(60)
940
+
941
+ total = self.correct + self.wrong
942
+ win_rate = (100 * self.correct / max(1, total))
943
+ bridge_stats = deriv_bridge.get_stats()
944
+
945
+ # ✅ FIX: Include health info in status
946
+ signal_age = time.time() - self._last_signal_time if self._last_signal_time > 0 else -1
947
+
948
+ logger.info(
949
+ f"[STATUS] Signals={self.signals_received} | "
950
+ f"Rewards={self.rewards_sent} | "
951
+ f"WinRate={win_rate:.1f}% | "
952
+ f"Tracked={len(self._tracked)} | "
953
+ f"ActiveTasks={self._active_reward_tasks} | "
954
+ f"SignalAge={signal_age:.0f}s | "
955
+ f"Ticks={bridge_stats['ticks_received']} | "
956
+ f"Healthy={'✅' if self._connection_healthy else '❌'} | "
957
+ f"Platform={'HF Spaces' if IS_HF_SPACES else 'Local'}"
958
+ )
959
+
960
+ # Flush any pending batch
961
+ async with self._batch_lock:
962
+ if self._batch and time.time() - self._last_batch_time > 30:
963
+ await self._send_batch()
964
+
965
+ async def start(self) -> None:
966
+ """Start the rewards engine"""
967
+ # V9.0 FIX: Recreate asyncio primitives on the running loop
968
+ self._batch_lock = asyncio.Lock()
969
+ self._shutdown = asyncio.Event()
970
+ self._reward_semaphore = asyncio.Semaphore(MAX_CONCURRENT_REWARD_TASKS)
971
+
972
+ # ✅ FIX v5.2.1: Store event loop reference BEFORE anything else
973
+ # This is CRITICAL because _on_signal runs in the Redis listener THREAD
974
+ # and needs to schedule coroutines on THIS loop
975
+ self._loop = asyncio.get_running_loop()
976
+
977
+ logger.info("=" * 70)
978
+ logger.info("K1RL QUANT - INSTITUTIONAL REWARDS v5.2.1-V75")
979
+ logger.info("HuggingFace Spaces Edition 🤗 (V75 NAMESPACE ISOLATION))")
980
+ logger.info("=" * 70)
981
+
982
+ if IS_HF_SPACES:
983
+ logger.info("🤗 Running in HuggingFace Spaces environment")
984
+ logger.info(" - V75 channel prefix: '%s'" % CHANNEL_PREFIX)
985
+ logger.info(" - V75 databases: features=DB%d, rewards=DB%d" % (REDIS_DB_FEATURES, REDIS_DB_REWARDS))
986
+ logger.info(" - Robust RedisAblyClient V10.1 (blocking listener)")
987
+ logger.info(" - Bounded reward task pool (max=%d)" % MAX_CONCURRENT_REWARD_TASKS)
988
+ logger.info(" - Connection health monitoring")
989
+ logger.info(" - ✅ Event loop captured for thread→asyncio bridge")
990
+
991
+ if not await self.initialize():
992
+ raise Exception("Initialization failed")
993
+
994
+ # ✅ FIX #12: Subscribe using RedisAblyClient V10.1 format
995
+ # The V10.1 RedisAblyChannel.subscribe() expects (event_name, callback)
996
+ # where callback receives a RedisMessage object (not raw dict)
997
+ await self.signal_channel.subscribe("message", self._on_signal)
998
+ logger.info(f"✅ Subscribed to {ABLY_SIGNAL_CHANNEL}:message (V75 namespace, robust V10.1 listener)")
999
+ logger.info(f" Event loop captured: {self._loop is not None} | Running: {self._loop.is_running() if self._loop else False}")
1000
+
1001
+ # Start health monitor
1002
+ health_task = asyncio.create_task(self._health_monitor_loop())
1003
+
1004
+ # Start status loop
1005
+ status_task = asyncio.create_task(self._status_loop())
1006
+
1007
+ try:
1008
+ # Main loop
1009
+ while not self._shutdown.is_set():
1010
+ await asyncio.sleep(1)
1011
+ finally:
1012
+ health_task.cancel()
1013
+ status_task.cancel()
1014
+
1015
+ async def shutdown(self) -> None:
1016
+ """Clean shutdown"""
1017
+ logger.info("🛑 Shutting down rewards engine...")
1018
+ self._shutdown.set()
1019
+
1020
+ # Flush batch
1021
+ async with self._batch_lock:
1022
+ if self._batch:
1023
+ await self._send_batch()
1024
+
1025
+ # Close connections
1026
+ if self.ably_realtime:
1027
+ self.ably_realtime.close()
1028
+ await deriv_bridge.shutdown()
1029
+
1030
+ logger.info("✅ Shutdown complete")
1031
+
1032
+ # ============================================================================
1033
+ # GLOBAL INSTANCES
1034
+ # ============================================================================
1035
+
1036
+ deriv_bridge = DerivStreamingBridge()
1037
+
1038
+ # ============================================================================
1039
+ # MAIN
1040
+ # ============================================================================
1041
+
1042
+ async def main():
1043
+ print("\n" + "=" * 70)
1044
+ print("K1RL QUANT - INSTITUTIONAL REWARDS v5.2.1-V75")
1045
+ print("HuggingFace Spaces Edition 🤗 (V75 NAMESPACE ISOLATION)")
1046
+ print("=" * 70)
1047
+ print(f"✅ V75 channel prefix: '{CHANNEL_PREFIX}'")
1048
+ print(f"✅ V75 databases: features=DB{REDIS_DB_FEATURES}, rewards=DB{REDIS_DB_REWARDS}")
1049
+ print("✅ FIXED: Uses robust RedisAblyClient V10.1 (blocking listener)")
1050
+ print("✅ FIXED: Bounded reward task pool (no coroutine leak)")
1051
+ print("✅ FIXED: Connection health monitoring")
1052
+ print("✅ FIXED: Deriv auto-reconnection")
1053
+ print("✅ FIXED: Event loop reference for thread→asyncio bridge")
1054
+ print("✅ Auto-reconnecting WebSocket")
1055
+ print("✅ O(1) signal tracking")
1056
+ print("✅ TTL price cache")
1057
+ print("✅ Batch processing with backpressure")
1058
+ print("✅ Memory-bounded buffers")
1059
+ print("✅ HuggingFace Spaces compatibility")
1060
+ print("✅ ZERO cross-talk with other Spaces (V75 namespace)")
1061
+ print("=" * 70 + "\n")
1062
+
1063
+ if IS_HF_SPACES:
1064
+ print("🤗 HuggingFace Spaces environment detected")
1065
+ print("")
1066
+
1067
+ engine = RewardsEngine()
1068
+
1069
+ try:
1070
+ await engine.start()
1071
+ except KeyboardInterrupt:
1072
+ print("\n>>> Shutdown requested")
1073
+ except Exception as e:
1074
+ logger.error(f"Fatal error: {e}")
1075
+ traceback.print_exc()
1076
+ finally:
1077
+ await engine.shutdown()
1078
+
1079
+ if __name__ == "__main__":
1080
+ try:
1081
+ asyncio.run(main())
1082
+ except KeyboardInterrupt:
1083
+ print("\n>>> Stopped")