commit 45e77e9a9c1def3582303ff560f9e50c074e4fe6 Author: rckrdmrd Date: Sun Jan 18 04:30:23 2026 -0600 feat: Initial commit - Database schemas and scripts DDL schemas for Trading Platform: - User management - Authentication - Payments - Education - ML predictions - Trading data Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d4f28a --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Logs +*.log + +# Backups +*.sql.bak +*.dump + +# Temp +tmp/ +.cache/ + +# Environment +.env +.env.local diff --git a/DIRECTIVA-POLITICA-CARGA-LIMPIA.md b/DIRECTIVA-POLITICA-CARGA-LIMPIA.md new file mode 100644 index 0000000..9644caa --- /dev/null +++ b/DIRECTIVA-POLITICA-CARGA-LIMPIA.md @@ -0,0 +1,284 @@ +# DIRECTIVA: Politica de Carga Limpia (DDL-First) + +**ID:** DIR-DB-001 +**Version:** 1.1.0 +**Fecha:** 2026-01-04 +**Estado:** ACTIVA +**Aplica a:** Todos los agentes que trabajen con base de datos + +--- + +## OBJETIVO + +Establecer una politica clara y obligatoria para la gestion del esquema de base de datos del proyecto Trading Platform (Trading Platform), garantizando que la base de datos pueda ser creada o recreada completamente desde archivos DDL sin dependencia de migraciones incrementales. + +--- + +## PRINCIPIO FUNDAMENTAL + +> **La base de datos SIEMPRE debe poder ser creada desde cero ejecutando unicamente los archivos DDL.** + +Esto significa: +- NO migraciones incrementales +- NO archivos de "fix" o "patch" +- NO scripts de correccion +- NO ALTER TABLE en archivos separados + +--- + +## REGLAS OBLIGATORIAS + +### 1. Estructura de Archivos + +``` +apps/database/ +├── ddl/ +│ └── schemas/ +│ ├── {schema}/ +│ │ ├── 00-extensions.sql # Extensiones del schema (opcional) +│ │ ├── 00-enums.sql # Tipos enumerados (o 01-enums.sql) +│ │ ├── tables/ +│ │ │ ├── 01-{tabla}.sql # Una tabla por archivo +│ │ │ ├── 02-{tabla}.sql +│ │ │ ├── 99-deferred-constraints.sql # Constraints diferidos (opcional) +│ │ │ └── ... +│ │ ├── functions/ +│ │ │ ├── 01-{funcion}.sql +│ │ │ └── ... +│ │ ├── triggers/ +│ │ │ └── ... +│ │ └── views/ +│ │ └── ... +├── seeds/ +│ ├── prod/ # Datos de produccion +│ └── dev/ # Datos de desarrollo +└── scripts/ + ├── create-database.sh # Crear BD + └── drop-and-recreate-database.sh # Recrear BD +``` + +### 2. Nomenclatura de Archivos + +| Tipo | Patron | Ejemplo | +|------|--------|---------| +| Extensiones | `00-extensions.sql` | `00-extensions.sql` (citext, pgcrypto por schema) | +| Enums | `00-enums.sql` o `01-enums.sql` | `00-enums.sql`, `01-enums.sql` | +| Tablas | `NN-{nombre}.sql` | `01-users.sql`, `02-profiles.sql` | +| Constraints diferidos | `99-deferred-constraints.sql` | Documentación de constraints no soportados | +| Funciones | `NN-{nombre}.sql` | `01-update_updated_at.sql` | +| Triggers | `NN-{nombre}.sql` | `01-audit_trigger.sql` | +| Views | `NN-{nombre}.sql` | `01-user_summary.sql` | + +El numero `NN` indica el orden de ejecucion dentro de cada carpeta. + +**Nota:** El script busca enums en `00-enums.sql` primero; si no existe, busca en `01-enums.sql`. + +### 3. Modificaciones al Schema + +**CORRECTO:** +```sql +-- Editar directamente el archivo DDL original +-- apps/database/ddl/schemas/auth/tables/01-users.sql + +CREATE TABLE IF NOT EXISTS auth.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + -- Agregar nuevas columnas aqui + phone VARCHAR(20), -- <-- Nueva columna + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**INCORRECTO:** +```sql +-- NO crear archivos de migracion +-- migrations/20251206_add_phone_to_users.sql <-- PROHIBIDO + +ALTER TABLE auth.users ADD COLUMN phone VARCHAR(20); +``` + +### 4. Cuando Necesitas Cambiar el Schema + +1. **Edita el archivo DDL original** de la tabla/funcion/trigger +2. **Ejecuta recreacion** en tu ambiente de desarrollo: + ```bash + ./scripts/drop-and-recreate-database.sh + ``` +3. **Verifica** que todo funcione correctamente +4. **Commit** los cambios al DDL + +### 5. Prohibiciones Explicitas + +| Accion | Permitido | Razon | +|--------|-----------|-------| +| Crear archivo `migrations/*.sql` | NO | Rompe carga limpia | +| Crear archivo `fix-*.sql` | NO | Rompe carga limpia | +| Crear archivo `patch-*.sql` | NO | Rompe carga limpia | +| Crear archivo `alter-*.sql` | NO | Rompe carga limpia | +| Usar `ALTER TABLE` en archivo separado | NO | Debe estar en DDL original | +| Modificar DDL original directamente | SI | Es la forma correcta | + +--- + +## ESTANDARES TECNICOS + +### Tipos de Datos + +| Uso | Tipo Correcto | Tipo Incorrecto | +|-----|---------------|-----------------| +| Timestamps | `TIMESTAMPTZ` | `TIMESTAMP` | +| UUIDs | `gen_random_uuid()` | `uuid_generate_v4()` | +| Moneda | `DECIMAL(20,8)` | `FLOAT`, `DOUBLE` | +| Texto variable | `VARCHAR(n)` | `CHAR(n)` para texto variable | + +### Convenciones SQL + +```sql +-- Nombres en snake_case +CREATE TABLE auth.user_profiles ( -- Correcto +CREATE TABLE auth.UserProfiles ( -- Incorrecto + +-- Siempre incluir schema +CREATE TABLE auth.users ( -- Correcto +CREATE TABLE users ( -- Incorrecto (usa public) + +-- IF NOT EXISTS en CREATE +CREATE TABLE IF NOT EXISTS auth.users ( +CREATE TYPE IF NOT EXISTS auth.user_status AS ENUM ( + +-- Indices con prefijo descriptivo +CREATE INDEX idx_users_email ON auth.users(email); +CREATE INDEX idx_users_created ON auth.users(created_at DESC); +``` + +### Foreign Keys + +```sql +-- Siempre referenciar con schema completo +user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + +-- Nunca asumir schema +user_id UUID NOT NULL REFERENCES users(id), -- INCORRECTO +``` + +--- + +## ORDEN DE CARGA + +El script `create-database.sh` ejecuta en este orden: + +1. **Extensiones Globales** + - uuid-ossp + - pgcrypto + - pg_trgm + - btree_gin + - vector (si disponible) + - citext + +2. **Schemas** (en orden) + - auth + - education + - financial + - trading + - investment + - ml + - llm + - audit + +3. **Por cada schema:** + - 00-extensions.sql (si existe) - extensiones específicas del schema + - 00-enums.sql o 01-enums.sql (si existe) + - tables/*.sql (orden numérico) + - functions/*.sql (orden numérico) + - triggers/*.sql (orden numérico) + - views/*.sql (orden numérico) + +4. **Seeds** + - prod/ o dev/ según ambiente + +### Configuración por Defecto + +```bash +DB_NAME=trading_platform +DB_USER=trading +DB_PASSWORD=trading_dev_2025 +DB_HOST=localhost +DB_PORT=5433 +``` + +Estas variables pueden sobrescribirse via entorno: +```bash +DB_PORT=5433 ./drop-and-recreate-database.sh +``` + +--- + +## VALIDACION + +### Pre-commit Checklist + +Antes de hacer commit de cambios a DDL: + +- [ ] No existen archivos `migrations/`, `fix-*`, `patch-*`, `alter-*` +- [ ] Todos los cambios estan en archivos DDL originales +- [ ] Se puede ejecutar `drop-and-recreate-database.sh` sin errores +- [ ] Todas las FKs usan schema completo (`auth.users`, no `users`) +- [ ] Todos los timestamps son `TIMESTAMPTZ` +- [ ] Todos los UUIDs usan `gen_random_uuid()` + +### Script de Validacion + +```bash +# Verificar que no hay archivos prohibidos +find apps/database -name "fix-*.sql" -o -name "patch-*.sql" -o -name "alter-*.sql" +# Debe retornar vacio + +# Verificar que no hay carpeta migrations con contenido nuevo +ls apps/database/migrations/ +# Solo debe existir si hay migraciones legacy (a eliminar) +``` + +--- + +## EXCEPCIONES + +### Unica Excepcion: Datos de Produccion + +Si hay datos de produccion que NO pueden perderse: + +1. **Exportar datos** antes de recrear +2. **Recrear schema** con DDL limpio +3. **Importar datos** desde backup + +Esto NO es una migracion, es un proceso de backup/restore. + +--- + +## CONSECUENCIAS DE VIOLAR ESTA DIRECTIVA + +1. **Build fallara** - CI/CD rechazara archivos prohibidos +2. **PR sera rechazado** - Code review detectara violaciones +3. **Deuda tecnica** - Se acumularan inconsistencias + +--- + +## REFERENCIAS + +- [_MAP.md - Database Schemas](../apps/database/schemas/_MAP.md) +- [DECISIONES-ARQUITECTONICAS.md](../docs/99-analisis/DECISIONES-ARQUITECTONICAS.md) +- [create-database.sh](../apps/database/scripts/create-database.sh) + +--- + +## HISTORIAL DE CAMBIOS + +| Version | Fecha | Cambio | +|---------|-------|--------| +| 1.0.0 | 2025-12-06 | Versión inicial | +| 1.1.0 | 2026-01-04 | Soporte para 00-extensions.sql, búsqueda flexible de enums (00 o 01), constraints diferidos | + +--- + +*Directiva establecida por Requirements-Analyst Agent* +*Trading Platform* +*Actualizada: 2026-01-04* diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bf84f4 --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# Trading Platform Database + +Definiciones DDL y scripts de base de datos para Trading Platform. + +## Stack Tecnologico + +- **DBMS:** PostgreSQL 16 +- **Extension ML:** pgvector (embeddings LLM) +- **Contenedor:** Docker (pgvector/pgvector:pg16) + +## Estructura del Proyecto + +``` +database/ +├── ddl/ +│ └── schemas/ # Definiciones DDL por schema +│ ├── audit/ # Logs de auditoria +│ ├── auth/ # Autenticacion y sesiones +│ ├── education/ # Cursos y gamificacion +│ ├── financial/ # Transacciones y wallets +│ ├── investment/ # Inversiones y portfolios +│ ├── llm/ # Embeddings y contextos LLM +│ ├── ml/ # Modelos y predicciones ML +│ └── trading/ # Ordenes y operaciones +├── schemas/ # _MAP.md e indices +├── scripts/ # Scripts de gestion +│ ├── create-database.sh +│ ├── drop-and-recreate-database.sh +│ └── validate-ddl.sh +└── seeds/ # Datos iniciales (dev) +``` + +## Schemas + +| Schema | Tablas | Descripcion | +|--------|--------|-------------| +| `auth` | 12 | Usuarios, sesiones, tokens, OAuth | +| `education` | 15 | Cursos, lecciones, quizzes, progreso | +| `trading` | 18 | Ordenes, posiciones, historial | +| `investment` | 12 | Portfolios, assets, distribuciones | +| `financial` | 10 | Wallets, transacciones, pagos | +| `ml` | 8 | Modelos, predicciones, senales | +| `llm` | 6 | Embeddings, conversaciones, contextos | +| `audit` | 9 | Logs, eventos, trazabilidad | + +**Total:** ~90 tablas, 102+ foreign keys + +## Instalacion + +### Opcion 1: Docker (Recomendado) + +```bash +# Desde raiz del proyecto trading-platform +docker-compose up -d postgres + +# Crear base de datos +cd apps/database/scripts +./create-database.sh +``` + +### Opcion 2: PostgreSQL Local + +```bash +# Requiere PostgreSQL 16 con pgvector instalado +# Ubuntu/Debian: +sudo apt install postgresql-16-pgvector + +# Crear base de datos +cd apps/database/scripts +./create-database.sh +``` + +## Scripts Disponibles + +| Script | Descripcion | +|--------|-------------| +| `create-database.sh` | Crear BD y cargar DDL | +| `drop-and-recreate-database.sh` | Recrear BD desde cero | +| `validate-ddl.sh` | Validar sintaxis SQL | + +## Variables de Entorno + +```env +# Database connection - Instancia nativa compartida +# Ref: orchestration/inventarios/DEVENV-PORTS-INVENTORY.yml +DB_HOST=localhost +DB_PORT=5432 # Instancia NATIVA (NO Docker) +DB_NAME=trading_platform +DB_USER=trading_user +DB_PASSWORD=trading_dev_2025 +``` + +> **IMPORTANTE:** El workspace usa arquitectura de instancia única compartida. PostgreSQL nativo en puerto 5432, NO Docker. Los proyectos se separan por DATABASE + USER. + +## Uso de Scripts + +### Crear Base de Datos + +```bash +cd apps/database/scripts + +# Con variables de entorno (instancia nativa) +export DB_HOST=localhost +export DB_PORT=5432 +export DB_NAME=trading_platform +export DB_USER=trading_user +export DB_PASSWORD=trading_dev_2025 + +./create-database.sh +``` + +### Recrear Base de Datos (Development) + +```bash +# ADVERTENCIA: Elimina todos los datos +./drop-and-recreate-database.sh +``` + +## Estructura DDL + +Cada schema sigue la estructura: + +``` +ddl/schemas/{schema}/ +├── 00-extensions.sql # Extensiones (si aplica) +├── tables/ +│ ├── 01-{tabla}.sql +│ ├── 02-{tabla}.sql +│ └── ... +├── functions/ +│ ├── 01-{funcion}.sql +│ └── ... +└── triggers/ + └── 01-{trigger}.sql +``` + +### Convencion de Nombres + +- **Tablas:** snake_case plural (`users`, `trading_orders`) +- **Columnas:** snake_case (`created_at`, `user_id`) +- **Primary Keys:** `id` (UUID) +- **Foreign Keys:** `{tabla_singular}_id` +- **Indices:** `idx_{tabla}_{columna}` +- **Triggers:** `trg_{tabla}_{accion}` + +## Seguridad + +- Row Level Security (RLS) habilitado en tablas multi-tenant +- Funciones con `SECURITY DEFINER` donde corresponde +- Passwords hasheados con bcrypt (en aplicacion) +- Audit logs automaticos via triggers + +## Migraciones + +Actualmente se usa DDL directo sin sistema de migraciones. +Para cambios: + +1. Modificar archivo DDL correspondiente +2. Documentar en `_MAP.md` del schema +3. Ejecutar `drop-and-recreate-database.sh` (dev) +4. En produccion: scripts de migracion manuales + +## Documentacion Relacionada + +- [Mapa de Schemas](./schemas/_MAP.md) +- [Inventario Database](../../docs/90-transversal/inventarios/DATABASE_INVENTORY.yml) +- [Politica de Carga Limpia](./DIRECTIVA-POLITICA-CARGA-LIMPIA.md) + +--- + +**Proyecto:** Trading Platform +**Version:** 0.1.0 +**Actualizado:** 2026-01-07 diff --git a/ddl/00-extensions.sql b/ddl/00-extensions.sql new file mode 100644 index 0000000..6ed9a68 --- /dev/null +++ b/ddl/00-extensions.sql @@ -0,0 +1,26 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- File: 00-extensions.sql +-- Description: PostgreSQL extensions required globally +-- ============================================================================ + +-- UUID generation extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Cryptographic functions for password hashing and token generation +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Network address types and functions +CREATE EXTENSION IF NOT EXISTS "citext"; + +-- Full text search +CREATE EXTENSION IF NOT EXISTS "unaccent"; + +-- Trigram similarity for fuzzy text matching +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions'; +COMMENT ON EXTENSION "pgcrypto" IS 'Cryptographic functions for secure password and token handling'; +COMMENT ON EXTENSION "citext" IS 'Case-insensitive text type for email addresses'; +COMMENT ON EXTENSION "unaccent" IS 'Text search dictionary that removes accents'; +COMMENT ON EXTENSION "pg_trgm" IS 'Trigram matching for similarity searches'; diff --git a/ddl/01-schemas.sql b/ddl/01-schemas.sql new file mode 100644 index 0000000..7fc1ea7 --- /dev/null +++ b/ddl/01-schemas.sql @@ -0,0 +1,37 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- File: 01-schemas.sql +-- Description: Database schemas creation +-- ============================================================================ + +-- Authentication and User Management +CREATE SCHEMA IF NOT EXISTS auth; +COMMENT ON SCHEMA auth IS 'Authentication, authorization, and user management'; + +-- Education and Learning +CREATE SCHEMA IF NOT EXISTS education; +COMMENT ON SCHEMA education IS 'Educational content, courses, and learning progress'; + +-- Trading Operations +CREATE SCHEMA IF NOT EXISTS trading; +COMMENT ON SCHEMA trading IS 'Trading bots, orders, positions, and market data'; + +-- Investment Management +CREATE SCHEMA IF NOT EXISTS investment; +COMMENT ON SCHEMA investment IS 'Investment products, accounts, and transactions'; + +-- Financial Operations +CREATE SCHEMA IF NOT EXISTS financial; +COMMENT ON SCHEMA financial IS 'Wallets, payments, subscriptions, and financial transactions'; + +-- Machine Learning +CREATE SCHEMA IF NOT EXISTS ml; +COMMENT ON SCHEMA ml IS 'ML models, predictions, and feature store'; + +-- Large Language Models +CREATE SCHEMA IF NOT EXISTS llm; +COMMENT ON SCHEMA llm IS 'LLM conversations, messages, and user preferences'; + +-- Audit and Compliance +CREATE SCHEMA IF NOT EXISTS audit; +COMMENT ON SCHEMA audit IS 'Audit logs, security events, and compliance tracking'; diff --git a/ddl/schemas/audit/00-enums.sql b/ddl/schemas/audit/00-enums.sql new file mode 100644 index 0000000..5356735 --- /dev/null +++ b/ddl/schemas/audit/00-enums.sql @@ -0,0 +1,63 @@ +-- ============================================================================ +-- AUDIT SCHEMA - ENUMs +-- ============================================================================ +-- OrbiQuant IA Trading Platform +-- Schema: audit +-- Propósito: Tipos enumerados para auditoría y logging +-- ============================================================================ + +-- Tipo de evento de auditoría +CREATE TYPE audit.audit_event_type AS ENUM ( + 'create', + 'read', + 'update', + 'delete', + 'login', + 'logout', + 'permission_change', + 'config_change', + 'export', + 'import' +); + +-- Severidad del evento +CREATE TYPE audit.event_severity AS ENUM ( + 'debug', + 'info', + 'warning', + 'error', + 'critical' +); + +-- Categoría del evento de seguridad +CREATE TYPE audit.security_event_category AS ENUM ( + 'authentication', + 'authorization', + 'data_access', + 'configuration', + 'suspicious_activity', + 'compliance' +); + +-- Estado del evento +CREATE TYPE audit.event_status AS ENUM ( + 'success', + 'failure', + 'blocked', + 'pending_review' +); + +-- Tipo de recurso auditado +CREATE TYPE audit.resource_type AS ENUM ( + 'user', + 'account', + 'transaction', + 'order', + 'position', + 'bot', + 'subscription', + 'payment', + 'course', + 'model', + 'system_config' +); diff --git a/ddl/schemas/audit/tables/01-audit_logs.sql b/ddl/schemas/audit/tables/01-audit_logs.sql new file mode 100644 index 0000000..545eda6 --- /dev/null +++ b/ddl/schemas/audit/tables/01-audit_logs.sql @@ -0,0 +1,54 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: audit_logs +-- ============================================================================ +-- Tabla principal de auditoría general +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Información del evento + event_type audit.audit_event_type NOT NULL, + event_status audit.event_status NOT NULL DEFAULT 'success', + severity audit.event_severity NOT NULL DEFAULT 'info', + + -- Quién realizó la acción + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + session_id UUID, + ip_address INET, + user_agent TEXT, + + -- Qué se modificó + resource_type audit.resource_type NOT NULL, + resource_id UUID, + resource_name VARCHAR(255), + + -- Detalles del cambio + action VARCHAR(100) NOT NULL, + description TEXT, + old_values JSONB, + new_values JSONB, + metadata JSONB DEFAULT '{}', + + -- Contexto + request_id UUID, + correlation_id UUID, + service_name VARCHAR(50), + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices para consultas frecuentes +CREATE INDEX idx_audit_logs_user_id ON audit.audit_logs(user_id); +CREATE INDEX idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id); +CREATE INDEX idx_audit_logs_event_type ON audit.audit_logs(event_type); +CREATE INDEX idx_audit_logs_created_at ON audit.audit_logs(created_at DESC); +CREATE INDEX idx_audit_logs_severity ON audit.audit_logs(severity) WHERE severity IN ('error', 'critical'); +CREATE INDEX idx_audit_logs_correlation ON audit.audit_logs(correlation_id) WHERE correlation_id IS NOT NULL; + +-- Índice GIN para búsqueda en JSONB +CREATE INDEX idx_audit_logs_metadata ON audit.audit_logs USING GIN (metadata); + +COMMENT ON TABLE audit.audit_logs IS 'Log general de auditoría para todas las acciones del sistema'; +COMMENT ON COLUMN audit.audit_logs.correlation_id IS 'ID para correlacionar eventos relacionados en una misma operación'; diff --git a/ddl/schemas/audit/tables/02-security_events.sql b/ddl/schemas/audit/tables/02-security_events.sql new file mode 100644 index 0000000..7701dc5 --- /dev/null +++ b/ddl/schemas/audit/tables/02-security_events.sql @@ -0,0 +1,57 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: security_events +-- ============================================================================ +-- Eventos de seguridad específicos +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.security_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Categorización + category audit.security_event_category NOT NULL, + severity audit.event_severity NOT NULL, + event_status audit.event_status NOT NULL DEFAULT 'success', + + -- Actor + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + ip_address INET NOT NULL, + user_agent TEXT, + geo_location JSONB, + + -- Detalles del evento + event_code VARCHAR(50) NOT NULL, + event_name VARCHAR(255) NOT NULL, + description TEXT, + + -- Contexto técnico + request_path VARCHAR(500), + request_method VARCHAR(10), + response_code INTEGER, + + -- Datos adicionales + risk_score DECIMAL(3, 2), + is_blocked BOOLEAN DEFAULT FALSE, + block_reason TEXT, + requires_review BOOLEAN DEFAULT FALSE, + reviewed_by UUID REFERENCES auth.users(id), + reviewed_at TIMESTAMPTZ, + review_notes TEXT, + + -- Metadata + raw_data JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_security_events_user ON audit.security_events(user_id); +CREATE INDEX idx_security_events_category ON audit.security_events(category); +CREATE INDEX idx_security_events_severity ON audit.security_events(severity); +CREATE INDEX idx_security_events_ip ON audit.security_events(ip_address); +CREATE INDEX idx_security_events_created ON audit.security_events(created_at DESC); +CREATE INDEX idx_security_events_blocked ON audit.security_events(is_blocked) WHERE is_blocked = TRUE; +CREATE INDEX idx_security_events_review ON audit.security_events(requires_review) WHERE requires_review = TRUE; + +COMMENT ON TABLE audit.security_events IS 'Eventos de seguridad para monitoreo y respuesta a incidentes'; +COMMENT ON COLUMN audit.security_events.risk_score IS 'Puntuación de riesgo calculada (0.00-1.00)'; diff --git a/ddl/schemas/audit/tables/03-system_events.sql b/ddl/schemas/audit/tables/03-system_events.sql new file mode 100644 index 0000000..03966c7 --- /dev/null +++ b/ddl/schemas/audit/tables/03-system_events.sql @@ -0,0 +1,47 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: system_events +-- ============================================================================ +-- Eventos del sistema (jobs, tareas programadas, errores) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.system_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Categorización + event_type VARCHAR(50) NOT NULL, + severity audit.event_severity NOT NULL DEFAULT 'info', + + -- Origen + service_name VARCHAR(100) NOT NULL, + component VARCHAR(100), + environment VARCHAR(20) NOT NULL DEFAULT 'production', + hostname VARCHAR(255), + + -- Detalles + event_name VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + stack_trace TEXT, + + -- Contexto + correlation_id UUID, + job_id VARCHAR(100), + duration_ms INTEGER, + + -- Metadata + metadata JSONB DEFAULT '{}', + tags TEXT[], + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_system_events_service ON audit.system_events(service_name); +CREATE INDEX idx_system_events_type ON audit.system_events(event_type); +CREATE INDEX idx_system_events_severity ON audit.system_events(severity); +CREATE INDEX idx_system_events_created ON audit.system_events(created_at DESC); +CREATE INDEX idx_system_events_correlation ON audit.system_events(correlation_id) WHERE correlation_id IS NOT NULL; +CREATE INDEX idx_system_events_job ON audit.system_events(job_id) WHERE job_id IS NOT NULL; +CREATE INDEX idx_system_events_tags ON audit.system_events USING GIN (tags); + +COMMENT ON TABLE audit.system_events IS 'Eventos del sistema para monitoreo de infraestructura y jobs'; diff --git a/ddl/schemas/audit/tables/04-trading_audit.sql b/ddl/schemas/audit/tables/04-trading_audit.sql new file mode 100644 index 0000000..2dc245e --- /dev/null +++ b/ddl/schemas/audit/tables/04-trading_audit.sql @@ -0,0 +1,57 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: trading_audit +-- ============================================================================ +-- Auditoría específica de operaciones de trading +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.trading_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Actor + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + bot_id UUID, + + -- Acción + action VARCHAR(50) NOT NULL, + action_status audit.event_status NOT NULL, + + -- Objeto de la acción + order_id UUID, + position_id UUID, + symbol VARCHAR(20) NOT NULL, + + -- Detalles de la operación + side VARCHAR(4) NOT NULL, -- 'buy' o 'sell' + order_type VARCHAR(20), + quantity DECIMAL(20, 8) NOT NULL, + price DECIMAL(20, 8), + executed_price DECIMAL(20, 8), + + -- Resultado + pnl DECIMAL(20, 8), + fees DECIMAL(20, 8), + + -- Contexto + strategy_id UUID, + signal_id UUID, + is_paper_trading BOOLEAN DEFAULT FALSE, + + -- Metadata + execution_time_ms INTEGER, + broker_response JSONB, + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_trading_audit_user ON audit.trading_audit(user_id); +CREATE INDEX idx_trading_audit_bot ON audit.trading_audit(bot_id) WHERE bot_id IS NOT NULL; +CREATE INDEX idx_trading_audit_symbol ON audit.trading_audit(symbol); +CREATE INDEX idx_trading_audit_action ON audit.trading_audit(action); +CREATE INDEX idx_trading_audit_created ON audit.trading_audit(created_at DESC); +CREATE INDEX idx_trading_audit_order ON audit.trading_audit(order_id) WHERE order_id IS NOT NULL; +CREATE INDEX idx_trading_audit_position ON audit.trading_audit(position_id) WHERE position_id IS NOT NULL; + +COMMENT ON TABLE audit.trading_audit IS 'Auditoría detallada de todas las operaciones de trading'; diff --git a/ddl/schemas/audit/tables/05-api_request_logs.sql b/ddl/schemas/audit/tables/05-api_request_logs.sql new file mode 100644 index 0000000..3b83ff5 --- /dev/null +++ b/ddl/schemas/audit/tables/05-api_request_logs.sql @@ -0,0 +1,49 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: api_request_logs +-- ============================================================================ +-- Log de requests a la API (para debugging y análisis) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.api_request_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Request + request_id UUID NOT NULL, + method VARCHAR(10) NOT NULL, + path VARCHAR(500) NOT NULL, + query_params JSONB, + headers JSONB, + body_size INTEGER, + + -- Actor + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + api_key_id UUID, + ip_address INET NOT NULL, + user_agent TEXT, + + -- Response + status_code INTEGER NOT NULL, + response_size INTEGER, + response_time_ms INTEGER NOT NULL, + + -- Contexto + service_name VARCHAR(50), + version VARCHAR(20), + error_code VARCHAR(50), + error_message TEXT, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices optimizados para consultas de análisis +CREATE INDEX idx_api_logs_user ON audit.api_request_logs(user_id); +CREATE INDEX idx_api_logs_path ON audit.api_request_logs(path); +CREATE INDEX idx_api_logs_status ON audit.api_request_logs(status_code); +CREATE INDEX idx_api_logs_created ON audit.api_request_logs(created_at DESC); +CREATE INDEX idx_api_logs_ip ON audit.api_request_logs(ip_address); +CREATE INDEX idx_api_logs_slow ON audit.api_request_logs(response_time_ms) WHERE response_time_ms > 1000; +CREATE INDEX idx_api_logs_errors ON audit.api_request_logs(status_code) WHERE status_code >= 400; + +COMMENT ON TABLE audit.api_request_logs IS 'Log de requests HTTP para análisis y debugging'; +COMMENT ON COLUMN audit.api_request_logs.body_size IS 'Tamaño del body en bytes (no se guarda contenido por seguridad)'; diff --git a/ddl/schemas/audit/tables/06-data_access_logs.sql b/ddl/schemas/audit/tables/06-data_access_logs.sql new file mode 100644 index 0000000..dce4435 --- /dev/null +++ b/ddl/schemas/audit/tables/06-data_access_logs.sql @@ -0,0 +1,45 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: data_access_logs +-- ============================================================================ +-- Log de acceso a datos sensibles (cumplimiento regulatorio) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.data_access_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Quién accedió + accessor_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + accessor_role VARCHAR(50) NOT NULL, + + -- A qué datos se accedió + target_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + data_category VARCHAR(50) NOT NULL, -- 'pii', 'financial', 'health', 'credentials' + data_fields TEXT[], -- campos específicos accedidos + + -- Cómo se accedió + access_type VARCHAR(20) NOT NULL, -- 'view', 'export', 'modify', 'delete' + access_reason TEXT, + + -- Contexto + request_id UUID, + ip_address INET, + user_agent TEXT, + + -- Compliance + consent_verified BOOLEAN DEFAULT FALSE, + legal_basis VARCHAR(100), + retention_days INTEGER, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_data_access_accessor ON audit.data_access_logs(accessor_user_id); +CREATE INDEX idx_data_access_target ON audit.data_access_logs(target_user_id); +CREATE INDEX idx_data_access_category ON audit.data_access_logs(data_category); +CREATE INDEX idx_data_access_type ON audit.data_access_logs(access_type); +CREATE INDEX idx_data_access_created ON audit.data_access_logs(created_at DESC); + +COMMENT ON TABLE audit.data_access_logs IS 'Registro de acceso a datos sensibles para cumplimiento GDPR/CCPA'; +COMMENT ON COLUMN audit.data_access_logs.legal_basis IS 'Base legal para el acceso (consentimiento, contrato, obligación legal, etc.)'; diff --git a/ddl/schemas/audit/tables/07-compliance_logs.sql b/ddl/schemas/audit/tables/07-compliance_logs.sql new file mode 100644 index 0000000..ff6cced --- /dev/null +++ b/ddl/schemas/audit/tables/07-compliance_logs.sql @@ -0,0 +1,52 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: compliance_logs +-- ============================================================================ +-- Log de eventos de cumplimiento regulatorio +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.compliance_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Regulación + regulation VARCHAR(50) NOT NULL, -- 'GDPR', 'CCPA', 'SOX', 'PCI-DSS', 'MiFID' + requirement VARCHAR(100) NOT NULL, + + -- Evento + event_type VARCHAR(50) NOT NULL, + event_description TEXT NOT NULL, + + -- Actor + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + system_initiated BOOLEAN DEFAULT FALSE, + + -- Estado + compliance_status VARCHAR(20) NOT NULL, -- 'compliant', 'non_compliant', 'remediation' + risk_level VARCHAR(20), -- 'low', 'medium', 'high', 'critical' + + -- Detalles + evidence JSONB, + remediation_required BOOLEAN DEFAULT FALSE, + remediation_deadline TIMESTAMPTZ, + remediation_notes TEXT, + + -- Revisión + reviewed_by UUID REFERENCES auth.users(id), + reviewed_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_compliance_regulation ON audit.compliance_logs(regulation); +CREATE INDEX idx_compliance_status ON audit.compliance_logs(compliance_status); +CREATE INDEX idx_compliance_risk ON audit.compliance_logs(risk_level); +CREATE INDEX idx_compliance_created ON audit.compliance_logs(created_at DESC); +CREATE INDEX idx_compliance_remediation ON audit.compliance_logs(remediation_required) + WHERE remediation_required = TRUE; + +COMMENT ON TABLE audit.compliance_logs IS 'Registro de cumplimiento regulatorio para auditorías'; diff --git a/ddl/schemas/auth/00-extensions.sql b/ddl/schemas/auth/00-extensions.sql new file mode 100644 index 0000000..2ccf481 --- /dev/null +++ b/ddl/schemas/auth/00-extensions.sql @@ -0,0 +1,19 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: 00-extensions.sql +-- Description: PostgreSQL extensions required for authentication schema +-- ============================================================================ + +-- UUID generation extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Cryptographic functions for password hashing and token generation +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Network address types and functions +CREATE EXTENSION IF NOT EXISTS "citext"; + +COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions'; +COMMENT ON EXTENSION "pgcrypto" IS 'Cryptographic functions for secure password and token handling'; +COMMENT ON EXTENSION "citext" IS 'Case-insensitive text type for email addresses'; diff --git a/ddl/schemas/auth/01-enums.sql b/ddl/schemas/auth/01-enums.sql new file mode 100644 index 0000000..3fd7991 --- /dev/null +++ b/ddl/schemas/auth/01-enums.sql @@ -0,0 +1,80 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: 01-enums.sql +-- Description: Enumerated types for authentication and authorization +-- ============================================================================ + +-- User account status +CREATE TYPE auth.user_status AS ENUM ( + 'pending_verification', + 'active', + 'suspended', + 'deactivated', + 'banned' +); + +COMMENT ON TYPE auth.user_status IS 'User account status lifecycle states'; + +-- User role for RBAC +CREATE TYPE auth.user_role AS ENUM ( + 'user', + 'trader', + 'analyst', + 'admin', + 'super_admin' +); + +COMMENT ON TYPE auth.user_role IS 'Role-based access control roles'; + +-- OAuth provider types +CREATE TYPE auth.oauth_provider AS ENUM ( + 'google', + 'facebook', + 'apple', + 'github', + 'microsoft', + 'twitter' +); + +COMMENT ON TYPE auth.oauth_provider IS 'Supported OAuth 2.0 providers'; + +-- Phone verification channel +CREATE TYPE auth.phone_channel AS ENUM ( + 'sms', + 'whatsapp' +); + +COMMENT ON TYPE auth.phone_channel IS 'Phone verification delivery channels'; + +-- Authentication event types for logging +CREATE TYPE auth.auth_event_type AS ENUM ( + 'login', + 'logout', + 'register', + 'password_change', + 'password_reset_request', + 'password_reset_complete', + 'email_verification', + 'phone_verification', + 'mfa_enabled', + 'mfa_disabled', + 'session_expired', + 'account_suspended', + 'account_reactivated', + 'failed_login', + 'oauth_linked', + 'oauth_unlinked' +); + +COMMENT ON TYPE auth.auth_event_type IS 'Types of authentication events for audit logging'; + +-- MFA method types +CREATE TYPE auth.mfa_method AS ENUM ( + 'none', + 'totp', + 'sms', + 'email' +); + +COMMENT ON TYPE auth.mfa_method IS 'Multi-factor authentication methods'; diff --git a/ddl/schemas/auth/functions/01-update_updated_at.sql b/ddl/schemas/auth/functions/01-update_updated_at.sql new file mode 100644 index 0000000..3cd3d22 --- /dev/null +++ b/ddl/schemas/auth/functions/01-update_updated_at.sql @@ -0,0 +1,48 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: functions/01-update_updated_at.sql +-- Description: Trigger function to automatically update updated_at timestamp +-- ============================================================================ + +CREATE OR REPLACE FUNCTION auth.update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.update_updated_at() IS 'Trigger function to automatically update updated_at column on row modification'; + +-- Apply trigger to all tables with updated_at column + +-- Users table +CREATE TRIGGER trigger_update_users_updated_at + BEFORE UPDATE ON auth.users + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); + +-- User profiles table +CREATE TRIGGER trigger_update_user_profiles_updated_at + BEFORE UPDATE ON auth.user_profiles + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); + +-- OAuth accounts table +CREATE TRIGGER trigger_update_oauth_accounts_updated_at + BEFORE UPDATE ON auth.oauth_accounts + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); + +-- Sessions table +CREATE TRIGGER trigger_update_sessions_updated_at + BEFORE UPDATE ON auth.sessions + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); + +-- Rate limiting config table +CREATE TRIGGER trigger_update_rate_limiting_config_updated_at + BEFORE UPDATE ON auth.rate_limiting_config + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); diff --git a/ddl/schemas/auth/functions/02-log_auth_event.sql b/ddl/schemas/auth/functions/02-log_auth_event.sql new file mode 100644 index 0000000..6b91882 --- /dev/null +++ b/ddl/schemas/auth/functions/02-log_auth_event.sql @@ -0,0 +1,75 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: functions/02-log_auth_event.sql +-- Description: Function to log authentication events to auth_logs table +-- ============================================================================ + +CREATE OR REPLACE FUNCTION auth.log_auth_event( + p_event_type auth.auth_event_type, + p_user_id UUID DEFAULT NULL, + p_email CITEXT DEFAULT NULL, + p_ip_address INET DEFAULT NULL, + p_user_agent TEXT DEFAULT NULL, + p_session_id UUID DEFAULT NULL, + p_success BOOLEAN DEFAULT true, + p_failure_reason VARCHAR(255) DEFAULT NULL, + p_metadata JSONB DEFAULT '{}'::jsonb +) +RETURNS UUID AS $$ +DECLARE + v_log_id UUID; +BEGIN + INSERT INTO auth.auth_logs ( + event_type, + user_id, + email, + ip_address, + user_agent, + session_id, + success, + failure_reason, + metadata, + created_at + ) VALUES ( + p_event_type, + p_user_id, + p_email, + p_ip_address, + p_user_agent, + p_session_id, + p_success, + p_failure_reason, + p_metadata, + NOW() + ) + RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.log_auth_event( + auth.auth_event_type, + UUID, + CITEXT, + INET, + TEXT, + UUID, + BOOLEAN, + VARCHAR, + JSONB +) IS 'Logs authentication events to the auth_logs table with optional metadata'; + +-- Example usage: +-- SELECT auth.log_auth_event( +-- 'login'::auth.auth_event_type, +-- '123e4567-e89b-12d3-a456-426614174000'::UUID, +-- 'user@example.com'::CITEXT, +-- '192.168.1.1'::INET, +-- 'Mozilla/5.0...', +-- '123e4567-e89b-12d3-a456-426614174001'::UUID, +-- true, +-- NULL, +-- '{"device": "mobile"}'::JSONB +-- ); diff --git a/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql b/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql new file mode 100644 index 0000000..f8574b0 --- /dev/null +++ b/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql @@ -0,0 +1,58 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: functions/03-cleanup_expired_sessions.sql +-- Description: Function to cleanup expired and inactive sessions +-- ============================================================================ + +CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions( + p_batch_size INTEGER DEFAULT 1000 +) +RETURNS TABLE( + deleted_count INTEGER, + execution_time_ms NUMERIC +) AS $$ +DECLARE + v_start_time TIMESTAMPTZ; + v_deleted_count INTEGER; +BEGIN + v_start_time := clock_timestamp(); + + -- Delete expired sessions + WITH deleted AS ( + DELETE FROM auth.sessions + WHERE ( + -- Expired sessions + expires_at < NOW() OR + -- Inactive sessions older than 30 days + (is_active = false AND invalidated_at < NOW() - INTERVAL '30 days') + ) + AND id IN ( + SELECT id FROM auth.sessions + WHERE ( + expires_at < NOW() OR + (is_active = false AND invalidated_at < NOW() - INTERVAL '30 days') + ) + LIMIT p_batch_size + ) + RETURNING * + ) + SELECT COUNT(*)::INTEGER INTO v_deleted_count FROM deleted; + + RETURN QUERY + SELECT + v_deleted_count, + EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time) * 1000)::NUMERIC; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.cleanup_expired_sessions(INTEGER) IS + 'Deletes expired and old inactive sessions in batches. Returns deleted count and execution time.'; + +-- Example usage: +-- SELECT * FROM auth.cleanup_expired_sessions(1000); + +-- Recommended: Schedule this function to run periodically via pg_cron or external scheduler +-- Example pg_cron schedule (runs daily at 2 AM): +-- SELECT cron.schedule('cleanup-expired-sessions', '0 2 * * *', +-- 'SELECT auth.cleanup_expired_sessions(1000);'); diff --git a/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql b/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql new file mode 100644 index 0000000..a01e6e9 --- /dev/null +++ b/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql @@ -0,0 +1,46 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: functions/04-create_user_profile_trigger.sql +-- Description: Automatically create user profile when new user is created +-- ============================================================================ + +CREATE OR REPLACE FUNCTION auth.create_user_profile() +RETURNS TRIGGER AS $$ +BEGIN + -- Create a new user profile for the newly created user + INSERT INTO auth.user_profiles ( + user_id, + language, + timezone, + newsletter_subscribed, + marketing_emails_enabled, + notifications_enabled, + created_at, + updated_at + ) VALUES ( + NEW.id, + 'en', + 'UTC', + false, + false, + true, + NOW(), + NOW() + ); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.create_user_profile() IS + 'Trigger function to automatically create a user profile when a new user is registered'; + +-- Create trigger on users table +CREATE TRIGGER trigger_create_user_profile + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION auth.create_user_profile(); + +COMMENT ON TRIGGER trigger_create_user_profile ON auth.users IS + 'Automatically creates a user profile entry when a new user is inserted'; diff --git a/ddl/schemas/auth/tables/01-users.sql b/ddl/schemas/auth/tables/01-users.sql new file mode 100644 index 0000000..a76cba8 --- /dev/null +++ b/ddl/schemas/auth/tables/01-users.sql @@ -0,0 +1,106 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/01-users.sql +-- Description: Core users table for authentication and user management +-- ============================================================================ + +CREATE TABLE auth.users ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Authentication Credentials + email CITEXT NOT NULL UNIQUE, + email_verified BOOLEAN NOT NULL DEFAULT false, + email_verified_at TIMESTAMPTZ, + password_hash VARCHAR(255), + + -- User Status and Role + status auth.user_status NOT NULL DEFAULT 'pending_verification', + role auth.user_role NOT NULL DEFAULT 'user', + + -- Multi-Factor Authentication + mfa_enabled BOOLEAN NOT NULL DEFAULT false, + mfa_method auth.mfa_method NOT NULL DEFAULT 'none', + mfa_secret VARCHAR(255), + backup_codes JSONB DEFAULT '[]', + + -- Phone Information + phone_number VARCHAR(20), + phone_verified BOOLEAN NOT NULL DEFAULT false, + phone_verified_at TIMESTAMPTZ, + + -- Security Settings + last_login_at TIMESTAMPTZ, + last_login_ip INET, + failed_login_attempts INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + + -- Account Lifecycle + suspended_at TIMESTAMPTZ, + suspended_reason TEXT, + deactivated_at TIMESTAMPTZ, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by_id UUID, + updated_by_id UUID, + + -- Constraints + CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), + -- NOTE: password_or_oauth constraint moved to 99-deferred-constraints.sql + -- to resolve circular dependency with oauth_accounts + CONSTRAINT failed_attempts_non_negative CHECK (failed_login_attempts >= 0), + CONSTRAINT email_verified_at_consistency CHECK ( + (email_verified = true AND email_verified_at IS NOT NULL) OR + (email_verified = false AND email_verified_at IS NULL) + ), + CONSTRAINT phone_verified_at_consistency CHECK ( + (phone_verified = true AND phone_verified_at IS NOT NULL) OR + (phone_verified = false AND phone_verified_at IS NULL) + ), + CONSTRAINT mfa_secret_consistency CHECK ( + (mfa_enabled = true AND mfa_secret IS NOT NULL AND mfa_method != 'none') OR + (mfa_enabled = false) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_users_email ON auth.users(email); +CREATE INDEX idx_users_status ON auth.users(status); +CREATE INDEX idx_users_role ON auth.users(role); +CREATE INDEX idx_users_last_login ON auth.users(last_login_at DESC); +CREATE INDEX idx_users_created_at ON auth.users(created_at DESC); +CREATE INDEX idx_users_email_verified ON auth.users(email_verified) WHERE email_verified = false; +CREATE INDEX idx_users_locked ON auth.users(locked_until) WHERE locked_until IS NOT NULL; +CREATE INDEX idx_users_phone ON auth.users(phone_number) WHERE phone_number IS NOT NULL; + +-- Table Comments +COMMENT ON TABLE auth.users IS 'Core users table for authentication and user management'; + +-- Column Comments +COMMENT ON COLUMN auth.users.id IS 'Unique identifier for the user'; +COMMENT ON COLUMN auth.users.email IS 'User email address (case-insensitive, unique)'; +COMMENT ON COLUMN auth.users.email_verified IS 'Whether the email has been verified'; +COMMENT ON COLUMN auth.users.email_verified_at IS 'Timestamp when email was verified'; +COMMENT ON COLUMN auth.users.password_hash IS 'Bcrypt hashed password (null for OAuth-only users)'; +COMMENT ON COLUMN auth.users.status IS 'Current status of the user account'; +COMMENT ON COLUMN auth.users.role IS 'User role for role-based access control'; +COMMENT ON COLUMN auth.users.mfa_enabled IS 'Whether multi-factor authentication is enabled'; +COMMENT ON COLUMN auth.users.mfa_method IS 'MFA method used (totp, sms, email)'; +COMMENT ON COLUMN auth.users.mfa_secret IS 'Secret key for TOTP MFA'; +COMMENT ON COLUMN auth.users.phone_number IS 'User phone number for SMS verification'; +COMMENT ON COLUMN auth.users.phone_verified IS 'Whether the phone number has been verified'; +COMMENT ON COLUMN auth.users.phone_verified_at IS 'Timestamp when phone was verified'; +COMMENT ON COLUMN auth.users.last_login_at IS 'Timestamp of last successful login'; +COMMENT ON COLUMN auth.users.last_login_ip IS 'IP address of last successful login'; +COMMENT ON COLUMN auth.users.failed_login_attempts IS 'Counter for failed login attempts'; +COMMENT ON COLUMN auth.users.locked_until IS 'Account locked until this timestamp (null if not locked)'; +COMMENT ON COLUMN auth.users.suspended_at IS 'Timestamp when account was suspended'; +COMMENT ON COLUMN auth.users.suspended_reason IS 'Reason for account suspension'; +COMMENT ON COLUMN auth.users.deactivated_at IS 'Timestamp when account was deactivated'; +COMMENT ON COLUMN auth.users.created_at IS 'Timestamp when user was created'; +COMMENT ON COLUMN auth.users.updated_at IS 'Timestamp when user was last updated'; +COMMENT ON COLUMN auth.users.created_by_id IS 'ID of user who created this record'; +COMMENT ON COLUMN auth.users.updated_by_id IS 'ID of user who last updated this record'; diff --git a/ddl/schemas/auth/tables/02-user_profiles.sql b/ddl/schemas/auth/tables/02-user_profiles.sql new file mode 100644 index 0000000..2092a4a --- /dev/null +++ b/ddl/schemas/auth/tables/02-user_profiles.sql @@ -0,0 +1,70 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/02-user_profiles.sql +-- Description: Extended user profile information +-- ============================================================================ + +CREATE TABLE auth.user_profiles ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL UNIQUE, + + -- Personal Information + first_name VARCHAR(100), + last_name VARCHAR(100), + display_name VARCHAR(200), + avatar_url TEXT, + bio TEXT, + + -- Localization + language VARCHAR(10) DEFAULT 'en', + timezone VARCHAR(50) DEFAULT 'UTC', + country_code VARCHAR(2), + + -- Preferences + newsletter_subscribed BOOLEAN NOT NULL DEFAULT false, + marketing_emails_enabled BOOLEAN NOT NULL DEFAULT false, + notifications_enabled BOOLEAN NOT NULL DEFAULT true, + + -- Metadata + metadata JSONB DEFAULT '{}'::jsonb, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_user_profiles_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE +); + +-- Indexes for Performance +CREATE INDEX idx_user_profiles_user_id ON auth.user_profiles(user_id); +CREATE INDEX idx_user_profiles_display_name ON auth.user_profiles(display_name); +CREATE INDEX idx_user_profiles_country ON auth.user_profiles(country_code); +CREATE INDEX idx_user_profiles_metadata ON auth.user_profiles USING gin(metadata); + +-- Table Comments +COMMENT ON TABLE auth.user_profiles IS 'Extended user profile information and preferences'; + +-- Column Comments +COMMENT ON COLUMN auth.user_profiles.id IS 'Unique identifier for the profile'; +COMMENT ON COLUMN auth.user_profiles.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.user_profiles.first_name IS 'User first name'; +COMMENT ON COLUMN auth.user_profiles.last_name IS 'User last name'; +COMMENT ON COLUMN auth.user_profiles.display_name IS 'Public display name'; +COMMENT ON COLUMN auth.user_profiles.avatar_url IS 'URL to user avatar image'; +COMMENT ON COLUMN auth.user_profiles.bio IS 'User biography or description'; +COMMENT ON COLUMN auth.user_profiles.language IS 'Preferred language (ISO 639-1 code)'; +COMMENT ON COLUMN auth.user_profiles.timezone IS 'User timezone (IANA timezone database)'; +COMMENT ON COLUMN auth.user_profiles.country_code IS 'Country code (ISO 3166-1 alpha-2)'; +COMMENT ON COLUMN auth.user_profiles.newsletter_subscribed IS 'Newsletter subscription preference'; +COMMENT ON COLUMN auth.user_profiles.marketing_emails_enabled IS 'Marketing emails preference'; +COMMENT ON COLUMN auth.user_profiles.notifications_enabled IS 'In-app notifications preference'; +COMMENT ON COLUMN auth.user_profiles.metadata IS 'Additional profile metadata as JSON'; +COMMENT ON COLUMN auth.user_profiles.created_at IS 'Timestamp when profile was created'; +COMMENT ON COLUMN auth.user_profiles.updated_at IS 'Timestamp when profile was last updated'; diff --git a/ddl/schemas/auth/tables/03-oauth_accounts.sql b/ddl/schemas/auth/tables/03-oauth_accounts.sql new file mode 100644 index 0000000..75fdba4 --- /dev/null +++ b/ddl/schemas/auth/tables/03-oauth_accounts.sql @@ -0,0 +1,69 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/03-oauth_accounts.sql +-- Description: OAuth provider accounts linked to users +-- ============================================================================ + +CREATE TABLE auth.oauth_accounts ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- OAuth Provider Information + provider auth.oauth_provider NOT NULL, + provider_account_id VARCHAR(255) NOT NULL, + provider_email CITEXT, + + -- OAuth Tokens + access_token TEXT, + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + + -- Provider Profile Data + profile_data JSONB DEFAULT '{}'::jsonb, + + -- Audit Fields + linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_oauth_accounts_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Unique Constraint: One provider account per user + CONSTRAINT unique_user_provider UNIQUE (user_id, provider), + + -- Unique Constraint: Provider account can only link to one user + CONSTRAINT unique_provider_account UNIQUE (provider, provider_account_id) +); + +-- Indexes for Performance +CREATE INDEX idx_oauth_accounts_user_id ON auth.oauth_accounts(user_id); +CREATE INDEX idx_oauth_accounts_provider ON auth.oauth_accounts(provider); +CREATE INDEX idx_oauth_accounts_provider_email ON auth.oauth_accounts(provider_email); +CREATE INDEX idx_oauth_accounts_last_used ON auth.oauth_accounts(last_used_at DESC); +CREATE INDEX idx_oauth_accounts_profile_data ON auth.oauth_accounts USING gin(profile_data); + +-- Table Comments +COMMENT ON TABLE auth.oauth_accounts IS 'OAuth provider accounts linked to users for social authentication'; + +-- Column Comments +COMMENT ON COLUMN auth.oauth_accounts.id IS 'Unique identifier for the OAuth account'; +COMMENT ON COLUMN auth.oauth_accounts.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.oauth_accounts.provider IS 'OAuth provider (google, facebook, etc.)'; +COMMENT ON COLUMN auth.oauth_accounts.provider_account_id IS 'User ID from the OAuth provider'; +COMMENT ON COLUMN auth.oauth_accounts.provider_email IS 'Email address from OAuth provider'; +COMMENT ON COLUMN auth.oauth_accounts.access_token IS 'OAuth access token (encrypted)'; +COMMENT ON COLUMN auth.oauth_accounts.refresh_token IS 'OAuth refresh token (encrypted)'; +COMMENT ON COLUMN auth.oauth_accounts.token_expires_at IS 'Access token expiration timestamp'; +COMMENT ON COLUMN auth.oauth_accounts.profile_data IS 'Profile data from OAuth provider as JSON'; +COMMENT ON COLUMN auth.oauth_accounts.linked_at IS 'Timestamp when account was linked'; +COMMENT ON COLUMN auth.oauth_accounts.last_used_at IS 'Timestamp when last used for authentication'; +COMMENT ON COLUMN auth.oauth_accounts.created_at IS 'Timestamp when record was created'; +COMMENT ON COLUMN auth.oauth_accounts.updated_at IS 'Timestamp when record was last updated'; diff --git a/ddl/schemas/auth/tables/04-sessions.sql b/ddl/schemas/auth/tables/04-sessions.sql new file mode 100644 index 0000000..b7d61de --- /dev/null +++ b/ddl/schemas/auth/tables/04-sessions.sql @@ -0,0 +1,87 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/04-sessions.sql +-- Description: User session management for authentication +-- ============================================================================ + +CREATE TABLE auth.sessions ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- Session Token + session_token VARCHAR(255) NOT NULL UNIQUE, + + -- Session Lifecycle + expires_at TIMESTAMPTZ NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Session Metadata + ip_address INET, + user_agent TEXT, + device_type VARCHAR(50), + device_name VARCHAR(100), + browser VARCHAR(50), + os VARCHAR(50), + + -- Geolocation + country_code VARCHAR(2), + city VARCHAR(100), + + -- Security + last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + invalidated_at TIMESTAMPTZ, + invalidation_reason VARCHAR(100), + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Check Constraints + CONSTRAINT valid_session_dates CHECK (expires_at > created_at), + CONSTRAINT invalidated_consistency CHECK ( + (is_active = false AND invalidated_at IS NOT NULL) OR + (is_active = true AND invalidated_at IS NULL) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id); +CREATE INDEX idx_sessions_token ON auth.sessions(session_token); +CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at); +CREATE INDEX idx_sessions_active ON auth.sessions(is_active, expires_at) WHERE is_active = true; +CREATE INDEX idx_sessions_last_activity ON auth.sessions(last_activity_at DESC); +CREATE INDEX idx_sessions_ip_address ON auth.sessions(ip_address); +CREATE INDEX idx_sessions_user_active ON auth.sessions(user_id, is_active, expires_at) + WHERE is_active = true; + +-- Table Comments +COMMENT ON TABLE auth.sessions IS 'User session management for authentication and activity tracking'; + +-- Column Comments +COMMENT ON COLUMN auth.sessions.id IS 'Unique identifier for the session'; +COMMENT ON COLUMN auth.sessions.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.sessions.session_token IS 'Unique session token for authentication'; +COMMENT ON COLUMN auth.sessions.expires_at IS 'Session expiration timestamp'; +COMMENT ON COLUMN auth.sessions.is_active IS 'Whether the session is currently active'; +COMMENT ON COLUMN auth.sessions.ip_address IS 'IP address of the session'; +COMMENT ON COLUMN auth.sessions.user_agent IS 'User agent string from the browser'; +COMMENT ON COLUMN auth.sessions.device_type IS 'Device type (desktop, mobile, tablet)'; +COMMENT ON COLUMN auth.sessions.device_name IS 'Device name or model'; +COMMENT ON COLUMN auth.sessions.browser IS 'Browser name and version'; +COMMENT ON COLUMN auth.sessions.os IS 'Operating system name and version'; +COMMENT ON COLUMN auth.sessions.country_code IS 'Country code from IP geolocation'; +COMMENT ON COLUMN auth.sessions.city IS 'City from IP geolocation'; +COMMENT ON COLUMN auth.sessions.last_activity_at IS 'Timestamp of last session activity'; +COMMENT ON COLUMN auth.sessions.invalidated_at IS 'Timestamp when session was invalidated'; +COMMENT ON COLUMN auth.sessions.invalidation_reason IS 'Reason for session invalidation'; +COMMENT ON COLUMN auth.sessions.created_at IS 'Timestamp when session was created'; +COMMENT ON COLUMN auth.sessions.updated_at IS 'Timestamp when session was last updated'; diff --git a/ddl/schemas/auth/tables/05-email_verifications.sql b/ddl/schemas/auth/tables/05-email_verifications.sql new file mode 100644 index 0000000..8995898 --- /dev/null +++ b/ddl/schemas/auth/tables/05-email_verifications.sql @@ -0,0 +1,65 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/05-email_verifications.sql +-- Description: Email verification tokens and tracking +-- ============================================================================ + +CREATE TABLE auth.email_verifications ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- Email and Token + email CITEXT NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + + -- Token Lifecycle + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + is_verified BOOLEAN NOT NULL DEFAULT false, + + -- Metadata + ip_address INET, + user_agent TEXT, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Check Constraints + CONSTRAINT valid_expiration CHECK (expires_at > created_at), + CONSTRAINT verified_consistency CHECK ( + (is_verified = true AND verified_at IS NOT NULL) OR + (is_verified = false AND verified_at IS NULL) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_email_verifications_user_id ON auth.email_verifications(user_id); +CREATE INDEX idx_email_verifications_token ON auth.email_verifications(token); +CREATE INDEX idx_email_verifications_email ON auth.email_verifications(email); +CREATE INDEX idx_email_verifications_expires ON auth.email_verifications(expires_at); +CREATE INDEX idx_email_verifications_pending ON auth.email_verifications(user_id, is_verified, expires_at) + WHERE is_verified = false; + +-- Table Comments +COMMENT ON TABLE auth.email_verifications IS 'Email verification tokens and tracking for user email confirmation'; + +-- Column Comments +COMMENT ON COLUMN auth.email_verifications.id IS 'Unique identifier for the verification record'; +COMMENT ON COLUMN auth.email_verifications.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.email_verifications.email IS 'Email address to be verified'; +COMMENT ON COLUMN auth.email_verifications.token IS 'Unique verification token sent to email'; +COMMENT ON COLUMN auth.email_verifications.expires_at IS 'Token expiration timestamp'; +COMMENT ON COLUMN auth.email_verifications.verified_at IS 'Timestamp when email was verified'; +COMMENT ON COLUMN auth.email_verifications.is_verified IS 'Whether the email has been verified'; +COMMENT ON COLUMN auth.email_verifications.ip_address IS 'IP address when verification was requested'; +COMMENT ON COLUMN auth.email_verifications.user_agent IS 'User agent when verification was requested'; +COMMENT ON COLUMN auth.email_verifications.created_at IS 'Timestamp when verification was created'; diff --git a/ddl/schemas/auth/tables/06-phone_verifications.sql b/ddl/schemas/auth/tables/06-phone_verifications.sql new file mode 100644 index 0000000..e809854 --- /dev/null +++ b/ddl/schemas/auth/tables/06-phone_verifications.sql @@ -0,0 +1,78 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/06-phone_verifications.sql +-- Description: Phone number verification tokens and tracking +-- ============================================================================ + +CREATE TABLE auth.phone_verifications ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- Phone and Verification Code + phone_number VARCHAR(20) NOT NULL, + verification_code VARCHAR(10) NOT NULL, + channel auth.phone_channel NOT NULL DEFAULT 'sms', + + -- Token Lifecycle + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + is_verified BOOLEAN NOT NULL DEFAULT false, + + -- Attempt Tracking + send_attempts INTEGER NOT NULL DEFAULT 0, + verification_attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + + -- Metadata + ip_address INET, + user_agent TEXT, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_phone_verifications_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Check Constraints + CONSTRAINT valid_expiration CHECK (expires_at > created_at), + CONSTRAINT verified_consistency CHECK ( + (is_verified = true AND verified_at IS NOT NULL) OR + (is_verified = false AND verified_at IS NULL) + ), + CONSTRAINT valid_attempts CHECK ( + send_attempts >= 0 AND + verification_attempts >= 0 AND + verification_attempts <= max_attempts + ) +); + +-- Indexes for Performance +CREATE INDEX idx_phone_verifications_user_id ON auth.phone_verifications(user_id); +CREATE INDEX idx_phone_verifications_phone ON auth.phone_verifications(phone_number); +CREATE INDEX idx_phone_verifications_expires ON auth.phone_verifications(expires_at); +CREATE INDEX idx_phone_verifications_pending ON auth.phone_verifications(user_id, is_verified, expires_at) + WHERE is_verified = false; + +-- Table Comments +COMMENT ON TABLE auth.phone_verifications IS 'Phone number verification codes and tracking for user phone confirmation'; + +-- Column Comments +COMMENT ON COLUMN auth.phone_verifications.id IS 'Unique identifier for the verification record'; +COMMENT ON COLUMN auth.phone_verifications.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.phone_verifications.phone_number IS 'Phone number to be verified'; +COMMENT ON COLUMN auth.phone_verifications.verification_code IS 'Verification code sent via SMS'; +COMMENT ON COLUMN auth.phone_verifications.expires_at IS 'Code expiration timestamp'; +COMMENT ON COLUMN auth.phone_verifications.verified_at IS 'Timestamp when phone was verified'; +COMMENT ON COLUMN auth.phone_verifications.is_verified IS 'Whether the phone has been verified'; +COMMENT ON COLUMN auth.phone_verifications.send_attempts IS 'Number of times code was sent'; +COMMENT ON COLUMN auth.phone_verifications.verification_attempts IS 'Number of verification attempts'; +COMMENT ON COLUMN auth.phone_verifications.max_attempts IS 'Maximum allowed verification attempts'; +COMMENT ON COLUMN auth.phone_verifications.ip_address IS 'IP address when verification was requested'; +COMMENT ON COLUMN auth.phone_verifications.user_agent IS 'User agent when verification was requested'; +COMMENT ON COLUMN auth.phone_verifications.created_at IS 'Timestamp when verification was created'; diff --git a/ddl/schemas/auth/tables/07-password_reset_tokens.sql b/ddl/schemas/auth/tables/07-password_reset_tokens.sql new file mode 100644 index 0000000..b18b2cb --- /dev/null +++ b/ddl/schemas/auth/tables/07-password_reset_tokens.sql @@ -0,0 +1,65 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/07-password_reset_tokens.sql +-- Description: Password reset tokens and tracking +-- ============================================================================ + +CREATE TABLE auth.password_reset_tokens ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- Email and Token + email CITEXT NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + + -- Token Lifecycle + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + is_used BOOLEAN NOT NULL DEFAULT false, + + -- Metadata + ip_address INET, + user_agent TEXT, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Check Constraints + CONSTRAINT valid_expiration CHECK (expires_at > created_at), + CONSTRAINT used_consistency CHECK ( + (is_used = true AND used_at IS NOT NULL) OR + (is_used = false AND used_at IS NULL) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_password_reset_tokens_user_id ON auth.password_reset_tokens(user_id); +CREATE INDEX idx_password_reset_tokens_token ON auth.password_reset_tokens(token); +CREATE INDEX idx_password_reset_tokens_email ON auth.password_reset_tokens(email); +CREATE INDEX idx_password_reset_tokens_expires ON auth.password_reset_tokens(expires_at); +CREATE INDEX idx_password_reset_tokens_active ON auth.password_reset_tokens(user_id, is_used, expires_at) + WHERE is_used = false; + +-- Table Comments +COMMENT ON TABLE auth.password_reset_tokens IS 'Password reset tokens for secure password recovery'; + +-- Column Comments +COMMENT ON COLUMN auth.password_reset_tokens.id IS 'Unique identifier for the reset token'; +COMMENT ON COLUMN auth.password_reset_tokens.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.password_reset_tokens.email IS 'Email address for password reset'; +COMMENT ON COLUMN auth.password_reset_tokens.token IS 'Unique reset token sent to email'; +COMMENT ON COLUMN auth.password_reset_tokens.expires_at IS 'Token expiration timestamp'; +COMMENT ON COLUMN auth.password_reset_tokens.used_at IS 'Timestamp when token was used'; +COMMENT ON COLUMN auth.password_reset_tokens.is_used IS 'Whether the token has been used'; +COMMENT ON COLUMN auth.password_reset_tokens.ip_address IS 'IP address when reset was requested'; +COMMENT ON COLUMN auth.password_reset_tokens.user_agent IS 'User agent when reset was requested'; +COMMENT ON COLUMN auth.password_reset_tokens.created_at IS 'Timestamp when token was created'; diff --git a/ddl/schemas/auth/tables/08-auth_logs.sql b/ddl/schemas/auth/tables/08-auth_logs.sql new file mode 100644 index 0000000..810c96c --- /dev/null +++ b/ddl/schemas/auth/tables/08-auth_logs.sql @@ -0,0 +1,74 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/08-auth_logs.sql +-- Description: Authentication event audit logging with optional partitioning +-- ============================================================================ + +CREATE TABLE auth.auth_logs ( + -- Primary Key + id UUID DEFAULT gen_random_uuid(), + + -- Event Information + event_type auth.auth_event_type NOT NULL, + user_id UUID, + email CITEXT, + + -- Request Context + ip_address INET, + user_agent TEXT, + session_id UUID, + + -- Event Details + success BOOLEAN NOT NULL DEFAULT false, + failure_reason VARCHAR(255), + + -- Additional Metadata + metadata JSONB DEFAULT '{}'::jsonb, + + -- Timestamp (partition key) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Primary Key includes partition key for partitioned tables + PRIMARY KEY (id, created_at) +) PARTITION BY RANGE (created_at); + +-- Create initial partitions for current and next month +-- These should be created dynamically by a maintenance job in production + +-- Current month partition +CREATE TABLE auth.auth_logs_current PARTITION OF auth.auth_logs + FOR VALUES FROM (DATE_TRUNC('month', CURRENT_DATE)) + TO (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')); + +-- Next month partition +CREATE TABLE auth.auth_logs_next PARTITION OF auth.auth_logs + FOR VALUES FROM (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')) + TO (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '2 months')); + +-- Indexes for Performance (will be inherited by partitions) +CREATE INDEX idx_auth_logs_user_id ON auth.auth_logs(user_id, created_at DESC); +CREATE INDEX idx_auth_logs_email ON auth.auth_logs(email, created_at DESC); +CREATE INDEX idx_auth_logs_event_type ON auth.auth_logs(event_type, created_at DESC); +CREATE INDEX idx_auth_logs_ip_address ON auth.auth_logs(ip_address, created_at DESC); +CREATE INDEX idx_auth_logs_session_id ON auth.auth_logs(session_id); +CREATE INDEX idx_auth_logs_created_at ON auth.auth_logs(created_at DESC); +CREATE INDEX idx_auth_logs_failures ON auth.auth_logs(user_id, created_at DESC) + WHERE success = false; +CREATE INDEX idx_auth_logs_metadata ON auth.auth_logs USING gin(metadata); + +-- Table Comments +COMMENT ON TABLE auth.auth_logs IS 'Authentication event audit logging with monthly partitioning for performance'; + +-- Column Comments +COMMENT ON COLUMN auth.auth_logs.id IS 'Unique identifier for the log entry'; +COMMENT ON COLUMN auth.auth_logs.event_type IS 'Type of authentication event'; +COMMENT ON COLUMN auth.auth_logs.user_id IS 'Reference to the user (null for failed logins)'; +COMMENT ON COLUMN auth.auth_logs.email IS 'Email address associated with the event'; +COMMENT ON COLUMN auth.auth_logs.ip_address IS 'IP address of the request'; +COMMENT ON COLUMN auth.auth_logs.user_agent IS 'User agent string from the request'; +COMMENT ON COLUMN auth.auth_logs.session_id IS 'Session ID if applicable'; +COMMENT ON COLUMN auth.auth_logs.success IS 'Whether the event was successful'; +COMMENT ON COLUMN auth.auth_logs.failure_reason IS 'Reason for failure if applicable'; +COMMENT ON COLUMN auth.auth_logs.metadata IS 'Additional event metadata as JSON'; +COMMENT ON COLUMN auth.auth_logs.created_at IS 'Timestamp when event occurred (partition key)'; diff --git a/ddl/schemas/auth/tables/09-login_attempts.sql b/ddl/schemas/auth/tables/09-login_attempts.sql new file mode 100644 index 0000000..bb4d9e3 --- /dev/null +++ b/ddl/schemas/auth/tables/09-login_attempts.sql @@ -0,0 +1,67 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/09-login_attempts.sql +-- Description: Login attempt tracking for rate limiting and security monitoring +-- ============================================================================ + +CREATE TABLE auth.login_attempts ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Attempt Information + email CITEXT, + user_id UUID, + + -- Request Context + ip_address INET NOT NULL, + user_agent TEXT, + + -- Attempt Result + success BOOLEAN NOT NULL, + failure_reason VARCHAR(100), + + -- Additional Details + attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Metadata + metadata JSONB DEFAULT '{}'::jsonb, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Check Constraints + CONSTRAINT login_attempt_has_identifier CHECK ( + email IS NOT NULL OR user_id IS NOT NULL + ), + CONSTRAINT failure_reason_consistency CHECK ( + (success = false AND failure_reason IS NOT NULL) OR + (success = true AND failure_reason IS NULL) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_login_attempts_email ON auth.login_attempts(email, created_at DESC); +CREATE INDEX idx_login_attempts_user_id ON auth.login_attempts(user_id, created_at DESC); +CREATE INDEX idx_login_attempts_ip ON auth.login_attempts(ip_address, created_at DESC); +CREATE INDEX idx_login_attempts_created ON auth.login_attempts(created_at DESC); +CREATE INDEX idx_login_attempts_failures ON auth.login_attempts(email, ip_address, created_at DESC) + WHERE success = false; +CREATE INDEX idx_login_attempts_success ON auth.login_attempts(email, created_at DESC) + WHERE success = true; +CREATE INDEX idx_login_attempts_metadata ON auth.login_attempts USING gin(metadata); + +-- Table Comments +COMMENT ON TABLE auth.login_attempts IS 'Login attempt tracking for rate limiting, brute force detection, and security monitoring'; + +-- Column Comments +COMMENT ON COLUMN auth.login_attempts.id IS 'Unique identifier for the login attempt'; +COMMENT ON COLUMN auth.login_attempts.email IS 'Email address used in login attempt'; +COMMENT ON COLUMN auth.login_attempts.user_id IS 'User ID if resolved from email'; +COMMENT ON COLUMN auth.login_attempts.ip_address IS 'IP address of the login attempt'; +COMMENT ON COLUMN auth.login_attempts.user_agent IS 'User agent string from the request'; +COMMENT ON COLUMN auth.login_attempts.success IS 'Whether the login attempt was successful'; +COMMENT ON COLUMN auth.login_attempts.failure_reason IS 'Reason for login failure (invalid_password, account_locked, etc.)'; +COMMENT ON COLUMN auth.login_attempts.attempted_at IS 'Timestamp when login was attempted'; +COMMENT ON COLUMN auth.login_attempts.metadata IS 'Additional attempt metadata as JSON'; +COMMENT ON COLUMN auth.login_attempts.created_at IS 'Timestamp when record was created'; diff --git a/ddl/schemas/auth/tables/10-rate_limiting_config.sql b/ddl/schemas/auth/tables/10-rate_limiting_config.sql new file mode 100644 index 0000000..b70162c --- /dev/null +++ b/ddl/schemas/auth/tables/10-rate_limiting_config.sql @@ -0,0 +1,82 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/10-rate_limiting_config.sql +-- Description: Rate limiting configuration for API endpoints and auth operations +-- ============================================================================ + +CREATE TABLE auth.rate_limiting_config ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Endpoint Configuration + endpoint VARCHAR(200) NOT NULL UNIQUE, + description TEXT, + + -- Rate Limiting Parameters + max_requests INTEGER NOT NULL DEFAULT 100, + window_seconds INTEGER NOT NULL DEFAULT 60, + block_duration_seconds INTEGER DEFAULT 300, + + -- Scope Configuration + scope VARCHAR(50) NOT NULL DEFAULT 'ip', + + -- Status + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Metadata + metadata JSONB DEFAULT '{}'::jsonb, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by_id UUID, + updated_by_id UUID, + + -- Check Constraints + CONSTRAINT valid_rate_limits CHECK ( + max_requests > 0 AND + window_seconds > 0 AND + (block_duration_seconds IS NULL OR block_duration_seconds > 0) + ), + CONSTRAINT valid_scope CHECK ( + scope IN ('ip', 'user', 'email', 'global') + ) +); + +-- Indexes for Performance +CREATE INDEX idx_rate_limiting_endpoint ON auth.rate_limiting_config(endpoint); +CREATE INDEX idx_rate_limiting_active ON auth.rate_limiting_config(is_active) WHERE is_active = true; +CREATE INDEX idx_rate_limiting_scope ON auth.rate_limiting_config(scope); +CREATE INDEX idx_rate_limiting_metadata ON auth.rate_limiting_config USING gin(metadata); + +-- Insert Default Rate Limiting Rules +INSERT INTO auth.rate_limiting_config (endpoint, description, max_requests, window_seconds, block_duration_seconds, scope) VALUES + ('/auth/login', 'Login endpoint rate limit', 5, 300, 900, 'ip'), + ('/auth/register', 'Registration endpoint rate limit', 3, 3600, 1800, 'ip'), + ('/auth/password-reset/request', 'Password reset request limit', 3, 3600, 1800, 'email'), + ('/auth/password-reset/verify', 'Password reset verification limit', 5, 300, 900, 'ip'), + ('/auth/verify-email', 'Email verification limit', 10, 3600, 1800, 'user'), + ('/auth/verify-phone', 'Phone verification limit', 5, 3600, 1800, 'user'), + ('/auth/refresh-token', 'Token refresh limit', 20, 300, 600, 'user'), + ('/auth/logout', 'Logout endpoint limit', 10, 60, NULL, 'user'), + ('/auth/mfa/enable', 'MFA enable limit', 5, 3600, NULL, 'user'), + ('/auth/mfa/verify', 'MFA verification limit', 5, 300, 900, 'user'); + +-- Table Comments +COMMENT ON TABLE auth.rate_limiting_config IS 'Rate limiting configuration for API endpoints to prevent abuse and brute force attacks'; + +-- Column Comments +COMMENT ON COLUMN auth.rate_limiting_config.id IS 'Unique identifier for the configuration'; +COMMENT ON COLUMN auth.rate_limiting_config.endpoint IS 'API endpoint path to rate limit'; +COMMENT ON COLUMN auth.rate_limiting_config.description IS 'Description of the rate limit purpose'; +COMMENT ON COLUMN auth.rate_limiting_config.max_requests IS 'Maximum requests allowed within the time window'; +COMMENT ON COLUMN auth.rate_limiting_config.window_seconds IS 'Time window in seconds for rate limiting'; +COMMENT ON COLUMN auth.rate_limiting_config.block_duration_seconds IS 'Duration to block after exceeding limit (null for no block)'; +COMMENT ON COLUMN auth.rate_limiting_config.scope IS 'Scope of rate limit (ip, user, email, global)'; +COMMENT ON COLUMN auth.rate_limiting_config.is_active IS 'Whether this rate limit is currently active'; +COMMENT ON COLUMN auth.rate_limiting_config.metadata IS 'Additional configuration metadata as JSON'; +COMMENT ON COLUMN auth.rate_limiting_config.created_at IS 'Timestamp when configuration was created'; +COMMENT ON COLUMN auth.rate_limiting_config.updated_at IS 'Timestamp when configuration was last updated'; +COMMENT ON COLUMN auth.rate_limiting_config.created_by_id IS 'ID of user who created this configuration'; +COMMENT ON COLUMN auth.rate_limiting_config.updated_by_id IS 'ID of user who last updated this configuration'; diff --git a/ddl/schemas/auth/tables/99-deferred-constraints.sql b/ddl/schemas/auth/tables/99-deferred-constraints.sql new file mode 100644 index 0000000..b8c0e81 --- /dev/null +++ b/ddl/schemas/auth/tables/99-deferred-constraints.sql @@ -0,0 +1,34 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/99-deferred-constraints.sql +-- Description: Notes on deferred constraints +-- ============================================================================ + +-- NOTE: The password_or_oauth constraint has been removed because PostgreSQL +-- does not support subqueries in CHECK constraints. +-- +-- Original constraint intent: +-- Ensure user has either a password OR an OAuth account for authentication +-- +-- This validation should be implemented at the application level: +-- - Backend: Validate in auth.service.ts during user creation/update +-- - Database: Consider using a TRIGGER if strict enforcement is required +-- +-- Alternative: Create a trigger function +-- CREATE OR REPLACE FUNCTION auth.check_password_or_oauth() +-- RETURNS TRIGGER AS $$ +-- BEGIN +-- IF NEW.password_hash IS NULL THEN +-- IF NOT EXISTS (SELECT 1 FROM auth.oauth_accounts WHERE user_id = NEW.id) THEN +-- RAISE EXCEPTION 'User must have either password or OAuth account'; +-- END IF; +-- END IF; +-- RETURN NEW; +-- END; +-- $$ LANGUAGE plpgsql; +-- +-- CREATE TRIGGER trg_check_password_or_oauth +-- AFTER INSERT OR UPDATE ON auth.users +-- FOR EACH ROW +-- EXECUTE FUNCTION auth.check_password_or_oauth(); diff --git a/ddl/schemas/education/00-enums.sql b/ddl/schemas/education/00-enums.sql new file mode 100644 index 0000000..e235aa8 --- /dev/null +++ b/ddl/schemas/education/00-enums.sql @@ -0,0 +1,64 @@ +-- ===================================================== +-- ENUMS - Schema Education +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- PostgreSQL: 15+ +-- ===================================================== + +-- Nivel de dificultad +CREATE TYPE education.difficulty_level AS ENUM ( + 'beginner', + 'intermediate', + 'advanced', + 'expert' +); + +-- Estado de curso +CREATE TYPE education.course_status AS ENUM ( + 'draft', + 'published', + 'archived' +); + +-- Estado de enrollment +CREATE TYPE education.enrollment_status AS ENUM ( + 'active', + 'completed', + 'expired', + 'cancelled' +); + +-- Tipo de contenido de lección +CREATE TYPE education.lesson_content_type AS ENUM ( + 'video', + 'article', + 'interactive', + 'quiz' +); + +-- Tipo de pregunta de quiz +CREATE TYPE education.question_type AS ENUM ( + 'multiple_choice', + 'true_false', + 'multiple_select', + 'fill_blank', + 'code_challenge' +); + +-- Tipo de logro/badge +CREATE TYPE education.achievement_type AS ENUM ( + 'course_completion', + 'quiz_perfect_score', + 'streak_milestone', + 'level_up', + 'special_event' +); + +COMMENT ON TYPE education.difficulty_level IS 'Nivel de dificultad de cursos'; +COMMENT ON TYPE education.course_status IS 'Estado del curso (draft, published, archived)'; +COMMENT ON TYPE education.enrollment_status IS 'Estado de la inscripción del usuario'; +COMMENT ON TYPE education.lesson_content_type IS 'Tipo de contenido de la lección'; +COMMENT ON TYPE education.question_type IS 'Tipo de pregunta en quizzes'; +COMMENT ON TYPE education.achievement_type IS 'Tipo de logro/badge'; diff --git a/ddl/schemas/education/README.md b/ddl/schemas/education/README.md new file mode 100644 index 0000000..7bb6ccd --- /dev/null +++ b/ddl/schemas/education/README.md @@ -0,0 +1,353 @@ +# Schema: education + +**Proyecto:** Trading Platform (Trading Platform) +**Módulo:** OQI-002 - Education +**Especificación:** ET-EDU-001-database.md +**PostgreSQL:** 15+ + +--- + +## Descripción + +Schema completo para el módulo educativo de Trading Platform, implementando: +- Gestión de cursos, módulos y lecciones +- Sistema de enrollments y progreso de estudiantes +- Quizzes y evaluaciones +- Certificados de finalización +- Sistema de gamificación (XP, niveles, streaks, achievements) +- Reviews de cursos +- Activity logging + +--- + +## Estructura de Archivos + +``` +education/ +├── 00-enums.sql # Tipos ENUM +├── tables/ +│ ├── 01-categories.sql # Categorías de cursos +│ ├── 02-courses.sql # Cursos +│ ├── 03-modules.sql # Módulos del curso +│ ├── 04-lessons.sql # Lecciones +│ ├── 05-enrollments.sql # Inscripciones de usuarios +│ ├── 06-progress.sql # Progreso en lecciones +│ ├── 07-quizzes.sql # Quizzes/evaluaciones +│ ├── 08-quiz_questions.sql # Preguntas de quiz +│ ├── 09-quiz_attempts.sql # Intentos de quiz +│ ├── 10-certificates.sql # Certificados +│ ├── 11-user_achievements.sql # Logros/badges +│ ├── 12-user_gamification_profile.sql # Perfil de gamificación +│ ├── 13-user_activity_log.sql # Log de actividades +│ └── 14-course_reviews.sql # Reviews de cursos +└── functions/ + ├── 01-update_updated_at.sql # Trigger updated_at + ├── 02-update_enrollment_progress.sql # Actualizar progreso + ├── 03-auto_complete_enrollment.sql # Auto-completar enrollment + ├── 04-generate_certificate.sql # Generar certificados + ├── 05-update_course_stats.sql # Actualizar estadísticas + ├── 06-update_enrollment_count.sql # Contador de enrollments + ├── 07-update_gamification_profile.sql # Sistema de gamificación + └── 08-views.sql # Vistas útiles +``` + +--- + +## Orden de Ejecución + +Para crear el schema completo, ejecutar en este orden: + +```bash +# 1. ENUMs +psql -f 00-enums.sql + +# 2. Tablas (en orden de dependencias) +psql -f tables/01-categories.sql +psql -f tables/02-courses.sql +psql -f tables/03-modules.sql +psql -f tables/04-lessons.sql +psql -f tables/05-enrollments.sql +psql -f tables/06-progress.sql +psql -f tables/07-quizzes.sql +psql -f tables/08-quiz_questions.sql +psql -f tables/09-quiz_attempts.sql +psql -f tables/10-certificates.sql +psql -f tables/11-user_achievements.sql +psql -f tables/12-user_gamification_profile.sql +psql -f tables/13-user_activity_log.sql +psql -f tables/14-course_reviews.sql + +# 3. Funciones y Triggers +psql -f functions/01-update_updated_at.sql +psql -f functions/02-update_enrollment_progress.sql +psql -f functions/03-auto_complete_enrollment.sql +psql -f functions/04-generate_certificate.sql +psql -f functions/05-update_course_stats.sql +psql -f functions/06-update_enrollment_count.sql +psql -f functions/07-update_gamification_profile.sql +psql -f functions/08-views.sql +``` + +--- + +## Tablas + +### Principales + +| Tabla | Descripción | +|-------|-------------| +| `categories` | Categorías de cursos con soporte para jerarquía | +| `courses` | Cursos del módulo educativo | +| `modules` | Módulos que agrupan lecciones | +| `lessons` | Lecciones individuales (video, artículo, interactivo) | +| `enrollments` | Inscripciones de usuarios a cursos | +| `progress` | Progreso del usuario en cada lección | + +### Evaluación + +| Tabla | Descripción | +|-------|-------------| +| `quizzes` | Quizzes/evaluaciones | +| `quiz_questions` | Preguntas de los quizzes | +| `quiz_attempts` | Intentos de usuarios en quizzes | + +### Logros + +| Tabla | Descripción | +|-------|-------------| +| `certificates` | Certificados de finalización | +| `user_achievements` | Logros/badges obtenidos | + +### Gamificación + +| Tabla | Descripción | +|-------|-------------| +| `user_gamification_profile` | XP, niveles, streaks, estadísticas | +| `user_activity_log` | Log de todas las actividades | +| `course_reviews` | Reviews y calificaciones de cursos | + +--- + +## ENUMs + +- `difficulty_level`: beginner, intermediate, advanced, expert +- `course_status`: draft, published, archived +- `enrollment_status`: active, completed, expired, cancelled +- `lesson_content_type`: video, article, interactive, quiz +- `question_type`: multiple_choice, true_false, multiple_select, fill_blank, code_challenge +- `achievement_type`: course_completion, quiz_perfect_score, streak_milestone, level_up, special_event + +--- + +## Funciones Principales + +### Gamificación + +```sql +-- Actualizar XP del usuario +SELECT education.update_user_xp( + 'user-uuid', -- user_id + 100 -- xp_to_add +); + +-- Actualizar streak del usuario +SELECT education.update_user_streak('user-uuid'); +``` + +### Triggers Automáticos + +- `updated_at`: Se actualiza automáticamente en todas las tablas +- `update_enrollment_progress()`: Calcula progreso al completar lecciones +- `auto_complete_enrollment()`: Completa enrollment al alcanzar 100% +- `generate_certificate_number()`: Genera número único de certificado +- `update_course_rating_stats()`: Actualiza rating promedio del curso +- `update_enrollment_count()`: Actualiza contador de enrollments +- `update_streak_on_activity()`: Actualiza streak en cada actividad + +--- + +## Vistas + +| Vista | Descripción | +|-------|-------------| +| `v_courses_with_stats` | Cursos con estadísticas agregadas | +| `v_user_course_progress` | Progreso del usuario por curso | +| `v_leaderboard_weekly` | Top 100 usuarios por XP semanal | +| `v_leaderboard_monthly` | Top 100 usuarios por XP mensual | +| `v_leaderboard_alltime` | Top 100 usuarios por XP total | +| `v_user_statistics` | Estadísticas completas del usuario | +| `v_popular_courses` | Top 50 cursos más populares | + +--- + +## Dependencias + +### Schemas externos +- `auth.users` - Tabla de usuarios (requerida) + +### Extensions +- `gen_random_uuid()` - Built-in en PostgreSQL 13+ + +--- + +## Políticas de Seguridad (RLS) + +Para habilitar Row Level Security (implementar según necesidad): + +```sql +-- Habilitar RLS +ALTER TABLE education.enrollments ENABLE ROW LEVEL SECURITY; +ALTER TABLE education.progress ENABLE ROW LEVEL SECURITY; +ALTER TABLE education.quiz_attempts ENABLE ROW LEVEL SECURITY; +ALTER TABLE education.certificates ENABLE ROW LEVEL SECURITY; + +-- Política: usuarios solo ven sus propios datos +CREATE POLICY user_own_data ON education.enrollments + FOR ALL + USING (user_id = current_setting('app.user_id')::UUID); +``` + +--- + +## Ejemplos de Uso + +### Enrollar usuario a un curso + +```sql +INSERT INTO education.enrollments (user_id, course_id) +VALUES ('user-uuid', 'course-uuid') +RETURNING *; +``` + +### Registrar progreso en lección + +```sql +INSERT INTO education.progress ( + user_id, + lesson_id, + enrollment_id, + is_completed, + watch_percentage +) VALUES ( + 'user-uuid', + 'lesson-uuid', + 'enrollment-uuid', + true, + 100.00 +); +-- Esto automáticamente actualizará el enrollment progress +``` + +### Completar quiz + +```sql +INSERT INTO education.quiz_attempts ( + user_id, + quiz_id, + enrollment_id, + is_completed, + is_passed, + user_answers, + score_percentage, + xp_earned +) VALUES ( + 'user-uuid', + 'quiz-uuid', + 'enrollment-uuid', + true, + true, + '[{"questionId": "q1", "answer": "A", "isCorrect": true}]'::jsonb, + 85.00, + 50 +); +``` + +### Emitir certificado + +```sql +INSERT INTO education.certificates ( + user_id, + course_id, + enrollment_id, + user_name, + course_title, + completion_date, + final_score +) VALUES ( + 'user-uuid', + 'course-uuid', + 'enrollment-uuid', + 'John Doe', + 'Introducción al Trading', + CURRENT_DATE, + 92.50 +); +-- El número de certificado y código de verificación se generan automáticamente +``` + +### Agregar review a curso + +```sql +INSERT INTO education.course_reviews ( + user_id, + course_id, + enrollment_id, + rating, + title, + content +) VALUES ( + 'user-uuid', + 'course-uuid', + 'enrollment-uuid', + 5, + 'Excelente curso', + 'Muy bien explicado y con ejemplos prácticos' +); +``` + +--- + +## Notas Importantes + +1. **Referencias**: Todas las FKs a usuarios usan `auth.users(id)` +2. **Cascadas**: Las eliminaciones en CASCADE están definidas donde corresponde +3. **Índices**: Creados para optimizar queries frecuentes +4. **Constraints**: Validaciones de lógica de negocio implementadas +5. **JSONB**: Usado para metadata flexible (attachments, user_answers, etc.) +6. **Denormalización**: Algunas estadísticas están denormalizadas para performance + +--- + +## Mantenimiento + +### Resetear XP semanal/mensual + +```sql +-- Resetear XP semanal (ejecutar cada lunes) +UPDATE education.user_gamification_profile SET weekly_xp = 0; + +-- Resetear XP mensual (ejecutar el 1ro de cada mes) +UPDATE education.user_gamification_profile SET monthly_xp = 0; +``` + +### Recalcular estadísticas de curso + +```sql +-- Recalcular total de módulos y lecciones +UPDATE education.courses c +SET + total_modules = (SELECT COUNT(*) FROM education.modules WHERE course_id = c.id), + total_lessons = ( + SELECT COUNT(*) + FROM education.lessons l + JOIN education.modules m ON l.module_id = m.id + WHERE m.course_id = c.id + ); +``` + +--- + +## Versión + +**Versión:** 1.0.0 +**Última actualización:** 2025-12-06 diff --git a/ddl/schemas/education/TECHNICAL.md b/ddl/schemas/education/TECHNICAL.md new file mode 100644 index 0000000..8154a71 --- /dev/null +++ b/ddl/schemas/education/TECHNICAL.md @@ -0,0 +1,458 @@ +# Documentación Técnica - Schema Education + +**Proyecto:** Trading Platform (Trading Platform) +**Schema:** education +**PostgreSQL:** 15+ +**Versión:** 1.0.0 + +--- + +## Estadísticas del Schema + +- **ENUMs:** 6 tipos +- **Tablas:** 14 tablas +- **Funciones:** 8 funciones +- **Triggers:** 15+ triggers +- **Vistas:** 7 vistas +- **Índices:** 60+ índices +- **Total líneas SQL:** ~1,350 líneas + +--- + +## Índices por Tabla + +### categories +- `idx_categories_parent` - parent_id +- `idx_categories_slug` - slug +- `idx_categories_active` - is_active (WHERE is_active = true) + +### courses +- `idx_courses_category` - category_id +- `idx_courses_slug` - slug +- `idx_courses_status` - status +- `idx_courses_difficulty` - difficulty_level +- `idx_courses_instructor` - instructor_id +- `idx_courses_published` - published_at (WHERE status = 'published') + +### modules +- `idx_modules_course` - course_id +- `idx_modules_order` - course_id, display_order + +### lessons +- `idx_lessons_module` - module_id +- `idx_lessons_order` - module_id, display_order +- `idx_lessons_type` - content_type +- `idx_lessons_preview` - is_preview (WHERE is_preview = true) + +### enrollments +- `idx_enrollments_user` - user_id +- `idx_enrollments_course` - course_id +- `idx_enrollments_status` - status +- `idx_enrollments_user_active` - user_id, status (WHERE status = 'active') + +### progress +- `idx_progress_user` - user_id +- `idx_progress_lesson` - lesson_id +- `idx_progress_enrollment` - enrollment_id +- `idx_progress_completed` - is_completed (WHERE is_completed = true) +- `idx_progress_user_enrollment` - user_id, enrollment_id + +### quizzes +- `idx_quizzes_module` - module_id +- `idx_quizzes_lesson` - lesson_id +- `idx_quizzes_active` - is_active (WHERE is_active = true) + +### quiz_questions +- `idx_quiz_questions_quiz` - quiz_id +- `idx_quiz_questions_order` - quiz_id, display_order + +### quiz_attempts +- `idx_quiz_attempts_user` - user_id +- `idx_quiz_attempts_quiz` - quiz_id +- `idx_quiz_attempts_enrollment` - enrollment_id +- `idx_quiz_attempts_user_quiz` - user_id, quiz_id +- `idx_quiz_attempts_completed` - is_completed, completed_at + +### certificates +- `idx_certificates_user` - user_id +- `idx_certificates_course` - course_id +- `idx_certificates_number` - certificate_number +- `idx_certificates_verification` - verification_code + +### user_achievements +- `idx_user_achievements_user` - user_id +- `idx_user_achievements_type` - achievement_type +- `idx_user_achievements_earned` - earned_at DESC +- `idx_user_achievements_course` - course_id + +### user_gamification_profile +- `idx_gamification_user` - user_id +- `idx_gamification_level` - current_level DESC +- `idx_gamification_xp` - total_xp DESC +- `idx_gamification_weekly` - weekly_xp DESC +- `idx_gamification_monthly` - monthly_xp DESC + +### user_activity_log +- `idx_activity_user` - user_id +- `idx_activity_type` - activity_type +- `idx_activity_created` - created_at DESC +- `idx_activity_user_date` - user_id, created_at DESC +- `idx_activity_course` - course_id (WHERE course_id IS NOT NULL) + +### course_reviews +- `idx_reviews_course` - course_id +- `idx_reviews_user` - user_id +- `idx_reviews_rating` - rating +- `idx_reviews_approved` - is_approved (WHERE is_approved = true) +- `idx_reviews_featured` - is_featured (WHERE is_featured = true) +- `idx_reviews_helpful` - helpful_votes DESC + +--- + +## Constraints + +### CHECK Constraints + +**categories:** +- `valid_color_format` - Color debe ser formato #RRGGBB + +**courses:** +- `valid_rating` - avg_rating >= 0 AND <= 5 +- `valid_price` - price_usd >= 0 + +**lessons:** +- `video_fields_required` - Si content_type='video', video_url y video_duration_seconds requeridos + +**enrollments:** +- `valid_progress` - progress_percentage >= 0 AND <= 100 +- `valid_completion` - Si status='completed', completed_at y progress=100 requeridos + +**progress:** +- `valid_watch_percentage` - watch_percentage >= 0 AND <= 100 +- `completion_requires_date` - Si is_completed=true, completed_at requerido + +**quizzes:** +- `valid_passing_score` - passing_score_percentage > 0 AND <= 100 +- `quiz_association` - Debe tener module_id O lesson_id (no ambos) + +**quiz_questions:** +- `valid_options` - Si question_type requiere options, options no puede ser NULL + +**quiz_attempts:** +- `valid_score_percentage` - score_percentage >= 0 AND <= 100 + +**user_gamification_profile:** +- `valid_level` - current_level >= 1 +- `valid_xp` - total_xp >= 0 +- `valid_streak` - current_streak_days >= 0 AND longest_streak_days >= 0 +- `valid_avg_score` - average_quiz_score >= 0 AND <= 100 + +**course_reviews:** +- `rating` - rating >= 1 AND <= 5 + +### UNIQUE Constraints + +- `categories.slug` - UNIQUE +- `courses.slug` - UNIQUE +- `modules.unique_course_order` - UNIQUE(course_id, display_order) +- `lessons.unique_module_order` - UNIQUE(module_id, display_order) +- `enrollments.unique_user_course` - UNIQUE(user_id, course_id) +- `progress.unique_user_lesson` - UNIQUE(user_id, lesson_id) +- `certificates.certificate_number` - UNIQUE +- `certificates.verification_code` - UNIQUE +- `certificates.unique_user_course_cert` - UNIQUE(user_id, course_id) +- `user_gamification_profile.unique_user_gamification` - UNIQUE(user_id) +- `course_reviews.unique_user_course_review` - UNIQUE(user_id, course_id) + +--- + +## Foreign Keys + +### Relaciones con auth.users +- `courses.instructor_id` → `auth.users(id)` ON DELETE RESTRICT +- `enrollments.user_id` → `auth.users(id)` ON DELETE CASCADE +- `progress.user_id` → `auth.users(id)` ON DELETE CASCADE +- `quiz_attempts.user_id` → `auth.users(id)` ON DELETE CASCADE +- `certificates.user_id` → `auth.users(id)` ON DELETE CASCADE +- `user_achievements.user_id` → `auth.users(id)` ON DELETE CASCADE +- `user_gamification_profile.user_id` → `auth.users(id)` ON DELETE CASCADE +- `user_activity_log.user_id` → `auth.users(id)` ON DELETE CASCADE +- `course_reviews.user_id` → `auth.users(id)` ON DELETE CASCADE +- `course_reviews.approved_by` → `auth.users(id)` + +### Relaciones internas +- `categories.parent_id` → `categories(id)` ON DELETE SET NULL +- `courses.category_id` → `categories(id)` ON DELETE RESTRICT +- `modules.course_id` → `courses(id)` ON DELETE CASCADE +- `modules.unlock_after_module_id` → `modules(id)` ON DELETE SET NULL +- `lessons.module_id` → `modules(id)` ON DELETE CASCADE +- `enrollments.course_id` → `courses(id)` ON DELETE RESTRICT +- `progress.lesson_id` → `lessons(id)` ON DELETE CASCADE +- `progress.enrollment_id` → `enrollments(id)` ON DELETE CASCADE +- `quizzes.module_id` → `modules(id)` ON DELETE CASCADE +- `quizzes.lesson_id` → `lessons(id)` ON DELETE CASCADE +- `quiz_questions.quiz_id` → `quizzes(id)` ON DELETE CASCADE +- `quiz_attempts.quiz_id` → `quizzes(id)` ON DELETE RESTRICT +- `quiz_attempts.enrollment_id` → `enrollments(id)` ON DELETE SET NULL +- `certificates.course_id` → `courses(id)` ON DELETE RESTRICT +- `certificates.enrollment_id` → `enrollments(id)` ON DELETE RESTRICT +- `user_achievements.course_id` → `courses(id)` ON DELETE SET NULL +- `user_achievements.quiz_id` → `quizzes(id)` ON DELETE SET NULL +- `user_activity_log.course_id` → `courses(id)` ON DELETE SET NULL +- `user_activity_log.lesson_id` → `lessons(id)` ON DELETE SET NULL +- `user_activity_log.quiz_id` → `quizzes(id)` ON DELETE SET NULL +- `course_reviews.course_id` → `courses(id)` ON DELETE CASCADE +- `course_reviews.enrollment_id` → `enrollments(id)` ON DELETE CASCADE + +--- + +## Triggers + +### Triggers de updated_at +Aplica a: categories, courses, modules, lessons, enrollments, progress, quizzes, quiz_questions, user_gamification_profile, course_reviews + +**Función:** `education.update_updated_at_column()` +**Trigger:** `update_{table}_updated_at` +**Evento:** BEFORE UPDATE +**Acción:** Actualiza `updated_at = NOW()` + +### Triggers de lógica de negocio + +**update_enrollment_on_progress** +- Tabla: progress +- Función: `education.update_enrollment_progress()` +- Evento: AFTER INSERT OR UPDATE +- Condición: WHEN (NEW.is_completed = true) +- Acción: Recalcula progreso del enrollment + +**auto_complete_enrollment_trigger** +- Tabla: enrollments +- Función: `education.auto_complete_enrollment()` +- Evento: BEFORE UPDATE +- Acción: Completa enrollment si progress >= 100% + +**generate_certificate_number_trigger** +- Tabla: certificates +- Función: `education.generate_certificate_number()` +- Evento: BEFORE INSERT +- Acción: Genera certificate_number y verification_code + +**update_course_rating_on_review_insert** +- Tabla: course_reviews +- Función: `education.update_course_rating_stats()` +- Evento: AFTER INSERT +- Acción: Actualiza avg_rating del curso + +**update_course_rating_on_review_update** +- Tabla: course_reviews +- Función: `education.update_course_rating_stats()` +- Evento: AFTER UPDATE +- Condición: rating o is_approved cambió +- Acción: Actualiza avg_rating del curso + +**update_course_rating_on_review_delete** +- Tabla: course_reviews +- Función: `education.update_course_rating_stats()` +- Evento: AFTER DELETE +- Acción: Actualiza avg_rating del curso + +**update_enrollment_count_on_insert** +- Tabla: enrollments +- Función: `education.update_enrollment_count()` +- Evento: AFTER INSERT +- Acción: Incrementa contador en courses + +**update_enrollment_count_on_delete** +- Tabla: enrollments +- Función: `education.update_enrollment_count()` +- Evento: AFTER DELETE +- Acción: Decrementa contador en courses + +**update_streak_on_activity** +- Tabla: user_activity_log +- Función: `education.trigger_update_streak()` +- Evento: AFTER INSERT +- Acción: Actualiza streak del usuario + +--- + +## Funciones Públicas + +### education.update_user_xp(user_id UUID, xp_to_add INTEGER) +Actualiza XP del usuario y recalcula nivel. + +**Parámetros:** +- `user_id`: UUID del usuario +- `xp_to_add`: Cantidad de XP a agregar + +**Lógica:** +- Suma XP al total +- Calcula nuevo nivel basado en fórmula cuadrática +- Actualiza weekly_xp y monthly_xp +- Crea achievement si subió de nivel + +**Ejemplo:** +```sql +SELECT education.update_user_xp( + '00000000-0000-0000-0000-000000000001', + 100 +); +``` + +### education.update_user_streak(user_id UUID) +Actualiza streak del usuario basado en actividad diaria. + +**Parámetros:** +- `user_id`: UUID del usuario + +**Lógica:** +- Verifica última actividad +- Incrementa streak si es día consecutivo +- Resetea streak si se rompió +- Crea achievement en milestones (7, 30, 100 días) + +**Ejemplo:** +```sql +SELECT education.update_user_streak( + '00000000-0000-0000-0000-000000000001' +); +``` + +--- + +## Vistas Materializadas Recomendadas + +Para mejorar performance en queries frecuentes: + +```sql +-- Top cursos por enrollments (actualizar diariamente) +CREATE MATERIALIZED VIEW education.mv_top_courses AS +SELECT * FROM education.v_popular_courses; + +CREATE UNIQUE INDEX ON education.mv_top_courses(id); + +-- Leaderboards (actualizar cada hora) +CREATE MATERIALIZED VIEW education.mv_leaderboard_weekly AS +SELECT * FROM education.v_leaderboard_weekly; + +CREATE UNIQUE INDEX ON education.mv_leaderboard_weekly(user_id); +``` + +--- + +## Optimizaciones Recomendadas + +### 1. Particionamiento de user_activity_log + +Para logs con alto volumen: + +```sql +-- Particionar por mes +CREATE TABLE education.user_activity_log_2025_12 + PARTITION OF education.user_activity_log + FOR VALUES FROM ('2025-12-01') TO ('2026-01-01'); +``` + +### 2. Índices adicionales según uso + +```sql +-- Si hay muchas búsquedas por título de curso +CREATE INDEX idx_courses_title_trgm ON education.courses + USING gin(title gin_trgm_ops); + +-- Requiere extension pg_trgm +CREATE EXTENSION IF NOT EXISTS pg_trgm; +``` + +### 3. Vacuum y Analyze automático + +```sql +-- Configurar autovacuum para tablas con alta escritura +ALTER TABLE education.user_activity_log + SET (autovacuum_vacuum_scale_factor = 0.01); + +ALTER TABLE education.progress + SET (autovacuum_vacuum_scale_factor = 0.02); +``` + +--- + +## Monitoreo + +### Queries útiles para monitoreo + +**Tamaño de tablas:** +```sql +SELECT + schemaname, + tablename, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size +FROM pg_tables +WHERE schemaname = 'education' +ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; +``` + +**Índices no usados:** +```sql +SELECT + schemaname, + tablename, + indexname, + idx_scan +FROM pg_stat_user_indexes +WHERE schemaname = 'education' AND idx_scan = 0; +``` + +**Actividad de enrollments hoy:** +```sql +SELECT COUNT(*) +FROM education.enrollments +WHERE enrolled_at::date = CURRENT_DATE; +``` + +**Cursos más populares (últimos 7 días):** +```sql +SELECT + c.title, + COUNT(e.id) as new_enrollments +FROM education.courses c +LEFT JOIN education.enrollments e ON c.id = e.course_id + AND e.enrolled_at >= NOW() - INTERVAL '7 days' +GROUP BY c.id, c.title +ORDER BY new_enrollments DESC +LIMIT 10; +``` + +--- + +## Backup y Restore + +### Backup solo del schema education + +```bash +pg_dump -h localhost -U postgres -n education trading_platform > education_backup.sql +``` + +### Restore + +```bash +psql -h localhost -U postgres trading_platform < education_backup.sql +``` + +--- + +## Versión y Changelog + +**v1.0.0** (2025-12-06) +- Implementación inicial completa +- 14 tablas +- 8 funciones +- 7 vistas +- Sistema de gamificación completo +- Reviews de cursos +- Activity logging + +--- + +**Documentación generada:** 2025-12-06 +**Última revisión:** 2025-12-06 diff --git a/ddl/schemas/education/functions/01-update_updated_at.sql b/ddl/schemas/education/functions/01-update_updated_at.sql new file mode 100644 index 0000000..896e37d --- /dev/null +++ b/ddl/schemas/education/functions/01-update_updated_at.sql @@ -0,0 +1,69 @@ +-- ===================================================== +-- FUNCTION: education.update_updated_at_column() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Actualiza automáticamente el campo updated_at +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_updated_at_column() IS 'Actualiza automáticamente updated_at en cada UPDATE'; + +-- Aplicar trigger a todas las tablas relevantes +CREATE TRIGGER update_categories_updated_at + BEFORE UPDATE ON education.categories + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_courses_updated_at + BEFORE UPDATE ON education.courses + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_modules_updated_at + BEFORE UPDATE ON education.modules + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_lessons_updated_at + BEFORE UPDATE ON education.lessons + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_enrollments_updated_at + BEFORE UPDATE ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_progress_updated_at + BEFORE UPDATE ON education.progress + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_quizzes_updated_at + BEFORE UPDATE ON education.quizzes + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_quiz_questions_updated_at + BEFORE UPDATE ON education.quiz_questions + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_user_gamification_profile_updated_at + BEFORE UPDATE ON education.user_gamification_profile + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_course_reviews_updated_at + BEFORE UPDATE ON education.course_reviews + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); diff --git a/ddl/schemas/education/functions/02-update_enrollment_progress.sql b/ddl/schemas/education/functions/02-update_enrollment_progress.sql new file mode 100644 index 0000000..cffd831 --- /dev/null +++ b/ddl/schemas/education/functions/02-update_enrollment_progress.sql @@ -0,0 +1,57 @@ +-- ===================================================== +-- FUNCTION: education.update_enrollment_progress() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Actualiza el progreso del enrollment cuando se completa una lección +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.update_enrollment_progress() +RETURNS TRIGGER AS $$ +DECLARE + v_total_lessons INTEGER; + v_completed_lessons INTEGER; + v_progress_percentage DECIMAL(5,2); +BEGIN + -- Obtener total de lecciones obligatorias del curso + SELECT COUNT(*) + INTO v_total_lessons + FROM education.lessons l + JOIN education.modules m ON l.module_id = m.id + JOIN education.courses c ON m.course_id = c.id + WHERE c.id = ( + SELECT course_id FROM education.enrollments WHERE id = NEW.enrollment_id + ) AND l.is_mandatory = true; + + -- Obtener lecciones completadas + SELECT COUNT(*) + INTO v_completed_lessons + FROM education.progress + WHERE enrollment_id = NEW.enrollment_id + AND is_completed = true; + + -- Calcular porcentaje de progreso + v_progress_percentage := (v_completed_lessons::DECIMAL / NULLIF(v_total_lessons, 0)::DECIMAL) * 100; + + -- Actualizar enrollment + UPDATE education.enrollments + SET + progress_percentage = COALESCE(v_progress_percentage, 0), + completed_lessons = v_completed_lessons, + total_lessons = v_total_lessons, + updated_at = NOW() + WHERE id = NEW.enrollment_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_enrollment_progress() IS 'Actualiza progreso del enrollment al completar lecciones'; + +-- Trigger para actualizar el progreso +CREATE TRIGGER update_enrollment_on_progress + AFTER INSERT OR UPDATE ON education.progress + FOR EACH ROW + WHEN (NEW.is_completed = true) + EXECUTE FUNCTION education.update_enrollment_progress(); diff --git a/ddl/schemas/education/functions/03-auto_complete_enrollment.sql b/ddl/schemas/education/functions/03-auto_complete_enrollment.sql new file mode 100644 index 0000000..db0e68c --- /dev/null +++ b/ddl/schemas/education/functions/03-auto_complete_enrollment.sql @@ -0,0 +1,29 @@ +-- ===================================================== +-- FUNCTION: education.auto_complete_enrollment() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Completa automáticamente el enrollment cuando alcanza 100% +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.auto_complete_enrollment() +RETURNS TRIGGER AS $$ +BEGIN + -- Si el progreso llegó al 100% y está activo, completarlo + IF NEW.progress_percentage >= 100 AND NEW.status = 'active' THEN + NEW.status := 'completed'; + NEW.completed_at := NOW(); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.auto_complete_enrollment() IS 'Completa automáticamente el enrollment al alcanzar 100%'; + +-- Trigger para auto-completar enrollment +CREATE TRIGGER auto_complete_enrollment_trigger + BEFORE UPDATE ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.auto_complete_enrollment(); diff --git a/ddl/schemas/education/functions/04-generate_certificate.sql b/ddl/schemas/education/functions/04-generate_certificate.sql new file mode 100644 index 0000000..b1170e4 --- /dev/null +++ b/ddl/schemas/education/functions/04-generate_certificate.sql @@ -0,0 +1,47 @@ +-- ===================================================== +-- FUNCTION: education.generate_certificate_number() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Genera automáticamente el número de certificado y código de verificación +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.generate_certificate_number() +RETURNS TRIGGER AS $$ +DECLARE + v_year INTEGER; + v_sequence INTEGER; +BEGIN + v_year := EXTRACT(YEAR FROM NOW()); + + -- Obtener siguiente número de secuencia para el año + SELECT COALESCE(MAX( + CAST(SUBSTRING(certificate_number FROM 14) AS INTEGER) + ), 0) + 1 + INTO v_sequence + FROM education.certificates + WHERE certificate_number LIKE 'OQI-CERT-' || v_year || '-%'; + + -- Generar número de certificado: OQI-CERT-2025-00001 + NEW.certificate_number := FORMAT('OQI-CERT-%s-%s', + v_year, + LPAD(v_sequence::TEXT, 5, '0') + ); + + -- Generar código de verificación único + NEW.verification_code := UPPER( + SUBSTRING(MD5(RANDOM()::TEXT || NOW()::TEXT) FROM 1 FOR 16) + ); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.generate_certificate_number() IS 'Genera número de certificado y código de verificación automáticamente'; + +-- Trigger para generar número de certificado +CREATE TRIGGER generate_certificate_number_trigger + BEFORE INSERT ON education.certificates + FOR EACH ROW + EXECUTE FUNCTION education.generate_certificate_number(); diff --git a/ddl/schemas/education/functions/05-update_course_stats.sql b/ddl/schemas/education/functions/05-update_course_stats.sql new file mode 100644 index 0000000..c52aef5 --- /dev/null +++ b/ddl/schemas/education/functions/05-update_course_stats.sql @@ -0,0 +1,59 @@ +-- ===================================================== +-- FUNCTION: education.update_course_stats() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Actualiza estadísticas denormalizadas del curso +-- ===================================================== + +-- Función para actualizar estadísticas de reviews +CREATE OR REPLACE FUNCTION education.update_course_rating_stats() +RETURNS TRIGGER AS $$ +DECLARE + v_course_id UUID; + v_avg_rating DECIMAL(3,2); + v_total_reviews INTEGER; +BEGIN + -- Obtener course_id del NEW o OLD record + v_course_id := COALESCE(NEW.course_id, OLD.course_id); + + -- Calcular promedio solo de reviews aprobadas + SELECT + COALESCE(AVG(rating), 0), + COUNT(*) + INTO v_avg_rating, v_total_reviews + FROM education.course_reviews + WHERE course_id = v_course_id + AND is_approved = true; + + -- Actualizar estadísticas en el curso + UPDATE education.courses + SET + avg_rating = v_avg_rating, + total_reviews = v_total_reviews, + updated_at = NOW() + WHERE id = v_course_id; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_course_rating_stats() IS 'Actualiza avg_rating y total_reviews del curso'; + +-- Triggers para actualizar estadísticas +CREATE TRIGGER update_course_rating_on_review_insert + AFTER INSERT ON education.course_reviews + FOR EACH ROW + EXECUTE FUNCTION education.update_course_rating_stats(); + +CREATE TRIGGER update_course_rating_on_review_update + AFTER UPDATE ON education.course_reviews + FOR EACH ROW + WHEN (OLD.rating IS DISTINCT FROM NEW.rating OR OLD.is_approved IS DISTINCT FROM NEW.is_approved) + EXECUTE FUNCTION education.update_course_rating_stats(); + +CREATE TRIGGER update_course_rating_on_review_delete + AFTER DELETE ON education.course_reviews + FOR EACH ROW + EXECUTE FUNCTION education.update_course_rating_stats(); diff --git a/ddl/schemas/education/functions/06-update_enrollment_count.sql b/ddl/schemas/education/functions/06-update_enrollment_count.sql new file mode 100644 index 0000000..b386cb0 --- /dev/null +++ b/ddl/schemas/education/functions/06-update_enrollment_count.sql @@ -0,0 +1,41 @@ +-- ===================================================== +-- FUNCTION: education.update_enrollment_count() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Actualiza el contador de enrollments en courses +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.update_enrollment_count() +RETURNS TRIGGER AS $$ +DECLARE + v_course_id UUID; +BEGIN + v_course_id := COALESCE(NEW.course_id, OLD.course_id); + + -- Actualizar contador de enrollments + UPDATE education.courses + SET + total_enrollments = ( + SELECT COUNT(*) FROM education.enrollments WHERE course_id = v_course_id + ), + updated_at = NOW() + WHERE id = v_course_id; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_enrollment_count() IS 'Actualiza total_enrollments del curso'; + +-- Triggers para actualizar contador +CREATE TRIGGER update_enrollment_count_on_insert + AFTER INSERT ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.update_enrollment_count(); + +CREATE TRIGGER update_enrollment_count_on_delete + AFTER DELETE ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.update_enrollment_count(); diff --git a/ddl/schemas/education/functions/07-update_gamification_profile.sql b/ddl/schemas/education/functions/07-update_gamification_profile.sql new file mode 100644 index 0000000..b99f470 --- /dev/null +++ b/ddl/schemas/education/functions/07-update_gamification_profile.sql @@ -0,0 +1,158 @@ +-- ===================================================== +-- FUNCTION: education.update_gamification_profile() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: Función adicional para gamificación +-- Descripción: Actualiza el perfil de gamificación del usuario +-- ===================================================== + +-- Función para actualizar XP y nivel +CREATE OR REPLACE FUNCTION education.update_user_xp( + p_user_id UUID, + p_xp_to_add INTEGER +) +RETURNS VOID AS $$ +DECLARE + v_profile RECORD; + v_new_total_xp INTEGER; + v_new_level INTEGER; + v_xp_to_next INTEGER; +BEGIN + -- Obtener o crear perfil + INSERT INTO education.user_gamification_profile (user_id) + VALUES (p_user_id) + ON CONFLICT (user_id) DO NOTHING; + + -- Obtener perfil actual + SELECT * INTO v_profile + FROM education.user_gamification_profile + WHERE user_id = p_user_id; + + -- Calcular nuevo XP total + v_new_total_xp := v_profile.total_xp + p_xp_to_add; + + -- Calcular nuevo nivel (cada nivel requiere 100 XP más que el anterior) + -- Nivel 1: 0-99 XP, Nivel 2: 100-299 XP, Nivel 3: 300-599 XP, etc. + v_new_level := FLOOR((-100 + SQRT(10000 + 800 * v_new_total_xp)) / 200) + 1; + + -- XP necesario para siguiente nivel + v_xp_to_next := (v_new_level * 100 + (v_new_level * (v_new_level - 1) * 100)) - v_new_total_xp; + + -- Actualizar perfil + UPDATE education.user_gamification_profile + SET + total_xp = v_new_total_xp, + current_level = v_new_level, + xp_to_next_level = v_xp_to_next, + weekly_xp = weekly_xp + p_xp_to_add, + monthly_xp = monthly_xp + p_xp_to_add, + last_activity_date = CURRENT_DATE, + updated_at = NOW() + WHERE user_id = p_user_id; + + -- Si subió de nivel, crear achievement + IF v_new_level > v_profile.current_level THEN + INSERT INTO education.user_achievements ( + user_id, + achievement_type, + title, + description, + xp_bonus + ) VALUES ( + p_user_id, + 'level_up', + 'Level Up! - Nivel ' || v_new_level, + 'Has alcanzado el nivel ' || v_new_level, + 50 + ); + END IF; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_user_xp(UUID, INTEGER) IS 'Actualiza XP del usuario y recalcula nivel'; + +-- Función para actualizar streak +CREATE OR REPLACE FUNCTION education.update_user_streak(p_user_id UUID) +RETURNS VOID AS $$ +DECLARE + v_last_activity DATE; + v_current_streak INTEGER; + v_longest_streak INTEGER; +BEGIN + -- Obtener o crear perfil + INSERT INTO education.user_gamification_profile (user_id) + VALUES (p_user_id) + ON CONFLICT (user_id) DO NOTHING; + + -- Obtener datos actuales + SELECT last_activity_date, current_streak_days, longest_streak_days + INTO v_last_activity, v_current_streak, v_longest_streak + FROM education.user_gamification_profile + WHERE user_id = p_user_id; + + -- Actualizar streak + IF v_last_activity IS NULL THEN + -- Primera actividad + v_current_streak := 1; + ELSIF v_last_activity = CURRENT_DATE THEN + -- Ya tuvo actividad hoy, no hacer nada + RETURN; + ELSIF v_last_activity = CURRENT_DATE - INTERVAL '1 day' THEN + -- Actividad día consecutivo + v_current_streak := v_current_streak + 1; + ELSE + -- Se rompió el streak + v_current_streak := 1; + END IF; + + -- Actualizar longest streak si corresponde + IF v_current_streak > v_longest_streak THEN + v_longest_streak := v_current_streak; + + -- Crear achievement por streak milestones + IF v_current_streak IN (7, 30, 100) THEN + INSERT INTO education.user_achievements ( + user_id, + achievement_type, + title, + description, + xp_bonus, + metadata + ) VALUES ( + p_user_id, + 'streak_milestone', + 'Streak de ' || v_current_streak || ' días', + 'Has mantenido una racha de ' || v_current_streak || ' días consecutivos', + v_current_streak * 5, + jsonb_build_object('streak_days', v_current_streak) + ); + END IF; + END IF; + + -- Actualizar perfil + UPDATE education.user_gamification_profile + SET + current_streak_days = v_current_streak, + longest_streak_days = v_longest_streak, + last_activity_date = CURRENT_DATE, + updated_at = NOW() + WHERE user_id = p_user_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_user_streak(UUID) IS 'Actualiza streak del usuario basado en actividad diaria'; + +-- Trigger para actualizar streak en actividades +CREATE OR REPLACE FUNCTION education.trigger_update_streak() +RETURNS TRIGGER AS $$ +BEGIN + PERFORM education.update_user_streak(NEW.user_id); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_streak_on_activity + AFTER INSERT ON education.user_activity_log + FOR EACH ROW + EXECUTE FUNCTION education.trigger_update_streak(); diff --git a/ddl/schemas/education/functions/08-views.sql b/ddl/schemas/education/functions/08-views.sql new file mode 100644 index 0000000..e6a99a0 --- /dev/null +++ b/ddl/schemas/education/functions/08-views.sql @@ -0,0 +1,142 @@ +-- ===================================================== +-- VIEWS - Schema Education +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +-- Vista: Cursos con estadísticas completas +CREATE OR REPLACE VIEW education.v_courses_with_stats AS +SELECT + c.*, + cat.name as category_name, + cat.slug as category_slug, + COUNT(DISTINCT m.id) as modules_count, + COUNT(DISTINCT l.id) as lessons_count, + SUM(l.video_duration_seconds) as total_duration_seconds, + COUNT(DISTINCT e.id) as enrollments_count, + COUNT(DISTINCT CASE WHEN e.status = 'completed' THEN e.id END) as completions_count +FROM education.courses c +LEFT JOIN education.categories cat ON c.category_id = cat.id +LEFT JOIN education.modules m ON c.id = m.course_id +LEFT JOIN education.lessons l ON m.id = l.module_id +LEFT JOIN education.enrollments e ON c.id = e.course_id +GROUP BY c.id, cat.name, cat.slug; + +COMMENT ON VIEW education.v_courses_with_stats IS 'Cursos con estadísticas agregadas de módulos, lecciones y enrollments'; + +-- Vista: Progreso del usuario por curso +CREATE OR REPLACE VIEW education.v_user_course_progress AS +SELECT + e.user_id, + e.course_id, + c.title as course_title, + c.slug as course_slug, + c.thumbnail_url, + e.status as enrollment_status, + e.progress_percentage, + e.enrolled_at, + e.completed_at, + e.total_xp_earned, + COUNT(DISTINCT p.id) as lessons_viewed, + COUNT(DISTINCT CASE WHEN p.is_completed THEN p.id END) as lessons_completed +FROM education.enrollments e +JOIN education.courses c ON e.course_id = c.id +LEFT JOIN education.progress p ON e.id = p.enrollment_id +GROUP BY e.id, e.user_id, e.course_id, c.title, c.slug, c.thumbnail_url; + +COMMENT ON VIEW education.v_user_course_progress IS 'Progreso detallado del usuario en cada curso enrollado'; + +-- Vista: Leaderboard de usuarios +CREATE OR REPLACE VIEW education.v_leaderboard_weekly AS +SELECT + ugp.user_id, + ugp.weekly_xp, + ugp.current_level, + ugp.current_streak_days, + RANK() OVER (ORDER BY ugp.weekly_xp DESC) as rank +FROM education.user_gamification_profile ugp +WHERE ugp.weekly_xp > 0 +ORDER BY ugp.weekly_xp DESC +LIMIT 100; + +COMMENT ON VIEW education.v_leaderboard_weekly IS 'Top 100 usuarios por XP semanal'; + +CREATE OR REPLACE VIEW education.v_leaderboard_monthly AS +SELECT + ugp.user_id, + ugp.monthly_xp, + ugp.current_level, + ugp.current_streak_days, + RANK() OVER (ORDER BY ugp.monthly_xp DESC) as rank +FROM education.user_gamification_profile ugp +WHERE ugp.monthly_xp > 0 +ORDER BY ugp.monthly_xp DESC +LIMIT 100; + +COMMENT ON VIEW education.v_leaderboard_monthly IS 'Top 100 usuarios por XP mensual'; + +CREATE OR REPLACE VIEW education.v_leaderboard_alltime AS +SELECT + ugp.user_id, + ugp.total_xp, + ugp.current_level, + ugp.total_courses_completed, + RANK() OVER (ORDER BY ugp.total_xp DESC) as rank +FROM education.user_gamification_profile ugp +WHERE ugp.total_xp > 0 +ORDER BY ugp.total_xp DESC +LIMIT 100; + +COMMENT ON VIEW education.v_leaderboard_alltime IS 'Top 100 usuarios por XP total histórico'; + +-- Vista: Estadísticas del usuario +CREATE OR REPLACE VIEW education.v_user_statistics AS +SELECT + ugp.user_id, + ugp.total_xp, + ugp.current_level, + ugp.xp_to_next_level, + ugp.current_streak_days, + ugp.longest_streak_days, + ugp.total_courses_completed, + ugp.total_lessons_completed, + ugp.total_quizzes_passed, + ugp.total_certificates_earned, + ugp.average_quiz_score, + COUNT(DISTINCT e.id) as total_enrollments, + COUNT(DISTINCT CASE WHEN e.status = 'active' THEN e.id END) as active_enrollments, + COUNT(DISTINCT ua.id) as total_achievements +FROM education.user_gamification_profile ugp +LEFT JOIN education.enrollments e ON ugp.user_id = e.user_id +LEFT JOIN education.user_achievements ua ON ugp.user_id = ua.user_id +GROUP BY ugp.user_id, ugp.total_xp, ugp.current_level, ugp.xp_to_next_level, + ugp.current_streak_days, ugp.longest_streak_days, ugp.total_courses_completed, + ugp.total_lessons_completed, ugp.total_quizzes_passed, ugp.total_certificates_earned, + ugp.average_quiz_score; + +COMMENT ON VIEW education.v_user_statistics IS 'Estadísticas completas del usuario (gamificación + progreso)'; + +-- Vista: Cursos populares +CREATE OR REPLACE VIEW education.v_popular_courses AS +SELECT + c.id, + c.title, + c.slug, + c.thumbnail_url, + c.difficulty_level, + c.avg_rating, + c.total_reviews, + c.total_enrollments, + COUNT(DISTINCT e.id) as recent_enrollments_30d, + COUNT(DISTINCT CASE WHEN e.status = 'completed' THEN e.id END) as completions +FROM education.courses c +LEFT JOIN education.enrollments e ON c.id = e.course_id + AND e.enrolled_at >= NOW() - INTERVAL '30 days' +WHERE c.status = 'published' +GROUP BY c.id +ORDER BY recent_enrollments_30d DESC, c.avg_rating DESC +LIMIT 50; + +COMMENT ON VIEW education.v_popular_courses IS 'Top 50 cursos más populares (enrollments últimos 30 días)'; diff --git a/ddl/schemas/education/install.sh b/ddl/schemas/education/install.sh new file mode 100755 index 0000000..521d882 --- /dev/null +++ b/ddl/schemas/education/install.sh @@ -0,0 +1,132 @@ +#!/bin/bash + +# ===================================================== +# INSTALL SCRIPT - Schema Education +# ===================================================== +# Proyecto: OrbiQuant IA (Trading Platform) +# Módulo: OQI-002 - Education +# Especificación: ET-EDU-001-database.md +# ===================================================== + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" +DB_NAME="${DB_NAME:-trading_platform}" +DB_USER="${DB_USER:-postgres}" +SCHEMA_NAME="education" + +echo -e "${GREEN}=================================================${NC}" +echo -e "${GREEN} OrbiQuant IA - Education Schema Installation${NC}" +echo -e "${GREEN}=================================================${NC}" +echo "" + +# Check if psql is available +if ! command -v psql &> /dev/null; then + echo -e "${RED}Error: psql command not found${NC}" + echo "Please install PostgreSQL client" + exit 1 +fi + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "Configuration:" +echo " Database: $DB_NAME" +echo " Host: $DB_HOST:$DB_PORT" +echo " User: $DB_USER" +echo " Schema: $SCHEMA_NAME" +echo "" + +# Function to execute SQL file +execute_sql() { + local file=$1 + local description=$2 + + echo -e "${YELLOW}▶${NC} $description" + + if [ ! -f "$file" ]; then + echo -e "${RED} ✗ File not found: $file${NC}" + return 1 + fi + + if PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$file" > /dev/null 2>&1; then + echo -e "${GREEN} ✓ Success${NC}" + return 0 + else + echo -e "${RED} ✗ Failed${NC}" + return 1 + fi +} + +# Create schema if not exists +echo -e "${YELLOW}▶${NC} Creating schema: $SCHEMA_NAME" +PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "CREATE SCHEMA IF NOT EXISTS $SCHEMA_NAME;" > /dev/null 2>&1 +echo -e "${GREEN} ✓ Schema created/verified${NC}" +echo "" + +# 1. Install ENUMs +echo -e "${GREEN}[1/3] Installing ENUMs...${NC}" +execute_sql "$SCRIPT_DIR/00-enums.sql" "Creating ENUM types" +echo "" + +# 2. Install Tables +echo -e "${GREEN}[2/3] Installing Tables...${NC}" +execute_sql "$SCRIPT_DIR/tables/01-categories.sql" "Creating table: categories" +execute_sql "$SCRIPT_DIR/tables/02-courses.sql" "Creating table: courses" +execute_sql "$SCRIPT_DIR/tables/03-modules.sql" "Creating table: modules" +execute_sql "$SCRIPT_DIR/tables/04-lessons.sql" "Creating table: lessons" +execute_sql "$SCRIPT_DIR/tables/05-enrollments.sql" "Creating table: enrollments" +execute_sql "$SCRIPT_DIR/tables/06-progress.sql" "Creating table: progress" +execute_sql "$SCRIPT_DIR/tables/07-quizzes.sql" "Creating table: quizzes" +execute_sql "$SCRIPT_DIR/tables/08-quiz_questions.sql" "Creating table: quiz_questions" +execute_sql "$SCRIPT_DIR/tables/09-quiz_attempts.sql" "Creating table: quiz_attempts" +execute_sql "$SCRIPT_DIR/tables/10-certificates.sql" "Creating table: certificates" +execute_sql "$SCRIPT_DIR/tables/11-user_achievements.sql" "Creating table: user_achievements" +execute_sql "$SCRIPT_DIR/tables/12-user_gamification_profile.sql" "Creating table: user_gamification_profile" +execute_sql "$SCRIPT_DIR/tables/13-user_activity_log.sql" "Creating table: user_activity_log" +execute_sql "$SCRIPT_DIR/tables/14-course_reviews.sql" "Creating table: course_reviews" +echo "" + +# 3. Install Functions and Triggers +echo -e "${GREEN}[3/3] Installing Functions and Triggers...${NC}" +execute_sql "$SCRIPT_DIR/functions/01-update_updated_at.sql" "Creating trigger: update_updated_at" +execute_sql "$SCRIPT_DIR/functions/02-update_enrollment_progress.sql" "Creating function: update_enrollment_progress" +execute_sql "$SCRIPT_DIR/functions/03-auto_complete_enrollment.sql" "Creating function: auto_complete_enrollment" +execute_sql "$SCRIPT_DIR/functions/04-generate_certificate.sql" "Creating function: generate_certificate_number" +execute_sql "$SCRIPT_DIR/functions/05-update_course_stats.sql" "Creating function: update_course_stats" +execute_sql "$SCRIPT_DIR/functions/06-update_enrollment_count.sql" "Creating function: update_enrollment_count" +execute_sql "$SCRIPT_DIR/functions/07-update_gamification_profile.sql" "Creating functions: gamification" +execute_sql "$SCRIPT_DIR/functions/08-views.sql" "Creating views" +echo "" + +# Verify installation +echo -e "${YELLOW}▶${NC} Verifying installation..." + +TABLE_COUNT=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA_NAME' AND table_type = 'BASE TABLE';" 2>/dev/null | xargs) + +if [ "$TABLE_COUNT" -eq "14" ]; then + echo -e "${GREEN} ✓ All 14 tables created successfully${NC}" +else + echo -e "${RED} ✗ Expected 14 tables, found $TABLE_COUNT${NC}" +fi + +echo "" +echo -e "${GREEN}=================================================${NC}" +echo -e "${GREEN} Installation Complete!${NC}" +echo -e "${GREEN}=================================================${NC}" +echo "" +echo "Schema '$SCHEMA_NAME' has been installed successfully." +echo "" +echo "Next steps:" +echo " 1. Review the README.md for usage examples" +echo " 2. Run seed data scripts if needed" +echo " 3. Configure Row Level Security (RLS) policies" +echo "" diff --git a/ddl/schemas/education/seeds-example.sql b/ddl/schemas/education/seeds-example.sql new file mode 100644 index 0000000..b64b0d2 --- /dev/null +++ b/ddl/schemas/education/seeds-example.sql @@ -0,0 +1,238 @@ +-- ===================================================== +-- SEED DATA - Schema Education (EJEMPLO) +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== +-- NOTA: Este es un archivo de ejemplo. +-- Los datos reales deben ir en /apps/database/seeds/ +-- ===================================================== + +BEGIN; + +-- ===================================================== +-- 1. CATEGORIES +-- ===================================================== + +INSERT INTO education.categories (id, name, slug, description, color, display_order, is_active) VALUES +('11111111-1111-1111-1111-111111111111', 'Trading Básico', 'trading-basico', 'Fundamentos del trading y mercados financieros', '#3B82F6', 1, true), +('22222222-2222-2222-2222-222222222222', 'Análisis Técnico', 'analisis-tecnico', 'Herramientas y técnicas de análisis técnico', '#10B981', 2, true), +('33333333-3333-3333-3333-333333333333', 'Análisis Fundamental', 'analisis-fundamental', 'Evaluación fundamental de activos', '#F59E0B', 3, true), +('44444444-4444-4444-4444-444444444444', 'Gestión de Riesgo', 'gestion-riesgo', 'Estrategias de gestión de riesgo y capital', '#EF4444', 4, true), +('55555555-5555-5555-5555-555555555555', 'Trading Algorítmico', 'trading-algoritmico', 'Automatización y estrategias algorítmicas', '#8B5CF6', 5, true), +('66666666-6666-6666-6666-666666666666', 'Psicología del Trading', 'psicologia-trading', 'Aspectos psicológicos y emocionales del trading', '#EC4899', 6, true); + +-- ===================================================== +-- 2. COURSES +-- ===================================================== +-- NOTA: instructor_id debe existir en auth.users +-- Para este ejemplo, usar un UUID válido de tu sistema + +INSERT INTO education.courses ( + id, + title, + slug, + short_description, + full_description, + category_id, + difficulty_level, + instructor_id, + instructor_name, + is_free, + xp_reward, + status, + published_at, + total_modules, + total_lessons +) VALUES +( + 'c1111111-1111-1111-1111-111111111111', + 'Introducción al Trading', + 'introduccion-trading', + 'Aprende los conceptos básicos del trading desde cero', + 'Este curso te enseñará los fundamentos del trading, incluyendo tipos de mercados, instrumentos financieros, y cómo realizar tus primeras operaciones de forma segura.', + '11111111-1111-1111-1111-111111111111', + 'beginner', + '00000000-0000-0000-0000-000000000001', -- Reemplazar con ID real + 'Instructor Demo', + true, + 500, + 'published', + NOW(), + 3, + 12 +); + +-- ===================================================== +-- 3. MODULES +-- ===================================================== + +INSERT INTO education.modules (id, course_id, title, description, display_order, duration_minutes) VALUES +('m1111111-1111-1111-1111-111111111111', 'c1111111-1111-1111-1111-111111111111', 'Módulo 1: Fundamentos', 'Conceptos básicos del trading', 1, 120), +('m2222222-2222-2222-2222-222222222222', 'c1111111-1111-1111-1111-111111111111', 'Módulo 2: Mercados Financieros', 'Tipos de mercados y activos', 2, 180), +('m3333333-3333-3333-3333-333333333333', 'c1111111-1111-1111-1111-111111111111', 'Módulo 3: Primeros Pasos', 'Cómo empezar a operar', 3, 150); + +-- ===================================================== +-- 4. LESSONS +-- ===================================================== + +INSERT INTO education.lessons ( + id, + module_id, + title, + description, + content_type, + video_url, + video_duration_seconds, + display_order, + is_preview, + xp_reward +) VALUES +( + 'l1111111-1111-1111-1111-111111111111', + 'm1111111-1111-1111-1111-111111111111', + '¿Qué es el Trading?', + 'Introducción a los conceptos básicos del trading', + 'video', + 'https://example.com/videos/lesson-1.mp4', + 900, + 1, + true, + 10 +), +( + 'l2222222-2222-2222-2222-222222222222', + 'm1111111-1111-1111-1111-111111111111', + 'Tipos de Traders', + 'Conoce los diferentes estilos de trading', + 'video', + 'https://example.com/videos/lesson-2.mp4', + 1200, + 2, + false, + 10 +), +( + 'l3333333-3333-3333-3333-333333333333', + 'm1111111-1111-1111-1111-111111111111', + 'Terminología Básica', + 'Vocabulario esencial del trading', + 'article', + NULL, + NULL, + 3, + false, + 15 +); + +-- ===================================================== +-- 5. QUIZZES +-- ===================================================== + +INSERT INTO education.quizzes ( + id, + module_id, + title, + description, + passing_score_percentage, + max_attempts, + xp_reward, + xp_perfect_score_bonus +) VALUES +( + 'q1111111-1111-1111-1111-111111111111', + 'm1111111-1111-1111-1111-111111111111', + 'Quiz: Fundamentos del Trading', + 'Evalúa tus conocimientos sobre los conceptos básicos', + 70, + 3, + 50, + 20 +); + +-- ===================================================== +-- 6. QUIZ QUESTIONS +-- ===================================================== + +INSERT INTO education.quiz_questions ( + id, + quiz_id, + question_text, + question_type, + options, + explanation, + points, + display_order +) VALUES +( + 'qq111111-1111-1111-1111-111111111111', + 'q1111111-1111-1111-1111-111111111111', + '¿Qué es el trading?', + 'multiple_choice', + '[ + {"id": "a", "text": "Comprar y vender activos financieros", "isCorrect": true}, + {"id": "b", "text": "Solo comprar acciones", "isCorrect": false}, + {"id": "c", "text": "Invertir a largo plazo únicamente", "isCorrect": false}, + {"id": "d", "text": "Ahorrar dinero en un banco", "isCorrect": false} + ]'::jsonb, + 'El trading implica la compra y venta de activos financieros con el objetivo de obtener ganancias a corto o mediano plazo.', + 1, + 1 +), +( + 'qq222222-2222-2222-2222-222222222222', + 'q1111111-1111-1111-1111-111111111111', + '¿El trading es una actividad de riesgo?', + 'true_false', + '[ + {"id": "true", "text": "Verdadero", "isCorrect": true}, + {"id": "false", "text": "Falso", "isCorrect": false} + ]'::jsonb, + 'Sí, el trading es una actividad que conlleva riesgos y es posible perder dinero. Por eso es importante la gestión de riesgo.', + 1, + 2 +); + +-- ===================================================== +-- EJEMPLO DE USO - ENROLLMENTS +-- ===================================================== +-- NOTA: Estos son ejemplos comentados. NO ejecutar sin IDs reales. + +/* +-- Enrollar un usuario a un curso +INSERT INTO education.enrollments (user_id, course_id, total_lessons) +VALUES ( + '00000000-0000-0000-0000-000000000001', -- ID del usuario + 'c1111111-1111-1111-1111-111111111111', -- ID del curso + 12 -- Total de lecciones del curso +); + +-- Registrar progreso en una lección +INSERT INTO education.progress ( + user_id, + lesson_id, + enrollment_id, + is_completed, + watch_percentage +) VALUES ( + '00000000-0000-0000-0000-000000000001', + 'l1111111-1111-1111-1111-111111111111', + '', + true, + 100.00 +); +*/ + +COMMIT; + +-- ===================================================== +-- Verificación +-- ===================================================== + +SELECT 'Categories created:' as info, COUNT(*) as count FROM education.categories; +SELECT 'Courses created:' as info, COUNT(*) as count FROM education.courses; +SELECT 'Modules created:' as info, COUNT(*) as count FROM education.modules; +SELECT 'Lessons created:' as info, COUNT(*) as count FROM education.lessons; +SELECT 'Quizzes created:' as info, COUNT(*) as count FROM education.quizzes; +SELECT 'Questions created:' as info, COUNT(*) as count FROM education.quiz_questions; diff --git a/ddl/schemas/education/tables/01-categories.sql b/ddl/schemas/education/tables/01-categories.sql new file mode 100644 index 0000000..1d33e57 --- /dev/null +++ b/ddl/schemas/education/tables/01-categories.sql @@ -0,0 +1,42 @@ +-- ===================================================== +-- TABLE: education.categories +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Información básica + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + + -- Jerarquía + parent_id UUID REFERENCES education.categories(id) ON DELETE SET NULL, + + -- Ordenamiento y visualización + display_order INTEGER DEFAULT 0, + icon_url VARCHAR(500), + color VARCHAR(7), -- Código hex #RRGGBB + + -- Metadata + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$') +); + +-- Índices +CREATE INDEX idx_categories_parent ON education.categories(parent_id); +CREATE INDEX idx_categories_slug ON education.categories(slug); +CREATE INDEX idx_categories_active ON education.categories(is_active) WHERE is_active = true; + +-- Comentarios +COMMENT ON TABLE education.categories IS 'Categorías de cursos con soporte para jerarquía'; +COMMENT ON COLUMN education.categories.parent_id IS 'Categoría padre para jerarquía'; +COMMENT ON COLUMN education.categories.display_order IS 'Orden de visualización'; +COMMENT ON COLUMN education.categories.color IS 'Color en formato hexadecimal #RRGGBB'; diff --git a/ddl/schemas/education/tables/02-courses.sql b/ddl/schemas/education/tables/02-courses.sql new file mode 100644 index 0000000..8b452f3 --- /dev/null +++ b/ddl/schemas/education/tables/02-courses.sql @@ -0,0 +1,74 @@ +-- ===================================================== +-- TABLE: education.courses +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.courses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Información básica + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL UNIQUE, + short_description VARCHAR(500), + full_description TEXT, + + -- Categorización + category_id UUID NOT NULL REFERENCES education.categories(id) ON DELETE RESTRICT, + difficulty_level education.difficulty_level NOT NULL DEFAULT 'beginner', + + -- Contenido + thumbnail_url VARCHAR(500), + trailer_url VARCHAR(500), -- Video de presentación + + -- Metadata educativa + duration_minutes INTEGER, -- Duración estimada total + prerequisites TEXT[], -- IDs de cursos prerequisitos + learning_objectives TEXT[], -- Array de objetivos + + -- Instructor + instructor_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + instructor_name VARCHAR(200), -- Denormalizado para performance + + -- Pricing (para futuras features) + is_free BOOLEAN DEFAULT true, + price_usd DECIMAL(10,2), + + -- Gamificación + xp_reward INTEGER DEFAULT 0, -- XP al completar el curso + + -- Estado + status education.course_status DEFAULT 'draft', + published_at TIMESTAMPTZ, + + -- Estadísticas (denormalizadas) + total_modules INTEGER DEFAULT 0, + total_lessons INTEGER DEFAULT 0, + total_enrollments INTEGER DEFAULT 0, + avg_rating DECIMAL(3,2) DEFAULT 0.00, + total_reviews INTEGER DEFAULT 0, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_rating CHECK (avg_rating >= 0 AND avg_rating <= 5), + CONSTRAINT valid_price CHECK (price_usd >= 0) +); + +-- Índices +CREATE INDEX idx_courses_category ON education.courses(category_id); +CREATE INDEX idx_courses_slug ON education.courses(slug); +CREATE INDEX idx_courses_status ON education.courses(status); +CREATE INDEX idx_courses_difficulty ON education.courses(difficulty_level); +CREATE INDEX idx_courses_instructor ON education.courses(instructor_id); +CREATE INDEX idx_courses_published ON education.courses(published_at) WHERE status = 'published'; + +-- Comentarios +COMMENT ON TABLE education.courses IS 'Cursos del módulo educativo'; +COMMENT ON COLUMN education.courses.instructor_name IS 'Denormalizado para performance en queries'; +COMMENT ON COLUMN education.courses.prerequisites IS 'Array de UUIDs de cursos prerequisitos'; +COMMENT ON COLUMN education.courses.learning_objectives IS 'Array de objetivos de aprendizaje'; +COMMENT ON COLUMN education.courses.xp_reward IS 'XP otorgado al completar el curso'; diff --git a/ddl/schemas/education/tables/03-modules.sql b/ddl/schemas/education/tables/03-modules.sql new file mode 100644 index 0000000..09ffcd2 --- /dev/null +++ b/ddl/schemas/education/tables/03-modules.sql @@ -0,0 +1,43 @@ +-- ===================================================== +-- TABLE: education.modules +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.modules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación con curso + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + + -- Información básica + title VARCHAR(200) NOT NULL, + description TEXT, + + -- Ordenamiento + display_order INTEGER NOT NULL DEFAULT 0, + + -- Metadata + duration_minutes INTEGER, + + -- Control de acceso + is_locked BOOLEAN DEFAULT false, -- Requiere completar módulos anteriores + unlock_after_module_id UUID REFERENCES education.modules(id) ON DELETE SET NULL, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_course_order UNIQUE(course_id, display_order) +); + +-- Índices +CREATE INDEX idx_modules_course ON education.modules(course_id); +CREATE INDEX idx_modules_order ON education.modules(course_id, display_order); + +-- Comentarios +COMMENT ON TABLE education.modules IS 'Módulos que agrupan lecciones dentro de un curso'; +COMMENT ON COLUMN education.modules.is_locked IS 'Si requiere completar módulos anteriores para desbloquearse'; +COMMENT ON COLUMN education.modules.unlock_after_module_id IS 'Módulo que debe completarse antes de acceder a este'; diff --git a/ddl/schemas/education/tables/04-lessons.sql b/ddl/schemas/education/tables/04-lessons.sql new file mode 100644 index 0000000..30666e4 --- /dev/null +++ b/ddl/schemas/education/tables/04-lessons.sql @@ -0,0 +1,66 @@ +-- ===================================================== +-- TABLE: education.lessons +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.lessons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación con módulo + module_id UUID NOT NULL REFERENCES education.modules(id) ON DELETE CASCADE, + + -- Información básica + title VARCHAR(200) NOT NULL, + description TEXT, + + -- Tipo de contenido + content_type education.lesson_content_type NOT NULL DEFAULT 'video', + + -- Contenido video + video_url VARCHAR(500), -- URL de Vimeo/S3 + video_duration_seconds INTEGER, + video_provider VARCHAR(50), -- 'vimeo', 's3', etc. + video_id VARCHAR(200), -- ID del video en el provider + + -- Contenido texto/article + article_content TEXT, + + -- Recursos adicionales + attachments JSONB, -- [{name, url, type, size}] + + -- Ordenamiento + display_order INTEGER NOT NULL DEFAULT 0, + + -- Configuración + is_preview BOOLEAN DEFAULT false, -- Puede verse sin enrollment + is_mandatory BOOLEAN DEFAULT true, -- Requerido para completar el curso + + -- Gamificación + xp_reward INTEGER DEFAULT 10, -- XP al completar la lección + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_module_order UNIQUE(module_id, display_order), + CONSTRAINT video_fields_required CHECK ( + (content_type != 'video') OR + (video_url IS NOT NULL AND video_duration_seconds IS NOT NULL) + ) +); + +-- Índices +CREATE INDEX idx_lessons_module ON education.lessons(module_id); +CREATE INDEX idx_lessons_order ON education.lessons(module_id, display_order); +CREATE INDEX idx_lessons_type ON education.lessons(content_type); +CREATE INDEX idx_lessons_preview ON education.lessons(is_preview) WHERE is_preview = true; + +-- Comentarios +COMMENT ON TABLE education.lessons IS 'Lecciones individuales dentro de módulos'; +COMMENT ON COLUMN education.lessons.attachments IS 'Archivos adjuntos en formato JSON: [{name, url, type, size}]'; +COMMENT ON COLUMN education.lessons.is_preview IS 'Puede verse sin enrollment (preview gratuito)'; +COMMENT ON COLUMN education.lessons.is_mandatory IS 'Requerido para completar el curso'; +COMMENT ON COLUMN education.lessons.xp_reward IS 'XP otorgado al completar la lección'; diff --git a/ddl/schemas/education/tables/05-enrollments.sql b/ddl/schemas/education/tables/05-enrollments.sql new file mode 100644 index 0000000..78053f2 --- /dev/null +++ b/ddl/schemas/education/tables/05-enrollments.sql @@ -0,0 +1,56 @@ +-- ===================================================== +-- TABLE: education.enrollments +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.enrollments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT, + + -- Estado + status education.enrollment_status DEFAULT 'active', + + -- Progreso + progress_percentage DECIMAL(5,2) DEFAULT 0.00, + completed_lessons INTEGER DEFAULT 0, + total_lessons INTEGER DEFAULT 0, -- Snapshot del total al enrollarse + + -- Fechas importantes + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, -- Primera lección vista + completed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, -- Para cursos con límite de tiempo + + -- Gamificación + total_xp_earned INTEGER DEFAULT 0, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_course UNIQUE(user_id, course_id), + CONSTRAINT valid_progress CHECK (progress_percentage >= 0 AND progress_percentage <= 100), + CONSTRAINT valid_completion CHECK ( + (status != 'completed') OR + (completed_at IS NOT NULL AND progress_percentage = 100) + ) +); + +-- Índices +CREATE INDEX idx_enrollments_user ON education.enrollments(user_id); +CREATE INDEX idx_enrollments_course ON education.enrollments(course_id); +CREATE INDEX idx_enrollments_status ON education.enrollments(status); +CREATE INDEX idx_enrollments_user_active ON education.enrollments(user_id, status) + WHERE status = 'active'; + +-- Comentarios +COMMENT ON TABLE education.enrollments IS 'Inscripciones de usuarios a cursos'; +COMMENT ON COLUMN education.enrollments.total_lessons IS 'Snapshot del total de lecciones al momento de enrollarse'; +COMMENT ON COLUMN education.enrollments.started_at IS 'Timestamp de la primera lección vista'; +COMMENT ON COLUMN education.enrollments.expires_at IS 'Fecha de expiración para cursos con límite de tiempo'; diff --git a/ddl/schemas/education/tables/06-progress.sql b/ddl/schemas/education/tables/06-progress.sql new file mode 100644 index 0000000..2f84c8d --- /dev/null +++ b/ddl/schemas/education/tables/06-progress.sql @@ -0,0 +1,52 @@ +-- ===================================================== +-- TABLE: education.progress +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.progress ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + lesson_id UUID NOT NULL REFERENCES education.lessons(id) ON DELETE CASCADE, + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, + + -- Estado + is_completed BOOLEAN DEFAULT false, + + -- Progreso de video + last_position_seconds INTEGER DEFAULT 0, + total_watch_time_seconds INTEGER DEFAULT 0, -- Tiempo total visto + watch_percentage DECIMAL(5,2) DEFAULT 0.00, + + -- Tracking + first_viewed_at TIMESTAMPTZ, + last_viewed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_lesson UNIQUE(user_id, lesson_id), + CONSTRAINT valid_watch_percentage CHECK (watch_percentage >= 0 AND watch_percentage <= 100), + CONSTRAINT completion_requires_date CHECK ( + (NOT is_completed) OR (completed_at IS NOT NULL) + ) +); + +-- Índices +CREATE INDEX idx_progress_user ON education.progress(user_id); +CREATE INDEX idx_progress_lesson ON education.progress(lesson_id); +CREATE INDEX idx_progress_enrollment ON education.progress(enrollment_id); +CREATE INDEX idx_progress_completed ON education.progress(is_completed) WHERE is_completed = true; +CREATE INDEX idx_progress_user_enrollment ON education.progress(user_id, enrollment_id); + +-- Comentarios +COMMENT ON TABLE education.progress IS 'Progreso individual del usuario en cada lección'; +COMMENT ON COLUMN education.progress.last_position_seconds IS 'Última posición del video en segundos'; +COMMENT ON COLUMN education.progress.total_watch_time_seconds IS 'Tiempo total de visualización acumulado'; +COMMENT ON COLUMN education.progress.watch_percentage IS 'Porcentaje de la lección completada'; diff --git a/ddl/schemas/education/tables/07-quizzes.sql b/ddl/schemas/education/tables/07-quizzes.sql new file mode 100644 index 0000000..d5ac8a8 --- /dev/null +++ b/ddl/schemas/education/tables/07-quizzes.sql @@ -0,0 +1,57 @@ +-- ===================================================== +-- TABLE: education.quizzes +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.quizzes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación (puede estar asociado a módulo o lección) + module_id UUID REFERENCES education.modules(id) ON DELETE CASCADE, + lesson_id UUID REFERENCES education.lessons(id) ON DELETE CASCADE, + + -- Información básica + title VARCHAR(200) NOT NULL, + description TEXT, + + -- Configuración + passing_score_percentage INTEGER DEFAULT 70, -- % mínimo para aprobar + max_attempts INTEGER, -- NULL = intentos ilimitados + time_limit_minutes INTEGER, -- NULL = sin límite de tiempo + + -- Opciones + shuffle_questions BOOLEAN DEFAULT true, + shuffle_answers BOOLEAN DEFAULT true, + show_correct_answers BOOLEAN DEFAULT true, -- Después de completar + + -- Gamificación + xp_reward INTEGER DEFAULT 50, + xp_perfect_score_bonus INTEGER DEFAULT 20, + + -- Estado + is_active BOOLEAN DEFAULT true, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_passing_score CHECK (passing_score_percentage > 0 AND passing_score_percentage <= 100), + CONSTRAINT quiz_association CHECK ( + (module_id IS NOT NULL AND lesson_id IS NULL) OR + (module_id IS NULL AND lesson_id IS NOT NULL) + ) +); + +-- Índices +CREATE INDEX idx_quizzes_module ON education.quizzes(module_id); +CREATE INDEX idx_quizzes_lesson ON education.quizzes(lesson_id); +CREATE INDEX idx_quizzes_active ON education.quizzes(is_active) WHERE is_active = true; + +-- Comentarios +COMMENT ON TABLE education.quizzes IS 'Quizzes/evaluaciones asociadas a módulos o lecciones'; +COMMENT ON COLUMN education.quizzes.max_attempts IS 'NULL = intentos ilimitados'; +COMMENT ON COLUMN education.quizzes.time_limit_minutes IS 'NULL = sin límite de tiempo'; +COMMENT ON COLUMN education.quizzes.xp_perfect_score_bonus IS 'XP bonus por obtener 100% de score'; diff --git a/ddl/schemas/education/tables/08-quiz_questions.sql b/ddl/schemas/education/tables/08-quiz_questions.sql new file mode 100644 index 0000000..76383a1 --- /dev/null +++ b/ddl/schemas/education/tables/08-quiz_questions.sql @@ -0,0 +1,56 @@ +-- ===================================================== +-- TABLE: education.quiz_questions +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.quiz_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación + quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE CASCADE, + + -- Pregunta + question_text TEXT NOT NULL, + question_type education.question_type NOT NULL DEFAULT 'multiple_choice', + + -- Opciones de respuesta (para multiple_choice, true_false, multiple_select) + options JSONB, -- [{id, text, isCorrect}] + + -- Respuesta correcta (para fill_blank, code_challenge) + correct_answer TEXT, + + -- Explicación + explanation TEXT, -- Mostrar después de responder + + -- Recursos adicionales + image_url VARCHAR(500), + code_snippet TEXT, + + -- Puntuación + points INTEGER DEFAULT 1, + + -- Ordenamiento + display_order INTEGER DEFAULT 0, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_options CHECK ( + (question_type NOT IN ('multiple_choice', 'true_false', 'multiple_select')) OR + (options IS NOT NULL) + ) +); + +-- Índices +CREATE INDEX idx_quiz_questions_quiz ON education.quiz_questions(quiz_id); +CREATE INDEX idx_quiz_questions_order ON education.quiz_questions(quiz_id, display_order); + +-- Comentarios +COMMENT ON TABLE education.quiz_questions IS 'Preguntas individuales de los quizzes'; +COMMENT ON COLUMN education.quiz_questions.options IS 'Array JSON de opciones: [{id, text, isCorrect}]'; +COMMENT ON COLUMN education.quiz_questions.correct_answer IS 'Respuesta correcta para fill_blank y code_challenge'; +COMMENT ON COLUMN education.quiz_questions.explanation IS 'Explicación mostrada después de responder'; diff --git a/ddl/schemas/education/tables/09-quiz_attempts.sql b/ddl/schemas/education/tables/09-quiz_attempts.sql new file mode 100644 index 0000000..626f416 --- /dev/null +++ b/ddl/schemas/education/tables/09-quiz_attempts.sql @@ -0,0 +1,53 @@ +-- ===================================================== +-- TABLE: education.quiz_attempts +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.quiz_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE RESTRICT, + enrollment_id UUID REFERENCES education.enrollments(id) ON DELETE SET NULL, + + -- Estado del intento + is_completed BOOLEAN DEFAULT false, + is_passed BOOLEAN DEFAULT false, + + -- Respuestas del usuario + user_answers JSONB, -- [{questionId, answer, isCorrect, points}] + + -- Puntuación + score_points INTEGER DEFAULT 0, + max_points INTEGER DEFAULT 0, + score_percentage DECIMAL(5,2) DEFAULT 0.00, + + -- Tiempo + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + time_taken_seconds INTEGER, + + -- XP ganado + xp_earned INTEGER DEFAULT 0, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_score_percentage CHECK (score_percentage >= 0 AND score_percentage <= 100) +); + +-- Índices +CREATE INDEX idx_quiz_attempts_user ON education.quiz_attempts(user_id); +CREATE INDEX idx_quiz_attempts_quiz ON education.quiz_attempts(quiz_id); +CREATE INDEX idx_quiz_attempts_enrollment ON education.quiz_attempts(enrollment_id); +CREATE INDEX idx_quiz_attempts_user_quiz ON education.quiz_attempts(user_id, quiz_id); +CREATE INDEX idx_quiz_attempts_completed ON education.quiz_attempts(is_completed, completed_at); + +-- Comentarios +COMMENT ON TABLE education.quiz_attempts IS 'Intentos de los usuarios en los quizzes'; +COMMENT ON COLUMN education.quiz_attempts.user_answers IS 'Respuestas del usuario: [{questionId, answer, isCorrect, points}]'; +COMMENT ON COLUMN education.quiz_attempts.time_taken_seconds IS 'Tiempo total empleado en segundos'; diff --git a/ddl/schemas/education/tables/10-certificates.sql b/ddl/schemas/education/tables/10-certificates.sql new file mode 100644 index 0000000..c0016b6 --- /dev/null +++ b/ddl/schemas/education/tables/10-certificates.sql @@ -0,0 +1,54 @@ +-- ===================================================== +-- TABLE: education.certificates +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.certificates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT, + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE RESTRICT, + + -- Información del certificado + certificate_number VARCHAR(50) NOT NULL UNIQUE, -- OQI-CERT-XXXX-YYYY + + -- Datos del certificado (snapshot) + user_name VARCHAR(200) NOT NULL, + course_title VARCHAR(200) NOT NULL, + instructor_name VARCHAR(200), + completion_date DATE NOT NULL, + + -- Metadata de logro + final_score DECIMAL(5,2), -- Promedio de quizzes + total_xp_earned INTEGER, + + -- URL del PDF generado + certificate_url VARCHAR(500), + + -- Verificación + verification_code VARCHAR(100) UNIQUE, -- Para verificar autenticidad + is_verified BOOLEAN DEFAULT true, + + -- Metadata + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_course_cert UNIQUE(user_id, course_id) +); + +-- Índices +CREATE INDEX idx_certificates_user ON education.certificates(user_id); +CREATE INDEX idx_certificates_course ON education.certificates(course_id); +CREATE INDEX idx_certificates_number ON education.certificates(certificate_number); +CREATE INDEX idx_certificates_verification ON education.certificates(verification_code); + +-- Comentarios +COMMENT ON TABLE education.certificates IS 'Certificados emitidos al completar cursos'; +COMMENT ON COLUMN education.certificates.certificate_number IS 'Número único formato: OQI-CERT-YYYY-NNNNN'; +COMMENT ON COLUMN education.certificates.verification_code IS 'Código para verificar autenticidad del certificado'; +COMMENT ON COLUMN education.certificates.final_score IS 'Promedio de todos los quizzes del curso'; diff --git a/ddl/schemas/education/tables/11-user_achievements.sql b/ddl/schemas/education/tables/11-user_achievements.sql new file mode 100644 index 0000000..2337773 --- /dev/null +++ b/ddl/schemas/education/tables/11-user_achievements.sql @@ -0,0 +1,47 @@ +-- ===================================================== +-- TABLE: education.user_achievements +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.user_achievements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Tipo de logro + achievement_type education.achievement_type NOT NULL, + + -- Información del logro + title VARCHAR(200) NOT NULL, + description TEXT, + badge_icon_url VARCHAR(500), + + -- Metadata del logro + metadata JSONB, -- Información específica del logro + + -- Referencias + course_id UUID REFERENCES education.courses(id) ON DELETE SET NULL, + quiz_id UUID REFERENCES education.quizzes(id) ON DELETE SET NULL, + + -- XP bonus por el logro + xp_bonus INTEGER DEFAULT 0, + + -- Metadata + earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_user_achievements_user ON education.user_achievements(user_id); +CREATE INDEX idx_user_achievements_type ON education.user_achievements(achievement_type); +CREATE INDEX idx_user_achievements_earned ON education.user_achievements(earned_at DESC); +CREATE INDEX idx_user_achievements_course ON education.user_achievements(course_id); + +-- Comentarios +COMMENT ON TABLE education.user_achievements IS 'Logros/badges obtenidos por los usuarios'; +COMMENT ON COLUMN education.user_achievements.metadata IS 'Información adicional específica del tipo de logro'; +COMMENT ON COLUMN education.user_achievements.xp_bonus IS 'XP bonus otorgado por este logro'; diff --git a/ddl/schemas/education/tables/12-user_gamification_profile.sql b/ddl/schemas/education/tables/12-user_gamification_profile.sql new file mode 100644 index 0000000..8b1b310 --- /dev/null +++ b/ddl/schemas/education/tables/12-user_gamification_profile.sql @@ -0,0 +1,56 @@ +-- ===================================================== +-- TABLE: education.user_gamification_profile +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: Tabla adicional para gamificación +-- ===================================================== + +CREATE TABLE education.user_gamification_profile ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- XP y nivel + total_xp INTEGER NOT NULL DEFAULT 0, + current_level INTEGER NOT NULL DEFAULT 1, + xp_to_next_level INTEGER NOT NULL DEFAULT 100, + + -- Streaks + current_streak_days INTEGER DEFAULT 0, + longest_streak_days INTEGER DEFAULT 0, + last_activity_date DATE, + + -- Estadísticas + total_courses_completed INTEGER DEFAULT 0, + total_lessons_completed INTEGER DEFAULT 0, + total_quizzes_passed INTEGER DEFAULT 0, + total_certificates_earned INTEGER DEFAULT 0, + average_quiz_score DECIMAL(5,2) DEFAULT 0.00, + + -- Ranking + weekly_xp INTEGER DEFAULT 0, + monthly_xp INTEGER DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_gamification UNIQUE(user_id), + CONSTRAINT valid_level CHECK (current_level >= 1), + CONSTRAINT valid_xp CHECK (total_xp >= 0), + CONSTRAINT valid_streak CHECK (current_streak_days >= 0 AND longest_streak_days >= 0), + CONSTRAINT valid_avg_score CHECK (average_quiz_score >= 0 AND average_quiz_score <= 100) +); + +-- Índices +CREATE INDEX idx_gamification_user ON education.user_gamification_profile(user_id); +CREATE INDEX idx_gamification_level ON education.user_gamification_profile(current_level DESC); +CREATE INDEX idx_gamification_xp ON education.user_gamification_profile(total_xp DESC); +CREATE INDEX idx_gamification_weekly ON education.user_gamification_profile(weekly_xp DESC); +CREATE INDEX idx_gamification_monthly ON education.user_gamification_profile(monthly_xp DESC); + +-- Comentarios +COMMENT ON TABLE education.user_gamification_profile IS 'Perfil de gamificación del usuario con XP, niveles, streaks y estadísticas'; +COMMENT ON COLUMN education.user_gamification_profile.current_streak_days IS 'Días consecutivos de actividad actual'; +COMMENT ON COLUMN education.user_gamification_profile.longest_streak_days IS 'Racha más larga de días consecutivos'; +COMMENT ON COLUMN education.user_gamification_profile.weekly_xp IS 'XP acumulado en la semana actual (para leaderboards)'; +COMMENT ON COLUMN education.user_gamification_profile.monthly_xp IS 'XP acumulado en el mes actual (para leaderboards)'; diff --git a/ddl/schemas/education/tables/13-user_activity_log.sql b/ddl/schemas/education/tables/13-user_activity_log.sql new file mode 100644 index 0000000..50dad0b --- /dev/null +++ b/ddl/schemas/education/tables/13-user_activity_log.sql @@ -0,0 +1,43 @@ +-- ===================================================== +-- TABLE: education.user_activity_log +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: Tabla adicional para tracking de actividad +-- ===================================================== + +CREATE TABLE education.user_activity_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Tipo de actividad + activity_type VARCHAR(50) NOT NULL, -- lesson_view, quiz_complete, course_enroll, etc. + + -- Referencias opcionales + course_id UUID REFERENCES education.courses(id) ON DELETE SET NULL, + lesson_id UUID REFERENCES education.lessons(id) ON DELETE SET NULL, + quiz_id UUID REFERENCES education.quizzes(id) ON DELETE SET NULL, + + -- Metadata + metadata JSONB DEFAULT '{}', + xp_earned INTEGER DEFAULT 0, + + -- Contexto + ip_address INET, + user_agent TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_activity_user ON education.user_activity_log(user_id); +CREATE INDEX idx_activity_type ON education.user_activity_log(activity_type); +CREATE INDEX idx_activity_created ON education.user_activity_log(created_at DESC); +CREATE INDEX idx_activity_user_date ON education.user_activity_log(user_id, created_at DESC); +CREATE INDEX idx_activity_course ON education.user_activity_log(course_id) WHERE course_id IS NOT NULL; + +-- Comentarios +COMMENT ON TABLE education.user_activity_log IS 'Log de actividades del usuario en el módulo educativo'; +COMMENT ON COLUMN education.user_activity_log.activity_type IS 'Tipos: lesson_view, lesson_complete, quiz_start, quiz_complete, course_enroll, etc.'; +COMMENT ON COLUMN education.user_activity_log.metadata IS 'Información adicional específica del tipo de actividad'; +COMMENT ON COLUMN education.user_activity_log.xp_earned IS 'XP ganado en esta actividad (si aplica)'; diff --git a/ddl/schemas/education/tables/14-course_reviews.sql b/ddl/schemas/education/tables/14-course_reviews.sql new file mode 100644 index 0000000..f0dd4d4 --- /dev/null +++ b/ddl/schemas/education/tables/14-course_reviews.sql @@ -0,0 +1,48 @@ +-- ===================================================== +-- TABLE: education.course_reviews +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: Tabla adicional para reviews de cursos +-- ===================================================== + +CREATE TABLE education.course_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, + + -- Review + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + title VARCHAR(200), + content TEXT, + + -- Moderación + is_approved BOOLEAN DEFAULT false, + is_featured BOOLEAN DEFAULT false, + approved_by UUID REFERENCES auth.users(id), + approved_at TIMESTAMPTZ, + + -- Votos útiles + helpful_votes INTEGER DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_course_review UNIQUE(user_id, course_id) +); + +-- Índices +CREATE INDEX idx_reviews_course ON education.course_reviews(course_id); +CREATE INDEX idx_reviews_user ON education.course_reviews(user_id); +CREATE INDEX idx_reviews_rating ON education.course_reviews(rating); +CREATE INDEX idx_reviews_approved ON education.course_reviews(is_approved) WHERE is_approved = true; +CREATE INDEX idx_reviews_featured ON education.course_reviews(is_featured) WHERE is_featured = true; +CREATE INDEX idx_reviews_helpful ON education.course_reviews(helpful_votes DESC); + +-- Comentarios +COMMENT ON TABLE education.course_reviews IS 'Reviews y calificaciones de cursos por usuarios'; +COMMENT ON COLUMN education.course_reviews.is_approved IS 'Review aprobada por moderador'; +COMMENT ON COLUMN education.course_reviews.is_featured IS 'Review destacada para mostrar en página del curso'; +COMMENT ON COLUMN education.course_reviews.helpful_votes IS 'Número de votos útiles de otros usuarios'; diff --git a/ddl/schemas/education/uninstall.sh b/ddl/schemas/education/uninstall.sh new file mode 100755 index 0000000..8e69f19 --- /dev/null +++ b/ddl/schemas/education/uninstall.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# ===================================================== +# UNINSTALL SCRIPT - Schema Education +# ===================================================== +# Proyecto: OrbiQuant IA (Trading Platform) +# Módulo: OQI-002 - Education +# ===================================================== + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Configuration +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" +DB_NAME="${DB_NAME:-trading_platform}" +DB_USER="${DB_USER:-postgres}" +SCHEMA_NAME="education" + +echo -e "${YELLOW}=================================================${NC}" +echo -e "${YELLOW} OrbiQuant IA - Education Schema Uninstall${NC}" +echo -e "${YELLOW}=================================================${NC}" +echo "" + +echo -e "${RED}WARNING: This will DROP the entire '$SCHEMA_NAME' schema and ALL its data!${NC}" +echo "" +read -p "Are you sure you want to continue? (type 'yes' to confirm): " CONFIRM + +if [ "$CONFIRM" != "yes" ]; then + echo "Uninstall cancelled." + exit 0 +fi + +echo "" +echo -e "${YELLOW}▶${NC} Dropping schema: $SCHEMA_NAME (CASCADE)" + +if PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "DROP SCHEMA IF EXISTS $SCHEMA_NAME CASCADE;" > /dev/null 2>&1; then + echo -e "${GREEN} ✓ Schema dropped successfully${NC}" +else + echo -e "${RED} ✗ Failed to drop schema${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}=================================================${NC}" +echo -e "${GREEN} Uninstall Complete!${NC}" +echo -e "${GREEN}=================================================${NC}" +echo "" +echo "Schema '$SCHEMA_NAME' has been removed." +echo "" diff --git a/ddl/schemas/education/verify.sh b/ddl/schemas/education/verify.sh new file mode 100755 index 0000000..22d6f88 --- /dev/null +++ b/ddl/schemas/education/verify.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +# ===================================================== +# VERIFY SCRIPT - Schema Education +# ===================================================== +# Proyecto: OrbiQuant IA (Trading Platform) +# Módulo: OQI-002 - Education +# ===================================================== + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Configuration +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" +DB_NAME="${DB_NAME:-trading_platform}" +DB_USER="${DB_USER:-postgres}" +SCHEMA_NAME="education" + +echo -e "${BLUE}=================================================${NC}" +echo -e "${BLUE} OrbiQuant IA - Education Schema Verification${NC}" +echo -e "${BLUE}=================================================${NC}" +echo "" + +# Check if psql is available +if ! command -v psql &> /dev/null; then + echo -e "${RED}Error: psql command not found${NC}" + exit 1 +fi + +# Function to run query and return result +run_query() { + local query=$1 + PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "$query" 2>/dev/null | xargs +} + +echo "Configuration:" +echo " Database: $DB_NAME" +echo " Host: $DB_HOST:$DB_PORT" +echo " Schema: $SCHEMA_NAME" +echo "" + +# Check if schema exists +echo -e "${YELLOW}▶${NC} Checking schema existence..." +SCHEMA_EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = '$SCHEMA_NAME';") +if [ "$SCHEMA_EXISTS" -eq "1" ]; then + echo -e "${GREEN} ✓ Schema exists${NC}" +else + echo -e "${RED} ✗ Schema not found${NC}" + exit 1 +fi + +# Check ENUMs +echo "" +echo -e "${YELLOW}▶${NC} Checking ENUMs..." +EXPECTED_ENUMS=("difficulty_level" "course_status" "enrollment_status" "lesson_content_type" "question_type" "achievement_type") +ENUM_COUNT=0 + +for enum_name in "${EXPECTED_ENUMS[@]}"; do + EXISTS=$(run_query "SELECT COUNT(*) FROM pg_type WHERE typname = '$enum_name' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '$SCHEMA_NAME');") + if [ "$EXISTS" -eq "1" ]; then + echo -e "${GREEN} ✓ $enum_name${NC}" + ((ENUM_COUNT++)) + else + echo -e "${RED} ✗ $enum_name${NC}" + fi +done + +# Check tables +echo "" +echo -e "${YELLOW}▶${NC} Checking tables..." +EXPECTED_TABLES=("categories" "courses" "modules" "lessons" "enrollments" "progress" "quizzes" "quiz_questions" "quiz_attempts" "certificates" "user_achievements" "user_gamification_profile" "user_activity_log" "course_reviews") +TABLE_COUNT=0 + +for table_name in "${EXPECTED_TABLES[@]}"; do + EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA_NAME' AND table_name = '$table_name';") + if [ "$EXISTS" -eq "1" ]; then + ROW_COUNT=$(run_query "SELECT COUNT(*) FROM $SCHEMA_NAME.$table_name;") + echo -e "${GREEN} ✓ $table_name${NC} ($ROW_COUNT rows)" + ((TABLE_COUNT++)) + else + echo -e "${RED} ✗ $table_name${NC}" + fi +done + +# Check functions +echo "" +echo -e "${YELLOW}▶${NC} Checking functions..." +EXPECTED_FUNCTIONS=("update_updated_at_column" "update_enrollment_progress" "auto_complete_enrollment" "generate_certificate_number" "update_course_rating_stats" "update_enrollment_count" "update_user_xp" "update_user_streak") +FUNCTION_COUNT=0 + +for function_name in "${EXPECTED_FUNCTIONS[@]}"; do + EXISTS=$(run_query "SELECT COUNT(*) FROM pg_proc WHERE proname = '$function_name' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = '$SCHEMA_NAME');") + if [ "$EXISTS" -ge "1" ]; then + echo -e "${GREEN} ✓ $function_name${NC}" + ((FUNCTION_COUNT++)) + else + echo -e "${RED} ✗ $function_name${NC}" + fi +done + +# Check views +echo "" +echo -e "${YELLOW}▶${NC} Checking views..." +EXPECTED_VIEWS=("v_courses_with_stats" "v_user_course_progress" "v_leaderboard_weekly" "v_leaderboard_monthly" "v_leaderboard_alltime" "v_user_statistics" "v_popular_courses") +VIEW_COUNT=0 + +for view_name in "${EXPECTED_VIEWS[@]}"; do + EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.views WHERE table_schema = '$SCHEMA_NAME' AND table_name = '$view_name';") + if [ "$EXISTS" -eq "1" ]; then + echo -e "${GREEN} ✓ $view_name${NC}" + ((VIEW_COUNT++)) + else + echo -e "${RED} ✗ $view_name${NC}" + fi +done + +# Summary +echo "" +echo -e "${BLUE}=================================================${NC}" +echo -e "${BLUE} Verification Summary${NC}" +echo -e "${BLUE}=================================================${NC}" +echo "" +echo "ENUMs: $ENUM_COUNT / ${#EXPECTED_ENUMS[@]}" +echo "Tables: $TABLE_COUNT / ${#EXPECTED_TABLES[@]}" +echo "Functions: $FUNCTION_COUNT / ${#EXPECTED_FUNCTIONS[@]}" +echo "Views: $VIEW_COUNT / ${#EXPECTED_VIEWS[@]}" +echo "" + +TOTAL_EXPECTED=$((${#EXPECTED_ENUMS[@]} + ${#EXPECTED_TABLES[@]} + ${#EXPECTED_FUNCTIONS[@]} + ${#EXPECTED_VIEWS[@]})) +TOTAL_FOUND=$((ENUM_COUNT + TABLE_COUNT + FUNCTION_COUNT + VIEW_COUNT)) + +if [ "$TOTAL_FOUND" -eq "$TOTAL_EXPECTED" ]; then + echo -e "${GREEN}✓ All components verified successfully!${NC}" + exit 0 +else + echo -e "${YELLOW}⚠ Some components are missing ($TOTAL_FOUND / $TOTAL_EXPECTED)${NC}" + exit 1 +fi diff --git a/ddl/schemas/financial/00-enums.sql b/ddl/schemas/financial/00-enums.sql new file mode 100644 index 0000000..c8619cc --- /dev/null +++ b/ddl/schemas/financial/00-enums.sql @@ -0,0 +1,131 @@ +-- ===================================================== +-- ORBIQUANT IA - FINANCIAL SCHEMA ENUMS +-- ===================================================== +-- Description: Type definitions for financial domain +-- Schema: financial +-- ===================================================== + +-- Tipos de wallet +CREATE TYPE financial.wallet_type AS ENUM ( + 'trading', -- Para operaciones de trading + 'investment', -- Para cuentas PAMM + 'earnings', -- Para ganancias/distribuciones + 'referral' -- Para bonos de referidos +); + +-- Estados de wallet +CREATE TYPE financial.wallet_status AS ENUM ( + 'active', + 'frozen', + 'closed' +); + +-- Tipos de transacción +CREATE TYPE financial.transaction_type AS ENUM ( + 'deposit', + 'withdrawal', + 'transfer_in', + 'transfer_out', + 'fee', + 'refund', + 'earning', + 'distribution', + 'bonus' +); + +-- Estados de transacción +CREATE TYPE financial.transaction_status AS ENUM ( + 'pending', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'reversed' +); + +-- Planes de suscripción +CREATE TYPE financial.subscription_plan AS ENUM ( + 'free', + 'basic', + 'pro', + 'premium', + 'enterprise' +); + +-- Estados de suscripción +CREATE TYPE financial.subscription_status AS ENUM ( + 'active', + 'past_due', + 'cancelled', + 'incomplete', + 'trialing', + 'unpaid', + 'paused' +); + +-- Monedas soportadas +CREATE TYPE financial.currency_code AS ENUM ( + 'USD', + 'MXN', + 'EUR' +); + +-- Métodos de pago +CREATE TYPE financial.payment_method AS ENUM ( + 'card', + 'bank_transfer', + 'wire', + 'crypto', + 'paypal', + 'stripe' +); + +-- Estados de pago +CREATE TYPE financial.payment_status AS ENUM ( + 'pending', + 'processing', + 'succeeded', + 'failed', + 'cancelled', + 'refunded' +); + +-- Tipos de invoice +CREATE TYPE financial.invoice_type AS ENUM ( + 'subscription', + 'one_time', + 'usage' +); + +-- Estados de invoice +CREATE TYPE financial.invoice_status AS ENUM ( + 'draft', + 'open', + 'paid', + 'void', + 'uncollectible' +); + +-- Acciones de auditoría +CREATE TYPE financial.audit_action AS ENUM ( + 'created', + 'balance_updated', + 'status_changed', + 'limit_changed', + 'frozen', + 'unfrozen', + 'closed' +); + +COMMENT ON TYPE financial.wallet_type IS 'Tipos de wallets en el sistema'; +COMMENT ON TYPE financial.wallet_status IS 'Estados posibles de una wallet'; +COMMENT ON TYPE financial.transaction_type IS 'Tipos de transacciones financieras'; +COMMENT ON TYPE financial.transaction_status IS 'Estados del ciclo de vida de una transacción'; +COMMENT ON TYPE financial.subscription_plan IS 'Planes de suscripción disponibles'; +COMMENT ON TYPE financial.subscription_status IS 'Estados de suscripción según Stripe'; +COMMENT ON TYPE financial.currency_code IS 'Códigos de moneda ISO 4217 soportados'; +COMMENT ON TYPE financial.payment_method IS 'Métodos de pago aceptados'; +COMMENT ON TYPE financial.payment_status IS 'Estados de procesamiento de pagos'; +COMMENT ON TYPE financial.invoice_type IS 'Tipos de factura'; +COMMENT ON TYPE financial.invoice_status IS 'Estados de factura'; +COMMENT ON TYPE financial.audit_action IS 'Acciones auditables en wallets'; diff --git a/ddl/schemas/financial/functions/01-update_wallet_balance.sql b/ddl/schemas/financial/functions/01-update_wallet_balance.sql new file mode 100644 index 0000000..1fc9aaf --- /dev/null +++ b/ddl/schemas/financial/functions/01-update_wallet_balance.sql @@ -0,0 +1,283 @@ +-- ===================================================== +-- ORBIQUANT IA - UPDATE WALLET BALANCE FUNCTION +-- ===================================================== +-- Description: Safely update wallet balance with audit trail +-- Schema: financial +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.update_wallet_balance( + p_wallet_id UUID, + p_amount DECIMAL(20,8), + p_operation VARCHAR(20), -- 'add', 'subtract', 'set' + p_transaction_id UUID DEFAULT NULL, + p_actor_id UUID DEFAULT NULL, + p_actor_type VARCHAR(50) DEFAULT 'system', + p_reason TEXT DEFAULT NULL, + p_metadata JSONB DEFAULT '{}' +) +RETURNS TABLE ( + success BOOLEAN, + new_balance DECIMAL(20,8), + new_available DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet RECORD; + v_old_balance DECIMAL(20,8); + v_old_available DECIMAL(20,8); + v_new_balance DECIMAL(20,8); + v_new_available DECIMAL(20,8); +BEGIN + -- Lock wallet row for update + SELECT * INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id + FOR UPDATE; + + -- Validar que existe + IF NOT FOUND THEN + RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found'; + RETURN; + END IF; + + -- Validar que está activa + IF v_wallet.status != 'active' THEN + RETURN QUERY SELECT false, v_wallet.balance, v_wallet.available_balance, + 'Wallet is not active (status: ' || v_wallet.status::TEXT || ')'; + RETURN; + END IF; + + -- Guardar valores antiguos + v_old_balance := v_wallet.balance; + v_old_available := v_wallet.available_balance; + + -- Calcular nuevo balance según operación + CASE p_operation + WHEN 'add' THEN + v_new_balance := v_old_balance + p_amount; + v_new_available := v_old_available + p_amount; + WHEN 'subtract' THEN + v_new_balance := v_old_balance - p_amount; + v_new_available := v_old_available - p_amount; + WHEN 'set' THEN + v_new_balance := p_amount; + v_new_available := p_amount - v_wallet.pending_balance; + ELSE + RETURN QUERY SELECT false, v_old_balance, v_old_available, + 'Invalid operation: ' || p_operation; + RETURN; + END CASE; + + -- Validar que no quede negativo + IF v_new_balance < 0 THEN + RETURN QUERY SELECT false, v_old_balance, v_old_available, + 'Insufficient balance (current: ' || v_old_balance::TEXT || ', required: ' || p_amount::TEXT || ')'; + RETURN; + END IF; + + IF v_new_available < 0 THEN + RETURN QUERY SELECT false, v_old_balance, v_old_available, + 'Insufficient available balance (current: ' || v_old_available::TEXT || ')'; + RETURN; + END IF; + + -- Validar min_balance si existe + IF v_wallet.min_balance IS NOT NULL AND v_new_available < v_wallet.min_balance THEN + RETURN QUERY SELECT false, v_old_balance, v_old_available, + 'Would violate minimum balance requirement (min: ' || v_wallet.min_balance::TEXT || ')'; + RETURN; + END IF; + + -- Actualizar wallet + UPDATE financial.wallets + SET + balance = v_new_balance, + available_balance = v_new_available, + last_transaction_at = NOW(), + updated_at = NOW() + WHERE id = p_wallet_id; + + -- Registrar en audit log + INSERT INTO financial.wallet_audit_log ( + wallet_id, + action, + actor_id, + actor_type, + old_values, + new_values, + balance_before, + balance_after, + transaction_id, + reason, + metadata + ) VALUES ( + p_wallet_id, + 'balance_updated', + p_actor_id, + p_actor_type, + jsonb_build_object( + 'balance', v_old_balance, + 'available_balance', v_old_available + ), + jsonb_build_object( + 'balance', v_new_balance, + 'available_balance', v_new_available + ), + v_old_balance, + v_new_balance, + p_transaction_id, + p_reason, + p_metadata + ); + + -- Retornar éxito + RETURN QUERY SELECT true, v_new_balance, v_new_available, NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.update_wallet_balance IS 'Safely update wallet balance with validation and audit trail'; + +-- Función helper para reservar fondos (pending balance) +CREATE OR REPLACE FUNCTION financial.reserve_wallet_funds( + p_wallet_id UUID, + p_amount DECIMAL(20,8), + p_reason TEXT DEFAULT NULL +) +RETURNS TABLE ( + success BOOLEAN, + new_available DECIMAL(20,8), + new_pending DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet RECORD; +BEGIN + -- Lock wallet + SELECT * INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found'; + RETURN; + END IF; + + IF v_wallet.status != 'active' THEN + RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance, + 'Wallet is not active'; + RETURN; + END IF; + + IF v_wallet.available_balance < p_amount THEN + RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance, + 'Insufficient available balance'; + RETURN; + END IF; + + -- Mover de available a pending + UPDATE financial.wallets + SET + available_balance = available_balance - p_amount, + pending_balance = pending_balance + p_amount, + updated_at = NOW() + WHERE id = p_wallet_id; + + -- Audit log + INSERT INTO financial.wallet_audit_log ( + wallet_id, action, actor_type, reason, + old_values, new_values + ) VALUES ( + p_wallet_id, 'balance_updated', 'system', p_reason, + jsonb_build_object('available', v_wallet.available_balance, 'pending', v_wallet.pending_balance), + jsonb_build_object('available', v_wallet.available_balance - p_amount, 'pending', v_wallet.pending_balance + p_amount) + ); + + RETURN QUERY SELECT + true, + v_wallet.available_balance - p_amount, + v_wallet.pending_balance + p_amount, + NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.reserve_wallet_funds IS 'Reserve funds by moving from available to pending balance'; + +-- Función helper para liberar fondos reservados +CREATE OR REPLACE FUNCTION financial.release_wallet_funds( + p_wallet_id UUID, + p_amount DECIMAL(20,8), + p_to_available BOOLEAN DEFAULT true, + p_reason TEXT DEFAULT NULL +) +RETURNS TABLE ( + success BOOLEAN, + new_available DECIMAL(20,8), + new_pending DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet RECORD; + v_new_balance DECIMAL(20,8); +BEGIN + -- Lock wallet + SELECT * INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found'; + RETURN; + END IF; + + IF v_wallet.pending_balance < p_amount THEN + RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance, + 'Insufficient pending balance'; + RETURN; + END IF; + + -- Liberar fondos + IF p_to_available THEN + -- Devolver a available + v_new_balance := v_wallet.balance; + UPDATE financial.wallets + SET + available_balance = available_balance + p_amount, + pending_balance = pending_balance - p_amount, + updated_at = NOW() + WHERE id = p_wallet_id; + ELSE + -- Remover completamente (ej: después de withdrawal exitoso) + v_new_balance := v_wallet.balance - p_amount; + UPDATE financial.wallets + SET + balance = balance - p_amount, + pending_balance = pending_balance - p_amount, + updated_at = NOW() + WHERE id = p_wallet_id; + END IF; + + -- Audit log + INSERT INTO financial.wallet_audit_log ( + wallet_id, action, actor_type, reason, metadata + ) VALUES ( + p_wallet_id, 'balance_updated', 'system', p_reason, + jsonb_build_object('released_amount', p_amount, 'to_available', p_to_available) + ); + + SELECT available_balance, pending_balance INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id; + + RETURN QUERY SELECT true, v_wallet.available_balance, v_wallet.pending_balance, NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.release_wallet_funds IS 'Release reserved funds back to available or remove from balance'; diff --git a/ddl/schemas/financial/functions/02-process_transaction.sql b/ddl/schemas/financial/functions/02-process_transaction.sql new file mode 100644 index 0000000..c1e5c8e --- /dev/null +++ b/ddl/schemas/financial/functions/02-process_transaction.sql @@ -0,0 +1,327 @@ +-- ===================================================== +-- ORBIQUANT IA - PROCESS TRANSACTION FUNCTION +-- ===================================================== +-- Description: Create and process wallet transactions atomically +-- Schema: financial +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.process_transaction( + p_wallet_id UUID, + p_transaction_type financial.transaction_type, + p_amount DECIMAL(20,8), + p_currency financial.currency_code, + p_fee DECIMAL(15,8) DEFAULT 0, + p_description TEXT DEFAULT NULL, + p_reference_id VARCHAR(100) DEFAULT NULL, + p_destination_wallet_id UUID DEFAULT NULL, + p_idempotency_key VARCHAR(255) DEFAULT NULL, + p_metadata JSONB DEFAULT '{}', + p_auto_complete BOOLEAN DEFAULT false +) +RETURNS TABLE ( + success BOOLEAN, + transaction_id UUID, + new_balance DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet RECORD; + v_tx_id UUID; + v_existing_status financial.transaction_status; -- Para validación de idempotencia + v_balance_before DECIMAL(20,8); + v_balance_after DECIMAL(20,8); + v_update_result RECORD; + v_dest_tx_id UUID; +BEGIN + -- Validar idempotency + IF p_idempotency_key IS NOT NULL THEN + SELECT id, status INTO v_tx_id, v_existing_status + FROM financial.wallet_transactions + WHERE idempotency_key = p_idempotency_key; + + IF FOUND THEN + -- Transacción ya existe + SELECT balance INTO v_balance_after + FROM financial.wallets + WHERE id = p_wallet_id; + + RETURN QUERY SELECT + true, + v_tx_id, + v_balance_after, + 'Transaction already exists (idempotent)'::TEXT; + RETURN; + END IF; + END IF; + + -- Lock wallet + SELECT * INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, NULL::UUID, 0::DECIMAL, 'Wallet not found'; + RETURN; + END IF; + + IF v_wallet.status != 'active' THEN + RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, + 'Wallet is not active (status: ' || v_wallet.status::TEXT || ')'; + RETURN; + END IF; + + -- Validar currency match + IF v_wallet.currency != p_currency THEN + RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, + 'Currency mismatch (wallet: ' || v_wallet.currency::TEXT || ', transaction: ' || p_currency::TEXT || ')'; + RETURN; + END IF; + + -- Validar destination para transfers + IF p_transaction_type IN ('transfer_out', 'transfer_in') AND p_destination_wallet_id IS NULL THEN + RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, + 'Transfer requires destination_wallet_id'; + RETURN; + END IF; + + -- No permitir self-transfers + IF p_destination_wallet_id = p_wallet_id THEN + RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, + 'Cannot transfer to same wallet'; + RETURN; + END IF; + + v_balance_before := v_wallet.balance; + + -- Crear transacción + INSERT INTO financial.wallet_transactions ( + wallet_id, + transaction_type, + status, + amount, + fee, + currency, + balance_before, + destination_wallet_id, + reference_id, + description, + metadata, + idempotency_key, + processed_at + ) VALUES ( + p_wallet_id, + p_transaction_type, + CASE WHEN p_auto_complete THEN 'completed'::financial.transaction_status + ELSE 'pending'::financial.transaction_status END, + p_amount, + p_fee, + p_currency, + v_balance_before, + p_destination_wallet_id, + p_reference_id, + p_description, + p_metadata, + p_idempotency_key, + CASE WHEN p_auto_complete THEN NOW() ELSE NULL END + ) + RETURNING id INTO v_tx_id; + + -- Si es auto_complete, procesar inmediatamente + IF p_auto_complete THEN + -- Determinar operación de balance + CASE p_transaction_type + WHEN 'deposit', 'transfer_in', 'earning', 'distribution', 'bonus', 'refund' THEN + -- Aumentar balance + SELECT * INTO v_update_result + FROM financial.update_wallet_balance( + p_wallet_id, + p_amount - p_fee, + 'add', + v_tx_id, + NULL, + 'system', + 'Transaction: ' || p_transaction_type::TEXT + ); + + WHEN 'withdrawal', 'transfer_out', 'fee' THEN + -- Disminuir balance + SELECT * INTO v_update_result + FROM financial.update_wallet_balance( + p_wallet_id, + p_amount + p_fee, + 'subtract', + v_tx_id, + NULL, + 'system', + 'Transaction: ' || p_transaction_type::TEXT + ); + + ELSE + RETURN QUERY SELECT false, v_tx_id, v_balance_before, + 'Unknown transaction type: ' || p_transaction_type::TEXT; + RETURN; + END CASE; + + -- Verificar éxito de actualización + IF NOT v_update_result.success THEN + -- Marcar transacción como fallida + UPDATE financial.wallet_transactions + SET + status = 'failed', + failed_reason = v_update_result.error_message, + failed_at = NOW() + WHERE id = v_tx_id; + + RETURN QUERY SELECT false, v_tx_id, v_balance_before, v_update_result.error_message; + RETURN; + END IF; + + v_balance_after := v_update_result.new_balance; + + -- Actualizar balance_after en transacción + UPDATE financial.wallet_transactions + SET + balance_after = v_balance_after, + completed_at = NOW() + WHERE id = v_tx_id; + + -- Si es transfer_out, crear transfer_in en destino + IF p_transaction_type = 'transfer_out' AND p_destination_wallet_id IS NOT NULL THEN + SELECT * INTO v_update_result + FROM financial.process_transaction( + p_destination_wallet_id, + 'transfer_in', + p_amount - p_fee, -- El fee lo paga el origen + p_currency, + 0, -- Sin fee adicional en destino + 'Transfer from wallet ' || p_wallet_id::TEXT, + p_reference_id, + p_wallet_id, -- Origen como destino inverso + p_idempotency_key || '_dest', -- Idempotency para destino + p_metadata, + true -- Auto-complete + ); + + IF v_update_result.success THEN + v_dest_tx_id := v_update_result.transaction_id; + + -- Vincular transacciones + UPDATE financial.wallet_transactions + SET related_transaction_id = v_dest_tx_id + WHERE id = v_tx_id; + + UPDATE financial.wallet_transactions + SET related_transaction_id = v_tx_id + WHERE id = v_dest_tx_id; + END IF; + END IF; + + -- Actualizar totals en wallet + IF p_transaction_type IN ('deposit', 'transfer_in') THEN + UPDATE financial.wallets + SET total_deposits = total_deposits + p_amount + WHERE id = p_wallet_id; + ELSIF p_transaction_type IN ('withdrawal', 'transfer_out') THEN + UPDATE financial.wallets + SET total_withdrawals = total_withdrawals + p_amount + WHERE id = p_wallet_id; + END IF; + + ELSE + -- Transaction pending, no balance update yet + v_balance_after := v_balance_before; + END IF; + + RETURN QUERY SELECT true, v_tx_id, v_balance_after, NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.process_transaction IS 'Create and optionally complete a wallet transaction atomically'; + +-- Función para completar transacción pendiente +CREATE OR REPLACE FUNCTION financial.complete_transaction( + p_transaction_id UUID +) +RETURNS TABLE ( + success BOOLEAN, + new_balance DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_tx RECORD; + v_update_result RECORD; +BEGIN + -- Lock transaction + SELECT * INTO v_tx + FROM financial.wallet_transactions + WHERE id = p_transaction_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 0::DECIMAL, 'Transaction not found'; + RETURN; + END IF; + + IF v_tx.status != 'pending' THEN + RETURN QUERY SELECT false, 0::DECIMAL, + 'Transaction is not pending (status: ' || v_tx.status::TEXT || ')'; + RETURN; + END IF; + + -- Procesar según tipo + CASE v_tx.transaction_type + WHEN 'deposit', 'transfer_in', 'earning', 'distribution', 'bonus', 'refund' THEN + SELECT * INTO v_update_result + FROM financial.update_wallet_balance( + v_tx.wallet_id, + v_tx.amount - v_tx.fee, + 'add', + p_transaction_id + ); + + WHEN 'withdrawal', 'transfer_out', 'fee' THEN + SELECT * INTO v_update_result + FROM financial.update_wallet_balance( + v_tx.wallet_id, + v_tx.amount + v_tx.fee, + 'subtract', + p_transaction_id + ); + + ELSE + RETURN QUERY SELECT false, 0::DECIMAL, 'Unknown transaction type'; + RETURN; + END CASE; + + IF NOT v_update_result.success THEN + -- Marcar como fallida + UPDATE financial.wallet_transactions + SET + status = 'failed', + failed_reason = v_update_result.error_message, + failed_at = NOW() + WHERE id = p_transaction_id; + + RETURN QUERY SELECT false, 0::DECIMAL, v_update_result.error_message; + RETURN; + END IF; + + -- Marcar como completada + UPDATE financial.wallet_transactions + SET + status = 'completed', + balance_after = v_update_result.new_balance, + completed_at = NOW(), + processed_at = COALESCE(processed_at, NOW()) + WHERE id = p_transaction_id; + + RETURN QUERY SELECT true, v_update_result.new_balance, NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.complete_transaction IS 'Complete a pending wallet transaction'; diff --git a/ddl/schemas/financial/functions/03-triggers.sql b/ddl/schemas/financial/functions/03-triggers.sql new file mode 100644 index 0000000..c245ff3 --- /dev/null +++ b/ddl/schemas/financial/functions/03-triggers.sql @@ -0,0 +1,278 @@ +-- ===================================================== +-- ORBIQUANT IA - FINANCIAL SCHEMA TRIGGERS +-- ===================================================== +-- Description: Automated triggers for data integrity and audit +-- Schema: financial +-- ===================================================== + +-- ===================================================== +-- TRIGGER: Update timestamps +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.update_timestamp() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + +-- Apply to all tables with updated_at +CREATE TRIGGER trigger_wallets_updated_at + BEFORE UPDATE ON financial.wallets + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_transactions_updated_at + BEFORE UPDATE ON financial.wallet_transactions + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_subscriptions_updated_at + BEFORE UPDATE ON financial.subscriptions + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_payments_updated_at + BEFORE UPDATE ON financial.payments + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_invoices_updated_at + BEFORE UPDATE ON financial.invoices + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_exchange_rates_updated_at + BEFORE UPDATE ON financial.currency_exchange_rates + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_wallet_limits_updated_at + BEFORE UPDATE ON financial.wallet_limits + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +-- ===================================================== +-- TRIGGER: Auto-generate invoice number +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.generate_invoice_number() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.invoice_number IS NULL THEN + NEW.invoice_number := 'INV-' || TO_CHAR(NOW(), 'YYYYMM') || '-' || + LPAD(nextval('financial.invoice_number_seq')::TEXT, 6, '0'); + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_invoice_number + BEFORE INSERT ON financial.invoices + FOR EACH ROW + EXECUTE FUNCTION financial.generate_invoice_number(); + +-- ===================================================== +-- TRIGGER: Validate wallet balance consistency +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.validate_wallet_balance() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + -- Validar que balance = available + pending + IF NEW.balance != (NEW.available_balance + NEW.pending_balance) THEN + RAISE EXCEPTION 'Balance consistency error: balance (%) != available (%) + pending (%)', + NEW.balance, NEW.available_balance, NEW.pending_balance; + END IF; + + -- Validar que no haya negativos + IF NEW.balance < 0 OR NEW.available_balance < 0 OR NEW.pending_balance < 0 THEN + RAISE EXCEPTION 'Negative balance detected: balance=%, available=%, pending=%', + NEW.balance, NEW.available_balance, NEW.pending_balance; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_wallet_balance_validation + BEFORE INSERT OR UPDATE ON financial.wallets + FOR EACH ROW + EXECUTE FUNCTION financial.validate_wallet_balance(); + +-- ===================================================== +-- TRIGGER: Audit wallet status changes +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.audit_wallet_status_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + -- Solo auditar si cambió el status + IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN + INSERT INTO financial.wallet_audit_log ( + wallet_id, + action, + actor_type, + old_values, + new_values, + reason + ) VALUES ( + NEW.id, + 'status_changed', + 'system', + jsonb_build_object('status', OLD.status), + jsonb_build_object('status', NEW.status), + 'Status changed from ' || OLD.status::TEXT || ' to ' || NEW.status::TEXT + ); + + -- Si se cerró, registrar timestamp + IF NEW.status = 'closed' AND NEW.closed_at IS NULL THEN + NEW.closed_at := NOW(); + END IF; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_wallet_status_audit + BEFORE UPDATE ON financial.wallets + FOR EACH ROW + EXECUTE FUNCTION financial.audit_wallet_status_change(); + +-- ===================================================== +-- TRIGGER: Prevent modification of completed transactions +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.protect_completed_transactions() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF OLD.status = 'completed' AND NEW.status != 'completed' THEN + RAISE EXCEPTION 'Cannot modify completed transaction %', OLD.id; + END IF; + + IF OLD.status = 'completed' AND ( + OLD.amount != NEW.amount OR + OLD.wallet_id != NEW.wallet_id OR + OLD.transaction_type != NEW.transaction_type + ) THEN + RAISE EXCEPTION 'Cannot modify core fields of completed transaction %', OLD.id; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_protect_completed_tx + BEFORE UPDATE ON financial.wallet_transactions + FOR EACH ROW + EXECUTE FUNCTION financial.protect_completed_transactions(); + +-- ===================================================== +-- TRIGGER: Set payment succeeded_at timestamp +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.set_payment_timestamps() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + -- Set succeeded_at when status changes to succeeded + IF NEW.status = 'succeeded' AND OLD.status != 'succeeded' THEN + NEW.succeeded_at := NOW(); + END IF; + + -- Set failed_at when status changes to failed + IF NEW.status = 'failed' AND OLD.status != 'failed' THEN + NEW.failed_at := NOW(); + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_payment_timestamps + BEFORE UPDATE ON financial.payments + FOR EACH ROW + EXECUTE FUNCTION financial.set_payment_timestamps(); + +-- ===================================================== +-- TRIGGER: Update subscription ended_at +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.set_subscription_ended_at() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + -- Set ended_at when status changes to cancelled and cancel_at_period_end is false + IF NEW.status = 'cancelled' AND + OLD.status != 'cancelled' AND + NOT NEW.cancel_at_period_end AND + NEW.ended_at IS NULL THEN + NEW.ended_at := NOW(); + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_subscription_ended_at + BEFORE UPDATE ON financial.subscriptions + FOR EACH ROW + EXECUTE FUNCTION financial.set_subscription_ended_at(); + +-- ===================================================== +-- TRIGGER: Validate transaction currency matches wallet +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.validate_transaction_currency() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet_currency financial.currency_code; +BEGIN + -- Get wallet currency + SELECT currency INTO v_wallet_currency + FROM financial.wallets + WHERE id = NEW.wallet_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Wallet % not found', NEW.wallet_id; + END IF; + + -- Validate currency match + IF NEW.currency != v_wallet_currency THEN + RAISE EXCEPTION 'Transaction currency (%) does not match wallet currency (%)', + NEW.currency, v_wallet_currency; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_transaction_currency_validation + BEFORE INSERT ON financial.wallet_transactions + FOR EACH ROW + EXECUTE FUNCTION financial.validate_transaction_currency(); + +COMMENT ON FUNCTION financial.update_timestamp IS 'Auto-update updated_at timestamp'; +COMMENT ON FUNCTION financial.generate_invoice_number IS 'Auto-generate invoice number with format INV-YYYYMM-XXXXXX'; +COMMENT ON FUNCTION financial.validate_wallet_balance IS 'Ensure balance = available + pending'; +COMMENT ON FUNCTION financial.audit_wallet_status_change IS 'Log wallet status changes to audit log'; +COMMENT ON FUNCTION financial.protect_completed_transactions IS 'Prevent modification of completed transactions'; +COMMENT ON FUNCTION financial.set_payment_timestamps IS 'Auto-set succeeded_at and failed_at timestamps'; +COMMENT ON FUNCTION financial.set_subscription_ended_at IS 'Auto-set ended_at when subscription is cancelled'; +COMMENT ON FUNCTION financial.validate_transaction_currency IS 'Ensure transaction currency matches wallet currency'; diff --git a/ddl/schemas/financial/functions/04-views.sql b/ddl/schemas/financial/functions/04-views.sql new file mode 100644 index 0000000..a179df8 --- /dev/null +++ b/ddl/schemas/financial/functions/04-views.sql @@ -0,0 +1,258 @@ +-- ===================================================== +-- ORBIQUANT IA - FINANCIAL SCHEMA VIEWS +-- ===================================================== +-- Description: Useful views for common financial queries +-- Schema: financial +-- ===================================================== + +-- ===================================================== +-- VIEW: Active user wallets summary +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_user_wallets_summary AS +SELECT + w.user_id, + w.wallet_type, + w.currency, + w.balance, + w.available_balance, + w.pending_balance, + w.status, + w.last_transaction_at, + w.total_deposits, + w.total_withdrawals, + w.created_at, + -- Transaction counts + (SELECT COUNT(*) + FROM financial.wallet_transactions wt + WHERE wt.wallet_id = w.id AND wt.status = 'completed') as total_transactions, + (SELECT COUNT(*) + FROM financial.wallet_transactions wt + WHERE wt.wallet_id = w.id AND wt.status = 'pending') as pending_transactions, + -- Latest transaction + (SELECT wt.created_at + FROM financial.wallet_transactions wt + WHERE wt.wallet_id = w.id + ORDER BY wt.created_at DESC + LIMIT 1) as last_tx_date +FROM financial.wallets w +WHERE w.status = 'active'; + +COMMENT ON VIEW financial.v_user_wallets_summary IS 'Active wallets with transaction statistics'; + +-- ===================================================== +-- VIEW: User total balance across all wallets (USD) +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_user_total_balance AS +SELECT + user_id, + SUM(CASE WHEN currency = 'USD' THEN balance ELSE 0 END) as total_usd, + SUM(CASE WHEN currency = 'MXN' THEN balance ELSE 0 END) as total_mxn, + SUM(CASE WHEN currency = 'EUR' THEN balance ELSE 0 END) as total_eur, + -- Totals by wallet type + SUM(CASE WHEN wallet_type = 'trading' AND currency = 'USD' THEN balance ELSE 0 END) as trading_usd, + SUM(CASE WHEN wallet_type = 'investment' AND currency = 'USD' THEN balance ELSE 0 END) as investment_usd, + SUM(CASE WHEN wallet_type = 'earnings' AND currency = 'USD' THEN balance ELSE 0 END) as earnings_usd, + SUM(CASE WHEN wallet_type = 'referral' AND currency = 'USD' THEN balance ELSE 0 END) as referral_usd, + COUNT(*) as wallet_count, + MAX(last_transaction_at) as last_activity +FROM financial.wallets +WHERE status = 'active' +GROUP BY user_id; + +COMMENT ON VIEW financial.v_user_total_balance IS 'Aggregated balance per user across all wallets'; + +-- ===================================================== +-- VIEW: Recent transactions (last 30 days) +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_recent_transactions AS +SELECT + wt.id, + wt.wallet_id, + w.user_id, + w.wallet_type, + wt.transaction_type, + wt.status, + wt.amount, + wt.fee, + wt.net_amount, + wt.currency, + wt.description, + wt.reference_id, + wt.balance_before, + wt.balance_after, + wt.created_at, + wt.completed_at, + -- Days since transaction + EXTRACT(DAY FROM NOW() - wt.created_at) as days_ago +FROM financial.wallet_transactions wt +JOIN financial.wallets w ON w.id = wt.wallet_id +WHERE wt.created_at >= NOW() - INTERVAL '30 days' +ORDER BY wt.created_at DESC; + +COMMENT ON VIEW financial.v_recent_transactions IS 'Wallet transactions from last 30 days'; + +-- ===================================================== +-- VIEW: Active subscriptions with user details +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_active_subscriptions AS +SELECT + s.id, + s.user_id, + s.plan, + s.status, + s.price, + s.currency, + s.billing_interval, + s.current_period_start, + s.current_period_end, + s.next_payment_at, + s.cancel_at_period_end, + s.stripe_subscription_id, + -- Days until renewal + EXTRACT(DAY FROM s.current_period_end - NOW()) as days_until_renewal, + -- Is in trial + (s.status = 'trialing') as is_trial, + -- Trial days remaining + CASE + WHEN s.trial_end IS NOT NULL THEN EXTRACT(DAY FROM s.trial_end - NOW()) + ELSE NULL + END as trial_days_remaining +FROM financial.subscriptions s +WHERE s.status IN ('active', 'trialing', 'past_due') +ORDER BY s.current_period_end ASC; + +COMMENT ON VIEW financial.v_active_subscriptions IS 'Active subscriptions with renewal information'; + +-- ===================================================== +-- VIEW: Pending payments +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_pending_payments AS +SELECT + p.id, + p.user_id, + p.subscription_id, + p.amount, + p.currency, + p.payment_method, + p.status, + p.description, + p.stripe_payment_intent_id, + p.created_at, + -- Days pending + EXTRACT(DAY FROM NOW() - p.created_at) as days_pending +FROM financial.payments p +WHERE p.status IN ('pending', 'processing') +ORDER BY p.created_at ASC; + +COMMENT ON VIEW financial.v_pending_payments IS 'Payments awaiting completion'; + +-- ===================================================== +-- VIEW: Unpaid invoices +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_unpaid_invoices AS +SELECT + i.id, + i.user_id, + i.invoice_number, + i.total, + i.amount_due, + i.currency, + i.due_date, + i.status, + i.invoice_date, + i.hosted_invoice_url, + -- Days overdue + CASE + WHEN i.due_date IS NOT NULL AND i.due_date < NOW() + THEN EXTRACT(DAY FROM NOW() - i.due_date) + ELSE 0 + END as days_overdue, + -- Is overdue + (i.due_date IS NOT NULL AND i.due_date < NOW()) as is_overdue +FROM financial.invoices i +WHERE i.status = 'open' AND i.paid = false +ORDER BY i.due_date ASC NULLS LAST; + +COMMENT ON VIEW financial.v_unpaid_invoices IS 'Open invoices with overdue status'; + +-- ===================================================== +-- VIEW: Daily transaction volume +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_daily_transaction_volume AS +SELECT + DATE(wt.created_at) as transaction_date, + wt.transaction_type, + wt.currency, + COUNT(*) as transaction_count, + SUM(wt.amount) as total_amount, + SUM(wt.fee) as total_fees, + SUM(wt.net_amount) as total_net_amount, + AVG(wt.amount) as avg_amount +FROM financial.wallet_transactions wt +WHERE wt.status = 'completed' + AND wt.created_at >= NOW() - INTERVAL '90 days' +GROUP BY DATE(wt.created_at), wt.transaction_type, wt.currency +ORDER BY transaction_date DESC, transaction_type; + +COMMENT ON VIEW financial.v_daily_transaction_volume IS 'Daily aggregated transaction statistics'; + +-- ===================================================== +-- VIEW: Wallet activity summary (last 7 days) +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_wallet_activity_7d AS +SELECT + w.id as wallet_id, + w.user_id, + w.wallet_type, + w.currency, + w.balance, + -- Transaction counts by type + COUNT(CASE WHEN wt.transaction_type = 'deposit' THEN 1 END) as deposits_7d, + COUNT(CASE WHEN wt.transaction_type = 'withdrawal' THEN 1 END) as withdrawals_7d, + COUNT(CASE WHEN wt.transaction_type IN ('transfer_in', 'transfer_out') THEN 1 END) as transfers_7d, + -- Amounts + SUM(CASE WHEN wt.transaction_type = 'deposit' THEN wt.amount ELSE 0 END) as deposit_amount_7d, + SUM(CASE WHEN wt.transaction_type = 'withdrawal' THEN wt.amount ELSE 0 END) as withdrawal_amount_7d, + -- Total activity + COUNT(wt.id) as total_transactions_7d +FROM financial.wallets w +LEFT JOIN financial.wallet_transactions wt ON wt.wallet_id = w.id + AND wt.created_at >= NOW() - INTERVAL '7 days' + AND wt.status = 'completed' +WHERE w.status = 'active' +GROUP BY w.id, w.user_id, w.wallet_type, w.currency, w.balance; + +COMMENT ON VIEW financial.v_wallet_activity_7d IS 'Wallet activity summary for last 7 days'; + +-- ===================================================== +-- VIEW: Subscription revenue metrics +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_subscription_revenue AS +SELECT + s.plan, + s.billing_interval, + s.currency, + COUNT(*) as active_count, + SUM(s.price) as total_monthly_value, + AVG(s.price) as avg_price, + -- MRR calculation (Monthly Recurring Revenue) + SUM(CASE + WHEN s.billing_interval = 'month' THEN s.price + WHEN s.billing_interval = 'year' THEN s.price / 12 + ELSE 0 + END) as monthly_recurring_revenue +FROM financial.subscriptions s +WHERE s.status IN ('active', 'trialing') +GROUP BY s.plan, s.billing_interval, s.currency +ORDER BY s.plan, s.billing_interval; + +COMMENT ON VIEW financial.v_subscription_revenue IS 'Subscription metrics and MRR calculation'; diff --git a/ddl/schemas/financial/tables/01-wallets.sql b/ddl/schemas/financial/tables/01-wallets.sql new file mode 100644 index 0000000..a34300e --- /dev/null +++ b/ddl/schemas/financial/tables/01-wallets.sql @@ -0,0 +1,85 @@ +-- ===================================================== +-- ORBIQUANT IA - WALLETS TABLE (UNIFIED) +-- ===================================================== +-- Description: Single source of truth for all wallet types +-- Schema: financial +-- ===================================================== +-- UNIFICACIÓN: Esta tabla reemplaza definiciones previas en: +-- - OQI-004 (trading schema) +-- - OQI-005 (investment schema) +-- - OQI-008 (financial schema - legacy) +-- ===================================================== + +CREATE TABLE financial.wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + + -- Tipo y estado + wallet_type financial.wallet_type NOT NULL, + status financial.wallet_status NOT NULL DEFAULT 'active', + + -- Balance (precisión de 8 decimales para soportar crypto) + balance DECIMAL(20,8) NOT NULL DEFAULT 0.00, + available_balance DECIMAL(20,8) NOT NULL DEFAULT 0.00, + pending_balance DECIMAL(20,8) NOT NULL DEFAULT 0.00, + + -- Moneda + currency financial.currency_code NOT NULL DEFAULT 'USD', + + -- Stripe integration (si aplica) + stripe_account_id VARCHAR(255), + stripe_customer_id VARCHAR(255), + + -- Límites operacionales + daily_withdrawal_limit DECIMAL(15,2), + monthly_withdrawal_limit DECIMAL(15,2), + min_balance DECIMAL(15,2) DEFAULT 0.00, + + -- Tracking de uso + last_transaction_at TIMESTAMPTZ, + total_deposits DECIMAL(20,8) DEFAULT 0.00, + total_withdrawals DECIMAL(20,8) DEFAULT 0.00, + + -- Metadata extensible + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT positive_balance CHECK (balance >= 0), + CONSTRAINT positive_available CHECK (available_balance >= 0), + CONSTRAINT positive_pending CHECK (pending_balance >= 0), + CONSTRAINT available_lte_balance CHECK (available_balance <= balance), + CONSTRAINT balance_equation CHECK (balance = available_balance + pending_balance), + CONSTRAINT positive_limits CHECK ( + (daily_withdrawal_limit IS NULL OR daily_withdrawal_limit > 0) AND + (monthly_withdrawal_limit IS NULL OR monthly_withdrawal_limit > 0) + ), + CONSTRAINT unique_user_wallet_type UNIQUE(user_id, wallet_type, currency), + CONSTRAINT closed_status_has_date CHECK ( + (status = 'closed' AND closed_at IS NOT NULL) OR + (status != 'closed' AND closed_at IS NULL) + ) +); + +-- Indexes para performance +CREATE INDEX idx_wallets_user_id ON financial.wallets(user_id); +CREATE INDEX idx_wallets_wallet_type ON financial.wallets(wallet_type); +CREATE INDEX idx_wallets_status ON financial.wallets(status) WHERE status = 'active'; +CREATE INDEX idx_wallets_currency ON financial.wallets(currency); +CREATE INDEX idx_wallets_user_type_currency ON financial.wallets(user_id, wallet_type, currency); +CREATE INDEX idx_wallets_stripe_account ON financial.wallets(stripe_account_id) WHERE stripe_account_id IS NOT NULL; +CREATE INDEX idx_wallets_last_transaction ON financial.wallets(last_transaction_at DESC NULLS LAST); + +-- Comments +COMMENT ON TABLE financial.wallets IS 'Unified wallet table - single source of truth for all wallet types'; +COMMENT ON COLUMN financial.wallets.wallet_type IS 'Type of wallet: trading, investment, earnings, referral'; +COMMENT ON COLUMN financial.wallets.balance IS 'Total balance = available + pending'; +COMMENT ON COLUMN financial.wallets.available_balance IS 'Balance available for immediate use'; +COMMENT ON COLUMN financial.wallets.pending_balance IS 'Balance in pending transactions'; +COMMENT ON COLUMN financial.wallets.stripe_account_id IS 'Stripe Connect account ID for payouts'; +COMMENT ON COLUMN financial.wallets.stripe_customer_id IS 'Stripe Customer ID for payments'; +COMMENT ON COLUMN financial.wallets.metadata IS 'Extensible JSON field for additional data'; diff --git a/ddl/schemas/financial/tables/02-wallet_transactions.sql b/ddl/schemas/financial/tables/02-wallet_transactions.sql new file mode 100644 index 0000000..3ec0e62 --- /dev/null +++ b/ddl/schemas/financial/tables/02-wallet_transactions.sql @@ -0,0 +1,101 @@ +-- ===================================================== +-- ORBIQUANT IA - WALLET TRANSACTIONS TABLE +-- ===================================================== +-- Description: Complete transaction history for all wallets +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.wallet_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Wallet relacionada + wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE RESTRICT, + + -- Tipo y estado + transaction_type financial.transaction_type NOT NULL, + status financial.transaction_status NOT NULL DEFAULT 'pending', + + -- Montos (precisión de 8 decimales) + amount DECIMAL(20,8) NOT NULL, + fee DECIMAL(15,8) DEFAULT 0, + net_amount DECIMAL(20,8) GENERATED ALWAYS AS (amount - fee) STORED, + + -- Moneda + currency financial.currency_code NOT NULL, + + -- Balances snapshot (para auditoría) + balance_before DECIMAL(20,8), + balance_after DECIMAL(20,8), + + -- Referencias externas + stripe_payment_intent_id VARCHAR(255), + stripe_transfer_id VARCHAR(255), + stripe_charge_id VARCHAR(255), + reference_id VARCHAR(100), -- ID de referencia interna (ej: trade_id, investment_id) + + -- Para transfers entre wallets + destination_wallet_id UUID REFERENCES financial.wallets(id) ON DELETE RESTRICT, + related_transaction_id UUID REFERENCES financial.wallet_transactions(id), -- TX relacionada (para transfers bidireccionales) + + -- Descripción y notas + description TEXT, + notes TEXT, + metadata JSONB DEFAULT '{}', + + -- Procesamiento + processed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + failed_reason TEXT, + + -- Idempotency + idempotency_key VARCHAR(255) UNIQUE, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT positive_amount CHECK (amount > 0), + CONSTRAINT positive_fee CHECK (fee >= 0), + CONSTRAINT fee_lte_amount CHECK (fee <= amount), + CONSTRAINT destination_for_transfers CHECK ( + (transaction_type IN ('transfer_in', 'transfer_out') AND destination_wallet_id IS NOT NULL) OR + (transaction_type NOT IN ('transfer_in', 'transfer_out')) + ), + CONSTRAINT no_self_transfer CHECK (wallet_id != destination_wallet_id), + CONSTRAINT completed_has_timestamp CHECK ( + (status = 'completed' AND completed_at IS NOT NULL) OR + (status != 'completed') + ), + CONSTRAINT failed_has_reason CHECK ( + (status = 'failed' AND failed_reason IS NOT NULL AND failed_at IS NOT NULL) OR + (status != 'failed') + ) +); + +-- Indexes para performance y queries comunes +CREATE INDEX idx_wt_wallet_id ON financial.wallet_transactions(wallet_id); +CREATE INDEX idx_wt_transaction_type ON financial.wallet_transactions(transaction_type); +CREATE INDEX idx_wt_status ON financial.wallet_transactions(status); +CREATE INDEX idx_wt_created_at ON financial.wallet_transactions(created_at DESC); +CREATE INDEX idx_wt_reference_id ON financial.wallet_transactions(reference_id) WHERE reference_id IS NOT NULL; +CREATE INDEX idx_wt_stripe_payment ON financial.wallet_transactions(stripe_payment_intent_id) WHERE stripe_payment_intent_id IS NOT NULL; +CREATE INDEX idx_wt_stripe_transfer ON financial.wallet_transactions(stripe_transfer_id) WHERE stripe_transfer_id IS NOT NULL; +CREATE INDEX idx_wt_destination ON financial.wallet_transactions(destination_wallet_id) WHERE destination_wallet_id IS NOT NULL; +CREATE INDEX idx_wt_wallet_status_created ON financial.wallet_transactions(wallet_id, status, created_at DESC); +CREATE INDEX idx_wt_idempotency ON financial.wallet_transactions(idempotency_key) WHERE idempotency_key IS NOT NULL; + +-- Composite index para queries de rango por wallet +CREATE INDEX idx_wt_wallet_date_range ON financial.wallet_transactions(wallet_id, created_at DESC) + WHERE status = 'completed'; + +-- Comments +COMMENT ON TABLE financial.wallet_transactions IS 'Complete transaction history for all wallet operations'; +COMMENT ON COLUMN financial.wallet_transactions.transaction_type IS 'Type of transaction: deposit, withdrawal, transfer, fee, etc.'; +COMMENT ON COLUMN financial.wallet_transactions.net_amount IS 'Amount after fees (computed column)'; +COMMENT ON COLUMN financial.wallet_transactions.reference_id IS 'Internal reference to related entity (trade, investment, etc.)'; +COMMENT ON COLUMN financial.wallet_transactions.destination_wallet_id IS 'Target wallet for transfer operations'; +COMMENT ON COLUMN financial.wallet_transactions.related_transaction_id IS 'Related transaction for bidirectional transfers'; +COMMENT ON COLUMN financial.wallet_transactions.idempotency_key IS 'Unique key to prevent duplicate transactions'; +COMMENT ON COLUMN financial.wallet_transactions.metadata IS 'Extensible JSON field for transaction-specific data'; diff --git a/ddl/schemas/financial/tables/03-subscriptions.sql b/ddl/schemas/financial/tables/03-subscriptions.sql new file mode 100644 index 0000000..483e3ca --- /dev/null +++ b/ddl/schemas/financial/tables/03-subscriptions.sql @@ -0,0 +1,107 @@ +-- ===================================================== +-- ORBIQUANT IA - SUBSCRIPTIONS TABLE +-- ===================================================== +-- Description: User subscription management with Stripe integration +-- Schema: financial +-- ===================================================== +-- DECISION: Planes en USD como moneda base +-- ===================================================== + +CREATE TABLE financial.subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + + -- Plan y estado + plan financial.subscription_plan NOT NULL, + status financial.subscription_status NOT NULL DEFAULT 'incomplete', + + -- Stripe integration + stripe_subscription_id VARCHAR(255) UNIQUE, + stripe_customer_id VARCHAR(255), + stripe_price_id VARCHAR(255), + stripe_product_id VARCHAR(255), + + -- Pricing + price DECIMAL(10,2) NOT NULL, + currency financial.currency_code NOT NULL DEFAULT 'USD', + billing_interval VARCHAR(20) NOT NULL DEFAULT 'month', -- month, year + + -- Billing periods + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + trial_start TIMESTAMPTZ, + trial_end TIMESTAMPTZ, + + -- Cancelación + cancelled_at TIMESTAMPTZ, + cancel_at_period_end BOOLEAN DEFAULT false, + cancellation_reason TEXT, + cancellation_feedback JSONB, + + -- Downgrade/Upgrade tracking + previous_plan financial.subscription_plan, + scheduled_plan financial.subscription_plan, + scheduled_plan_effective_at TIMESTAMPTZ, + + -- Payment tracking + last_payment_at TIMESTAMPTZ, + next_payment_at TIMESTAMPTZ, + failed_payment_count INTEGER DEFAULT 0, + + -- Features/Quotas (se pueden mover a tabla separada si crece) + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT positive_price CHECK (price >= 0), + CONSTRAINT valid_billing_interval CHECK (billing_interval IN ('month', 'year')), + CONSTRAINT trial_dates_order CHECK ( + (trial_start IS NULL AND trial_end IS NULL) OR + (trial_start IS NOT NULL AND trial_end IS NOT NULL AND trial_start < trial_end) + ), + CONSTRAINT period_dates_order CHECK ( + (current_period_start IS NULL AND current_period_end IS NULL) OR + (current_period_start IS NOT NULL AND current_period_end IS NOT NULL AND current_period_start < current_period_end) + ), + CONSTRAINT cancel_date_valid CHECK ( + (cancelled_at IS NULL) OR + (cancelled_at >= created_at) + ), + CONSTRAINT ended_when_cancelled CHECK ( + (ended_at IS NULL) OR + (cancelled_at IS NOT NULL AND ended_at >= cancelled_at) + ), + CONSTRAINT scheduled_plan_different CHECK ( + scheduled_plan IS NULL OR scheduled_plan != plan + ) +); + +-- Indexes +CREATE INDEX idx_subscriptions_user_id ON financial.subscriptions(user_id); +CREATE INDEX idx_subscriptions_status ON financial.subscriptions(status); +CREATE INDEX idx_subscriptions_plan ON financial.subscriptions(plan); +CREATE INDEX idx_subscriptions_stripe_sub ON financial.subscriptions(stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL; +CREATE INDEX idx_subscriptions_stripe_customer ON financial.subscriptions(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; +CREATE INDEX idx_subscriptions_active ON financial.subscriptions(user_id, status) WHERE status = 'active'; +CREATE INDEX idx_subscriptions_period_end ON financial.subscriptions(current_period_end) WHERE status = 'active'; +CREATE INDEX idx_subscriptions_trial_end ON financial.subscriptions(trial_end) WHERE status = 'trialing'; +CREATE INDEX idx_subscriptions_next_payment ON financial.subscriptions(next_payment_at) WHERE next_payment_at IS NOT NULL; + +-- Unique constraint: un usuario solo puede tener una suscripción activa a la vez +CREATE UNIQUE INDEX idx_subscriptions_user_active ON financial.subscriptions(user_id) + WHERE status IN ('active', 'trialing', 'past_due'); + +-- Comments +COMMENT ON TABLE financial.subscriptions IS 'User subscription management with Stripe integration'; +COMMENT ON COLUMN financial.subscriptions.plan IS 'Subscription plan tier'; +COMMENT ON COLUMN financial.subscriptions.status IS 'Subscription status (Stripe-compatible states)'; +COMMENT ON COLUMN financial.subscriptions.price IS 'Subscription price in specified currency'; +COMMENT ON COLUMN financial.subscriptions.billing_interval IS 'Billing frequency: month or year'; +COMMENT ON COLUMN financial.subscriptions.cancel_at_period_end IS 'If true, subscription will cancel at end of current period'; +COMMENT ON COLUMN financial.subscriptions.scheduled_plan IS 'Plan to switch to at scheduled_plan_effective_at'; +COMMENT ON COLUMN financial.subscriptions.failed_payment_count IS 'Number of consecutive failed payment attempts'; +COMMENT ON COLUMN financial.subscriptions.metadata IS 'Plan features, quotas, and additional configuration'; diff --git a/ddl/schemas/financial/tables/04-invoices.sql b/ddl/schemas/financial/tables/04-invoices.sql new file mode 100644 index 0000000..7892a41 --- /dev/null +++ b/ddl/schemas/financial/tables/04-invoices.sql @@ -0,0 +1,120 @@ +-- ===================================================== +-- ORBIQUANT IA - INVOICES TABLE +-- ===================================================== +-- Description: Invoice records for subscriptions and one-time charges +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + subscription_id UUID REFERENCES financial.subscriptions(id) ON DELETE SET NULL, + + -- Stripe integration + stripe_invoice_id VARCHAR(255) UNIQUE, + stripe_customer_id VARCHAR(255), + + -- Invoice details + invoice_number VARCHAR(100) UNIQUE, -- Número de factura interno + invoice_type financial.invoice_type NOT NULL, + status financial.invoice_status NOT NULL DEFAULT 'draft', + + -- Amounts + subtotal DECIMAL(15,2) NOT NULL DEFAULT 0, + tax DECIMAL(15,2) DEFAULT 0, + total DECIMAL(15,2) NOT NULL, + amount_paid DECIMAL(15,2) DEFAULT 0, + amount_due DECIMAL(15,2) GENERATED ALWAYS AS (total - amount_paid) STORED, + + currency financial.currency_code NOT NULL DEFAULT 'USD', + + -- Dates + invoice_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + due_date TIMESTAMPTZ, + period_start TIMESTAMPTZ, + period_end TIMESTAMPTZ, + + -- Payment + paid BOOLEAN DEFAULT false, + paid_at TIMESTAMPTZ, + attempted BOOLEAN DEFAULT false, + attempt_count INTEGER DEFAULT 0, + next_payment_attempt TIMESTAMPTZ, + + -- URLs + hosted_invoice_url TEXT, -- Stripe hosted invoice URL + invoice_pdf_url TEXT, + + -- Billing details + billing_email VARCHAR(255), + billing_name VARCHAR(255), + billing_address JSONB, + + -- Line items (si se quiere detalle simple) + line_items JSONB DEFAULT '[]', + + -- Metadata + description TEXT, + notes TEXT, + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finalized_at TIMESTAMPTZ, + voided_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT positive_subtotal CHECK (subtotal >= 0), + CONSTRAINT positive_tax CHECK (tax >= 0), + CONSTRAINT positive_total CHECK (total >= 0), + CONSTRAINT positive_paid CHECK (amount_paid >= 0), + CONSTRAINT paid_lte_total CHECK (amount_paid <= total), + CONSTRAINT total_equals_subtotal_plus_tax CHECK (total = subtotal + tax), + CONSTRAINT paid_has_timestamp CHECK ( + (paid = false) OR + (paid = true AND paid_at IS NOT NULL) + ), + CONSTRAINT finalized_for_open_paid CHECK ( + (status IN ('open', 'paid') AND finalized_at IS NOT NULL) OR + (status NOT IN ('open', 'paid')) + ), + CONSTRAINT void_has_timestamp CHECK ( + (status = 'void' AND voided_at IS NOT NULL) OR + (status != 'void') + ), + CONSTRAINT due_date_after_invoice CHECK ( + due_date IS NULL OR due_date >= invoice_date + ), + CONSTRAINT period_dates_order CHECK ( + (period_start IS NULL AND period_end IS NULL) OR + (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start < period_end) + ) +); + +-- Sequence para invoice numbers +CREATE SEQUENCE financial.invoice_number_seq START 1000; + +-- Indexes +CREATE INDEX idx_invoices_user_id ON financial.invoices(user_id); +CREATE INDEX idx_invoices_subscription_id ON financial.invoices(subscription_id) WHERE subscription_id IS NOT NULL; +CREATE INDEX idx_invoices_status ON financial.invoices(status); +CREATE INDEX idx_invoices_stripe_id ON financial.invoices(stripe_invoice_id) WHERE stripe_invoice_id IS NOT NULL; +CREATE INDEX idx_invoices_invoice_number ON financial.invoices(invoice_number); +CREATE INDEX idx_invoices_due_date ON financial.invoices(due_date) WHERE due_date IS NOT NULL AND status = 'open'; +CREATE INDEX idx_invoices_invoice_date ON financial.invoices(invoice_date DESC); +CREATE INDEX idx_invoices_user_date ON financial.invoices(user_id, invoice_date DESC); +CREATE INDEX idx_invoices_unpaid ON financial.invoices(user_id, status) WHERE paid = false AND status = 'open'; +CREATE INDEX idx_invoices_period ON financial.invoices(period_start, period_end) WHERE period_start IS NOT NULL; + +-- Comments +COMMENT ON TABLE financial.invoices IS 'Invoice records for subscriptions and one-time charges'; +COMMENT ON COLUMN financial.invoices.invoice_number IS 'Internal unique invoice number (auto-generated)'; +COMMENT ON COLUMN financial.invoices.invoice_type IS 'Type: subscription, one_time, or usage-based'; +COMMENT ON COLUMN financial.invoices.amount_due IS 'Computed: total - amount_paid'; +COMMENT ON COLUMN financial.invoices.line_items IS 'JSON array of invoice line items with description, amount, quantity'; +COMMENT ON COLUMN financial.invoices.billing_address IS 'JSON object with billing address details'; +COMMENT ON COLUMN financial.invoices.hosted_invoice_url IS 'URL to Stripe-hosted invoice page'; +COMMENT ON COLUMN financial.invoices.attempt_count IS 'Number of payment attempts made'; diff --git a/ddl/schemas/financial/tables/05-payments.sql b/ddl/schemas/financial/tables/05-payments.sql new file mode 100644 index 0000000..f3f6175 --- /dev/null +++ b/ddl/schemas/financial/tables/05-payments.sql @@ -0,0 +1,86 @@ +-- ===================================================== +-- ORBIQUANT IA - PAYMENTS TABLE +-- ===================================================== +-- Description: Payment transaction records +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + subscription_id UUID REFERENCES financial.subscriptions(id) ON DELETE SET NULL, + invoice_id UUID REFERENCES financial.invoices(id) ON DELETE SET NULL, + wallet_transaction_id UUID REFERENCES financial.wallet_transactions(id) ON DELETE SET NULL, + + -- Stripe integration + stripe_payment_intent_id VARCHAR(255) UNIQUE, + stripe_charge_id VARCHAR(255), + stripe_payment_method_id VARCHAR(255), + + -- Payment details + amount DECIMAL(15,2) NOT NULL, + currency financial.currency_code NOT NULL, + payment_method financial.payment_method NOT NULL, + status financial.payment_status NOT NULL DEFAULT 'pending', + + -- Descripción + description TEXT, + statement_descriptor VARCHAR(255), -- Lo que ve el usuario en su estado de cuenta + + -- Refunds + refunded BOOLEAN DEFAULT false, + refund_amount DECIMAL(15,2), + refund_reason TEXT, + refunded_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Error handling + failure_code VARCHAR(100), + failure_message TEXT, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + succeeded_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT positive_amount CHECK (amount > 0), + CONSTRAINT positive_refund CHECK (refund_amount IS NULL OR refund_amount > 0), + CONSTRAINT refund_lte_amount CHECK (refund_amount IS NULL OR refund_amount <= amount), + CONSTRAINT refunded_has_data CHECK ( + (refunded = false AND refund_amount IS NULL AND refunded_at IS NULL) OR + (refunded = true AND refund_amount IS NOT NULL AND refunded_at IS NOT NULL) + ), + CONSTRAINT succeeded_has_timestamp CHECK ( + (status = 'succeeded' AND succeeded_at IS NOT NULL) OR + (status != 'succeeded') + ), + CONSTRAINT failed_has_data CHECK ( + (status = 'failed' AND failed_at IS NOT NULL) OR + (status != 'failed') + ) +); + +-- Indexes +CREATE INDEX idx_payments_user_id ON financial.payments(user_id); +CREATE INDEX idx_payments_subscription_id ON financial.payments(subscription_id) WHERE subscription_id IS NOT NULL; +CREATE INDEX idx_payments_invoice_id ON financial.payments(invoice_id) WHERE invoice_id IS NOT NULL; +CREATE INDEX idx_payments_status ON financial.payments(status); +CREATE INDEX idx_payments_stripe_intent ON financial.payments(stripe_payment_intent_id) WHERE stripe_payment_intent_id IS NOT NULL; +CREATE INDEX idx_payments_created_at ON financial.payments(created_at DESC); +CREATE INDEX idx_payments_user_created ON financial.payments(user_id, created_at DESC); +CREATE INDEX idx_payments_payment_method ON financial.payments(payment_method); +CREATE INDEX idx_payments_refunded ON financial.payments(refunded) WHERE refunded = true; + +-- Comments +COMMENT ON TABLE financial.payments IS 'Payment transaction records with Stripe integration'; +COMMENT ON COLUMN financial.payments.stripe_payment_intent_id IS 'Stripe PaymentIntent ID'; +COMMENT ON COLUMN financial.payments.payment_method IS 'Payment method used: card, bank_transfer, crypto, etc.'; +COMMENT ON COLUMN financial.payments.statement_descriptor IS 'Text shown on customer bank statement'; +COMMENT ON COLUMN financial.payments.wallet_transaction_id IS 'Related wallet transaction if payment funds a wallet'; +COMMENT ON COLUMN financial.payments.metadata IS 'Additional payment metadata and context'; diff --git a/ddl/schemas/financial/tables/06-wallet_audit_log.sql b/ddl/schemas/financial/tables/06-wallet_audit_log.sql new file mode 100644 index 0000000..05e2547 --- /dev/null +++ b/ddl/schemas/financial/tables/06-wallet_audit_log.sql @@ -0,0 +1,68 @@ +-- ===================================================== +-- ORBIQUANT IA - WALLET AUDIT LOG TABLE +-- ===================================================== +-- Description: Audit trail for all wallet state changes +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.wallet_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Wallet referencia + wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE CASCADE, + + -- Acción + action financial.audit_action NOT NULL, + + -- Actor (quien realizó el cambio) + actor_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + actor_type VARCHAR(50) DEFAULT 'user', -- user, system, admin, api + + -- Cambios registrados + old_values JSONB, + new_values JSONB, + + -- Balance snapshot + balance_before DECIMAL(20,8), + balance_after DECIMAL(20,8), + + -- Transacción relacionada (si aplica) + transaction_id UUID REFERENCES financial.wallet_transactions(id) ON DELETE SET NULL, + + -- Contexto + reason TEXT, + metadata JSONB DEFAULT '{}', + + -- IP y user agent (para auditoría de seguridad) + ip_address INET, + user_agent TEXT, + + -- Timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT balance_change_has_amounts CHECK ( + (action = 'balance_updated' AND balance_before IS NOT NULL AND balance_after IS NOT NULL) OR + (action != 'balance_updated') + ) +); + +-- Indexes +CREATE INDEX idx_wal_wallet_id ON financial.wallet_audit_log(wallet_id); +CREATE INDEX idx_wal_action ON financial.wallet_audit_log(action); +CREATE INDEX idx_wal_actor_id ON financial.wallet_audit_log(actor_id) WHERE actor_id IS NOT NULL; +CREATE INDEX idx_wal_created_at ON financial.wallet_audit_log(created_at DESC); +CREATE INDEX idx_wal_wallet_created ON financial.wallet_audit_log(wallet_id, created_at DESC); +CREATE INDEX idx_wal_transaction_id ON financial.wallet_audit_log(transaction_id) WHERE transaction_id IS NOT NULL; + +-- Partitioning hint: Esta tabla puede crecer mucho, considerar particionamiento por created_at +-- PARTITION BY RANGE (created_at); + +-- Comments +COMMENT ON TABLE financial.wallet_audit_log IS 'Immutable audit trail for all wallet state changes'; +COMMENT ON COLUMN financial.wallet_audit_log.action IS 'Type of action performed on wallet'; +COMMENT ON COLUMN financial.wallet_audit_log.actor_id IS 'User who performed the action (NULL for system actions)'; +COMMENT ON COLUMN financial.wallet_audit_log.actor_type IS 'Type of actor: user, system, admin, api'; +COMMENT ON COLUMN financial.wallet_audit_log.old_values IS 'JSON snapshot of values before change'; +COMMENT ON COLUMN financial.wallet_audit_log.new_values IS 'JSON snapshot of values after change'; +COMMENT ON COLUMN financial.wallet_audit_log.metadata IS 'Additional context and metadata'; diff --git a/ddl/schemas/financial/tables/07-currency_exchange_rates.sql b/ddl/schemas/financial/tables/07-currency_exchange_rates.sql new file mode 100644 index 0000000..d184bed --- /dev/null +++ b/ddl/schemas/financial/tables/07-currency_exchange_rates.sql @@ -0,0 +1,132 @@ +-- ===================================================== +-- ORBIQUANT IA - CURRENCY EXCHANGE RATES TABLE +-- ===================================================== +-- Description: Historical exchange rates for multi-currency support +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.currency_exchange_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Par de monedas + from_currency financial.currency_code NOT NULL, + to_currency financial.currency_code NOT NULL, + + -- Tasa de cambio + rate DECIMAL(18,8) NOT NULL, + + -- Fuente de datos + source VARCHAR(100) NOT NULL DEFAULT 'manual', -- manual, api, stripe, coinbase, etc. + provider VARCHAR(100), -- nombre del proveedor si es API + + -- Validez temporal + valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + valid_to TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT positive_rate CHECK (rate > 0), + CONSTRAINT different_currencies CHECK (from_currency != to_currency), + CONSTRAINT valid_dates_order CHECK ( + valid_to IS NULL OR valid_to > valid_from + ), + CONSTRAINT unique_rate_period UNIQUE(from_currency, to_currency, valid_from) +); + +-- Indexes +CREATE INDEX idx_cer_currencies ON financial.currency_exchange_rates(from_currency, to_currency); +CREATE INDEX idx_cer_valid_from ON financial.currency_exchange_rates(valid_from DESC); +-- Index for currently valid rates (without time-based predicate for immutability) +CREATE INDEX idx_cer_valid_period ON financial.currency_exchange_rates(from_currency, to_currency, valid_from DESC) + WHERE valid_to IS NULL; +CREATE INDEX idx_cer_source ON financial.currency_exchange_rates(source); + +-- Comments +COMMENT ON TABLE financial.currency_exchange_rates IS 'Historical exchange rates for currency conversion'; +COMMENT ON COLUMN financial.currency_exchange_rates.rate IS 'Exchange rate: 1 from_currency = rate * to_currency'; +COMMENT ON COLUMN financial.currency_exchange_rates.source IS 'Source of exchange rate data'; +COMMENT ON COLUMN financial.currency_exchange_rates.valid_from IS 'Start of rate validity period'; +COMMENT ON COLUMN financial.currency_exchange_rates.valid_to IS 'End of rate validity period (NULL = currently valid)'; +COMMENT ON COLUMN financial.currency_exchange_rates.metadata IS 'Additional rate metadata (bid, ask, spread, etc.)'; + +-- Función helper para obtener tasa de cambio actual +CREATE OR REPLACE FUNCTION financial.get_exchange_rate( + p_from_currency financial.currency_code, + p_to_currency financial.currency_code, + p_at_time TIMESTAMPTZ DEFAULT NOW() +) +RETURNS DECIMAL(18,8) +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_rate DECIMAL(18,8); +BEGIN + -- Si son la misma moneda, retornar 1 + IF p_from_currency = p_to_currency THEN + RETURN 1.0; + END IF; + + -- Buscar tasa de cambio válida + SELECT rate INTO v_rate + FROM financial.currency_exchange_rates + WHERE from_currency = p_from_currency + AND to_currency = p_to_currency + AND valid_from <= p_at_time + AND (valid_to IS NULL OR valid_to > p_at_time) + ORDER BY valid_from DESC + LIMIT 1; + + -- Si no se encuentra, intentar inversa + IF v_rate IS NULL THEN + SELECT 1.0 / rate INTO v_rate + FROM financial.currency_exchange_rates + WHERE from_currency = p_to_currency + AND to_currency = p_from_currency + AND valid_from <= p_at_time + AND (valid_to IS NULL OR valid_to > p_at_time) + ORDER BY valid_from DESC + LIMIT 1; + END IF; + + -- Si aún no hay tasa, retornar NULL + RETURN v_rate; +END; +$$; + +COMMENT ON FUNCTION financial.get_exchange_rate IS 'Get exchange rate between currencies at specific time'; + +-- Función para convertir montos +CREATE OR REPLACE FUNCTION financial.convert_currency( + p_amount DECIMAL, + p_from_currency financial.currency_code, + p_to_currency financial.currency_code, + p_at_time TIMESTAMPTZ DEFAULT NOW() +) +RETURNS DECIMAL(20,8) +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_rate DECIMAL(18,8); +BEGIN + -- Obtener tasa de cambio + v_rate := financial.get_exchange_rate(p_from_currency, p_to_currency, p_at_time); + + -- Si no hay tasa, retornar NULL + IF v_rate IS NULL THEN + RETURN NULL; + END IF; + + -- Convertir y retornar + RETURN p_amount * v_rate; +END; +$$; + +COMMENT ON FUNCTION financial.convert_currency IS 'Convert amount between currencies at specific time'; diff --git a/ddl/schemas/financial/tables/08-wallet_limits.sql b/ddl/schemas/financial/tables/08-wallet_limits.sql new file mode 100644 index 0000000..ec47298 --- /dev/null +++ b/ddl/schemas/financial/tables/08-wallet_limits.sql @@ -0,0 +1,102 @@ +-- ===================================================== +-- ORBIQUANT IA - WALLET LIMITS TABLE +-- ===================================================== +-- Description: Configurable limits and thresholds for wallets +-- Schema: financial +-- ===================================================== +-- Separado de wallets para permitir límites más complejos +-- y dinámicos basados en plan, nivel de verificación, etc. +-- ===================================================== + +CREATE TABLE financial.wallet_limits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Wallet o configuración global + wallet_id UUID REFERENCES financial.wallets(id) ON DELETE CASCADE, + wallet_type financial.wallet_type, -- Para límites por tipo de wallet + subscription_plan financial.subscription_plan, -- Para límites por plan + + -- Límites de transacción única + min_deposit DECIMAL(15,2), + max_deposit DECIMAL(15,2), + min_withdrawal DECIMAL(15,2), + max_withdrawal DECIMAL(15,2), + min_transfer DECIMAL(15,2), + max_transfer DECIMAL(15,2), + + -- Límites periódicos + daily_deposit_limit DECIMAL(15,2), + daily_withdrawal_limit DECIMAL(15,2), + daily_transfer_limit DECIMAL(15,2), + + weekly_deposit_limit DECIMAL(15,2), + weekly_withdrawal_limit DECIMAL(15,2), + weekly_transfer_limit DECIMAL(15,2), + + monthly_deposit_limit DECIMAL(15,2), + monthly_withdrawal_limit DECIMAL(15,2), + monthly_transfer_limit DECIMAL(15,2), + + -- Límites de volumen + max_pending_transactions INTEGER, + max_daily_transaction_count INTEGER, + + -- Balance limits + min_balance DECIMAL(15,2) DEFAULT 0, + max_balance DECIMAL(15,2), + + -- Moneda de los límites + currency financial.currency_code NOT NULL DEFAULT 'USD', + + -- Prioridad (mayor número = mayor prioridad) + priority INTEGER DEFAULT 0, + + -- Vigencia + active BOOLEAN DEFAULT true, + valid_from TIMESTAMPTZ DEFAULT NOW(), + valid_to TIMESTAMPTZ, + + -- Metadata + description TEXT, + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT wallet_or_type_or_plan CHECK ( + (wallet_id IS NOT NULL AND wallet_type IS NULL AND subscription_plan IS NULL) OR + (wallet_id IS NULL AND wallet_type IS NOT NULL AND subscription_plan IS NULL) OR + (wallet_id IS NULL AND wallet_type IS NULL AND subscription_plan IS NOT NULL) + ), + CONSTRAINT positive_limits CHECK ( + (min_deposit IS NULL OR min_deposit > 0) AND + (max_deposit IS NULL OR max_deposit > 0) AND + (min_withdrawal IS NULL OR min_withdrawal > 0) AND + (max_withdrawal IS NULL OR max_withdrawal > 0) AND + (min_transfer IS NULL OR min_transfer > 0) AND + (max_transfer IS NULL OR max_transfer > 0) + ), + CONSTRAINT min_max_deposit CHECK (min_deposit IS NULL OR max_deposit IS NULL OR min_deposit <= max_deposit), + CONSTRAINT min_max_withdrawal CHECK (min_withdrawal IS NULL OR max_withdrawal IS NULL OR min_withdrawal <= max_withdrawal), + CONSTRAINT min_max_transfer CHECK (min_transfer IS NULL OR max_transfer IS NULL OR min_transfer <= max_transfer), + CONSTRAINT valid_dates_order CHECK (valid_to IS NULL OR valid_to > valid_from) +); + +-- Indexes +CREATE INDEX idx_wl_wallet_id ON financial.wallet_limits(wallet_id) WHERE wallet_id IS NOT NULL; +CREATE INDEX idx_wl_wallet_type ON financial.wallet_limits(wallet_type) WHERE wallet_type IS NOT NULL; +CREATE INDEX idx_wl_subscription_plan ON financial.wallet_limits(subscription_plan) WHERE subscription_plan IS NOT NULL; +CREATE INDEX idx_wl_active ON financial.wallet_limits(active, priority DESC) WHERE active = true; +-- Index for currently valid limits (without time-based predicate for immutability) +CREATE INDEX idx_wl_valid_period ON financial.wallet_limits(valid_from, valid_to) + WHERE active = true AND valid_to IS NULL; + +-- Comments +COMMENT ON TABLE financial.wallet_limits IS 'Configurable transaction limits for wallets'; +COMMENT ON COLUMN financial.wallet_limits.wallet_id IS 'Specific wallet (takes highest priority)'; +COMMENT ON COLUMN financial.wallet_limits.wallet_type IS 'Limits for all wallets of this type'; +COMMENT ON COLUMN financial.wallet_limits.subscription_plan IS 'Limits based on subscription plan'; +COMMENT ON COLUMN financial.wallet_limits.priority IS 'Higher number = higher priority when multiple limits apply'; +COMMENT ON COLUMN financial.wallet_limits.currency IS 'Currency for all limit amounts'; diff --git a/ddl/schemas/financial/tables/09-customers.sql b/ddl/schemas/financial/tables/09-customers.sql new file mode 100644 index 0000000..394add5 --- /dev/null +++ b/ddl/schemas/financial/tables/09-customers.sql @@ -0,0 +1,68 @@ +-- ============================================================================ +-- FINANCIAL SCHEMA - Tabla: customers +-- ============================================================================ +-- Clientes de Stripe y datos de facturacion +-- Vincula usuarios con su informacion de pago +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS financial.customers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relacion con usuario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Stripe + stripe_customer_id VARCHAR(100) UNIQUE, + stripe_default_payment_method_id VARCHAR(100), + + -- Datos de facturacion + billing_name VARCHAR(255), + billing_email VARCHAR(255), + billing_phone VARCHAR(50), + + -- Direccion de facturacion + billing_address_line1 VARCHAR(255), + billing_address_line2 VARCHAR(255), + billing_city VARCHAR(100), + billing_state VARCHAR(100), + billing_postal_code VARCHAR(20), + billing_country VARCHAR(2), -- ISO 3166-1 alpha-2 + + -- Datos fiscales (Mexico) + tax_id VARCHAR(20), -- RFC + tax_id_type VARCHAR(20) DEFAULT 'mx_rfc', -- Tipo de ID fiscal + legal_name VARCHAR(255), -- Razon social + + -- Preferencias + currency financial.currency_code NOT NULL DEFAULT 'USD', + locale VARCHAR(10) DEFAULT 'en-US', + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT TRUE, + delinquent BOOLEAN NOT NULL DEFAULT FALSE, + delinquent_since TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT uq_customers_user UNIQUE(user_id), + CONSTRAINT chk_valid_country CHECK (billing_country IS NULL OR LENGTH(billing_country) = 2) +); + +-- Indices +CREATE INDEX idx_customers_user ON financial.customers(user_id); +CREATE INDEX idx_customers_stripe ON financial.customers(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; +CREATE INDEX idx_customers_email ON financial.customers(billing_email) WHERE billing_email IS NOT NULL; +CREATE INDEX idx_customers_delinquent ON financial.customers(delinquent) WHERE delinquent = TRUE; +CREATE INDEX idx_customers_tax_id ON financial.customers(tax_id) WHERE tax_id IS NOT NULL; + +-- Comentarios +COMMENT ON TABLE financial.customers IS 'Clientes de Stripe con datos de facturacion'; +COMMENT ON COLUMN financial.customers.stripe_customer_id IS 'ID del cliente en Stripe (cus_xxx)'; +COMMENT ON COLUMN financial.customers.tax_id IS 'RFC para Mexico, VAT para EU, etc.'; +COMMENT ON COLUMN financial.customers.delinquent IS 'True si tiene pagos vencidos'; diff --git a/ddl/schemas/financial/tables/10-payment_methods.sql b/ddl/schemas/financial/tables/10-payment_methods.sql new file mode 100644 index 0000000..b2cd2d1 --- /dev/null +++ b/ddl/schemas/financial/tables/10-payment_methods.sql @@ -0,0 +1,180 @@ +-- ============================================================================ +-- FINANCIAL SCHEMA - Tabla: payment_methods +-- ============================================================================ +-- Metodos de pago guardados por usuarios para pagos recurrentes +-- Integra con Stripe para almacenamiento seguro de tarjetas y cuentas +-- ============================================================================ + +-- Enum para tipo de metodo de pago guardado +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'saved_payment_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'financial')) THEN + CREATE TYPE financial.saved_payment_type AS ENUM ( + 'card', + 'bank_account', + 'sepa_debit', + 'crypto_wallet' + ); + END IF; +END$$; + +-- Enum para estado del metodo de pago +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'payment_method_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'financial')) THEN + CREATE TYPE financial.payment_method_status AS ENUM ( + 'pending_verification', + 'active', + 'expired', + 'failed', + 'removed' + ); + END IF; +END$$; + +CREATE TABLE IF NOT EXISTS financial.payment_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + customer_id UUID REFERENCES financial.customers(id) ON DELETE SET NULL, + + -- Stripe integration + stripe_payment_method_id VARCHAR(100) UNIQUE, + stripe_fingerprint VARCHAR(100), -- Para detectar duplicados + + -- Tipo y estado + payment_type financial.saved_payment_type NOT NULL, + status financial.payment_method_status NOT NULL DEFAULT 'pending_verification', + + -- Informacion del metodo (datos no sensibles) + -- Para tarjetas: last4, brand, exp_month, exp_year + -- Para bancos: last4, bank_name, account_type + display_info JSONB NOT NULL DEFAULT '{}', + + -- Metodo por defecto + is_default BOOLEAN NOT NULL DEFAULT FALSE, + + -- Datos de tarjeta (solo informacion visible) + card_brand VARCHAR(20), -- 'visa', 'mastercard', 'amex', etc. + card_last4 VARCHAR(4), + card_exp_month INTEGER, + card_exp_year INTEGER, + card_funding VARCHAR(20), -- 'credit', 'debit', 'prepaid' + + -- Datos de cuenta bancaria (solo informacion visible) + bank_name VARCHAR(100), + bank_last4 VARCHAR(4), + bank_account_type VARCHAR(20), -- 'checking', 'savings' + + -- Datos de crypto wallet + crypto_network VARCHAR(20), -- 'ethereum', 'bitcoin', 'polygon' + crypto_address_last8 VARCHAR(8), + + -- Verificacion + verified_at TIMESTAMPTZ, + verification_method VARCHAR(50), -- 'micro_deposits', '3d_secure', 'instant' + + -- Billing address (para 3DS y validacion) + billing_address JSONB DEFAULT '{}', + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, -- Para tarjetas con fecha de expiracion + removed_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT chk_card_info CHECK ( + payment_type != 'card' OR ( + card_brand IS NOT NULL AND + card_last4 IS NOT NULL AND + card_exp_month IS NOT NULL AND + card_exp_year IS NOT NULL + ) + ), + CONSTRAINT chk_bank_info CHECK ( + payment_type != 'bank_account' OR ( + bank_name IS NOT NULL AND + bank_last4 IS NOT NULL + ) + ), + CONSTRAINT chk_crypto_info CHECK ( + payment_type != 'crypto_wallet' OR ( + crypto_network IS NOT NULL AND + crypto_address_last8 IS NOT NULL + ) + ), + CONSTRAINT chk_valid_exp_month CHECK ( + card_exp_month IS NULL OR (card_exp_month >= 1 AND card_exp_month <= 12) + ), + CONSTRAINT chk_valid_exp_year CHECK ( + card_exp_year IS NULL OR card_exp_year >= 2024 + ) +); + +-- Indices +CREATE INDEX idx_payment_methods_user ON financial.payment_methods(user_id); +CREATE INDEX idx_payment_methods_customer ON financial.payment_methods(customer_id); +CREATE INDEX idx_payment_methods_stripe ON financial.payment_methods(stripe_payment_method_id); +CREATE INDEX idx_payment_methods_status ON financial.payment_methods(status); +CREATE INDEX idx_payment_methods_default ON financial.payment_methods(user_id, is_default) + WHERE is_default = TRUE; +CREATE INDEX idx_payment_methods_fingerprint ON financial.payment_methods(stripe_fingerprint) + WHERE stripe_fingerprint IS NOT NULL; +CREATE INDEX idx_payment_methods_active ON financial.payment_methods(user_id, payment_type) + WHERE status = 'active'; + +-- Comentarios +COMMENT ON TABLE financial.payment_methods IS 'Metodos de pago guardados por usuarios con integracion Stripe'; +COMMENT ON COLUMN financial.payment_methods.stripe_payment_method_id IS 'ID del PaymentMethod en Stripe'; +COMMENT ON COLUMN financial.payment_methods.stripe_fingerprint IS 'Fingerprint para detectar tarjetas duplicadas'; +COMMENT ON COLUMN financial.payment_methods.display_info IS 'Informacion visible del metodo para UI'; +COMMENT ON COLUMN financial.payment_methods.is_default IS 'Metodo de pago por defecto del usuario'; +COMMENT ON COLUMN financial.payment_methods.billing_address IS 'Direccion de facturacion para 3D Secure'; + +-- Trigger para asegurar un solo metodo por defecto por usuario +CREATE OR REPLACE FUNCTION financial.ensure_single_default_payment_method() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_default = TRUE THEN + UPDATE financial.payment_methods + SET is_default = FALSE, updated_at = NOW() + WHERE user_id = NEW.user_id + AND id != NEW.id + AND is_default = TRUE; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tr_ensure_single_default_payment_method + BEFORE INSERT OR UPDATE OF is_default ON financial.payment_methods + FOR EACH ROW + WHEN (NEW.is_default = TRUE) + EXECUTE FUNCTION financial.ensure_single_default_payment_method(); + +-- Funcion para marcar tarjetas expiradas +CREATE OR REPLACE FUNCTION financial.check_expired_cards() +RETURNS INTEGER AS $$ +DECLARE + v_count INTEGER; +BEGIN + UPDATE financial.payment_methods + SET status = 'expired', updated_at = NOW() + WHERE payment_type = 'card' + AND status = 'active' + AND ( + card_exp_year < EXTRACT(YEAR FROM CURRENT_DATE) OR + (card_exp_year = EXTRACT(YEAR FROM CURRENT_DATE) AND card_exp_month < EXTRACT(MONTH FROM CURRENT_DATE)) + ); + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION financial.check_expired_cards() IS 'Marca como expiradas las tarjetas vencidas. Ejecutar mensualmente.'; diff --git a/ddl/schemas/investment/00-enums.sql b/ddl/schemas/investment/00-enums.sql new file mode 100644 index 0000000..689f19c --- /dev/null +++ b/ddl/schemas/investment/00-enums.sql @@ -0,0 +1,52 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - ENUMS +-- ===================================================== +-- Description: Enumerations for PAMM investment system +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +-- Agentes de inversión (Trading Agents) +CREATE TYPE investment.trading_agent AS ENUM ( + 'atlas', -- Conservador: 3-5% mensual + 'orion', -- Moderado: 5-10% mensual + 'nova' -- Agresivo: 10%+ mensual +); + +-- Perfil de riesgo (unificado con cuestionario) +CREATE TYPE investment.risk_profile AS ENUM ( + 'conservative', + 'moderate', + 'aggressive' +); + +-- Estado de cuenta PAMM +CREATE TYPE investment.account_status AS ENUM ( + 'pending_kyc', + 'active', + 'suspended', + 'closed' +); + +-- Frecuencia de distribución (DECISIÓN: mensual por defecto) +CREATE TYPE investment.distribution_frequency AS ENUM ( + 'monthly', + 'quarterly' +); + +-- Tipo de transacción +CREATE TYPE investment.transaction_type AS ENUM ( + 'deposit', + 'withdrawal', + 'distribution' +); + +-- Estado de transacción +CREATE TYPE investment.transaction_status AS ENUM ( + 'pending', + 'processing', + 'completed', + 'failed', + 'cancelled' +); diff --git a/ddl/schemas/investment/tables/01-products.sql b/ddl/schemas/investment/tables/01-products.sql new file mode 100644 index 0000000..6bc2136 --- /dev/null +++ b/ddl/schemas/investment/tables/01-products.sql @@ -0,0 +1,60 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - PRODUCTS TABLE +-- ===================================================== +-- Description: PAMM investment products +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + code VARCHAR(20) NOT NULL UNIQUE, -- PAMM-ATLAS, PAMM-ORION, PAMM-NOVA + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Agente asociado + trading_agent investment.trading_agent NOT NULL, + + -- Parámetros de inversión + min_investment DECIMAL(15,2) NOT NULL, + max_investment DECIMAL(15,2), + + -- Rentabilidad objetivo + target_return_min DECIMAL(5,2), -- % mensual mínimo esperado + target_return_max DECIMAL(5,2), -- % mensual máximo esperado + + -- Distribución de ganancias + distribution_frequency investment.distribution_frequency DEFAULT 'monthly', + investor_share_percent DECIMAL(5,2) DEFAULT 80.00, -- 80% para inversor + platform_share_percent DECIMAL(5,2) DEFAULT 20.00, -- 20% para plataforma + + -- Perfil de riesgo recomendado + recommended_risk_profile investment.risk_profile NOT NULL, + + -- Estado + is_active BOOLEAN DEFAULT true, + is_accepting_new_investors BOOLEAN DEFAULT true, + + -- Límites + total_capacity DECIMAL(15,2), -- Capacidad total del producto + current_aum DECIMAL(15,2) DEFAULT 0.00, -- Assets Under Management + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_products_agent ON investment.products(trading_agent); +CREATE INDEX idx_products_active ON investment.products(is_active) WHERE is_active = true; +CREATE INDEX idx_products_risk_profile ON investment.products(recommended_risk_profile); + +-- Comentarios +COMMENT ON TABLE investment.products IS 'PAMM investment products linked to trading agents'; +COMMENT ON COLUMN investment.products.code IS 'Unique product code (e.g., PAMM-ATLAS)'; +COMMENT ON COLUMN investment.products.current_aum IS 'Current Assets Under Management'; +COMMENT ON COLUMN investment.products.investor_share_percent IS 'Percentage of profits distributed to investors (80%)'; +COMMENT ON COLUMN investment.products.platform_share_percent IS 'Percentage of profits retained by platform (20%)'; diff --git a/ddl/schemas/investment/tables/02-risk_questionnaire.sql b/ddl/schemas/investment/tables/02-risk_questionnaire.sql new file mode 100644 index 0000000..8cfad1e --- /dev/null +++ b/ddl/schemas/investment/tables/02-risk_questionnaire.sql @@ -0,0 +1,63 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - RISK QUESTIONNAIRE TABLE +-- ===================================================== +-- Description: Risk assessment questionnaire (15 questions) +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.risk_questionnaire ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Usuario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Respuestas (15 preguntas) + responses JSONB NOT NULL, -- [{question_id, answer, score}] + + -- Resultado + total_score INTEGER NOT NULL CHECK (total_score >= 0 AND total_score <= 100), + calculated_profile investment.risk_profile NOT NULL, + + -- Recomendación de agente + recommended_agent investment.trading_agent, + + -- Validez + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, -- Válido por 1 año + -- Note: is_expired removed - compute dynamically as (expires_at < NOW()) + + -- Metadata + ip_address INET, + user_agent TEXT, + completion_time_seconds INTEGER, -- Tiempo que tardó en completar + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_questionnaire_user ON investment.risk_questionnaire(user_id); +CREATE INDEX idx_questionnaire_profile ON investment.risk_questionnaire(calculated_profile); +-- Index for valid questionnaires (without time-based predicate for immutability) +CREATE INDEX idx_questionnaire_valid ON investment.risk_questionnaire(user_id, expires_at DESC); + +-- Comentarios +COMMENT ON TABLE investment.risk_questionnaire IS 'Risk assessment questionnaire responses (valid for 1 year)'; +COMMENT ON COLUMN investment.risk_questionnaire.responses IS 'Array of question responses with scores: [{question_id, answer, score}]'; +COMMENT ON COLUMN investment.risk_questionnaire.total_score IS 'Sum of all question scores (0-100)'; +COMMENT ON COLUMN investment.risk_questionnaire.calculated_profile IS 'Risk profile calculated from total_score'; +COMMENT ON COLUMN investment.risk_questionnaire.recommended_agent IS 'Trading agent recommendation based on risk profile'; +COMMENT ON COLUMN investment.risk_questionnaire.expires_at IS 'Questionnaire expires after 1 year, user must retake'; + +-- Ejemplo de estructura de responses JSONB: +COMMENT ON COLUMN investment.risk_questionnaire.responses IS +'Example: [ + {"question_id": "Q1", "answer": "A", "score": 5}, + {"question_id": "Q2", "answer": "B", "score": 10}, + ... +] +Scoring logic: +- Conservative (0-40): Atlas agent recommended +- Moderate (41-70): Orion agent recommended +- Aggressive (71-100): Nova agent recommended'; diff --git a/ddl/schemas/investment/tables/03-accounts.sql b/ddl/schemas/investment/tables/03-accounts.sql new file mode 100644 index 0000000..8b906c7 --- /dev/null +++ b/ddl/schemas/investment/tables/03-accounts.sql @@ -0,0 +1,67 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - ACCOUNTS TABLE +-- ===================================================== +-- Description: Individual investor PAMM accounts +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Propietario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Producto PAMM + product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE RESTRICT, + + -- Identificación + account_number VARCHAR(20) NOT NULL UNIQUE, -- INV-202512-00001 + + -- Balance + initial_balance DECIMAL(15,2) NOT NULL, + current_balance DECIMAL(15,2) NOT NULL, + total_deposits DECIMAL(15,2) DEFAULT 0.00, + total_withdrawals DECIMAL(15,2) DEFAULT 0.00, + total_distributions DECIMAL(15,2) DEFAULT 0.00, + + -- Rentabilidad + total_return_percent DECIMAL(10,4) DEFAULT 0.00, + total_return_amount DECIMAL(15,2) DEFAULT 0.00, + + -- Perfil de riesgo del usuario + user_risk_profile investment.risk_profile NOT NULL, + questionnaire_id UUID REFERENCES investment.risk_questionnaire(id), + + -- Estado + status investment.account_status DEFAULT 'pending_kyc', + + -- KYC/Compliance + kyc_verified BOOLEAN DEFAULT false, + kyc_verified_at TIMESTAMPTZ, + kyc_verified_by VARCHAR(100), + + -- Fechas importantes + opened_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + last_distribution_at TIMESTAMPTZ, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_accounts_user ON investment.accounts(user_id); +CREATE INDEX idx_accounts_product ON investment.accounts(product_id); +CREATE INDEX idx_accounts_status ON investment.accounts(status); +CREATE INDEX idx_accounts_active ON investment.accounts(status) WHERE status = 'active'; +CREATE INDEX idx_accounts_number ON investment.accounts(account_number); + +-- Comentarios +COMMENT ON TABLE investment.accounts IS 'Individual investor PAMM accounts'; +COMMENT ON COLUMN investment.accounts.account_number IS 'Unique account identifier (INV-YYYYMM-NNNNN)'; +COMMENT ON COLUMN investment.accounts.current_balance IS 'Current account balance including all deposits, withdrawals, and distributions'; +COMMENT ON COLUMN investment.accounts.total_return_percent IS 'Cumulative return percentage since account opening'; +COMMENT ON COLUMN investment.accounts.user_risk_profile IS 'Risk profile from questionnaire, must match product recommendation'; diff --git a/ddl/schemas/investment/tables/04-distributions.sql b/ddl/schemas/investment/tables/04-distributions.sql new file mode 100644 index 0000000..6ca102a --- /dev/null +++ b/ddl/schemas/investment/tables/04-distributions.sql @@ -0,0 +1,69 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - DISTRIBUTIONS TABLE +-- ===================================================== +-- Description: Profit distributions (80/20 split) +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.distributions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Producto PAMM + product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE RESTRICT, + + -- Periodo + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + period_label VARCHAR(20) NOT NULL, -- 2025-12, 2025-Q4 + + -- Performance del agente de trading + total_profit_amount DECIMAL(15,2) NOT NULL, -- Ganancia total generada + total_profit_percent DECIMAL(10,4) NOT NULL, -- % de retorno + + -- Distribución 80/20 + investor_total_amount DECIMAL(15,2) NOT NULL, -- 80% para inversores + platform_total_amount DECIMAL(15,2) NOT NULL, -- 20% para plataforma + + -- Cuentas participantes + participating_accounts INTEGER NOT NULL, + total_aum_at_period_start DECIMAL(15,2) NOT NULL, + total_aum_at_period_end DECIMAL(15,2) NOT NULL, + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed + + -- Procesamiento + calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + approved_by VARCHAR(100), + approved_at TIMESTAMPTZ, + distributed_at TIMESTAMPTZ, + + -- Metadata + notes TEXT, + distribution_metadata JSONB, -- Detalles adicionales + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Validación + CONSTRAINT valid_period CHECK (period_end > period_start), + CONSTRAINT valid_split CHECK ( + investor_total_amount + platform_total_amount = total_profit_amount + ) +); + +-- Índices +CREATE INDEX idx_distributions_product ON investment.distributions(product_id); +CREATE INDEX idx_distributions_period ON investment.distributions(period_start, period_end); +CREATE INDEX idx_distributions_status ON investment.distributions(status); +CREATE UNIQUE INDEX idx_distributions_product_period ON investment.distributions(product_id, period_label); + +-- Comentarios +COMMENT ON TABLE investment.distributions IS 'Periodic profit distributions with 80/20 split'; +COMMENT ON COLUMN investment.distributions.period_label IS 'Human-readable period identifier (YYYY-MM or YYYY-QN)'; +COMMENT ON COLUMN investment.distributions.total_profit_amount IS 'Total profit generated by trading agent during period'; +COMMENT ON COLUMN investment.distributions.investor_total_amount IS '80% of total profit distributed to all investors'; +COMMENT ON COLUMN investment.distributions.platform_total_amount IS '20% of total profit retained by platform'; +COMMENT ON COLUMN investment.distributions.total_aum_at_period_start IS 'Total Assets Under Management at period start'; diff --git a/ddl/schemas/investment/tables/05-transactions.sql b/ddl/schemas/investment/tables/05-transactions.sql new file mode 100644 index 0000000..cce6a12 --- /dev/null +++ b/ddl/schemas/investment/tables/05-transactions.sql @@ -0,0 +1,69 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - TRANSACTIONS TABLE +-- ===================================================== +-- Description: Deposits, withdrawals, and distributions +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Cuenta asociada + account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE CASCADE, + + -- Identificación + transaction_number VARCHAR(30) NOT NULL UNIQUE, -- TXN-202512-00001 + + -- Tipo y monto + transaction_type investment.transaction_type NOT NULL, + amount DECIMAL(15,2) NOT NULL CHECK (amount > 0), + + -- Estado + status investment.transaction_status DEFAULT 'pending', + + -- Detalles de pago (para deposits/withdrawals) + payment_method VARCHAR(50), -- bank_transfer, card, crypto + payment_reference VARCHAR(100), + payment_metadata JSONB, + + -- Distribución (para transaction_type = 'distribution') + distribution_id UUID REFERENCES investment.distributions(id), + + -- Balance después de transacción + balance_before DECIMAL(15,2), + balance_after DECIMAL(15,2), + + -- Procesamiento + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + failure_reason TEXT, + + -- Aprobación (para withdrawals) + requires_approval BOOLEAN DEFAULT false, + approved_by VARCHAR(100), + approved_at TIMESTAMPTZ, + + -- Metadata + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_transactions_account ON investment.transactions(account_id); +CREATE INDEX idx_transactions_type ON investment.transactions(transaction_type); +CREATE INDEX idx_transactions_status ON investment.transactions(status); +CREATE INDEX idx_transactions_number ON investment.transactions(transaction_number); +CREATE INDEX idx_transactions_distribution ON investment.transactions(distribution_id); +CREATE INDEX idx_transactions_requested ON investment.transactions(requested_at DESC); + +-- Comentarios +COMMENT ON TABLE investment.transactions IS 'All account transactions: deposits, withdrawals, and distributions'; +COMMENT ON COLUMN investment.transactions.transaction_number IS 'Unique transaction identifier (TXN-YYYYMM-NNNNN)'; +COMMENT ON COLUMN investment.transactions.payment_method IS 'Payment method for deposits/withdrawals'; +COMMENT ON COLUMN investment.transactions.distribution_id IS 'Link to distribution record if transaction_type is distribution'; +COMMENT ON COLUMN investment.transactions.requires_approval IS 'Whether withdrawal requires manual approval'; diff --git a/ddl/schemas/investment/tables/06-withdrawal_requests.sql b/ddl/schemas/investment/tables/06-withdrawal_requests.sql new file mode 100644 index 0000000..8659bc0 --- /dev/null +++ b/ddl/schemas/investment/tables/06-withdrawal_requests.sql @@ -0,0 +1,119 @@ +-- ============================================================================ +-- INVESTMENT SCHEMA - Tabla: withdrawal_requests +-- ============================================================================ +-- Solicitudes de retiro de cuentas PAMM +-- Requiere aprobacion manual para montos grandes +-- ============================================================================ + +-- Enum para estado de solicitud +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'withdrawal_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'investment')) THEN + CREATE TYPE investment.withdrawal_status AS ENUM ( + 'pending', + 'under_review', + 'approved', + 'processing', + 'completed', + 'rejected', + 'cancelled' + ); + END IF; +END$$; + +CREATE TABLE IF NOT EXISTS investment.withdrawal_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + + -- Solicitud + request_number VARCHAR(20) NOT NULL UNIQUE, + amount DECIMAL(20, 8) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + + -- Estado + status investment.withdrawal_status NOT NULL DEFAULT 'pending', + + -- Destino del retiro + destination_type VARCHAR(20) NOT NULL, -- 'wallet', 'bank', 'crypto' + destination_details JSONB NOT NULL DEFAULT '{}', + + -- Fees + fee_amount DECIMAL(20, 8) NOT NULL DEFAULT 0, + fee_percentage DECIMAL(5, 2) NOT NULL DEFAULT 0, + net_amount DECIMAL(20, 8) GENERATED ALWAYS AS (amount - fee_amount) STORED, + + -- Aprobacion + requires_approval BOOLEAN NOT NULL DEFAULT FALSE, + reviewed_by UUID REFERENCES auth.users(id), + reviewed_at TIMESTAMPTZ, + review_notes TEXT, + + -- Procesamiento + processed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + transaction_reference VARCHAR(100), + + -- Rechazo/Cancelacion + rejection_reason TEXT, + cancelled_at TIMESTAMPTZ, + cancellation_reason TEXT, + + -- Metadata + ip_address INET, + user_agent TEXT, + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_positive_amount CHECK (amount > 0), + CONSTRAINT chk_valid_fee CHECK (fee_amount >= 0 AND fee_amount <= amount), + CONSTRAINT chk_fee_percentage CHECK (fee_percentage >= 0 AND fee_percentage <= 100), + CONSTRAINT chk_destination_type CHECK (destination_type IN ('wallet', 'bank', 'crypto')) +); + +-- Indices +CREATE INDEX idx_withdrawal_requests_account ON investment.withdrawal_requests(account_id); +CREATE INDEX idx_withdrawal_requests_user ON investment.withdrawal_requests(user_id); +CREATE INDEX idx_withdrawal_requests_status ON investment.withdrawal_requests(status); +CREATE INDEX idx_withdrawal_requests_created ON investment.withdrawal_requests(created_at DESC); +CREATE INDEX idx_withdrawal_requests_pending ON investment.withdrawal_requests(status, created_at) + WHERE status IN ('pending', 'under_review'); + +-- Comentarios +COMMENT ON TABLE investment.withdrawal_requests IS 'Solicitudes de retiro de cuentas PAMM'; +COMMENT ON COLUMN investment.withdrawal_requests.request_number IS 'Numero unico de solicitud (WR-YYYYMMDD-XXXX)'; +COMMENT ON COLUMN investment.withdrawal_requests.requires_approval IS 'True si el monto requiere aprobacion manual'; +COMMENT ON COLUMN investment.withdrawal_requests.destination_details IS 'Detalles del destino (IBAN, wallet address, etc.)'; + +-- Funcion para generar numero de solicitud +CREATE OR REPLACE FUNCTION investment.generate_withdrawal_request_number() +RETURNS TRIGGER AS $$ +DECLARE + v_date TEXT; + v_seq INTEGER; +BEGIN + v_date := TO_CHAR(NOW(), 'YYYYMMDD'); + + SELECT COALESCE(MAX( + CAST(SUBSTRING(request_number FROM 'WR-[0-9]{8}-([0-9]+)') AS INTEGER) + ), 0) + 1 + INTO v_seq + FROM investment.withdrawal_requests + WHERE request_number LIKE 'WR-' || v_date || '-%'; + + NEW.request_number := 'WR-' || v_date || '-' || LPAD(v_seq::TEXT, 4, '0'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tr_generate_withdrawal_request_number + BEFORE INSERT ON investment.withdrawal_requests + FOR EACH ROW + WHEN (NEW.request_number IS NULL) + EXECUTE FUNCTION investment.generate_withdrawal_request_number(); diff --git a/ddl/schemas/investment/tables/07-daily_performance.sql b/ddl/schemas/investment/tables/07-daily_performance.sql new file mode 100644 index 0000000..a39fd01 --- /dev/null +++ b/ddl/schemas/investment/tables/07-daily_performance.sql @@ -0,0 +1,114 @@ +-- ============================================================================ +-- INVESTMENT SCHEMA - Tabla: daily_performance +-- ============================================================================ +-- Snapshots diarios de rendimiento de cuentas PAMM +-- Usado para graficos, reportes y calculo de metricas +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS investment.daily_performance ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE CASCADE, + + -- Fecha del snapshot + snapshot_date DATE NOT NULL, + + -- Balance + opening_balance DECIMAL(20, 8) NOT NULL, + closing_balance DECIMAL(20, 8) NOT NULL, + + -- Rendimiento del dia + daily_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, + daily_return_percentage DECIMAL(10, 6) NOT NULL DEFAULT 0, + + -- Rendimiento acumulado + cumulative_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, + cumulative_return_percentage DECIMAL(10, 6) NOT NULL DEFAULT 0, + + -- Movimientos del dia + deposits DECIMAL(20, 8) NOT NULL DEFAULT 0, + withdrawals DECIMAL(20, 8) NOT NULL DEFAULT 0, + distributions_received DECIMAL(20, 8) NOT NULL DEFAULT 0, + + -- Metricas del agente de trading + trades_executed INTEGER NOT NULL DEFAULT 0, + winning_trades INTEGER NOT NULL DEFAULT 0, + losing_trades INTEGER NOT NULL DEFAULT 0, + win_rate DECIMAL(5, 2), + + -- Volatilidad y riesgo + max_drawdown DECIMAL(10, 6), + sharpe_ratio DECIMAL(10, 6), + volatility DECIMAL(10, 6), + + -- High/Low del dia + high_water_mark DECIMAL(20, 8), + lowest_point DECIMAL(20, 8), + + -- Metadata del snapshot + snapshot_source VARCHAR(50) DEFAULT 'cron', -- 'cron', 'manual', 'system' + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT uq_daily_performance_account_date UNIQUE(account_id, snapshot_date), + CONSTRAINT chk_valid_balances CHECK (opening_balance >= 0 AND closing_balance >= 0), + CONSTRAINT chk_valid_movements CHECK (deposits >= 0 AND withdrawals >= 0), + CONSTRAINT chk_valid_trades CHECK ( + trades_executed >= 0 AND + winning_trades >= 0 AND + losing_trades >= 0 AND + winning_trades + losing_trades <= trades_executed + ), + CONSTRAINT chk_valid_win_rate CHECK (win_rate IS NULL OR (win_rate >= 0 AND win_rate <= 100)) +); + +-- Indices +CREATE INDEX idx_daily_performance_account ON investment.daily_performance(account_id); +CREATE INDEX idx_daily_performance_product ON investment.daily_performance(product_id); +CREATE INDEX idx_daily_performance_date ON investment.daily_performance(snapshot_date DESC); +CREATE INDEX idx_daily_performance_account_date ON investment.daily_performance(account_id, snapshot_date DESC); + +-- Index for recent performance (removed time-based predicate for immutability) +CREATE INDEX idx_daily_performance_recent ON investment.daily_performance(account_id, snapshot_date DESC); + +-- Comentarios +COMMENT ON TABLE investment.daily_performance IS 'Snapshots diarios de rendimiento de cuentas PAMM'; +COMMENT ON COLUMN investment.daily_performance.snapshot_date IS 'Fecha del snapshot (una entrada por dia por cuenta)'; +COMMENT ON COLUMN investment.daily_performance.daily_return_percentage IS 'Retorno del dia como porcentaje'; +COMMENT ON COLUMN investment.daily_performance.cumulative_return_percentage IS 'Retorno acumulado desde apertura de cuenta'; +COMMENT ON COLUMN investment.daily_performance.max_drawdown IS 'Maximo drawdown del dia'; +COMMENT ON COLUMN investment.daily_performance.high_water_mark IS 'Punto mas alto alcanzado'; + +-- Vista para resumen mensual +CREATE OR REPLACE VIEW investment.v_monthly_performance AS +SELECT + account_id, + product_id, + DATE_TRUNC('month', snapshot_date) AS month, + MIN(opening_balance) AS month_opening, + MAX(closing_balance) AS month_closing, + SUM(daily_pnl) AS total_pnl, + AVG(daily_return_percentage) AS avg_daily_return, + SUM(deposits) AS total_deposits, + SUM(withdrawals) AS total_withdrawals, + SUM(distributions_received) AS total_distributions, + SUM(trades_executed) AS total_trades, + SUM(winning_trades) AS total_winning, + SUM(losing_trades) AS total_losing, + CASE + WHEN SUM(winning_trades) + SUM(losing_trades) > 0 + THEN ROUND(SUM(winning_trades)::DECIMAL / (SUM(winning_trades) + SUM(losing_trades)) * 100, 2) + ELSE NULL + END AS monthly_win_rate, + MIN(lowest_point) AS monthly_low, + MAX(high_water_mark) AS monthly_high, + COUNT(*) AS trading_days +FROM investment.daily_performance +GROUP BY account_id, product_id, DATE_TRUNC('month', snapshot_date); + +COMMENT ON VIEW investment.v_monthly_performance IS 'Resumen mensual de rendimiento agregado'; diff --git a/ddl/schemas/llm/00-enums.sql b/ddl/schemas/llm/00-enums.sql new file mode 100644 index 0000000..107bcdb --- /dev/null +++ b/ddl/schemas/llm/00-enums.sql @@ -0,0 +1,63 @@ +-- ===================================================== +-- LLM SCHEMA - ENUMS +-- ===================================================== +-- Description: Enumerations for LLM agent system +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +-- Rol del mensaje +CREATE TYPE llm.message_role AS ENUM ( + 'user', + 'assistant', + 'system', + 'tool' +); + +-- Estado de la conversación +CREATE TYPE llm.conversation_status AS ENUM ( + 'active', + 'archived', + 'deleted' +); + +-- Tipo de conversación +CREATE TYPE llm.conversation_type AS ENUM ( + 'general', -- Conversación general + 'trading_advice', -- Consulta sobre trading + 'education', -- Preguntas educativas + 'market_analysis', -- Análisis de mercado + 'support', -- Soporte técnico + 'onboarding' -- Onboarding de usuario +); + +-- Tono de comunicación +CREATE TYPE llm.communication_tone AS ENUM ( + 'casual', + 'professional', + 'technical' +); + +-- Nivel de verbosidad +CREATE TYPE llm.verbosity_level AS ENUM ( + 'brief', + 'normal', + 'detailed' +); + +-- Frecuencia de alertas +CREATE TYPE llm.alert_frequency AS ENUM ( + 'low', + 'normal', + 'high' +); + +-- Tipo de memoria +CREATE TYPE llm.memory_type AS ENUM ( + 'fact', -- Hecho sobre el usuario + 'preference', -- Preferencia del usuario + 'context', -- Contexto de conversaciones + 'goal', -- Objetivo del usuario + 'constraint' -- Restricción o límite +); diff --git a/ddl/schemas/llm/00-extensions.sql b/ddl/schemas/llm/00-extensions.sql new file mode 100644 index 0000000..20dcd24 --- /dev/null +++ b/ddl/schemas/llm/00-extensions.sql @@ -0,0 +1,13 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: llm +-- File: 00-extensions.sql +-- Description: PostgreSQL extensions required for LLM schema (embeddings) +-- ============================================================================ + +-- pgvector extension for vector similarity search +-- Required for storing and querying embeddings +-- Installation: https://github.com/pgvector/pgvector +CREATE EXTENSION IF NOT EXISTS "vector"; + +COMMENT ON EXTENSION "vector" IS 'Vector similarity search extension (pgvector) for LLM embeddings'; diff --git a/ddl/schemas/llm/tables/01-conversations.sql b/ddl/schemas/llm/tables/01-conversations.sql new file mode 100644 index 0000000..abac1b6 --- /dev/null +++ b/ddl/schemas/llm/tables/01-conversations.sql @@ -0,0 +1,63 @@ +-- ===================================================== +-- LLM SCHEMA - CONVERSATIONS TABLE +-- ===================================================== +-- Description: Chat conversations with LLM agent +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE llm.conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Usuario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Identificación + title VARCHAR(200), -- Auto-generado o definido por usuario + + -- Tipo y contexto + conversation_type llm.conversation_type DEFAULT 'general', + + -- Estado + status llm.conversation_status DEFAULT 'active', + + -- Resumen de conversación (generado por LLM) + summary TEXT, + + -- Metadata + total_messages INTEGER DEFAULT 0, + total_tokens_used INTEGER DEFAULT 0, + + -- Tags para búsqueda + tags TEXT[] DEFAULT '{}', + + -- Contexto de negocio + related_symbols VARCHAR(20)[] DEFAULT '{}', -- Símbolos discutidos + related_topics TEXT[] DEFAULT '{}', -- Temas discutidos + + -- Fechas + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_message_at TIMESTAMPTZ, + archived_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_conversations_user ON llm.conversations(user_id); +CREATE INDEX idx_conversations_status ON llm.conversations(status); +CREATE INDEX idx_conversations_type ON llm.conversations(conversation_type); +CREATE INDEX idx_conversations_active ON llm.conversations(user_id, last_message_at DESC) + WHERE status = 'active'; +CREATE INDEX idx_conversations_tags ON llm.conversations USING GIN(tags); +CREATE INDEX idx_conversations_symbols ON llm.conversations USING GIN(related_symbols); + +-- Comentarios +COMMENT ON TABLE llm.conversations IS 'Chat conversations between users and LLM agent'; +COMMENT ON COLUMN llm.conversations.title IS 'Conversation title (auto-generated from first messages or user-defined)'; +COMMENT ON COLUMN llm.conversations.summary IS 'AI-generated summary of conversation content'; +COMMENT ON COLUMN llm.conversations.total_tokens_used IS 'Cumulative token count for cost tracking'; +COMMENT ON COLUMN llm.conversations.related_symbols IS 'Trading symbols mentioned in conversation'; +COMMENT ON COLUMN llm.conversations.related_topics IS 'Topics discussed (e.g., technical_analysis, risk_management)'; diff --git a/ddl/schemas/llm/tables/02-messages.sql b/ddl/schemas/llm/tables/02-messages.sql new file mode 100644 index 0000000..bdf4ecc --- /dev/null +++ b/ddl/schemas/llm/tables/02-messages.sql @@ -0,0 +1,98 @@ +-- ===================================================== +-- LLM SCHEMA - MESSAGES TABLE +-- ===================================================== +-- Description: Individual messages in conversations +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE llm.messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Conversación + conversation_id UUID NOT NULL REFERENCES llm.conversations(id) ON DELETE CASCADE, + + -- Rol y contenido + role llm.message_role NOT NULL, + content TEXT NOT NULL, + + -- Metadata de LLM + model_name VARCHAR(100), -- claude-opus-4-5, gpt-4, etc. + prompt_tokens INTEGER, + completion_tokens INTEGER, + total_tokens INTEGER, + + -- Contexto utilizado + context_used JSONB, -- RAG context, market data, user profile, etc. + + -- Tools/Functions llamadas + tool_calls JSONB, -- Function calls realizadas + tool_results JSONB, -- Resultados de tool calls + + -- Metadata de procesamiento + response_time_ms INTEGER, + temperature DECIMAL(3,2), + + -- Feedback del usuario + user_rating INTEGER CHECK (user_rating >= 1 AND user_rating <= 5), + user_feedback TEXT, + + -- Referencias + references_symbols VARCHAR(20)[] DEFAULT '{}', + references_concepts TEXT[] DEFAULT '{}', + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_messages_conversation ON llm.messages(conversation_id); +CREATE INDEX idx_messages_role ON llm.messages(role); +CREATE INDEX idx_messages_created ON llm.messages(created_at DESC); +CREATE INDEX idx_messages_conversation_created ON llm.messages(conversation_id, created_at ASC); +CREATE INDEX idx_messages_rated ON llm.messages(user_rating) WHERE user_rating IS NOT NULL; + +-- Comentarios +COMMENT ON TABLE llm.messages IS 'Individual messages in LLM conversations'; +COMMENT ON COLUMN llm.messages.role IS 'Message sender: user, assistant, system, or tool'; +COMMENT ON COLUMN llm.messages.content IS 'Message text content'; +COMMENT ON COLUMN llm.messages.model_name IS 'LLM model used to generate response'; +COMMENT ON COLUMN llm.messages.context_used IS 'Context provided to LLM (RAG docs, market data, user profile)'; +COMMENT ON COLUMN llm.messages.tool_calls IS 'Functions/tools called by LLM during response generation'; +COMMENT ON COLUMN llm.messages.user_rating IS 'User satisfaction rating (1-5 stars)'; + +-- Ejemplo de context_used JSONB: +COMMENT ON COLUMN llm.messages.context_used IS +'Example: { + "market_data": { + "symbol": "BTCUSDT", + "price": 45234.12, + "change_24h": 0.0234 + }, + "user_profile": { + "risk_profile": "moderate", + "preferred_symbols": ["BTCUSDT", "ETHUSDT"] + }, + "rag_documents": [ + {"doc_id": "123", "relevance": 0.89, "snippet": "..."}, + {"doc_id": "456", "relevance": 0.76, "snippet": "..."} + ] +}'; + +-- Ejemplo de tool_calls JSONB: +COMMENT ON COLUMN llm.messages.tool_calls IS +'Example: [ + { + "tool": "get_market_data", + "params": {"symbol": "BTCUSDT", "timeframe": "1h"}, + "result": {...} + }, + { + "tool": "calculate_indicator", + "params": {"indicator": "rsi", "period": 14}, + "result": {"rsi": 65.42} + } +]'; diff --git a/ddl/schemas/llm/tables/03-user_preferences.sql b/ddl/schemas/llm/tables/03-user_preferences.sql new file mode 100644 index 0000000..c5ae01a --- /dev/null +++ b/ddl/schemas/llm/tables/03-user_preferences.sql @@ -0,0 +1,68 @@ +-- ===================================================== +-- LLM SCHEMA - USER PREFERENCES TABLE +-- ===================================================== +-- Description: User preferences for LLM agent interactions +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE llm.user_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Preferencias de comunicación + language VARCHAR(5) DEFAULT 'es', -- ISO 639-1 + tone llm.communication_tone DEFAULT 'professional', + verbosity llm.verbosity_level DEFAULT 'normal', + + -- Preferencias de trading + preferred_symbols VARCHAR(20)[] DEFAULT '{}', + preferred_timeframe VARCHAR(10) DEFAULT '1h', + risk_tolerance VARCHAR(20) DEFAULT 'moderate', -- conservative, moderate, aggressive + + -- Preferencias de notificación + proactive_alerts BOOLEAN DEFAULT true, + alert_frequency llm.alert_frequency DEFAULT 'normal', + notification_hours_start TIME DEFAULT '08:00:00', + notification_hours_end TIME DEFAULT '22:00:00', + timezone VARCHAR(50) DEFAULT 'America/Mexico_City', + + -- Intereses + topics_of_interest TEXT[] DEFAULT '{}', -- trading, education, news, market_analysis + + -- Nivel de experiencia (para personalizar explicaciones) + trading_experience_level VARCHAR(20) DEFAULT 'beginner', -- beginner, intermediate, advanced, expert + + -- Preferencias de análisis + preferred_analysis_types TEXT[] DEFAULT '{}', -- technical, fundamental, sentiment, onchain + + -- Formato de respuestas + include_charts BOOLEAN DEFAULT true, + include_data_tables BOOLEAN DEFAULT true, + include_explanations BOOLEAN DEFAULT true, + + -- Metadata + onboarding_completed BOOLEAN DEFAULT false, + onboarding_completed_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE UNIQUE INDEX idx_user_preferences_user ON llm.user_preferences(user_id); +CREATE INDEX idx_user_preferences_language ON llm.user_preferences(language); + +-- Comentarios +COMMENT ON TABLE llm.user_preferences IS 'User preferences for personalized LLM agent interactions'; +COMMENT ON COLUMN llm.user_preferences.language IS 'Preferred language for responses (ISO 639-1 code)'; +COMMENT ON COLUMN llm.user_preferences.tone IS 'Communication style: casual, professional, or technical'; +COMMENT ON COLUMN llm.user_preferences.verbosity IS 'Response length preference: brief, normal, or detailed'; +COMMENT ON COLUMN llm.user_preferences.preferred_symbols IS 'Trading pairs user is most interested in'; +COMMENT ON COLUMN llm.user_preferences.proactive_alerts IS 'Whether agent should send proactive notifications'; +COMMENT ON COLUMN llm.user_preferences.alert_frequency IS 'How often to receive alerts: low, normal, or high'; +COMMENT ON COLUMN llm.user_preferences.notification_hours_start IS 'Start of notification window in user timezone'; +COMMENT ON COLUMN llm.user_preferences.notification_hours_end IS 'End of notification window in user timezone'; +COMMENT ON COLUMN llm.user_preferences.topics_of_interest IS 'Topics user wants to learn about or discuss'; +COMMENT ON COLUMN llm.user_preferences.trading_experience_level IS 'User experience level for tailoring explanations'; diff --git a/ddl/schemas/llm/tables/04-user_memory.sql b/ddl/schemas/llm/tables/04-user_memory.sql new file mode 100644 index 0000000..4d4f6b4 --- /dev/null +++ b/ddl/schemas/llm/tables/04-user_memory.sql @@ -0,0 +1,82 @@ +-- ===================================================== +-- LLM SCHEMA - USER MEMORY TABLE +-- ===================================================== +-- Description: Persistent memory about users for personalization +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE llm.user_memory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Tipo de memoria + memory_type llm.memory_type NOT NULL, + + -- Contenido + key VARCHAR(200) NOT NULL, + value TEXT NOT NULL, + + -- Importancia + importance_score DECIMAL(3,2) DEFAULT 0.50 CHECK (importance_score >= 0.00 AND importance_score <= 1.00), + + -- Fuente + source_conversation_id UUID REFERENCES llm.conversations(id) ON DELETE SET NULL, + extracted_from TEXT, -- Fragmento del que se extrajo la memoria + extraction_method VARCHAR(50) DEFAULT 'llm', -- llm, manual, system + + -- Validez + is_active BOOLEAN DEFAULT true, + expires_at TIMESTAMPTZ, + + -- Confirmación (algunas memorias pueden requerir confirmación del usuario) + requires_confirmation BOOLEAN DEFAULT false, + confirmed_by_user BOOLEAN DEFAULT false, + confirmed_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_user_memory_key UNIQUE(user_id, memory_type, key) +); + +-- Índices +CREATE INDEX idx_memory_user ON llm.user_memory(user_id); +CREATE INDEX idx_memory_type ON llm.user_memory(memory_type); +CREATE INDEX idx_memory_active ON llm.user_memory(is_active) WHERE is_active = true; +CREATE INDEX idx_memory_importance ON llm.user_memory(importance_score DESC); +CREATE INDEX idx_memory_conversation ON llm.user_memory(source_conversation_id); +CREATE INDEX idx_memory_expires ON llm.user_memory(expires_at) WHERE expires_at IS NOT NULL; + +-- Comentarios +COMMENT ON TABLE llm.user_memory IS 'Persistent memory about users for LLM personalization and context'; +COMMENT ON COLUMN llm.user_memory.memory_type IS 'Type of memory: fact, preference, context, goal, or constraint'; +COMMENT ON COLUMN llm.user_memory.key IS 'Memory identifier (e.g., "favorite_symbol", "trading_goal", "risk_limit")'; +COMMENT ON COLUMN llm.user_memory.value IS 'Memory content (e.g., "BTCUSDT", "save for house", "max 5% per trade")'; +COMMENT ON COLUMN llm.user_memory.importance_score IS 'Importance weight (0.00-1.00) for retrieval prioritization'; +COMMENT ON COLUMN llm.user_memory.extracted_from IS 'Original text from which memory was extracted'; +COMMENT ON COLUMN llm.user_memory.requires_confirmation IS 'Whether this memory needs explicit user confirmation'; + +-- Ejemplos de memorias por tipo: +COMMENT ON COLUMN llm.user_memory.memory_type IS +'Memory type examples: +- fact: "trading_since" = "2020", "max_loss_experienced" = "15%" +- preference: "favorite_indicator" = "RSI", "avoids_margin_trading" = "true" +- context: "recent_portfolio_loss" = "trying to recover", "learning_focus" = "risk management" +- goal: "monthly_target" = "5% return", "learning_goal" = "master technical analysis" +- constraint: "max_risk_per_trade" = "2%", "no_trading_during_work" = "9am-5pm"'; + +-- Ejemplo de metadata JSONB: +COMMENT ON COLUMN llm.user_memory.metadata IS +'Example: { + "confidence": 0.85, + "last_mentioned": "2025-12-05T10:30:00Z", + "mention_count": 5, + "related_memories": ["mem_123", "mem_456"], + "tags": ["trading_style", "risk_management"] +}'; diff --git a/ddl/schemas/llm/tables/05-embeddings.sql b/ddl/schemas/llm/tables/05-embeddings.sql new file mode 100644 index 0000000..d913ea8 --- /dev/null +++ b/ddl/schemas/llm/tables/05-embeddings.sql @@ -0,0 +1,122 @@ +-- ===================================================== +-- LLM SCHEMA - EMBEDDINGS TABLE +-- ===================================================== +-- Description: Vector embeddings for RAG and semantic search +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +-- NOTA: Requiere extensión pgvector +-- CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE llm.embeddings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Tipo de contenido + content_type VARCHAR(50) NOT NULL, -- message, document, faq, tutorial, article + + -- Referencia al contenido original + content_id UUID, -- ID del mensaje, documento, etc. + + -- Contenido + content TEXT NOT NULL, + content_hash VARCHAR(64), -- SHA-256 para deduplicación + + -- Metadata del contenido + title VARCHAR(500), + description TEXT, + + -- Vector embedding (dimensión depende del modelo) + -- OpenAI text-embedding-3-small: 1536 dims + -- OpenAI text-embedding-3-large: 3072 dims + -- Voyage AI: 1024 dims + embedding vector(1536), -- Ajustar según modelo usado + + -- Modelo usado para generar embedding + embedding_model VARCHAR(100) NOT NULL, -- text-embedding-3-small, voyage-2, etc. + + -- Metadata para filtrado + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- Si es contenido específico de usuario + is_public BOOLEAN DEFAULT true, + + -- Categorización + category VARCHAR(100), -- education, trading, market_news, platform_help + subcategory VARCHAR(100), + tags TEXT[] DEFAULT '{}', + + -- Relevancia + importance_score DECIMAL(3,2) DEFAULT 0.50, + + -- Contexto adicional + context_metadata JSONB, -- Metadata adicional para mejorar recuperación + + -- Fuente + source_url VARCHAR(500), + source_type VARCHAR(50), -- internal, external, generated + + -- Validez + is_active BOOLEAN DEFAULT true, + expires_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_embeddings_type ON llm.embeddings(content_type); +CREATE INDEX idx_embeddings_user ON llm.embeddings(user_id) WHERE user_id IS NOT NULL; +CREATE INDEX idx_embeddings_category ON llm.embeddings(category); +CREATE INDEX idx_embeddings_tags ON llm.embeddings USING GIN(tags); +CREATE INDEX idx_embeddings_active ON llm.embeddings(is_active) WHERE is_active = true; +CREATE INDEX idx_embeddings_hash ON llm.embeddings(content_hash); + +-- Índice para búsqueda vectorial (HNSW para mejor performance) +-- Requiere pgvector +CREATE INDEX idx_embeddings_vector_hnsw ON llm.embeddings + USING hnsw (embedding vector_cosine_ops); + +-- Índice alternativo: IVFFlat (más rápido de construir, menos preciso) +-- CREATE INDEX idx_embeddings_vector_ivfflat ON llm.embeddings +-- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- Comentarios +COMMENT ON TABLE llm.embeddings IS 'Vector embeddings for RAG and semantic search using pgvector'; +COMMENT ON COLUMN llm.embeddings.content_type IS 'Type of content: message, document, faq, tutorial, article'; +COMMENT ON COLUMN llm.embeddings.content_id IS 'Reference to original content (e.g., message ID)'; +COMMENT ON COLUMN llm.embeddings.content IS 'Text content that was embedded'; +COMMENT ON COLUMN llm.embeddings.content_hash IS 'SHA-256 hash for deduplication'; +COMMENT ON COLUMN llm.embeddings.embedding IS 'Vector embedding (dimension depends on model)'; +COMMENT ON COLUMN llm.embeddings.embedding_model IS 'Model used to generate embedding'; +COMMENT ON COLUMN llm.embeddings.is_public IS 'Whether embedding is accessible to all users or user-specific'; +COMMENT ON COLUMN llm.embeddings.importance_score IS 'Relevance score for retrieval prioritization'; + +-- Ejemplo de uso para búsqueda semántica: +COMMENT ON TABLE llm.embeddings IS +'Vector search example: +SELECT + content, + title, + 1 - (embedding <=> query_embedding) AS similarity +FROM llm.embeddings +WHERE is_active = true + AND category = ''education'' +ORDER BY embedding <=> query_embedding +LIMIT 5; + +Operators: +- <-> : L2 distance +- <#> : inner product +- <=> : cosine distance (recommended)'; + +-- Ejemplo de context_metadata JSONB: +COMMENT ON COLUMN llm.embeddings.context_metadata IS +'Example: { + "language": "es", + "difficulty_level": "beginner", + "reading_time_minutes": 5, + "author": "system", + "last_updated": "2025-12-01", + "related_symbols": ["BTCUSDT"], + "related_topics": ["technical_analysis", "rsi"] +}'; diff --git a/ddl/schemas/market_data/00-enums.sql b/ddl/schemas/market_data/00-enums.sql new file mode 100644 index 0000000..b934808 --- /dev/null +++ b/ddl/schemas/market_data/00-enums.sql @@ -0,0 +1,29 @@ +-- ============================================================================ +-- Schema: market_data +-- File: 00-enums.sql +-- Description: ENUMs para datos de mercado OHLCV +-- ============================================================================ + +-- Tipo de activo +CREATE TYPE market_data.asset_type AS ENUM ( + 'forex', -- Pares de divisas (EURUSD, GBPUSD, etc.) + 'crypto', -- Criptomonedas (BTCUSD, ETHUSD, etc.) + 'commodity', -- Commodities (XAUUSD, XAGUSD, etc.) + 'index', -- Índices (SPX500, NAS100, etc.) + 'stock' -- Acciones +); + +-- Temporalidad +CREATE TYPE market_data.timeframe AS ENUM ( + '1m', + '5m', + '15m', + '30m', + '1h', + '4h', + '1d', + '1w' +); + +COMMENT ON TYPE market_data.asset_type IS 'Clasificación de tipo de activo financiero'; +COMMENT ON TYPE market_data.timeframe IS 'Temporalidades soportadas para datos OHLCV'; diff --git a/ddl/schemas/market_data/functions/01-aggregate_15m.sql b/ddl/schemas/market_data/functions/01-aggregate_15m.sql new file mode 100644 index 0000000..0f45611 --- /dev/null +++ b/ddl/schemas/market_data/functions/01-aggregate_15m.sql @@ -0,0 +1,83 @@ +-- ============================================================================ +-- Schema: market_data +-- Function: aggregate_5m_to_15m +-- Description: Genera datos de 15m a partir de 5m para un ticker +-- ============================================================================ + +CREATE OR REPLACE FUNCTION market_data.aggregate_5m_to_15m( + p_ticker_id INTEGER, + p_start_date TIMESTAMPTZ DEFAULT NULL, + p_end_date TIMESTAMPTZ DEFAULT NULL +) +RETURNS INTEGER AS $$ +DECLARE + v_inserted INTEGER; + v_start TIMESTAMPTZ; + v_end TIMESTAMPTZ; +BEGIN + -- Determinar rango de fechas + v_start := COALESCE(p_start_date, '2015-01-01'::TIMESTAMPTZ); + v_end := COALESCE(p_end_date, NOW()); + + -- Insertar datos agregados de 15m + WITH aggregated AS ( + SELECT + ticker_id, + date_trunc('hour', timestamp) + + INTERVAL '15 minutes' * (EXTRACT(MINUTE FROM timestamp)::INT / 15) AS ts_15m, + (array_agg(open ORDER BY timestamp))[1] AS open, + MAX(high) AS high, + MIN(low) AS low, + (array_agg(close ORDER BY timestamp DESC))[1] AS close, + SUM(volume) AS volume, + AVG(vwap) AS vwap, + COUNT(*) AS candle_count + FROM market_data.ohlcv_5m + WHERE ticker_id = p_ticker_id + AND timestamp >= v_start + AND timestamp < v_end + GROUP BY ticker_id, ts_15m + HAVING COUNT(*) >= 2 -- Al menos 2 velas de 5m + ) + INSERT INTO market_data.ohlcv_15m (ticker_id, timestamp, open, high, low, close, volume, vwap, candle_count) + SELECT ticker_id, ts_15m, open, high, low, close, volume, vwap, candle_count + FROM aggregated + ON CONFLICT (ticker_id, timestamp) DO UPDATE SET + open = EXCLUDED.open, + high = EXCLUDED.high, + low = EXCLUDED.low, + close = EXCLUDED.close, + volume = EXCLUDED.volume, + vwap = EXCLUDED.vwap, + candle_count = EXCLUDED.candle_count; + + GET DIAGNOSTICS v_inserted = ROW_COUNT; + + RETURN v_inserted; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION market_data.aggregate_5m_to_15m IS 'Genera datos OHLCV de 15m agregando velas de 5m'; + +-- ============================================================================ +-- Function: aggregate_all_15m +-- Description: Genera datos de 15m para todos los tickers activos +-- ============================================================================ + +CREATE OR REPLACE FUNCTION market_data.aggregate_all_15m() +RETURNS TABLE (ticker_symbol VARCHAR, rows_inserted INTEGER) AS $$ +DECLARE + r RECORD; + v_count INTEGER; +BEGIN + FOR r IN SELECT id, symbol FROM market_data.tickers WHERE is_active = true + LOOP + v_count := market_data.aggregate_5m_to_15m(r.id); + ticker_symbol := r.symbol; + rows_inserted := v_count; + RETURN NEXT; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION market_data.aggregate_all_15m IS 'Genera datos 15m para todos los tickers activos'; diff --git a/ddl/schemas/market_data/tables/01-tickers.sql b/ddl/schemas/market_data/tables/01-tickers.sql new file mode 100644 index 0000000..2e3e92e --- /dev/null +++ b/ddl/schemas/market_data/tables/01-tickers.sql @@ -0,0 +1,54 @@ +-- ============================================================================ +-- Schema: market_data +-- Table: tickers +-- Description: Catálogo de activos/tickers para datos de mercado +-- Dependencies: market_data.asset_type +-- ============================================================================ + +CREATE TABLE market_data.tickers ( + id SERIAL PRIMARY KEY, + + -- Identificación + symbol VARCHAR(20) NOT NULL UNIQUE, -- XAUUSD, EURUSD, BTCUSD + name VARCHAR(100) NOT NULL, -- Gold/US Dollar, Euro/US Dollar + + -- Clasificación + asset_type market_data.asset_type NOT NULL, + base_currency VARCHAR(10) NOT NULL, -- XAU, EUR, BTC + quote_currency VARCHAR(10) NOT NULL, -- USD + + -- Configuración ML + is_ml_enabled BOOLEAN DEFAULT true, -- Habilitado para ML signals + supported_timeframes VARCHAR(50)[] DEFAULT ARRAY['5m', '15m'], + + -- Polygon.io mapping + polygon_ticker VARCHAR(20), -- C:XAUUSD, X:BTCUSD + + -- Estado + is_active BOOLEAN DEFAULT true, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_tickers_symbol ON market_data.tickers(symbol); +CREATE INDEX idx_tickers_asset_type ON market_data.tickers(asset_type); +CREATE INDEX idx_tickers_ml_enabled ON market_data.tickers(is_ml_enabled) WHERE is_ml_enabled = true; +CREATE INDEX idx_tickers_polygon ON market_data.tickers(polygon_ticker); + +-- Comentarios +COMMENT ON TABLE market_data.tickers IS 'Catálogo de activos financieros para datos OHLCV'; +COMMENT ON COLUMN market_data.tickers.symbol IS 'Símbolo del activo (XAUUSD, BTCUSD, etc.)'; +COMMENT ON COLUMN market_data.tickers.polygon_ticker IS 'Símbolo en formato Polygon.io (C:XAUUSD, X:BTCUSD)'; +COMMENT ON COLUMN market_data.tickers.is_ml_enabled IS 'Indica si el activo está habilitado para ML signals'; + +-- Seed: 6 activos principales +INSERT INTO market_data.tickers (symbol, name, asset_type, base_currency, quote_currency, polygon_ticker) VALUES + ('XAUUSD', 'Gold/US Dollar', 'commodity', 'XAU', 'USD', 'C:XAUUSD'), + ('EURUSD', 'Euro/US Dollar', 'forex', 'EUR', 'USD', 'C:EURUSD'), + ('BTCUSD', 'Bitcoin/US Dollar', 'crypto', 'BTC', 'USD', 'X:BTCUSD'), + ('GBPUSD', 'British Pound/US Dollar', 'forex', 'GBP', 'USD', 'C:GBPUSD'), + ('USDJPY', 'US Dollar/Japanese Yen', 'forex', 'USD', 'JPY', 'C:USDJPY'), + ('AUDUSD', 'Australian Dollar/US Dollar', 'forex', 'AUD', 'USD', 'C:AUDUSD'); diff --git a/ddl/schemas/market_data/tables/02-ohlcv_5m.sql b/ddl/schemas/market_data/tables/02-ohlcv_5m.sql new file mode 100644 index 0000000..3f6a85e --- /dev/null +++ b/ddl/schemas/market_data/tables/02-ohlcv_5m.sql @@ -0,0 +1,44 @@ +-- ============================================================================ +-- Schema: market_data +-- Table: ohlcv_5m +-- Description: Datos OHLCV agregados a 5 minutos +-- Dependencies: market_data.tickers +-- ============================================================================ + +CREATE TABLE market_data.ohlcv_5m ( + id BIGSERIAL, + ticker_id INTEGER NOT NULL REFERENCES market_data.tickers(id), + + -- Timestamp + timestamp TIMESTAMPTZ NOT NULL, + + -- OHLCV + open DECIMAL(20,8) NOT NULL, + high DECIMAL(20,8) NOT NULL, + low DECIMAL(20,8) NOT NULL, + close DECIMAL(20,8) NOT NULL, + volume DECIMAL(20,4) DEFAULT 0, + + -- Datos adicionales de Polygon + vwap DECIMAL(20,8), -- Volume Weighted Average Price + ts_epoch BIGINT, -- Timestamp original en ms + + -- Metadatos + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraint único + CONSTRAINT ohlcv_5m_unique UNIQUE (ticker_id, timestamp), + PRIMARY KEY (id) +); + +-- Índices optimizados para consultas ML +CREATE INDEX idx_ohlcv_5m_ticker_ts ON market_data.ohlcv_5m(ticker_id, timestamp DESC); +CREATE INDEX idx_ohlcv_5m_timestamp ON market_data.ohlcv_5m(timestamp DESC); +-- Índice para consultas recientes (sin filtro temporal - NOW() no es IMMUTABLE) +CREATE INDEX idx_ohlcv_5m_ticker_recent ON market_data.ohlcv_5m(ticker_id, timestamp DESC) + WHERE timestamp >= '2024-01-01'::TIMESTAMPTZ; + +-- Comentarios +COMMENT ON TABLE market_data.ohlcv_5m IS 'Datos OHLCV agregados a 5 minutos - Fuente: Polygon.io'; +COMMENT ON COLUMN market_data.ohlcv_5m.vwap IS 'Volume Weighted Average Price del período'; +COMMENT ON COLUMN market_data.ohlcv_5m.ts_epoch IS 'Timestamp original de Polygon en milisegundos'; diff --git a/ddl/schemas/market_data/tables/03-ohlcv_15m.sql b/ddl/schemas/market_data/tables/03-ohlcv_15m.sql new file mode 100644 index 0000000..7b86dac --- /dev/null +++ b/ddl/schemas/market_data/tables/03-ohlcv_15m.sql @@ -0,0 +1,43 @@ +-- ============================================================================ +-- Schema: market_data +-- Table: ohlcv_15m +-- Description: Datos OHLCV agregados a 15 minutos (derivados de 5m) +-- Dependencies: market_data.tickers, market_data.ohlcv_5m +-- ============================================================================ + +CREATE TABLE market_data.ohlcv_15m ( + id BIGSERIAL, + ticker_id INTEGER NOT NULL REFERENCES market_data.tickers(id), + + -- Timestamp (inicio del período de 15 minutos) + timestamp TIMESTAMPTZ NOT NULL, + + -- OHLCV agregados + open DECIMAL(20,8) NOT NULL, + high DECIMAL(20,8) NOT NULL, + low DECIMAL(20,8) NOT NULL, + close DECIMAL(20,8) NOT NULL, + volume DECIMAL(20,4) DEFAULT 0, + + -- Datos agregados + vwap DECIMAL(20,8), -- VWAP del período + candle_count INTEGER DEFAULT 3, -- Número de velas 5m agregadas + + -- Metadatos + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraint único + CONSTRAINT ohlcv_15m_unique UNIQUE (ticker_id, timestamp), + PRIMARY KEY (id) +); + +-- Índices optimizados para consultas ML +CREATE INDEX idx_ohlcv_15m_ticker_ts ON market_data.ohlcv_15m(ticker_id, timestamp DESC); +CREATE INDEX idx_ohlcv_15m_timestamp ON market_data.ohlcv_15m(timestamp DESC); +-- Índice para consultas recientes (sin filtro temporal - NOW() no es IMMUTABLE) +CREATE INDEX idx_ohlcv_15m_ticker_recent ON market_data.ohlcv_15m(ticker_id, timestamp DESC) + WHERE timestamp >= '2024-01-01'::TIMESTAMPTZ; + +-- Comentarios +COMMENT ON TABLE market_data.ohlcv_15m IS 'Datos OHLCV agregados a 15 minutos - Derivados de ohlcv_5m'; +COMMENT ON COLUMN market_data.ohlcv_15m.candle_count IS 'Número de velas de 5m que componen esta vela de 15m'; diff --git a/ddl/schemas/market_data/tables/04-staging.sql b/ddl/schemas/market_data/tables/04-staging.sql new file mode 100644 index 0000000..e076e54 --- /dev/null +++ b/ddl/schemas/market_data/tables/04-staging.sql @@ -0,0 +1,20 @@ +-- ============================================================================ +-- Schema: market_data +-- Table: ohlcv_5m_staging +-- Description: Tabla temporal para carga masiva de datos +-- ============================================================================ + +CREATE TABLE market_data.ohlcv_5m_staging ( + ticker_id INTEGER, + timestamp TIMESTAMPTZ, + open DECIMAL(20,8), + high DECIMAL(20,8), + low DECIMAL(20,8), + close DECIMAL(20,8), + volume DECIMAL(20,4), + vwap DECIMAL(20,8), + ts_epoch BIGINT +); + +-- Sin índices para carga rápida +COMMENT ON TABLE market_data.ohlcv_5m_staging IS 'Tabla staging para carga masiva de datos OHLCV'; diff --git a/ddl/schemas/ml/00-enums.sql b/ddl/schemas/ml/00-enums.sql new file mode 100644 index 0000000..7b01f33 --- /dev/null +++ b/ddl/schemas/ml/00-enums.sql @@ -0,0 +1,68 @@ +-- ===================================================== +-- ML SCHEMA - ENUMS +-- ===================================================== +-- Description: Enumerations for ML signals system +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +-- Tipo de modelo ML +CREATE TYPE ml.model_type AS ENUM ( + 'classification', + 'regression', + 'time_series', + 'clustering', + 'anomaly_detection', + 'reinforcement_learning' +); + +-- Framework de ML +CREATE TYPE ml.framework AS ENUM ( + 'sklearn', + 'tensorflow', + 'pytorch', + 'xgboost', + 'lightgbm', + 'prophet', + 'custom' +); + +-- Estado del modelo +CREATE TYPE ml.model_status AS ENUM ( + 'development', + 'testing', + 'staging', + 'production', + 'deprecated', + 'archived' +); + +-- Tipo de predicción +CREATE TYPE ml.prediction_type AS ENUM ( + 'price_direction', -- UP/DOWN/NEUTRAL + 'price_target', -- Precio objetivo + 'volatility', -- Alta/Media/Baja + 'trend', -- Tendencia + 'signal', -- BUY/SELL/HOLD + 'risk_score' -- Score de riesgo +); + +-- Resultado de predicción +CREATE TYPE ml.prediction_result AS ENUM ( + 'buy', + 'sell', + 'hold', + 'up', + 'down', + 'neutral' +); + +-- Estado de outcome +CREATE TYPE ml.outcome_status AS ENUM ( + 'pending', + 'correct', + 'incorrect', + 'partially_correct', + 'expired' +); diff --git a/ddl/schemas/ml/functions/05-calculate_prediction_accuracy.sql b/ddl/schemas/ml/functions/05-calculate_prediction_accuracy.sql new file mode 100644 index 0000000..a0a58fa --- /dev/null +++ b/ddl/schemas/ml/functions/05-calculate_prediction_accuracy.sql @@ -0,0 +1,392 @@ +-- ===================================================== +-- ML SCHEMA - CALCULATE PREDICTION ACCURACY FUNCTION +-- ===================================================== +-- Description: Function to calculate LLM prediction accuracy metrics +-- Schema: ml +-- Author: Database Agent +-- Date: 2026-01-04 +-- Module: OQI-010-llm-trading-integration +-- ===================================================== + +-- ----------------------------------------------------- +-- Function: ml.calculate_llm_prediction_accuracy +-- ----------------------------------------------------- +-- Calculates accuracy metrics for LLM predictions +-- Parameters: +-- p_symbol: Trading symbol (required) +-- p_days: Number of days to analyze (default: 30) +-- Returns: +-- total_predictions: Total number of resolved predictions +-- direction_accuracy: Percentage of correct direction predictions +-- target_hit_rate: Percentage of predictions that hit take profit +-- avg_pnl_pips: Average profit/loss in pips +-- profit_factor: Ratio of gross profit to gross loss +-- win_rate: Percentage of profitable trades +-- avg_resolution_candles: Average candles to resolution +-- ----------------------------------------------------- + +CREATE OR REPLACE FUNCTION ml.calculate_llm_prediction_accuracy( + p_symbol VARCHAR, + p_days INT DEFAULT 30 +) +RETURNS TABLE( + total_predictions INT, + direction_accuracy DECIMAL(5,4), + target_hit_rate DECIMAL(5,4), + stop_hit_rate DECIMAL(5,4), + avg_pnl_pips DECIMAL(10,2), + profit_factor DECIMAL(10,4), + win_rate DECIMAL(5,4), + avg_resolution_candles DECIMAL(10,2) +) AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*)::INT AS total_predictions, + + -- Direction accuracy (correct predictions / total) + COALESCE( + AVG(CASE WHEN o.direction_correct = TRUE THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS direction_accuracy, + + -- Target hit rate (predictions that reached take profit) + COALESCE( + AVG(CASE WHEN o.target_reached = TRUE THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS target_hit_rate, + + -- Stop hit rate (predictions that hit stop loss) + COALESCE( + AVG(CASE WHEN o.stop_hit = TRUE THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS stop_hit_rate, + + -- Average PnL in pips + COALESCE(AVG(o.pnl_pips)::DECIMAL(10,2), 0.0) AS avg_pnl_pips, + + -- Profit factor (gross profit / gross loss) + CASE + WHEN COALESCE(SUM(CASE WHEN o.pnl_pips < 0 THEN ABS(o.pnl_pips) ELSE 0 END), 0) > 0 + THEN ( + COALESCE(SUM(CASE WHEN o.pnl_pips > 0 THEN o.pnl_pips ELSE 0 END), 0) / + SUM(CASE WHEN o.pnl_pips < 0 THEN ABS(o.pnl_pips) ELSE 0 END) + )::DECIMAL(10,4) + ELSE NULL + END AS profit_factor, + + -- Win rate (profitable trades / total) + COALESCE( + AVG(CASE WHEN o.pnl_pips > 0 THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS win_rate, + + -- Average candles to resolution + COALESCE(AVG(o.resolution_candles)::DECIMAL(10,2), 0.0) AS avg_resolution_candles + + FROM ml.llm_predictions p + INNER JOIN ml.llm_prediction_outcomes o ON p.id = o.prediction_id + WHERE p.symbol = p_symbol + AND p.created_at >= NOW() - (p_days || ' days')::INTERVAL + AND o.resolved_at IS NOT NULL; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION ml.calculate_llm_prediction_accuracy(VARCHAR, INT) IS +'Calculates comprehensive accuracy metrics for LLM predictions. + +Parameters: + - p_symbol: Trading symbol to analyze (e.g., XAUUSD, BTCUSDT) + - p_days: Number of days to look back (default: 30) + +Returns: + - total_predictions: Count of resolved predictions in period + - direction_accuracy: Ratio of correct direction predictions (0.0 to 1.0) + - target_hit_rate: Ratio of predictions that hit take profit (0.0 to 1.0) + - stop_hit_rate: Ratio of predictions that hit stop loss (0.0 to 1.0) + - avg_pnl_pips: Average profit/loss in pips + - profit_factor: Gross profit / Gross loss (>1.0 is profitable) + - win_rate: Ratio of profitable trades (0.0 to 1.0) + - avg_resolution_candles: Average candles until outcome determined + +Example usage: + SELECT * FROM ml.calculate_llm_prediction_accuracy(''XAUUSD'', 30); + SELECT * FROM ml.calculate_llm_prediction_accuracy(''BTCUSDT'', 7); +'; + + +-- ----------------------------------------------------- +-- Function: ml.calculate_llm_prediction_accuracy_by_phase +-- ----------------------------------------------------- +-- Calculates accuracy metrics grouped by AMD phase +-- ----------------------------------------------------- + +CREATE OR REPLACE FUNCTION ml.calculate_llm_prediction_accuracy_by_phase( + p_symbol VARCHAR, + p_days INT DEFAULT 30 +) +RETURNS TABLE( + amd_phase VARCHAR(50), + total_predictions INT, + direction_accuracy DECIMAL(5,4), + avg_pnl_pips DECIMAL(10,2), + win_rate DECIMAL(5,4) +) AS $$ +BEGIN + RETURN QUERY + SELECT + p.amd_phase, + COUNT(*)::INT AS total_predictions, + COALESCE( + AVG(CASE WHEN o.direction_correct = TRUE THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS direction_accuracy, + COALESCE(AVG(o.pnl_pips)::DECIMAL(10,2), 0.0) AS avg_pnl_pips, + COALESCE( + AVG(CASE WHEN o.pnl_pips > 0 THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS win_rate + FROM ml.llm_predictions p + INNER JOIN ml.llm_prediction_outcomes o ON p.id = o.prediction_id + WHERE p.symbol = p_symbol + AND p.created_at >= NOW() - (p_days || ' days')::INTERVAL + AND o.resolved_at IS NOT NULL + AND p.amd_phase IS NOT NULL + GROUP BY p.amd_phase + ORDER BY total_predictions DESC; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION ml.calculate_llm_prediction_accuracy_by_phase(VARCHAR, INT) IS +'Calculates prediction accuracy metrics grouped by AMD phase. + +Useful for understanding which AMD phases produce the most accurate predictions. + +Example usage: + SELECT * FROM ml.calculate_llm_prediction_accuracy_by_phase(''XAUUSD'', 30); +'; + + +-- ----------------------------------------------------- +-- Function: ml.calculate_llm_prediction_accuracy_by_killzone +-- ----------------------------------------------------- +-- Calculates accuracy metrics grouped by ICT Killzone +-- ----------------------------------------------------- + +CREATE OR REPLACE FUNCTION ml.calculate_llm_prediction_accuracy_by_killzone( + p_symbol VARCHAR, + p_days INT DEFAULT 30 +) +RETURNS TABLE( + killzone VARCHAR(50), + total_predictions INT, + direction_accuracy DECIMAL(5,4), + avg_pnl_pips DECIMAL(10,2), + win_rate DECIMAL(5,4) +) AS $$ +BEGIN + RETURN QUERY + SELECT + p.killzone, + COUNT(*)::INT AS total_predictions, + COALESCE( + AVG(CASE WHEN o.direction_correct = TRUE THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS direction_accuracy, + COALESCE(AVG(o.pnl_pips)::DECIMAL(10,2), 0.0) AS avg_pnl_pips, + COALESCE( + AVG(CASE WHEN o.pnl_pips > 0 THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS win_rate + FROM ml.llm_predictions p + INNER JOIN ml.llm_prediction_outcomes o ON p.id = o.prediction_id + WHERE p.symbol = p_symbol + AND p.created_at >= NOW() - (p_days || ' days')::INTERVAL + AND o.resolved_at IS NOT NULL + AND p.killzone IS NOT NULL + GROUP BY p.killzone + ORDER BY total_predictions DESC; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION ml.calculate_llm_prediction_accuracy_by_killzone(VARCHAR, INT) IS +'Calculates prediction accuracy metrics grouped by ICT Killzone. + +Useful for understanding which trading sessions produce the best predictions. + +Example usage: + SELECT * FROM ml.calculate_llm_prediction_accuracy_by_killzone(''XAUUSD'', 30); +'; + + +-- ----------------------------------------------------- +-- Function: ml.calculate_llm_prediction_accuracy_by_confluence +-- ----------------------------------------------------- +-- Calculates accuracy metrics grouped by confluence score ranges +-- ----------------------------------------------------- + +CREATE OR REPLACE FUNCTION ml.calculate_llm_prediction_accuracy_by_confluence( + p_symbol VARCHAR, + p_days INT DEFAULT 30 +) +RETURNS TABLE( + confluence_range VARCHAR(20), + total_predictions INT, + direction_accuracy DECIMAL(5,4), + avg_pnl_pips DECIMAL(10,2), + win_rate DECIMAL(5,4) +) AS $$ +BEGIN + RETURN QUERY + SELECT + CASE + WHEN p.confluence_score >= 0.8 THEN '0.8-1.0 (High)' + WHEN p.confluence_score >= 0.6 THEN '0.6-0.8 (Medium-High)' + WHEN p.confluence_score >= 0.4 THEN '0.4-0.6 (Medium)' + WHEN p.confluence_score >= 0.2 THEN '0.2-0.4 (Medium-Low)' + ELSE '0.0-0.2 (Low)' + END AS confluence_range, + COUNT(*)::INT AS total_predictions, + COALESCE( + AVG(CASE WHEN o.direction_correct = TRUE THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS direction_accuracy, + COALESCE(AVG(o.pnl_pips)::DECIMAL(10,2), 0.0) AS avg_pnl_pips, + COALESCE( + AVG(CASE WHEN o.pnl_pips > 0 THEN 1.0 ELSE 0.0 END)::DECIMAL(5,4), + 0.0 + ) AS win_rate + FROM ml.llm_predictions p + INNER JOIN ml.llm_prediction_outcomes o ON p.id = o.prediction_id + WHERE p.symbol = p_symbol + AND p.created_at >= NOW() - (p_days || ' days')::INTERVAL + AND o.resolved_at IS NOT NULL + AND p.confluence_score IS NOT NULL + GROUP BY + CASE + WHEN p.confluence_score >= 0.8 THEN '0.8-1.0 (High)' + WHEN p.confluence_score >= 0.6 THEN '0.6-0.8 (Medium-High)' + WHEN p.confluence_score >= 0.4 THEN '0.4-0.6 (Medium)' + WHEN p.confluence_score >= 0.2 THEN '0.2-0.4 (Medium-Low)' + ELSE '0.0-0.2 (Low)' + END + ORDER BY confluence_range DESC; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION ml.calculate_llm_prediction_accuracy_by_confluence(VARCHAR, INT) IS +'Calculates prediction accuracy metrics grouped by confluence score ranges. + +Validates whether higher confluence scores correlate with better accuracy. + +Example usage: + SELECT * FROM ml.calculate_llm_prediction_accuracy_by_confluence(''XAUUSD'', 30); +'; + + +-- ----------------------------------------------------- +-- Function: ml.get_active_risk_events +-- ----------------------------------------------------- +-- Returns all unresolved risk events for a user +-- ----------------------------------------------------- + +CREATE OR REPLACE FUNCTION ml.get_active_risk_events( + p_user_id UUID DEFAULT NULL +) +RETURNS TABLE( + id UUID, + event_type VARCHAR(50), + severity VARCHAR(20), + details JSONB, + action_taken VARCHAR(100), + created_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + r.id, + r.event_type, + r.severity, + r.details, + r.action_taken, + r.created_at + FROM ml.risk_events r + WHERE r.resolved = FALSE + AND (p_user_id IS NULL OR r.user_id = p_user_id OR r.user_id IS NULL) + ORDER BY + CASE r.severity + WHEN 'emergency' THEN 1 + WHEN 'critical' THEN 2 + WHEN 'warning' THEN 3 + ELSE 4 + END, + r.created_at DESC; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION ml.get_active_risk_events(UUID) IS +'Returns all unresolved risk events, optionally filtered by user. + +Parameters: + - p_user_id: User ID to filter by (NULL for all events including system-wide) + +Returns events ordered by severity (emergency first) then by time. + +Example usage: + SELECT * FROM ml.get_active_risk_events(); + SELECT * FROM ml.get_active_risk_events(''550e8400-e29b-41d4-a716-446655440000''); +'; + + +-- ----------------------------------------------------- +-- Function: ml.check_circuit_breaker_status +-- ----------------------------------------------------- +-- Checks if circuit breaker is active for a user +-- ----------------------------------------------------- + +CREATE OR REPLACE FUNCTION ml.check_circuit_breaker_status( + p_user_id UUID DEFAULT NULL +) +RETURNS TABLE( + is_active BOOLEAN, + event_id UUID, + trigger_reason TEXT, + created_at TIMESTAMPTZ, + details JSONB +) AS $$ +BEGIN + RETURN QUERY + SELECT + TRUE AS is_active, + r.id AS event_id, + r.details->>'trigger_reason' AS trigger_reason, + r.created_at, + r.details + FROM ml.risk_events r + WHERE r.event_type = 'CIRCUIT_BREAKER' + AND r.resolved = FALSE + AND (p_user_id IS NULL OR r.user_id = p_user_id OR r.user_id IS NULL) + ORDER BY r.created_at DESC + LIMIT 1; + + -- If no rows returned, return false status + IF NOT FOUND THEN + RETURN QUERY SELECT FALSE, NULL::UUID, NULL::TEXT, NULL::TIMESTAMPTZ, NULL::JSONB; + END IF; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION ml.check_circuit_breaker_status(UUID) IS +'Checks if circuit breaker is currently active for a user. + +Returns: + - is_active: TRUE if circuit breaker is engaged + - event_id: ID of the active circuit breaker event + - trigger_reason: Reason the circuit breaker was triggered + - created_at: When the circuit breaker was activated + - details: Full details of the event + +Example usage: + SELECT * FROM ml.check_circuit_breaker_status(); + SELECT is_active FROM ml.check_circuit_breaker_status(''550e8400-e29b-41d4-a716-446655440000''); +'; diff --git a/ddl/schemas/ml/tables/01-models.sql b/ddl/schemas/ml/tables/01-models.sql new file mode 100644 index 0000000..d47077e --- /dev/null +++ b/ddl/schemas/ml/tables/01-models.sql @@ -0,0 +1,65 @@ +-- ===================================================== +-- ML SCHEMA - MODELS TABLE +-- ===================================================== +-- Description: ML models registry +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.models ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + name VARCHAR(100) NOT NULL UNIQUE, + display_name VARCHAR(200) NOT NULL, + description TEXT, + + -- Tipo y framework + model_type ml.model_type NOT NULL, + framework ml.framework NOT NULL, + + -- Categoría + category VARCHAR(50) NOT NULL, -- sentiment, technical, fundamental, hybrid + + -- Alcance + applies_to_symbols VARCHAR(20)[] DEFAULT '{}', -- ['BTCUSDT', 'ETHUSDT'] o [] para todos + applies_to_timeframes VARCHAR(10)[] DEFAULT '{}', -- ['1h', '4h', '1d'] o [] para todos + + -- Estado + status ml.model_status DEFAULT 'development', + + -- Versión actual en producción + current_version_id UUID, + + -- Metadata + owner VARCHAR(100) NOT NULL, + repository_url VARCHAR(500), + documentation_url VARCHAR(500), + + -- Métricas agregadas (de todas las versiones) + total_predictions INTEGER DEFAULT 0, + total_correct_predictions INTEGER DEFAULT 0, + overall_accuracy DECIMAL(5,4), + + -- Fechas + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deployed_at TIMESTAMPTZ, + deprecated_at TIMESTAMPTZ +); + +-- Índices +CREATE INDEX idx_models_name ON ml.models(name); +CREATE INDEX idx_models_status ON ml.models(status); +CREATE INDEX idx_models_type ON ml.models(model_type); +CREATE INDEX idx_models_category ON ml.models(category); +CREATE INDEX idx_models_production ON ml.models(status) WHERE status = 'production'; + +-- Comentarios +COMMENT ON TABLE ml.models IS 'Registry of ML models for trading signals'; +COMMENT ON COLUMN ml.models.name IS 'Unique technical name (e.g., sentiment_analyzer_v1)'; +COMMENT ON COLUMN ml.models.applies_to_symbols IS 'Symbols this model can analyze. Empty array = all symbols'; +COMMENT ON COLUMN ml.models.applies_to_timeframes IS 'Timeframes this model supports. Empty array = all timeframes'; +COMMENT ON COLUMN ml.models.current_version_id IS 'Reference to current production version'; +COMMENT ON COLUMN ml.models.overall_accuracy IS 'Aggregated accuracy across all versions and predictions'; diff --git a/ddl/schemas/ml/tables/02-model_versions.sql b/ddl/schemas/ml/tables/02-model_versions.sql new file mode 100644 index 0000000..255b677 --- /dev/null +++ b/ddl/schemas/ml/tables/02-model_versions.sql @@ -0,0 +1,102 @@ +-- ===================================================== +-- ML SCHEMA - MODEL VERSIONS TABLE +-- ===================================================== +-- Description: Versioned ML model artifacts and metadata +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.model_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Modelo padre + model_id UUID NOT NULL REFERENCES ml.models(id) ON DELETE CASCADE, + + -- Versión + version VARCHAR(50) NOT NULL, -- Semantic versioning: 1.0.0, 1.1.0, 2.0.0 + + -- Artefacto + artifact_path VARCHAR(500) NOT NULL, -- S3 path, local path, registry URL + artifact_size_bytes BIGINT, + checksum VARCHAR(64), -- SHA-256 + + -- Métricas de entrenamiento + training_metrics JSONB, -- {accuracy, precision, recall, f1, loss} + validation_metrics JSONB, -- {accuracy, precision, recall, f1, loss} + test_metrics JSONB, -- {accuracy, precision, recall, f1, loss} + + -- Features + feature_set JSONB NOT NULL, -- Lista de features usadas + feature_importance JSONB, -- Importancia de cada feature + + -- Hiperparámetros + hyperparameters JSONB, -- Parámetros del modelo + + -- Dataset info + training_dataset_size INTEGER, + training_dataset_path VARCHAR(500), + data_version VARCHAR(50), + + -- Estado + is_production BOOLEAN DEFAULT false, + deployed_at TIMESTAMPTZ, + deployment_metadata JSONB, + + -- Metadata de entrenamiento + training_started_at TIMESTAMPTZ, + training_completed_at TIMESTAMPTZ, + training_duration_seconds INTEGER, + trained_by VARCHAR(100), + training_environment JSONB, -- Python version, library versions, etc. + + -- Performance en producción + production_predictions INTEGER DEFAULT 0, + production_accuracy DECIMAL(5,4), + + -- Notas + release_notes TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_model_version UNIQUE(model_id, version) +); + +-- Índices +CREATE INDEX idx_model_versions_model ON ml.model_versions(model_id); +CREATE INDEX idx_model_versions_production ON ml.model_versions(is_production) + WHERE is_production = true; +CREATE INDEX idx_model_versions_version ON ml.model_versions(version); +CREATE INDEX idx_model_versions_deployed ON ml.model_versions(deployed_at DESC); + +-- Comentarios +COMMENT ON TABLE ml.model_versions IS 'Versioned artifacts and metadata for ML models'; +COMMENT ON COLUMN ml.model_versions.version IS 'Semantic version (major.minor.patch)'; +COMMENT ON COLUMN ml.model_versions.artifact_path IS 'Location of serialized model file'; +COMMENT ON COLUMN ml.model_versions.checksum IS 'SHA-256 hash for artifact integrity verification'; +COMMENT ON COLUMN ml.model_versions.feature_set IS 'Array of feature names used by this version'; +COMMENT ON COLUMN ml.model_versions.hyperparameters IS 'Model hyperparameters for reproducibility'; +COMMENT ON COLUMN ml.model_versions.is_production IS 'Whether this version is currently deployed in production'; + +-- Ejemplo de training_metrics JSONB: +COMMENT ON COLUMN ml.model_versions.training_metrics IS +'Example: { + "accuracy": 0.8542, + "precision": 0.8234, + "recall": 0.7891, + "f1_score": 0.8058, + "loss": 0.3421, + "auc_roc": 0.9123 +}'; + +-- Ejemplo de feature_set JSONB: +COMMENT ON COLUMN ml.model_versions.feature_set IS +'Example: [ + "rsi_14", + "macd_signal", + "volume_sma_20", + "price_change_1h", + "sentiment_score" +]'; diff --git a/ddl/schemas/ml/tables/03-predictions.sql b/ddl/schemas/ml/tables/03-predictions.sql new file mode 100644 index 0000000..aa6fc33 --- /dev/null +++ b/ddl/schemas/ml/tables/03-predictions.sql @@ -0,0 +1,94 @@ +-- ===================================================== +-- ML SCHEMA - PREDICTIONS TABLE +-- ===================================================== +-- Description: ML model predictions and signals +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.predictions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Modelo y versión + model_id UUID NOT NULL REFERENCES ml.models(id) ON DELETE CASCADE, + model_version_id UUID NOT NULL REFERENCES ml.model_versions(id) ON DELETE CASCADE, + + -- Símbolo y timeframe + symbol VARCHAR(20) NOT NULL, + timeframe VARCHAR(10) NOT NULL, + + -- Tipo de predicción + prediction_type ml.prediction_type NOT NULL, + + -- Resultado de predicción + prediction_result ml.prediction_result, + prediction_value DECIMAL(20,8), -- Para predicciones numéricas + + -- Confianza + confidence_score DECIMAL(5,4) NOT NULL CHECK (confidence_score >= 0 AND confidence_score <= 1), + + -- Input features utilizados + input_features JSONB NOT NULL, + + -- Output completo del modelo + model_output JSONB, -- Raw output del modelo + + -- Contexto de mercado al momento de predicción + market_price DECIMAL(20,8), + market_timestamp TIMESTAMPTZ NOT NULL, + + -- Horizonte temporal + prediction_horizon VARCHAR(20), -- 1h, 4h, 1d, 1w + valid_until TIMESTAMPTZ, + + -- Metadata + prediction_metadata JSONB, + + -- Procesamiento + inference_time_ms INTEGER, -- Tiempo de inferencia en milisegundos + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_predictions_model ON ml.predictions(model_id); +CREATE INDEX idx_predictions_version ON ml.predictions(model_version_id); +CREATE INDEX idx_predictions_symbol ON ml.predictions(symbol); +CREATE INDEX idx_predictions_symbol_time ON ml.predictions(symbol, market_timestamp DESC); +CREATE INDEX idx_predictions_type ON ml.predictions(prediction_type); +CREATE INDEX idx_predictions_created ON ml.predictions(created_at DESC); +-- Index for predictions with validity period (without time-based predicate for immutability) +CREATE INDEX idx_predictions_valid ON ml.predictions(valid_until) + WHERE valid_until IS NOT NULL; + +-- Particionamiento por fecha (opcional, para alto volumen) +-- CREATE INDEX idx_predictions_timestamp ON ml.predictions(market_timestamp DESC); + +-- Comentarios +COMMENT ON TABLE ml.predictions IS 'ML model predictions and trading signals'; +COMMENT ON COLUMN ml.predictions.prediction_type IS 'Type of prediction being made'; +COMMENT ON COLUMN ml.predictions.prediction_result IS 'Categorical result (buy/sell/hold/up/down/neutral)'; +COMMENT ON COLUMN ml.predictions.prediction_value IS 'Numeric prediction value (e.g., target price, probability)'; +COMMENT ON COLUMN ml.predictions.confidence_score IS 'Model confidence in prediction (0.0 to 1.0)'; +COMMENT ON COLUMN ml.predictions.input_features IS 'Feature values used for this prediction'; +COMMENT ON COLUMN ml.predictions.prediction_horizon IS 'Time horizon for prediction validity'; +COMMENT ON COLUMN ml.predictions.inference_time_ms IS 'Model inference latency in milliseconds'; + +-- Ejemplo de input_features JSONB: +COMMENT ON COLUMN ml.predictions.input_features IS +'Example: { + "rsi_14": 65.42, + "macd_signal": 0.0234, + "volume_sma_20": 1234567.89, + "price_change_1h": 0.0145, + "sentiment_score": 0.72 +}'; + +-- Ejemplo de model_output JSONB: +COMMENT ON COLUMN ml.predictions.model_output IS +'Example: { + "probabilities": {"buy": 0.72, "sell": 0.15, "hold": 0.13}, + "raw_score": 0.5823, + "feature_contributions": {...} +}'; diff --git a/ddl/schemas/ml/tables/04-prediction_outcomes.sql b/ddl/schemas/ml/tables/04-prediction_outcomes.sql new file mode 100644 index 0000000..40322a8 --- /dev/null +++ b/ddl/schemas/ml/tables/04-prediction_outcomes.sql @@ -0,0 +1,68 @@ +-- ===================================================== +-- ML SCHEMA - PREDICTION OUTCOMES TABLE +-- ===================================================== +-- Description: Actual outcomes vs predictions for model evaluation +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.prediction_outcomes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Predicción asociada + prediction_id UUID NOT NULL REFERENCES ml.predictions(id) ON DELETE CASCADE, + + -- Resultado real + actual_result ml.prediction_result, + actual_value DECIMAL(20,8), + + -- Evaluación + outcome_status ml.outcome_status NOT NULL DEFAULT 'pending', + is_correct BOOLEAN, + + -- Métricas de error + absolute_error DECIMAL(20,8), -- |predicted - actual| + relative_error DECIMAL(10,6), -- (predicted - actual) / actual + + -- Contexto de mercado en verificación + actual_price DECIMAL(20,8), + price_change_percent DECIMAL(10,6), + + -- Timing + outcome_timestamp TIMESTAMPTZ NOT NULL, -- Cuando se verificó el outcome + time_to_outcome_hours DECIMAL(10,2), -- Horas desde predicción hasta outcome + + -- Metadata + outcome_metadata JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_prediction_outcome UNIQUE(prediction_id) +); + +-- Índices +CREATE INDEX idx_outcomes_prediction ON ml.prediction_outcomes(prediction_id); +CREATE INDEX idx_outcomes_status ON ml.prediction_outcomes(outcome_status); +CREATE INDEX idx_outcomes_correct ON ml.prediction_outcomes(is_correct) + WHERE is_correct IS NOT NULL; +CREATE INDEX idx_outcomes_timestamp ON ml.prediction_outcomes(outcome_timestamp DESC); + +-- Comentarios +COMMENT ON TABLE ml.prediction_outcomes IS 'Actual results for model predictions, used for performance tracking'; +COMMENT ON COLUMN ml.prediction_outcomes.actual_result IS 'What actually happened (buy/sell/hold/up/down/neutral)'; +COMMENT ON COLUMN ml.prediction_outcomes.actual_value IS 'Actual numeric value (e.g., actual price reached)'; +COMMENT ON COLUMN ml.prediction_outcomes.is_correct IS 'Whether prediction matched actual outcome'; +COMMENT ON COLUMN ml.prediction_outcomes.absolute_error IS 'Absolute difference between predicted and actual'; +COMMENT ON COLUMN ml.prediction_outcomes.relative_error IS 'Percentage error relative to actual value'; +COMMENT ON COLUMN ml.prediction_outcomes.time_to_outcome_hours IS 'Time elapsed from prediction to outcome verification'; + +-- Ejemplo de outcome_metadata JSONB: +COMMENT ON COLUMN ml.prediction_outcomes.outcome_metadata IS +'Example: { + "verification_method": "automated", + "market_volatility": "high", + "external_events": ["fed_announcement"], + "notes": "Prediction incorrect due to unexpected news" +}'; diff --git a/ddl/schemas/ml/tables/05-feature_store.sql b/ddl/schemas/ml/tables/05-feature_store.sql new file mode 100644 index 0000000..67b1b32 --- /dev/null +++ b/ddl/schemas/ml/tables/05-feature_store.sql @@ -0,0 +1,120 @@ +-- ===================================================== +-- ML SCHEMA - FEATURE STORE TABLE +-- ===================================================== +-- Description: Pre-calculated features for ML models +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.feature_store ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación del feature set + symbol VARCHAR(20) NOT NULL, + timeframe VARCHAR(10) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + + -- Features técnicos + technical_features JSONB, -- RSI, MACD, Bollinger, SMA, EMA, etc. + + -- Features de volumen + volume_features JSONB, -- Volume profiles, OBV, VWAP, etc. + + -- Features de precio + price_features JSONB, -- Price changes, returns, volatility, etc. + + -- Features de sentimiento (si disponible) + sentiment_features JSONB, -- Social sentiment, news sentiment, etc. + + -- Features on-chain (para crypto) + onchain_features JSONB, -- Network metrics, whale activity, etc. + + -- Features derivados + derived_features JSONB, -- Features calculados de otros features + + -- Metadata + feature_version VARCHAR(20), -- Versión del cálculo de features + calculation_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + calculation_duration_ms INTEGER, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_feature_set UNIQUE(symbol, timeframe, timestamp) +); + +-- Índices +CREATE INDEX idx_feature_store_symbol ON ml.feature_store(symbol); +CREATE INDEX idx_feature_store_symbol_time ON ml.feature_store(symbol, timeframe, timestamp DESC); +CREATE INDEX idx_feature_store_timestamp ON ml.feature_store(timestamp DESC); +CREATE INDEX idx_feature_store_version ON ml.feature_store(feature_version); + +-- Particionamiento por fecha (recomendado para alto volumen) +-- Se puede implementar particionamiento mensual o trimestral + +-- Comentarios +COMMENT ON TABLE ml.feature_store IS 'Pre-calculated features for ML model inference and training'; +COMMENT ON COLUMN ml.feature_store.symbol IS 'Trading symbol (e.g., BTCUSDT)'; +COMMENT ON COLUMN ml.feature_store.timeframe IS 'Timeframe for features (1m, 5m, 15m, 1h, 4h, 1d)'; +COMMENT ON COLUMN ml.feature_store.timestamp IS 'Timestamp of the candle/bar these features represent'; +COMMENT ON COLUMN ml.feature_store.feature_version IS 'Version of feature calculation logic'; + +-- Ejemplos de features JSONB: +COMMENT ON COLUMN ml.feature_store.technical_features IS +'Example: { + "rsi_14": 65.42, + "rsi_9": 68.21, + "macd": 0.0234, + "macd_signal": 0.0189, + "macd_histogram": 0.0045, + "bb_upper": 45678.90, + "bb_middle": 45234.12, + "bb_lower": 44789.34, + "sma_20": 45123.45, + "ema_12": 45234.56, + "ema_26": 45012.34 +}'; + +COMMENT ON COLUMN ml.feature_store.volume_features IS +'Example: { + "volume": 1234567.89, + "volume_sma_20": 1123456.78, + "volume_ratio": 1.098, + "obv": 98765432.10, + "vwap": 45234.12, + "buy_volume": 678901.23, + "sell_volume": 555666.66 +}'; + +COMMENT ON COLUMN ml.feature_store.price_features IS +'Example: { + "open": 45100.00, + "high": 45500.00, + "low": 44900.00, + "close": 45234.12, + "price_change_1h": 0.0145, + "price_change_4h": 0.0234, + "price_change_24h": 0.0567, + "volatility_1h": 0.0089, + "atr_14": 234.56 +}'; + +COMMENT ON COLUMN ml.feature_store.sentiment_features IS +'Example: { + "social_sentiment": 0.72, + "news_sentiment": 0.65, + "fear_greed_index": 68, + "twitter_volume": 12345, + "reddit_mentions": 678 +}'; + +COMMENT ON COLUMN ml.feature_store.onchain_features IS +'Example: { + "active_addresses": 123456, + "transaction_volume": 98765.43, + "exchange_inflow": 1234.56, + "exchange_outflow": 2345.67, + "whale_transactions": 23, + "network_growth": 0.023 +}'; diff --git a/ddl/schemas/ml/tables/06-llm_predictions.sql b/ddl/schemas/ml/tables/06-llm_predictions.sql new file mode 100644 index 0000000..008e13b --- /dev/null +++ b/ddl/schemas/ml/tables/06-llm_predictions.sql @@ -0,0 +1,107 @@ +-- ===================================================== +-- ML SCHEMA - LLM PREDICTIONS TABLE +-- ===================================================== +-- Description: Predictions generated by the LLM Trading Agent +-- Schema: ml +-- Author: Database Agent +-- Date: 2026-01-04 +-- Module: OQI-010-llm-trading-integration +-- ===================================================== + +CREATE TABLE ml.llm_predictions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Symbol and timeframe + symbol VARCHAR(20) NOT NULL, + timeframe VARCHAR(10) NOT NULL, + + -- AMD Phase prediction + amd_phase VARCHAR(50), + amd_confidence DECIMAL(5,4) CHECK (amd_confidence >= 0 AND amd_confidence <= 1), + + -- Signal prediction + signal_direction VARCHAR(10), + signal_confidence DECIMAL(5,4) CHECK (signal_confidence >= 0 AND signal_confidence <= 1), + + -- Price levels + entry_price DECIMAL(20,8), + stop_loss DECIMAL(20,8), + take_profit DECIMAL(20,8), + + -- ICT/SMC context + killzone VARCHAR(50), + ote_zone VARCHAR(20), + + -- Confluence score (0.0 to 1.0) + confluence_score DECIMAL(5,4) CHECK (confluence_score >= 0 AND confluence_score <= 1), + + -- LLM generated explanation + explanation TEXT, + + -- Model metadata + model_version VARCHAR(50) NOT NULL DEFAULT 'v1.0', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_llm_predictions_signal_direction CHECK ( + signal_direction IS NULL OR signal_direction IN ('LONG', 'SHORT', 'HOLD') + ), + CONSTRAINT chk_llm_predictions_amd_phase CHECK ( + amd_phase IS NULL OR amd_phase IN ( + 'accumulation', 'manipulation', 'distribution', + 're_accumulation', 're_distribution', 'markup', 'markdown' + ) + ), + CONSTRAINT chk_llm_predictions_killzone CHECK ( + killzone IS NULL OR killzone IN ( + 'asian_session', 'london_open', 'london_session', + 'new_york_open', 'new_york_session', 'london_close', 'none' + ) + ), + CONSTRAINT chk_llm_predictions_ote_zone CHECK ( + ote_zone IS NULL OR ote_zone IN ('premium', 'discount', 'equilibrium') + ), + CONSTRAINT chk_llm_predictions_price_levels CHECK ( + (entry_price IS NULL AND stop_loss IS NULL AND take_profit IS NULL) OR + (entry_price IS NOT NULL AND stop_loss IS NOT NULL) + ) +); + +-- Indices +CREATE INDEX idx_llm_predictions_symbol ON ml.llm_predictions(symbol); +CREATE INDEX idx_llm_predictions_symbol_time ON ml.llm_predictions(symbol, created_at DESC); +CREATE INDEX idx_llm_predictions_timeframe ON ml.llm_predictions(timeframe); +CREATE INDEX idx_llm_predictions_created ON ml.llm_predictions(created_at DESC); +CREATE INDEX idx_llm_predictions_amd_phase ON ml.llm_predictions(amd_phase) + WHERE amd_phase IS NOT NULL; +CREATE INDEX idx_llm_predictions_signal ON ml.llm_predictions(signal_direction) + WHERE signal_direction IS NOT NULL; +CREATE INDEX idx_llm_predictions_confluence ON ml.llm_predictions(confluence_score DESC) + WHERE confluence_score IS NOT NULL; +CREATE INDEX idx_llm_predictions_model_version ON ml.llm_predictions(model_version); + +-- High confluence active signals +CREATE INDEX idx_llm_predictions_active_signals ON ml.llm_predictions(symbol, confluence_score DESC) + WHERE confluence_score >= 0.6 AND signal_direction IN ('LONG', 'SHORT'); + +-- Comments +COMMENT ON TABLE ml.llm_predictions IS 'Predictions generated by the LLM Trading Agent with AMD phase detection and ICT/SMC context'; + +COMMENT ON COLUMN ml.llm_predictions.id IS 'Unique identifier for the prediction'; +COMMENT ON COLUMN ml.llm_predictions.symbol IS 'Trading symbol (e.g., XAUUSD, BTCUSDT)'; +COMMENT ON COLUMN ml.llm_predictions.timeframe IS 'Timeframe for analysis (e.g., 5m, 15m, 1h, 4h)'; +COMMENT ON COLUMN ml.llm_predictions.amd_phase IS 'AMD cycle phase: accumulation, manipulation, distribution, re_accumulation, etc.'; +COMMENT ON COLUMN ml.llm_predictions.amd_confidence IS 'Confidence score for AMD phase detection (0.0 to 1.0)'; +COMMENT ON COLUMN ml.llm_predictions.signal_direction IS 'Trade direction: LONG, SHORT, or HOLD'; +COMMENT ON COLUMN ml.llm_predictions.signal_confidence IS 'Confidence score for signal direction (0.0 to 1.0)'; +COMMENT ON COLUMN ml.llm_predictions.entry_price IS 'Recommended entry price level'; +COMMENT ON COLUMN ml.llm_predictions.stop_loss IS 'Recommended stop loss price level'; +COMMENT ON COLUMN ml.llm_predictions.take_profit IS 'Recommended take profit price level'; +COMMENT ON COLUMN ml.llm_predictions.killzone IS 'ICT Killzone active during prediction'; +COMMENT ON COLUMN ml.llm_predictions.ote_zone IS 'Optimal Trade Entry zone: premium, discount, or equilibrium'; +COMMENT ON COLUMN ml.llm_predictions.confluence_score IS 'Overall confluence score from all ML models (0.0 to 1.0)'; +COMMENT ON COLUMN ml.llm_predictions.explanation IS 'Natural language explanation generated by the LLM'; +COMMENT ON COLUMN ml.llm_predictions.model_version IS 'Version of the LLM model used for prediction'; +COMMENT ON COLUMN ml.llm_predictions.created_at IS 'Timestamp when prediction was generated'; diff --git a/ddl/schemas/ml/tables/07-llm_prediction_outcomes.sql b/ddl/schemas/ml/tables/07-llm_prediction_outcomes.sql new file mode 100644 index 0000000..5457532 --- /dev/null +++ b/ddl/schemas/ml/tables/07-llm_prediction_outcomes.sql @@ -0,0 +1,89 @@ +-- ===================================================== +-- ML SCHEMA - LLM PREDICTION OUTCOMES TABLE +-- ===================================================== +-- Description: Actual outcomes for LLM predictions to track accuracy +-- Schema: ml +-- Author: Database Agent +-- Date: 2026-01-04 +-- Module: OQI-010-llm-trading-integration +-- ===================================================== + +CREATE TABLE ml.llm_prediction_outcomes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Reference to prediction + prediction_id UUID NOT NULL REFERENCES ml.llm_predictions(id) ON DELETE CASCADE, + + -- Actual market results + actual_direction VARCHAR(10), + actual_high DECIMAL(20,8), + actual_low DECIMAL(20,8), + + -- Evaluation metrics + direction_correct BOOLEAN, + target_reached BOOLEAN, + stop_hit BOOLEAN, + + -- PnL metrics + pnl_pips DECIMAL(10,2), + pnl_percentage DECIMAL(10,4), + + -- Resolution timing + resolved_at TIMESTAMPTZ, + resolution_candles INTEGER, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_llm_prediction_outcome UNIQUE(prediction_id), + CONSTRAINT chk_llm_outcomes_actual_direction CHECK ( + actual_direction IS NULL OR actual_direction IN ('UP', 'DOWN', 'FLAT') + ), + CONSTRAINT chk_llm_outcomes_resolution CHECK ( + (resolved_at IS NOT NULL AND (direction_correct IS NOT NULL OR target_reached IS NOT NULL OR stop_hit IS NOT NULL)) + OR resolved_at IS NULL + ), + CONSTRAINT chk_llm_outcomes_high_low CHECK ( + (actual_high IS NULL AND actual_low IS NULL) OR + (actual_high IS NOT NULL AND actual_low IS NOT NULL AND actual_high >= actual_low) + ), + CONSTRAINT chk_llm_outcomes_resolution_candles CHECK ( + resolution_candles IS NULL OR resolution_candles >= 0 + ) +); + +-- Indices +CREATE INDEX idx_llm_outcomes_prediction ON ml.llm_prediction_outcomes(prediction_id); +CREATE INDEX idx_llm_outcomes_resolved ON ml.llm_prediction_outcomes(resolved_at DESC) + WHERE resolved_at IS NOT NULL; +CREATE INDEX idx_llm_outcomes_direction_correct ON ml.llm_prediction_outcomes(direction_correct) + WHERE direction_correct IS NOT NULL; +CREATE INDEX idx_llm_outcomes_target_reached ON ml.llm_prediction_outcomes(target_reached) + WHERE target_reached IS NOT NULL; +CREATE INDEX idx_llm_outcomes_stop_hit ON ml.llm_prediction_outcomes(stop_hit) + WHERE stop_hit IS NOT NULL; +CREATE INDEX idx_llm_outcomes_pnl ON ml.llm_prediction_outcomes(pnl_pips DESC) + WHERE pnl_pips IS NOT NULL; +CREATE INDEX idx_llm_outcomes_created ON ml.llm_prediction_outcomes(created_at DESC); + +-- Index for accuracy calculations (joins with predictions) +CREATE INDEX idx_llm_outcomes_accuracy_calc ON ml.llm_prediction_outcomes(prediction_id, direction_correct, target_reached, pnl_pips) + WHERE resolved_at IS NOT NULL; + +-- Comments +COMMENT ON TABLE ml.llm_prediction_outcomes IS 'Actual outcomes for LLM predictions, used for model accuracy tracking and fine-tuning feedback'; + +COMMENT ON COLUMN ml.llm_prediction_outcomes.id IS 'Unique identifier for the outcome record'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.prediction_id IS 'Reference to the original LLM prediction'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.actual_direction IS 'Actual price direction: UP, DOWN, or FLAT'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.actual_high IS 'Highest price reached during prediction window'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.actual_low IS 'Lowest price reached during prediction window'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.direction_correct IS 'Whether the predicted direction matched actual direction'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.target_reached IS 'Whether the take profit level was reached'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.stop_hit IS 'Whether the stop loss level was hit'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.pnl_pips IS 'Profit/loss in pips if trade was taken'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.pnl_percentage IS 'Profit/loss as percentage of entry price'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.resolved_at IS 'Timestamp when the prediction outcome was determined'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.resolution_candles IS 'Number of candles until resolution (target/stop hit or expiry)'; +COMMENT ON COLUMN ml.llm_prediction_outcomes.created_at IS 'Timestamp when outcome record was created'; diff --git a/ddl/schemas/ml/tables/08-llm_decisions.sql b/ddl/schemas/ml/tables/08-llm_decisions.sql new file mode 100644 index 0000000..7cb47c7 --- /dev/null +++ b/ddl/schemas/ml/tables/08-llm_decisions.sql @@ -0,0 +1,106 @@ +-- ===================================================== +-- ML SCHEMA - LLM DECISIONS TABLE +-- ===================================================== +-- Description: Decisions made by the LLM Trading Agent +-- Schema: ml +-- Author: Database Agent +-- Date: 2026-01-04 +-- Module: OQI-010-llm-trading-integration +-- ===================================================== + +CREATE TABLE ml.llm_decisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Reference to prediction + prediction_id UUID REFERENCES ml.llm_predictions(id) ON DELETE SET NULL, + + -- Decision details + decision_type VARCHAR(50) NOT NULL, + action_taken VARCHAR(50) NOT NULL, + reasoning TEXT, + + -- Risk assessment + risk_level VARCHAR(20), + position_size DECIMAL(10,4), + risk_pct DECIMAL(5,4) CHECK (risk_pct >= 0 AND risk_pct <= 1), + + -- Execution details + executed BOOLEAN NOT NULL DEFAULT FALSE, + execution_venue VARCHAR(20), + order_id VARCHAR(100), + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_llm_decisions_type CHECK ( + decision_type IN ( + 'TRADE', -- Execute a trade + 'ALERT', -- Send alert to user + 'WAIT', -- Wait for better conditions + 'CLOSE', -- Close existing position + 'PARTIAL_CLOSE', -- Partially close position + 'MODIFY_SL', -- Modify stop loss + 'MODIFY_TP', -- Modify take profit + 'SCALE_IN', -- Add to existing position + 'HEDGE', -- Open hedging position + 'STANDBY' -- No action, monitoring + ) + ), + CONSTRAINT chk_llm_decisions_action CHECK ( + action_taken IN ( + 'BUY', 'SELL', 'HOLD', 'CLOSE_LONG', 'CLOSE_SHORT', + 'PARTIAL_CLOSE_LONG', 'PARTIAL_CLOSE_SHORT', + 'MOVE_SL_BE', 'TRAILING_STOP', 'ALERT_SENT', + 'WAITING', 'MONITORING', 'REJECTED', 'SKIPPED' + ) + ), + CONSTRAINT chk_llm_decisions_risk_level CHECK ( + risk_level IS NULL OR risk_level IN ( + 'minimal', 'conservative', 'moderate', 'aggressive', 'high_risk' + ) + ), + CONSTRAINT chk_llm_decisions_venue CHECK ( + execution_venue IS NULL OR execution_venue IN ('MT4', 'MT5', 'BINANCE', 'PAPER') + ), + CONSTRAINT chk_llm_decisions_position_size CHECK ( + position_size IS NULL OR position_size > 0 + ), + CONSTRAINT chk_llm_decisions_execution CHECK ( + (executed = TRUE AND execution_venue IS NOT NULL) OR executed = FALSE + ) +); + +-- Indices +CREATE INDEX idx_llm_decisions_prediction ON ml.llm_decisions(prediction_id) + WHERE prediction_id IS NOT NULL; +CREATE INDEX idx_llm_decisions_type ON ml.llm_decisions(decision_type); +CREATE INDEX idx_llm_decisions_action ON ml.llm_decisions(action_taken); +CREATE INDEX idx_llm_decisions_executed ON ml.llm_decisions(executed); +CREATE INDEX idx_llm_decisions_venue ON ml.llm_decisions(execution_venue) + WHERE execution_venue IS NOT NULL; +CREATE INDEX idx_llm_decisions_order ON ml.llm_decisions(order_id) + WHERE order_id IS NOT NULL; +CREATE INDEX idx_llm_decisions_created ON ml.llm_decisions(created_at DESC); +CREATE INDEX idx_llm_decisions_risk_level ON ml.llm_decisions(risk_level) + WHERE risk_level IS NOT NULL; + +-- Index for trade decisions that were executed +CREATE INDEX idx_llm_decisions_trades ON ml.llm_decisions(created_at DESC) + WHERE decision_type = 'TRADE' AND executed = TRUE; + +-- Comments +COMMENT ON TABLE ml.llm_decisions IS 'Decisions made by the LLM Trading Agent based on predictions and risk assessment'; + +COMMENT ON COLUMN ml.llm_decisions.id IS 'Unique identifier for the decision'; +COMMENT ON COLUMN ml.llm_decisions.prediction_id IS 'Reference to the prediction that triggered this decision'; +COMMENT ON COLUMN ml.llm_decisions.decision_type IS 'Type of decision: TRADE, ALERT, WAIT, CLOSE, etc.'; +COMMENT ON COLUMN ml.llm_decisions.action_taken IS 'Specific action: BUY, SELL, HOLD, CLOSE_LONG, etc.'; +COMMENT ON COLUMN ml.llm_decisions.reasoning IS 'LLM generated reasoning for the decision'; +COMMENT ON COLUMN ml.llm_decisions.risk_level IS 'Risk profile applied: minimal, conservative, moderate, aggressive'; +COMMENT ON COLUMN ml.llm_decisions.position_size IS 'Position size in lots or units'; +COMMENT ON COLUMN ml.llm_decisions.risk_pct IS 'Risk percentage of capital (0.0 to 1.0)'; +COMMENT ON COLUMN ml.llm_decisions.executed IS 'Whether the decision was executed in market'; +COMMENT ON COLUMN ml.llm_decisions.execution_venue IS 'Execution venue: MT4, MT5, BINANCE, or PAPER'; +COMMENT ON COLUMN ml.llm_decisions.order_id IS 'Order ID from execution venue if executed'; +COMMENT ON COLUMN ml.llm_decisions.created_at IS 'Timestamp when decision was made'; diff --git a/ddl/schemas/ml/tables/09-risk_events.sql b/ddl/schemas/ml/tables/09-risk_events.sql new file mode 100644 index 0000000..dde2dad --- /dev/null +++ b/ddl/schemas/ml/tables/09-risk_events.sql @@ -0,0 +1,145 @@ +-- ===================================================== +-- ML SCHEMA - RISK EVENTS TABLE +-- ===================================================== +-- Description: Risk management events and circuit breaker triggers +-- Schema: ml +-- Author: Database Agent +-- Date: 2026-01-04 +-- Module: OQI-010-llm-trading-integration +-- ===================================================== + +CREATE TABLE ml.risk_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- User context (optional, for multi-user systems) + user_id UUID, + + -- Event classification + event_type VARCHAR(50) NOT NULL, + severity VARCHAR(20) NOT NULL, + + -- Event details + details JSONB NOT NULL DEFAULT '{}', + + -- Response + action_taken VARCHAR(100), + + -- Resolution status + resolved BOOLEAN NOT NULL DEFAULT FALSE, + resolved_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_risk_events_type CHECK ( + event_type IN ( + 'CIRCUIT_BREAKER', -- Trading halted due to losses + 'DAILY_LIMIT', -- Daily loss limit reached + 'WEEKLY_LIMIT', -- Weekly loss limit reached + 'EXPOSURE_LIMIT', -- Maximum exposure exceeded + 'POSITION_LIMIT', -- Maximum position size exceeded + 'CORRELATION_LIMIT', -- Too many correlated positions + 'DRAWDOWN_WARNING', -- Approaching drawdown limit + 'CONSECUTIVE_LOSSES', -- Multiple consecutive losing trades + 'VOLATILITY_SPIKE', -- Abnormal market volatility detected + 'LIQUIDITY_WARNING', -- Low liquidity conditions + 'NEWS_EVENT', -- High-impact news event + 'SYSTEM_ERROR', -- System malfunction + 'MANUAL_OVERRIDE', -- User manually intervened + 'POSITION_TIMEOUT', -- Position held too long + 'SLIPPAGE_ALERT' -- Excessive slippage detected + ) + ), + CONSTRAINT chk_risk_events_severity CHECK ( + severity IN ('info', 'warning', 'critical', 'emergency') + ), + CONSTRAINT chk_risk_events_resolution CHECK ( + (resolved = TRUE AND resolved_at IS NOT NULL) OR resolved = FALSE + ) +); + +-- Indices +CREATE INDEX idx_risk_events_user ON ml.risk_events(user_id) + WHERE user_id IS NOT NULL; +CREATE INDEX idx_risk_events_type ON ml.risk_events(event_type); +CREATE INDEX idx_risk_events_severity ON ml.risk_events(severity); +CREATE INDEX idx_risk_events_resolved ON ml.risk_events(resolved); +CREATE INDEX idx_risk_events_created ON ml.risk_events(created_at DESC); +CREATE INDEX idx_risk_events_resolved_at ON ml.risk_events(resolved_at DESC) + WHERE resolved_at IS NOT NULL; + +-- Active critical events (unresolved) +CREATE INDEX idx_risk_events_active_critical ON ml.risk_events(created_at DESC) + WHERE resolved = FALSE AND severity IN ('critical', 'emergency'); + +-- User active events +CREATE INDEX idx_risk_events_user_active ON ml.risk_events(user_id, created_at DESC) + WHERE resolved = FALSE; + +-- Index for circuit breaker status check +CREATE INDEX idx_risk_events_circuit_breaker ON ml.risk_events(user_id, created_at DESC) + WHERE event_type = 'CIRCUIT_BREAKER' AND resolved = FALSE; + +-- Comments +COMMENT ON TABLE ml.risk_events IS 'Risk management events including circuit breakers, limit violations, and system alerts'; + +COMMENT ON COLUMN ml.risk_events.id IS 'Unique identifier for the risk event'; +COMMENT ON COLUMN ml.risk_events.user_id IS 'User ID for multi-user systems (NULL for system-wide events)'; +COMMENT ON COLUMN ml.risk_events.event_type IS 'Type of risk event: CIRCUIT_BREAKER, DAILY_LIMIT, EXPOSURE_LIMIT, etc.'; +COMMENT ON COLUMN ml.risk_events.severity IS 'Event severity: info, warning, critical, emergency'; +COMMENT ON COLUMN ml.risk_events.details IS 'JSON object with event-specific details'; +COMMENT ON COLUMN ml.risk_events.action_taken IS 'Action taken in response to the event'; +COMMENT ON COLUMN ml.risk_events.resolved IS 'Whether the event has been resolved'; +COMMENT ON COLUMN ml.risk_events.resolved_at IS 'Timestamp when event was resolved'; +COMMENT ON COLUMN ml.risk_events.created_at IS 'Timestamp when event was detected'; + +-- Example of details JSONB structure +COMMENT ON COLUMN ml.risk_events.details IS +'Example structures by event type: + +CIRCUIT_BREAKER: +{ + "trigger_reason": "daily_drawdown_limit", + "current_drawdown_pct": 5.2, + "limit_pct": 5.0, + "trading_paused_until": "2026-01-05T00:00:00Z", + "affected_symbols": ["XAUUSD", "EURUSD"] +} + +DAILY_LIMIT: +{ + "current_loss_pct": 2.1, + "limit_pct": 2.0, + "trades_today": 8, + "last_trade_pnl": -45.50 +} + +EXPOSURE_LIMIT: +{ + "current_exposure_pct": 22.5, + "limit_pct": 20.0, + "open_positions": [ + {"symbol": "XAUUSD", "exposure_pct": 8.5}, + {"symbol": "EURUSD", "exposure_pct": 7.0}, + {"symbol": "BTCUSDT", "exposure_pct": 7.0} + ] +} + +VOLATILITY_SPIKE: +{ + "symbol": "XAUUSD", + "current_volatility": 2.5, + "normal_volatility": 0.8, + "volatility_ratio": 3.125, + "recommendation": "reduce_position_size" +} + +NEWS_EVENT: +{ + "event_name": "FOMC Decision", + "impact": "high", + "scheduled_time": "2026-01-04T19:00:00Z", + "affected_pairs": ["EURUSD", "GBPUSD", "USDJPY", "XAUUSD"], + "recommendation": "close_positions_before_event" +}'; diff --git a/ddl/schemas/ml/tables/10-backtest_runs.sql b/ddl/schemas/ml/tables/10-backtest_runs.sql new file mode 100644 index 0000000..4228863 --- /dev/null +++ b/ddl/schemas/ml/tables/10-backtest_runs.sql @@ -0,0 +1,100 @@ +-- ===================================================== +-- ML SCHEMA - BACKTEST RUNS TABLE +-- ===================================================== +-- Description: Backtest execution history and results +-- Schema: ml +-- Author: ML Module Implementation +-- Date: 2026-01-18 +-- ===================================================== + +-- Backtest status enum +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'backtest_status') THEN + CREATE TYPE ml.backtest_status AS ENUM ( + 'pending', + 'running', + 'completed', + 'failed', + 'cancelled' + ); + END IF; +END$$; + +CREATE TABLE IF NOT EXISTS ml.backtest_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- User who initiated the backtest (optional for system backtests) + user_id UUID REFERENCES core.users(id) ON DELETE SET NULL, + + -- Symbol and date range + symbol VARCHAR(20) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + + -- Status + status ml.backtest_status NOT NULL DEFAULT 'pending', + + -- Configuration + config JSONB NOT NULL, + -- Example config: + -- { + -- "initialCapital": 10000, + -- "riskPerTrade": 0.02, + -- "rrConfig": "rr_2_1", + -- "strategy": "ml_signals", + -- "params": {} + -- } + + -- Results (populated when completed) + result JSONB, + -- Example result: + -- { + -- "totalTrades": 150, + -- "winRate": 0.52, + -- "netProfit": 2500.00, + -- "profitFactor": 1.45, + -- "maxDrawdown": 0.15, + -- "sharpeRatio": 1.8, + -- "sortinoRatio": 2.1, + -- "calmarRatio": 1.2, + -- "expectancy": 0.05, + -- "totalProfitR": 45.5, + -- "trades": [...] + -- } + + -- Error message (if failed) + error TEXT, + + -- Timing + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT valid_date_range CHECK (end_date >= start_date), + CONSTRAINT valid_config CHECK (jsonb_typeof(config) = 'object') +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_backtest_runs_user ON ml.backtest_runs(user_id); +CREATE INDEX IF NOT EXISTS idx_backtest_runs_symbol ON ml.backtest_runs(symbol); +CREATE INDEX IF NOT EXISTS idx_backtest_runs_status ON ml.backtest_runs(status); +CREATE INDEX IF NOT EXISTS idx_backtest_runs_created ON ml.backtest_runs(created_at DESC); + +-- Index for finding completed backtests by profit +CREATE INDEX IF NOT EXISTS idx_backtest_runs_profit ON ml.backtest_runs((result->>'netProfit')) + WHERE status = 'completed'; + +-- Composite index for user queries +CREATE INDEX IF NOT EXISTS idx_backtest_runs_user_symbol ON ml.backtest_runs(user_id, symbol, created_at DESC); + +-- Comments +COMMENT ON TABLE ml.backtest_runs IS 'Backtest execution history and results'; +COMMENT ON COLUMN ml.backtest_runs.user_id IS 'User who initiated the backtest (null for system backtests)'; +COMMENT ON COLUMN ml.backtest_runs.symbol IS 'Trading symbol/pair tested'; +COMMENT ON COLUMN ml.backtest_runs.config IS 'Backtest configuration (capital, risk, strategy, etc.)'; +COMMENT ON COLUMN ml.backtest_runs.result IS 'Backtest results including metrics and trades'; +COMMENT ON COLUMN ml.backtest_runs.error IS 'Error message if backtest failed'; +COMMENT ON COLUMN ml.backtest_runs.started_at IS 'When backtest execution started'; +COMMENT ON COLUMN ml.backtest_runs.completed_at IS 'When backtest finished (success or failure)'; diff --git a/ddl/schemas/trading/00-enums.sql b/ddl/schemas/trading/00-enums.sql new file mode 100644 index 0000000..d30b9eb --- /dev/null +++ b/ddl/schemas/trading/00-enums.sql @@ -0,0 +1,78 @@ +-- ============================================================================ +-- Schema: trading +-- File: 00-enums.sql +-- Description: Enumeraciones para el módulo de trading +-- Dependencies: None +-- ============================================================================ + +-- Tipo de orden +CREATE TYPE trading.order_type AS ENUM ( + 'market', + 'limit', + 'stop', + 'stop_limit', + 'trailing_stop' +); + +-- Estado de orden (CON 'partially_filled' que faltaba!) +CREATE TYPE trading.order_status AS ENUM ( + 'pending', + 'open', + 'partially_filled', -- AGREGADO - faltaba en análisis + 'filled', + 'cancelled', + 'rejected', + 'expired' +); + +-- Lado de la orden +CREATE TYPE trading.order_side AS ENUM ( + 'buy', + 'sell' +); + +-- Estado de posición +CREATE TYPE trading.position_status AS ENUM ( + 'open', + 'closed', + 'liquidated' +); + +-- Tipo de señal (interfaz con ML) +CREATE TYPE trading.signal_type AS ENUM ( + 'entry_long', + 'entry_short', + 'exit_long', + 'exit_short', + 'hold' +); + +-- Nivel de confianza +CREATE TYPE trading.confidence_level AS ENUM ( + 'low', + 'medium', + 'high', + 'very_high' +); + +-- Timeframes soportados +CREATE TYPE trading.timeframe AS ENUM ( + '1m', '5m', '15m', '30m', + '1h', '4h', + '1d', '1w', '1M' +); + +-- Tipo de bot +CREATE TYPE trading.bot_type AS ENUM ( + 'paper', -- Paper trading (simulación) + 'live', -- Trading real + 'backtest' -- Backtesting +); + +-- Estado de bot +CREATE TYPE trading.bot_status AS ENUM ( + 'active', + 'paused', + 'stopped', + 'error' +); diff --git a/ddl/schemas/trading/functions/01-calculate_position_pnl.sql b/ddl/schemas/trading/functions/01-calculate_position_pnl.sql new file mode 100644 index 0000000..6059978 --- /dev/null +++ b/ddl/schemas/trading/functions/01-calculate_position_pnl.sql @@ -0,0 +1,96 @@ +-- ============================================================================ +-- Schema: trading +-- Function: calculate_position_pnl +-- Description: Calcula el PnL (realized y unrealized) de una posición +-- Parameters: +-- p_position_id: ID de la posición +-- p_current_price: Precio actual del mercado (para unrealized PnL) +-- Returns: JSONB con realized_pnl, unrealized_pnl, pnl_percentage +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.calculate_position_pnl( + p_position_id UUID, + p_current_price DECIMAL(20,8) DEFAULT NULL +) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + v_position RECORD; + v_realized_pnl DECIMAL(20,8) := 0; + v_unrealized_pnl DECIMAL(20,8) := 0; + v_pnl_percentage DECIMAL(10,4) := 0; + v_total_commission DECIMAL(20,8) := 0; + v_current_value DECIMAL(20,8); +BEGIN + -- Obtener datos de la posición + SELECT + p.position_side, + p.status, + p.entry_price, + p.entry_quantity, + p.entry_value, + p.entry_commission, + p.exit_price, + p.exit_quantity, + p.exit_value, + p.exit_commission + INTO v_position + FROM trading.positions p + WHERE p.id = p_position_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Position % not found', p_position_id; + END IF; + + -- Calcular comisiones totales + v_total_commission := COALESCE(v_position.entry_commission, 0) + COALESCE(v_position.exit_commission, 0); + + -- Calcular PnL según el estado + IF v_position.status = 'closed' OR v_position.status = 'liquidated' THEN + -- Posición cerrada: calcular realized PnL + IF v_position.position_side = 'buy' THEN + -- Long position: profit = (exit_price - entry_price) * quantity - commissions + v_realized_pnl := (v_position.exit_value - v_position.entry_value) - v_total_commission; + ELSE + -- Short position: profit = (entry_price - exit_price) * quantity - commissions + v_realized_pnl := (v_position.entry_value - v_position.exit_value) - v_total_commission; + END IF; + + -- Calcular porcentaje + IF v_position.entry_value > 0 THEN + v_pnl_percentage := (v_realized_pnl / v_position.entry_value) * 100; + END IF; + + ELSIF v_position.status = 'open' AND p_current_price IS NOT NULL THEN + -- Posición abierta: calcular unrealized PnL + v_current_value := p_current_price * v_position.entry_quantity; + + IF v_position.position_side = 'buy' THEN + -- Long position + v_unrealized_pnl := (v_current_value - v_position.entry_value) - v_position.entry_commission; + ELSE + -- Short position + v_unrealized_pnl := (v_position.entry_value - v_current_value) - v_position.entry_commission; + END IF; + + -- Calcular porcentaje + IF v_position.entry_value > 0 THEN + v_pnl_percentage := (v_unrealized_pnl / v_position.entry_value) * 100; + END IF; + END IF; + + -- Retornar resultado + RETURN jsonb_build_object( + 'position_id', p_position_id, + 'status', v_position.status, + 'realized_pnl', v_realized_pnl, + 'unrealized_pnl', v_unrealized_pnl, + 'pnl_percentage', v_pnl_percentage, + 'total_commission', v_total_commission + ); +END; +$$; + +-- Comentarios +COMMENT ON FUNCTION trading.calculate_position_pnl IS 'Calcula el PnL realizado y no realizado de una posición'; diff --git a/ddl/schemas/trading/functions/02-update_bot_stats.sql b/ddl/schemas/trading/functions/02-update_bot_stats.sql new file mode 100644 index 0000000..aede3e3 --- /dev/null +++ b/ddl/schemas/trading/functions/02-update_bot_stats.sql @@ -0,0 +1,88 @@ +-- ============================================================================ +-- Schema: trading +-- Function: update_bot_stats +-- Description: Actualiza las estadísticas de un bot basándose en sus posiciones +-- Parameters: +-- p_bot_id: ID del bot +-- Returns: JSONB con las estadísticas actualizadas +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.update_bot_stats( + p_bot_id UUID +) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + v_total_trades INTEGER := 0; + v_winning_trades INTEGER := 0; + v_losing_trades INTEGER := 0; + v_total_pnl DECIMAL(20,8) := 0; + v_win_rate DECIMAL(5,2) := 0; + v_current_capital DECIMAL(20,8); + v_initial_capital DECIMAL(20,8); +BEGIN + -- Obtener capital inicial + SELECT initial_capital INTO v_initial_capital + FROM trading.bots + WHERE id = p_bot_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Bot % not found', p_bot_id; + END IF; + + -- Contar trades cerrados + SELECT + COUNT(*)::INTEGER, + COUNT(*) FILTER (WHERE realized_pnl > 0)::INTEGER, + COUNT(*) FILTER (WHERE realized_pnl < 0)::INTEGER, + COALESCE(SUM(realized_pnl), 0) + INTO + v_total_trades, + v_winning_trades, + v_losing_trades, + v_total_pnl + FROM trading.positions + WHERE bot_id = p_bot_id + AND status IN ('closed', 'liquidated'); + + -- Calcular win rate + IF v_total_trades > 0 THEN + v_win_rate := (v_winning_trades::DECIMAL / v_total_trades::DECIMAL) * 100; + END IF; + + -- Calcular capital actual + v_current_capital := v_initial_capital + v_total_pnl; + + -- Actualizar bot + UPDATE trading.bots + SET + total_trades = v_total_trades, + winning_trades = v_winning_trades, + total_profit_loss = v_total_pnl, + win_rate = v_win_rate, + current_capital = v_current_capital, + updated_at = NOW() + WHERE id = p_bot_id; + + -- Retornar estadísticas + RETURN jsonb_build_object( + 'bot_id', p_bot_id, + 'total_trades', v_total_trades, + 'winning_trades', v_winning_trades, + 'losing_trades', v_losing_trades, + 'win_rate', v_win_rate, + 'total_profit_loss', v_total_pnl, + 'initial_capital', v_initial_capital, + 'current_capital', v_current_capital, + 'roi_percentage', CASE + WHEN v_initial_capital > 0 + THEN ((v_current_capital - v_initial_capital) / v_initial_capital * 100) + ELSE 0 + END + ); +END; +$$; + +-- Comentarios +COMMENT ON FUNCTION trading.update_bot_stats IS 'Actualiza las estadísticas agregadas de un trading bot'; diff --git a/ddl/schemas/trading/functions/03-initialize_paper_balance.sql b/ddl/schemas/trading/functions/03-initialize_paper_balance.sql new file mode 100644 index 0000000..c350e33 --- /dev/null +++ b/ddl/schemas/trading/functions/03-initialize_paper_balance.sql @@ -0,0 +1,165 @@ +-- ============================================================================ +-- TRADING SCHEMA - Funcion: initialize_paper_balance +-- ============================================================================ +-- Inicializa el balance de paper trading para un usuario +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.initialize_paper_balance( + p_user_id UUID, + p_initial_amount DECIMAL(20, 8) DEFAULT 10000.00, + p_asset VARCHAR(10) DEFAULT 'USDT' +) +RETURNS UUID AS $$ +DECLARE + v_balance_id UUID; +BEGIN + INSERT INTO trading.paper_balances ( + user_id, + asset, + total, + available, + locked, + initial_balance + ) + VALUES ( + p_user_id, + p_asset, + p_initial_amount, + p_initial_amount, + 0, + p_initial_amount + ) + ON CONFLICT (user_id, asset) DO NOTHING + RETURNING id INTO v_balance_id; + + -- Si ya existia, obtener el ID + IF v_balance_id IS NULL THEN + SELECT id INTO v_balance_id + FROM trading.paper_balances + WHERE user_id = p_user_id AND asset = p_asset; + END IF; + + RETURN v_balance_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION trading.initialize_paper_balance IS 'Inicializa balance de paper trading para un usuario (default $10,000 USDT)'; + +-- ============================================================================ +-- Funcion: reset_paper_balance +-- ============================================================================ +-- Resetea el balance de paper trading a su valor inicial +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.reset_paper_balance( + p_user_id UUID, + p_asset VARCHAR(10) DEFAULT 'USDT', + p_new_amount DECIMAL(20, 8) DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +DECLARE + v_initial DECIMAL(20, 8); +BEGIN + -- Obtener balance inicial original o usar el nuevo monto + IF p_new_amount IS NOT NULL THEN + v_initial := p_new_amount; + ELSE + SELECT initial_balance INTO v_initial + FROM trading.paper_balances + WHERE user_id = p_user_id AND asset = p_asset; + END IF; + + -- Resetear balance + UPDATE trading.paper_balances + SET + total = v_initial, + available = v_initial, + locked = 0, + initial_balance = v_initial, + total_deposits = 0, + total_withdrawals = 0, + total_pnl = 0, + last_reset_at = NOW(), + reset_count = reset_count + 1, + updated_at = NOW() + WHERE user_id = p_user_id AND asset = p_asset; + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION trading.reset_paper_balance IS 'Resetea el balance de paper trading al valor inicial'; + +-- ============================================================================ +-- Funcion: update_paper_balance +-- ============================================================================ +-- Actualiza el balance de paper trading (lock/unlock/pnl) +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.update_paper_balance( + p_user_id UUID, + p_asset VARCHAR(10), + p_amount DECIMAL(20, 8), + p_operation VARCHAR(20) -- 'lock', 'unlock', 'pnl', 'deposit', 'withdrawal' +) +RETURNS BOOLEAN AS $$ +BEGIN + CASE p_operation + WHEN 'lock' THEN + UPDATE trading.paper_balances + SET + available = available - p_amount, + locked = locked + p_amount, + updated_at = NOW() + WHERE user_id = p_user_id + AND asset = p_asset + AND available >= p_amount; + + WHEN 'unlock' THEN + UPDATE trading.paper_balances + SET + available = available + p_amount, + locked = locked - p_amount, + updated_at = NOW() + WHERE user_id = p_user_id + AND asset = p_asset + AND locked >= p_amount; + + WHEN 'pnl' THEN + UPDATE trading.paper_balances + SET + total = total + p_amount, + available = available + p_amount, + total_pnl = total_pnl + p_amount, + updated_at = NOW() + WHERE user_id = p_user_id AND asset = p_asset; + + WHEN 'deposit' THEN + UPDATE trading.paper_balances + SET + total = total + p_amount, + available = available + p_amount, + total_deposits = total_deposits + p_amount, + updated_at = NOW() + WHERE user_id = p_user_id AND asset = p_asset; + + WHEN 'withdrawal' THEN + UPDATE trading.paper_balances + SET + total = total - p_amount, + available = available - p_amount, + total_withdrawals = total_withdrawals + p_amount, + updated_at = NOW() + WHERE user_id = p_user_id + AND asset = p_asset + AND available >= p_amount; + + ELSE + RETURN FALSE; + END CASE; + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION trading.update_paper_balance IS 'Actualiza balance de paper trading (lock/unlock/pnl/deposit/withdrawal)'; diff --git a/ddl/schemas/trading/functions/04-create_default_watchlist.sql b/ddl/schemas/trading/functions/04-create_default_watchlist.sql new file mode 100644 index 0000000..bca56b8 --- /dev/null +++ b/ddl/schemas/trading/functions/04-create_default_watchlist.sql @@ -0,0 +1,80 @@ +-- ============================================================================ +-- TRADING SCHEMA - Funcion: create_default_watchlist +-- ============================================================================ +-- Crea watchlist default y balance inicial para nuevos usuarios +-- Se ejecuta como trigger en auth.users +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.create_user_trading_defaults() +RETURNS TRIGGER AS $$ +DECLARE + v_watchlist_id UUID; + v_btc_id UUID; + v_eth_id UUID; + v_bnb_id UUID; +BEGIN + -- 1. Crear watchlist default "My Favorites" + INSERT INTO trading.watchlists ( + user_id, + name, + description, + is_default, + display_order + ) + VALUES ( + NEW.id, + 'My Favorites', + 'Default watchlist with popular trading pairs', + true, + 0 + ) + RETURNING id INTO v_watchlist_id; + + -- 2. Agregar simbolos populares si existen en la tabla symbols + SELECT id INTO v_btc_id FROM trading.symbols WHERE symbol = 'BTCUSDT' LIMIT 1; + SELECT id INTO v_eth_id FROM trading.symbols WHERE symbol = 'ETHUSDT' LIMIT 1; + SELECT id INTO v_bnb_id FROM trading.symbols WHERE symbol = 'BNBUSDT' LIMIT 1; + + IF v_btc_id IS NOT NULL THEN + INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order) + VALUES (v_watchlist_id, v_btc_id, 0) + ON CONFLICT DO NOTHING; + END IF; + + IF v_eth_id IS NOT NULL THEN + INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order) + VALUES (v_watchlist_id, v_eth_id, 1) + ON CONFLICT DO NOTHING; + END IF; + + IF v_bnb_id IS NOT NULL THEN + INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order) + VALUES (v_watchlist_id, v_bnb_id, 2) + ON CONFLICT DO NOTHING; + END IF; + + -- 3. Inicializar balance de paper trading ($10,000 USDT) + PERFORM trading.initialize_paper_balance(NEW.id, 10000.00, 'USDT'); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION trading.create_user_trading_defaults IS 'Trigger function: crea watchlist default y balance inicial para nuevos usuarios'; + +-- ============================================================================ +-- Trigger en auth.users +-- ============================================================================ +-- Nota: Este trigger debe crearse despues de que exista la tabla auth.users +-- Se recomienda ejecutarlo en un script de post-instalacion o manualmente +-- ============================================================================ + +-- DROP TRIGGER IF EXISTS tr_create_user_trading_defaults ON auth.users; + +-- CREATE TRIGGER tr_create_user_trading_defaults +-- AFTER INSERT ON auth.users +-- FOR EACH ROW +-- EXECUTE FUNCTION trading.create_user_trading_defaults(); + +-- COMMENT ON TRIGGER tr_create_user_trading_defaults ON auth.users IS +-- 'Crea watchlist default y balance de paper trading para nuevos usuarios'; diff --git a/ddl/schemas/trading/tables/01-symbols.sql b/ddl/schemas/trading/tables/01-symbols.sql new file mode 100644 index 0000000..7858d70 --- /dev/null +++ b/ddl/schemas/trading/tables/01-symbols.sql @@ -0,0 +1,49 @@ +-- ============================================================================ +-- Schema: trading +-- Table: symbols +-- Description: Catálogo de símbolos/instrumentos financieros operables +-- Dependencies: None +-- ============================================================================ + +CREATE TABLE trading.symbols ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + symbol VARCHAR(20) NOT NULL UNIQUE, -- BTC/USDT, EUR/USD + name VARCHAR(100) NOT NULL, + base_asset VARCHAR(10) NOT NULL, -- BTC, EUR + quote_asset VARCHAR(10) NOT NULL, -- USDT, USD + + -- Tipo + asset_class VARCHAR(50) NOT NULL, -- crypto, forex, stocks + exchange VARCHAR(50), -- binance, coinbase + + -- Precisión + price_precision INTEGER NOT NULL DEFAULT 8, + quantity_precision INTEGER NOT NULL DEFAULT 8, + + -- Límites + min_quantity DECIMAL(20,8), + max_quantity DECIMAL(20,8), + min_notional DECIMAL(20,8), + + -- Estado + is_active BOOLEAN DEFAULT true, + is_tradeable BOOLEAN DEFAULT true, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_symbols_symbol ON trading.symbols(symbol); +CREATE INDEX idx_symbols_active ON trading.symbols(is_active) WHERE is_active = true; +CREATE INDEX idx_symbols_asset_class ON trading.symbols(asset_class); +CREATE INDEX idx_symbols_exchange ON trading.symbols(exchange); + +-- Comentarios +COMMENT ON TABLE trading.symbols IS 'Catálogo de símbolos/instrumentos financieros operables'; +COMMENT ON COLUMN trading.symbols.symbol IS 'Símbolo del instrumento (e.g., BTC/USDT)'; +COMMENT ON COLUMN trading.symbols.asset_class IS 'Clase de activo: crypto, forex, stocks, commodities'; +COMMENT ON COLUMN trading.symbols.is_tradeable IS 'Indica si el símbolo está disponible para trading'; diff --git a/ddl/schemas/trading/tables/02-watchlists.sql b/ddl/schemas/trading/tables/02-watchlists.sql new file mode 100644 index 0000000..bd53a13 --- /dev/null +++ b/ddl/schemas/trading/tables/02-watchlists.sql @@ -0,0 +1,40 @@ +-- ============================================================================ +-- Schema: trading +-- Table: watchlists +-- Description: Listas de seguimiento personalizadas de usuarios +-- Dependencies: auth.users +-- ============================================================================ + +CREATE TABLE trading.watchlists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Propietario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Configuración + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Ordenamiento + display_order INTEGER DEFAULT 0, + + -- Estado + is_default BOOLEAN DEFAULT false, + is_public BOOLEAN DEFAULT false, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraint: nombre único por usuario + CONSTRAINT uq_watchlists_user_name UNIQUE (user_id, name) +); + +-- Índices +CREATE INDEX idx_watchlists_user ON trading.watchlists(user_id); +CREATE INDEX idx_watchlists_user_default ON trading.watchlists(user_id, is_default) WHERE is_default = true; + +-- Comentarios +COMMENT ON TABLE trading.watchlists IS 'Listas de seguimiento personalizadas de símbolos'; +COMMENT ON COLUMN trading.watchlists.is_default IS 'Indica si es la watchlist por defecto del usuario'; +COMMENT ON COLUMN trading.watchlists.is_public IS 'Indica si la watchlist es visible públicamente'; diff --git a/ddl/schemas/trading/tables/03-watchlist_items.sql b/ddl/schemas/trading/tables/03-watchlist_items.sql new file mode 100644 index 0000000..e20dc6d --- /dev/null +++ b/ddl/schemas/trading/tables/03-watchlist_items.sql @@ -0,0 +1,38 @@ +-- ============================================================================ +-- Schema: trading +-- Table: watchlist_items +-- Description: Items individuales dentro de watchlists +-- Dependencies: trading.watchlists, trading.symbols +-- ============================================================================ + +CREATE TABLE trading.watchlist_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + watchlist_id UUID NOT NULL REFERENCES trading.watchlists(id) ON DELETE CASCADE, + symbol_id UUID NOT NULL REFERENCES trading.symbols(id) ON DELETE CASCADE, + + -- Configuración personalizada + notes TEXT, + alert_price_high DECIMAL(20,8), + alert_price_low DECIMAL(20,8), + + -- Ordenamiento + display_order INTEGER DEFAULT 0, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraint: un símbolo solo una vez por watchlist + CONSTRAINT uq_watchlist_items_watchlist_symbol UNIQUE (watchlist_id, symbol_id) +); + +-- Índices +CREATE INDEX idx_watchlist_items_watchlist ON trading.watchlist_items(watchlist_id); +CREATE INDEX idx_watchlist_items_symbol ON trading.watchlist_items(symbol_id); + +-- Comentarios +COMMENT ON TABLE trading.watchlist_items IS 'Símbolos individuales dentro de una watchlist'; +COMMENT ON COLUMN trading.watchlist_items.alert_price_high IS 'Precio superior para alertas'; +COMMENT ON COLUMN trading.watchlist_items.alert_price_low IS 'Precio inferior para alertas'; diff --git a/ddl/schemas/trading/tables/04-bots.sql b/ddl/schemas/trading/tables/04-bots.sql new file mode 100644 index 0000000..3eef4fc --- /dev/null +++ b/ddl/schemas/trading/tables/04-bots.sql @@ -0,0 +1,64 @@ +-- ============================================================================ +-- Schema: trading +-- Table: bots +-- Description: Trading bots configurados por usuarios +-- Dependencies: auth.users, trading.bot_type, trading.bot_status, trading.timeframe +-- ============================================================================ + +CREATE TABLE trading.bots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Propietario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Configuración básica + name VARCHAR(100) NOT NULL, + bot_type trading.bot_type NOT NULL DEFAULT 'paper', + status trading.bot_status NOT NULL DEFAULT 'paused', + + -- Símbolos a operar + symbols VARCHAR(20)[] NOT NULL, + timeframe trading.timeframe NOT NULL DEFAULT '1h', + + -- Capital + initial_capital DECIMAL(20,8) NOT NULL, + current_capital DECIMAL(20,8) NOT NULL, + + -- Risk management + max_position_size_pct DECIMAL(5,2) DEFAULT 10.00, -- % del capital + max_daily_loss_pct DECIMAL(5,2) DEFAULT 5.00, + max_drawdown_pct DECIMAL(5,2) DEFAULT 20.00, + + -- Estrategia (referencia a ML signal provider) + strategy_type VARCHAR(50), -- 'atlas', 'orion', 'nova', 'custom' + strategy_config JSONB DEFAULT '{}', + + -- Estadísticas + total_trades INTEGER DEFAULT 0, + winning_trades INTEGER DEFAULT 0, + total_profit_loss DECIMAL(20,8) DEFAULT 0, + win_rate DECIMAL(5,2) DEFAULT 0, + + -- Metadata + started_at TIMESTAMPTZ, + stopped_at TIMESTAMPTZ, + last_trade_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_bots_user ON trading.bots(user_id); +CREATE INDEX idx_bots_status ON trading.bots(status); +CREATE INDEX idx_bots_type ON trading.bots(bot_type); +CREATE INDEX idx_bots_user_status ON trading.bots(user_id, status); + +-- Comentarios +COMMENT ON TABLE trading.bots IS 'Trading bots automatizados configurados por usuarios'; +COMMENT ON COLUMN trading.bots.bot_type IS 'Tipo: paper (simulación), live (real), backtest'; +COMMENT ON COLUMN trading.bots.strategy_type IS 'Estrategia ML: atlas, orion, nova, custom'; +COMMENT ON COLUMN trading.bots.max_position_size_pct IS 'Máximo % del capital por posición'; +COMMENT ON COLUMN trading.bots.max_daily_loss_pct IS 'Máxima pérdida diaria permitida (%)'; +COMMENT ON COLUMN trading.bots.max_drawdown_pct IS 'Máximo drawdown permitido (%)'; diff --git a/ddl/schemas/trading/tables/05-orders.sql b/ddl/schemas/trading/tables/05-orders.sql new file mode 100644 index 0000000..c937b21 --- /dev/null +++ b/ddl/schemas/trading/tables/05-orders.sql @@ -0,0 +1,67 @@ +-- ============================================================================ +-- Schema: trading +-- Table: orders +-- Description: Órdenes de trading (pendientes, ejecutadas, canceladas) +-- Dependencies: auth.users, trading.bots, trading.symbols +-- ============================================================================ + +CREATE TABLE trading.orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + bot_id UUID REFERENCES trading.bots(id) ON DELETE SET NULL, + symbol_id UUID NOT NULL REFERENCES trading.symbols(id), + + -- Identificador externo (de exchange) + external_order_id VARCHAR(100), + + -- Tipo y lado + order_type trading.order_type NOT NULL, + order_side trading.order_side NOT NULL, + status trading.order_status NOT NULL DEFAULT 'pending', + + -- Precios + price DECIMAL(20,8), -- Precio límite (NULL para market orders) + stop_price DECIMAL(20,8), -- Precio stop + average_fill_price DECIMAL(20,8), -- Precio promedio de ejecución + + -- Cantidades + quantity DECIMAL(20,8) NOT NULL, + filled_quantity DECIMAL(20,8) DEFAULT 0, + remaining_quantity DECIMAL(20,8) NOT NULL, + + -- Costos + commission DECIMAL(20,8) DEFAULT 0, + commission_asset VARCHAR(10), + + -- Time in force + time_in_force VARCHAR(20) DEFAULT 'GTC', -- GTC, IOC, FOK + + -- Validez + expires_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + error_message TEXT, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + filled_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ +); + +-- Índices +CREATE INDEX idx_orders_user ON trading.orders(user_id); +CREATE INDEX idx_orders_bot ON trading.orders(bot_id); +CREATE INDEX idx_orders_symbol ON trading.orders(symbol_id); +CREATE INDEX idx_orders_status ON trading.orders(status); +CREATE INDEX idx_orders_created ON trading.orders(created_at DESC); +CREATE INDEX idx_orders_external ON trading.orders(external_order_id); + +-- Comentarios +COMMENT ON TABLE trading.orders IS 'Órdenes de trading (todas las órdenes del sistema)'; +COMMENT ON COLUMN trading.orders.external_order_id IS 'ID de la orden en el exchange externo'; +COMMENT ON COLUMN trading.orders.time_in_force IS 'GTC (Good Till Cancel), IOC (Immediate or Cancel), FOK (Fill or Kill)'; +COMMENT ON COLUMN trading.orders.average_fill_price IS 'Precio promedio de ejecución para órdenes parcialmente completadas'; diff --git a/ddl/schemas/trading/tables/06-positions.sql b/ddl/schemas/trading/tables/06-positions.sql new file mode 100644 index 0000000..92da537 --- /dev/null +++ b/ddl/schemas/trading/tables/06-positions.sql @@ -0,0 +1,70 @@ +-- ============================================================================ +-- Schema: trading +-- Table: positions +-- Description: Posiciones abiertas y cerradas de trading +-- Dependencies: auth.users, trading.bots, trading.symbols +-- ============================================================================ + +CREATE TABLE trading.positions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + bot_id UUID REFERENCES trading.bots(id) ON DELETE SET NULL, + symbol_id UUID NOT NULL REFERENCES trading.symbols(id), + + -- Tipo + position_side trading.order_side NOT NULL, -- buy (long), sell (short) + status trading.position_status NOT NULL DEFAULT 'open', + + -- Entrada + entry_price DECIMAL(20,8) NOT NULL, + entry_quantity DECIMAL(20,8) NOT NULL, + entry_value DECIMAL(20,8) NOT NULL, + entry_commission DECIMAL(20,8) DEFAULT 0, + + -- Posiciones parciales (para cierres parciales y promedios) + current_quantity DECIMAL(20,8) NOT NULL, -- Cantidad actual (puede diferir de entry_quantity) + average_entry_price DECIMAL(20,8) NOT NULL, -- Precio promedio de entrada (para DCA) + + -- Salida + exit_price DECIMAL(20,8), + exit_quantity DECIMAL(20,8), + exit_value DECIMAL(20,8), + exit_commission DECIMAL(20,8) DEFAULT 0, + + -- PnL + realized_pnl DECIMAL(20,8) DEFAULT 0, + unrealized_pnl DECIMAL(20,8) DEFAULT 0, + pnl_percentage DECIMAL(10,4) DEFAULT 0, + + -- Stop loss y take profit + stop_loss_price DECIMAL(20,8), + take_profit_price DECIMAL(20,8), + + -- Metadata + metadata JSONB DEFAULT '{}', + close_reason VARCHAR(50), -- 'take_profit', 'stop_loss', 'manual', 'liquidation' + + -- Timestamps + opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_positions_user ON trading.positions(user_id); +CREATE INDEX idx_positions_bot ON trading.positions(bot_id); +CREATE INDEX idx_positions_symbol ON trading.positions(symbol_id); +CREATE INDEX idx_positions_status ON trading.positions(status); +CREATE INDEX idx_positions_opened ON trading.positions(opened_at DESC); +CREATE INDEX idx_positions_user_status ON trading.positions(user_id, status); + +-- Comentarios +COMMENT ON TABLE trading.positions IS 'Posiciones de trading abiertas y cerradas'; +COMMENT ON COLUMN trading.positions.position_side IS 'buy = posición larga, sell = posición corta'; +COMMENT ON COLUMN trading.positions.realized_pnl IS 'Ganancia/pérdida realizada (posición cerrada)'; +COMMENT ON COLUMN trading.positions.unrealized_pnl IS 'Ganancia/pérdida no realizada (posición abierta)'; +COMMENT ON COLUMN trading.positions.close_reason IS 'Razón del cierre: take_profit, stop_loss, manual, liquidation'; +COMMENT ON COLUMN trading.positions.current_quantity IS 'Cantidad actual de la posición (puede diferir de entry_quantity tras cierres parciales)'; +COMMENT ON COLUMN trading.positions.average_entry_price IS 'Precio promedio de entrada (calculado con DCA o adiciones)'; diff --git a/ddl/schemas/trading/tables/07-trades.sql b/ddl/schemas/trading/tables/07-trades.sql new file mode 100644 index 0000000..3094231 --- /dev/null +++ b/ddl/schemas/trading/tables/07-trades.sql @@ -0,0 +1,49 @@ +-- ============================================================================ +-- Schema: trading +-- Table: trades +-- Description: Historial detallado de trades ejecutados +-- Dependencies: trading.orders, trading.positions +-- ============================================================================ + +CREATE TABLE trading.trades ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + order_id UUID NOT NULL REFERENCES trading.orders(id) ON DELETE CASCADE, + position_id UUID REFERENCES trading.positions(id) ON DELETE SET NULL, + + -- Identificador externo (de exchange) + external_trade_id VARCHAR(100), + + -- Detalles del trade + symbol VARCHAR(20) NOT NULL, + side trading.order_side NOT NULL, + price DECIMAL(20,8) NOT NULL, + quantity DECIMAL(20,8) NOT NULL, + quote_quantity DECIMAL(20,8) NOT NULL, -- price * quantity + + -- Comisión + commission DECIMAL(20,8) DEFAULT 0, + commission_asset VARCHAR(10), + + -- Metadata + is_maker BOOLEAN DEFAULT false, + metadata JSONB DEFAULT '{}', + + -- Timestamps + executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_trades_order ON trading.trades(order_id); +CREATE INDEX idx_trades_position ON trading.trades(position_id); +CREATE INDEX idx_trades_symbol ON trading.trades(symbol); +CREATE INDEX idx_trades_executed ON trading.trades(executed_at DESC); +CREATE INDEX idx_trades_external ON trading.trades(external_trade_id); + +-- Comentarios +COMMENT ON TABLE trading.trades IS 'Historial detallado de trades ejecutados (fills individuales)'; +COMMENT ON COLUMN trading.trades.external_trade_id IS 'ID del trade en el exchange externo'; +COMMENT ON COLUMN trading.trades.quote_quantity IS 'Valor total del trade (price * quantity)'; +COMMENT ON COLUMN trading.trades.is_maker IS 'true si el trade fue maker, false si fue taker'; diff --git a/ddl/schemas/trading/tables/08-signals.sql b/ddl/schemas/trading/tables/08-signals.sql new file mode 100644 index 0000000..c8065c6 --- /dev/null +++ b/ddl/schemas/trading/tables/08-signals.sql @@ -0,0 +1,68 @@ +-- ============================================================================ +-- Schema: trading +-- Table: signals +-- Description: INTERFAZ con ML - Señales de trading generadas por modelos ML +-- Dependencies: trading.signal_type, trading.confidence_level, trading.timeframe +-- +-- IMPORTANTE: Esta tabla es la INTERFAZ entre Trading y ML Signals +-- Resuelve la dependencia circular permitiendo que ambos módulos +-- trabajen independientemente. ML escribe señales aquí, Trading las consume. +-- ============================================================================ + +CREATE TABLE trading.signals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Origen de la señal + source VARCHAR(50) NOT NULL, -- 'ml_atlas', 'ml_orion', 'ml_nova', 'manual' + model_version VARCHAR(50), + + -- Target + symbol VARCHAR(20) NOT NULL, + timeframe trading.timeframe NOT NULL, + + -- Señal + signal_type trading.signal_type NOT NULL, + confidence trading.confidence_level NOT NULL, + confidence_score DECIMAL(5,4), -- 0.0000 to 1.0000 + + -- Precios objetivo + entry_price DECIMAL(20,8), + target_price DECIMAL(20,8), + stop_loss DECIMAL(20,8), + + -- Predicciones + predicted_delta_high DECIMAL(10,4), -- % esperado de subida + predicted_delta_low DECIMAL(10,4), -- % esperado de bajada + + -- Resultado (se actualiza después) + actual_outcome VARCHAR(20), -- 'hit_target', 'hit_stop', 'expired', 'cancelled' + actual_delta DECIMAL(10,4), + outcome_at TIMESTAMPTZ, + + -- Validez + valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + valid_until TIMESTAMPTZ NOT NULL, + is_active BOOLEAN DEFAULT true, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_signals_symbol ON trading.signals(symbol); +CREATE INDEX idx_signals_active ON trading.signals(is_active, valid_until) WHERE is_active = true; +CREATE INDEX idx_signals_source ON trading.signals(source); +CREATE INDEX idx_signals_created ON trading.signals(created_at DESC); +CREATE INDEX idx_signals_symbol_timeframe ON trading.signals(symbol, timeframe); +CREATE INDEX idx_signals_outcome ON trading.signals(actual_outcome); + +-- Comentarios +COMMENT ON TABLE trading.signals IS 'INTERFAZ: Señales de trading generadas por modelos ML o manualmente'; +COMMENT ON COLUMN trading.signals.source IS 'Origen: ml_atlas, ml_orion, ml_nova, manual'; +COMMENT ON COLUMN trading.signals.confidence_score IS 'Score numérico de confianza entre 0 y 1'; +COMMENT ON COLUMN trading.signals.predicted_delta_high IS 'Porcentaje esperado de subida desde entry_price'; +COMMENT ON COLUMN trading.signals.predicted_delta_low IS 'Porcentaje esperado de bajada desde entry_price'; +COMMENT ON COLUMN trading.signals.actual_outcome IS 'Resultado real: hit_target, hit_stop, expired, cancelled'; diff --git a/ddl/schemas/trading/tables/09-trading_metrics.sql b/ddl/schemas/trading/tables/09-trading_metrics.sql new file mode 100644 index 0000000..3c83eab --- /dev/null +++ b/ddl/schemas/trading/tables/09-trading_metrics.sql @@ -0,0 +1,67 @@ +-- ============================================================================ +-- Schema: trading +-- Table: trading_metrics +-- Description: Métricas diarias de performance por bot +-- Dependencies: trading.bots +-- ============================================================================ + +CREATE TABLE trading.trading_metrics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + bot_id UUID NOT NULL REFERENCES trading.bots(id) ON DELETE CASCADE, + + -- Período + metric_date DATE NOT NULL, + + -- Capital + starting_capital DECIMAL(20,8) NOT NULL, + ending_capital DECIMAL(20,8) NOT NULL, + peak_capital DECIMAL(20,8) NOT NULL, + + -- Trading + total_trades INTEGER DEFAULT 0, + winning_trades INTEGER DEFAULT 0, + losing_trades INTEGER DEFAULT 0, + + -- PnL + gross_profit DECIMAL(20,8) DEFAULT 0, + gross_loss DECIMAL(20,8) DEFAULT 0, + net_profit DECIMAL(20,8) DEFAULT 0, + + -- Ratios + win_rate DECIMAL(5,2) DEFAULT 0, + profit_factor DECIMAL(10,4) DEFAULT 0, -- gross_profit / gross_loss + sharpe_ratio DECIMAL(10,4), + max_drawdown DECIMAL(10,4) DEFAULT 0, + + -- Trades + avg_win DECIMAL(20,8) DEFAULT 0, + avg_loss DECIMAL(20,8) DEFAULT 0, + largest_win DECIMAL(20,8) DEFAULT 0, + largest_loss DECIMAL(20,8) DEFAULT 0, + + -- Tiempos promedio + avg_trade_duration_minutes INTEGER, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraint: una métrica por bot por día + CONSTRAINT uq_trading_metrics_bot_date UNIQUE (bot_id, metric_date) +); + +-- Índices +CREATE INDEX idx_trading_metrics_bot ON trading.trading_metrics(bot_id); +CREATE INDEX idx_trading_metrics_date ON trading.trading_metrics(metric_date DESC); +CREATE INDEX idx_trading_metrics_bot_date ON trading.trading_metrics(bot_id, metric_date DESC); + +-- Comentarios +COMMENT ON TABLE trading.trading_metrics IS 'Métricas diarias de performance por bot'; +COMMENT ON COLUMN trading.trading_metrics.profit_factor IS 'Ratio gross_profit / gross_loss (>1 es rentable)'; +COMMENT ON COLUMN trading.trading_metrics.sharpe_ratio IS 'Ratio de Sharpe ajustado por riesgo'; +COMMENT ON COLUMN trading.trading_metrics.max_drawdown IS 'Máximo drawdown del día (%)'; diff --git a/ddl/schemas/trading/tables/10-paper_balances.sql b/ddl/schemas/trading/tables/10-paper_balances.sql new file mode 100644 index 0000000..e282203 --- /dev/null +++ b/ddl/schemas/trading/tables/10-paper_balances.sql @@ -0,0 +1,51 @@ +-- ============================================================================ +-- TRADING SCHEMA - Tabla: paper_balances +-- ============================================================================ +-- Balances virtuales para paper trading +-- Permite a usuarios practicar sin riesgo real +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS trading.paper_balances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Usuario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Asset y balance + asset VARCHAR(10) NOT NULL DEFAULT 'USDT', + total DECIMAL(20, 8) NOT NULL DEFAULT 10000.00, + available DECIMAL(20, 8) NOT NULL DEFAULT 10000.00, + locked DECIMAL(20, 8) NOT NULL DEFAULT 0, + + -- Tracking + initial_balance DECIMAL(20, 8) NOT NULL DEFAULT 10000.00, + total_deposits DECIMAL(20, 8) NOT NULL DEFAULT 0, + total_withdrawals DECIMAL(20, 8) NOT NULL DEFAULT 0, + total_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, + + -- Reset tracking + last_reset_at TIMESTAMPTZ, + reset_count INTEGER NOT NULL DEFAULT 0, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT uq_paper_balances_user_asset UNIQUE(user_id, asset), + CONSTRAINT chk_balance_consistency CHECK (total = available + locked), + CONSTRAINT chk_balance_non_negative CHECK (total >= 0 AND available >= 0 AND locked >= 0), + CONSTRAINT chk_initial_positive CHECK (initial_balance > 0) +); + +-- Indices +CREATE INDEX idx_paper_balances_user ON trading.paper_balances(user_id); +CREATE INDEX idx_paper_balances_asset ON trading.paper_balances(asset); + +-- Comentarios +COMMENT ON TABLE trading.paper_balances IS 'Balances virtuales para paper trading - cada usuario tiene balance por asset'; +COMMENT ON COLUMN trading.paper_balances.total IS 'Balance total = available + locked'; +COMMENT ON COLUMN trading.paper_balances.available IS 'Balance disponible para nuevas ordenes'; +COMMENT ON COLUMN trading.paper_balances.locked IS 'Balance bloqueado en ordenes abiertas'; +COMMENT ON COLUMN trading.paper_balances.initial_balance IS 'Balance inicial (default $10,000 USDT)'; +COMMENT ON COLUMN trading.paper_balances.reset_count IS 'Numero de veces que el usuario ha reseteado su balance'; diff --git a/schemas/00_init_schemas.sql b/schemas/00_init_schemas.sql new file mode 100644 index 0000000..eac578d --- /dev/null +++ b/schemas/00_init_schemas.sql @@ -0,0 +1,123 @@ +-- ============================================================================ +-- OrbiQuant IA - Inicialización de Esquemas +-- ============================================================================ +-- Archivo: 00_init_schemas.sql +-- Descripción: Creación de esquemas y extensiones base +-- Fecha: 2025-12-05 +-- ============================================================================ + +-- Extensiones requeridas +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- Generación de UUIDs +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Funciones criptográficas +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Búsqueda de texto similar + +-- ============================================================================ +-- ESQUEMAS +-- ============================================================================ + +-- Esquema público (usuarios y configuración global) +-- Ya existe por defecto, solo documentamos +COMMENT ON SCHEMA public IS 'Usuarios, perfiles, configuración global y autenticación'; + +-- Esquema de educación +CREATE SCHEMA IF NOT EXISTS education; +COMMENT ON SCHEMA education IS 'Cursos, lecciones, inscripciones y contenido educativo'; + +-- Esquema de trading +CREATE SCHEMA IF NOT EXISTS trading; +COMMENT ON SCHEMA trading IS 'Bots, señales, estrategias y operaciones de trading'; + +-- Esquema de inversión +CREATE SCHEMA IF NOT EXISTS investment; +COMMENT ON SCHEMA investment IS 'Cuentas de inversión, productos y gestión de portafolios'; + +-- Esquema financiero +CREATE SCHEMA IF NOT EXISTS financial; +COMMENT ON SCHEMA financial IS 'Pagos, suscripciones, wallets y transacciones'; + +-- Esquema de machine learning +CREATE SCHEMA IF NOT EXISTS ml; +COMMENT ON SCHEMA ml IS 'Modelos ML, predicciones, features y métricas'; + +-- Esquema de auditoría +CREATE SCHEMA IF NOT EXISTS audit; +COMMENT ON SCHEMA audit IS 'Logs de auditoría, eventos del sistema y seguridad'; + +-- ============================================================================ +-- TIPOS ENUMERADOS GLOBALES +-- ============================================================================ + +-- Estados comunes +CREATE TYPE status_enum AS ENUM ('active', 'inactive', 'pending', 'suspended', 'deleted'); + +-- Roles del sistema +CREATE TYPE user_role_enum AS ENUM ( + 'investor', -- Cliente final pasivo + 'trader_pro', -- Usuario activo con herramientas + 'student', -- Alumno de cursos + 'admin', -- Administrador + 'risk_officer', -- Oficial de riesgos + 'support' -- Soporte al cliente +); + +-- Perfiles de riesgo +CREATE TYPE risk_profile_enum AS ENUM ('conservative', 'moderate', 'aggressive'); + +-- Direcciones de trading +CREATE TYPE trade_direction_enum AS ENUM ('long', 'short'); + +-- Estados de orden +CREATE TYPE order_status_enum AS ENUM ('pending', 'open', 'filled', 'cancelled', 'expired'); + +-- Tipos de transacción financiera +CREATE TYPE transaction_type_enum AS ENUM ( + 'deposit', + 'withdrawal', + 'subscription_payment', + 'course_purchase', + 'investment_deposit', + 'investment_withdrawal', + 'profit_distribution', + 'fee', + 'refund' +); + +-- ============================================================================ +-- FUNCIÓN PARA TIMESTAMPS AUTOMÁTICOS +-- ============================================================================ + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- ============================================================================ +-- FUNCIÓN PARA AUDITORÍA AUTOMÁTICA +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit.log_changes() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO audit.audit_logs ( + table_name, + record_id, + action, + old_data, + new_data, + user_id, + ip_address + ) VALUES ( + TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, + COALESCE(NEW.id, OLD.id)::text, + TG_OP, + CASE WHEN TG_OP = 'DELETE' THEN row_to_json(OLD) ELSE NULL END, + CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN row_to_json(NEW) ELSE NULL END, + current_setting('app.current_user_id', true)::uuid, + current_setting('app.client_ip', true) + ); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; diff --git a/schemas/01_public_schema.sql b/schemas/01_public_schema.sql new file mode 100644 index 0000000..c9b54a5 --- /dev/null +++ b/schemas/01_public_schema.sql @@ -0,0 +1,280 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema PUBLIC +-- ============================================================================ +-- Archivo: 01_public_schema.sql +-- Descripción: Usuarios, perfiles, autenticación y configuración +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO public; + +-- ============================================================================ +-- TABLA: users +-- Descripción: Usuarios base del sistema (compatible con Supabase Auth) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL UNIQUE, + email_verified BOOLEAN DEFAULT FALSE, + phone VARCHAR(20), + phone_verified BOOLEAN DEFAULT FALSE, + encrypted_password VARCHAR(255), + + -- Metadata de auth + confirmation_token VARCHAR(255), + confirmation_sent_at TIMESTAMPTZ, + confirmed_at TIMESTAMPTZ, + recovery_token VARCHAR(255), + recovery_sent_at TIMESTAMPTZ, + + -- 2FA + totp_secret VARCHAR(255), + totp_enabled BOOLEAN DEFAULT FALSE, + backup_codes TEXT[], + + -- Control de acceso + role user_role_enum DEFAULT 'investor', + status status_enum DEFAULT 'pending', + failed_login_attempts INT DEFAULT 0, + locked_until TIMESTAMPTZ, + last_login_at TIMESTAMPTZ, + last_login_ip INET, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_status ON users(status); +CREATE INDEX idx_users_role ON users(role); + +-- ============================================================================ +-- TABLA: profiles +-- Descripción: Información extendida del usuario +-- ============================================================================ +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + + -- Datos personales + first_name VARCHAR(100), + last_name VARCHAR(100), + display_name VARCHAR(100), + avatar_url TEXT, + date_of_birth DATE, + + -- Ubicación + country_code CHAR(2), + timezone VARCHAR(50) DEFAULT 'UTC', + language VARCHAR(5) DEFAULT 'es', + + -- Preferencias + preferred_currency CHAR(3) DEFAULT 'USD', + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_profiles_user_id ON profiles(user_id); + +-- ============================================================================ +-- TABLA: user_settings +-- Descripción: Configuraciones y preferencias del usuario +-- ============================================================================ +CREATE TABLE IF NOT EXISTS user_settings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + + -- Notificaciones + email_notifications BOOLEAN DEFAULT TRUE, + push_notifications BOOLEAN DEFAULT TRUE, + sms_notifications BOOLEAN DEFAULT FALSE, + + -- Alertas de trading + price_alerts BOOLEAN DEFAULT TRUE, + signal_alerts BOOLEAN DEFAULT TRUE, + portfolio_alerts BOOLEAN DEFAULT TRUE, + + -- Reportes + weekly_report BOOLEAN DEFAULT TRUE, + monthly_report BOOLEAN DEFAULT TRUE, + + -- Privacidad + profile_public BOOLEAN DEFAULT FALSE, + show_portfolio_value BOOLEAN DEFAULT FALSE, + + -- UI + theme VARCHAR(20) DEFAULT 'dark', + chart_preferences JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================ +-- TABLA: kyc_verifications +-- Descripción: Verificación de identidad (KYC) +-- ============================================================================ +CREATE TYPE kyc_status_enum AS ENUM ('pending', 'submitted', 'approved', 'rejected', 'expired'); +CREATE TYPE kyc_document_type AS ENUM ('passport', 'national_id', 'drivers_license', 'residence_permit'); + +CREATE TABLE IF NOT EXISTS kyc_verifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Estado + status kyc_status_enum DEFAULT 'pending', + level INT DEFAULT 0, -- 0: ninguno, 1: básico, 2: completo + + -- Documentos + document_type kyc_document_type, + document_number VARCHAR(50), + document_country CHAR(2), + document_expiry DATE, + document_front_url TEXT, + document_back_url TEXT, + selfie_url TEXT, + proof_of_address_url TEXT, + + -- Verificación + verified_at TIMESTAMPTZ, + verified_by UUID REFERENCES users(id), + rejection_reason TEXT, + + -- Metadata + verification_provider VARCHAR(50), + external_verification_id VARCHAR(100), + + -- Timestamps + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_kyc_user_id ON kyc_verifications(user_id); +CREATE INDEX idx_kyc_status ON kyc_verifications(status); + +-- ============================================================================ +-- TABLA: risk_profiles +-- Descripción: Perfil de riesgo del usuario (cuestionario) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS risk_profiles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Resultado + profile risk_profile_enum NOT NULL, + score INT, -- 0-100 + + -- Respuestas del cuestionario (JSON) + questionnaire_answers JSONB NOT NULL, + + -- Parámetros derivados + max_drawdown_tolerance DECIMAL(5,2), -- % máximo de pérdida tolerada + investment_horizon_months INT, -- Horizonte en meses + experience_level INT, -- 1-5 + + -- Validez + valid_until TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_risk_profiles_user_id ON risk_profiles(user_id); + +-- ============================================================================ +-- TABLA: sessions +-- Descripción: Sesiones activas de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Token + refresh_token VARCHAR(255) NOT NULL UNIQUE, + + -- Información del dispositivo + user_agent TEXT, + ip_address INET, + device_info JSONB, + + -- Validez + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + last_active_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + +-- ============================================================================ +-- TABLA: notifications +-- Descripción: Notificaciones del sistema +-- ============================================================================ +CREATE TYPE notification_type_enum AS ENUM ( + 'system', 'trading', 'investment', 'education', 'payment', 'security', 'marketing' +); + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Contenido + type notification_type_enum NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + data JSONB, + + -- Estado + read_at TIMESTAMPTZ, + + -- Canales enviados + sent_email BOOLEAN DEFAULT FALSE, + sent_push BOOLEAN DEFAULT FALSE, + sent_sms BOOLEAN DEFAULT FALSE, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notifications_user_id ON notifications(user_id); +CREATE INDEX idx_notifications_read ON notifications(user_id, read_at); +CREATE INDEX idx_notifications_created ON notifications(created_at DESC); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_profiles_updated_at + BEFORE UPDATE ON profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_user_settings_updated_at + BEFORE UPDATE ON user_settings + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_kyc_updated_at + BEFORE UPDATE ON kyc_verifications + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_risk_profiles_updated_at + BEFORE UPDATE ON risk_profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/schemas/01b_oauth_providers.sql b/schemas/01b_oauth_providers.sql new file mode 100644 index 0000000..afda232 --- /dev/null +++ b/schemas/01b_oauth_providers.sql @@ -0,0 +1,220 @@ +-- ============================================================================ +-- OrbiQuant IA - OAuth Providers +-- ============================================================================ +-- Archivo: 01b_oauth_providers.sql +-- Descripción: Proveedores OAuth y vinculación de cuentas sociales +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO public; + +-- ============================================================================ +-- ENUM: auth_provider_enum +-- ============================================================================ +CREATE TYPE auth_provider_enum AS ENUM ( + 'email', -- Email/password tradicional + 'phone', -- Teléfono (SMS/WhatsApp) + 'google', -- Google OAuth + 'facebook', -- Facebook OAuth + 'twitter', -- X (Twitter) OAuth + 'apple', -- Apple Sign In + 'github' -- GitHub OAuth +); + +-- ============================================================================ +-- TABLA: oauth_accounts +-- Descripción: Cuentas OAuth vinculadas a usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS oauth_accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Proveedor + provider auth_provider_enum NOT NULL, + provider_account_id VARCHAR(255) NOT NULL, + + -- Tokens OAuth + access_token TEXT, + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + token_type VARCHAR(50), + scope TEXT, + + -- Datos del perfil del proveedor + provider_email VARCHAR(255), + provider_name VARCHAR(255), + provider_avatar_url TEXT, + provider_profile JSONB, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + -- Constraints + UNIQUE(provider, provider_account_id), + UNIQUE(user_id, provider) +); + +CREATE INDEX idx_oauth_user_id ON oauth_accounts(user_id); +CREATE INDEX idx_oauth_provider ON oauth_accounts(provider); +CREATE INDEX idx_oauth_provider_account ON oauth_accounts(provider, provider_account_id); + +-- ============================================================================ +-- TABLA: phone_verifications +-- Descripción: Verificaciones de teléfono (OTP via SMS/WhatsApp) +-- ============================================================================ +CREATE TYPE phone_channel_enum AS ENUM ('sms', 'whatsapp', 'call'); + +CREATE TABLE IF NOT EXISTS phone_verifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Teléfono + phone_number VARCHAR(20) NOT NULL, + country_code VARCHAR(5) NOT NULL, + + -- OTP + otp_code VARCHAR(6) NOT NULL, + otp_hash VARCHAR(255) NOT NULL, + channel phone_channel_enum DEFAULT 'whatsapp', + + -- Estado + verified BOOLEAN DEFAULT FALSE, + attempts INT DEFAULT 0, + max_attempts INT DEFAULT 3, + + -- Vinculación + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + purpose VARCHAR(50) NOT NULL DEFAULT 'login', -- login, register, verify, 2fa + + -- Validez + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_phone_verif_phone ON phone_verifications(phone_number); +CREATE INDEX idx_phone_verif_expires ON phone_verifications(expires_at); +CREATE INDEX idx_phone_verif_user ON phone_verifications(user_id); + +-- ============================================================================ +-- TABLA: email_verifications +-- Descripción: Tokens de verificación de email +-- ============================================================================ +CREATE TABLE IF NOT EXISTS email_verifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Email + email VARCHAR(255) NOT NULL, + + -- Token + token VARCHAR(255) NOT NULL UNIQUE, + token_hash VARCHAR(255) NOT NULL, + + -- Vinculación + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + purpose VARCHAR(50) NOT NULL DEFAULT 'verify', -- verify, reset_password, change_email + + -- Estado + used BOOLEAN DEFAULT FALSE, + + -- Validez + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_email_verif_email ON email_verifications(email); +CREATE INDEX idx_email_verif_token ON email_verifications(token_hash); +CREATE INDEX idx_email_verif_user ON email_verifications(user_id); + +-- ============================================================================ +-- TABLA: auth_logs +-- Descripción: Logs de eventos de autenticación +-- ============================================================================ +CREATE TYPE auth_event_enum AS ENUM ( + 'login_success', + 'login_failed', + 'logout', + 'register', + 'password_reset_request', + 'password_reset_complete', + 'email_verified', + 'phone_verified', + 'oauth_linked', + 'oauth_unlinked', + '2fa_enabled', + '2fa_disabled', + '2fa_verified', + 'session_revoked', + 'account_locked', + 'account_unlocked' +); + +CREATE TABLE IF NOT EXISTS auth_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + + -- Evento + event auth_event_enum NOT NULL, + provider auth_provider_enum, + + -- Contexto + ip_address INET, + user_agent TEXT, + device_fingerprint VARCHAR(255), + location JSONB, -- {country, city, region} + + -- Metadata + metadata JSONB, + + -- Resultado + success BOOLEAN DEFAULT TRUE, + error_message TEXT, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_auth_logs_user ON auth_logs(user_id); +CREATE INDEX idx_auth_logs_event ON auth_logs(event); +CREATE INDEX idx_auth_logs_created ON auth_logs(created_at DESC); +CREATE INDEX idx_auth_logs_ip ON auth_logs(ip_address); + +-- ============================================================================ +-- MODIFICAR TABLA users: agregar campo auth_provider +-- ============================================================================ +ALTER TABLE users ADD COLUMN IF NOT EXISTS primary_auth_provider auth_provider_enum DEFAULT 'email'; + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_oauth_accounts_updated_at + BEFORE UPDATE ON oauth_accounts + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================================================ +-- FUNCIONES DE UTILIDAD +-- ============================================================================ + +-- Función para generar OTP de 6 dígitos +CREATE OR REPLACE FUNCTION generate_otp() +RETURNS VARCHAR(6) AS $$ +BEGIN + RETURN LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0'); +END; +$$ LANGUAGE plpgsql; + +-- Función para limpiar verificaciones expiradas +CREATE OR REPLACE FUNCTION cleanup_expired_verifications() +RETURNS void AS $$ +BEGIN + DELETE FROM phone_verifications WHERE expires_at < NOW() AND verified = FALSE; + DELETE FROM email_verifications WHERE expires_at < NOW() AND used = FALSE; +END; +$$ LANGUAGE plpgsql; diff --git a/schemas/02_education_schema.sql b/schemas/02_education_schema.sql new file mode 100644 index 0000000..12f95a5 --- /dev/null +++ b/schemas/02_education_schema.sql @@ -0,0 +1,398 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema EDUCATION +-- ============================================================================ +-- Archivo: 02_education_schema.sql +-- Descripción: Cursos, lecciones, inscripciones y contenido educativo +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO education; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE course_level_enum AS ENUM ('beginner', 'intermediate', 'advanced', 'expert'); +CREATE TYPE course_status_enum AS ENUM ('draft', 'published', 'archived'); +CREATE TYPE content_type_enum AS ENUM ('video', 'text', 'quiz', 'exercise', 'resource'); +CREATE TYPE enrollment_status_enum AS ENUM ('active', 'completed', 'expired', 'cancelled'); + +-- ============================================================================ +-- TABLA: categories +-- Descripción: Categorías de cursos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS categories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + icon VARCHAR(50), + parent_id UUID REFERENCES categories(id), + sort_order INT DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_categories_parent ON categories(parent_id); +CREATE INDEX idx_categories_slug ON categories(slug); + +-- ============================================================================ +-- TABLA: courses +-- Descripción: Cursos de trading +-- ============================================================================ +CREATE TABLE IF NOT EXISTS courses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Información básica + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + short_description VARCHAR(500), + thumbnail_url TEXT, + preview_video_url TEXT, + + -- Categorización + category_id UUID REFERENCES categories(id), + level course_level_enum DEFAULT 'beginner', + tags TEXT[], + + -- Pricing + is_free BOOLEAN DEFAULT FALSE, + price DECIMAL(10,2) DEFAULT 0, + currency CHAR(3) DEFAULT 'USD', + + -- Acceso + requires_subscription BOOLEAN DEFAULT FALSE, + min_subscription_tier VARCHAR(20), -- 'basic', 'pro', 'elite' + + -- Metadata + duration_minutes INT, + lessons_count INT DEFAULT 0, + enrolled_count INT DEFAULT 0, + + -- Rating + average_rating DECIMAL(3,2) DEFAULT 0, + ratings_count INT DEFAULT 0, + + -- Estado + status course_status_enum DEFAULT 'draft', + published_at TIMESTAMPTZ, + + -- Autor + instructor_id UUID REFERENCES public.users(id), + + -- AI Generation + ai_generated BOOLEAN DEFAULT FALSE, + ai_generation_prompt TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_courses_slug ON courses(slug); +CREATE INDEX idx_courses_category ON courses(category_id); +CREATE INDEX idx_courses_status ON courses(status); +CREATE INDEX idx_courses_level ON courses(level); +CREATE INDEX idx_courses_instructor ON courses(instructor_id); + +-- ============================================================================ +-- TABLA: modules +-- Descripción: Módulos/secciones de un curso +-- ============================================================================ +CREATE TABLE IF NOT EXISTS modules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + title VARCHAR(255) NOT NULL, + description TEXT, + sort_order INT DEFAULT 0, + + -- Progreso requerido del módulo anterior para desbloquear + unlock_after_module_id UUID REFERENCES modules(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_modules_course ON modules(course_id); + +-- ============================================================================ +-- TABLA: lessons +-- Descripción: Lecciones individuales +-- ============================================================================ +CREATE TABLE IF NOT EXISTS lessons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + -- Contenido + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + content_type content_type_enum DEFAULT 'video', + + -- Video + video_url TEXT, + video_duration_seconds INT, + video_provider VARCHAR(50), -- 'youtube', 'vimeo', 'internal' + + -- Texto + content_markdown TEXT, + content_html TEXT, + + -- Recursos adicionales + resources JSONB DEFAULT '[]', -- [{name, url, type}] + + -- Orden + sort_order INT DEFAULT 0, + + -- Acceso + is_preview BOOLEAN DEFAULT FALSE, -- Disponible sin inscripción + + -- AI + ai_generated BOOLEAN DEFAULT FALSE, + ai_summary TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(course_id, slug) +); + +CREATE INDEX idx_lessons_module ON lessons(module_id); +CREATE INDEX idx_lessons_course ON lessons(course_id); + +-- ============================================================================ +-- TABLA: quizzes +-- Descripción: Cuestionarios y evaluaciones +-- ============================================================================ +CREATE TABLE IF NOT EXISTS quizzes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + title VARCHAR(255) NOT NULL, + description TEXT, + + -- Configuración + passing_score INT DEFAULT 70, -- Porcentaje mínimo + max_attempts INT, -- NULL = ilimitado + time_limit_minutes INT, + shuffle_questions BOOLEAN DEFAULT FALSE, + show_correct_answers BOOLEAN DEFAULT TRUE, + + -- AI + ai_generated BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_quizzes_lesson ON quizzes(lesson_id); +CREATE INDEX idx_quizzes_course ON quizzes(course_id); + +-- ============================================================================ +-- TABLA: quiz_questions +-- Descripción: Preguntas de cuestionarios +-- ============================================================================ +CREATE TYPE question_type_enum AS ENUM ('multiple_choice', 'true_false', 'multiple_answer', 'short_answer'); + +CREATE TABLE IF NOT EXISTS quiz_questions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, + + question_type question_type_enum DEFAULT 'multiple_choice', + question_text TEXT NOT NULL, + explanation TEXT, -- Explicación mostrada después de responder + + -- Opciones (para multiple_choice y multiple_answer) + options JSONB, -- [{id, text, is_correct}] + + -- Para short_answer + correct_answers TEXT[], -- Respuestas aceptadas + + points INT DEFAULT 1, + sort_order INT DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_quiz_questions_quiz ON quiz_questions(quiz_id); + +-- ============================================================================ +-- TABLA: enrollments +-- Descripción: Inscripciones de usuarios a cursos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS enrollments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + -- Estado + status enrollment_status_enum DEFAULT 'active', + + -- Progreso + progress_percentage DECIMAL(5,2) DEFAULT 0, + lessons_completed INT DEFAULT 0, + + -- Acceso + enrolled_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ, -- NULL = acceso permanente + completed_at TIMESTAMPTZ, + + -- Pago (si aplica) + payment_id UUID, -- Referencia a financial.payments + + -- Certificado + certificate_issued BOOLEAN DEFAULT FALSE, + certificate_url TEXT, + certificate_issued_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, course_id) +); + +CREATE INDEX idx_enrollments_user ON enrollments(user_id); +CREATE INDEX idx_enrollments_course ON enrollments(course_id); +CREATE INDEX idx_enrollments_status ON enrollments(status); + +-- ============================================================================ +-- TABLA: lesson_progress +-- Descripción: Progreso por lección +-- ============================================================================ +CREATE TABLE IF NOT EXISTS lesson_progress ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + enrollment_id UUID NOT NULL REFERENCES enrollments(id) ON DELETE CASCADE, + + -- Progreso de video + video_watched_seconds INT DEFAULT 0, + video_completed BOOLEAN DEFAULT FALSE, + + -- Estado + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + -- Notas del usuario + user_notes TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, lesson_id) +); + +CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id); +CREATE INDEX idx_lesson_progress_lesson ON lesson_progress(lesson_id); +CREATE INDEX idx_lesson_progress_enrollment ON lesson_progress(enrollment_id); + +-- ============================================================================ +-- TABLA: quiz_attempts +-- Descripción: Intentos de cuestionarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS quiz_attempts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, + enrollment_id UUID REFERENCES enrollments(id) ON DELETE SET NULL, + + -- Resultado + score DECIMAL(5,2), + passed BOOLEAN, + + -- Respuestas + answers JSONB, -- [{question_id, answer, is_correct}] + + -- Tiempo + started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMPTZ, + time_spent_seconds INT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_quiz_attempts_user ON quiz_attempts(user_id); +CREATE INDEX idx_quiz_attempts_quiz ON quiz_attempts(quiz_id); + +-- ============================================================================ +-- TABLA: course_reviews +-- Descripción: Reseñas de cursos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS course_reviews ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + rating INT NOT NULL CHECK (rating >= 1 AND rating <= 5), + review_text TEXT, + + -- Moderación + is_approved BOOLEAN DEFAULT TRUE, + is_featured BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, course_id) +); + +CREATE INDEX idx_course_reviews_course ON course_reviews(course_id); + +-- ============================================================================ +-- TABLA: ai_content_generations +-- Descripción: Registro de contenido generado por IA +-- ============================================================================ +CREATE TABLE IF NOT EXISTS ai_content_generations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Referencia + entity_type VARCHAR(50) NOT NULL, -- 'course', 'lesson', 'quiz' + entity_id UUID NOT NULL, + + -- Generación + prompt TEXT NOT NULL, + model_used VARCHAR(100), + generated_content TEXT NOT NULL, + tokens_used INT, + + -- Estado + approved BOOLEAN DEFAULT FALSE, + approved_by UUID REFERENCES public.users(id), + approved_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_ai_generations_entity ON ai_content_generations(entity_type, entity_id); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_courses_updated_at + BEFORE UPDATE ON courses + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_modules_updated_at + BEFORE UPDATE ON modules + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_lessons_updated_at + BEFORE UPDATE ON lessons + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_enrollments_updated_at + BEFORE UPDATE ON enrollments + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_lesson_progress_updated_at + BEFORE UPDATE ON lesson_progress + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/schemas/03_trading_schema.sql b/schemas/03_trading_schema.sql new file mode 100644 index 0000000..bfa2a7a --- /dev/null +++ b/schemas/03_trading_schema.sql @@ -0,0 +1,428 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema TRADING +-- ============================================================================ +-- Archivo: 03_trading_schema.sql +-- Descripción: Bots, señales, estrategias y operaciones de trading +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO trading; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE bot_status_enum AS ENUM ('active', 'paused', 'stopped', 'error', 'maintenance'); +CREATE TYPE signal_status_enum AS ENUM ('pending', 'active', 'triggered', 'expired', 'cancelled'); +CREATE TYPE position_status_enum AS ENUM ('open', 'closed', 'pending'); +CREATE TYPE timeframe_enum AS ENUM ('1m', '5m', '15m', '30m', '1h', '4h', '1d', '1w'); +CREATE TYPE amd_phase_enum AS ENUM ('accumulation', 'manipulation', 'distribution', 'unknown'); +CREATE TYPE volatility_regime_enum AS ENUM ('low', 'medium', 'high', 'extreme'); + +-- ============================================================================ +-- TABLA: symbols +-- Descripción: Instrumentos financieros disponibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS symbols ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + symbol VARCHAR(20) NOT NULL UNIQUE, -- 'XAUUSD', 'EURUSD', 'BTCUSD' + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Clasificación + asset_class VARCHAR(50), -- 'forex', 'crypto', 'commodities', 'indices', 'stocks' + base_currency VARCHAR(10), + quote_currency VARCHAR(10), + + -- Trading info + pip_value DECIMAL(10,6), + lot_size DECIMAL(10,2), + min_lot DECIMAL(10,4), + max_lot DECIMAL(10,2), + tick_size DECIMAL(10,6), + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + trading_hours JSONB, -- [{day, open, close}] + + -- Data provider + data_provider VARCHAR(50), + provider_symbol VARCHAR(50), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_symbols_symbol ON symbols(symbol); +CREATE INDEX idx_symbols_asset_class ON symbols(asset_class); + +-- ============================================================================ +-- TABLA: strategies +-- Descripción: Estrategias de trading disponibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS strategies ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + + -- Tipo + strategy_type VARCHAR(50), -- 'intraday', 'swing', 'scalping', 'position' + + -- Configuración + default_timeframe timeframe_enum DEFAULT '15m', + supported_symbols TEXT[], -- NULL = todos + risk_reward_ratio DECIMAL(4,2) DEFAULT 2.0, + + -- Parámetros configurables (JSON Schema) + parameters_schema JSONB, + default_parameters JSONB, + + -- ML Model asociado + uses_ml_model BOOLEAN DEFAULT TRUE, + ml_model_id UUID, -- Referencia a ml.models + + -- AMD + uses_amd BOOLEAN DEFAULT TRUE, + favorable_phases amd_phase_enum[], + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_strategies_slug ON strategies(slug); + +-- ============================================================================ +-- TABLA: bots (Agentes de Trading) +-- Descripción: Instancias de bots/agentes de trading +-- ============================================================================ +CREATE TABLE IF NOT EXISTS bots ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + avatar_url TEXT, + + -- Perfil de riesgo + risk_profile public.risk_profile_enum NOT NULL, + + -- Objetivos + target_monthly_return DECIMAL(5,2), -- % objetivo + max_drawdown DECIMAL(5,2), -- % máximo + max_position_size DECIMAL(5,2), -- % del capital + + -- Estrategias + strategy_id UUID REFERENCES strategies(id), + strategy_parameters JSONB, + + -- Configuración + supported_symbols TEXT[], + default_timeframe timeframe_enum DEFAULT '15m', + min_confidence DECIMAL(3,2) DEFAULT 0.55, + + -- Horarios de operación + trading_schedule JSONB, -- Horarios permitidos + + -- Estado + status bot_status_enum DEFAULT 'stopped', + last_activity_at TIMESTAMPTZ, + error_message TEXT, + + -- Métricas globales (actualizadas periódicamente) + total_trades INT DEFAULT 0, + winning_trades INT DEFAULT 0, + total_profit DECIMAL(15,2) DEFAULT 0, + sharpe_ratio DECIMAL(5,2), + sortino_ratio DECIMAL(5,2), + max_drawdown_actual DECIMAL(5,2), + + -- Público + is_public BOOLEAN DEFAULT TRUE, -- Visible para asignar a cuentas + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_bots_slug ON bots(slug); +CREATE INDEX idx_bots_risk_profile ON bots(risk_profile); +CREATE INDEX idx_bots_status ON bots(status); + +-- ============================================================================ +-- TABLA: signals +-- Descripción: Señales de trading generadas +-- ============================================================================ +CREATE TABLE IF NOT EXISTS signals ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Origen + bot_id UUID REFERENCES bots(id), + strategy_id UUID REFERENCES strategies(id), + symbol_id UUID REFERENCES symbols(id), + symbol VARCHAR(20) NOT NULL, + + -- Señal + direction public.trade_direction_enum NOT NULL, + timeframe timeframe_enum NOT NULL, + + -- Precios + entry_price DECIMAL(20,8) NOT NULL, + stop_loss DECIMAL(20,8) NOT NULL, + take_profit DECIMAL(20,8) NOT NULL, + current_price DECIMAL(20,8), + + -- Predicción ML + predicted_delta_high DECIMAL(20,8), + predicted_delta_low DECIMAL(20,8), + prob_tp_first DECIMAL(5,4), -- Probabilidad de tocar TP antes que SL + confidence_score DECIMAL(5,4), + + -- Contexto de mercado + amd_phase amd_phase_enum, + volatility_regime volatility_regime_enum, + market_context JSONB, -- Datos adicionales del contexto + + -- R:R + risk_reward_ratio DECIMAL(4,2), + risk_pips DECIMAL(10,4), + reward_pips DECIMAL(10,4), + + -- Estado + status signal_status_enum DEFAULT 'pending', + triggered_at TIMESTAMPTZ, + expired_at TIMESTAMPTZ, + + -- Resultado (si se operó) + outcome VARCHAR(20), -- 'tp_hit', 'sl_hit', 'manual_close', 'expired' + pnl DECIMAL(15,2), + + -- Timestamps + valid_until TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_signals_bot ON signals(bot_id); +CREATE INDEX idx_signals_symbol ON signals(symbol); +CREATE INDEX idx_signals_status ON signals(status); +CREATE INDEX idx_signals_created ON signals(created_at DESC); +CREATE INDEX idx_signals_direction ON signals(direction); + +-- ============================================================================ +-- TABLA: positions +-- Descripción: Posiciones de trading (abiertas y cerradas) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS positions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Referencias + bot_id UUID REFERENCES bots(id), + signal_id UUID REFERENCES signals(id), + investment_account_id UUID, -- Referencia a investment.accounts + symbol_id UUID REFERENCES symbols(id), + symbol VARCHAR(20) NOT NULL, + + -- Posición + direction public.trade_direction_enum NOT NULL, + lot_size DECIMAL(10,4) NOT NULL, + + -- Precios + entry_price DECIMAL(20,8) NOT NULL, + stop_loss DECIMAL(20,8), + take_profit DECIMAL(20,8), + exit_price DECIMAL(20,8), + + -- Broker + broker_order_id VARCHAR(100), + broker_position_id VARCHAR(100), + + -- Estado + status position_status_enum DEFAULT 'open', + opened_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMPTZ, + close_reason VARCHAR(50), -- 'tp', 'sl', 'manual', 'forced', 'margin_call' + + -- P&L + realized_pnl DECIMAL(15,2), + unrealized_pnl DECIMAL(15,2), + commission DECIMAL(10,2) DEFAULT 0, + swap DECIMAL(10,2) DEFAULT 0, + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_positions_bot ON positions(bot_id); +CREATE INDEX idx_positions_signal ON positions(signal_id); +CREATE INDEX idx_positions_account ON positions(investment_account_id); +CREATE INDEX idx_positions_status ON positions(status); +CREATE INDEX idx_positions_opened ON positions(opened_at DESC); + +-- ============================================================================ +-- TABLA: price_alerts +-- Descripción: Alertas de precio configuradas por usuarios +-- ============================================================================ +CREATE TYPE alert_condition_enum AS ENUM ('above', 'below', 'crosses_above', 'crosses_below'); + +CREATE TABLE IF NOT EXISTS price_alerts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + symbol VARCHAR(20) NOT NULL, + condition alert_condition_enum NOT NULL, + price DECIMAL(20,8) NOT NULL, + note TEXT, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + triggered_at TIMESTAMPTZ, + triggered_price DECIMAL(20,8), + + -- Notificación + notify_email BOOLEAN DEFAULT TRUE, + notify_push BOOLEAN DEFAULT TRUE, + + -- Recurrencia + is_recurring BOOLEAN DEFAULT FALSE, -- Si se reactiva después de triggear + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_price_alerts_user ON price_alerts(user_id); +CREATE INDEX idx_price_alerts_symbol ON price_alerts(symbol); +CREATE INDEX idx_price_alerts_active ON price_alerts(is_active) WHERE is_active = TRUE; + +-- ============================================================================ +-- TABLA: watchlists +-- Descripción: Listas de seguimiento de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS watchlists ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + name VARCHAR(100) NOT NULL, + is_default BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_watchlists_user ON watchlists(user_id); + +-- ============================================================================ +-- TABLA: watchlist_items +-- Descripción: Items en watchlists +-- ============================================================================ +CREATE TABLE IF NOT EXISTS watchlist_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + watchlist_id UUID NOT NULL REFERENCES watchlists(id) ON DELETE CASCADE, + + symbol VARCHAR(20) NOT NULL, + sort_order INT DEFAULT 0, + notes TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(watchlist_id, symbol) +); + +CREATE INDEX idx_watchlist_items_watchlist ON watchlist_items(watchlist_id); + +-- ============================================================================ +-- TABLA: paper_trading_accounts +-- Descripción: Cuentas de paper trading (simulación) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS paper_trading_accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + name VARCHAR(100) DEFAULT 'Paper Account', + initial_balance DECIMAL(15,2) NOT NULL DEFAULT 100000, + current_balance DECIMAL(15,2) NOT NULL DEFAULT 100000, + currency CHAR(3) DEFAULT 'USD', + + -- Métricas + total_trades INT DEFAULT 0, + winning_trades INT DEFAULT 0, + total_pnl DECIMAL(15,2) DEFAULT 0, + max_drawdown DECIMAL(5,2) DEFAULT 0, + + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_paper_accounts_user ON paper_trading_accounts(user_id); + +-- ============================================================================ +-- TABLA: paper_trading_positions +-- Descripción: Posiciones de paper trading +-- ============================================================================ +CREATE TABLE IF NOT EXISTS paper_trading_positions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES paper_trading_accounts(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + symbol VARCHAR(20) NOT NULL, + direction public.trade_direction_enum NOT NULL, + lot_size DECIMAL(10,4) NOT NULL, + + entry_price DECIMAL(20,8) NOT NULL, + stop_loss DECIMAL(20,8), + take_profit DECIMAL(20,8), + exit_price DECIMAL(20,8), + + status position_status_enum DEFAULT 'open', + opened_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMPTZ, + close_reason VARCHAR(50), + + realized_pnl DECIMAL(15,2), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_paper_positions_account ON paper_trading_positions(account_id); +CREATE INDEX idx_paper_positions_user ON paper_trading_positions(user_id); +CREATE INDEX idx_paper_positions_status ON paper_trading_positions(status); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_symbols_updated_at + BEFORE UPDATE ON symbols + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_strategies_updated_at + BEFORE UPDATE ON strategies + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_bots_updated_at + BEFORE UPDATE ON bots + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_signals_updated_at + BEFORE UPDATE ON signals + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_positions_updated_at + BEFORE UPDATE ON positions + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/schemas/04_investment_schema.sql b/schemas/04_investment_schema.sql new file mode 100644 index 0000000..b91cd22 --- /dev/null +++ b/schemas/04_investment_schema.sql @@ -0,0 +1,426 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema INVESTMENT +-- ============================================================================ +-- Archivo: 04_investment_schema.sql +-- Descripción: Cuentas de inversión, productos y gestión de portafolios +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO investment; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE product_type_enum AS ENUM ( + 'fixed_return', -- Rendimiento fijo objetivo (ej: 5% mensual) + 'variable_return', -- Rendimiento variable con reparto de utilidades + 'long_term_portfolio' -- Cartera de largo plazo (acciones, ETFs) +); + +CREATE TYPE account_status_enum AS ENUM ( + 'pending_kyc', -- Esperando verificación KYC + 'pending_deposit', -- Esperando depósito inicial + 'active', -- Cuenta activa operando + 'paused', -- Pausada por usuario o admin + 'suspended', -- Suspendida por compliance + 'closed' -- Cerrada +); + +CREATE TYPE fee_type_enum AS ENUM ( + 'management', -- Comisión de administración + 'performance', -- Comisión de rendimiento + 'deposit', -- Comisión de depósito + 'withdrawal', -- Comisión de retiro + 'subscription' -- Comisión de suscripción +); + +-- ============================================================================ +-- TABLA: products +-- Descripción: Productos de inversión disponibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS products ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + short_description VARCHAR(500), + + -- Tipo y riesgo + product_type product_type_enum NOT NULL, + risk_profile public.risk_profile_enum NOT NULL, + + -- Objetivos + target_monthly_return DECIMAL(5,2), -- % objetivo mensual + max_drawdown DECIMAL(5,2), -- % máximo drawdown permitido + guaranteed_return BOOLEAN DEFAULT FALSE, -- SIEMPRE FALSE para cumplimiento + + -- Comisiones + management_fee_percent DECIMAL(5,2) DEFAULT 0, -- % anual sobre AUM + performance_fee_percent DECIMAL(5,2) DEFAULT 0, -- % sobre ganancias + profit_share_platform DECIMAL(5,2), -- % de utilidades para plataforma (ej: 50) + profit_share_client DECIMAL(5,2), -- % de utilidades para cliente (ej: 50) + + -- Límites + min_investment DECIMAL(15,2) DEFAULT 100, + max_investment DECIMAL(15,2), + min_investment_period_days INT DEFAULT 30, + + -- Restricciones + requires_kyc_level INT DEFAULT 1, + allowed_risk_profiles public.risk_profile_enum[], + + -- Bot/Agente asociado + default_bot_id UUID REFERENCES trading.bots(id), + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + is_visible BOOLEAN DEFAULT TRUE, + + -- Metadata + terms_url TEXT, + risk_disclosure_url TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_products_slug ON products(slug); +CREATE INDEX idx_products_type ON products(product_type); +CREATE INDEX idx_products_risk ON products(risk_profile); + +-- ============================================================================ +-- TABLA: accounts +-- Descripción: Cuentas de inversión de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + product_id UUID NOT NULL REFERENCES products(id), + bot_id UUID REFERENCES trading.bots(id), + + -- Identificación + account_number VARCHAR(20) NOT NULL UNIQUE, -- OQ-INV-XXXXXX + name VARCHAR(100), + + -- Moneda + currency CHAR(3) DEFAULT 'USD', + + -- Balances + initial_deposit DECIMAL(15,2) NOT NULL, + current_balance DECIMAL(15,2) NOT NULL, + available_balance DECIMAL(15,2) NOT NULL, -- Balance disponible para retiro + reserved_balance DECIMAL(15,2) DEFAULT 0, -- En operaciones abiertas + + -- Rendimiento acumulado + total_profit DECIMAL(15,2) DEFAULT 0, + total_fees_paid DECIMAL(15,2) DEFAULT 0, + total_deposits DECIMAL(15,2) DEFAULT 0, + total_withdrawals DECIMAL(15,2) DEFAULT 0, + + -- Métricas de rendimiento + total_return_percent DECIMAL(8,4) DEFAULT 0, + monthly_return_percent DECIMAL(8,4) DEFAULT 0, + max_drawdown_percent DECIMAL(5,2) DEFAULT 0, + sharpe_ratio DECIMAL(5,2), + + -- Estado + status account_status_enum DEFAULT 'pending_deposit', + activated_at TIMESTAMPTZ, + paused_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + + -- Configuración del usuario + auto_compound BOOLEAN DEFAULT TRUE, -- Reinvertir ganancias + max_drawdown_override DECIMAL(5,2), -- Override del drawdown máximo + pause_on_drawdown BOOLEAN DEFAULT TRUE, -- Pausar si alcanza DD máximo + + -- Aceptación de términos + terms_accepted_at TIMESTAMPTZ, + risk_disclosure_accepted_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_accounts_user ON accounts(user_id); +CREATE INDEX idx_accounts_product ON accounts(product_id); +CREATE INDEX idx_accounts_status ON accounts(status); +CREATE INDEX idx_accounts_number ON accounts(account_number); + +-- ============================================================================ +-- TABLA: account_transactions +-- Descripción: Movimientos en cuentas de inversión +-- ============================================================================ +CREATE TYPE account_transaction_type AS ENUM ( + 'deposit', + 'withdrawal', + 'profit', + 'loss', + 'fee', + 'adjustment', + 'transfer_in', + 'transfer_out' +); + +CREATE TABLE IF NOT EXISTS account_transactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, + + -- Tipo y monto + transaction_type account_transaction_type NOT NULL, + amount DECIMAL(15,2) NOT NULL, -- Positivo o negativo según tipo + currency CHAR(3) DEFAULT 'USD', + + -- Balances + balance_before DECIMAL(15,2) NOT NULL, + balance_after DECIMAL(15,2) NOT NULL, + + -- Referencia externa + reference_type VARCHAR(50), -- 'wallet_transaction', 'position', 'fee_charge' + reference_id UUID, + + -- Descripción + description TEXT, + + -- Estado + status VARCHAR(20) DEFAULT 'completed', -- 'pending', 'completed', 'cancelled' + processed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_account_tx_account ON account_transactions(account_id); +CREATE INDEX idx_account_tx_type ON account_transactions(transaction_type); +CREATE INDEX idx_account_tx_created ON account_transactions(created_at DESC); + +-- ============================================================================ +-- TABLA: performance_snapshots +-- Descripción: Snapshots periódicos de rendimiento +-- ============================================================================ +CREATE TABLE IF NOT EXISTS performance_snapshots ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + + -- Período + snapshot_date DATE NOT NULL, + period_type VARCHAR(20) NOT NULL, -- 'daily', 'weekly', 'monthly' + + -- Valores + opening_balance DECIMAL(15,2) NOT NULL, + closing_balance DECIMAL(15,2) NOT NULL, + deposits DECIMAL(15,2) DEFAULT 0, + withdrawals DECIMAL(15,2) DEFAULT 0, + profit_loss DECIMAL(15,2) NOT NULL, + fees DECIMAL(15,2) DEFAULT 0, + + -- Métricas + return_percent DECIMAL(8,4), + drawdown_percent DECIMAL(5,2), + + -- Trading stats + total_trades INT DEFAULT 0, + winning_trades INT DEFAULT 0, + positions_opened INT DEFAULT 0, + positions_closed INT DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(account_id, snapshot_date, period_type) +); + +CREATE INDEX idx_perf_snapshots_account ON performance_snapshots(account_id); +CREATE INDEX idx_perf_snapshots_date ON performance_snapshots(snapshot_date DESC); + +-- ============================================================================ +-- TABLA: profit_distributions +-- Descripción: Distribución de utilidades (para cuentas con profit sharing) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS profit_distributions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, + + -- Período + period_start DATE NOT NULL, + period_end DATE NOT NULL, + + -- Cálculo + gross_profit DECIMAL(15,2) NOT NULL, + management_fee DECIMAL(15,2) DEFAULT 0, + net_profit DECIMAL(15,2) NOT NULL, + + -- Distribución + platform_share_percent DECIMAL(5,2) NOT NULL, + client_share_percent DECIMAL(5,2) NOT NULL, + platform_amount DECIMAL(15,2) NOT NULL, + client_amount DECIMAL(15,2) NOT NULL, + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'approved', 'distributed', 'cancelled' + distributed_at TIMESTAMPTZ, + approved_by UUID REFERENCES auth.users(id), + + -- Referencia de pago + payment_reference VARCHAR(100), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_profit_dist_account ON profit_distributions(account_id); +CREATE INDEX idx_profit_dist_period ON profit_distributions(period_end DESC); +CREATE INDEX idx_profit_dist_status ON profit_distributions(status); + +-- ============================================================================ +-- TABLA: deposit_requests +-- Descripción: Solicitudes de depósito a cuentas de inversión +-- ============================================================================ +CREATE TABLE IF NOT EXISTS deposit_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES auth.users(id), + + amount DECIMAL(15,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Origen + source_type VARCHAR(50) NOT NULL, -- 'wallet', 'external' + source_wallet_id UUID, -- Referencia a financial.wallets + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'approved', 'completed', 'rejected' + processed_at TIMESTAMPTZ, + processed_by UUID REFERENCES auth.users(id), + rejection_reason TEXT, + + -- Transacción resultante + transaction_id UUID REFERENCES account_transactions(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_deposit_requests_account ON deposit_requests(account_id); +CREATE INDEX idx_deposit_requests_user ON deposit_requests(user_id); +CREATE INDEX idx_deposit_requests_status ON deposit_requests(status); + +-- ============================================================================ +-- TABLA: withdrawal_requests +-- Descripción: Solicitudes de retiro de cuentas de inversión +-- ============================================================================ +CREATE TABLE IF NOT EXISTS withdrawal_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES auth.users(id), + + amount DECIMAL(15,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Destino + destination_type VARCHAR(50) NOT NULL, -- 'wallet', 'bank_transfer' + destination_wallet_id UUID, + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'rejected' + processed_at TIMESTAMPTZ, + processed_by UUID REFERENCES auth.users(id), + rejection_reason TEXT, + + -- Comisiones + fee_amount DECIMAL(10,2) DEFAULT 0, + net_amount DECIMAL(15,2), + + -- Transacción resultante + transaction_id UUID REFERENCES account_transactions(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_withdrawal_requests_account ON withdrawal_requests(account_id); +CREATE INDEX idx_withdrawal_requests_user ON withdrawal_requests(user_id); +CREATE INDEX idx_withdrawal_requests_status ON withdrawal_requests(status); + +-- ============================================================================ +-- TABLA: bot_assignments +-- Descripción: Asignación de bots a cuentas de inversión +-- ============================================================================ +CREATE TABLE IF NOT EXISTS bot_assignments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + bot_id UUID NOT NULL REFERENCES trading.bots(id), + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + assigned_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + deactivated_at TIMESTAMPTZ, + + -- Razón del cambio + assignment_reason TEXT, + deactivation_reason TEXT, + + -- Usuario que asignó + assigned_by UUID REFERENCES auth.users(id), -- NULL = automático + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_bot_assignments_account ON bot_assignments(account_id); +CREATE INDEX idx_bot_assignments_bot ON bot_assignments(bot_id); +CREATE INDEX idx_bot_assignments_active ON bot_assignments(is_active) WHERE is_active = TRUE; + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_products_updated_at + BEFORE UPDATE ON products + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_accounts_updated_at + BEFORE UPDATE ON accounts + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_deposit_requests_updated_at + BEFORE UPDATE ON deposit_requests + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_withdrawal_requests_updated_at + BEFORE UPDATE ON withdrawal_requests + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_profit_distributions_updated_at + BEFORE UPDATE ON profit_distributions + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================ +-- FUNCIÓN: Generar número de cuenta +-- ============================================================================ +CREATE OR REPLACE FUNCTION generate_account_number() +RETURNS TRIGGER AS $$ +DECLARE + seq_num INT; +BEGIN + SELECT COALESCE(MAX(CAST(SUBSTRING(account_number FROM 8) AS INT)), 0) + 1 + INTO seq_num + FROM investment.accounts; + + NEW.account_number := 'OQ-INV-' || LPAD(seq_num::TEXT, 6, '0'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_account_number + BEFORE INSERT ON accounts + FOR EACH ROW + WHEN (NEW.account_number IS NULL) + EXECUTE FUNCTION generate_account_number(); diff --git a/schemas/05_financial_schema.sql b/schemas/05_financial_schema.sql new file mode 100644 index 0000000..deb2b80 --- /dev/null +++ b/schemas/05_financial_schema.sql @@ -0,0 +1,500 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema FINANCIAL +-- ============================================================================ +-- Archivo: 05_financial_schema.sql +-- Descripción: Pagos, suscripciones, wallets y transacciones +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO financial; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE subscription_status_enum AS ENUM ( + 'trialing', -- Período de prueba + 'active', -- Activa y pagando + 'past_due', -- Pago vencido + 'cancelled', -- Cancelada + 'unpaid', -- Sin pago + 'paused' -- Pausada +); + +CREATE TYPE payment_status_enum AS ENUM ( + 'pending', + 'processing', + 'succeeded', + 'failed', + 'refunded', + 'cancelled' +); + +CREATE TYPE payment_method_enum AS ENUM ( + 'card', + 'bank_transfer', + 'paypal', + 'crypto', + 'wallet_balance' +); + +-- ============================================================================ +-- TABLA: subscription_plans +-- Descripción: Planes de suscripción disponibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS subscription_plans ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + name VARCHAR(50) NOT NULL, + slug VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + + -- Pricing + price_monthly DECIMAL(10,2) NOT NULL, + price_yearly DECIMAL(10,2), + currency CHAR(3) DEFAULT 'USD', + + -- Stripe + stripe_price_id_monthly VARCHAR(100), + stripe_price_id_yearly VARCHAR(100), + stripe_product_id VARCHAR(100), + + -- Features (JSON array) + features JSONB DEFAULT '[]', -- [{name, description, included: boolean}] + + -- Límites + max_watchlists INT, + max_alerts INT, + ml_predictions_access BOOLEAN DEFAULT FALSE, + signals_access BOOLEAN DEFAULT FALSE, + backtesting_access BOOLEAN DEFAULT FALSE, + api_access BOOLEAN DEFAULT FALSE, + priority_support BOOLEAN DEFAULT FALSE, + + -- Educación + courses_access VARCHAR(20) DEFAULT 'none', -- 'none', 'free_only', 'basic', 'all' + + -- Orden de display + sort_order INT DEFAULT 0, + is_featured BOOLEAN DEFAULT FALSE, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_plans_slug ON subscription_plans(slug); + +-- ============================================================================ +-- TABLA: stripe_customers +-- Descripción: Clientes de Stripe vinculados a usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS stripe_customers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + + stripe_customer_id VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255), + + -- Método de pago por defecto + default_payment_method_id VARCHAR(100), + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_stripe_customers_user ON stripe_customers(user_id); +CREATE INDEX idx_stripe_customers_stripe ON stripe_customers(stripe_customer_id); + +-- ============================================================================ +-- TABLA: subscriptions +-- Descripción: Suscripciones activas de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS subscriptions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + plan_id UUID NOT NULL REFERENCES subscription_plans(id), + + -- Stripe + stripe_subscription_id VARCHAR(100) UNIQUE, + stripe_customer_id VARCHAR(100) REFERENCES stripe_customers(stripe_customer_id), + + -- Estado + status subscription_status_enum DEFAULT 'active', + + -- Período de facturación + billing_cycle VARCHAR(20) DEFAULT 'monthly', -- 'monthly', 'yearly' + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + + -- Trial + trial_start TIMESTAMPTZ, + trial_end TIMESTAMPTZ, + + -- Cancelación + cancel_at_period_end BOOLEAN DEFAULT FALSE, + cancelled_at TIMESTAMPTZ, + cancellation_reason TEXT, + + -- Precio actual (puede diferir del plan por descuentos) + current_price DECIMAL(10,2), + currency CHAR(3) DEFAULT 'USD', + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_subscriptions_user ON subscriptions(user_id); +CREATE INDEX idx_subscriptions_plan ON subscriptions(plan_id); +CREATE INDEX idx_subscriptions_status ON subscriptions(status); +CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id); + +-- ============================================================================ +-- TABLA: wallets +-- Descripción: Wallets internos de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS wallets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + + -- Balance + currency CHAR(3) NOT NULL DEFAULT 'USD', + balance DECIMAL(15,2) NOT NULL DEFAULT 0, + available_balance DECIMAL(15,2) NOT NULL DEFAULT 0, + pending_balance DECIMAL(15,2) NOT NULL DEFAULT 0, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + -- Límites + daily_withdrawal_limit DECIMAL(15,2) DEFAULT 10000, + monthly_withdrawal_limit DECIMAL(15,2) DEFAULT 50000, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, currency) +); + +CREATE INDEX idx_wallets_user ON wallets(user_id); + +-- ============================================================================ +-- TABLA: wallet_transactions +-- Descripción: Transacciones en wallets +-- ============================================================================ +CREATE TABLE IF NOT EXISTS wallet_transactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + wallet_id UUID NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Tipo y monto + transaction_type public.transaction_type_enum NOT NULL, + amount DECIMAL(15,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Balance + balance_before DECIMAL(15,2) NOT NULL, + balance_after DECIMAL(15,2) NOT NULL, + + -- Referencia + reference_type VARCHAR(50), -- 'payment', 'subscription', 'investment_account', 'refund' + reference_id UUID, + external_reference VARCHAR(100), -- ID externo (Stripe, etc.) + + -- Descripción + description TEXT, + + -- Estado + status payment_status_enum DEFAULT 'succeeded', + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_wallet_tx_wallet ON wallet_transactions(wallet_id); +CREATE INDEX idx_wallet_tx_user ON wallet_transactions(user_id); +CREATE INDEX idx_wallet_tx_type ON wallet_transactions(transaction_type); +CREATE INDEX idx_wallet_tx_created ON wallet_transactions(created_at DESC); + +-- ============================================================================ +-- TABLA: payments +-- Descripción: Pagos procesados +-- ============================================================================ +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Tipo + payment_type VARCHAR(50) NOT NULL, -- 'subscription', 'course', 'deposit', 'one_time' + + -- Monto + amount DECIMAL(10,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + fee DECIMAL(10,2) DEFAULT 0, + net_amount DECIMAL(10,2), + + -- Método + payment_method payment_method_enum, + payment_method_details JSONB, -- Últimos 4 dígitos, tipo de tarjeta, etc. + + -- Stripe + stripe_payment_intent_id VARCHAR(100), + stripe_charge_id VARCHAR(100), + stripe_invoice_id VARCHAR(100), + + -- Estado + status payment_status_enum DEFAULT 'pending', + failure_reason TEXT, + + -- Referencia + reference_type VARCHAR(50), -- 'subscription', 'course', 'wallet_deposit' + reference_id UUID, + + -- Descripción + description TEXT, + + -- Metadata + metadata JSONB, + ip_address INET, + + -- Facturación + invoice_url TEXT, + receipt_url TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_payments_user ON payments(user_id); +CREATE INDEX idx_payments_status ON payments(status); +CREATE INDEX idx_payments_type ON payments(payment_type); +CREATE INDEX idx_payments_stripe ON payments(stripe_payment_intent_id); +CREATE INDEX idx_payments_created ON payments(created_at DESC); + +-- ============================================================================ +-- TABLA: refunds +-- Descripción: Reembolsos procesados +-- ============================================================================ +CREATE TABLE IF NOT EXISTS refunds ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + payment_id UUID NOT NULL REFERENCES payments(id), + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Monto + amount DECIMAL(10,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Stripe + stripe_refund_id VARCHAR(100), + + -- Razón + reason VARCHAR(100), + notes TEXT, + + -- Estado + status payment_status_enum DEFAULT 'pending', + + -- Procesado por + processed_by UUID REFERENCES auth.users(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_refunds_payment ON refunds(payment_id); +CREATE INDEX idx_refunds_user ON refunds(user_id); + +-- ============================================================================ +-- TABLA: invoices +-- Descripción: Facturas generadas +-- ============================================================================ +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Número de factura + invoice_number VARCHAR(50) NOT NULL UNIQUE, + + -- Stripe + stripe_invoice_id VARCHAR(100) UNIQUE, + + -- Montos + subtotal DECIMAL(10,2) NOT NULL, + tax DECIMAL(10,2) DEFAULT 0, + total DECIMAL(10,2) NOT NULL, + amount_paid DECIMAL(10,2) DEFAULT 0, + amount_due DECIMAL(10,2), + currency CHAR(3) DEFAULT 'USD', + + -- Estado + status VARCHAR(20) DEFAULT 'draft', -- 'draft', 'open', 'paid', 'void', 'uncollectible' + + -- Fechas + due_date DATE, + paid_at TIMESTAMPTZ, + + -- Items + line_items JSONB DEFAULT '[]', -- [{description, quantity, unit_price, amount}] + + -- PDFs + pdf_url TEXT, + hosted_invoice_url TEXT, + + -- Datos fiscales del cliente + billing_details JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_invoices_user ON invoices(user_id); +CREATE INDEX idx_invoices_status ON invoices(status); +CREATE INDEX idx_invoices_number ON invoices(invoice_number); + +-- ============================================================================ +-- TABLA: payout_requests +-- Descripción: Solicitudes de retiro a cuenta bancaria/externa +-- ============================================================================ +CREATE TABLE IF NOT EXISTS payout_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id), + wallet_id UUID NOT NULL REFERENCES wallets(id), + + -- Monto + amount DECIMAL(15,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + fee DECIMAL(10,2) DEFAULT 0, + net_amount DECIMAL(15,2), + + -- Destino + payout_method VARCHAR(50) NOT NULL, -- 'bank_transfer', 'paypal', 'crypto' + destination_details JSONB, -- Cuenta bancaria, dirección, etc. + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'rejected', 'cancelled' + processed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + -- Procesamiento + processed_by UUID REFERENCES auth.users(id), + rejection_reason TEXT, + external_reference VARCHAR(100), + + -- Transacción de wallet + wallet_transaction_id UUID REFERENCES wallet_transactions(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_payouts_user ON payout_requests(user_id); +CREATE INDEX idx_payouts_wallet ON payout_requests(wallet_id); +CREATE INDEX idx_payouts_status ON payout_requests(status); + +-- ============================================================================ +-- TABLA: promo_codes +-- Descripción: Códigos promocionales y descuentos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS promo_codes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + code VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + + -- Tipo de descuento + discount_type VARCHAR(20) NOT NULL, -- 'percentage', 'fixed_amount' + discount_value DECIMAL(10,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Aplicabilidad + applies_to VARCHAR(50) DEFAULT 'all', -- 'all', 'subscription', 'course' + applicable_plan_ids UUID[], + applicable_course_ids UUID[], + + -- Límites + max_uses INT, + current_uses INT DEFAULT 0, + max_uses_per_user INT DEFAULT 1, + + -- Validez + valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + valid_until TIMESTAMPTZ, + + -- Requisitos + min_purchase_amount DECIMAL(10,2), + first_time_only BOOLEAN DEFAULT FALSE, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_promo_codes_code ON promo_codes(code); +CREATE INDEX idx_promo_codes_active ON promo_codes(is_active) WHERE is_active = TRUE; + +-- ============================================================================ +-- TABLA: promo_code_uses +-- Descripción: Uso de códigos promocionales +-- ============================================================================ +CREATE TABLE IF NOT EXISTS promo_code_uses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + promo_code_id UUID NOT NULL REFERENCES promo_codes(id), + user_id UUID NOT NULL REFERENCES auth.users(id), + payment_id UUID REFERENCES payments(id), + + discount_applied DECIMAL(10,2) NOT NULL, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_promo_uses_code ON promo_code_uses(promo_code_id); +CREATE INDEX idx_promo_uses_user ON promo_code_uses(user_id); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_plans_updated_at + BEFORE UPDATE ON subscription_plans + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_stripe_customers_updated_at + BEFORE UPDATE ON stripe_customers + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_subscriptions_updated_at + BEFORE UPDATE ON subscriptions + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_wallets_updated_at + BEFORE UPDATE ON wallets + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_payments_updated_at + BEFORE UPDATE ON payments + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_invoices_updated_at + BEFORE UPDATE ON invoices + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_payouts_updated_at + BEFORE UPDATE ON payout_requests + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/schemas/06_ml_schema.sql b/schemas/06_ml_schema.sql new file mode 100644 index 0000000..935adce --- /dev/null +++ b/schemas/06_ml_schema.sql @@ -0,0 +1,426 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema ML (Machine Learning) +-- ============================================================================ +-- Archivo: 06_ml_schema.sql +-- Descripción: Modelos ML, predicciones, features y métricas +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO ml; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE model_type_enum AS ENUM ( + 'range_predictor', -- Predicción de rangos (ΔHigh/ΔLow) + 'tpsl_classifier', -- Clasificación TP vs SL + 'signal_generator', -- Generador de señales + 'regime_classifier', -- Clasificación de régimen de mercado + 'amd_detector', -- Detector de fases AMD + 'volatility_model', -- Modelo de volatilidad + 'ensemble' -- Meta-modelo ensemble +); + +CREATE TYPE model_status_enum AS ENUM ( + 'training', + 'validating', + 'ready', + 'deployed', + 'deprecated', + 'failed' +); + +-- ============================================================================ +-- TABLA: models +-- Descripción: Registro de modelos ML +-- ============================================================================ +CREATE TABLE IF NOT EXISTS models ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT, + + -- Tipo y versión + model_type model_type_enum NOT NULL, + version VARCHAR(20) NOT NULL, + is_latest BOOLEAN DEFAULT FALSE, + + -- Símbolos y timeframes + symbols TEXT[], -- NULL = todos + timeframes trading.timeframe_enum[], + + -- Arquitectura + algorithm VARCHAR(50), -- 'xgboost', 'gru', 'transformer', 'ensemble' + architecture_config JSONB, -- Configuración de arquitectura + + -- Hiperparámetros + hyperparameters JSONB, + + -- Features + feature_columns TEXT[], + feature_count INT, + + -- Artifact + artifact_path TEXT, -- Path al modelo serializado + artifact_size_mb DECIMAL(10,2), + + -- Estado + status model_status_enum DEFAULT 'training', + deployed_at TIMESTAMPTZ, + + -- Metadata + training_duration_seconds INT, + total_samples INT, + created_by UUID REFERENCES public.users(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(slug, version) +); + +CREATE INDEX idx_models_slug ON models(slug); +CREATE INDEX idx_models_type ON models(model_type); +CREATE INDEX idx_models_status ON models(status); +CREATE INDEX idx_models_latest ON models(is_latest) WHERE is_latest = TRUE; + +-- ============================================================================ +-- TABLA: training_runs +-- Descripción: Ejecuciones de entrenamiento +-- ============================================================================ +CREATE TABLE IF NOT EXISTS training_runs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID REFERENCES models(id) ON DELETE SET NULL, + + -- Configuración + run_name VARCHAR(100), + config JSONB NOT NULL, + + -- Datos + training_data_start DATE, + training_data_end DATE, + validation_data_start DATE, + validation_data_end DATE, + total_samples INT, + training_samples INT, + validation_samples INT, + + -- Walk-forward + walk_forward_splits INT, + walk_forward_config JSONB, + + -- Estado + status VARCHAR(20) DEFAULT 'running', -- 'running', 'completed', 'failed', 'cancelled' + started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMPTZ, + error_message TEXT, + + -- Recursos + gpu_used BOOLEAN DEFAULT FALSE, + memory_peak_mb INT, + duration_seconds INT, + + -- Logs + logs_path TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_training_runs_model ON training_runs(model_id); +CREATE INDEX idx_training_runs_status ON training_runs(status); + +-- ============================================================================ +-- TABLA: model_metrics +-- Descripción: Métricas de rendimiento de modelos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS model_metrics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + training_run_id UUID REFERENCES training_runs(id), + + -- Tipo de métricas + metric_set VARCHAR(50) NOT NULL, -- 'training', 'validation', 'test', 'production' + split_index INT, -- Para walk-forward + + -- Métricas de regresión + mae DECIMAL(10,6), + rmse DECIMAL(10,6), + mape DECIMAL(10,6), + r2_score DECIMAL(10,6), + + -- Métricas de clasificación + accuracy DECIMAL(5,4), + precision_score DECIMAL(5,4), + recall_score DECIMAL(5,4), + f1_score DECIMAL(5,4), + roc_auc DECIMAL(5,4), + + -- Métricas por clase + confusion_matrix JSONB, + classification_report JSONB, + + -- Métricas de trading + win_rate DECIMAL(5,4), + profit_factor DECIMAL(6,2), + sharpe_ratio DECIMAL(6,2), + sortino_ratio DECIMAL(6,2), + max_drawdown DECIMAL(5,4), + + -- Feature importance + feature_importance JSONB, + + -- Timestamp + calculated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_model_metrics_model ON model_metrics(model_id); +CREATE INDEX idx_model_metrics_set ON model_metrics(metric_set); + +-- ============================================================================ +-- TABLA: predictions +-- Descripción: Predicciones generadas +-- ============================================================================ +CREATE TABLE IF NOT EXISTS predictions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID NOT NULL REFERENCES models(id), + + -- Contexto + symbol VARCHAR(20) NOT NULL, + timeframe trading.timeframe_enum NOT NULL, + prediction_timestamp TIMESTAMPTZ NOT NULL, + + -- Input + candle_timestamp TIMESTAMPTZ NOT NULL, -- Timestamp de la vela de entrada + input_features JSONB, -- Features usadas (opcional, para debugging) + + -- Predicción de rango + predicted_delta_high DECIMAL(20,8), + predicted_delta_low DECIMAL(20,8), + predicted_delta_high_1h DECIMAL(20,8), + predicted_delta_low_1h DECIMAL(20,8), + + -- Clasificación de bins (ATR-based) + predicted_high_bin INT, + predicted_low_bin INT, + bin_probabilities JSONB, + + -- TP/SL prediction + prob_tp_first DECIMAL(5,4), + rr_config VARCHAR(20), -- 'rr_2_1', 'rr_3_1' + + -- Confianza + confidence_score DECIMAL(5,4), + model_uncertainty DECIMAL(5,4), + + -- Contexto de mercado predicho + predicted_amd_phase trading.amd_phase_enum, + predicted_volatility trading.volatility_regime_enum, + + -- Resultado real (llenado posteriormente) + actual_delta_high DECIMAL(20,8), + actual_delta_low DECIMAL(20,8), + actual_tp_sl_outcome VARCHAR(20), -- 'tp_hit', 'sl_hit', 'neither' + outcome_recorded_at TIMESTAMPTZ, + + -- Error calculado + error_high DECIMAL(20,8), + error_low DECIMAL(20,8), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_predictions_model ON predictions(model_id); +CREATE INDEX idx_predictions_symbol ON predictions(symbol); +CREATE INDEX idx_predictions_timestamp ON predictions(prediction_timestamp DESC); +CREATE INDEX idx_predictions_candle ON predictions(candle_timestamp); + +-- Particionamiento por fecha para mejor rendimiento +-- (En producción, considerar particionar por mes) + +-- ============================================================================ +-- TABLA: prediction_accuracy_daily +-- Descripción: Precisión de predicciones agregada por día +-- ============================================================================ +CREATE TABLE IF NOT EXISTS prediction_accuracy_daily ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + symbol VARCHAR(20) NOT NULL, + date DATE NOT NULL, + + -- Conteos + total_predictions INT DEFAULT 0, + predictions_evaluated INT DEFAULT 0, + + -- Métricas de rango + mae_high DECIMAL(10,6), + mae_low DECIMAL(10,6), + mape_high DECIMAL(10,6), + mape_low DECIMAL(10,6), + + -- Métricas de TP/SL + tp_sl_predictions INT DEFAULT 0, + tp_correct INT DEFAULT 0, + sl_correct INT DEFAULT 0, + accuracy_tp_sl DECIMAL(5,4), + + -- Métricas de bins + bin_accuracy DECIMAL(5,4), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(model_id, symbol, date) +); + +CREATE INDEX idx_pred_accuracy_model ON prediction_accuracy_daily(model_id); +CREATE INDEX idx_pred_accuracy_date ON prediction_accuracy_daily(date DESC); + +-- ============================================================================ +-- TABLA: feature_store +-- Descripción: Features pre-calculadas para inferencia rápida +-- ============================================================================ +CREATE TABLE IF NOT EXISTS feature_store ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + symbol VARCHAR(20) NOT NULL, + timeframe trading.timeframe_enum NOT NULL, + candle_timestamp TIMESTAMPTZ NOT NULL, + + -- OHLCV base + open DECIMAL(20,8) NOT NULL, + high DECIMAL(20,8) NOT NULL, + low DECIMAL(20,8) NOT NULL, + close DECIMAL(20,8) NOT NULL, + volume DECIMAL(20,4), + + -- Features calculadas (las 21 del modelo) + features JSONB NOT NULL, + + -- Indicadores técnicos + indicators JSONB, + + -- Validación + is_valid BOOLEAN DEFAULT TRUE, + validation_errors TEXT[], + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(symbol, timeframe, candle_timestamp) +); + +CREATE INDEX idx_feature_store_symbol ON feature_store(symbol, timeframe); +CREATE INDEX idx_feature_store_timestamp ON feature_store(candle_timestamp DESC); + +-- ============================================================================ +-- TABLA: model_drift_alerts +-- Descripción: Alertas de drift de modelo +-- ============================================================================ +CREATE TABLE IF NOT EXISTS model_drift_alerts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID NOT NULL REFERENCES models(id), + + -- Tipo de drift + drift_type VARCHAR(50) NOT NULL, -- 'feature_drift', 'prediction_drift', 'performance_drift' + + -- Detalles + metric_name VARCHAR(100), + expected_value DECIMAL(10,6), + actual_value DECIMAL(10,6), + deviation_percent DECIMAL(10,4), + + -- Severidad + severity VARCHAR(20), -- 'low', 'medium', 'high', 'critical' + + -- Estado + status VARCHAR(20) DEFAULT 'active', -- 'active', 'acknowledged', 'resolved' + acknowledged_by UUID REFERENCES public.users(id), + acknowledged_at TIMESTAMPTZ, + resolved_at TIMESTAMPTZ, + + -- Acción tomada + action_taken TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_drift_alerts_model ON model_drift_alerts(model_id); +CREATE INDEX idx_drift_alerts_status ON model_drift_alerts(status); + +-- ============================================================================ +-- TABLA: ab_tests +-- Descripción: Tests A/B de modelos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS ab_tests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Modelos + control_model_id UUID NOT NULL REFERENCES models(id), + treatment_model_id UUID NOT NULL REFERENCES models(id), + + -- Configuración + traffic_split DECIMAL(3,2) DEFAULT 0.50, -- % al tratamiento + target_metric VARCHAR(50), -- Métrica principal a optimizar + + -- Estado + status VARCHAR(20) DEFAULT 'draft', -- 'draft', 'running', 'paused', 'completed', 'cancelled' + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + + -- Resultados + control_samples INT DEFAULT 0, + treatment_samples INT DEFAULT 0, + control_metric_value DECIMAL(10,6), + treatment_metric_value DECIMAL(10,6), + statistical_significance DECIMAL(5,4), + winner VARCHAR(20), -- 'control', 'treatment', 'inconclusive' + + created_by UUID REFERENCES public.users(id), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_ab_tests_status ON ab_tests(status); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_models_updated_at + BEFORE UPDATE ON models + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_ab_tests_updated_at + BEFORE UPDATE ON ab_tests + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================ +-- FUNCIÓN: Marcar modelo como latest +-- ============================================================================ +CREATE OR REPLACE FUNCTION set_model_as_latest() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_latest = TRUE THEN + UPDATE ml.models + SET is_latest = FALSE + WHERE slug = NEW.slug + AND id != NEW.id + AND is_latest = TRUE; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER ensure_single_latest_model + AFTER INSERT OR UPDATE ON models + FOR EACH ROW + WHEN (NEW.is_latest = TRUE) + EXECUTE FUNCTION set_model_as_latest(); diff --git a/schemas/07_audit_schema.sql b/schemas/07_audit_schema.sql new file mode 100644 index 0000000..ada98e7 --- /dev/null +++ b/schemas/07_audit_schema.sql @@ -0,0 +1,402 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema AUDIT +-- ============================================================================ +-- Archivo: 07_audit_schema.sql +-- Descripción: Logs de auditoría, eventos del sistema y seguridad +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO audit; + +-- ============================================================================ +-- TABLA: audit_logs +-- Descripción: Log general de acciones en el sistema +-- ============================================================================ +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Tabla afectada + table_name VARCHAR(100) NOT NULL, + record_id TEXT, + + -- Acción + action VARCHAR(20) NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE' + + -- Datos + old_data JSONB, + new_data JSONB, + + -- Usuario + user_id UUID, + user_email VARCHAR(255), + + -- Contexto + ip_address INET, + user_agent TEXT, + session_id UUID, + + -- Timestamp + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Índices para búsqueda eficiente +CREATE INDEX idx_audit_logs_table ON audit_logs(table_name); +CREATE INDEX idx_audit_logs_record ON audit_logs(table_name, record_id); +CREATE INDEX idx_audit_logs_user ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_created ON audit_logs(created_at DESC); + +-- Particionamiento por mes (para producción) +-- CREATE TABLE audit_logs_2025_01 PARTITION OF audit_logs +-- FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); + +-- ============================================================================ +-- TABLA: security_events +-- Descripción: Eventos de seguridad +-- ============================================================================ +CREATE TYPE security_event_type AS ENUM ( + 'login_success', + 'login_failed', + 'logout', + 'password_changed', + 'password_reset_requested', + 'password_reset_completed', + '2fa_enabled', + '2fa_disabled', + '2fa_failed', + 'account_locked', + 'account_unlocked', + 'suspicious_activity', + 'api_key_created', + 'api_key_revoked', + 'permission_denied', + 'rate_limit_exceeded' +); + +CREATE TYPE security_severity AS ENUM ('info', 'warning', 'error', 'critical'); + +CREATE TABLE IF NOT EXISTS security_events ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Usuario + user_id UUID REFERENCES auth.users(id), + user_email VARCHAR(255), + + -- Evento + event_type security_event_type NOT NULL, + severity security_severity DEFAULT 'info', + description TEXT, + + -- Contexto + ip_address INET, + user_agent TEXT, + location JSONB, -- {country, city, lat, lon} + + -- Datos adicionales + metadata JSONB, + + -- Estado + acknowledged BOOLEAN DEFAULT FALSE, + acknowledged_by UUID REFERENCES auth.users(id), + acknowledged_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_security_events_user ON security_events(user_id); +CREATE INDEX idx_security_events_type ON security_events(event_type); +CREATE INDEX idx_security_events_severity ON security_events(severity); +CREATE INDEX idx_security_events_created ON security_events(created_at DESC); +CREATE INDEX idx_security_events_unack ON security_events(acknowledged) WHERE acknowledged = FALSE; + +-- ============================================================================ +-- TABLA: system_events +-- Descripción: Eventos del sistema (no de usuario) +-- ============================================================================ +CREATE TYPE system_event_type AS ENUM ( + 'service_started', + 'service_stopped', + 'service_error', + 'database_backup', + 'database_restore', + 'deployment', + 'config_changed', + 'scheduled_job_started', + 'scheduled_job_completed', + 'scheduled_job_failed', + 'integration_connected', + 'integration_disconnected', + 'integration_error', + 'alert_triggered', + 'maintenance_started', + 'maintenance_completed' +); + +CREATE TABLE IF NOT EXISTS system_events ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Evento + event_type system_event_type NOT NULL, + service_name VARCHAR(100), + description TEXT, + + -- Severidad + severity security_severity DEFAULT 'info', + + -- Detalles + details JSONB, + error_message TEXT, + stack_trace TEXT, + + -- Metadata + hostname VARCHAR(255), + environment VARCHAR(50), -- 'development', 'staging', 'production' + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_system_events_type ON system_events(event_type); +CREATE INDEX idx_system_events_service ON system_events(service_name); +CREATE INDEX idx_system_events_severity ON system_events(severity); +CREATE INDEX idx_system_events_created ON system_events(created_at DESC); + +-- ============================================================================ +-- TABLA: trading_audit +-- Descripción: Auditoría específica de operaciones de trading +-- ============================================================================ +CREATE TABLE IF NOT EXISTS trading_audit ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Referencias + bot_id UUID, + signal_id UUID, + position_id UUID, + account_id UUID, + user_id UUID, + + -- Evento + event_type VARCHAR(50) NOT NULL, -- 'signal_generated', 'order_placed', 'order_filled', etc. + description TEXT, + + -- Datos de la operación + symbol VARCHAR(20), + direction VARCHAR(10), + lot_size DECIMAL(10,4), + price DECIMAL(20,8), + + -- Precios + entry_price DECIMAL(20,8), + stop_loss DECIMAL(20,8), + take_profit DECIMAL(20,8), + + -- Resultado + pnl DECIMAL(15,2), + outcome VARCHAR(20), + + -- Contexto del modelo + model_id UUID, + confidence DECIMAL(5,4), + amd_phase VARCHAR(20), + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_trading_audit_bot ON trading_audit(bot_id); +CREATE INDEX idx_trading_audit_signal ON trading_audit(signal_id); +CREATE INDEX idx_trading_audit_position ON trading_audit(position_id); +CREATE INDEX idx_trading_audit_account ON trading_audit(account_id); +CREATE INDEX idx_trading_audit_event ON trading_audit(event_type); +CREATE INDEX idx_trading_audit_symbol ON trading_audit(symbol); +CREATE INDEX idx_trading_audit_created ON trading_audit(created_at DESC); + +-- ============================================================================ +-- TABLA: api_request_logs +-- Descripción: Logs de requests a la API +-- ============================================================================ +CREATE TABLE IF NOT EXISTS api_request_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Usuario + user_id UUID, + api_key_id UUID, + + -- Request + method VARCHAR(10) NOT NULL, + path VARCHAR(500) NOT NULL, + query_params JSONB, + headers JSONB, + body_size INT, + + -- Response + status_code INT, + response_time_ms INT, + response_size INT, + + -- Contexto + ip_address INET, + user_agent TEXT, + + -- Error (si aplica) + error_message TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Índices para análisis +CREATE INDEX idx_api_logs_user ON api_request_logs(user_id); +CREATE INDEX idx_api_logs_path ON api_request_logs(path); +CREATE INDEX idx_api_logs_status ON api_request_logs(status_code); +CREATE INDEX idx_api_logs_created ON api_request_logs(created_at DESC); + +-- ============================================================================ +-- TABLA: data_access_logs +-- Descripción: Log de acceso a datos sensibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS data_access_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Usuario que accedió + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Dato accedido + resource_type VARCHAR(100) NOT NULL, -- 'user_profile', 'kyc_document', 'wallet_balance', etc. + resource_id UUID, + resource_owner_id UUID, -- Usuario dueño del dato + + -- Acción + action VARCHAR(50) NOT NULL, -- 'view', 'export', 'modify' + + -- Contexto + reason TEXT, -- Justificación del acceso + ip_address INET, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_data_access_user ON data_access_logs(user_id); +CREATE INDEX idx_data_access_resource ON data_access_logs(resource_type, resource_id); +CREATE INDEX idx_data_access_owner ON data_access_logs(resource_owner_id); +CREATE INDEX idx_data_access_created ON data_access_logs(created_at DESC); + +-- ============================================================================ +-- TABLA: compliance_logs +-- Descripción: Logs de cumplimiento regulatorio +-- ============================================================================ +CREATE TABLE IF NOT EXISTS compliance_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Usuario + user_id UUID REFERENCES auth.users(id), + + -- Evento + event_type VARCHAR(100) NOT NULL, + -- 'terms_accepted', 'risk_disclosure_accepted', 'kyc_submitted', 'kyc_approved', + -- 'aml_check_passed', 'suspicious_activity_flagged', etc. + + -- Detalles + description TEXT, + document_version VARCHAR(50), + document_url TEXT, + + -- Metadata + metadata JSONB, + ip_address INET, + + -- Estado de revisión + requires_review BOOLEAN DEFAULT FALSE, + reviewed_by UUID REFERENCES auth.users(id), + reviewed_at TIMESTAMPTZ, + review_notes TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_compliance_user ON compliance_logs(user_id); +CREATE INDEX idx_compliance_event ON compliance_logs(event_type); +CREATE INDEX idx_compliance_review ON compliance_logs(requires_review) WHERE requires_review = TRUE; +CREATE INDEX idx_compliance_created ON compliance_logs(created_at DESC); + +-- ============================================================================ +-- VISTAS DE ADMINISTRACIÓN +-- ============================================================================ + +-- Vista: Actividad reciente de usuarios +CREATE OR REPLACE VIEW admin_user_activity AS +SELECT + u.id AS user_id, + u.email, + p.first_name, + p.last_name, + u.role, + u.status, + u.last_login_at, + u.last_login_ip, + (SELECT COUNT(*) FROM audit.security_events se WHERE se.user_id = u.id AND se.created_at > NOW() - INTERVAL '24 hours') AS security_events_24h, + (SELECT COUNT(*) FROM audit.api_request_logs ar WHERE ar.user_id = u.id AND ar.created_at > NOW() - INTERVAL '24 hours') AS api_requests_24h +FROM auth.users u +LEFT JOIN public.profiles p ON p.user_id = u.id; + +-- Vista: Alertas de seguridad pendientes +CREATE OR REPLACE VIEW admin_security_alerts AS +SELECT + se.*, + u.email AS user_email, + p.first_name, + p.last_name +FROM audit.security_events se +LEFT JOIN auth.users u ON u.id = se.user_id +LEFT JOIN public.profiles p ON p.user_id = se.user_id +WHERE se.acknowledged = FALSE + AND se.severity IN ('warning', 'error', 'critical') +ORDER BY se.created_at DESC; + +-- Vista: Resumen de trading por bot +CREATE OR REPLACE VIEW admin_bot_trading_summary AS +SELECT + ta.bot_id, + b.name AS bot_name, + b.risk_profile, + DATE(ta.created_at) AS date, + COUNT(*) FILTER (WHERE ta.event_type = 'signal_generated') AS signals_generated, + COUNT(*) FILTER (WHERE ta.event_type = 'order_placed') AS orders_placed, + COUNT(*) FILTER (WHERE ta.event_type = 'order_filled') AS orders_filled, + SUM(ta.pnl) AS total_pnl, + COUNT(*) FILTER (WHERE ta.pnl > 0) AS winning_trades, + COUNT(*) FILTER (WHERE ta.pnl < 0) AS losing_trades +FROM audit.trading_audit ta +LEFT JOIN trading.bots b ON b.id = ta.bot_id +WHERE ta.bot_id IS NOT NULL +GROUP BY ta.bot_id, b.name, b.risk_profile, DATE(ta.created_at); + +-- ============================================================================ +-- FUNCIONES DE LIMPIEZA +-- ============================================================================ + +-- Función para limpiar logs antiguos +CREATE OR REPLACE FUNCTION cleanup_old_logs(retention_days INT DEFAULT 90) +RETURNS TABLE( + table_name TEXT, + rows_deleted BIGINT +) AS $$ +DECLARE + cutoff_date TIMESTAMPTZ; +BEGIN + cutoff_date := NOW() - (retention_days || ' days')::INTERVAL; + + -- API request logs (30 días por defecto) + DELETE FROM audit.api_request_logs WHERE created_at < cutoff_date; + RETURN QUERY SELECT 'api_request_logs'::TEXT, COUNT(*)::BIGINT FROM audit.api_request_logs WHERE created_at < cutoff_date; + + -- Audit logs (90 días por defecto, excepto críticos) + DELETE FROM audit.audit_logs WHERE created_at < cutoff_date; + + -- Security events (mantener indefinidamente los críticos) + DELETE FROM audit.security_events + WHERE created_at < cutoff_date + AND severity NOT IN ('error', 'critical'); + +END; +$$ LANGUAGE plpgsql; diff --git a/schemas/_MAP.md b/schemas/_MAP.md new file mode 100644 index 0000000..2ea7bf3 --- /dev/null +++ b/schemas/_MAP.md @@ -0,0 +1,298 @@ +--- +id: "MAP-database-schemas" +title: "Mapa de Schemas de Base de Datos" +type: "Index" +status: "Activo" +project: "trading-platform" +version: "2.0.0" +created_date: "2025-12-06" +updated_date: "2026-01-07" +author: "Claude-Orquestador" +--- + +# _MAP - Database Schemas + +> Índice de navegación para los esquemas de base de datos +> +> **ACTUALIZADO:** 2026-01-07 - market_data schema + 6 activos + ML tables +> **Política:** Carga Limpia (DDL-First) - Ver DIRECTIVA-POLITICA-CARGA-LIMPIA.md +> **Total:** 77 tablas, 104 FK, 9 schemas + +--- + +## Estructura de Archivos + +``` +apps/database/ +├── ddl/ +│ └── schemas/ +│ ├── auth/ # 12 tablas - Autenticación y usuarios +│ ├── education/ # 14 tablas - Plataforma educativa +│ ├── trading/ # 10 tablas - Trading y Paper Engine +│ ├── investment/ # 7 tablas - Cuentas PAMM +│ ├── financial/ # 10 tablas - Pagos, wallets unificadas +│ ├── ml/ # 9 tablas - Machine Learning +│ ├── llm/ # 4 tablas - LLM Agent (+ pgvector) +│ ├── audit/ # 7 tablas - Auditoría y compliance +│ └── market_data/ # 4 tablas - **NUEVO** Datos OHLCV +├── seeds/ +│ └── prod/ # Seeds de producción +└── scripts/ + ├── create-database.sh + ├── drop-and-recreate-database.sh + └── migrate_6_assets.sh # **NUEVO** Migración MySQL→PostgreSQL +``` + +--- + +## Resumen de Schemas + +| Schema | Propósito | Tablas | Funciones | Estado | +|--------|-----------|--------|-----------|--------| +| auth | Autenticación y usuarios | 12 | 4 | ✅ Completado | +| education | Plataforma educativa | 14 | 8 | ✅ Completado | +| trading | Trading y Paper Engine | 10 | 4 | ✅ Completado | +| investment | Cuentas PAMM | 7 | - | ✅ Completado | +| financial | Wallets unificadas, pagos | 10 | 4 | ✅ Completado | +| ml | Machine Learning signals | 9 | 1 | ✅ Completado | +| llm | LLM Agent, RAG | 4 | - | ⚠️ Requiere pgvector | +| audit | Logs, compliance | 7 | - | ✅ Completado | +| **market_data** | **Datos OHLCV 5m/15m** | **4** | **2** | ✅ **NUEVO** | + +**Total:** 77 tablas, 104 FK + +--- + +## Detalle por Schema + +### auth (12 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `users` | 01-users.sql | Usuarios base del sistema | +| `user_profiles` | 02-user_profiles.sql | Información extendida | +| `oauth_accounts` | 03-oauth_accounts.sql | Cuentas OAuth (Google, GitHub) | +| `sessions` | 04-sessions.sql | Sesiones activas | +| `email_verifications` | 05-email_verifications.sql | Verificación de email | +| `phone_verifications` | 06-phone_verifications.sql | Verificación de teléfono | +| `password_reset_tokens` | 07-password_reset_tokens.sql | Tokens de reset | +| `auth_logs` | 08-auth_logs.sql | Log de autenticación | +| `login_attempts` | 09-login_attempts.sql | Control de intentos | +| `rate_limiting_config` | 10-rate_limiting_config.sql | Configuración rate limit | +| `deferred_constraints` | 99-deferred-constraints.sql | **NUEVO** - FK diferidas | + +**Funciones:** update_updated_at, log_auth_event, cleanup_expired_sessions, create_user_profile_trigger + +### market_data (4 tablas) **NUEVO** + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `tickers` | 01-tickers.sql | Catálogo de 6 activos (XAUUSD, EURUSD, BTCUSD, GBPUSD, USDJPY, AUDUSD) | +| `ohlcv_5m` | 02-ohlcv_5m.sql | Datos OHLCV 5 minutos (~4M registros) | +| `ohlcv_15m` | 03-ohlcv_15m.sql | Datos OHLCV 15 minutos (~1.3M registros) | +| `ohlcv_5m_staging` | 04-staging.sql | Tabla staging para carga masiva | + +**Funciones:** aggregate_5m_to_15m, aggregate_all_15m + +**Datos cargados:** +| Activo | Registros 5m | Registros 15m | Cobertura | +|--------|-------------|---------------|-----------| +| XAUUSD | 706,397 | 234,961 | 2015-2025 | +| EURUSD | 755,896 | 251,839 | 2015-2025 | +| BTCUSD | 360,288 | 119,907 | 2015-2025 | +| GBPUSD | 752,281 | 250,430 | 2015-2025 | +| USDJPY | 746,464 | 248,520 | 2015-2025 | +| AUDUSD | 760,289 | 253,380 | 2015-2025 | + +### ml (9 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `models` | 01-models.sql | Registro de modelos ML | +| `model_versions` | 02-model_versions.sql | Versionado de modelos | +| `predictions` | 03-predictions.sql | Predicciones generadas | +| `prediction_outcomes` | 04-prediction_outcomes.sql | Resultados de predicciones | +| `feature_store` | 05-feature_store.sql | Features pre-calculadas | +| `llm_predictions` | 06-llm_predictions.sql | **NUEVO** - Predicciones LLM | +| `llm_prediction_outcomes` | 07-llm_prediction_outcomes.sql | **NUEVO** - Resultados LLM | +| `llm_decisions` | 08-llm_decisions.sql | **NUEVO** - Decisiones LLM | +| `risk_events` | 09-risk_events.sql | **NUEVO** - Eventos de riesgo | + +**Funciones:** calculate_prediction_accuracy + +### education (14 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `categories` | 01-categories.sql | Categorías de cursos | +| `courses` | 02-courses.sql | Cursos de trading | +| `modules` | 03-modules.sql | Módulos de cursos | +| `lessons` | 04-lessons.sql | Lecciones individuales | +| `enrollments` | 05-enrollments.sql | Inscripciones | +| `progress` | 06-progress.sql | Progreso por lección | +| `quizzes` | 07-quizzes.sql | Cuestionarios | +| `quiz_questions` | 08-quiz_questions.sql | Preguntas de quiz | +| `quiz_attempts` | 09-quiz_attempts.sql | Intentos de quiz | +| `certificates` | 10-certificates.sql | Certificados emitidos | +| `user_achievements` | 11-user_achievements.sql | Logros de usuarios | +| `user_gamification_profile` | 12-user_gamification_profile.sql | Perfil gamificación | +| `user_activity_log` | 13-user_activity_log.sql | Log de actividad | +| `course_reviews` | 14-course_reviews.sql | Reseñas de cursos | + +**Funciones:** update_updated_at, update_enrollment_progress, auto_complete_enrollment, generate_certificate, update_course_stats, update_enrollment_count, update_gamification_profile, views + +### trading (10 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `symbols` | 01-symbols.sql | Instrumentos financieros | +| `watchlists` | 02-watchlists.sql | Listas de seguimiento | +| `watchlist_items` | 03-watchlist_items.sql | Items en watchlists | +| `bots` | 04-bots.sql | Agentes de trading (Atlas, Orion, Nova) | +| `orders` | 05-orders.sql | Órdenes de trading | +| `positions` | 06-positions.sql | Posiciones abiertas/cerradas | +| `trades` | 07-trades.sql | Historial de trades | +| `signals` | 08-signals.sql | Señales generadas (interfaz ML) | +| `trading_metrics` | 09-trading_metrics.sql | Métricas de rendimiento | +| `paper_balances` | 10-paper_balances.sql | Balances paper trading | + +**Funciones:** calculate_position_pnl, update_bot_stats, initialize_paper_balance, create_default_watchlist + +### investment (7 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `products` | 01-products.sql | Productos PAMM | +| `risk_questionnaire` | 02-risk_questionnaire.sql | Cuestionario de riesgo | +| `accounts` | 03-accounts.sql | Cuentas de inversión | +| `distributions` | 04-distributions.sql | Distribución de ganancias (80/20) | +| `transactions` | 05-transactions.sql | Movimientos de cuenta | +| `withdrawal_requests` | 06-withdrawal_requests.sql | Solicitudes de retiro | +| `daily_performance` | 07-daily_performance.sql | Rendimiento diario | + +### financial (10 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `wallets` | 01-wallets.sql | Wallets del sistema (unificado) | +| `wallet_transactions` | 02-wallet_transactions.sql | Transacciones de wallet | +| `subscriptions` | 03-subscriptions.sql | Suscripciones activas | +| `invoices` | 04-invoices.sql | Facturas | +| `payments` | 05-payments.sql | Pagos procesados (Stripe) | +| `wallet_audit_log` | 06-wallet_audit_log.sql | Auditoría de wallets | +| `currency_exchange_rates` | 07-currency_exchange_rates.sql | Tipos de cambio USD/MXN | +| `wallet_limits` | 08-wallet_limits.sql | Límites de operación | +| `customers` | 09-customers.sql | Clientes Stripe | +| `payment_methods` | 10-payment_methods.sql | Métodos de pago | + +**Funciones:** update_wallet_balance, process_transaction, triggers, views + +### llm (4 tablas + extension) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| *(extension)* | 00-extensions.sql | pgvector extension para embeddings | +| `conversations` | 01-conversations.sql | Conversaciones con LLM | +| `messages` | 02-messages.sql | Mensajes de conversación | +| `user_preferences` | 03-user_preferences.sql | Preferencias de usuario | +| `user_memory` | 04-user_memory.sql | Memoria persistente | + +> **Nota:** La tabla `embeddings` (05-embeddings.sql) requiere pgvector. Sin pgvector instalado, esta tabla no se crea. + +### audit (7 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `audit_logs` | 01-audit_logs.sql | Log general de auditoría | +| `security_events` | 02-security_events.sql | Eventos de seguridad | +| `system_events` | 03-system_events.sql | Eventos del sistema | +| `trading_audit` | 04-trading_audit.sql | Auditoría de trading | +| `api_request_logs` | 05-api_request_logs.sql | Logs de API | +| `data_access_logs` | 06-data_access_logs.sql | Acceso a datos sensibles (GDPR) | +| `compliance_logs` | 07-compliance_logs.sql | Cumplimiento regulatorio | + +--- + +## Orden de Ejecución (create-database.sh) + +```bash +1. CREATE DATABASE trading_platform +2. Extensions (uuid-ossp, pgcrypto, pg_trgm, btree_gin) + - pgvector opcional (requiere instalación) +3. Schemas: + - auth, education, financial, trading, investment, + - ml, llm, audit, market_data +4. DDL por schema en orden: + - auth (sin dependencias) + - education (depende de auth) + - financial (depende de auth) + - trading (depende de auth) + - investment (depende de auth, trading) + - ml (depende de auth, trading) + - llm (depende de auth) + - audit (depende de auth) + - market_data (sin dependencias) +5. Seeds (prod o dev) +6. Validación +``` + +--- + +## Comandos de Uso + +```bash +# Crear base de datos nueva +./scripts/create-database.sh + +# Eliminar y recrear (desarrollo) +./scripts/drop-and-recreate-database.sh + +# Solo cargar seeds +./scripts/create-database.sh --seeds-only --env dev + +# Migrar datos de MySQL (6 activos, 10 años) +./scripts/migrate_6_assets.sh +``` + +--- + +## Scripts Disponibles + +| Script | Descripción | +|--------|-------------| +| `create-database.sh` | Crea BD con DDL y seeds | +| `drop-and-recreate-database.sh` | Drop + create (desarrollo) | +| `migrate_6_assets.sh` | **NUEVO** - Migra OHLCV desde MySQL | +| `migrate_all_tickers.sh` | Migra todos los tickers (17+) | +| `validate-ddl.sh` | Valida sintaxis de DDL | + +--- + +## Notas Importantes + +- **TIMESTAMPTZ**: Todas las columnas de fecha usan timezone +- **gen_random_uuid()**: Para generación de UUIDs +- **Locale fallback**: Script maneja locales faltantes (WSL2) +- **pgvector opcional**: llm.embeddings requiere pgvector instalado +- **Datos de mercado**: 10 años de datos OHLCV (2015-2025) + +--- + +## Changelog + +| Fecha | Versión | Cambios | +|-------|---------|---------| +| 2026-01-07 | 2.0.0 | market_data schema, 6 activos, tablas ML extendidas | +| 2025-12-06 | 1.0.0 | Creación inicial | + +--- + +## Referencias + +- [DIRECTIVA-POLITICA-CARGA-LIMPIA.md](../DIRECTIVA-POLITICA-CARGA-LIMPIA.md) +- [VALIDACION-ALINEACION-ML-2026-01-07.md](../../docs/99-analisis/VALIDACION-ALINEACION-ML-2026-01-07.md) +- [ET-ML-004-api.md](../../docs/02-definicion-modulos/OQI-006-ml-signals/especificaciones/ET-ML-004-api.md) + +--- +*Última actualización: 2026-01-07* +*Generado por: Claude-Orquestador* diff --git a/scripts/create-database.sh b/scripts/create-database.sh new file mode 100755 index 0000000..11b8acc --- /dev/null +++ b/scripts/create-database.sh @@ -0,0 +1,322 @@ +#!/bin/bash +# ============================================================================ +# CREATE DATABASE SCRIPT - Trading Platform +# ============================================================================ +# +# Script de carga limpia para crear la base de datos desde DDL. +# Cumple con DIRECTIVA-POLITICA-CARGA-LIMPIA.md +# +# USO: +# ./create-database.sh # Crear BD (si no existe) +# ./create-database.sh --drop-first # Drop y recrear +# ./create-database.sh --seeds-only # Solo ejecutar seeds +# +# ============================================================================ + +set -e + +# Colores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuración +# Valores según DEVENV-PORTS-INVENTORY.yml (instancia nativa compartida) +# IMPORTANTE: Puerto 5432 = instancia nativa PostgreSQL (NO Docker) +# Nombres homologados: trading_platform / trading_user (2026-01-07) +DB_NAME="${DB_NAME:-trading_platform}" +DB_USER="${DB_USER:-trading_user}" +DB_PASSWORD="${DB_PASSWORD:-trading_dev_2025}" +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DDL_DIR="$SCRIPT_DIR/../ddl" +SEEDS_DIR="$SCRIPT_DIR/../seeds" + +# Función de logging +log() { + echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[✓]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[!]${NC} $1" +} + +log_error() { + echo -e "${RED}[✗]${NC} $1" +} + +# Función para ejecutar SQL +run_sql() { + local file=$1 + local description=$2 + + if [ -f "$file" ]; then + log "Ejecutando: $description" + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f "$file" -q + log_success "$description" + else + log_warning "Archivo no encontrado: $file" + fi +} + +# Función para ejecutar SQL en orden +run_sql_dir() { + local dir=$1 + local description=$2 + + if [ -d "$dir" ]; then + log "Procesando directorio: $description" + for file in "$dir"/*.sql; do + if [ -f "$file" ]; then + local filename=$(basename "$file") + run_sql "$file" "$filename" + fi + done + else + log_warning "Directorio no encontrado: $dir" + fi +} + +# Verificar conexión +check_connection() { + log "Verificando conexión a PostgreSQL..." + if PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c '\q' 2>/dev/null; then + log_success "Conexión exitosa" + else + log_error "No se puede conectar a PostgreSQL" + exit 1 + fi +} + +# Drop database si existe +drop_database() { + log "Eliminando base de datos $DB_NAME si existe..." + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;" -q + log_success "Base de datos eliminada" +} + +# Crear database +create_database() { + log "Creando base de datos $DB_NAME..." + # Intentar con locale explícito, si falla usar default + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c "CREATE DATABASE $DB_NAME WITH ENCODING 'UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8' TEMPLATE=template0;" -q 2>/dev/null || \ + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c "CREATE DATABASE $DB_NAME WITH ENCODING 'UTF8';" -q 2>/dev/null || true + log_success "Base de datos creada" +} + +# Crear schemas +create_schemas() { + log "Creando schemas..." + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -q << EOF +-- Schemas principales +CREATE SCHEMA IF NOT EXISTS auth; +CREATE SCHEMA IF NOT EXISTS education; +CREATE SCHEMA IF NOT EXISTS trading; +CREATE SCHEMA IF NOT EXISTS investment; +CREATE SCHEMA IF NOT EXISTS financial; +CREATE SCHEMA IF NOT EXISTS ml; +CREATE SCHEMA IF NOT EXISTS llm; +CREATE SCHEMA IF NOT EXISTS audit; +CREATE SCHEMA IF NOT EXISTS market_data; + +-- Comentarios +COMMENT ON SCHEMA auth IS 'Autenticación y usuarios'; +COMMENT ON SCHEMA education IS 'Plataforma educativa'; +COMMENT ON SCHEMA trading IS 'Trading y paper engine'; +COMMENT ON SCHEMA investment IS 'Cuentas PAMM'; +COMMENT ON SCHEMA financial IS 'Pagos, suscripciones, wallets'; +COMMENT ON SCHEMA ml IS 'Machine Learning signals'; +COMMENT ON SCHEMA llm IS 'LLM Agent'; +COMMENT ON SCHEMA audit IS 'Auditoría y logs'; +COMMENT ON SCHEMA market_data IS 'Datos de mercado OHLCV (5m, 15m)'; + +-- Search path +ALTER DATABASE $DB_NAME SET search_path TO auth, public; +EOF + log_success "Schemas creados" +} + +# Cargar extensiones +load_extensions() { + log "Cargando extensiones..." + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -q << EOF +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; +CREATE EXTENSION IF NOT EXISTS "btree_gin"; +CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector para embeddings LLM +EOF + log_success "Extensiones cargadas" +} + +# Cargar DDL por schema +load_ddl() { + local schema=$1 + local schema_dir="$DDL_DIR/schemas/$schema" + + log "Cargando DDL para schema: $schema" + + # 0. Extensiones primero (si existen) + if [ -f "$schema_dir/00-extensions.sql" ]; then + run_sql "$schema_dir/00-extensions.sql" "$schema: Extensions" + fi + + # 1. Enums (busca 00-enums.sql o 01-enums.sql) + if [ -f "$schema_dir/00-enums.sql" ]; then + run_sql "$schema_dir/00-enums.sql" "$schema: ENUMs" + elif [ -f "$schema_dir/01-enums.sql" ]; then + run_sql "$schema_dir/01-enums.sql" "$schema: ENUMs" + fi + + # 2. Tablas en orden + run_sql_dir "$schema_dir/tables" "$schema: Tables" + + # 3. Funciones + run_sql_dir "$schema_dir/functions" "$schema: Functions" + + # 4. Triggers + run_sql_dir "$schema_dir/triggers" "$schema: Triggers" + + # 5. Views + run_sql_dir "$schema_dir/views" "$schema: Views" + + # 6. Indexes adicionales + if [ -f "$schema_dir/99-indexes.sql" ]; then + run_sql "$schema_dir/99-indexes.sql" "$schema: Indexes" + fi + + log_success "Schema $schema cargado" +} + +# Cargar todos los DDL +load_all_ddl() { + log "Cargando todos los DDL..." + + # Orden de carga (respeta dependencias) + local schemas=("auth" "education" "financial" "trading" "investment" "ml" "llm" "audit" "market_data") + + for schema in "${schemas[@]}"; do + if [ -d "$DDL_DIR/schemas/$schema" ]; then + load_ddl "$schema" + else + log_warning "Schema $schema no tiene DDL definido" + fi + done + + log_success "Todos los DDL cargados" +} + +# Cargar seeds +load_seeds() { + local env=${1:-prod} + local seeds_path="$SEEDS_DIR/$env" + + log "Cargando seeds ($env)..." + + if [ -d "$seeds_path" ]; then + for schema_dir in "$seeds_path"/*/; do + if [ -d "$schema_dir" ]; then + local schema=$(basename "$schema_dir") + run_sql_dir "$schema_dir" "Seeds: $schema" + fi + done + log_success "Seeds cargados" + else + log_warning "No hay seeds para ambiente: $env" + fi +} + +# Validar integridad +validate_database() { + log "Validando integridad de la base de datos..." + + # Contar tablas por schema + local result=$(PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -t -c " + SELECT schemaname, COUNT(*) + FROM pg_tables + WHERE schemaname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit', 'market_data') + GROUP BY schemaname + ORDER BY schemaname; + ") + + echo "" + echo "=== Resumen de Tablas por Schema ===" + echo "$result" + echo "" + + # Verificar FKs + local fk_count=$(PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -t -c " + SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_type = 'FOREIGN KEY'; + ") + log_success "Foreign Keys: $fk_count" + + log_success "Validación completada" +} + +# Main +main() { + echo "" + echo "==============================================" + echo " Trading Platform - Database Setup" + echo " Política de Carga Limpia (DDL-First)" + echo "==============================================" + echo "" + + local drop_first=false + local seeds_only=false + local seed_env="prod" + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --drop-first) + drop_first=true + shift + ;; + --seeds-only) + seeds_only=true + shift + ;; + --env) + seed_env=$2 + shift 2 + ;; + *) + log_error "Argumento desconocido: $1" + exit 1 + ;; + esac + done + + check_connection + + if [ "$seeds_only" = true ]; then + load_seeds "$seed_env" + exit 0 + fi + + if [ "$drop_first" = true ]; then + drop_database + fi + + create_database + create_schemas + load_extensions + load_all_ddl + load_seeds "$seed_env" + validate_database + + echo "" + log_success "Base de datos creada exitosamente!" + echo "" +} + +main "$@" diff --git a/scripts/drop-and-recreate-database.sh b/scripts/drop-and-recreate-database.sh new file mode 100755 index 0000000..0bcd458 --- /dev/null +++ b/scripts/drop-and-recreate-database.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# ============================================================================ +# DROP AND RECREATE DATABASE - Trading Platform +# ============================================================================ +# +# Alias para carga limpia completa. +# Wrapper de create-database.sh con --drop-first +# +# ============================================================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +exec "$SCRIPT_DIR/create-database.sh" --drop-first "$@" diff --git a/scripts/migrate_6_assets.sh b/scripts/migrate_6_assets.sh new file mode 100755 index 0000000..c5e776f --- /dev/null +++ b/scripts/migrate_6_assets.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# ============================================================================ +# MIGRATE 6 MAIN ASSETS FROM MYSQL TO POSTGRESQL +# ============================================================================ +# Migra los 6 activos principales con datos de 5m desde MySQL remoto +# a PostgreSQL local (trading_platform.market_data) +# +# Activos: XAUUSD, EURUSD, BTCUSD, GBPUSD, USDJPY, AUDUSD +# Temporalidad: 5 minutos +# Cobertura: ~10 años (2015-2025) +# ============================================================================ + +set -e + +# MySQL remoto (solo lectura) +MYSQL_HOST="72.60.226.4" +MYSQL_USER="root" +MYSQL_PASS="AfcItz2391,." +MYSQL_DB="db_trading_meta" + +# PostgreSQL local +PG_HOST="${PG_HOST:-localhost}" +PG_PORT="${PG_PORT:-5432}" +PG_USER="${PG_USER:-trading_user}" +PG_PASS="${PG_PASS:-trading_dev_2025}" +PG_DB="${PG_DB:-trading_platform}" + +# Colores +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log() { echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"; } +log_success() { echo -e "${GREEN}[✓]${NC} $1"; } +log_warning() { echo -e "${YELLOW}[!]${NC} $1"; } +log_error() { echo -e "${RED}[✗]${NC} $1"; } + +# Mapeo de tickers MySQL → PostgreSQL ID +declare -A TICKERS=( + ["C:XAUUSD"]="1" + ["C:EURUSD"]="2" + ["X:BTCUSD"]="3" + ["C:GBPUSD"]="4" + ["C:USDJPY"]="5" + ["C:AUDUSD"]="6" +) + +migrate_ticker() { + local mysql_sym=$1 + local pg_id=$2 + local tmpfile="/tmp/ticker_${pg_id}.tsv" + + log "Migrando $mysql_sym (id=$pg_id)..." + + # Export from MySQL + mysql -h $MYSQL_HOST -u $MYSQL_USER -p"$MYSQL_PASS" $MYSQL_DB \ + --batch --skip-column-names \ + -e "SELECT $pg_id, date_agg, open, high, low, close, volume, vwap, UNIX_TIMESTAMP(date_agg)*1000 + FROM tickers_agg_data + WHERE ticker='$mysql_sym' + ORDER BY date_agg" 2>/dev/null \ + > "$tmpfile" + + local count=$(wc -l < "$tmpfile") + log " Exportados $count registros de MySQL" + + # Truncate staging + PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ + -c "TRUNCATE market_data.ohlcv_5m_staging;" -q + + # Import to staging + PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ + -c "\COPY market_data.ohlcv_5m_staging FROM '$tmpfile' WITH (FORMAT text, DELIMITER E'\t')" -q + + # Upsert to main table + local inserted=$(PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -t -c " + INSERT INTO market_data.ohlcv_5m (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) + SELECT DISTINCT ON (ticker_id, timestamp) + ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch + FROM market_data.ohlcv_5m_staging + ON CONFLICT (ticker_id, timestamp) DO NOTHING; + SELECT COUNT(*) FROM market_data.ohlcv_5m WHERE ticker_id = $pg_id; + " | tr -d ' ') + + log_success "$mysql_sym: $inserted registros en PostgreSQL" + rm -f "$tmpfile" +} + +echo "" +echo "==============================================" +echo " Migración de 6 Activos Principales" +echo " MySQL → PostgreSQL (market_data)" +echo "==============================================" +echo "" + +# Verificar conexiones +log "Verificando conexión a MySQL..." +mysql -h $MYSQL_HOST -u $MYSQL_USER -p"$MYSQL_PASS" $MYSQL_DB -e "SELECT 1" >/dev/null 2>&1 || { + log_error "No se puede conectar a MySQL" + exit 1 +} +log_success "MySQL OK" + +log "Verificando conexión a PostgreSQL..." +PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -c "SELECT 1" >/dev/null 2>&1 || { + log_error "No se puede conectar a PostgreSQL" + exit 1 +} +log_success "PostgreSQL OK" + +echo "" +log "Iniciando migración de 6 activos..." +echo "" + +for mysql_sym in "C:XAUUSD" "C:EURUSD" "X:BTCUSD" "C:GBPUSD" "C:USDJPY" "C:AUDUSD"; do + pg_id=${TICKERS[$mysql_sym]} + migrate_ticker "$mysql_sym" "$pg_id" +done + +echo "" +log "Generando datos de 15 minutos..." +PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -c "SELECT * FROM market_data.aggregate_all_15m();" + +echo "" +echo "==============================================" +echo " Resumen Final" +echo "==============================================" +echo "" + +PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -c " +SELECT + t.symbol, + COUNT(o5.id) as rows_5m, + MIN(o5.timestamp) as desde, + MAX(o5.timestamp) as hasta, + EXTRACT(YEAR FROM AGE(MAX(o5.timestamp), MIN(o5.timestamp)))::INT as anos +FROM market_data.tickers t +LEFT JOIN market_data.ohlcv_5m o5 ON o5.ticker_id = t.id +GROUP BY t.symbol +ORDER BY t.symbol; +" + +echo "" +log_success "Migración completada" diff --git a/scripts/migrate_all_tickers.sh b/scripts/migrate_all_tickers.sh new file mode 100755 index 0000000..86f9269 --- /dev/null +++ b/scripts/migrate_all_tickers.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Migrate all tickers from MySQL to PostgreSQL +# Run in background: nohup ./migrate_all_tickers.sh > migration.log 2>&1 & + +set -e + +MYSQL_HOST="72.60.226.4" +MYSQL_USER="root" +MYSQL_PASS="AfcItz2391,." +MYSQL_DB="db_trading_meta" + +PG_USER="trading_user" +PG_PASS="trading_dev_2025" +PG_DB="trading_data" + +# Ticker mapping +declare -A TICKERS=( + ["C:EURUSD"]="2" + ["C:GBPUSD"]="3" + ["C:USDJPY"]="4" + ["C:USDCAD"]="5" + ["C:AUDUSD"]="6" + ["C:NZDUSD"]="7" + ["C:EURGBP"]="8" + ["C:EURAUD"]="9" + ["C:EURCHF"]="10" + ["C:GBPJPY"]="11" + ["C:GBPAUD"]="12" + ["C:GBPCAD"]="13" + ["C:GBPNZD"]="14" + ["C:AUDCAD"]="15" + ["C:AUDCHF"]="16" + ["C:AUDNZD"]="17" + ["C:XAUUSD"]="18" +) + +migrate_ticker() { + local mysql_sym=$1 + local pg_id=$2 + local tmpfile="/tmp/ticker_${pg_id}.tsv" + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting $mysql_sym (id=$pg_id)..." + + # Export from MySQL + mysql -h $MYSQL_HOST -u $MYSQL_USER -p"$MYSQL_PASS" $MYSQL_DB \ + --batch --skip-column-names \ + -e "SELECT $pg_id, date_agg, open, high, low, close, volume, vwap, ts FROM tickers_agg_data WHERE ticker='$mysql_sym' ORDER BY date_agg" 2>/dev/null \ + > "$tmpfile" + + local count=$(wc -l < "$tmpfile") + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Exported $count rows for $mysql_sym" + + # Truncate staging and import + PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB -c "TRUNCATE market_data.ohlcv_5m_staging;" >/dev/null + + PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB \ + -c "\COPY market_data.ohlcv_5m_staging FROM '$tmpfile' WITH (FORMAT text, DELIMITER E'\t')" >/dev/null + + # Upsert to main table + local inserted=$(PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB -t -c " + INSERT INTO market_data.ohlcv_5m (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) + SELECT DISTINCT ON (ticker_id, timestamp) + ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch + FROM market_data.ohlcv_5m_staging + ON CONFLICT (ticker_id, timestamp) DO NOTHING; + SELECT COUNT(*) FROM market_data.ohlcv_5m WHERE ticker_id = $pg_id; + ") + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Completed $mysql_sym: $inserted rows in PostgreSQL" + rm -f "$tmpfile" +} + +echo "============================================" +echo "Starting full migration at $(date)" +echo "============================================" + +for mysql_sym in "${!TICKERS[@]}"; do + pg_id=${TICKERS[$mysql_sym]} + migrate_ticker "$mysql_sym" "$pg_id" +done + +echo "" +echo "============================================" +echo "Migration complete at $(date)" +echo "============================================" + +# Final verification +echo "" +echo "Final row counts:" +PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB -c " +SELECT t.symbol, COUNT(*) as rows +FROM market_data.ohlcv_5m o +JOIN market_data.tickers t ON t.id = o.ticker_id +GROUP BY t.symbol +ORDER BY t.symbol; +" diff --git a/scripts/migrate_direct.sh b/scripts/migrate_direct.sh new file mode 100755 index 0000000..e135cd5 --- /dev/null +++ b/scripts/migrate_direct.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Direct MySQL to PostgreSQL migration script +# Uses mysqldump and psql for direct data transfer + +set -e + +# Configuration +MYSQL_HOST="72.60.226.4" +MYSQL_PORT="3306" +MYSQL_USER="root" +MYSQL_PASS="AfcItz2391,." +MYSQL_DB="db_trading_meta" + +PG_HOST="localhost" +PG_PORT="5432" +PG_USER="trading_user" +PG_PASS="trading_dev_2025" +PG_DB="trading_data" + +# Ticker mapping: MySQL symbol -> (PG symbol, ticker_id) +declare -A TICKER_MAP=( + ["X:BTCUSD"]="1" + ["C:EURUSD"]="2" + ["C:GBPUSD"]="3" + ["C:USDJPY"]="4" + ["C:USDCAD"]="5" + ["C:AUDUSD"]="6" + ["C:NZDUSD"]="7" + ["C:EURGBP"]="8" + ["C:EURAUD"]="9" + ["C:EURCHF"]="10" + ["C:GBPJPY"]="11" + ["C:GBPAUD"]="12" + ["C:GBPCAD"]="13" + ["C:GBPNZD"]="14" + ["C:AUDCAD"]="15" + ["C:AUDCHF"]="16" + ["C:AUDNZD"]="17" + ["C:XAUUSD"]="18" +) + +migrate_ticker() { + local mysql_ticker=$1 + local pg_ticker_id=$2 + + echo "Migrating $mysql_ticker (ticker_id=$pg_ticker_id)..." + + # Export from MySQL and import to PostgreSQL + mysql -h $MYSQL_HOST -P $MYSQL_PORT -u $MYSQL_USER -p"$MYSQL_PASS" $MYSQL_DB \ + -N -e "SELECT $pg_ticker_id, date_agg, open, high, low, close, volume, vwap, ts FROM tickers_agg_data WHERE ticker='$mysql_ticker' ORDER BY date_agg" \ + | PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ + -c "COPY market_data.ohlcv_5m (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) FROM STDIN WITH (FORMAT csv, DELIMITER E'\t')" + + echo "Completed $mysql_ticker" +} + +# Main +echo "Starting migration..." +echo "MySQL: $MYSQL_HOST:$MYSQL_PORT/$MYSQL_DB" +echo "PostgreSQL: $PG_HOST:$PG_PORT/$PG_DB" +echo "" + +# Migrate specific ticker or all +if [ -n "$1" ]; then + # Migrate specific ticker + mysql_ticker=$1 + pg_ticker_id=${TICKER_MAP[$mysql_ticker]} + if [ -z "$pg_ticker_id" ]; then + echo "Unknown ticker: $mysql_ticker" + exit 1 + fi + migrate_ticker "$mysql_ticker" "$pg_ticker_id" +else + # Migrate all tickers + for mysql_ticker in "${!TICKER_MAP[@]}"; do + pg_ticker_id=${TICKER_MAP[$mysql_ticker]} + migrate_ticker "$mysql_ticker" "$pg_ticker_id" + done +fi + +echo "" +echo "Migration complete!" + +# Verify counts +echo "" +echo "Verification:" +PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ + -c "SELECT t.symbol, COUNT(*) as rows FROM market_data.ohlcv_5m o JOIN market_data.tickers t ON t.id = o.ticker_id GROUP BY t.symbol ORDER BY t.symbol" diff --git a/scripts/migrate_mysql_to_postgres.py b/scripts/migrate_mysql_to_postgres.py new file mode 100644 index 0000000..0e584e2 --- /dev/null +++ b/scripts/migrate_mysql_to_postgres.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +""" +MySQL to PostgreSQL Migration Script +OrbiQuant IA Trading Platform + +Migrates market data from MySQL (remote) to PostgreSQL (local). + +Usage: + python migrate_mysql_to_postgres.py --full # Full migration + python migrate_mysql_to_postgres.py --incremental # Only new data + python migrate_mysql_to_postgres.py --ticker BTCUSD # Specific ticker +""" + +import os +import sys +import argparse +import logging +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +import pandas as pd +import numpy as np +from tqdm import tqdm + +# Database connections +import mysql.connector +import psycopg2 +from psycopg2.extras import execute_values + +# Configuration +MYSQL_CONFIG = { + 'host': '72.60.226.4', + 'port': 3306, + 'user': 'root', + 'password': 'AfcItz2391,.', + 'database': 'db_trading_meta' +} + +POSTGRES_CONFIG = { + 'host': 'localhost', + 'port': 5432, + 'user': 'orbiquant_user', + 'password': 'orbiquant_dev_2025', + 'database': 'orbiquant_trading' +} + +# Ticker mapping (MySQL symbol -> asset_type) +TICKER_MAPPING = { + 'X:BTCUSD': ('BTCUSD', 'crypto', 'BTC', 'USD'), + 'C:EURUSD': ('EURUSD', 'forex', 'EUR', 'USD'), + 'C:GBPUSD': ('GBPUSD', 'forex', 'GBP', 'USD'), + 'C:USDJPY': ('USDJPY', 'forex', 'USD', 'JPY'), + 'C:USDCAD': ('USDCAD', 'forex', 'USD', 'CAD'), + 'C:AUDUSD': ('AUDUSD', 'forex', 'AUD', 'USD'), + 'C:NZDUSD': ('NZDUSD', 'forex', 'NZD', 'USD'), + 'C:EURGBP': ('EURGBP', 'forex', 'EUR', 'GBP'), + 'C:EURAUD': ('EURAUD', 'forex', 'EUR', 'AUD'), + 'C:EURCHF': ('EURCHF', 'forex', 'EUR', 'CHF'), + 'C:GBPJPY': ('GBPJPY', 'forex', 'GBP', 'JPY'), + 'C:GBPAUD': ('GBPAUD', 'forex', 'GBP', 'AUD'), + 'C:GBPCAD': ('GBPCAD', 'forex', 'GBP', 'CAD'), + 'C:GBPNZD': ('GBPNZD', 'forex', 'GBP', 'NZD'), + 'C:AUDCAD': ('AUDCAD', 'forex', 'AUD', 'CAD'), + 'C:AUDCHF': ('AUDCHF', 'forex', 'AUD', 'CHF'), + 'C:AUDNZD': ('AUDNZD', 'forex', 'AUD', 'NZD'), + 'C:XAUUSD': ('XAUUSD', 'commodity', 'XAU', 'USD'), +} + +# Logging setup +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class MySQLConnection: + """MySQL connection manager.""" + + def __init__(self, config: Dict[str, Any]): + self.config = config + self.conn = None + + def __enter__(self): + self.conn = mysql.connector.connect(**self.config) + return self.conn + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.conn: + self.conn.close() + + +class PostgresConnection: + """PostgreSQL connection manager.""" + + def __init__(self, config: Dict[str, Any]): + self.config = config + self.conn = None + + def __enter__(self): + self.conn = psycopg2.connect(**self.config) + return self.conn + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.conn: + self.conn.close() + + +def create_database_if_not_exists(): + """Create PostgreSQL database if it doesn't exist.""" + config = POSTGRES_CONFIG.copy() + db_name = config.pop('database') + + try: + conn = psycopg2.connect(**config, database='postgres') + conn.autocommit = True + cursor = conn.cursor() + + # Check if database exists + cursor.execute( + "SELECT 1 FROM pg_catalog.pg_database WHERE datname = %s", + (db_name,) + ) + + if not cursor.fetchone(): + cursor.execute(f'CREATE DATABASE {db_name}') + logger.info(f"Created database: {db_name}") + else: + logger.info(f"Database {db_name} already exists") + + cursor.close() + conn.close() + + except Exception as e: + logger.error(f"Error creating database: {e}") + raise + + +def run_migrations(): + """Run SQL migrations.""" + migration_file = os.path.join( + os.path.dirname(__file__), + '..', 'migrations', '001_create_market_data_schema.sql' + ) + + with PostgresConnection(POSTGRES_CONFIG) as conn: + cursor = conn.cursor() + + # Read and execute migration + with open(migration_file, 'r') as f: + sql = f.read() + + try: + cursor.execute(sql) + conn.commit() + logger.info("Migrations completed successfully") + except Exception as e: + conn.rollback() + logger.warning(f"Migration error (may already exist): {e}") + + cursor.close() + + +def insert_tickers(): + """Insert ticker master data.""" + with PostgresConnection(POSTGRES_CONFIG) as conn: + cursor = conn.cursor() + + for mysql_symbol, (symbol, asset_type, base, quote) in TICKER_MAPPING.items(): + try: + cursor.execute(""" + INSERT INTO market_data.tickers + (symbol, name, asset_type, base_currency, quote_currency) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (symbol) DO NOTHING + """, (symbol, f"{base}/{quote}", asset_type, base, quote)) + except Exception as e: + logger.warning(f"Error inserting ticker {symbol}: {e}") + + conn.commit() + cursor.close() + logger.info(f"Inserted {len(TICKER_MAPPING)} tickers") + + +def get_ticker_id_map() -> Dict[str, int]: + """Get mapping of symbol to ticker_id.""" + with PostgresConnection(POSTGRES_CONFIG) as conn: + cursor = conn.cursor() + cursor.execute("SELECT symbol, id FROM market_data.tickers") + result = {row[0]: row[1] for row in cursor.fetchall()} + cursor.close() + return result + + +def get_last_timestamp(ticker_id: int) -> Optional[datetime]: + """Get the last timestamp for a ticker in PostgreSQL.""" + with PostgresConnection(POSTGRES_CONFIG) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT MAX(timestamp) FROM market_data.ohlcv_5m + WHERE ticker_id = %s + """, (ticker_id,)) + result = cursor.fetchone()[0] + cursor.close() + return result + + +def migrate_ohlcv_data( + mysql_ticker: str, + pg_ticker_id: int, + start_date: Optional[datetime] = None, + batch_size: int = 50000 +): + """Migrate OHLCV data for a specific ticker.""" + + # Build query + query = f""" + SELECT + date_agg, + open, + high, + low, + close, + volume, + vwap, + ts + FROM tickers_agg_data + WHERE ticker = '{mysql_ticker}' + """ + + if start_date: + query += f" AND date_agg > '{start_date.strftime('%Y-%m-%d %H:%M:%S')}'" + + query += " ORDER BY date_agg" + + with MySQLConnection(MYSQL_CONFIG) as mysql_conn: + mysql_cursor = mysql_conn.cursor() + mysql_cursor.execute(query) + + total_rows = 0 + batch = [] + + with PostgresConnection(POSTGRES_CONFIG) as pg_conn: + pg_cursor = pg_conn.cursor() + + for row in tqdm(mysql_cursor, desc=f"Migrating {mysql_ticker}"): + date_agg, open_p, high, low, close, volume, vwap, ts = row + + batch.append(( + pg_ticker_id, + date_agg, + float(open_p), + float(high), + float(low), + float(close), + float(volume), + float(vwap) if vwap else None, + int(ts) if ts else None + )) + + if len(batch) >= batch_size: + insert_batch(pg_cursor, batch) + pg_conn.commit() + total_rows += len(batch) + batch = [] + + # Insert remaining + if batch: + insert_batch(pg_cursor, batch) + pg_conn.commit() + total_rows += len(batch) + + pg_cursor.close() + + mysql_cursor.close() + + logger.info(f"Migrated {total_rows} rows for {mysql_ticker}") + return total_rows + + +def insert_batch(cursor, batch: List[tuple]): + """Insert batch of OHLCV data.""" + query = """ + INSERT INTO market_data.ohlcv_5m + (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) + VALUES %s + ON CONFLICT (ticker_id, timestamp) DO NOTHING + """ + execute_values(cursor, query, batch) + + +def migrate_all(incremental: bool = False, tickers: Optional[List[str]] = None): + """Migrate all data from MySQL to PostgreSQL.""" + + logger.info("Starting migration...") + + # Step 1: Create database and run migrations + create_database_if_not_exists() + run_migrations() + + # Step 2: Insert tickers + insert_tickers() + + # Step 3: Get ticker ID mapping + ticker_id_map = get_ticker_id_map() + logger.info(f"Ticker ID map: {ticker_id_map}") + + # Step 4: Migrate OHLCV data + total_migrated = 0 + + for mysql_symbol, (pg_symbol, _, _, _) in TICKER_MAPPING.items(): + if tickers and pg_symbol not in tickers: + continue + + if pg_symbol not in ticker_id_map: + logger.warning(f"Ticker {pg_symbol} not found in PostgreSQL") + continue + + ticker_id = ticker_id_map[pg_symbol] + + # Check for incremental + start_date = None + if incremental: + start_date = get_last_timestamp(ticker_id) + if start_date: + logger.info(f"Incremental from {start_date} for {pg_symbol}") + + rows = migrate_ohlcv_data(mysql_symbol, ticker_id, start_date) + total_migrated += rows + + logger.info(f"Migration complete. Total rows: {total_migrated}") + + +def verify_migration(): + """Verify migration by comparing row counts.""" + logger.info("Verifying migration...") + + with MySQLConnection(MYSQL_CONFIG) as mysql_conn: + mysql_cursor = mysql_conn.cursor() + + with PostgresConnection(POSTGRES_CONFIG) as pg_conn: + pg_cursor = pg_conn.cursor() + + for mysql_symbol, (pg_symbol, _, _, _) in TICKER_MAPPING.items(): + # MySQL count + mysql_cursor.execute( + f"SELECT COUNT(*) FROM tickers_agg_data WHERE ticker = '{mysql_symbol}'" + ) + mysql_count = mysql_cursor.fetchone()[0] + + # PostgreSQL count + pg_cursor.execute(""" + SELECT COUNT(*) FROM market_data.ohlcv_5m o + JOIN market_data.tickers t ON t.id = o.ticker_id + WHERE t.symbol = %s + """, (pg_symbol,)) + pg_count = pg_cursor.fetchone()[0] + + status = "✅" if mysql_count == pg_count else "❌" + logger.info(f"{status} {pg_symbol}: MySQL={mysql_count}, PostgreSQL={pg_count}") + + pg_cursor.close() + mysql_cursor.close() + + +def main(): + parser = argparse.ArgumentParser(description='MySQL to PostgreSQL Migration') + parser.add_argument('--full', action='store_true', help='Full migration') + parser.add_argument('--incremental', action='store_true', help='Incremental migration') + parser.add_argument('--ticker', type=str, help='Specific ticker to migrate') + parser.add_argument('--verify', action='store_true', help='Verify migration') + parser.add_argument('--schema-only', action='store_true', help='Only create schema') + + args = parser.parse_args() + + if args.schema_only: + create_database_if_not_exists() + run_migrations() + insert_tickers() + logger.info("Schema created successfully") + return + + if args.verify: + verify_migration() + return + + tickers = [args.ticker] if args.ticker else None + incremental = args.incremental + + migrate_all(incremental=incremental, tickers=tickers) + + +if __name__ == '__main__': + main() diff --git a/scripts/validate-ddl.sh b/scripts/validate-ddl.sh new file mode 100755 index 0000000..13ca877 --- /dev/null +++ b/scripts/validate-ddl.sh @@ -0,0 +1,305 @@ +#!/bin/bash + +# ============================================================================ +# OrbiQuant IA - Trading Platform +# Script: validate-ddl.sh +# Description: Validates and executes all DDL scripts in correct order +# ============================================================================ + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Load environment variables +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DB_DIR="$(dirname "$SCRIPT_DIR")" +DDL_DIR="$DB_DIR/ddl" +ENV_FILE="$DB_DIR/.env" + +if [ -f "$ENV_FILE" ]; then + source "$ENV_FILE" +else + echo -e "${RED}Error: .env file not found at $ENV_FILE${NC}" + exit 1 +fi + +# Database connection parameters +export PGHOST="${DB_HOST:-localhost}" +export PGPORT="${DB_PORT:-5432}" +export PGDATABASE="${DB_NAME:-trading_data}" +export PGUSER="${DB_USER:-trading_user}" +export PGPASSWORD="${DB_PASSWORD}" + +# Output file for combined SQL +MASTER_SQL="/tmp/trading_platform_ddl_master.sql" +ERROR_LOG="/tmp/trading_platform_ddl_errors.log" + +echo -e "${BLUE}============================================${NC}" +echo -e "${BLUE}OrbiQuant Trading Platform - DDL Validation${NC}" +echo -e "${BLUE}============================================${NC}" +echo "" + +# Check PostgreSQL connection +echo -e "${YELLOW}[1/5] Checking PostgreSQL connection...${NC}" +if pg_isready -h "$PGHOST" -p "$PGPORT" > /dev/null 2>&1; then + echo -e "${GREEN}✓ PostgreSQL is ready${NC}" +else + echo -e "${RED}✗ PostgreSQL is not ready${NC}" + exit 1 +fi + +# Check if database exists, create if not +echo -e "${YELLOW}[2/5] Checking database existence...${NC}" +DB_EXISTS=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$PGDATABASE'") +if [ "$DB_EXISTS" != "1" ]; then + echo -e "${YELLOW}Database $PGDATABASE does not exist. Creating...${NC}" + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -c "CREATE DATABASE $PGDATABASE;" + echo -e "${GREEN}✓ Database created${NC}" +else + echo -e "${GREEN}✓ Database exists${NC}" +fi + +# Generate master SQL file +echo -e "${YELLOW}[3/5] Generating master SQL script...${NC}" +cat > "$MASTER_SQL" << 'EOF' +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Master DDL Script - Auto-generated +-- ============================================================================ + +\set ON_ERROR_STOP on +\timing on + +-- Drop existing schemas (CASCADE will drop all dependent objects) +DO $$ +BEGIN + DROP SCHEMA IF EXISTS audit CASCADE; + DROP SCHEMA IF EXISTS llm CASCADE; + DROP SCHEMA IF EXISTS ml CASCADE; + DROP SCHEMA IF EXISTS financial CASCADE; + DROP SCHEMA IF EXISTS investment CASCADE; + DROP SCHEMA IF EXISTS trading CASCADE; + DROP SCHEMA IF EXISTS education CASCADE; + DROP SCHEMA IF EXISTS auth CASCADE; + RAISE NOTICE 'All schemas dropped successfully'; +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'Error dropping schemas: %', SQLERRM; +END $$; + +EOF + +# Add extensions +echo -e " ${BLUE}→${NC} Adding extensions..." +if [ -f "$DDL_DIR/00-extensions.sql" ]; then + echo "-- Extensions" >> "$MASTER_SQL" + cat "$DDL_DIR/00-extensions.sql" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" +else + echo -e "${RED}Warning: 00-extensions.sql not found${NC}" +fi + +# Add schemas +echo -e " ${BLUE}→${NC} Adding schemas..." +if [ -f "$DDL_DIR/01-schemas.sql" ]; then + echo "-- Schemas" >> "$MASTER_SQL" + cat "$DDL_DIR/01-schemas.sql" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" +else + echo -e "${RED}Warning: 01-schemas.sql not found${NC}" +fi + +# Schema order (important for foreign key dependencies) +SCHEMAS=("auth" "education" "trading" "investment" "financial" "ml" "llm" "audit") + +# Process each schema +for schema in "${SCHEMAS[@]}"; do + echo -e " ${BLUE}→${NC} Processing schema: $schema" + + SCHEMA_DIR="$DDL_DIR/schemas/$schema" + + if [ ! -d "$SCHEMA_DIR" ]; then + echo -e "${YELLOW} Warning: Schema directory not found: $SCHEMA_DIR${NC}" + continue + fi + + echo "" >> "$MASTER_SQL" + echo "-- ============================================================================" >> "$MASTER_SQL" + echo "-- Schema: $schema" >> "$MASTER_SQL" + echo "-- ============================================================================" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" + + # Add enums first (try both 00-enums.sql and 01-enums.sql) + for enum_file in "$SCHEMA_DIR/00-enums.sql" "$SCHEMA_DIR/01-enums.sql"; do + if [ -f "$enum_file" ]; then + echo " ${BLUE}→${NC} Adding enums from $(basename "$enum_file")" + echo "-- Enums for $schema" >> "$MASTER_SQL" + cat "$enum_file" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" + break + fi + done + + # Add tables in numeric order + if [ -d "$SCHEMA_DIR/tables" ]; then + echo " ${BLUE}→${NC} Adding tables..." + for table_file in $(ls "$SCHEMA_DIR/tables/"*.sql 2>/dev/null | sort -V); do + echo " - $(basename "$table_file")" + echo "-- Table: $(basename "$table_file" .sql)" >> "$MASTER_SQL" + cat "$table_file" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" + done + fi + + # Add functions + if [ -d "$SCHEMA_DIR/functions" ]; then + echo " ${BLUE}→${NC} Adding functions..." + for func_file in $(ls "$SCHEMA_DIR/functions/"*.sql 2>/dev/null | sort -V); do + echo " - $(basename "$func_file")" + echo "-- Function: $(basename "$func_file" .sql)" >> "$MASTER_SQL" + cat "$func_file" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" + done + fi +done + +# Add summary query at the end +cat >> "$MASTER_SQL" << 'EOF' + +-- ============================================================================ +-- Summary: Count created objects +-- ============================================================================ + +\echo '' +\echo '============================================' +\echo 'Database Objects Summary' +\echo '============================================' + +SELECT + schemaname as schema, + COUNT(*) as table_count +FROM pg_tables +WHERE schemaname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +GROUP BY schemaname +ORDER BY schemaname; + +\echo '' +\echo 'Functions by Schema:' + +SELECT + n.nspname as schema, + COUNT(*) as function_count +FROM pg_proc p +JOIN pg_namespace n ON p.pronamespace = n.oid +WHERE n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +GROUP BY n.nspname +ORDER BY n.nspname; + +\echo '' +\echo 'Enums by Schema:' + +SELECT + n.nspname as schema, + COUNT(*) as enum_count +FROM pg_type t +JOIN pg_namespace n ON t.typnamespace = n.oid +WHERE t.typtype = 'e' + AND n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +GROUP BY n.nspname +ORDER BY n.nspname; + +\echo '' +\echo '============================================' +\echo 'DDL Validation Complete' +\echo '============================================' + +EOF + +echo -e "${GREEN}✓ Master SQL script generated: $MASTER_SQL${NC}" + +# Execute the master SQL file +echo -e "${YELLOW}[4/5] Executing DDL scripts...${NC}" +echo -e "${BLUE}Database: $PGDATABASE${NC}" +echo -e "${BLUE}User: $PGUSER${NC}" +echo -e "${BLUE}Host: $PGHOST:$PGPORT${NC}" +echo "" + +if psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -f "$MASTER_SQL" 2> "$ERROR_LOG"; then + echo "" + echo -e "${GREEN}✓ DDL execution completed successfully${NC}" + + # Show any warnings (if error log has content but exit code was 0) + if [ -s "$ERROR_LOG" ]; then + echo -e "${YELLOW}Warnings/Notices:${NC}" + cat "$ERROR_LOG" + fi +else + echo "" + echo -e "${RED}✗ DDL execution failed${NC}" + echo -e "${RED}Error details:${NC}" + cat "$ERROR_LOG" + + echo "" + echo -e "${YELLOW}Analyzing errors...${NC}" + + # Try to identify which file caused the error + if grep -q "syntax error" "$ERROR_LOG"; then + echo -e "${RED}Syntax errors found:${NC}" + grep -A 2 "syntax error" "$ERROR_LOG" + fi + + if grep -q "does not exist" "$ERROR_LOG"; then + echo -e "${RED}Missing dependencies (tables/functions):${NC}" + grep "does not exist" "$ERROR_LOG" + fi + + if grep -q "violates foreign key" "$ERROR_LOG"; then + echo -e "${RED}Foreign key violations:${NC}" + grep "violates foreign key" "$ERROR_LOG" + fi + + exit 1 +fi + +# Final validation +echo -e "${YELLOW}[5/5] Final validation...${NC}" + +# Count created objects +TOTAL_TABLES=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc " + SELECT COUNT(*) + FROM pg_tables + WHERE schemaname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +") + +TOTAL_FUNCTIONS=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc " + SELECT COUNT(*) + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +") + +TOTAL_ENUMS=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc " + SELECT COUNT(*) + FROM pg_type t + JOIN pg_namespace n ON t.typnamespace = n.oid + WHERE t.typtype = 'e' + AND n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +") + +echo "" +echo -e "${GREEN}============================================${NC}" +echo -e "${GREEN}Validation Summary${NC}" +echo -e "${GREEN}============================================${NC}" +echo -e "Total Tables: ${GREEN}$TOTAL_TABLES${NC}" +echo -e "Total Functions: ${GREEN}$TOTAL_FUNCTIONS${NC}" +echo -e "Total Enums: ${GREEN}$TOTAL_ENUMS${NC}" +echo "" +echo -e "${GREEN}✓ All DDL scripts validated successfully${NC}" +echo "" +echo -e "Master SQL file: ${BLUE}$MASTER_SQL${NC}" +echo -e "Error log: ${BLUE}$ERROR_LOG${NC}" diff --git a/seeds/prod/education/01-education-courses.sql b/seeds/prod/education/01-education-courses.sql new file mode 100644 index 0000000..30b0bce --- /dev/null +++ b/seeds/prod/education/01-education-courses.sql @@ -0,0 +1,186 @@ +-- ===================================================== +-- SEED DATA - Schema Education (Producción) +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Basado en: Curso_Basico.md (ICT/IPDA) +-- ===================================================== + +-- ===================================================== +-- 0. SYSTEM USER (para instructor) +-- ===================================================== + +-- Crear usuario sistema/admin para ser instructor +INSERT INTO auth.users (id, email, email_verified, email_verified_at, password_hash, status, role) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'academy@orbiquant.com', + true, + NOW(), + '$2b$10$dummyhashdummyhashdummyhashdummyhashdummyhash', -- placeholder + 'active', + 'admin' +) ON CONFLICT (email) DO NOTHING; + +-- ===================================================== +-- 1. CATEGORIES +-- ===================================================== + +INSERT INTO education.categories (id, name, slug, description, color, display_order, is_active) VALUES +('10000001-0001-0000-0000-000000000001', 'Trading Básico', 'trading-basico', 'Fundamentos del trading y mercados financieros', '#3B82F6', 1, true), +('10000001-0002-0000-0000-000000000002', 'Análisis Técnico ICT', 'analisis-tecnico-ict', 'Metodología ICT y Smart Money Concepts', '#10B981', 2, true), +('10000001-0003-0000-0000-000000000003', 'Estrategias Avanzadas', 'estrategias-avanzadas', 'Estrategias de trading profesional', '#8B5CF6', 3, true), +('10000001-0004-0000-0000-000000000004', 'Trading con IA', 'trading-ia', 'Trading asistido por inteligencia artificial', '#F59E0B', 4, true), +('10000001-0005-0000-0000-000000000005', 'Gestión de Riesgo', 'gestion-riesgo', 'Control de riesgo y gestión de capital', '#EF4444', 5, true) +ON CONFLICT (slug) DO NOTHING; + +-- ===================================================== +-- 2. CURSO: Trading ICT - De Cero a Profesional +-- ===================================================== + +INSERT INTO education.courses ( + id, title, slug, short_description, full_description, category_id, + difficulty_level, instructor_id, instructor_name, is_free, price_usd, xp_reward, + status, published_at, total_modules, total_lessons, thumbnail_url, + learning_objectives, prerequisites +) VALUES ( + '20000001-0001-0000-0000-000000000001', + 'Trading ICT - De Cero a Profesional', + 'trading-ict-completo', + 'Domina la metodología ICT/IPDA desde los fundamentos hasta estrategias avanzadas', + 'Curso completo de trading basado en la metodología ICT (Inner Circle Trader) y el concepto IPDA (Interbank Price Delivery Algorithmic). Aprenderás a leer el mercado como lo hacen las instituciones, identificar zonas de liquidez, order blocks, y ejecutar trades de alta probabilidad.', + '10000001-0002-0000-0000-000000000002', + 'beginner', + '00000000-0000-0000-0000-000000000001', + 'OrbiQuant Academy', + true, + NULL, + 1000, + 'published', + NOW(), + 7, + 28, + '/images/courses/ict-course-thumbnail.jpg', + ARRAY['Entender la estructura del mercado según IPDA', 'Identificar Higher Highs, Lower Lows y cambios de tendencia', 'Detectar Order Blocks y Fair Value Gaps', 'Operar en las Kill Zones con alta probabilidad', 'Aplicar gestión de riesgo profesional'], + ARRAY['Acceso a una plataforma de trading (MT4/MT5 o TradingView)', 'Conocimientos básicos de informática', 'Disciplina y compromiso de estudio'] +) ON CONFLICT (slug) DO NOTHING; + +-- ===================================================== +-- 3. MODULES (7 módulos basados en Curso_Basico.md) +-- ===================================================== + +INSERT INTO education.modules (id, course_id, title, description, display_order, duration_minutes) VALUES +('30000001-0001-0000-0000-000000000001', '20000001-0001-0000-0000-000000000001', 'Módulo 1: Elementos Básicos', 'Mercado de derivados, OTC, tipos de gráficos y velas japonesas', 1, 45), +('30000001-0002-0000-0000-000000000002', '20000001-0001-0000-0000-000000000001', 'Módulo 2: Factores y Herramientas', 'Tipos de broker, regulaciones, plataformas MT4/MT5 y TradingView', 2, 60), +('30000001-0003-0000-0000-000000000003', '20000001-0001-0000-0000-000000000001', 'Módulo 3: Introducción al IPDA', 'Interbank Price Delivery Algorithmic, estructura y narrativa del precio', 3, 90), +('30000001-0004-0000-0000-000000000004', '20000001-0001-0000-0000-000000000001', 'Módulo 4: Tendencia y Key Levels', 'Identificación de tendencias, Key Levels, Power of Three y Matrices IPDA', 4, 75), +('30000001-0005-0000-0000-000000000005', '20000001-0001-0000-0000-000000000001', 'Módulo 5: Kill Zones y Momentum', 'Horarios de las principales bolsas y sesiones de trading', 5, 60), +('30000001-0006-0000-0000-000000000006', '20000001-0001-0000-0000-000000000001', 'Módulo 6: Order Blocks', 'Tipos de Order Blocks: OB, OBQ, KOB, GOB, POB', 6, 90), +('30000001-0007-0000-0000-000000000007', '20000001-0001-0000-0000-000000000001', 'Módulo 7: Imbalances', 'FVG, iFVG, BPR, VFVG, NWOG, NDOG', 7, 75) +ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- 4. LESSONS +-- ===================================================== + +-- Módulo 1: Elementos Básicos +INSERT INTO education.lessons (id, module_id, title, description, content_type, article_content, display_order, is_preview, xp_reward) VALUES +('40000001-0101-0000-0000-000000000001', '30000001-0001-0000-0000-000000000001', 'Mercado de Derivados y OTC', 'Qué son los derivados, futuros, opciones, CFDs y el mercado OTC', 'article', '

Derivados

El mercado de derivados es donde se negocian instrumentos financieros cuyo valor depende (o deriva) del precio de otro activo.

Ejemplos de derivados:

  • Futuros
  • Opciones
  • CFDs (Contratos por diferencia)
  • Swaps

OTC (Over The Counter)

OTC significa "fuera de mercado". Son operaciones que no pasan por una bolsa formal.

', 1, true, 10), +('40000001-0102-0000-0000-000000000002', '30000001-0001-0000-0000-000000000001', 'Tipos de Gráficos', 'Gráfico de líneas, barras y velas japonesas', 'article', '

1. Gráfico de Líneas

Muestra solo el precio de cierre de cada periodo.

2. Gráfico de Barras

Muestra OHLC (Open, High, Low, Close).

3. Gráfico de Velas Japonesas

El más usado en trading profesional.

', 2, true, 10), +('40000001-0103-0000-0000-000000000003', '30000001-0001-0000-0000-000000000001', 'Velas Japonesas en Profundidad', 'Anatomía de las velas, patrones básicos', 'article', '

Anatomía de una Vela

  • Cuerpo: Distancia entre apertura y cierre
  • Mecha superior: Precio máximo
  • Mecha inferior: Precio mínimo
', 3, false, 15), +('40000001-0104-0000-0000-000000000004', '30000001-0001-0000-0000-000000000001', 'Timeframes y Análisis Multi-temporal', 'Selección de temporalidades según estilo de trading', 'article', '

Timeframes

  • M1, M5, M15: Scalping
  • H1, H4: Day trading
  • D1, W1: Swing trading
', 4, false, 10); + +-- Módulo 2: Factores y Herramientas +INSERT INTO education.lessons (id, module_id, title, description, content_type, article_content, display_order, is_preview, xp_reward) VALUES +('40000001-0201-0000-0000-000000000001', '30000001-0002-0000-0000-000000000002', 'Tipos de Broker', 'ECN, STP, Market Maker y modelos híbridos', 'article', '

ECN

Te conecta directamente con la red de participantes.

STP

Pasa órdenes a proveedores de liquidez.

Market Maker

Crea mercado interno.

', 1, true, 10), +('40000001-0202-0000-0000-000000000002', '30000001-0002-0000-0000-000000000002', 'Regulaciones Globales', 'FCA, ASIC, CySEC, CFTC y su importancia', 'article', '

Principales Reguladores

  • FCA (Reino Unido)
  • ASIC (Australia)
  • CySEC (Chipre/UE)
  • CFTC/NFA (EE.UU.)
', 2, false, 10), +('40000001-0203-0000-0000-000000000003', '30000001-0002-0000-0000-000000000002', 'MetaTrader 4 y 5', 'Configuración y uso de MT4/MT5', 'article', '

MetaTrader

La plataforma más popular para trading de forex y CFDs.

', 3, false, 15), +('40000001-0204-0000-0000-000000000004', '30000001-0002-0000-0000-000000000002', 'TradingView', 'Plataforma de análisis técnico avanzado', 'article', '

TradingView

La mejor plataforma para análisis técnico con comunidad activa.

', 4, false, 10); + +-- Módulo 3: Introducción al IPDA +INSERT INTO education.lessons (id, module_id, title, description, content_type, article_content, display_order, is_preview, xp_reward) VALUES +('40000001-0301-0000-0000-000000000001', '30000001-0003-0000-0000-000000000003', 'Qué es IPDA', 'Interbank Price Delivery Algorithmic explicado', 'article', '

IPDA

IPDA significa Interbank Price Delivery Algorithmic. Es el concepto de que el precio se mueve de manera algorítmica para entregar liquidez institucional.

', 1, true, 15), +('40000001-0302-0000-0000-000000000002', '30000001-0003-0000-0000-000000000003', 'Estructura del Mercado ICT', 'BOS, CHoCH, Market Structure Shift', 'article', '

Conceptos Clave

  • BOS: Break of Structure
  • CHoCH: Change of Character
  • MSS: Market Structure Shift
', 2, false, 20), +('40000001-0303-0000-0000-000000000003', '30000001-0003-0000-0000-000000000003', 'Narrativa del Precio', 'Cómo interpretar la historia del precio', 'article', '

La Narrativa

El precio cuenta una historia. Cada vela es un capítulo.

', 3, false, 15), +('40000001-0304-0000-0000-000000000004', '30000001-0003-0000-0000-000000000003', 'Liquidez Institucional', 'Buy-side y Sell-side liquidity', 'article', '

Liquidez

  • Buy-side: Liquidez por encima de máximos
  • Sell-side: Liquidez por debajo de mínimos
', 4, false, 20); + +-- Módulo 4: Tendencia y Key Levels +INSERT INTO education.lessons (id, module_id, title, description, content_type, article_content, display_order, is_preview, xp_reward) VALUES +('40000001-0401-0000-0000-000000000001', '30000001-0004-0000-0000-000000000004', 'Higher Highs y Lower Lows', 'Identificación de tendencias', 'article', '

Estructura de Tendencia

  • Alcista: Higher Highs + Higher Lows
  • Bajista: Lower Highs + Lower Lows
', 1, true, 15), +('40000001-0402-0000-0000-000000000002', '30000001-0004-0000-0000-000000000004', 'Key Levels', 'Soporte, resistencia y niveles clave', 'article', '

Key Levels

Niveles donde el precio ha reaccionado históricamente.

', 2, false, 15), +('40000001-0403-0000-0000-000000000003', '30000001-0004-0000-0000-000000000004', 'Power of Three (AMD)', 'Accumulation, Manipulation, Distribution', 'article', '

Power of Three

  • Accumulation: Rango lateral
  • Manipulation: Falsa ruptura
  • Distribution: Movimiento real
', 3, false, 25), +('40000001-0404-0000-0000-000000000004', '30000001-0004-0000-0000-000000000004', 'Matrices IPDA', 'Premium y Discount zones', 'article', '

Matrices

  • Premium: Por encima del 50% del rango
  • Discount: Por debajo del 50% del rango
', 4, false, 20); + +-- Módulo 5: Kill Zones +INSERT INTO education.lessons (id, module_id, title, description, content_type, article_content, display_order, is_preview, xp_reward) VALUES +('40000001-0501-0000-0000-000000000001', '30000001-0005-0000-0000-000000000005', 'Sesiones del Mercado', 'Asia, Londres, Nueva York', 'article', '

Sesiones

  • Asia: 00:00 - 08:00 UTC
  • Londres: 08:00 - 16:00 UTC
  • Nueva York: 13:00 - 21:00 UTC
', 1, true, 15), +('40000001-0502-0000-0000-000000000002', '30000001-0005-0000-0000-000000000005', 'Kill Zones', 'Horarios de alta probabilidad', 'article', '

Kill Zones

Momentos del día donde hay mayor probabilidad de movimientos direccionales.

', 2, false, 20), +('40000001-0503-0000-0000-000000000003', '30000001-0005-0000-0000-000000000005', 'ICT Killzone Times', 'London Open, NY Open, etc.', 'article', '

Horarios Clave

  • London Open: 02:00 - 05:00 EST
  • NY Open: 07:00 - 10:00 EST
  • London Close: 10:00 - 12:00 EST
', 3, false, 15), +('40000001-0504-0000-0000-000000000004', '30000001-0005-0000-0000-000000000005', 'Momentum y Volatilidad', 'Medición y aprovechamiento', 'article', '

Momentum

La fuerza del movimiento del precio.

', 4, false, 15); + +-- Módulo 6: Order Blocks +INSERT INTO education.lessons (id, module_id, title, description, content_type, article_content, display_order, is_preview, xp_reward) VALUES +('40000001-0601-0000-0000-000000000001', '30000001-0006-0000-0000-000000000006', 'Qué es un Order Block', 'Definición y características', 'article', '

Order Block

La última vela opuesta antes de un movimiento impulsivo. Representa donde las instituciones colocaron órdenes.

', 1, true, 20), +('40000001-0602-0000-0000-000000000002', '30000001-0006-0000-0000-000000000006', 'Tipos de Order Blocks', 'OB, OBQ, KOB, GOB, POB', 'article', '

Tipos

  • OB: Order Block regular
  • OBQ: Order Block Qualitativo
  • KOB: Key Order Block
  • GOB: Golden Order Block
  • POB: Propulsion Order Block
', 2, false, 25), +('40000001-0603-0000-0000-000000000003', '30000001-0006-0000-0000-000000000006', 'Validación de Order Blocks', 'Cómo confirmar un OB válido', 'article', '

Validación

  • Debe estar en zona de descuento/premium
  • Debe tener desplazamiento (displacement)
  • Debe respetar estructura de mercado
', 3, false, 20), +('40000001-0604-0000-0000-000000000004', '30000001-0006-0000-0000-000000000006', 'Trading con Order Blocks', 'Entradas y gestión de trades', 'article', '

Estrategia

  1. Identificar OB en zona premium/discount
  2. Esperar retroceso al OB
  3. Confirmar con estructura menor
  4. Entrada con SL detrás del OB
', 4, false, 25); + +-- Módulo 7: Imbalances +INSERT INTO education.lessons (id, module_id, title, description, content_type, article_content, display_order, is_preview, xp_reward) VALUES +('40000001-0701-0000-0000-000000000001', '30000001-0007-0000-0000-000000000007', 'Fair Value Gaps', 'Qué son los FVG y cómo identificarlos', 'article', '

FVG

Un Fair Value Gap es un desequilibrio de precio donde hay un "gap" entre la mecha de una vela y la mecha de otra, dejando un espacio vacío.

', 1, true, 20), +('40000001-0702-0000-0000-000000000002', '30000001-0007-0000-0000-000000000007', 'Tipos de FVG', 'iFVG, BISI, SIBI', 'article', '

Tipos

  • iFVG: Inverted FVG
  • BISI: Buy-side Imbalance, Sell-side Inefficiency
  • SIBI: Sell-side Imbalance, Buy-side Inefficiency
', 2, false, 20), +('40000001-0703-0000-0000-000000000003', '30000001-0007-0000-0000-000000000007', 'BPR y VFVG', 'Balanced Price Range y Void FVG', 'article', '

Conceptos Avanzados

  • BPR: Cuando un FVG alcista y bajista se superponen
  • VFVG: FVG que ha sido parcialmente rellenado
', 3, false, 20), +('40000001-0704-0000-0000-000000000004', '30000001-0007-0000-0000-000000000007', 'NWOG y NDOG', 'New Week/Day Opening Gap', 'article', '

Opening Gaps

  • NWOG: Gap entre cierre del viernes y apertura del domingo
  • NDOG: Gap entre cierre y apertura diaria
', 4, false, 15); + +-- ===================================================== +-- 5. QUIZZES +-- ===================================================== + +INSERT INTO education.quizzes (id, lesson_id, title, description, passing_score_percentage, time_limit_minutes, max_attempts, xp_reward) VALUES +('50000001-0001-0000-0000-000000000001', '40000001-0103-0000-0000-000000000003', 'Quiz: Velas Japonesas', 'Evalúa tu conocimiento sobre velas japonesas', 70, 10, 3, 50), +('50000001-0002-0000-0000-000000000002', '40000001-0302-0000-0000-000000000002', 'Quiz: Estructura de Mercado', 'Evalúa tu conocimiento sobre BOS, CHoCH y MSS', 70, 15, 3, 75), +('50000001-0003-0000-0000-000000000003', '40000001-0403-0000-0000-000000000003', 'Quiz: Power of Three', 'Evalúa tu conocimiento sobre AMD', 70, 10, 3, 75), +('50000001-0004-0000-0000-000000000004', '40000001-0602-0000-0000-000000000002', 'Quiz: Order Blocks', 'Evalúa tu conocimiento sobre tipos de OB', 70, 15, 3, 100), +('50000001-0005-0000-0000-000000000005', '40000001-0701-0000-0000-000000000001', 'Quiz: Fair Value Gaps', 'Evalúa tu conocimiento sobre FVG e imbalances', 70, 10, 3, 75); + +-- ===================================================== +-- 6. QUIZ QUESTIONS +-- ===================================================== + +-- Quiz 1: Velas Japonesas +INSERT INTO education.quiz_questions (id, quiz_id, question_text, question_type, options, explanation, points, display_order) VALUES +('60000001-0101-0000-0000-000000000001', '50000001-0001-0000-0000-000000000001', '¿Qué representa el cuerpo de una vela japonesa?', 'multiple_choice', '[{"id":"a","text":"La distancia entre apertura y cierre","isCorrect":true},{"id":"b","text":"El precio máximo","isCorrect":false},{"id":"c","text":"El precio mínimo","isCorrect":false},{"id":"d","text":"El volumen","isCorrect":false}]', 'El cuerpo representa la diferencia entre el precio de apertura y cierre del periodo.', 10, 1), +('60000001-0102-0000-0000-000000000002', '50000001-0001-0000-0000-000000000001', '¿Qué color tiene una vela alcista?', 'multiple_choice', '[{"id":"a","text":"Verde o Blanco","isCorrect":true},{"id":"b","text":"Rojo o Negro","isCorrect":false},{"id":"c","text":"Azul","isCorrect":false},{"id":"d","text":"Amarillo","isCorrect":false}]', 'Las velas alcistas (cierre > apertura) son verdes o blancas.', 10, 2), +('60000001-0103-0000-0000-000000000003', '50000001-0001-0000-0000-000000000001', '¿Qué muestra la mecha superior de una vela?', 'multiple_choice', '[{"id":"a","text":"El precio máximo alcanzado","isCorrect":true},{"id":"b","text":"El precio mínimo","isCorrect":false},{"id":"c","text":"El volumen","isCorrect":false},{"id":"d","text":"El spread","isCorrect":false}]', 'La mecha superior indica el precio máximo que alcanzó durante ese periodo.', 10, 3); + +-- Quiz 2: Estructura de Mercado +INSERT INTO education.quiz_questions (id, quiz_id, question_text, question_type, options, explanation, points, display_order) VALUES +('60000002-0201-0000-0000-000000000001', '50000001-0002-0000-0000-000000000002', '¿Qué significa BOS en ICT?', 'multiple_choice', '[{"id":"a","text":"Break of Structure","isCorrect":true},{"id":"b","text":"Buy Order Signal","isCorrect":false},{"id":"c","text":"Bearish Order Setup","isCorrect":false},{"id":"d","text":"Bullish Option Strategy","isCorrect":false}]', 'BOS significa Break of Structure - ruptura de la estructura de mercado.', 10, 1), +('60000002-0202-0000-0000-000000000002', '50000001-0002-0000-0000-000000000002', '¿Qué indica un CHoCH?', 'multiple_choice', '[{"id":"a","text":"Cambio de carácter del mercado","isCorrect":true},{"id":"b","text":"Continuación de tendencia","isCorrect":false},{"id":"c","text":"Consolidación","isCorrect":false},{"id":"d","text":"Corrección menor","isCorrect":false}]', 'CHoCH (Change of Character) indica un posible cambio de tendencia.', 10, 2), +('60000002-0203-0000-0000-000000000003', '50000001-0002-0000-0000-000000000002', '¿Cómo se identifica una tendencia alcista?', 'multiple_choice', '[{"id":"a","text":"Higher Highs y Higher Lows","isCorrect":true},{"id":"b","text":"Lower Highs y Lower Lows","isCorrect":false},{"id":"c","text":"Igual Highs e igual Lows","isCorrect":false},{"id":"d","text":"Solo Higher Highs","isCorrect":false}]', 'Una tendencia alcista se confirma con máximos y mínimos cada vez más altos.', 10, 3); + +-- Quiz 3: Power of Three +INSERT INTO education.quiz_questions (id, quiz_id, question_text, question_type, options, explanation, points, display_order) VALUES +('60000003-0301-0000-0000-000000000001', '50000001-0003-0000-0000-000000000003', '¿Cuáles son las 3 fases del Power of Three?', 'multiple_choice', '[{"id":"a","text":"Accumulation, Manipulation, Distribution","isCorrect":true},{"id":"b","text":"Advance, Move, Drop","isCorrect":false},{"id":"c","text":"Analysis, Market, Delivery","isCorrect":false},{"id":"d","text":"Accumulate, Move, Distribute","isCorrect":false}]', 'AMD: Accumulation (rango), Manipulation (falsa ruptura), Distribution (movimiento real).', 15, 1), +('60000003-0302-0000-0000-000000000002', '50000001-0003-0000-0000-000000000003', '¿Qué ocurre en la fase de Manipulation?', 'multiple_choice', '[{"id":"a","text":"Una falsa ruptura para atrapar traders","isCorrect":true},{"id":"b","text":"El movimiento principal del precio","isCorrect":false},{"id":"c","text":"Consolidación lateral","isCorrect":false},{"id":"d","text":"Cierre de posiciones","isCorrect":false}]', 'La manipulación es diseñada para tomar liquidez y atrapar traders en el lado equivocado.', 15, 2); + +-- Quiz 4: Order Blocks +INSERT INTO education.quiz_questions (id, quiz_id, question_text, question_type, options, explanation, points, display_order) VALUES +('60000004-0401-0000-0000-000000000001', '50000001-0004-0000-0000-000000000004', '¿Qué es un Order Block?', 'multiple_choice', '[{"id":"a","text":"La última vela opuesta antes de un impulso","isCorrect":true},{"id":"b","text":"Un nivel de soporte","isCorrect":false},{"id":"c","text":"Una zona de consolidación","isCorrect":false},{"id":"d","text":"Un patrón de velas","isCorrect":false}]', 'Un OB es la última vela de color opuesto antes de un movimiento impulsivo, donde las instituciones colocaron órdenes.', 15, 1), +('60000004-0402-0000-0000-000000000002', '50000001-0004-0000-0000-000000000004', '¿Qué significa OBQ?', 'multiple_choice', '[{"id":"a","text":"Order Block Qualitativo","isCorrect":true},{"id":"b","text":"Order Block Quick","isCorrect":false},{"id":"c","text":"Order Block Quantity","isCorrect":false},{"id":"d","text":"Order Block Quote","isCorrect":false}]', 'OBQ es un Order Block que cumple criterios adicionales de calidad.', 10, 2), +('60000004-0403-0000-0000-000000000003', '50000001-0004-0000-0000-000000000004', '¿Cuál es el OB más fuerte?', 'multiple_choice', '[{"id":"a","text":"Key Order Block (KOB)","isCorrect":true},{"id":"b","text":"Regular OB","isCorrect":false},{"id":"c","text":"Propulsion OB","isCorrect":false},{"id":"d","text":"Void OB","isCorrect":false}]', 'El KOB es el OB que causó un BOS y es considerado el más significativo.', 15, 3); + +-- Quiz 5: Fair Value Gaps +INSERT INTO education.quiz_questions (id, quiz_id, question_text, question_type, options, explanation, points, display_order) VALUES +('60000005-0501-0000-0000-000000000001', '50000001-0005-0000-0000-000000000005', '¿Qué es un Fair Value Gap?', 'multiple_choice', '[{"id":"a","text":"Un desequilibrio de precio entre mechas de velas","isCorrect":true},{"id":"b","text":"Un gap de apertura","isCorrect":false},{"id":"c","text":"Una zona de consolidación","isCorrect":false},{"id":"d","text":"Un nivel de Fibonacci","isCorrect":false}]', 'Un FVG es un área donde hay un "hueco" entre la mecha de una vela y otra, indicando un desequilibrio.', 15, 1), +('60000005-0502-0000-0000-000000000002', '50000001-0005-0000-0000-000000000005', '¿Qué significa NWOG?', 'multiple_choice', '[{"id":"a","text":"New Week Opening Gap","isCorrect":true},{"id":"b","text":"New Wave Order Gap","isCorrect":false},{"id":"c","text":"Next Week Open Gap","isCorrect":false},{"id":"d","text":"Neutral Week Order Gap","isCorrect":false}]', 'NWOG es el gap entre el cierre del viernes y la apertura del domingo.', 10, 2), +('60000005-0503-0000-0000-000000000003', '50000001-0005-0000-0000-000000000005', '¿Qué es un BPR?', 'multiple_choice', '[{"id":"a","text":"Balanced Price Range","isCorrect":true},{"id":"b","text":"Bullish Price Range","isCorrect":false},{"id":"c","text":"Bearish Price Reversal","isCorrect":false},{"id":"d","text":"Buy Price Region","isCorrect":false}]', 'BPR ocurre cuando un FVG alcista y bajista se superponen, creando una zona de equilibrio.', 15, 3); + +-- ===================================================== +-- VERIFICACIÓN +-- ===================================================== +SELECT 'Categories:' as info, COUNT(*) as count FROM education.categories; +SELECT 'Courses:' as info, COUNT(*) as count FROM education.courses; +SELECT 'Modules:' as info, COUNT(*) as count FROM education.modules; +SELECT 'Lessons:' as info, COUNT(*) as count FROM education.lessons; +SELECT 'Quizzes:' as info, COUNT(*) as count FROM education.quizzes; +SELECT 'Questions:' as info, COUNT(*) as count FROM education.quiz_questions;