Compare commits

..

12 Commits

Author SHA1 Message Date
rob 9a389d20d2 Fix chart sync, indicators popup z-index, and volume height
- Rewrite chart binding to use single unified sync handler per chart
  instead of cumulative handlers that conflicted with each other
- Use v5 API unsubscribeVisibleTimeRangeChange for cleanup
- Add _isSyncing flag to prevent recursive sync loops
- Raise indicators popup z-index from 99 to 150 (above formation
  overlay at 100) to prevent formations from triggering mouseleave
- Reduce volume indicator height from 30% to 15% of chart
- Remove obsolete v2 scaleMargins call on Volume series
- Add better candlestick init logging for debugging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-11 02:03:56 -03:00
rob 5874f1cc7a Keep strategy dialog open on save failure
Previously the dialog closed immediately after submitting, before
knowing if the save succeeded. If there was an error (e.g., duplicate
name), users had to recreate their entire strategy.

Now the dialog only closes on success, and shows a helpful error
message on failure so users can fix the issue without losing work.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-11 00:59:37 -03:00
rob d2f31e7111 Add Blockly integration for chart formations in strategies
- Add formation_blocks.js with dynamic block generation per formation
- Add handle_formation() to PythonGenerator for code generation
- Add process_formation() to StrategyInstance for runtime execution
- Inject formations manager into paper/backtest strategy instances
- Add get_current_candle_time() override for backtest bar timestamps
- Add Formations category to Blockly toolbox
- Fix scope property names in formations.js (exchange_name, interval)

Formations can now be referenced in strategy logic by their line properties
(line, upper, lower, midline) and values are calculated at candle time.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-11 00:51:56 -03:00
rob 18d773320c Fix draft formation not preserving drag changes on save
When dragging a draft formation's anchors, the changes were being
stored in dragFormation and renderedFormations, but not synced
back to draftFormation. When completeDrawing() was called to save,
it used draftFormation.lines_json which still had the original
horizontal line data.

Now _endDrag() checks if the dragged formation is the draft
(tbl_key === draftTblKey) and syncs lines_json back to draftFormation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 23:06:18 -03:00
rob ee199022fa Fix anchor positioning when outside visible time range
- Add _getAnchorPixelPosition method that handles anchors outside
  the visible time range by calculating their position along the line
- Update _createAnchor to use robust position calculation
- Update _updateFormationPositionsInPlace to use new method

Previously, anchors would not update when their time coordinate was
outside the visible range (because _chartToPixel returns null).
Lines would update correctly via _getInfiniteLineEndpoints, but anchors
would stay at stale positions.

Now anchors are positioned at the viewport edge when their actual
time coordinate is outside the visible range, keeping them on the line.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 22:48:03 -03:00
rob d7398569a8 Fix formation overlay sync to update on every RAF frame
- Remove hasViewChanged check from RAF loop
- Update formations on every animation frame for smooth chart sync
- This ensures formations stay positioned correctly when:
  - Time scale changes (horizontal scroll/zoom)
  - Price scale changes (vertical scroll/zoom)
- Simplify _hasViewChanged method (kept for potential future optimization)
- Clean up debug logging

The previous implementation only updated when time range changed,
causing formations to drift when the price scale was modified.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 22:38:16 -03:00
rob 2d01ee07eb Fix formation line rendering for points outside visible range
The _getInfiniteLineEndpoints method was returning null when anchor
points were outside the visible time range, causing lines to not
render. Fixed by calculating line intersections with viewport edges
in chart coordinates first, then converting to pixels.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 21:55:06 -03:00
rob d7c8d42905 Add channel drawing UX with parallel line placement
Channel drawing now works like line drawing:
- First click places primary horizontal line (3 anchors)
- Mouse move shows dotted parallel preview line
- Second click places parallel line (1 center anchor)

Channel anchor behavior:
- Primary line center anchor: moves both lines together
- Primary line end anchors: pivots both lines (stays parallel)
- Secondary line center anchor: adjusts distance to primary

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 21:41:15 -03:00
rob 53797c2f39 Improve line drawing UX with single-click placement and 3-anchor control
- Single click places a solid horizontal line with 3 anchors
- Center anchor (green): drag to move entire line without changing angle
- End anchors (blue): drag to pivot from opposite end, changing angle
- Hovering over any part of the line shows all anchors
- Name input appears immediately after line is placed
- Line can be adjusted before saving

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 21:17:12 -03:00
rob ac4c085acd Improve formations drawing UX with step-by-step flow
Changed UX flow from confusing to clear:
1. Click Line/Channel -> Shows instructions + points counter
2. Click points on chart -> Updates counter (Points: 1/2)
3. After enough points -> Shows name input + Save button
4. Enter name + Save -> Formation created

formations_hud.html:
- Add instructions panel with Cancel button
- Add points status display
- Separate name input controls (shown after points placed)

formations.js:
- Add showDrawingInstructions() with type-specific text
- Add updatePointsStatus() for live counter
- Add showNameInput() after drawing complete
- Add hideAllDrawingUI() for cleanup

formation_overlay.js:
- Add onPointsChangedCallback for UI updates
- Store _pointsNeeded per drawing session
- Stop accepting clicks after enough points
- Change cursor back to default when complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 20:00:47 -03:00
rob fceb040ef0 Fix formations stability and socket reliability issues
formation_overlay.js:
- Fix Map mutation during iteration causing infinite loops
- Use array snapshots for _updateAllFormations and clearAllFormations
- Add guards to skip unnecessary RAF work when no formations exist
- Simplify range change detection with primitive comparison

Formations.py:
- Fix DataCache method names to match actual API
- Use insert_row_into_datacache, modify_datacache_item,
  remove_row_from_datacache

communication.js:
- Enable persistent reconnection (Infinity attempts)
- Add bounded queue (250 max) to prevent memory growth
- Coalesce candle_data messages (keep latest only)
- Prioritize control/CRUD messages in queue flush

formations.js:
- Add user feedback alert when deleting while offline

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 19:39:29 -03:00
rob 5717ac6a81 Implement chart formations MVP with Line and Channel support
Add formations feature for drawing trendlines and channels on charts:

Backend (Formations.py):
- CRUD operations with DataCache integration
- Database table with scope-based indexing
- Line value calculation with infinite extension
- Property value lookup for strategy integration

Frontend (formations.js, formation_overlay.js):
- Three-class pattern: UIManager, DataManager, Controller
- SVG overlay for drawing with RAF sync loop
- Click-to-draw interface with temp line preview
- Anchor dragging for line adjustment
- Coordinate conversion using v5 API

UI Integration:
- HUD panel with Line/Channel buttons
- Formation cards with hover details
- Drawing controls (name input, save/cancel)

Socket handlers for real-time sync:
- request_formations, new_formation
- edit_formation, delete_formation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 16:22:08 -03:00
19 changed files with 3522 additions and 144 deletions

View File

@ -11,6 +11,7 @@ from Configuration import Configuration
from ExchangeInterface import ExchangeInterface
from indicators import Indicators
from Signals import Signals
from Formations import Formations
from ExternalSources import ExternalSources
from ExternalIndicators import ExternalIndicatorsManager
from trade import Trades
@ -54,6 +55,9 @@ class BrighterTrades:
# Object that maintains signals.
self.signals = Signals(self.data)
# Object that maintains chart formations (trendlines, channels, patterns).
self.formations = Formations(self.data)
# Object that maintains external data sources (custom signal types).
self.external_sources = ExternalSources(self.data)
@ -79,7 +83,8 @@ class BrighterTrades:
self.backtester = Backtester(data_cache=self.data, strategies=self.strategies,
indicators=self.indicators, socketio=socketio,
edm_client=self.edm_client,
external_indicators=self.external_indicators)
external_indicators=self.external_indicators,
signals=self.signals, formations=self.formations)
self.backtests = {} # In-memory storage for backtests (replace with DB access in production)
# Wallet manager for Bitcoin wallets and credits ledger
@ -1005,6 +1010,11 @@ class BrighterTrades:
indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators
)
# Inject signals and formations managers for strategy execution
instance.signals = self.signals
instance.formations = self.formations
instance.formation_owner_id = indicator_owner_id if indicator_owner_id else user_id
# Store fee tracking info on the instance
if strategy_run_id:
instance.strategy_run_id = strategy_run_id
@ -1888,6 +1898,42 @@ class BrighterTrades:
logger.error(f"Error getting public strategies: {e}", exc_info=True)
return standard_reply("public_strategies_error", {"message": str(e)})
# ===== Formation Handlers =====
if msg_type == 'request_formations':
# Get formations for current chart scope
exchange = msg_data.get('exchange')
market = msg_data.get('market')
timeframe = msg_data.get('timeframe')
if not all([exchange, market, timeframe]):
return standard_reply("formation_error", {"message": "Missing scope parameters"})
formations = self.formations.get_for_scope(user_id, exchange, market, timeframe)
return standard_reply("formations", {"formations": formations})
if msg_type == 'new_formation':
result = self.formations.create(user_id, msg_data)
if result.get('success'):
return standard_reply("formation_created", result)
else:
return standard_reply("formation_error", result)
if msg_type == 'edit_formation':
result = self.formations.update(user_id, msg_data)
if result.get('success'):
return standard_reply("formation_updated", result)
else:
return standard_reply("formation_error", result)
if msg_type == 'delete_formation':
tbl_key = msg_data.get('tbl_key')
if not tbl_key:
return standard_reply("formation_error", {"message": "Missing tbl_key"})
result = self.formations.delete(user_id, tbl_key)
if result.get('success'):
return standard_reply("formation_deleted", result)
else:
return standard_reply("formation_error", result)
if msg_type == 'reply':
# If the message is a reply log the response to the terminal.
print(f"\napp.py:Received reply: {msg_data}")

490
src/Formations.py Normal file
View File

@ -0,0 +1,490 @@
"""
Formations module for chart formation management.
Handles CRUD operations for chart formations (trendlines, channels, patterns)
with database-backed storage via DataCache.
"""
import json
import logging
import uuid
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from DataCache_v3 import DataCache
# Configure logging
logger = logging.getLogger(__name__)
@dataclass
class Formation:
"""Class for individual formation properties."""
tbl_key: str
user_id: int
name: str
formation_type: str # 'support_resistance', 'channel', etc.
exchange: str
market: str
timeframe: str
lines_json: str # JSON string of line data
color: str = '#667eea'
visible: bool = True
created_at: int = 0
updated_at: int = 0
@property
def lines(self) -> List[dict]:
"""Parse lines_json and return as list of line dicts."""
try:
data = json.loads(self.lines_json)
return data.get('lines', [])
except (json.JSONDecodeError, TypeError):
return []
@property
def targets(self) -> List[dict]:
"""Parse lines_json and return targets (Phase C feature)."""
try:
data = json.loads(self.lines_json)
return data.get('targets', [])
except (json.JSONDecodeError, TypeError):
return []
class Formations:
"""Manages chart formations with database-backed storage via DataCache."""
TABLE_NAME = 'formations'
def __init__(self, data_cache: DataCache):
"""
Initialize the Formations class.
:param data_cache: Instance of DataCache to manage cache and database interactions.
"""
self.data_cache = data_cache
# Ensure the formations table exists in the database
self._ensure_table_exists()
# Create a cache for formations
self.data_cache.create_cache(
name='formations',
cache_type='table',
size_limit=1000,
eviction_policy='deny',
columns=[
"tbl_key",
"user_id",
"name",
"formation_type",
"exchange",
"market",
"timeframe",
"lines_json",
"color",
"visible",
"created_at",
"updated_at"
]
)
# In-memory cache of Formation objects
self.formations: Dict[str, Formation] = {}
# Load existing formations from database
self._load_formations_from_db()
def _ensure_table_exists(self) -> None:
"""Create the formations table in the database if it doesn't exist."""
try:
if not self.data_cache.db.table_exists(self.TABLE_NAME):
create_sql = """
CREATE TABLE IF NOT EXISTS formations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tbl_key TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
formation_type TEXT NOT NULL,
exchange TEXT NOT NULL,
market TEXT NOT NULL,
timeframe TEXT NOT NULL,
lines_json TEXT NOT NULL,
color TEXT DEFAULT '#667eea',
visible INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(user_id, name, exchange, market, timeframe)
)
"""
self.data_cache.db.execute_sql(create_sql, params=[])
# Create index for scope queries
index_sql = """
CREATE INDEX IF NOT EXISTS idx_formations_scope
ON formations(user_id, exchange, market, timeframe)
"""
self.data_cache.db.execute_sql(index_sql, params=[])
logger.info("Created formations table in database")
except Exception as e:
logger.error(f"Error ensuring formations table exists: {e}", exc_info=True)
def _load_formations_from_db(self) -> None:
"""Load all formations from database into memory."""
try:
formations_df = self.data_cache.get_all_rows_from_datacache(cache_name='formations')
if formations_df is not None and not formations_df.empty:
for _, row in formations_df.iterrows():
formation = Formation(
tbl_key=row.get('tbl_key', ''),
user_id=int(row.get('user_id', 0)),
name=row.get('name', ''),
formation_type=row.get('formation_type', ''),
exchange=row.get('exchange', ''),
market=row.get('market', ''),
timeframe=row.get('timeframe', ''),
lines_json=row.get('lines_json', '{}'),
color=row.get('color', '#667eea'),
visible=bool(row.get('visible', True)),
created_at=int(row.get('created_at', 0)),
updated_at=int(row.get('updated_at', 0))
)
self.formations[formation.tbl_key] = formation
logger.info(f"Loaded {len(self.formations)} formations from database")
except Exception as e:
logger.error(f"Error loading formations from database: {e}", exc_info=True)
def create(self, user_id: int, data: dict) -> dict:
"""
Create a new formation.
:param user_id: ID of the user creating the formation
:param data: Dictionary containing formation data
:return: Dictionary with success status and formation data or error message
"""
try:
# Validate required fields
required_fields = ['name', 'formation_type', 'exchange', 'market', 'timeframe', 'lines_json']
for field in required_fields:
if field not in data or not data[field]:
return {"success": False, "message": f"Missing required field: {field}"}
# Check for duplicate name in same scope
name = data['name']
exchange = data['exchange']
market = data['market']
timeframe = data['timeframe']
for formation in self.formations.values():
if (formation.user_id == user_id and
formation.name == name and
formation.exchange == exchange and
formation.market == market and
formation.timeframe == timeframe):
return {"success": False, "message": "A formation with this name already exists in this scope"}
# Generate unique key and timestamps
tbl_key = str(uuid.uuid4())
now = int(time.time())
# Prepare formation data
columns = (
"tbl_key", "user_id", "name", "formation_type", "exchange",
"market", "timeframe", "lines_json", "color", "visible",
"created_at", "updated_at"
)
values = (
tbl_key,
user_id,
name,
data['formation_type'],
exchange,
market,
timeframe,
data['lines_json'] if isinstance(data['lines_json'], str) else json.dumps(data['lines_json']),
data.get('color', '#667eea'),
1, # visible
now,
now
)
# Insert into database/cache via DataCache
self.data_cache.insert_row_into_datacache(
cache_name='formations',
columns=columns,
values=values
)
# Create Formation object and add to memory cache
formation = Formation(
tbl_key=tbl_key,
user_id=user_id,
name=name,
formation_type=data['formation_type'],
exchange=exchange,
market=market,
timeframe=timeframe,
lines_json=data['lines_json'] if isinstance(data['lines_json'], str) else json.dumps(data['lines_json']),
color=data.get('color', '#667eea'),
visible=True,
created_at=now,
updated_at=now
)
self.formations[tbl_key] = formation
logger.info(f"Created formation '{name}' with tbl_key {tbl_key}")
return {
"success": True,
"message": "Formation created successfully",
"formation": self._formation_to_dict(formation)
}
except Exception as e:
logger.error(f"Error creating formation: {e}", exc_info=True)
return {"success": False, "message": f"Error creating formation: {str(e)}"}
def update(self, user_id: int, data: dict) -> dict:
"""
Update an existing formation.
:param user_id: ID of the user updating the formation
:param data: Dictionary containing formation data with tbl_key
:return: Dictionary with success status
"""
try:
tbl_key = data.get('tbl_key')
if not tbl_key:
return {"success": False, "message": "Missing tbl_key"}
# Find existing formation
formation = self.formations.get(tbl_key)
if not formation:
return {"success": False, "message": "Formation not found"}
# Verify ownership
if formation.user_id != user_id:
return {"success": False, "message": "Not authorized to edit this formation"}
# Update fields
now = int(time.time())
update_data = {
'updated_at': now
}
if 'name' in data:
formation.name = data['name']
update_data['name'] = data['name']
if 'lines_json' in data:
lines_json = data['lines_json'] if isinstance(data['lines_json'], str) else json.dumps(data['lines_json'])
formation.lines_json = lines_json
update_data['lines_json'] = lines_json
if 'color' in data:
formation.color = data['color']
update_data['color'] = data['color']
if 'visible' in data:
formation.visible = bool(data['visible'])
update_data['visible'] = int(data['visible'])
formation.updated_at = now
# Update in database/cache
self.data_cache.modify_datacache_item(
cache_name='formations',
filter_vals=[('tbl_key', tbl_key)],
field_names=tuple(update_data.keys()),
new_values=tuple(update_data.values()),
key=tbl_key,
overwrite='tbl_key'
)
logger.info(f"Updated formation '{formation.name}' (tbl_key: {tbl_key})")
return {
"success": True,
"message": "Formation updated successfully",
"formation": self._formation_to_dict(formation)
}
except Exception as e:
logger.error(f"Error updating formation: {e}", exc_info=True)
return {"success": False, "message": f"Error updating formation: {str(e)}"}
def delete(self, user_id: int, tbl_key: str) -> dict:
"""
Delete a formation.
:param user_id: ID of the user deleting the formation
:param tbl_key: Unique key of the formation to delete
:return: Dictionary with success status
"""
try:
# Find existing formation
formation = self.formations.get(tbl_key)
if not formation:
return {"success": False, "message": "Formation not found"}
# Verify ownership
if formation.user_id != user_id:
return {"success": False, "message": "Not authorized to delete this formation"}
# Remove from database/cache
self.data_cache.remove_row_from_datacache(
cache_name='formations',
filter_vals=[('tbl_key', tbl_key)]
)
# Remove from memory cache
del self.formations[tbl_key]
logger.info(f"Deleted formation '{formation.name}' (tbl_key: {tbl_key})")
return {
"success": True,
"message": "Formation deleted successfully",
"tbl_key": tbl_key
}
except Exception as e:
logger.error(f"Error deleting formation: {e}", exc_info=True)
return {"success": False, "message": f"Error deleting formation: {str(e)}"}
def get_for_scope(self, user_id: int, exchange: str, market: str, timeframe: str) -> List[dict]:
"""
Get all formations for a specific scope (exchange/market/timeframe).
:param user_id: ID of the user
:param exchange: Exchange name
:param market: Market/trading pair
:param timeframe: Timeframe
:return: List of formation dictionaries
"""
result = []
for formation in self.formations.values():
if (formation.user_id == user_id and
formation.exchange == exchange and
formation.market == market and
formation.timeframe == timeframe):
result.append(self._formation_to_dict(formation))
return result
def get_by_tbl_key(self, user_id: int, tbl_key: str) -> Optional[dict]:
"""
Get a formation by its tbl_key.
:param user_id: ID of the user (for ownership verification)
:param tbl_key: Unique key of the formation
:return: Formation dictionary or None
"""
formation = self.formations.get(tbl_key)
if formation and formation.user_id == user_id:
return self._formation_to_dict(formation)
return None
def get_by_tbl_key_for_strategy(self, tbl_key: str, owner_user_id: int) -> Optional[dict]:
"""
Get a formation by tbl_key for strategy execution.
This uses the strategy owner's formations, not the current user's.
Parallel to indicator_owner_id pattern.
:param tbl_key: Unique key of the formation
:param owner_user_id: User ID of the strategy owner
:return: Formation dictionary or None
"""
formation = self.formations.get(tbl_key)
if formation and formation.user_id == owner_user_id:
return self._formation_to_dict(formation)
return None
def calculate_line_value(self, line: dict, timestamp: int) -> float:
"""
Calculate price at timestamp using linear interpolation/extrapolation.
This implements infinite line extension - works for any timestamp,
past or future, by extrapolating the line defined by two points.
:param line: Dict with point1 and point2, each having 'time' and 'price'
:param timestamp: Unix timestamp in seconds UTC
:return: Extrapolated price value
"""
t1 = line['point1']['time']
p1 = line['point1']['price']
t2 = line['point2']['time']
p2 = line['point2']['price']
# Handle vertical line (same timestamp)
if t1 == t2:
logger.warning(f"Vertical line detected (t1==t2={t1}), returning average price")
return (p1 + p2) / 2
# Calculate slope and extrapolate
slope = (p2 - p1) / (t2 - t1)
return p1 + slope * (timestamp - t1)
def get_property_value(self, formation: dict, property_name: str, timestamp: int) -> Optional[float]:
"""
Get the value of a formation property at a given timestamp.
:param formation: Formation dictionary
:param property_name: Name of the property ('line', 'upper', 'lower', 'midline', etc.)
:param timestamp: Unix timestamp in seconds UTC
:return: Price value or None
"""
try:
lines_data = json.loads(formation.get('lines_json', '{}'))
lines = lines_data.get('lines', [])
if not lines:
return None
formation_type = formation.get('formation_type', '')
# Support/Resistance - single line
if formation_type == 'support_resistance':
if property_name == 'line' and len(lines) > 0:
return self.calculate_line_value(lines[0], timestamp)
# Channel - two parallel lines
elif formation_type == 'channel':
if property_name == 'upper' and len(lines) > 0:
return self.calculate_line_value(lines[0], timestamp)
elif property_name == 'lower' and len(lines) > 1:
return self.calculate_line_value(lines[1], timestamp)
elif property_name == 'midline' and len(lines) >= 2:
upper = self.calculate_line_value(lines[0], timestamp)
lower = self.calculate_line_value(lines[1], timestamp)
return (upper + lower) / 2
# Default: return first line value
if len(lines) > 0:
return self.calculate_line_value(lines[0], timestamp)
return None
except Exception as e:
logger.error(f"Error getting property value: {e}", exc_info=True)
return None
def _formation_to_dict(self, formation: Formation) -> dict:
"""Convert Formation object to dictionary."""
return {
'tbl_key': formation.tbl_key,
'user_id': formation.user_id,
'name': formation.name,
'formation_type': formation.formation_type,
'exchange': formation.exchange,
'market': formation.market,
'timeframe': formation.timeframe,
'lines_json': formation.lines_json,
'color': formation.color,
'visible': formation.visible,
'created_at': formation.created_at,
'updated_at': formation.updated_at
}

View File

@ -145,6 +145,8 @@ class PythonGenerator:
handler_method = self.handle_indicator
elif node_type.startswith('signal_'):
handler_method = self.handle_signal
elif node_type.startswith('formation_') or node_type == 'formation':
handler_method = self.handle_formation
else:
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
handler_code = handler_method(node, indent_level)
@ -195,6 +197,8 @@ class PythonGenerator:
handler_method = self.handle_indicator
elif node_type.startswith('signal_'):
handler_method = self.handle_signal
elif node_type.startswith('formation_') or node_type == 'formation':
handler_method = self.handle_formation
else:
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
condition_code = handler_method(condition_node, indent_level=indent_level)
@ -291,6 +295,46 @@ class PythonGenerator:
logger.debug(f"Generated signal condition: {expr}")
return expr
# ==============================
# Formations Handlers
# ==============================
def handle_formation(self, node: Dict[str, Any], indent_level: int) -> str:
"""
Handles formation nodes by generating a function call to get formation value.
Uses tbl_key (stable UUID) for referencing formations.
:param node: The formation node.
:param indent_level: Current indentation level.
:return: A string representing the formation value lookup.
"""
fields = node.get('fields', {})
# Get formation reference by tbl_key (stable) not name
tbl_key = fields.get('TBL_KEY')
property_name = fields.get('PROPERTY', 'line')
formation_name = fields.get('NAME', 'unknown')
if not tbl_key:
logger.error(f"formation node missing TBL_KEY. fields={fields}")
return 'None'
# Track formation usage for dependency resolution
if not hasattr(self, 'formations_used'):
self.formations_used = []
self.formations_used.append({
'tbl_key': tbl_key,
'name': formation_name,
'property': property_name
})
# Generate code that calls process_formation
# Uses current candle time by default (timestamp=None)
expr = f"process_formation('{tbl_key}', '{property_name}')"
logger.debug(f"Generated formation lookup: {expr}")
return expr
# ==============================
# Balances Handlers
# ==============================

View File

@ -87,6 +87,7 @@ class StrategyInstance:
'notify_user': self.notify_user,
'process_indicator': self.process_indicator,
'process_signal': self.process_signal,
'process_formation': self.process_formation,
'get_strategy_profit_loss': self.get_strategy_profit_loss,
'is_in_profit': self.is_in_profit,
'is_in_loss': self.is_in_loss,
@ -814,6 +815,59 @@ class StrategyInstance:
traceback.print_exc()
return False if output_field == 'triggered' else None
def process_formation(self, tbl_key: str, property_name: str = 'line', timestamp: int = None) -> float:
"""
Gets the price value of a formation property at a given timestamp.
Uses formation_owner_id (not current user) for subscribed strategies.
Parallel to indicator_owner_id pattern.
:param tbl_key: Unique key of the formation (UUID).
:param property_name: Property to retrieve ('line', 'upper', 'lower', 'midline', etc.).
:param timestamp: Unix timestamp in seconds UTC. If None, uses current candle time.
:return: Price value at the timestamp, or None on error.
"""
try:
# Check if formations manager is available
if not hasattr(self, 'formations') or self.formations is None:
logger.warning(f"Formations manager not available in StrategyInstance")
return None
# Default timestamp: use current candle time if available
if timestamp is None:
timestamp = self.get_current_candle_time()
# Use formation_owner_id for subscribed strategies (parallel to indicator_owner_id)
owner_id = getattr(self, 'formation_owner_id', self.user_id)
# Look up the formation by tbl_key using owner's formations
formation = self.formations.get_by_tbl_key_for_strategy(tbl_key, owner_id)
if formation is None:
logger.warning(f"Formation with tbl_key '{tbl_key}' not found for owner {owner_id}")
return None
# Get the property value at the timestamp
value = self.formations.get_property_value(formation, property_name, timestamp)
logger.debug(f"Formation '{formation.get('name')}' {property_name} at {timestamp}: {value}")
return value
except Exception as e:
logger.error(
f"Error processing formation '{tbl_key}' in StrategyInstance '{self.strategy_instance_id}': {e}",
exc_info=True)
traceback.print_exc()
return None
def get_current_candle_time(self) -> int:
"""
Returns the current candle timestamp in seconds UTC.
In backtest mode, this is overridden to return the historical bar time.
In live/paper mode, returns the current time.
"""
import time
return int(time.time())
def get_strategy_profit_loss(self, strategy_id: str) -> float:
"""
Retrieves the current profit or loss of the strategy.

View File

@ -414,6 +414,16 @@ class BacktestStrategyInstance(StrategyInstance):
logger.warning(f"Could not get candle datetime: {e}")
return dt.datetime.now()
def get_current_candle_time(self) -> int:
"""
Returns the current candle timestamp in seconds UTC.
In backtest mode, returns the historical bar time being processed.
This is critical for accurate formation value lookups.
"""
candle_datetime = self.get_current_candle_datetime()
return int(candle_datetime.timestamp())
def get_collected_alerts(self) -> list:
"""
Returns the list of collected alerts for inclusion in backtest results.

View File

@ -42,7 +42,7 @@ class EquityCurveAnalyzer(bt.Analyzer):
# Backtester Class
class Backtester:
def __init__(self, data_cache: DataCache, strategies: Strategies, indicators: Indicators, socketio,
edm_client=None, external_indicators=None):
edm_client=None, external_indicators=None, signals=None, formations=None):
""" Initialize the Backtesting class with a cache for back-tests """
self.data_cache = data_cache
self.strategies = strategies
@ -50,6 +50,8 @@ class Backtester:
self.socketio = socketio
self.edm_client = edm_client
self.external_indicators = external_indicators
self.signals = signals
self.formations = formations
# Ensure 'tests' cache exists
self.data_cache.create_cache(
@ -883,6 +885,11 @@ class Backtester:
indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators
)
# Inject signals and formations for strategy execution
strategy_instance.signals = self.signals
strategy_instance.formations = self.formations
strategy_instance.formation_owner_id = indicator_owner_id if indicator_owner_id else user_id
# Cache the backtest
self.cache_backtest(backtest_key, msg_data, strategy_instance_id)

View File

@ -1053,6 +1053,10 @@ class StratWorkspaceManager {
const signalBlocksModule = await import('./blocks/signal_blocks.js');
signalBlocksModule.defineSignalBlocks();
// Load and define formation blocks
const formationBlocksModule = await import('./blocks/formation_blocks.js');
formationBlocksModule.defineFormationBlocks();
} catch (error) {
console.error("Error loading Blockly modules: ", error);
return;
@ -1457,9 +1461,17 @@ class Strategies {
if (data.success && data.strategy) {
this.dataManager.addNewStrategy(data.strategy);
this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies());
// Close the dialog only on success
this.uiManager.hideForm();
} else {
console.error("Failed to create strategy:", data.message);
alert(`Strategy creation failed: ${data.message}`);
// Keep dialog open and show error - user can fix the issue
const errorMsg = data.message || 'Unknown error';
if (errorMsg.toLowerCase().includes('name') || errorMsg.toLowerCase().includes('exists')) {
alert(`Strategy name already exists. Please choose a different name.`);
} else {
alert(`Strategy creation failed: ${errorMsg}`);
}
}
}
@ -1471,6 +1483,9 @@ class Strategies {
if (data.success) {
console.log("Strategy updated successfully:", data);
// Close the dialog on success
this.uiManager.hideForm();
// Locate the strategy in the local state by its tbl_key
const updatedStrategyKey = data.strategy.tbl_key;
const updatedAt = data.updated_at;
@ -1519,7 +1534,13 @@ class Strategies {
}
} else {
console.error("Failed to update strategy:", data.message);
alert(`Strategy update failed: ${data.message}`);
// Keep dialog open and show error - user can fix the issue
const errorMsg = data.message || 'Unknown error';
if (errorMsg.toLowerCase().includes('name') || errorMsg.toLowerCase().includes('exists')) {
alert(`Strategy name already exists. Please choose a different name.`);
} else {
alert(`Strategy update failed: ${errorMsg}`);
}
}
}
@ -1700,7 +1721,8 @@ class Strategies {
// Determine message type based on action
const messageType = action === 'new' ? 'new_strategy' : 'edit_strategy';
this.comms.sendToApp(messageType, strategyData);
this.uiManager.hideForm();
// Don't hide form here - wait for server response
// Form will be hidden in handleStrategyCreated/handleStrategyUpdated on success
} else {
console.error("Comms instance not available or invalid action type.");
}

View File

@ -0,0 +1,121 @@
// client/formation_blocks.js
// Define Blockly blocks for user-created formations
export function defineFormationBlocks() {
// Ensure Blockly.JSON is available
if (!Blockly.JSON) {
console.error('Blockly.JSON is not defined. Ensure json_generators.js is loaded before formation_blocks.js.');
return;
}
// Get all user formations for current scope
const formations = window.UI?.formations?.dataManager?.getAllFormations?.() || [];
const toolboxCategory = document.querySelector('#toolbox_advanced category[name="Formations"]');
if (!toolboxCategory) {
console.error('Formations category not found in the toolbox.');
return;
}
// Clear existing formation blocks (for refresh)
const existingBlocks = toolboxCategory.querySelectorAll('block[type^="formation_"]');
existingBlocks.forEach(block => block.remove());
// Check if there are any formations to display
if (formations.length === 0) {
// Add helpful message when no formations exist
if (!toolboxCategory.querySelector('label')) {
const labelElement = document.createElement('label');
labelElement.setAttribute('text', 'No formations configured yet.');
toolboxCategory.appendChild(labelElement);
const labelElement2 = document.createElement('label');
labelElement2.setAttribute('text', 'Draw formations on the chart');
toolboxCategory.appendChild(labelElement2);
const labelElement3 = document.createElement('label');
labelElement3.setAttribute('text', 'using the Formations panel.');
toolboxCategory.appendChild(labelElement3);
}
console.log('No formations available - added help message to toolbox.');
return;
}
// Remove help labels if formations exist
const labels = toolboxCategory.querySelectorAll('label');
labels.forEach(label => label.remove());
// Property options vary by formation type
const propertyOptionsByType = {
'support_resistance': [
['line value', 'line']
],
'channel': [
['upper line', 'upper'],
['lower line', 'lower'],
['midline', 'midline']
]
};
// Default properties for unknown types
const defaultProperties = [
['line value', 'line']
];
for (const formation of formations) {
const formationName = formation.name;
const formationType = formation.formation_type;
const tblKey = formation.tbl_key;
// Create a unique block type using tbl_key (stable UUID)
const sanitizedName = formationName.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
const blockType = 'formation_' + sanitizedName + '_' + tblKey.substring(0, 8);
// Get property options for this formation type
const propertyOptions = propertyOptionsByType[formationType] || defaultProperties;
// Define the block for this formation
Blockly.defineBlocksWithJsonArray([{
"type": blockType,
"message0": `Formation: ${formationName} %1`,
"args0": [
{
"type": "field_dropdown",
"name": "PROPERTY",
"options": propertyOptions
}
],
"output": "dynamic_value",
"colour": 290, // Purple-ish color for formations
"tooltip": `Get the price value of formation '${formationName}' (${formationType}) at current candle time`,
"helpUrl": ""
}]);
// Define the JSON generator for this block
Blockly.JSON[blockType] = function(block) {
const selectedProperty = block.getFieldValue('PROPERTY');
const json = {
type: 'formation',
fields: {
TBL_KEY: tblKey,
NAME: formationName,
PROPERTY: selectedProperty
}
};
// Output as dynamic_value
return {
type: 'dynamic_value',
values: [json]
};
};
// Append the newly created block to the Formations category in the toolbox
const blockElement = document.createElement('block');
blockElement.setAttribute('type', blockType);
toolboxCategory.appendChild(blockElement);
}
console.log(`Formation blocks defined: ${formations.length} formations added to toolbox.`);
}

View File

@ -398,7 +398,7 @@ height: 500px;
text-align: left;
font-size: 12px;
color: white;
z-index: 99;
z-index: 150; /* Above formation overlay (z-index: 100) */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

View File

@ -13,8 +13,12 @@ class Charts {
/* A list of bound charts this is necessary for maintaining a dynamic
number of charts with their position and zoom factors bound.*/
this.bound_charts=[];
// Store unsubscribe functions for cleanup when rebinding
this._syncUnsubscribes = [];
// Debounce timestamp to prevent infinite loop in chart synchronization
this._lastSyncTime = 0;
// Flag to prevent recursive sync
this._isSyncing = false;
// Only the main chart is created by default.
this.create_main_chart();
}
@ -34,9 +38,11 @@ class Charts {
// Initialize the candlestick series if price_history is available
if (this.price_history && this.price_history.length > 0) {
this.candleSeries.setData(this.price_history);
console.log('Candle series init:', this.price_history);
console.log(`Candle series initialized with ${this.price_history.length} candles`);
console.log('First candle:', this.price_history[0]);
console.log('Last candle:', this.price_history[this.price_history.length - 1]);
} else {
console.error('Price history is not available or is empty.');
console.error('Price history is not available or is empty. Received:', this.price_history);
}
this.bind_charts(this.chart_1);
}
@ -158,147 +164,73 @@ class Charts {
bind_charts(chart){
// keep a list of charts and bind all their position and spacing.
// Add (arg1) to bound_charts
this.add_to_list(chart);
// Get the number of objects in bound_charts
let bcl = Object.keys(this.bound_charts).length;
// if bound_charts has two element in it bind them
if (bcl == 2) { this.bind2charts(); }
// if bound_charts has three elements in it bind them
if (bcl == 3) { this.bind3charts(); }
// if bound_charts has four elements in it bind them
if (bcl == 4) { this.bind4charts(); }
// if bound_charts has five elements in it bind them
if (bcl == 5) { this.bind5charts(); }
return;
}
add_to_list(chart){
// If the chart isn't already included in the list, add it.
if ( !this.bound_charts.includes(chart) ){
// Add chart to list if not already present
if (!this.bound_charts.includes(chart)) {
this.bound_charts.push(chart);
}
// Only need to sync when we have 2+ charts
if (this.bound_charts.length >= 2) {
this._rebindAllCharts();
}
}
bind2charts(){
//On change in chart 1 change chart 2
let syncHandler1 = (e) => {
const now = Date.now();
if (now - this._lastSyncTime < 50) return;
this._lastSyncTime = now;
// Get the barSpacing(zoom) and position of 1st chart (v5 API: options().barSpacing)
let barSpacing1 = this.bound_charts[0].timeScale().options().barSpacing;
let scrollPosition1 = this.bound_charts[0].timeScale().scrollPosition();
// Apply barSpacing(zoom) and position to 2nd chart.
this.bound_charts[1].timeScale().applyOptions({ rightOffset: scrollPosition1, barSpacing: barSpacing1 });
}
this.bound_charts[0].timeScale().subscribeVisibleTimeRangeChange(syncHandler1);
//On change in chart 2 change chart 1
let syncHandler2 = (e) => {
const now = Date.now();
if (now - this._lastSyncTime < 50) return;
this._lastSyncTime = now;
// Get the barSpacing(zoom) and position of chart 2 (v5 API: options().barSpacing)
let barSpacing2 = this.bound_charts[1].timeScale().options().barSpacing;
let scrollPosition2 = this.bound_charts[1].timeScale().scrollPosition();
// Apply barSpacing(zoom) and position to chart 1
this.bound_charts[0].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 });
}
this.bound_charts[1].timeScale().subscribeVisibleTimeRangeChange(syncHandler2);
}
bind3charts(){
//On change to chart 1 change chart 2 and 3
let syncHandler = (e) => {
const now = Date.now();
if (now - this._lastSyncTime < 50) return;
this._lastSyncTime = now;
// Get the barSpacing(zoom) and position of chart 1 (v5 API)
let barSpacing1 = this.bound_charts[0].timeScale().options().barSpacing;
let scrollPosition1 = this.bound_charts[0].timeScale().scrollPosition();
// Apply barSpacing(zoom) and position to new chart
this.bound_charts[1].timeScale().applyOptions({ rightOffset: scrollPosition1, barSpacing: barSpacing1 });
this.bound_charts[2].timeScale().applyOptions({ rightOffset: scrollPosition1, barSpacing: barSpacing1 });
}
this.bound_charts[0].timeScale().subscribeVisibleTimeRangeChange(syncHandler);
//On change to chart 2 change chart 1 and 3
let syncHandler2 = (e) => {
const now = Date.now();
if (now - this._lastSyncTime < 50) return;
this._lastSyncTime = now;
// Get the barSpacing(zoom) and position of chart 2 (v5 API)
let barSpacing2 = this.bound_charts[1].timeScale().options().barSpacing;
let scrollPosition2 = this.bound_charts[1].timeScale().scrollPosition();
// Apply barSpacing(zoom) and position to chart 1 and 3
this.bound_charts[0].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 });
this.bound_charts[2].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 });
}
this.bound_charts[1].timeScale().subscribeVisibleTimeRangeChange(syncHandler2);
//On change to chart 3 change chart 1 and 2
let syncHandler3 = (e) => {
const now = Date.now();
if (now - this._lastSyncTime < 50) return;
this._lastSyncTime = now;
// Get the barSpacing(zoom) and position of new chart (v5 API)
let barSpacing2 = this.bound_charts[2].timeScale().options().barSpacing;
let scrollPosition2 = this.bound_charts[2].timeScale().scrollPosition();
// Apply barSpacing(zoom) and position to parent chart
this.bound_charts[0].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 });
this.bound_charts[1].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 });
}
this.bound_charts[2].timeScale().subscribeVisibleTimeRangeChange(syncHandler3);
}
bind4charts(){
// Sync all 4 charts together (v5 API: options().barSpacing)
let syncFromChart = (sourceIndex) => {
return (e) => {
const now = Date.now();
if (now - this._lastSyncTime < 50) return;
this._lastSyncTime = now;
let barSpacing = this.bound_charts[sourceIndex].timeScale().options().barSpacing;
let scrollPosition = this.bound_charts[sourceIndex].timeScale().scrollPosition();
for (let i = 0; i < 4; i++) {
if (i !== sourceIndex) {
this.bound_charts[i].timeScale().applyOptions({ rightOffset: scrollPosition, barSpacing: barSpacing });
}
}
/**
* Unsubscribe all existing sync handlers and set up fresh ones.
* This prevents cumulative handlers from conflicting.
*/
_rebindAllCharts() {
// Unsubscribe all existing handlers using v5 API
for (const {chart, handler} of this._syncUnsubscribes) {
try {
chart.timeScale().unsubscribeVisibleTimeRangeChange(handler);
} catch (e) {
// Ignore errors during cleanup
}
}
this.bound_charts[0].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(0));
this.bound_charts[1].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(1));
this.bound_charts[2].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(2));
this.bound_charts[3].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(3));
}
this._syncUnsubscribes = [];
// Create a single sync handler that works with all current charts
const chartCount = this.bound_charts.length;
for (let sourceIndex = 0; sourceIndex < chartCount; sourceIndex++) {
const sourceChart = this.bound_charts[sourceIndex];
const syncHandler = () => {
// Prevent recursive sync (when we apply options, it triggers another event)
if (this._isSyncing) return;
bind5charts(){
// Sync all 5 charts together (main + RSI + MACD + %B + Patterns) (v5 API)
let syncFromChart = (sourceIndex) => {
return (e) => {
const now = Date.now();
if (now - this._lastSyncTime < 50) return;
this._isSyncing = true;
this._lastSyncTime = now;
let barSpacing = this.bound_charts[sourceIndex].timeScale().options().barSpacing;
let scrollPosition = this.bound_charts[sourceIndex].timeScale().scrollPosition();
for (let i = 0; i < 5; i++) {
if (i !== sourceIndex) {
this.bound_charts[i].timeScale().applyOptions({ rightOffset: scrollPosition, barSpacing: barSpacing });
try {
const barSpacing = sourceChart.timeScale().options().barSpacing;
const scrollPosition = sourceChart.timeScale().scrollPosition();
// Apply to all other charts
for (let i = 0; i < this.bound_charts.length; i++) {
if (i !== sourceIndex) {
this.bound_charts[i].timeScale().applyOptions({
rightOffset: scrollPosition,
barSpacing: barSpacing
});
}
}
} finally {
// Use setTimeout to reset flag after current event loop
setTimeout(() => {
this._isSyncing = false;
}, 0);
}
}
};
// Subscribe and store chart+handler for later unsubscribe
sourceChart.timeScale().subscribeVisibleTimeRangeChange(syncHandler);
this._syncUnsubscribes.push({chart: sourceChart, handler: syncHandler});
}
this.bound_charts[0].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(0));
this.bound_charts[1].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(1));
this.bound_charts[2].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(2));
this.bound_charts[3].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(3));
this.bound_charts[4].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(4));
}
// Set trade markers on chart for all trades in backtest results

View File

@ -16,6 +16,7 @@ class Comms {
// Initialize the message queue
this.messageQueue = [];
this.maxQueueSize = 250;
// Save the userName
this.userName = userName;
@ -62,8 +63,9 @@ class Comms {
query: { 'user_name': this.userName },
transports: ['websocket'], // Optional: Force WebSocket transport
autoConnect: true,
reconnectionAttempts: 5, // Optional: Number of reconnection attempts
reconnectionDelay: 1000 // Optional: Delay between reconnections
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000
});
// Handle connection events
@ -118,8 +120,24 @@ class Comms {
* Flushes the message queue by sending all queued messages.
*/
_flushMessageQueue() {
while (this.messageQueue.length > 0) {
const { messageType, data } = this.messageQueue.shift();
// Prioritize control/CRUD messages first, keep only the latest candle_data.
const queued = this.messageQueue.splice(0);
const priority = [];
let latestCandleMessage = null;
queued.forEach((msg) => {
if (msg.messageType === 'candle_data') {
latestCandleMessage = msg;
} else {
priority.push(msg);
}
});
const messagesToSend = latestCandleMessage
? [...priority, latestCandleMessage]
: priority;
messagesToSend.forEach(({ messageType, data }) => {
this.socket.emit('message', {
message_type: messageType,
data: {
@ -128,6 +146,29 @@ class Comms {
}
});
console.log(`Comms: Sent queued message-> ${JSON.stringify({ messageType, data })}`);
});
}
_enqueueMessage(messageType, data) {
if (messageType === 'candle_data') {
// Candle data is high-frequency. Keep only the latest unsent candle update.
const existingIndex = this.messageQueue.findIndex(msg => msg.messageType === 'candle_data');
if (existingIndex !== -1) {
this.messageQueue[existingIndex] = { messageType, data };
return;
}
}
this.messageQueue.push({ messageType, data });
// Prevent unbounded queue growth while disconnected.
while (this.messageQueue.length > this.maxQueueSize) {
const candleIndex = this.messageQueue.findIndex(msg => msg.messageType === 'candle_data');
if (candleIndex !== -1) {
this.messageQueue.splice(candleIndex, 1);
} else {
this.messageQueue.shift();
}
}
}
@ -468,7 +509,7 @@ class Comms {
// Not an error; message will be queued
console.warn('Socket.IO connection is not open. Queuing message.');
// Queue the message to be sent once connected
this.messageQueue.push({ messageType, data });
this._enqueueMessage(messageType, data);
console.warn(`Comms: Queued message-> ${JSON.stringify({ messageType, data })} (Connection not open)`);
}
}

File diff suppressed because it is too large Load Diff

694
src/static/formations.js Normal file
View File

@ -0,0 +1,694 @@
/**
* FormationsUIManager - Handles DOM updates and formation card rendering
*/
class FormationsUIManager {
constructor() {
this.targetEl = null;
this.instructionsEl = null;
this.nameControlsEl = null;
this.nameInputEl = null;
this.instructionTextEl = null;
this.pointsStatusEl = null;
this.onDeleteFormation = null;
this.onEditFormation = null;
}
/**
* Initializes the UI elements.
* @param {string} targetId - ID of the formations list container
*/
initUI(targetId) {
this.targetEl = document.getElementById(targetId);
if (!this.targetEl) {
console.warn(`Formations container "${targetId}" not found.`);
}
this.instructionsEl = document.getElementById('formation_drawing_instructions');
this.nameControlsEl = document.getElementById('formation_name_controls');
this.nameInputEl = document.getElementById('formation_name_input');
this.instructionTextEl = document.getElementById('formation_instruction_text');
this.pointsStatusEl = document.getElementById('formation_points_status');
}
/**
* Register callback for delete formation.
* @param {Function} callback - Function to call when delete is clicked
*/
registerDeleteCallback(callback) {
this.onDeleteFormation = callback;
}
/**
* Register callback for edit formation.
* @param {Function} callback - Function to call when edit is clicked
*/
registerEditCallback(callback) {
this.onEditFormation = callback;
}
/**
* Show drawing instructions for a formation type.
* @param {string} type - Formation type
* @param {number} pointsNeeded - Number of points needed
*/
showDrawingInstructions(type, pointsNeeded) {
// Hide name controls if visible
if (this.nameControlsEl) {
this.nameControlsEl.style.display = 'none';
}
// Show instructions
if (this.instructionsEl) {
this.instructionsEl.style.display = 'block';
}
// Set instruction text based on type
const instructions = {
'support_resistance': 'Click on chart to place a line. Drag anchors to adjust.',
'channel': 'Click to place first line, then click to place parallel line.'
};
if (this.instructionTextEl) {
this.instructionTextEl.textContent = instructions[type] || 'Click on chart to place points';
}
// Update points status (for single-click types, show descriptive text)
if (type === 'support_resistance') {
if (this.pointsStatusEl) {
this.pointsStatusEl.textContent = 'Click anywhere on the chart';
this.pointsStatusEl.style.color = '#667eea';
}
} else if (type === 'channel') {
if (this.pointsStatusEl) {
this.pointsStatusEl.textContent = 'Step 1: Click to place primary line';
this.pointsStatusEl.style.color = '#667eea';
}
} else {
this.updatePointsStatus(0, pointsNeeded);
}
}
/**
* Update the points status display.
* @param {number} current - Current points placed
* @param {number} needed - Points needed
*/
updatePointsStatus(current, needed) {
if (this.pointsStatusEl) {
this.pointsStatusEl.textContent = `Points: ${current} / ${needed}`;
// Change color when complete
this.pointsStatusEl.style.color = current >= needed ? '#28a745' : '#667eea';
}
}
/**
* Show name input after points are placed.
*/
showNameInput() {
// Hide instructions
if (this.instructionsEl) {
this.instructionsEl.style.display = 'none';
}
// Show name controls
if (this.nameControlsEl) {
this.nameControlsEl.style.display = 'block';
}
// Focus and clear input
if (this.nameInputEl) {
this.nameInputEl.value = '';
this.nameInputEl.focus();
}
}
/**
* Hide all drawing-related UI.
*/
hideAllDrawingUI() {
if (this.instructionsEl) {
this.instructionsEl.style.display = 'none';
}
if (this.nameControlsEl) {
this.nameControlsEl.style.display = 'none';
}
}
/**
* Legacy method - kept for compatibility
*/
showDrawingControls() {
// Now handled by showDrawingInstructions and showNameInput
}
/**
* Legacy method - kept for compatibility
*/
hideDrawingControls() {
this.hideAllDrawingUI();
}
/**
* Get the formation name from input.
* @returns {string} Formation name
*/
getFormationName() {
return this.nameInputEl ? this.nameInputEl.value.trim() : '';
}
/**
* Render all formations as cards.
* @param {Array} formations - List of formation objects
*/
renderFormations(formations) {
if (!this.targetEl) {
console.warn("Formations container not initialized");
return;
}
this.targetEl.innerHTML = '';
if (!formations || formations.length === 0) {
this.targetEl.innerHTML = '<p style="color: #888; font-size: 12px; padding: 10px;">No formations yet. Click a button above to draw one.</p>';
return;
}
formations.forEach(formation => {
const card = this._createFormationCard(formation);
this.targetEl.appendChild(card);
});
}
/**
* Create a formation card element.
* @param {Object} formation - Formation data
* @returns {HTMLElement} Card element
*/
_createFormationCard(formation) {
const card = document.createElement('div');
card.className = 'formation-item';
card.dataset.tblKey = formation.tbl_key;
card.dataset.type = formation.formation_type;
// Format type for display
const typeDisplay = this._formatType(formation.formation_type);
card.innerHTML = `
<div class="formation-icon">
<span class="formation-name">${this._escapeHtml(formation.name)}</span>
<span class="formation-type">${typeDisplay}</span>
</div>
<button class="edit-button" title="Edit">&#9998;</button>
<button class="delete-button" title="Delete">&times;</button>
<div class="formation-hover">
<strong>${this._escapeHtml(formation.name)}</strong>
<div class="formation-details">
<span>Type: ${typeDisplay}</span>
<span><span class="formation-color-dot" style="background: ${formation.color}"></span>Color</span>
<span>Scope: ${formation.exchange}/${formation.market}/${formation.timeframe}</span>
</div>
</div>
`;
// Add click handlers
const deleteBtn = card.querySelector('.delete-button');
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (this.onDeleteFormation) {
this.onDeleteFormation(formation.tbl_key, formation.name);
}
});
const editBtn = card.querySelector('.edit-button');
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (this.onEditFormation) {
this.onEditFormation(formation.tbl_key);
}
});
// Card click to select
card.addEventListener('click', () => {
this._selectCard(card);
if (this.onEditFormation) {
this.onEditFormation(formation.tbl_key);
}
});
return card;
}
/**
* Select a card visually.
* @param {HTMLElement} card - Card to select
*/
_selectCard(card) {
// Deselect all
this.targetEl.querySelectorAll('.formation-item').forEach(c => {
c.classList.remove('selected');
});
// Select this one
card.classList.add('selected');
}
/**
* Format formation type for display.
* @param {string} type - Formation type
* @returns {string} Formatted type
*/
_formatType(type) {
const typeMap = {
'support_resistance': 'Line',
'channel': 'Channel',
'triangle': 'Triangle',
'head_shoulders': 'H&S',
'double_bottom': 'Double Bottom',
'double_top': 'Double Top'
};
return typeMap[type] || type;
}
/**
* Escape HTML to prevent XSS.
* @param {string} str - String to escape
* @returns {string} Escaped string
*/
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
}
/**
* FormationsDataManager - Manages in-memory formations data
*/
class FormationsDataManager {
constructor() {
this.formations = [];
}
/**
* Set all formations.
* @param {Array} formations - List of formations
*/
setFormations(formations) {
this.formations = Array.isArray(formations) ? formations : [];
}
/**
* Get all formations.
* @returns {Array} List of formations
*/
getAllFormations() {
return this.formations;
}
/**
* Add a new formation.
* @param {Object} formation - Formation to add
*/
addFormation(formation) {
if (formation) {
this.formations.push(formation);
}
}
/**
* Update an existing formation.
* @param {Object} updatedFormation - Updated formation data
*/
updateFormation(updatedFormation) {
const index = this.formations.findIndex(f => f.tbl_key === updatedFormation.tbl_key);
if (index !== -1) {
this.formations[index] = { ...this.formations[index], ...updatedFormation };
}
}
/**
* Remove a formation by tbl_key.
* @param {string} tblKey - Formation tbl_key to remove
*/
removeFormation(tblKey) {
this.formations = this.formations.filter(f => f.tbl_key !== tblKey);
}
/**
* Get a formation by tbl_key.
* @param {string} tblKey - Formation tbl_key
* @returns {Object|null} Formation or null
*/
getFormation(tblKey) {
return this.formations.find(f => f.tbl_key === tblKey) || null;
}
}
/**
* Formations - Main coordinator class for formations feature
*/
class Formations {
constructor(ui) {
this.ui = ui;
this.comms = ui?.data?.comms;
this.data = ui?.data;
this.dataManager = new FormationsDataManager();
this.uiManager = new FormationsUIManager();
this.overlay = null;
// Current drawing state
this.drawingMode = null;
this.currentScope = null;
// Set up callbacks
this.uiManager.registerDeleteCallback(this.deleteFormation.bind(this));
this.uiManager.registerEditCallback(this.selectFormation.bind(this));
this._initialized = false;
}
/**
* Initialize the formations system.
* @param {string} targetId - ID of formations list container
*/
initialize(targetId) {
try {
this.uiManager.initUI(targetId);
if (!this.comms) {
console.error("Communications instance not available for Formations");
return;
}
// Register socket handlers
this.registerSocketHandlers();
// Get current scope from chart data
// Note: bt_data uses 'exchange_name' not 'exchange', and 'timeframe' not 'interval'
this.currentScope = {
exchange: this.data?.exchange || window.bt_data?.exchange_name || 'binance',
market: this.data?.trading_pair || window.bt_data?.trading_pair || 'BTC/USDT',
timeframe: this.data?.interval || window.bt_data?.timeframe || '1h'
};
// Fetch formations for current scope
this.fetchFormations();
this._initialized = true;
console.log("Formations initialized for scope:", this.currentScope);
} catch (error) {
console.error("Error initializing Formations:", error);
}
}
/**
* Initialize the SVG overlay for drawing.
* @param {string} chartContainerId - ID of chart container
* @param {Object} chart - Lightweight Charts instance
* @param {Object} candleSeries - Candlestick series for coordinate conversion
*/
initOverlay(chartContainerId, chart, candleSeries) {
if (typeof FormationOverlay !== 'undefined') {
this.overlay = new FormationOverlay(chartContainerId, chart, candleSeries);
this.overlay.setOnSaveCallback(this.saveFormation.bind(this));
console.log("Formation overlay initialized");
} else {
console.warn("FormationOverlay class not loaded");
}
}
/**
* Register socket handlers for formations.
*/
registerSocketHandlers() {
this.comms.on('formations', this.handleFormationsResponse.bind(this));
this.comms.on('formation_created', this.handleFormationCreated.bind(this));
this.comms.on('formation_updated', this.handleFormationUpdated.bind(this));
this.comms.on('formation_deleted', this.handleFormationDeleted.bind(this));
this.comms.on('formation_error', this.handleFormationError.bind(this));
}
/**
* Fetch formations for current scope.
*/
fetchFormations() {
if (!this.comms || !this.currentScope) return;
this.comms.sendToApp('request_formations', this.currentScope);
}
// ================ Socket Handlers ================
/**
* Handle formations list response.
* @param {Object} data - Response with formations array
*/
handleFormationsResponse(data) {
console.log("Received formations:", data);
const formations = data.formations || [];
this.dataManager.setFormations(formations);
this.uiManager.renderFormations(formations);
// Render on overlay if available
if (this.overlay) {
this.overlay.clearAllFormations();
formations.forEach(f => this.overlay.renderFormation(f));
}
}
/**
* Handle formation created event.
* @param {Object} data - Response with formation data
*/
handleFormationCreated(data) {
console.log("Formation created:", data);
if (data.success && data.formation) {
this.dataManager.addFormation(data.formation);
this.uiManager.renderFormations(this.dataManager.getAllFormations());
if (this.overlay) {
this.overlay.renderFormation(data.formation);
}
this.uiManager.hideAllDrawingUI();
this.drawingMode = null;
} else {
alert(`Failed to create formation: ${data.message}`);
}
}
/**
* Handle formation updated event.
* @param {Object} data - Response with updated formation data
*/
handleFormationUpdated(data) {
console.log("Formation updated:", data);
if (data.success && data.formation) {
this.dataManager.updateFormation(data.formation);
this.uiManager.renderFormations(this.dataManager.getAllFormations());
if (this.overlay) {
this.overlay.updateFormation(data.formation);
}
} else {
alert(`Failed to update formation: ${data.message}`);
}
}
/**
* Handle formation deleted event.
* @param {Object} data - Response with tbl_key of deleted formation
*/
handleFormationDeleted(data) {
console.log("Formation deleted:", data);
if (data.success && data.tbl_key) {
this.dataManager.removeFormation(data.tbl_key);
this.uiManager.renderFormations(this.dataManager.getAllFormations());
if (this.overlay) {
this.overlay.removeFormation(data.tbl_key);
}
}
}
/**
* Handle formation error.
* @param {Object} data - Error data
*/
handleFormationError(data) {
console.error("Formation error:", data.message);
alert(`Formation error: ${data.message}`);
}
// ================ Drawing Methods ================
/**
* Get points needed for a formation type.
* @param {string} type - Formation type
* @returns {number}
*/
_getPointsNeeded(type) {
const pointsMap = {
'support_resistance': 1, // Single click creates line with 3 anchors
'channel': 2 // Two clicks: primary line + parallel line placement
};
return pointsMap[type] || 2;
}
/**
* Start drawing a new formation.
* @param {string} type - Formation type ('support_resistance', 'channel')
*/
startDrawing(type) {
console.log("Starting drawing mode:", type);
this.drawingMode = type;
const pointsNeeded = this._getPointsNeeded(type);
// Show drawing instructions (not name input yet)
this.uiManager.showDrawingInstructions(type, pointsNeeded);
// Tell overlay to start drawing, with callbacks
if (this.overlay) {
this.overlay.setOnPointsChangedCallback(this._onPointsChanged.bind(this));
this.overlay.setOnDraftReadyCallback(this._onDraftReady.bind(this));
this.overlay.startDrawing(type);
}
}
/**
* Called when points are added/changed during drawing.
* @param {number} currentPoints - Current number of points
* @param {number} pointsNeeded - Points needed for completion
*/
_onPointsChanged(currentPoints, pointsNeeded) {
// For single-click formations, use _onDraftReady instead
if (this.drawingMode === 'support_resistance') {
return;
}
// For channel, update status based on step
if (this.drawingMode === 'channel') {
if (this.uiManager.pointsStatusEl) {
if (currentPoints === 1) {
this.uiManager.pointsStatusEl.textContent = 'Step 2: Move mouse and click to place parallel line';
this.uiManager.pointsStatusEl.style.color = '#667eea';
}
}
return;
}
// Update the UI status for other multi-point drawings
this.uiManager.updatePointsStatus(currentPoints, pointsNeeded);
// If we have enough points, show the name input
if (currentPoints >= pointsNeeded) {
this.uiManager.showNameInput();
}
}
/**
* Called when a draft formation is ready (line/channel placed, can be named).
*/
_onDraftReady() {
// Update status based on formation type
if (this.uiManager.pointsStatusEl) {
if (this.drawingMode === 'channel') {
this.uiManager.pointsStatusEl.textContent = 'Channel placed! Drag anchors to adjust.';
} else {
this.uiManager.pointsStatusEl.textContent = 'Line placed! Drag anchors to adjust.';
}
this.uiManager.pointsStatusEl.style.color = '#28a745';
}
// Show name input
this.uiManager.showNameInput();
}
/**
* Complete the current drawing.
*/
completeDrawing() {
const name = this.uiManager.getFormationName();
if (!name) {
alert("Please enter a formation name");
return;
}
if (this.overlay) {
this.overlay.completeDrawing(name);
}
}
/**
* Cancel the current drawing.
*/
cancelDrawing() {
this.drawingMode = null;
this.uiManager.hideAllDrawingUI();
if (this.overlay) {
this.overlay.cancelDrawing();
}
}
/**
* Save a formation (callback from overlay).
* @param {Object} formationData - Formation data to save
*/
saveFormation(formationData) {
if (!this.comms) return;
const payload = {
...formationData,
...this.currentScope
};
this.comms.sendToApp('new_formation', payload);
}
/**
* Select a formation for editing.
* @param {string} tblKey - Formation tbl_key
*/
selectFormation(tblKey) {
console.log("Selecting formation:", tblKey);
if (this.overlay) {
this.overlay.selectFormation(tblKey);
}
}
/**
* Delete a formation.
* @param {string} tblKey - Formation tbl_key
* @param {string} name - Formation name (for confirmation)
*/
deleteFormation(tblKey, name) {
if (!confirm(`Delete formation "${name}"?`)) {
return;
}
if (this.comms) {
if (!this.comms.connectionOpen) {
alert('Server connection is offline. Delete will be sent after reconnect.');
}
this.comms.sendToApp('delete_formation', { tbl_key: tblKey });
}
}
/**
* Update a formation's lines (called from overlay after drag).
* @param {string} tblKey - Formation tbl_key
* @param {Object} linesData - Updated lines data
*/
updateFormationLines(tblKey, linesData) {
if (!this.comms) return;
this.comms.sendToApp('edit_formation', {
tbl_key: tblKey,
lines_json: JSON.stringify(linesData)
});
}
}

View File

@ -10,6 +10,7 @@ class User_Interface {
this.users = new Users();
this.indicators = new Indicators(this.data.comms);
this.signals = new Signals(this);
this.formations = new Formations(this);
this.backtesting = new Backtesting(this);
this.statistics = new Statistics(this.data.comms);
this.account = new Account();
@ -78,6 +79,8 @@ class User_Interface {
this.indicators.addToCharts(this.charts, ind_init_data);
this.signals.initialize('signal_list', 'new_sig_form');
this.formations.initialize('formations_list');
this.formations.initOverlay(this.data.chart1_id, this.charts.chart_1, this.charts.candleSeries);
this.alerts.set_target();
this.alerts.initialize(this.data.comms);
this.controls.init_TP_selector();

View File

@ -101,10 +101,10 @@ class Indicator {
priceScaleId: 'volume_ps',
});
// v5: scaleMargins must be set on the price scale, not series options
// Volume should only take up bottom 30% of the chart
// Volume should only take up bottom 15% of the chart
chart.priceScale('volume_ps').applyOptions({
scaleMargins: {
top: 0.7,
top: 0.85,
bottom: 0,
},
});
@ -481,7 +481,7 @@ class Volume extends Indicator {
constructor(name, chart) {
super(name);
this.addHist(name, chart);
this.hist[name].applyOptions({ scaleMargins: { top: 0.95, bottom: 0.0 } });
// Note: scaleMargins are set in addHist() on the price scale (v5 API)
this.outputs = ['value'];
}

View File

@ -10,6 +10,8 @@
{% include "indicators_hud.html" %}
<button class="collapsible bg_blue">Signals</button>
{% include "signals_hud.html" %}
<button class="collapsible bg_blue">Formations</button>
{% include "formations_hud.html" %}
<button class="collapsible bg_blue">Strategies</button>
{% include "strategies_hud.html" %}
<button class="collapsible bg_blue">Statistics</button>

View File

@ -0,0 +1,267 @@
<div class="content" id="formations_panel">
<h4 style="margin: 5px 0 10px 0;">Draw Formation</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 5px;">
<button class="btn btn-sm" onclick="UI.formations.startDrawing('support_resistance')">
<span style="font-size: 18px;"></span> Line
</button>
<button class="btn btn-sm" onclick="UI.formations.startDrawing('channel')">
<span style="font-size: 18px;"></span> Channel
</button>
</div>
<!-- Drawing instructions (shown when drawing starts) -->
<div id="formation_drawing_instructions" style="display: none; margin-top: 10px; padding: 10px; background: #2a2a2a; border-radius: 5px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span id="formation_instruction_text" style="color: #8899aa; font-size: 12px;">Click on chart to place points...</span>
<button class="btn btn-sm" style="background: #dc3545; padding: 4px 8px;" onclick="UI.formations.cancelDrawing()">Cancel</button>
</div>
<div style="margin-top: 8px; color: #667eea; font-size: 11px;">
<span id="formation_points_status">Points: 0 / 2</span>
</div>
</div>
<!-- Name input (shown after points are placed) -->
<div id="formation_name_controls" style="display: none; margin-top: 10px; padding: 10px; background: #2a2a2a; border-radius: 5px;">
<div style="margin-bottom: 8px; color: #28a745; font-size: 12px;">Points placed. Enter a name:</div>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" id="formation_name_input" placeholder="Formation name"
style="flex: 1; padding: 5px; border-radius: 3px; border: 1px solid #444; background: #1e1e1e; color: #e0e0e0;">
<button class="btn btn-sm" style="background: #28a745;" onclick="UI.formations.completeDrawing()">Save</button>
<button class="btn btn-sm" style="background: #dc3545;" onclick="UI.formations.cancelDrawing()">Cancel</button>
</div>
</div>
<hr>
<h3>Formations</h3>
<div class="formations-container" id="formations_list"></div>
</div>
<style>
/* Formations container - flex grid for cards */
.formations-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
padding: 10px;
}
/* Individual formation card */
.formation-item {
position: relative;
width: 100px;
height: 100px;
border-radius: 10px;
background: linear-gradient(145deg, #2a3a5a, #1e2a40);
box-shadow: 5px 5px 10px #151f30, -5px -5px 10px #253550;
cursor: pointer;
transition: all 0.3s ease;
overflow: visible;
border: 2px solid #3a5a8a;
}
.formation-item:hover {
transform: translateY(-3px);
box-shadow: 8px 8px 15px #151f30, -8px -8px 15px #253550;
border-color: #667eea;
}
.formation-item.selected {
border-color: #667eea;
box-shadow: 0 0 15px rgba(102, 126, 234, 0.5);
}
/* Formation icon area */
.formation-icon {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px;
position: relative;
}
/* CSS-based formation icon */
.formation-icon::before {
content: '';
position: absolute;
top: 10px;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #667eea 0%, #4a5cd8 100%);
border-radius: 8px;
}
/* Line icon */
.formation-item[data-type="support_resistance"] .formation-icon::after {
content: '';
position: absolute;
top: 25px;
width: 30px;
height: 3px;
background: white;
transform: rotate(-15deg);
}
/* Channel icon - two parallel lines */
.formation-item[data-type="channel"] .formation-icon::after {
content: '';
position: absolute;
top: 20px;
width: 28px;
height: 3px;
background: white;
box-shadow: 0 10px 0 white;
transform: rotate(-15deg);
}
/* Formation name */
.formation-name {
font-size: 11px;
font-weight: bold;
text-align: center;
color: #e0e0e0;
margin-top: 50px;
word-wrap: break-word;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Formation type label */
.formation-type {
font-size: 9px;
color: #8899aa;
margin-top: 2px;
}
/* Delete button */
.formation-item .delete-button {
position: absolute;
top: -8px;
right: -8px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #dc3545;
color: white;
border: 2px solid #1e2a40;
font-size: 12px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
z-index: 10;
transition: transform 0.2s;
}
.formation-item:hover .delete-button {
display: flex;
}
.formation-item .delete-button:hover {
transform: scale(1.2);
}
/* Edit button */
.formation-item .edit-button {
position: absolute;
top: -8px;
right: 18px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #667eea;
color: white;
border: 2px solid #1e2a40;
font-size: 11px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
z-index: 10;
transition: transform 0.2s;
}
.formation-item:hover .edit-button {
display: flex;
}
.formation-item .edit-button:hover {
transform: scale(1.2);
}
/* Hover details panel */
.formation-hover {
position: absolute;
top: 110px;
left: 50%;
transform: translateX(-50%);
width: 180px;
padding: 10px;
background: #2a3a5a;
border: 1px solid #4a6a9a;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
z-index: 100;
display: none;
font-size: 11px;
color: #e0e0e0;
}
.formation-item:hover .formation-hover {
display: block;
}
.formation-hover strong {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #e0e0e0;
border-bottom: 1px solid #4a6a9a;
padding-bottom: 5px;
}
.formation-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.formation-details span {
color: #aabbcc;
}
/* Color indicator */
.formation-color-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
}
/* Button styles */
.btn-sm {
padding: 8px 12px;
font-size: 12px;
border-radius: 5px;
border: none;
background: linear-gradient(145deg, #3a4a6a, #2a3a5a);
color: #e0e0e0;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-sm:hover {
background: linear-gradient(145deg, #4a5a7a, #3a4a6a);
transform: translateY(-1px);
}
.btn-sm:active {
transform: translateY(0);
}
</style>

View File

@ -29,6 +29,8 @@
<script src="{{ url_for('static', filename='communication.js') }}"></script>
<script src="{{ url_for('static', filename='controls.js') }}"></script>
<script src="{{ url_for('static', filename='signals.js') }}"></script>
<script src="{{ url_for('static', filename='formation_overlay.js') }}"></script>
<script src="{{ url_for('static', filename='formations.js') }}"></script>
<script src="{{ url_for('static', filename='trade.js') }}"></script>
<script src="{{ url_for('static', filename='backtesting.js') }}"></script>
<script src="{{ url_for('static', filename='Statistics.js') }}?v=1"></script>

View File

@ -201,6 +201,11 @@ and you set fee to 50%, you earn $0.50 per profitable trade.
<!-- Signal blocks will be added here dynamically -->
</category>
<!-- Formations Category -->
<category name="Formations" colour="290" tooltip="Use chart formations (trendlines, channels) in strategy logic">
<!-- Formation blocks will be added here dynamically -->
</category>
<!-- Balances Subcategory -->
<category name="Balances" colour="#E69500" tooltip="Access strategy and account balance information">
<label text="Track your trading capital"></label>