Alvin3y1 commited on
Commit
6bf2975
·
verified ·
1 Parent(s): 6618dcb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +502 -175
app.py CHANGED
@@ -5,157 +5,551 @@ import time
5
  import aiohttp
6
  from aiohttp import web
7
  import websockets
8
- import collections
9
 
10
- # --- CONFIGURATION ---
11
  SYMBOL_KRAKEN = "BTC/USD"
12
  PORT = 7860
13
- BROADCAST_RATE = 0.1 # Broadcast every 100ms
 
 
 
14
 
15
- # --- GLOBAL STATE ---
16
  market_state = {
17
- "prev_best_bid": None,
18
- "prev_best_ask": None,
19
- "prev_bid_qty": 0.0,
20
- "prev_ask_qty": 0.0,
21
-
22
- "cumulative_ofi": 0.0,
23
- "ofi_history": collections.deque(maxlen=300),
24
- "price_history": collections.deque(maxlen=300),
25
-
26
  "bids": {},
27
  "asks": {},
28
- "mid_price": 0.0,
 
 
 
 
 
 
 
29
  "ready": False
30
  }
31
 
32
  connected_clients = set()
33
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
34
 
35
- def calculate_ofi_step(best_bid, bid_qty, best_ask, ask_qty):
36
- state = market_state
37
- if state['prev_best_bid'] is None:
 
 
 
 
38
  return 0.0
39
 
40
- # 1. Bid Side (Buying Pressure)
41
- e_bid = 0.0
42
- if best_bid > state['prev_best_bid']:
43
- e_bid = bid_qty
44
- elif best_bid < state['prev_best_bid']:
45
- e_bid = -state['prev_bid_qty']
 
 
 
46
  else:
47
- e_bid = bid_qty - state['prev_bid_qty']
48
-
49
- # 2. Ask Side (Selling Pressure)
50
- e_ask = 0.0
51
- if best_ask > state['prev_best_ask']:
52
- e_ask = -state['prev_ask_qty']
53
- elif best_ask < state['prev_best_ask']:
54
- e_ask = ask_qty
 
 
 
 
 
 
 
55
  else:
56
- e_ask = ask_qty - state['prev_ask_qty']
 
 
57
 
58
- return e_bid - e_ask
 
59
 
60
  def process_market_data():
61
- if not market_state['bids'] or not market_state['asks']:
62
- return None
63
 
64
- # Get BBO
65
  sorted_bids = sorted(market_state['bids'].items(), key=lambda x: -x[0])
66
  sorted_asks = sorted(market_state['asks'].items(), key=lambda x: x[0])
67
-
 
 
68
  best_bid_p, best_bid_q = sorted_bids[0]
69
  best_ask_p, best_ask_q = sorted_asks[0]
70
- mid_price = (best_bid_p + best_ask_p) / 2
71
-
72
- # Calculate OFI
73
- instant_ofi = calculate_ofi_step(best_bid_p, best_bid_q, best_ask_p, best_ask_q)
74
- decay = 0.99
75
- market_state['cumulative_ofi'] = (market_state['cumulative_ofi'] * decay) + instant_ofi
76
 
77
- # Visualization scaling
78
- ofi_visual_value = mid_price + (market_state['cumulative_ofi'] * 0.5)
79
 
80
- # Update Previous State
81
- market_state['prev_best_bid'] = best_bid_p
82
- market_state['prev_best_ask'] = best_ask_p
83
- market_state['prev_bid_qty'] = best_bid_q
84
- market_state['prev_ask_qty'] = best_ask_q
 
 
 
 
85
 
86
- now = time.time()
87
- market_state['price_history'].append({'time': now, 'value': mid_price})
88
- market_state['ofi_history'].append({'time': now, 'value': ofi_visual_value})
 
 
 
 
 
 
 
 
89
 
90
- # Signal Logic
91
- signal = "NEUTRAL"
92
- strength = 0.0
93
- diff = ofi_visual_value - mid_price
94
- if abs(diff) > 1.0:
95
- strength = min(abs(diff) / 10.0, 1.0)
96
- signal = "BULLISH PRESSURE" if diff > 0 else "BEARISH DRAG"
 
 
 
97
 
98
  return {
99
- "price": list(market_state['price_history']),
100
- "ofi": list(market_state['ofi_history']),
101
- "current_price": mid_price,
102
- "signal": signal,
103
- "strength": strength * 100,
104
- "raw_ofi": market_state['cumulative_ofi']
 
 
 
 
105
  }
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  async def kraken_worker():
108
  global market_state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  while True:
110
  try:
111
  async with websockets.connect("wss://ws.kraken.com/v2") as ws:
112
- logging.info(f"Connected to Kraken {SYMBOL_KRAKEN}")
113
 
114
- # Subscribe
115
  await ws.send(json.dumps({
116
  "method": "subscribe",
117
  "params": {"channel": "book", "symbol": [SYMBOL_KRAKEN], "depth": 10}
118
  }))
 
 
 
 
 
 
 
 
119
 
120
  async for message in ws:
121
  payload = json.loads(message)
122
-
123
- # Handle Heartbeats/Status
124
- if payload.get("channel") == "status":
125
- continue
126
-
127
- # Handle Book Updates
128
- if payload.get("channel") == "book":
129
- # v2 data is usually a list of objects
130
- data_list = payload.get("data", [])
131
- for data in data_list:
132
- for bid in data.get('bids', []):
133
- market_state['bids'][float(bid['price'])] = float(bid['qty'])
134
- for ask in data.get('asks', []):
135
- market_state['asks'][float(ask['price'])] = float(ask['qty'])
136
 
137
- # Mark as ready once we have data
138
  if market_state['bids'] and market_state['asks']:
139
  market_state['ready'] = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  except Exception as e:
142
- logging.error(f"WS Error: {e}")
143
- await asyncio.sleep(2)
144
 
145
  async def broadcast_worker():
146
  while True:
147
- # 1. Always process data to build history (if market is ready)
148
- payload = None
149
- if market_state['ready']:
150
  payload = process_market_data()
151
-
152
- # 2. Only broadcast if we have clients AND data
153
- if connected_clients and payload:
154
  msg = json.dumps(payload)
155
  for ws in list(connected_clients):
156
  try: await ws.send_str(msg)
157
  except: pass
158
-
159
  await asyncio.sleep(BROADCAST_RATE)
160
 
161
  async def websocket_handler(request):
@@ -163,103 +557,36 @@ async def websocket_handler(request):
163
  await ws.prepare(request)
164
  connected_clients.add(ws)
165
  try:
166
- async for msg in ws: pass
 
167
  finally:
168
  connected_clients.remove(ws)
169
  return ws
170
 
171
- HTML_PAGE = f"""
172
- <!DOCTYPE html>
173
- <html>
174
- <head>
175
- <title>OFI Divergence Strategy</title>
176
- <script src="https://unpkg.com/lightweight-charts@4.1.1/dist/lightweight-charts.standalone.production.js"></script>
177
- <style>
178
- body {{ background: #000; color: #fff; font-family: 'Courier New', monospace; margin: 0; display: flex; flex-direction: column; height: 100vh; }}
179
- .header {{ padding: 15px; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; }}
180
- .metric-box {{ text-align: right; }}
181
- .big-val {{ font-size: 24px; font-weight: bold; }}
182
- .sub-val {{ font-size: 12px; color: #888; }}
183
- #chart-container {{ flex: 1; width: 100%; }}
184
- .green {{ color: #00ff9d; }}
185
- .red {{ color: #ff3b3b; }}
186
- </style>
187
- </head>
188
- <body>
189
- <div class="header">
190
- <div>
191
- <div style="font-size: 18px;">KRAKEN: {SYMBOL_KRAKEN}</div>
192
- <div style="color: #666;">STRATEGY: ORDER FLOW IMBALANCE (CKS)</div>
193
- </div>
194
- <div class="metric-box">
195
- <div id="signal-text" class="big-val">WAITING...</div>
196
- <div id="signal-str" class="sub-val">CONFIDENCE: 0%</div>
197
- </div>
198
- <div class="metric-box">
199
- <div id="price-text" class="big-val">---</div>
200
- <div class="sub-val">CURRENT PRICE</div>
201
- </div>
202
- </div>
203
- <div id="chart-container"></div>
204
-
205
- <script>
206
- const chart = LightweightCharts.createChart(document.getElementById('chart-container'), {{
207
- layout: {{ background: {{ color: '#000' }}, textColor: '#888' }},
208
- grid: {{ vertLines: {{ color: '#111' }}, horzLines: {{ color: '#111' }} }},
209
- timeScale: {{ timeVisible: true, secondsVisible: true }},
210
- rightPriceScale: {{ scaleMargins: {{ top: 0.1, bottom: 0.1 }} }},
211
- }});
212
-
213
- const priceSeries = chart.addLineSeries({{ color: '#2979ff', lineWidth: 2, title: 'Price' }});
214
- const ofiSeries = chart.addLineSeries({{ color: '#ffeb3b', lineWidth: 2, title: 'OFI Pressure', lineStyle: 2 }});
215
-
216
- const dom = {{
217
- price: document.getElementById('price-text'),
218
- signal: document.getElementById('signal-text'),
219
- strength: document.getElementById('signal-str')
220
- }};
221
-
222
- function connect() {{
223
- const ws = new WebSocket((location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/ws');
224
- ws.onmessage = (e) => {{
225
- const data = JSON.parse(e.data);
226
-
227
- if(data.price && data.price.length > 0) {{
228
- const latestPrice = data.price[data.price.length-1];
229
-
230
- priceSeries.setData(data.price);
231
- ofiSeries.setData(data.ofi);
232
-
233
- dom.price.innerText = latestPrice.value.toLocaleString('en-US', {{minimumFractionDigits: 2}});
234
-
235
- dom.signal.innerText = data.signal;
236
- dom.signal.className = data.signal.includes('BULL') ? 'big-val green' : (data.signal.includes('BEAR') ? 'big-val red' : 'big-val');
237
-
238
- dom.strength.innerText = `CONFIDENCE: ${{data.strength.toFixed(1)}}% (OFI: ${{data.raw_ofi.toFixed(2)}})`;
239
- }}
240
- }};
241
- ws.onclose = () => setTimeout(connect, 2000);
242
- }}
243
- connect();
244
- </script>
245
- </body>
246
- </html>
247
- """
248
-
249
  async def handle_index(request):
250
  return web.Response(text=HTML_PAGE, content_type='text/html')
251
 
 
 
 
 
 
 
 
 
 
 
252
  async def main():
253
  app = web.Application()
254
  app.router.add_get('/', handle_index)
255
  app.router.add_get('/ws', websocket_handler)
256
- app.on_startup.append(lambda a: asyncio.create_task(kraken_worker()))
257
- app.on_startup.append(lambda a: asyncio.create_task(broadcast_worker()))
258
  runner = web.AppRunner(app)
259
  await runner.setup()
260
  site = web.TCPSite(runner, '0.0.0.0', PORT)
261
  await site.start()
262
- print(f"Strategy Dashboard: http://localhost:{PORT}")
263
  await asyncio.Event().wait()
264
 
265
  if __name__ == "__main__":
 
5
  import aiohttp
6
  from aiohttp import web
7
  import websockets
 
8
 
 
9
  SYMBOL_KRAKEN = "BTC/USD"
10
  PORT = 7860
11
+ HISTORY_LENGTH = 300
12
+ BROADCAST_RATE = 0.1 # 100ms Updates (High Frequency)
13
+
14
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
15
 
 
16
  market_state = {
 
 
 
 
 
 
 
 
 
17
  "bids": {},
18
  "asks": {},
19
+ "history": [],
20
+ "ofi_history": [], # Stores the Cumulative OFI
21
+ "trade_vol_history": [],
22
+ "ohlc_history": [],
23
+ "current_vol_window": {"buy": 0.0, "sell": 0.0, "start": time.time()},
24
+ "current_mid": 0.0,
25
+ "prev_book": None, # To track t-1 state for CKS model
26
+ "cumulative_ofi": 0.0, # The running total of Order Flow Imbalance
27
  "ready": False
28
  }
29
 
30
  connected_clients = set()
 
31
 
32
+ def calculate_ofi(best_bid_p, best_bid_q, best_ask_p, best_ask_q):
33
+ """
34
+ Cont-Kukanov-Stoikov (CKS) Order Flow Imbalance Model.
35
+ e_n = I(b_n >= b_{n-1})*q_n^b - I(b_n <= b_{n-1})*q_{n-1}^b ...
36
+ """
37
+ prev = market_state['prev_book']
38
+ if not prev:
39
  return 0.0
40
 
41
+ ofi_delta = 0.0
42
+
43
+ # --- BID SIDE IMPACT (Buying Pressure) ---
44
+ if best_bid_p > prev['bp']:
45
+ # Price Improvement: Aggressive Buy (Add full current qty)
46
+ ofi_delta += best_bid_q
47
+ elif best_bid_p < prev['bp']:
48
+ # Price Drop: Support Pulled (Subtract previous qty)
49
+ ofi_delta -= prev['bq']
50
  else:
51
+ # Price Same: Net change in liquidity
52
+ # If Qty increases, Support added (+). If Qty decreases, Support pulled (-).
53
+ ofi_delta += (best_bid_q - prev['bq'])
54
+
55
+ # --- ASK SIDE IMPACT (Selling Pressure) ---
56
+ # Note: Ask logic is inverted. Higher Ask = Less Pressure (Bullish), Lower Ask = More Pressure (Bearish)
57
+ # We subtract Ask Impact from Total OFI.
58
+
59
+ ask_impact = 0.0
60
+ if best_ask_p < prev['ap']:
61
+ # Price Drop: Aggressive Sell (Add full current qty to sell pressure)
62
+ ask_impact += best_ask_q
63
+ elif best_ask_p > prev['ap']:
64
+ # Price Rise: Resistance Removed (Subtract previous qty from sell pressure)
65
+ ask_impact -= prev['aq']
66
  else:
67
+ # Price Same: Net change in liquidity
68
+ # If Qty increases, Resistance added (+). If Qty decreases, Resistance removed (-).
69
+ ask_impact += (best_ask_q - prev['aq'])
70
 
71
+ # Net OFI = Bid Impact - Ask Impact
72
+ return ofi_delta - ask_impact
73
 
74
  def process_market_data():
75
+ if not market_state['ready']: return {"error": "Initializing..."}
 
76
 
77
+ # 1. Sort Book to get Best Bid/Ask
78
  sorted_bids = sorted(market_state['bids'].items(), key=lambda x: -x[0])
79
  sorted_asks = sorted(market_state['asks'].items(), key=lambda x: x[0])
80
+
81
+ if not sorted_bids or not sorted_asks: return {"error": "Empty Book"}
82
+
83
  best_bid_p, best_bid_q = sorted_bids[0]
84
  best_ask_p, best_ask_q = sorted_asks[0]
85
+ mid = (best_bid_p + best_ask_p) / 2
86
+ market_state['current_mid'] = mid
 
 
 
 
87
 
88
+ now = time.time()
 
89
 
90
+ # 2. CKS OFI Calculation
91
+ ofi_step = calculate_ofi(best_bid_p, best_bid_q, best_ask_p, best_ask_q)
92
+ market_state['cumulative_ofi'] += ofi_step
93
+
94
+ # Store state for next tick (t-1)
95
+ market_state['prev_book'] = {
96
+ 'bp': best_bid_p, 'bq': best_bid_q,
97
+ 'ap': best_ask_p, 'aq': best_ask_q
98
+ }
99
 
100
+ # 3. History Management
101
+ if not market_state['history'] or (now - market_state['history'][-1]['t'] > 0.5):
102
+ # Price History
103
+ market_state['history'].append({'t': now, 'p': mid})
104
+ if len(market_state['history']) > HISTORY_LENGTH:
105
+ market_state['history'].pop(0)
106
+
107
+ # OFI History (Synced with price updates)
108
+ market_state['ofi_history'].append({'t': now, 'v': market_state['cumulative_ofi']})
109
+ if len(market_state['ofi_history']) > HISTORY_LENGTH:
110
+ market_state['ofi_history'].pop(0)
111
 
112
+ # 4. Volume Window Logic
113
+ if now - market_state['current_vol_window']['start'] >= 1.0:
114
+ market_state['trade_vol_history'].append({
115
+ 't': now,
116
+ 'buy': market_state['current_vol_window']['buy'],
117
+ 'sell': market_state['current_vol_window']['sell']
118
+ })
119
+ if len(market_state['trade_vol_history']) > 60:
120
+ market_state['trade_vol_history'].pop(0)
121
+ market_state['current_vol_window'] = {"buy": 0.0, "sell": 0.0, "start": now}
122
 
123
  return {
124
+ "mid": mid,
125
+ "history": market_state['history'],
126
+ "ofi": market_state['ofi_history'],
127
+ "trade_history": market_state['trade_vol_history'],
128
+ "ohlc": market_state['ohlc_history'],
129
+ "stats": {
130
+ "ofi_val": market_state['cumulative_ofi'],
131
+ "bid_depth": sum(q for p, q in sorted_bids[:10]),
132
+ "ask_depth": sum(q for p, q in sorted_asks[:10])
133
+ }
134
  }
135
 
136
+ HTML_PAGE = f"""
137
+ <!DOCTYPE html>
138
+ <html lang="en">
139
+ <head>
140
+ <meta charset="UTF-8">
141
+ <title>{SYMBOL_KRAKEN} | Institutional OFI</title>
142
+ <script src="https://unpkg.com/lightweight-charts@4.1.1/dist/lightweight-charts.standalone.production.js"></script>
143
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
144
+ <style>
145
+ :root {{
146
+ --bg-base: #000000;
147
+ --bg-panel: #0a0a0a;
148
+ --border: #252525;
149
+ --text-main: #FFFFFF;
150
+ --text-dim: #999999;
151
+ --green: #00ff9d;
152
+ --red: #ff3b3b;
153
+ --blue: #2979ff;
154
+ --yellow: #ffeb3b;
155
+ }}
156
+ body {{
157
+ margin: 0; padding: 0;
158
+ background-color: var(--bg-base);
159
+ color: var(--text-main);
160
+ font-family: 'Inter', sans-serif;
161
+ overflow: hidden;
162
+ height: 100vh; width: 100vw;
163
+ }}
164
+ .layout {{
165
+ display: grid;
166
+ grid-template-rows: 34px 1fr 1fr;
167
+ grid-template-columns: 3fr 1fr;
168
+ gap: 1px;
169
+ background-color: var(--border);
170
+ height: 100vh;
171
+ box-sizing: border-box;
172
+ }}
173
+ .panel {{ background: var(--bg-panel); display: flex; flex-direction: column; overflow: hidden; }}
174
+
175
+ .status-bar {{
176
+ grid-column: 1 / 3;
177
+ grid-row: 1 / 2;
178
+ background: var(--bg-panel);
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: space-between;
182
+ padding: 0 12px;
183
+ font-family: 'JetBrains Mono', monospace;
184
+ font-size: 12px;
185
+ text-transform: uppercase;
186
+ border-bottom: 1px solid var(--border);
187
+ z-index: 50;
188
+ }}
189
+ .status-left {{ display: flex; gap: 20px; align-items: center; }}
190
+ .live-dot {{ width: 8px; height: 8px; background-color: var(--green); border-radius: 50%; display: inline-block; box-shadow: 0 0 8px var(--green); }}
191
+ .ticker-val {{ font-weight: 700; color: #fff; font-size: 13px; }}
192
+
193
+ #p-chart {{ grid-column: 1 / 2; grid-row: 2 / 3; }}
194
+
195
+ #p-bottom {{
196
+ grid-column: 1 / 2; grid-row: 3 / 4;
197
+ display: flex;
198
+ flex-direction: column;
199
+ background: var(--bg-panel);
200
+ }}
201
+
202
+ #p-sidebar {{
203
+ grid-column: 2 / 3;
204
+ grid-row: 2 / 4;
205
+ padding: 15px;
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 15px;
209
+ border-left: 1px solid var(--border);
210
+ overflow: hidden;
211
+ }}
212
+
213
+ .chart-header {{
214
+ height: 24px;
215
+ min-height: 24px;
216
+ display: flex;
217
+ align-items: center;
218
+ padding-left: 12px;
219
+ font-size: 10px;
220
+ font-weight: 700;
221
+ color: var(--text-dim);
222
+ background: #050505;
223
+ border-bottom: 1px solid #151515;
224
+ letter-spacing: 0.5px;
225
+ }}
226
+
227
+ .data-group {{ display: flex; flex-direction: column; gap: 4px; }}
228
+ .label {{ font-size: 10px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
229
+ .value {{ font-family: 'JetBrains Mono', monospace; font-size: 20px; font-weight: 700; color: #fff; }}
230
+ .value-lg {{ font-size: 26px; }}
231
+
232
+ .divider {{ height: 1px; background: var(--border); width: 100%; }}
233
+ .c-green {{ color: var(--green); }}
234
+ .c-red {{ color: var(--red); }}
235
+ .c-yellow {{ color: var(--yellow); }}
236
+
237
+ .sidebar-chart-box {{
238
+ flex: 1;
239
+ display: flex;
240
+ flex-direction: column;
241
+ min-height: 0;
242
+ }}
243
+ .mini-chart {{
244
+ flex: 1;
245
+ background: rgba(255,255,255,0.02);
246
+ border: 1px solid var(--border);
247
+ border-radius: 4px;
248
+ }}
249
+ </style>
250
+ </head>
251
+ <body>
252
+ <div class="layout">
253
+ <div class="status-bar">
254
+ <div class="status-left">
255
+ <span class="live-dot"></span>
256
+ <span style="font-weight:700; color:#fff;">{SYMBOL_KRAKEN}</span>
257
+ <span id="price-ticker" class="ticker-val">---</span>
258
+ </div>
259
+ <div class="status-right" id="clock">00:00:00 UTC</div>
260
+ </div>
261
+
262
+ <div id="p-chart" class="panel">
263
+ <div class="chart-header">
264
+ PRICE (BLUE - RHS) vs <span class="c-yellow">CUMULATIVE OFI (YELLOW - LHS)</span>
265
+ </div>
266
+ <div id="tv-price" style="flex: 1; width: 100%;"></div>
267
+ </div>
268
+
269
+ <div id="p-bottom" class="panel">
270
+ <div class="chart-header">1M KLINE (KRAKEN OHLC)</div>
271
+ <div id="tv-candles" style="flex: 1; width: 100%;"></div>
272
+ </div>
273
+
274
+ <div id="p-sidebar" class="panel">
275
+
276
+ <div class="data-group">
277
+ <span class="label">Net OFI (Buying Pressure)</span>
278
+ <span id="ofi-val" class="value value-lg c-yellow">---</span>
279
+ </div>
280
+
281
+ <div class="divider"></div>
282
+
283
+ <div class="data-group">
284
+ <span class="label">Interpretation</span>
285
+ <div style="font-size: 11px; color: #888; line-height: 1.4;">
286
+ <span class="c-yellow">Yellow Line</span> tracks Order Flow Imbalance.
287
+ <br><br>
288
+ <b>Divergence:</b><br>
289
+ Yellow UP + Price Flat = <span class="c-green">BULLISH COIL</span><br>
290
+ Yellow DOWN + Price Flat = <span class="c-red">BEARISH DIST</span>
291
+ </div>
292
+ </div>
293
+
294
+ <div class="divider"></div>
295
+
296
+ <div class="sidebar-chart-box">
297
+ <span class="label" style="margin-bottom:4px;">Real-time Volume Ticks</span>
298
+ <div id="sidebar-vol" class="mini-chart"></div>
299
+ </div>
300
+ </div>
301
+ </div>
302
+
303
+ <script>
304
+ setInterval(() => {{
305
+ const now = new Date();
306
+ document.getElementById('clock').innerText = now.toISOString().split('T')[1].split('.')[0] + ' UTC';
307
+ }}, 1000);
308
+
309
+ document.addEventListener('DOMContentLoaded', () => {{
310
+ const dom = {{
311
+ ticker: document.getElementById('price-ticker'),
312
+ ofiVal: document.getElementById('ofi-val')
313
+ }};
314
+
315
+ // --- MASTER CHART (Dual Axis) ---
316
+ const priceChart = LightweightCharts.createChart(document.getElementById('tv-price'), {{
317
+ layout: {{ background: {{ type: 'solid', color: '#0a0a0a' }}, textColor: '#888', fontFamily: 'JetBrains Mono' }},
318
+ grid: {{ vertLines: {{ color: '#151515' }}, horzLines: {{ color: '#151515' }} }},
319
+ rightPriceScale: {{ visible: true, borderColor: '#2979ff' }}, // Price on Right
320
+ leftPriceScale: {{ visible: true, borderColor: '#ffeb3b' }}, // OFI on Left
321
+ timeScale: {{ borderColor: '#222', timeVisible: true, secondsVisible: true }},
322
+ }});
323
+
324
+ // Price Series (Blue, Right Axis)
325
+ const priceSeries = priceChart.addLineSeries({{
326
+ color: '#2979ff', lineWidth: 2, title: 'Price',
327
+ priceScaleId: 'right'
328
+ }});
329
+
330
+ // OFI Series (Yellow, Left Axis)
331
+ const ofiSeries = priceChart.addLineSeries({{
332
+ color: '#ffeb3b', lineWidth: 2, title: 'Cumulative OFI',
333
+ priceScaleId: 'left',
334
+ lastValueVisible: true
335
+ }});
336
+
337
+ // --- CANDLE CHART ---
338
+ const candleChart = LightweightCharts.createChart(document.getElementById('tv-candles'), {{
339
+ layout: {{ background: {{ type: 'solid', color: '#0a0a0a' }}, textColor: '#888', fontFamily: 'JetBrains Mono' }},
340
+ grid: {{ vertLines: {{ color: '#151515' }}, horzLines: {{ color: '#151515' }} }},
341
+ timeScale: {{ timeVisible: true, secondsVisible: false }},
342
+ }});
343
+ const candleSeries = candleChart.addCandlestickSeries({{
344
+ upColor: '#00ff9d', downColor: '#ff3b3b', borderVisible: false, wickUpColor: '#00ff9d', wickDownColor: '#ff3b3b'
345
+ }});
346
+
347
+ // --- VOLUME CHART ---
348
+ const volChart = LightweightCharts.createChart(document.getElementById('sidebar-vol'), {{
349
+ layout: {{ background: {{ type: 'solid', color: '#0a0a0a' }}, textColor: '#888' }},
350
+ grid: {{ visible: false }},
351
+ rightPriceScale: {{ visible: false }},
352
+ timeScale: {{ visible: false }},
353
+ handleScroll: false, handleScale: false
354
+ }});
355
+ const volBuySeries = volChart.addHistogramSeries({{ color: '#00ff9d' }});
356
+ const volSellSeries = volChart.addHistogramSeries({{ color: '#ff3b3b' }});
357
+
358
+ // Resize Logic
359
+ new ResizeObserver(entries => {{
360
+ for(let entry of entries) {{
361
+ const {{width, height}} = entry.contentRect;
362
+ if(entry.target.id === 'tv-price') priceChart.applyOptions({{width, height}});
363
+ if(entry.target.id === 'tv-candles') candleChart.applyOptions({{width, height}});
364
+ if(entry.target.id === 'sidebar-vol') volChart.applyOptions({{width, height}});
365
+ }}
366
+ }}).observe(document.body);
367
+
368
+ function connect() {{
369
+ const ws = new WebSocket((location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/ws');
370
+
371
+ ws.onmessage = (e) => {{
372
+ const data = JSON.parse(e.data);
373
+ if (data.error) return;
374
+
375
+ // 1. Update Price
376
+ if (data.history.length) {{
377
+ const hist = data.history.map(d => ({{ time: Math.floor(d.t), value: d.p }}));
378
+ const uniqueHist = [...new Map(hist.map(i => [i.time, i])).values()];
379
+ priceSeries.setData(uniqueHist);
380
+
381
+ const lastP = uniqueHist[uniqueHist.length-1].value;
382
+ dom.ticker.innerText = lastP.toLocaleString('en-US', {{ minimumFractionDigits: 2 }});
383
+ }}
384
+
385
+ // 2. Update OFI
386
+ if (data.ofi.length) {{
387
+ const ofiData = data.ofi.map(d => ({{ time: Math.floor(d.t), value: d.v }}));
388
+ const uniqueOfi = [...new Map(ofiData.map(i => [i.time, i])).values()];
389
+ ofiSeries.setData(uniqueOfi);
390
+
391
+ // Update Sidebar Value
392
+ const lastOfi = data.stats.ofi_val;
393
+ dom.ofiVal.innerText = lastOfi.toLocaleString('en-US', {{ minimumFractionDigits: 0 }});
394
+ dom.ofiVal.style.color = lastOfi >= 0 ? 'var(--yellow)' : 'var(--text-dim)';
395
+ }}
396
+
397
+ // 3. Update Candles
398
+ if (data.ohlc.length) {{
399
+ const candles = data.ohlc.map(c => ({{
400
+ time: c.time, open: c.open, high: c.high, low: c.low, close: c.close
401
+ }}));
402
+ const uniqueCandles = [...new Map(candles.map(i => [i.time, i])).values()];
403
+ candleSeries.setData(uniqueCandles);
404
+ }}
405
+
406
+ // 4. Update Volume
407
+ if (data.trade_history && data.trade_history.length) {{
408
+ const buyData = [], sellData = [];
409
+ data.trade_history.forEach(t => {{
410
+ const time = Math.floor(t.t);
411
+ buyData.push({{ time: time, value: t.buy }});
412
+ sellData.push({{ time: time, value: t.sell }});
413
+ }});
414
+ volBuySeries.setData([...new Map(buyData.map(i => [i.time, i])).values()]);
415
+ volSellSeries.setData([...new Map(sellData.map(i => [i.time, i])).values()]);
416
+ }}
417
+ }};
418
+ ws.onclose = () => setTimeout(connect, 2000);
419
+ }}
420
+ connect();
421
+ }});
422
+ </script>
423
+ </body>
424
+ </html>
425
+ """
426
+
427
  async def kraken_worker():
428
  global market_state
429
+
430
+ # 1. Fetch History
431
+ try:
432
+ async with aiohttp.ClientSession() as session:
433
+ url = "https://api.kraken.com/0/public/OHLC?pair=XBTUSD&interval=1"
434
+ async with session.get(url) as response:
435
+ if response.status == 200:
436
+ data = await response.json()
437
+ if 'result' in data:
438
+ for key in data['result']:
439
+ if key != 'last':
440
+ raw_candles = data['result'][key]
441
+ market_state['ohlc_history'] = [
442
+ {
443
+ 'time': int(c[0]),
444
+ 'open': float(c[1]),
445
+ 'high': float(c[2]),
446
+ 'low': float(c[3]),
447
+ 'close': float(c[4])
448
+ }
449
+ for c in raw_candles[-120:]
450
+ ]
451
+ break
452
+ except Exception as e:
453
+ logging.error(f"History fetch failed: {e}")
454
+
455
+ # 2. Real-time Connection
456
  while True:
457
  try:
458
  async with websockets.connect("wss://ws.kraken.com/v2") as ws:
459
+ logging.info(f"🔌 Connected to Kraken ({SYMBOL_KRAKEN})")
460
 
461
+ # Subscribe to Book (Level 1 is actually enough for CKS, but we take more for vol check)
462
  await ws.send(json.dumps({
463
  "method": "subscribe",
464
  "params": {"channel": "book", "symbol": [SYMBOL_KRAKEN], "depth": 10}
465
  }))
466
+ await ws.send(json.dumps({
467
+ "method": "subscribe",
468
+ "params": {"channel": "trade", "symbol": [SYMBOL_KRAKEN]}
469
+ }))
470
+ await ws.send(json.dumps({
471
+ "method": "subscribe",
472
+ "params": {"channel": "ohlc", "symbol": [SYMBOL_KRAKEN], "interval": 1}
473
+ }))
474
 
475
  async for message in ws:
476
  payload = json.loads(message)
477
+ channel = payload.get("channel")
478
+ data = payload.get("data", [])
479
+
480
+ if channel == "book":
481
+ # Standard Book Maintenance
482
+ for item in data:
483
+ for bid in item.get('bids', []):
484
+ q, p = float(bid['qty']), float(bid['price'])
485
+ if q == 0: market_state['bids'].pop(p, None)
486
+ else: market_state['bids'][p] = q
487
+ for ask in item.get('asks', []):
488
+ q, p = float(ask['qty']), float(ask['price'])
489
+ if q == 0: market_state['asks'].pop(p, None)
490
+ else: market_state['asks'][p] = q
491
 
 
492
  if market_state['bids'] and market_state['asks']:
493
  market_state['ready'] = True
494
+
495
+ elif channel == "trade":
496
+ for trade in data:
497
+ try:
498
+ qty = float(trade['qty'])
499
+ price = float(trade['price'])
500
+ side = trade['side']
501
+
502
+ # Vol Accumulation
503
+ if side == 'buy': market_state['current_vol_window']['buy'] += qty
504
+ else: market_state['current_vol_window']['sell'] += qty
505
+
506
+ # Live Candle Logic
507
+ current_minute_start = int(time.time()) // 60 * 60
508
+ if market_state['ohlc_history']:
509
+ last_candle = market_state['ohlc_history'][-1]
510
+ if last_candle['time'] == current_minute_start:
511
+ last_candle['close'] = price
512
+ if price > last_candle['high']: last_candle['high'] = price
513
+ if price < last_candle['low']: last_candle['low'] = price
514
+ elif current_minute_start > last_candle['time']:
515
+ new_candle = {'time': current_minute_start, 'open': price, 'high': price, 'low': price, 'close': price}
516
+ market_state['ohlc_history'].append(new_candle)
517
+ if len(market_state['ohlc_history']) > 200:
518
+ market_state['ohlc_history'].pop(0)
519
+ except: pass
520
+
521
+ elif channel == "ohlc":
522
+ for candle in data:
523
+ try:
524
+ start_time = int(float(candle['endtime'])) - 60
525
+ c_data = {
526
+ 'time': start_time,
527
+ 'open': float(candle['open']),
528
+ 'high': float(candle['high']),
529
+ 'low': float(candle['low']),
530
+ 'close': float(candle['close'])
531
+ }
532
+ if market_state['ohlc_history']:
533
+ if market_state['ohlc_history'][-1]['time'] == start_time:
534
+ market_state['ohlc_history'][-1] = c_data
535
+ elif market_state['ohlc_history'][-1]['time'] < start_time:
536
+ market_state['ohlc_history'].append(c_data)
537
+ if len(market_state['ohlc_history']) > 200:
538
+ market_state['ohlc_history'].pop(0)
539
+ except: pass
540
 
541
  except Exception as e:
542
+ logging.warning(f"⚠️ Reconnecting: {e}")
543
+ await asyncio.sleep(3)
544
 
545
  async def broadcast_worker():
546
  while True:
547
+ if connected_clients and market_state['ready']:
 
 
548
  payload = process_market_data()
 
 
 
549
  msg = json.dumps(payload)
550
  for ws in list(connected_clients):
551
  try: await ws.send_str(msg)
552
  except: pass
 
553
  await asyncio.sleep(BROADCAST_RATE)
554
 
555
  async def websocket_handler(request):
 
557
  await ws.prepare(request)
558
  connected_clients.add(ws)
559
  try:
560
+ async for msg in ws:
561
+ pass
562
  finally:
563
  connected_clients.remove(ws)
564
  return ws
565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  async def handle_index(request):
567
  return web.Response(text=HTML_PAGE, content_type='text/html')
568
 
569
+ async def start_background(app):
570
+ app['kraken_task'] = asyncio.create_task(kraken_worker())
571
+ app['broadcast_task'] = asyncio.create_task(broadcast_worker())
572
+
573
+ async def cleanup_background(app):
574
+ app['kraken_task'].cancel()
575
+ app['broadcast_task'].cancel()
576
+ try: await app['kraken_task']; await app['broadcast_task']
577
+ except: pass
578
+
579
  async def main():
580
  app = web.Application()
581
  app.router.add_get('/', handle_index)
582
  app.router.add_get('/ws', websocket_handler)
583
+ app.on_startup.append(start_background)
584
+ app.on_cleanup.append(cleanup_background)
585
  runner = web.AppRunner(app)
586
  await runner.setup()
587
  site = web.TCPSite(runner, '0.0.0.0', PORT)
588
  await site.start()
589
+ print(f"🚀 Quant OFI Dashboard: http://localhost:{PORT}")
590
  await asyncio.Event().wait()
591
 
592
  if __name__ == "__main__":