Phase 4: Paper trading implementation

- Create PaperStrategyInstance extending StrategyInstance
- Integrate PaperBroker for simulated order execution
- Add trade_order() method translating Blockly calls to broker
- Add mode selection in Strategies.create_strategy_instance()
- Update execute_strategy() to support paper/backtest/live modes
- Include comprehensive tests for paper trading functionality

Paper trading now works with:
- Market and limit orders
- Position tracking with P&L
- Balance management
- Trade history
- Reset functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-02-28 17:07:34 -04:00
parent 1bb224b15d
commit 51ec74175d
3 changed files with 632 additions and 11 deletions

View File

@ -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

View File

@ -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

251
tests/test_paper_trading.py Normal file
View File

@ -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'