diff --git a/brokers/__init__.py b/brokers/__init__.py new file mode 100644 index 000000000..24f720371 --- /dev/null +++ b/brokers/__init__.py @@ -0,0 +1,138 @@ +""" +Broker module for PowerTrader AI. +Provides a unified interface for different cryptocurrency exchanges. +""" + +import os +from typing import Optional + +from .base import BrokerAPI +from .robinhood import RobinhoodBroker +from .bitvavo import BitvavoBroker +from .paper import PaperBroker + + +# Available brokers (real trading) +BROKERS = { + "robinhood": RobinhoodBroker, + "bitvavo": BitvavoBroker, +} + + +def get_broker( + broker_name: str, + base_dir: Optional[str] = None, + paper_trading: bool = False, + paper_balance: float = 10000.0, +) -> BrokerAPI: + """ + Factory function to create a broker instance. + + Args: + broker_name: Name of the broker ('robinhood' or 'bitvavo') + base_dir: Base directory for credential files (default: current dir) + paper_trading: If True, wrap broker in paper trading simulator + paper_balance: Initial balance for paper trading + + Returns: + Configured broker instance + + Raises: + ValueError: If broker_name is not supported + SystemExit: If credentials are not found + """ + broker_name = broker_name.lower().strip() + + if broker_name not in BROKERS: + raise ValueError( + f"Unsupported broker: {broker_name}. " + f"Available brokers: {', '.join(BROKERS.keys())}" + ) + + if base_dir is None: + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + # Create the real broker + if broker_name == "robinhood": + real_broker = _create_robinhood_broker(base_dir) + elif broker_name == "bitvavo": + real_broker = _create_bitvavo_broker(base_dir) + + # Wrap in paper trading if enabled + if paper_trading: + state_file = os.path.join(base_dir, "paper_trading_state.json") + return PaperBroker( + price_source=real_broker, + initial_balance=paper_balance, + base_currency=real_broker.base_currency, + state_file=state_file, + ) + + return real_broker + + +def _create_robinhood_broker(base_dir: str) -> RobinhoodBroker: + """Create and configure Robinhood broker.""" + key_path = os.path.join(base_dir, "r_key.txt") + secret_path = os.path.join(base_dir, "r_secret.txt") + + api_key = "" + private_key = "" + + try: + with open(key_path, "r", encoding="utf-8") as f: + api_key = (f.read() or "").strip() + with open(secret_path, "r", encoding="utf-8") as f: + private_key = (f.read() or "").strip() + except Exception: + pass + + if not api_key or not private_key: + print( + "\n[PowerTrader] Robinhood API credentials not found.\n" + "Open the GUI and go to Settings → Robinhood API → Setup / Update.\n" + "That wizard will generate your keypair, tell you where to paste " + "the public key on Robinhood,\n" + "and will save r_key.txt + r_secret.txt so this trader can authenticate.\n" + ) + raise SystemExit(1) + + return RobinhoodBroker(api_key, private_key) + + +def _create_bitvavo_broker(base_dir: str) -> BitvavoBroker: + """Create and configure Bitvavo broker.""" + key_path = os.path.join(base_dir, "b_key.txt") + secret_path = os.path.join(base_dir, "b_secret.txt") + + api_key = "" + api_secret = "" + + try: + with open(key_path, "r", encoding="utf-8") as f: + api_key = (f.read() or "").strip() + with open(secret_path, "r", encoding="utf-8") as f: + api_secret = (f.read() or "").strip() + except Exception: + pass + + if not api_key or not api_secret: + print( + "\n[PowerTrader] Bitvavo API credentials not found.\n" + "Open the GUI and go to Settings → Bitvavo API → Setup / Update.\n" + "Create API keys at https://account.bitvavo.com/user/api\n" + "and save them to b_key.txt + b_secret.txt.\n" + ) + raise SystemExit(1) + + return BitvavoBroker(api_key, api_secret) + + +__all__ = [ + "BrokerAPI", + "RobinhoodBroker", + "BitvavoBroker", + "PaperBroker", + "get_broker", + "BROKERS", +] diff --git a/brokers/__pycache__/__init__.cpython-314.pyc b/brokers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 000000000..69e26ff55 Binary files /dev/null and b/brokers/__pycache__/__init__.cpython-314.pyc differ diff --git a/brokers/__pycache__/base.cpython-314.pyc b/brokers/__pycache__/base.cpython-314.pyc new file mode 100644 index 000000000..02a29c7d9 Binary files /dev/null and b/brokers/__pycache__/base.cpython-314.pyc differ diff --git a/brokers/__pycache__/bitvavo.cpython-314.pyc b/brokers/__pycache__/bitvavo.cpython-314.pyc new file mode 100644 index 000000000..ea564b878 Binary files /dev/null and b/brokers/__pycache__/bitvavo.cpython-314.pyc differ diff --git a/brokers/__pycache__/robinhood.cpython-314.pyc b/brokers/__pycache__/robinhood.cpython-314.pyc new file mode 100644 index 000000000..86d23a04f Binary files /dev/null and b/brokers/__pycache__/robinhood.cpython-314.pyc differ diff --git a/brokers/base.py b/brokers/base.py new file mode 100644 index 000000000..157010fdd --- /dev/null +++ b/brokers/base.py @@ -0,0 +1,149 @@ +""" +Abstract base class for broker implementations. +All broker integrations (Robinhood, Bitvavo, etc.) should inherit from this class. +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Tuple + + +class BrokerAPI(ABC): + """Abstract base class defining the interface for all broker implementations.""" + + # Broker identification + name: str = "base" + base_currency: str = "USD" + + @abstractmethod + def get_account(self) -> Optional[Dict[str, Any]]: + """ + Get account information including buying power. + + Returns: + Dict with account info or None on failure. + Expected keys: 'buying_power', 'buying_power_currency' + """ + pass + + @abstractmethod + def get_holdings(self) -> Optional[Dict[str, Any]]: + """ + Get current holdings/positions. + + Returns: + Dict with holdings info or None on failure. + Expected format: {'results': [{'asset_code': 'BTC', 'total_quantity': '0.5'}, ...]} + """ + pass + + @abstractmethod + def get_trading_pairs(self) -> List[Dict[str, Any]]: + """ + Get available trading pairs. + + Returns: + List of trading pair dicts or empty list on failure. + """ + pass + + @abstractmethod + def get_orders(self, symbol: str) -> Optional[Dict[str, Any]]: + """ + Get order history for a symbol. + + Args: + symbol: Trading pair symbol (e.g., 'BTC-USD' or 'BTC-EUR') + + Returns: + Dict with orders info or None on failure. + Expected format: {'results': [order1, order2, ...]} + """ + pass + + @abstractmethod + def get_price(self, symbols: List[str]) -> Tuple[Dict[str, float], Dict[str, float], List[str]]: + """ + Get current bid/ask prices for symbols. + + Args: + symbols: List of trading pair symbols + + Returns: + Tuple of (buy_prices, sell_prices, valid_symbols) + - buy_prices: {symbol: ask_price} + - sell_prices: {symbol: bid_price} + - valid_symbols: list of symbols that returned valid prices + """ + pass + + @abstractmethod + def place_buy_order( + self, + client_order_id: str, + side: str, + order_type: str, + symbol: str, + amount_in_base_currency: float, + ) -> Optional[Dict[str, Any]]: + """ + Place a buy order. + + Args: + client_order_id: Unique order identifier + side: 'buy' + order_type: 'market' or 'limit' + symbol: Trading pair symbol + amount_in_base_currency: Amount to spend in base currency (USD/EUR) + + Returns: + Order response dict or None on failure. + """ + pass + + @abstractmethod + def place_sell_order( + self, + client_order_id: str, + side: str, + order_type: str, + symbol: str, + asset_quantity: float, + ) -> Optional[Dict[str, Any]]: + """ + Place a sell order. + + Args: + client_order_id: Unique order identifier + side: 'sell' + order_type: 'market' or 'limit' + symbol: Trading pair symbol + asset_quantity: Amount of asset to sell + + Returns: + Order response dict or None on failure. + """ + pass + + def format_symbol(self, coin: str) -> str: + """ + Format a coin symbol to the broker's trading pair format. + + Args: + coin: Base coin symbol (e.g., 'BTC') + + Returns: + Formatted trading pair (e.g., 'BTC-USD' or 'BTC-EUR') + """ + return f"{coin}-{self.base_currency}" + + def extract_coin(self, symbol: str) -> str: + """ + Extract the coin symbol from a trading pair. + + Args: + symbol: Trading pair (e.g., 'BTC-USD') + + Returns: + Coin symbol (e.g., 'BTC') + """ + return symbol.split("-")[0] diff --git a/brokers/bitvavo.py b/brokers/bitvavo.py new file mode 100644 index 000000000..a0300a16f --- /dev/null +++ b/brokers/bitvavo.py @@ -0,0 +1,278 @@ +""" +Bitvavo broker implementation. +Uses the official python-bitvavo-api SDK with HMAC-SHA256 authentication. +""" + +import time +import uuid +from typing import Any, Dict, List, Optional, Tuple + +try: + from python_bitvavo_api.bitvavo import Bitvavo +except ImportError: + raise ImportError( + "python-bitvavo-api is not installed. " + "Install it with: pip install python-bitvavo-api" + ) + +from .base import BrokerAPI + + +class BitvavoBroker(BrokerAPI): + """Bitvavo API implementation using official SDK.""" + + name = "bitvavo" + base_currency = "EUR" + + def __init__(self, api_key: str, api_secret: str): + """ + Initialize Bitvavo broker. + + Args: + api_key: Bitvavo API key + api_secret: Bitvavo API secret + """ + self.client = Bitvavo({ + "APIKEY": api_key, + "APISECRET": api_secret, + "RESTURL": "https://api.bitvavo.com/v2", + "WSURL": "wss://ws.bitvavo.com/v2/", + "ACCESSWINDOW": 10000, + }) + + # Cache for transient API failures + self._last_good_bid_ask: Dict[str, Dict] = {} + + def get_account(self) -> Optional[Dict[str, Any]]: + """ + Get account information. + + Returns Bitvavo balance formatted to match expected structure. + """ + try: + balance = self.client.balance({}) + + if isinstance(balance, dict) and "error" in balance: + return None + + # Calculate total EUR balance (available + in order) + eur_balance = 0.0 + for item in balance: + if item.get("symbol") == "EUR": + eur_balance = float(item.get("available", 0)) + break + + return { + "buying_power": eur_balance, + "buying_power_currency": "EUR", + } + except Exception: + return None + + def get_holdings(self) -> Optional[Dict[str, Any]]: + """ + Get current holdings. + + Returns balances formatted to match expected structure. + """ + try: + balance = self.client.balance({}) + + if isinstance(balance, dict) and "error" in balance: + return None + + results = [] + for item in balance: + symbol = item.get("symbol", "") + available = float(item.get("available", 0)) + in_order = float(item.get("inOrder", 0)) + total = available + in_order + + # Skip EUR and zero balances + if symbol == "EUR" or total <= 0: + continue + + results.append({ + "asset_code": symbol, + "total_quantity": str(total), + "available_quantity": str(available), + "in_order_quantity": str(in_order), + }) + + return {"results": results} + except Exception: + return None + + def get_trading_pairs(self) -> List[Dict[str, Any]]: + """Get available trading pairs.""" + try: + markets = self.client.markets({}) + + if isinstance(markets, dict) and "error" in markets: + return [] + + # Filter for EUR pairs only + eur_pairs = [ + market for market in markets + if market.get("quote") == "EUR" and market.get("status") == "trading" + ] + + return eur_pairs + except Exception: + return [] + + def get_orders(self, symbol: str) -> Optional[Dict[str, Any]]: + """ + Get order history for a symbol. + + Args: + symbol: Trading pair (e.g., 'BTC-EUR') + """ + try: + # Convert format if needed (BTC-EUR -> BTC-EUR) + market = symbol.replace("-", "-") + + # Get trades (filled orders) + trades = self.client.trades(market, {}) + + if isinstance(trades, dict) and "error" in trades: + return None + + # Format to match expected structure + results = [] + for trade in trades: + results.append({ + "id": trade.get("id"), + "side": trade.get("side"), + "state": "filled", + "created_at": trade.get("timestamp"), + "executions": [{ + "quantity": trade.get("amount"), + "effective_price": trade.get("price"), + }], + }) + + return {"results": results} + except Exception: + return None + + def get_price( + self, symbols: List[str] + ) -> Tuple[Dict[str, float], Dict[str, float], List[str]]: + """Get current bid/ask prices.""" + buy_prices = {} + sell_prices = {} + valid_symbols = [] + + for symbol in symbols: + try: + # Get ticker for this market + ticker = self.client.tickerBook({"market": symbol}) + + if isinstance(ticker, dict) and "error" not in ticker: + ask = float(ticker.get("ask", 0)) + bid = float(ticker.get("bid", 0)) + + if ask > 0 and bid > 0: + buy_prices[symbol] = ask + sell_prices[symbol] = bid + valid_symbols.append(symbol) + + # Update cache + self._last_good_bid_ask[symbol] = { + "ask": ask, + "bid": bid, + "ts": time.time(), + } + else: + # Fallback to cached prices + cached = self._last_good_bid_ask.get(symbol) + if cached: + ask = float(cached.get("ask", 0) or 0) + bid = float(cached.get("bid", 0) or 0) + if ask > 0 and bid > 0: + buy_prices[symbol] = ask + sell_prices[symbol] = bid + valid_symbols.append(symbol) + except Exception: + # Fallback to cached prices + cached = self._last_good_bid_ask.get(symbol) + if cached: + ask = float(cached.get("ask", 0) or 0) + bid = float(cached.get("bid", 0) or 0) + if ask > 0 and bid > 0: + buy_prices[symbol] = ask + sell_prices[symbol] = bid + valid_symbols.append(symbol) + + return buy_prices, sell_prices, valid_symbols + + def place_buy_order( + self, + client_order_id: str, + side: str, + order_type: str, + symbol: str, + amount_in_base_currency: float, + ) -> Optional[Dict[str, Any]]: + """ + Place a buy order. + + For market orders, Bitvavo accepts 'amountQuote' (EUR amount to spend). + """ + try: + order_params = { + "market": symbol, + "side": "buy", + "orderType": "market", + "amountQuote": str(round(amount_in_base_currency, 2)), + } + + response = self.client.placeOrder( + symbol, + "buy", + "market", + order_params + ) + + if isinstance(response, dict) and "error" in response: + return None + + return response + except Exception: + return None + + def place_sell_order( + self, + client_order_id: str, + side: str, + order_type: str, + symbol: str, + asset_quantity: float, + ) -> Optional[Dict[str, Any]]: + """ + Place a sell order. + + For market orders, Bitvavo accepts 'amount' (asset quantity to sell). + """ + try: + order_params = { + "market": symbol, + "side": "sell", + "orderType": "market", + "amount": str(asset_quantity), + } + + response = self.client.placeOrder( + symbol, + "sell", + "market", + order_params + ) + + if isinstance(response, dict) and "error" in response: + return None + + return response + except Exception: + return None diff --git a/brokers/paper.py b/brokers/paper.py new file mode 100644 index 000000000..4a6f3814f --- /dev/null +++ b/brokers/paper.py @@ -0,0 +1,331 @@ +""" +Paper trading broker implementation. +Simulates trades without using real money - perfect for testing strategies. +""" + +import json +import os +import time +import uuid +from typing import Any, Dict, List, Optional, Tuple + +from .base import BrokerAPI + + +class PaperBroker(BrokerAPI): + """ + Paper trading broker that simulates trades with virtual money. + + Uses real market data from another broker but executes virtual trades. + State is persisted to disk so it survives restarts. + """ + + name = "paper" + + def __init__( + self, + price_source: BrokerAPI, + initial_balance: float = 10000.0, + base_currency: str = "EUR", + state_file: str = "paper_trading_state.json", + ): + """ + Initialize paper trading broker. + + Args: + price_source: Real broker to get market prices from + initial_balance: Starting virtual balance + base_currency: Base currency (EUR or USD) + state_file: File to persist state + """ + self.price_source = price_source + self.base_currency = base_currency + self._state_file = state_file + self._initial_balance = initial_balance + + # Load or initialize state + self._state = self._load_state() + + def _load_state(self) -> Dict[str, Any]: + """Load state from disk or initialize fresh state.""" + if os.path.isfile(self._state_file): + try: + with open(self._state_file, "r", encoding="utf-8") as f: + state = json.load(f) + # Validate state has required keys + if all(k in state for k in ["balance", "holdings", "orders", "trades"]): + return state + except Exception: + pass + + # Initialize fresh state + return { + "balance": self._initial_balance, + "holdings": {}, # {"BTC": {"quantity": 0.5, "avg_cost": 45000.0}, ...} + "orders": [], # Order history + "trades": [], # Trade history + "created_at": time.time(), + } + + def _save_state(self) -> None: + """Persist state to disk.""" + try: + self._state["updated_at"] = time.time() + tmp = f"{self._state_file}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(self._state, f, indent=2) + os.replace(tmp, self._state_file) + except Exception: + pass + + def reset(self, new_balance: Optional[float] = None) -> None: + """Reset paper trading state to initial values.""" + self._state = { + "balance": new_balance or self._initial_balance, + "holdings": {}, + "orders": [], + "trades": [], + "created_at": time.time(), + } + self._save_state() + + def get_account(self) -> Optional[Dict[str, Any]]: + """Get virtual account information.""" + return { + "buying_power": self._state["balance"], + "buying_power_currency": self.base_currency, + "paper_trading": True, + } + + def get_holdings(self) -> Optional[Dict[str, Any]]: + """Get virtual holdings.""" + results = [] + for asset, data in self._state["holdings"].items(): + qty = data.get("quantity", 0) + if qty > 0: + results.append({ + "asset_code": asset, + "total_quantity": str(qty), + "available_quantity": str(qty), + "avg_cost": data.get("avg_cost", 0), + }) + return {"results": results} + + def get_trading_pairs(self) -> List[Dict[str, Any]]: + """Get trading pairs from price source.""" + return self.price_source.get_trading_pairs() + + def get_orders(self, symbol: str) -> Optional[Dict[str, Any]]: + """Get virtual order history for a symbol.""" + coin = self.extract_coin(symbol) + orders = [o for o in self._state["orders"] if o.get("coin") == coin] + + # Format to match expected structure + results = [] + for o in orders: + results.append({ + "id": o.get("id"), + "side": o.get("side"), + "state": "filled", + "created_at": o.get("timestamp"), + "executions": [{ + "quantity": o.get("quantity"), + "effective_price": o.get("price"), + }], + }) + + return {"results": results} + + def get_price( + self, symbols: List[str] + ) -> Tuple[Dict[str, float], Dict[str, float], List[str]]: + """Get real market prices from price source.""" + return self.price_source.get_price(symbols) + + def place_buy_order( + self, + client_order_id: str, + side: str, + order_type: str, + symbol: str, + amount_in_base_currency: float, + ) -> Optional[Dict[str, Any]]: + """ + Simulate a buy order. + + Deducts from virtual balance and adds to holdings. + """ + # Get current price + buy_prices, _, valid = self.get_price([symbol]) + if symbol not in buy_prices: + return None + + price = buy_prices[symbol] + + # Check sufficient balance + if amount_in_base_currency > self._state["balance"]: + return None + + # Calculate quantity + quantity = amount_in_base_currency / price + coin = self.extract_coin(symbol) + + # Update balance + self._state["balance"] -= amount_in_base_currency + + # Update holdings with weighted average cost + if coin in self._state["holdings"]: + existing = self._state["holdings"][coin] + old_qty = existing.get("quantity", 0) + old_cost = existing.get("avg_cost", 0) + new_qty = old_qty + quantity + # Weighted average cost + if new_qty > 0: + new_avg_cost = ((old_qty * old_cost) + (quantity * price)) / new_qty + else: + new_avg_cost = price + self._state["holdings"][coin] = { + "quantity": new_qty, + "avg_cost": new_avg_cost, + } + else: + self._state["holdings"][coin] = { + "quantity": quantity, + "avg_cost": price, + } + + # Record order + order_id = str(uuid.uuid4()) + order = { + "id": order_id, + "client_order_id": client_order_id, + "coin": coin, + "symbol": symbol, + "side": "buy", + "quantity": quantity, + "price": price, + "amount": amount_in_base_currency, + "timestamp": time.time(), + } + self._state["orders"].append(order) + self._state["trades"].append(order) + + self._save_state() + + return { + "id": order_id, + "state": "filled", + "side": "buy", + "quantity": quantity, + "price": price, + "paper_trading": True, + } + + def place_sell_order( + self, + client_order_id: str, + side: str, + order_type: str, + symbol: str, + asset_quantity: float, + ) -> Optional[Dict[str, Any]]: + """ + Simulate a sell order. + + Removes from holdings and adds to virtual balance. + """ + coin = self.extract_coin(symbol) + + # Check sufficient holdings + holding = self._state["holdings"].get(coin, {}) + available = holding.get("quantity", 0) + + if asset_quantity > available: + return None + + # Get current price + _, sell_prices, valid = self.get_price([symbol]) + if symbol not in sell_prices: + return None + + price = sell_prices[symbol] + amount = asset_quantity * price + + # Update holdings + new_qty = available - asset_quantity + if new_qty <= 0.00000001: # Effectively zero + del self._state["holdings"][coin] + else: + self._state["holdings"][coin]["quantity"] = new_qty + + # Update balance + self._state["balance"] += amount + + # Record order + order_id = str(uuid.uuid4()) + order = { + "id": order_id, + "client_order_id": client_order_id, + "coin": coin, + "symbol": symbol, + "side": "sell", + "quantity": asset_quantity, + "price": price, + "amount": amount, + "timestamp": time.time(), + "avg_cost": holding.get("avg_cost", 0), + } + self._state["orders"].append(order) + self._state["trades"].append(order) + + self._save_state() + + return { + "id": order_id, + "state": "filled", + "side": "sell", + "quantity": asset_quantity, + "price": price, + "paper_trading": True, + } + + def get_performance(self) -> Dict[str, Any]: + """ + Calculate paper trading performance metrics. + + Returns: + Dict with performance stats + """ + # Get current prices for holdings + holdings = self._state["holdings"] + symbols = [self.format_symbol(coin) for coin in holdings.keys()] + + holdings_value = 0.0 + if symbols: + _, sell_prices, _ = self.get_price(symbols) + for coin, data in holdings.items(): + symbol = self.format_symbol(coin) + price = sell_prices.get(symbol, 0) + qty = data.get("quantity", 0) + holdings_value += qty * price + + total_value = self._state["balance"] + holdings_value + profit_loss = total_value - self._initial_balance + profit_pct = (profit_loss / self._initial_balance) * 100 if self._initial_balance > 0 else 0 + + # Count trades + buy_count = sum(1 for t in self._state["trades"] if t["side"] == "buy") + sell_count = sum(1 for t in self._state["trades"] if t["side"] == "sell") + + return { + "initial_balance": self._initial_balance, + "current_balance": self._state["balance"], + "holdings_value": holdings_value, + "total_value": total_value, + "profit_loss": profit_loss, + "profit_pct": profit_pct, + "total_trades": len(self._state["trades"]), + "buy_trades": buy_count, + "sell_trades": sell_count, + "base_currency": self.base_currency, + } diff --git a/brokers/robinhood.py b/brokers/robinhood.py new file mode 100644 index 000000000..417ee62a3 --- /dev/null +++ b/brokers/robinhood.py @@ -0,0 +1,226 @@ +""" +Robinhood broker implementation. +Uses Ed25519 signing for API authentication. +""" + +import base64 +import json +import time +from typing import Any, Dict, List, Optional, Tuple + +import requests +from nacl.signing import SigningKey + +from .base import BrokerAPI + + +class RobinhoodBroker(BrokerAPI): + """Robinhood Crypto API implementation.""" + + name = "robinhood" + base_currency = "USD" + + def __init__(self, api_key: str, private_key_base64: str): + """ + Initialize Robinhood broker. + + Args: + api_key: Robinhood API key + private_key_base64: Base64-encoded Ed25519 private key seed + """ + self.api_key = api_key + private_key_seed = base64.b64decode(private_key_base64) + self.private_key = SigningKey(private_key_seed) + self.base_url = "https://trading.robinhood.com" + + # Cache for transient API failures + self._last_good_bid_ask: Dict[str, Dict] = {} + + def _get_current_timestamp(self) -> int: + """Get current UTC timestamp in seconds.""" + return int(time.time()) + + def _get_authorization_header( + self, method: str, path: str, body: str, timestamp: int + ) -> Dict[str, str]: + """Generate authorization headers for API request.""" + message_to_sign = f"{self.api_key}{timestamp}{path}{method}{body}" + signed = self.private_key.sign(message_to_sign.encode("utf-8")) + + return { + "x-api-key": self.api_key, + "x-signature": base64.b64encode(signed.signature).decode("utf-8"), + "x-timestamp": str(timestamp), + } + + def _make_api_request( + self, method: str, path: str, body: Optional[str] = "" + ) -> Any: + """Make an authenticated API request.""" + timestamp = self._get_current_timestamp() + headers = self._get_authorization_header(method, path, body, timestamp) + url = self.base_url + path + + try: + if method == "GET": + response = requests.get(url, headers=headers, timeout=10) + elif method == "POST": + response = requests.post( + url, headers=headers, json=json.loads(body), timeout=10 + ) + else: + return None + + response.raise_for_status() + return response.json() + except requests.HTTPError: + try: + return response.json() + except Exception: + return None + except Exception: + return None + + def get_account(self) -> Optional[Dict[str, Any]]: + """Get account information.""" + path = "/api/v1/crypto/trading/accounts/" + return self._make_api_request("GET", path) + + def get_holdings(self) -> Optional[Dict[str, Any]]: + """Get current holdings.""" + path = "/api/v1/crypto/trading/holdings/" + return self._make_api_request("GET", path) + + def get_trading_pairs(self) -> List[Dict[str, Any]]: + """Get available trading pairs.""" + path = "/api/v1/crypto/trading/trading_pairs/" + response = self._make_api_request("GET", path) + + if not response or "results" not in response: + return [] + + return response.get("results", []) + + def get_orders(self, symbol: str) -> Optional[Dict[str, Any]]: + """Get order history for a symbol.""" + path = f"/api/v1/crypto/trading/orders/?symbol={symbol}" + return self._make_api_request("GET", path) + + def get_price( + self, symbols: List[str] + ) -> Tuple[Dict[str, float], Dict[str, float], List[str]]: + """Get current bid/ask prices.""" + buy_prices = {} + sell_prices = {} + valid_symbols = [] + + for symbol in symbols: + if symbol == "USDC-USD": + continue + + path = f"/api/v1/crypto/marketdata/best_bid_ask/?symbol={symbol}" + response = self._make_api_request("GET", path) + + if response and "results" in response: + result = response["results"][0] + ask = float(result["ask_inclusive_of_buy_spread"]) + bid = float(result["bid_inclusive_of_sell_spread"]) + + buy_prices[symbol] = ask + sell_prices[symbol] = bid + valid_symbols.append(symbol) + + # Update cache + self._last_good_bid_ask[symbol] = { + "ask": ask, + "bid": bid, + "ts": time.time(), + } + else: + # Fallback to cached prices + cached = self._last_good_bid_ask.get(symbol) + if cached: + ask = float(cached.get("ask", 0.0) or 0.0) + bid = float(cached.get("bid", 0.0) or 0.0) + if ask > 0.0 and bid > 0.0: + buy_prices[symbol] = ask + sell_prices[symbol] = bid + valid_symbols.append(symbol) + + return buy_prices, sell_prices, valid_symbols + + def place_buy_order( + self, + client_order_id: str, + side: str, + order_type: str, + symbol: str, + amount_in_base_currency: float, + ) -> Optional[Dict[str, Any]]: + """Place a buy order.""" + # Get current price to calculate quantity + buy_prices, _, _ = self.get_price([symbol]) + if symbol not in buy_prices: + return None + + current_price = buy_prices[symbol] + asset_quantity = amount_in_base_currency / current_price + + max_retries = 5 + response = None + + for _ in range(max_retries): + rounded_quantity = round(asset_quantity, 8) + + body = { + "client_order_id": client_order_id, + "side": side, + "type": order_type, + "symbol": symbol, + "market_order_config": {"asset_quantity": f"{rounded_quantity:.8f}"}, + } + + path = "/api/v1/crypto/trading/orders/" + response = self._make_api_request("POST", path, json.dumps(body)) + + if response and "errors" not in response: + return response + + # Handle precision errors + if response and "errors" in response: + for error in response["errors"]: + detail = error.get("detail", "") + if "has too much precision" in detail: + nearest_value = detail.split("nearest ")[1].split(" ")[0] + decimal_places = len(nearest_value.split(".")[1].rstrip("0")) + asset_quantity = round(asset_quantity, decimal_places) + break + elif "must be greater than or equal to" in detail: + return None + + return None + + def place_sell_order( + self, + client_order_id: str, + side: str, + order_type: str, + symbol: str, + asset_quantity: float, + ) -> Optional[Dict[str, Any]]: + """Place a sell order.""" + body = { + "client_order_id": client_order_id, + "side": side, + "type": order_type, + "symbol": symbol, + "market_order_config": {"asset_quantity": f"{asset_quantity:.8f}"}, + } + + path = "/api/v1/crypto/trading/orders/" + response = self._make_api_request("POST", path, json.dumps(body)) + + if response and isinstance(response, dict) and "errors" not in response: + return response + + return None diff --git a/pt_hub.py b/pt_hub.py index 8d28fea06..06ebe5a97 100644 --- a/pt_hub.py +++ b/pt_hub.py @@ -271,6 +271,9 @@ def set_values(self, long_sig: Any, short_sig: Any) -> None: # ----------------------------- DEFAULT_SETTINGS = { + "broker": "robinhood", # 'robinhood' or 'bitvavo' + "paper_trading": False, # True to simulate trades without real money + "paper_balance": 10000.0, # Starting balance for paper trading "main_neural_dir": r"C:\PowerTrader_AI", "coins": ["BTC", "ETH", "XRP", "BNB", "DOGE"], "default_timeframe": "1hour", @@ -4344,6 +4347,9 @@ def do_browse(): main_dir_var = tk.StringVar(value=self.settings["main_neural_dir"]) coins_var = tk.StringVar(value=",".join(self.settings["coins"])) hub_dir_var = tk.StringVar(value=self.settings.get("hub_data_dir", "")) + broker_var = tk.StringVar(value=self.settings.get("broker", "robinhood")) + paper_trading_var = tk.BooleanVar(value=bool(self.settings.get("paper_trading", False))) + paper_balance_var = tk.StringVar(value=str(self.settings.get("paper_balance", 10000.0))) neural_script_var = tk.StringVar(value=self.settings["script_neural_runner2"]) trainer_script_var = tk.StringVar(value=self.settings.get("script_neural_trainer", "pt_trainer.py")) @@ -4355,6 +4361,32 @@ def do_browse(): auto_start_var = tk.BooleanVar(value=bool(self.settings.get("auto_start_scripts", False))) r = 0 + + # Broker selection dropdown + ttk.Label(frm, text="Broker:").grid(row=r, column=0, sticky="w", padx=(0, 10), pady=6) + broker_combo = ttk.Combobox(frm, textvariable=broker_var, values=["robinhood", "bitvavo"], state="readonly", width=20) + broker_combo.grid(row=r, column=1, sticky="w", pady=6) + ttk.Label(frm, text="").grid(row=r, column=2, sticky="e", padx=(10, 0), pady=6) + r += 1 + + # Paper trading options + paper_frame = ttk.Frame(frm) + paper_frame.grid(row=r, column=0, columnspan=3, sticky="ew", pady=6) + + paper_chk = ttk.Checkbutton( + paper_frame, + text="Paper Trading (simulate without real money)", + variable=paper_trading_var + ) + paper_chk.pack(side="left") + + ttk.Label(paper_frame, text=" Balance:").pack(side="left", padx=(20, 5)) + paper_bal_entry = ttk.Entry(paper_frame, textvariable=paper_balance_var, width=12) + paper_bal_entry.pack(side="left") + ttk.Label(paper_frame, text="EUR/USD").pack(side="left", padx=(5, 0)) + + r += 1 + add_row(r, "Main neural folder:", main_dir_var, browse="dir"); r += 1 add_row(r, "Coins (comma):", coins_var); r += 1 add_row(r, "Hub data dir (optional):", hub_dir_var, browse="dir"); r += 1 @@ -4959,6 +4991,153 @@ def do_save(): _refresh_api_status() + # --- Bitvavo API setup (writes b_key.txt + b_secret.txt used by pt_trader.py) --- + def _bitvavo_api_paths() -> Tuple[str, str]: + key_path = os.path.join(self.project_dir, "b_key.txt") + secret_path = os.path.join(self.project_dir, "b_secret.txt") + return key_path, secret_path + + def _read_bitvavo_api_files() -> Tuple[str, str]: + key_path, secret_path = _bitvavo_api_paths() + try: + with open(key_path, "r", encoding="utf-8") as f: + k = (f.read() or "").strip() + except Exception: + k = "" + try: + with open(secret_path, "r", encoding="utf-8") as f: + s = (f.read() or "").strip() + except Exception: + s = "" + return k, s + + bitvavo_status_var = tk.StringVar(value="") + + def _refresh_bitvavo_status() -> None: + key_path, secret_path = _bitvavo_api_paths() + k, s = _read_bitvavo_api_files() + + missing = [] + if not k: + missing.append("b_key.txt (API Key)") + if not s: + missing.append("b_secret.txt (API Secret)") + + if missing: + bitvavo_status_var.set("Not configured ❌ (missing " + ", ".join(missing) + ")") + else: + bitvavo_status_var.set("Configured ✅ (credentials found)") + + def _open_bitvavo_api_wizard() -> None: + """Simple wizard to set up Bitvavo API credentials.""" + import webbrowser + + wiz = tk.Toplevel(win) + wiz.title("Bitvavo API Setup") + wiz.geometry("600x400") + wiz.minsize(500, 350) + wiz.configure(bg=DARK_BG) + + container = ttk.Frame(wiz) + container.pack(fill="both", expand=True, padx=20, pady=20) + container.columnconfigure(0, weight=1) + + key_path, secret_path = _bitvavo_api_paths() + existing_key, existing_secret = _read_bitvavo_api_files() + + # Instructions + ttk.Label( + container, + text="Bitvavo API Setup", + font=("TkDefaultFont", 14, "bold") + ).grid(row=0, column=0, sticky="w", pady=(0, 10)) + + ttk.Label( + container, + text="1. Go to Bitvavo → Settings → API Keys\n" + "2. Create a new API key with trading permissions\n" + "3. Copy the API Key and Secret below", + justify="left" + ).grid(row=1, column=0, sticky="w", pady=(0, 15)) + + def open_bitvavo_page(): + webbrowser.open("https://account.bitvavo.com/user/api") + + ttk.Button(container, text="Open Bitvavo API page", command=open_bitvavo_page).grid(row=2, column=0, sticky="w", pady=(0, 15)) + + # API Key input + ttk.Label(container, text="API Key:").grid(row=3, column=0, sticky="w") + api_key_var = tk.StringVar(value=existing_key) + api_key_entry = ttk.Entry(container, textvariable=api_key_var, width=60) + api_key_entry.grid(row=4, column=0, sticky="ew", pady=(0, 10)) + + # API Secret input + ttk.Label(container, text="API Secret:").grid(row=5, column=0, sticky="w") + api_secret_var = tk.StringVar(value=existing_secret) + api_secret_entry = ttk.Entry(container, textvariable=api_secret_var, width=60, show="*") + api_secret_entry.grid(row=6, column=0, sticky="ew", pady=(0, 20)) + + def do_save(): + key = api_key_var.get().strip() + secret = api_secret_var.get().strip() + + if not key or not secret: + messagebox.showerror("Missing credentials", "Please enter both API Key and API Secret.") + return + + try: + with open(key_path, "w", encoding="utf-8") as f: + f.write(key) + with open(secret_path, "w", encoding="utf-8") as f: + f.write(secret) + except Exception as e: + messagebox.showerror("Save failed", f"Could not save credentials:\n\n{e}") + return + + _refresh_bitvavo_status() + messagebox.showinfo("Saved", "Bitvavo API credentials saved successfully!") + wiz.destroy() + + btns = ttk.Frame(container) + btns.grid(row=7, column=0, sticky="ew") + ttk.Button(btns, text="Save", command=do_save).pack(side="left") + ttk.Button(btns, text="Cancel", command=wiz.destroy).pack(side="left", padx=8) + + def _clear_bitvavo_api_files() -> None: + """Delete b_key.txt / b_secret.txt.""" + key_path, secret_path = _bitvavo_api_paths() + if not messagebox.askyesno( + "Delete Bitvavo credentials?", + f"This will delete:\n {key_path}\n {secret_path}\n\nAre you sure?" + ): + return + + try: + if os.path.isfile(key_path): + os.remove(key_path) + if os.path.isfile(secret_path): + os.remove(secret_path) + except Exception as e: + messagebox.showerror("Delete failed", f"Couldn't delete the files:\n\n{e}") + return + + _refresh_bitvavo_status() + messagebox.showinfo("Deleted", "Deleted b_key.txt and b_secret.txt.") + + ttk.Label(frm, text="Bitvavo API:").grid(row=r, column=0, sticky="w", padx=(0, 10), pady=6) + + bitvavo_row = ttk.Frame(frm) + bitvavo_row.grid(row=r, column=1, columnspan=2, sticky="ew", pady=6) + bitvavo_row.columnconfigure(0, weight=1) + + ttk.Label(bitvavo_row, textvariable=bitvavo_status_var).grid(row=0, column=0, sticky="w") + ttk.Button(bitvavo_row, text="Setup", command=_open_bitvavo_api_wizard).grid(row=0, column=1, sticky="e", padx=(10, 0)) + ttk.Button(bitvavo_row, text="Clear", command=_clear_bitvavo_api_files).grid(row=0, column=2, sticky="e", padx=(8, 0)) + + r += 1 + + _refresh_bitvavo_status() + ttk.Separator(frm, orient="horizontal").grid(row=r, column=0, columnspan=3, sticky="ew", pady=10); r += 1 @@ -4979,6 +5158,12 @@ def save(): # Track coins before changes so we can detect newly added coins prev_coins = set([str(c).strip().upper() for c in (self.settings.get("coins") or []) if str(c).strip()]) + self.settings["broker"] = broker_var.get().strip().lower() + self.settings["paper_trading"] = bool(paper_trading_var.get()) + try: + self.settings["paper_balance"] = float(paper_balance_var.get().strip()) + except ValueError: + self.settings["paper_balance"] = 10000.0 self.settings["main_neural_dir"] = main_dir_var.get().strip() self.settings["coins"] = [c.strip().upper() for c in coins_var.get().split(",") if c.strip()] self.settings["hub_data_dir"] = hub_dir_var.get().strip() diff --git a/pt_trader.py b/pt_trader.py index 5673fe4bb..a2e521dd4 100644 --- a/pt_trader.py +++ b/pt_trader.py @@ -14,6 +14,9 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 from cryptography.hazmat.primitives import serialization +# Broker abstraction +from brokers import get_broker, BrokerAPI + # ----------------------------- # GUI HUB OUTPUTS # ----------------------------- @@ -42,6 +45,9 @@ "mtime": None, "coins": ['BTC', 'ETH', 'XRP', 'BNB', 'DOGE'], # fallback defaults "main_neural_dir": None, + "broker": "robinhood", # 'robinhood' or 'bitvavo' + "paper_trading": False, + "paper_balance": 10000.0, } def _load_gui_settings() -> dict: @@ -75,14 +81,32 @@ def _load_gui_settings() -> dict: else: main_neural_dir = None + # Load broker setting + broker = data.get("broker", "robinhood") + if broker not in ("robinhood", "bitvavo"): + broker = "robinhood" + + # Load paper trading settings + paper_trading = bool(data.get("paper_trading", False)) + try: + paper_balance = float(data.get("paper_balance", 10000.0)) + except (ValueError, TypeError): + paper_balance = 10000.0 + _gui_settings_cache["mtime"] = mtime _gui_settings_cache["coins"] = coins _gui_settings_cache["main_neural_dir"] = main_neural_dir + _gui_settings_cache["broker"] = broker + _gui_settings_cache["paper_trading"] = paper_trading + _gui_settings_cache["paper_balance"] = paper_balance return { "mtime": mtime, "coins": list(coins), "main_neural_dir": main_neural_dir, + "broker": broker, + "paper_trading": paper_trading, + "paper_balance": paper_balance, } except Exception: return dict(_gui_settings_cache) @@ -151,37 +175,52 @@ def _refresh_paths_and_symbols(): base_paths = _build_base_paths(main_dir, crypto_symbols) -#API STUFF -API_KEY = "" -BASE64_PRIVATE_KEY = "" - -try: - with open('r_key.txt', 'r', encoding='utf-8') as f: - API_KEY = (f.read() or "").strip() - with open('r_secret.txt', 'r', encoding='utf-8') as f: - BASE64_PRIVATE_KEY = (f.read() or "").strip() -except Exception: - API_KEY = "" - BASE64_PRIVATE_KEY = "" - -if not API_KEY or not BASE64_PRIVATE_KEY: - print( - "\n[PowerTrader] Robinhood API credentials not found.\n" - "Open the GUI and go to Settings → Robinhood API → Setup / Update.\n" - "That wizard will generate your keypair, tell you where to paste the public key on Robinhood,\n" - "and will save r_key.txt + r_secret.txt so this trader can authenticate.\n" +# Load broker based on settings +def _get_configured_broker() -> BrokerAPI: + """Load and return the configured broker instance.""" + settings = _load_gui_settings() + broker_name = settings.get("broker", "robinhood") + paper_trading = settings.get("paper_trading", False) + paper_balance = settings.get("paper_balance", 10000.0) + base_dir = os.path.dirname(os.path.abspath(__file__)) + return get_broker( + broker_name, + base_dir, + paper_trading=paper_trading, + paper_balance=paper_balance, ) - raise SystemExit(1) + +# Initialize broker (will be reloaded if settings change) +_current_broker: Optional[BrokerAPI] = None +_current_broker_name: Optional[str] = None +_current_paper_trading: Optional[bool] = None class CryptoAPITrading: def __init__(self): + global _current_broker, _current_broker_name, _current_paper_trading + # keep a copy of the folder map (same idea as trader.py) self.path_map = dict(base_paths) - self.api_key = API_KEY - private_key_seed = base64.b64decode(BASE64_PRIVATE_KEY) - self.private_key = SigningKey(private_key_seed) - self.base_url = "https://trading.robinhood.com" + # Load broker from settings + settings = _load_gui_settings() + broker_name = settings.get("broker", "robinhood") + paper_trading = settings.get("paper_trading", False) + + # Initialize or reuse broker (reload if broker or paper mode changed) + if (_current_broker is None or + _current_broker_name != broker_name or + _current_paper_trading != paper_trading): + _current_broker = _get_configured_broker() + _current_broker_name = broker_name + _current_paper_trading = paper_trading + if paper_trading: + print(f"\n[PowerTrader] 📝 PAPER TRADING MODE - No real trades will be executed") + print(f"[PowerTrader] Starting balance: {settings.get('paper_balance', 10000.0):.2f} {_current_broker.base_currency}\n") + + self.broker = _current_broker + self.broker_name = broker_name + self.paper_trading = paper_trading self.dca_levels_triggered = {} # Track DCA levels for each crypto self.dca_levels = [-2.5, -5.0, -10.0, -20.0, -30.0, -40.0, -50.0] # Moved to instance variable @@ -449,7 +488,7 @@ def initialize_dca_levels(self): for holding in holdings.get("results", []): symbol = holding["asset_code"] - full_symbol = f"{symbol}-USD" + full_symbol = self.broker.format_symbol(symbol) orders = self.get_orders(full_symbol) if not orders or "results" not in orders: @@ -618,66 +657,23 @@ def _reset_dca_window_for_trade(self, base_symbol: str, sold: bool = False, ts: self._dca_buy_ts[base] = [] - def make_api_request(self, method: str, path: str, body: Optional[str] = "") -> Any: - - timestamp = self._get_current_timestamp() - headers = self.get_authorization_header(method, path, body, timestamp) - url = self.base_url + path - - try: - if method == "GET": - response = requests.get(url, headers=headers, timeout=10) - elif method == "POST": - response = requests.post(url, headers=headers, json=json.loads(body), timeout=10) - - response.raise_for_status() - return response.json() - except requests.HTTPError as http_err: - try: - # Parse and return the JSON error response - error_response = response.json() - return error_response # Return the JSON error for further handling - except Exception: - return None - except Exception: - return None - - def get_authorization_header( - self, method: str, path: str, body: str, timestamp: int - ) -> Dict[str, str]: - message_to_sign = f"{self.api_key}{timestamp}{path}{method}{body}" - signed = self.private_key.sign(message_to_sign.encode("utf-8")) - - return { - "x-api-key": self.api_key, - "x-signature": base64.b64encode(signed.signature).decode("utf-8"), - "x-timestamp": str(timestamp), - } + # --- Broker API delegation methods --- def get_account(self) -> Any: - path = "/api/v1/crypto/trading/accounts/" - return self.make_api_request("GET", path) + """Get account info via broker.""" + return self.broker.get_account() def get_holdings(self) -> Any: - path = "/api/v1/crypto/trading/holdings/" - return self.make_api_request("GET", path) + """Get holdings via broker.""" + return self.broker.get_holdings() def get_trading_pairs(self) -> Any: - path = "/api/v1/crypto/trading/trading_pairs/" - response = self.make_api_request("GET", path) - - if not response or "results" not in response: - return [] - - trading_pairs = response.get("results", []) - if not trading_pairs: - return [] - - return trading_pairs + """Get trading pairs via broker.""" + return self.broker.get_trading_pairs() def get_orders(self, symbol: str) -> Any: - path = f"/api/v1/crypto/trading/orders/?symbol={symbol}" - return self.make_api_request("GET", path) + """Get orders via broker.""" + return self.broker.get_orders(symbol) def calculate_cost_basis(self): holdings = self.get_holdings() @@ -693,7 +689,9 @@ def calculate_cost_basis(self): cost_basis = {} for asset_code in active_assets: - orders = self.get_orders(f"{asset_code}-USD") + # Use broker's symbol format (USD vs EUR) + symbol = self.broker.format_symbol(asset_code) + orders = self.get_orders(symbol) if not orders or "results" not in orders: continue @@ -734,48 +732,8 @@ def calculate_cost_basis(self): return cost_basis def get_price(self, symbols: list) -> Dict[str, float]: - buy_prices = {} - sell_prices = {} - valid_symbols = [] - - for symbol in symbols: - if symbol == "USDC-USD": - continue - - path = f"/api/v1/crypto/marketdata/best_bid_ask/?symbol={symbol}" - response = self.make_api_request("GET", path) - - if response and "results" in response: - result = response["results"][0] - ask = float(result["ask_inclusive_of_buy_spread"]) - bid = float(result["bid_inclusive_of_sell_spread"]) - - buy_prices[symbol] = ask - sell_prices[symbol] = bid - valid_symbols.append(symbol) - - # Update cache for transient failures later - try: - self._last_good_bid_ask[symbol] = {"ask": ask, "bid": bid, "ts": time.time()} - except Exception: - pass - else: - # Fallback to cached bid/ask so account value never drops due to a transient miss - cached = None - try: - cached = self._last_good_bid_ask.get(symbol) - except Exception: - cached = None - - if cached: - ask = float(cached.get("ask", 0.0) or 0.0) - bid = float(cached.get("bid", 0.0) or 0.0) - if ask > 0.0 and bid > 0.0: - buy_prices[symbol] = ask - sell_prices[symbol] = bid - valid_symbols.append(symbol) - - return buy_prices, sell_prices, valid_symbols + """Get prices via broker.""" + return self.broker.get_price(symbols) def place_buy_order( @@ -789,69 +747,37 @@ def place_buy_order( pnl_pct: Optional[float] = None, tag: Optional[str] = None, ) -> Any: - # Fetch the current price of the asset - current_buy_prices, current_sell_prices, valid_symbols = self.get_price([symbol]) - current_price = current_buy_prices[symbol] - asset_quantity = amount_in_usd / current_price - - max_retries = 5 - retries = 0 - - while retries < max_retries: - retries += 1 - try: - # Default precision to 8 decimals initially - rounded_quantity = round(asset_quantity, 8) - - body = { - "client_order_id": client_order_id, - "side": side, - "type": order_type, - "symbol": symbol, - "market_order_config": { - "asset_quantity": f"{rounded_quantity:.8f}" # Start with 8 decimal places - } - } + """Place buy order via broker.""" + # Get current price for recording + current_buy_prices, _, _ = self.get_price([symbol]) + current_price = current_buy_prices.get(symbol, 0) + + # Place order via broker + response = self.broker.place_buy_order( + client_order_id=client_order_id, + side=side, + order_type=order_type, + symbol=symbol, + amount_in_base_currency=amount_in_usd, + ) - path = "/api/v1/crypto/trading/orders/" - response = self.make_api_request("POST", path, json.dumps(body)) - if response and "errors" not in response: - # Record for GUI history (estimated fill at current_price) - try: - order_id = response.get("id", None) if isinstance(response, dict) else None - except Exception: - order_id = None - self._record_trade( - side="buy", - symbol=symbol, - qty=float(rounded_quantity), - price=float(current_price), - avg_cost_basis=float(avg_cost_basis) if avg_cost_basis is not None else None, - pnl_pct=float(pnl_pct) if pnl_pct is not None else None, - tag=tag, - order_id=order_id, - ) - return response # Successfully placed order + if response: + # Calculate quantity for recording + asset_quantity = amount_in_usd / current_price if current_price > 0 else 0 + order_id = response.get("id", None) if isinstance(response, dict) else None - except Exception as e: - pass #print(traceback.format_exc()) - - - # Check for precision errors - if response and "errors" in response: - for error in response["errors"]: - if "has too much precision" in error.get("detail", ""): - # Extract required precision directly from the error message - detail = error["detail"] - nearest_value = detail.split("nearest ")[1].split(" ")[0] - - decimal_places = len(nearest_value.split(".")[1].rstrip("0")) - asset_quantity = round(asset_quantity, decimal_places) - break - elif "must be greater than or equal to" in error.get("detail", ""): - return None + self._record_trade( + side="buy", + symbol=symbol, + qty=float(asset_quantity), + price=float(current_price) if current_price > 0 else None, + avg_cost_basis=float(avg_cost_basis) if avg_cost_basis is not None else None, + pnl_pct=float(pnl_pct) if pnl_pct is not None else None, + tag=tag, + order_id=order_id, + ) - return None + return response def place_sell_order( @@ -866,21 +792,16 @@ def place_sell_order( pnl_pct: Optional[float] = None, tag: Optional[str] = None, ) -> Any: - body = { - "client_order_id": client_order_id, - "side": side, - "type": order_type, - "symbol": symbol, - "market_order_config": { - "asset_quantity": f"{asset_quantity:.8f}" - } - } - - path = "/api/v1/crypto/trading/orders/" - - response = self.make_api_request("POST", path, json.dumps(body)) + """Place sell order via broker.""" + response = self.broker.place_sell_order( + client_order_id=client_order_id, + side=side, + order_type=order_type, + symbol=symbol, + asset_quantity=asset_quantity, + ) - if response and isinstance(response, dict) and "errors" not in response: + if response and isinstance(response, dict): order_id = response.get("id", None) self._record_trade( side="sell", @@ -916,12 +837,12 @@ def manage_trades(self): # Use the stored cost_basis instead of recalculating cost_basis = self.cost_basis - # Fetch current prices - symbols = [holding["asset_code"] + "-USD" for holding in holdings.get("results", [])] + # Fetch current prices using broker's symbol format + symbols = [self.broker.format_symbol(holding["asset_code"]) for holding in holdings.get("results", [])] # ALSO fetch prices for tracked coins even if not currently held (so GUI can show bid/ask lines) for s in crypto_symbols: - full = f"{s}-USD" + full = self.broker.format_symbol(s) if full not in symbols: symbols.append(full) @@ -960,7 +881,7 @@ def manage_trades(self): if qty <= 0.0: continue - sym = f"{asset}-USD" + sym = self.broker.format_symbol(asset) bp = float(current_buy_prices.get(sym, 0.0) or 0.0) sp = float(current_sell_prices.get(sym, 0.0) or 0.0) @@ -1011,7 +932,7 @@ def manage_trades(self): positions = {} for holding in holdings.get("results", []): symbol = holding["asset_code"] - full_symbol = f"{symbol}-USD" + full_symbol = self.broker.format_symbol(symbol) if full_symbol not in valid_symbols or symbol == "USDC": continue @@ -1336,7 +1257,7 @@ def manage_trades(self): if sym in positions: continue - full_symbol = f"{sym}-USD" + full_symbol = self.broker.format_symbol(sym) if full_symbol not in valid_symbols or sym == "USDC": continue @@ -1381,12 +1302,12 @@ def manage_trades(self): if allocation_in_usd < 0.5: allocation_in_usd = 0.5 - holding_full_symbols = [f"{h['asset_code']}-USD" for h in holdings.get("results", [])] + holding_full_symbols = [self.broker.format_symbol(h['asset_code']) for h in holdings.get("results", [])] start_index = 0 while start_index < len(crypto_symbols): base_symbol = crypto_symbols[start_index].upper().strip() - full_symbol = f"{base_symbol}-USD" + full_symbol = self.broker.format_symbol(base_symbol) # Skip if already held if full_symbol in holding_full_symbols: @@ -1431,7 +1352,7 @@ def manage_trades(self): ) time.sleep(5) holdings = self.get_holdings() - holding_full_symbols = [f"{h['asset_code']}-USD" for h in holdings.get("results", [])] + holding_full_symbols = [self.broker.format_symbol(h['asset_code']) for h in holdings.get("results", [])] start_index += 1 diff --git a/requirements.txt b/requirements.txt index 6c718916f..630e75704 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ colorama cryptography PyNaCl kucoin-python +python-bitvavo-api