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
'; + return; + } + + let html = ''; + for (const alert of this.alerts) { + html += ` +
+ ${alert.getTypeIcon()} + ${alert.getTimeString()} + ${alert.msg} +
+ `; + } + this.target.innerHTML = html; + } + + /** + * Set the target DOM element (called after DOM is ready) + */ + set_target() { + this.target = document.getElementById(this.targetId); + if (this.target) { + this.updateHtml(); + } + } + + /** + * Programmatically add an alert (for use from other JS modules) + */ + notify(type, source, message) { + this.addAlert(new Alert(type, source, message, message)); } } - diff --git a/src/static/general.js b/src/static/general.js index 755d5ed..b676014 100644 --- a/src/static/general.js +++ b/src/static/general.js @@ -11,6 +11,7 @@ class User_Interface { this.indicators = new Indicators(this.data.comms); this.signals = new Signals(this); this.backtesting = new Backtesting(this); + this.statistics = new Statistics(this.data.comms); // Register a callback function for when indicator updates are received from the data object this.data.registerCallback_i_updates(this.indicators.update); @@ -53,8 +54,9 @@ class User_Interface { }; this.indicators.addToCharts(this.charts, ind_init_data); - this.signals.request_signals(); + this.signals.initialize('signal_list', 'new_sig_form'); this.alerts.set_target(); + this.alerts.initialize(this.data.comms); this.controls.init_TP_selector(); this.trade.initialize(); this.exchanges.initialize(); diff --git a/src/static/signals.js b/src/static/signals.js index a156cfc..e12313f 100644 --- a/src/static/signals.js +++ b/src/static/signals.js @@ -1,233 +1,873 @@ +/** + * SigUIManager - Handles DOM updates and signal card rendering + */ +class SigUIManager { + constructor() { + this.targetEl = null; + this.formElement = null; + this.onDeleteSignal = null; + } + + /** + * Initializes the UI elements with provided IDs. + * @param {string} targetId - The ID of the HTML element where signals will be displayed. + * @param {string} formElId - The ID of the HTML element for the signal creation form. + */ + initUI(targetId, formElId) { + this.targetEl = document.getElementById(targetId); + if (!this.targetEl) { + console.warn(`Element for displaying signals "${targetId}" not found.`); + } + + this.formElement = document.getElementById(formElId); + if (!this.formElement) { + console.warn(`Signals form element "${formElId}" not found.`); + } + } + + /** + * Displays the form for creating or editing a signal. + * @param {string} action - The action to perform ('new' or 'edit'). + * @param {object|null} signalData - The data of the signal to edit (only applicable for 'edit' action). + */ + displayForm(action, signalData = null) { + if (!this.formElement) { + console.error("Form element not initialized."); + return; + } + + const headerTitle = this.formElement.querySelector("h1"); + const submitCreateBtn = this.formElement.querySelector("#submit-create-signal"); + const submitEditBtn = this.formElement.querySelector("#submit-edit-signal"); + const nameBox = this.formElement.querySelector('#signal_name'); + const publicCheckbox = this.formElement.querySelector('#signal_public_checkbox'); + const tblKeyInput = this.formElement.querySelector('#signal_tbl_key'); + + // Reset form to panel 1 + const panel1 = this.formElement.querySelector('#panel_1'); + const panel2 = this.formElement.querySelector('#panel_2'); + const panel3 = this.formElement.querySelector('#panel_3'); + if (panel1) panel1.style.display = 'grid'; + if (panel2) panel2.style.display = 'none'; + if (panel3) panel3.style.display = 'none'; + + if (action === 'new') { + if (headerTitle) headerTitle.textContent = "Add New Signal"; + if (submitCreateBtn) submitCreateBtn.style.display = "inline-block"; + if (submitEditBtn) submitEditBtn.style.display = "none"; + if (nameBox) nameBox.value = ''; + if (publicCheckbox) publicCheckbox.checked = false; + if (tblKeyInput) tblKeyInput.value = ''; + } else if (action === 'edit' && signalData) { + if (headerTitle) headerTitle.textContent = "Edit Signal"; + if (submitCreateBtn) submitCreateBtn.style.display = "none"; + if (submitEditBtn) submitEditBtn.style.display = "inline-block"; + if (nameBox) nameBox.value = signalData.name || ''; + if (publicCheckbox) publicCheckbox.checked = !!signalData.public; + if (tblKeyInput) tblKeyInput.value = signalData.tbl_key || ''; + + // Pre-fill source fields + const sigSource = this.formElement.querySelector('#sig_source'); + const sigProp = this.formElement.querySelector('#sig_prop'); + const sig2Source = this.formElement.querySelector('#sig2_source'); + const sig2Prop = this.formElement.querySelector('#sig2_prop'); + const sigType = this.formElement.querySelector('#select_s_type'); + const valueInput = this.formElement.querySelector('#value'); + + if (sigSource && signalData.source1) sigSource.value = signalData.source1; + if (sigProp && signalData.prop1) { + // Fill prop options first, then set value + if (UI.signals && signalData.source1) { + UI.signals.fill_prop('sig_prop', signalData.source1); + } + setTimeout(() => { if (sigProp) sigProp.value = signalData.prop1; }, 50); + } + + // Handle source2 - could be 'value' for fixed value type + if (signalData.source2 === 'value') { + if (sigType) sigType.value = 'Value'; + if (valueInput) valueInput.value = signalData.prop2 || ''; + } else { + if (sigType) sigType.value = 'Comparison'; + if (sig2Source && signalData.source2) sig2Source.value = signalData.source2; + if (sig2Prop && signalData.prop2) { + if (UI.signals && signalData.source2) { + UI.signals.fill_prop('sig2_prop', signalData.source2); + } + setTimeout(() => { if (sig2Prop) sig2Prop.value = signalData.prop2; }, 50); + } + } + + // Set operator + const operatorRadios = this.formElement.querySelectorAll('input[name="Operator"]'); + operatorRadios.forEach(radio => { + radio.checked = radio.value === signalData.operator; + }); + + // Set range if applicable + if (signalData.operator === '+/-' && signalData.range) { + const rangeVal = this.formElement.querySelector('#rangeVal'); + const rangeSlider = this.formElement.querySelector('#rangeSlider'); + if (rangeVal) rangeVal.value = signalData.range; + if (rangeSlider) rangeSlider.value = signalData.range; + } + } + + this.formElement.style.display = "grid"; + } + + /** + * Hides the signal form. + */ + hideForm() { + if (this.formElement) { + this.formElement.style.display = 'none'; + } + } + + /** + * Updates the HTML representation of the signals as cards. + * @param {Object[]} signals - The list of signals to display. + */ + updateSignalsHtml(signals) { + if (!this.targetEl) { + console.error("Target element for displaying signals is not set."); + return; + } + + // Clear existing content + while (this.targetEl.firstChild) { + this.targetEl.removeChild(this.targetEl.firstChild); + } + + // Create and append new elements for all signals + for (const signal of signals) { + try { + const signalCard = this._createSignalCard(signal); + this.targetEl.appendChild(signalCard); + } catch (error) { + console.error(`Error processing signal:`, error, signal); + } + } + } + + /** + * Creates a signal card HTML element. + * @param {Object} signal - The signal data. + * @returns {HTMLElement} - The card element. + */ + _createSignalCard(signal) { + const signalItem = document.createElement('div'); + signalItem.className = 'signal-item'; + signalItem.setAttribute('data-signal-id', signal.tbl_key || signal.name); + + // Add state-based styling + const isTrue = signal.state === true || signal.state === 'true' || signal.state === 1; + if (isTrue) { + signalItem.classList.add('signal-true'); + } else { + signalItem.classList.add('signal-false'); + } + + // Delete button + const deleteButton = document.createElement('button'); + deleteButton.className = 'delete-button'; + deleteButton.innerHTML = '✘'; + deleteButton.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.onDeleteSignal) { + this.onDeleteSignal(signal.tbl_key || signal.name); + } + }); + signalItem.appendChild(deleteButton); + + // Signal icon container + const signalIcon = document.createElement('div'); + signalIcon.className = 'signal-icon'; + signalIcon.addEventListener('click', () => { + // Open edit form when clicking on signal + this.displayForm('edit', signal); + }); + + // Signal name + const signalName = document.createElement('div'); + signalName.className = 'signal-name'; + signalName.textContent = signal.name || 'Unnamed Signal'; + signalIcon.appendChild(signalName); + + // State indicator + const stateIndicator = document.createElement('div'); + stateIndicator.className = 'signal-state'; + stateIndicator.id = `${signal.name}_state`; + stateIndicator.textContent = isTrue ? 'TRUE' : 'FALSE'; + signalIcon.appendChild(stateIndicator); + + signalItem.appendChild(signalIcon); + + // Hover details panel + const signalHover = document.createElement('div'); + signalHover.className = 'signal-hover'; + + // Build hover content + let hoverHtml = `${signal.name || 'Unnamed Signal'}`; + hoverHtml += `
`; + hoverHtml += `Source 1: ${signal.source1} (${signal.prop1})`; + hoverHtml += `Value: ${signal.value1 ?? signal.last_value1 ?? 'N/A'}`; + hoverHtml += `Operator: ${signal.operator}${signal.operator === '+/-' ? ` (range: ${signal.range})` : ''}`; + + if (signal.source2 === 'value') { + hoverHtml += `Compare to: ${signal.prop2}`; + } else { + hoverHtml += `Source 2: ${signal.source2} (${signal.prop2})`; + hoverHtml += `Value: ${signal.value2 ?? signal.last_value2 ?? 'N/A'}`; + } + + // State display + const stateClass = isTrue ? 'state-true' : 'state-false'; + hoverHtml += `State: ${isTrue ? 'TRUE' : 'FALSE'}`; + + if (signal.public) { + hoverHtml += `Public`; + } + + hoverHtml += `
`; + + signalHover.innerHTML = hoverHtml; + signalItem.appendChild(signalHover); + + return signalItem; + } + + /** + * Updates a single signal's state display. + * @param {string} signalName - The name of the signal. + * @param {boolean} state - The new state. + */ + updateSignalState(signalName, state) { + const stateEl = document.getElementById(`${signalName}_state`); + if (stateEl) { + const isTrue = state === true || state === 'true' || state === 1; + stateEl.textContent = isTrue ? 'TRUE' : 'FALSE'; + + // Update parent card styling + const card = stateEl.closest('.signal-item'); + if (card) { + card.classList.remove('signal-true', 'signal-false'); + card.classList.add(isTrue ? 'signal-true' : 'signal-false'); + } + } + } + + /** + * Sets the callback function for deleting a signal. + * @param {Function} callback - The callback function. + */ + registerDeleteSignalCallback(callback) { + this.onDeleteSignal = callback; + } +} + + +/** + * SigDataManager - Manages in-memory signal data store + */ +class SigDataManager { + constructor() { + this.signals = []; + } + + /** + * Fetches the saved signals from the server. + * @param {Object} comms - The communications instance. + * @param {Object} data - An object containing user data. + */ + fetchSavedSignals(comms, data) { + if (comms) { + try { + const requestData = { + request: 'signals', + user_name: data?.user_name + }; + comms.sendToApp('request', requestData); + } catch (error) { + console.error("Error fetching saved signals:", error.message); + } + } else { + throw new Error('Communications instance not available.'); + } + } + + /** + * Adds a new signal to the local store. + * @param {Object} data - The signal data. + */ + addNewSignal(data) { + const signalData = data.signal || data; + console.log("Adding new signal:", signalData); + if (!signalData.name) { + console.error("Signal data missing 'name' field:", signalData); + return; + } + + // Check for duplicates + const exists = this.signals.find(s => s.tbl_key === signalData.tbl_key || s.name === signalData.name); + if (!exists) { + this.signals.push(signalData); + } + } + + /** + * Retrieves a signal by its tbl_key. + * @param {string} tbl_key - The tbl_key of the signal. + * @returns {Object|null} - The signal object or null. + */ + getSignalById(tbl_key) { + return this.signals.find(signal => signal.tbl_key === tbl_key) || null; + } + + /** + * Retrieves a signal by its name. + * @param {string} name - The name of the signal. + * @returns {Object|null} - The signal object or null. + */ + getSignalByName(name) { + return this.signals.find(signal => signal.name === name) || null; + } + + /** + * Updates signal data. + * @param {Object} data - The updated signal data. + */ + updateSignalData(data) { + const signalData = data.signal || data; + const signalKey = signalData.tbl_key || signalData.name; + if (!signalKey) return; + + const index = this.signals.findIndex( + signal => signal.tbl_key === signalKey || signal.name === signalKey + ); + + if (index !== -1) { + this.signals[index] = { ...this.signals[index], ...signalData }; + } else { + this.signals.push(signalData); + } + } + + /** + * Removes a signal from the store. + * @param {string} identifier - The tbl_key or name of the signal. + */ + removeSignal(identifier) { + console.log(`Removing signal: ${identifier}`); + this.signals = this.signals.filter( + sig => sig.tbl_key !== identifier && sig.name !== identifier + ); + } + + /** + * Updates signal states from server updates. + * @param {Object} stateUpdates - Map of signal names to states. + */ + applyStateUpdates(stateUpdates) { + for (const name in stateUpdates) { + const signal = this.getSignalByName(name); + if (signal) { + signal.state = stateUpdates[name]; + } + } + } + + /** + * Returns all signals. + * @returns {Object[]} - The list of signals. + */ + getAllSignals() { + return this.signals; + } + + /** + * Sets all signals (used when loading from server). + * @param {Object[]} signals - The list of signals. + */ + setSignals(signals) { + this.signals = Array.isArray(signals) ? signals : []; + } +} + + +/** + * Signals - Main coordinator class that manages SigUIManager, SigDataManager, and SocketIO communication + */ class Signals { constructor(ui) { this.ui = ui; - this.comms = ui.data.comms; - this.indicators = ui.indicators; - this.data = ui.data; - this.signals = []; + this.comms = ui?.data?.comms; + this.indicatorData = ui?.data?.indicators; + this.data = ui?.data; - // Register handlers with Comms for specific message types - this.comms.on('signal_created', this.handleSignalCreated.bind(this)); - this.comms.on('signal_updated', this.handleSignalUpdated.bind(this)); - this.comms.on('signal_deleted', this.handleSignalDeleted.bind(this)); - this.comms.on('updates', this.handleUpdates.bind(this)); + this.dataManager = new SigDataManager(); + this.uiManager = new SigUIManager(); + + // Set up delete callback + this.uiManager.registerDeleteSignalCallback(this.deleteSignal.bind(this)); + + // Bind methods + this.submitSignal = this.submitSignal.bind(this); + + this._initialized = false; } + /** + * Initializes the Signals instance. + * @param {string} targetId - The ID of the signals container element. + * @param {string} formElId - The ID of the signal form element. + */ + initialize(targetId, formElId) { + try { + this.uiManager.initUI(targetId, formElId); + + if (!this.comms) { + console.error("Communications instance not available."); + return; + } + + // Register handlers with Comms + this.comms.on('signals', this.handleSignalsResponse.bind(this)); + this.comms.on('signal_created', this.handleSignalCreated.bind(this)); + this.comms.on('signal_updated', this.handleSignalUpdated.bind(this)); + this.comms.on('signal_deleted', this.handleSignalDeleted.bind(this)); + this.comms.on('signal_error', this.handleSignalError.bind(this)); + this.comms.on('updates', this.handleUpdates.bind(this)); + + // Fetch saved signals + this.dataManager.fetchSavedSignals(this.comms, this.data); + + this._initialized = true; + } catch (error) { + console.error("Error initializing Signals:", error); + } + } + + /** + * Handle initial signals list response from server. + * @param {Array} data - List of signal objects. + */ + handleSignalsResponse(data) { + console.log("Received signals list:", data); + if (Array.isArray(data)) { + this.dataManager.setSignals(data); + this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); + } + } + + /** + * Handle new signal created event. + * @param {Object} data - Server response with signal data. + */ handleSignalCreated(data) { - console.log("New signal created:", data); - // Logic to update signals UI - const list_of_one = [data]; - this.set_data(list_of_one); + console.log("Signal created:", data); + if (data.success) { + this.dataManager.addNewSignal(data); + this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); + } else { + alert(`Failed to create signal: ${data.message}`); + } } + /** + * Handle signal updated event. + * @param {Object} data - Server response with updated signal data. + */ handleSignalUpdated(data) { console.log("Signal updated:", data); - // Logic to update signals UI - this.update_signal_states(data); + if (data.success) { + this.dataManager.updateSignalData(data); + this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); + } else { + alert(`Failed to update signal: ${data.message}`); + } } + /** + * Handle signal deleted event. + * @param {Object} data - Server response with deleted signal info. + */ handleSignalDeleted(data) { console.log("Signal deleted:", data); - // Logic to remove signal from UI - this.delete_signal(data.name); + const identifier = data.tbl_key || data.name; + if (identifier) { + this.dataManager.removeSignal(identifier); + this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); + } } + /** + * Handle signal error. + * @param {Object} data - Error data. + */ + handleSignalError(data) { + console.error("Signal error:", data.message); + alert(`Signal error: ${data.message}`); + } + + /** + * Handle updates (including signal state changes). + * @param {Object} data - Update data from server. + */ handleUpdates(data) { const { s_updates } = data; if (s_updates) { - this.update_signal_states(s_updates); + this.dataManager.applyStateUpdates(s_updates); + // Update UI for state changes + for (const name in s_updates) { + this.uiManager.updateSignalState(name, s_updates[name]); + } } } - // Call to display the 'Create new signal' dialog. - open_signal_Form() { document.getElementById("new_sig_form").style.display = "grid"; } - // Call to hide the 'Create new signal' dialog. - close_signal_Form() { document.getElementById("new_sig_form").style.display = "none"; } + // ================ Form Methods ================ + /** + * Opens the signal creation form. + */ + open_signal_Form() { + this.uiManager.displayForm('new'); + } + + /** + * Closes the signal form. + */ + close_signal_Form() { + this.uiManager.hideForm(); + } + + /** + * Requests signals from server. + */ request_signals() { - // Requests a list of all the signals from the server. if (this.comms) { - this.comms.sendToApp('request', { request: 'signals', user_name: this.data.user_name }); - } else { - console.error('Comms instance not available.'); + this.comms.sendToApp('request', { request: 'signals', user_name: this.data?.user_name }); } } - delete_signal(signal_name) { - // Requests that the server remove a specific signal. - this.comms.sendToApp('delete_signal', { name: signal_name }); - // Get the signal element from the UI - let child = document.getElementById(signal_name + '_item'); - // Ask the parent of the signal element to remove its child(signal) from the document. - if (child && child.parentNode) { - child.parentNode.removeChild(child); + /** + * Deletes a signal by tbl_key or name. + * @param {string} identifier - The tbl_key or name of the signal. + */ + deleteSignal(identifier) { + if (!this.comms) { + console.error("Comms instance not available."); + return; } + + const signal = this.dataManager.getSignalById(identifier) || + this.dataManager.getSignalByName(identifier); + + const deleteData = signal + ? { tbl_key: signal.tbl_key, name: signal.name } + : { name: identifier }; + + this.comms.sendToApp('delete_signal', deleteData); } - i_update(updates) { - for (let signal in this.signals) { - let s1 = this.signals[signal].source1; - if (s1 in updates) { - let p1 = this.signals[signal].prop1; - let value1 = updates[s1].data[0][p1]; - this.signals[signal].value1 = value1.toFixed(2); - } else { - console.log('!no update for: s1 maybe the indicator is disabled'); - break; - } - if (this.signals[signal].source2 != 'value') { - let s2 = this.signals[signal].source2; - if (s2 in updates) { - let p2 = this.signals[signal].prop2; - let value2 = updates[s2].data[0][p2]; - this.signals[signal].value2 = value2.toFixed(2); - } else { - console.log('!no update for: s2 maybe the indicator is disabled'); - break; - } - } - document.getElementById(this.signals[signal].name + '_value1').innerHTML = this.signals[signal].value1; - document.getElementById(this.signals[signal].name + '_value2').innerHTML = this.signals[signal].value2; + /** + * Submits a new or edited signal. + * @param {string} action - 'new' or 'edit'. + */ + submitSignal(action) { + const formElement = this.uiManager.formElement; + if (!formElement) { + console.error("Form element not available."); + return; } + + const name = formElement.querySelector('#signal_name')?.value?.trim(); + const source1 = formElement.querySelector('#sig_source')?.value; + const prop1 = formElement.querySelector('#sig_prop')?.value; + const source2 = formElement.querySelector('#sig2_source')?.value; + const prop2 = formElement.querySelector('#sig2_prop')?.value; + const operator = formElement.querySelector('input[name="Operator"]:checked')?.value; + const range = formElement.querySelector('#rangeVal')?.value; + const sigType = formElement.querySelector('#select_s_type')?.value; + const value = formElement.querySelector('#value')?.value; + const publicCheckbox = formElement.querySelector('#signal_public_checkbox'); + const tblKey = formElement.querySelector('#signal_tbl_key')?.value; + + if (!name) { + alert("Please provide a name for the signal."); + return; + } + if (!prop1) { + alert("Please select a property for the signal source."); + return; + } + + // Build signal data + let actualSource2 = source2; + let actualProp2 = prop2; + + if (sigType !== 'Comparison') { + actualSource2 = 'value'; + actualProp2 = value; + } + + const signalData = { + name, + source1, + prop1, + operator, + source2: actualSource2, + prop2: actualProp2, + state: false, + value1: null, + value2: null, + public: publicCheckbox?.checked ? 1 : 0, + user_name: this.data?.user_name + }; + + if (operator === '+/-') { + signalData.range = parseFloat(range) || 0; + } + + if (action === 'edit' && tblKey) { + signalData.tbl_key = tblKey; + } + + const messageType = action === 'new' ? 'new_signal' : 'edit_signal'; + this.comms.sendToApp(messageType, signalData); + + this.close_signal_Form(); } - update_signal_states(s_updates) { - for (let name in s_updates) { - let id = name + '_state'; - let span = document.getElementById(id); - if (span) { - span.innerHTML = s_updates[name]; - } - console.log('state change!'); - console.log(name); - } + /** + * Submits a new signal (legacy method name). + */ + submitNewSignal() { + this.submitSignal('new'); } - set_data(signals) { - var ul = document.getElementById("signal_list"); - for (let sig in signals) { - let obj = typeof(signals[sig]) == 'string' ? JSON.parse(signals[sig]) : signals[sig]; - this.signals.push(obj); - let click_func = `this.delete_signal('${obj.name}')`; - let delete_btn = ``; - - let signal_name = ` ${obj.name}: `; - let signal_state = `${obj.state}
`; - let signal_source1 = `${obj.source1}(${obj.prop1}) `; - let signal_val1 = `${obj.value1}`; - let operator = ` ${obj.operator} `; - let signal_source2 = `${obj.source2}(${obj.prop2}) `; - let signal_val2 = `${obj.value2}`; - - let html = delete_btn + signal_name + signal_state + signal_source1 + signal_val1 + operator + signal_source2 + signal_val2; - - let li = document.createElement("li"); - li.id = obj.name + '_item'; - li.innerHTML = html; - ul.appendChild(li); - } - } + // ================ Helper Methods ================ + /** + * Fills property dropdown based on indicator type. + * @param {string} target_id - The ID of the select element. + * @param {string} indctr - The indicator name. + */ fill_prop(target_id, indctr) { - var target = document.getElementById(target_id); - var properties = this.indicators[indctr]; + const target = document.getElementById(target_id); + const indicatorConfig = this.indicatorData ? this.indicatorData[indctr] : null; - removeOptions(target); - for(let prop in properties) { - if (prop == 'type' || prop == 'visible' || prop == 'period' || prop.substring(0, 5) == 'color') { - continue; - } - var opt = document.createElement("option"); - opt.innerHTML = prop; + if (!target) return; + + // Clear existing options + while (target.options.length > 0) { + target.remove(0); + } + + if (!indicatorConfig) { + console.warn(`Indicator "${indctr}" not found in indicator data`); + return; + } + + // Get the indicator outputs based on type + const outputMap = { + 'SMA': ['value'], + 'EMA': ['value'], + 'LREG': ['value'], + 'RSI': ['value'], + 'ATR': ['value'], + 'Volume': ['value'], + 'MACD': ['macd', 'signal', 'hist'], + 'BOLBands': ['upper', 'middle', 'lower'] + }; + + const indicatorType = indicatorConfig.type; + const outputs = outputMap[indicatorType] || ['value']; + + for (const output of outputs) { + const opt = document.createElement("option"); + opt.value = output; + opt.textContent = output; target.appendChild(opt); } - function removeOptions(selectElement) { - var i, L = selectElement.options.length - 1; - for(i = L; i >= 0; i--) { - selectElement.remove(i); + } + + /** + * Switches between form panels. + * @param {string} p1 - Panel to hide. + * @param {string} p2 - Panel to show. + */ + switch_panel(p1, p2) { + const panel1 = document.getElementById(p1); + const panel2 = document.getElementById(p2); + if (panel1) panel1.style.display = 'none'; + if (panel2) panel2.style.display = 'grid'; + } + + /** + * Conditionally hides an element. + * @param {*} firstValue - First value to compare. + * @param {*} scndValue - Second value to compare. + * @param {string} id - Element ID to show/hide. + */ + hideIfTrue(firstValue, scndValue, id) { + const el = document.getElementById(id); + if (el) { + el.style.display = firstValue === scndValue ? 'none' : 'block'; + } + } + + /** + * Gets the current value of an indicator property from the DOM. + * @param {string} source - The indicator source name. + * @param {string} prop - The property name. + * @returns {string} - The value. + */ + _getIndicatorValue(source, prop) { + let element = document.getElementById(source + '_' + prop) + || document.getElementById(source + '_value'); + + if (!element) { + console.warn(`Could not find indicator value element for ${source}_${prop}`); + return '0'; + } + + let rawValue = element.value || element.textContent || '0'; + + // Parse multi-value format if needed + if (rawValue.includes(':') && rawValue.includes(',')) { + const parts = rawValue.split(',').map(p => p.trim()); + for (const part of parts) { + const [key, val] = part.split(':').map(s => s.trim()); + if (key === prop) { + return val; + } } } + + return rawValue; } - switch_panel(p1, p2) { - document.getElementById(p1).style.display = 'none'; - document.getElementById(p2).style.display = 'grid'; - } - - hideIfTrue(firstValue, scndValue, id) { - if (firstValue == scndValue) { - document.getElementById(id).style.display = 'none'; - } else { - document.getElementById(id).style.display = 'block'; - } - } - + /** + * Handles panel 1 "Next" button click. + * @param {number} n - Panel number. + */ ns_next(n) { - if (n == 1) { - let sigName = document.getElementById('signal_name').value; - let sigSource = document.getElementById('sig_source').value; - let sigProp = document.getElementById('sig_prop').value; - if (sigName == '') { alert('Please give the signal a name.'); return; } - document.getElementById('sig_display').innerHTML = `${sigName}: {${sigSource}:${sigProp}}`; - let indctrVal = document.getElementById(sigSource + '_' + sigProp).value; - document.getElementById('value').value = indctrVal; + if (n === 1) { + const sigName = document.getElementById('signal_name')?.value; + const sigSource = document.getElementById('sig_source')?.value; + const sigProp = document.getElementById('sig_prop')?.value; + + if (!sigName) { + alert('Please give the signal a name.'); + return; + } + if (!sigProp) { + alert('Please select a property.'); + return; + } + + const display = document.getElementById('sig_display'); + if (display) { + display.innerHTML = `${sigName}: {${sigSource}:${sigProp}}`; + } + + // Get current indicator value + const indctrVal = this._getIndicatorValue(sigSource, sigProp); + const valueInput = document.getElementById('value'); + if (valueInput) { + valueInput.value = indctrVal || '0'; + } + this.switch_panel('panel_1', 'panel_2'); } - if (n == 2) { - let sigName = document.getElementById('signal_name').value; - let sigSource = document.getElementById('sig_source').value; - let sigProp = document.getElementById('sig_prop').value; - let sig2Source = document.getElementById('sig2_source').value; - let sig2Prop = document.getElementById('sig2_prop').value; - let operator = document.querySelector('input[name="Operator"]:checked').value; - let range = document.getElementById('rangeVal').value; - let sigType = document.getElementById('select_s_type').value; - let value = document.getElementById('value').value; - let sig1 = `${sigSource} : ${sigProp}`; - let sig2 = sigType == 'Comparison' ? `${sig2Source} : ${sig2Prop}` : value; - let operatorStr = operator == '+/-' ? `${operator} ${range}` : operator; + if (n === 2) { + const sigName = document.getElementById('signal_name')?.value; + const sigSource = document.getElementById('sig_source')?.value; + const sigProp = document.getElementById('sig_prop')?.value; + const sig2Source = document.getElementById('sig2_source')?.value; + const sig2Prop = document.getElementById('sig2_prop')?.value; + const operator = document.querySelector('input[name="Operator"]:checked')?.value; + const range = document.getElementById('rangeVal')?.value; + const sigType = document.getElementById('select_s_type')?.value; + const value = document.getElementById('value')?.value; - let sigDisplayStr = `(${sigName}) (${sig1}) (${operatorStr}) (${sig2})`; - let sig1_realtime = document.getElementById(sigSource + '_' + sigProp).value; - let sig2_realtime = sigType == 'Comparison' ? document.getElementById(sig2Source + '_' + sig2Prop).value : sig2; + const sig1 = `${sigSource} : ${sigProp}`; + const sig2 = sigType === 'Comparison' ? `${sig2Source} : ${sig2Prop}` : value; + const operatorStr = operator === '+/-' ? `${operator} ${range}` : operator; - document.getElementById('sig_display2').innerHTML = sigDisplayStr; - document.getElementById('sig_realtime').innerHTML = `(${sigProp} : ${sig1_realtime}) (${operatorStr}) (${sig2_realtime})`; + const sigDisplayStr = `(${sigName}) (${sig1}) (${operatorStr}) (${sig2})`; + const sig1_realtime = this._getIndicatorValue(sigSource, sigProp); + const sig2_realtime = sigType === 'Comparison' + ? this._getIndicatorValue(sig2Source, sig2Prop) + : value; + + const display2 = document.getElementById('sig_display2'); + const realtime = document.getElementById('sig_realtime'); + const evalEl = document.getElementById('sig_eval'); + + if (display2) display2.innerHTML = sigDisplayStr; + if (realtime) realtime.innerHTML = `(${sigProp} : ${sig1_realtime}) (${operatorStr}) (${sig2_realtime})`; + + // Evaluate let evalStr; - if (operator == '=') evalStr = (sig1_realtime == sig2_realtime); - if (operator == '>') evalStr = (sig1_realtime > sig2_realtime); - if (operator == '<') evalStr = (sig1_realtime < sig2_realtime); - if (operator == '+/-') evalStr = (Math.abs(sig1_realtime - sig2_realtime) <= range); + if (operator === '==') evalStr = parseFloat(sig1_realtime) === parseFloat(sig2_realtime); + if (operator === '>') evalStr = parseFloat(sig1_realtime) > parseFloat(sig2_realtime); + if (operator === '<') evalStr = parseFloat(sig1_realtime) < parseFloat(sig2_realtime); + if (operator === '+/-') evalStr = Math.abs(parseFloat(sig1_realtime) - parseFloat(sig2_realtime)) <= parseFloat(range); + + if (evalEl) evalEl.innerHTML = evalStr ? 'true' : 'false'; - document.getElementById('sig_eval').innerHTML = evalStr; this.switch_panel('panel_2', 'panel_3'); } } - submitNewSignal() { - let name = document.getElementById('signal_name').value; - let source1 = document.getElementById('sig_source').value; - let prop1 = document.getElementById('sig_prop').value; - let source2 = document.getElementById('sig2_source').value; - let prop2 = document.getElementById('sig2_prop').value; - let operator = document.querySelector('input[name="Operator"]:checked').value; - let range = document.getElementById('rangeVal').value; - let sigType = document.getElementById('select_s_type').value; - let value = document.getElementById('value').value; - - if (sigType != 'Comparison') { - source2 = 'value'; - prop2 = value; + // Legacy methods for backwards compatibility + i_update(updates) { + for (const signal of this.dataManager.getAllSignals()) { + const s1 = signal.source1; + if (s1 in updates) { + const p1 = signal.prop1; + const value1 = updates[s1].data[0][p1]; + signal.value1 = value1.toFixed(2); + } + if (signal.source2 !== 'value') { + const s2 = signal.source2; + if (s2 in updates) { + const p2 = signal.prop2; + const value2 = updates[s2].data[0][p2]; + signal.value2 = value2.toFixed(2); + } + } + const val1El = document.getElementById(signal.name + '_value1'); + const val2El = document.getElementById(signal.name + '_value2'); + if (val1El) val1El.innerHTML = signal.value1; + if (val2El) val2El.innerHTML = signal.value2; } + } - let state = false; - let value1 = null; - let value2 = null; - let data = operator == "+/-" ? {name, source1, prop1, operator, source2, prop2, range, state, value1, value2} : {name, source1, prop1, operator, source2, prop2, state, value1, value2}; + update_signal_states(s_updates) { + for (const name in s_updates) { + this.uiManager.updateSignalState(name, s_updates[name]); + } + } - this.comms.sendToApp("new_signal", data); - this.close_signal_Form(); + set_data(signals) { + // Legacy method - now handled by handleSignalsResponse + if (Array.isArray(signals)) { + for (const sig of signals) { + const obj = typeof sig === 'string' ? JSON.parse(sig) : sig; + this.dataManager.addNewSignal(obj); + } + this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); + } + } + + delete_signal(signal_name) { + // Legacy method - redirect to new method + this.deleteSignal(signal_name); } } diff --git a/src/templates/alerts_hud.html b/src/templates/alerts_hud.html index 94e1bdd..8d1b60b 100644 --- a/src/templates/alerts_hud.html +++ b/src/templates/alerts_hud.html @@ -1,3 +1,111 @@
-

No Alerts

+
+ Alerts + +
+
+
No Alerts
+
+ + diff --git a/src/templates/new_signal_popup.html b/src/templates/new_signal_popup.html index d0abb61..65725fe 100644 --- a/src/templates/new_signal_popup.html +++ b/src/templates/new_signal_popup.html @@ -1,15 +1,18 @@
- -
- -

Adds New Signal

- + + + + +
+ +

Add New Signal

+
- + - + - -
+ +
+ +
+ +
@@ -113,7 +123,10 @@
- + + + +
diff --git a/src/templates/signals_hud.html b/src/templates/signals_hud.html index c96b987..f47180c 100644 --- a/src/templates/signals_hud.html +++ b/src/templates/signals_hud.html @@ -2,5 +2,204 @@

Signals

-
    +
    + +