diff --git a/src/Strategies.py b/src/Strategies.py index f644160..69caaca 100644 --- a/src/Strategies.py +++ b/src/Strategies.py @@ -7,9 +7,10 @@ import datetime as dt import json import uuid import traceback -from typing import Any +from typing import Any, Optional from PythonGenerator import PythonGenerator from StrategyInstance import StrategyInstance +from brokers import TradingMode # Configure logging logger = logging.getLogger(__name__) @@ -61,7 +62,100 @@ class Strategies: self.default_exchange = 'Binance' self.default_symbol = 'BTCUSD' - self.active_instances: dict[tuple[int, str], StrategyInstance] = {} # Key: (user_id, strategy_id) + self.active_instances: dict[tuple[int, str, str], StrategyInstance] = {} # Key: (user_id, strategy_id, mode) + + def create_strategy_instance( + self, + mode: str, + strategy_instance_id: str, + strategy_id: str, + strategy_name: str, + user_id: int, + generated_code: str, + initial_balance: float = 10000.0, + commission: float = 0.001, + slippage: float = 0.0, + price_provider: Any = None, + ) -> StrategyInstance: + """ + Factory method to create the appropriate strategy instance based on mode. + + :param mode: Trading mode ('backtest', 'paper', 'live'). + :param strategy_instance_id: Unique instance identifier. + :param strategy_id: Strategy identifier. + :param strategy_name: Strategy name. + :param user_id: User identifier. + :param generated_code: Generated Python code from Blockly. + :param initial_balance: Starting balance for paper/backtest. + :param commission: Commission rate. + :param slippage: Slippage rate. + :param price_provider: Callable for getting current prices. + :return: Strategy instance appropriate for the mode. + """ + mode = mode.lower() + + if mode == TradingMode.PAPER: + from paper_strategy_instance import PaperStrategyInstance + return PaperStrategyInstance( + strategy_instance_id=strategy_instance_id, + strategy_id=strategy_id, + strategy_name=strategy_name, + user_id=user_id, + generated_code=generated_code, + data_cache=self.data_cache, + indicators=self.indicators_manager, + trades=self.trades, + initial_balance=initial_balance, + commission=commission, + slippage=slippage if slippage > 0 else 0.0005, + price_provider=price_provider, + ) + + elif mode == TradingMode.BACKTEST: + from backtest_strategy_instance import BacktestStrategyInstance + return BacktestStrategyInstance( + strategy_instance_id=strategy_instance_id, + strategy_id=strategy_id, + strategy_name=strategy_name, + user_id=user_id, + generated_code=generated_code, + data_cache=self.data_cache, + indicators=self.indicators_manager, + trades=self.trades, + ) + + elif mode == TradingMode.LIVE: + # Live trading not yet implemented - fall back to paper for safety + logger.warning("Live trading mode not yet implemented. Using paper trading instead.") + from paper_strategy_instance import PaperStrategyInstance + return PaperStrategyInstance( + strategy_instance_id=strategy_instance_id, + strategy_id=strategy_id, + strategy_name=strategy_name, + user_id=user_id, + generated_code=generated_code, + data_cache=self.data_cache, + indicators=self.indicators_manager, + trades=self.trades, + initial_balance=initial_balance, + commission=commission, + slippage=slippage, + price_provider=price_provider, + ) + + else: + # Default to standard StrategyInstance for unknown modes + logger.warning(f"Unknown mode '{mode}'. Using standard StrategyInstance.") + return StrategyInstance( + strategy_instance_id=strategy_instance_id, + strategy_id=strategy_id, + strategy_name=strategy_name, + user_id=user_id, + generated_code=generated_code, + data_cache=self.data_cache, + indicators=self.indicators_manager, + trades=self.trades, + ) def _save_strategy(self, strategy_data: dict, default_source: dict) -> dict: """ @@ -351,11 +445,24 @@ class Strategies: except Exception as e: logger.error(f"Error updating stats for strategy '{strategy_id}': {e}", exc_info=True) - def execute_strategy(self, strategy_data: dict[str, Any]) -> dict[str, Any]: + def execute_strategy( + self, + strategy_data: dict[str, Any], + mode: str = 'backtest', + initial_balance: float = 10000.0, + commission: float = 0.001, + slippage: float = 0.0, + price_provider: Any = None, + ) -> dict[str, Any]: """ Executes a strategy based on the provided strategy data. :param strategy_data: A dictionary containing strategy details. + :param mode: Trading mode ('backtest', 'paper', 'live'). + :param initial_balance: Starting balance for paper/backtest modes. + :param commission: Commission rate. + :param slippage: Slippage rate for market orders. + :param price_provider: Callable for getting current prices (paper/live). :return: A dictionary indicating success or failure with relevant messages. """ try: @@ -367,9 +474,9 @@ class Strategies: if not strategy_id or not strategy_name or not user_id: return {"success": False, "message": "Strategy data is incomplete."} - # Generate a deterministic strategy_instance_id - strategy_instance_id = f"{user_id}_{strategy_name}" - instance_key = (user_id, strategy_id) # Unique key for the strategy-user pair + # Generate a deterministic strategy_instance_id (include mode for uniqueness) + strategy_instance_id = f"{user_id}_{strategy_name}_{mode}" + instance_key = (user_id, strategy_id, mode) # Include mode in key # Retrieve or create StrategyInstance if instance_key not in self.active_instances: @@ -377,16 +484,18 @@ class Strategies: if not generated_code: return {"success": False, "message": "No 'next()' method defined for the strategy."} - # Instantiate StrategyInstance - strategy_instance = StrategyInstance( + # Use factory method to create appropriate instance for mode + strategy_instance = self.create_strategy_instance( + mode=mode, strategy_instance_id=strategy_instance_id, strategy_id=strategy_id, strategy_name=strategy_name, user_id=user_id, generated_code=generated_code, - data_cache=self.data_cache, - indicators=self.indicators_manager, - trades=self.trades + initial_balance=initial_balance, + commission=commission, + slippage=slippage, + price_provider=price_provider, ) # Store in active_instances diff --git a/src/paper_strategy_instance.py b/src/paper_strategy_instance.py new file mode 100644 index 0000000..c096f7d --- /dev/null +++ b/src/paper_strategy_instance.py @@ -0,0 +1,261 @@ +""" +Paper Trading Strategy Instance for BrighterTrading. + +Extends StrategyInstance with paper trading capabilities using +the PaperBroker for simulated order execution. +""" + +import logging +from typing import Any, Optional +import datetime as dt + +from StrategyInstance import StrategyInstance +from brokers import PaperBroker, OrderSide, OrderType + +logger = logging.getLogger(__name__) + + +class PaperStrategyInstance(StrategyInstance): + """ + Strategy instance for paper trading mode. + + Uses PaperBroker for simulated order execution with live price data. + Maintains full strategy execution context while simulating trades. + """ + + def __init__( + self, + strategy_instance_id: str, + strategy_id: str, + strategy_name: str, + user_id: int, + generated_code: str, + data_cache: Any, + indicators: Any | None, + trades: Any | None, + initial_balance: float = 10000.0, + commission: float = 0.001, + slippage: float = 0.0005, + price_provider: Any = None, + ): + """ + Initialize the PaperStrategyInstance. + + :param strategy_instance_id: Unique identifier for this instance. + :param strategy_id: Strategy identifier. + :param strategy_name: Strategy name. + :param user_id: User identifier. + :param generated_code: Python code generated from Blockly. + :param data_cache: DataCache instance. + :param indicators: Indicators manager. + :param trades: Trades manager (not used directly, kept for compatibility). + :param initial_balance: Starting paper trading balance. + :param commission: Commission rate for paper trades. + :param slippage: Slippage rate for market orders. + :param price_provider: Callable to get current prices. + """ + # Initialize the paper broker + self.paper_broker = PaperBroker( + initial_balance=initial_balance, + commission=commission, + slippage=slippage, + price_provider=price_provider, + data_cache=data_cache + ) + + # Set broker before calling parent __init__ + self.broker = self.paper_broker + + super().__init__( + strategy_instance_id, strategy_id, strategy_name, user_id, + generated_code, data_cache, indicators, trades + ) + + # Initialize balance attributes from paper broker + self.starting_balance = initial_balance + self.current_balance = initial_balance + self.available_balance = initial_balance + self.available_strategy_balance = initial_balance + + # Update exec_context with balance attributes + self.exec_context['starting_balance'] = self.starting_balance + self.exec_context['current_balance'] = self.current_balance + self.exec_context['available_balance'] = self.available_balance + self.exec_context['available_strategy_balance'] = self.available_strategy_balance + + logger.info(f"PaperStrategyInstance created with balance: {initial_balance}") + + def trade_order( + self, + trade_type: str, + size: float, + order_type: str, + source: dict = None, + tif: str = 'GTC', + stop_loss: dict = None, + trailing_stop: dict = None, + take_profit: dict = None, + limit: dict = None, + trailing_limit: dict = None, + target_market: dict = None, + name_order: dict = None + ): + """ + Place an order via the paper broker. + + This method translates the Blockly-generated order call to + the PaperBroker interface. + """ + # Extract symbol from source + symbol = 'BTC/USDT' # Default + if source: + symbol = source.get('symbol') or source.get('market', 'BTC/USDT') + + # Map trade_type to OrderSide + if trade_type.lower() == 'buy': + side = OrderSide.BUY + elif trade_type.lower() == 'sell': + side = OrderSide.SELL + else: + logger.error(f"Invalid trade_type '{trade_type}'. Order not executed.") + return + + # Map order_type to OrderType + order_type_upper = order_type.upper() + if order_type_upper == 'MARKET': + bt_order_type = OrderType.MARKET + price = None + elif order_type_upper == 'LIMIT': + bt_order_type = OrderType.LIMIT + price = limit.get('value') if limit else self.get_current_price() + else: + bt_order_type = OrderType.MARKET + price = None + + # Extract stop loss and take profit + stop_loss_price = stop_loss.get('value') if stop_loss else None + take_profit_price = take_profit.get('value') if take_profit else None + + # Place the order + result = self.paper_broker.place_order( + symbol=symbol, + side=side, + order_type=bt_order_type, + size=size, + price=price, + stop_loss=stop_loss_price, + take_profit=take_profit_price, + time_in_force=tif + ) + + if result.success: + message = f"{trade_type.upper()} order placed: {size} {symbol} @ {order_type_upper}" + self.notify_user(message) + logger.info(message) + + # Track order in history + self.orders.append({ + 'order_id': result.order_id, + 'symbol': symbol, + 'side': trade_type, + 'size': size, + 'type': order_type, + 'status': result.status.value, + 'timestamp': dt.datetime.now().isoformat() + }) + else: + logger.warning(f"Order failed: {result.message}") + self.notify_user(f"Order failed: {result.message}") + + return result + + def update_prices(self, price_data: dict): + """ + Update current prices in the paper broker. + + :param price_data: Dict mapping symbols to prices. + """ + for symbol, price in price_data.items(): + self.paper_broker.update_price(symbol, price) + + # Process any pending orders + events = self.paper_broker.update() + for event in events: + if event['type'] == 'fill': + self.trade_history.append(event) + logger.info(f"Order filled: {event}") + + # Update balance attributes + self._update_balances() + + def _update_balances(self): + """Update balance attributes from paper broker.""" + self.current_balance = self.paper_broker.get_balance() + self.available_balance = self.paper_broker.get_available_balance() + + self.exec_context['current_balance'] = self.current_balance + self.exec_context['available_balance'] = self.available_balance + + def get_current_price(self, timeframe: str = '1h', exchange: str = 'binance', + symbol: str = 'BTC/USDT') -> float: + """Get current price from paper broker.""" + return self.paper_broker.get_current_price(symbol) + + def get_available_balance(self) -> float: + """Get available cash balance.""" + self.available_balance = self.paper_broker.get_available_balance() + self.exec_context['available_balance'] = self.available_balance + return self.available_balance + + def get_current_balance(self) -> float: + """Get current total balance including unrealized P&L.""" + self.current_balance = self.paper_broker.get_balance() + self.exec_context['current_balance'] = self.current_balance + return self.current_balance + + def get_starting_balance(self) -> float: + """Get starting balance.""" + return self.starting_balance + + def get_active_trades(self) -> int: + """Get number of active positions.""" + return len(self.paper_broker.get_all_positions()) + + def get_filled_orders(self) -> int: + """Get number of filled orders.""" + return len([o for o in self.paper_broker._orders.values() + if o.status.value == 'filled']) + + def get_position(self, symbol: str): + """Get position for a symbol.""" + return self.paper_broker.get_position(symbol) + + def close_position(self, symbol: str): + """Close a position.""" + return self.paper_broker.close_position(symbol) + + def close_all_positions(self): + """Close all positions.""" + return self.paper_broker.close_all_positions() + + def get_trade_history(self): + """Get all executed trades.""" + return self.paper_broker.get_trade_history() + + def reset(self): + """Reset the paper trading state.""" + self.paper_broker.reset() + self._update_balances() + self.trade_history = [] + self.orders = [] + logger.info("PaperStrategyInstance reset") + + def save_context(self): + """Save strategy context including paper trading state.""" + self._update_balances() + super().save_context() + + def notify_user(self, message: str): + """Send notification to user.""" + logger.info(f"[Paper] {message}") + # Could emit via SocketIO if available diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py new file mode 100644 index 0000000..675eeb1 --- /dev/null +++ b/tests/test_paper_trading.py @@ -0,0 +1,251 @@ +""" +Tests for paper trading functionality. +""" +import pytest +from paper_strategy_instance import PaperStrategyInstance +from brokers import OrderSide, OrderType, OrderStatus + + +class TestPaperStrategyInstance: + """Tests for PaperStrategyInstance.""" + + def test_create_instance(self): + """Test creating a paper strategy instance.""" + instance = PaperStrategyInstance( + strategy_instance_id='test-instance-1', + strategy_id='test-strategy-1', + strategy_name='Test Strategy', + user_id=1, + generated_code='def next(self): pass', + data_cache=None, + indicators=None, + trades=None, + initial_balance=10000.0, + ) + + assert instance.strategy_instance_id == 'test-instance-1' + assert instance.starting_balance == 10000.0 + assert instance.get_current_balance() == 10000.0 + assert instance.get_available_balance() == 10000.0 + + def test_update_prices(self): + """Test updating prices in paper broker.""" + instance = PaperStrategyInstance( + strategy_instance_id='test-1', + strategy_id='strat-1', + strategy_name='Test', + user_id=1, + generated_code='def next(self): pass', + data_cache=None, + indicators=None, + trades=None, + ) + + instance.update_prices({'BTC/USDT': 50000.0, 'ETH/USDT': 3000.0}) + + assert instance.get_current_price(symbol='BTC/USDT') == 50000.0 + assert instance.get_current_price(symbol='ETH/USDT') == 3000.0 + + def test_trade_order_market_buy(self): + """Test placing a market buy order.""" + instance = PaperStrategyInstance( + strategy_instance_id='test-1', + strategy_id='strat-1', + strategy_name='Test', + user_id=1, + generated_code='def next(self): pass', + data_cache=None, + indicators=None, + trades=None, + initial_balance=10000.0, + commission=0.001, + ) + + # Set price + instance.update_prices({'BTC/USDT': 50000.0}) + + # Place order + result = instance.trade_order( + trade_type='buy', + size=0.1, + order_type='MARKET', + source={'symbol': 'BTC/USDT'} + ) + + assert result.success + assert result.status == OrderStatus.FILLED + + # Check position exists + pos = instance.get_position('BTC/USDT') + assert pos is not None + assert pos.size == 0.1 + + # Check balance reduced + assert instance.get_available_balance() < 10000.0 + + def test_trade_order_sell(self): + """Test selling a position.""" + instance = PaperStrategyInstance( + strategy_instance_id='test-1', + strategy_id='strat-1', + strategy_name='Test', + user_id=1, + generated_code='def next(self): pass', + data_cache=None, + indicators=None, + trades=None, + initial_balance=10000.0, + commission=0, + slippage=0, + ) + + # Buy first + instance.update_prices({'BTC/USDT': 50000.0}) + instance.trade_order( + trade_type='buy', + size=0.1, + order_type='MARKET', + source={'symbol': 'BTC/USDT'} + ) + + # Now sell at higher price + instance.update_prices({'BTC/USDT': 51000.0}) + result = instance.trade_order( + trade_type='sell', + size=0.1, + order_type='MARKET', + source={'symbol': 'BTC/USDT'} + ) + + assert result.success + + # Position should be closed + pos = instance.get_position('BTC/USDT') + assert pos is None + + def test_close_position(self): + """Test closing a position via close_position method.""" + instance = PaperStrategyInstance( + strategy_instance_id='test-1', + strategy_id='strat-1', + strategy_name='Test', + user_id=1, + generated_code='def next(self): pass', + data_cache=None, + indicators=None, + trades=None, + ) + + instance.update_prices({'BTC/USDT': 50000.0}) + instance.trade_order( + trade_type='buy', + size=0.1, + order_type='MARKET', + source={'symbol': 'BTC/USDT'} + ) + + result = instance.close_position('BTC/USDT') + assert result.success + + def test_close_all_positions(self): + """Test closing all positions.""" + instance = PaperStrategyInstance( + strategy_instance_id='test-1', + strategy_id='strat-1', + strategy_name='Test', + user_id=1, + generated_code='def next(self): pass', + data_cache=None, + indicators=None, + trades=None, + ) + + instance.update_prices({'BTC/USDT': 50000.0, 'ETH/USDT': 3000.0}) + + instance.trade_order( + trade_type='buy', + size=0.1, + order_type='MARKET', + source={'symbol': 'BTC/USDT'} + ) + instance.trade_order( + trade_type='buy', + size=1.0, + order_type='MARKET', + source={'symbol': 'ETH/USDT'} + ) + + assert instance.get_active_trades() == 2 + + results = instance.close_all_positions() + assert len(results) == 2 + assert all(r.success for r in results) + assert instance.get_active_trades() == 0 + + def test_reset(self): + """Test resetting paper trading state.""" + instance = PaperStrategyInstance( + strategy_instance_id='test-1', + strategy_id='strat-1', + strategy_name='Test', + user_id=1, + generated_code='def next(self): pass', + data_cache=None, + indicators=None, + trades=None, + initial_balance=10000.0, + ) + + instance.update_prices({'BTC/USDT': 50000.0}) + instance.trade_order( + trade_type='buy', + size=0.1, + order_type='MARKET', + source={'symbol': 'BTC/USDT'} + ) + + assert instance.get_available_balance() < 10000.0 + + instance.reset() + + assert instance.get_available_balance() == 10000.0 + assert instance.get_active_trades() == 0 + + def test_get_trade_history(self): + """Test getting trade history.""" + instance = PaperStrategyInstance( + strategy_instance_id='test-1', + strategy_id='strat-1', + strategy_name='Test', + user_id=1, + generated_code='def next(self): pass', + data_cache=None, + indicators=None, + trades=None, + ) + + instance.update_prices({'BTC/USDT': 50000.0}) + instance.trade_order( + trade_type='buy', + size=0.1, + order_type='MARKET', + source={'symbol': 'BTC/USDT'} + ) + + history = instance.get_trade_history() + assert len(history) == 1 + assert history[0]['symbol'] == 'BTC/USDT' + assert history[0]['side'] == 'buy' + + +class TestStrategiesModeSeletion: + """Tests for strategy mode selection.""" + + def test_mode_selection_imports(self): + """Test that mode selection imports work.""" + from Strategies import Strategies + from brokers import TradingMode + + assert TradingMode.PAPER == 'paper' + assert TradingMode.BACKTEST == 'backtest' + assert TradingMode.LIVE == 'live'