diff --git a/WIKI/dashboard_configuration.md b/WIKI/dashboard_configuration.md new file mode 100644 index 0000000..10bd551 --- /dev/null +++ b/WIKI/dashboard_configuration.md @@ -0,0 +1,90 @@ +# Dashboard Configuration Guide + +This guide explains how to configure which tables are displayed on the live terminal dashboard. + +## Overview + +The dashboard is rendered by the `DashboardRenderer` class in `dashboard.py`. It currently supports two tables: + +| Table Key | Title | Description | +|-----------|-------|-------------| +| `market` | Market Dashboard | Live prices, best bid/ask, gap, and direction for watched coins | +| `strategies` | Strategies | Signal, signal price, last change, timeframe, and size for each enabled strategy | + +Each table can be independently enabled or disabled. When only one table is visible, it takes the full terminal width. When both are visible, they split side-by-side. + +## Configuration + +### Default Visibility + +The default table visibility is set when `DashboardRenderer` is instantiated in `main_app.py` (`MainApp.__init__`): + +```python +self.renderer = DashboardRenderer(table_visibility={ + "market": True, + "strategies": False, +}) +``` + +By default, the **market table is enabled** and the **strategies table is disabled**. + +### Changing Default Visibility + +To change which tables are shown by default, edit the `table_visibility` dict in `main_app.py` (`MainApp.__init__`, line 349): + +```python +self.renderer = DashboardRenderer(table_visibility={ + "market": True, + "strategies": True, # enable strategies table +}) +``` + +### Runtime Toggling + +Tables can be toggled at runtime through the `MainApp.toggle_table()` method, which delegates to `DashboardRenderer.toggle_table()`: + +```python +# Flip the strategies table on/off +app.toggle_table("strategies") + +# Explicitly enable +app.toggle_table("strategies", enabled=True) + +# Explicitly disable +app.toggle_table("strategies", enabled=False) +``` + +The same methods are available directly on the renderer: + +```python +renderer = DashboardRenderer() +renderer.toggle_table("market") # flip +renderer.toggle_table("strategies", False) # disable +``` + +## How It Works + +### DashboardRenderer (`dashboard.py`) + +- `__init__(console=None, table_visibility=None)` — accepts an optional `table_visibility` dict. If not provided, defaults to `{"market": True, "strategies": False}`. +- `toggle_table(table_name, enabled=None)` — flips the visibility state when `enabled` is `None`, or sets it to the given boolean. Raises `ValueError` for unknown table names. +- `build_layout(...)` — conditionally builds only the tables that are enabled, then arranges them: + - **One table:** `Layout(table)` — full width + - **Two tables:** `Layout.split_row(Layout(t1), Layout(t2))` — side-by-side + - **Zero tables:** empty `Layout` + +### MainApp (`main_app.py`) + +- `MainApp.__init__` creates the `DashboardRenderer` with the `table_visibility` config. +- `MainApp.toggle_table(table_name, enabled=None)` delegates to the renderer for runtime toggling. +- `MainApp.display_dashboard()` calls `renderer.build_layout()` which respects the current visibility settings. + +## File Reference + +| File | Line | Description | +|------|------|-------------| +| `dashboard.py` | 22 | `DashboardRenderer.__init__` — accepts `table_visibility` parameter | +| `dashboard.py` | 32 | `toggle_table()` method — flips or sets table visibility | +| `dashboard.py` | 172 | `build_layout()` — conditionally includes tables based on visibility | +| `main_app.py` | 349 | `MainApp.__init__` — sets default `table_visibility` | +| `main_app.py` | 385 | `MainApp.toggle_table()` — runtime toggle method | diff --git a/WIKI/indicators.md b/WIKI/indicators.md new file mode 100644 index 0000000..8bcf9ea --- /dev/null +++ b/WIKI/indicators.md @@ -0,0 +1,239 @@ +# Indicators Guide + +This guide explains how to configure and use the Indicators table on the live terminal dashboard. + +## Overview + +The Indicators table displays computed financial indicators (e.g., WTI/BRENT ratio, live prices, moving averages, RSI) with their current value, 1-hour and 1-day percentage changes, and deviation from a long-term average. + +The system is **config-driven** — new indicators are added by editing `_data/indicators.json`. No code changes are required for standard indicator types. + +## Dashboard Table + +The Indicators table is displayed below the Market table in the dashboard. It shows: + +| Column | Description | +|--------|-------------| +| `#` | Indicator number | +| `Indicator` | Display name from config | +| `Value` | Current indicator value | +| `1h Change` | Percentage change over the last 1 hour | +| `1D Change` | Percentage change over the last 1 day | +| `Deviation` | Deviation from the long-term average | + +Changes are color-coded: **green** for positive, **red** for negative, **yellow** for neutral. + +## Configuration + +### Default Visibility + +The Indicators table is enabled by default. The visibility is set in `main_app.py` (`MainApp.__init__`): + +```python +self.renderer = DashboardRenderer(table_visibility={ + "market": True, + "strategies": False, + "indicators": True, +}) +``` + +### Runtime Toggling + +Toggle the Indicators table at runtime: + +```python +app.toggle_table("indicators") # flip on/off +app.toggle_table("indicators", enabled=True) # explicitly enable +app.toggle_table("indicators", enabled=False) # explicitly disable +``` + +## Indicator Types + +The following indicator types are supported in `_data/indicators.json`: + +### `ratio` — A/B Ratio + +Computes `numerator / denominator`. + +```json +"wti_brent_ratio": { + "display_name": "WTI/BRENT", + "type": "ratio", + "numerator": "xyz:CL", + "denominator": "xyz:BRENTOIL", + "changes": ["1h", "1d"], + "show_deviation": true +} +``` + +- **Value**: `live(numerator) / live(denominator)` from latest 1m candle closes +- **1h Change**: compares to ratio from 1h candle close prices +- **1D Change**: compares to ratio from 1d candle close prices +- **Deviation**: `(current - long_avg) / long_avg * 100`, where `long_avg` is the mean of daily ratios over all available history + +### `price` — Single Price + +```json +"wti_price": { + "display_name": "WTI", + "type": "price", + "coin": "xyz:CL", + "changes": ["1h", "1d"], + "show_deviation": true +} +``` + +- **Value**: latest close price from `{coin}_1m` table +- **1h/1D Change**: compares to close from 1h/1d candle tables +- **Deviation**: `(current - long_avg) / long_avg * 100`, where `long_avg` is the mean of daily closes + +### `spread` — Price Difference + +Computes `numerator - denominator`. + +```json +"wti_brent_spread": { + "display_name": "WTI-BRENT Spread", + "type": "spread", + "numerator": "xyz:CL", + "denominator": "xyz:BRENTOIL", + "changes": ["1h", "1d"], + "show_deviation": true +} +``` + +### `diff_pct` — Percentage Difference + +Computes `(numerator - denominator) / denominator * 100`. + +```json +"wti_brent_diff": { + "display_name": "WTI-BRENT Diff%", + "type": "diff_pct", + "numerator": "xyz:CL", + "denominator": "xyz:BRENTOIL", + "changes": ["1h", "1d"], + "show_deviation": true +} +``` + +### `ma` — Moving Average + +```json +"wti_ma_20": { + "display_name": "WTI MA(20)", + "type": "ma", + "coin": "xyz:CL", + "timeframe": "1h", + "period": 20, + "changes": ["1h", "1d"], + "show_deviation": true +} +``` + +- **Value**: latest SMA value on the specified timeframe +- **1h Change**: compares to MA value from 1h candle table +- **1D Change**: compares to MA value from 1d candle table +- **Deviation**: `(current_price - MA) / MA * 100` (how far the live price is from the MA) + +### `rsi` — Relative Strength Index + +```json +"wti_rsi_14": { + "display_name": "WTI RSI(14)", + "type": "rsi", + "coin": "xyz:CL", + "timeframe": "1h", + "period": 14, + "changes": ["1h", "1d"], + "show_deviation": true +} +``` + +- **Value**: latest RSI value (0-100) on the specified timeframe +- **1h/1D Change**: absolute change in RSI points +- **Deviation**: `RSI - 50` (deviation from neutral) + +### `custom` — Custom Function + +Calls a user-defined Python function. + +```json +"custom_indicator": { + "display_name": "My Custom Indicator", + "type": "custom", + "module": "indicators.custom_indicators", + "function": "my_custom_calc", + "args": {"param1": "value1"}, + "changes": ["1h", "1d"], + "show_deviation": true +} +``` + +The custom function must accept `db_path` as the first argument, plus any `args` from the config, and return a dict: + +```python +def my_custom_calc(db_path, **kwargs): + return { + "value": 0.96611, + "reference": 0.96044, + "changes": {"1h": 0.12, "1d": -0.45}, + "deviation": 0.59 + } +} +``` + +## Data Sources + +All indicator calculations read from the SQLite database `_data/market_data.db`: + +- **Live value**: latest close price from `{coin}_1m` candle table (updated in real-time by `live_candle_fetcher.py`) +- **1h change**: close price from `{coin}_1h` candle table (second-to-last completed 1h candle) +- **1D change**: close price from `{coin}_1d` candle table (second-to-last completed 1d candle) +- **Reference value**: mean of daily values over all available historical data + +## Process Architecture + +``` +indicators_fetcher.py (subprocess, runs every 30s) + | + +---> indicators.py (IndicatorCalculator) + | | + | +---> _data/market_data.db (SQLite candle data) + | +---> _data/indicators.json (config) + | + +---> _logs/indicators_status.json (output) + | + +---> main_app.py (MainApp.read_indicators_status) + | + +---> dashboard.py (DashboardRenderer.build_indicators_table) +``` + +## File Reference + +| File | Description | +|------|-------------| +| `_data/indicators.json` | Indicator definitions (config) | +| `indicators.py` | `IndicatorCalculator` class — computation logic | +| `indicators_fetcher.py` | Standalone script — runs in a loop, computes indicators, writes JSON | +| `dashboard.py` | `DashboardRenderer.build_indicators_table()` — renders the table | +| `main_app.py` | `run_indicators_fetcher()` — process target; `MainApp.read_indicators_status()` — reads JSON | +| `_logs/indicators_status.json` | Output file with computed indicator values | + +## Adding a New Indicator + +1. Edit `_data/indicators.json` and add a new entry: + +```json +"my_new_indicator": { + "display_name": "My Indicator", + "type": "price", + "coin": "BTC", + "changes": ["1h", "1d"], + "show_deviation": true +} +``` + +2. Restart the application (`python main_app.py`). The Indicators Fetcher will automatically pick up the new config on its next run. + +No code changes are needed for standard indicator types (`ratio`, `price`, `spread`, `diff_pct`, `ma`, `rsi`). For custom calculations, use the `custom` type. diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 0000000..ef4f281 --- /dev/null +++ b/dashboard.py @@ -0,0 +1,254 @@ +""" +Dashboard rendering module using rich. +Provides DashboardRenderer for building rich terminal tables and layouts. +""" + +from datetime import datetime, timezone + +try: + from rich.console import Console, Group + from rich.table import Table + from rich.live import Live + from rich.layout import Layout + from rich.text import Text + from rich.padding import Padding + RICH_AVAILABLE = True +except ImportError: + RICH_AVAILABLE = False + + +class DashboardRenderer: + """Encapsulates all rich-based dashboard rendering logic.""" + + def __init__(self, console=None, table_visibility=None): + if not RICH_AVAILABLE: + raise ImportError("rich is not available. Install with: pip install rich") + self.console = console or Console() + self.previous_prices = {} + self.table_visibility = table_visibility or { + "market": True, + "strategies": False, + "indicators": True, + } + + def toggle_table(self, table_name, enabled=None): + """Toggle a table's visibility on the dashboard. + + Args: + table_name: The key of the table to toggle (e.g. "market", "strategies"). + enabled: If None, flips the current state. Otherwise sets to the given value. + + Returns: + The new visibility state for the table. + """ + if table_name not in self.table_visibility: + raise ValueError(f"Unknown table: {table_name}") + if enabled is None: + self.table_visibility[table_name] = not self.table_visibility[table_name] + else: + self.table_visibility[table_name] = enabled + return self.table_visibility[table_name] + + def _format_price(self, price_val, width=10): + """Format a price value with appropriate precision.""" + try: + price_float = float(price_val) + if price_float < 1: + return f"{price_float:>{width}.6f}" + elif price_float < 100: + return f"{price_float:>{width}.4f}" + else: + return f"{price_float:>{width}.2f}" + except (ValueError, TypeError): + return f"{'Loading...':>{width}}" + + def build_market_table(self, watched_coins, prices, display_names): + """Build the market dashboard table.""" + table = Table(title="Market Dashboard", show_header=True, header_style="bold cyan", title_style="bold white") + table.add_column("#", justify="right", style="dim", width=3) + table.add_column("Coin", justify="center", width=8) + table.add_column("Best Bid", justify="right") + table.add_column("Live Price", justify="right") + table.add_column("Best Ask", justify="right") + table.add_column("Gap", justify="right") + table.add_column("Dir", justify="center", width=3) + + for i, coin in enumerate(watched_coins, 1): + display_name = display_names.get(coin, coin) + mid = prices.get(coin) + bid = prices.get(f"{coin}_bid") + ask = prices.get(f"{coin}_ask") + + formatted_mid = self._format_price(mid) + formatted_bid = self._format_price(bid) + formatted_ask = self._format_price(ask) + + gap_str = "Loading..." + gap_style = "dim" + try: + gap_val = float(ask) - float(bid) + if gap_val < 1: + gap_str = f"{gap_val:.6f}" + else: + gap_str = f"{gap_val:.4f}" + gap_style = "green" if gap_val > 0 else "red" + except (ValueError, TypeError): + pass + + direction = " " + direction_style = "dim" + prev_mid = self.previous_prices.get(coin) + if prev_mid is not None and mid is not None: + try: + if float(mid) > float(prev_mid): + direction = "↑" + direction_style = "green" + elif float(mid) < float(prev_mid): + direction = "↓" + direction_style = "red" + except (ValueError, TypeError): + pass + + table.add_row( + str(i), display_name, formatted_bid, formatted_mid, formatted_ask, + Text(gap_str, style=gap_style), + Text(direction, style=direction_style) + ) + + if mid is not None: + self.previous_prices[coin] = mid + + return table + + def build_strategy_table(self, strategy_statuses, strategy_configs): + """Build the strategies table.""" + table = Table(title="Strategies", show_header=True, header_style="bold cyan", title_style="bold white") + table.add_column("#", justify="center", width=3) + table.add_column("Strategy Name", width=25) + table.add_column("Coin", justify="center", width=8) + table.add_column("Signal", justify="center", width=10) + table.add_column("Signal Price", justify="right", width=14) + table.add_column("Last Change", justify="right", width=19) + table.add_column("TF", justify="center", width=7) + table.add_column("Size", justify="center", width=10) + + for i, (name, status) in enumerate(strategy_statuses.items(), 1): + signal = status.get('current_signal', 'N/A') + price = status.get('signal_price') + price_display = f"{price:.4f}" if isinstance(price, (int, float)) else "-" + last_change = status.get('last_signal_change_utc') + last_change_display = 'Never' + if last_change: + dt_utc = datetime.fromisoformat(last_change.replace('Z', '+00:00')).replace(tzinfo=timezone.utc) + dt_local = dt_utc.astimezone(None) + last_change_display = dt_local.strftime('%Y-%m-%d %H:%M') + + config_params = strategy_configs.get(name, {}).get('parameters', {}) + coin = status.get('coin', config_params.get('coin', 'N/A')) + + size = status.get('size') + if not size: + if 'coins_to_copy' in config_params: + size = 'Multi' + else: + size = config_params.get('size', 'N/A') + + timeframe = config_params.get('timeframe', 'N/A') + + signal_style = "" + if signal == "BUY": + signal_style = "green" + elif signal == "SELL": + signal_style = "red" + elif signal == "NEUTRAL": + signal_style = "yellow" + + table.add_row( + str(i), name, coin, + Text(signal, style=signal_style) if signal_style else signal, + price_display, last_change_display, timeframe, str(size) + ) + + return table + + def _format_change_value(self, value): + """Format a percentage change value with color styling.""" + if value is None: + return Text("N/A", style="dim") + if value > 0: + return Text(f"+{value:.2f}%", style="green") + elif value < 0: + return Text(f"{value:.2f}%", style="red") + else: + return Text(f"{value:.2f}%", style="yellow") + + def _format_value(self, value, width=12): + """Format a numeric value for display.""" + if value is None: + return Text("N/A", style="dim") + try: + val = float(value) + if abs(val) < 1: + return Text(f"{val:>{width}.6f}") + elif abs(val) < 100: + return Text(f"{val:>{width}.4f}") + else: + return Text(f"{val:>{width}.2f}") + except (ValueError, TypeError): + return Text("N/A", style="dim") + + def build_indicators_table(self, indicators_status): + """Build the indicators dashboard table.""" + table = Table(title="Indicators", show_header=True, header_style="bold cyan", title_style="bold white") + table.add_column("#", justify="right", style="dim", width=3) + table.add_column("Indicator", width=20) + table.add_column("Value", justify="right") + table.add_column("1h Change", justify="right", width=12) + table.add_column("1D Change", justify="right", width=12) + table.add_column("Deviation", justify="right", width=12) + + if not indicators_status: + table.add_row("1", "Loading...", "N/A", "N/A", "N/A", "N/A") + return table + + indicators = indicators_status.get("indicators", {}) + for i, (name, data) in enumerate(indicators.items(), 1): + display_name = data.get("display_name", name) + value = data.get("value") + changes = data.get("changes", {}) + deviation = data.get("deviation") + + formatted_value = self._format_value(value) + change_1h = self._format_change_value(changes.get("1h")) + change_1d = self._format_change_value(changes.get("1d")) + + if deviation is not None: + if deviation > 0: + deviation_str = Text(f"+{deviation:.2f}%", style="green") + elif deviation < 0: + deviation_str = Text(f"{deviation:.2f}%", style="red") + else: + deviation_str = Text(f"{deviation:.2f}%", style="yellow") + else: + deviation_str = Text("N/A", style="dim") + + table.add_row( + str(i), display_name, formatted_value, + change_1h, change_1d, deviation_str + ) + + return table + + def build_layout(self, watched_coins, prices, display_names, strategy_statuses, strategy_configs, indicators_status=None): + """Build the complete dashboard layout with vertically stacked tables.""" + tables = [] + if self.table_visibility.get("market", True): + tables.append(self.build_market_table(watched_coins, prices, display_names)) + if self.table_visibility.get("indicators", True): + tables.append(Padding(self.build_indicators_table(indicators_status), (2, 0, 0, 0))) + if self.table_visibility.get("strategies", True): + tables.append(self.build_strategy_table(strategy_statuses, strategy_configs)) + + if not tables: + return Layout() + return Layout(Group(*tables)) diff --git a/indicators.py b/indicators.py new file mode 100644 index 0000000..e495cf3 --- /dev/null +++ b/indicators.py @@ -0,0 +1,425 @@ +""" +Indicator calculation module. +Provides IndicatorCalculator for computing various financial indicators +from SQLite candle data, including ratios, prices, moving averages, RSI, +and custom functions. +""" + +import json +import os +import sqlite3 +import importlib +import logging +import pandas as pd +import numpy as np + + +class IndicatorCalculator: + """ + Computes indicator values from SQLite candle data. + Supports ratio, price, spread, diff_pct, ma, rsi, and custom types. + """ + + def __init__(self, config_path, db_path): + self.config_path = config_path + self.db_path = db_path + self.config = self._load_config() + + def _load_config(self): + """Load indicator definitions from JSON config file.""" + try: + with open(self.config_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError) as e: + logging.error(f"Failed to load indicators config from '{self.config_path}': {e}") + return {} + + def _get_latest_close(self, coin, timeframe="1m"): + """Get the latest close price from a candle table.""" + table = f"{coin}_{timeframe}" + try: + with sqlite3.connect(self.db_path) as conn: + result = conn.execute( + f'SELECT close FROM "{table}" ORDER BY timestamp_ms DESC LIMIT 1' + ).fetchone() + return float(result[0]) if result and result[0] is not None else None + except Exception as e: + logging.debug(f"Could not get latest close for {coin} ({timeframe}): {e}") + return None + + def _get_close_n_candles_ago(self, coin, timeframe, n=1): + """Get the close price from n candles ago (n=1 = most recent completed candle).""" + table = f"{coin}_{timeframe}" + try: + with sqlite3.connect(self.db_path) as conn: + result = conn.execute( + f'SELECT close FROM "{table}" ORDER BY timestamp_ms DESC LIMIT 1 OFFSET {n}' + ).fetchone() + return float(result[0]) if result and result[0] is not None else None + except Exception as e: + logging.debug(f"Could not get close {n} candles ago for {coin} ({timeframe}): {e}") + return None + + def _get_all_closes(self, coin, timeframe="1d"): + """Get all close prices from a candle table, ordered by time.""" + table = f"{coin}_{timeframe}" + try: + with sqlite3.connect(self.db_path) as conn: + result = conn.execute( + f'SELECT close FROM "{table}" ORDER BY timestamp_ms' + ).fetchall() + return [float(r[0]) for r in result if r[0] is not None] + except Exception as e: + logging.debug(f"Could not get all closes for {coin} ({timeframe}): {e}") + return [] + + def _get_all_ratio(self, num_coin, den_coin, timeframe="1d"): + """Get all ratio values (num/den) from candle tables, ordered by time.""" + num_table = f"{num_coin}_{timeframe}" + den_table = f"{den_coin}_{timeframe}" + try: + with sqlite3.connect(self.db_path) as conn: + result = conn.execute( + f'SELECT n.close / d.close as ratio ' + f'FROM "{num_table}" n ' + f'JOIN "{den_table}" d ON n.timestamp_ms = d.timestamp_ms ' + f'ORDER BY n.timestamp_ms' + ).fetchall() + return [float(r[0]) for r in result if r[0] is not None] + except Exception as e: + logging.debug(f"Could not get ratio series for {num_coin}/{den_coin} ({timeframe}): {e}") + return [] + + def _get_all_spread(self, num_coin, den_coin, timeframe="1d"): + """Get all spread values (num - den) from candle tables, ordered by time.""" + num_table = f"{num_coin}_{timeframe}" + den_table = f"{den_coin}_{timeframe}" + try: + with sqlite3.connect(self.db_path) as conn: + result = conn.execute( + f'SELECT n.close - d.close as spread ' + f'FROM "{num_table}" n ' + f'JOIN "{den_table}" d ON n.timestamp_ms = d.timestamp_ms ' + f'ORDER BY n.timestamp_ms' + ).fetchall() + return [float(r[0]) for r in result if r[0] is not None] + except Exception as e: + logging.debug(f"Could not get spread series for {num_coin}/{den_coin} ({timeframe}): {e}") + return [] + + def _get_all_diff_pct(self, num_coin, den_coin, timeframe="1d"): + """Get all percentage difference values ((num-den)/den*100) from candle tables.""" + num_table = f"{num_coin}_{timeframe}" + den_table = f"{den_coin}_{timeframe}" + try: + with sqlite3.connect(self.db_path) as conn: + result = conn.execute( + f'SELECT (n.close - d.close) / d.close * 100 as diff_pct ' + f'FROM "{num_table}" n ' + f'JOIN "{den_table}" d ON n.timestamp_ms = d.timestamp_ms ' + f'ORDER BY n.timestamp_ms' + ).fetchall() + return [float(r[0]) for r in result if r[0] is not None] + except Exception as e: + logging.debug(f"Could not get diff_pct series for {num_coin}/{den_coin} ({timeframe}): {e}") + return [] + + def _compute_ma(self, closes, period): + """Compute Simple Moving Average using pandas.""" + if len(closes) < period: + return [] + series = pd.Series(closes) + ma = series.rolling(window=period).mean() + return ma.dropna().tolist() + + def _compute_rsi(self, closes, period): + """Compute RSI using Wilder's smoothing method.""" + if len(closes) < period + 1: + return [] + series = pd.Series(closes) + delta = series.diff() + gain = delta.where(delta > 0, 0) + loss = (-delta).where(delta < 0, 0) + avg_gain = gain.rolling(window=period, min_periods=period).mean() + avg_loss = loss.rolling(window=period, min_periods=period).mean() + rs = avg_gain / avg_loss.replace(0, np.nan) + rsi = 100 - (100 / (1 + rs)) + return rsi.dropna().tolist() + + def _get_ma_value(self, coin, timeframe, period, n_candles_ago=0): + """Get MA value from n candles ago (0 = latest, 1 = second-to-last).""" + closes = self._get_all_closes(coin, timeframe) + if not closes: + return None + ma_values = self._compute_ma(closes, period) + if not ma_values: + return None + if n_candles_ago < len(ma_values): + return ma_values[-(1 + n_candles_ago)] + return None + + def _get_rsi_value(self, coin, timeframe, period, n_candles_ago=0): + """Get RSI value from n candles ago (0 = latest, 1 = second-to-last).""" + closes = self._get_all_closes(coin, timeframe) + if not closes: + return None + rsi_values = self._compute_rsi(closes, period) + if not rsi_values: + return None + if n_candles_ago < len(rsi_values): + return rsi_values[-(1 + n_candles_ago)] + return None + + def _format_change(self, current, past): + """Compute percentage change between two values.""" + if past is None or past == 0 or current is None: + return None + return (current - past) / past * 100 + + def calculate_indicator(self, ind_def): + """ + Calculate a single indicator based on its definition. + Returns a dict with value, changes, reference, and deviation. + """ + ind_type = ind_def.get("type", "price") + + if ind_type == "ratio": + return self._calc_ratio(ind_def) + elif ind_type == "price": + return self._calc_price(ind_def) + elif ind_type == "spread": + return self._calc_spread(ind_def) + elif ind_type == "diff_pct": + return self._calc_diff_pct(ind_def) + elif ind_type == "ma": + return self._calc_ma(ind_def) + elif ind_type == "rsi": + return self._calc_rsi(ind_def) + elif ind_type == "custom": + return self._calc_custom(ind_def) + else: + logging.warning(f"Unknown indicator type: {ind_type}") + return None + + def _calc_ratio(self, ind_def): + """Calculate a ratio indicator (numerator / denominator).""" + num = ind_def["numerator"] + den = ind_def["denominator"] + + num_now = self._get_latest_close(num) + den_now = self._get_latest_close(den) + if num_now is None or den_now is None or den_now == 0: + return None + current = num_now / den_now + + changes = {} + for period in ind_def.get("changes", []): + num_past = self._get_close_n_candles_ago(num, period, n=1) + den_past = self._get_close_n_candles_ago(den, period, n=1) + if num_past is not None and den_past is not None and den_past != 0: + past = num_past / den_past + changes[period] = self._format_change(current, past) + else: + changes[period] = None + + reference = None + deviation = None + if ind_def.get("show_deviation", False): + ratios = self._get_all_ratio(num, den, "1d") + if ratios: + reference = sum(ratios) / len(ratios) + deviation = self._format_change(current, reference) + + return {"value": current, "reference": reference, "changes": changes, "deviation": deviation} + + def _calc_price(self, ind_def): + """Calculate a single price indicator.""" + coin = ind_def["coin"] + + current = self._get_latest_close(coin) + if current is None: + return None + + changes = {} + for period in ind_def.get("changes", []): + past = self._get_close_n_candles_ago(coin, period, n=1) + changes[period] = self._format_change(current, past) + + reference = None + deviation = None + if ind_def.get("show_deviation", False): + closes = self._get_all_closes(coin, "1d") + if closes: + reference = sum(closes) / len(closes) + deviation = self._format_change(current, reference) + + return {"value": current, "reference": reference, "changes": changes, "deviation": deviation} + + def _calc_spread(self, ind_def): + """Calculate a spread indicator (numerator - denominator).""" + num = ind_def["numerator"] + den = ind_def["denominator"] + + num_now = self._get_latest_close(num) + den_now = self._get_latest_close(den) + if num_now is None or den_now is None: + return None + current = num_now - den_now + + changes = {} + for period in ind_def.get("changes", []): + num_past = self._get_close_n_candles_ago(num, period, n=1) + den_past = self._get_close_n_candles_ago(den, period, n=1) + if num_past is not None and den_past is not None: + past = num_past - den_past + changes[period] = self._format_change(current, past) + else: + changes[period] = None + + reference = None + deviation = None + if ind_def.get("show_deviation", False): + spreads = self._get_all_spread(num, den, "1d") + if spreads: + reference = sum(spreads) / len(spreads) + deviation = self._format_change(current, reference) + + return {"value": current, "reference": reference, "changes": changes, "deviation": deviation} + + def _calc_diff_pct(self, ind_def): + """Calculate a percentage difference indicator ((num-den)/den*100).""" + num = ind_def["numerator"] + den = ind_def["denominator"] + + num_now = self._get_latest_close(num) + den_now = self._get_latest_close(den) + if num_now is None or den_now is None or den_now == 0: + return None + current = (num_now - den_now) / den_now * 100 + + changes = {} + for period in ind_def.get("changes", []): + num_past = self._get_close_n_candles_ago(num, period, n=1) + den_past = self._get_close_n_candles_ago(den, period, n=1) + if num_past is not None and den_past is not None and den_past != 0: + past = (num_past - den_past) / den_past * 100 + changes[period] = self._format_change(current, past) + else: + changes[period] = None + + reference = None + deviation = None + if ind_def.get("show_deviation", False): + diffs = self._get_all_diff_pct(num, den, "1d") + if diffs: + reference = sum(diffs) / len(diffs) + deviation = self._format_change(current, reference) + + return {"value": current, "reference": reference, "changes": changes, "deviation": deviation} + + def _calc_ma(self, ind_def): + """Calculate a moving average indicator.""" + coin = ind_def["coin"] + timeframe = ind_def.get("timeframe", "1h") + period = ind_def.get("period", 20) + + current = self._get_ma_value(coin, timeframe, period, n_candles_ago=0) + if current is None: + return None + + changes = {} + for period_label in ind_def.get("changes", []): + if period_label == "1h": + past = self._get_ma_value(coin, "1h", period, n_candles_ago=1) + elif period_label == "1d": + past = self._get_ma_value(coin, "1d", period, n_candles_ago=1) + else: + past = self._get_ma_value(coin, period_label, period, n_candles_ago=1) + changes[period_label] = self._format_change(current, past) + + reference = None + deviation = None + if ind_def.get("show_deviation", False): + live_price = self._get_latest_close(coin) + if live_price is not None and current != 0: + reference = current + deviation = (live_price - current) / current * 100 + + return {"value": current, "reference": reference, "changes": changes, "deviation": deviation} + + def _calc_rsi(self, ind_def): + """Calculate an RSI indicator.""" + coin = ind_def["coin"] + timeframe = ind_def.get("timeframe", "1h") + period = ind_def.get("period", 14) + + current = self._get_rsi_value(coin, timeframe, period, n_candles_ago=0) + if current is None: + return None + + changes = {} + for period_label in ind_def.get("changes", []): + if period_label == "1h": + past = self._get_rsi_value(coin, "1h", period, n_candles_ago=1) + elif period_label == "1d": + past = self._get_rsi_value(coin, "1d", period, n_candles_ago=1) + else: + past = self._get_rsi_value(coin, period_label, period, n_candles_ago=1) + if past is not None: + changes[period_label] = current - past + else: + changes[period_label] = None + + reference = 50.0 + deviation = None + if ind_def.get("show_deviation", False): + deviation = current - 50.0 + + return {"value": current, "reference": reference, "changes": changes, "deviation": deviation} + + def _calc_custom(self, ind_def): + """Calculate a custom indicator by calling a user-defined function.""" + module_path = ind_def.get("module") + function_name = ind_def.get("function") + args = ind_def.get("args", {}) + + if not module_path or not function_name: + logging.error(f"Custom indicator missing 'module' or 'function': {ind_def}") + return None + + try: + module = importlib.import_module(module_path) + func = getattr(module, function_name) + except (ImportError, AttributeError) as e: + logging.error(f"Failed to load custom indicator {module_path}.{function_name}: {e}") + return None + + try: + result = func(self.db_path, **args) + if not isinstance(result, dict): + logging.error(f"Custom indicator {function_name} must return a dict, got {type(result)}") + return None + return result + except Exception as e: + logging.error(f"Custom indicator {function_name} raised an error: {e}", exc_info=True) + return None + + def calculate_all(self): + """Calculate all indicators defined in the config file.""" + results = {} + for name, ind_def in self.config.items(): + result = self.calculate_indicator(ind_def) + if result: + results[name] = { + "display_name": ind_def.get("display_name", name), + **result + } + else: + results[name] = { + "display_name": ind_def.get("display_name", name), + "value": None, + "reference": None, + "changes": {}, + "deviation": None + } + return results diff --git a/indicators_fetcher.py b/indicators_fetcher.py new file mode 100644 index 0000000..d52a347 --- /dev/null +++ b/indicators_fetcher.py @@ -0,0 +1,86 @@ +""" +Indicators Data Fetcher + +A standalone process that runs in a loop to compute financial indicators +(ratios, prices, MAs, RSI, custom) from SQLite candle data and save +the results to a JSON status file for the main dashboard to display. + +Follows the same pattern as dashboard_data_fetcher.py. +""" + +import logging +import os +import sys +import json +import time +import argparse +from datetime import datetime, timezone + +from logging_utils import setup_logging +from indicators import IndicatorCalculator + + +class IndicatorsFetcher: + """ + Periodically computes all configured indicators and saves them to a JSON file. + """ + + def __init__(self, log_level: str): + setup_logging(log_level, 'IndicatorsFetcher') + + project_root = os.path.dirname(os.path.abspath(__file__)) + self.db_path = os.path.join(project_root, "_data", "market_data.db") + self.config_path = os.path.join(project_root, "_data", "indicators.json") + self.status_file_path = os.path.join(project_root, "_logs", "indicators_status.json") + + self.calculator = IndicatorCalculator( + config_path=self.config_path, + db_path=self.db_path + ) + + logging.info(f"Indicators Fetcher initialized. DB: {self.db_path}, Config: {self.config_path}") + + def fetch_and_save_indicators(self): + """Compute all indicators and save to JSON status file.""" + try: + results = self.calculator.calculate_all() + + status = { + "last_updated_utc": datetime.now(timezone.utc).isoformat(), + "indicators": results + } + + logs_dir = os.path.dirname(self.status_file_path) + os.makedirs(logs_dir, exist_ok=True) + + temp_file_path = self.status_file_path + ".tmp" + with open(temp_file_path, 'w', encoding='utf-8') as f: + json.dump(status, f, indent=4, default=str) + os.replace(temp_file_path, self.status_file_path) + + logging.debug(f"Successfully updated indicators status file with {len(results)} indicators.") + + except Exception as e: + logging.error(f"Failed to fetch or save indicators: {e}", exc_info=True) + + def run(self): + """Main loop to periodically compute and save indicators.""" + logging.info("Starting Indicators Fetcher loop (update interval: 30s)") + while True: + try: + self.fetch_and_save_indicators() + except Exception as e: + logging.error(f"Indicators Fetcher loop error: {e}", exc_info=True) + time.sleep(30) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Run the Indicators Data Fetcher.") + parser.add_argument("--log-level", default="normal", choices=['off', 'normal', 'debug']) + args = parser.parse_args() + + fetcher = IndicatorsFetcher(log_level=args.log_level) + try: + fetcher.run() + except KeyboardInterrupt: + logging.info("Indicators Data Fetcher stopped.") diff --git a/main_app.py b/main_app.py index 7535a1c..a729fcb 100644 --- a/main_app.py +++ b/main_app.py @@ -8,7 +8,7 @@ import multiprocessing import schedule import sqlite3 import pandas as pd -from datetime import datetime, timezone +from datetime import datetime import importlib # --- REMOVED: import signal --- # --- REMOVED: from queue import Empty --- @@ -18,6 +18,9 @@ from logging_utils import setup_logging from live_market_utils import start_live_feed # --- Import the base class for type hinting (optional but good practice) --- from strategies.base_strategy import BaseStrategy +# --- Rich dashboard renderer --- +from dashboard import DashboardRenderer +from rich.live import Live # --- Configuration --- WATCHED_COINS = ["BTC", "ETH", "SOL", "BNB", "HYPE", "SUI", "xyz:BRENTOIL", "xyz:CL"] @@ -31,24 +34,11 @@ RESAMPLER_SCRIPT = "resampler.py" # --- REMOVED: Market Cap Fetcher --- # --- REMOVED: trade_executor.py is no longer a script --- DASHBOARD_DATA_FETCHER_SCRIPT = "dashboard_data_fetcher.py" +INDICATORS_FETCHER_SCRIPT = "indicators_fetcher.py" STRATEGY_CONFIG_FILE = os.path.join("_data", "strategies.json") DB_PATH = os.path.join("_data", "market_data.db") # --- REMOVED: Market Cap File --- LOGS_DIR = "_logs" -TRADE_EXECUTOR_STATUS_FILE = os.path.join(LOGS_DIR, "trade_executor_status.json") - - -def format_market_cap(mc_value): - """Formats a large number into a human-readable market cap string.""" - if not isinstance(mc_value, (int, float)) or mc_value == 0: - return "N/A" - if mc_value >= 1_000_000_000_000: - return f"${mc_value / 1_000_000_000_000:.2f}T" - if mc_value >= 1_000_000_000: - return f"${mc_value / 1_000_000_000:.2f}B" - if mc_value >= 1_000_000: - return f"${mc_value / 1_000_000:.2f}M" - return f"${mc_value:,.2f}" def run_live_candle_fetcher(): @@ -348,17 +338,53 @@ def run_dashboard_data_fetcher(): time.sleep(10) +def run_indicators_fetcher(): + """Target function to run the indicators_fetcher.py script.""" + + # --- GRACEFUL SHUTDOWN HANDLER --- + import signal + + def handle_shutdown_signal(signum, frame): + try: + logging.info(f"Shutdown signal ({signum}) received. Initiating graceful exit...") + except NameError: + print(f"[IndicatorsFetcher] Shutdown signal ({signum}) received. Initiating graceful exit...") + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, handle_shutdown_signal) + # --- END GRACEFUL SHUTDOWN HANDLER --- + + log_file = os.path.join(LOGS_DIR, "indicators_fetcher.log") + while True: + try: + with open(log_file, 'a') as f: + f.write(f"\n--- Starting Indicators Fetcher at {datetime.now()} ---\n") + subprocess.run([sys.executable, INDICATORS_FETCHER_SCRIPT, "--log-level", "normal"], check=True, stdout=f, stderr=subprocess.STDOUT) + except KeyboardInterrupt: + logging.info("Indicators Fetcher stopping.") + break + except (subprocess.CalledProcessError, Exception) as e: + with open(log_file, 'a') as f: + f.write(f"\n--- PROCESS ERROR at {datetime.now()} ---\n") + f.write(f"Indicators Fetcher failed: {e}. Restarting...\n") + time.sleep(10) + + class MainApp: def __init__(self, coins_to_watch: list, processes: dict, strategy_configs: dict, shared_prices: dict): self.watched_coins = coins_to_watch self.shared_prices = shared_prices self.prices = {} - # --- REMOVED: self.market_caps --- - self.open_positions = {} self.background_processes = processes self.process_status = {} self.strategy_configs = strategy_configs self.strategy_statuses = {} + self.indicators_status = {} + self.renderer = DashboardRenderer(table_visibility={ + "market": True, + "strategies": False, + "indicators": True, + }) def read_prices(self): """Reads the latest prices directly from the shared memory dictionary.""" @@ -386,190 +412,47 @@ class MainApp: enabled_statuses[name] = {"current_signal": "Initializing..."} self.strategy_statuses = enabled_statuses - def read_executor_status(self): - """Reads the live status file from the trade executor.""" - if os.path.exists(TRADE_EXECUTOR_STATUS_FILE): + def read_indicators_status(self): + """Reads the indicators status JSON file.""" + status_file = os.path.join(LOGS_DIR, "indicators_status.json") + if os.path.exists(status_file): try: - with open(TRADE_EXECUTOR_STATUS_FILE, 'r', encoding='utf-8') as f: - # --- FIX: Read the 'open_positions' key from the file --- - status_data = json.load(f) - self.open_positions = status_data.get('open_positions', {}) + with open(status_file, 'r', encoding='utf-8') as f: + self.indicators_status = json.load(f) except (IOError, json.JSONDecodeError): - logging.debug("Could not read trade executor status file.") + self.indicators_status = {} else: - self.open_positions = {} + self.indicators_status = {} def check_process_status(self): """Checks if the background processes are still running.""" for name, process in self.background_processes.items(): self.process_status[name] = "Running" if process.is_alive() else "STOPPED" - def _format_price(self, price_val, width=10): - """Helper function to format prices for the dashboard.""" - try: - price_float = float(price_val) - if price_float < 1: - price_str = f"{price_float:>{width}.6f}" - elif price_float < 100: - price_str = f"{price_float:>{width}.4f}" - else: - price_str = f"{price_float:>{width}.2f}" - except (ValueError, TypeError): - price_str = f"{'Loading...':>{width}}" - return price_str + def toggle_table(self, table_name, enabled=None): + """Toggle a dashboard table's visibility at runtime.""" + return self.renderer.toggle_table(table_name, enabled) def display_dashboard(self): - """Displays a formatted dashboard with side-by-side tables.""" - print("\x1b[H\x1b[J", end="") # Clear screen - - left_table_lines = ["--- Market Dashboard ---"] - # --- MODIFIED: Adjusted width for new columns --- - left_table_width = 65 - left_table_lines.append("-" * left_table_width) - # --- MODIFIED: Replaced Market Cap with Gap --- - left_table_lines.append(f"{'#':<2} | {'Coin':^6} | {'Best Bid':>10} | {'Live Price':>10} | {'Best Ask':>10} | {'Gap':>10} |") - left_table_lines.append("-" * left_table_width) - for i, coin in enumerate(self.watched_coins, 1): - # Use display name for dashboard, but keep internal symbol for price lookup - display_name = COIN_DISPLAY_NAMES.get(coin, coin) - - # --- MODIFIED: Fetch all three price types --- - mid_price = self.prices.get(coin, "Loading...") - bid_price = self.prices.get(f"{coin}_bid", "Loading...") - ask_price = self.prices.get(f"{coin}_ask", "Loading...") - - # --- MODIFIED: Use the new formatting helper --- - formatted_mid = self._format_price(mid_price) - formatted_bid = self._format_price(bid_price) - formatted_ask = self._format_price(ask_price) - - # --- MODIFIED: Calculate gap --- - gap_str = f"{'Loading...':>10}" - try: - # Calculate the spread - gap_val = float(ask_price) - float(bid_price) - # Format gap with high precision, similar to price - if gap_val < 1: - gap_str = f"{gap_val:>{10}.6f}" - else: - gap_str = f"{gap_val:>{10}.4f}" - except (ValueError, TypeError): - pass # Keep 'Loading...' - - # --- REMOVED: Market Cap logic --- - - # --- MODIFIED: Print all price columns including gap --- - left_table_lines.append(f"{i:<2} | {display_name:^6} | {formatted_bid} | {formatted_mid} | {formatted_ask} | {gap_str} |") - left_table_lines.append("-" * left_table_width) - - right_table_lines = ["--- Strategy Status ---"] - # --- FIX: Adjusted table width after removing parameters --- - right_table_width = 105 - right_table_lines.append("-" * right_table_width) - # --- FIX: Removed 'Parameters' from header --- - right_table_lines.append(f"{'#':^2} | {'Strategy Name':<25} | {'Coin':^6} | {'Signal':^8} | {'Signal Price':>12} | {'Last Change':>17} | {'TF':^5} | {'Size':^8} |") - right_table_lines.append("-" * right_table_width) - for i, (name, status) in enumerate(self.strategy_statuses.items(), 1): - signal = status.get('current_signal', 'N/A') - price = status.get('signal_price') - price_display = f"{price:.4f}" if isinstance(price, (int, float)) else "-" - last_change = status.get('last_signal_change_utc') - last_change_display = 'Never' - if last_change: - dt_utc = datetime.fromisoformat(last_change.replace('Z', '+00:00')).replace(tzinfo=timezone.utc) - dt_local = dt_utc.astimezone(None) - last_change_display = dt_local.strftime('%Y-%m-%d %H:%M') - - config_params = self.strategy_configs.get(name, {}).get('parameters', {}) - - # --- FIX: Read coin/size from status file first, fallback to config --- - coin = status.get('coin', config_params.get('coin', 'N/A')) - - # --- FIX: Handle nested 'coins_to_copy' logic for size --- - # --- MODIFIED: Read 'size' from status first, then config, then 'Multi' --- - size = status.get('size') - if not size: - if 'coins_to_copy' in config_params: - size = 'Multi' - else: - size = config_params.get('size', 'N/A') - - timeframe = config_params.get('timeframe', 'N/A') - - # --- FIX: Removed parameter string logic --- - - # --- FIX: Removed 'params_str' from the formatted line --- - - size_display = f"{size:>8}" - if isinstance(size, (int, float)): - # --- MODIFIED: More flexible size formatting --- - if size < 0.0001: - size_display = f"{size:>8.6f}" - elif size < 1: - size_display = f"{size:>8.4f}" - else: - size_display = f"{size:>8.2f}" - # --- END NEW LOGIC --- - - right_table_lines.append(f"{i:^2} | {name:<25} | {coin:^6} | {signal:^8} | {price_display:>12} | {last_change_display:>17} | {timeframe:^5} | {size_display} |") - right_table_lines.append("-" * right_table_width) - - output_lines = [] - max_rows = max(len(left_table_lines), len(right_table_lines)) - separator = " " - indent = " " * 10 - for i in range(max_rows): - left_part = left_table_lines[i] if i < len(left_table_lines) else " " * left_table_width - right_part = indent + right_table_lines[i] if i < len(right_table_lines) else "" - output_lines.append(f"{left_part}{separator}{right_part}") - - output_lines.append("\n--- Open Positions ---") - pos_table_width = 100 - output_lines.append("-" * pos_table_width) - output_lines.append(f"{'Account':<10} | {'Coin':<6} | {'Size':>15} | {'Entry Price':>12} | {'Mark Price':>12} | {'PNL':>15} | {'Leverage':>10} |") - output_lines.append("-" * pos_table_width) - - # --- FIX: Correctly read and display open positions --- - if not self.open_positions: - output_lines.append(f"{'No open positions.':^{pos_table_width}}") - else: - for account, positions in self.open_positions.items(): - if not positions: - continue - for coin, pos in positions.items(): - try: - size_f = float(pos.get('size', 0)) - entry_f = float(pos.get('entry_price', 0)) - mark_f = float(self.prices.get(coin, 0)) - pnl_f = (mark_f - entry_f) * size_f if size_f > 0 else (entry_f - mark_f) * abs(size_f) - lev = pos.get('leverage', 1) - - size_str = f"{size_f:>{15}.5f}" - entry_str = f"{entry_f:>{12}.2f}" - mark_str = f"{mark_f:>{12}.2f}" - pnl_str = f"{pnl_f:>{15}.2f}" - lev_str = f"{lev}x" - - output_lines.append(f"{account:<10} | {coin:<6} | {size_str} | {entry_str} | {mark_str} | {pnl_str} | {lev_str:>10} |") - except (ValueError, TypeError): - output_lines.append(f"{account:<10} | {coin:<6} | {'Error parsing data...':^{pos_table_width-20}} |") - - output_lines.append("-" * pos_table_width) - - final_output = "\n".join(output_lines) - print(final_output) - sys.stdout.flush() + """Build and return the rich dashboard layout.""" + return self.renderer.build_layout( + self.watched_coins, + self.prices, + COIN_DISPLAY_NAMES, + self.strategy_statuses, + self.strategy_configs, + self.indicators_status + ) def run(self): """Main loop to read data, display dashboard, and check processes.""" - while True: - self.read_prices() - # --- REMOVED: self.read_market_caps() --- - self.read_strategy_statuses() - self.read_executor_status() - # --- REMOVED: self.check_process_status() --- - self.display_dashboard() - time.sleep(0.5) + with Live(self.display_dashboard(), refresh_per_second=2, console=self.renderer.console) as live: + while True: + self.read_prices() + self.read_strategy_statuses() + self.read_indicators_status() + live.update(self.display_dashboard()) + time.sleep(0.5) if __name__ == "__main__": setup_logging('normal', 'MainApp') @@ -613,6 +496,7 @@ if __name__ == "__main__": processes["Resampler"] = multiprocessing.Process(target=resampler_scheduler, args=(list(required_timeframes),), daemon=True) # --- REMOVED: Market Cap Fetcher Process --- processes["Dashboard Data"] = multiprocessing.Process(target=run_dashboard_data_fetcher, daemon=True) + processes["Indicators"] = multiprocessing.Process(target=run_indicators_fetcher, daemon=True) processes["Position Manager"] = multiprocessing.Process( target=run_position_manager, diff --git a/requirements.txt b/requirements.txt index a827519..c6673f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,6 +39,7 @@ pydantic_core==2.41.5 python-dateutil==2.9.0.post0 python-dotenv==1.2.1 pytz==2025.2 +rich==13.9.4 regex==2025.11.3 requests==2.32.5 rlp==4.1.0