No Alerts
+diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py
index 1eeafd8..e5d3bcd 100644
--- a/src/BrighterTrades.py
+++ b/src/BrighterTrades.py
@@ -34,7 +34,7 @@ class BrighterTrades:
self.users = Users(data_cache=self.data)
# Object that maintains signals.
- self.signals = Signals(self.config)
+ self.signals = Signals(self.data)
# Object that maintains candlestick and price data.
self.candles = Candles(users=self.users, exchanges=self.exchanges, datacache=self.data,
@@ -370,19 +370,48 @@ class BrighterTrades:
return updates
- def received_new_signal(self, data: dict) -> str | dict:
+ def received_new_signal(self, data: dict, user_id: int = None) -> dict:
"""
Handles the creation of a new signal based on the provided data.
:param data: A dictionary containing the attributes of the new signal.
- :return: An error message if the required attribute is missing, or the incoming data for chaining on success.
+ :param user_id: The ID of the user creating the signal.
+ :return: A dictionary containing success or failure information.
"""
- if 'name' not in data:
- return "The new signal must have a 'name' attribute."
+ # Validate required fields
+ required_fields = ['name', 'source1', 'prop1', 'source2', 'prop2', 'operator']
+ missing_fields = [field for field in required_fields if field not in data]
+ if missing_fields:
+ return {"success": False, "message": f"Missing fields: {', '.join(missing_fields)}"}
- self.signals.new_signal(data)
- self.config.set_setting('signals_list', self.signals.get_signals('dict'))
- return data
+ # Add creator field
+ if user_id is not None:
+ data['creator'] = user_id
+
+ # Save the signal
+ result = self.signals.new_signal(data)
+ return result
+
+ def received_edit_signal(self, data: dict, user_id: int = None) -> dict:
+ """
+ Handles editing an existing signal based on the provided data.
+
+ :param data: A dictionary containing the attributes of the signal to edit.
+ :param user_id: The ID of the user editing the signal.
+ :return: A dictionary containing success or failure information.
+ """
+ if not data.get('tbl_key'):
+ return {"success": False, "message": "Signal tbl_key not provided."}
+
+ # Verify user has permission to edit
+ if user_id is not None:
+ signal = self.signals.get_signal_by_tbl_key(data['tbl_key'])
+ if signal and signal.creator != user_id and not signal.public:
+ return {"success": False, "message": "You don't have permission to edit this signal."}
+ data['creator'] = user_id
+
+ result = self.signals.edit_signal(data)
+ return result
def received_new_strategy(self, data: dict) -> dict:
"""
@@ -420,7 +449,7 @@ class BrighterTrades:
# Prepare the strategy data for insertion
try:
strategy_data = {
- "creator": user_id,
+ "creator": int(user_id), # Convert numpy.int64 to native int for SQLite
"name": data['name'].strip(),
"workspace": data['workspace'].strip(),
"code": code_json,
@@ -958,27 +987,43 @@ class BrighterTrades:
"count": len(running_strategies),
}
- def delete_signal(self, signal_name: str) -> None:
+ def delete_signal(self, data: dict, user_id: int = None) -> dict:
"""
- Deletes a signal from the signals instance and removes it from the configuration file.
+ Deletes a signal from the signals instance.
- :param signal_name: The name of the signal to delete.
- :return: None
+ :param data: Dictionary containing 'tbl_key' or 'name' of the signal to delete.
+ :param user_id: The ID of the user deleting the signal (for permission check).
+ :return: A dictionary indicating success or failure.
"""
+ tbl_key = data.get('tbl_key')
+ signal_name = data.get('name')
- # Delete the signal from the signals instance.
- self.signals.delete_signal(signal_name)
+ # If only name provided, find the tbl_key
+ if not tbl_key and signal_name:
+ signal = self.signals.get_signal_by_name(signal_name)
+ if signal:
+ tbl_key = signal.tbl_key
- # # Delete the signal from the configuration file.TODO
- # self.config.remove('signals', signal_name)
+ if not tbl_key:
+ return {"success": False, "message": "Signal not found.", "tbl_key": None}
- def get_signals_json(self) -> str:
+ # Verify user has permission to delete
+ if user_id is not None:
+ signal = self.signals.get_signal_by_tbl_key(tbl_key)
+ if signal and signal.creator != user_id:
+ return {"success": False, "message": "You don't have permission to delete this signal."}
+
+ # Delete the signal
+ return self.signals.delete_signal(tbl_key)
+
+ def get_signals_json(self, user_id: int = None) -> list:
"""
- Retrieve all the signals from the signals instance and return them as a JSON object.
+ Retrieve signals visible to the user (their own + public signals) and return as a list.
- :return: str - A JSON object containing all the signals.
+ :param user_id: The ID of the user making the request.
+ :return: list - A list of signal dictionaries.
"""
- return self.signals.get_signals('json')
+ return self.signals.get_all_signals(user_id, 'dict')
def get_strategies_json(self, user_id) -> list:
"""
@@ -1175,10 +1220,18 @@ class BrighterTrades:
user_name=user_name, default_market=market)
elif setting == 'toggle_indicator':
- # Parse the indicator field as a JSON array
- try:
- indicators_to_toggle = json.loads(params.get('indicator', '[]'))
- except json.JSONDecodeError:
+ # Get indicator names - can be a list (from form checkboxes) or JSON string
+ indicator_param = params.get('indicator', [])
+ if isinstance(indicator_param, list):
+ indicators_to_toggle = indicator_param
+ elif isinstance(indicator_param, str):
+ # Try to parse as JSON for backwards compatibility
+ try:
+ indicators_to_toggle = json.loads(indicator_param)
+ except json.JSONDecodeError:
+ # If not JSON, treat as single indicator name
+ indicators_to_toggle = [indicator_param] if indicator_param else []
+ else:
indicators_to_toggle = []
user_id = self.get_user_info(user_name=user_name, info='User_id')
@@ -1233,8 +1286,8 @@ class BrighterTrades:
if msg_type == 'request':
request_for = msg_data.get('request')
if request_for == 'signals':
- if signals := self.get_signals_json():
- return standard_reply("signals", signals)
+ signals = self.get_signals_json(user_id)
+ return standard_reply("signals", signals if signals else [])
elif request_for == 'strategies':
if user_id is None:
@@ -1251,8 +1304,18 @@ class BrighterTrades:
# Processing commands
if msg_type == 'delete_signal':
- pass
- # self.delete_signal(msg_data)
+ result = self.delete_signal(msg_data, user_id)
+ if result.get('success'):
+ return standard_reply("signal_deleted", {
+ "message": result.get('message'),
+ "tbl_key": result.get('tbl_key'),
+ "name": result.get('name')
+ })
+ else:
+ return standard_reply("signal_error", {
+ "message": result.get('message'),
+ "tbl_key": result.get('tbl_key')
+ })
if msg_type == 'delete_strategy':
result = self.delete_strategy(msg_data)
@@ -1271,8 +1334,18 @@ class BrighterTrades:
self.close_trade(msg_data)
if msg_type == 'new_signal':
- if r_data := self.received_new_signal(msg_data):
- return standard_reply("signal_created", r_data)
+ result = self.received_new_signal(msg_data, user_id)
+ if result.get('success'):
+ return standard_reply("signal_created", result)
+ else:
+ return standard_reply("signal_error", result)
+
+ if msg_type == 'edit_signal':
+ result = self.received_edit_signal(msg_data, user_id)
+ if result.get('success'):
+ return standard_reply("signal_updated", result)
+ else:
+ return standard_reply("signal_error", result)
if msg_type == 'new_strategy':
try:
diff --git a/src/Signals.py b/src/Signals.py
index fe99d8e..2096ed0 100644
--- a/src/Signals.py
+++ b/src/Signals.py
@@ -1,5 +1,15 @@
import json
+import logging
+import uuid
+import datetime as dt
from dataclasses import dataclass
+from typing import Any
+
+import pandas as pd
+from DataCache_v3 import DataCache
+
+# Configure logging
+logger = logging.getLogger(__name__)
@dataclass()
@@ -12,9 +22,12 @@ class Signal:
prop2: str
operator: str
state: bool = False
- value1: int = None
- value2: int = None
- range: int = None
+ value1: float = None
+ value2: float = None
+ range: float = None
+ tbl_key: str = None
+ creator: int = None
+ public: bool = False
def set_value1(self, value):
self.value1 = value
@@ -23,7 +36,6 @@ class Signal:
self.value2 = value
def compare(self):
-
if self.value1 is None:
raise ValueError('Signal: Cannot compare: value1 not set')
if self.value2 is None:
@@ -40,148 +52,516 @@ class Signal:
self.state = False
else:
string = str(self.value1) + self.operator + str(self.value2)
- print(string)
+ logger.debug(f"Signal comparison: {string}")
if eval(string):
self.state = True
else:
self.state = False
- state_change = False
- if self.state != previous_state:
- state_change = True
+
+ state_change = self.state != previous_state
return state_change
class Signals:
- def __init__(self, config):
+ """Manages signals with database-backed storage via DataCache."""
- # list of Signal objects.
- self.signals = []
-
- # load a list of existing signals from file.
- loaded_signals = config.get_setting('signals_list')
- if loaded_signals is None:
- # Populate the list and file with defaults defined in this class.
- loaded_signals = self.get_signals_defaults()
- config.set_setting('signals_list', loaded_signals)
-
- # Initialize signals with loaded data.
- if loaded_signals is not None:
- self.create_signal_from_dic(loaded_signals)
-
- @staticmethod
- def get_signals_defaults():
- """These defaults are loaded if the config file is not found."""
- s1 = {"name": "20x50_GC", "source1": "EMA 20",
- "prop1": "value", "source2": "EMA 50",
- "prop2": "value", "operator": ">",
- "state": False, "value1": None,
- "value2": None, "range": None}
- s2 = {"name": "50x200_GC", "source1": "EMA 50",
- "prop1": "value", "source2": "EMA 200",
- "prop2": "value", "operator": ">",
- "state": False, "value1": None,
- "value2": None, "range": None}
- s3 = {"name": "5x15_GC", "source1": "EMA 5",
- "prop1": "value", "source2": "EMA 15",
- "prop2": "value", "operator": ">",
- "state": False, "value1": None,
- "value2": None, "range": None}
- return [s1, s2, s3]
-
- def create_signal_from_dic(self, signals_list=None):
+ def __init__(self, data_cache: DataCache):
"""
- :param signals_list: list of dict
- :return True: on success.
- Create and store signal objects from list of dictionaries.
+ Initializes the Signals class.
+
+ :param data_cache: Instance of DataCache to manage cache and database interactions.
"""
+ self.data_cache = data_cache
- # If no signals were provided used a default list.
- if signals_list is None:
- signals_list = self.get_signals_defaults()
- # Loop through the provided list, unpack the dictionaries, create and store the signal objects.
- for sig in signals_list:
- self.new_signal(sig)
- return True
+ # Ensure the signals table exists in the database
+ self._ensure_table_exists()
- def get_signals(self, form):
- # Return a python object of all the signals stored in this instance.
+ # Create a cache for signals with necessary columns
+ self.data_cache.create_cache(
+ name='signals',
+ cache_type='table',
+ size_limit=500,
+ eviction_policy='deny',
+ default_expiration=dt.timedelta(hours=24),
+ columns=[
+ "creator", # User ID who created the signal
+ "name", # Signal name
+ "source1", # First indicator source
+ "prop1", # First indicator property
+ "source2", # Second indicator source (or 'value')
+ "prop2", # Second indicator property (or the value itself)
+ "operator", # Comparison operator: >, <, ==, +/-
+ "range", # Range value for +/- operator
+ "public", # Whether signal is visible to other users
+ "state", # Current signal state (True/False)
+ "last_value1", # Last calculated value1
+ "last_value2", # Last calculated value2
+ "tbl_key" # Unique identifier
+ ]
+ )
+
+ # In-memory cache of Signal objects for quick processing
+ self.signals: list[Signal] = []
+
+ # Load existing signals from database into memory
+ self._load_signals_from_db()
+
+ def _ensure_table_exists(self) -> None:
+ """Create the signals table in the database if it doesn't exist."""
+ try:
+ if not self.data_cache.db.table_exists('signals'):
+ create_sql = """
+ CREATE TABLE IF NOT EXISTS signals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ creator INTEGER,
+ name TEXT NOT NULL,
+ source1 TEXT NOT NULL,
+ prop1 TEXT NOT NULL,
+ source2 TEXT NOT NULL,
+ prop2 TEXT NOT NULL,
+ operator TEXT NOT NULL,
+ range REAL,
+ public INTEGER DEFAULT 0,
+ state INTEGER DEFAULT 0,
+ last_value1 REAL,
+ last_value2 REAL,
+ tbl_key TEXT UNIQUE
+ )
+ """
+ self.data_cache.db.execute_sql(create_sql, params=[])
+ logger.info("Created signals table in database")
+ except Exception as e:
+ logger.error(f"Error ensuring signals table exists: {e}", exc_info=True)
+
+ def _load_signals_from_db(self) -> None:
+ """Load all signals from database into memory."""
+ try:
+ signals_df = self.data_cache.get_all_rows_from_datacache(cache_name='signals')
+ if signals_df is not None and not signals_df.empty:
+ for _, row in signals_df.iterrows():
+ signal = Signal(
+ name=row.get('name', ''),
+ source1=row.get('source1', ''),
+ prop1=row.get('prop1', ''),
+ source2=row.get('source2', ''),
+ prop2=row.get('prop2', ''),
+ operator=row.get('operator', '=='),
+ state=bool(row.get('state', False)),
+ value1=row.get('last_value1'),
+ value2=row.get('last_value2'),
+ range=row.get('range'),
+ tbl_key=row.get('tbl_key'),
+ creator=row.get('creator'),
+ public=bool(row.get('public', False))
+ )
+ self.signals.append(signal)
+ logger.info(f"Loaded {len(self.signals)} signals from database")
+ except Exception as e:
+ logger.error(f"Error loading signals from database: {e}", exc_info=True)
+
+ def _save_signal(self, signal_data: dict) -> dict:
+ """
+ Saves a signal to the cache and database. Handles both creation and editing.
+
+ :param signal_data: A dictionary containing signal data.
+ :return: A dictionary containing success or failure information.
+ """
+ is_edit = 'tbl_key' in signal_data and signal_data['tbl_key']
+
+ try:
+ tbl_key = signal_data.get('tbl_key') if is_edit else str(uuid.uuid4())
+
+ if is_edit:
+ # Verify the existing signal
+ existing_signal = self.data_cache.get_rows_from_datacache(
+ cache_name='signals',
+ filter_vals=[('tbl_key', tbl_key)],
+ include_tbl_key=True
+ )
+ if existing_signal.empty:
+ return {"success": False, "message": "Signal not found."}
+ else:
+ # Check for duplicate signal name for the same user
+ creator = signal_data.get('creator')
+ name = signal_data.get('name')
+ if creator and name:
+ filter_conditions = [('creator', creator), ('name', name)]
+ existing_signal = self.data_cache.get_rows_from_datacache(
+ cache_name='signals',
+ filter_vals=filter_conditions,
+ include_tbl_key=True
+ )
+ if not existing_signal.empty:
+ return {"success": False, "message": "A signal with this name already exists"}
+
+ # Validate required fields
+ required_fields = ['name', 'source1', 'prop1', 'source2', 'prop2', 'operator']
+ for field in required_fields:
+ if not signal_data.get(field):
+ return {"success": False, "message": f"Missing required field: {field}"}
+
+ # Prepare fields for database operations
+ columns = (
+ "creator", "name", "source1", "prop1", "source2", "prop2",
+ "operator", "range", "public", "state", "last_value1", "last_value2", "tbl_key"
+ )
+
+ # Convert range to float if present
+ range_val = signal_data.get('range')
+ if range_val is not None:
+ try:
+ range_val = float(range_val)
+ except (TypeError, ValueError):
+ range_val = None
+
+ values = (
+ signal_data.get('creator'),
+ signal_data['name'],
+ signal_data['source1'],
+ signal_data['prop1'],
+ signal_data['source2'],
+ signal_data['prop2'],
+ signal_data['operator'],
+ range_val,
+ int(signal_data.get('public', 0)),
+ int(signal_data.get('state', 0)),
+ signal_data.get('value1') or signal_data.get('last_value1'),
+ signal_data.get('value2') or signal_data.get('last_value2'),
+ tbl_key
+ )
+
+ if is_edit:
+ # Update the existing signal in database
+ self.data_cache.modify_datacache_item(
+ cache_name='signals',
+ filter_vals=[('tbl_key', tbl_key)],
+ field_names=columns,
+ new_values=values,
+ key=tbl_key,
+ overwrite='tbl_key'
+ )
+
+ # Update in-memory signal
+ for sig in self.signals:
+ if sig.tbl_key == tbl_key:
+ sig.name = signal_data['name']
+ sig.source1 = signal_data['source1']
+ sig.prop1 = signal_data['prop1']
+ sig.source2 = signal_data['source2']
+ sig.prop2 = signal_data['prop2']
+ sig.operator = signal_data['operator']
+ sig.range = range_val
+ sig.public = bool(signal_data.get('public', 0))
+ sig.creator = signal_data.get('creator')
+ break
+ else:
+ # Insert a new signal into database
+ self.data_cache.insert_row_into_datacache(
+ cache_name='signals',
+ columns=columns,
+ values=values
+ )
+
+ # Add to in-memory signals
+ new_signal = Signal(
+ name=signal_data['name'],
+ source1=signal_data['source1'],
+ prop1=signal_data['prop1'],
+ source2=signal_data['source2'],
+ prop2=signal_data['prop2'],
+ operator=signal_data['operator'],
+ range=range_val,
+ state=bool(signal_data.get('state', False)),
+ value1=signal_data.get('value1'),
+ value2=signal_data.get('value2'),
+ tbl_key=tbl_key,
+ creator=signal_data.get('creator'),
+ public=bool(signal_data.get('public', 0))
+ )
+ self.signals.append(new_signal)
+
+ # Prepare the response
+ response_signal = signal_data.copy()
+ response_signal['tbl_key'] = tbl_key
+
+ return {
+ "success": True,
+ "message": "Signal saved successfully" if is_edit else "Signal created successfully",
+ "signal": response_signal,
+ "updated_at": dt.datetime.now(dt.timezone.utc).isoformat()
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to save signal: {e}", exc_info=True)
+ operation = "update" if is_edit else "create"
+ return {"success": False, "message": f"Failed to {operation} signal: {str(e)}"}
+
+ def new_signal(self, signal_data: dict) -> dict:
+ """
+ Add a new signal to the cache and database.
+
+ :param signal_data: A dictionary containing signal data.
+ :return: A dictionary containing success or failure information.
+ """
+ return self._save_signal(signal_data)
+
+ def edit_signal(self, signal_data: dict) -> dict:
+ """
+ Updates an existing signal in the cache and database.
+
+ :param signal_data: A dictionary containing the updated signal data.
+ :return: A dictionary containing success or failure information.
+ """
+ return self._save_signal(signal_data)
+
+ def delete_signal(self, tbl_key: str) -> dict:
+ """
+ Deletes a signal identified by its tbl_key.
+
+ :param tbl_key: The unique identifier of the signal to delete.
+ :return: A dictionary indicating success or failure.
+ """
+ try:
+ # Get the signal name before deleting (for response)
+ signal_name = None
+ for sig in self.signals:
+ if sig.tbl_key == tbl_key:
+ signal_name = sig.name
+ break
+
+ # Remove from database
+ self.data_cache.remove_row_from_datacache(
+ cache_name='signals',
+ filter_vals=[('tbl_key', tbl_key)]
+ )
+
+ # Remove from in-memory list
+ self.signals = [sig for sig in self.signals if sig.tbl_key != tbl_key]
+
+ return {
+ "success": True,
+ "message": "Signal deleted successfully.",
+ "tbl_key": tbl_key,
+ "name": signal_name
+ }
+ except Exception as e:
+ logger.error(f"Failed to delete signal '{tbl_key}': {e}", exc_info=True)
+ return {"success": False, "message": f"Failed to delete signal: {str(e)}"}
+
+ def get_all_signals(self, user_id: int | None, form: str = 'dict') -> Any:
+ """
+ Return signals visible to the user (their own + public signals).
+
+ :param user_id: The ID of the user making the request. If None, returns only public signals.
+ :param form: The desired format ('df', 'json', 'dict', 'obj').
+ :return: Signals in the requested format.
+ """
+ valid_forms = {'df', 'json', 'dict', 'obj'}
+ if form not in valid_forms:
+ raise ValueError(f"Invalid form '{form}'. Expected one of {valid_forms}.")
+
+ try:
+ # Fetch public signals
+ public_df = self.data_cache.get_rows_from_datacache(
+ cache_name='signals',
+ filter_vals=[('public', 1)],
+ include_tbl_key=True
+ )
+
+ if user_id is not None:
+ # Fetch user's private signals
+ user_df = self.data_cache.get_rows_from_datacache(
+ cache_name='signals',
+ filter_vals=[('creator', user_id), ('public', 0)],
+ include_tbl_key=True
+ )
+ # Concatenate the two DataFrames
+ signals_df = pd.concat([public_df, user_df], ignore_index=True)
+ else:
+ signals_df = public_df
+
+ # Return empty result if no signals found
+ if signals_df is None or signals_df.empty:
+ if form == 'df':
+ return pd.DataFrame()
+ elif form == 'obj':
+ return []
+ elif form == 'json':
+ return '[]'
+ else:
+ return []
+
+ # Return in requested format
+ if form == 'df':
+ return signals_df
+ elif form == 'json':
+ return signals_df.to_json(orient='records')
+ elif form == 'dict':
+ return signals_df.to_dict('records')
+ elif form == 'obj':
+ # Return in-memory Signal objects filtered for this user
+ return [
+ sig for sig in self.signals
+ if sig.public or sig.creator == user_id
+ ]
+
+ except Exception as e:
+ logger.error(f"Error getting signals: {e}", exc_info=True)
+ if form == 'df':
+ return pd.DataFrame()
+ elif form == 'obj':
+ return []
+ elif form == 'json':
+ return '[]'
+ else:
+ return []
+
+ def get_signals(self, form: str, user_id: int = None) -> Any:
+ """
+ Legacy method for backwards compatibility.
+ Return signals in the specified format.
+
+ :param form: The desired format ('obj', 'json', 'dict').
+ :param user_id: Optional user ID for filtering.
+ :return: Signals in the requested format.
+ """
if form == 'obj':
+ if user_id is not None:
+ return [sig for sig in self.signals if sig.public or sig.creator == user_id]
return self.signals
- # Return a JSON object of all the signals stored in this instance.
elif form == 'json':
- sigs = self.signals
+ sigs = self.signals if user_id is None else [
+ sig for sig in self.signals if sig.public or sig.creator == user_id
+ ]
json_str = []
for sig in sigs:
- # TODO: note - Explore why I had to treat signals and strategies different here.
json_str.append(json.dumps(sig.__dict__))
return json_str
- # Return a dictionary object of all the signals stored in this instance.
elif form == 'dict':
- sigs = self.signals
- s_list = []
- for sig in sigs:
- dic = sig.__dict__
- s_list.append(dic)
- return s_list
+ sigs = self.signals if user_id is None else [
+ sig for sig in self.signals if sig.public or sig.creator == user_id
+ ]
+ return [sig.__dict__ for sig in sigs]
+ return None
- def get_signal_by_name(self, name):
+ def get_signal_by_name(self, name: str) -> Signal | None:
+ """Get a signal by its name."""
for signal in self.signals:
if signal.name == name:
return signal
return None
- def new_signal(self, data):
- self.signals.append(Signal(**data))
-
- def delete_signal(self, signal_name):
- print(f'removing {signal_name}')
- for sig in self.signals:
- if sig.name == signal_name:
- self.signals.remove(sig)
- break
-
- def update_signals(self, candles, indicators):
- # TODO: This function is not used. but may be used later if candles need to be specified.
+ def get_signal_by_tbl_key(self, tbl_key: str) -> Signal | None:
+ """Get a signal by its tbl_key."""
for signal in self.signals:
- self.process_signal(signal, candles, indicators)
+ if signal.tbl_key == tbl_key:
+ return signal
+ return None
- def process_all_signals(self, indicators):
- """Loop through all the signals and process
- them based on the last indicator results."""
+ def update_signal_state(self, tbl_key: str, state: bool, value1: float = None, value2: float = None) -> None:
+ """
+ Update a signal's state and values in both memory and database.
+
+ :param tbl_key: The signal's unique identifier.
+ :param state: The new state value.
+ :param value1: Optional new value1.
+ :param value2: Optional new value2.
+ """
+ try:
+ # Update in-memory signal
+ for sig in self.signals:
+ if sig.tbl_key == tbl_key:
+ sig.state = state
+ if value1 is not None:
+ sig.value1 = value1
+ if value2 is not None:
+ sig.value2 = value2
+ break
+
+ # Update in database
+ update_values = {'state': int(state)}
+ if value1 is not None:
+ update_values['last_value1'] = value1
+ if value2 is not None:
+ update_values['last_value2'] = value2
+
+ field_names = tuple(update_values.keys())
+ new_values = tuple(update_values.values())
+
+ self.data_cache.modify_datacache_item(
+ cache_name='signals',
+ filter_vals=[('tbl_key', tbl_key)],
+ field_names=field_names,
+ new_values=new_values,
+ key=tbl_key,
+ overwrite='tbl_key'
+ )
+ except Exception as e:
+ logger.error(f"Error updating signal state: {e}", exc_info=True)
+
+ def process_all_signals(self, indicators) -> dict:
+ """
+ Loop through all signals and process them based on the last indicator results.
+
+ :param indicators: The Indicators instance with calculated values.
+ :return: Dictionary of signals that changed state.
+ """
state_changes = {}
for signal in self.signals:
change_in_state = self.process_signal(signal, indicators)
if change_in_state:
- state_changes.update({signal.name: signal.state})
+ state_changes[signal.name] = signal.state
+ # Persist state change to database
+ if signal.tbl_key:
+ self.update_signal_state(
+ signal.tbl_key,
+ signal.state,
+ signal.value1,
+ signal.value2
+ )
return state_changes
- def process_signal(self, signal, indicators, candles=None):
- """Receives a signal, makes the comparison with the last value
- calculated by the indicators and returns the result.
- If candles are provided it will ask the indicators to
- calculate new values based on those candles."""
+ def process_signal(self, signal: Signal, indicators, candles=None) -> bool:
+ """
+ Process a signal by comparing indicator values.
+
+ :param signal: The signal to process.
+ :param indicators: The Indicators instance with calculated values.
+ :param candles: Optional candles for recalculation.
+ :return: True if the signal state changed, False otherwise.
+ """
if candles is None:
# Get the source of the first signal
source_1 = signal.source1
- # Ask the indicator for the last result.
+ # Ask the indicator for the last result
if source_1 in indicators.indicators:
- signal.value1 = indicators.indicators[source_1].properties[signal.prop1]
+ signal.value1 = indicators.indicators[source_1].properties.get(signal.prop1)
else:
- print('Could not calculate signal source indicator not found.')
+ logger.debug(f'Could not calculate signal: source indicator "{source_1}" not found.')
return False
# Get the source of the second signal
source_2 = signal.source2
# If the source is a set value it will be stored in prop2
if source_2 == 'value':
- signal.value2 = signal.prop2
+ try:
+ signal.value2 = float(signal.prop2)
+ except (TypeError, ValueError):
+ signal.value2 = signal.prop2
else:
- # Ask the indicator for the last result.
+ # Ask the indicator for the last result
if source_2 in indicators.indicators:
- signal.value2 = indicators.indicators[source_2].properties[signal.prop2]
+ signal.value2 = indicators.indicators[source_2].properties.get(signal.prop2)
else:
- print('Could not calculate signal source2 indicator not found.')
+ logger.debug(f'Could not calculate signal: source2 indicator "{source_2}" not found.')
return False
- # Compare the retrieved values.
- state_change = signal.compare()
- return state_change
+
+ # Compare the retrieved values
+ try:
+ state_change = signal.compare()
+ return state_change
+ except ValueError as e:
+ logger.debug(f"Signal comparison error: {e}")
+ return False
+
+ return False
diff --git a/src/static/Alerts.js b/src/static/Alerts.js
index a51080e..27e50df 100644
--- a/src/static/Alerts.js
+++ b/src/static/Alerts.js
@@ -1,76 +1,214 @@
-class Alert{
- constructor(alert_type, source, state) {
- // The type of alert.
- this.type = alert_type;
- // The source of the alert.
+/**
+ * Alert - Individual alert object
+ */
+class Alert {
+ constructor(alertType, source, state, message = null) {
+ this.type = alertType;
this.source = source;
- // Other info in the alert.
this.state = state;
- // The alert messages.
- if (alert_type=='signal'){
- this.msg = 'Signal state change: ' + this.source + ' = ' + this.state;
- }
- if (alert_type=='strategy'){
- this.msg = 'Strategy alert: ' + this.source + ' = ' + this.state;
+ this.timestamp = new Date();
+
+ // Generate message based on type
+ if (message) {
+ this.msg = message;
+ } else if (alertType === 'signal') {
+ this.msg = `Signal "${source}" changed to ${state ? 'TRUE' : 'FALSE'}`;
+ } else if (alertType === 'strategy') {
+ this.msg = `Strategy: ${source} - ${state}`;
+ } else if (alertType === 'trade') {
+ this.msg = `Trade: ${source} - ${state}`;
+ } else if (alertType === 'error') {
+ this.msg = `Error: ${source} - ${state}`;
+ } else if (alertType === 'notification') {
+ this.msg = message || state;
+ } else {
+ this.msg = `${alertType}: ${source} = ${state}`;
}
}
- alert_source(){
- return this.source;
+
+ getTimeString() {
+ return this.timestamp.toLocaleTimeString();
}
- alert_type(){
- return this.type;
+
+ getTypeIcon() {
+ switch (this.type) {
+ case 'signal': return '🔔';
+ case 'strategy': return '📊';
+ case 'trade': return '💰';
+ case 'error': return '⚠️';
+ case 'notification': return '📢';
+ default: return '•';
+ }
}
- alert_state(){
- return this.state;
- }
- alert_msg(){
- return this.msg;
+
+ getTypeClass() {
+ return `alert-${this.type}`;
}
}
+/**
+ * Alerts - Manages alert collection and display
+ */
class Alerts {
- constructor(target_id) {
- // The list of alert messages.
+ constructor(targetId) {
this.alerts = [];
- // The html element id that displays the alert messages.
- this.target_id = target_id;
- // The html element that displays the alert messages.
+ this.targetId = targetId;
this.target = null;
+ this.maxAlerts = 50; // Keep last 50 alerts
+ this.comms = null;
}
- publish_alerts(alert_type, data){
- if (alert_type == 'signal_changes'){
- // If the alert_type is signal changes then data will
- // contain a list of objects with format: { name: str, state: bool }
- console.log('publishing alerts')
- for(let sig in data){
- console.log('publishing single alert');
- this.alerts.push( new Alert('signal', sig, data[sig]) );
+
+ /**
+ * Initialize alerts system with communications
+ * @param {Object} comms - The communications instance
+ */
+ initialize(comms) {
+ this.comms = comms;
+
+ if (this.comms) {
+ // Register for updates (contains s_updates for signal changes)
+ this.comms.on('updates', this.handleUpdates.bind(this));
+ // Register for direct alerts
+ this.comms.on('alert', this.handleAlert.bind(this));
+ // Register for strategy events
+ this.comms.on('strategy_events', this.handleStrategyEvents.bind(this));
+
+ console.log('Alerts system initialized');
+ }
+ }
+
+ /**
+ * Handle updates from server (includes signal state changes)
+ */
+ handleUpdates(data) {
+ // Handle signal state changes
+ if (data.s_updates && Object.keys(data.s_updates).length > 0) {
+ this.publishAlerts('signal_changes', data.s_updates);
+ }
+
+ // Handle strategy events that should trigger alerts
+ if (data.stg_updts && Array.isArray(data.stg_updts)) {
+ for (const event of data.stg_updts) {
+ if (event.type === 'error') {
+ this.addAlert(new Alert('error', event.strategy_name || 'Strategy', event.message));
+ } else if (event.type === 'trade_executed') {
+ this.addAlert(new Alert('trade', event.strategy_name || 'Trade',
+ `${event.side} ${event.amount} @ ${event.price}`));
+ } else if (event.type === 'strategy_exited') {
+ this.addAlert(new Alert('strategy', event.strategy_name || 'Strategy', 'Exited'));
+ }
}
- this.update_html();
-
- }
- if (alert_type == 'strategy'){
- // If the alert_type is strategy then data will
- // contain a list of objects with format: { name: str, state: bool }
- console.log('publishing strategy alerts')
- this.alerts.push( new Alert('strategy', 'source', data) );
- this.update_html();
-
}
}
- update_html(){
- let alerts ='';
- for (let index in this.alerts){
- let alert = this.alerts[index].alert_msg();
- alerts += '' + alert + '
';
- }
- this.target.innerHTML = alerts;
+ /**
+ * Handle direct alert from server
+ */
+ handleAlert(data) {
+ const alert = new Alert(
+ data.type || 'notification',
+ data.source || 'System',
+ data.state || data.message,
+ data.message
+ );
+ this.addAlert(alert);
}
- set_target(){
- // This is called after the html document has been parsed.
- this.target = document.getElementById(this.target_id);
+ /**
+ * Handle strategy events
+ */
+ handleStrategyEvents(data) {
+ if (!data || !data.events) return;
+
+ for (const event of data.events) {
+ if (event.type === 'trade_executed') {
+ this.addAlert(new Alert('trade', data.strategy_name || 'Strategy',
+ `${event.side} ${event.amount} @ ${event.price}`));
+ } else if (event.type === 'error') {
+ this.addAlert(new Alert('error', data.strategy_name || 'Strategy', event.message));
+ } else if (event.type === 'notification') {
+ this.addAlert(new Alert('notification', data.strategy_name || 'Strategy', event.message));
+ }
+ }
+ }
+
+ /**
+ * Publish alerts based on type and data
+ */
+ publishAlerts(alertType, data) {
+ if (alertType === 'signal_changes') {
+ // data is { signalName: state, ... }
+ for (const sig in data) {
+ this.addAlert(new Alert('signal', sig, data[sig]));
+ }
+ } else if (alertType === 'strategy') {
+ this.addAlert(new Alert('strategy', data.source || 'Strategy', data.message || data));
+ } else if (alertType === 'trade') {
+ this.addAlert(new Alert('trade', data.source || 'Trade', data.message || data));
+ }
+ }
+
+ /**
+ * Add a single alert
+ */
+ addAlert(alert) {
+ this.alerts.unshift(alert); // Add to front (newest first)
+
+ // Trim to max alerts
+ if (this.alerts.length > this.maxAlerts) {
+ this.alerts = this.alerts.slice(0, this.maxAlerts);
+ }
+
+ this.updateHtml();
+ console.log('Alert added:', alert.msg);
+ }
+
+ /**
+ * Clear all alerts
+ */
+ clearAlerts() {
+ this.alerts = [];
+ this.updateHtml();
+ }
+
+ /**
+ * Update the HTML display
+ */
+ updateHtml() {
+ if (!this.target) return;
+
+ if (this.alerts.length === 0) {
+ this.target.innerHTML = '
No Alerts
+