From 5717ac6a81ca3cdeb4e1fb84fb8c2ab3056a5f90 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 10 Mar 2026 16:22:08 -0300 Subject: [PATCH] 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 --- src/BrighterTrades.py | 40 ++ src/Formations.py | 490 +++++++++++++++++ src/static/formation_overlay.js | 870 ++++++++++++++++++++++++++++++ src/static/formations.js | 539 ++++++++++++++++++ src/static/general.js | 3 + src/templates/control_panel.html | 2 + src/templates/formations_hud.html | 255 +++++++++ src/templates/index.html | 2 + 8 files changed, 2201 insertions(+) create mode 100644 src/Formations.py create mode 100644 src/static/formation_overlay.js create mode 100644 src/static/formations.js create mode 100644 src/templates/formations_hud.html diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py index 347f053..2dc5758 100644 --- a/src/BrighterTrades.py +++ b/src/BrighterTrades.py @@ -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) @@ -1888,6 +1892,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}") diff --git a/src/Formations.py b/src/Formations.py new file mode 100644 index 0000000..67fb780 --- /dev/null +++ b/src/Formations.py @@ -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 via DataCache + self.data_cache.add_row_to_datacache( + cache_name='formations', + row_data=dict(zip(columns, values)), + tbl_key=tbl_key, + persist=True + ) + + # 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 + self.data_cache.update_row_in_datacache( + cache_name='formations', + tbl_key=tbl_key, + updates=update_data, + persist=True + ) + + 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 + self.data_cache.delete_row_from_datacache( + cache_name='formations', + tbl_key=tbl_key, + persist=True + ) + + # 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 + } diff --git a/src/static/formation_overlay.js b/src/static/formation_overlay.js new file mode 100644 index 0000000..2092220 --- /dev/null +++ b/src/static/formation_overlay.js @@ -0,0 +1,870 @@ +/** + * FormationOverlay - SVG-based overlay for drawing and rendering chart formations + * + * Uses SVG layer positioned over the chart container. + * Syncs with chart via requestAnimationFrame polling (not event subscriptions). + */ +class FormationOverlay { + constructor(chartContainerId, chart, candleSeries) { + this.chartContainerId = chartContainerId; + this.chart = chart; + this.candleSeries = candleSeries; + + // SVG layer reference + this.svg = null; + this.container = null; + + // Drawing state + this.drawingMode = null; // 'support_resistance', 'channel' + this.currentPoints = []; + this.tempLine = null; + + // Rendered formations: Map + this.renderedFormations = new Map(); + + // Selected formation for editing + this.selectedTblKey = null; + + // Dragging state + this.isDragging = false; + this.dragAnchor = null; + this.dragFormation = null; + + // RAF loop state + this._loopRunning = false; + this._animationFrameId = null; + this._lastTimeRange = null; + this._lastPriceRange = null; + + // Callback for saving formations + this.onSaveCallback = null; + + // Colors + this.defaultColor = '#667eea'; + this.selectedColor = '#ff9500'; + this.anchorColor = '#ffffff'; + + // Initialize + this._createSVGLayer(); + this._startSyncLoop(); + this._attachEventListeners(); + } + + /** + * Set callback for when a formation is ready to save. + * @param {Function} callback - Called with formation data + */ + setOnSaveCallback(callback) { + this.onSaveCallback = callback; + } + + /** + * Create the SVG overlay layer. + */ + _createSVGLayer() { + this.container = document.getElementById(this.chartContainerId); + if (!this.container) { + console.error(`FormationOverlay: Container "${this.chartContainerId}" not found`); + return; + } + + // Ensure container has relative positioning + const containerStyle = window.getComputedStyle(this.container); + if (containerStyle.position === 'static') { + this.container.style.position = 'relative'; + } + + // Create SVG element + this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.svg.setAttribute('class', 'formation-overlay'); + this.svg.style.cssText = ` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 100; + `; + + // Create groups for layering + this.linesGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + this.linesGroup.setAttribute('class', 'formation-lines'); + + this.anchorsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + this.anchorsGroup.setAttribute('class', 'formation-anchors'); + this.anchorsGroup.style.pointerEvents = 'all'; + + this.tempGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + this.tempGroup.setAttribute('class', 'formation-temp'); + + this.svg.appendChild(this.linesGroup); + this.svg.appendChild(this.anchorsGroup); + this.svg.appendChild(this.tempGroup); + + this.container.appendChild(this.svg); + } + + /** + * Start the requestAnimationFrame sync loop. + * Uses guards to prevent multiple loops. + */ + _startSyncLoop() { + if (this._loopRunning) return; + this._loopRunning = true; + + const sync = () => { + if (!this._loopRunning) return; + + // Check if chart view has changed + if (this._hasViewChanged()) { + this._updateAllFormations(); + } + + this._animationFrameId = requestAnimationFrame(sync); + }; + + this._animationFrameId = requestAnimationFrame(sync); + } + + /** + * Check if the chart view (time/price range) has changed. + * Uses time scale visible range - in v5, price scale methods differ. + * @returns {boolean} + */ + _hasViewChanged() { + if (!this.chart) return false; + + try { + const timeScale = this.chart.timeScale(); + const timeRange = timeScale.getVisibleLogicalRange(); + // In v5, use barsInLogicalRange for visible bars info + const visibleBars = this.candleSeries ? this.candleSeries.barsInLogicalRange(timeRange) : null; + + const timeChanged = JSON.stringify(timeRange) !== JSON.stringify(this._lastTimeRange); + const barsChanged = JSON.stringify(visibleBars) !== JSON.stringify(this._lastPriceRange); + + if (timeChanged || barsChanged) { + this._lastTimeRange = timeRange; + this._lastPriceRange = visibleBars; + return true; + } + } catch (e) { + // Fallback: always update if there's an error + return true; + } + + return false; + } + + /** + * Attach mouse event listeners for drawing and dragging. + */ + _attachEventListeners() { + if (!this.container) return; + + // Click handler for drawing + this.container.addEventListener('click', (e) => { + if (this.drawingMode) { + const coords = this._pixelToChart(e.offsetX, e.offsetY); + if (coords) { + this._handleDrawingClick(coords); + } + } + }); + + // Mouse move for temp line preview + this.container.addEventListener('mousemove', (e) => { + if (this.drawingMode && this.currentPoints.length > 0) { + const coords = this._pixelToChart(e.offsetX, e.offsetY); + if (coords) { + this._updateTempLine(coords); + } + } + + // Handle dragging + if (this.isDragging && this.dragAnchor && this.dragFormation) { + const coords = this._pixelToChart(e.offsetX, e.offsetY); + if (coords) { + this._handleDrag(coords); + } + } + }); + + // Mouse up to end dragging + this.container.addEventListener('mouseup', () => { + if (this.isDragging) { + this._endDrag(); + } + }); + + // Mouse leave to cancel dragging + this.container.addEventListener('mouseleave', () => { + if (this.isDragging) { + this._endDrag(); + } + }); + + // Escape key to cancel drawing + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.drawingMode) { + this.cancelDrawing(); + } + }); + } + + /** + * Convert pixel coordinates to chart time/price. + * @param {number} x - Pixel X + * @param {number} y - Pixel Y + * @returns {{time: number, price: number}|null} + */ + _pixelToChart(x, y) { + if (!this.chart || !this.candleSeries) return null; + + try { + const timeScale = this.chart.timeScale(); + const time = timeScale.coordinateToTime(x); + const price = this.candleSeries.coordinateToPrice(y); + + if (time === null || price === null) return null; + + return { time: Math.floor(time), price }; + } catch (e) { + console.warn('FormationOverlay: pixel to chart conversion failed', e); + return null; + } + } + + /** + * Convert chart time/price to pixel coordinates. + * @param {number} time - Unix timestamp + * @param {number} price - Price value + * @returns {{x: number, y: number}|null} + */ + _chartToPixel(time, price) { + if (!this.chart || !this.candleSeries) return null; + + try { + const timeScale = this.chart.timeScale(); + const x = timeScale.timeToCoordinate(time); + const y = this.candleSeries.priceToCoordinate(price); + + if (x === null || y === null) return null; + + return { x, y }; + } catch (e) { + console.warn('FormationOverlay: chart to pixel conversion failed', e); + return null; + } + } + + /** + * Calculate infinite line endpoints that extend to viewport edges. + * @param {Object} point1 - {time, price} + * @param {Object} point2 - {time, price} + * @returns {{start: {x, y}, end: {x, y}}|null} + */ + _getInfiniteLineEndpoints(point1, point2) { + if (!this.svg) return null; + + const width = this.svg.clientWidth || this.container.clientWidth; + const height = this.svg.clientHeight || this.container.clientHeight; + + // Convert anchor points to pixels + const p1 = this._chartToPixel(point1.time, point1.price); + const p2 = this._chartToPixel(point2.time, point2.price); + + if (!p1 || !p2) return null; + + // Calculate line parameters + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + + // Handle vertical line + if (Math.abs(dx) < 0.001) { + return { + start: { x: p1.x, y: 0 }, + end: { x: p1.x, y: height } + }; + } + + // Handle horizontal line + if (Math.abs(dy) < 0.001) { + return { + start: { x: 0, y: p1.y }, + end: { x: width, y: p1.y } + }; + } + + // Calculate slope + const slope = dy / dx; + + // Calculate y-intercept (y = mx + b => b = y - mx) + const b = p1.y - slope * p1.x; + + // Find intersections with viewport edges + const intersections = []; + + // Left edge (x = 0) + const yLeft = b; + if (yLeft >= 0 && yLeft <= height) { + intersections.push({ x: 0, y: yLeft }); + } + + // Right edge (x = width) + const yRight = slope * width + b; + if (yRight >= 0 && yRight <= height) { + intersections.push({ x: width, y: yRight }); + } + + // Top edge (y = 0) + const xTop = -b / slope; + if (xTop >= 0 && xTop <= width) { + intersections.push({ x: xTop, y: 0 }); + } + + // Bottom edge (y = height) + const xBottom = (height - b) / slope; + if (xBottom >= 0 && xBottom <= width) { + intersections.push({ x: xBottom, y: height }); + } + + // We need exactly 2 intersections + if (intersections.length < 2) { + // Fallback: just use the anchor points + return { start: p1, end: p2 }; + } + + // Sort by x to get consistent start/end + intersections.sort((a, b) => a.x - b.x); + + return { + start: intersections[0], + end: intersections[intersections.length - 1] + }; + } + + // ================ Drawing Methods ================ + + /** + * Start drawing a formation. + * @param {string} type - Formation type ('support_resistance', 'channel') + */ + startDrawing(type) { + this.drawingMode = type; + this.currentPoints = []; + this._clearTempElements(); + + // Change cursor + if (this.container) { + this.container.style.cursor = 'crosshair'; + } + + // Enable pointer events on SVG for clicks + if (this.svg) { + this.svg.style.pointerEvents = 'all'; + } + + console.log('FormationOverlay: Started drawing', type); + } + + /** + * Handle click during drawing mode. + * @param {Object} coords - {time, price} + */ + _handleDrawingClick(coords) { + this.currentPoints.push(coords); + + // Draw anchor at click point + this._drawTempAnchor(coords); + + // Check if drawing is complete based on formation type + const pointsNeeded = this._getPointsNeeded(this.drawingMode); + + if (this.currentPoints.length >= pointsNeeded) { + // Drawing complete, wait for name input + console.log('FormationOverlay: Points collected', this.currentPoints); + } + } + + /** + * Get number of points needed for a formation type. + * @param {string} type - Formation type + * @returns {number} + */ + _getPointsNeeded(type) { + const pointsMap = { + 'support_resistance': 2, + 'channel': 3, + 'triangle': 3, + 'head_shoulders': 5, + 'double_bottom': 3, + 'double_top': 3 + }; + return pointsMap[type] || 2; + } + + /** + * Draw a temporary anchor circle. + * @param {Object} coords - {time, price} + */ + _drawTempAnchor(coords) { + const pixel = this._chartToPixel(coords.time, coords.price); + if (!pixel) return; + + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', pixel.x); + circle.setAttribute('cy', pixel.y); + circle.setAttribute('r', 6); + circle.setAttribute('fill', this.defaultColor); + circle.setAttribute('stroke', this.anchorColor); + circle.setAttribute('stroke-width', 2); + + this.tempGroup.appendChild(circle); + } + + /** + * Update the temporary preview line. + * @param {Object} coords - Current mouse position {time, price} + */ + _updateTempLine(coords) { + // Remove existing temp line + if (this.tempLine) { + this.tempLine.remove(); + this.tempLine = null; + } + + if (this.currentPoints.length === 0) return; + + const lastPoint = this.currentPoints[this.currentPoints.length - 1]; + const endpoints = this._getInfiniteLineEndpoints(lastPoint, coords); + + if (!endpoints) return; + + this.tempLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + this.tempLine.setAttribute('x1', endpoints.start.x); + this.tempLine.setAttribute('y1', endpoints.start.y); + this.tempLine.setAttribute('x2', endpoints.end.x); + this.tempLine.setAttribute('y2', endpoints.end.y); + this.tempLine.setAttribute('stroke', this.defaultColor); + this.tempLine.setAttribute('stroke-width', 2); + this.tempLine.setAttribute('stroke-dasharray', '5,5'); + this.tempLine.style.opacity = '0.7'; + + this.tempGroup.appendChild(this.tempLine); + } + + /** + * Clear temporary drawing elements. + */ + _clearTempElements() { + while (this.tempGroup.firstChild) { + this.tempGroup.removeChild(this.tempGroup.firstChild); + } + this.tempLine = null; + } + + /** + * Complete the current drawing and save. + * @param {string} name - Formation name + */ + completeDrawing(name) { + if (!this.drawingMode || this.currentPoints.length < 2) { + console.warn('FormationOverlay: Not enough points to complete drawing'); + return; + } + + // Build lines data based on formation type + const lines = this._buildLinesFromPoints(this.drawingMode, this.currentPoints); + + const formationData = { + name: name, + formation_type: this.drawingMode, + lines_json: JSON.stringify({ lines: lines }), + color: this.defaultColor + }; + + // Call save callback + if (this.onSaveCallback) { + this.onSaveCallback(formationData); + } + + // Reset drawing state + this._exitDrawingMode(); + } + + /** + * Build lines array from clicked points. + * @param {string} type - Formation type + * @param {Array} points - Array of {time, price} + * @returns {Array} Lines array + */ + _buildLinesFromPoints(type, points) { + const lines = []; + + if (type === 'support_resistance' && points.length >= 2) { + // Single line from two points + lines.push({ + point1: { time: points[0].time, price: points[0].price }, + point2: { time: points[1].time, price: points[1].price } + }); + } else if (type === 'channel' && points.length >= 3) { + // First line from points 0-1 + lines.push({ + point1: { time: points[0].time, price: points[0].price }, + point2: { time: points[1].time, price: points[1].price } + }); + // Second parallel line: same time span, offset by third point's price difference + const priceOffset = points[2].price - points[0].price; + lines.push({ + point1: { time: points[0].time, price: points[0].price + priceOffset }, + point2: { time: points[1].time, price: points[1].price + priceOffset } + }); + } + + return lines; + } + + /** + * Cancel the current drawing. + */ + cancelDrawing() { + this._exitDrawingMode(); + } + + /** + * Exit drawing mode and reset state. + */ + _exitDrawingMode() { + this.drawingMode = null; + this.currentPoints = []; + this._clearTempElements(); + + // Reset cursor + if (this.container) { + this.container.style.cursor = ''; + } + + // Disable pointer events on SVG + if (this.svg) { + this.svg.style.pointerEvents = 'none'; + } + } + + // ================ Rendering Methods ================ + + /** + * Render a formation on the chart. + * @param {Object} formation - Formation object with tbl_key, lines_json, color, etc. + */ + renderFormation(formation) { + if (!formation || !formation.tbl_key) return; + + // Remove existing if updating + this.removeFormation(formation.tbl_key); + + const linesData = JSON.parse(formation.lines_json || '{}'); + const lines = linesData.lines || []; + const color = formation.color || this.defaultColor; + const elements = []; + + lines.forEach((line, index) => { + // Draw the infinite line + const lineEl = this._createLine(line.point1, line.point2, color, formation.tbl_key); + if (lineEl) { + this.linesGroup.appendChild(lineEl); + elements.push(lineEl); + } + + // Draw anchor points + const anchor1 = this._createAnchor(line.point1, formation.tbl_key, index, 'point1'); + const anchor2 = this._createAnchor(line.point2, formation.tbl_key, index, 'point2'); + + if (anchor1) { + this.anchorsGroup.appendChild(anchor1); + elements.push(anchor1); + } + if (anchor2) { + this.anchorsGroup.appendChild(anchor2); + elements.push(anchor2); + } + }); + + this.renderedFormations.set(formation.tbl_key, { + formation: formation, + elements: elements + }); + } + + /** + * Create an SVG line element. + * @param {Object} point1 - {time, price} + * @param {Object} point2 - {time, price} + * @param {string} color - Line color + * @param {string} tblKey - Formation tbl_key + * @returns {SVGLineElement|null} + */ + _createLine(point1, point2, color, tblKey) { + const endpoints = this._getInfiniteLineEndpoints(point1, point2); + if (!endpoints) return null; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', endpoints.start.x); + line.setAttribute('y1', endpoints.start.y); + line.setAttribute('x2', endpoints.end.x); + line.setAttribute('y2', endpoints.end.y); + line.setAttribute('stroke', color); + line.setAttribute('stroke-width', 2); + line.setAttribute('data-tbl-key', tblKey); + line.setAttribute('data-point1-time', point1.time); + line.setAttribute('data-point1-price', point1.price); + line.setAttribute('data-point2-time', point2.time); + line.setAttribute('data-point2-price', point2.price); + + return line; + } + + /** + * Create an anchor circle for dragging. + * @param {Object} point - {time, price} + * @param {string} tblKey - Formation tbl_key + * @param {number} lineIndex - Index of line in formation + * @param {string} pointKey - 'point1' or 'point2' + * @returns {SVGCircleElement|null} + */ + _createAnchor(point, tblKey, lineIndex, pointKey) { + const pixel = this._chartToPixel(point.time, point.price); + if (!pixel) return null; + + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', pixel.x); + circle.setAttribute('cy', pixel.y); + circle.setAttribute('r', 6); + circle.setAttribute('fill', this.defaultColor); + circle.setAttribute('stroke', this.anchorColor); + circle.setAttribute('stroke-width', 2); + circle.setAttribute('cursor', 'move'); + circle.setAttribute('data-tbl-key', tblKey); + circle.setAttribute('data-line-index', lineIndex); + circle.setAttribute('data-point-key', pointKey); + circle.setAttribute('data-time', point.time); + circle.setAttribute('data-price', point.price); + + // Make anchors visible on hover + circle.style.opacity = '0'; + circle.style.transition = 'opacity 0.2s'; + + // Anchor event listeners + circle.addEventListener('mouseenter', () => { + circle.style.opacity = '1'; + }); + + circle.addEventListener('mouseleave', () => { + if (!this.isDragging) { + circle.style.opacity = '0'; + } + }); + + circle.addEventListener('mousedown', (e) => { + e.stopPropagation(); + this._startDrag(circle, tblKey); + }); + + return circle; + } + + /** + * Update all rendered formations (called when chart view changes). + */ + _updateAllFormations() { + for (const [tblKey, data] of this.renderedFormations) { + // Re-render the formation + this.renderFormation(data.formation); + } + + // Update temp elements if drawing + if (this.drawingMode && this.currentPoints.length > 0) { + this._clearTempElements(); + this.currentPoints.forEach(pt => this._drawTempAnchor(pt)); + } + } + + /** + * Update a specific formation. + * @param {Object} formation - Updated formation data + */ + updateFormation(formation) { + this.renderFormation(formation); + } + + /** + * Remove a formation from the overlay. + * @param {string} tblKey - Formation tbl_key + */ + removeFormation(tblKey) { + const data = this.renderedFormations.get(tblKey); + if (data) { + data.elements.forEach(el => el.remove()); + this.renderedFormations.delete(tblKey); + } + } + + /** + * Clear all formations from the overlay. + */ + clearAllFormations() { + for (const tblKey of this.renderedFormations.keys()) { + this.removeFormation(tblKey); + } + } + + /** + * Select a formation for editing. + * @param {string} tblKey - Formation tbl_key + */ + selectFormation(tblKey) { + // Deselect previous + if (this.selectedTblKey) { + const prevData = this.renderedFormations.get(this.selectedTblKey); + if (prevData) { + prevData.elements.forEach(el => { + if (el.tagName === 'line') { + el.setAttribute('stroke', prevData.formation.color || this.defaultColor); + } + if (el.tagName === 'circle') { + el.setAttribute('fill', prevData.formation.color || this.defaultColor); + } + }); + } + } + + this.selectedTblKey = tblKey; + + // Highlight selected + const data = this.renderedFormations.get(tblKey); + if (data) { + data.elements.forEach(el => { + if (el.tagName === 'line') { + el.setAttribute('stroke', this.selectedColor); + } + if (el.tagName === 'circle') { + el.setAttribute('fill', this.selectedColor); + el.style.opacity = '1'; + } + }); + } + } + + // ================ Dragging Methods ================ + + /** + * Start dragging an anchor. + * @param {SVGCircleElement} anchor - The anchor element + * @param {string} tblKey - Formation tbl_key + */ + _startDrag(anchor, tblKey) { + this.isDragging = true; + this.dragAnchor = anchor; + + const data = this.renderedFormations.get(tblKey); + if (data) { + this.dragFormation = data.formation; + } + + // Visual feedback + anchor.setAttribute('r', 8); + + // Prevent text selection during drag + document.body.style.userSelect = 'none'; + } + + /** + * Handle drag movement. + * @param {Object} coords - New position {time, price} + */ + _handleDrag(coords) { + if (!this.dragAnchor || !this.dragFormation) return; + + const lineIndex = parseInt(this.dragAnchor.getAttribute('data-line-index'), 10); + const pointKey = this.dragAnchor.getAttribute('data-point-key'); + + // Null guards + if (isNaN(lineIndex) || !pointKey) return; + + // Parse current lines + let linesData; + try { + linesData = JSON.parse(this.dragFormation.lines_json || '{}'); + } catch (e) { + return; + } + + if (!linesData.lines || !linesData.lines[lineIndex]) return; + + // Update the point + linesData.lines[lineIndex][pointKey] = { + time: coords.time, + price: coords.price + }; + + // Update formation data + this.dragFormation.lines_json = JSON.stringify(linesData); + + // Update anchor position + const pixel = this._chartToPixel(coords.time, coords.price); + if (pixel) { + this.dragAnchor.setAttribute('cx', pixel.x); + this.dragAnchor.setAttribute('cy', pixel.y); + this.dragAnchor.setAttribute('data-time', coords.time); + this.dragAnchor.setAttribute('data-price', coords.price); + } + + // Re-render the formation + this.renderFormation(this.dragFormation); + } + + /** + * End dragging. + */ + _endDrag() { + if (this.dragAnchor) { + this.dragAnchor.setAttribute('r', 6); + } + + // If we were dragging, save the changes + // (This would typically emit an update event) + + this.isDragging = false; + this.dragAnchor = null; + this.dragFormation = null; + + document.body.style.userSelect = ''; + } + + // ================ Cleanup ================ + + /** + * Destroy the overlay and clean up resources. + */ + destroy() { + // Stop RAF loop + this._loopRunning = false; + if (this._animationFrameId) { + cancelAnimationFrame(this._animationFrameId); + this._animationFrameId = null; + } + + // Remove SVG element + if (this.svg && this.svg.parentNode) { + this.svg.parentNode.removeChild(this.svg); + } + + // Clear references + this.svg = null; + this.container = null; + this.renderedFormations.clear(); + + console.log('FormationOverlay: Destroyed'); + } +} diff --git a/src/static/formations.js b/src/static/formations.js new file mode 100644 index 0000000..47b1d3b --- /dev/null +++ b/src/static/formations.js @@ -0,0 +1,539 @@ +/** + * FormationsUIManager - Handles DOM updates and formation card rendering + */ +class FormationsUIManager { + constructor() { + this.targetEl = null; + this.drawingControlsEl = null; + this.nameInputEl = 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.drawingControlsEl = document.getElementById('formation_drawing_controls'); + this.nameInputEl = document.getElementById('formation_name_input'); + } + + /** + * 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 controls. + */ + showDrawingControls() { + if (this.drawingControlsEl) { + this.drawingControlsEl.style.display = 'block'; + } + if (this.nameInputEl) { + this.nameInputEl.value = ''; + this.nameInputEl.focus(); + } + } + + /** + * Hide drawing controls. + */ + hideDrawingControls() { + if (this.drawingControlsEl) { + this.drawingControlsEl.style.display = 'none'; + } + } + + /** + * 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 = '

No formations yet. Click a button above to draw one.

'; + 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 = ` +
+ ${this._escapeHtml(formation.name)} + ${typeDisplay} +
+ + +
+ ${this._escapeHtml(formation.name)} +
+ Type: ${typeDisplay} + Color + Scope: ${formation.exchange}/${formation.market}/${formation.timeframe} +
+
+ `; + + // 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 + this.currentScope = { + exchange: this.data?.exchange || window.bt_data?.exchange || 'kucoin', + market: this.data?.trading_pair || window.bt_data?.trading_pair || 'BTC/USDT', + timeframe: this.data?.timeframe || 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.hideDrawingControls(); + 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 ================ + + /** + * Start drawing a new formation. + * @param {string} type - Formation type ('support_resistance', 'channel') + */ + startDrawing(type) { + console.log("Starting drawing mode:", type); + this.drawingMode = type; + + // Show drawing controls + this.uiManager.showDrawingControls(); + + // Tell overlay to start drawing + if (this.overlay) { + this.overlay.startDrawing(type); + } + } + + /** + * 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.hideDrawingControls(); + + 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) { + 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) + }); + } +} diff --git a/src/static/general.js b/src/static/general.js index 830730c..ced1bd3 100644 --- a/src/static/general.js +++ b/src/static/general.js @@ -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(); diff --git a/src/templates/control_panel.html b/src/templates/control_panel.html index 395ad98..4f335a4 100644 --- a/src/templates/control_panel.html +++ b/src/templates/control_panel.html @@ -10,6 +10,8 @@ {% include "indicators_hud.html" %} {% include "signals_hud.html" %} + + {% include "formations_hud.html" %} {% include "strategies_hud.html" %} diff --git a/src/templates/formations_hud.html b/src/templates/formations_hud.html new file mode 100644 index 0000000..cb0a917 --- /dev/null +++ b/src/templates/formations_hud.html @@ -0,0 +1,255 @@ +
+

Draw Formation

+
+ + +
+ + + + +
+

Formations

+
+
+ + diff --git a/src/templates/index.html b/src/templates/index.html index 2088fa0..489b94f 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -29,6 +29,8 @@ + +