|
|
import asyncio |
|
|
import json |
|
|
import logging |
|
|
import time |
|
|
import bisect |
|
|
import math |
|
|
import statistics |
|
|
from datetime import datetime |
|
|
from aiohttp import web |
|
|
import websockets |
|
|
|
|
|
|
|
|
SYMBOL_KRAKEN = "BTC/USD" |
|
|
PORT = 7860 |
|
|
HISTORY_LENGTH = 300 |
|
|
BROADCAST_RATE = 0.1 |
|
|
|
|
|
|
|
|
DECAY_LAMBDA = 50.0 |
|
|
IMPACT_SENSITIVITY = 2.0 |
|
|
WALL_DAMPENING = 0.8 |
|
|
Z_SCORE_THRESHOLD = 3.0 |
|
|
WALL_LOOKBACK = 200 |
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s') |
|
|
|
|
|
|
|
|
market_state = { |
|
|
"bids": {}, |
|
|
"asks": {}, |
|
|
"history": [], |
|
|
"pred_history": [], |
|
|
"trade_vol_history": [], |
|
|
"current_vol_window": {"buy": 0.0, "sell": 0.0, "start": time.time()}, |
|
|
"current_mid": 0.0, |
|
|
"ready": False |
|
|
} |
|
|
|
|
|
connected_clients = set() |
|
|
|
|
|
|
|
|
|
|
|
def detect_anomalies(orders, scan_depth): |
|
|
if len(orders) < 10: return [] |
|
|
relevant_orders = orders[:scan_depth] |
|
|
volumes = [q for p, q in relevant_orders] |
|
|
if not volumes: return [] |
|
|
|
|
|
try: |
|
|
avg_vol = statistics.mean(volumes) |
|
|
stdev_vol = statistics.stdev(volumes) |
|
|
except statistics.StatisticsError: |
|
|
return [] |
|
|
|
|
|
if stdev_vol == 0: return [] |
|
|
|
|
|
walls = [] |
|
|
for price, qty in relevant_orders: |
|
|
z_score = (qty - avg_vol) / stdev_vol |
|
|
if z_score > Z_SCORE_THRESHOLD: |
|
|
walls.append({"price": price, "vol": qty, "z_score": z_score}) |
|
|
|
|
|
walls.sort(key=lambda x: x['z_score'], reverse=True) |
|
|
return walls[:3] |
|
|
|
|
|
def calculate_micro_price_structure(diff_x, diff_y_net, current_mid, best_bid, best_ask, walls): |
|
|
if not diff_x or len(diff_x) < 5: return None |
|
|
|
|
|
|
|
|
weighted_imbalance = 0.0 |
|
|
total_weight = 0.0 |
|
|
|
|
|
for i in range(len(diff_x)): |
|
|
dist = diff_x[i] |
|
|
net_vol = diff_y_net[i] |
|
|
weight = math.exp(-dist / DECAY_LAMBDA) |
|
|
weighted_imbalance += net_vol * weight |
|
|
total_weight += weight |
|
|
|
|
|
rho = weighted_imbalance / total_weight if total_weight > 0 else 0 |
|
|
|
|
|
|
|
|
spread = best_ask - best_bid |
|
|
theoretical_delta = (spread / 2) * rho * IMPACT_SENSITIVITY |
|
|
projected_price = current_mid + theoretical_delta |
|
|
|
|
|
|
|
|
final_delta = theoretical_delta |
|
|
if final_delta > 0 and walls['asks']: |
|
|
nearest_wall = walls['asks'][0] |
|
|
if projected_price >= nearest_wall['price']: |
|
|
damp_factor = 1.0 / (1.0 + (nearest_wall['z_score'] * 0.2)) |
|
|
final_delta *= damp_factor |
|
|
elif final_delta < 0 and walls['bids']: |
|
|
nearest_wall = walls['bids'][0] |
|
|
if projected_price <= nearest_wall['price']: |
|
|
damp_factor = 1.0 / (1.0 + (nearest_wall['z_score'] * 0.2)) |
|
|
final_delta *= damp_factor |
|
|
|
|
|
return { |
|
|
"projected": current_mid + final_delta, |
|
|
"rho": rho |
|
|
} |
|
|
|
|
|
def process_market_data(): |
|
|
if not market_state['ready']: return {"error": "Initializing..."} |
|
|
|
|
|
mid = market_state['current_mid'] |
|
|
|
|
|
|
|
|
now = time.time() |
|
|
if now - market_state['current_vol_window']['start'] >= 1.0: |
|
|
market_state['trade_vol_history'].append({ |
|
|
't': now, |
|
|
'buy': market_state['current_vol_window']['buy'], |
|
|
'sell': market_state['current_vol_window']['sell'] |
|
|
}) |
|
|
if len(market_state['trade_vol_history']) > 60: |
|
|
market_state['trade_vol_history'].pop(0) |
|
|
|
|
|
|
|
|
market_state['current_vol_window'] = {"buy": 0.0, "sell": 0.0, "start": now} |
|
|
|
|
|
|
|
|
sorted_bids = sorted(market_state['bids'].items(), key=lambda x: -x[0]) |
|
|
sorted_asks = sorted(market_state['asks'].items(), key=lambda x: x[0]) |
|
|
|
|
|
if not sorted_bids or not sorted_asks: return {"error": "Empty Book"} |
|
|
|
|
|
best_bid = sorted_bids[0][0] |
|
|
best_ask = sorted_asks[0][0] |
|
|
|
|
|
bid_walls = detect_anomalies(sorted_bids, WALL_LOOKBACK) |
|
|
ask_walls = detect_anomalies(sorted_asks, WALL_LOOKBACK) |
|
|
|
|
|
d_b_x, d_b_y, cum = [], [], 0 |
|
|
for p, q in sorted_bids[:300]: |
|
|
d = mid - p |
|
|
if d >= 0: |
|
|
cum += q |
|
|
d_b_x.append(d); d_b_y.append(cum) |
|
|
|
|
|
d_a_x, d_a_y, cum = [], [], 0 |
|
|
for p, q in sorted_asks[:300]: |
|
|
d = p - mid |
|
|
if d >= 0: |
|
|
cum += q |
|
|
d_a_x.append(d); d_a_y.append(cum) |
|
|
|
|
|
diff_x, diff_y_net = [], [] |
|
|
chart_bids, chart_asks = [], [] |
|
|
|
|
|
if d_b_x and d_a_x: |
|
|
max_dist = min(d_b_x[-1], d_a_x[-1]) |
|
|
step_size = max_dist / 100 |
|
|
steps = [i * step_size for i in range(1, 101)] |
|
|
|
|
|
for s in steps: |
|
|
idx_b = bisect.bisect_right(d_b_x, s) |
|
|
vol_b = d_b_y[idx_b-1] if idx_b > 0 else 0 |
|
|
idx_a = bisect.bisect_right(d_a_x, s) |
|
|
vol_a = d_a_y[idx_a-1] if idx_a > 0 else 0 |
|
|
|
|
|
diff_x.append(s) |
|
|
diff_y_net.append(vol_b - vol_a) |
|
|
chart_bids.append(vol_b) |
|
|
chart_asks.append(vol_a) |
|
|
|
|
|
analysis = calculate_micro_price_structure( |
|
|
diff_x, diff_y_net, mid, best_bid, best_ask, |
|
|
{"bids": bid_walls, "asks": ask_walls} |
|
|
) |
|
|
|
|
|
if analysis: |
|
|
if not market_state['pred_history'] or (now - market_state['pred_history'][-1]['t'] > 0.5): |
|
|
market_state['pred_history'].append({'t': now, 'p': analysis['projected']}) |
|
|
if len(market_state['pred_history']) > HISTORY_LENGTH: |
|
|
market_state['pred_history'].pop(0) |
|
|
|
|
|
return { |
|
|
"mid": mid, |
|
|
"history": market_state['history'], |
|
|
"pred_history": market_state['pred_history'], |
|
|
"trade_history": market_state['trade_vol_history'], |
|
|
"depth_x": diff_x, |
|
|
"depth_net": diff_y_net, |
|
|
"depth_bids": chart_bids, |
|
|
"depth_asks": chart_asks, |
|
|
"analysis": analysis, |
|
|
"walls": {"bids": bid_walls, "asks": ask_walls} |
|
|
} |
|
|
|
|
|
|
|
|
HTML_PAGE = f""" |
|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<title>{SYMBOL_KRAKEN}</title> |
|
|
<script src="https://unpkg.com/lightweight-charts@4.1.1/dist/lightweight-charts.standalone.production.js"></script> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
:root {{ |
|
|
--bg-base: #000000; |
|
|
--bg-panel: #0a0a0a; |
|
|
--border: #252525; |
|
|
--text-main: #FFFFFF; |
|
|
--text-dim: #999999; |
|
|
--green: #00ff9d; |
|
|
--red: #ff3b3b; |
|
|
--blue: #2979ff; |
|
|
--yellow: #ffeb3b; |
|
|
}} |
|
|
body {{ |
|
|
margin: 0; padding: 0; |
|
|
background-color: var(--bg-base); |
|
|
color: var(--text-main); |
|
|
font-family: 'Inter', sans-serif; |
|
|
overflow: hidden; |
|
|
height: 100vh; width: 100vw; |
|
|
}} |
|
|
|
|
|
.layout {{ |
|
|
display: grid; |
|
|
grid-template-rows: 34px 1fr 1fr; |
|
|
grid-template-columns: 3fr 1fr; |
|
|
gap: 1px; |
|
|
background-color: var(--border); |
|
|
height: 100vh; |
|
|
box-sizing: border-box; |
|
|
}} |
|
|
|
|
|
.panel {{ background: var(--bg-panel); display: flex; flex-direction: column; overflow: hidden; }} |
|
|
|
|
|
.status-bar {{ |
|
|
grid-column: 1 / 3; |
|
|
grid-row: 1 / 2; |
|
|
background: var(--bg-panel); |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: space-between; |
|
|
padding: 0 12px; |
|
|
font-family: 'JetBrains Mono', monospace; |
|
|
font-size: 12px; |
|
|
text-transform: uppercase; |
|
|
border-bottom: 1px solid var(--border); |
|
|
z-index: 50; |
|
|
}} |
|
|
.status-left {{ display: flex; gap: 20px; align-items: center; }} |
|
|
.status-right {{ color: var(--text-dim); letter-spacing: 1px; }} |
|
|
.live-dot {{ width: 8px; height: 8px; background-color: var(--green); border-radius: 50%; display: inline-block; box-shadow: 0 0 8px var(--green); }} |
|
|
.ticker-val {{ font-weight: 700; color: #fff; font-size: 13px; }} |
|
|
|
|
|
#p-chart {{ grid-column: 1 / 2; grid-row: 2 / 3; }} |
|
|
|
|
|
#p-depth {{ |
|
|
grid-column: 1 / 2; grid-row: 3 / 4; |
|
|
display: grid; |
|
|
grid-template-columns: 1fr 1fr; |
|
|
gap: 1px; |
|
|
background: var(--border); |
|
|
}} |
|
|
.depth-sub {{ background: var(--bg-panel); display: flex; flex-direction: column; position: relative; }} |
|
|
|
|
|
#p-sidebar {{ |
|
|
grid-column: 2 / 3; |
|
|
grid-row: 2 / 4; |
|
|
padding: 20px; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 15px; /* Tighter gap to fit the new chart */ |
|
|
border-left: 1px solid var(--border); |
|
|
overflow-y: hidden; |
|
|
}} |
|
|
|
|
|
.chart-header {{ |
|
|
height: 24px; |
|
|
min-height: 24px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
padding-left: 12px; |
|
|
font-size: 10px; |
|
|
font-weight: 700; |
|
|
color: var(--text-dim); |
|
|
background: #050505; |
|
|
border-bottom: 1px solid #151515; |
|
|
letter-spacing: 0.5px; |
|
|
}} |
|
|
|
|
|
.data-group {{ display: flex; flex-direction: column; gap: 4px; }} |
|
|
.label {{ font-size: 10px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }} |
|
|
.value {{ font-family: 'JetBrains Mono', monospace; font-size: 20px; font-weight: 700; color: #fff; }} |
|
|
.value-lg {{ font-size: 26px; }} |
|
|
.value-sub {{ font-family: 'JetBrains Mono', monospace; font-size: 11px; margin-top: 2px; color: #666; }} |
|
|
|
|
|
.divider {{ height: 1px; background: var(--border); width: 100%; }} |
|
|
.c-green {{ color: var(--green); }} |
|
|
.c-red {{ color: var(--red); }} |
|
|
.c-dim {{ color: var(--text-dim); }} |
|
|
|
|
|
.list-container {{ display: flex; flex-direction: column; gap: 8px; overflow-y: auto; max-height: 120px; }} |
|
|
.list-item {{ |
|
|
display: flex; justify-content: space-between; |
|
|
font-family: 'JetBrains Mono', monospace; |
|
|
font-size: 11px; |
|
|
border-bottom: 1px solid #151515; |
|
|
padding-bottom: 4px; |
|
|
}} |
|
|
.list-item span:first-child {{ color: #e0e0e0; }} |
|
|
.list-item:last-child {{ border: none; }} |
|
|
|
|
|
#sidebar-chart {{ |
|
|
flex: 1; |
|
|
background: rgba(255,255,255,0.02); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 4px; |
|
|
min-height: 100px; |
|
|
}} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
|
|
|
<div class="layout"> |
|
|
<div class="status-bar"> |
|
|
<div class="status-left"> |
|
|
<span class="live-dot"></span> |
|
|
<span style="font-weight:700; color:#fff;">{SYMBOL_KRAKEN}</span> |
|
|
<span id="price-ticker" class="ticker-val">---</span> |
|
|
</div> |
|
|
<div class="status-right" id="clock">00:00:00 UTC</div> |
|
|
</div> |
|
|
|
|
|
<div id="p-chart" class="panel"> |
|
|
<div class="chart-header">PRICE ACTION (BLUE) // PREDICTION (YELLOW)</div> |
|
|
<div id="tv-price" style="flex: 1; width: 100%;"></div> |
|
|
</div> |
|
|
|
|
|
<div id="p-depth"> |
|
|
<div class="depth-sub"> |
|
|
<div class="chart-header">LIQUIDITY DENSITY</div> |
|
|
<div id="tv-raw" style="flex: 1; width: 100%;"></div> |
|
|
</div> |
|
|
<div class="depth-sub"> |
|
|
<div class="chart-header">ORDER FLOW IMBALANCE</div> |
|
|
<div id="tv-net" style="flex: 1; width: 100%;"></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="p-sidebar" class="panel"> |
|
|
|
|
|
<div class="data-group"> |
|
|
<span class="label">Micro-Price Delta</span> |
|
|
<div style="display:flex; align-items: baseline; gap: 10px;"> |
|
|
<span id="proj-pct" class="value value-lg">--%</span> |
|
|
<span id="proj-val" class="value-sub">---</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="divider"></div> |
|
|
|
|
|
<div class="data-group"> |
|
|
<span class="label">OFI Imbalance Ratio</span> |
|
|
<span id="score-val" class="value">0.00</span> |
|
|
</div> |
|
|
|
|
|
<div class="divider"></div> |
|
|
|
|
|
<div class="data-group"> |
|
|
<span class="label">Detected Walls (Z > 3.0)</span> |
|
|
<div id="wall-list" class="list-container"> |
|
|
<span class="c-dim" style="font-size: 11px;">Scanning...</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- NEW CHART UNDER WALLS --> |
|
|
<div class="data-group" style="flex: 1; display:flex; flex-direction:column;"> |
|
|
<span class="label">Real-time Volume (Ticks)</span> |
|
|
<div id="sidebar-chart"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
setInterval(() => {{ |
|
|
const now = new Date(); |
|
|
document.getElementById('clock').innerText = now.toISOString().split('T')[1].split('.')[0] + ' UTC'; |
|
|
}}, 1000); |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {{ |
|
|
const dom = {{ |
|
|
ticker: document.getElementById('price-ticker'), |
|
|
score: document.getElementById('score-val'), |
|
|
projVal: document.getElementById('proj-val'), |
|
|
projPct: document.getElementById('proj-pct'), |
|
|
wallList: document.getElementById('wall-list') |
|
|
}}; |
|
|
|
|
|
const chartOpts = {{ |
|
|
layout: {{ background: {{ type: 'solid', color: '#0a0a0a' }}, textColor: '#888', fontFamily: 'JetBrains Mono' }}, |
|
|
grid: {{ vertLines: {{ color: '#151515' }}, horzLines: {{ color: '#151515' }} }}, |
|
|
rightPriceScale: {{ borderColor: '#222', scaleMargins: {{ top: 0.1, bottom: 0.1 }} }}, |
|
|
timeScale: {{ borderColor: '#222', timeVisible: true, secondsVisible: true }}, |
|
|
crosshair: {{ mode: 1, vertLine: {{ color: '#444', labelBackgroundColor: '#444' }}, horzLine: {{ color: '#444', labelBackgroundColor: '#444' }} }} |
|
|
}}; |
|
|
|
|
|
// 1. MAIN PRICE CHART |
|
|
const priceChart = LightweightCharts.createChart(document.getElementById('tv-price'), chartOpts); |
|
|
const priceSeries = priceChart.addLineSeries({{ color: '#2979ff', lineWidth: 2, title: 'Price' }}); // BLUE |
|
|
const predSeries = priceChart.addLineSeries({{ color: '#ffeb3b', lineWidth: 2, lineStyle: 2, title: 'Forecast' }}); // YELLOW |
|
|
|
|
|
// 2. DEPTH CHARTS |
|
|
const rawChart = LightweightCharts.createChart(document.getElementById('tv-raw'), {{ |
|
|
...chartOpts, localization: {{ timeFormatter: t => '$' + t.toFixed(2) }} |
|
|
}}); |
|
|
const bidSeries = rawChart.addAreaSeries({{ lineColor: '#00ff9d', topColor: 'rgba(0, 255, 157, 0.15)', bottomColor: 'rgba(0,0,0,0)', lineWidth: 1 }}); |
|
|
const askSeries = rawChart.addAreaSeries({{ lineColor: '#ff3b3b', topColor: 'rgba(255, 59, 59, 0.15)', bottomColor: 'rgba(0,0,0,0)', lineWidth: 1 }}); |
|
|
|
|
|
const netChart = LightweightCharts.createChart(document.getElementById('tv-net'), {{ |
|
|
...chartOpts, localization: {{ timeFormatter: t => '$' + t.toFixed(2) }} |
|
|
}}); |
|
|
const netSeries = netChart.addHistogramSeries({{ color: '#2979ff' }}); |
|
|
|
|
|
// 3. SIDEBAR VOLUME CHART |
|
|
const volChart = LightweightCharts.createChart(document.getElementById('sidebar-chart'), {{ |
|
|
...chartOpts, |
|
|
grid: {{ vertLines: {{ visible: false }}, horzLines: {{ visible: false }} }}, |
|
|
rightPriceScale: {{ visible: false }}, |
|
|
timeScale: {{ visible: false }}, |
|
|
handleScroll: false, |
|
|
handleScale: false |
|
|
}}); |
|
|
const volBuySeries = volChart.addHistogramSeries({{ color: '#00ff9d' }}); |
|
|
const volSellSeries = volChart.addHistogramSeries({{ color: '#ff3b3b' }}); |
|
|
|
|
|
let activeLines = []; |
|
|
|
|
|
// RESIZE OBSERVER |
|
|
new ResizeObserver(entries => {{ |
|
|
for(let entry of entries) {{ |
|
|
const {{width, height}} = entry.contentRect; |
|
|
if(entry.target.id === 'tv-price') priceChart.applyOptions({{width, height}}); |
|
|
if(entry.target.id === 'tv-raw') rawChart.applyOptions({{width, height}}); |
|
|
if(entry.target.id === 'tv-net') netChart.applyOptions({{width, height}}); |
|
|
if(entry.target.id === 'sidebar-chart') volChart.applyOptions({{width, height}}); |
|
|
}} |
|
|
}}).observe(document.body); |
|
|
|
|
|
['tv-price', 'tv-raw', 'tv-net', 'sidebar-chart'].forEach(id => {{ |
|
|
new ResizeObserver(e => {{ |
|
|
const t = document.getElementById(id); |
|
|
if(id === 'tv-price') priceChart.applyOptions({{ width: t.clientWidth, height: t.clientHeight }}); |
|
|
if(id === 'tv-raw') rawChart.applyOptions({{ width: t.clientWidth, height: t.clientHeight }}); |
|
|
if(id === 'tv-net') netChart.applyOptions({{ width: t.clientWidth, height: t.clientHeight }}); |
|
|
if(id === 'sidebar-chart') volChart.applyOptions({{ width: t.clientWidth, height: t.clientHeight }}); |
|
|
}}).observe(document.getElementById(id)); |
|
|
}}); |
|
|
|
|
|
function connect() {{ |
|
|
const ws = new WebSocket((location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/ws'); |
|
|
|
|
|
ws.onmessage = (e) => {{ |
|
|
const data = JSON.parse(e.data); |
|
|
if (data.error) return; |
|
|
|
|
|
if (data.history.length) {{ |
|
|
const hist = data.history.map(d => ({{ time: Math.floor(d.t), value: d.p }})); |
|
|
const cleanHist = [...new Map(hist.map(i => [i.time, i])).values()]; |
|
|
priceSeries.setData(cleanHist); |
|
|
|
|
|
const lastP = cleanHist[cleanHist.length-1].value; |
|
|
dom.ticker.innerText = lastP.toLocaleString('en-US', {{ minimumFractionDigits: 2 }}); |
|
|
|
|
|
if (data.analysis) {{ |
|
|
const proj = data.analysis.projected; |
|
|
const rho = data.analysis.rho; |
|
|
|
|
|
predSeries.setData([ |
|
|
cleanHist[cleanHist.length-1], |
|
|
{{ time: cleanHist[cleanHist.length-1].time + 60, value: proj }} |
|
|
]); |
|
|
|
|
|
const pct = ((proj - lastP) / lastP) * 100; |
|
|
const sign = pct >= 0 ? "+" : ""; |
|
|
|
|
|
dom.projPct.innerText = `${{sign}}${{pct.toFixed(4)}}%`; |
|
|
dom.projPct.style.color = pct >= 0 ? "var(--green)" : "var(--red)"; |
|
|
|
|
|
dom.projVal.innerText = proj.toLocaleString('en-US', {{ minimumFractionDigits: 2 }}); |
|
|
|
|
|
dom.score.innerText = rho.toFixed(3); |
|
|
dom.score.style.color = rho > 0 ? "var(--green)" : (rho < 0 ? "var(--red)" : "var(--text-main)"); |
|
|
}} |
|
|
}} |
|
|
|
|
|
// WALLS |
|
|
if (data.walls) {{ |
|
|
activeLines.forEach(l => priceSeries.removePriceLine(l)); |
|
|
activeLines = []; |
|
|
let html = ""; |
|
|
|
|
|
const addWall = (w, type) => {{ |
|
|
const color = type === 'BID' ? '#00ff9d' : '#ff3b3b'; |
|
|
activeLines.push(priceSeries.createPriceLine({{ price: w.price, color: color, lineWidth: 1, lineStyle: 2, axisLabelVisible: false }})); |
|
|
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>`; |
|
|
}}; |
|
|
|
|
|
data.walls.asks.forEach(w => addWall(w, 'ASK')); |
|
|
data.walls.bids.forEach(w => addWall(w, 'BID')); |
|
|
dom.wallList.innerHTML = html || '<span class="c-dim" style="font-size:11px">Scanning...</span>'; |
|
|
}} |
|
|
|
|
|
// VOLUME CHART IN SIDEBAR |
|
|
if (data.trade_history && data.trade_history.length) {{ |
|
|
const buyData = []; |
|
|
const sellData = []; |
|
|
data.trade_history.forEach(t => {{ |
|
|
const time = Math.floor(t.t); |
|
|
buyData.push({{ time: time, value: t.buy }}); |
|
|
sellData.push({{ time: time, value: t.sell }}); |
|
|
}}); |
|
|
// Ensure unique time points for LW Charts |
|
|
const uniqueBuys = [...new Map(buyData.map(i => [i.time, i])).values()]; |
|
|
const uniqueSells = [...new Map(sellData.map(i => [i.time, i])).values()]; |
|
|
|
|
|
volBuySeries.setData(uniqueBuys); |
|
|
volSellSeries.setData(uniqueSells); |
|
|
}} |
|
|
|
|
|
// DEPTH |
|
|
if (data.depth_x.length) {{ |
|
|
const bids = [], asks = [], nets = []; |
|
|
for(let i=0; i<data.depth_x.length; i++) {{ |
|
|
const t = data.depth_x[i]; |
|
|
bids.push({{ time: t, value: data.depth_bids[i] }}); |
|
|
asks.push({{ time: t, value: data.depth_asks[i] }}); |
|
|
nets.push({{ time: t, value: data.depth_net[i], color: data.depth_net[i] > 0 ? '#00ff9d' : '#ff3b3b' }}); |
|
|
}} |
|
|
bidSeries.setData(bids); |
|
|
askSeries.setData(asks); |
|
|
netSeries.setData(nets); |
|
|
}} |
|
|
}}; |
|
|
|
|
|
ws.onclose = () => setTimeout(connect, 2000); |
|
|
}} |
|
|
connect(); |
|
|
}}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
|
|
|
async def kraken_worker(): |
|
|
global market_state |
|
|
while True: |
|
|
try: |
|
|
async with websockets.connect("wss://ws.kraken.com/v2") as ws: |
|
|
logging.info(f"π Connected to Kraken ({SYMBOL_KRAKEN})") |
|
|
|
|
|
|
|
|
await ws.send(json.dumps({ |
|
|
"method": "subscribe", |
|
|
"params": {"channel": "book", "symbol": [SYMBOL_KRAKEN], "depth": 500} |
|
|
})) |
|
|
await ws.send(json.dumps({ |
|
|
"method": "subscribe", |
|
|
"params": {"channel": "trade", "symbol": [SYMBOL_KRAKEN]} |
|
|
})) |
|
|
|
|
|
async for message in ws: |
|
|
payload = json.loads(message) |
|
|
channel = payload.get("channel") |
|
|
data = payload.get("data", []) |
|
|
|
|
|
if channel == "book": |
|
|
for item in data: |
|
|
for bid in item.get('bids', []): |
|
|
q, p = float(bid['qty']), float(bid['price']) |
|
|
if q == 0: market_state['bids'].pop(p, None) |
|
|
else: market_state['bids'][p] = q |
|
|
for ask in item.get('asks', []): |
|
|
q, p = float(ask['qty']), float(ask['price']) |
|
|
if q == 0: market_state['asks'].pop(p, None) |
|
|
else: market_state['asks'][p] = q |
|
|
|
|
|
if market_state['bids'] and market_state['asks']: |
|
|
best_bid = max(market_state['bids'].keys()) |
|
|
best_ask = min(market_state['asks'].keys()) |
|
|
mid = (best_bid + best_ask) / 2 |
|
|
market_state['prev_mid'] = market_state['current_mid'] |
|
|
market_state['current_mid'] = mid |
|
|
market_state['ready'] = True |
|
|
|
|
|
now = time.time() |
|
|
if not market_state['history'] or (now - market_state['history'][-1]['t'] > 0.5): |
|
|
market_state['history'].append({'t': now, 'p': mid}) |
|
|
if len(market_state['history']) > HISTORY_LENGTH: |
|
|
market_state['history'].pop(0) |
|
|
|
|
|
elif channel == "trade": |
|
|
|
|
|
for trade in data: |
|
|
|
|
|
|
|
|
try: |
|
|
qty = float(trade['qty']) |
|
|
side = trade['side'] |
|
|
if side == 'buy': |
|
|
market_state['current_vol_window']['buy'] += qty |
|
|
else: |
|
|
market_state['current_vol_window']['sell'] += qty |
|
|
except: |
|
|
pass |
|
|
|
|
|
except Exception as e: |
|
|
logging.warning(f"β οΈ Reconnecting: {e}") |
|
|
await asyncio.sleep(3) |
|
|
|
|
|
async def broadcast_worker(): |
|
|
while True: |
|
|
if connected_clients and market_state['ready']: |
|
|
payload = process_market_data() |
|
|
msg = json.dumps(payload) |
|
|
for ws in list(connected_clients): |
|
|
try: await ws.send_str(msg) |
|
|
except: pass |
|
|
await asyncio.sleep(BROADCAST_RATE) |
|
|
|
|
|
async def websocket_handler(request): |
|
|
ws = web.WebSocketResponse() |
|
|
await ws.prepare(request) |
|
|
connected_clients.add(ws) |
|
|
try: |
|
|
async for msg in ws: pass |
|
|
finally: |
|
|
connected_clients.remove(ws) |
|
|
return ws |
|
|
|
|
|
async def handle_index(request): |
|
|
return web.Response(text=HTML_PAGE, content_type='text/html') |
|
|
|
|
|
async def start_background(app): |
|
|
app['kraken_task'] = asyncio.create_task(kraken_worker()) |
|
|
app['broadcast_task'] = asyncio.create_task(broadcast_worker()) |
|
|
|
|
|
async def cleanup_background(app): |
|
|
app['kraken_task'].cancel() |
|
|
app['broadcast_task'].cancel() |
|
|
try: await app['kraken_task']; await app['broadcast_task'] |
|
|
except: pass |
|
|
|
|
|
async def main(): |
|
|
app = web.Application() |
|
|
app.router.add_get('/', handle_index) |
|
|
app.router.add_get('/ws', websocket_handler) |
|
|
app.on_startup.append(start_background) |
|
|
app.on_cleanup.append(cleanup_background) |
|
|
runner = web.AppRunner(app) |
|
|
await runner.setup() |
|
|
site = web.TCPSite(runner, '0.0.0.0', PORT) |
|
|
await site.start() |
|
|
print(f"π Quant Dashboard: http://localhost:{PORT}") |
|
|
await asyncio.Event().wait() |
|
|
|
|
|
if __name__ == "__main__": |
|
|
try: asyncio.run(main()) |
|
|
except KeyboardInterrupt: pass |