Refactor Signals system and implement Alerts

Signals System Refactor:
- Migrated from config.yml to DataCache/SQLite storage
- Added user ownership (creator field) and public/private visibility
- Implemented full CRUD operations with tbl_key identifiers
- Auto-creates signals table in database on init
- Card-based UI with 100x100 icons and hover detail panels
- State visualization: green pulsing border (TRUE), red (FALSE)
- Edit support via popup form with Create/Edit buttons

Alerts System Implementation:
- Connected frontend Alerts.js to SocketIO communications
- Alerts triggered by signal state changes (s_updates)
- Support for strategy events, trades, errors, notifications
- Color-coded alerts with icons and timestamps
- Clear button and max 50 alerts limit (ephemeral, not persisted)

Backend changes:
- BrighterTrades.py: Updated signal handlers with user_id
- Added received_edit_signal() and permission checks
- Signal filtering by user ownership

Frontend changes:
- signals.js: Three-manager pattern (SigUIManager, SigDataManager, Signals)
- Alerts.js: Event handlers for updates and strategy_events
- general.js: Initialize signals and alerts with comms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-03-02 04:31:39 -04:00
parent dd8467468c
commit c895a3615d
8 changed files with 1920 additions and 367 deletions

View File

@ -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
# 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(params.get('indicator', '[]'))
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:

View File

@ -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.
if form == 'obj':
return self.signals
# Return a JSON 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':
sigs = self.signals
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
elif form == 'json':
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 get_signal_by_tbl_key(self, tbl_key: str) -> Signal | None:
"""Get a signal by its tbl_key."""
for signal in self.signals:
if signal.tbl_key == tbl_key:
return signal
return None
def delete_signal(self, signal_name):
print(f'removing {signal_name}')
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.name == signal_name:
self.signals.remove(sig)
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
def update_signals(self, candles, indicators):
# TODO: This function is not used. but may be used later if candles need to be specified.
for signal in self.signals:
self.process_signal(signal, candles, indicators)
# 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
def process_all_signals(self, indicators):
"""Loop through all the signals and process
them based on the last indicator results."""
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':
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.
# 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

View File

@ -1,76 +1,214 @@
/**
* Alert - Individual alert object
*/
class Alert {
constructor(alert_type, source, state) {
// The type of alert.
this.type = alert_type;
// The source of the 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;
}
}
alert_source(){
return this.source;
}
alert_type(){
return this.type;
}
alert_state(){
return this.state;
}
alert_msg(){
return this.msg;
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}`;
}
}
getTimeString() {
return this.timestamp.toLocaleTimeString();
}
getTypeIcon() {
switch (this.type) {
case 'signal': return '🔔';
case 'strategy': return '📊';
case 'trade': return '💰';
case 'error': return '⚠️';
case 'notification': return '📢';
default: return '•';
}
}
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]) );
}
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();
/**
* 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');
}
}
update_html(){
let alerts ='';
for (let index in this.alerts){
let alert = this.alerts[index].alert_msg();
alerts += '<span>' + alert + '</span><br>';
}
this.target.innerHTML = alerts;
/**
* 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'));
}
}
}
}
/**
* 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);
}
/**
* 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 = '<div class="no-alerts">No Alerts</div>';
return;
}
let html = '';
for (const alert of this.alerts) {
html += `
<div class="alert-item ${alert.getTypeClass()}">
<span class="alert-icon">${alert.getTypeIcon()}</span>
<span class="alert-time">${alert.getTimeString()}</span>
<span class="alert-message">${alert.msg}</span>
</div>
`;
}
this.target.innerHTML = html;
}
/**
* Set the target DOM element (called after DOM is ready)
*/
set_target() {
// This is called after the html document has been parsed.
this.target = document.getElementById(this.target_id);
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));
}
}

View File

@ -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();

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,111 @@
<div class="content" id="alerts_content">
<p id="alert_list">No Alerts</p>
<div class="alerts-header">
<span>Alerts</span>
<button class="btn-clear-alerts" onclick="UI.alerts.clearAlerts()" title="Clear all alerts">Clear</button>
</div>
<div id="alert_list" class="alerts-list">
<div class="no-alerts">No Alerts</div>
</div>
</div>
<style>
#alerts_content {
padding: 5px;
height: 100%;
display: flex;
flex-direction: column;
}
.alerts-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 10px;
border-bottom: 1px solid #ddd;
margin-bottom: 5px;
}
.alerts-header span {
font-weight: bold;
font-size: 14px;
}
.btn-clear-alerts {
padding: 2px 8px;
font-size: 11px;
border: 1px solid #ccc;
border-radius: 3px;
background: #f5f5f5;
cursor: pointer;
}
.btn-clear-alerts:hover {
background: #e0e0e0;
}
.alerts-list {
flex: 1;
overflow-y: auto;
padding: 5px;
}
.no-alerts {
text-align: center;
color: #888;
padding: 20px;
font-style: italic;
}
.alert-item {
display: flex;
align-items: flex-start;
padding: 6px 8px;
margin-bottom: 4px;
border-radius: 4px;
font-size: 12px;
background: #f8f9fa;
border-left: 3px solid #ccc;
}
.alert-item.alert-signal {
border-left-color: #17a2b8;
background: #e8f4f8;
}
.alert-item.alert-strategy {
border-left-color: #6f42c1;
background: #f3f0f8;
}
.alert-item.alert-trade {
border-left-color: #28a745;
background: #e8f5e9;
}
.alert-item.alert-error {
border-left-color: #dc3545;
background: #fdecea;
}
.alert-item.alert-notification {
border-left-color: #ffc107;
background: #fff8e1;
}
.alert-icon {
margin-right: 6px;
font-size: 14px;
}
.alert-time {
color: #666;
font-size: 10px;
margin-right: 8px;
white-space: nowrap;
}
.alert-message {
flex: 1;
word-break: break-word;
}
</style>

View File

@ -1,15 +1,18 @@
<div class="form-popup" id="new_sig_form">
<form action="/new_signal" class="form-container">
<!-- Panel 1 of 3 (5 rows, 2 columns) -->
<div id="panel_1" class="form_panels" style="grid-template-columns:repeat(2,1fr);grid-template-rows: repeat(5,1fr);">
<!-- Panel title (row 1/5)-->
<h1 style="grid-column: 1 / span 2; grid-row: 1;">Adds New Signal</h1>
<!-- Name Input field (row 2/5)-->
<!-- Hidden field for tbl_key (used when editing) -->
<input type="hidden" id="signal_tbl_key" name="signal_tbl_key" value="" />
<!-- Panel 1 of 3 (6 rows, 2 columns) -->
<div id="panel_1" class="form_panels" style="grid-template-columns:repeat(2,1fr);grid-template-rows: repeat(6,1fr);">
<!-- Panel title (row 1/6)-->
<h1 style="grid-column: 1 / span 2; grid-row: 1;">Add New Signal</h1>
<!-- Name Input field (row 2/6)-->
<div id = "SigName_div" style="grid-column: 1 / span 2; grid_row:2;">
<label for='signal_name' >Signal Name:</label>
<input type="text" id="signal_name" name="signal_name" />
</div>
<!-- Source Input field (row 3/5)-->
<!-- Source Input field (row 3/6)-->
<label for="sig_source" style="grid-column: 1; grid-row: 3;"><b>Signal source</b></label>
<select name="sig_source" id="sig_source" style="grid-column: 2; grid-row: 3;" onchange= "UI.signals.fill_prop('sig_prop', this.value)">
<!-- Jinja2 loop through and populate the options -->
@ -22,13 +25,20 @@
{% endif %}
{% endfor %}
</select>
<!-- Property Input field (row 4/5)-->
<!-- Property Input field (row 4/6)-->
<label style="grid-column: 1; grid-row: 4;" for="sig_prop"><b>Property</b></label>
<select style="grid-column: 2; grid-row: 4;" id="sig_prop" name="sig_prop" >
</select>
<script>UI.signals.fill_prop('sig_prop', '{{ns.optonVal}}')</script>
<!-- Input controls (row 5/5)-->
<div style="grid-column: 1 / span 2; grid-row: 5;">
<!-- Public checkbox (row 5/6) -->
<div style="grid-column: 1 / span 2; grid-row: 5; text-align: center; padding-top: 5px;">
<label for="signal_public_checkbox" style="display: inline-block; height: auto;">
<input type="checkbox" id="signal_public_checkbox" name="signal_public" />
Make this signal public (visible to all users)
</label>
</div>
<!-- Input controls (row 6/6)-->
<div style="grid-column: 1 / span 2; grid-row: 6;">
<button type="button" class="btn cancel" onclick="UI.signals.close_signal_Form()">Close</button>
<button type="button" class="btn next" onclick="UI.signals.ns_next(1)">Next</button>
</div>
@ -113,7 +123,10 @@
<!-- Input controls (row 6/6) -->
<div class="padDiv" style="grid-column: 1/3; grid-row: 6;">
<button type="button" class="btn" onclick="UI.signals.switch_panel('panel_3','panel_2')">Back</button>
<button type="button" class="btn submit" onclick="UI.signals.submitNewSignal()">Next</button>
<!-- Create button (shown for new signals) -->
<button type="button" id="submit-create-signal" class="btn submit" onclick="UI.signals.submitSignal('new')">Create</button>
<!-- Edit button (shown when editing) -->
<button type="button" id="submit-edit-signal" class="btn submit" style="display:none;" onclick="UI.signals.submitSignal('edit')">Save Changes</button>
</div>
</div><!----End panel 3--------->
</form>

View File

@ -2,5 +2,204 @@
<button class="btn" id="new_signal" onclick="UI.signals.open_signal_Form()">New Signal</button>
<hr>
<h3>Signals</h3>
<div><ul id="signal_list"></ul></div>
<div class="signals-container" id="signal_list"></div>
</div>
<style>
/* Signals container - flex grid for cards */
.signals-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
padding: 10px;
}
/* Individual signal card */
.signal-item {
position: relative;
width: 100px;
height: 100px;
border-radius: 10px;
background: linear-gradient(145deg, #f0f0f0, #cacaca);
box-shadow: 5px 5px 10px #bebebe, -5px -5px 10px #ffffff;
cursor: pointer;
transition: all 0.3s ease;
overflow: visible;
}
.signal-item:hover {
transform: translateY(-3px);
box-shadow: 8px 8px 15px #bebebe, -8px -8px 15px #ffffff;
}
/* State-based border colors */
.signal-item.signal-true {
border: 3px solid #28a745;
animation: pulse-green 2s infinite;
}
.signal-item.signal-false {
border: 3px solid #dc3545;
}
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(40, 167, 69, 0); }
100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); }
}
/* Signal icon area */
.signal-icon {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px;
position: relative;
}
/* CSS-based signal icon */
.signal-icon::before {
content: '';
position: absolute;
top: 10px;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.signal-icon::after {
content: '\2248'; /* Approximately equal symbol */
position: absolute;
top: 15px;
font-size: 28px;
color: white;
font-weight: bold;
}
/* Signal name */
.signal-name {
font-size: 11px;
font-weight: bold;
text-align: center;
color: #333;
margin-top: 50px;
word-wrap: break-word;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* State indicator */
.signal-state {
font-size: 10px;
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
margin-top: 3px;
}
.signal-true .signal-state {
background: #28a745;
color: white;
}
.signal-false .signal-state {
background: #dc3545;
color: white;
}
/* Delete button */
.signal-item .delete-button {
position: absolute;
top: -8px;
right: -8px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #dc3545;
color: white;
border: 2px solid white;
font-size: 12px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
z-index: 10;
transition: transform 0.2s;
}
.signal-item:hover .delete-button {
display: flex;
}
.signal-item .delete-button:hover {
transform: scale(1.2);
}
/* Hover details panel */
.signal-hover {
position: absolute;
top: 110px;
left: 50%;
transform: translateX(-50%);
width: 200px;
padding: 10px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
z-index: 100;
display: none;
font-size: 11px;
}
.signal-item:hover .signal-hover {
display: block;
}
.signal-hover strong {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
.signal-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.signal-details span {
color: #666;
}
.signal-details .state-true {
color: #28a745;
font-weight: bold;
}
.signal-details .state-false {
color: #dc3545;
font-weight: bold;
}
.signal-public-badge {
display: inline-block;
background: #17a2b8;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 9px;
margin-top: 5px;
}
</style>