diff --git a/signals/signal_health_client.py b/signals/signal_health_client.py index 1119da6..9cd23d1 100755 --- a/signals/signal_health_client.py +++ b/signals/signal_health_client.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Health Check Client for Signal Generator +Health Check Client for Multi-Personality Signal Generator Query the running signal generator status """ @@ -28,19 +28,61 @@ def check_health(socket_path="/tmp/signals_health.sock"): # Parse and display health = json.loads(response.decode('utf-8')) - print("=" * 60) - print("SIGNAL GENERATOR HEALTH STATUS") - print("=" * 60) + print("=" * 70) + print("MULTI-PERSONALITY SIGNAL GENERATOR HEALTH STATUS") + print("=" * 70) print(f"Status: {health['status']}") - print(f"Personality: {health['personality']}") + print(f"Mode: {health.get('mode', 'single-personality')}") + + # Handle both old single-personality and new multi-personality format + if 'personalities' in health: + print(f"Personalities: {', '.join(health['personalities'])}") + elif 'personality' in health: + print(f"Personality: {health['personality']}") + print(f"Timeframes: {', '.join(health['timeframes'])}") print(f"Uptime: {health['uptime_seconds']}s ({health['uptime_seconds']//60}m)") - print(f"Total Signals: {health['total_signals']}") - print(f" - Buy: {health['buy_signals']}") - print(f" - Sell: {health['sell_signals']}") - print(f"Last Signal: {health['last_signal'] or 'None'}") - print(f"Errors: {health['errors']}") print(f"Connected Clients: {health['connected_clients']}") + print(f"Debug Mode: {'ON' if health.get('debug_mode') else 'OFF'}") + + # Total stats (aggregated across all personalities) + if 'total_stats' in health: + total = health['total_stats'] + print(f"\nAGGREGATED STATISTICS:") + print(f" Total Signals: {total['total_signals']}") + print(f" - Buy: {total['buy_signals']}") + print(f" - Sell: {total['sell_signals']}") + print(f" Errors: {total['errors']}") + else: + # Fallback for old format + print(f"\nSTATISTICS:") + print(f" Total Signals: {health.get('total_signals', 0)}") + print(f" - Buy: {health.get('buy_signals', 0)}") + print(f" - Sell: {health.get('sell_signals', 0)}") + print(f" Errors: {health.get('errors', 0)}") + + # Per-personality breakdown (if available) + if 'personality_stats' in health: + print("\n" + "=" * 70) + print("PER-PERSONALITY BREAKDOWN") + print("=" * 70) + + for personality, stats in health['personality_stats'].items(): + print(f"\n{personality.upper()}:") + print(f" Total Signals: {stats['total_signals']}") + print(f" - Buy: {stats['buy_signals']}") + print(f" - Sell: {stats['sell_signals']}") + print(f" Last Signal: {stats['last_signal'] or 'None'}") + print(f" Errors: {stats['errors']}") + + if stats.get('recent_signals'): + print(f" Recent Signals:") + for sig in stats['recent_signals'][:3]: + print(f" [{sig['timeframe']}] {sig['signal']} @ ${sig['price']:.2f} " + f"(conf: {sig['confidence']:.2f})") + else: + # Old format + print(f" Last Signal: {health.get('last_signal') or 'None'}") # Database Status print("\n" + "=" * 70) @@ -58,20 +100,22 @@ def check_health(socket_path="/tmp/signals_health.sock"): print(f" Total Rows: {candles.get('row_count', 0)}") for tf, info in candles.get('timeframes', {}).items(): age = info.get('age_seconds') - age_str = f"{age}s ago" if age else "N/A" - print(f" [{tf}]: {info['count']} rows, latest: {age_str}") + age_str = f"{age}s ago" if age is not None else "N/A" + status = "⚠ STALE" if age and age > 300 else "" + print(f" [{tf}]: {info['count']} rows, latest: {age_str} {status}") # Analysis DB analysis = db.get('analysis_db', {}) - print(f"Analysis DB: {'✓ OK' if analysis.get('accessible') else '✗ FAILED'}") + print(f"\nAnalysis DB: {'✓ OK' if analysis.get('accessible') else '✗ FAILED'}") if analysis.get('error'): print(f" Error: {analysis['error']}") else: print(f" Total Rows: {analysis.get('row_count', 0)}") for tf, info in analysis.get('timeframes', {}).items(): age = info.get('age_seconds') - age_str = f"{age}s ago" if age else "N/A" - print(f" [{tf}]: {info['count']} rows, latest: {age_str}") + age_str = f"{age}s ago" if age is not None else "N/A" + status = "⚠ STALE" if age and age > 300 else "" + print(f" [{tf}]: {info['count']} rows, latest: {age_str} {status}") # Configuration print("\n" + "=" * 70) @@ -81,21 +125,38 @@ def check_health(socket_path="/tmp/signals_health.sock"): print(f"Min Confidence: {cfg.get('min_confidence', 'N/A')}") print(f"Cooldown: {cfg.get('cooldown_seconds', 'N/A')}s") print(f"Lookback: {cfg.get('lookback', 'N/A')} candles") + print(f"Config Reloads: {cfg.get('reloads', 0)}") + if cfg.get('weights'): - print(f"Weights:") - for indicator, weight in cfg['weights'].items(): - print(f" {indicator:12s} {weight}") + # Multi-personality format + if isinstance(cfg['weights'], dict) and any(k in cfg['weights'] for k in ['scalping', 'swing']): + for personality, weights in cfg['weights'].items(): + print(f"\n{personality.upper()} Weights:") + for indicator, weight in weights.items(): + print(f" {indicator:12s} {weight}") + else: + # Single personality format + print(f"Weights:") + for indicator, weight in cfg['weights'].items(): + print(f" {indicator:12s} {weight}") - if health['recent_signals']: - print("\n" + "=" * 60) - print("RECENT SIGNALS") - print("=" * 60) - for sig in health['recent_signals']: - print(f" [{sig['timeframe']}] {sig['signal']} @ ${sig['price']:.2f} " + # Recent signals (all personalities combined) + recent = health.get('recent_signals', []) + if recent: + print("\n" + "=" * 70) + print("RECENT SIGNALS (ALL PERSONALITIES)") + print("=" * 70) + for sig in recent[:10]: + personality_tag = f"[{sig.get('personality', '?').upper()}]" + print(f" {personality_tag:12s} [{sig['timeframe']}] {sig['signal']} @ ${sig['price']:.2f} " f"(conf: {sig['confidence']:.2f})") - print(f" Reasons: {', '.join(sig['reasons'])}") + if sig.get('reasons'): + reasons_str = ', '.join(sig['reasons'][:3]) + if len(sig['reasons']) > 3: + reasons_str += f" (+{len(sig['reasons'])-3} more)" + print(f" Reasons: {reasons_str}") - print("=" * 60) + print("=" * 70) return 0 @@ -106,8 +167,15 @@ def check_health(socket_path="/tmp/signals_health.sock"): except ConnectionRefusedError: print(f"Error: Connection refused at {socket_path}") return 1 + except KeyError as e: + print(f"Error: Missing expected field in health response: {e}") + print("\nRaw response:") + print(json.dumps(health, indent=2)) + return 1 except Exception as e: print(f"Error: {e}") + import traceback + traceback.print_exc() return 1 if __name__ == "__main__": diff --git a/trader/Pipfile b/trader/Pipfile new file mode 100644 index 0000000..cf080f9 --- /dev/null +++ b/trader/Pipfile @@ -0,0 +1,12 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +rich = "*" + +[dev-packages] + +[requires] +python_version = "3.13" diff --git a/trader/README.md b/trader/README.md new file mode 100644 index 0000000..d62d309 --- /dev/null +++ b/trader/README.md @@ -0,0 +1,139 @@ +# Paper Trading Bot + +A real-time paper trading bot that executes trades based on signals from the multi-personality signal generator. + +## Features + +- **Real-time TUI Interface** - Beautiful terminal UI showing all trading activity +- **Multi-personality Support** - Trade signals from both scalping and swing strategies +- **Risk Management** - Configurable stop-loss, take-profit, and position sizing +- **SQLite Tracking** - All trades stored in database for analysis +- **Live Price Monitoring** - Reads current BTC price from candles.db +- **Signal Filtering** - Filter by confidence, personality, and timeframe + +## Installation + +```bash +cd traider +pip install -r requirements.txt +``` + +## Configuration + +Edit `config.json` to customize: + +```json +{ + "initial_balance": 10000.0, // Starting capital + "position_size_percent": 2.0, // % of balance per trade + "max_positions": 3, // Max concurrent positions + "stop_loss_percent": 2.0, // Stop loss % + "take_profit_percent": 4.0, // Take profit % + "min_confidence": 0.5, // Minimum signal confidence + "enabled_personalities": ["scalping", "swing"], + "enabled_timeframes": ["1m", "5m"] +} +``` + +## Usage + +Make sure the signal generator is running first: + +```bash +# In signals/ directory +./signals.py +``` + +Then start the paper trader: + +```bash +# In traider/ directory +./trader.py +``` + +## TUI Interface + +The TUI displays: + +- **Header**: Current BTC price, balance, equity, and total PnL +- **Statistics**: Win rate, total trades, wins/losses +- **Open Positions**: Live view of active trades with unrealized PnL +- **Recent Closed Trades**: Last 10 completed trades +- **Recent Signals**: Incoming signals from the generator + +## Database Schema + +### trades table +- Trade details (entry/exit prices, type, timeframe) +- PnL calculations +- Signal confidence and personality +- Exit reasons (Stop Loss, Take Profit) + +### balance_history table +- Historical balance snapshots +- Equity tracking over time + +## Trading Logic + +1. **Signal Reception**: Listens to Unix socket from signal generator +2. **Filtering**: Applies confidence, personality, and timeframe filters +3. **Position Sizing**: Calculates position based on balance % +4. **Entry**: Opens LONG (BUY signal) or SHORT (SELL signal) position +5. **Exit Monitoring**: Continuously checks stop-loss and take-profit levels +6. **Closure**: Calculates PnL and updates balance + +## Risk Management + +- Maximum concurrent positions limit +- Stop-loss protection on every trade +- Position sizing based on account balance +- (Future: Daily loss limits, trailing stops) + +## Keyboard Controls + +- `Ctrl+C` - Graceful shutdown (closes socket connections, saves state) + +## File Structure + +``` +traider/ +├── trader.py # Main bot with TUI +├── config.json # Configuration +├── trades.db # Trade history (auto-created) +├── requirements.txt # Python dependencies +└── logs/ # (Future: logging) +``` + +## Example Output + +``` +┌─────────────────────────────────────────────────────────┐ +│ PAPER TRADING BOT | BTC: $95,234.50 | Balance: $10,245.32 │ +│ Equity: $10,387.12 | PnL: +$387.12 (+3.87%) │ +└─────────────────────────────────────────────────────────┘ + +┌─────────────┐ ┌──────────────────────────┐ +│ Statistics │ │ Open Positions (2) │ +├─────────────┤ ├──────────────────────────┤ +│ Total: 47 │ │ ID Type Entry PnL │ +│ Wins: 28 │ │ 48 LONG 95100 +$142 │ +│ Losses: 19 │ │ 49 LONG 95200 +$34 │ +│ Win Rate: 60%│ └──────────────────────────┘ +└─────────────┘ +``` + +## Notes + +- This is **paper trading only** - no real money involved +- Requires running signal generator and populated candles.db +- Price updates every 1 second from most recent candle +- Signals processed in real-time as they arrive + +## Future Enhancements + +- Trailing stop-loss implementation +- Daily loss limit enforcement +- Performance analytics dashboard +- Export trade history to CSV +- Backtesting mode +- Web dashboard option diff --git a/trader/config.json b/trader/config.json new file mode 100644 index 0000000..227312f --- /dev/null +++ b/trader/config.json @@ -0,0 +1,20 @@ +{ + "trades_db": "trades.db", + "candles_db": "../onramp/market_data.db", + "signal_socket": "/tmp/signals.sock", + + "initial_balance": 100.0, + "position_size_percent": 10.0, + "max_positions": 3, + + "stop_loss_percent": 2.0, + "take_profit_percent": 4.0, + + "min_confidence": 0.5, + "enabled_personalities": ["scalping", "swing"], + "enabled_timeframes": ["1m", "5m"], + + "max_daily_loss_percent": 5.0, + "trailing_stop": false, + "trailing_stop_percent": 1.5 +} diff --git a/trader/paper_trader.py b/trader/paper_trader.py new file mode 100755 index 0000000..44b26e5 --- /dev/null +++ b/trader/paper_trader.py @@ -0,0 +1,701 @@ +#!/usr/bin/env python3 +""" +Paper Trading Bot +Executes paper trades based on signals from the signal generator +Real-time TUI interface with trade tracking +""" + +import sqlite3 +import json +import socket +import time +import signal +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional, Dict, List +from dataclasses import dataclass, asdict +from enum import Enum +import threading +from collections import deque + +from rich.console import Console +from rich.live import Live +from rich.table import Table +from rich.layout import Layout +from rich.panel import Panel +from rich.text import Text +from rich import box + + +class TradeStatus(Enum): + OPEN = "OPEN" + CLOSED = "CLOSED" + STOPPED = "STOPPED" + + +class TradeType(Enum): + LONG = "LONG" + SHORT = "SHORT" + + +@dataclass +class Trade: + id: Optional[int] + trade_type: str + entry_price: float + entry_time: str + position_size: float + stop_loss: float + take_profit: Optional[float] + timeframe: str + personality: str + signal_confidence: float + status: str + exit_price: Optional[float] = None + exit_time: Optional[str] = None + pnl: Optional[float] = None + pnl_percent: Optional[float] = None + exit_reason: Optional[str] = None + + +class Config: + def __init__(self, config_path: str = "config.json"): + self.config_path = config_path + self.reload() + + def reload(self): + with open(self.config_path, "r") as f: + data = json.load(f) + + # Database paths + self.trades_db = data.get("trades_db", "trades.db") + self.candles_db = data.get("candles_db", "../onramp/market_data.db") + + # Signal socket + self.signal_socket = data.get("signal_socket", "/tmp/signals.sock") + + # Trading parameters + self.initial_balance = data.get("initial_balance", 10000.0) + self.position_size_percent = data.get("position_size_percent", 2.0) # % of balance per trade + self.max_positions = data.get("max_positions", 3) + self.stop_loss_percent = data.get("stop_loss_percent", 2.0) + self.take_profit_percent = data.get("take_profit_percent", 4.0) + + # Strategy filters + self.min_confidence = data.get("min_confidence", 0.5) + self.enabled_personalities = data.get("enabled_personalities", ["scalping", "swing"]) + self.enabled_timeframes = data.get("enabled_timeframes", ["1m", "5m"]) + + # Risk management + self.max_daily_loss_percent = data.get("max_daily_loss_percent", 5.0) + self.trailing_stop = data.get("trailing_stop", False) + self.trailing_stop_percent = data.get("trailing_stop_percent", 1.5) + + +class TradeDatabase: + def __init__(self, db_path: str): + self.db_path = db_path + self.init_database() + + def init_database(self): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_type TEXT NOT NULL, + entry_price REAL NOT NULL, + entry_time TEXT NOT NULL, + position_size REAL NOT NULL, + stop_loss REAL NOT NULL, + take_profit REAL, + timeframe TEXT NOT NULL, + personality TEXT NOT NULL, + signal_confidence REAL NOT NULL, + status TEXT NOT NULL, + exit_price REAL, + exit_time TEXT, + pnl REAL, + pnl_percent REAL, + exit_reason TEXT + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS balance_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + balance REAL NOT NULL, + equity REAL NOT NULL, + open_positions INTEGER NOT NULL + ) + """) + + conn.commit() + conn.close() + + def save_trade(self, trade: Trade) -> int: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + if trade.id is None: + cursor.execute(""" + INSERT INTO trades ( + trade_type, entry_price, entry_time, position_size, + stop_loss, take_profit, timeframe, personality, + signal_confidence, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + trade.trade_type, trade.entry_price, trade.entry_time, + trade.position_size, trade.stop_loss, trade.take_profit, + trade.timeframe, trade.personality, trade.signal_confidence, + trade.status + )) + trade_id = cursor.lastrowid + else: + cursor.execute(""" + UPDATE trades SET + exit_price = ?, exit_time = ?, pnl = ?, + pnl_percent = ?, status = ?, exit_reason = ? + WHERE id = ? + """, ( + trade.exit_price, trade.exit_time, trade.pnl, + trade.pnl_percent, trade.status, trade.exit_reason, + trade.id + )) + trade_id = trade.id + + conn.commit() + conn.close() + return trade_id + + def get_open_trades(self) -> List[Trade]: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM trades WHERE status = 'OPEN' ORDER BY entry_time DESC") + rows = cursor.fetchall() + conn.close() + + trades = [] + for row in rows: + trades.append(Trade(**dict(row))) + return trades + + def get_recent_trades(self, limit: int = 10) -> List[Trade]: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM trades + WHERE status != 'OPEN' + ORDER BY exit_time DESC + LIMIT ? + """, (limit,)) + rows = cursor.fetchall() + conn.close() + + trades = [] + for row in rows: + trades.append(Trade(**dict(row))) + return trades + + def save_balance_snapshot(self, balance: float, equity: float, open_positions: int): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO balance_history (timestamp, balance, equity, open_positions) + VALUES (?, ?, ?, ?) + """, (datetime.now(timezone.utc).isoformat(), balance, equity, open_positions)) + + conn.commit() + conn.close() + + def get_statistics(self) -> Dict: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # Total trades + cursor.execute("SELECT COUNT(*) FROM trades WHERE status != 'OPEN'") + total_trades = cursor.fetchone()[0] + + # Win/Loss + cursor.execute("SELECT COUNT(*) FROM trades WHERE status != 'OPEN' AND pnl > 0") + wins = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM trades WHERE status != 'OPEN' AND pnl < 0") + losses = cursor.fetchone()[0] + + # Total PnL + cursor.execute("SELECT SUM(pnl) FROM trades WHERE status != 'OPEN'") + total_pnl = cursor.fetchone()[0] or 0.0 + + # Average win/loss + cursor.execute("SELECT AVG(pnl) FROM trades WHERE status != 'OPEN' AND pnl > 0") + avg_win = cursor.fetchone()[0] or 0.0 + + cursor.execute("SELECT AVG(pnl) FROM trades WHERE status != 'OPEN' AND pnl < 0") + avg_loss = cursor.fetchone()[0] or 0.0 + + conn.close() + + win_rate = (wins / total_trades * 100) if total_trades > 0 else 0.0 + + return { + "total_trades": total_trades, + "wins": wins, + "losses": losses, + "win_rate": win_rate, + "total_pnl": total_pnl, + "avg_win": avg_win, + "avg_loss": avg_loss, + } + + +class PriceMonitor: + """Monitor current price from candles database""" + + def __init__(self, candles_db: str): + self.candles_db = candles_db + self.current_price = None + self.last_update = None + + def get_current_price(self) -> Optional[float]: + """Get most recent close price from 1m timeframe""" + try: + conn = sqlite3.connect(f"file:{self.candles_db}?mode=ro", uri=True, timeout=5) + cursor = conn.cursor() + + cursor.execute(""" + SELECT close, timestamp + FROM candles + WHERE timeframe = '1m' + ORDER BY timestamp DESC + LIMIT 1 + """) + + row = cursor.fetchone() + conn.close() + + if row: + self.current_price = float(row[0]) + self.last_update = row[1] + return self.current_price + + return None + + except Exception as e: + return None + + +class PaperTrader: + def __init__(self, config: Config): + self.config = config + self.db = TradeDatabase(config.trades_db) + self.price_monitor = PriceMonitor(config.candles_db) + + self.balance = config.initial_balance + self.open_trades: List[Trade] = [] + self.recent_signals = deque(maxlen=20) + + self.running = False + self.lock = threading.Lock() + + # Stats + self.stats = { + "signals_received": 0, + "signals_filtered": 0, + "trades_opened": 0, + "trades_closed": 0, + } + + # Load open trades from DB + self.open_trades = self.db.get_open_trades() + + def connect_to_signals(self) -> socket.socket: + """Connect to signal generator socket""" + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(self.config.signal_socket) + return sock + + def calculate_position_size(self, price: float) -> float: + """Calculate position size based on balance and config""" + position_value = self.balance * (self.config.position_size_percent / 100) + return position_value / price + + def should_take_signal(self, signal: Dict) -> bool: + """Filter signals based on config""" + # Check confidence + if signal.get("confidence", 0) < self.config.min_confidence: + return False + + # Check personality + if signal.get("personality") not in self.config.enabled_personalities: + return False + + # Check timeframe + if signal.get("timeframe") not in self.config.enabled_timeframes: + return False + + # Check max positions + if len(self.open_trades) >= self.config.max_positions: + return False + + return True + + def open_trade(self, signal: Dict): + """Open a new paper trade""" + with self.lock: + trade_type = TradeType.LONG.value if signal["signal"] == "BUY" else TradeType.SHORT.value + entry_price = signal["price"] + position_size = self.calculate_position_size(entry_price) + + # Calculate stop loss and take profit + if trade_type == TradeType.LONG.value: + stop_loss = entry_price * (1 - self.config.stop_loss_percent / 100) + take_profit = entry_price * (1 + self.config.take_profit_percent / 100) + else: + stop_loss = entry_price * (1 + self.config.stop_loss_percent / 100) + take_profit = entry_price * (1 - self.config.take_profit_percent / 100) + + trade = Trade( + id=None, + trade_type=trade_type, + entry_price=entry_price, + entry_time=signal.get("generated_at", datetime.now(timezone.utc).isoformat()), + position_size=position_size, + stop_loss=stop_loss, + take_profit=take_profit, + timeframe=signal["timeframe"], + personality=signal["personality"], + signal_confidence=signal["confidence"], + status=TradeStatus.OPEN.value, + ) + + trade_id = self.db.save_trade(trade) + trade.id = trade_id + self.open_trades.append(trade) + + self.stats["trades_opened"] += 1 + + def check_exits(self, current_price: float): + """Check if any open trades should be closed""" + with self.lock: + for trade in self.open_trades[:]: + should_exit = False + exit_reason = None + + if trade.trade_type == TradeType.LONG.value: + # Long position checks + if current_price <= trade.stop_loss: + should_exit = True + exit_reason = "Stop Loss" + elif trade.take_profit and current_price >= trade.take_profit: + should_exit = True + exit_reason = "Take Profit" + else: + # Short position checks + if current_price >= trade.stop_loss: + should_exit = True + exit_reason = "Stop Loss" + elif trade.take_profit and current_price <= trade.take_profit: + should_exit = True + exit_reason = "Take Profit" + + if should_exit: + self.close_trade(trade, current_price, exit_reason) + + def close_trade(self, trade: Trade, exit_price: float, reason: str): + """Close a trade and calculate PnL""" + trade.exit_price = exit_price + trade.exit_time = datetime.now(timezone.utc).isoformat() + trade.exit_reason = reason + + # Calculate PnL + if trade.trade_type == TradeType.LONG.value: + pnl = (exit_price - trade.entry_price) * trade.position_size + else: + pnl = (trade.entry_price - exit_price) * trade.position_size + + trade.pnl = pnl + trade.pnl_percent = (pnl / (trade.entry_price * trade.position_size)) * 100 + trade.status = TradeStatus.CLOSED.value + + self.balance += pnl + self.db.save_trade(trade) + self.open_trades.remove(trade) + + self.stats["trades_closed"] += 1 + + def get_equity(self, current_price: float) -> float: + """Calculate current equity (balance + unrealized PnL)""" + unrealized_pnl = 0.0 + + for trade in self.open_trades: + if trade.trade_type == TradeType.LONG.value: + pnl = (current_price - trade.entry_price) * trade.position_size + else: + pnl = (trade.entry_price - current_price) * trade.position_size + unrealized_pnl += pnl + + return self.balance + unrealized_pnl + + def process_signal(self, signal: Dict): + """Process incoming signal""" + self.stats["signals_received"] += 1 + self.recent_signals.append(signal) + + if self.should_take_signal(signal): + self.open_trade(signal) + else: + self.stats["signals_filtered"] += 1 + + def signal_listener_thread(self): + """Background thread to listen for signals""" + while self.running: + try: + sock = self.connect_to_signals() + sock.settimeout(1.0) + + buffer = "" + while self.running: + try: + data = sock.recv(4096).decode("utf-8") + if not data: + break + + buffer += data + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if line.strip(): + signal = json.loads(line) + self.process_signal(signal) + + except socket.timeout: + continue + except json.JSONDecodeError: + continue + + sock.close() + + except Exception as e: + if self.running: + time.sleep(5) # Wait before reconnecting + + def price_monitor_thread(self): + """Background thread to monitor prices and check exits""" + while self.running: + current_price = self.price_monitor.get_current_price() + + if current_price and self.open_trades: + self.check_exits(current_price) + + time.sleep(1) + + def build_tui(self) -> Layout: + """Build the TUI layout""" + layout = Layout() + layout.split_column( + Layout(name="header", size=3), + Layout(name="body"), + Layout(name="footer", size=3), + ) + + layout["body"].split_row( + Layout(name="left"), + Layout(name="right"), + ) + + layout["left"].split_column( + Layout(name="stats", size=10), + Layout(name="open_trades"), + ) + + layout["right"].split_column( + Layout(name="recent_closed", size=15), + Layout(name="signals"), + ) + + return layout + + def render_header(self) -> Panel: + """Render header panel""" + current_price = self.price_monitor.current_price or 0.0 + equity = self.get_equity(current_price) + pnl = equity - self.config.initial_balance + pnl_percent = (pnl / self.config.initial_balance) * 100 + + pnl_color = "green" if pnl >= 0 else "red" + + text = Text() + text.append("PAPER TRADING BOT", style="bold cyan") + text.append(f" | BTC: ${current_price:,.2f}", style="yellow") + text.append(f" | Balance: ${self.balance:,.2f}", style="white") + text.append(f" | Equity: ${equity:,.2f}", style="white") + text.append(f" | PnL: ${pnl:+,.2f} ({pnl_percent:+.2f}%)", style=pnl_color) + + return Panel(text, style="bold") + + def render_stats(self) -> Panel: + """Render statistics panel""" + db_stats = self.db.get_statistics() + + table = Table(show_header=False, box=box.SIMPLE) + table.add_column("Metric", style="cyan") + table.add_column("Value", justify="right") + + table.add_row("Total Trades", str(db_stats["total_trades"])) + table.add_row("Wins", f"[green]{db_stats['wins']}[/green]") + table.add_row("Losses", f"[red]{db_stats['losses']}[/red]") + table.add_row("Win Rate", f"{db_stats['win_rate']:.1f}%") + table.add_row("Total PnL", f"${db_stats['total_pnl']:,.2f}") + table.add_row("", "") + table.add_row("Signals RX", str(self.stats["signals_received"])) + table.add_row("Filtered", str(self.stats["signals_filtered"])) + + return Panel(table, title="Statistics", border_style="blue") + + def render_open_trades(self) -> Panel: + """Render open trades table""" + table = Table(box=box.SIMPLE) + table.add_column("ID", style="cyan") + table.add_column("Type") + table.add_column("Entry", justify="right") + table.add_column("Current", justify="right") + table.add_column("PnL", justify="right") + table.add_column("TF") + table.add_column("Pers") + + current_price = self.price_monitor.current_price or 0.0 + + for trade in self.open_trades: + if trade.trade_type == TradeType.LONG.value: + pnl = (current_price - trade.entry_price) * trade.position_size + type_color = "green" + else: + pnl = (trade.entry_price - current_price) * trade.position_size + type_color = "red" + + pnl_color = "green" if pnl >= 0 else "red" + + table.add_row( + str(trade.id), + f"[{type_color}]{trade.trade_type}[/{type_color}]", + f"${trade.entry_price:,.2f}", + f"${current_price:,.2f}", + f"[{pnl_color}]${pnl:+,.2f}[/{pnl_color}]", + trade.timeframe, + trade.personality[:4].upper(), + ) + + return Panel(table, title=f"Open Positions ({len(self.open_trades)})", border_style="green") + + def render_recent_closed(self) -> Panel: + """Render recent closed trades""" + table = Table(box=box.SIMPLE) + table.add_column("ID", style="cyan") + table.add_column("Type") + table.add_column("PnL", justify="right") + table.add_column("Exit") + + recent = self.db.get_recent_trades(10) + + for trade in recent: + type_color = "green" if trade.trade_type == TradeType.LONG.value else "red" + pnl_color = "green" if trade.pnl and trade.pnl >= 0 else "red" + + table.add_row( + str(trade.id), + f"[{type_color}]{trade.trade_type}[/{type_color}]", + f"[{pnl_color}]${trade.pnl:+,.2f}[/{pnl_color}]" if trade.pnl else "N/A", + trade.exit_reason or "N/A", + ) + + return Panel(table, title="Recent Closed Trades", border_style="yellow") + + def render_signals(self) -> Panel: + """Render recent signals""" + table = Table(box=box.SIMPLE) + table.add_column("Signal") + table.add_column("TF") + table.add_column("Conf", justify="right") + table.add_column("Pers") + + for signal in list(self.recent_signals)[-10:]: + signal_color = "green" if signal["signal"] == "BUY" else "red" + + table.add_row( + f"[{signal_color}]{signal['signal']}[/{signal_color}]", + signal["timeframe"], + f"{signal['confidence']:.2f}", + signal["personality"][:4].upper(), + ) + + return Panel(table, title="Recent Signals", border_style="magenta") + + def render_footer(self) -> Panel: + """Render footer panel""" + text = Text() + text.append("Press ", style="dim") + text.append("Ctrl+C", style="bold red") + text.append(" to exit", style="dim") + return Panel(text, style="dim") + + def update_display(self, layout: Layout): + """Update all TUI panels""" + layout["header"].update(self.render_header()) + layout["stats"].update(self.render_stats()) + layout["open_trades"].update(self.render_open_trades()) + layout["recent_closed"].update(self.render_recent_closed()) + layout["signals"].update(self.render_signals()) + layout["footer"].update(self.render_footer()) + + def run(self): + """Main run loop with TUI""" + self.running = True + + # Start background threads + signal_thread = threading.Thread(target=self.signal_listener_thread, daemon=True) + price_thread = threading.Thread(target=self.price_monitor_thread, daemon=True) + + signal_thread.start() + price_thread.start() + + # TUI + console = Console() + layout = self.build_tui() + + try: + with Live(layout, console=console, screen=True, refresh_per_second=2): + while self.running: + self.update_display(layout) + time.sleep(0.5) + + except KeyboardInterrupt: + pass + finally: + self.running = False + signal_thread.join(timeout=2) + price_thread.join(timeout=2) + + +def main(): + config = Config() + trader = PaperTrader(config) + + def shutdown(sig, frame): + trader.running = False + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + trader.run() + + +if __name__ == "__main__": + main()