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:
parent
dd8467468c
commit
c895a3615d
|
|
@ -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:
|
||||
|
|
|
|||
570
src/Signals.py
570
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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue