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>
This commit is contained in:
rob 2026-03-10 16:22:08 -03:00
parent ba7b6e79ff
commit 5717ac6a81
8 changed files with 2201 additions and 0 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)
@ -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}")

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 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
}

View File

@ -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<tbl_key, {formation, elements: SVGElement[]}>
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');
}
}

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

@ -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 = '<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
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)
});
}
}

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

@ -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,255 @@
<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 controls (shown when drawing) -->
<div id="formation_drawing_controls" style="display: none; margin-top: 10px; padding: 10px; background: #2a2a2a; border-radius: 5px;">
<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>