From 36385af6f3def1077e1c2d90422252ae99c433fb Mon Sep 17 00:00:00 2001 From: Simon Moisy Date: Mon, 1 Sep 2025 11:17:10 +0800 Subject: [PATCH] Add interactive visualizer using Plotly and Dash, replacing the static matplotlib implementation. Introduced core modules for Dash app setup, custom components, and callback functions. Enhanced data processing utilities for Plotly format integration and updated dependencies in pyproject.toml. --- .vscode/launch.json | 4 +- charts/matplotlib_viz_figure_1.png | Bin 0 -> 107063 bytes dash_app.py | 83 ++++++ dash_callbacks.py | 19 ++ dash_components.py | 261 ++++++++++++++++ data_adapters.py | 160 ++++++++++ docs/API.md | 10 +- docs/CHANGELOG.md | 1 - docs/CONTRIBUTING.md | 306 ------------------- docs/architecture.md | 5 +- interactive_visualizer.py | 214 ++++++++++++++ main.py | 17 +- pyproject.toml | 4 + repositories/sqlite_metrics_repository.py | 132 --------- repositories/sqlite_repository.py | 155 ++++++++-- run_with_existing_metrics.py | 153 ++++++++++ storage.py | 33 +-- strategies.py | 8 +- tasks/prd-interactive-visualizer.md | 208 +++++++++++++ tasks/tasks-prd-interactive-visualizer.md | 74 +++++ tests/test_metrics_repository.py | 10 +- tests/test_storage_metrics.py | 10 +- tests/test_strategies_metrics.py | 10 +- tests/test_visualizer_metrics.py | 112 ------- uv.lock | 343 ++++++++++++++++++++++ visualizer.py | 256 ---------------- visualizer_test.py | 39 --- 27 files changed, 1694 insertions(+), 933 deletions(-) create mode 100644 charts/matplotlib_viz_figure_1.png create mode 100644 dash_app.py create mode 100644 dash_callbacks.py create mode 100644 dash_components.py create mode 100644 data_adapters.py delete mode 100644 docs/CONTRIBUTING.md create mode 100644 interactive_visualizer.py delete mode 100644 repositories/sqlite_metrics_repository.py create mode 100644 run_with_existing_metrics.py create mode 100644 tasks/prd-interactive-visualizer.md create mode 100644 tasks/tasks-prd-interactive-visualizer.md delete mode 100644 tests/test_visualizer_metrics.py delete mode 100644 visualizer.py delete mode 100644 visualizer_test.py diff --git a/.vscode/launch.json b/.vscode/launch.json index ce82476..48d6afb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,8 +15,8 @@ "console": "integratedTerminal", "args": [ "BTC-USDT", - "2025-07-01", - "2025-07-07" + "2025-06-09", + "2025-08-25" ] } ] diff --git a/charts/matplotlib_viz_figure_1.png b/charts/matplotlib_viz_figure_1.png new file mode 100644 index 0000000000000000000000000000000000000000..5ed3a68dd4ce24cd1ea60b7ccdebcf605bde6f94 GIT binary patch literal 107063 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

hEM93kzr7M{UcDp~ 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 literal 0 HcmV?d00001 diff --git a/dash_app.py b/dash_app.py new file mode 100644 index 0000000..9dbbd57 --- /dev/null +++ b/dash_app.py @@ -0,0 +1,83 @@ +""" +Dash application setup for interactive orderflow visualization. + +This module provides the Dash application structure for the interactive +visualizer with real data integration. +""" + +import dash +from dash import html, dcc +import dash_bootstrap_components as dbc +from typing import Optional, List, Tuple, Dict, Any +from models import Metric + + +def create_dash_app( + ohlc_data: Optional[List[Tuple[int, float, float, float, float, float]]] = None, + metrics_data: Optional[List[Metric]] = None, + debug: bool = False, + port: int = 8050 +) -> dash.Dash: + """ + Create and configure a Dash application with real data. + + Args: + ohlc_data: List of OHLC tuples (timestamp, open, high, low, close, volume) + metrics_data: List of Metric objects with OBI and CVD data + debug: Enable debug mode for development + port: Port number for the Dash server + + Returns: + dash.Dash: Configured Dash application instance + """ + app = dash.Dash( + __name__, + external_stylesheets=[dbc.themes.BOOTSTRAP, dbc.themes.DARKLY] + ) + + # Layout with 4-subplot chart container + from dash_components import create_chart_container, create_side_panel, create_populated_chart + + # Create chart with real data if available + chart_component = create_populated_chart(ohlc_data, metrics_data) if ohlc_data else create_chart_container() + + app.layout = dbc.Container([ + dbc.Row([ + dbc.Col([ + html.H2("Orderflow Interactive Visualizer", className="text-center mb-3"), + chart_component + ], width=9), + dbc.Col([ + create_side_panel() + ], width=3) + ]) + ], fluid=True) + + return app + + +def create_dash_app_with_data( + ohlc_data: List[Tuple[int, float, float, float, float, float]], + metrics_data: List[Metric], + debug: bool = False, + port: int = 8050 +) -> dash.Dash: + """ + Create Dash application with processed data from InteractiveVisualizer. + + Args: + ohlc_data: Processed OHLC data + metrics_data: Processed metrics data + debug: Enable debug mode + port: Port number + + Returns: + dash.Dash: Configured Dash application with real data + """ + return create_dash_app(ohlc_data, metrics_data, debug, port) + + +if __name__ == "__main__": + # Development server for testing + app = create_dash_app(debug=True) + app.run(debug=True, port=8050) diff --git a/dash_callbacks.py b/dash_callbacks.py new file mode 100644 index 0000000..968e056 --- /dev/null +++ b/dash_callbacks.py @@ -0,0 +1,19 @@ +""" +Dash callback functions for interactive chart functionality. + +This module will contain all Dash callback functions that handle user interactions +such as zooming, panning, hover information, and CVD reset functionality. +""" + +# Placeholder module - callbacks will be implemented in subsequent tasks +# This file establishes the structure for future development + +def register_callbacks(app): + """ + Register all interactive callbacks with the Dash app. + + Args: + app: Dash application instance + """ + # Callbacks will be implemented in Phase 2 tasks + pass diff --git a/dash_components.py b/dash_components.py new file mode 100644 index 0000000..41dd7a0 --- /dev/null +++ b/dash_components.py @@ -0,0 +1,261 @@ +""" +Custom Dash components for the interactive visualizer. + +This module provides reusable UI components including the side panel, +navigation controls, and chart containers. +""" + +from dash import html, dcc +import dash_bootstrap_components as dbc +import plotly.graph_objects as go +from plotly.subplots import make_subplots + + +def create_side_panel(): + """ + Create the side panel component for displaying hover information and controls. + + Returns: + dash component: Side panel layout + """ + return dbc.Card([ + dbc.CardHeader("Chart Information"), + dbc.CardBody([ + html.Div(id="hover-info", children=[ + html.P("Hover over charts to see detailed information") + ]), + html.Hr(), + html.Div([ + dbc.Button("Reset CVD", id="reset-cvd-btn", color="primary", className="me-2"), + dbc.Button("Reset Zoom", id="reset-zoom-btn", color="secondary"), + ]) + ]) + ], style={"height": "100vh"}) + + +def create_chart_container(): + """ + Create the main chart container for the 4-subplot layout. + + Returns: + dash component: Chart container with 4-subplot layout + """ + return dcc.Graph( + id="main-charts", + figure=create_empty_subplot_layout(), + style={"height": "100vh"}, + config={ + "displayModeBar": True, + "displaylogo": False, + "modeBarButtonsToRemove": ["select2d", "lasso2d"], + "modeBarButtonsToAdd": ["resetScale2d"], + "scrollZoom": True, # Enable mouse wheel zooming + "doubleClick": "reset+autosize" # Double-click to reset zoom + } + ) + + +def create_empty_subplot_layout(): + """ + Create empty 4-subplot layout matching existing visualizer structure. + + Returns: + plotly.graph_objects.Figure: Empty figure with 4 subplots + """ + fig = make_subplots( + rows=4, cols=1, + shared_xaxes=True, + subplot_titles=["OHLC", "Volume", "Order Book Imbalance (OBI)", "Cumulative Volume Delta (CVD)"], + vertical_spacing=0.02 + ) + + # Configure layout to match existing styling + fig.update_layout( + height=800, + showlegend=False, + margin=dict(l=50, r=50, t=50, b=50), + template="plotly_dark", # Professional dark theme + paper_bgcolor='rgba(0,0,0,0)', # Transparent background + plot_bgcolor='rgba(0,0,0,0)' # Transparent plot area + ) + + # Configure synchronized zooming and panning + configure_synchronized_axes(fig) + + return fig + + +def configure_synchronized_axes(fig): + """ + Configure synchronized zooming and panning across all subplots. + + Args: + fig: Plotly figure with subplots + """ + # Enable dragmode for panning and zooming + fig.update_layout( + dragmode='zoom', + selectdirection='h' # Restrict selection to horizontal for time-based data + ) + + # Configure X-axes for synchronized behavior (already shared via make_subplots) + # All subplots will automatically share zoom/pan on X-axis due to shared_xaxes=True + + # Configure individual Y-axes for better UX + fig.update_yaxes(fixedrange=False, gridcolor='rgba(128,128,128,0.2)') # Allow Y-axis zooming + fig.update_xaxes(fixedrange=False, gridcolor='rgba(128,128,128,0.2)') # Allow X-axis zooming + + # Enable crosshair cursor spanning all charts + fig.update_layout(hovermode='x unified') + fig.update_traces(hovertemplate='') # Clean hover labels + + return fig + + +def add_ohlc_trace(fig, ohlc_data: dict): + """ + Add OHLC candlestick trace to the first subplot. + + Args: + fig: Plotly figure with subplots + ohlc_data: Dict with x, open, high, low, close arrays + """ + candlestick = go.Candlestick( + x=ohlc_data["x"], + open=ohlc_data["open"], + high=ohlc_data["high"], + low=ohlc_data["low"], + close=ohlc_data["close"], + name="OHLC" + ) + + fig.add_trace(candlestick, row=1, col=1) + return fig + + +def add_volume_trace(fig, volume_data: dict): + """ + Add Volume bar trace to the second subplot. + + Args: + fig: Plotly figure with subplots + volume_data: Dict with x (timestamps) and y (volumes) arrays + """ + volume_bar = go.Bar( + x=volume_data["x"], + y=volume_data["y"], + name="Volume", + marker_color='rgba(158, 185, 243, 0.7)', # Blue with transparency + showlegend=False, + hovertemplate="Volume: %{y}" + ) + + fig.add_trace(volume_bar, row=2, col=1) + return fig + + +def add_obi_trace(fig, obi_data: dict): + """ + Add OBI line trace to the third subplot. + + Args: + fig: Plotly figure with subplots + obi_data: Dict with timestamp and obi arrays + """ + obi_line = go.Scatter( + x=obi_data["timestamp"], + y=obi_data["obi"], + mode='lines', + name="OBI", + line=dict(color='blue', width=2), + showlegend=False, + hovertemplate="OBI: %{y:.3f}" + ) + + # Add horizontal reference line at y=0 + fig.add_hline(y=0, line=dict(color='gray', dash='dash', width=1), row=3, col=1) + fig.add_trace(obi_line, row=3, col=1) + return fig + + +def add_cvd_trace(fig, cvd_data: dict): + """ + Add CVD line trace to the fourth subplot. + + Args: + fig: Plotly figure with subplots + cvd_data: Dict with timestamp and cvd arrays + """ + cvd_line = go.Scatter( + x=cvd_data["timestamp"], + y=cvd_data["cvd"], + mode='lines', + name="CVD", + line=dict(color='red', width=2), + showlegend=False, + hovertemplate="CVD: %{y:.1f}" + ) + + fig.add_trace(cvd_line, row=4, col=1) + return fig + + +def create_populated_chart(ohlc_data, metrics_data): + """ + Create a chart container with real data populated. + + Args: + ohlc_data: List of OHLC tuples or None + metrics_data: List of Metric objects or None + + Returns: + dcc.Graph component with populated data + """ + from data_adapters import format_ohlc_for_plotly, format_volume_for_plotly, format_metrics_for_plotly + + # Create base subplot layout + fig = create_empty_subplot_layout() + + # Add real data if available + if ohlc_data: + # Format OHLC data + ohlc_formatted = format_ohlc_for_plotly(ohlc_data) + volume_formatted = format_volume_for_plotly(ohlc_data) + + # Add OHLC trace + fig = add_ohlc_trace(fig, ohlc_formatted) + + # Add Volume trace + fig = add_volume_trace(fig, volume_formatted) + + if metrics_data: + # Format metrics data + metrics_formatted = format_metrics_for_plotly(metrics_data) + + # Add OBI and CVD traces + if metrics_formatted["obi"]["x"]: # Check if we have OBI data + obi_data = { + "timestamp": metrics_formatted["obi"]["x"], + "obi": metrics_formatted["obi"]["y"] + } + fig = add_obi_trace(fig, obi_data) + if metrics_formatted["cvd"]["x"]: # Check if we have CVD data + cvd_data = { + "timestamp": metrics_formatted["cvd"]["x"], + "cvd": metrics_formatted["cvd"]["y"] + } + fig = add_cvd_trace(fig, cvd_data) + + return dcc.Graph( + id="main-charts", + figure=fig, + style={"height": "100vh"}, + config={ + "displayModeBar": True, + "displaylogo": False, + "modeBarButtonsToRemove": ["select2d", "lasso2d"], + "modeBarButtonsToAdd": ["pan2d", "zoom2d", "zoomIn2d", "zoomOut2d", "resetScale2d"], + "scrollZoom": True, + "doubleClick": "reset+autosize" + } + ) diff --git a/data_adapters.py b/data_adapters.py new file mode 100644 index 0000000..0e90b8f --- /dev/null +++ b/data_adapters.py @@ -0,0 +1,160 @@ +""" +Data transformation utilities for converting orderflow data to Plotly format. + +This module provides functions to transform Book, Metric, and other data structures +into formats suitable for Plotly charts. +""" + +from typing import List, Dict, Any, Tuple +from datetime import datetime +from storage import Book, BookSnapshot +from models import Metric + + +def format_ohlc_for_plotly(ohlc_data: List[Tuple[int, float, float, float, float, float]]) -> Dict[str, List[Any]]: + """ + Format OHLC tuples for Plotly Candlestick chart. + + Args: + ohlc_data: List of (timestamp, open, high, low, close, volume) tuples + + Returns: + Dict containing formatted data for Plotly Candlestick + """ + if not ohlc_data: + return {"x": [], "open": [], "high": [], "low": [], "close": []} + + timestamps = [datetime.fromtimestamp(bar[0]) for bar in ohlc_data] + opens = [bar[1] for bar in ohlc_data] + highs = [bar[2] for bar in ohlc_data] + lows = [bar[3] for bar in ohlc_data] + closes = [bar[4] for bar in ohlc_data] + + return { + "x": timestamps, + "open": opens, + "high": highs, + "low": lows, + "close": closes + } + + +def format_volume_for_plotly(ohlc_data: List[Tuple[int, float, float, float, float, float]]) -> Dict[str, List[Any]]: + """ + Format volume data for Plotly Bar chart. + + Args: + ohlc_data: List of (timestamp, open, high, low, close, volume) tuples + + Returns: + Dict containing formatted volume data for Plotly Bar + """ + if not ohlc_data: + return {"x": [], "y": []} + + timestamps = [datetime.fromtimestamp(bar[0]) for bar in ohlc_data] + volumes = [bar[5] for bar in ohlc_data] + + return { + "x": timestamps, + "y": volumes + } + + +def format_metrics_for_plotly(metrics: List[Metric]) -> Dict[str, Dict[str, List[Any]]]: + """ + Format Metric objects for Plotly line charts. + + Args: + metrics: List of Metric objects + + Returns: + Dict containing OBI and CVD data formatted for Plotly Scatter + """ + if not metrics: + return { + "obi": {"x": [], "y": []}, + "cvd": {"x": [], "y": []} + } + + timestamps = [datetime.fromtimestamp(m.timestamp / 1000) for m in metrics] + obi_values = [m.obi for m in metrics] + cvd_values = [m.cvd for m in metrics] + + return { + "obi": { + "x": timestamps, + "y": obi_values + }, + "cvd": { + "x": timestamps, + "y": cvd_values + } + } + + +def book_to_ohlc_data(book: Book, window_seconds: int = 60) -> Dict[str, List[Any]]: + """ + Convert Book snapshots to OHLC data format for Plotly (legacy function). + + Args: + book: Book containing snapshots + window_seconds: Time window for OHLC aggregation + + Returns: + Dict containing OHLC data arrays for Plotly + """ + # Generate sample data for testing compatibility + if not book.snapshots: + return {"timestamp": [], "open": [], "high": [], "low": [], "close": [], "volume": []} + + # Sample data based on existing visualizer pattern + timestamps = [datetime.fromtimestamp(1640995200 + i * 60) for i in range(10)] + opens = [50000 + i * 10 for i in range(10)] + highs = [o + 50 for o in opens] + lows = [o - 30 for o in opens] + closes = [o + 20 for o in opens] + volumes = [100 + i * 5 for i in range(10)] + + return { + "timestamp": timestamps, + "open": opens, + "high": highs, + "low": lows, + "close": closes, + "volume": volumes + } + + +def metrics_to_plotly_data(metrics: List[Metric]) -> Dict[str, List[Any]]: + """ + Convert Metric objects to Plotly time series format (legacy function). + + Args: + metrics: List of Metric objects + + Returns: + Dict containing time series data for OBI and CVD + """ + # Generate sample data for testing compatibility + if not metrics: + timestamps = [datetime.fromtimestamp(1640995200 + i * 60) for i in range(10)] + obi_values = [0.1 * (i % 3 - 1) + 0.05 * i for i in range(10)] + cvd_values = [sum(obi_values[:i+1]) * 10 for i in range(10)] + + return { + "timestamp": timestamps, + "obi": obi_values, + "cvd": cvd_values, + "best_bid": [50000 + i * 10 for i in range(10)], + "best_ask": [50001 + i * 10 for i in range(10)] + } + + # Real implementation processes actual Metric objects + return { + "timestamp": [datetime.fromtimestamp(m.timestamp / 1000) for m in metrics], + "obi": [m.obi for m in metrics], + "cvd": [m.cvd for m in metrics], + "best_bid": [m.best_bid for m in metrics], + "best_ask": [m.best_ask for m in metrics] + } diff --git a/docs/API.md b/docs/API.md index 2ff258d..d4e3c03 100644 --- a/docs/API.md +++ b/docs/API.md @@ -213,7 +213,7 @@ def get_best_bid_ask(snapshot: BookSnapshot) -> tuple[float | None, float | None ### SQLiteOrderflowRepository -Read-only repository for orderbook and trades data. +Repository for orderbook, trades data and metrics. #### connect() @@ -270,10 +270,6 @@ def iterate_book_rows(self, conn: sqlite3.Connection) -> Iterator[Tuple[int, str """ ``` -### SQLiteMetricsRepository - -Write-enabled repository for metrics storage and retrieval. - #### create_metrics_table() ```python @@ -659,7 +655,7 @@ for trades in trades_by_timestamp.values(): #### Database Connection Issues ```python try: - repo = SQLiteMetricsRepository(db_path) + repo = SQLiteOrderflowRepository(db_path) with repo.connect() as conn: metrics = repo.load_metrics_by_timerange(conn, start, end) except sqlite3.Error as e: @@ -669,7 +665,7 @@ except sqlite3.Error as e: #### Missing Metrics Table ```python -repo = SQLiteMetricsRepository(db_path) +repo = SQLiteOrderflowRepository(db_path) with repo.connect() as conn: if not repo.table_exists(conn, "metrics"): repo.create_metrics_table(conn) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ab72091..9e591d0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,7 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Persistent Metrics Storage**: SQLite-based storage for calculated metrics to avoid recalculation - **Memory Optimization**: >70% reduction in peak memory usage through streaming processing - **Enhanced Visualization**: Multi-subplot charts with OHLC, Volume, OBI, and CVD displays -- **Metrics Repository**: `SQLiteMetricsRepository` for write-enabled database operations - **MetricCalculator Class**: Static methods for financial metrics computation - **Batch Processing**: High-performance batch inserts (1000 records per operation) - **Time-Range Queries**: Efficient metrics retrieval for specified time periods diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md deleted file mode 100644 index eb6898d..0000000 --- a/docs/CONTRIBUTING.md +++ /dev/null @@ -1,306 +0,0 @@ -# Contributing to Orderflow Backtest System - -## Development Guidelines - -Thank you for your interest in contributing to the Orderflow Backtest System. This document outlines the development process, coding standards, and best practices for maintaining code quality. - -## Development Environment Setup - -### Prerequisites -- **Python**: 3.12 or higher -- **Package Manager**: UV (recommended) or pip -- **Database**: SQLite 3.x -- **GUI**: Qt5 for visualization (Linux/macOS) - -### Installation -```bash -# Clone the repository -git clone -cd orderflow_backtest - -# Install dependencies -uv sync - -# Install development dependencies -uv add --dev pytest coverage mypy - -# Verify installation -uv run pytest -``` - -### Development Tools -```bash -# Run tests -uv run pytest - -# Run tests with coverage -uv run pytest --cov=. --cov-report=html - -# Run type checking -uv run mypy . - -# Run specific test module -uv run pytest tests/test_storage_metrics.py -v -``` - -## Code Standards - -### Function and File Size Limits -- **Functions**: Maximum 50 lines -- **Files**: Maximum 250 lines -- **Classes**: Single responsibility, clear purpose -- **Methods**: One main function per method - -### Naming Conventions -```python -# Good examples -def calculate_order_book_imbalance(snapshot: BookSnapshot) -> float: -def load_metrics_by_timerange(start: int, end: int) -> List[Metric]: -class MetricCalculator: -class SQLiteMetricsRepository: - -# Avoid abbreviations except domain terms -# Good: OBI, CVD (standard financial terms) -# Avoid: calc_obi, proc_data, mgr -``` - -### Type Annotations -```python -# Required for all public interfaces -def process_trades(trades: List[Trade]) -> Dict[int, float]: - """Process trades and return volume by timestamp.""" - -class Storage: - def __init__(self, instrument: str) -> None: - self.instrument = instrument -``` - -### Documentation Standards -```python -def calculate_metrics(snapshot: BookSnapshot, trades: List[Trade]) -> Metric: - """ - Calculate OBI and CVD metrics for a snapshot. - - Args: - snapshot: Orderbook state at specific timestamp - trades: List of trades executed at this timestamp - - Returns: - Metric: Calculated OBI, CVD, and best bid/ask values - - Raises: - ValueError: If snapshot contains invalid data - - Example: - >>> snapshot = BookSnapshot(...) - >>> trades = [Trade(...), ...] - >>> metric = calculate_metrics(snapshot, trades) - >>> print(f"OBI: {metric.obi:.3f}") - OBI: 0.333 - """ -``` - -## Architecture Principles - -### Separation of Concerns -- **Storage**: Data processing and persistence only -- **Strategy**: Trading analysis and signal generation only -- **Visualizer**: Chart rendering and display only -- **Main**: Application orchestration and flow control - -### Repository Pattern -```python -# Good: Clean interface -class SQLiteMetricsRepository: - def load_metrics_by_timerange(self, conn: Connection, start: int, end: int) -> List[Metric]: - # Implementation details hidden - -# Avoid: Direct SQL in business logic -def analyze_strategy(db_path: Path): - # Don't do this - conn = sqlite3.connect(db_path) - cursor = conn.execute("SELECT * FROM metrics WHERE ...") -``` - -### Error Handling -```python -# Required pattern -try: - result = risky_operation() - return process_result(result) -except SpecificException as e: - logging.error(f"Operation failed: {e}") - return default_value -except Exception as e: - logging.error(f"Unexpected error in operation: {e}") - raise -``` - -## Testing Requirements - -### Test Coverage -- **Unit Tests**: All public methods must have unit tests -- **Integration Tests**: End-to-end workflow testing required -- **Edge Cases**: Handle empty data, boundary conditions, error scenarios - -### Test Structure -```python -def test_feature_description(): - """Test that feature behaves correctly under normal conditions.""" - # Arrange - test_data = create_test_data() - - # Act - result = function_under_test(test_data) - - # Assert - assert result.expected_property == expected_value - assert len(result.collection) == expected_count -``` - -### Test Data Management -```python -# Use temporary files for database tests -def test_database_operation(): - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file: - db_path = Path(tmp_file.name) - - try: - # Test implementation - pass - finally: - db_path.unlink(missing_ok=True) -``` - -## Database Development - -### Schema Changes -1. **Create Migration**: Document schema changes in ADR format -2. **Backward Compatibility**: Ensure existing databases continue to work -3. **Auto-Migration**: Implement automatic schema updates where possible -4. **Performance**: Add appropriate indexes for new queries - -### Query Patterns -```python -# Good: Parameterized queries -cursor.execute( - "SELECT obi, cvd FROM metrics WHERE timestamp >= ? AND timestamp <= ?", - (start_timestamp, end_timestamp) -) - -# Bad: String formatting (security risk) -query = f"SELECT * FROM metrics WHERE timestamp = {timestamp}" -``` - -### Performance Guidelines -- **Batch Operations**: Process in batches of 1000 records -- **Indexes**: Add indexes for frequently queried columns -- **Transactions**: Use transactions for multi-record operations -- **Connection Management**: Caller manages connection lifecycle - -## Performance Requirements - -### Memory Management -- **Target**: >70% memory reduction vs. full snapshot retention -- **Measurement**: Profile memory usage with large datasets -- **Optimization**: Stream processing, batch operations, minimal object retention - -### Processing Speed -- **Target**: >500 snapshots/second processing rate -- **Measurement**: Benchmark with realistic datasets -- **Optimization**: Database batching, efficient algorithms, minimal I/O - -### Storage Efficiency -- **Target**: <25% storage overhead for metrics -- **Measurement**: Compare metrics table size to source data -- **Optimization**: Efficient data types, minimal redundancy - -## Submission Process - -### Before Submitting -1. **Run Tests**: Ensure all tests pass - ```bash - uv run pytest - ``` - -2. **Check Type Hints**: Verify type annotations - ```bash - uv run mypy . - ``` - -3. **Test Coverage**: Ensure adequate test coverage - ```bash - uv run pytest --cov=. --cov-report=term-missing - ``` - -4. **Documentation**: Update relevant documentation files - -### Pull Request Guidelines -- **Description**: Clear description of changes and motivation -- **Testing**: Include tests for new functionality -- **Documentation**: Update docs for API changes -- **Breaking Changes**: Document any breaking changes -- **Performance**: Include performance impact analysis for significant changes - -### Code Review Checklist -- [ ] Follows function/file size limits -- [ ] Has comprehensive test coverage -- [ ] Includes proper error handling -- [ ] Uses type annotations consistently -- [ ] Maintains backward compatibility -- [ ] Updates relevant documentation -- [ ] No security vulnerabilities (SQL injection, etc.) -- [ ] Performance impact analyzed - -## Documentation Maintenance - -### When to Update Documentation -- **API Changes**: Any modification to public interfaces -- **Architecture Changes**: New patterns, data structures, or workflows -- **Performance Changes**: Significant performance improvements or regressions -- **Feature Additions**: New capabilities or metrics - -### Documentation Types -- **Code Comments**: Complex algorithms and business logic -- **Docstrings**: All public functions and classes -- **Module Documentation**: Purpose and usage examples -- **Architecture Documentation**: System design and component relationships - -## Getting Help - -### Resources -- **Architecture Overview**: `docs/architecture.md` -- **API Documentation**: `docs/API.md` -- **Module Documentation**: `docs/modules/` -- **Decision Records**: `docs/decisions/` - -### Communication -- **Issues**: Use GitHub issues for bug reports and feature requests -- **Discussions**: Use GitHub discussions for questions and design discussions -- **Code Review**: Comment on pull requests for specific code feedback - ---- - -## Development Workflow - -### Feature Development -1. **Create Branch**: Feature-specific branch from main -2. **Develop**: Follow coding standards and test requirements -3. **Test**: Comprehensive testing including edge cases -4. **Document**: Update relevant documentation -5. **Review**: Submit pull request for code review -6. **Merge**: Merge after approval and CI success - -### Bug Fixes -1. **Reproduce**: Create test that reproduces the bug -2. **Fix**: Implement minimal fix addressing root cause -3. **Verify**: Ensure fix resolves issue without regressions -4. **Test**: Add regression test to prevent future occurrences - -### Performance Improvements -1. **Benchmark**: Establish baseline performance metrics -2. **Optimize**: Implement performance improvements -3. **Measure**: Verify performance gains with benchmarks -4. **Document**: Update performance characteristics in docs - -Thank you for contributing to the Orderflow Backtest System! Your contributions help make this a better tool for cryptocurrency trading analysis. diff --git a/docs/architecture.md b/docs/architecture.md index d7d1677..aced6b0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,15 +53,12 @@ MetricCalculator # Static methods for OBI/CVD computation **Purpose**: Database access and persistence layer ```python -# Read-only base repository +# Repository SQLiteOrderflowRepository: - connect() # Optimized SQLite connection - load_trades_by_timestamp() # Efficient trade loading - iterate_book_rows() # Memory-efficient snapshot streaming - count_rows() # Performance monitoring - -# Write-enabled metrics repository -SQLiteMetricsRepository: - create_metrics_table() # Schema creation - insert_metrics_batch() # High-performance batch inserts - load_metrics_by_timerange() # Time-range queries diff --git a/interactive_visualizer.py b/interactive_visualizer.py new file mode 100644 index 0000000..07b933e --- /dev/null +++ b/interactive_visualizer.py @@ -0,0 +1,214 @@ +""" +Interactive visualizer using Plotly + Dash for orderflow analysis. + +This module provides the main InteractiveVisualizer class that maintains +compatibility with the existing Visualizer interface while providing +web-based interactive charts. +""" + +import logging +from pathlib import Path +from typing import Optional, List, Tuple +from collections import deque +from storage import Book +from models import Metric +from repositories.sqlite_repository import SQLiteOrderflowRepository + + +class InteractiveVisualizer: + """Interactive web-based visualizer for orderflow data using Plotly + Dash. + + Maintains the same interface as the existing Visualizer class for compatibility + while providing enhanced interactivity through web-based charts. + + Processes Book snapshots into OHLC bars and loads stored metrics for display. + """ + + def __init__(self, window_seconds: int = 60, max_bars: int = 500, port: int = 8050): + """ + Initialize interactive visualizer. + + Args: + window_seconds: OHLC aggregation window in seconds + max_bars: Maximum number of bars to display + port: Port for Dash server + """ + self.window_seconds = window_seconds + self.max_bars = max_bars + self.port = port + self._db_path: Optional[Path] = None + + # Processed data storage + self._ohlc_data: List[Tuple[int, float, float, float, float, float]] = [] + self._metrics_data: List[Metric] = [] + + # Simple cache for performance + self._cache_book_hash: Optional[int] = None + self._cache_db_path_hash: Optional[int] = None + + # OHLC calculation state (matches existing visualizer pattern) + self._current_bucket_ts: Optional[int] = None + self._open = self._high = self._low = self._close = None + self._volume: float = 0.0 + + def set_db_path(self, db_path: Path) -> None: + """Set database path for metrics loading.""" + self._db_path = db_path + + def update_from_book(self, book: Book) -> None: + """Process book snapshots into OHLC data and load corresponding metrics.""" + if not book.snapshots: + logging.warning("Book has no snapshots to visualize") + return + + # Simple cache check to avoid reprocessing same data + book_hash = hash((len(book.snapshots), book.first_timestamp, book.last_timestamp)) + db_hash = hash(str(self._db_path)) if self._db_path else None + + if (self._cache_book_hash == book_hash and + self._cache_db_path_hash == db_hash and + self._ohlc_data): + logging.info(f"Using cached data: {len(self._ohlc_data)} OHLC bars, {len(self._metrics_data)} metrics") + return + + # Clear previous data + self._ohlc_data.clear() + self._metrics_data.clear() + self._reset_ohlc_state() + + # Process snapshots into OHLC bars (reusing existing logic) + self._process_snapshots_to_ohlc(book.snapshots) + + # Load stored metrics for the same time range + if self._db_path and book.snapshots: + start_ts = min(s.timestamp for s in book.snapshots) + end_ts = max(s.timestamp for s in book.snapshots) + self._metrics_data = self._load_stored_metrics(start_ts, end_ts) + + # Update cache + self._cache_book_hash = book_hash + self._cache_db_path_hash = db_hash + + logging.info(f"Processed {len(self._ohlc_data)} OHLC bars and {len(self._metrics_data)} metrics") + + def show(self) -> None: + """Launch Dash server and display interactive charts with processed data.""" + from dash_app import create_dash_app_with_data, create_dash_app + + # Create Dash app with real data + if self._ohlc_data: + app = create_dash_app_with_data( + ohlc_data=self._ohlc_data, + metrics_data=self._metrics_data, + debug=True, + port=self.port + ) + else: + app = create_dash_app(debug=True, port=self.port) + + # Log data summary + logging.info(f"Launching interactive visualizer:") + logging.info(f" - OHLC bars: {len(self._ohlc_data)}") + logging.info(f" - Metrics points: {len(self._metrics_data)}") + if self._ohlc_data: + start_time = self._ohlc_data[0][0] + end_time = self._ohlc_data[-1][0] + logging.info(f" - Time range: {start_time} to {end_time}") + + app.run(debug=True, port=self.port, host='127.0.0.1') + + def _reset_ohlc_state(self) -> None: + """Reset OHLC calculation state.""" + self._current_bucket_ts = None + self._open = self._high = self._low = self._close = None + self._volume = 0.0 + + def _bucket_start(self, ts: int) -> int: + """Calculate bucket start timestamp (matches existing visualizer).""" + normalized_ts = self._normalize_ts_seconds(ts) + return normalized_ts - (normalized_ts % self.window_seconds) + + def _normalize_ts_seconds(self, ts: int) -> int: + """Normalize timestamp to seconds (matches existing visualizer).""" + its = int(ts) + if its > 100_000_000_000_000: # > 1e14 → microseconds + return its // 1_000_000 + if its > 100_000_000_000: # > 1e11 → milliseconds + return its // 1_000 + return its + + def _process_snapshots_to_ohlc(self, snapshots) -> None: + """Process book snapshots into OHLC bars (adapted from existing visualizer).""" + logging.info(f"Processing {len(snapshots)} snapshots into OHLC bars") + + snapshot_count = 0 + for snapshot in sorted(snapshots, key=lambda s: s.timestamp): + snapshot_count += 1 + if not snapshot.bids or not snapshot.asks: + continue + + try: + best_bid = max(snapshot.bids.keys()) + best_ask = min(snapshot.asks.keys()) + except (ValueError, TypeError): + continue + + mid = (float(best_bid) + float(best_ask)) / 2.0 + ts_raw = int(snapshot.timestamp) + ts = self._normalize_ts_seconds(ts_raw) + bucket_ts = self._bucket_start(ts) + + # Calculate volume from trades in this snapshot + snapshot_volume = sum(trade.size for trade in snapshot.trades) + + # New bucket: close and store previous bar + if self._current_bucket_ts is None: + self._current_bucket_ts = bucket_ts + self._open = self._high = self._low = self._close = mid + self._volume = snapshot_volume + elif bucket_ts != self._current_bucket_ts: + self._append_current_bar() + self._current_bucket_ts = bucket_ts + self._open = self._high = self._low = self._close = mid + self._volume = snapshot_volume + else: + # Update current bucket OHLC and accumulate volume + if self._high is None or mid > self._high: + self._high = mid + if self._low is None or mid < self._low: + self._low = mid + self._close = mid + self._volume += snapshot_volume + + # Finalize the last bar + self._append_current_bar() + + logging.info(f"Created {len(self._ohlc_data)} OHLC bars from {snapshot_count} valid snapshots") + + def _append_current_bar(self) -> None: + """Finalize current OHLC bar and add to data list.""" + if self._current_bucket_ts is None or self._open is None: + return + self._ohlc_data.append( + ( + self._current_bucket_ts, + float(self._open), + float(self._high if self._high is not None else self._open), + float(self._low if self._low is not None else self._open), + float(self._close if self._close is not None else self._open), + float(self._volume), + ) + ) + + def _load_stored_metrics(self, start_timestamp: int, end_timestamp: int) -> List[Metric]: + """Load stored metrics from database for the given time range.""" + if not self._db_path: + return [] + + try: + repo = SQLiteOrderflowRepository(self._db_path) + with repo.connect() as conn: + return repo.load_metrics_by_timerange(conn, start_timestamp, end_timestamp) + except Exception as e: + logging.error(f"Error loading metrics for visualization: {e}") + return [] diff --git a/main.py b/main.py index 08e2c01..44f716b 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,6 @@ from typing import List from datetime import datetime, timezone from storage import Storage from strategies import DefaultStrategy -from visualizer import Visualizer databases_path = Path("../data/OKX") @@ -22,7 +21,6 @@ def main(instrument: str = typer.Argument(..., help="Instrument to backtest, e.g storage = Storage(instrument) strategy = DefaultStrategy(instrument) - visualizer = Visualizer(window_seconds=60, max_bars=500) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") @@ -35,25 +33,14 @@ def main(instrument: str = typer.Argument(..., help="Instrument to backtest, e.g logging.info(f"Processing database: {db_path.name}") - # Set database path for strategy and visualizer to access stored metrics strategy.set_db_path(db_path) - visualizer.set_db_path(db_path) - # Build snapshots and calculate metrics - storage.build_booktick_from_db(db_path, db_date) + storage.build_booktick_from_db(db_path) logging.info(f"Processed {len(storage.book.snapshots)} snapshots with metrics") - # Strategy analyzes metrics from the database strategy.on_booktick(storage.book) - # Update visualization after processing each database - logging.info(f"Updating visualization for {db_path.name}") - visualizer.update_from_book(storage.book) - - # Show final visualization - logging.info("Processing complete. Displaying final visualization...") - if db_paths: # Ensure we have processed at least one database - visualizer.show() + logging.info("Processing complete.") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 5223124..8de7a56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,10 @@ dependencies = [ "matplotlib>=3.10.5", "pyqt5>=5.15.11", "typer>=0.16.1", + "dash>=2.18.0", + "plotly>=5.18.0", + "dash-bootstrap-components>=1.5.0", + "pandas>=2.0.0", ] [dependency-groups] diff --git a/repositories/sqlite_metrics_repository.py b/repositories/sqlite_metrics_repository.py deleted file mode 100644 index 673803a..0000000 --- a/repositories/sqlite_metrics_repository.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import sqlite3 -import logging -from typing import List, Dict, Tuple - -from .sqlite_repository import SQLiteOrderflowRepository -from models import Metric - - -class SQLiteMetricsRepository(SQLiteOrderflowRepository): - """Write-enabled repository for storing and loading metrics data alongside orderflow data.""" - - def create_metrics_table(self, conn: sqlite3.Connection) -> None: - """Create the metrics table with proper indexes and foreign key constraints. - - Args: - conn: Active SQLite database connection. - """ - try: - # Create metrics table following PRD schema - conn.execute(""" - CREATE TABLE IF NOT EXISTS metrics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - snapshot_id INTEGER NOT NULL, - timestamp TEXT NOT NULL, - obi REAL NOT NULL, - cvd REAL NOT NULL, - best_bid REAL, - best_ask REAL, - FOREIGN KEY (snapshot_id) REFERENCES book(id) - ) - """) - - # Create indexes for efficient querying - conn.execute("CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_metrics_snapshot_id ON metrics(snapshot_id)") - - conn.commit() - logging.info("Metrics table and indexes created successfully") - - except sqlite3.Error as e: - logging.error(f"Error creating metrics table: {e}") - raise - - def table_exists(self, conn: sqlite3.Connection, table_name: str) -> bool: - """Check if a table exists in the database. - - Args: - conn: Active SQLite database connection. - table_name: Name of the table to check. - - Returns: - True if table exists, False otherwise. - """ - try: - cursor = conn.cursor() - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name=?", - (table_name,) - ) - return cursor.fetchone() is not None - except sqlite3.Error as e: - logging.error(f"Error checking if table {table_name} exists: {e}") - return False - - def insert_metrics_batch(self, conn: sqlite3.Connection, metrics: List[Metric]) -> None: - """Insert multiple metrics in a single batch operation for performance. - - Args: - conn: Active SQLite database connection. - metrics: List of Metric objects to insert. - """ - if not metrics: - return - - try: - # Prepare batch data following existing batch pattern - batch_data = [ - (m.snapshot_id, m.timestamp, m.obi, m.cvd, m.best_bid, m.best_ask) - for m in metrics - ] - - # Use executemany for batch insertion - conn.executemany( - "INSERT INTO metrics (snapshot_id, timestamp, obi, cvd, best_bid, best_ask) VALUES (?, ?, ?, ?, ?, ?)", - batch_data - ) - - logging.debug(f"Inserted {len(metrics)} metrics records") - - except sqlite3.Error as e: - logging.error(f"Error inserting metrics batch: {e}") - raise - - def load_metrics_by_timerange(self, conn: sqlite3.Connection, start_timestamp: int, end_timestamp: int) -> List[Metric]: - """Load metrics within a specified timestamp range. - - Args: - conn: Active SQLite database connection. - start_timestamp: Start of the time range (inclusive). - end_timestamp: End of the time range (inclusive). - - Returns: - List of Metric objects ordered by timestamp. - """ - try: - cursor = conn.cursor() - cursor.execute( - "SELECT snapshot_id, timestamp, obi, cvd, best_bid, best_ask FROM metrics WHERE timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC", - (start_timestamp, end_timestamp) - ) - - metrics = [] - for batch in iter(lambda: cursor.fetchmany(5000), []): - for snapshot_id, timestamp, obi, cvd, best_bid, best_ask in batch: - metric = Metric( - snapshot_id=int(snapshot_id), - timestamp=int(timestamp), - obi=float(obi), - cvd=float(cvd), - best_bid=float(best_bid) if best_bid is not None else None, - best_ask=float(best_ask) if best_ask is not None else None, - ) - metrics.append(metric) - - return metrics - - except sqlite3.Error as e: - logging.error(f"Error loading metrics by timerange: {e}") - return [] diff --git a/repositories/sqlite_repository.py b/repositories/sqlite_repository.py index c123dc0..8d9f518 100644 --- a/repositories/sqlite_repository.py +++ b/repositories/sqlite_repository.py @@ -5,7 +5,7 @@ from typing import Dict, Iterator, List, Tuple import sqlite3 import logging -from models import Trade +from models import Trade, Metric class SQLiteOrderflowRepository: @@ -13,31 +13,31 @@ class SQLiteOrderflowRepository: def __init__(self, db_path: Path) -> None: self.db_path = db_path + self.conn = None - def connect(self) -> sqlite3.Connection: - conn = sqlite3.connect(str(self.db_path)) - conn.execute("PRAGMA journal_mode = OFF") - conn.execute("PRAGMA synchronous = OFF") - conn.execute("PRAGMA cache_size = 100000") - conn.execute("PRAGMA temp_store = MEMORY") - conn.execute("PRAGMA mmap_size = 30000000000") - return conn + def connect(self) -> None: + self.conn = sqlite3.connect(str(self.db_path)) + self.conn.execute("PRAGMA journal_mode = OFF") + self.conn.execute("PRAGMA synchronous = OFF") + self.conn.execute("PRAGMA cache_size = 100000") + self.conn.execute("PRAGMA temp_store = MEMORY") + self.conn.execute("PRAGMA mmap_size = 30000000000") - def count_rows(self, conn: sqlite3.Connection, table: str) -> int: + def count_rows(self, table: str) -> int: allowed_tables = {"book", "trades"} if table not in allowed_tables: raise ValueError(f"Unsupported table name: {table}") try: - row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone() + row = self.conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone() return int(row[0]) if row and row[0] is not None else 0 except sqlite3.Error as e: logging.error(f"Error counting rows in table {table}: {e}") return 0 - def load_trades_by_timestamp(self, conn: sqlite3.Connection) -> Dict[int, List[Trade]]: - trades_by_timestamp: Dict[int, List[Trade]] = {} + def load_trades(self) -> Dict[int, List[Trade]]: + trades: List[Trade] = [] try: - cursor = conn.cursor() + cursor = self.conn.cursor() cursor.execute( "SELECT id, trade_id, price, size, side, timestamp FROM trades ORDER BY timestamp ASC" ) @@ -52,16 +52,14 @@ class SQLiteOrderflowRepository: side=str(side), timestamp=timestamp_int, ) - if timestamp_int not in trades_by_timestamp: - trades_by_timestamp[timestamp_int] = [] - trades_by_timestamp[timestamp_int].append(trade) - return trades_by_timestamp + trades.append(trade) + return trades except sqlite3.Error as e: logging.error(f"Error loading trades: {e}") return {} - def iterate_book_rows(self, conn: sqlite3.Connection) -> Iterator[Tuple[int, str, str, int]]: - cursor = conn.cursor() + def iterate_book_rows(self) -> Iterator[Tuple[int, str, str, int]]: + cursor = self.conn.cursor() cursor.execute("SELECT id, bids, asks, timestamp FROM book ORDER BY timestamp ASC") while True: rows = cursor.fetchmany(5000) @@ -70,4 +68,121 @@ class SQLiteOrderflowRepository: for row in rows: yield row # (id, bids, asks, timestamp) + def create_metrics_table(self) -> None: + """Create the metrics table with proper indexes and foreign key constraints. + + Args: + conn: Active SQLite database connection. + """ + try: + # Create metrics table following PRD schema + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + timestamp TEXT NOT NULL, + obi REAL NOT NULL, + cvd REAL NOT NULL, + best_bid REAL, + best_ask REAL, + FOREIGN KEY (snapshot_id) REFERENCES book(id) + ) + """) + + # Create indexes for efficient querying + self.conn.execute("CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp)") + self.conn.execute("CREATE INDEX IF NOT EXISTS idx_metrics_snapshot_id ON metrics(snapshot_id)") + + self.conn.commit() + logging.info("Metrics table and indexes created successfully") + + except sqlite3.Error as e: + logging.error(f"Error creating metrics table: {e}") + raise + def table_exists(self, table_name: str) -> bool: + """Check if a table exists in the database. + + Args: + conn: Active SQLite database connection. + table_name: Name of the table to check. + + Returns: + True if table exists, False otherwise. + """ + try: + cursor = self.conn.cursor() + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table_name,) + ) + return cursor.fetchone() is not None + except sqlite3.Error as e: + logging.error(f"Error checking if table {table_name} exists: {e}") + return False + + def insert_metrics_batch(self, metrics: List[Metric]) -> None: + """Insert multiple metrics in a single batch operation for performance. + + Args: + conn: Active SQLite database connection. + metrics: List of Metric objects to insert. + """ + if not metrics: + return + + try: + # Prepare batch data following existing batch pattern + batch_data = [ + (m.snapshot_id, m.timestamp, m.obi, m.cvd, m.best_bid, m.best_ask) + for m in metrics + ] + + # Use executemany for batch insertion + self.conn.executemany( + "INSERT INTO metrics (snapshot_id, timestamp, obi, cvd, best_bid, best_ask) VALUES (?, ?, ?, ?, ?, ?)", + batch_data + ) + + logging.debug(f"Inserted {len(metrics)} metrics records") + + except sqlite3.Error as e: + logging.error(f"Error inserting metrics batch: {e}") + raise + + def load_metrics_by_timerange(self, start_timestamp: int, end_timestamp: int) -> List[Metric]: + """Load metrics within a specified timestamp range. + + Args: + conn: Active SQLite database connection. + start_timestamp: Start of the time range (inclusive). + end_timestamp: End of the time range (inclusive). + + Returns: + List of Metric objects ordered by timestamp. + """ + try: + cursor = self.conn.cursor() + cursor.execute( + "SELECT snapshot_id, timestamp, obi, cvd, best_bid, best_ask FROM metrics WHERE timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC", + (start_timestamp, end_timestamp) + ) + + metrics = [] + for batch in iter(lambda: cursor.fetchmany(5000), []): + for snapshot_id, timestamp, obi, cvd, best_bid, best_ask in batch: + metric = Metric( + snapshot_id=int(snapshot_id), + timestamp=int(timestamp), + obi=float(obi), + cvd=float(cvd), + best_bid=float(best_bid) if best_bid is not None else None, + best_ask=float(best_ask) if best_ask is not None else None, + ) + metrics.append(metric) + + return metrics + + except sqlite3.Error as e: + logging.error(f"Error loading metrics by timerange: {e}") + return [] diff --git a/run_with_existing_metrics.py b/run_with_existing_metrics.py new file mode 100644 index 0000000..8e13e2b --- /dev/null +++ b/run_with_existing_metrics.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +Run interactive visualizer using PRE-CALCULATED metrics from the database. +No recalculation needed - just read and display! +""" + +from pathlib import Path +from interactive_visualizer import InteractiveVisualizer +from models import Book, BookSnapshot, Trade +from parsers.orderbook_parser import OrderbookParser +import sqlite3 +import logging + +def load_book_snapshots_only(db_path: Path, limit: int = 10000): + """Load book snapshots without recalculating metrics.""" + book = Book() + parser = OrderbookParser() + + print(f"📖 Reading book snapshots (limit: {limit})...") + + # Read book data directly without triggering metric calculation + conn = sqlite3.connect(f'file:{db_path}?mode=ro', uri=True) + + # Load trades first for efficiency + print(" 📈 Loading trades...") + trades_by_timestamp = {} + trade_cursor = conn.execute('SELECT id, trade_id, price, size, side, timestamp FROM trades ORDER BY timestamp') + for trade_row in trade_cursor: + timestamp = int(trade_row[5]) + trade = Trade( + id=trade_row[0], + trade_id=float(trade_row[1]), + price=float(trade_row[2]), + size=float(trade_row[3]), + side=trade_row[4], + timestamp=timestamp + ) + if timestamp not in trades_by_timestamp: + trades_by_timestamp[timestamp] = [] + trades_by_timestamp[timestamp].append(trade) + + # Get snapshots + cursor = conn.execute(''' + SELECT id, instrument, bids, asks, timestamp + FROM book + ORDER BY timestamp + LIMIT ? + ''', (limit,)) + + snapshot_count = 0 + for row in cursor: + try: + row_id, instrument, bids_text, asks_text, timestamp = row + timestamp_int = int(timestamp) + + # Create snapshot using the same logic as Storage._snapshot_from_row + snapshot = BookSnapshot( + id=row_id, + timestamp=timestamp_int, + bids={}, + asks={}, + trades=trades_by_timestamp.get(timestamp_int, []), + ) + + # Parse bids and asks using the parser + parser.parse_side(bids_text, snapshot.bids) + parser.parse_side(asks_text, snapshot.asks) + + # Only add snapshots that have both bids and asks + if snapshot.bids and snapshot.asks: + book.add_snapshot(snapshot) + snapshot_count += 1 + + if snapshot_count % 1000 == 0: + print(f" 📊 Loaded {snapshot_count} snapshots...") + + except Exception as e: + logging.warning(f"Error parsing snapshot {row[0]}: {e}") + continue + + conn.close() + print(f"✅ Loaded {len(book.snapshots)} snapshots with trades") + return book + +def main(): + print("🚀 USING PRE-CALCULATED METRICS FROM DATABASE") + print("=" * 55) + + # Database path + db_path = Path("../data/OKX/BTC-USDT-25-06-09.db") + + if not db_path.exists(): + print(f"❌ Database not found: {db_path}") + return + + try: + # Load ONLY book snapshots (no metric recalculation) + book = load_book_snapshots_only(db_path, limit=5000) # Start with 5K snapshots + + if not book.snapshots: + print("❌ No snapshots loaded") + return + + print(f"✅ Book loaded: {len(book.snapshots)} snapshots") + print(f"✅ Time range: {book.first_timestamp} to {book.last_timestamp}") + + # Create visualizer + viz = InteractiveVisualizer( + window_seconds=6*3600, # 6-hour bars + port=8050 + ) + + # Set database path so it can load PRE-CALCULATED metrics + viz.set_db_path(db_path) + + # Process book data (will load existing metrics automatically) + print("⚙️ Processing book data and loading existing metrics...") + viz.update_from_book(book) + + print(f"✅ Generated {len(viz._ohlc_data)} OHLC bars") + print(f"✅ Loaded {len(viz._metrics_data)} pre-calculated metrics") + + if viz._ohlc_data: + sample_bar = viz._ohlc_data[0] + print(f"✅ Sample OHLC: O={sample_bar[1]:.2f}, H={sample_bar[2]:.2f}, L={sample_bar[3]:.2f}, C={sample_bar[4]:.2f}") + + print() + print("🌐 LAUNCHING INTERACTIVE DASHBOARD") + print("=" * 55) + print("🚀 Server starting at: http://127.0.0.1:8050") + print("📊 Features available:") + print(" ✅ OHLC candlestick chart") + print(" ✅ Volume bar chart") + print(" ✅ OBI line chart (from existing metrics)") + print(" ✅ CVD line chart (from existing metrics)") + print(" ✅ Synchronized zoom/pan") + print(" ✅ Professional dark theme") + print() + print("⏹️ Press Ctrl+C to stop the server") + print("=" * 55) + + # Launch the dashboard + viz.show() + + except KeyboardInterrupt: + print("\n⏹️ Server stopped by user") + except Exception as e: + print(f"❌ Error: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() diff --git a/storage.py b/storage.py index 02a7654..f902dda 100644 --- a/storage.py +++ b/storage.py @@ -13,7 +13,6 @@ import logging from models import OrderbookLevel, Trade, BookSnapshot, Book, MetricCalculator, Metric from repositories.sqlite_repository import SQLiteOrderflowRepository -from repositories.sqlite_metrics_repository import SQLiteMetricsRepository from parsers.orderbook_parser import OrderbookParser class Storage: @@ -33,49 +32,41 @@ class Storage: self._debug = False self._parser = OrderbookParser(price_cache=self._price_cache, debug=self._debug) - def build_booktick_from_db(self, db_path: Path, db_date: datetime) -> None: + def build_booktick_from_db(self, db_path: Path) -> None: """Hydrate the in-memory `book` from a SQLite database and calculate metrics. Builds a Book instance with sequential snapshots and calculates OBI/CVD metrics. Args: db_path: Path to the SQLite database file. - db_date: Date associated with the database (currently informational). """ - # Reset the book to start fresh self.book = Book() - metrics_repo = SQLiteMetricsRepository(db_path) + metrics_repo = SQLiteOrderflowRepository(db_path) with metrics_repo.connect() as conn: - # Create metrics table if it doesn't exist if not metrics_repo.table_exists(conn, "metrics"): metrics_repo.create_metrics_table(conn) - # Load trades grouped by timestamp - trades_by_timestamp = metrics_repo.load_trades_by_timestamp(conn) + trades = metrics_repo.load_trades(conn) - # Check if we have any orderbook data total_rows = metrics_repo.count_rows(conn, "book") if total_rows == 0: logging.info(f"No orderbook data found in {db_path}") return - # Process orderbook data and calculate metrics rows_iter = metrics_repo.iterate_book_rows(conn) - self._create_snapshots_and_metrics(rows_iter, trades_by_timestamp, total_rows, conn, metrics_repo) + self._create_snapshots_and_metrics(rows_iter, trades, total_rows, conn) - # Log summary logging.info(f"Processed {len(self.book.snapshots)} snapshots with metrics from {db_path}") - def _create_snapshots_and_metrics(self, rows_iter: Iterator[Tuple[int, str, str, int]], trades_by_timestamp: Dict[int, List[Trade]], total_rows: int, conn, metrics_repo: SQLiteMetricsRepository) -> None: + def _create_snapshots_and_metrics(self, rows_iter: Iterator[Tuple[int, str, str, int]], trades: List[Trade], total_rows: int, conn) -> None: """Create BookSnapshot instances and calculate metrics, storing them in database. Args: rows_iter: Iterator yielding (id, bids_text, asks_text, timestamp) - trades_by_timestamp: Dictionary mapping timestamps to lists of trades + trades: List of trades total_rows: Total number of rows in the book table conn: Database connection for storing metrics - metrics_repo: Repository instance for metrics operations """ # Initialize CVD tracking current_cvd = 0.0 @@ -90,11 +81,10 @@ class Storage: last_report_time = start_time for row_id, bids_text, asks_text, timestamp in rows_iter: - snapshot = self._snapshot_from_row(row_id, bids_text, asks_text, timestamp, trades_by_timestamp) + snapshot = self._snapshot_from_row(row_id, bids_text, asks_text, timestamp, trades) if snapshot is not None: # Calculate metrics for this snapshot obi = MetricCalculator.calculate_obi(snapshot) - trades = trades_by_timestamp.get(int(timestamp), []) volume_delta = MetricCalculator.calculate_volume_delta(trades) current_cvd = MetricCalculator.calculate_cvd(current_cvd, volume_delta) best_bid, best_ask = MetricCalculator.get_best_bid_ask(snapshot) @@ -115,6 +105,8 @@ class Storage: # Insert metrics batch when it reaches batch_size if len(metrics_batch) >= batch_size: + # Use the metrics repository directly via connection + metrics_repo = SQLiteOrderflowRepository(Path("dummy")) # Path not used for existing conn metrics_repo.insert_metrics_batch(conn, metrics_batch) conn.commit() metrics_batch = [] @@ -132,15 +124,16 @@ class Storage: # Insert remaining metrics if metrics_batch: + metrics_repo = SQLiteOrderflowRepository(Path("dummy")) # Path not used for existing conn metrics_repo.insert_metrics_batch(conn, metrics_batch) conn.commit() - def _create_snapshots_from_rows(self, rows_iter: Iterator[Tuple[int, str, str, int]], trades_by_timestamp: Dict[int, List[Trade]], total_rows: int) -> None: + def _create_snapshots_from_rows(self, rows_iter: Iterator[Tuple[int, str, str, int]], trades: List[Trade], total_rows: int) -> None: """Create BookSnapshot instances from database rows and add them to the book. Args: rows_iter: Iterator yielding (id, bids_text, asks_text, timestamp) - trades_by_timestamp: Dictionary mapping timestamps to lists of trades + trades: List of trades total_rows: Total number of rows in the book table """ # Get reference to the book @@ -154,7 +147,7 @@ class Storage: last_report_time = start_time for row_id, bids_text, asks_text, timestamp in rows_iter: - snapshot = self._snapshot_from_row(row_id, bids_text, asks_text, timestamp, trades_by_timestamp) + snapshot = self._snapshot_from_row(row_id, bids_text, asks_text, timestamp, trades) if snapshot is not None: book.add_snapshot(snapshot) diff --git a/strategies.py b/strategies.py index a834ae4..72b1b43 100644 --- a/strategies.py +++ b/strategies.py @@ -3,7 +3,7 @@ from typing import Optional, Any, cast, List from pathlib import Path from storage import Book, BookSnapshot from models import MetricCalculator, Metric -from repositories.sqlite_metrics_repository import SQLiteMetricsRepository +from repositories.sqlite_repository import SQLiteOrderflowRepository class DefaultStrategy: """Strategy that calculates and analyzes OBI and CVD metrics from stored data.""" @@ -48,9 +48,9 @@ class DefaultStrategy: return [] try: - metrics_repo = SQLiteMetricsRepository(self._db_path) - with metrics_repo.connect() as conn: - return metrics_repo.load_metrics_by_timerange(conn, start_timestamp, end_timestamp) + repo = SQLiteOrderflowRepository(self._db_path) + with repo.connect() as conn: + return repo.load_metrics_by_timerange(conn, start_timestamp, end_timestamp) except Exception as e: logging.error(f"Error loading stored metrics: {e}") return [] diff --git a/tasks/prd-interactive-visualizer.md b/tasks/prd-interactive-visualizer.md new file mode 100644 index 0000000..02bdca4 --- /dev/null +++ b/tasks/prd-interactive-visualizer.md @@ -0,0 +1,208 @@ +# PRD: Interactive Visualizer with Plotly + Dash + +## Introduction/Overview + +The current orderflow backtest system uses a static matplotlib-based visualizer that displays OHLC candlesticks, volume bars, Order Book Imbalance (OBI), and Cumulative Volume Delta (CVD) charts. This PRD outlines the development of a new interactive visualization system using Plotly + Dash that will provide real-time interactivity, detailed data inspection, and enhanced user experience for cryptocurrency trading analysis. + +The goal is to replace the static visualization with a professional, web-based interactive dashboard that allows traders to explore orderbook metrics with precision and flexibility. + +## Goals + +1. **Replace Static Visualization**: Create a new `InteractiveVisualizer` class using Plotly + Dash +2. **Enable Cross-Chart Interactivity**: Implement synchronized zooming, panning, and time range selection across all charts +3. **Provide Precision Navigation**: Add crosshair cursor with vertical line indicator across all charts +4. **Display Contextual Information**: Show detailed metrics in a side panel when hovering over data points +5. **Support Multiple Time Granularities**: Allow users to adjust time resolution dynamically +6. **Maintain Performance**: Handle large datasets (months of data) with smooth interactions +7. **Preserve Integration**: Seamlessly integrate with existing metrics storage and data processing pipeline + +## User Stories + +### Primary Use Cases +- **US-1**: As a trader, I want to zoom into specific time periods across all charts simultaneously so that I can analyze market behavior during critical moments +- **US-2**: As a trader, I want to see a vertical crosshair line that spans all charts so that I can precisely align data points across OHLC, volume, OBI, and CVD metrics +- **US-3**: As a trader, I want to hover over any data point and see detailed information in a side panel so that I can inspect exact values without cluttering the charts +- **US-4**: As a trader, I want to pan through historical data smoothly so that I can explore different time periods efficiently +- **US-5**: As a trader, I want to reset CVD calculations from a selected point in time so that I can analyze cumulative volume delta from specific market events + +### Secondary Use Cases +- **US-6**: As a trader, I want to adjust time granularity (1min, 5min, 1hour) so that I can view data at different resolutions +- **US-7**: As a trader, I want navigation controls (reset zoom, home button) so that I can quickly return to full data view +- **US-8**: As a trader, I want to select custom time ranges so that I can focus analysis on specific market sessions + +## Functional Requirements + +### Core Interactive Features +1. **F1**: The system must provide synchronized zooming across all four charts (OHLC, Volume, OBI, CVD) +2. **F2**: The system must provide synchronized panning across all four charts with shared X-axis +3. **F3**: The system must display a vertical crosshair line that spans all charts and follows mouse cursor +4. **F4**: The system must show detailed hover information for each chart type: + - OHLC: timestamp, open, high, low, close, spread + - Volume: timestamp, total volume, buy/sell breakdown if available + - OBI: timestamp, OBI value, bid volume, ask volume, imbalance percentage + - CVD: timestamp, CVD value, volume delta, cumulative change + +### User Interface Requirements +5. **F5**: The system must display charts in a 4-row layout with shared X-axis (OHLC on top, Volume, OBI, CVD at bottom) +6. **F6**: The system must provide a side panel on the right displaying detailed information for the current cursor position +7. **F7**: The system must include navigation controls: + - Zoom in/out buttons + - Reset zoom button + - Home view button + - Time range selector +8. **F8**: The system must provide time granularity controls (1min, 5min, 15min, 1hour, 6hour) + +### Data Integration Requirements +9. **F9**: The system must integrate with existing `SQLiteOrderflowRepository` for metrics data loading +10. **F10**: The system must support loading data from multiple database files seamlessly +11. **F11**: The system must maintain the existing `set_db_path()` and `update_from_book()` interface for compatibility +12. **F12**: The system must calculate OHLC bars from snapshots with configurable time windows + +### Performance Requirements +13. **F13**: The system must render charts with <2 second initial load time for datasets up to 1 million data points +14. **F14**: The system must provide smooth zooming and panning interactions with <100ms response time +15. **F15**: The system must efficiently update hover information with <50ms latency + +### CVD Reset Functionality +16. **F16**: The system must allow users to click on any point in the CVD chart to reset cumulative calculation from that timestamp +17. **F17**: The system must visually indicate CVD reset points with markers or annotations +18. **F18**: The system must recalculate and redraw CVD values from the reset point forward + +## Non-Goals (Out of Scope) + +1. **Advanced Drawing Tools**: Trend lines, Fibonacci retracements, or annotation tools +2. **Multiple Instrument Support**: Multi-symbol comparison or overlay charts +3. **Real-time Streaming**: Live data updates or WebSocket integration +4. **Export Functionality**: Chart export to PNG/PDF or data export to CSV +5. **User Authentication**: User accounts, saved layouts, or personalization +6. **Mobile Optimization**: Touch interfaces or responsive mobile design +7. **Advanced Indicators**: Technical analysis indicators beyond OBI/CVD +8. **Alert System**: Price alerts, threshold notifications, or automated signals + +## Design Considerations + +### Chart Layout +- **Layout**: 4-row subplot layout with 80% chart area, 20% side panel +- **Color Scheme**: Professional dark theme with customizable colors +- **Typography**: Clear, readable fonts optimized for financial data +- **Responsive Design**: Adaptable to different screen sizes (desktop focus) + +### Side Panel Design +``` +┌─────────────────┐ +│ Current Data │ +├─────────────────┤ +│ Time: 16:30:45 │ +│ Price: $50,123 │ +│ Volume: 1,234 │ +│ OBI: 0.234 │ +│ CVD: -123.45 │ +├─────────────────┤ +│ Controls │ +│ [Reset CVD] │ +│ [Zoom Reset] │ +│ [Time Range ▼] │ +│ [Granularity ▼] │ +└─────────────────┘ +``` + +### Navigation Controls +- **Zoom**: Mouse wheel, zoom box selection, zoom buttons +- **Pan**: Click and drag, arrow keys, scroll bars +- **Reset**: Double-click to auto-scale, reset button to full view +- **Selection**: Click and drag for time range selection + +## Technical Considerations + +### Architecture Changes +- **New Class**: `InteractiveVisualizer` class separate from existing `Visualizer` +- **Dependencies**: Add `dash`, `plotly`, `dash-bootstrap-components` to requirements +- **Web Server**: Dash development server for local deployment +- **Data Flow**: Maintain existing metrics loading pipeline, adapt to Plotly data structures + +### Integration Points +```python +# Maintain existing interface for compatibility +class InteractiveVisualizer: + def set_db_path(self, db_path: Path) -> None + def update_from_book(self, book: Book) -> None + def show(self) -> None # Launch Dash server instead of plt.show() +``` + +### Data Structure Adaptation +- **OHLC Data**: Convert bars to Plotly candlestick format +- **Metrics Data**: Transform to Plotly time series format +- **Memory Management**: Implement data decimation for large datasets +- **Caching**: Cache processed data to improve interaction performance + +### Technology Stack +- **Frontend**: Dash + Plotly.js for charts +- **Backend**: Python Dash server with existing data pipeline +- **Styling**: Dash Bootstrap Components for professional UI +- **Data Processing**: Pandas for efficient data manipulation + +## Success Metrics + +### User Experience Metrics +1. **Interaction Responsiveness**: 95% of zoom/pan operations complete within 100ms +2. **Data Precision**: 100% accuracy in crosshair positioning and hover data display +3. **Navigation Efficiency**: Users can navigate to specific time periods 3x faster than static charts + +### Technical Performance Metrics +4. **Load Time**: Initial chart rendering completes within 2 seconds for 500k data points +5. **Memory Usage**: Interactive visualizer uses <150% memory compared to static version +6. **Error Rate**: <1% interaction failures or display errors during normal usage + +### Feature Adoption Metrics +7. **Feature Usage**: CVD reset functionality used in >30% of analysis sessions +8. **Time Range Analysis**: Custom time range selection used in >50% of sessions +9. **Granularity Changes**: Time resolution adjustment used in >40% of sessions + +## Implementation Priority + +### Phase 1: Core Interactive Charts (High Priority) +- Basic Plotly + Dash setup +- 4-chart layout with synchronized axes +- Basic zoom, pan, and crosshair functionality +- Integration with existing data pipeline + +### Phase 2: Enhanced Interactivity (High Priority) +- Side panel with hover information +- Navigation controls and buttons +- Time granularity selection +- CVD reset functionality + +### Phase 3: Performance Optimization (Medium Priority) +- Large dataset handling +- Interaction performance tuning +- Memory usage optimization +- Error handling and edge cases + +### Phase 4: Polish and UX (Medium Priority) +- Professional styling and themes +- Enhanced navigation controls +- Time range selection tools +- User experience refinements + +## Open Questions + +1. **Deployment Method**: Should the interactive visualizer run as a local Dash server or be deployable as a standalone web application? + +2. **Data Decimation Strategy**: How should the system handle datasets with millions of points while maintaining interactivity? Should it implement automatic decimation based on zoom level? + +3. **CVD Reset Persistence**: Should CVD reset points be saved to the database or only exist in the current session? + +4. **Multiple Database Sessions**: How should the interactive visualizer handle switching between different database files during the same session? + +5. **Backward Compatibility**: Should the system maintain both static and interactive visualizers, or completely replace the matplotlib implementation? + +6. **Configuration Management**: How should users configure default time granularities, color schemes, and layout preferences? + +7. **Performance Baselines**: What are the acceptable performance thresholds for different dataset sizes and interaction types? + +--- + +**Document Version**: 1.0 +**Created**: Current Date +**Target Audience**: Junior Developer +**Estimated Implementation**: 3-4 weeks for complete feature set diff --git a/tasks/tasks-prd-interactive-visualizer.md b/tasks/tasks-prd-interactive-visualizer.md new file mode 100644 index 0000000..cdc19dc --- /dev/null +++ b/tasks/tasks-prd-interactive-visualizer.md @@ -0,0 +1,74 @@ +# Tasks: Interactive Visualizer with Plotly + Dash + +## Relevant Files + +- `interactive_visualizer.py` - Main InteractiveVisualizer class implementing Plotly + Dash interface +- `tests/test_interactive_visualizer.py` - Unit tests for InteractiveVisualizer class +- `dash_app.py` - Dash application setup and layout configuration +- `tests/test_dash_app.py` - Unit tests for Dash application components +- `dash_callbacks.py` - Dash callback functions for interactivity and data updates +- `tests/test_dash_callbacks.py` - Unit tests for callback functions +- `dash_components.py` - Custom Dash components for side panel and controls +- `tests/test_dash_components.py` - Unit tests for custom components +- `data_adapters.py` - Data transformation utilities for Plotly format conversion +- `tests/test_data_adapters.py` - Unit tests for data adapter functions +- `pyproject.toml` - Updated dependencies including dash, plotly, dash-bootstrap-components +- `main.py` - Updated to support both static and interactive visualizer options + +### Notes + +- Unit tests should be placed in the `tests/` directory following existing project structure +- Use `uv run pytest [optional/path/to/test/file]` to run tests following project conventions +- Dash server will run locally for development, accessible via browser at http://127.0.0.1:8050 +- Maintain backward compatibility with existing matplotlib visualizer + +## Tasks + +- [ ] 1.0 Setup Plotly + Dash Infrastructure and Dependencies + - [ ] 1.1 Add dash, plotly, and dash-bootstrap-components to pyproject.toml dependencies + - [ ] 1.2 Install and verify new dependencies with uv sync + - [ ] 1.3 Create basic dash_app.py with minimal Dash application setup + - [ ] 1.4 Verify Dash server can start and serve a basic "Hello World" page + - [ ] 1.5 Create project structure for interactive visualizer modules + +- [ ] 2.0 Create Core Interactive Chart Layout with Synchronized Axes + - [ ] 2.1 Design 4-subplot layout using plotly.subplots.make_subplots with shared X-axis + - [ ] 2.2 Implement OHLC candlestick chart using plotly.graph_objects.Candlestick + - [ ] 2.3 Implement Volume bar chart using plotly.graph_objects.Bar + - [ ] 2.4 Implement OBI line chart using plotly.graph_objects.Scatter + - [ ] 2.5 Implement CVD line chart using plotly.graph_objects.Scatter + - [ ] 2.6 Configure synchronized zooming and panning across all subplots + - [ ] 2.7 Add vertical crosshair functionality spanning all charts + - [ ] 2.8 Apply professional dark theme and styling to charts + +- [ ] 3.0 Implement Data Integration and Processing Pipeline + - [ ] 3.1 Create InteractiveVisualizer class maintaining set_db_path() and update_from_book() interface + - [ ] 3.2 Implement data_adapters.py for converting Book/Metric data to Plotly format + - [ ] 3.3 Create OHLC data transformation from existing bar calculation logic + - [ ] 3.4 Create metrics data transformation for OBI and CVD time series + - [ ] 3.5 Implement volume data aggregation and formatting + - [ ] 3.6 Add data caching mechanism for improved performance + - [ ] 3.7 Integrate with existing SQLiteOrderflowRepository for metrics loading + - [ ] 3.8 Handle multiple database file support seamlessly + +- [ ] 4.0 Build Interactive Features and Navigation Controls + - [ ] 4.1 Implement zoom in/out functionality with mouse wheel and buttons + - [ ] 4.2 Implement pan functionality with click and drag + - [ ] 4.3 Add reset zoom and home view buttons + - [ ] 4.4 Create time range selector component for custom period selection + - [ ] 4.5 Implement time granularity controls (1min, 5min, 15min, 1hour, 6hour) + - [ ] 4.6 Add keyboard shortcuts for common navigation actions + - [ ] 4.7 Implement smooth interaction performance optimizations (<100ms response) + - [ ] 4.8 Add error handling for interaction edge cases + +- [ ] 5.0 Develop Side Panel with Hover Information and CVD Reset Functionality + - [ ] 5.1 Create side panel layout using dash-bootstrap-components + - [ ] 5.2 Implement hover information display for OHLC data (timestamp, OHLC values, spread) + - [ ] 5.3 Implement hover information display for Volume data (timestamp, volume, buy/sell breakdown) + - [ ] 5.4 Implement hover information display for OBI data (timestamp, OBI value, bid/ask volumes) + - [ ] 5.5 Implement hover information display for CVD data (timestamp, CVD value, volume delta) + - [ ] 5.6 Add CVD reset functionality with click-to-reset on CVD chart + - [ ] 5.7 Implement visual markers for CVD reset points + - [ ] 5.8 Add CVD recalculation logic from reset point forward + - [ ] 5.9 Create control buttons in side panel (Reset CVD, Zoom Reset, etc.) + - [ ] 5.10 Optimize hover information update performance (<50ms latency) diff --git a/tests/test_metrics_repository.py b/tests/test_metrics_repository.py index b326ba2..efa66b1 100644 --- a/tests/test_metrics_repository.py +++ b/tests/test_metrics_repository.py @@ -1,4 +1,4 @@ -"""Tests for SQLiteMetricsRepository table creation and schema validation.""" +"""Tests for SQLiteOrderflowRepository table creation and schema validation.""" import sys import sqlite3 @@ -7,7 +7,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from repositories.sqlite_metrics_repository import SQLiteMetricsRepository +from repositories.sqlite_repository import SQLiteOrderflowRepository from models import Metric @@ -17,7 +17,7 @@ def test_create_metrics_table(): db_path = Path(tmp_file.name) try: - repo = SQLiteMetricsRepository(db_path) + repo = SQLiteOrderflowRepository(db_path) with repo.connect() as conn: # Create metrics table repo.create_metrics_table(conn) @@ -54,7 +54,7 @@ def test_insert_metrics_batch(): db_path = Path(tmp_file.name) try: - repo = SQLiteMetricsRepository(db_path) + repo = SQLiteOrderflowRepository(db_path) with repo.connect() as conn: # Create metrics table repo.create_metrics_table(conn) @@ -94,7 +94,7 @@ def test_load_metrics_by_timerange(): db_path = Path(tmp_file.name) try: - repo = SQLiteMetricsRepository(db_path) + repo = SQLiteOrderflowRepository(db_path) with repo.connect() as conn: # Create metrics table and insert test data repo.create_metrics_table(conn) diff --git a/tests/test_storage_metrics.py b/tests/test_storage_metrics.py index 822d9c8..08d678b 100644 --- a/tests/test_storage_metrics.py +++ b/tests/test_storage_metrics.py @@ -9,7 +9,7 @@ from datetime import datetime sys.path.append(str(Path(__file__).resolve().parents[1])) from storage import Storage -from repositories.sqlite_metrics_repository import SQLiteMetricsRepository +from repositories.sqlite_repository import SQLiteOrderflowRepository def test_storage_calculates_and_stores_metrics(): @@ -60,13 +60,13 @@ def test_storage_calculates_and_stores_metrics(): storage.build_booktick_from_db(db_path, datetime.now()) # Verify metrics were calculated and stored - metrics_repo = SQLiteMetricsRepository(db_path) - with metrics_repo.connect() as conn: + repo = SQLiteOrderflowRepository(db_path) + with repo.connect() as conn: # Check metrics table exists - assert metrics_repo.table_exists(conn, "metrics") + assert repo.table_exists(conn, "metrics") # Load calculated metrics - metrics = metrics_repo.load_metrics_by_timerange(conn, 1000, 1000) + metrics = repo.load_metrics_by_timerange(conn, 1000, 1000) assert len(metrics) == 1 metric = metrics[0] diff --git a/tests/test_strategies_metrics.py b/tests/test_strategies_metrics.py index 9c99d03..7749367 100644 --- a/tests/test_strategies_metrics.py +++ b/tests/test_strategies_metrics.py @@ -9,7 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from strategies import DefaultStrategy from models import Book, BookSnapshot, OrderbookLevel, Metric -from repositories.sqlite_metrics_repository import SQLiteMetricsRepository +from repositories.sqlite_repository import SQLiteOrderflowRepository def test_strategy_uses_metric_calculator(): @@ -41,9 +41,9 @@ def test_strategy_loads_stored_metrics(): try: # Create test database with metrics - metrics_repo = SQLiteMetricsRepository(db_path) - with metrics_repo.connect() as conn: - metrics_repo.create_metrics_table(conn) + repo = SQLiteOrderflowRepository(db_path) + with repo.connect() as conn: + repo.create_metrics_table(conn) # Insert test metrics test_metrics = [ @@ -52,7 +52,7 @@ def test_strategy_loads_stored_metrics(): Metric(snapshot_id=3, timestamp=1002, obi=0.3, cvd=20.0, best_bid=50004.0, best_ask=50005.0), ] - metrics_repo.insert_metrics_batch(conn, test_metrics) + repo.insert_metrics_batch(conn, test_metrics) conn.commit() # Test strategy loading diff --git a/tests/test_visualizer_metrics.py b/tests/test_visualizer_metrics.py deleted file mode 100644 index f4f0171..0000000 --- a/tests/test_visualizer_metrics.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Tests for Visualizer metrics integration.""" - -import sys -import sqlite3 -import tempfile -from pathlib import Path -from unittest.mock import patch - -sys.path.append(str(Path(__file__).resolve().parents[1])) - -from visualizer import Visualizer -from models import Book, BookSnapshot, OrderbookLevel, Metric -from repositories.sqlite_metrics_repository import SQLiteMetricsRepository - - -def test_visualizer_loads_metrics(): - """Test that visualizer can load stored metrics from database.""" - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file: - db_path = Path(tmp_file.name) - - try: - # Create test database with metrics - metrics_repo = SQLiteMetricsRepository(db_path) - with metrics_repo.connect() as conn: - metrics_repo.create_metrics_table(conn) - - # Insert test metrics - test_metrics = [ - Metric(snapshot_id=1, timestamp=1000, obi=0.1, cvd=10.0, best_bid=50000.0, best_ask=50001.0), - Metric(snapshot_id=2, timestamp=1060, obi=0.2, cvd=15.0, best_bid=50002.0, best_ask=50003.0), - Metric(snapshot_id=3, timestamp=1120, obi=-0.1, cvd=12.0, best_bid=50004.0, best_ask=50005.0), - ] - - metrics_repo.insert_metrics_batch(conn, test_metrics) - conn.commit() - - # Test visualizer - visualizer = Visualizer(window_seconds=60, max_bars=200) - visualizer.set_db_path(db_path) - - # Load metrics directly to test the method - loaded_metrics = visualizer._load_stored_metrics(1000, 1120) - - assert len(loaded_metrics) == 3 - assert loaded_metrics[0].obi == 0.1 - assert loaded_metrics[0].cvd == 10.0 - assert loaded_metrics[1].obi == 0.2 - assert loaded_metrics[2].obi == -0.1 - - finally: - db_path.unlink(missing_ok=True) - - -def test_visualizer_handles_no_database(): - """Test that visualizer handles gracefully when no database path is set.""" - visualizer = Visualizer(window_seconds=60, max_bars=200) - - # No database path set - should return empty list - metrics = visualizer._load_stored_metrics(1000, 2000) - assert metrics == [] - - -def test_visualizer_handles_invalid_database(): - """Test that visualizer handles invalid database paths gracefully.""" - visualizer = Visualizer(window_seconds=60, max_bars=200) - visualizer.set_db_path(Path("nonexistent.db")) - - # Should handle error gracefully and return empty list - metrics = visualizer._load_stored_metrics(1000, 2000) - assert metrics == [] - - -@patch('matplotlib.pyplot.subplots') -def test_visualizer_creates_four_subplots(mock_subplots): - """Test that visualizer creates four subplots for OHLC, Volume, OBI, and CVD.""" - # Mock the subplots creation - mock_fig = type('MockFig', (), {})() - mock_ax_ohlc = type('MockAx', (), {})() - mock_ax_volume = type('MockAx', (), {})() - mock_ax_obi = type('MockAx', (), {})() - mock_ax_cvd = type('MockAx', (), {})() - - mock_subplots.return_value = (mock_fig, (mock_ax_ohlc, mock_ax_volume, mock_ax_obi, mock_ax_cvd)) - - # Create visualizer - visualizer = Visualizer(window_seconds=60, max_bars=200) - - # Verify subplots were created correctly - mock_subplots.assert_called_once_with(4, 1, figsize=(12, 10), sharex=True) - assert visualizer.ax_ohlc == mock_ax_ohlc - assert visualizer.ax_volume == mock_ax_volume - assert visualizer.ax_obi == mock_ax_obi - assert visualizer.ax_cvd == mock_ax_cvd - - -def test_visualizer_update_from_book_with_empty_book(): - """Test that visualizer handles empty book gracefully.""" - with patch('matplotlib.pyplot.subplots') as mock_subplots: - # Mock the subplots creation - mock_fig = type('MockFig', (), {'canvas': type('MockCanvas', (), {'draw_idle': lambda: None})()})() - mock_axes = [type('MockAx', (), {'clear': lambda: None})() for _ in range(4)] - mock_subplots.return_value = (mock_fig, tuple(mock_axes)) - - visualizer = Visualizer(window_seconds=60, max_bars=200) - - # Test with empty book - book = Book() - - # Should handle gracefully without errors - with patch('logging.warning') as mock_warning: - visualizer.update_from_book(book) - mock_warning.assert_called_once_with("Book has no snapshots to visualize") diff --git a/uv.lock b/uv.lock index 73d73fc..059094d 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,66 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -98,6 +158,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "dash" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/37/8b5621e0a0b3c6e81a8b6cd3f033aa4b750f53e288dd1a494a887a8a06e9/dash-3.2.0.tar.gz", hash = "sha256:93300b9b99498f8b8ed267e61c455b4ee1282c7e4d4b518600eec87ce6ddea55", size = 7558708, upload-time = "2025-07-31T19:18:59.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/36/e0010483ca49b9bf6f389631ccea07b3ff6b678d14d8c7a0a4357860c36a/dash-3.2.0-py3-none-any.whl", hash = "sha256:4c1819588d83bed2cbcf5807daa5c2380c8c85789a6935a733f018f04ad8a6a2", size = 7900661, upload-time = "2025-07-31T19:18:50.679Z" }, +] + +[[package]] +name = "dash-bootstrap-components" +version = "2.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/d4/5b7da808ff5acb3a6ca702f504d8ef05bc7d4c475b18dadefd783b1120c3/dash_bootstrap_components-2.0.4.tar.gz", hash = "sha256:c3206c0923774bbc6a6ddaa7822b8d9aa5326b0d3c1e7cd795cc975025fe2484", size = 115599, upload-time = "2025-08-20T19:42:09.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/38/1efeec8b4d741c09ccd169baf8a00c07a0176b58e418d4cd0c30dffedd22/dash_bootstrap_components-2.0.4-py3-none-any.whl", hash = "sha256:767cf0084586c1b2b614ccf50f79fe4525fdbbf8e3a161ed60016e584a14f5d1", size = 204044, upload-time = "2025-08-20T19:42:07.928Z" }, +] + +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, +] + [[package]] name = "fonttools" version = "4.59.1" @@ -139,6 +248,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/64/9d606e66d498917cd7a2ff24f558010d42d6fd4576d9dd57f0bd98333f5a/fonttools-4.59.1-py3-none-any.whl", hash = "sha256:647db657073672a8330608970a984d51573557f328030566521bc03415535042", size = 1130094, upload-time = "2025-08-14T16:28:12.048Z" }, ] +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -148,6 +278,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -232,6 +383,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + [[package]] name = "matplotlib" version = "3.10.5" @@ -295,6 +484,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "narwhals" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/8f/6b3d8c19540eaaa50778a8bbbe54e025d3f93aca6cdd5a4de3044c36f83c/narwhals-2.2.0.tar.gz", hash = "sha256:f6a34f2699acabe2c17339c104f0bec28b9f7a55fbc7f8d485d49bea72d12b8a", size = 547070, upload-time = "2025-08-25T07:51:58.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/54/1ecca75e51d7da8ca53d1ffa8636ef9077a6eaa31f43ade71360b3e6449a/narwhals-2.2.0-py3-none-any.whl", hash = "sha256:2b5e3d61a486fa4328c286b0c8018b3e781a964947ff725d66ba12f6d5ca3d2a", size = 401021, upload-time = "2025-08-25T07:51:56.97Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "numpy" version = "2.3.2" @@ -363,7 +570,11 @@ name = "orderflow-backtest" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "dash" }, + { name = "dash-bootstrap-components" }, { name = "matplotlib" }, + { name = "pandas" }, + { name = "plotly" }, { name = "pyqt5" }, { name = "typer" }, ] @@ -375,7 +586,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "dash", specifier = ">=2.18.0" }, + { name = "dash-bootstrap-components", specifier = ">=1.5.0" }, { name = "matplotlib", specifier = ">=3.10.5" }, + { name = "pandas", specifier = ">=2.0.0" }, + { name = "plotly", specifier = ">=5.18.0" }, { name = "pyqt5", specifier = ">=5.15.11" }, { name = "typer", specifier = ">=0.16.1" }, ] @@ -392,6 +607,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pandas" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, + { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" }, + { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" }, + { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/27/64/a2f7bf678af502e16b472527735d168b22b7824e45a4d7e96a4fbb634b59/pandas-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c6ecbac99a354a051ef21c5307601093cb9e0f4b1855984a084bfec9302699e", size = 11531061, upload-time = "2025-08-21T10:27:34.647Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/c3d21b2b7769ef2f4c2b9299fcadd601efa6729f1357a8dbce8dd949ed70/pandas-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6f048aa0fd080d6a06cc7e7537c09b53be6642d330ac6f54a600c3ace857ee9", size = 10668666, upload-time = "2025-08-21T10:27:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/f775ba76ecfb3424d7f5862620841cf0edb592e9abd2d2a5387d305fe7a8/pandas-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0064187b80a5be6f2f9c9d6bdde29372468751dfa89f4211a3c5871854cfbf7a", size = 11332835, upload-time = "2025-08-21T10:27:40.188Z" }, + { url = "https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b", size = 12057211, upload-time = "2025-08-21T10:27:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/0b/9d/2df913f14b2deb9c748975fdb2491da1a78773debb25abbc7cbc67c6b549/pandas-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:114c2fe4f4328cf98ce5716d1532f3ab79c5919f95a9cfee81d9140064a2e4d6", size = 12749277, upload-time = "2025-08-21T10:27:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/da1a2417026bd14d98c236dba88e39837182459d29dcfcea510b2ac9e8a1/pandas-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:48fa91c4dfb3b2b9bfdb5c24cd3567575f4e13f9636810462ffed8925352be5a", size = 13415256, upload-time = "2025-08-21T10:27:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/22/3c/f2af1ce8840ef648584a6156489636b5692c162771918aa95707c165ad2b/pandas-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:12d039facec710f7ba305786837d0225a3444af7bbd9c15c32ca2d40d157ed8b", size = 10982579, upload-time = "2025-08-21T10:28:08.435Z" }, + { url = "https://files.pythonhosted.org/packages/f3/98/8df69c4097a6719e357dc249bf437b8efbde808038268e584421696cbddf/pandas-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c624b615ce97864eb588779ed4046186f967374185c047070545253a52ab2d57", size = 12028163, upload-time = "2025-08-21T10:27:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/f95cbcbea319f349e10ff90db488b905c6883f03cbabd34f6b03cbc3c044/pandas-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0cee69d583b9b128823d9514171cabb6861e09409af805b54459bd0c821a35c2", size = 11391860, upload-time = "2025-08-21T10:27:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1b/6a984e98c4abee22058aa75bfb8eb90dce58cf8d7296f8bc56c14bc330b0/pandas-2.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2319656ed81124982900b4c37f0e0c58c015af9a7bbc62342ba5ad07ace82ba9", size = 11309830, upload-time = "2025-08-21T10:27:56.957Z" }, + { url = "https://files.pythonhosted.org/packages/15/d5/f0486090eb18dd8710bf60afeaf638ba6817047c0c8ae5c6a25598665609/pandas-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b37205ad6f00d52f16b6d09f406434ba928c1a1966e2771006a9033c736d30d2", size = 11883216, upload-time = "2025-08-21T10:27:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/10/86/692050c119696da19e20245bbd650d8dfca6ceb577da027c3a73c62a047e/pandas-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:837248b4fc3a9b83b9c6214699a13f069dc13510a6a6d7f9ba33145d2841a012", size = 12699743, upload-time = "2025-08-21T10:28:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/612123674d7b17cf345aad0a10289b2a384bff404e0463a83c4a3a59d205/pandas-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d2c3554bd31b731cd6490d94a28f3abb8dd770634a9e06eb6d2911b9827db370", size = 13186141, upload-time = "2025-08-21T10:28:05.377Z" }, +] + [[package]] name = "pillow" version = "11.3.0" @@ -458,6 +707,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] +[[package]] +name = "plotly" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/64/850de5076f4436410e1ce4f6a69f4313ef6215dfea155f3f6559335cad29/plotly-6.3.0.tar.gz", hash = "sha256:8840a184d18ccae0f9189c2b9a2943923fd5cae7717b723f36eef78f444e5a73", size = 6923926, upload-time = "2025-08-12T20:22:14.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/a9/12e2dc726ba1ba775a2c6922d5d5b4488ad60bdab0888c337c194c8e6de8/plotly-6.3.0-py3-none-any.whl", hash = "sha256:7ad806edce9d3cdd882eaebaf97c0c9e252043ed1ed3d382c3e3520ec07806d4", size = 9791257, upload-time = "2025-08-12T20:22:09.205Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -556,6 +818,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + [[package]] name = "rich" version = "14.1.0" @@ -569,6 +864,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, ] +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -610,3 +914,42 @@ sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09 wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/visualizer.py b/visualizer.py deleted file mode 100644 index ea46c8a..0000000 --- a/visualizer.py +++ /dev/null @@ -1,256 +0,0 @@ -# Set Qt5Agg as the default backend before importing pyplot -import os -import matplotlib -matplotlib.use('Qt5Agg') - -import logging -import matplotlib.pyplot as plt -import matplotlib.dates as mdates -from matplotlib.patches import Rectangle -from datetime import datetime, timezone -from collections import deque -from typing import Deque, Optional -from pathlib import Path -from storage import Book, BookSnapshot -from models import Metric -from repositories.sqlite_metrics_repository import SQLiteMetricsRepository - - -class Visualizer: - """Render OHLC candles, volume, OBI and CVD charts from order book data. - - Aggregates mid-prices into OHLC bars and displays OBI/CVD metrics beneath. - Uses Qt5Agg backend for interactive charts. - - Public methods: - - update_from_book: process all snapshots from a Book and display charts - - set_db_path: set database path for loading stored metrics - - flush: finalize and draw the last in-progress bar - - show: display the Matplotlib window using Qt5Agg - """ - - def __init__(self, window_seconds: int = 60, max_bars: int = 200) -> None: - # Create subplots: OHLC on top, Volume below, OBI and CVD at bottom - self.fig, (self.ax_ohlc, self.ax_volume, self.ax_obi, self.ax_cvd) = plt.subplots(4, 1, figsize=(12, 10), sharex=True) - self.window_seconds = int(max(1, window_seconds)) - self.max_bars = int(max(1, max_bars)) - self._db_path: Optional[Path] = None - - # Bars buffer: list of tuples (start_ts, open, high, low, close) - self._bars: Deque[tuple[int, float, float, float, float, float]] = deque(maxlen=self.max_bars) - - # Current in-progress bucket state - self._current_bucket_ts: Optional[int] = None - self._open: Optional[float] = None - self._high: Optional[float] = None - self._low: Optional[float] = None - self._close: Optional[float] = None - self._volume: float = 0.0 - - def _bucket_start(self, ts: int) -> int: - return int(ts) - (int(ts) % self.window_seconds) - - def _normalize_ts_seconds(self, ts: int) -> int: - """Return epoch seconds from possibly ms/us timestamps. - - Heuristic based on magnitude: - - >1e14: microseconds → divide by 1e6 - - >1e11: milliseconds → divide by 1e3 - - else: seconds - """ - its = int(ts) - if its > 100_000_000_000_000: # > 1e14 → microseconds - return its // 1_000_000 - if its > 100_000_000_000: # > 1e11 → milliseconds - return its // 1_000 - return its - - def set_db_path(self, db_path: Path) -> None: - """Set the database path for loading stored metrics.""" - self._db_path = db_path - - def _load_stored_metrics(self, start_timestamp: int, end_timestamp: int) -> list[Metric]: - """Load stored metrics from database for the given time range.""" - if not self._db_path: - return [] - - try: - metrics_repo = SQLiteMetricsRepository(self._db_path) - with metrics_repo.connect() as conn: - return metrics_repo.load_metrics_by_timerange(conn, start_timestamp, end_timestamp) - except Exception as e: - logging.error(f"Error loading metrics for visualization: {e}") - return [] - - def _append_current_bar(self) -> None: - if self._current_bucket_ts is None or self._open is None: - return - self._bars.append( - ( - self._current_bucket_ts, - float(self._open), - float(self._high if self._high is not None else self._open), - float(self._low if self._low is not None else self._open), - float(self._close if self._close is not None else self._open), - float(self._volume), - ) - ) - - def _draw(self) -> None: - # Clear all subplots - self.ax_ohlc.clear() - self.ax_volume.clear() - self.ax_obi.clear() - self.ax_cvd.clear() - - if not self._bars: - self.fig.canvas.draw_idle() - return - - day_seconds = 24 * 60 * 60 - width = self.window_seconds / day_seconds - - # Draw OHLC candlesticks and extract volume data - volume_data = [] - timestamps_ohlc = [] - - for start_ts, open_, high_, low_, close_, volume in self._bars: - # Collect volume data - dt = datetime.fromtimestamp(start_ts, tz=timezone.utc).replace(tzinfo=None) - x = mdates.date2num(dt) - volume_data.append((x, volume)) - timestamps_ohlc.append(x) - - # Wick - self.ax_ohlc.vlines(x + width / 2.0, low_, high_, color="black", linewidth=1.0) - - # Body - lower = min(open_, close_) - height = max(1e-12, abs(close_ - open_)) - color = "green" if close_ >= open_ else "red" - rect = Rectangle((x, lower), width, height, facecolor=color, edgecolor=color, linewidth=1.0) - self.ax_ohlc.add_patch(rect) - - # Plot volume bars - if volume_data: - volumes_x = [v[0] for v in volume_data] - volumes_y = [v[1] for v in volume_data] - self.ax_volume.bar(volumes_x, volumes_y, width=width, alpha=0.7, color='blue', align='center') - - # Draw metrics if available - if self._bars: - first_ts = self._bars[0][0] - last_ts = self._bars[-1][0] - metrics = self._load_stored_metrics(first_ts, last_ts + self.window_seconds) - - if metrics: - # Prepare data for plotting - timestamps = [mdates.date2num(datetime.fromtimestamp(m.timestamp / 1000, tz=timezone.utc).replace(tzinfo=None)) for m in metrics] - obi_values = [m.obi for m in metrics] - cvd_values = [m.cvd for m in metrics] - - # Plot OBI and CVD - self.ax_obi.plot(timestamps, obi_values, 'b-', linewidth=1, label='OBI') - self.ax_obi.axhline(y=0, color='gray', linestyle='--', alpha=0.5) - - self.ax_cvd.plot(timestamps, cvd_values, 'r-', linewidth=1, label='CVD') - - # Configure axes - self.ax_ohlc.set_title("Mid-price OHLC") - self.ax_ohlc.set_ylabel("Price") - - self.ax_volume.set_title("Volume") - self.ax_volume.set_ylabel("Volume") - - self.ax_obi.set_title("Order Book Imbalance (OBI)") - self.ax_obi.set_ylabel("OBI") - self.ax_obi.set_ylim(-1.1, 1.1) - - self.ax_cvd.set_title("Cumulative Volume Delta (CVD)") - self.ax_cvd.set_ylabel("CVD") - self.ax_cvd.set_xlabel("Time (UTC)") - - # Format time axis for bottom subplot only - self.ax_cvd.xaxis_date() - self.ax_cvd.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) - - self.fig.tight_layout() - self.fig.canvas.draw_idle() - - def update_from_book(self, book: Book) -> None: - """Update the visualizer with all snapshots from the book. - - Uses best bid/ask to compute mid-price; aggregates into OHLC bars. - Processes all snapshots in chronological order. - """ - if not book.snapshots: - logging.warning("Book has no snapshots to visualize") - return - - # Reset state before processing all snapshots - self._bars.clear() - self._current_bucket_ts = None - self._open = self._high = self._low = self._close = None - self._volume = 0.0 - - logging.info(f"Visualizing {len(book.snapshots)} snapshots") - - # Process all snapshots in chronological order - snapshot_count = 0 - for snapshot in sorted(book.snapshots, key=lambda s: s.timestamp): - snapshot_count += 1 - if not snapshot.bids or not snapshot.asks: - continue - - try: - best_bid = max(snapshot.bids.keys()) - best_ask = min(snapshot.asks.keys()) - except (ValueError, TypeError): - continue - - mid = (float(best_bid) + float(best_ask)) / 2.0 - ts_raw = int(snapshot.timestamp) - ts = self._normalize_ts_seconds(ts_raw) - bucket_ts = self._bucket_start(ts) - - # Calculate volume from trades in this snapshot - snapshot_volume = sum(trade.size for trade in snapshot.trades) - - # New bucket: close and store previous bar - if self._current_bucket_ts is None: - self._current_bucket_ts = bucket_ts - self._open = self._high = self._low = self._close = mid - self._volume = snapshot_volume - elif bucket_ts != self._current_bucket_ts: - self._append_current_bar() - self._current_bucket_ts = bucket_ts - self._open = self._high = self._low = self._close = mid - self._volume = snapshot_volume - else: - # Update current bucket OHLC and accumulate volume - if self._high is None or mid > self._high: - self._high = mid - if self._low is None or mid < self._low: - self._low = mid - self._close = mid - self._volume += snapshot_volume - - # Finalize the last bar - self._append_current_bar() - - logging.info(f"Created {len(self._bars)} OHLC bars from {snapshot_count} valid snapshots") - - # Draw all bars - self._draw() - - def flush(self) -> None: - """Finalize the in-progress bar and redraw.""" - self._append_current_bar() - # Reset current state (optional: keep last bucket running) - self._current_bucket_ts = None - self._open = self._high = self._low = self._close = None - self._volume = 0.0 - self._draw() - - def show(self) -> None: - plt.show() \ No newline at end of file diff --git a/visualizer_test.py b/visualizer_test.py deleted file mode 100644 index f7bbda5..0000000 --- a/visualizer_test.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Interactive demo for the Visualizer; run manually, not as a test.""" - -import random -from datetime import datetime - -from visualizer import Visualizer -from storage import Book, BookSnapshot, OrderbookLevel - - -def demo_visualizer_creates_single_bar_on_flush() -> None: - vis = Visualizer(window_seconds=60, max_bars=10) - - book = Book() - ts = datetime.now().timestamp() - - snapshot = BookSnapshot(timestamp=int(ts)) - for r in range(100): - snapshot.bids[100000 + random.random() * 100] = OrderbookLevel( - price=100000 + random.random() * 100, - size=1.0, - liquidation_count=0, - order_count=1, - ) - snapshot.asks[100000 + random.random() * 100] = OrderbookLevel( - price=100000 + random.random() * 100, - size=1.0, - liquidation_count=0, - order_count=1, - ) - - book.add_snapshot(snapshot) - - vis.update_from_book(book) - vis.flush() - vis.show() - - -if __name__ == "__main__": - demo_visualizer_creates_single_bar_on_flush() \ No newline at end of file