import logging import pandas as pd 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: Any, indicators: Any | None, trades: Any | None, edm_client: Any = None, indicator_owner_id: int = 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. :param edm_client: Reference to the EDM client for candle data. :param indicator_owner_id: For subscribed strategies, the creator's user ID for indicator lookup. If None, uses user_id. """ # 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 self.edm_client = edm_client # For subscribed strategies, indicator lookup uses the creator's indicators self.indicator_owner_id = indicator_owner_id if indicator_owner_id is not None else user_id # Initialize context variables self.flags: dict[str, Any] = {} self.variables: dict[str, Any] = {} self.starting_balance: float = 0.0 self.current_balance: float = 0.0 self.available_balance: float = 0.0 self.available_strategy_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() self.trade_history = [] # List to store trade details (not the Trades manager) self.orders = [] # List to store order details self.profit_loss = 0.0 # Total P&L self.statistics = { 'total_return': 0.0, 'sharpe_ratio': 0.0, 'sortino_ratio': 0.0, 'max_drawdown': 0.0, 'profit_factor': 0.0, 'win_rate': 0.0, 'loss_rate': 0.0, } # 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, 'process_signal': self.process_signal, 'process_formation': self.process_formation, '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_balance': self.get_available_balance, 'get_available_strategy_balance': self.get_available_strategy_balance, 'starting_balance': self.starting_balance, 'current_balance': self.current_balance, 'available_balance': self.available_balance, 'available_strategy_balance': self.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. """ try: # Fetch the context data once 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: self.initialize_new_context() logger.debug(f"Initialized new context for StrategyInstance '{self.strategy_instance_id}'.") else: self.load_context(context_data) logger.debug(f"Loaded existing context for StrategyInstance '{self.strategy_instance_id}'.") except Exception as e: logger.error(f"Error during initialization of StrategyInstance '{self.strategy_instance_id}': {e}", exc_info=True) traceback.print_exc() def initialize_new_context(self): """ Initializes a new context for the strategy instance and saves it to the cache. """ 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(dt.timezone.utc) # Initialize balance attributes self.starting_balance = self.fetch_user_balance() self.current_balance = self.starting_balance self.available_balance = self.calculate_available_balance() self.available_strategy_balance = self.starting_balance # Update exec_context with new 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 # Insert initial context into the cache self.insert_context() logger.debug(f"New context created and inserted for StrategyInstance '{self.strategy_instance_id}'.") def load_context(self, context_data: pd.DataFrame): """ Loads the strategy execution context from the provided context_data. """ try: context = context_data.iloc[0].to_dict() self.flags = json.loads(context.get('flags', '{}')) self.variables = json.loads(context.get('variables', '{}')) self.profit_loss = context.get('profit_loss', 0.0) self.active = bool(context.get('active', 1)) self.paused = bool(context.get('paused', 0)) self.exit = bool(context.get('exit', 0)) self.exit_method = context.get('exit_method', 'all') self.starting_balance = context.get('starting_balance', 0.0) self.current_balance = context.get('current_balance', self.starting_balance) self.available_balance = context.get('available_balance', self.current_balance) self.available_strategy_balance = context.get('available_strategy_balance', self.starting_balance) start_time_str = context.get('start_time') if start_time_str: self.start_time = dt.datetime.fromisoformat(start_time_str).replace(tzinfo=dt.timezone.utc) # Update exec_context with loaded flags, variables, and balance attributes 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 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.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 insert_context(self): """ Inserts a new context into the cache and database. """ try: columns = ( "strategy_instance_id", "flags", "variables", "profit_loss", "active", "paused", "exit", "exit_method", "start_time", "starting_balance", "current_balance", "available_balance", "available_strategy_balance" ) values = ( self.strategy_instance_id, json.dumps(self.flags), json.dumps(self.variables), self.profit_loss, int(self.active), int(self.paused), int(self.exit), self.exit_method, self.start_time.isoformat(), self.starting_balance, self.current_balance, self.available_balance, self.available_strategy_balance ) # Insert the new context without passing 'key' to avoid adding 'tbl_key' self.data_cache.insert_row_into_datacache( cache_name='strategy_contexts', columns=columns, values=values # key=None is implicit ) logger.debug(f"Context inserted for StrategyInstance '{self.strategy_instance_id}'.") except ValueError as ve: logger.error(f"ValueError in inserting context for '{self.strategy_instance_id}': {ve}") traceback.print_exc() except Exception as e: logger.error(f"Error inserting context for '{self.strategy_instance_id}': {e}") traceback.print_exc() def save_context(self): """ Saves the current strategy execution context to the cache and database. Determines whether to insert a new row or modify an existing one. """ try: # Check if the context exists existing_context = self.data_cache.get_rows_from_datacache( cache_name='strategy_contexts', filter_vals=[('strategy_instance_id', self.strategy_instance_id)] ) columns = ( "strategy_instance_id", "flags", "variables", "profit_loss", "active", "paused", "exit", "exit_method", "start_time", "starting_balance", "current_balance", "available_balance", "available_strategy_balance" ) values = ( self.strategy_instance_id, json.dumps(self.flags), json.dumps(self.variables), self.profit_loss, int(self.active), int(self.paused), int(self.exit), self.exit_method, self.start_time.isoformat(), self.starting_balance, self.current_balance, self.available_balance, self.available_strategy_balance ) if existing_context.empty: # Insert a new context since it doesn't exist self.insert_context() logger.debug(f"Inserted new context for StrategyInstance '{self.strategy_instance_id}'.") else: # Modify the existing context without passing 'key' self.data_cache.modify_datacache_item( cache_name='strategy_contexts', filter_vals=[('strategy_instance_id', self.strategy_instance_id)], field_names=columns, new_values=values, overwrite='strategy_instance_id' # Ensures uniqueness # Do not pass 'key' ) logger.debug(f"Modified existing context for StrategyInstance '{self.strategy_instance_id}'.") except ValueError as ve: logger.error(f"ValueError in saving context for '{self.strategy_instance_id}': {ve}") traceback.print_exc() except Exception as e: logger.error(f"Error saving context for '{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 tick(self, candle_data: dict = None) -> list: """ Process one iteration of the strategy on a price tick. This method is called by the execution loop when new price data arrives. It updates prices, processes the strategy logic, and returns any events. :param candle_data: Optional candle data dict with 'symbol', 'close', etc. :return: List of events (orders, fills, errors, etc.) """ events = [] # Skip if strategy is paused or exiting if self.paused: return [{'type': 'skipped', 'reason': 'paused'}] if self.exit: return [{'type': 'skipped', 'reason': 'exiting'}] try: # Update current candle data in exec context if provided if candle_data: self.exec_context['current_candle'] = candle_data self.exec_context['current_price'] = candle_data.get('close') self.exec_context['current_symbol'] = candle_data.get('symbol', 'BTC/USDT') # Execute the strategy's next() method result = self.execute() if result.get('success'): # Collect any events generated during execution if '_events' in self.exec_context: events.extend(self.exec_context['_events']) self.exec_context['_events'] = [] events.append({ 'type': 'tick_complete', 'strategy_id': self.strategy_id, 'profit_loss': result.get('profit_loss', 0.0), }) else: events.append({ 'type': 'error', 'strategy_id': self.strategy_id, 'message': result.get('message', 'Unknown error'), }) except Exception as e: logger.error(f"Error in strategy tick: {e}", exc_info=True) events.append({ 'type': 'error', 'strategy_id': self.strategy_id, 'message': str(e), }) return events def execute(self) -> dict[str, Any]: """ Executes the strategy's 'next()' method. :return: Result of the execution. """ try: # Log the generated code once for debugging if not hasattr(self, '_code_logged'): logger.info(f"Strategy {self.strategy_id} generated code:\n{self.generated_code}") self._code_logged = True # Compile the generated code with a meaningful filename compiled_code = compile(self.generated_code, '', 'exec') exec(compiled_code, self.exec_context) # Call the 'next()' method if defined if 'next' in self.exec_context and callable(self.exec_context['next']): # Log flags before execution for debugging logger.debug(f"[STRATEGY EXEC] Flags before next(): {self.flags}") logger.debug(f"[STRATEGY EXEC] Variables before next(): {self.variables}") self.exec_context['next']() # Log flags after execution for debugging logger.debug(f"[STRATEGY EXEC] Flags after next(): {self.flags}") logger.debug(f"[STRATEGY EXEC] Variables after next(): {self.variables}") else: logger.error( f"'next' method not defined in generated_code for StrategyInstance '{self.strategy_instance_id}'." ) return {"success": False, "message": "'next' method not defined."} # 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: # Extract full traceback full_tb = traceback.format_exc() # Extract traceback object tb = traceback.extract_tb(e.__traceback__) # Initialize variables to hold error details error_line_no = None error_code_line = None # Debug: Log all frames in the traceback logger.debug("Traceback frames:") for frame in tb: logger.debug( f"Filename: {frame.filename}, Line: {frame.lineno}, Function: {frame.name}, Line Text: {frame.line}" ) # Iterate through traceback to find the frame with our compiled code for frame in tb: if frame.filename == '': error_line_no = frame.lineno error_code_line = frame.line break # Exit after finding the relevant frame if error_line_no: # Fetch the specific line from generated_code generated_code_lines = [line for line in self.generated_code.strip().split('\n') if line.strip()] logger.debug(f"Generated Code Lines Count: {len(generated_code_lines)}") if 1 <= error_line_no <= len(generated_code_lines): problematic_line = generated_code_lines[error_line_no - 1].strip() logger.debug(f"Problematic Line {error_line_no}:\n {problematic_line}\n") else: problematic_line = "Unknown line." logger.debug(f"Error line number {error_line_no} is out of bounds.") if error_code_line and error_code_line.strip(): # If frame.line has content, use it problematic_line = error_code_line.strip() logger.debug(f"Problematic Line from Traceback: {problematic_line}") else: # Otherwise, use the line from generated_code problematic_line = problematic_line # Log detailed error information logger.error( f"Error executing 'next()' for StrategyInstance '{self.strategy_instance_id}': {e}\n" f"Traceback:\n{full_tb}\n" f"Error occurred at line {error_line_no}:\n {problematic_line}\n" f"Generated Code:\n{self.generated_code}" ) # Optionally, include the problematic line in the returned message return { "success": False, "message": f"{e} at line {error_line_no}: {problematic_line}" } else: # If no specific frame found, log the full traceback logger.error( f"Error executing 'next()' for StrategyInstance '{self.strategy_instance_id}': {e}\n" f"Traceback:\n{full_tb}" ) 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.available_strategy_balance = balance self.exec_context['available_strategy_balance'] = self.available_strategy_balance self.save_context() 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: # Update self.current_balance from trades self.current_balance = self.trades.get_current_balance(self.user_id) self.exec_context['current_balance'] = self.current_balance logger.debug(f"Current balance retrieved: {self.current_balance}.") return self.current_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: logger.debug(f"Available strategy balance retrieved: {self.available_strategy_balance}.") return self.available_strategy_balance except Exception as e: logger.error(f"Error retrieving available strategy balance: {e}", exc_info=True) return 0.0 def fetch_user_balance(self) -> float: """ Fetches the user's total balance. :return: User's total balance. """ try: balance = self.trades.get_current_balance(self.user_id) logger.debug(f"Fetched user balance: {balance}.") return balance except Exception as e: logger.error(f"Error fetching user balance: {e}", exc_info=True) return 0.0 def calculate_available_balance(self) -> float: """ Calculates the user's available balance not tied up in trades or orders. :return: Available balance. """ try: balance = self.trades.get_available_balance(self.user_id) logger.debug(f"Calculated available balance: {balance}.") return balance except Exception as e: logger.error(f"Error calculating available 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: if self.edm_client is None: logger.error("EDM client not initialized. Cannot fetch candle data.") return None data = self.edm_client.get_candles_sync( exchange=exchange.lower(), symbol=symbol, timeframe=timeframe, limit=1 ) 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, 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. """ symbol = source['symbol'] if source and 'symbol' in source else 'Unknown' if trade_type == 'buy': logger.info(f"Executing BUY order: Size={size}, Symbol={symbol}, Order Type={order_type}") # Implement buy order logic here status, msg = self.trades.buy({ 'symbol': symbol, 'size': size, 'order_type': order_type, 'tif': tif, # Include other parameters as needed }, self.user_id) if status == 'success': # Update balances # Assume that the trade amount is size * price price = self.get_current_price() if price: trade_amount = size * price self.available_strategy_balance -= trade_amount self.available_balance -= trade_amount self.current_balance -= trade_amount self.exec_context['available_strategy_balance'] = self.available_strategy_balance self.exec_context['available_balance'] = self.available_balance self.exec_context['current_balance'] = self.current_balance self.save_context() else: logger.error(f"Buy order failed: {msg}") elif trade_type == 'sell': logger.info(f"Executing SELL order: Size={size}, Symbol={symbol}, Order Type={order_type}") # Implement sell order logic here status, msg = self.trades.sell({ 'symbol': symbol, 'size': size, 'order_type': order_type, 'tif': tif, # Include other parameters as needed }, self.user_id) if status == 'success': # Update balances accordingly price = self.get_current_price() if price: trade_amount = size * price self.available_strategy_balance += trade_amount self.available_balance += trade_amount self.current_balance += trade_amount self.exec_context['available_strategy_balance'] = self.available_strategy_balance self.exec_context['available_balance'] = self.available_balance self.exec_context['current_balance'] = self.current_balance self.save_context() else: logger.error(f"Sell order failed: {msg}") 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. For subscribed strategies, indicators are looked up using indicator_owner_id (the strategy creator's ID) rather than the running user's ID. :param indicator_name: Name of the indicator. :param output_field: Specific field of the indicator. :return: Indicator value. """ # Use indicator_owner_id for lookup (creator's indicators for subscribed strategies) lookup_user_id = self.indicator_owner_id logger.debug(f"StrategyInstance is Retrieving indicator '{indicator_name}' from Indicators for user '{lookup_user_id}'.") try: user_indicators = self.indicators.get_indicator_list(user_id=lookup_user_id) indicator = user_indicators.get(indicator_name) if not indicator: logger.error(f"Indicator '{indicator_name}' not found for user '{lookup_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 process_signal(self, signal_name: str, output_field: str = 'triggered') -> Any: """ Checks the state of a user-defined signal. :param signal_name: Name of the signal. :param output_field: Either 'triggered' (returns bool) or 'value' (returns numeric). :return: Boolean for triggered, numeric value for value, or None on error. """ try: # Get the signal from the signals manager if not hasattr(self, 'signals') or self.signals is None: logger.warning(f"Signals manager not available in StrategyInstance") return False if output_field == 'triggered' else None # Look up the signal by name signal = self.signals.get_signal_by_name(signal_name) if signal is None: logger.warning(f"Signal '{signal_name}' not found") return False if output_field == 'triggered' else None if output_field == 'triggered': # Return whether the signal condition is currently met # Signal is a dataclass with 'state' attribute is_triggered = getattr(signal, 'state', False) logger.debug(f"Signal '{signal_name}' triggered: {is_triggered}") return is_triggered else: # Return the numeric value of the signal (value1) value = getattr(signal, 'value1', None) logger.debug(f"Signal '{signal_name}' value: {value}") return value except Exception as e: logger.error( f"Error processing signal '{signal_name}' in StrategyInstance '{self.strategy_instance_id}': {e}", exc_info=True) traceback.print_exc() return False if output_field == 'triggered' else None def process_formation(self, tbl_key: str, property_name: str = 'line', timestamp: int = None) -> float: """ Gets the price value of a formation property at a given timestamp. Uses formation_owner_id (not current user) for subscribed strategies. Parallel to indicator_owner_id pattern. :param tbl_key: Unique key of the formation (UUID). :param property_name: Property to retrieve ('line', 'upper', 'lower', 'midline', etc.). :param timestamp: Unix timestamp in seconds UTC. If None, uses current candle time. :return: Price value at the timestamp, or None on error. """ try: # Check if formations manager is available if not hasattr(self, 'formations') or self.formations is None: logger.warning(f"Formations manager not available in StrategyInstance") return None # Default timestamp: use current candle time if available if timestamp is None: timestamp = self.get_current_candle_time() # Use formation_owner_id for subscribed strategies (parallel to indicator_owner_id) owner_id = getattr(self, 'formation_owner_id', self.user_id) # Look up the formation by tbl_key using owner's formations formation = self.formations.get_by_tbl_key_for_strategy(tbl_key, owner_id) if formation is None: logger.warning(f"Formation with tbl_key '{tbl_key}' not found for owner {owner_id}") return None # Get the property value at the timestamp value = self.formations.get_property_value(formation, property_name, timestamp) logger.debug(f"Formation '{formation.get('name')}' {property_name} at {timestamp}: {value}") return value except Exception as e: logger.error( f"Error processing formation '{tbl_key}' in StrategyInstance '{self.strategy_instance_id}': {e}", exc_info=True) traceback.print_exc() return None def get_current_candle_time(self) -> int: """ Returns the current candle timestamp in seconds UTC. In backtest mode, this is overridden to return the historical bar time. In live/paper mode, returns the current time. """ import time return int(time.time()) 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) def accumulate_trade_fee(self, trade_value_usd: float, commission_rate: float, is_profitable: bool) -> dict: """ Accumulate fee for a completed trade (only called for paid strategies). This method is called when a trade fills to accumulate fees that will be settled when the strategy stops. :param trade_value_usd: The USD value of the trade. :param commission_rate: The exchange commission rate (e.g., 0.001 for 0.1%). :param is_profitable: Whether the trade was profitable. :return: Dict with fee charged amount. """ # Check if this strategy has fee tracking enabled if not getattr(self, 'has_fee', False) or not getattr(self, 'strategy_run_id', None): return {'success': True, 'fee_charged': 0, 'reason': 'no_fees_enabled'} wallet_manager = getattr(self, 'wallet_manager', None) if not wallet_manager: return {'success': False, 'error': 'No wallet manager available'} # Calculate exchange fee in USD, then convert to satoshis # Assume 1 BTC = $50,000 for conversion (rough estimate, could be made dynamic) btc_price_usd = 50000 # This could be fetched from exchange exchange_fee_usd = trade_value_usd * commission_rate # Convert to satoshis: 1 BTC = 100,000,000 satoshis exchange_fee_btc = exchange_fee_usd / btc_price_usd exchange_fee_satoshis = int(exchange_fee_btc * 100_000_000) # Accumulate the fee result = wallet_manager.accumulate_trade_fee( strategy_run_id=self.strategy_run_id, exchange_fee_satoshis=exchange_fee_satoshis, is_profitable=is_profitable ) if result.get('fee_charged', 0) > 0: logger.debug(f"Accumulated fee: {result['fee_charged']} sats for trade worth ${trade_value_usd:.2f}") return result