978 lines
42 KiB
Python
978 lines
42 KiB
Python
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, '<strategy_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 == '<strategy_code>':
|
|
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
|