Alvin3y1 commited on
Commit
caf4cf3
·
verified ·
1 Parent(s): c428f6c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +761 -509
app.py CHANGED
@@ -2,578 +2,830 @@ import asyncio
2
  import json
3
  import logging
4
  import time
 
5
  import math
6
  import statistics
7
  import aiohttp
 
8
  from aiohttp import web
9
- from collections import deque
10
- from dataclasses import dataclass, field
11
- from typing import Dict, List, Optional
12
-
13
- # ==========================================
14
- # CONFIGURATION & HYPERPARAMETERS
15
- # ==========================================
16
- SYMBOL_DISPLAY = "BTC/USD"
17
- SYMBOL_KRAKEN = "BTC/USD" # Kraken WS V2 format
18
  PORT = 7860
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- # Quantitative Parameters
21
- OFI_LOOKBACK = 50 # Number of updates to smooth OFI
22
- LIQUIDITY_BANDS_USD = [10000, 50000, 150000, 500000] # Dynamic slippage tiers
23
- DEPTH_BUCKETS = 100 # Resolution of depth chart
24
- BROADCAST_INTERVAL = 0.1 # 100ms UI updates (10Hz)
25
-
26
- # Logging Setup
27
- logging.basicConfig(
28
- level=logging.INFO,
29
- format='%(asctime)s | %(levelname)s | %(message)s',
30
- datefmt='%H:%M:%S'
31
- )
32
- logger = logging.getLogger("QuantEngine")
33
-
34
- # ==========================================
35
- # DATA STRUCTURES
36
- # ==========================================
37
-
38
- @dataclass
39
- class MarketMicrostructure:
40
- """Thread-safe container for market state."""
41
- bids: Dict[float, float] = field(default_factory=dict)
42
- asks: Dict[float, float] = field(default_factory=dict)
43
-
44
- # State snapshots for OFI calculation
45
- prev_best_bid: float = 0.0
46
- prev_best_ask: float = 0.0
47
- prev_bid_qty: float = 0.0
48
- prev_ask_qty: float = 0.0
49
-
50
- # Computed Metrics
51
- mid_price: float = 0.0
52
- spread: float = 0.0
53
- ofi_rolling: float = 0.0
54
- ofi_history: deque = field(default_factory=lambda: deque(maxlen=OFI_LOOKBACK))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
- # Trade Data
57
- last_trade_price: float = 0.0
58
- trade_pressure: float = 0.0 # VWP equivalent
59
 
60
- ready: bool = False
61
-
62
- def get_sorted_book(self):
63
- """Returns sorted lists of (price, qty) tuples."""
64
- # Sorting is expensive, do it only when necessary or optimize with b-trees in C++
65
- # For Python/Websockets, standard timsort is sufficient for <1000 items
66
- b_sorted = sorted(self.bids.items(), key=lambda x: -x[0])
67
- a_sorted = sorted(self.asks.items(), key=lambda x: x[0])
68
- return b_sorted, a_sorted
69
-
70
- state = MarketMicrostructure()
71
-
72
- # ==========================================
73
- # QUANTITATIVE ALGORITHMS
74
- # ==========================================
75
-
76
- class QuantEngine:
77
- @staticmethod
78
- def calculate_ofi(best_bid, bid_qty, best_ask, ask_qty):
79
- """
80
- Order Flow Imbalance (OFI) Calculation.
81
- Formula based on Cont, Kukanov, Stoikov (2014).
82
- Measures net supply/demand changes at the best quotes.
83
- """
84
- if state.prev_best_bid == 0: return 0.0
85
-
86
- # Bid Contribution
87
- e_b = 0.0
88
- if best_bid > state.prev_best_bid:
89
- e_b = bid_qty
90
- elif best_bid < state.prev_best_bid:
91
- e_b = -state.prev_bid_qty
92
- else:
93
- e_b = bid_qty - state.prev_bid_qty
94
-
95
- # Ask Contribution
96
- e_a = 0.0
97
- if best_ask > state.prev_best_ask:
98
- e_a = state.prev_ask_qty
99
- elif best_ask < state.prev_best_ask:
100
- e_a = -ask_qty
101
- else:
102
- e_a = state.prev_ask_qty - ask_qty
103
-
104
- return e_b - e_a
105
-
106
- @staticmethod
107
- def calculate_liquidity_bands(bids, asks, mid_price):
108
- """
109
- Path of Least Resistance (POLR) v2.
110
- Calculates the price levels required to sweep specific USD amounts (Liquidity Bands).
111
- Returns a set of price points representing dynamic support/resistance.
112
- """
113
- bands = {'bids': [], 'asks': []}
114
-
115
- # Calculate Ask Bands (Resistance)
116
- current_cost = 0.0
117
- current_vol = 0.0
118
- ask_ptr = 0
119
-
120
- for target_usd in LIQUIDITY_BANDS_USD:
121
- while ask_ptr < len(asks):
122
- p, q = asks[ask_ptr]
123
- cost = p * q
124
- if current_cost + cost >= target_usd:
125
- # Interpolate exact price for remaining amount
126
- remaining = target_usd - current_cost
127
- bands['asks'].append(p) # Approx
128
- current_cost += remaining # Cap it here
129
- break
130
- current_cost += cost
131
- ask_ptr += 1
132
- if ask_ptr >= len(asks):
133
- bands['asks'].append(asks[-1][0])
134
-
135
- # Calculate Bid Bands (Support)
136
- current_cost = 0.0
137
- bid_ptr = 0
138
- for target_usd in LIQUIDITY_BANDS_USD:
139
- while bid_ptr < len(bids):
140
- p, q = bids[bid_ptr]
141
- cost = p * q
142
- if current_cost + cost >= target_usd:
143
- bands['bids'].append(p)
144
- current_cost += remaining
145
- break
146
- current_cost += cost
147
- bid_ptr += 1
148
- if bid_ptr >= len(bids):
149
- bands['bids'].append(bids[-1][0])
150
-
151
- return bands
152
-
153
- @staticmethod
154
- def aggregate_depth(bids, asks, mid):
155
- """Buckets order book depth for efficient frontend rendering."""
156
- # Simple decimation for visualization
157
- if not bids or not asks: return [], [], []
158
-
159
- range_pct = 0.02 # 2% depth
160
- min_p = mid * (1 - range_pct)
161
- max_p = mid * (1 + range_pct)
162
-
163
- chart_bids = []
164
  cum_vol = 0
165
- for p, q in bids:
166
- if p < min_p: break
167
  cum_vol += q
168
- chart_bids.append({'p': p, 'v': cum_vol})
169
-
170
- chart_asks = []
 
 
 
171
  cum_vol = 0
172
- for p, q in asks:
173
- if p > max_p: break
174
  cum_vol += q
175
- chart_asks.append({'p': p, 'v': cum_vol})
176
-
177
- # Downsample to N points
178
- def downsample(data, n):
179
- if not data: return []
180
- if len(data) <= n: return data
181
- step = len(data) / n
182
- return [data[int(i * step)] for i in range(n)]
183
-
184
- return downsample(chart_bids, DEPTH_BUCKETS), downsample(chart_asks, DEPTH_BUCKETS)
185
-
186
- # ==========================================
187
- # ASYNC WORKERS
188
- # ==========================================
 
 
 
 
 
 
 
189
 
190
- async def ingestion_worker():
191
- """Handles WebSocket connection to Kraken and updates Market State."""
192
- while True:
193
- try:
194
- async with aiohttp.ClientSession() as session:
195
- async with session.ws_connect("wss://ws.kraken.com/v2") as ws:
196
- logger.info(f"🔌 Connected to Kraken V2: {SYMBOL_KRAKEN}")
197
-
198
- # Subscribe to Book and Trade
199
- await ws.send_json({
200
- "method": "subscribe",
201
- "params": {"channel": "book", "symbol": [SYMBOL_KRAKEN], "depth": 500}
202
- })
203
- await ws.send_json({
204
- "method": "subscribe",
205
- "params": {"channel": "trade", "symbol": [SYMBOL_KRAKEN]}
206
- })
207
-
208
- async for msg in ws:
209
- payload = json.loads(msg.data)
210
- channel = payload.get("channel")
211
-
212
- if channel == "book":
213
- data = payload.get("data", [])[0]
214
- # Is this a snapshot or update? V2 usually sends snapshot first
215
- # For simplified handling here, we assume standard dict updates
216
-
217
- # Update Bids
218
- for bid in data.get('bids', []):
219
- price, qty = float(bid['price']), float(bid['qty'])
220
- if qty == 0: state.bids.pop(price, None)
221
- else: state.bids[price] = qty
222
-
223
- # Update Asks
224
- for ask in data.get('asks', []):
225
- price, qty = float(ask['price']), float(ask['qty'])
226
- if qty == 0: state.asks.pop(price, None)
227
- else: state.asks[price] = qty
228
-
229
- # --- QUANTITATIVE UPDATE TRIGGER ---
230
- if state.bids and state.asks:
231
- # 1. Get Sorted Top of Book
232
- b_sorted, a_sorted = state.get_sorted_book()
233
- best_bid, best_bid_qty = b_sorted[0]
234
- best_ask, best_ask_qty = a_sorted[0]
235
-
236
- # 2. Calculate Mid & Spread
237
- state.mid_price = (best_bid + best_ask) / 2
238
- state.spread = best_ask - best_bid
239
-
240
- # 3. Calculate OFI (Microstructure Alpha)
241
- ofi_val = QuantEngine.calculate_ofi(
242
- best_bid, best_bid_qty,
243
- best_ask, best_ask_qty
244
- )
245
- state.ofi_history.append(ofi_val)
246
- state.ofi_rolling = statistics.mean(state.ofi_history) if state.ofi_history else 0
247
-
248
- # 4. Update State for next tick
249
- state.prev_best_bid = best_bid
250
- state.prev_best_ask = best_ask
251
- state.prev_bid_qty = best_bid_qty
252
- state.prev_ask_qty = best_ask_qty
253
-
254
- state.ready = True
255
-
256
- elif channel == "trade":
257
- trades = payload.get("data", [])
258
- for t in trades:
259
- state.last_trade_price = float(t['price'])
260
- qty = float(t['qty'])
261
- side = t['side'] # 'buy' or 'sell'
262
-
263
- # Simple Buying/Selling Pressure Oscillator
264
- direction = 1 if side == 'buy' else -1
265
- state.trade_pressure = (state.trade_pressure * 0.95) + (direction * qty * 0.05)
266
 
267
- except Exception as e:
268
- logger.error(f"Ingestion Error: {e}")
269
- state.ready = False
270
- await asyncio.sleep(5)
271
 
272
- async def broadcast_worker(app):
273
- """Pushes analyzed metrics to frontend at fixed intervals."""
274
- while True:
275
- start_t = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
- if state.ready and app['websockets']:
278
- try:
279
- # 1. Snapshot Data
280
- bids, asks = state.get_sorted_book()
281
- mid = state.mid_price
282
-
283
- # 2. Run Heavy Analysis
284
- liq_bands = QuantEngine.calculate_liquidity_bands(bids, asks, mid)
285
- depth_b, depth_a = QuantEngine.aggregate_depth(bids, asks, mid)
286
-
287
- # 3. Construct Payload
288
- payload = {
289
- "t": time.time(),
290
- "mid": mid,
291
- "spread": state.spread,
292
- "trade_p": state.last_trade_price,
293
- "ofi": state.ofi_rolling,
294
- "pressure": state.trade_pressure,
295
- "bands": liq_bands,
296
- "depth": {"bids": depth_b, "asks": depth_a}
297
- }
298
-
299
- # 4. Broadcast
300
- msg = json.dumps(payload)
301
- for ws in set(app['websockets']):
302
- await ws.send_str(msg)
303
-
304
- except Exception as e:
305
- logger.error(f"Broadcast Logic Error: {e}")
306
-
307
- # Sleep remaining time to maintain Hz
308
- elapsed = time.time() - start_t
309
- wait = max(0, BROADCAST_INTERVAL - elapsed)
310
- await asyncio.sleep(wait)
311
-
312
- # ==========================================
313
- # FRONTEND (EMBEDDED HTML/JS)
314
- # ==========================================
315
 
316
- HTML_TEMPLATE = """
317
  <!DOCTYPE html>
318
  <html lang="en">
319
  <head>
320
  <meta charset="UTF-8">
321
- <title>QUANT // COMMAND</title>
322
  <script src="https://unpkg.com/lightweight-charts@4.1.1/dist/lightweight-charts.standalone.production.js"></script>
 
323
  <style>
324
- :root {
325
- --bg-dark: #050505;
326
- --bg-panel: #0E0E0E;
327
- --border: #1F1F1F;
328
- --text-main: #EAEAEA;
329
- --text-dim: #666666;
330
- --accent-green: #00F0FF; /* Cyber Cyan */
331
- --accent-red: #FF2A6D; /* Neon Red */
332
- --accent-yellow: #FAFF00;
333
- --accent-band: rgba(255, 255, 255, 0.08);
334
- }
335
- * { box-sizing: border-box; }
336
- body { margin: 0; background: var(--bg-dark); color: var(--text-main); font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; overflow: hidden; height: 100vh; width: 100vw; }
337
-
338
- /* GRID LAYOUT */
339
- .grid-container {
340
- display: grid;
341
- grid-template-columns: 3fr 1fr;
342
- grid-template-rows: 40px 2fr 1fr;
343
- height: 100vh;
 
 
 
 
344
  gap: 1px;
345
- background-color: var(--border);
346
- }
 
 
 
347
 
348
- .header { grid-column: 1 / 3; background: var(--bg-panel); display: flex; align-items: center; padding: 0 15px; border-bottom: 1px solid var(--border); justify-content: space-between; }
349
- .main-chart { grid-column: 1 / 2; grid-row: 2 / 3; background: var(--bg-panel); position: relative; }
350
- .sub-chart { grid-column: 1 / 2; grid-row: 3 / 4; background: var(--bg-panel); position: relative; }
351
- .sidebar { grid-column: 2 / 3; grid-row: 2 / 4; background: var(--bg-panel); padding: 10px; display: flex; flex-direction: column; gap: 10px; border-left: 1px solid var(--border); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
- /* TYPOGRAPHY */
354
- .title { font-weight: 800; letter-spacing: 1px; font-size: 14px; color: var(--text-main); }
355
- .live-tag { background: var(--accent-red); color: #000; font-weight: bold; font-size: 10px; padding: 2px 6px; border-radius: 2px; margin-right: 10px; }
356
- .metric-box { background: rgba(255,255,255,0.03); padding: 10px; border-radius: 4px; border: 1px solid var(--border); }
357
- .metric-label { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 4px; }
358
- .metric-val { font-family: 'Courier New', monospace; font-size: 18px; font-weight: 700; color: #FFF; }
359
- .small-chart-container { height: 150px; width: 100%; margin-top: auto; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
 
361
- /* UTILS */
362
- .c-up { color: var(--accent-green); }
363
- .c-down { color: var(--accent-red); }
364
- .chart-overlay-info { position: absolute; top: 10px; left: 10px; z-index: 10; font-family: monospace; font-size: 11px; background: rgba(0,0,0,0.5); padding: 5px; pointer-events: none; }
 
 
 
 
 
 
 
 
365
  </style>
366
  </head>
367
  <body>
368
-
369
- <div class="grid-container">
370
- <div class="header">
371
- <div><span class="live-tag">LIVE</span> <span class="title">KRAKEN // QUANTITATIVE MONITOR // BTC-USD</span></div>
372
- <div style="font-family: monospace; font-size: 12px; color: var(--text-dim);" id="connection-status">DISCONNECTED</div>
 
 
 
373
  </div>
374
 
375
- <div class="main-chart" id="chart-price">
376
- <div class="chart-overlay-info">
377
- MID: <span id="val-mid">---</span><br>
378
- SPREAD: <span id="val-spread">---</span>
379
  </div>
 
380
  </div>
381
-
382
- <div class="sub-chart" id="chart-ofi">
383
- <div class="chart-overlay-info">ORDER FLOW IMBALANCE (Rolling)</div>
 
 
 
 
 
 
 
384
  </div>
385
 
386
- <div class="sidebar">
387
- <div class="metric-box">
388
- <span class="metric-label">Liquidity Cost (Ask)</span>
389
- <span class="metric-val" id="val-ask-liq">---</span>
 
 
 
 
390
  </div>
391
- <div class="metric-box">
392
- <span class="metric-label">Liquidity Cost (Bid)</span>
393
- <span class="metric-val" id="val-bid-liq">---</span>
 
 
 
394
  </div>
395
- <div class="metric-box">
396
- <span class="metric-label">Net Pressure</span>
397
- <span class="metric-val" id="val-pressure">0.00</span>
 
 
 
 
 
398
  </div>
399
-
400
- <div style="flex:1; display:flex; flex-direction:column;">
401
- <span class="metric-label">REAL-TIME DEPTH</span>
402
- <div id="chart-depth" style="flex:1; width:100%;"></div>
 
 
 
 
 
403
  </div>
404
  </div>
405
  </div>
406
 
407
  <script>
408
- const CONFIG = {
409
- colors: {
410
- bg: '#0E0E0E',
411
- grid: '#1F1F1F',
412
- text: '#666',
413
- green: '#00F0FF',
414
- red: '#FF2A6D',
415
- band: 'rgba(255, 255, 255, 0.05)'
416
- }
417
- };
418
-
419
- // --- CHART INITIALIZATION ---
420
- const chartOpts = {
421
- layout: { background: { type: 'solid', color: CONFIG.colors.bg }, textColor: '#888' },
422
- grid: { vertLines: { color: CONFIG.colors.grid }, horzLines: { color: CONFIG.colors.grid } },
423
- crosshair: { mode: 1 },
424
- timeScale: { timeVisible: true, secondsVisible: true }
425
- };
426
-
427
- // 1. Price Chart
428
- const priceChart = LightweightCharts.createChart(document.getElementById('chart-price'), chartOpts);
429
- const midSeries = priceChart.addLineSeries({ color: '#FFF', lineWidth: 1, title: 'Mid' });
430
- const tradeSeries = priceChart.addAreaSeries({
431
- topColor: 'rgba(0, 240, 255, 0.1)', bottomColor: 'rgba(0,0,0,0)',
432
- lineColor: CONFIG.colors.green, lineWidth: 2, title: 'Last Trade'
433
- });
434
-
435
- // POLR Bands (4 levels)
436
- const bandSeries = [];
437
- for(let i=0; i<4; i++) {
438
- bandSeries.push({
439
- bid: priceChart.addLineSeries({ color: '#2b2b2b', lineWidth: 1, lineStyle: 2, lastValueVisible: false }),
440
- ask: priceChart.addLineSeries({ color: '#2b2b2b', lineWidth: 1, lineStyle: 2, lastValueVisible: false })
441
- });
442
- }
443
-
444
- // 2. OFI Chart
445
- const ofiChart = LightweightCharts.createChart(document.getElementById('chart-ofi'), {
446
- ...chartOpts,
447
- rightPriceScale: { scaleMargins: { top: 0.1, bottom: 0.1 } }
448
- });
449
- const ofiSeries = ofiChart.addHistogramSeries({ color: '#26a69a' });
450
-
451
- // 3. Depth Chart (Sidebar)
452
- const depthChart = LightweightCharts.createChart(document.getElementById('chart-depth'), {
453
- layout: { background: { type: 'solid', color: 'transparent' }, textColor: '#444' },
454
- grid: { visible: false, vertLines: { visible: false }, horzLines: { visible: false } },
455
- rightPriceScale: { visible: false },
456
- timeScale: { visible: false },
457
- crosshair: { visible: false },
458
- handleScroll: false, handleScale: false
459
- });
460
- const depthBidSeries = depthChart.addAreaSeries({ lineColor: CONFIG.colors.green, topColor: 'rgba(0, 240, 255, 0.2)', bottomColor: 'rgba(0,0,0,0)', lineWidth: 1 });
461
- const depthAskSeries = depthChart.addAreaSeries({ lineColor: CONFIG.colors.red, topColor: 'rgba(255, 42, 109, 0.2)', bottomColor: 'rgba(0,0,0,0)', lineWidth: 1 });
462
-
463
- // --- RESIZING ---
464
- new ResizeObserver(entries => {
465
- for (let entry of entries) {
466
- const { width, height } = entry.contentRect;
467
- if (entry.target.id === 'chart-price') priceChart.applyOptions({ width, height });
468
- if (entry.target.id === 'chart-ofi') ofiChart.applyOptions({ width, height });
469
- if (entry.target.id === 'chart-depth') depthChart.applyOptions({ width, height });
470
- }
471
- }).observe(document.body);
472
-
473
- // --- SYNC ---
474
- // Simple time-sync logic could be added here, but tricky with different scale types (Price vs Histogram)
475
-
476
- // --- WEBSOCKET ---
477
- const connect = () => {
478
- const ws = new WebSocket(`ws://${location.host}/ws`);
479
- const status = document.getElementById('connection-status');
480
 
481
- ws.onopen = () => { status.innerText = 'CONNECTED'; status.style.color = CONFIG.colors.green; };
482
- ws.onclose = () => { status.innerText = 'RECONNECTING...'; status.style.color = CONFIG.colors.red; setTimeout(connect, 2000); };
 
483
 
484
- ws.onmessage = (event) => {
485
- const data = JSON.parse(event.data);
486
- const t = data.t; // timestamp
487
-
488
- // 1. Update Price
489
- midSeries.update({ time: t, value: data.mid });
490
- tradeSeries.update({ time: t, value: data.trade_p });
491
-
492
- // 2. Update Bands
493
- if (data.bands) {
494
- data.bands.bids.forEach((p, i) => { if(bandSeries[i]) bandSeries[i].bid.update({ time: t, value: p }); });
495
- data.bands.asks.forEach((p, i) => { if(bandSeries[i]) bandSeries[i].ask.update({ time: t, value: p }); });
496
- }
497
-
498
- // 3. Update OFI
499
- const color = data.ofi >= 0 ? CONFIG.colors.green : CONFIG.colors.red;
500
- ofiSeries.update({ time: t, value: data.ofi, color: color });
501
-
502
- // 4. Update Depth (Snapshot mode, re-mapping x-axis to indices for smooth look)
503
- if (data.depth) {
504
- // Depth chart doesn't use time, just simple index 0..100
505
- const bidData = data.depth.bids.reverse().map((d, i) => ({ time: i, value: d.v }));
506
- const askData = data.depth.asks.map((d, i) => ({ time: i + 100, value: d.v }));
507
- depthBidSeries.setData(bidData);
508
- depthAskSeries.setData(askData);
509
- depthChart.timeScale().fitContent();
510
- }
511
-
512
- // 5. DOM Updates
513
- document.getElementById('val-mid').innerText = data.mid.toFixed(1);
514
- document.getElementById('val-spread').innerText = data.spread.toFixed(1);
515
 
516
- const pressEl = document.getElementById('val-pressure');
517
- pressEl.innerText = data.pressure.toFixed(4);
518
- pressEl.style.color = data.pressure > 0 ? CONFIG.colors.green : CONFIG.colors.red;
519
-
520
- // Simple distance to first band calc
521
- if(data.bands.asks.length) document.getElementById('val-ask-liq').innerText = (data.bands.asks[0] - data.mid).toFixed(2);
522
- if(data.bands.bids.length) document.getElementById('val-bid-liq').innerText = (data.mid - data.bands.bids[0]).toFixed(2);
523
- };
524
- };
525
-
526
- connect();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  </script>
528
  </body>
529
  </html>
530
  """
531
 
532
- # ==========================================
533
- # WEBSERVER SETUP
534
- # ==========================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
 
536
- async def ws_handler(request):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  ws = web.WebSocketResponse()
538
  await ws.prepare(request)
539
-
540
- request.app['websockets'].add(ws)
541
  try:
542
- async for msg in ws: pass
 
543
  finally:
544
- request.app['websockets'].discard(ws)
545
  return ws
546
 
547
- async def index_handler(request):
548
- return web.Response(text=HTML_TEMPLATE, content_type='text/html')
549
 
550
- async def on_startup(app):
551
- app['websockets'] = set()
552
- app['ingestion_task'] = asyncio.create_task(ingestion_worker())
553
- app['broadcast_task'] = asyncio.create_task(broadcast_worker(app))
554
 
555
- async def on_cleanup(app):
556
- app['ingestion_task'].cancel()
557
  app['broadcast_task'].cancel()
558
- for ws in app['websockets']:
559
- await ws.close()
560
 
561
- def main():
562
  app = web.Application()
563
- app.router.add_get('/', index_handler)
564
- app.router.add_get('/ws', ws_handler)
565
- app.on_startup.append(on_startup)
566
- app.on_cleanup.append(on_cleanup)
567
-
568
- print(f"🚀 QUANT SUITE ACTIVE: http://localhost:{PORT}")
569
- web.run_app(app, port=PORT, print=None)
 
 
 
570
 
571
  if __name__ == "__main__":
572
- try:
573
- # Windows selector event loop policy fix
574
- import sys
575
- if sys.platform == 'win32':
576
- asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
577
- main()
578
- except KeyboardInterrupt:
579
- pass
 
2
  import json
3
  import logging
4
  import time
5
+ import bisect
6
  import math
7
  import statistics
8
  import aiohttp
9
+ from datetime import datetime
10
  from aiohttp import web
11
+ import websockets
12
+
13
+ SYMBOL_KRAKEN = "BTC/USD"
 
 
 
 
 
 
14
  PORT = 7860
15
+ HISTORY_LENGTH = 300
16
+ BROADCAST_RATE = 0.1
17
+
18
+ DECAY_LAMBDA = 50.0
19
+ IMPACT_SENSITIVITY = 2.0
20
+ WALL_DAMPENING = 0.8
21
+ Z_SCORE_THRESHOLD = 3.0
22
+ WALL_LOOKBACK = 200
23
+
24
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
25
+
26
+ market_state = {
27
+ "bids": {},
28
+ "asks": {},
29
+ "history": [],
30
+ "pred_history": [],
31
+ "trade_vol_history": [],
32
+ "ohlc_history": [],
33
+ "current_vol_window": {"buy": 0.0, "sell": 0.0, "start": time.time()},
34
+ "current_mid": 0.0,
35
+ "ready": False
36
+ }
37
+
38
+ connected_clients = set()
39
+
40
+ def detect_anomalies(orders, scan_depth):
41
+ if len(orders) < 10: return []
42
+ relevant_orders = orders[:scan_depth]
43
+ volumes = [q for p, q in relevant_orders]
44
+ if not volumes: return []
45
 
46
+ try:
47
+ avg_vol = statistics.mean(volumes)
48
+ stdev_vol = statistics.stdev(volumes)
49
+ except statistics.StatisticsError:
50
+ return []
51
+
52
+ if stdev_vol == 0: return []
53
+
54
+ walls = []
55
+ for price, qty in relevant_orders:
56
+ z_score = (qty - avg_vol) / stdev_vol
57
+ if z_score > Z_SCORE_THRESHOLD:
58
+ walls.append({"price": price, "vol": qty, "z_score": z_score})
59
+
60
+ walls.sort(key=lambda x: x['z_score'], reverse=True)
61
+ return walls[:3]
62
+
63
+ def calculate_micro_price_structure(diff_x, diff_y_net, current_mid, best_bid, best_ask, walls):
64
+ if not diff_x or len(diff_x) < 5: return None
65
+
66
+ weighted_imbalance = 0.0
67
+ total_weight = 0.0
68
+
69
+ for i in range(len(diff_x)):
70
+ dist = diff_x[i]
71
+ net_vol = diff_y_net[i]
72
+ weight = math.exp(-dist / DECAY_LAMBDA)
73
+ weighted_imbalance += net_vol * weight
74
+ total_weight += weight
75
+
76
+ rho = weighted_imbalance / total_weight if total_weight > 0 else 0
77
+
78
+ spread = best_ask - best_bid
79
+ theoretical_delta = (spread / 2) * rho * IMPACT_SENSITIVITY
80
+ projected_price = current_mid + theoretical_delta
81
+
82
+ final_delta = theoretical_delta
83
+ if final_delta > 0 and walls['asks']:
84
+ nearest_wall = walls['asks'][0]
85
+ if projected_price >= nearest_wall['price']:
86
+ damp_factor = 1.0 / (1.0 + (nearest_wall['z_score'] * 0.2))
87
+ final_delta *= damp_factor
88
+ elif final_delta < 0 and walls['bids']:
89
+ nearest_wall = walls['bids'][0]
90
+ if projected_price <= nearest_wall['price']:
91
+ damp_factor = 1.0 / (1.0 + (nearest_wall['z_score'] * 0.2))
92
+ final_delta *= damp_factor
93
+
94
+ return {
95
+ "projected": current_mid + final_delta,
96
+ "rho": rho
97
+ }
98
+
99
+ def calculate_polr(bids, asks, mid):
100
+ """
101
+ Calculates the Path of Least Resistance.
102
+ HIGH RESOLUTION UPDATE:
103
+ Scans 200 steps at 0.2 BTC increments (Total 40 BTC depth).
104
+ """
105
+ if not bids or not asks: return []
106
+
107
+ sorted_bids = sorted(bids.items(), key=lambda x: -x[0])
108
+ sorted_asks = sorted(asks.items(), key=lambda x: x[0])
109
+
110
+ path_points = []
111
 
112
+ # Generate 200 points of resolution
113
+ # 0.2, 0.4, ... 40.0
114
+ volume_steps = [i * 0.2 for i in range(1, 201)]
115
 
116
+ for i, target_vol in enumerate(volume_steps):
117
+ ask_cost_dist = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  cum_vol = 0
119
+ target_ask_price = mid
120
+ for p, q in sorted_asks:
121
  cum_vol += q
122
+ if cum_vol >= target_vol:
123
+ target_ask_price = p
124
+ break
125
+ ask_cost_dist = target_ask_price - mid
126
+
127
+ bid_cost_dist = 0
128
  cum_vol = 0
129
+ target_bid_price = mid
130
+ for p, q in sorted_bids:
131
  cum_vol += q
132
+ if cum_vol >= target_vol:
133
+ target_bid_price = p
134
+ break
135
+ bid_cost_dist = mid - target_bid_price
136
+
137
+ # Avoid division by zero
138
+ if bid_cost_dist <= 0: bid_cost_dist = 0.01
139
+ if ask_cost_dist <= 0: ask_cost_dist = 0.01
140
+
141
+ # POLR Logic: The price gravitates towards the side that is "cheaper" (less volume resistance)
142
+ # If it costs more to buy (ask side thick), price goes down (to bids).
143
+ projected_p = mid
144
+ if ask_cost_dist > bid_cost_dist:
145
+ projected_p = target_ask_price
146
+ else:
147
+ projected_p = target_bid_price
148
+
149
+ path_points.append({
150
+ 'index': i,
151
+ 'p': projected_p
152
+ })
153
 
154
+ return path_points
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
+ def process_market_data():
157
+ if not market_state['ready']: return {"error": "Initializing..."}
 
 
158
 
159
+ mid = market_state['current_mid']
160
+
161
+ now = time.time()
162
+ if now - market_state['current_vol_window']['start'] >= 1.0:
163
+ market_state['trade_vol_history'].append({
164
+ 't': now,
165
+ 'buy': market_state['current_vol_window']['buy'],
166
+ 'sell': market_state['current_vol_window']['sell']
167
+ })
168
+ if len(market_state['trade_vol_history']) > 60:
169
+ market_state['trade_vol_history'].pop(0)
170
+ market_state['current_vol_window'] = {"buy": 0.0, "sell": 0.0, "start": now}
171
+
172
+ sorted_bids = sorted(market_state['bids'].items(), key=lambda x: -x[0])
173
+ sorted_asks = sorted(market_state['asks'].items(), key=lambda x: x[0])
174
+
175
+ if not sorted_bids or not sorted_asks: return {"error": "Empty Book"}
176
+
177
+ best_bid = sorted_bids[0][0]
178
+ best_ask = sorted_asks[0][0]
179
+
180
+ bid_walls = detect_anomalies(sorted_bids, WALL_LOOKBACK)
181
+ ask_walls = detect_anomalies(sorted_asks, WALL_LOOKBACK)
182
+
183
+ d_b_x, d_b_y, cum = [], [], 0
184
+ for p, q in sorted_bids[:300]:
185
+ d = mid - p
186
+ if d >= 0:
187
+ cum += q
188
+ d_b_x.append(d); d_b_y.append(cum)
189
+
190
+ d_a_x, d_a_y, cum = [], [], 0
191
+ for p, q in sorted_asks[:300]:
192
+ d = p - mid
193
+ if d >= 0:
194
+ cum += q
195
+ d_a_x.append(d); d_a_y.append(cum)
196
+
197
+ diff_x, diff_y_net = [], []
198
+ chart_bids, chart_asks = [], []
199
+
200
+ if d_b_x and d_a_x:
201
+ max_dist = min(d_b_x[-1], d_a_x[-1])
202
+ step_size = max_dist / 100
203
+ steps = [i * step_size for i in range(1, 101)]
204
 
205
+ for s in steps:
206
+ idx_b = bisect.bisect_right(d_b_x, s)
207
+ vol_b = d_b_y[idx_b-1] if idx_b > 0 else 0
208
+ idx_a = bisect.bisect_right(d_a_x, s)
209
+ vol_a = d_a_y[idx_a-1] if idx_a > 0 else 0
210
+
211
+ diff_x.append(s)
212
+ diff_y_net.append(vol_b - vol_a)
213
+ chart_bids.append(vol_b)
214
+ chart_asks.append(vol_a)
215
+
216
+ analysis = calculate_micro_price_structure(
217
+ diff_x, diff_y_net, mid, best_bid, best_ask,
218
+ {"bids": bid_walls, "asks": ask_walls}
219
+ )
220
+
221
+ polr_path = calculate_polr(market_state['bids'], market_state['asks'], mid)
222
+
223
+ if analysis:
224
+ if not market_state['pred_history'] or (now - market_state['pred_history'][-1]['t'] > 0.5):
225
+ market_state['pred_history'].append({'t': now, 'p': analysis['projected']})
226
+ if len(market_state['pred_history']) > HISTORY_LENGTH:
227
+ market_state['pred_history'].pop(0)
228
+
229
+ return {
230
+ "mid": mid,
231
+ "history": market_state['history'],
232
+ "pred_history": market_state['pred_history'],
233
+ "polr": polr_path,
234
+ "trade_history": market_state['trade_vol_history'],
235
+ "ohlc": market_state['ohlc_history'],
236
+ "depth_x": diff_x,
237
+ "depth_net": diff_y_net,
238
+ "depth_bids": chart_bids,
239
+ "depth_asks": chart_asks,
240
+ "analysis": analysis,
241
+ "walls": {"bids": bid_walls, "asks": ask_walls}
242
+ }
243
 
244
+ HTML_PAGE = f"""
245
  <!DOCTYPE html>
246
  <html lang="en">
247
  <head>
248
  <meta charset="UTF-8">
249
+ <title>{SYMBOL_KRAKEN} QUANT</title>
250
  <script src="https://unpkg.com/lightweight-charts@4.1.1/dist/lightweight-charts.standalone.production.js"></script>
251
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
252
  <style>
253
+ :root {{
254
+ --bg-base: #000000;
255
+ --bg-panel: #0a0a0a;
256
+ --border: #252525;
257
+ --text-main: #FFFFFF;
258
+ --text-dim: #999999;
259
+ --green: #00ff9d;
260
+ --red: #ff3b3b;
261
+ --blue: #2979ff;
262
+ --yellow: #ffeb3b;
263
+ --purple: #d500f9;
264
+ }}
265
+ body {{
266
+ margin: 0; padding: 0;
267
+ background-color: var(--bg-base);
268
+ color: var(--text-main);
269
+ font-family: 'Inter', sans-serif;
270
+ overflow: hidden;
271
+ height: 100vh; width: 100vw;
272
+ }}
273
+ .layout {{
274
+ display: grid;
275
+ grid-template-rows: 34px 1fr 1fr;
276
+ grid-template-columns: 3fr 1fr;
277
  gap: 1px;
278
+ background-color: var(--border);
279
+ height: 100vh;
280
+ box-sizing: border-box;
281
+ }}
282
+ .panel {{ background: var(--bg-panel); display: flex; flex-direction: column; overflow: hidden; }}
283
 
284
+ .status-bar {{
285
+ grid-column: 1 / 3;
286
+ grid-row: 1 / 2;
287
+ background: var(--bg-panel);
288
+ display: flex;
289
+ align-items: center;
290
+ justify-content: space-between;
291
+ padding: 0 12px;
292
+ font-family: 'JetBrains Mono', monospace;
293
+ font-size: 12px;
294
+ text-transform: uppercase;
295
+ border-bottom: 1px solid var(--border);
296
+ z-index: 50;
297
+ }}
298
+ .status-left {{ display: flex; gap: 20px; align-items: center; }}
299
+ .live-dot {{ width: 8px; height: 8px; background-color: var(--green); border-radius: 50%; display: inline-block; box-shadow: 0 0 8px var(--green); }}
300
+ .ticker-val {{ font-weight: 700; color: #fff; font-size: 13px; }}
301
+
302
+ #p-chart {{ grid-column: 1 / 2; grid-row: 2 / 3; }}
303
 
304
+ #p-bottom {{
305
+ grid-column: 1 / 2; grid-row: 3 / 4;
306
+ display: grid;
307
+ grid-template-columns: 1fr 1fr;
308
+ gap: 1px;
309
+ background: var(--border);
310
+ }}
311
+ .bottom-sub {{ background: var(--bg-panel); display: flex; flex-direction: column; position: relative; }}
312
+
313
+ #p-sidebar {{
314
+ grid-column: 2 / 3;
315
+ grid-row: 2 / 4;
316
+ padding: 15px;
317
+ display: flex;
318
+ flex-direction: column;
319
+ gap: 15px;
320
+ border-left: 1px solid var(--border);
321
+ overflow: hidden;
322
+ }}
323
+
324
+ .chart-header {{
325
+ height: 24px;
326
+ min-height: 24px;
327
+ display: flex;
328
+ align-items: center;
329
+ padding-left: 12px;
330
+ font-size: 10px;
331
+ font-weight: 700;
332
+ color: var(--text-dim);
333
+ background: #050505;
334
+ border-bottom: 1px solid #151515;
335
+ letter-spacing: 0.5px;
336
+ }}
337
+
338
+ .data-group {{ display: flex; flex-direction: column; gap: 4px; }}
339
+ .label {{ font-size: 10px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
340
+ .value {{ font-family: 'JetBrains Mono', monospace; font-size: 20px; font-weight: 700; color: #fff; }}
341
+ .value-lg {{ font-size: 26px; }}
342
+ .value-sub {{ font-family: 'JetBrains Mono', monospace; font-size: 11px; margin-top: 2px; color: #666; }}
343
+
344
+ .divider {{ height: 1px; background: var(--border); width: 100%; }}
345
+ .c-green {{ color: var(--green); }}
346
+ .c-red {{ color: var(--red); }}
347
+ .c-dim {{ color: var(--text-dim); }}
348
+ .c-purp {{ color: var(--purple); }}
349
+
350
+ .list-container {{ display: flex; flex-direction: column; gap: 8px; overflow-y: auto; height: 100px; }}
351
+ .list-item {{
352
+ display: flex; justify-content: space-between;
353
+ font-family: 'JetBrains Mono', monospace;
354
+ font-size: 11px;
355
+ border-bottom: 1px solid #151515;
356
+ padding-bottom: 4px;
357
+ }}
358
+ .list-item span:first-child {{ color: #e0e0e0; }}
359
+ .list-item:last-child {{ border: none; }}
360
 
361
+ .sidebar-chart-box {{
362
+ flex: 1;
363
+ display: flex;
364
+ flex-direction: column;
365
+ min-height: 0;
366
+ }}
367
+ .mini-chart {{
368
+ flex: 1;
369
+ background: rgba(255,255,255,0.02);
370
+ border: 1px solid var(--border);
371
+ border-radius: 4px;
372
+ }}
373
  </style>
374
  </head>
375
  <body>
376
+ <div class="layout">
377
+ <div class="status-bar">
378
+ <div class="status-left">
379
+ <span class="live-dot"></span>
380
+ <span style="font-weight:700; color:#fff;">{SYMBOL_KRAKEN}</span>
381
+ <span id="price-ticker" class="ticker-val">---</span>
382
+ </div>
383
+ <div class="status-right" id="clock">00:00:00 UTC</div>
384
  </div>
385
 
386
+ <div id="p-chart" class="panel">
387
+ <div class="chart-header">
388
+ PRICE (BLUE) // <span class="c-purp">POLR RIVER (HIGH RES)</span> // <span style="color:var(--yellow)">PRED (YELLOW)</span>
 
389
  </div>
390
+ <div id="tv-price" style="flex: 1; width: 100%;"></div>
391
  </div>
392
+
393
+ <div id="p-bottom">
394
+ <div class="bottom-sub">
395
+ <div class="chart-header">1M KLINE (KRAKEN OHLC)</div>
396
+ <div id="tv-candles" style="flex: 1; width: 100%;"></div>
397
+ </div>
398
+ <div class="bottom-sub">
399
+ <div class="chart-header">ORDER FLOW IMBALANCE</div>
400
+ <div id="tv-net" style="flex: 1; width: 100%;"></div>
401
+ </div>
402
  </div>
403
 
404
+ <div id="p-sidebar" class="panel">
405
+
406
+ <div class="data-group">
407
+ <span class="label">Micro-Price Delta</span>
408
+ <div style="display:flex; align-items: baseline; gap: 10px;">
409
+ <span id="proj-pct" class="value value-lg">--%</span>
410
+ <span id="proj-val" class="value-sub">---</span>
411
+ </div>
412
  </div>
413
+
414
+ <div class="divider"></div>
415
+
416
+ <div class="data-group">
417
+ <span class="label">OFI Imbalance Ratio</span>
418
+ <span id="score-val" class="value">0.00</span>
419
  </div>
420
+
421
+ <div class="divider"></div>
422
+
423
+ <div class="data-group">
424
+ <span class="label">Detected Walls (Z > 3.0)</span>
425
+ <div id="wall-list" class="list-container">
426
+ <span class="c-dim" style="font-size: 11px;">Scanning...</span>
427
+ </div>
428
  </div>
429
+
430
+ <div class="sidebar-chart-box">
431
+ <span class="label" style="margin-bottom:4px;">Real-time Volume Ticks</span>
432
+ <div id="sidebar-vol" class="mini-chart"></div>
433
+ </div>
434
+
435
+ <div class="sidebar-chart-box">
436
+ <span class="label" style="margin-bottom:4px;">Liquidity Density</span>
437
+ <div id="sidebar-density" class="mini-chart"></div>
438
  </div>
439
  </div>
440
  </div>
441
 
442
  <script>
443
+ setInterval(() => {{
444
+ const now = new Date();
445
+ document.getElementById('clock').innerText = now.toISOString().split('T')[1].split('.')[0] + ' UTC';
446
+ }}, 1000);
447
+
448
+ document.addEventListener('DOMContentLoaded', () => {{
449
+ const dom = {{
450
+ ticker: document.getElementById('price-ticker'),
451
+ score: document.getElementById('score-val'),
452
+ projVal: document.getElementById('proj-val'),
453
+ projPct: document.getElementById('proj-pct'),
454
+ wallList: document.getElementById('wall-list')
455
+ }};
456
+
457
+ const chartOpts = {{
458
+ layout: {{ background: {{ type: 'solid', color: '#0a0a0a' }}, textColor: '#888', fontFamily: 'JetBrains Mono' }},
459
+ grid: {{ vertLines: {{ color: '#151515' }}, horzLines: {{ color: '#151515' }} }},
460
+ rightPriceScale: {{ borderColor: '#222', scaleMargins: {{ top: 0.1, bottom: 0.1 }} }},
461
+ timeScale: {{ borderColor: '#222', timeVisible: true, secondsVisible: true }},
462
+ crosshair: {{ mode: 1, vertLine: {{ color: '#444', labelBackgroundColor: '#444' }}, horzLine: {{ color: '#444', labelBackgroundColor: '#444' }} }}
463
+ }};
464
+
465
+ const priceChart = LightweightCharts.createChart(document.getElementById('tv-price'), chartOpts);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
 
467
+ // --- POLR HIGH RES SETUP ---
468
+ const polrLines = [];
469
+ const polrCount = 200; // Increased to 200 to match backend
470
 
471
+ for(let i=0; i<polrCount; i++) {{
472
+ // Opacity decay: High opacity near index 0 (closest to price), fades out
473
+ const opacity = 0.8 * (1 - (i / polrCount));
474
+ const color = `rgba(213, 0, 249, ${{opacity.toFixed(3)}})`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
 
476
+ polrLines.push(
477
+ priceChart.addLineSeries({{
478
+ color: color,
479
+ lineWidth: 1,
480
+ crosshairMarkerVisible: false,
481
+ lastValueVisible: false,
482
+ priceLineVisible: false,
483
+ title: ''
484
+ }})
485
+ );
486
+ }}
487
+ // ---------------------------
488
+
489
+ const priceSeries = priceChart.addLineSeries({{ color: '#2979ff', lineWidth: 2, title: 'Price' }});
490
+ const predSeries = priceChart.addLineSeries({{ color: '#ffeb3b', lineWidth: 2, lineStyle: 2, title: 'Math Forecast' }});
491
+
492
+ const candleChart = LightweightCharts.createChart(document.getElementById('tv-candles'), {{
493
+ ...chartOpts,
494
+ timeScale: {{ timeVisible: true, secondsVisible: false }}
495
+ }});
496
+ const candleSeries = candleChart.addCandlestickSeries({{
497
+ upColor: '#00ff9d', downColor: '#ff3b3b', borderVisible: false, wickUpColor: '#00ff9d', wickDownColor: '#ff3b3b'
498
+ }});
499
+
500
+ const netChart = LightweightCharts.createChart(document.getElementById('tv-net'), {{
501
+ ...chartOpts, localization: {{ timeFormatter: t => '$' + t.toFixed(2) }}
502
+ }});
503
+ const netSeries = netChart.addHistogramSeries({{ color: '#2979ff' }});
504
+
505
+ const volChart = LightweightCharts.createChart(document.getElementById('sidebar-vol'), {{
506
+ ...chartOpts,
507
+ grid: {{ vertLines: {{ visible: false }}, horzLines: {{ visible: false }} }},
508
+ rightPriceScale: {{ visible: false }},
509
+ timeScale: {{ visible: false }},
510
+ handleScroll: false, handleScale: false
511
+ }});
512
+ const volBuySeries = volChart.addHistogramSeries({{ color: '#00ff9d' }});
513
+ const volSellSeries = volChart.addHistogramSeries({{ color: '#ff3b3b' }});
514
+
515
+ const denChart = LightweightCharts.createChart(document.getElementById('sidebar-density'), {{
516
+ ...chartOpts,
517
+ grid: {{ vertLines: {{ visible: false }}, horzLines: {{ visible: false }} }},
518
+ rightPriceScale: {{ visible: false }},
519
+ timeScale: {{ visible: false }},
520
+ handleScroll: false, handleScale: false
521
+ }});
522
+ const bidSeries = denChart.addAreaSeries({{ lineColor: '#00ff9d', topColor: 'rgba(0, 255, 157, 0.15)', bottomColor: 'rgba(0,0,0,0)', lineWidth: 1 }});
523
+ const askSeries = denChart.addAreaSeries({{ lineColor: '#ff3b3b', topColor: 'rgba(255, 59, 59, 0.15)', bottomColor: 'rgba(0,0,0,0)', lineWidth: 1 }});
524
+
525
+ let activeLines = [];
526
+ let activeCandleLines = [];
527
+
528
+ new ResizeObserver(entries => {{
529
+ for(let entry of entries) {{
530
+ const {{width, height}} = entry.contentRect;
531
+ if(entry.target.id === 'tv-price') priceChart.applyOptions({{width, height}});
532
+ if(entry.target.id === 'tv-candles') candleChart.applyOptions({{width, height}});
533
+ if(entry.target.id === 'tv-net') netChart.applyOptions({{width, height}});
534
+ if(entry.target.id === 'sidebar-vol') volChart.applyOptions({{width, height}});
535
+ if(entry.target.id === 'sidebar-density') denChart.applyOptions({{width, height}});
536
+ }}
537
+ }}).observe(document.body);
538
+
539
+ function connect() {{
540
+ const ws = new WebSocket((location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/ws');
541
+
542
+ ws.onmessage = (e) => {{
543
+ const data = JSON.parse(e.data);
544
+ if (data.error) return;
545
+
546
+ if (data.history.length) {{
547
+ const hist = data.history.map(d => ({{ time: Math.floor(d.t), value: d.p }}));
548
+ const cleanHist = [...new Map(hist.map(i => [i.time, i])).values()];
549
+ priceSeries.setData(cleanHist);
550
+
551
+ const lastP = cleanHist[cleanHist.length-1].value;
552
+ const lastTime = cleanHist[cleanHist.length-1].time;
553
+ dom.ticker.innerText = lastP.toLocaleString('en-US', {{ minimumFractionDigits: 2 }});
554
+
555
+ if (data.analysis) {{
556
+ const proj = data.analysis.projected;
557
+ const rho = data.analysis.rho;
558
+ predSeries.setData([
559
+ cleanHist[cleanHist.length-1],
560
+ {{ time: lastTime + 60, value: proj }}
561
+ ]);
562
+ const pct = ((proj - lastP) / lastP) * 100;
563
+ const sign = pct >= 0 ? "+" : "";
564
+ dom.projPct.innerText = `${{sign}}${{pct.toFixed(4)}}%`;
565
+ dom.projPct.style.color = pct >= 0 ? "var(--green)" : "var(--red)";
566
+ dom.projVal.innerText = proj.toLocaleString('en-US', {{ minimumFractionDigits: 2 }});
567
+ dom.score.innerText = rho.toFixed(3);
568
+ dom.score.style.color = rho > 0 ? "var(--green)" : (rho < 0 ? "var(--red)" : "var(--text-main)");
569
+ }}
570
+
571
+ if (data.polr && data.polr.length) {{
572
+ data.polr.forEach((point, index) => {{
573
+ if (index < polrLines.length) {{
574
+ polrLines[index].update({{
575
+ time: lastTime,
576
+ value: point.p
577
+ }});
578
+ }}
579
+ }});
580
+ }}
581
+ }}
582
+
583
+ if (data.ohlc && data.ohlc.length) {{
584
+ const candles = data.ohlc.map(c => ({{
585
+ time: c.time,
586
+ open: c.open,
587
+ high: c.high,
588
+ low: c.low,
589
+ close: c.close
590
+ }}));
591
+ const uniqueCandles = [...new Map(candles.map(i => [i.time, i])).values()];
592
+ candleSeries.setData(uniqueCandles);
593
+ }}
594
+
595
+ if (data.walls) {{
596
+ activeLines.forEach(l => priceSeries.removePriceLine(l));
597
+ activeLines = [];
598
+ activeCandleLines.forEach(l => candleSeries.removePriceLine(l));
599
+ activeCandleLines = [];
600
+
601
+ let html = "";
602
+ const addWall = (w, type) => {{
603
+ const color = type === 'BID' ? '#00ff9d' : '#ff3b3b';
604
+ const lineOpts = {{ price: w.price, color: color, lineWidth: 1, lineStyle: 2, axisLabelVisible: false }};
605
+
606
+ activeLines.push(priceSeries.createPriceLine(lineOpts));
607
+ activeCandleLines.push(candleSeries.createPriceLine(lineOpts));
608
+
609
+ html += `<div class="list-item"><span style="color:${{color}}">${{type}} ${{w.price}}</span><span class="c-dim">Z:${{w.z_score.toFixed(1)}}</span></div>`;
610
+ }};
611
+ data.walls.asks.forEach(w => addWall(w, 'ASK'));
612
+ data.walls.bids.forEach(w => addWall(w, 'BID'));
613
+ dom.wallList.innerHTML = html || '<span class="c-dim" style="font-size:11px">Scanning...</span>';
614
+ }}
615
+
616
+ if (data.trade_history && data.trade_history.length) {{
617
+ const buyData = [], sellData = [];
618
+ data.trade_history.forEach(t => {{
619
+ const time = Math.floor(t.t);
620
+ buyData.push({{ time: time, value: t.buy }});
621
+ sellData.push({{ time: time, value: t.sell }});
622
+ }});
623
+ volBuySeries.setData([...new Map(buyData.map(i => [i.time, i])).values()]);
624
+ volSellSeries.setData([...new Map(sellData.map(i => [i.time, i])).values()]);
625
+ }}
626
+
627
+ if (data.depth_x.length) {{
628
+ const bids = [], asks = [], nets = [];
629
+ for(let i=0; i<data.depth_x.length; i++) {{
630
+ const t = data.depth_x[i];
631
+ bids.push({{ time: t, value: data.depth_bids[i] }});
632
+ asks.push({{ time: t, value: data.depth_asks[i] }});
633
+ nets.push({{ time: t, value: data.depth_net[i], color: data.depth_net[i] > 0 ? '#00ff9d' : '#ff3b3b' }});
634
+ }}
635
+ bidSeries.setData(bids);
636
+ askSeries.setData(asks);
637
+ netSeries.setData(nets);
638
+ }}
639
+ }};
640
+ ws.onclose = () => setTimeout(connect, 2000);
641
+ }}
642
+ connect();
643
+ }});
644
  </script>
645
  </body>
646
  </html>
647
  """
648
 
649
+ async def kraken_worker():
650
+ global market_state
651
+ try:
652
+ async with aiohttp.ClientSession() as session:
653
+ url = "https://api.kraken.com/0/public/OHLC?pair=XBTUSD&interval=1"
654
+ async with session.get(url) as response:
655
+ if response.status == 200:
656
+ data = await response.json()
657
+ if 'result' in data:
658
+ for key in data['result']:
659
+ if key != 'last':
660
+ raw_candles = data['result'][key]
661
+ market_state['ohlc_history'] = [
662
+ {
663
+ 'time': int(c[0]),
664
+ 'open': float(c[1]),
665
+ 'high': float(c[2]),
666
+ 'low': float(c[3]),
667
+ 'close': float(c[4])
668
+ }
669
+ for c in raw_candles[-120:]
670
+ ]
671
+ break
672
+ except Exception as e:
673
+ logging.error(f"History fetch failed: {e}")
674
+
675
+ while True:
676
+ try:
677
+ async with websockets.connect("wss://ws.kraken.com/v2") as ws:
678
+ logging.info(f"🔌 Connected to Kraken ({SYMBOL_KRAKEN})")
679
+
680
+ await ws.send(json.dumps({
681
+ "method": "subscribe",
682
+ "params": {"channel": "book", "symbol": [SYMBOL_KRAKEN], "depth": 500}
683
+ }))
684
+ await ws.send(json.dumps({
685
+ "method": "subscribe",
686
+ "params": {"channel": "trade", "symbol": [SYMBOL_KRAKEN]}
687
+ }))
688
+ await ws.send(json.dumps({
689
+ "method": "subscribe",
690
+ "params": {"channel": "ohlc", "symbol": [SYMBOL_KRAKEN], "interval": 1}
691
+ }))
692
+
693
+ async for message in ws:
694
+ payload = json.loads(message)
695
+ channel = payload.get("channel")
696
+ data = payload.get("data", [])
697
+
698
+ if channel == "book":
699
+ for item in data:
700
+ for bid in item.get('bids', []):
701
+ q, p = float(bid['qty']), float(bid['price'])
702
+ if q == 0: market_state['bids'].pop(p, None)
703
+ else: market_state['bids'][p] = q
704
+ for ask in item.get('asks', []):
705
+ q, p = float(ask['qty']), float(ask['price'])
706
+ if q == 0: market_state['asks'].pop(p, None)
707
+ else: market_state['asks'][p] = q
708
+
709
+ if market_state['bids'] and market_state['asks']:
710
+ best_bid = max(market_state['bids'].keys())
711
+ best_ask = min(market_state['asks'].keys())
712
+ mid = (best_bid + best_ask) / 2
713
+ market_state['prev_mid'] = market_state['current_mid']
714
+ market_state['current_mid'] = mid
715
+ market_state['ready'] = True
716
+
717
+ now = time.time()
718
+ if not market_state['history'] or (now - market_state['history'][-1]['t'] > 0.5):
719
+ market_state['history'].append({'t': now, 'p': mid})
720
+ if len(market_state['history']) > HISTORY_LENGTH:
721
+ market_state['history'].pop(0)
722
+
723
+ elif channel == "trade":
724
+ for trade in data:
725
+ try:
726
+ qty = float(trade['qty'])
727
+ price = float(trade['price'])
728
+ side = trade['side']
729
+
730
+ if side == 'buy': market_state['current_vol_window']['buy'] += qty
731
+ else: market_state['current_vol_window']['sell'] += qty
732
+
733
+ current_minute_start = int(time.time()) // 60 * 60
734
+
735
+ if market_state['ohlc_history']:
736
+ last_candle = market_state['ohlc_history'][-1]
737
+
738
+ if last_candle['time'] == current_minute_start:
739
+ last_candle['close'] = price
740
+ if price > last_candle['high']: last_candle['high'] = price
741
+ if price < last_candle['low']: last_candle['low'] = price
742
+
743
+ elif current_minute_start > last_candle['time']:
744
+ new_candle = {
745
+ 'time': current_minute_start,
746
+ 'open': price,
747
+ 'high': price,
748
+ 'low': price,
749
+ 'close': price
750
+ }
751
+ market_state['ohlc_history'].append(new_candle)
752
+ if len(market_state['ohlc_history']) > 200:
753
+ market_state['ohlc_history'].pop(0)
754
+ except: pass
755
+
756
+ elif channel == "ohlc":
757
+ for candle in data:
758
+ try:
759
+ start_time = int(float(candle['endtime'])) - 60
760
+ c_data = {
761
+ 'time': start_time,
762
+ 'open': float(candle['open']),
763
+ 'high': float(candle['high']),
764
+ 'low': float(candle['low']),
765
+ 'close': float(candle['close'])
766
+ }
767
+
768
+ if market_state['ohlc_history']:
769
+ if market_state['ohlc_history'][-1]['time'] == start_time:
770
+ market_state['ohlc_history'][-1] = c_data
771
+ elif market_state['ohlc_history'][-1]['time'] < start_time:
772
+ market_state['ohlc_history'].append(c_data)
773
+ if len(market_state['ohlc_history']) > 200:
774
+ market_state['ohlc_history'].pop(0)
775
+ except Exception as e:
776
+ pass
777
 
778
+ except Exception as e:
779
+ logging.warning(f"⚠️ Reconnecting: {e}")
780
+ await asyncio.sleep(3)
781
+
782
+ async def broadcast_worker():
783
+ while True:
784
+ if connected_clients and market_state['ready']:
785
+ payload = process_market_data()
786
+ msg = json.dumps(payload)
787
+ for ws in list(connected_clients):
788
+ try: await ws.send_str(msg)
789
+ except: pass
790
+ await asyncio.sleep(BROADCAST_RATE)
791
+
792
+ async def websocket_handler(request):
793
  ws = web.WebSocketResponse()
794
  await ws.prepare(request)
795
+ connected_clients.add(ws)
 
796
  try:
797
+ async for msg in ws:
798
+ pass
799
  finally:
800
+ connected_clients.remove(ws)
801
  return ws
802
 
803
+ async def handle_index(request):
804
+ return web.Response(text=HTML_PAGE, content_type='text/html')
805
 
806
+ async def start_background(app):
807
+ app['kraken_task'] = asyncio.create_task(kraken_worker())
808
+ app['broadcast_task'] = asyncio.create_task(broadcast_worker())
 
809
 
810
+ async def cleanup_background(app):
811
+ app['kraken_task'].cancel()
812
  app['broadcast_task'].cancel()
813
+ try: await app['kraken_task']; await app['broadcast_task']
814
+ except: pass
815
 
816
+ async def main():
817
  app = web.Application()
818
+ app.router.add_get('/', handle_index)
819
+ app.router.add_get('/ws', websocket_handler)
820
+ app.on_startup.append(start_background)
821
+ app.on_cleanup.append(cleanup_background)
822
+ runner = web.AppRunner(app)
823
+ await runner.setup()
824
+ site = web.TCPSite(runner, '0.0.0.0', PORT)
825
+ await site.start()
826
+ print(f"🚀 Quant Dashboard: http://localhost:{PORT}")
827
+ await asyncio.Event().wait()
828
 
829
  if __name__ == "__main__":
830
+ try: asyncio.run(main())
831
+ except KeyboardInterrupt: pass