brighter-trading/src/StrategyInstance.py

522 lines
21 KiB
Python

import logging
from DataCache_v3 import DataCache
from indicators import Indicators
from trade import Trades
import datetime as dt
import json
import traceback
from typing import Any
# Configure logging
logger = logging.getLogger(__name__)
class StrategyInstance:
def __init__(self, strategy_instance_id: str, strategy_id: str, strategy_name: str,
user_id: int, generated_code: str, data_cache: DataCache, indicators: Indicators | None, trades: Trades | None):
"""
Initializes a StrategyInstance.
:param strategy_instance_id: Unique identifier for this strategy execution instance.
:param strategy_id: Identifier of the strategy definition.
:param strategy_name: Name of the strategy.
:param user_id: ID of the user who owns the strategy.
:param generated_code: The generated 'next()' method code.
:param data_cache: Reference to the DataCache instance.
:param indicators: Reference to the Indicators manager.
:param trades: Reference to the Trades manager.
"""
# Initialize the backtrader_strategy attribute
self.backtrader_strategy = None # Will be set by Backtrader's MappedStrategy
self.strategy_instance_id = strategy_instance_id
self.strategy_id = strategy_id
self.strategy_name = strategy_name
self.user_id = user_id
self.generated_code = generated_code
self.data_cache = data_cache
self.indicators = indicators
self.trades = trades
# Initialize context variables
self.flags: dict[str, Any] = {}
self.variables: dict[str, Any] = {}
self.starting_balance: float = 0.0
self.profit_loss: float = 0.0
self.active: bool = True
self.paused: bool = False
self.exit: bool = False
self.exit_method: str = 'all'
self.start_time = dt.datetime.now()
# Define the local execution environment
self.exec_context = {
'flags': self.flags,
'variables': self.variables,
'exit': self.exit,
'paused': self.paused,
'strategy_id': self.strategy_id,
'user_id': self.user_id,
'get_last_candle': self.get_last_candle,
'get_current_price': self.get_current_price,
'trade_order': self.trade_order,
'exit_strategy': self.exit_strategy,
'notify_user': self.notify_user,
'process_indicator': self.process_indicator,
'get_strategy_profit_loss': self.get_strategy_profit_loss,
'is_in_profit': self.is_in_profit,
'is_in_loss': self.is_in_loss,
'get_active_trades': self.get_active_trades,
'get_starting_balance': self.get_starting_balance,
'set_paused': self.set_paused,
'set_exit': self.set_exit,
'set_available_strategy_balance': self.set_available_strategy_balance,
'get_current_balance': self.get_current_balance,
'get_available_strategy_balance': self.get_available_strategy_balance
}
# Automatically load or initialize the context
self._initialize_or_load_context()
def _initialize_or_load_context(self):
"""
Checks if a context exists for the strategy instance. If it does, load it;
otherwise, initialize a new context.
"""
if self.data_cache.get_rows_from_datacache(
cache_name='strategy_contexts',
filter_vals=[('strategy_instance_id', self.strategy_instance_id)]
).empty:
self.initialize_new_context()
logger.debug(f"Initialized new context for StrategyInstance '{self.strategy_instance_id}'.")
else:
self.load_context()
logger.debug(f"Loaded existing context for StrategyInstance '{self.strategy_instance_id}'.")
def initialize_new_context(self):
"""
Initializes a new context for the strategy instance.
"""
self.flags = {}
self.variables = {}
self.profit_loss = 0.0
self.active = True
self.paused = False
self.exit = False
self.exit_method = 'all'
self.start_time = dt.datetime.now()
# Insert initial context into the cache
self.save_context()
logger.debug(f"New context created and saved for StrategyInstance '{self.strategy_instance_id}'.")
def load_context(self):
"""
Loads the strategy execution context from the database.
"""
try:
context_data = self.data_cache.get_rows_from_datacache(
cache_name='strategy_contexts',
filter_vals=[('strategy_instance_id', self.strategy_instance_id)]
)
if context_data.empty:
logger.warning(f"No context found for StrategyInstance ID: {self.strategy_instance_id}")
return
context = context_data.iloc[0].to_dict()
self.flags = json.loads(context.get('flags', '{}'))
self.profit_loss = context.get('profit_loss', 0.0)
self.active = bool(context.get('active', True))
self.paused = bool(context.get('paused', False))
self.exit = bool(context.get('exit', False))
self.exit_method = context.get('exit_method', 'all')
start_time_str = context.get('start_time')
if start_time_str:
self.start_time = dt.datetime.fromisoformat(start_time_str)
# Update exec_context with loaded flags and variables
self.exec_context['flags'] = self.flags
self.exec_context['variables'] = self.variables
self.exec_context['profit_loss'] = self.profit_loss
self.exec_context['active'] = self.active
self.exec_context['paused'] = self.paused
self.exec_context['exit'] = self.exit
self.exec_context['exit_method'] = self.exit_method
logger.debug(f"Context loaded for StrategyInstance '{self.strategy_instance_id}'.")
except Exception as e:
logger.error(f"Error loading context for StrategyInstance '{self.strategy_instance_id}': {e}",
exc_info=True)
traceback.print_exc()
def save_context(self):
"""
Saves the current strategy execution context to the database.
Inserts a new row if it doesn't exist; otherwise, updates the existing row.
"""
try:
self.data_cache.modify_datacache_item(
cache_name='strategy_contexts',
filter_vals=[('strategy_instance_id', self.strategy_instance_id)],
field_names=('flags', 'profit_loss', 'active', 'paused', 'exit', 'exit_method', 'start_time'),
new_values=(
json.dumps(self.flags),
self.profit_loss,
int(self.active),
int(self.paused),
int(self.exit),
self.exit_method,
self.start_time.isoformat()
)
)
logger.debug(f"Context saved for StrategyInstance '{self.strategy_instance_id}'.")
except ValueError as ve:
# If the record does not exist, insert it
logger.warning(f"StrategyInstance '{self.strategy_instance_id}' context not found. Attempting to insert.")
self.data_cache.insert_row_into_datacache(
cache_name='strategy_contexts',
columns=(
"strategy_instance_id", "flags", "profit_loss",
"active", "paused", "exit", "exit_method", "start_time"
),
values=(
self.strategy_instance_id,
json.dumps(self.flags),
self.profit_loss,
int(self.active),
int(self.paused),
int(self.exit),
self.exit_method,
self.start_time.isoformat()
)
)
logger.debug(f"Inserted new context for StrategyInstance '{self.strategy_instance_id}'.")
except Exception as e:
logger.error(f"Error saving context for StrategyInstance '{self.strategy_instance_id}': {e}")
traceback.print_exc()
def override_exec_context(self, key: str, value: Any):
"""
Overrides a specific mapping in the execution context with a different method or variable.
:param key: The key in exec_context to override.
:param value: The new method or value to assign.
"""
self.exec_context[key] = value
logger.debug(f"Overridden exec_context key '{key}' with new value '{value}'.")
def execute(self) -> dict[str, Any]:
"""
Executes the strategy's 'next()' method.
:return: Result of the execution.
"""
try:
# Execute the generated 'next()' method with exec_context as globals
exec(self.generated_code, self.exec_context)
# Call the 'next()' method if defined
if 'next' in self.exec_context and callable(self.exec_context['next']):
self.exec_context['next']()
else:
logger.error(
f"'next' method not defined in generated_code for StrategyInstance '{self.strategy_instance_id}'.")
# Retrieve and update profit/loss
self.profit_loss = self.exec_context.get('profit_loss', self.profit_loss)
self.save_context()
return {"success": True, "profit_loss": self.profit_loss}
except Exception as e:
logger.error(f"Error executing 'next()' for StrategyInstance '{self.strategy_instance_id}': {e}",
exc_info=True)
traceback.print_exc()
return {"success": False, "message": str(e)}
def set_paused(self, value: bool):
"""
Sets the paused state of the strategy.
:param value: True to pause, False to resume.
"""
self.paused = value
self.exec_context['paused'] = self.paused
self.save_context()
logger.debug(f"Strategy '{self.strategy_id}' paused: {self.paused}")
def set_exit(self, exit_flag: bool, exit_method: str = 'all'):
"""
Sets the exit state and method of the strategy.
:param exit_flag: True to initiate exit.
:param exit_method: Method to use for exiting ('all', 'in_profit', 'in_loss').
"""
self.exit = exit_flag
self.exit_method = exit_method
self.save_context()
logger.debug(f"Strategy '{self.strategy_id}' exit set: {self.exit} with method '{self.exit_method}'")
def set_available_strategy_balance(self, balance: float):
"""
Sets the available balance for the strategy.
:param balance: The new available balance.
"""
self.variables['available_strategy_balance'] = balance
logger.debug(f"Available strategy balance set to {balance}.")
def get_current_balance(self) -> float:
"""
Retrieves the current balance from the Trades manager.
:return: Current balance.
"""
try:
balance = self.trades.get_current_balance(self.user_id)
logger.debug(f"Current balance retrieved: {balance}.")
return balance
except Exception as e:
logger.error(f"Error retrieving current balance: {e}", exc_info=True)
return 0.0
def get_available_strategy_balance(self) -> float:
"""
Retrieves the available strategy balance.
:return: Available strategy balance.
"""
try:
balance = self.variables.get('available_strategy_balance', self.starting_balance)
logger.debug(f"Available strategy balance retrieved: {balance}.")
return balance
except Exception as e:
logger.error(f"Error retrieving available strategy balance: {e}", exc_info=True)
return 0.0
def get_total_filled_order_volume(self) -> float:
"""
Retrieves the total filled order volume for the strategy.
"""
return self.trades.get_total_filled_order_volume(self.strategy_id)
def get_total_unfilled_order_volume(self) -> float:
"""
Retrieves the total unfilled order volume for the strategy.
"""
return self.trades.get_total_unfilled_order_volume(self.strategy_id)
def get_last_candle(self, candle_part: str, timeframe: str, exchange: str, symbol: str):
"""
Retrieves the last candle data based on provided parameters.
:param candle_part: Part of the candle data (e.g., 'close', 'open').
:param timeframe: Timeframe of the candle.
:param exchange: Exchange name.
:param symbol: Trading symbol.
:return: Last candle value.
"""
try:
sdt = dt.datetime.now() - dt.timedelta(minutes=int(timeframe[:-1]))
data = self.data_cache.get_records_since(start_datetime=sdt, ex_details=[exchange, symbol, timeframe])
if not data.empty:
return data.iloc[-1][candle_part]
else:
logger.warning(f"No candle data found for {exchange} {symbol} {timeframe}.")
return None
except Exception as e:
logger.error(f"Error retrieving last candle: {e}", exc_info=True)
traceback.print_exc()
return None
def get_current_price(self, timeframe: str = '1h', exchange: str = 'binance',
symbol: str = 'BTC/USD') -> float | None:
"""
Retrieves the current market price for the specified symbol.
:param timeframe: The timeframe of the data.
:param exchange: The exchange name.
:param symbol: The trading symbol.
:return: The current price or None if unavailable.
"""
try:
# Assuming get_last_candle returns the last candle data as a dictionary
last_candle = self.get_last_candle('close', timeframe, exchange, symbol)
if last_candle is not None:
logger.debug(f"Retrieved current price for {symbol} on {exchange} ({timeframe}): {last_candle}")
return last_candle
else:
logger.warning(f"No last candle data available for {symbol} on {exchange} ({timeframe}).")
return None
except Exception as e:
logger.error(f"Error retrieving current price for {symbol} on {exchange} ({timeframe}): {e}", exc_info=True)
return None
def trade_order(
self,
trade_type: str,
size: float,
symbol: str,
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
):
"""
Unified trade order handler for executing buy and sell orders.
"""
if trade_type == 'buy':
logger.info(f"Executing BUY order: Size={size}, Symbol={symbol}, Order Type={order_type}")
# Implement buy order logic here
elif trade_type == 'sell':
logger.info(f"Executing SELL order: Size={size}, Symbol={symbol}, Order Type={order_type}")
# Implement sell order logic here
else:
logger.error(f"Invalid trade_type '{trade_type}'. Order not executed.")
return
# Handle trade options like stop_loss, take_profit, etc.
if stop_loss:
# Implement stop loss logic
pass
if take_profit:
# Implement take profit logic
pass
# Add handling for other trade options as needed
# Notify user about the trade execution
self.notify_user(f"{trade_type.capitalize()} order executed for {size} {symbol} at {order_type} price.")
def exit_strategy(self):
"""
Exits the strategy based on the exit_method.
"""
try:
if self.exit_method == 'all':
self.trades.exit_strategy_all(self.strategy_id)
logger.info(f"Exiting all positions for strategy '{self.strategy_id}'.")
elif self.exit_method == 'in_profit':
self.trades.exit_strategy_in_profit(self.strategy_id)
logger.info(f"Exiting profitable positions for strategy '{self.strategy_id}'.")
elif self.exit_method == 'in_loss':
self.trades.exit_strategy_in_loss(self.strategy_id)
logger.info(f"Exiting losing positions for strategy '{self.strategy_id}'.")
else:
logger.warning(
f"Unknown exit method '{self.exit_method}' for StrategyInstance '{self.strategy_instance_id}'.")
except Exception as e:
logger.error(
f"Error exiting strategy '{self.strategy_id}' in StrategyInstance '{self.strategy_instance_id}': {e}",
exc_info=True)
traceback.print_exc()
def notify_user(self, message: str):
"""
Sends a notification to the user.
:param message: Notification message.
"""
try:
self.trades.notify_user(self.user_id, message)
logger.debug(f"Notification sent to user '{self.user_id}': {message}")
except Exception as e:
logger.error(
f"Error notifying user '{self.user_id}' in StrategyInstance '{self.strategy_instance_id}': {e}",
exc_info=True)
traceback.print_exc()
def process_indicator(self, indicator_name: str, output_field: str) -> Any:
"""
Retrieves the latest value of an indicator.
:param indicator_name: Name of the indicator.
:param output_field: Specific field of the indicator.
:return: Indicator value.
"""
try:
user_indicators = self.indicators.get_indicator_list(user_id=self.user_id)
indicator = user_indicators.get(indicator_name)
if not indicator:
logger.error(f"Indicator '{indicator_name}' not found for user '{self.user_id}'.")
return None
indicator_value = self.indicators.process_indicator(indicator)
value = indicator_value.get(output_field, None)
logger.debug(f"Processed indicator '{indicator_name}' with output field '{output_field}': {value}")
return value
except Exception as e:
logger.error(
f"Error processing indicator '{indicator_name}' in StrategyInstance '{self.strategy_instance_id}': {e}",
exc_info=True)
traceback.print_exc()
return None
def get_strategy_profit_loss(self, strategy_id: str) -> float:
"""
Retrieves the current profit or loss of the strategy.
:param strategy_id: Unique identifier of the strategy.
:return: Profit or loss amount.
"""
try:
profit_loss = self.trades.get_profit(strategy_id)
logger.debug(f"Retrieved profit/loss for strategy '{strategy_id}': {profit_loss}")
return profit_loss
except Exception as e:
logger.error(f"Error retrieving profit/loss for StrategyInstance '{self.strategy_instance_id}': {e}",
exc_info=True)
traceback.print_exc()
return 0.0
def is_in_profit(self) -> bool:
"""
Determines if the strategy is currently in profit.
"""
profit = self.profit_loss
logger.debug(f"Checking if in profit: {profit} > 0")
return self.profit_loss > 0
def is_in_loss(self) -> bool:
"""
Determines if the strategy is currently in loss.
"""
loss = self.profit_loss
logger.debug(f"Checking if in loss: {loss} < 0")
return self.profit_loss < 0
def get_active_trades(self) -> int:
"""
Returns the number of active trades.
"""
active_trades_count = len(self.trades.active_trades)
logger.debug(f"Number of active trades: {active_trades_count}")
return active_trades_count
def get_starting_balance(self) -> float:
"""
Returns the starting balance.
"""
logger.debug(f"Starting balance: {self.starting_balance}")
return self.starting_balance
def get_filled_orders(self) -> int:
"""
Retrieves the number of filled orders for the strategy.
"""
return self.trades.get_filled_orders_count(self.strategy_id)
def get_unfilled_orders(self) -> int:
"""
Retrieves the number of unfilled orders for the strategy.
"""
return self.trades.get_unfilled_orders_count(self.strategy_id)
def get_available_balance(self) -> float:
"""
Retrieves the available balance for the strategy.
"""
return self.trades.get_available_balance(self.strategy_id)