Add external data sources and indicators for backtesting
External Sources: - New ExternalSources.py module for real-time API data fetching - Configurable refresh intervals, JSONPath extraction, and auth headers - External sources can be used in signals for live strategy conditions External Indicators: - New ExternalIndicators.py module for historical API data - Supports backtesting by fetching and caching historical data points - Timestamps are matched to candle times during backtest execution - Fear & Greed index and similar APIs now work in backtests Signal System Enhancements: - Added signal Blockly blocks for use in strategy builder - New signal_blocks.js for dynamic signal block generation - Signals category added to strategy toolbox - PythonGenerator updated to handle signal_* block types - process_signal() method added to StrategyInstance classes notify_user Block Enhancement: - Now supports dynamic values (variables, indicators, expressions) - Can display indicator values and calculations in notifications Bug Fixes: - Fixed Socket.IO parse error causing strategies not to load on refresh - Enhanced sanitize_for_json to handle pandas, datetime, bytes types - Fixed external indicator name matching (underscore/space conversion) - Fixed PRAGMA query missing fetch_all=True in ExternalIndicators Dependencies: - Added jsonpath-ng for JSONPath extraction from API responses Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a4422dc556
commit
b4bf6c5c8e
|
|
@ -14,6 +14,7 @@ email_validator~=2.2.0
|
||||||
aiohttp>=3.9.0
|
aiohttp>=3.9.0
|
||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
jsonpath-ng>=1.6.0
|
||||||
# Bitcoin wallet and encryption
|
# Bitcoin wallet and encryption
|
||||||
bit>=0.8.0
|
bit>=0.8.0
|
||||||
cryptography>=41.0.0
|
cryptography>=41.0.0
|
||||||
|
|
@ -11,9 +11,12 @@ from Configuration import Configuration
|
||||||
from ExchangeInterface import ExchangeInterface
|
from ExchangeInterface import ExchangeInterface
|
||||||
from indicators import Indicators
|
from indicators import Indicators
|
||||||
from Signals import Signals
|
from Signals import Signals
|
||||||
|
from ExternalSources import ExternalSources
|
||||||
|
from ExternalIndicators import ExternalIndicatorsManager
|
||||||
from trade import Trades
|
from trade import Trades
|
||||||
from edm_client import EdmClient, EdmWebSocketClient
|
from edm_client import EdmClient, EdmWebSocketClient
|
||||||
from wallet import WalletManager
|
from wallet import WalletManager
|
||||||
|
from utils import sanitize_for_json
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -51,6 +54,12 @@ class BrighterTrades:
|
||||||
# Object that maintains signals.
|
# Object that maintains signals.
|
||||||
self.signals = Signals(self.data)
|
self.signals = Signals(self.data)
|
||||||
|
|
||||||
|
# Object that maintains external data sources (custom signal types).
|
||||||
|
self.external_sources = ExternalSources(self.data)
|
||||||
|
|
||||||
|
# Object that maintains external indicators (API-based indicators with historical data).
|
||||||
|
self.external_indicators = ExternalIndicatorsManager(self.data)
|
||||||
|
|
||||||
# Object that maintains candlestick and price data.
|
# Object that maintains candlestick and price data.
|
||||||
self.candles = Candles(users=self.users, exchanges=self.exchanges, datacache=self.data,
|
self.candles = Candles(users=self.users, exchanges=self.exchanges, datacache=self.data,
|
||||||
config=self.config, edm_client=self.edm_client)
|
config=self.config, edm_client=self.edm_client)
|
||||||
|
|
@ -69,7 +78,8 @@ class BrighterTrades:
|
||||||
# Object responsible for testing trade and strategies data.
|
# Object responsible for testing trade and strategies data.
|
||||||
self.backtester = Backtester(data_cache=self.data, strategies=self.strategies,
|
self.backtester = Backtester(data_cache=self.data, strategies=self.strategies,
|
||||||
indicators=self.indicators, socketio=socketio,
|
indicators=self.indicators, socketio=socketio,
|
||||||
edm_client=self.edm_client)
|
edm_client=self.edm_client,
|
||||||
|
external_indicators=self.external_indicators)
|
||||||
self.backtests = {} # In-memory storage for backtests (replace with DB access in production)
|
self.backtests = {} # In-memory storage for backtests (replace with DB access in production)
|
||||||
|
|
||||||
# Wallet manager for Bitcoin wallets and credits ledger
|
# Wallet manager for Bitcoin wallets and credits ledger
|
||||||
|
|
@ -423,7 +433,12 @@ class BrighterTrades:
|
||||||
|
|
||||||
self.candles.set_new_candle(cdata)
|
self.candles.set_new_candle(cdata)
|
||||||
# i_updates = self.indicators.update_indicators()
|
# i_updates = self.indicators.update_indicators()
|
||||||
state_changes = self.signals.process_all_signals(self.indicators)
|
state_changes = self.signals.process_all_signals(
|
||||||
|
self.indicators, self.external_sources, self.external_indicators
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh external sources if needed
|
||||||
|
self.external_sources.refresh_all_sources()
|
||||||
|
|
||||||
# Build price updates dict: trades.update expects {symbol: price}
|
# Build price updates dict: trades.update expects {symbol: price}
|
||||||
symbol = cdata.get('symbol', cdata.get('market', 'BTC/USDT'))
|
symbol = cdata.get('symbol', cdata.get('market', 'BTC/USDT'))
|
||||||
|
|
@ -1554,8 +1569,8 @@ class BrighterTrades:
|
||||||
logger.info(f"[SOCKET] Received message type: {msg_type}")
|
logger.info(f"[SOCKET] Received message type: {msg_type}")
|
||||||
|
|
||||||
def standard_reply(reply_msg: str, reply_data: Any) -> dict:
|
def standard_reply(reply_msg: str, reply_data: Any) -> dict:
|
||||||
""" Formats a standard reply message. """
|
""" Formats a standard reply message with JSON-safe data. """
|
||||||
return {"reply": reply_msg, "data": reply_data}
|
return {"reply": reply_msg, "data": sanitize_for_json(reply_data)}
|
||||||
|
|
||||||
# Use authenticated_user_id if provided (from secure socket mapping)
|
# Use authenticated_user_id if provided (from secure socket mapping)
|
||||||
# Otherwise fall back to resolving from msg_data (for backwards compatibility)
|
# Otherwise fall back to resolving from msg_data (for backwards compatibility)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,718 @@
|
||||||
|
"""
|
||||||
|
External Indicators Manager - Handles custom API-based indicators with historical data.
|
||||||
|
|
||||||
|
Allows users to define external data sources that provide historical data,
|
||||||
|
enabling their use in backtesting alongside standard technical indicators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import datetime as dt
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from datetime import date, datetime
|
||||||
|
import requests
|
||||||
|
from jsonpath_ng import parse as jsonpath_parse
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from DataCache_v3 import DataCache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExternalIndicatorConfig:
|
||||||
|
"""Configuration for an external API-based indicator."""
|
||||||
|
name: str
|
||||||
|
historical_url: str # URL template with {start_date}, {end_date}, {limit} placeholders
|
||||||
|
auth_header: str = "" # e.g., "X-CMC_PRO_API_KEY"
|
||||||
|
auth_key: str = "" # The actual API key
|
||||||
|
value_jsonpath: str = "$.data[*].value" # JSONPath to extract values array
|
||||||
|
timestamp_jsonpath: str = "$.data[*].timestamp" # JSONPath to extract timestamps
|
||||||
|
date_format: str = "ISO" # "ISO" (2024-01-15) or "UNIX" (timestamp)
|
||||||
|
date_param_format: str = "ISO" # Format for URL date parameters
|
||||||
|
creator_id: int = None
|
||||||
|
enabled: bool = True
|
||||||
|
tbl_key: str = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
'name': self.name,
|
||||||
|
'historical_url': self.historical_url,
|
||||||
|
'auth_header': self.auth_header,
|
||||||
|
'value_jsonpath': self.value_jsonpath,
|
||||||
|
'timestamp_jsonpath': self.timestamp_jsonpath,
|
||||||
|
'date_format': self.date_format,
|
||||||
|
'date_param_format': self.date_param_format,
|
||||||
|
'creator_id': self.creator_id,
|
||||||
|
'enabled': self.enabled,
|
||||||
|
'tbl_key': self.tbl_key,
|
||||||
|
# Note: auth_key intentionally excluded for security
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalIndicator:
|
||||||
|
"""
|
||||||
|
Represents an external indicator instance with cached historical data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: ExternalIndicatorConfig, data_cache: DataCache):
|
||||||
|
self.config = config
|
||||||
|
self.data_cache = data_cache
|
||||||
|
self._cache: Dict[date, float] = {} # In-memory cache: date -> value
|
||||||
|
self._cache_loaded = False
|
||||||
|
|
||||||
|
def _load_cache_from_db(self) -> None:
|
||||||
|
"""Load cached historical data from database."""
|
||||||
|
if self._cache_loaded:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = self.data_cache.db.execute_sql(
|
||||||
|
"""SELECT data_date, value FROM external_indicator_data
|
||||||
|
WHERE indicator_key = ? ORDER BY data_date""",
|
||||||
|
params=(self.config.tbl_key,),
|
||||||
|
fetch_all=True
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
for row in rows:
|
||||||
|
data_date = datetime.strptime(row[0], '%Y-%m-%d').date()
|
||||||
|
self._cache[data_date] = float(row[1])
|
||||||
|
logger.debug(f"Loaded {len(self._cache)} cached values for {self.config.name}")
|
||||||
|
self._cache_loaded = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading indicator cache: {e}")
|
||||||
|
self._cache_loaded = True
|
||||||
|
|
||||||
|
def _save_to_cache(self, data_date: date, value: float) -> None:
|
||||||
|
"""Save a single data point to database cache."""
|
||||||
|
try:
|
||||||
|
self.data_cache.db.execute_sql(
|
||||||
|
"""INSERT OR REPLACE INTO external_indicator_data
|
||||||
|
(indicator_key, data_date, value) VALUES (?, ?, ?)""",
|
||||||
|
params=(self.config.tbl_key, data_date.strftime('%Y-%m-%d'), value)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving to indicator cache: {e}")
|
||||||
|
|
||||||
|
def _format_date_param(self, d: date) -> str:
|
||||||
|
"""Format a date for URL parameter based on config."""
|
||||||
|
if self.config.date_param_format == "UNIX":
|
||||||
|
return str(int(datetime.combine(d, datetime.min.time()).timestamp()))
|
||||||
|
else: # ISO
|
||||||
|
return d.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
def _parse_timestamp(self, ts_value: Any) -> Optional[date]:
|
||||||
|
"""Parse a timestamp from API response."""
|
||||||
|
try:
|
||||||
|
if self.config.date_format == "UNIX":
|
||||||
|
# Unix timestamp (seconds or milliseconds)
|
||||||
|
ts = float(ts_value)
|
||||||
|
if ts > 1e11: # Milliseconds
|
||||||
|
ts = ts / 1000
|
||||||
|
return datetime.fromtimestamp(ts).date()
|
||||||
|
else: # ISO format
|
||||||
|
if isinstance(ts_value, str):
|
||||||
|
# Try common formats
|
||||||
|
for fmt in ['%Y-%m-%dT%H:%M:%S.%fZ', '%Y-%m-%dT%H:%M:%SZ',
|
||||||
|
'%Y-%m-%dT%H:%M:%S', '%Y-%m-%d']:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(ts_value[:19], fmt[:len(ts_value)]).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
# Last resort: just take first 10 chars as date
|
||||||
|
return datetime.strptime(ts_value[:10], '%Y-%m-%d').date()
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parsing timestamp {ts_value}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_historical(self, start_date: date, end_date: date) -> Dict[date, float]:
|
||||||
|
"""
|
||||||
|
Fetch historical data from API for the given date range.
|
||||||
|
|
||||||
|
:param start_date: Start of date range.
|
||||||
|
:param end_date: End of date range.
|
||||||
|
:return: Dict mapping dates to values.
|
||||||
|
"""
|
||||||
|
self._load_cache_from_db()
|
||||||
|
|
||||||
|
# Check what we already have cached
|
||||||
|
missing_start = start_date
|
||||||
|
missing_end = end_date
|
||||||
|
|
||||||
|
# Find gaps in cache
|
||||||
|
cached_dates = set(self._cache.keys())
|
||||||
|
requested_dates = set()
|
||||||
|
current = start_date
|
||||||
|
while current <= end_date:
|
||||||
|
requested_dates.add(current)
|
||||||
|
current += dt.timedelta(days=1)
|
||||||
|
|
||||||
|
missing_dates = requested_dates - cached_dates
|
||||||
|
|
||||||
|
if not missing_dates:
|
||||||
|
# All data is cached
|
||||||
|
return {d: self._cache[d] for d in requested_dates if d in self._cache}
|
||||||
|
|
||||||
|
# Need to fetch missing data
|
||||||
|
missing_start = min(missing_dates)
|
||||||
|
missing_end = max(missing_dates)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build URL with date parameters
|
||||||
|
url = self.config.historical_url
|
||||||
|
url = url.replace('{start_date}', self._format_date_param(missing_start))
|
||||||
|
url = url.replace('{end_date}', self._format_date_param(missing_end))
|
||||||
|
|
||||||
|
# Calculate limit (days between dates)
|
||||||
|
limit = (missing_end - missing_start).days + 1
|
||||||
|
url = url.replace('{limit}', str(limit))
|
||||||
|
|
||||||
|
headers = {'Accept': 'application/json'}
|
||||||
|
if self.config.auth_header and self.config.auth_key:
|
||||||
|
headers[self.config.auth_header] = self.config.auth_key
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"API error fetching {self.config.name}: {response.status_code}")
|
||||||
|
return {d: self._cache.get(d) for d in requested_dates if d in self._cache}
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.info(f"[ExternalIndicator] API Response keys: {list(data.keys()) if isinstance(data, dict) else 'not a dict'}")
|
||||||
|
|
||||||
|
# Extract values and timestamps using JSONPath
|
||||||
|
value_expr = jsonpath_parse(self.config.value_jsonpath)
|
||||||
|
timestamp_expr = jsonpath_parse(self.config.timestamp_jsonpath)
|
||||||
|
|
||||||
|
values = [match.value for match in value_expr.find(data)]
|
||||||
|
timestamps = [match.value for match in timestamp_expr.find(data)]
|
||||||
|
|
||||||
|
logger.info(f"[ExternalIndicator] Extracted {len(values)} values and {len(timestamps)} timestamps")
|
||||||
|
if values:
|
||||||
|
logger.info(f"[ExternalIndicator] Sample values: {values[:3]}")
|
||||||
|
if timestamps:
|
||||||
|
logger.info(f"[ExternalIndicator] Sample timestamps: {timestamps[:3]}")
|
||||||
|
|
||||||
|
if len(values) != len(timestamps):
|
||||||
|
logger.error(f"Mismatch: {len(values)} values, {len(timestamps)} timestamps")
|
||||||
|
return {d: self._cache.get(d) for d in requested_dates if d in self._cache}
|
||||||
|
|
||||||
|
# Parse and cache the data
|
||||||
|
for ts, val in zip(timestamps, values):
|
||||||
|
parsed_date = self._parse_timestamp(ts)
|
||||||
|
if parsed_date:
|
||||||
|
try:
|
||||||
|
numeric_val = float(val)
|
||||||
|
self._cache[parsed_date] = numeric_val
|
||||||
|
self._save_to_cache(parsed_date, numeric_val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.debug(f"Non-numeric value for {parsed_date}: {val}")
|
||||||
|
|
||||||
|
logger.info(f"Fetched {len(values)} data points for {self.config.name}")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Request error fetching {self.config.name}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching {self.config.name}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Return requested data from cache
|
||||||
|
return {d: self._cache.get(d) for d in requested_dates if d in self._cache}
|
||||||
|
|
||||||
|
def get_value_for_timestamp(self, ts: datetime) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Get the indicator value for a specific timestamp.
|
||||||
|
Uses the value for that day (handles granularity mismatch).
|
||||||
|
|
||||||
|
:param ts: The timestamp to look up.
|
||||||
|
:return: The indicator value or None if not available.
|
||||||
|
"""
|
||||||
|
self._load_cache_from_db()
|
||||||
|
target_date = ts.date() if isinstance(ts, datetime) else ts
|
||||||
|
return self._cache.get(target_date)
|
||||||
|
|
||||||
|
def calculate(self, candles: pd.DataFrame) -> pd.Series:
|
||||||
|
"""
|
||||||
|
Calculate indicator values for a DataFrame of candles.
|
||||||
|
|
||||||
|
:param candles: DataFrame with 'time', 'timestamp', or 'date' column.
|
||||||
|
:return: Series of indicator values aligned to candles.
|
||||||
|
"""
|
||||||
|
if candles.empty:
|
||||||
|
return pd.Series(dtype=float)
|
||||||
|
|
||||||
|
# Determine date range from candles
|
||||||
|
# Check various column names used by different data sources
|
||||||
|
if 'time' in candles.columns:
|
||||||
|
# 'time' column is typically Unix timestamp in milliseconds
|
||||||
|
time_col = candles['time']
|
||||||
|
if time_col.dtype in ['int64', 'float64'] and time_col.iloc[0] > 1e9:
|
||||||
|
# Convert from milliseconds or seconds
|
||||||
|
if time_col.iloc[0] > 1e12:
|
||||||
|
dates = pd.to_datetime(time_col, unit='ms')
|
||||||
|
else:
|
||||||
|
dates = pd.to_datetime(time_col, unit='s')
|
||||||
|
else:
|
||||||
|
dates = pd.to_datetime(time_col)
|
||||||
|
elif 'timestamp' in candles.columns:
|
||||||
|
dates = pd.to_datetime(candles['timestamp'])
|
||||||
|
elif 'date' in candles.columns:
|
||||||
|
dates = pd.to_datetime(candles['date'])
|
||||||
|
elif 'datetime' in candles.columns:
|
||||||
|
dates = pd.to_datetime(candles['datetime'])
|
||||||
|
else:
|
||||||
|
# Try index
|
||||||
|
dates = pd.to_datetime(candles.index)
|
||||||
|
|
||||||
|
start_date = dates.min().date()
|
||||||
|
end_date = dates.max().date()
|
||||||
|
|
||||||
|
logger.info(f"[ExternalIndicator] Fetching data for {self.config.name} from {start_date} to {end_date}")
|
||||||
|
|
||||||
|
# Fetch historical data for this range
|
||||||
|
historical_data = self.fetch_historical(start_date, end_date)
|
||||||
|
|
||||||
|
logger.info(f"[ExternalIndicator] Got {len(historical_data)} data points from API")
|
||||||
|
|
||||||
|
# Map each candle to its date's value
|
||||||
|
values = []
|
||||||
|
for d in dates:
|
||||||
|
candle_date = d.date()
|
||||||
|
val = historical_data.get(candle_date)
|
||||||
|
values.append(val)
|
||||||
|
|
||||||
|
# Log some stats
|
||||||
|
non_null = sum(1 for v in values if v is not None)
|
||||||
|
logger.info(f"[ExternalIndicator] Mapped {non_null}/{len(values)} candles to values")
|
||||||
|
|
||||||
|
return pd.Series(values, index=candles.index, name=self.config.name)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalIndicatorsManager:
|
||||||
|
"""Manages external API-based indicators."""
|
||||||
|
|
||||||
|
def __init__(self, data_cache: DataCache):
|
||||||
|
self.data_cache = data_cache
|
||||||
|
self._ensure_tables_exist()
|
||||||
|
|
||||||
|
# In-memory storage of indicator configs
|
||||||
|
self.configs: Dict[str, ExternalIndicatorConfig] = {}
|
||||||
|
|
||||||
|
# Cached ExternalIndicator instances
|
||||||
|
self._indicators: Dict[str, ExternalIndicator] = {}
|
||||||
|
|
||||||
|
# Load existing configs from database
|
||||||
|
self._load_configs_from_db()
|
||||||
|
|
||||||
|
def _ensure_tables_exist(self) -> None:
|
||||||
|
"""Create necessary database tables."""
|
||||||
|
try:
|
||||||
|
# Table for indicator configurations
|
||||||
|
if not self.data_cache.db.table_exists('external_indicators'):
|
||||||
|
self.data_cache.db.execute_sql("""
|
||||||
|
CREATE TABLE IF NOT EXISTS external_indicators (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tbl_key TEXT UNIQUE NOT NULL,
|
||||||
|
creator_id INTEGER,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
historical_url TEXT NOT NULL,
|
||||||
|
auth_header TEXT,
|
||||||
|
auth_key TEXT,
|
||||||
|
value_jsonpath TEXT DEFAULT '$.data[*].value',
|
||||||
|
timestamp_jsonpath TEXT DEFAULT '$.data[*].timestamp',
|
||||||
|
date_format TEXT DEFAULT 'ISO',
|
||||||
|
date_param_format TEXT DEFAULT 'ISO',
|
||||||
|
enabled INTEGER DEFAULT 1
|
||||||
|
)
|
||||||
|
""", params=[])
|
||||||
|
logger.info("Created external_indicators table")
|
||||||
|
|
||||||
|
# Table for cached historical data
|
||||||
|
if not self.data_cache.db.table_exists('external_indicator_data'):
|
||||||
|
self.data_cache.db.execute_sql("""
|
||||||
|
CREATE TABLE IF NOT EXISTS external_indicator_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
indicator_key TEXT NOT NULL,
|
||||||
|
data_date TEXT NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
UNIQUE(indicator_key, data_date)
|
||||||
|
)
|
||||||
|
""", params=[])
|
||||||
|
self.data_cache.db.execute_sql(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_ext_ind_data ON external_indicator_data(indicator_key, data_date)",
|
||||||
|
params=[]
|
||||||
|
)
|
||||||
|
logger.info("Created external_indicator_data table")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating external indicator tables: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _load_configs_from_db(self) -> None:
|
||||||
|
"""Load all indicator configurations from database."""
|
||||||
|
try:
|
||||||
|
rows = self.data_cache.db.execute_sql(
|
||||||
|
"SELECT * FROM external_indicators",
|
||||||
|
params=[],
|
||||||
|
fetch_all=True
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
# Get column names
|
||||||
|
column_info = self.data_cache.db.execute_sql(
|
||||||
|
"PRAGMA table_info(external_indicators)",
|
||||||
|
params=[],
|
||||||
|
fetch_all=True
|
||||||
|
)
|
||||||
|
columns = [row[1] for row in (column_info or [])]
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
row_dict = dict(zip(columns, row))
|
||||||
|
config = ExternalIndicatorConfig(
|
||||||
|
name=row_dict.get('name', ''),
|
||||||
|
historical_url=row_dict.get('historical_url', ''),
|
||||||
|
auth_header=row_dict.get('auth_header', ''),
|
||||||
|
auth_key=row_dict.get('auth_key', ''),
|
||||||
|
value_jsonpath=row_dict.get('value_jsonpath', '$.data[*].value'),
|
||||||
|
timestamp_jsonpath=row_dict.get('timestamp_jsonpath', '$.data[*].timestamp'),
|
||||||
|
date_format=row_dict.get('date_format', 'ISO'),
|
||||||
|
date_param_format=row_dict.get('date_param_format', 'ISO'),
|
||||||
|
creator_id=row_dict.get('creator_id'),
|
||||||
|
enabled=bool(row_dict.get('enabled', 1)),
|
||||||
|
tbl_key=row_dict.get('tbl_key')
|
||||||
|
)
|
||||||
|
if config.tbl_key:
|
||||||
|
self.configs[config.tbl_key] = config
|
||||||
|
logger.info(f"Loaded {len(self.configs)} external indicator configs")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading external indicator configs: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _save_config_to_db(self, config: ExternalIndicatorConfig) -> None:
|
||||||
|
"""Save an indicator configuration to database."""
|
||||||
|
existing = self.data_cache.db.execute_sql(
|
||||||
|
"SELECT id FROM external_indicators WHERE tbl_key = ?",
|
||||||
|
params=(config.tbl_key,),
|
||||||
|
fetch_one=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
self.data_cache.db.execute_sql("""
|
||||||
|
UPDATE external_indicators SET
|
||||||
|
creator_id = ?, name = ?, historical_url = ?, auth_header = ?,
|
||||||
|
auth_key = ?, value_jsonpath = ?, timestamp_jsonpath = ?,
|
||||||
|
date_format = ?, date_param_format = ?, enabled = ?
|
||||||
|
WHERE tbl_key = ?
|
||||||
|
""", params=(
|
||||||
|
config.creator_id, config.name, config.historical_url,
|
||||||
|
config.auth_header, config.auth_key, config.value_jsonpath,
|
||||||
|
config.timestamp_jsonpath, config.date_format, config.date_param_format,
|
||||||
|
1 if config.enabled else 0, config.tbl_key
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
self.data_cache.db.execute_sql("""
|
||||||
|
INSERT INTO external_indicators (
|
||||||
|
tbl_key, creator_id, name, historical_url, auth_header, auth_key,
|
||||||
|
value_jsonpath, timestamp_jsonpath, date_format, date_param_format, enabled
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", params=(
|
||||||
|
config.tbl_key, config.creator_id, config.name, config.historical_url,
|
||||||
|
config.auth_header, config.auth_key, config.value_jsonpath,
|
||||||
|
config.timestamp_jsonpath, config.date_format, config.date_param_format,
|
||||||
|
1 if config.enabled else 0
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_indicator(self, historical_url: str, auth_header: str, auth_key: str,
|
||||||
|
value_jsonpath: str, timestamp_jsonpath: str,
|
||||||
|
date_format: str, date_param_format: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Test an external indicator configuration by fetching sample data.
|
||||||
|
|
||||||
|
:return: Dict with success status, sample values, and any errors.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Build URL with sample date range (last 7 days)
|
||||||
|
end_date = date.today()
|
||||||
|
start_date = end_date - dt.timedelta(days=7)
|
||||||
|
|
||||||
|
url = historical_url
|
||||||
|
|
||||||
|
# Format dates based on config
|
||||||
|
if date_param_format == "UNIX":
|
||||||
|
start_str = str(int(datetime.combine(start_date, datetime.min.time()).timestamp()))
|
||||||
|
end_str = str(int(datetime.combine(end_date, datetime.min.time()).timestamp()))
|
||||||
|
else:
|
||||||
|
start_str = start_date.strftime('%Y-%m-%d')
|
||||||
|
end_str = end_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
url = url.replace('{start_date}', start_str)
|
||||||
|
url = url.replace('{end_date}', end_str)
|
||||||
|
url = url.replace('{limit}', '7')
|
||||||
|
|
||||||
|
headers = {'Accept': 'application/json'}
|
||||||
|
if auth_header and auth_key:
|
||||||
|
headers[auth_header] = auth_key
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=15)
|
||||||
|
|
||||||
|
# Try to parse JSON for error messages
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f'Invalid JSON response (HTTP {response.status_code})'
|
||||||
|
}
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
# Try to extract API error message
|
||||||
|
error_msg = self._extract_api_error(data)
|
||||||
|
if error_msg:
|
||||||
|
return {'success': False, 'error': f'API Error: {error_msg}'}
|
||||||
|
return {'success': False, 'error': f'HTTP {response.status_code}: {response.reason}'}
|
||||||
|
|
||||||
|
# Extract values using JSONPath
|
||||||
|
value_expr = jsonpath_parse(value_jsonpath)
|
||||||
|
timestamp_expr = jsonpath_parse(timestamp_jsonpath)
|
||||||
|
|
||||||
|
values = [match.value for match in value_expr.find(data)]
|
||||||
|
timestamps = [match.value for match in timestamp_expr.find(data)]
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f"Value JSONPath '{value_jsonpath}' found no matches",
|
||||||
|
'response_preview': str(data)[:300]
|
||||||
|
}
|
||||||
|
|
||||||
|
if not timestamps:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f"Timestamp JSONPath '{timestamp_jsonpath}' found no matches",
|
||||||
|
'response_preview': str(data)[:300]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(values) != len(timestamps):
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f"Mismatch: {len(values)} values but {len(timestamps)} timestamps"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify values are numeric
|
||||||
|
sample_values = []
|
||||||
|
for val in values[:5]:
|
||||||
|
try:
|
||||||
|
sample_values.append(float(val))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f"Non-numeric value found: {val}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify timestamps can be parsed
|
||||||
|
parsed_dates = []
|
||||||
|
for ts in timestamps[:5]:
|
||||||
|
parsed = self._parse_timestamp_static(ts, date_format)
|
||||||
|
if parsed:
|
||||||
|
parsed_dates.append(parsed.strftime('%Y-%m-%d'))
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f"Could not parse timestamp: {ts}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'data_points': len(values),
|
||||||
|
'sample_values': sample_values,
|
||||||
|
'sample_dates': parsed_dates,
|
||||||
|
'message': f"Found {len(values)} data points"
|
||||||
|
}
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return {'success': False, 'error': 'Request timed out (15s)'}
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return {'success': False, 'error': 'Connection failed - check URL'}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error testing external indicator: {e}", exc_info=True)
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def _extract_api_error(self, data: Any) -> Optional[str]:
|
||||||
|
"""Extract error message from common API error formats."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# CoinMarketCap format
|
||||||
|
if 'status' in data and isinstance(data['status'], dict):
|
||||||
|
return data['status'].get('error_message')
|
||||||
|
|
||||||
|
# Generic formats
|
||||||
|
for key in ['error', 'message', 'error_message', 'msg']:
|
||||||
|
if key in data:
|
||||||
|
return str(data[key])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_timestamp_static(ts_value: Any, date_format: str) -> Optional[date]:
|
||||||
|
"""Static method to parse timestamp for testing."""
|
||||||
|
try:
|
||||||
|
if date_format == "UNIX":
|
||||||
|
ts = float(ts_value)
|
||||||
|
if ts > 1e11:
|
||||||
|
ts = ts / 1000
|
||||||
|
return datetime.fromtimestamp(ts).date()
|
||||||
|
else:
|
||||||
|
if isinstance(ts_value, str):
|
||||||
|
for fmt in ['%Y-%m-%dT%H:%M:%S.%fZ', '%Y-%m-%dT%H:%M:%SZ',
|
||||||
|
'%Y-%m-%dT%H:%M:%S', '%Y-%m-%d']:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(ts_value[:19], fmt[:len(ts_value)]).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return datetime.strptime(ts_value[:10], '%Y-%m-%d').date()
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_indicator(self, name: str, historical_url: str, auth_header: str,
|
||||||
|
auth_key: str, value_jsonpath: str, timestamp_jsonpath: str,
|
||||||
|
date_format: str, date_param_format: str,
|
||||||
|
creator_id: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a new external indicator after validating the configuration.
|
||||||
|
|
||||||
|
:return: Dict with success status and indicator data.
|
||||||
|
"""
|
||||||
|
# First test the configuration
|
||||||
|
test_result = self.test_indicator(
|
||||||
|
historical_url, auth_header, auth_key,
|
||||||
|
value_jsonpath, timestamp_jsonpath,
|
||||||
|
date_format, date_param_format
|
||||||
|
)
|
||||||
|
|
||||||
|
if not test_result['success']:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f"Validation failed: {test_result['error']}"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
tbl_key = str(uuid.uuid4())
|
||||||
|
|
||||||
|
config = ExternalIndicatorConfig(
|
||||||
|
name=name,
|
||||||
|
historical_url=historical_url,
|
||||||
|
auth_header=auth_header,
|
||||||
|
auth_key=auth_key,
|
||||||
|
value_jsonpath=value_jsonpath,
|
||||||
|
timestamp_jsonpath=timestamp_jsonpath,
|
||||||
|
date_format=date_format,
|
||||||
|
date_param_format=date_param_format,
|
||||||
|
creator_id=creator_id,
|
||||||
|
enabled=True,
|
||||||
|
tbl_key=tbl_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
self._save_config_to_db(config)
|
||||||
|
|
||||||
|
# Add to memory
|
||||||
|
self.configs[tbl_key] = config
|
||||||
|
|
||||||
|
logger.info(f"Created external indicator: {name}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'indicator': config.to_dict(),
|
||||||
|
'test_data': test_result,
|
||||||
|
'message': f"Created indicator '{name}' with {test_result['data_points']} data points available"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating external indicator: {e}", exc_info=True)
|
||||||
|
return {'success': False, 'message': str(e)}
|
||||||
|
|
||||||
|
def get_indicator(self, tbl_key: str) -> Optional[ExternalIndicator]:
|
||||||
|
"""Get an ExternalIndicator instance by tbl_key."""
|
||||||
|
if tbl_key not in self.configs:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if tbl_key not in self._indicators:
|
||||||
|
self._indicators[tbl_key] = ExternalIndicator(
|
||||||
|
self.configs[tbl_key], self.data_cache
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._indicators[tbl_key]
|
||||||
|
|
||||||
|
def get_indicator_by_name(self, name: str, user_id: int) -> Optional[ExternalIndicator]:
|
||||||
|
"""Get an ExternalIndicator by name for a specific user."""
|
||||||
|
for config in self.configs.values():
|
||||||
|
if config.name == name and config.creator_id == user_id:
|
||||||
|
return self.get_indicator(config.tbl_key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_indicators_for_user(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all indicator configs for a user."""
|
||||||
|
return [
|
||||||
|
config.to_dict()
|
||||||
|
for config in self.configs.values()
|
||||||
|
if config.creator_id == user_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_all_indicators(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all indicator configs."""
|
||||||
|
return [config.to_dict() for config in self.configs.values()]
|
||||||
|
|
||||||
|
def delete_indicator(self, tbl_key: str, user_id: int) -> Dict[str, Any]:
|
||||||
|
"""Delete an external indicator."""
|
||||||
|
config = self.configs.get(tbl_key)
|
||||||
|
if not config:
|
||||||
|
return {'success': False, 'message': 'Indicator not found'}
|
||||||
|
|
||||||
|
if config.creator_id != user_id:
|
||||||
|
return {'success': False, 'message': 'Not authorized to delete this indicator'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete cached data
|
||||||
|
self.data_cache.db.execute_sql(
|
||||||
|
"DELETE FROM external_indicator_data WHERE indicator_key = ?",
|
||||||
|
params=(tbl_key,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete config
|
||||||
|
self.data_cache.db.execute_sql(
|
||||||
|
"DELETE FROM external_indicators WHERE tbl_key = ?",
|
||||||
|
params=(tbl_key,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove from memory
|
||||||
|
del self.configs[tbl_key]
|
||||||
|
if tbl_key in self._indicators:
|
||||||
|
del self._indicators[tbl_key]
|
||||||
|
|
||||||
|
logger.info(f"Deleted external indicator: {config.name}")
|
||||||
|
return {'success': True, 'message': f"Deleted indicator '{config.name}'"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting external indicator: {e}", exc_info=True)
|
||||||
|
return {'success': False, 'message': str(e)}
|
||||||
|
|
||||||
|
def get_indicators_as_indicator_list(self, user_id: int) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get external indicators formatted for the indicator dropdown.
|
||||||
|
|
||||||
|
:param user_id: The user ID.
|
||||||
|
:return: Dict mapping indicator names to indicator-like data.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for config in self.configs.values():
|
||||||
|
if config.creator_id == user_id and config.enabled:
|
||||||
|
result[config.name] = {
|
||||||
|
'type': 'EXTERNAL_HISTORICAL',
|
||||||
|
'source_type': 'external_indicator',
|
||||||
|
'tbl_key': config.tbl_key,
|
||||||
|
'value': None # Will be filled during calculation
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,468 @@
|
||||||
|
"""
|
||||||
|
External Sources Manager - Handles custom API-based signal sources.
|
||||||
|
|
||||||
|
Allows users to define external data sources (like CoinMarketCap Fear & Greed Index)
|
||||||
|
that can be used as signal sources alongside technical indicators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import datetime as dt
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
import requests
|
||||||
|
from jsonpath_ng import parse as jsonpath_parse
|
||||||
|
|
||||||
|
from DataCache_v3 import DataCache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExternalSource:
|
||||||
|
"""Represents an external API data source."""
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
auth_header: str = "" # e.g., "X-CMC_PRO_API_KEY"
|
||||||
|
auth_key: str = "" # The actual API key (stored encrypted ideally)
|
||||||
|
json_path: str = "$.data[0].value" # JSONPath to extract value
|
||||||
|
refresh_interval: int = 300 # Seconds between fetches (default 5 min)
|
||||||
|
last_value: float = None
|
||||||
|
last_fetch_time: float = None # Unix timestamp
|
||||||
|
creator_id: int = None
|
||||||
|
enabled: bool = True
|
||||||
|
tbl_key: str = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
'name': self.name,
|
||||||
|
'url': self.url,
|
||||||
|
'auth_header': self.auth_header,
|
||||||
|
'json_path': self.json_path,
|
||||||
|
'refresh_interval': self.refresh_interval,
|
||||||
|
'last_value': self.last_value,
|
||||||
|
'last_fetch_time': self.last_fetch_time,
|
||||||
|
'creator_id': self.creator_id,
|
||||||
|
'enabled': self.enabled,
|
||||||
|
'tbl_key': self.tbl_key,
|
||||||
|
# Note: auth_key intentionally excluded for security
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalSources:
|
||||||
|
"""Manages external API data sources for signals."""
|
||||||
|
|
||||||
|
def __init__(self, data_cache: DataCache):
|
||||||
|
"""
|
||||||
|
Initialize the ExternalSources manager.
|
||||||
|
|
||||||
|
:param data_cache: DataCache instance for database operations.
|
||||||
|
"""
|
||||||
|
self.data_cache = data_cache
|
||||||
|
self._ensure_table_exists()
|
||||||
|
|
||||||
|
# Create cache for external sources
|
||||||
|
self.data_cache.create_cache(
|
||||||
|
name='external_sources',
|
||||||
|
cache_type='table',
|
||||||
|
size_limit=100,
|
||||||
|
eviction_policy='deny',
|
||||||
|
default_expiration=dt.timedelta(hours=24),
|
||||||
|
columns=[
|
||||||
|
"creator_id",
|
||||||
|
"name",
|
||||||
|
"url",
|
||||||
|
"auth_header",
|
||||||
|
"auth_key",
|
||||||
|
"json_path",
|
||||||
|
"refresh_interval",
|
||||||
|
"last_value",
|
||||||
|
"last_fetch_time",
|
||||||
|
"enabled",
|
||||||
|
"tbl_key"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# In-memory cache of ExternalSource objects
|
||||||
|
self.sources: Dict[str, ExternalSource] = {}
|
||||||
|
|
||||||
|
# Load existing sources
|
||||||
|
self._load_sources_from_db()
|
||||||
|
|
||||||
|
def _ensure_table_exists(self) -> None:
|
||||||
|
"""Create the external_sources table if it doesn't exist."""
|
||||||
|
try:
|
||||||
|
if not self.data_cache.db.table_exists('external_sources'):
|
||||||
|
create_sql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS external_sources (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
creator_id INTEGER,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
auth_header TEXT,
|
||||||
|
auth_key TEXT,
|
||||||
|
json_path TEXT DEFAULT '$.data[0].value',
|
||||||
|
refresh_interval INTEGER DEFAULT 300,
|
||||||
|
last_value REAL,
|
||||||
|
last_fetch_time REAL,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
tbl_key TEXT UNIQUE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
self.data_cache.db.execute_sql(create_sql, params=[])
|
||||||
|
logger.info("Created external_sources table in database")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating external_sources table: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _load_sources_from_db(self) -> None:
|
||||||
|
"""Load all external sources from database into memory."""
|
||||||
|
try:
|
||||||
|
sources_df = self.data_cache.get_all_rows_from_datacache(cache_name='external_sources')
|
||||||
|
if sources_df is not None and not sources_df.empty:
|
||||||
|
for _, row in sources_df.iterrows():
|
||||||
|
source = ExternalSource(
|
||||||
|
name=row.get('name', ''),
|
||||||
|
url=row.get('url', ''),
|
||||||
|
auth_header=row.get('auth_header', ''),
|
||||||
|
auth_key=row.get('auth_key', ''),
|
||||||
|
json_path=row.get('json_path', '$.data[0].value'),
|
||||||
|
refresh_interval=int(row.get('refresh_interval', 300)),
|
||||||
|
last_value=row.get('last_value'),
|
||||||
|
last_fetch_time=row.get('last_fetch_time'),
|
||||||
|
creator_id=row.get('creator_id'),
|
||||||
|
enabled=bool(row.get('enabled', True)),
|
||||||
|
tbl_key=row.get('tbl_key')
|
||||||
|
)
|
||||||
|
if source.tbl_key:
|
||||||
|
self.sources[source.tbl_key] = source
|
||||||
|
logger.info(f"Loaded {len(self.sources)} external sources from database")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading external sources: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def create_source(self, name: str, url: str, auth_header: str, auth_key: str,
|
||||||
|
json_path: str, refresh_interval: int, creator_id: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a new external source.
|
||||||
|
|
||||||
|
:param name: Display name for the source.
|
||||||
|
:param url: API endpoint URL.
|
||||||
|
:param auth_header: Header name for authentication (e.g., "X-CMC_PRO_API_KEY").
|
||||||
|
:param auth_key: API key value.
|
||||||
|
:param json_path: JSONPath expression to extract value from response.
|
||||||
|
:param refresh_interval: Seconds between data fetches.
|
||||||
|
:param creator_id: User ID of the creator.
|
||||||
|
:return: Dict with success status and source data.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tbl_key = str(uuid.uuid4())
|
||||||
|
|
||||||
|
source = ExternalSource(
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
auth_header=auth_header,
|
||||||
|
auth_key=auth_key,
|
||||||
|
json_path=json_path,
|
||||||
|
refresh_interval=refresh_interval,
|
||||||
|
creator_id=creator_id,
|
||||||
|
enabled=True,
|
||||||
|
tbl_key=tbl_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test the source by fetching data
|
||||||
|
fetch_result = self._fetch_value(source)
|
||||||
|
if not fetch_result['success']:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f"Failed to fetch data: {fetch_result['error']}"
|
||||||
|
}
|
||||||
|
|
||||||
|
source.last_value = fetch_result['value']
|
||||||
|
source.last_fetch_time = dt.datetime.now().timestamp()
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
self._save_source_to_db(source)
|
||||||
|
|
||||||
|
# Add to memory cache
|
||||||
|
self.sources[tbl_key] = source
|
||||||
|
|
||||||
|
logger.info(f"Created external source: {name} (value: {source.last_value})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'source': source.to_dict(),
|
||||||
|
'message': f"Created source '{name}' with initial value: {source.last_value}"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating external source: {e}", exc_info=True)
|
||||||
|
return {'success': False, 'message': str(e)}
|
||||||
|
|
||||||
|
def _save_source_to_db(self, source: ExternalSource) -> None:
|
||||||
|
"""Save an external source to the database (insert or update)."""
|
||||||
|
# Check if source already exists
|
||||||
|
existing = self.data_cache.db.execute_sql(
|
||||||
|
"SELECT id FROM external_sources WHERE tbl_key = ?",
|
||||||
|
params=(source.tbl_key,),
|
||||||
|
fetch_one=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing record
|
||||||
|
update_sql = """
|
||||||
|
UPDATE external_sources SET
|
||||||
|
creator_id = ?, name = ?, url = ?, auth_header = ?, auth_key = ?,
|
||||||
|
json_path = ?, refresh_interval = ?, last_value = ?, last_fetch_time = ?,
|
||||||
|
enabled = ?
|
||||||
|
WHERE tbl_key = ?
|
||||||
|
"""
|
||||||
|
self.data_cache.db.execute_sql(update_sql, params=(
|
||||||
|
source.creator_id,
|
||||||
|
source.name,
|
||||||
|
source.url,
|
||||||
|
source.auth_header,
|
||||||
|
source.auth_key,
|
||||||
|
source.json_path,
|
||||||
|
source.refresh_interval,
|
||||||
|
source.last_value,
|
||||||
|
source.last_fetch_time,
|
||||||
|
1 if source.enabled else 0,
|
||||||
|
source.tbl_key
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Insert new record
|
||||||
|
columns = (
|
||||||
|
'creator_id', 'name', 'url', 'auth_header', 'auth_key',
|
||||||
|
'json_path', 'refresh_interval', 'last_value', 'last_fetch_time',
|
||||||
|
'enabled', 'tbl_key'
|
||||||
|
)
|
||||||
|
values = (
|
||||||
|
source.creator_id,
|
||||||
|
source.name,
|
||||||
|
source.url,
|
||||||
|
source.auth_header,
|
||||||
|
source.auth_key,
|
||||||
|
source.json_path,
|
||||||
|
source.refresh_interval,
|
||||||
|
source.last_value,
|
||||||
|
source.last_fetch_time,
|
||||||
|
1 if source.enabled else 0,
|
||||||
|
source.tbl_key
|
||||||
|
)
|
||||||
|
self.data_cache.insert_row_into_datacache(
|
||||||
|
cache_name='external_sources',
|
||||||
|
columns=columns,
|
||||||
|
values=values
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_value(self, source: ExternalSource) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fetch the current value from an external API.
|
||||||
|
|
||||||
|
:param source: The ExternalSource to fetch from.
|
||||||
|
:return: Dict with success status, value, and optional error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
headers = {'Accept': 'application/json'}
|
||||||
|
|
||||||
|
# Add authentication header if provided
|
||||||
|
if source.auth_header and source.auth_key:
|
||||||
|
headers[source.auth_header] = source.auth_key
|
||||||
|
|
||||||
|
response = requests.get(source.url, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
# Parse JSON first to check for API error messages
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {'success': False, 'error': f'Invalid JSON response (HTTP {response.status_code})'}
|
||||||
|
|
||||||
|
# Check for API-level errors in response
|
||||||
|
if not response.ok:
|
||||||
|
# Try to extract error message from common API error formats
|
||||||
|
error_msg = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# CoinMarketCap format: {"status": {"error_message": "..."}}
|
||||||
|
if 'status' in data and isinstance(data['status'], dict):
|
||||||
|
error_msg = data['status'].get('error_message')
|
||||||
|
# Generic formats
|
||||||
|
elif 'error' in data:
|
||||||
|
error_msg = data.get('error')
|
||||||
|
elif 'message' in data:
|
||||||
|
error_msg = data.get('message')
|
||||||
|
elif 'error_message' in data:
|
||||||
|
error_msg = data.get('error_message')
|
||||||
|
|
||||||
|
if error_msg:
|
||||||
|
return {'success': False, 'error': f'API Error: {error_msg}'}
|
||||||
|
else:
|
||||||
|
return {'success': False, 'error': f'HTTP {response.status_code}: {response.reason}'}
|
||||||
|
|
||||||
|
# Extract value using JSONPath
|
||||||
|
jsonpath_expr = jsonpath_parse(source.json_path)
|
||||||
|
matches = jsonpath_expr.find(data)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
# Show a snippet of the response to help debug
|
||||||
|
response_preview = str(data)[:200]
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f"JSONPath '{source.json_path}' found no matches. Response: {response_preview}..."
|
||||||
|
}
|
||||||
|
|
||||||
|
value = matches[0].value
|
||||||
|
|
||||||
|
# Try to convert to float
|
||||||
|
try:
|
||||||
|
value = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass # Keep original value if not numeric
|
||||||
|
|
||||||
|
return {'success': True, 'value': value}
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return {'success': False, 'error': 'Request timed out (10s)'}
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return {'success': False, 'error': 'Connection failed - check URL'}
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return {'success': False, 'error': f'Request failed: {str(e)}'}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching external source: {e}", exc_info=True)
|
||||||
|
return {'success': False, 'error': f'Error: {str(e)}'}
|
||||||
|
|
||||||
|
def refresh_source(self, tbl_key: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Refresh the value for a specific source.
|
||||||
|
|
||||||
|
:param tbl_key: The unique key of the source to refresh.
|
||||||
|
:return: Dict with success status and new value.
|
||||||
|
"""
|
||||||
|
source = self.sources.get(tbl_key)
|
||||||
|
if not source:
|
||||||
|
return {'success': False, 'error': 'Source not found'}
|
||||||
|
|
||||||
|
result = self._fetch_value(source)
|
||||||
|
if result['success']:
|
||||||
|
source.last_value = result['value']
|
||||||
|
source.last_fetch_time = dt.datetime.now().timestamp()
|
||||||
|
self._save_source_to_db(source)
|
||||||
|
return {'success': True, 'value': result['value']}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def refresh_all_sources(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Refresh all sources that need updating based on their refresh interval.
|
||||||
|
|
||||||
|
:return: Dict with results for each source.
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
current_time = dt.datetime.now().timestamp()
|
||||||
|
|
||||||
|
for tbl_key, source in self.sources.items():
|
||||||
|
if not source.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if refresh is needed
|
||||||
|
if source.last_fetch_time:
|
||||||
|
time_since_fetch = current_time - source.last_fetch_time
|
||||||
|
if time_since_fetch < source.refresh_interval:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = self.refresh_source(tbl_key)
|
||||||
|
results[source.name] = result
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_source(self, tbl_key: str) -> Optional[ExternalSource]:
|
||||||
|
"""Get a source by its tbl_key."""
|
||||||
|
return self.sources.get(tbl_key)
|
||||||
|
|
||||||
|
def get_source_by_name(self, name: str) -> Optional[ExternalSource]:
|
||||||
|
"""Get a source by its name."""
|
||||||
|
for source in self.sources.values():
|
||||||
|
if source.name == name:
|
||||||
|
return source
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_sources_for_user(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all sources created by a specific user.
|
||||||
|
|
||||||
|
:param user_id: The user ID.
|
||||||
|
:return: List of source dictionaries.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
source.to_dict()
|
||||||
|
for source in self.sources.values()
|
||||||
|
if source.creator_id == user_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_all_sources(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all sources as dictionaries."""
|
||||||
|
return [source.to_dict() for source in self.sources.values()]
|
||||||
|
|
||||||
|
def delete_source(self, tbl_key: str, user_id: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete an external source.
|
||||||
|
|
||||||
|
:param tbl_key: The unique key of the source.
|
||||||
|
:param user_id: The user requesting deletion (must be creator).
|
||||||
|
:return: Dict with success status.
|
||||||
|
"""
|
||||||
|
source = self.sources.get(tbl_key)
|
||||||
|
if not source:
|
||||||
|
return {'success': False, 'message': 'Source not found'}
|
||||||
|
|
||||||
|
if source.creator_id != user_id:
|
||||||
|
return {'success': False, 'message': 'Not authorized to delete this source'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Remove from database
|
||||||
|
self.data_cache.db.execute_sql(
|
||||||
|
"DELETE FROM external_sources WHERE tbl_key = ?",
|
||||||
|
params=(tbl_key,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove from memory
|
||||||
|
del self.sources[tbl_key]
|
||||||
|
|
||||||
|
logger.info(f"Deleted external source: {source.name}")
|
||||||
|
return {'success': True, 'message': f"Deleted source '{source.name}'"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting external source: {e}", exc_info=True)
|
||||||
|
return {'success': False, 'message': str(e)}
|
||||||
|
|
||||||
|
def get_source_value(self, name: str) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Get the current value of a source by name.
|
||||||
|
Used by the signals system.
|
||||||
|
|
||||||
|
:param name: The source name.
|
||||||
|
:return: The current value or None.
|
||||||
|
"""
|
||||||
|
source = self.get_source_by_name(name)
|
||||||
|
if source:
|
||||||
|
return source.last_value
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_sources_as_indicators(self, user_id: int) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get sources formatted like indicators for use in signals.
|
||||||
|
|
||||||
|
:param user_id: The user ID to get sources for.
|
||||||
|
:return: Dict mapping source names to indicator-like data.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for source in self.sources.values():
|
||||||
|
if source.creator_id == user_id and source.enabled:
|
||||||
|
result[source.name] = {
|
||||||
|
'type': 'EXTERNAL',
|
||||||
|
'value': source.last_value,
|
||||||
|
'source_type': 'external',
|
||||||
|
'tbl_key': source.tbl_key,
|
||||||
|
'last_fetch_time': source.last_fetch_time
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
@ -143,6 +143,8 @@ class PythonGenerator:
|
||||||
# Route indicator_* types to the generic indicator handler
|
# Route indicator_* types to the generic indicator handler
|
||||||
if node_type.startswith('indicator_'):
|
if node_type.startswith('indicator_'):
|
||||||
handler_method = self.handle_indicator
|
handler_method = self.handle_indicator
|
||||||
|
elif node_type.startswith('signal_'):
|
||||||
|
handler_method = self.handle_signal
|
||||||
else:
|
else:
|
||||||
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
|
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
|
||||||
handler_code = handler_method(node, indent_level)
|
handler_code = handler_method(node, indent_level)
|
||||||
|
|
@ -191,6 +193,8 @@ class PythonGenerator:
|
||||||
# Route indicator_* types to the generic indicator handler
|
# Route indicator_* types to the generic indicator handler
|
||||||
if node_type.startswith('indicator_'):
|
if node_type.startswith('indicator_'):
|
||||||
handler_method = self.handle_indicator
|
handler_method = self.handle_indicator
|
||||||
|
elif node_type.startswith('signal_'):
|
||||||
|
handler_method = self.handle_signal
|
||||||
else:
|
else:
|
||||||
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
|
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
|
||||||
condition_code = handler_method(condition_node, indent_level=indent_level)
|
condition_code = handler_method(condition_node, indent_level=indent_level)
|
||||||
|
|
@ -239,6 +243,54 @@ class PythonGenerator:
|
||||||
logger.debug(f"Generated indicator condition: {expr}")
|
logger.debug(f"Generated indicator condition: {expr}")
|
||||||
return expr
|
return expr
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Signals Handlers
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
def handle_signal(self, node: Dict[str, Any], indent_level: int) -> str:
|
||||||
|
"""
|
||||||
|
Handles signal nodes by generating a function call to check signal state.
|
||||||
|
Supports both:
|
||||||
|
- Generic 'signal' type with NAME field
|
||||||
|
- Custom 'signal_<name>' types where name is extracted from the type
|
||||||
|
|
||||||
|
:param node: The signal node.
|
||||||
|
:param indent_level: Current indentation level.
|
||||||
|
:return: A string representing the signal check.
|
||||||
|
"""
|
||||||
|
fields = node.get('fields', {})
|
||||||
|
node_type = node.get('type', '')
|
||||||
|
|
||||||
|
# Try to get signal name from fields first, then from type
|
||||||
|
signal_name = fields.get('NAME')
|
||||||
|
if not signal_name and node_type.startswith('signal_'):
|
||||||
|
# Extract name from type, e.g., 'signal_my_signal' -> 'my_signal'
|
||||||
|
# Replace underscores back to spaces for display names
|
||||||
|
signal_name = node_type[len('signal_'):].replace('_', ' ')
|
||||||
|
|
||||||
|
output_field = fields.get('OUTPUT', 'triggered')
|
||||||
|
|
||||||
|
if not signal_name:
|
||||||
|
logger.error(f"signal node missing name. type={node_type}, fields={fields}")
|
||||||
|
return 'False'
|
||||||
|
|
||||||
|
# Collect the signal information
|
||||||
|
if not hasattr(self, 'signals_used'):
|
||||||
|
self.signals_used = []
|
||||||
|
self.signals_used.append({
|
||||||
|
'name': signal_name,
|
||||||
|
'output': output_field
|
||||||
|
})
|
||||||
|
|
||||||
|
# Generate code that calls process_signal
|
||||||
|
if output_field == 'triggered':
|
||||||
|
expr = f"process_signal('{signal_name}', 'triggered')"
|
||||||
|
else:
|
||||||
|
expr = f"process_signal('{signal_name}', 'value')"
|
||||||
|
|
||||||
|
logger.debug(f"Generated signal condition: {expr}")
|
||||||
|
return expr
|
||||||
|
|
||||||
# ==============================
|
# ==============================
|
||||||
# Balances Handlers
|
# Balances Handlers
|
||||||
# ==============================
|
# ==============================
|
||||||
|
|
@ -1192,14 +1244,43 @@ class PythonGenerator:
|
||||||
def handle_notify_user(self, node: Dict[str, Any], indent_level: int) -> List[str]:
|
def handle_notify_user(self, node: Dict[str, Any], indent_level: int) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Handles the 'notify_user' node type.
|
Handles the 'notify_user' node type.
|
||||||
|
Supports both static text messages and dynamic values (variables, calculations, etc.).
|
||||||
|
|
||||||
:param node: The notify_user node.
|
:param node: The notify_user node.
|
||||||
:param indent_level: Current indentation level.
|
:param indent_level: Current indentation level.
|
||||||
:return: List of generated code lines.
|
:return: List of generated code lines.
|
||||||
"""
|
"""
|
||||||
indent = ' ' * indent_level
|
indent = ' ' * indent_level
|
||||||
message = node.get('message', 'No message provided.').replace("'", "\\'")
|
message = node.get('message', '').replace("'", "\\'")
|
||||||
|
value_node = node.get('value')
|
||||||
|
|
||||||
|
# Generate the dynamic value expression if present
|
||||||
|
value_expr = None
|
||||||
|
if value_node:
|
||||||
|
if isinstance(value_node, dict):
|
||||||
|
result = self.generate_code_from_json(value_node, indent_level)
|
||||||
|
# Handle case where result is a list (from dynamic_value or value_input)
|
||||||
|
if isinstance(result, list):
|
||||||
|
# Use first element if single, otherwise join them
|
||||||
|
value_expr = result[0] if len(result) == 1 else ', '.join(str(v) for v in result)
|
||||||
|
else:
|
||||||
|
value_expr = result
|
||||||
|
else:
|
||||||
|
value_expr = str(value_node)
|
||||||
|
|
||||||
|
# Build the notify_user call based on what's provided
|
||||||
|
if message and value_expr:
|
||||||
|
# Both static message and dynamic value: concatenate with str()
|
||||||
|
return [f"{indent}notify_user('{message}' + str({value_expr}))"]
|
||||||
|
elif message:
|
||||||
|
# Only static message
|
||||||
return [f"{indent}notify_user('{message}')"]
|
return [f"{indent}notify_user('{message}')"]
|
||||||
|
elif value_expr:
|
||||||
|
# Only dynamic value
|
||||||
|
return [f"{indent}notify_user(str({value_expr}))"]
|
||||||
|
else:
|
||||||
|
# Neither provided - fallback
|
||||||
|
return [f"{indent}notify_user('No message provided.')"]
|
||||||
|
|
||||||
def handle_value_input(self, node: Dict[str, Any], indent_level: int) -> Union[str, List[str]]:
|
def handle_value_input(self, node: Dict[str, Any], indent_level: int) -> Union[str, List[str]]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -50,25 +50,33 @@ class Signal:
|
||||||
def compare(self):
|
def compare(self):
|
||||||
if self.value1 is None:
|
if self.value1 is None:
|
||||||
raise ValueError('Signal: Cannot compare: value1 not set')
|
raise ValueError('Signal: Cannot compare: value1 not set')
|
||||||
if self.value2 is None:
|
|
||||||
raise ValueError('Signal: Cannot compare: value2 not set')
|
|
||||||
|
|
||||||
previous_state = self.state
|
previous_state = self.state
|
||||||
|
|
||||||
if self.operator == '+/-':
|
# Handle pattern operators (only need value1)
|
||||||
|
if self.operator == 'is_bullish':
|
||||||
|
# Bullish patterns return positive values (typically 100)
|
||||||
|
self.state = self.value1 > 0
|
||||||
|
elif self.operator == 'is_bearish':
|
||||||
|
# Bearish patterns return negative values (typically -100)
|
||||||
|
self.state = self.value1 < 0
|
||||||
|
elif self.operator == 'is_detected':
|
||||||
|
# Any pattern (bullish or bearish) returns non-zero
|
||||||
|
self.state = self.value1 != 0
|
||||||
|
elif self.operator == '+/-':
|
||||||
|
# Range comparison requires both values
|
||||||
|
if self.value2 is None:
|
||||||
|
raise ValueError('Signal: Cannot compare: value2 not set')
|
||||||
if self.range is None:
|
if self.range is None:
|
||||||
raise ValueError('Signal: Cannot compare: range not set')
|
raise ValueError('Signal: Cannot compare: range not set')
|
||||||
if abs(self.value1 - self.value2) < self.range:
|
self.state = abs(self.value1 - self.value2) < self.range
|
||||||
self.state = True
|
|
||||||
else:
|
|
||||||
self.state = False
|
|
||||||
else:
|
else:
|
||||||
|
# Standard comparison operators (>, <, ==)
|
||||||
|
if self.value2 is None:
|
||||||
|
raise ValueError('Signal: Cannot compare: value2 not set')
|
||||||
string = str(self.value1) + self.operator + str(self.value2)
|
string = str(self.value1) + self.operator + str(self.value2)
|
||||||
logger.debug(f"Signal comparison: {string}")
|
logger.debug(f"Signal comparison: {string}")
|
||||||
if eval(string):
|
self.state = eval(string)
|
||||||
self.state = True
|
|
||||||
else:
|
|
||||||
self.state = False
|
|
||||||
|
|
||||||
state_change = self.state != previous_state
|
state_change = self.state != previous_state
|
||||||
return state_change
|
return state_change
|
||||||
|
|
@ -511,11 +519,13 @@ class Signals:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating signal state: {e}", exc_info=True)
|
logger.error(f"Error updating signal state: {e}", exc_info=True)
|
||||||
|
|
||||||
def process_all_signals(self, indicators) -> dict:
|
def process_all_signals(self, indicators, external_sources=None, external_indicators=None) -> dict:
|
||||||
"""
|
"""
|
||||||
Loop through all signals and process them based on the last indicator results.
|
Loop through all signals and process them based on the last indicator results.
|
||||||
|
|
||||||
:param indicators: The Indicators instance with calculated values.
|
:param indicators: The Indicators instance with calculated values.
|
||||||
|
:param external_sources: Optional ExternalSources instance for custom signal types (real-time only).
|
||||||
|
:param external_indicators: Optional ExternalIndicatorsManager for historical API indicators.
|
||||||
:return: Dictionary of signals that changed state.
|
:return: Dictionary of signals that changed state.
|
||||||
"""
|
"""
|
||||||
state_changes = {}
|
state_changes = {}
|
||||||
|
|
@ -535,6 +545,27 @@ class Signals:
|
||||||
name: IndicatorWrapper(data)
|
name: IndicatorWrapper(data)
|
||||||
for name, data in indicator_list.items()
|
for name, data in indicator_list.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add external sources as indicator-like entries (real-time)
|
||||||
|
if external_sources:
|
||||||
|
external_data = external_sources.get_sources_as_indicators(user_id)
|
||||||
|
for name, data in external_data.items():
|
||||||
|
user_indicator_cache[user_id][name] = IndicatorWrapper(data)
|
||||||
|
|
||||||
|
# Add external indicators as indicator-like entries (historical API)
|
||||||
|
if external_indicators:
|
||||||
|
ext_ind_data = external_indicators.get_indicators_as_indicator_list(user_id)
|
||||||
|
for name, data in ext_ind_data.items():
|
||||||
|
# For live trading, get the most recent value
|
||||||
|
ext_ind = external_indicators.get_indicator_by_name(name, user_id)
|
||||||
|
if ext_ind:
|
||||||
|
# Fetch today's value
|
||||||
|
from datetime import datetime
|
||||||
|
today_value = ext_ind.get_value_for_timestamp(datetime.now())
|
||||||
|
if today_value is not None:
|
||||||
|
data['value'] = today_value
|
||||||
|
user_indicator_cache[user_id][name] = IndicatorWrapper(data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not fetch indicators for user {user_id}: {e}")
|
logger.debug(f"Could not fetch indicators for user {user_id}: {e}")
|
||||||
user_indicator_cache[user_id] = {}
|
user_indicator_cache[user_id] = {}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ class StrategyInstance:
|
||||||
'exit_strategy': self.exit_strategy,
|
'exit_strategy': self.exit_strategy,
|
||||||
'notify_user': self.notify_user,
|
'notify_user': self.notify_user,
|
||||||
'process_indicator': self.process_indicator,
|
'process_indicator': self.process_indicator,
|
||||||
|
'process_signal': self.process_signal,
|
||||||
'get_strategy_profit_loss': self.get_strategy_profit_loss,
|
'get_strategy_profit_loss': self.get_strategy_profit_loss,
|
||||||
'is_in_profit': self.is_in_profit,
|
'is_in_profit': self.is_in_profit,
|
||||||
'is_in_loss': self.is_in_loss,
|
'is_in_loss': self.is_in_loss,
|
||||||
|
|
@ -774,6 +775,45 @@ class StrategyInstance:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def process_signal(self, signal_name: str, output_field: str = 'triggered') -> Any:
|
||||||
|
"""
|
||||||
|
Checks the state of a user-defined signal.
|
||||||
|
|
||||||
|
:param signal_name: Name of the signal.
|
||||||
|
:param output_field: Either 'triggered' (returns bool) or 'value' (returns numeric).
|
||||||
|
:return: Boolean for triggered, numeric value for value, or None on error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get the signal from the signals manager
|
||||||
|
if not hasattr(self, 'signals') or self.signals is None:
|
||||||
|
logger.warning(f"Signals manager not available in StrategyInstance")
|
||||||
|
return False if output_field == 'triggered' else None
|
||||||
|
|
||||||
|
# Look up the signal by name
|
||||||
|
signal = self.signals.get_signal_by_name(signal_name)
|
||||||
|
if signal is None:
|
||||||
|
logger.warning(f"Signal '{signal_name}' not found")
|
||||||
|
return False if output_field == 'triggered' else None
|
||||||
|
|
||||||
|
if output_field == 'triggered':
|
||||||
|
# Return whether the signal condition is currently met
|
||||||
|
# Signal is a dataclass with 'state' attribute
|
||||||
|
is_triggered = getattr(signal, 'state', False)
|
||||||
|
logger.debug(f"Signal '{signal_name}' triggered: {is_triggered}")
|
||||||
|
return is_triggered
|
||||||
|
else:
|
||||||
|
# Return the numeric value of the signal (value1)
|
||||||
|
value = getattr(signal, 'value1', None)
|
||||||
|
logger.debug(f"Signal '{signal_name}' value: {value}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error processing signal '{signal_name}' in StrategyInstance '{self.strategy_instance_id}': {e}",
|
||||||
|
exc_info=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
return False if output_field == 'triggered' else None
|
||||||
|
|
||||||
def get_strategy_profit_loss(self, strategy_id: str) -> float:
|
def get_strategy_profit_loss(self, strategy_id: str) -> float:
|
||||||
"""
|
"""
|
||||||
Retrieves the current profit or loss of the strategy.
|
Retrieves the current profit or loss of the strategy.
|
||||||
|
|
|
||||||
180
src/app.py
180
src/app.py
|
|
@ -1032,6 +1032,186 @@ def admin_credit_user():
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# External Sources API Routes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.route('/api/external_sources/test', methods=['POST'])
|
||||||
|
def test_external_source():
|
||||||
|
"""Test an external source API connection without saving."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
url = data.get('url')
|
||||||
|
auth_header = data.get('auth_header', '')
|
||||||
|
auth_key = data.get('auth_key', '')
|
||||||
|
json_path = data.get('json_path', '$.data[0].value')
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return jsonify({'success': False, 'error': 'URL is required'})
|
||||||
|
|
||||||
|
from ExternalSources import ExternalSource
|
||||||
|
source = ExternalSource(
|
||||||
|
name='test',
|
||||||
|
url=url,
|
||||||
|
auth_header=auth_header,
|
||||||
|
auth_key=auth_key,
|
||||||
|
json_path=json_path
|
||||||
|
)
|
||||||
|
|
||||||
|
result = brighter_trades.external_sources._fetch_value(source)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/external_sources/create', methods=['POST'])
|
||||||
|
def create_external_source():
|
||||||
|
"""Create a new external source."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'message': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name')
|
||||||
|
url = data.get('url')
|
||||||
|
auth_header = data.get('auth_header', '')
|
||||||
|
auth_key = data.get('auth_key', '')
|
||||||
|
json_path = data.get('json_path', '$.data[0].value')
|
||||||
|
refresh_interval = data.get('refresh_interval', 300)
|
||||||
|
|
||||||
|
if not name or not url:
|
||||||
|
return jsonify({'success': False, 'message': 'Name and URL are required'})
|
||||||
|
|
||||||
|
result = brighter_trades.external_sources.create_source(
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
auth_header=auth_header,
|
||||||
|
auth_key=auth_key,
|
||||||
|
json_path=json_path,
|
||||||
|
refresh_interval=refresh_interval,
|
||||||
|
creator_id=user_id
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/external_sources/list', methods=['GET'])
|
||||||
|
def list_external_sources():
|
||||||
|
"""List all external sources for the current user."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
sources = brighter_trades.external_sources.get_sources_for_user(user_id)
|
||||||
|
return jsonify({'success': True, 'sources': sources})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/external_sources/refresh/<tbl_key>', methods=['POST'])
|
||||||
|
def refresh_external_source(tbl_key):
|
||||||
|
"""Refresh a specific external source."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
result = brighter_trades.external_sources.refresh_source(tbl_key)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/external_sources/delete/<tbl_key>', methods=['DELETE'])
|
||||||
|
def delete_external_source(tbl_key):
|
||||||
|
"""Delete an external source."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'message': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
result = brighter_trades.external_sources.delete_source(tbl_key, user_id)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# External Indicators API Routes (Historical data for backtesting)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.route('/api/external_indicators/test', methods=['POST'])
|
||||||
|
def test_external_indicator():
|
||||||
|
"""Test an external indicator API configuration without saving."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
historical_url = data.get('historical_url')
|
||||||
|
auth_header = data.get('auth_header', '')
|
||||||
|
auth_key = data.get('auth_key', '')
|
||||||
|
value_jsonpath = data.get('value_jsonpath', '$.data[*].value')
|
||||||
|
timestamp_jsonpath = data.get('timestamp_jsonpath', '$.data[*].timestamp')
|
||||||
|
date_format = data.get('date_format', 'ISO')
|
||||||
|
date_param_format = data.get('date_param_format', 'ISO')
|
||||||
|
|
||||||
|
if not historical_url:
|
||||||
|
return jsonify({'success': False, 'error': 'Historical URL is required'})
|
||||||
|
|
||||||
|
result = brighter_trades.external_indicators.test_indicator(
|
||||||
|
historical_url=historical_url,
|
||||||
|
auth_header=auth_header,
|
||||||
|
auth_key=auth_key,
|
||||||
|
value_jsonpath=value_jsonpath,
|
||||||
|
timestamp_jsonpath=timestamp_jsonpath,
|
||||||
|
date_format=date_format,
|
||||||
|
date_param_format=date_param_format
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/external_indicators/create', methods=['POST'])
|
||||||
|
def create_external_indicator():
|
||||||
|
"""Create a new external indicator."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'message': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name')
|
||||||
|
historical_url = data.get('historical_url')
|
||||||
|
auth_header = data.get('auth_header', '')
|
||||||
|
auth_key = data.get('auth_key', '')
|
||||||
|
value_jsonpath = data.get('value_jsonpath', '$.data[*].value')
|
||||||
|
timestamp_jsonpath = data.get('timestamp_jsonpath', '$.data[*].timestamp')
|
||||||
|
date_format = data.get('date_format', 'ISO')
|
||||||
|
date_param_format = data.get('date_param_format', 'ISO')
|
||||||
|
|
||||||
|
if not name or not historical_url:
|
||||||
|
return jsonify({'success': False, 'message': 'Name and Historical URL are required'})
|
||||||
|
|
||||||
|
result = brighter_trades.external_indicators.create_indicator(
|
||||||
|
name=name,
|
||||||
|
historical_url=historical_url,
|
||||||
|
auth_header=auth_header,
|
||||||
|
auth_key=auth_key,
|
||||||
|
value_jsonpath=value_jsonpath,
|
||||||
|
timestamp_jsonpath=timestamp_jsonpath,
|
||||||
|
date_format=date_format,
|
||||||
|
date_param_format=date_param_format,
|
||||||
|
creator_id=user_id
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/external_indicators/list', methods=['GET'])
|
||||||
|
def list_external_indicators():
|
||||||
|
"""List all external indicators for the current user."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
indicators = brighter_trades.external_indicators.get_indicators_for_user(user_id)
|
||||||
|
return jsonify({'success': True, 'indicators': indicators})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/external_indicators/delete/<tbl_key>', methods=['DELETE'])
|
||||||
|
def delete_external_indicator(tbl_key):
|
||||||
|
"""Delete an external indicator."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'message': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
result = brighter_trades.external_indicators.delete_indicator(tbl_key, user_id)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Health Check Routes
|
# Health Check Routes
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,27 @@ class BacktestStrategyInstance(StrategyInstance):
|
||||||
logger.error("Backtrader strategy is not set in StrategyInstance.")
|
logger.error("Backtrader strategy is not set in StrategyInstance.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Try direct lookup first
|
||||||
df = self.backtrader_strategy.precomputed_indicators.get(indicator_name)
|
df = self.backtrader_strategy.precomputed_indicators.get(indicator_name)
|
||||||
|
|
||||||
|
# If not found, try alternative name formats
|
||||||
|
if df is None:
|
||||||
|
available = list(self.backtrader_strategy.precomputed_indicators.keys())
|
||||||
|
|
||||||
|
# Try underscore->space conversion (Blockly sanitizes names with underscores)
|
||||||
|
alt_name = indicator_name.replace('_', ' ')
|
||||||
|
if alt_name in available:
|
||||||
|
df = self.backtrader_strategy.precomputed_indicators.get(alt_name)
|
||||||
|
logger.debug(f"[BACKTEST] Found indicator '{alt_name}' (converted from '{indicator_name}')")
|
||||||
|
|
||||||
|
# Try case-insensitive match
|
||||||
|
if df is None:
|
||||||
|
for avail_name in available:
|
||||||
|
if avail_name.lower() == indicator_name.lower():
|
||||||
|
df = self.backtrader_strategy.precomputed_indicators.get(avail_name)
|
||||||
|
logger.debug(f"[BACKTEST] Found indicator '{avail_name}' (case-insensitive match)")
|
||||||
|
break
|
||||||
|
|
||||||
if df is None:
|
if df is None:
|
||||||
logger.error(f"[BACKTEST DEBUG] Indicator '{indicator_name}' not found in precomputed_indicators!")
|
logger.error(f"[BACKTEST DEBUG] Indicator '{indicator_name}' not found in precomputed_indicators!")
|
||||||
logger.error(f"[BACKTEST DEBUG] Available indicators: {list(self.backtrader_strategy.precomputed_indicators.keys())}")
|
logger.error(f"[BACKTEST DEBUG] Available indicators: {list(self.backtrader_strategy.precomputed_indicators.keys())}")
|
||||||
|
|
@ -219,6 +239,50 @@ class BacktestStrategyInstance(StrategyInstance):
|
||||||
logger.info(f"[BACKTEST] process_indicator returning: {indicator_name}.{output_field} = {value}")
|
logger.info(f"[BACKTEST] process_indicator returning: {indicator_name}.{output_field} = {value}")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
# 2b. Override process_signal for backtesting
|
||||||
|
def process_signal(self, signal_name: str, output_field: str = 'triggered'):
|
||||||
|
"""
|
||||||
|
Evaluates a signal condition during backtesting.
|
||||||
|
|
||||||
|
For backtesting, signals can be precomputed or we return a placeholder.
|
||||||
|
Full signal backtesting support requires precomputed signal states.
|
||||||
|
"""
|
||||||
|
logger.debug(f"[BACKTEST] process_signal called: signal='{signal_name}', output='{output_field}'")
|
||||||
|
|
||||||
|
if self.backtrader_strategy is None:
|
||||||
|
logger.error("Backtrader strategy is not set in StrategyInstance.")
|
||||||
|
return False if output_field == 'triggered' else None
|
||||||
|
|
||||||
|
# Get precomputed signals if available
|
||||||
|
precomputed_signals = getattr(self.backtrader_strategy, 'precomputed_signals', {})
|
||||||
|
signal_data = precomputed_signals.get(signal_name)
|
||||||
|
|
||||||
|
if signal_data is not None and len(signal_data) > 0:
|
||||||
|
# Use precomputed signal data
|
||||||
|
signal_pointer = self.backtrader_strategy.signal_pointers.get(signal_name, 0)
|
||||||
|
|
||||||
|
if signal_pointer >= len(signal_data):
|
||||||
|
logger.debug(f"[BACKTEST] Signal '{signal_name}' pointer out of bounds: {signal_pointer}")
|
||||||
|
return False if output_field == 'triggered' else None
|
||||||
|
|
||||||
|
signal_row = signal_data.iloc[signal_pointer]
|
||||||
|
|
||||||
|
if output_field == 'triggered':
|
||||||
|
triggered = bool(signal_row.get('triggered', False))
|
||||||
|
return triggered
|
||||||
|
else:
|
||||||
|
value = signal_row.get('value', None)
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
# Signal not precomputed - log warning once and return default
|
||||||
|
if not hasattr(self, '_signal_warnings'):
|
||||||
|
self._signal_warnings = set()
|
||||||
|
if signal_name not in self._signal_warnings:
|
||||||
|
logger.warning(f"[BACKTEST] Signal '{signal_name}' not precomputed. "
|
||||||
|
"Signal blocks in backtesting require precomputed signal data.")
|
||||||
|
self._signal_warnings.add(signal_name)
|
||||||
|
return False if output_field == 'triggered' else None
|
||||||
|
|
||||||
# 3. Override get_current_price
|
# 3. Override get_current_price
|
||||||
def get_current_price(self, timeframe: str = '1h', exchange: str = 'binance',
|
def get_current_price(self, timeframe: str = '1h', exchange: str = 'binance',
|
||||||
symbol: str = 'BTC/USD') -> float:
|
symbol: str = 'BTC/USD') -> float:
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,14 @@ class EquityCurveAnalyzer(bt.Analyzer):
|
||||||
# Backtester Class
|
# Backtester Class
|
||||||
class Backtester:
|
class Backtester:
|
||||||
def __init__(self, data_cache: DataCache, strategies: Strategies, indicators: Indicators, socketio,
|
def __init__(self, data_cache: DataCache, strategies: Strategies, indicators: Indicators, socketio,
|
||||||
edm_client=None):
|
edm_client=None, external_indicators=None):
|
||||||
""" Initialize the Backtesting class with a cache for back-tests """
|
""" Initialize the Backtesting class with a cache for back-tests """
|
||||||
self.data_cache = data_cache
|
self.data_cache = data_cache
|
||||||
self.strategies = strategies
|
self.strategies = strategies
|
||||||
self.indicators_manager = indicators
|
self.indicators_manager = indicators
|
||||||
self.socketio = socketio
|
self.socketio = socketio
|
||||||
self.edm_client = edm_client
|
self.edm_client = edm_client
|
||||||
|
self.external_indicators = external_indicators
|
||||||
|
|
||||||
# Ensure 'tests' cache exists
|
# Ensure 'tests' cache exists
|
||||||
self.data_cache.create_cache(
|
self.data_cache.create_cache(
|
||||||
|
|
@ -394,6 +395,27 @@ class Backtester:
|
||||||
logger.info(f"[BACKTEST] Computing indicator '{indicator_name}' on backtest data feed")
|
logger.info(f"[BACKTEST] Computing indicator '{indicator_name}' on backtest data feed")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# First, check if this is an external indicator (historical API)
|
||||||
|
if self.external_indicators:
|
||||||
|
ext_indicator = self.external_indicators.get_indicator_by_name(indicator_name, user_id)
|
||||||
|
if ext_indicator:
|
||||||
|
logger.info(f"[BACKTEST] '{indicator_name}' is an external indicator - fetching historical data from API")
|
||||||
|
# Use the external indicator's calculate method
|
||||||
|
result_series = ext_indicator.calculate(candle_data)
|
||||||
|
|
||||||
|
if result_series is not None and len(result_series) > 0:
|
||||||
|
# Convert series to DataFrame with 'value' column
|
||||||
|
result_df = pd.DataFrame({
|
||||||
|
'time': candle_data['time'] if 'time' in candle_data.columns else range(len(result_series)),
|
||||||
|
'value': result_series.values
|
||||||
|
})
|
||||||
|
result_df.reset_index(drop=True, inplace=True)
|
||||||
|
precomputed_indicators[indicator_name] = result_df
|
||||||
|
logger.info(f"[BACKTEST] Precomputed external indicator '{indicator_name}' with {len(result_df)} data points.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[BACKTEST] No historical data available for external indicator '{indicator_name}'.")
|
||||||
|
continue
|
||||||
|
|
||||||
# Fetch indicator definition from cache
|
# Fetch indicator definition from cache
|
||||||
indicators = self.data_cache.get_rows_from_datacache(
|
indicators = self.data_cache.get_rows_from_datacache(
|
||||||
cache_name='indicators',
|
cache_name='indicators',
|
||||||
|
|
@ -613,7 +635,8 @@ class Backtester:
|
||||||
|
|
||||||
def run_backtest(self, strategy_class, data_feed: pd.DataFrame, msg_data: dict, user_name: str,
|
def run_backtest(self, strategy_class, data_feed: pd.DataFrame, msg_data: dict, user_name: str,
|
||||||
socket_conn_id: str, strategy_instance: BacktestStrategyInstance, backtest_name: str,
|
socket_conn_id: str, strategy_instance: BacktestStrategyInstance, backtest_name: str,
|
||||||
user_id: str, backtest_key: str, strategy_name: str, precomputed_indicators: dict):
|
user_id: str, backtest_key: str, strategy_name: str, precomputed_indicators: dict,
|
||||||
|
precomputed_signals: dict = None):
|
||||||
"""
|
"""
|
||||||
Runs a backtest using Backtrader and uses Flask-SocketIO's background tasks.
|
Runs a backtest using Backtrader and uses Flask-SocketIO's background tasks.
|
||||||
Sends progress updates to the client via WebSocket.
|
Sends progress updates to the client via WebSocket.
|
||||||
|
|
@ -654,6 +677,7 @@ class Backtester:
|
||||||
strategy_class,
|
strategy_class,
|
||||||
strategy_instance=strategy_instance,
|
strategy_instance=strategy_instance,
|
||||||
precomputed_indicators=precomputed_indicators, # Pass precomputed indicators
|
precomputed_indicators=precomputed_indicators, # Pass precomputed indicators
|
||||||
|
precomputed_signals=precomputed_signals or {}, # Pass precomputed signals
|
||||||
socketio=self.socketio, # Pass SocketIO instance
|
socketio=self.socketio, # Pass SocketIO instance
|
||||||
socket_conn_id=socket_conn_id, # Pass SocketIO connection ID
|
socket_conn_id=socket_conn_id, # Pass SocketIO connection ID
|
||||||
data_length=len(data_feed), # Pass data length for progress updates
|
data_length=len(data_feed), # Pass data length for progress updates
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class MappedStrategy(bt.Strategy):
|
||||||
params = (
|
params = (
|
||||||
('strategy_instance', None), # Instance of BacktestStrategyInstance
|
('strategy_instance', None), # Instance of BacktestStrategyInstance
|
||||||
('precomputed_indicators', None), # Dict of precomputed indicators
|
('precomputed_indicators', None), # Dict of precomputed indicators
|
||||||
|
('precomputed_signals', None), # Dict of precomputed signal states
|
||||||
('socketio', None), # SocketIO instance for emitting progress
|
('socketio', None), # SocketIO instance for emitting progress
|
||||||
('socket_conn_id', None), # Socket connection ID for emitting progress
|
('socket_conn_id', None), # Socket connection ID for emitting progress
|
||||||
('data_length', None), # Total number of data points for progress calculation
|
('data_length', None), # Total number of data points for progress calculation
|
||||||
|
|
@ -41,6 +42,12 @@ class MappedStrategy(bt.Strategy):
|
||||||
self.precomputed_indicators: Dict[str, pd.DataFrame] = self.p.precomputed_indicators or {}
|
self.precomputed_indicators: Dict[str, pd.DataFrame] = self.p.precomputed_indicators or {}
|
||||||
self.indicator_pointers: Dict[str, int] = {name: 0 for name in self.precomputed_indicators.keys()}
|
self.indicator_pointers: Dict[str, int] = {name: 0 for name in self.precomputed_indicators.keys()}
|
||||||
self.indicator_names = list(self.precomputed_indicators.keys())
|
self.indicator_names = list(self.precomputed_indicators.keys())
|
||||||
|
|
||||||
|
# Initialize signal data
|
||||||
|
self.precomputed_signals: Dict[str, pd.DataFrame] = self.p.precomputed_signals or {}
|
||||||
|
self.signal_pointers: Dict[str, int] = {name: 0 for name in self.precomputed_signals.keys()}
|
||||||
|
self.signal_names = list(self.precomputed_signals.keys())
|
||||||
|
|
||||||
self.current_step = 0
|
self.current_step = 0
|
||||||
|
|
||||||
# Initialize lists to store orders and trades
|
# Initialize lists to store orders and trades
|
||||||
|
|
@ -158,6 +165,11 @@ class MappedStrategy(bt.Strategy):
|
||||||
if name in self.indicator_pointers:
|
if name in self.indicator_pointers:
|
||||||
self.indicator_pointers[name] += 1
|
self.indicator_pointers[name] += 1
|
||||||
|
|
||||||
|
# Advance signal pointers for the next candle
|
||||||
|
for name in self.signal_names:
|
||||||
|
if name in self.signal_pointers:
|
||||||
|
self.signal_pointers[name] += 1
|
||||||
|
|
||||||
# Check if we're at the second-to-last bar
|
# Check if we're at the second-to-last bar
|
||||||
if self.current_step == (self.p.data_length - 1):
|
if self.current_step == (self.p.data_length - 1):
|
||||||
if self.position:
|
if self.position:
|
||||||
|
|
|
||||||
|
|
@ -1050,6 +1050,10 @@ class StratWorkspaceManager {
|
||||||
const indicatorBlocksModule = await import('./blocks/indicator_blocks.js');
|
const indicatorBlocksModule = await import('./blocks/indicator_blocks.js');
|
||||||
indicatorBlocksModule.defineIndicatorBlocks();
|
indicatorBlocksModule.defineIndicatorBlocks();
|
||||||
|
|
||||||
|
// Load and define signal blocks
|
||||||
|
const signalBlocksModule = await import('./blocks/signal_blocks.js');
|
||||||
|
signalBlocksModule.defineSignalBlocks();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading Blockly modules: ", error);
|
console.error("Error loading Blockly modules: ", error);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -19,23 +19,29 @@ export function defineValuesAndFlags() {
|
||||||
* Description:
|
* Description:
|
||||||
* This block allows users to send customizable notification messages to the user.
|
* This block allows users to send customizable notification messages to the user.
|
||||||
* It can be used to inform about strategy status updates, alerts, or other important events.
|
* It can be used to inform about strategy status updates, alerts, or other important events.
|
||||||
|
* Supports both static text messages and dynamic values (variables, calculations, etc.).
|
||||||
*/
|
*/
|
||||||
Blockly.defineBlocksWithJsonArray([{
|
Blockly.defineBlocksWithJsonArray([{
|
||||||
"type": "notify_user",
|
"type": "notify_user",
|
||||||
"message0": "Notify User %1",
|
"message0": "Notify User %1 %2",
|
||||||
"args0": [
|
"args0": [
|
||||||
{
|
{
|
||||||
"type": "field_input",
|
"type": "field_input",
|
||||||
"name": "MESSAGE",
|
"name": "MESSAGE",
|
||||||
"text": "Message",
|
"text": "",
|
||||||
"check": "String",
|
|
||||||
"spellcheck": true
|
"spellcheck": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input_value",
|
||||||
|
"name": "VALUE",
|
||||||
|
"check": ["number", "string", "dynamic_value"],
|
||||||
|
"align": "RIGHT"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previousStatement": null,
|
"previousStatement": null,
|
||||||
"nextStatement": null,
|
"nextStatement": null,
|
||||||
"colour": 210,
|
"colour": 210,
|
||||||
"tooltip": "Send a customizable notification message to the user. Can be used to inform about strategy status, alerts, or important events.",
|
"tooltip": "Send a notification message to the user. Enter a static message and/or attach dynamic values (variables, calculations). The values will be appended to the message.",
|
||||||
"helpUrl": "https://example.com/docs/notify_user"
|
"helpUrl": "https://example.com/docs/notify_user"
|
||||||
}]);
|
}]);
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,35 @@ export function defineVAFGenerators() {
|
||||||
* Generator for 'notify_user' Block
|
* Generator for 'notify_user' Block
|
||||||
*
|
*
|
||||||
* Description:
|
* Description:
|
||||||
* Generates a JSON object representing a user notification. The message
|
* Generates a JSON object representing a user notification. Supports both
|
||||||
* provided by the user is included in the JSON structure.
|
* static text messages and dynamic values (variables, calculations, etc.).
|
||||||
*/
|
*/
|
||||||
Blockly.JSON['notify_user'] = function(block) {
|
Blockly.JSON['notify_user'] = function(block) {
|
||||||
// Retrieve the 'MESSAGE' field input
|
// Retrieve the 'MESSAGE' field input (static text)
|
||||||
const message = block.getFieldValue('MESSAGE');
|
const message = block.getFieldValue('MESSAGE') || '';
|
||||||
|
|
||||||
// Validate the message
|
// Retrieve the connected VALUE block (dynamic value)
|
||||||
if (!message || message.trim() === "") {
|
const valueBlock = block.getInputTargetBlock('VALUE');
|
||||||
console.warn("Empty MESSAGE in notify_user block. Defaulting to 'No message provided.'");
|
let dynamicValue = null;
|
||||||
|
|
||||||
|
if (valueBlock) {
|
||||||
|
const generator = Blockly.JSON[valueBlock.type];
|
||||||
|
if (typeof generator === 'function') {
|
||||||
|
dynamicValue = generator(valueBlock);
|
||||||
|
} else {
|
||||||
|
console.warn(`No generator found for block type "${valueBlock.type}" in notify_user.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: at least one of message or dynamicValue should be present
|
||||||
|
if (message.trim() === "" && !dynamicValue) {
|
||||||
|
console.warn("Empty MESSAGE and no VALUE in notify_user block. Defaulting to 'No message provided.'");
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = {
|
const json = {
|
||||||
type: 'notify_user',
|
type: 'notify_user',
|
||||||
message: message && message.trim() !== "" ? message : 'No message provided.'
|
message: message.trim(),
|
||||||
|
value: dynamicValue
|
||||||
};
|
};
|
||||||
return json;
|
return json;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
// client/signal_blocks.js
|
||||||
|
|
||||||
|
// Define Blockly blocks for user-created signals
|
||||||
|
export function defineSignalBlocks() {
|
||||||
|
|
||||||
|
// Ensure Blockly.JSON is available
|
||||||
|
if (!Blockly.JSON) {
|
||||||
|
console.error('Blockly.JSON is not defined. Ensure json_generators.js is loaded before signal_blocks.js.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user signals
|
||||||
|
const signals = window.UI?.signals?.dataManager?.getAllSignals?.() || [];
|
||||||
|
const toolboxCategory = document.querySelector('#toolbox_advanced category[name="Signals"]');
|
||||||
|
|
||||||
|
if (!toolboxCategory) {
|
||||||
|
console.error('Signals category not found in the toolbox.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any signals to display
|
||||||
|
if (signals.length === 0) {
|
||||||
|
// Add helpful message when no signals exist
|
||||||
|
const labelElement = document.createElement('label');
|
||||||
|
labelElement.setAttribute('text', 'No signals configured yet.');
|
||||||
|
toolboxCategory.appendChild(labelElement);
|
||||||
|
|
||||||
|
const labelElement2 = document.createElement('label');
|
||||||
|
labelElement2.setAttribute('text', 'Create signals from the Signals panel');
|
||||||
|
toolboxCategory.appendChild(labelElement2);
|
||||||
|
|
||||||
|
const labelElement3 = document.createElement('label');
|
||||||
|
labelElement3.setAttribute('text', 'on the right side of the screen.');
|
||||||
|
toolboxCategory.appendChild(labelElement3);
|
||||||
|
|
||||||
|
console.log('No signals available - added help message to toolbox.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const signal of signals) {
|
||||||
|
const signalName = signal.name;
|
||||||
|
|
||||||
|
// Create a unique block type by replacing spaces with underscores
|
||||||
|
const sanitizedSignalName = signalName.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
|
||||||
|
const blockType = 'signal_' + sanitizedSignalName;
|
||||||
|
|
||||||
|
// Define the block for this signal
|
||||||
|
Blockly.defineBlocksWithJsonArray([{
|
||||||
|
"type": blockType,
|
||||||
|
"message0": `Signal: ${signalName} %1`,
|
||||||
|
"args0": [
|
||||||
|
{
|
||||||
|
"type": "field_dropdown",
|
||||||
|
"name": "OUTPUT",
|
||||||
|
"options": [
|
||||||
|
["is triggered", "triggered"],
|
||||||
|
["value", "value"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"output": "dynamic_value",
|
||||||
|
"colour": 160,
|
||||||
|
"tooltip": `Check if the '${signalName}' signal is triggered or get its value`,
|
||||||
|
"helpUrl": ""
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// Define the JSON generator for this block
|
||||||
|
Blockly.JSON[blockType] = function(block) {
|
||||||
|
const selectedOutput = block.getFieldValue('OUTPUT');
|
||||||
|
const json = {
|
||||||
|
type: 'signal',
|
||||||
|
fields: {
|
||||||
|
NAME: signalName,
|
||||||
|
OUTPUT: selectedOutput
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Output as dynamic_value
|
||||||
|
return {
|
||||||
|
type: 'dynamic_value',
|
||||||
|
values: [json]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append the newly created block to the Signals category in the toolbox
|
||||||
|
const blockElement = document.createElement('block');
|
||||||
|
blockElement.setAttribute('type', blockType);
|
||||||
|
toolboxCategory.appendChild(blockElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Signal blocks defined: ${signals.length} signals added to toolbox.`);
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,8 @@ class User_Interface {
|
||||||
this.initializeResizablePopup("exchanges_config_form", null, "exchange_draggable_header", "resize-exchange");
|
this.initializeResizablePopup("exchanges_config_form", null, "exchange_draggable_header", "resize-exchange");
|
||||||
this.initializeResizablePopup("new_ind_form", null, "indicator_draggable_header", "resize-indicator");
|
this.initializeResizablePopup("new_ind_form", null, "indicator_draggable_header", "resize-indicator");
|
||||||
this.initializeResizablePopup("new_sig_form", null, "signal_draggable_header", "resize-signal");
|
this.initializeResizablePopup("new_sig_form", null, "signal_draggable_header", "resize-signal");
|
||||||
|
this.initializeResizablePopup("new_signal_type_form", null, "signal_type_draggable_header", "resize-signal-type");
|
||||||
|
this.initializeResizablePopup("external_indicator_form", null, "external_indicator_header", "resize-external-indicator");
|
||||||
this.initializeResizablePopup("new_trade_form", null, "trade_draggable_header", "resize-trade");
|
this.initializeResizablePopup("new_trade_form", null, "trade_draggable_header", "resize-trade");
|
||||||
this.initializeResizablePopup("ai_strategy_form", null, "ai_strategy_header", "resize-ai-strategy");
|
this.initializeResizablePopup("ai_strategy_form", null, "ai_strategy_header", "resize-ai-strategy");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,14 @@ class Indicators {
|
||||||
indicatorOutputs[name] = ['value']; // Default output if not defined
|
indicatorOutputs[name] = ['value']; // Default output if not defined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include external indicators (historical API data)
|
||||||
|
const externalIndicators = window.UI?.signals?.externalIndicators || [];
|
||||||
|
for (const extInd of externalIndicators) {
|
||||||
|
// External indicators have a single 'value' output
|
||||||
|
indicatorOutputs[extInd.name] = ['value'];
|
||||||
|
}
|
||||||
|
|
||||||
return indicatorOutputs;
|
return indicatorOutputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,160 @@
|
||||||
|
<div class="form-popup" id="external_indicator_form" style="display: none; width: 500px;">
|
||||||
|
<div class="dialog-header" id="external_indicator_header">
|
||||||
|
<h1>Add External Indicator</h1>
|
||||||
|
<button class="dialog-close-btn" onclick="UI.signals.closeExternalIndicatorForm()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="form-container" onsubmit="return false;">
|
||||||
|
<p style="color: #888; font-size: 12px; margin-bottom: 15px;">
|
||||||
|
Configure an external API that provides historical data for backtesting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<label for="ext_ind_name"><b>Indicator Name:</b></label>
|
||||||
|
<input type="text" id="ext_ind_name" placeholder="e.g., Fear & Greed Index" required>
|
||||||
|
|
||||||
|
<!-- Historical URL -->
|
||||||
|
<label for="ext_ind_url"><b>Historical Data URL:</b></label>
|
||||||
|
<input type="text" id="ext_ind_url"
|
||||||
|
placeholder="https://api.example.com/data?start={start_date}&limit={limit}" required>
|
||||||
|
<p style="color: #666; font-size: 11px; margin: 5px 0 15px 0;">
|
||||||
|
Use placeholders: <code>{start_date}</code>, <code>{end_date}</code>, <code>{limit}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Auth Header -->
|
||||||
|
<label for="ext_ind_auth_header"><b>Auth Header Name:</b> (optional)</label>
|
||||||
|
<input type="text" id="ext_ind_auth_header" placeholder="e.g., X-CMC_PRO_API_KEY">
|
||||||
|
|
||||||
|
<!-- API Key -->
|
||||||
|
<label for="ext_ind_auth_key"><b>API Key:</b> (optional)</label>
|
||||||
|
<input type="password" id="ext_ind_auth_key" placeholder="Your API key">
|
||||||
|
|
||||||
|
<!-- JSONPath for Values -->
|
||||||
|
<label for="ext_ind_value_jsonpath"><b>Value JSONPath:</b></label>
|
||||||
|
<input type="text" id="ext_ind_value_jsonpath" value="$.data[*].value" required>
|
||||||
|
<p style="color: #666; font-size: 11px; margin: 5px 0 15px 0;">
|
||||||
|
JSONPath expression to extract array of numeric values
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- JSONPath for Timestamps -->
|
||||||
|
<label for="ext_ind_timestamp_jsonpath"><b>Timestamp JSONPath:</b></label>
|
||||||
|
<input type="text" id="ext_ind_timestamp_jsonpath" value="$.data[*].timestamp" required>
|
||||||
|
<p style="color: #666; font-size: 11px; margin: 5px 0 15px 0;">
|
||||||
|
JSONPath expression to extract array of timestamps
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Date Format in Response -->
|
||||||
|
<label for="ext_ind_date_format"><b>Timestamp Format in Response:</b></label>
|
||||||
|
<select id="ext_ind_date_format">
|
||||||
|
<option value="ISO">ISO (2024-01-15 or 2024-01-15T12:00:00Z)</option>
|
||||||
|
<option value="UNIX">Unix Timestamp (seconds or milliseconds)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Date Format for URL Parameters -->
|
||||||
|
<label for="ext_ind_date_param_format"><b>Date Format for URL Parameters:</b></label>
|
||||||
|
<select id="ext_ind_date_param_format">
|
||||||
|
<option value="ISO">ISO (2024-01-15)</option>
|
||||||
|
<option value="UNIX">Unix Timestamp</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<hr style="margin: 20px 0;">
|
||||||
|
|
||||||
|
<!-- Test Result Area -->
|
||||||
|
<div id="ext_ind_test_result" style="display: none; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div id="ext_ind_loading" style="display: none; text-align: center; padding: 10px;">
|
||||||
|
<span>Testing API...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
|
<button type="button" class="btn" onclick="UI.signals.testExternalIndicator()"
|
||||||
|
style="background: #17a2b8;">Test Connection</button>
|
||||||
|
<button type="button" class="btn" onclick="UI.signals.submitExternalIndicator()"
|
||||||
|
style="background: #28a745;">Save Indicator</button>
|
||||||
|
<button type="button" class="btn cancel" onclick="UI.signals.closeExternalIndicatorForm()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#external_indicator_form {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 1000;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#external_indicator_form .form-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#external_indicator_form label {
|
||||||
|
color: #e0e0e0;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#external_indicator_form input[type="text"],
|
||||||
|
#external_indicator_form input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#external_indicator_form select {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#external_indicator_form code {
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #17a2b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#external_indicator_form .btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#external_indicator_form .btn.cancel {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ext_ind_test_result.success {
|
||||||
|
background: rgba(40, 167, 69, 0.2);
|
||||||
|
border: 1px solid #28a745;
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ext_ind_test_result.error {
|
||||||
|
background: rgba(220, 53, 69, 0.2);
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -43,6 +43,8 @@
|
||||||
{% include "new_strategy_popup.html" %}
|
{% include "new_strategy_popup.html" %}
|
||||||
{% include "ai_strategy_dialog.html" %}
|
{% include "ai_strategy_dialog.html" %}
|
||||||
{% include "new_signal_popup.html" %}
|
{% include "new_signal_popup.html" %}
|
||||||
|
{% include "signal_type_popup.html" %}
|
||||||
|
{% include "external_indicator_popup.html" %}
|
||||||
{% include "new_indicator_popup.html" %}
|
{% include "new_indicator_popup.html" %}
|
||||||
{% include "trade_details_popup.html" %}
|
{% include "trade_details_popup.html" %}
|
||||||
{% include "indicator_popup.html" %}
|
{% include "indicator_popup.html" %}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,18 @@
|
||||||
<div class="section" style="grid-column: 1;">
|
<div class="section" style="grid-column: 1;">
|
||||||
<button class="btn" onclick="UI.indicators.open_form()">New Indicator</button>
|
<button class="btn" onclick="UI.indicators.open_form()">New Indicator</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Button for displaying indicator options -->
|
<!-- Button for adding an external API indicator -->
|
||||||
<div class="section" style="grid-column: 2;">
|
<div class="section" style="grid-column: 2;">
|
||||||
<button class="btn">Indicator Options</button>
|
<button class="btn" onclick="UI.signals.openExternalIndicatorForm()" style="width: 200px;">+ External Indicator</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- External Indicators Section -->
|
||||||
|
<div id="external_indicators_section" style="display: none; margin: 15px 0; padding: 10px; border-radius: 8px;">
|
||||||
|
<h3 style="color: #9f7aea; margin-bottom: 10px;">External Indicators <span style="font-size: 11px; color: #888;">(Historical API - works with backtesting)</span></h3>
|
||||||
|
<div class="external-indicators-container" id="external_indicators_list" style="display: flex; flex-wrap: wrap; gap: 10px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Indicator list display section -->
|
<!-- Indicator list display section -->
|
||||||
<div class="section" style="margin-top: 15px; overflow-x: auto; display: grid; grid-auto-rows: minmax(80px, auto); grid-template-columns: 75px 200px 100px 150px 150px 75px 100px 100px auto; gap: 10px; padding-left: 10px;">
|
<div class="section" style="margin-top: 15px; overflow-x: auto; display: grid; grid-auto-rows: minmax(80px, auto); grid-template-columns: 75px 200px 100px 150px 150px 75px 100px 100px auto; gap: 10px; padding-left: 10px;">
|
||||||
<div style="background-color: #F7E1C1; grid-column: 1 / span 9; padding: 5px 0; display: grid; grid-template-columns: 75px 200px 100px 150px 150px 75px 100px 100px auto; gap: 10px;">
|
<div style="background-color: #F7E1C1; grid-column: 1 / span 9; padding: 5px 0; display: grid; grid-template-columns: 75px 200px 100px 150px 150px 75px 100px 100px auto; gap: 10px;">
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@
|
||||||
|
|
||||||
<!-- Panel 2 of 3 -->
|
<!-- Panel 2 of 3 -->
|
||||||
<div id="panel_2" class="form_panels" style="display: none; grid-template-columns: 1fr 1fr; gap: 10px;">
|
<div id="panel_2" class="form_panels" style="display: none; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||||
<!-- Signal Type -->
|
<!-- Signal Type (hidden for pattern indicators) -->
|
||||||
<div style="grid-column: 1 / span 2;">
|
<div id="signal_type_row" style="grid-column: 1 / span 2;">
|
||||||
<label for="signal_type"><b>Signal Type:</b></label>
|
<label for="signal_type"><b>Signal Type:</b></label>
|
||||||
<select name="signal_type" id="select_s_type" style="margin-left: 10px;" onchange="UI.signals.hideIfTrue(this.value,'Value','subpanel_1');UI.signals.hideIfTrue(this.value,'Comparison','subpanel_2');">
|
<select name="signal_type" id="select_s_type" style="margin-left: 10px;" onchange="UI.signals.hideIfTrue(this.value,'Value','subpanel_1');UI.signals.hideIfTrue(this.value,'Comparison','subpanel_2');">
|
||||||
<option>Value</option>
|
<option>Value</option>
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
<!-- Display signal -->
|
<!-- Display signal -->
|
||||||
<span id="sig_display" style="grid-column: 1 / span 2; padding: 10px; background: #f8f9fa; border-radius: 5px;">name:{property}(operator)name:{property}</span>
|
<span id="sig_display" style="grid-column: 1 / span 2; padding: 10px; background: #f8f9fa; border-radius: 5px;">name:{property}(operator)name:{property}</span>
|
||||||
|
|
||||||
<!-- Operator field -->
|
<!-- Standard Operator field (for non-pattern indicators) -->
|
||||||
<div id="sig_operator" style="grid-column: 1 / span 2;">
|
<div id="sig_operator" style="grid-column: 1 / span 2;">
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px;">
|
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px;">
|
||||||
<label><input type="radio" id="gt" name="Operator" value=">"> Greater than</label>
|
<label><input type="radio" id="gt" name="Operator" value=">"> Greater than</label>
|
||||||
|
|
@ -79,6 +79,28 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pattern Operator field (for candlestick pattern indicators) -->
|
||||||
|
<div id="pattern_operator" style="grid-column: 1 / span 2; display: none;">
|
||||||
|
<label style="font-weight: bold; display: block; margin-bottom: 10px;">Pattern Detection:</label>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; padding: 8px; background: #f0f8ff; border-radius: 5px; cursor: pointer;">
|
||||||
|
<input type="radio" name="PatternOperator" value="is_detected" checked="checked">
|
||||||
|
<span style="flex: 1;"><strong>Any Pattern Detected</strong></span>
|
||||||
|
<span style="color: #666; font-size: 11px;">Bullish or Bearish</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; padding: 8px; background: #f0fff0; border-radius: 5px; cursor: pointer;">
|
||||||
|
<input type="radio" name="PatternOperator" value="is_bullish">
|
||||||
|
<span style="flex: 1;"><strong>Bullish Pattern</strong></span>
|
||||||
|
<span style="color: #28a745; font-size: 11px;">▲ Buy signal</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; padding: 8px; background: #fff0f0; border-radius: 5px; cursor: pointer;">
|
||||||
|
<input type="radio" name="PatternOperator" value="is_bearish">
|
||||||
|
<span style="flex: 1;"><strong>Bearish Pattern</strong></span>
|
||||||
|
<span style="color: #dc3545; font-size: 11px;">▼ Sell signal</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Value sub-panel -->
|
<!-- Value sub-panel -->
|
||||||
<div id="subpanel_2" style="grid-column: 1 / span 2;">
|
<div id="subpanel_2" style="grid-column: 1 / span 2;">
|
||||||
<label for="value"><b>Value:</b></label>
|
<label for="value"><b>Value:</b></label>
|
||||||
|
|
@ -89,19 +111,13 @@
|
||||||
<div id="subpanel_1" style="grid-column: 1 / span 2; display: none;">
|
<div id="subpanel_1" style="grid-column: 1 / span 2; display: none;">
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||||
<div>
|
<div>
|
||||||
<label for="sig2_source"><b>Signal Source:</b></label>
|
<label for="sig2_source"><b style="display: inline-flex; width: 200px;">Signal Source:</b></label>
|
||||||
<select name="sig2_source" id="sig2_source" style="width: 100%; margin-top: 5px;" onchange="UI.signals.fill_prop('sig2_prop', this.value)">
|
<select name="sig2_source" id="sig2_source" style="width: 100%; margin-top: 5px;" onchange="UI.signals.fill_prop('sig2_prop', this.value)">
|
||||||
{% set ns = namespace(optonVal=0) %}
|
<!-- Options populated by JavaScript -->
|
||||||
{% for each in indicator_list %}
|
|
||||||
<option>{{each}}</option>
|
|
||||||
{% if loop.index0 == 0 %}
|
|
||||||
{% set ns.optonVal = each %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="sig2_prop"><b>Property:</b></label>
|
<label for="sig2_prop"><b style="display: inline-flex; width: 200px;">Property:</b></label>
|
||||||
<select id="sig2_prop" name="sig2_prop" style="width: 100%; margin-top: 5px;"></select>
|
<select id="sig2_prop" name="sig2_prop" style="width: 100%; margin-top: 5px;"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,11 @@ and you set fee to 50%, you earn $0.50 per profitable trade.
|
||||||
<!-- Indicator blocks will be added here -->
|
<!-- Indicator blocks will be added here -->
|
||||||
</category>
|
</category>
|
||||||
|
|
||||||
|
<!-- Signals Category -->
|
||||||
|
<category name="Signals" colour="160" tooltip="Use your configured signals in strategy logic">
|
||||||
|
<!-- Signal blocks will be added here dynamically -->
|
||||||
|
</category>
|
||||||
|
|
||||||
<!-- Balances Subcategory -->
|
<!-- Balances Subcategory -->
|
||||||
<category name="Balances" colour="#E69500" tooltip="Access strategy and account balance information">
|
<category name="Balances" colour="#E69500" tooltip="Access strategy and account balance information">
|
||||||
<label text="Track your trading capital"></label>
|
<label text="Track your trading capital"></label>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
<!-- Signal Type (External Source) Form Popup -->
|
||||||
|
<div class="form-popup" id="new_signal_type_form" style="display: none; overflow: hidden; position: absolute; width: 480px; height: 520px; border-radius: 10px;">
|
||||||
|
|
||||||
|
<!-- Draggable Header Section -->
|
||||||
|
<div class="dialog-header" id="signal_type_draggable_header">
|
||||||
|
<h1 id="signal-type-form-header">New Signal Type</h1>
|
||||||
|
<button class="dialog-close-btn" onclick="UI.signals.closeSignalTypeForm()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<form class="form-container" style="padding: 15px; overflow-y: auto; height: calc(100% - 50px);">
|
||||||
|
<!-- Hidden field for tbl_key (used when editing) -->
|
||||||
|
<input type="hidden" id="signal_type_tbl_key" name="signal_type_tbl_key" value="" />
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr; gap: 12px;">
|
||||||
|
|
||||||
|
<!-- Name Input -->
|
||||||
|
<div>
|
||||||
|
<label for="signal_type_name"><b>Name:</b></label>
|
||||||
|
<input type="text" id="signal_type_name" name="signal_type_name"
|
||||||
|
placeholder="e.g., Fear & Greed Index"
|
||||||
|
style="width: 100%; margin-top: 5px; padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Endpoint URL -->
|
||||||
|
<div>
|
||||||
|
<label for="signal_type_url"><b>API Endpoint URL:</b></label>
|
||||||
|
<input type="text" id="signal_type_url" name="signal_type_url"
|
||||||
|
placeholder="https://pro-api.coinmarketcap.com/v3/fear-and-greed/latest"
|
||||||
|
style="width: 100%; margin-top: 5px; padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authentication Section -->
|
||||||
|
<div style="background: #f5f5f5; padding: 12px; border-radius: 6px;">
|
||||||
|
<label style="display: block; margin-bottom: 8px;"><b>Authentication (optional):</b></label>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||||
|
<div>
|
||||||
|
<label for="signal_type_auth_header" style="font-size: 12px;">Header Name:</label>
|
||||||
|
<input type="text" id="signal_type_auth_header" name="signal_type_auth_header"
|
||||||
|
placeholder="X-CMC_PRO_API_KEY"
|
||||||
|
style="width: 100%; margin-top: 3px; padding: 6px; border-radius: 4px; border: 1px solid #ccc; font-size: 12px;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="signal_type_auth_key" style="font-size: 12px;">API Key:</label>
|
||||||
|
<input type="password" id="signal_type_auth_key" name="signal_type_auth_key"
|
||||||
|
placeholder="Your API key"
|
||||||
|
style="width: 100%; margin-top: 3px; padding: 6px; border-radius: 4px; border: 1px solid #ccc; font-size: 12px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JSON Path -->
|
||||||
|
<div>
|
||||||
|
<label for="signal_type_json_path"><b>JSON Path to Value:</b></label>
|
||||||
|
<input type="text" id="signal_type_json_path" name="signal_type_json_path"
|
||||||
|
value="$.data[0].value"
|
||||||
|
placeholder="$.data[0].value"
|
||||||
|
style="width: 100%; margin-top: 5px; padding: 8px; border-radius: 4px; border: 1px solid #ccc; font-family: monospace;">
|
||||||
|
<small style="color: #666; display: block; margin-top: 4px;">
|
||||||
|
JSONPath expression to extract the value from the API response.
|
||||||
|
<a href="https://jsonpath.com/" target="_blank" style="color: #3E3AF2;">JSONPath reference</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refresh Interval -->
|
||||||
|
<div>
|
||||||
|
<label for="signal_type_refresh"><b>Refresh Interval:</b></label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; margin-top: 5px;">
|
||||||
|
<input type="number" id="signal_type_refresh" name="signal_type_refresh"
|
||||||
|
value="300" min="60" max="86400"
|
||||||
|
style="width: 100px; padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
|
||||||
|
<span>seconds</span>
|
||||||
|
<span style="color: #666; font-size: 12px;">(min: 60s, default: 300s = 5 min)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Result Area -->
|
||||||
|
<div id="signal_type_test_result" style="display: none; padding: 10px; border-radius: 6px; margin-top: 5px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div id="signal_type_loading" style="display: none; text-align: center; padding: 10px;">
|
||||||
|
<div style="border: 3px solid #444; border-top: 3px solid #3E3AF2; border-radius: 50%; width: 24px; height: 24px; animation: spin 1s linear infinite; margin: 0 auto;"></div>
|
||||||
|
<span style="color: #666; font-size: 12px;">Testing API connection...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div style="text-align: center; margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee;">
|
||||||
|
<button type="button" class="btn cancel" onclick="UI.signals.closeSignalTypeForm()" style="margin-right: 10px;">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn" onclick="UI.signals.testSignalType()" style="margin-right: 10px; background: #17a2b8; color: white;">
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn submit" id="submit-signal-type" onclick="UI.signals.submitSignalType()">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Resizer -->
|
||||||
|
<div id="resize-signal-type" class="resize-handle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#new_signal_type_form {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #444;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new_signal_type_form input[type="text"],
|
||||||
|
#new_signal_type_form input[type="password"],
|
||||||
|
#new_signal_type_form input[type="number"] {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new_signal_type_form input::placeholder {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new_signal_type_form .btn.cancel {
|
||||||
|
background: #444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new_signal_type_form .btn.cancel:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new_signal_type_form .btn.submit {
|
||||||
|
background: linear-gradient(135deg, #3E3AF2 0%, #6366f1 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new_signal_type_form .btn.submit:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new_signal_type_form label b {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new_signal_type_form small {
|
||||||
|
color: #888 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new_signal_type_form div[style*="background: #f5f5f5"] {
|
||||||
|
background: #2a2a2a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#signal_type_test_result.success {
|
||||||
|
background: #1e3a1e;
|
||||||
|
border: 1px solid #28a745;
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
#signal_type_test_result.error {
|
||||||
|
background: #3a1e1e;
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
<div class="content" id="signal_content">
|
<div class="content" id="signal_content">
|
||||||
<button class="btn" id="new_signal" onclick="UI.signals.open_signal_Form()">New Signal</button>
|
<button class="btn" id="new_signal" onclick="UI.signals.open_signal_Form()">New Signal</button>
|
||||||
|
<button class="btn" id="new_external_source" onclick="UI.signals.openSignalTypeForm()" style="background: #17a2b8;">+ External Source</button>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
<!-- External Sources Section (Real-time only) -->
|
||||||
|
<div id="external_sources_section" style="display: none; margin-bottom: 15px;">
|
||||||
|
<h3>External Sources <span style="font-size: 11px; color: #888;">(Real-time only - no backtesting)</span></h3>
|
||||||
|
<div class="external-sources-container" id="external_sources_list"></div>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Signals</h3>
|
<h3>Signals</h3>
|
||||||
<div class="signals-container" id="signal_list"></div>
|
<div class="signals-container" id="signal_list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,4 +211,146 @@
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* External Sources Styles */
|
||||||
|
.external-sources-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-source-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: linear-gradient(145deg, #2a2a2a, #1e1e1e);
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 150px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-source-item:hover {
|
||||||
|
border-color: #17a2b8;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-source-item .source-name {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-source-item .source-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #17a2b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-source-item .source-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-source-item .delete-source-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-source-item:hover .delete-source-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-source-item .refresh-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 28px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-source-item:hover .refresh-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* External Indicators Styles */
|
||||||
|
.external-indicators-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-indicator-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: linear-gradient(145deg, #3d2a5a, #2a1e3e);
|
||||||
|
border: 1px solid #6f42c1;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 150px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-indicator-item:hover {
|
||||||
|
border-color: #9f7aea;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-indicator-item .indicator-name {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-indicator-item .indicator-type {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9f7aea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-indicator-item .delete-indicator-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-indicator-item:hover .delete-indicator-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
30
src/utils.py
30
src/utils.py
|
|
@ -1,14 +1,16 @@
|
||||||
"""
|
"""
|
||||||
Utility functions for BrighterTrading.
|
Utility functions for BrighterTrading.
|
||||||
"""
|
"""
|
||||||
|
import datetime
|
||||||
import math
|
import math
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
def sanitize_for_json(value):
|
def sanitize_for_json(value):
|
||||||
"""
|
"""
|
||||||
Convert a value to a JSON-serializable type.
|
Convert a value to a JSON-serializable type.
|
||||||
Handles numpy types, inf, nan, and nested structures.
|
Handles numpy types, pandas types, datetime, inf, nan, and nested structures.
|
||||||
|
|
||||||
:param value: Any value that needs to be JSON serialized
|
:param value: Any value that needs to be JSON serialized
|
||||||
:return: JSON-serializable value
|
:return: JSON-serializable value
|
||||||
|
|
@ -16,6 +18,12 @@ def sanitize_for_json(value):
|
||||||
# Handle None
|
# Handle None
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
# Handle pandas NA/NaT (safely check for scalar values only)
|
||||||
|
try:
|
||||||
|
if pd.isna(value) and not isinstance(value, (dict, list, tuple, set)):
|
||||||
|
return None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass # pd.isna fails on some types, that's fine
|
||||||
# Handle numpy integer types
|
# Handle numpy integer types
|
||||||
if isinstance(value, np.integer):
|
if isinstance(value, np.integer):
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
@ -31,20 +39,38 @@ def sanitize_for_json(value):
|
||||||
# Handle numpy arrays
|
# Handle numpy arrays
|
||||||
elif isinstance(value, np.ndarray):
|
elif isinstance(value, np.ndarray):
|
||||||
return [sanitize_for_json(v) for v in value.tolist()]
|
return [sanitize_for_json(v) for v in value.tolist()]
|
||||||
|
# Handle pandas Timestamp
|
||||||
|
elif isinstance(value, pd.Timestamp):
|
||||||
|
return value.isoformat()
|
||||||
|
# Handle datetime objects
|
||||||
|
elif isinstance(value, (datetime.datetime, datetime.date)):
|
||||||
|
return value.isoformat()
|
||||||
|
# Handle pandas Timedelta
|
||||||
|
elif isinstance(value, pd.Timedelta):
|
||||||
|
return str(value)
|
||||||
# Handle regular floats with inf/nan
|
# Handle regular floats with inf/nan
|
||||||
elif isinstance(value, float):
|
elif isinstance(value, float):
|
||||||
if math.isinf(value) or math.isnan(value):
|
if math.isinf(value) or math.isnan(value):
|
||||||
return None
|
return None
|
||||||
return value
|
return value
|
||||||
|
# Handle bytes
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
try:
|
||||||
|
return value.decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return value.hex()
|
||||||
# Handle nested dicts
|
# Handle nested dicts
|
||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
return {k: sanitize_for_json(v) for k, v in value.items()}
|
return {str(k): sanitize_for_json(v) for k, v in value.items()}
|
||||||
# Handle lists
|
# Handle lists
|
||||||
elif isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
return [sanitize_for_json(v) for v in value]
|
return [sanitize_for_json(v) for v in value]
|
||||||
# Handle tuples
|
# Handle tuples
|
||||||
elif isinstance(value, tuple):
|
elif isinstance(value, tuple):
|
||||||
return tuple(sanitize_for_json(v) for v in value)
|
return tuple(sanitize_for_json(v) for v in value)
|
||||||
|
# Handle sets
|
||||||
|
elif isinstance(value, set):
|
||||||
|
return [sanitize_for_json(v) for v in value]
|
||||||
# Return other types as-is (str, int, bool, etc.)
|
# Return other types as-is (str, int, bool, etc.)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue