trading-platform/docs/02-definicion-modulos/OQI-003-trading-charts/especificaciones/ET-TRD-010-drawing-tools-persistence.md
Adrian Flores Cortes cea9ae85f1 docs: Add 8 ET specifications from TASK-002 audit gaps
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>
2026-01-25 14:20:53 -06:00

38 KiB

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

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

{
  "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:

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:

{
  "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:

GET /api/chart-drawings/BTCUSD?timeframe=1h
Authorization: Bearer <jwt_token>

Response:

{
  "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:

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:

{
  "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

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

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

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

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

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

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