From 954781bfb8368c3df2a940650e3520858b829ce4 Mon Sep 17 00:00:00 2001 From: apeer Date: Fri, 2 Jan 2026 19:52:17 +0100 Subject: [PATCH 1/2] Add Bitvavo broker support - Add broker abstraction layer (brokers/ module) - Implement RobinhoodBroker and BitvavoBroker classes - Add broker selection dropdown in GUI settings - Add Bitvavo API setup wizard in settings dialog - Update pt_trader.py to use broker interface - Add python-bitvavo-api to requirements.txt Supports EUR trading pairs for Bitvavo (BTC-EUR, ETH-EUR, etc.) while maintaining full Robinhood compatibility (USD pairs). --- brokers/__init__.py | 116 +++++++ brokers/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 5274 bytes brokers/__pycache__/base.cpython-314.pyc | Bin 0 -> 6763 bytes brokers/__pycache__/bitvavo.cpython-314.pyc | Bin 0 -> 11005 bytes brokers/__pycache__/robinhood.cpython-314.pyc | Bin 0 -> 11333 bytes brokers/base.py | 149 +++++++++ brokers/bitvavo.py | 278 ++++++++++++++++ brokers/robinhood.py | 226 +++++++++++++ pt_hub.py | 158 +++++++++ pt_trader.py | 300 ++++++------------ requirements.txt | 1 + 11 files changed, 1024 insertions(+), 204 deletions(-) create mode 100644 brokers/__init__.py create mode 100644 brokers/__pycache__/__init__.cpython-314.pyc create mode 100644 brokers/__pycache__/base.cpython-314.pyc create mode 100644 brokers/__pycache__/bitvavo.cpython-314.pyc create mode 100644 brokers/__pycache__/robinhood.cpython-314.pyc create mode 100644 brokers/base.py create mode 100644 brokers/bitvavo.py create mode 100644 brokers/robinhood.py diff --git a/brokers/__init__.py b/brokers/__init__.py new file mode 100644 index 000000000..517eea310 --- /dev/null +++ b/brokers/__init__.py @@ -0,0 +1,116 @@ +""" +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 + + +# Available brokers +BROKERS = { + "robinhood": RobinhoodBroker, + "bitvavo": BitvavoBroker, +} + + +def get_broker(broker_name: str, base_dir: Optional[str] = None) -> 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) + + 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__))) + + if broker_name == "robinhood": + return _create_robinhood_broker(base_dir) + elif broker_name == "bitvavo": + return _create_bitvavo_broker(base_dir) + + +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", + "get_broker", + "BROKERS", +] diff --git a/brokers/__pycache__/__init__.cpython-314.pyc b/brokers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..69e26ff55723d1db8c3054d35c4ee5e48787fbf7 GIT binary patch literal 5274 zcmeHLU2GJ`9iP43``WwnH~14g*unP2_MI^wb_~S@OtAwo?gB1V4XgFto^Q+f_B6X^ zY%5JqRaH6aL!f=IqeQ|;NPtS!MoN`F^d+g)w@Rj$$j%C+swJ)ZW@;i!^ripVkGqS5 zl14>UA2O$#`OnOMX8!Y^ncr__JIg{mg7C-CMdf!Pg#Jo1PT*`|^ED81NI*Kej09$c z(HTfxBd%dqXPePwMqo#{VYlu!Wp2bX?A5)d>>lwA`*nXa5e6|kOz_nr z!C#9~6)8p!2t4EhG^YoJAjlz84n>eqewNY81f~^*E9R5@aiU(437%Ht*_4bED#2sw zRhgV8QXGnpjYRk{qRuFBS;G>}rj>*u$FY*uWs;C$R)e^bNXSG^>o`W{GP)Ye63E5o zusj<}O6e(Ci}0gi#_*lZ=!%+_QXu=SagU9SK&D(!CzW(kRpVAE6orl}`iwNAS_Oum zI2wj`(yECu;6VDP!2^F5{+j|sIW&$gyZV2OP#nsd(6^Z+BQO(CV+FxA;d10+X;*_& zhLFHcIO~L>EO?6h2W?>lCQWaNX=z$Ee3OzUi*bb*9wO^mk`A*5tLcPLRYI+Sd3eC1 z(~_=HLtBh$tGyN#MJ281q8Q1{89`B$(rHzfbXgQh1t{)-zqS{m9NI#=n4ZTpdi%r% zP44x-d*YYhFvXP-vtknzQLHc{$oSz8<8zyTfMKaO#vDIURXZH8(=lr4w&2udpl#6; zFF`Ny{nVoqUZ;V;jH4jzTY(*;qizeuwqt>9RgiT?Ibd5CM7^9NCz)|3aoHRz=(HRw zbEINAR#}uCY2taesCIfTrV}-e>{z@#&46bFcmGB38Q23pt94q8GmdU6_*d|h=+r+; zN8n%wdZkyuP5if{n68pJoXDnQ^e|yvg;OWP-oX;Kb{CvZO_$O!Il}YD$dop~V+bWX zcL0yV{>5qn>q*&Z)X`ozV(l1Epxr)Z?P06Az1IiuagZ?Vdf7Gvbisf)9DhYh;e?U` zr09st2`QV>2e5?&I&BcQMnRyhjg&)T@KdsG>kteDCaC-dgdEz~jjDIARkhx)YRy;eU90N6U)7nf>du}1 zv$yK)_7A;#9+cIsm$fV(UhdvNOi#;tOWW=6&G5ZbzdCdG%r8dsEr(b49NA!*(9unn zE$257>*qIkW#~eT#$F2XFP>?z`D{d+_GqyS?xAz0>#J;5&mW{rUZ8 z@~yArt49}nFx<5(bhz^NJHCARzE$78b$`v0|3g0%?Ojgh%cHBl=wlY~Erg!)&p8-b}2p}mF~LXpP8Y}1)0Dx1+kT0xnDLURMN z?=g*TX&W@T_d_#RG!@j$KsStu$Fs0NIaK7Fu~O8wmB-w+P?7*jFb8biObL^Hh4!vO zd%)@p3xBS$1jol2ffL+`yYXR=aP2kJ8PkXAB7J7wsO6(>BgZq553iYT7%yXjtmY2YObyo~>3G0PJ}B_s~J; z{$Gy*HBS)Z4t?wk0$sU7PdA5Qs_%Pt4t1jUo5DkG_XqCXAb-#r9;$QyKIj8^#T^*h z;a{mR<+{L7hkvD&qvahejXMKFFR?5A48+fmjz|lbyel`9nESoi2_8MoMO@m z5X3a)p~8HDR!S!v;Xg+kBur=iB8@0%kWLzP(Wsk7CVE&bK_avuN+WtlHh9Vo7G02D zDEJ&JG*eX?nGo{{2w#;IDywrZKPOgbXuQ$(X4_jGHzG@s<;k`B=>7U=zJA}r%O$Mv zqR9$u=@Xvlv!1W`qIn2KSW13O3 literal 0 HcmV?d00001 diff --git a/brokers/__pycache__/base.cpython-314.pyc b/brokers/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02a29c7d9b0d057849c43fed00fcc26d7ccaecc6 GIT binary patch literal 6763 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 literal 0 HcmV?d00001 diff --git a/brokers/__pycache__/robinhood.cpython-314.pyc b/brokers/__pycache__/robinhood.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86d23a04fd95624d4149b0cb3aadf2094395e948 GIT binary patch literal 11333 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 F{|9%ZVwL~^ literal 0 HcmV?d00001 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/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..d2990f8c1 100644 --- a/pt_hub.py +++ b/pt_hub.py @@ -271,6 +271,7 @@ def set_values(self, long_sig: Any, short_sig: Any) -> None: # ----------------------------- DEFAULT_SETTINGS = { + "broker": "robinhood", # 'robinhood' or 'bitvavo' "main_neural_dir": r"C:\PowerTrader_AI", "coins": ["BTC", "ETH", "XRP", "BNB", "DOGE"], "default_timeframe": "1hour", @@ -4344,6 +4345,7 @@ 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")) 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 +4357,14 @@ 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 + 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 +4969,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 +5136,7 @@ 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["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..9de2b0482 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,7 @@ "mtime": None, "coins": ['BTC', 'ETH', 'XRP', 'BNB', 'DOGE'], # fallback defaults "main_neural_dir": None, + "broker": "robinhood", # 'robinhood' or 'bitvavo' } def _load_gui_settings() -> dict: @@ -75,14 +79,21 @@ 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" + _gui_settings_cache["mtime"] = mtime _gui_settings_cache["coins"] = coins _gui_settings_cache["main_neural_dir"] = main_neural_dir + _gui_settings_cache["broker"] = broker return { "mtime": mtime, "coins": list(coins), "main_neural_dir": main_neural_dir, + "broker": broker, } except Exception: return dict(_gui_settings_cache) @@ -151,37 +162,36 @@ 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" - ) - raise SystemExit(1) +# 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") + base_dir = os.path.dirname(os.path.abspath(__file__)) + return get_broker(broker_name, base_dir) + +# Initialize broker (will be reloaded if settings change) +_current_broker: Optional[BrokerAPI] = None +_current_broker_name: Optional[str] = None class CryptoAPITrading: def __init__(self): + global _current_broker, _current_broker_name + # 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") + + # Initialize or reuse broker + if _current_broker is None or _current_broker_name != broker_name: + _current_broker = _get_configured_broker() + _current_broker_name = broker_name + + self.broker = _current_broker + self.broker_name = broker_name 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 +459,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 +628,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 +660,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 +703,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 +718,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 +763,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 +808,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 +852,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 +903,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 +1228,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 +1273,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 +1323,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 From 281571989d673e73b30fa9cc78bc22e030beea5b Mon Sep 17 00:00:00 2001 From: apeer Date: Fri, 2 Jan 2026 20:18:13 +0100 Subject: [PATCH 2/2] Add paper trading feature - Add PaperBroker class for simulated trading without real money - Uses real market prices but executes virtual trades - Persists state to paper_trading_state.json (survives restarts) - Add paper trading toggle and balance setting in GUI - Track virtual balance, holdings, and trade history - Calculate performance metrics (P&L, win rate) Perfect for testing strategies before using real funds. --- brokers/__init__.py | 30 +++- brokers/paper.py | 331 ++++++++++++++++++++++++++++++++++++++++++++ pt_hub.py | 27 ++++ pt_trader.py | 37 ++++- 4 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 brokers/paper.py diff --git a/brokers/__init__.py b/brokers/__init__.py index 517eea310..24f720371 100644 --- a/brokers/__init__.py +++ b/brokers/__init__.py @@ -9,22 +9,30 @@ from .base import BrokerAPI from .robinhood import RobinhoodBroker from .bitvavo import BitvavoBroker +from .paper import PaperBroker -# Available brokers +# Available brokers (real trading) BROKERS = { "robinhood": RobinhoodBroker, "bitvavo": BitvavoBroker, } -def get_broker(broker_name: str, base_dir: Optional[str] = None) -> BrokerAPI: +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 @@ -44,10 +52,23 @@ def get_broker(broker_name: str, base_dir: Optional[str] = None) -> BrokerAPI: 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": - return _create_robinhood_broker(base_dir) + real_broker = _create_robinhood_broker(base_dir) elif broker_name == "bitvavo": - return _create_bitvavo_broker(base_dir) + 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: @@ -111,6 +132,7 @@ def _create_bitvavo_broker(base_dir: str) -> BitvavoBroker: "BrokerAPI", "RobinhoodBroker", "BitvavoBroker", + "PaperBroker", "get_broker", "BROKERS", ] 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/pt_hub.py b/pt_hub.py index d2990f8c1..06ebe5a97 100644 --- a/pt_hub.py +++ b/pt_hub.py @@ -272,6 +272,8 @@ 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", @@ -4346,6 +4348,8 @@ def do_browse(): 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")) @@ -4365,6 +4369,24 @@ def do_browse(): 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 @@ -5137,6 +5159,11 @@ def save(): 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 9de2b0482..a2e521dd4 100644 --- a/pt_trader.py +++ b/pt_trader.py @@ -46,6 +46,8 @@ "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: @@ -84,16 +86,27 @@ def _load_gui_settings() -> dict: 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) @@ -167,16 +180,24 @@ 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) + return get_broker( + broker_name, + base_dir, + paper_trading=paper_trading, + paper_balance=paper_balance, + ) # 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 + 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) @@ -184,14 +205,22 @@ def __init__(self): # 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 - if _current_broker is None or _current_broker_name != broker_name: + # 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