Complete remaining ET specs identified in INTEGRATION-PLAN: - ET-EDU-007: Video Player Advanced (554 LOC component) - ET-MT4-001: WebSocket Integration (BLOCKER - 0% implemented) - ET-ML-009: Ensemble Signal (Multi-strategy aggregation) - ET-TRD-009: Risk-Based Position Sizer (391 LOC component) - ET-TRD-010: Drawing Tools Persistence (backend + store) - ET-TRD-011: Market Bias Indicator (multi-timeframe analysis) - ET-PFM-009: Custom Charts (SVG AllocationChart + Canvas PerformanceChart) - ET-ML-008: ICT Analysis Card (expanded - 294 LOC component) All specs include: - Architecture diagrams - Complete code examples - API contracts - Implementation guides - Testing scenarios Related: TASK-2026-01-25-002-FRONTEND-COMPREHENSIVE-AUDIT Priority: P1-P3 (mixed) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1269 lines
38 KiB
Markdown
1269 lines
38 KiB
Markdown
# ET-TRD-010: Drawing Tools Persistence
|
|
|
|
**Versión:** 1.0.0
|
|
**Fecha:** 2026-01-25
|
|
**Epic:** OQI-003 - Trading y Charts
|
|
**Componente:** Backend API + Frontend Store + Database Schema
|
|
**Estado:** 🟡 50% Implementado (UI funciona, falta persistencia)
|
|
**Prioridad:** P2
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **ID** | ET-TRD-010 |
|
|
| **Tipo** | Especificación Técnica |
|
|
| **Epic** | OQI-003 |
|
|
| **US Relacionada** | US-TRD-010 (Persistir Dibujos en Charts) |
|
|
| **Componente Actual** | `ChartDrawingToolsPanel.tsx` (420 líneas, ✅ funcional) |
|
|
| **Gap** | Persistencia (backend API + DB + Zustand store) |
|
|
| **Complejidad** | Media (CRUD + sincronización) |
|
|
| **Esfuerzo Estimado** | 3 horas |
|
|
|
|
---
|
|
|
|
## 1. Descripción General
|
|
|
|
**Drawing Tools Persistence** permite a los usuarios guardar sus análisis técnicos (líneas de tendencia, Fibonacci, rectángulos, anotaciones, etc.) en el backend para que persistan entre sesiones y dispositivos.
|
|
|
|
### Estado Actual (50% Implementado)
|
|
|
|
✅ **Frontend UI Completado:**
|
|
- Componente `ChartDrawingToolsPanel.tsx` con 10 herramientas de dibujo
|
|
- Gestión de estado local (color, grosor, visibilidad, bloqueo)
|
|
- Duplicación, eliminación, undo
|
|
- 420 líneas de código funcional
|
|
|
|
❌ **Falta Implementar:**
|
|
- Backend API REST (`/api/chart-drawings`)
|
|
- Tabla `chart_drawings` en PostgreSQL
|
|
- Zustand store para gestión de estado
|
|
- Hook `useDrawings` para sincronización
|
|
- Auto-save debounced (1 segundo)
|
|
- Import/export JSON
|
|
|
|
---
|
|
|
|
## 2. Arquitectura de Persistencia
|
|
|
|
### 2.1 Flujo de Datos
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Drawing Persistence Architecture │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ User Interaction (Chart Canvas) │
|
|
│ │ │
|
|
│ v │
|
|
│ ┌──────────────────────────────────────┐ │
|
|
│ │ ChartDrawingToolsPanel.tsx │ │
|
|
│ │ • Draw, Edit, Delete │ │
|
|
│ │ • Local state management │ │
|
|
│ └──────────────┬───────────────────────┘ │
|
|
│ │ │
|
|
│ v │
|
|
│ ┌──────────────────────────────────────┐ │
|
|
│ │ useDrawings Hook │ │
|
|
│ │ • Debounce changes (1s) │ │
|
|
│ │ • Call Zustand actions │ │
|
|
│ └──────────────┬───────────────────────┘ │
|
|
│ │ │
|
|
│ v │
|
|
│ ┌──────────────────────────────────────┐ │
|
|
│ │ drawingsStore (Zustand) │ │
|
|
│ │ • Local cache │ │
|
|
│ │ • Optimistic updates │ │
|
|
│ │ • API calls │ │
|
|
│ └──────────────┬───────────────────────┘ │
|
|
│ │ │
|
|
│ ┌───────┴────────┐ │
|
|
│ │ │ │
|
|
│ v v │
|
|
│ POST /api/drawings GET /api/drawings/:symbol │
|
|
│ │ │ │
|
|
│ v v │
|
|
│ ┌──────────────────────────────────────┐ │
|
|
│ │ Express Backend │ │
|
|
│ │ • ChartDrawingsController │ │
|
|
│ │ • Validation │ │
|
|
│ │ • Auth check (JWT) │ │
|
|
│ └──────────────┬───────────────────────┘ │
|
|
│ │ │
|
|
│ v │
|
|
│ ┌──────────────────────────────────────┐ │
|
|
│ │ PostgreSQL: chart_drawings │ │
|
|
│ │ • user_id, symbol, timeframe │ │
|
|
│ │ • drawing_data (JSONB) │ │
|
|
│ │ • created_at, updated_at │ │
|
|
│ └──────────────────────────────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Database Schema
|
|
|
|
### 3.1 Tabla `chart_drawings`
|
|
|
|
```sql
|
|
-- Schema: trading
|
|
CREATE TABLE IF NOT EXISTS trading.chart_drawings (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
|
|
|
|
-- Chart context
|
|
symbol VARCHAR(20) NOT NULL, -- BTCUSD, ETHUSD, etc.
|
|
timeframe VARCHAR(10) NOT NULL, -- 1m, 5m, 15m, 1h, 4h, 1d
|
|
|
|
-- Drawing metadata
|
|
tool_type VARCHAR(20) NOT NULL, -- trendline, fibonacci, rectangle, etc.
|
|
label VARCHAR(100), -- Optional user label
|
|
|
|
-- Drawing properties
|
|
drawing_data JSONB NOT NULL, -- Complete drawing object (color, points, etc.)
|
|
|
|
-- State
|
|
visible BOOLEAN DEFAULT true,
|
|
locked BOOLEAN DEFAULT false,
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
-- Indexes
|
|
CONSTRAINT unique_user_symbol_drawing UNIQUE (user_id, symbol, id)
|
|
);
|
|
|
|
-- Indexes for performance
|
|
CREATE INDEX idx_chart_drawings_user_symbol ON trading.chart_drawings(user_id, symbol);
|
|
CREATE INDEX idx_chart_drawings_user_symbol_timeframe ON trading.chart_drawings(user_id, symbol, timeframe);
|
|
CREATE INDEX idx_chart_drawings_created_at ON trading.chart_drawings(created_at DESC);
|
|
|
|
-- GIN index for JSONB queries
|
|
CREATE INDEX idx_chart_drawings_data ON trading.chart_drawings USING GIN(drawing_data);
|
|
|
|
-- Auto-update timestamp
|
|
CREATE TRIGGER update_chart_drawings_updated_at
|
|
BEFORE UPDATE ON trading.chart_drawings
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION core.update_updated_at_column();
|
|
```
|
|
|
|
### 3.2 Estructura de `drawing_data` (JSONB)
|
|
|
|
```json
|
|
{
|
|
"id": "draw_1738045812345_abc123",
|
|
"tool": "trendline",
|
|
"color": "#3B82F6",
|
|
"lineWidth": 2,
|
|
"visible": true,
|
|
"locked": false,
|
|
"label": "Strong uptrend",
|
|
"points": [
|
|
{ "x": 100, "y": 200, "price": 43250.50, "time": "2026-01-25T10:00:00Z" },
|
|
{ "x": 500, "y": 150, "price": 44100.00, "time": "2026-01-25T14:00:00Z" }
|
|
],
|
|
"createdAt": "2026-01-25T10:30:15Z"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Backend API
|
|
|
|
### 4.1 REST Endpoints
|
|
|
|
| Método | Endpoint | Descripción | Auth |
|
|
|--------|----------|-------------|------|
|
|
| GET | `/api/chart-drawings/:symbol` | Obtener todos los dibujos de un símbolo | ✅ JWT |
|
|
| GET | `/api/chart-drawings/:symbol/:timeframe` | Filtrar por timeframe | ✅ JWT |
|
|
| POST | `/api/chart-drawings` | Crear nuevo dibujo | ✅ JWT |
|
|
| PUT | `/api/chart-drawings/:id` | Actualizar dibujo existente | ✅ JWT |
|
|
| DELETE | `/api/chart-drawings/:id` | Eliminar dibujo | ✅ JWT |
|
|
| DELETE | `/api/chart-drawings/:symbol/all` | Eliminar todos los dibujos de un símbolo | ✅ JWT |
|
|
| POST | `/api/chart-drawings/import` | Importar dibujos desde JSON | ✅ JWT |
|
|
| GET | `/api/chart-drawings/export/:symbol` | Exportar dibujos a JSON | ✅ JWT |
|
|
|
|
### 4.2 Request/Response Examples
|
|
|
|
#### POST `/api/chart-drawings` - Create Drawing
|
|
|
|
**Request:**
|
|
```http
|
|
POST /api/chart-drawings
|
|
Authorization: Bearer <jwt_token>
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"symbol": "BTCUSD",
|
|
"timeframe": "1h",
|
|
"tool_type": "trendline",
|
|
"label": "Strong uptrend",
|
|
"drawing_data": {
|
|
"id": "draw_1738045812345_abc123",
|
|
"tool": "trendline",
|
|
"color": "#3B82F6",
|
|
"lineWidth": 2,
|
|
"visible": true,
|
|
"locked": false,
|
|
"points": [
|
|
{ "x": 100, "y": 200, "price": 43250.50, "time": "2026-01-25T10:00:00Z" },
|
|
{ "x": 500, "y": 150, "price": 44100.00, "time": "2026-01-25T14:00:00Z" }
|
|
],
|
|
"createdAt": "2026-01-25T10:30:15Z"
|
|
},
|
|
"visible": true,
|
|
"locked": false
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"user_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"symbol": "BTCUSD",
|
|
"timeframe": "1h",
|
|
"tool_type": "trendline",
|
|
"label": "Strong uptrend",
|
|
"drawing_data": { /* ... */ },
|
|
"visible": true,
|
|
"locked": false,
|
|
"created_at": "2026-01-25T10:30:15Z",
|
|
"updated_at": "2026-01-25T10:30:15Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET `/api/chart-drawings/:symbol` - Fetch Drawings
|
|
|
|
**Request:**
|
|
```http
|
|
GET /api/chart-drawings/BTCUSD?timeframe=1h
|
|
Authorization: Bearer <jwt_token>
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": [
|
|
{
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"symbol": "BTCUSD",
|
|
"timeframe": "1h",
|
|
"tool_type": "trendline",
|
|
"label": "Strong uptrend",
|
|
"drawing_data": {
|
|
"id": "draw_1738045812345_abc123",
|
|
"tool": "trendline",
|
|
"color": "#3B82F6",
|
|
"lineWidth": 2,
|
|
"visible": true,
|
|
"locked": false,
|
|
"points": [
|
|
{ "x": 100, "y": 200, "price": 43250.50, "time": "2026-01-25T10:00:00Z" },
|
|
{ "x": 500, "y": 150, "price": 44100.00, "time": "2026-01-25T14:00:00Z" }
|
|
],
|
|
"createdAt": "2026-01-25T10:30:15Z"
|
|
},
|
|
"visible": true,
|
|
"locked": false,
|
|
"created_at": "2026-01-25T10:30:15Z",
|
|
"updated_at": "2026-01-25T10:30:15Z"
|
|
},
|
|
{
|
|
"id": "660f9511-f39c-52e5-b827-557766551111",
|
|
"symbol": "BTCUSD",
|
|
"timeframe": "1h",
|
|
"tool_type": "fibonacci",
|
|
"label": "Fib retracement",
|
|
"drawing_data": { /* ... */ },
|
|
"visible": true,
|
|
"locked": false,
|
|
"created_at": "2026-01-25T11:00:00Z",
|
|
"updated_at": "2026-01-25T11:00:00Z"
|
|
}
|
|
],
|
|
"meta": {
|
|
"total": 2,
|
|
"symbol": "BTCUSD",
|
|
"timeframe": "1h"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### PUT `/api/chart-drawings/:id` - Update Drawing
|
|
|
|
**Request:**
|
|
```http
|
|
PUT /api/chart-drawings/550e8400-e29b-41d4-a716-446655440000
|
|
Authorization: Bearer <jwt_token>
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"drawing_data": {
|
|
"id": "draw_1738045812345_abc123",
|
|
"tool": "trendline",
|
|
"color": "#10B981", // Changed color
|
|
"lineWidth": 3, // Changed width
|
|
"visible": true,
|
|
"locked": true, // Locked
|
|
"points": [
|
|
{ "x": 100, "y": 200, "price": 43250.50, "time": "2026-01-25T10:00:00Z" },
|
|
{ "x": 500, "y": 150, "price": 44100.00, "time": "2026-01-25T14:00:00Z" }
|
|
],
|
|
"createdAt": "2026-01-25T10:30:15Z"
|
|
},
|
|
"locked": true
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"drawing_data": { /* updated data */ },
|
|
"locked": true,
|
|
"updated_at": "2026-01-25T11:15:30Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Backend Implementation
|
|
|
|
### 5.1 Controller: `ChartDrawingsController.ts`
|
|
|
|
```typescript
|
|
// apps/backend/src/controllers/chart-drawings.controller.ts
|
|
|
|
import { Request, Response } from 'express';
|
|
import { pool } from '../config/database';
|
|
import { authenticateToken } from '../middleware/auth.middleware';
|
|
|
|
interface Drawing {
|
|
id: string;
|
|
tool: string;
|
|
color: string;
|
|
lineWidth: number;
|
|
visible: boolean;
|
|
locked: boolean;
|
|
label?: string;
|
|
points: { x: number; y: number; price?: number; time?: string }[];
|
|
createdAt: string;
|
|
}
|
|
|
|
interface CreateDrawingRequest {
|
|
symbol: string;
|
|
timeframe: string;
|
|
tool_type: string;
|
|
label?: string;
|
|
drawing_data: Drawing;
|
|
visible?: boolean;
|
|
locked?: boolean;
|
|
}
|
|
|
|
export class ChartDrawingsController {
|
|
|
|
// GET /api/chart-drawings/:symbol
|
|
static async getDrawings(req: Request, res: Response) {
|
|
const { symbol } = req.params;
|
|
const { timeframe } = req.query;
|
|
const userId = req.user!.id;
|
|
|
|
try {
|
|
let query = `
|
|
SELECT * FROM trading.chart_drawings
|
|
WHERE user_id = $1 AND symbol = $2
|
|
`;
|
|
const params: any[] = [userId, symbol];
|
|
|
|
if (timeframe) {
|
|
query += ` AND timeframe = $3`;
|
|
params.push(timeframe);
|
|
}
|
|
|
|
query += ` ORDER BY created_at DESC`;
|
|
|
|
const result = await pool.query(query, params);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result.rows,
|
|
meta: {
|
|
total: result.rows.length,
|
|
symbol,
|
|
timeframe: timeframe || 'all'
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching drawings:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to fetch drawings'
|
|
});
|
|
}
|
|
}
|
|
|
|
// POST /api/chart-drawings
|
|
static async createDrawing(req: Request, res: Response) {
|
|
const userId = req.user!.id;
|
|
const { symbol, timeframe, tool_type, label, drawing_data, visible = true, locked = false } = req.body as CreateDrawingRequest;
|
|
|
|
// Validation
|
|
if (!symbol || !timeframe || !tool_type || !drawing_data) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Missing required fields: symbol, timeframe, tool_type, drawing_data'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const query = `
|
|
INSERT INTO trading.chart_drawings (
|
|
user_id, symbol, timeframe, tool_type, label, drawing_data, visible, locked
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *
|
|
`;
|
|
|
|
const result = await pool.query(query, [
|
|
userId,
|
|
symbol,
|
|
timeframe,
|
|
tool_type,
|
|
label,
|
|
JSON.stringify(drawing_data),
|
|
visible,
|
|
locked
|
|
]);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
data: result.rows[0]
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating drawing:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to create drawing'
|
|
});
|
|
}
|
|
}
|
|
|
|
// PUT /api/chart-drawings/:id
|
|
static async updateDrawing(req: Request, res: Response) {
|
|
const { id } = req.params;
|
|
const userId = req.user!.id;
|
|
const { drawing_data, visible, locked, label } = req.body;
|
|
|
|
try {
|
|
// Build dynamic update query
|
|
const updates: string[] = [];
|
|
const values: any[] = [];
|
|
let paramCount = 1;
|
|
|
|
if (drawing_data !== undefined) {
|
|
updates.push(`drawing_data = $${paramCount++}`);
|
|
values.push(JSON.stringify(drawing_data));
|
|
}
|
|
if (visible !== undefined) {
|
|
updates.push(`visible = $${paramCount++}`);
|
|
values.push(visible);
|
|
}
|
|
if (locked !== undefined) {
|
|
updates.push(`locked = $${paramCount++}`);
|
|
values.push(locked);
|
|
}
|
|
if (label !== undefined) {
|
|
updates.push(`label = $${paramCount++}`);
|
|
values.push(label);
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'No fields to update'
|
|
});
|
|
}
|
|
|
|
const query = `
|
|
UPDATE trading.chart_drawings
|
|
SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $${paramCount} AND user_id = $${paramCount + 1}
|
|
RETURNING *
|
|
`;
|
|
|
|
values.push(id, userId);
|
|
|
|
const result = await pool.query(query, values);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Drawing not found or unauthorized'
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result.rows[0]
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating drawing:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to update drawing'
|
|
});
|
|
}
|
|
}
|
|
|
|
// DELETE /api/chart-drawings/:id
|
|
static async deleteDrawing(req: Request, res: Response) {
|
|
const { id } = req.params;
|
|
const userId = req.user!.id;
|
|
|
|
try {
|
|
const query = `
|
|
DELETE FROM trading.chart_drawings
|
|
WHERE id = $1 AND user_id = $2
|
|
RETURNING id
|
|
`;
|
|
|
|
const result = await pool.query(query, [id, userId]);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Drawing not found or unauthorized'
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Drawing deleted successfully'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error deleting drawing:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to delete drawing'
|
|
});
|
|
}
|
|
}
|
|
|
|
// DELETE /api/chart-drawings/:symbol/all
|
|
static async clearAllDrawings(req: Request, res: Response) {
|
|
const { symbol } = req.params;
|
|
const userId = req.user!.id;
|
|
const { timeframe } = req.query;
|
|
|
|
try {
|
|
let query = `
|
|
DELETE FROM trading.chart_drawings
|
|
WHERE user_id = $1 AND symbol = $2
|
|
`;
|
|
const params: any[] = [userId, symbol];
|
|
|
|
if (timeframe) {
|
|
query += ` AND timeframe = $3`;
|
|
params.push(timeframe);
|
|
}
|
|
|
|
query += ` RETURNING id`;
|
|
|
|
const result = await pool.query(query, params);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Deleted ${result.rows.length} drawings`,
|
|
count: result.rows.length
|
|
});
|
|
} catch (error) {
|
|
console.error('Error clearing drawings:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to clear drawings'
|
|
});
|
|
}
|
|
}
|
|
|
|
// POST /api/chart-drawings/import
|
|
static async importDrawings(req: Request, res: Response) {
|
|
const userId = req.user!.id;
|
|
const { drawings } = req.body;
|
|
|
|
if (!Array.isArray(drawings) || drawings.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Invalid drawings array'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const insertedDrawings = [];
|
|
|
|
for (const drawing of drawings) {
|
|
const { symbol, timeframe, tool_type, label, drawing_data, visible, locked } = drawing;
|
|
|
|
const query = `
|
|
INSERT INTO trading.chart_drawings (
|
|
user_id, symbol, timeframe, tool_type, label, drawing_data, visible, locked
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *
|
|
`;
|
|
|
|
const result = await pool.query(query, [
|
|
userId,
|
|
symbol,
|
|
timeframe,
|
|
tool_type,
|
|
label,
|
|
JSON.stringify(drawing_data),
|
|
visible ?? true,
|
|
locked ?? false
|
|
]);
|
|
|
|
insertedDrawings.push(result.rows[0]);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Imported ${insertedDrawings.length} drawings`,
|
|
data: insertedDrawings
|
|
});
|
|
} catch (error) {
|
|
console.error('Error importing drawings:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to import drawings'
|
|
});
|
|
}
|
|
}
|
|
|
|
// GET /api/chart-drawings/export/:symbol
|
|
static async exportDrawings(req: Request, res: Response) {
|
|
const { symbol } = req.params;
|
|
const userId = req.user!.id;
|
|
const { timeframe } = req.query;
|
|
|
|
try {
|
|
let query = `
|
|
SELECT * FROM trading.chart_drawings
|
|
WHERE user_id = $1 AND symbol = $2
|
|
`;
|
|
const params: any[] = [userId, symbol];
|
|
|
|
if (timeframe) {
|
|
query += ` AND timeframe = $3`;
|
|
params.push(timeframe);
|
|
}
|
|
|
|
const result = await pool.query(query, params);
|
|
|
|
const exportData = {
|
|
version: '1.0.0',
|
|
exported_at: new Date().toISOString(),
|
|
symbol,
|
|
timeframe: timeframe || 'all',
|
|
count: result.rows.length,
|
|
drawings: result.rows
|
|
};
|
|
|
|
res.json({
|
|
success: true,
|
|
data: exportData
|
|
});
|
|
} catch (error) {
|
|
console.error('Error exporting drawings:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to export drawings'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.2 Routes: `chart-drawings.routes.ts`
|
|
|
|
```typescript
|
|
// apps/backend/src/routes/chart-drawings.routes.ts
|
|
|
|
import { Router } from 'express';
|
|
import { ChartDrawingsController } from '../controllers/chart-drawings.controller';
|
|
import { authenticateToken } from '../middleware/auth.middleware';
|
|
|
|
const router = Router();
|
|
|
|
// All routes require authentication
|
|
router.use(authenticateToken);
|
|
|
|
// CRUD operations
|
|
router.get('/chart-drawings/:symbol', ChartDrawingsController.getDrawings);
|
|
router.post('/chart-drawings', ChartDrawingsController.createDrawing);
|
|
router.put('/chart-drawings/:id', ChartDrawingsController.updateDrawing);
|
|
router.delete('/chart-drawings/:id', ChartDrawingsController.deleteDrawing);
|
|
router.delete('/chart-drawings/:symbol/all', ChartDrawingsController.clearAllDrawings);
|
|
|
|
// Import/Export
|
|
router.post('/chart-drawings/import', ChartDrawingsController.importDrawings);
|
|
router.get('/chart-drawings/export/:symbol', ChartDrawingsController.exportDrawings);
|
|
|
|
export default router;
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Frontend Implementation
|
|
|
|
### 6.1 Zustand Store: `drawingsStore.ts`
|
|
|
|
```typescript
|
|
// apps/frontend/src/stores/drawingsStore.ts
|
|
|
|
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
import { apiClient } from '../lib/apiClient';
|
|
|
|
export interface Drawing {
|
|
id: string;
|
|
tool: string;
|
|
color: string;
|
|
lineWidth: number;
|
|
visible: boolean;
|
|
locked: boolean;
|
|
label?: string;
|
|
points: { x: number; y: number; price?: number; time?: string }[];
|
|
createdAt: string;
|
|
}
|
|
|
|
interface ServerDrawing {
|
|
id: string;
|
|
user_id: string;
|
|
symbol: string;
|
|
timeframe: string;
|
|
tool_type: string;
|
|
label?: string;
|
|
drawing_data: Drawing;
|
|
visible: boolean;
|
|
locked: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface DrawingsState {
|
|
// State
|
|
drawings: Map<string, Drawing[]>; // key: "symbol:timeframe"
|
|
loading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
fetchDrawings: (symbol: string, timeframe: string) => Promise<void>;
|
|
addDrawing: (symbol: string, timeframe: string, drawing: Drawing) => Promise<void>;
|
|
updateDrawing: (symbol: string, timeframe: string, drawing: Drawing) => Promise<void>;
|
|
removeDrawing: (symbol: string, timeframe: string, drawingId: string) => Promise<void>;
|
|
clearAllDrawings: (symbol: string, timeframe: string) => Promise<void>;
|
|
importDrawings: (drawings: any[]) => Promise<void>;
|
|
exportDrawings: (symbol: string, timeframe?: string) => Promise<any>;
|
|
|
|
// Local actions (optimistic updates)
|
|
addDrawingLocally: (symbol: string, timeframe: string, drawing: Drawing) => void;
|
|
updateDrawingLocally: (symbol: string, timeframe: string, drawing: Drawing) => void;
|
|
removeDrawingLocally: (symbol: string, timeframe: string, drawingId: string) => void;
|
|
}
|
|
|
|
export const useDrawingsStore = create<DrawingsState>()(
|
|
devtools(
|
|
(set, get) => ({
|
|
drawings: new Map(),
|
|
loading: false,
|
|
error: null,
|
|
|
|
// Fetch all drawings for a symbol+timeframe
|
|
fetchDrawings: async (symbol: string, timeframe: string) => {
|
|
set({ loading: true, error: null });
|
|
|
|
try {
|
|
const response = await apiClient.get(`/api/chart-drawings/${symbol}`, {
|
|
params: { timeframe }
|
|
});
|
|
|
|
const serverDrawings: ServerDrawing[] = response.data.data;
|
|
const drawings = serverDrawings.map(d => d.drawing_data);
|
|
|
|
const key = `${symbol}:${timeframe}`;
|
|
const newMap = new Map(get().drawings);
|
|
newMap.set(key, drawings);
|
|
|
|
set({ drawings: newMap, loading: false });
|
|
} catch (error) {
|
|
console.error('Error fetching drawings:', error);
|
|
set({
|
|
error: error instanceof Error ? error.message : 'Failed to fetch drawings',
|
|
loading: false
|
|
});
|
|
}
|
|
},
|
|
|
|
// Add new drawing (optimistic update + API call)
|
|
addDrawing: async (symbol: string, timeframe: string, drawing: Drawing) => {
|
|
// Optimistic update
|
|
get().addDrawingLocally(symbol, timeframe, drawing);
|
|
|
|
try {
|
|
await apiClient.post('/api/chart-drawings', {
|
|
symbol,
|
|
timeframe,
|
|
tool_type: drawing.tool,
|
|
label: drawing.label,
|
|
drawing_data: drawing,
|
|
visible: drawing.visible,
|
|
locked: drawing.locked
|
|
});
|
|
} catch (error) {
|
|
console.error('Error saving drawing:', error);
|
|
// Rollback optimistic update
|
|
get().removeDrawingLocally(symbol, timeframe, drawing.id);
|
|
set({
|
|
error: error instanceof Error ? error.message : 'Failed to save drawing'
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Update existing drawing
|
|
updateDrawing: async (symbol: string, timeframe: string, drawing: Drawing) => {
|
|
// Find server ID (we need to maintain a mapping or fetch it)
|
|
// For simplicity, we'll refetch after update
|
|
|
|
try {
|
|
// This requires knowing the server UUID
|
|
// In practice, we'd maintain a clientId -> serverId mapping
|
|
// For now, we'll use drawing.id as both (needs adjustment)
|
|
|
|
await apiClient.put(`/api/chart-drawings/${drawing.id}`, {
|
|
drawing_data: drawing,
|
|
visible: drawing.visible,
|
|
locked: drawing.locked,
|
|
label: drawing.label
|
|
});
|
|
|
|
// Optimistic update
|
|
get().updateDrawingLocally(symbol, timeframe, drawing);
|
|
} catch (error) {
|
|
console.error('Error updating drawing:', error);
|
|
set({
|
|
error: error instanceof Error ? error.message : 'Failed to update drawing'
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Remove drawing
|
|
removeDrawing: async (symbol: string, timeframe: string, drawingId: string) => {
|
|
// Optimistic update
|
|
const key = `${symbol}:${timeframe}`;
|
|
const currentDrawings = get().drawings.get(key) || [];
|
|
const drawingToRemove = currentDrawings.find(d => d.id === drawingId);
|
|
|
|
get().removeDrawingLocally(symbol, timeframe, drawingId);
|
|
|
|
try {
|
|
await apiClient.delete(`/api/chart-drawings/${drawingId}`);
|
|
} catch (error) {
|
|
console.error('Error deleting drawing:', error);
|
|
// Rollback
|
|
if (drawingToRemove) {
|
|
get().addDrawingLocally(symbol, timeframe, drawingToRemove);
|
|
}
|
|
set({
|
|
error: error instanceof Error ? error.message : 'Failed to delete drawing'
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Clear all drawings
|
|
clearAllDrawings: async (symbol: string, timeframe: string) => {
|
|
const key = `${symbol}:${timeframe}`;
|
|
const backup = get().drawings.get(key) || [];
|
|
|
|
// Optimistic clear
|
|
const newMap = new Map(get().drawings);
|
|
newMap.set(key, []);
|
|
set({ drawings: newMap });
|
|
|
|
try {
|
|
await apiClient.delete(`/api/chart-drawings/${symbol}/all`, {
|
|
params: { timeframe }
|
|
});
|
|
} catch (error) {
|
|
console.error('Error clearing drawings:', error);
|
|
// Rollback
|
|
const rollbackMap = new Map(get().drawings);
|
|
rollbackMap.set(key, backup);
|
|
set({
|
|
drawings: rollbackMap,
|
|
error: error instanceof Error ? error.message : 'Failed to clear drawings'
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Import drawings
|
|
importDrawings: async (drawings: any[]) => {
|
|
try {
|
|
await apiClient.post('/api/chart-drawings/import', { drawings });
|
|
// Refetch current symbol/timeframe after import
|
|
// (caller should trigger refetch)
|
|
} catch (error) {
|
|
console.error('Error importing drawings:', error);
|
|
set({
|
|
error: error instanceof Error ? error.message : 'Failed to import drawings'
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Export drawings
|
|
exportDrawings: async (symbol: string, timeframe?: string) => {
|
|
try {
|
|
const response = await apiClient.get(`/api/chart-drawings/export/${symbol}`, {
|
|
params: { timeframe }
|
|
});
|
|
return response.data.data;
|
|
} catch (error) {
|
|
console.error('Error exporting drawings:', error);
|
|
set({
|
|
error: error instanceof Error ? error.message : 'Failed to export drawings'
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Local optimistic updates
|
|
addDrawingLocally: (symbol: string, timeframe: string, drawing: Drawing) => {
|
|
const key = `${symbol}:${timeframe}`;
|
|
const current = get().drawings.get(key) || [];
|
|
const newMap = new Map(get().drawings);
|
|
newMap.set(key, [...current, drawing]);
|
|
set({ drawings: newMap });
|
|
},
|
|
|
|
updateDrawingLocally: (symbol: string, timeframe: string, drawing: Drawing) => {
|
|
const key = `${symbol}:${timeframe}`;
|
|
const current = get().drawings.get(key) || [];
|
|
const updated = current.map(d => d.id === drawing.id ? drawing : d);
|
|
const newMap = new Map(get().drawings);
|
|
newMap.set(key, updated);
|
|
set({ drawings: newMap });
|
|
},
|
|
|
|
removeDrawingLocally: (symbol: string, timeframe: string, drawingId: string) => {
|
|
const key = `${symbol}:${timeframe}`;
|
|
const current = get().drawings.get(key) || [];
|
|
const filtered = current.filter(d => d.id !== drawingId);
|
|
const newMap = new Map(get().drawings);
|
|
newMap.set(key, filtered);
|
|
set({ drawings: newMap });
|
|
},
|
|
}),
|
|
{ name: 'drawings-store' }
|
|
)
|
|
);
|
|
```
|
|
|
|
### 6.2 Hook: `useDrawings.ts`
|
|
|
|
```typescript
|
|
// apps/frontend/src/modules/trading/hooks/useDrawings.ts
|
|
|
|
import { useEffect, useRef, useCallback } from 'react';
|
|
import { useDrawingsStore } from '../../../stores/drawingsStore';
|
|
import { useDebouncedCallback } from 'use-debounce';
|
|
import type { Drawing } from '../../../stores/drawingsStore';
|
|
|
|
export const useDrawings = (symbol: string, timeframe: string) => {
|
|
const {
|
|
drawings,
|
|
loading,
|
|
error,
|
|
fetchDrawings,
|
|
addDrawing,
|
|
updateDrawing,
|
|
removeDrawing,
|
|
clearAllDrawings,
|
|
} = useDrawingsStore();
|
|
|
|
const key = `${symbol}:${timeframe}`;
|
|
const currentDrawings = drawings.get(key) || [];
|
|
|
|
// Fetch on mount and when symbol/timeframe changes
|
|
useEffect(() => {
|
|
fetchDrawings(symbol, timeframe);
|
|
}, [symbol, timeframe]);
|
|
|
|
// Debounced save (1 second delay)
|
|
const debouncedUpdate = useDebouncedCallback(
|
|
(drawing: Drawing) => {
|
|
updateDrawing(symbol, timeframe, drawing);
|
|
},
|
|
1000
|
|
);
|
|
|
|
// API methods
|
|
const handleAddDrawing = useCallback(async (drawing: Drawing) => {
|
|
await addDrawing(symbol, timeframe, drawing);
|
|
}, [symbol, timeframe, addDrawing]);
|
|
|
|
const handleUpdateDrawing = useCallback((drawing: Drawing, immediate = false) => {
|
|
if (immediate) {
|
|
updateDrawing(symbol, timeframe, drawing);
|
|
} else {
|
|
debouncedUpdate(drawing);
|
|
}
|
|
}, [symbol, timeframe, updateDrawing, debouncedUpdate]);
|
|
|
|
const handleRemoveDrawing = useCallback(async (drawingId: string) => {
|
|
await removeDrawing(symbol, timeframe, drawingId);
|
|
}, [symbol, timeframe, removeDrawing]);
|
|
|
|
const handleClearAll = useCallback(async () => {
|
|
await clearAllDrawings(symbol, timeframe);
|
|
}, [symbol, timeframe, clearAllDrawings]);
|
|
|
|
return {
|
|
drawings: currentDrawings,
|
|
loading,
|
|
error,
|
|
addDrawing: handleAddDrawing,
|
|
updateDrawing: handleUpdateDrawing,
|
|
removeDrawing: handleRemoveDrawing,
|
|
clearAll: handleClearAll,
|
|
refetch: () => fetchDrawings(symbol, timeframe),
|
|
};
|
|
};
|
|
```
|
|
|
|
### 6.3 Integration with `ChartDrawingToolsPanel.tsx`
|
|
|
|
```tsx
|
|
// apps/frontend/src/modules/trading/components/ChartDrawingToolsPanel.tsx
|
|
// MODIFICACIÓN: Integrar con useDrawings hook
|
|
|
|
import { useDrawings } from '../hooks/useDrawings';
|
|
import { useTradingStore } from '../../../stores/tradingStore';
|
|
|
|
const ChartDrawingToolsPanelWithPersistence: React.FC = () => {
|
|
const { selectedSymbol, timeframe } = useTradingStore();
|
|
const {
|
|
drawings,
|
|
loading,
|
|
error,
|
|
addDrawing,
|
|
updateDrawing,
|
|
removeDrawing,
|
|
clearAll
|
|
} = useDrawings(selectedSymbol, timeframe);
|
|
|
|
const [activeTool, setActiveTool] = useState<DrawingTool | null>(null);
|
|
|
|
const handleDrawingAdd = async (drawing: Drawing) => {
|
|
try {
|
|
await addDrawing(drawing);
|
|
} catch (error) {
|
|
console.error('Failed to save drawing:', error);
|
|
// Show toast notification
|
|
}
|
|
};
|
|
|
|
const handleDrawingUpdate = (drawing: Drawing) => {
|
|
// Debounced auto-save (1 second)
|
|
updateDrawing(drawing, false);
|
|
};
|
|
|
|
const handleDrawingRemove = async (drawingId: string) => {
|
|
try {
|
|
await removeDrawing(drawingId);
|
|
} catch (error) {
|
|
console.error('Failed to delete drawing:', error);
|
|
}
|
|
};
|
|
|
|
const handleClearAll = async () => {
|
|
if (confirm(`Delete all ${drawings.length} drawings?`)) {
|
|
try {
|
|
await clearAll();
|
|
} catch (error) {
|
|
console.error('Failed to clear drawings:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ChartDrawingToolsPanel
|
|
drawings={drawings}
|
|
activeTool={activeTool}
|
|
onToolSelect={setActiveTool}
|
|
onDrawingAdd={handleDrawingAdd}
|
|
onDrawingUpdate={handleDrawingUpdate}
|
|
onDrawingRemove={handleDrawingRemove}
|
|
onClearAll={handleClearAll}
|
|
/>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Tipos TypeScript
|
|
|
|
### 7.1 Frontend Types
|
|
|
|
```typescript
|
|
// apps/frontend/src/types/drawings.types.ts
|
|
|
|
export type DrawingTool =
|
|
| 'trendline'
|
|
| 'horizontal'
|
|
| 'vertical'
|
|
| 'rectangle'
|
|
| 'circle'
|
|
| 'fibonacci'
|
|
| 'text'
|
|
| 'arrow'
|
|
| 'channel'
|
|
| 'pitchfork';
|
|
|
|
export interface DrawingPoint {
|
|
x: number;
|
|
y: number;
|
|
price?: number;
|
|
time?: string;
|
|
}
|
|
|
|
export interface Drawing {
|
|
id: string;
|
|
tool: DrawingTool;
|
|
color: string;
|
|
lineWidth: number;
|
|
visible: boolean;
|
|
locked: boolean;
|
|
label?: string;
|
|
points: DrawingPoint[];
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface ServerDrawing {
|
|
id: string;
|
|
user_id: string;
|
|
symbol: string;
|
|
timeframe: string;
|
|
tool_type: DrawingTool;
|
|
label?: string;
|
|
drawing_data: Drawing;
|
|
visible: boolean;
|
|
locked: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Performance Optimizations
|
|
|
|
### 8.1 Auto-Save Strategy
|
|
|
|
- **Debounced Save:** 1 segundo de delay después del último cambio
|
|
- **Immediate Save:** Eventos críticos (delete, lock, visibility toggle)
|
|
- **Optimistic Updates:** UI actualiza inmediatamente, rollback en error
|
|
|
|
### 8.2 Caching
|
|
|
|
- Zustand store mantiene caché por `symbol:timeframe`
|
|
- Re-fetch solo cuando cambia symbol/timeframe
|
|
- LocalStorage backup (opcional, para offline mode)
|
|
|
|
### 8.3 Database Indexes
|
|
|
|
- Index compuesto `(user_id, symbol, timeframe)` para queries rápidas
|
|
- GIN index en `drawing_data` JSONB para búsquedas por propiedades
|
|
|
|
---
|
|
|
|
## 9. Testing Checklist
|
|
|
|
### Backend Tests
|
|
- [ ] Create drawing with valid data
|
|
- [ ] Create drawing with missing fields (validation)
|
|
- [ ] Fetch drawings by symbol
|
|
- [ ] Fetch drawings by symbol + timeframe
|
|
- [ ] Update drawing (color, position, lock)
|
|
- [ ] Delete drawing
|
|
- [ ] Delete all drawings for symbol
|
|
- [ ] Unauthorized access (different user)
|
|
- [ ] Import/export JSON
|
|
- [ ] JSONB query performance (>1000 drawings)
|
|
|
|
### Frontend Tests
|
|
- [ ] Draw line, save, reload → persists
|
|
- [ ] Update color → auto-saves after 1 second
|
|
- [ ] Delete drawing → removed from DB
|
|
- [ ] Lock drawing → prevents edits
|
|
- [ ] Hide drawing → visibility toggle
|
|
- [ ] Clear all → confirms and deletes
|
|
- [ ] Switch symbol → loads different drawings
|
|
- [ ] Switch timeframe → filters correctly
|
|
- [ ] Offline mode → queues operations
|
|
- [ ] Concurrent edits → conflict resolution
|
|
|
|
---
|
|
|
|
## 10. Roadmap de Implementación
|
|
|
|
### Fase 1: Backend Foundation (1h)
|
|
1. Crear tabla `chart_drawings` en PostgreSQL
|
|
2. Implementar `ChartDrawingsController` con CRUD básico
|
|
3. Agregar rutas y middleware de autenticación
|
|
4. Test con Postman/Thunder Client
|
|
|
|
### Fase 2: Frontend Store (1h)
|
|
1. Crear `drawingsStore.ts` con Zustand
|
|
2. Implementar optimistic updates
|
|
3. Crear `useDrawings` hook con debounce
|
|
4. Test con React DevTools
|
|
|
|
### Fase 3: Integration (1h)
|
|
1. Modificar `ChartDrawingToolsPanel` para usar hook
|
|
2. Conectar eventos (add, update, delete)
|
|
3. Test end-to-end
|
|
4. Agregar notificaciones de error/éxito
|
|
|
|
---
|
|
|
|
## 11. Referencias
|
|
|
|
- **Componente Actual:** `apps/frontend/src/modules/trading/components/ChartDrawingToolsPanel.tsx` (420 líneas)
|
|
- **Código Backend:** `apps/backend/src/controllers/chart-drawings.controller.ts` (a crear)
|
|
- **Store:** `apps/frontend/src/stores/drawingsStore.ts` (a crear)
|
|
- **Hook:** `apps/frontend/src/modules/trading/hooks/useDrawings.ts` (a crear)
|
|
- **US:** `US-TRD-010-persistir-dibujos.md`
|
|
|
|
---
|
|
|
|
**Última actualización:** 2026-01-25
|
|
**Responsable:** Frontend Lead + Backend Engineer
|
|
|