389 lines
12 KiB
Python
389 lines
12 KiB
Python
# app.py
|
|
|
|
import dash
|
|
from dash import html, dcc, Output, Input
|
|
import dash_bootstrap_components as dbc
|
|
import plotly.graph_objs as go
|
|
from plotly.subplots import make_subplots
|
|
import pandas as pd
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, List, Tuple
|
|
|
|
from viz_io import DATA_FILE, DEPTH_FILE, METRICS_FILE
|
|
|
|
_LAST_DATA: List[list] = []
|
|
_LAST_DEPTH: dict = {"bids": [], "asks": []}
|
|
_LAST_METRICS: List[list] = []
|
|
|
|
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.FLATLY], suppress_callback_exceptions=True)
|
|
|
|
app.layout = dbc.Container([
|
|
dbc.Row([
|
|
dbc.Col([
|
|
dcc.Graph(
|
|
id='ohlc-chart',
|
|
config={'displayModeBar': True, 'displaylogo': False},
|
|
style={'height': '100vh'}
|
|
)
|
|
], width=9),
|
|
dbc.Col([
|
|
dcc.Graph(
|
|
id='depth-chart',
|
|
config={'displayModeBar': True, 'displaylogo': False},
|
|
style={'height': '100vh'}
|
|
)
|
|
], width=3)
|
|
]),
|
|
dcc.Interval(
|
|
id='interval-update',
|
|
interval=500,
|
|
n_intervals=0
|
|
)
|
|
], fluid=True, style={
|
|
'backgroundColor': '#000000',
|
|
'minHeight': '100vh',
|
|
'color': '#ffffff'
|
|
})
|
|
|
|
def build_empty_ohlc_fig() -> go.Figure:
|
|
"""Empty OHLC+Volume+OBI+CVD subplot layout to avoid layout jump before data."""
|
|
fig = make_subplots(
|
|
rows=4,
|
|
cols=1,
|
|
shared_xaxes=True,
|
|
vertical_spacing=0.03,
|
|
row_heights=[0.50, 0.18, 0.16, 0.16],
|
|
subplot_titles=('OHLC', 'Volume', 'OBI', 'CVD'),
|
|
specs=[[{"secondary_y": False}],
|
|
[{"secondary_y": False}],
|
|
[{"secondary_y": False}],
|
|
[{"secondary_y": False}]]
|
|
)
|
|
fig.update_layout(
|
|
xaxis_title='Time',
|
|
yaxis_title='Price',
|
|
template='plotly_dark',
|
|
autosize=True,
|
|
xaxis_rangeslider_visible=False,
|
|
paper_bgcolor='#000000',
|
|
plot_bgcolor='#000000',
|
|
uirevision='ohlc_v2',
|
|
yaxis2_title='Volume',
|
|
yaxis3_title='OBI',
|
|
yaxis4_title='CVD',
|
|
showlegend=False,
|
|
xaxis_rangeselector=dict(
|
|
buttons=list([
|
|
dict(count=1, label="1m", step="minute", stepmode="backward"),
|
|
dict(count=5, label="5m", step="minute", stepmode="backward"),
|
|
dict(count=15, label="15m", step="minute", stepmode="backward"),
|
|
dict(count=1, label="1h", step="hour", stepmode="backward"),
|
|
dict(step="all", label="All")
|
|
])
|
|
)
|
|
)
|
|
fig.update_xaxes(matches='x', row=1, col=1)
|
|
fig.update_xaxes(matches='x', row=2, col=1)
|
|
fig.update_xaxes(matches='x', row=3, col=1)
|
|
return fig
|
|
|
|
|
|
def build_empty_depth_fig() -> go.Figure:
|
|
fig = go.Figure()
|
|
fig.update_layout(
|
|
xaxis_title='Size',
|
|
yaxis_title='Price',
|
|
template='plotly_dark',
|
|
autosize=True,
|
|
paper_bgcolor='#000000',
|
|
plot_bgcolor='#000000',
|
|
uirevision='depth',
|
|
showlegend=False
|
|
)
|
|
return fig
|
|
|
|
|
|
def build_ohlc_fig(df: pd.DataFrame) -> go.Figure:
|
|
"""Build a four-row subplot with OHLC candlesticks, volume, OBI, and CVD.
|
|
|
|
The four subplots share the same X axis (time). Volume bars are directionally
|
|
colored (green for up, red for down). Candlestick colors use Plotly defaults.
|
|
"""
|
|
fig = make_subplots(
|
|
rows=4,
|
|
cols=1,
|
|
shared_xaxes=True,
|
|
vertical_spacing=0.03,
|
|
row_heights=[0.50, 0.18, 0.16, 0.16],
|
|
subplot_titles=('OHLC', 'Volume', 'OBI', 'CVD'),
|
|
specs=[[{"secondary_y": False}],
|
|
[{"secondary_y": False}],
|
|
[{"secondary_y": False}],
|
|
[{"secondary_y": False}]]
|
|
)
|
|
|
|
fig.add_trace(
|
|
go.Candlestick(
|
|
x=df['datetime'],
|
|
open=df['open'],
|
|
high=df['high'],
|
|
low=df['low'],
|
|
close=df['close'],
|
|
name='OHLC'
|
|
),
|
|
row=1,
|
|
col=1
|
|
)
|
|
|
|
volume_colors = [
|
|
'rgba(0,200,0,0.7)' if close_val >= open_val else 'rgba(200,0,0,0.7)'
|
|
for open_val, close_val in zip(df['open'], df['close'])
|
|
]
|
|
|
|
fig.add_trace(
|
|
go.Bar(
|
|
x=df['datetime'],
|
|
y=df['volume'],
|
|
marker=dict(color=volume_colors, line=dict(width=0)),
|
|
name='Volume',
|
|
width=50000
|
|
),
|
|
row=2,
|
|
col=1
|
|
)
|
|
|
|
fig.update_layout(
|
|
template='plotly_dark',
|
|
autosize=True,
|
|
xaxis_rangeslider_visible=False,
|
|
paper_bgcolor='#000000',
|
|
plot_bgcolor='#000000',
|
|
uirevision='ohlc_v2',
|
|
yaxis_title='Price',
|
|
yaxis2_title='Volume',
|
|
yaxis3_title='OBI',
|
|
yaxis4_title='CVD',
|
|
showlegend=False,
|
|
xaxis_rangeselector=dict(
|
|
buttons=list([
|
|
dict(count=1, label="1m", step="minute", stepmode="backward"),
|
|
dict(count=5, label="5m", step="minute", stepmode="backward"),
|
|
dict(count=15, label="15m", step="minute", stepmode="backward"),
|
|
dict(count=1, label="1h", step="hour", stepmode="backward"),
|
|
dict(step="all", label="All")
|
|
])
|
|
)
|
|
)
|
|
|
|
# All x-axes share the same range selector buttons
|
|
fig.update_xaxes(matches='x', row=1, col=1)
|
|
fig.update_xaxes(matches='x', row=2, col=1)
|
|
fig.update_xaxes(matches='x', row=3, col=1)
|
|
fig.update_xaxes(matches='x', row=4, col=1)
|
|
|
|
return fig
|
|
|
|
|
|
def add_obi_subplot(fig: go.Figure, metrics: pd.DataFrame) -> go.Figure:
|
|
"""Add raw OBI candlestick data to the existing row 3 subplot."""
|
|
if metrics.empty:
|
|
return fig
|
|
|
|
fig.add_trace(
|
|
go.Candlestick(
|
|
x=metrics['datetime'],
|
|
open=metrics['obi_open'],
|
|
high=metrics['obi_high'],
|
|
low=metrics['obi_low'],
|
|
close=metrics['obi_close'],
|
|
increasing_line_color='rgba(0, 120, 255, 1.0)',
|
|
decreasing_line_color='rgba(0, 80, 180, 1.0)',
|
|
increasing_fillcolor='rgba(0, 120, 255, 0.6)',
|
|
decreasing_fillcolor='rgba(0, 80, 180, 0.6)',
|
|
name='OBI'
|
|
),
|
|
row=3, col=1
|
|
)
|
|
# Zero line
|
|
fig.add_hline(y=0, line=dict(color='rgba(100,100,150,0.5)', width=1), row=3, col=1)
|
|
return fig
|
|
|
|
|
|
def add_cvd_subplot(fig: go.Figure, metrics: pd.DataFrame) -> go.Figure:
|
|
"""Add CVD line chart to the existing row 4 subplot."""
|
|
if metrics.empty or 'cvd_value' not in metrics.columns:
|
|
return fig
|
|
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=metrics['datetime'],
|
|
y=metrics['cvd_value'],
|
|
mode='lines+markers',
|
|
line=dict(color='rgba(255, 165, 0, 1.0)', width=3),
|
|
marker=dict(color='rgba(255, 165, 0, 0.8)', size=4),
|
|
name='CVD'
|
|
),
|
|
row=4, col=1
|
|
)
|
|
# Zero line
|
|
fig.add_hline(y=0, line=dict(color='rgba(100,100,150,0.5)', width=1), row=4, col=1)
|
|
return fig
|
|
|
|
|
|
def _cumulate_levels(levels: List[List[float]], reverse: bool, limit: int) -> List[Tuple[float, float]]:
|
|
try:
|
|
lv = sorted(levels, key=lambda x: float(x[0]), reverse=reverse)[:limit]
|
|
cumulative: List[Tuple[float, float]] = []
|
|
total = 0.0
|
|
for price, size in lv:
|
|
total += float(size)
|
|
cumulative.append((float(price), total))
|
|
return cumulative
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def build_depth_fig(depth: Any, levels_per_side: int = 50) -> go.Figure:
|
|
fig = build_empty_depth_fig()
|
|
if not isinstance(depth, dict):
|
|
return fig
|
|
bids = depth.get('bids', [])
|
|
asks = depth.get('asks', [])
|
|
|
|
cum_bids = _cumulate_levels(bids, reverse=True, limit=levels_per_side)
|
|
cum_asks = _cumulate_levels(asks, reverse=False, limit=levels_per_side)
|
|
|
|
if cum_bids:
|
|
fig.add_trace(go.Scatter(
|
|
x=[s for _, s in cum_bids],
|
|
y=[p for p, _ in cum_bids],
|
|
mode='lines',
|
|
name='Bids',
|
|
line=dict(color='rgba(0,200,0,1)'),
|
|
fill='tozerox',
|
|
fillcolor='rgba(0,200,0,0.2)',
|
|
line_shape='hv'
|
|
))
|
|
if cum_asks:
|
|
fig.add_trace(go.Scatter(
|
|
x=[s for _, s in cum_asks],
|
|
y=[p for p, _ in cum_asks],
|
|
mode='lines',
|
|
name='Asks',
|
|
line=dict(color='rgba(200,0,0,1)'),
|
|
fill='tozerox',
|
|
fillcolor='rgba(200,0,0,0.2)',
|
|
line_shape='hv'
|
|
))
|
|
return fig
|
|
|
|
|
|
# Setup callback
|
|
@app.callback(
|
|
[Output('ohlc-chart', 'figure'), Output('depth-chart', 'figure')],
|
|
[Input('interval-update', 'n_intervals')]
|
|
)
|
|
def update_chart(n):
|
|
try:
|
|
if not DATA_FILE.exists():
|
|
data = _LAST_DATA
|
|
else:
|
|
try:
|
|
with open(DATA_FILE, "r") as f:
|
|
data = json.load(f)
|
|
|
|
if isinstance(data, list):
|
|
_LAST_DATA.clear()
|
|
_LAST_DATA.extend(data)
|
|
else:
|
|
data = _LAST_DATA
|
|
except json.JSONDecodeError:
|
|
logging.warning("JSON decode error while reading OHLC data; using cached data")
|
|
data = _LAST_DATA
|
|
|
|
depth_data = _LAST_DEPTH
|
|
try:
|
|
if DEPTH_FILE.exists():
|
|
with open(DEPTH_FILE, "r") as f:
|
|
loaded = json.load(f)
|
|
if isinstance(loaded, dict):
|
|
_LAST_DEPTH.clear()
|
|
_LAST_DEPTH.update(loaded)
|
|
depth_data = _LAST_DEPTH
|
|
except json.JSONDecodeError:
|
|
logging.warning("JSON decode error while reading depth data; using cached depth data")
|
|
except Exception as e:
|
|
logging.debug(f"Depth read skipped: {e}")
|
|
depth_fig = build_depth_fig(depth_data, levels_per_side=50)
|
|
|
|
# Read metrics
|
|
metrics_raw = _LAST_METRICS
|
|
try:
|
|
if METRICS_FILE.exists():
|
|
with open(METRICS_FILE, "r") as f:
|
|
loaded = json.load(f)
|
|
if isinstance(loaded, list):
|
|
_LAST_METRICS.clear()
|
|
_LAST_METRICS.extend(loaded)
|
|
metrics_raw = _LAST_METRICS
|
|
except json.JSONDecodeError:
|
|
logging.warning("JSON decode error while reading metrics data; using cached metrics data")
|
|
except Exception as e:
|
|
logging.debug(f"Metrics read skipped: {e}")
|
|
|
|
if not data:
|
|
ohlc_fig = build_empty_ohlc_fig()
|
|
return [ohlc_fig, depth_fig]
|
|
|
|
df = pd.DataFrame(data, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
|
|
df['datetime'] = pd.to_datetime(df['timestamp'], unit='ms')
|
|
ohlc_fig = build_ohlc_fig(df)
|
|
|
|
# Build metrics df and append OBI and CVD subplots
|
|
if metrics_raw:
|
|
# Handle both 5-element (legacy) and 6-element (with CVD) schemas
|
|
if metrics_raw and len(metrics_raw[0]) == 6:
|
|
md = pd.DataFrame(metrics_raw, columns=[
|
|
'timestamp', 'obi_open', 'obi_high', 'obi_low', 'obi_close', 'cvd_value'
|
|
])
|
|
else:
|
|
md = pd.DataFrame(metrics_raw, columns=[
|
|
'timestamp', 'obi_open', 'obi_high', 'obi_low', 'obi_close'
|
|
])
|
|
md['cvd_value'] = 0.0 # Default CVD for backward compatibility
|
|
|
|
md['datetime'] = pd.to_datetime(md['timestamp'], unit='ms')
|
|
ohlc_fig = add_obi_subplot(ohlc_fig, md)
|
|
ohlc_fig = add_cvd_subplot(ohlc_fig, md)
|
|
|
|
return [ohlc_fig, depth_fig]
|
|
|
|
except Exception as e:
|
|
logging.error(f"Error updating chart: {e}")
|
|
|
|
ohlc_fig = go.Figure()
|
|
ohlc_fig.update_layout(
|
|
title=f'Error: {str(e)}',
|
|
xaxis_title='Time',
|
|
yaxis_title='Price',
|
|
template='plotly_white',
|
|
height=600
|
|
)
|
|
|
|
depth_fig = go.Figure()
|
|
depth_fig.update_layout(
|
|
title=f'Error: {str(e)}',
|
|
xaxis_title='Spread',
|
|
yaxis_title='Spread Size',
|
|
template='plotly_white',
|
|
height=600
|
|
)
|
|
return [ohlc_fig, depth_fig]
|
|
|
|
if __name__ == '__main__':
|
|
logging.basicConfig(level=logging.INFO)
|
|
logging.info(f"Starting OHLC visualizer on http://localhost:8050")
|
|
logging.info(f"Registered callbacks: {list(app.callback_map.keys())}")
|
|
app.run(debug=False, port=8050, host='0.0.0.0') |