[TRADING-DB] feat: Add trading schema DDL

This commit is contained in:
rckrdmrd 2026-01-16 20:00:40 -06:00
parent cd6590ec25
commit 29dfb82d6c
8 changed files with 2582 additions and 0 deletions

View File

@ -0,0 +1,229 @@
-- ============================================================================
-- SCHEMA: trading
-- TABLE: symbols
-- DESCRIPTION: Simbolos/instrumentos habilitados para trading por tenant
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS trading;
-- Grant usage
GRANT USAGE ON SCHEMA trading TO trading_app;
GRANT USAGE ON SCHEMA trading TO trading_readonly;
-- ============================================================================
-- ENUMS COMPARTIDOS DEL SCHEMA TRADING
-- ============================================================================
-- Enum para direccion de trade
DO $$ BEGIN
CREATE TYPE trading.trade_direction AS ENUM (
'long', -- Compra / Buy
'short' -- Venta / Sell
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de posicion
DO $$ BEGIN
CREATE TYPE trading.position_status AS ENUM (
'pending', -- Pendiente de ejecucion
'open', -- Posicion abierta
'closed', -- Cerrada manualmente
'stopped', -- Cerrada por stop loss
'target_hit', -- Cerrada por take profit
'expired', -- Expirada
'cancelled' -- Cancelada antes de ejecutar
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para tipo de orden
DO $$ BEGIN
CREATE TYPE trading.order_type AS ENUM (
'market', -- Orden de mercado
'limit', -- Orden limitada
'stop', -- Stop order
'stop_limit' -- Stop limit order
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para timeframe
DO $$ BEGIN
CREATE TYPE trading.timeframe AS ENUM (
'M1', 'M5', 'M15', 'M30', -- Minutos
'H1', 'H4', -- Horas
'D1', 'W1', 'MN' -- Dia, Semana, Mes
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- ============================================================================
-- TABLA: symbols
-- Simbolos habilitados por tenant (copia de market_data.tickers con config tenant)
-- ============================================================================
CREATE TABLE IF NOT EXISTS trading.symbols (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
ticker_id UUID NOT NULL REFERENCES market_data.tickers(id),
-- Datos del simbolo (denormalizados para performance)
symbol VARCHAR(20) NOT NULL,
name VARCHAR(200),
type market_data.instrument_type,
-- Configuracion por tenant
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
is_tradeable BOOLEAN NOT NULL DEFAULT TRUE,
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
-- Limites especificos del tenant
min_lot_size DECIMAL(10, 4),
max_lot_size DECIMAL(10, 4),
max_position_size DECIMAL(15, 2), -- Tamaño maximo de posicion en USD
max_daily_volume DECIMAL(15, 2), -- Volumen diario maximo
-- Spread markup (si el tenant agrega spread)
spread_markup_pips DECIMAL(10, 4) DEFAULT 0,
-- Comisiones del tenant
commission_per_lot DECIMAL(10, 4) DEFAULT 0,
commission_percent DECIMAL(5, 4) DEFAULT 0,
-- Margen requerido (override del default)
margin_required_percent DECIMAL(5, 2),
max_leverage INTEGER,
-- Trading hours override
custom_trading_hours JSONB,
-- Categorias/tags del tenant
categories VARCHAR(50)[],
tags VARCHAR(50)[],
display_order INTEGER DEFAULT 999,
-- Estadisticas del tenant
trade_count INTEGER NOT NULL DEFAULT 0,
total_volume DECIMAL(20, 4) NOT NULL DEFAULT 0,
last_trade_at TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT symbols_unique_per_tenant UNIQUE (tenant_id, ticker_id),
CONSTRAINT symbols_unique_symbol_per_tenant UNIQUE (tenant_id, symbol)
);
COMMENT ON TABLE trading.symbols IS
'Simbolos habilitados para trading por tenant con configuracion personalizada';
COMMENT ON COLUMN trading.symbols.spread_markup_pips IS
'Pips adicionales de spread que el tenant agrega al precio del broker';
-- Indices
CREATE INDEX IF NOT EXISTS idx_symbols_tenant
ON trading.symbols(tenant_id);
CREATE INDEX IF NOT EXISTS idx_symbols_ticker
ON trading.symbols(ticker_id);
CREATE INDEX IF NOT EXISTS idx_symbols_symbol
ON trading.symbols(tenant_id, symbol);
CREATE INDEX IF NOT EXISTS idx_symbols_enabled
ON trading.symbols(tenant_id, is_enabled, is_tradeable)
WHERE is_enabled = TRUE AND is_tradeable = TRUE;
CREATE INDEX IF NOT EXISTS idx_symbols_type
ON trading.symbols(tenant_id, type);
-- GIN index para categorias
CREATE INDEX IF NOT EXISTS idx_symbols_categories_gin
ON trading.symbols USING GIN (categories);
-- Trigger para updated_at
CREATE OR REPLACE FUNCTION trading.update_trading_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS symbol_updated_at ON trading.symbols;
CREATE TRIGGER symbol_updated_at
BEFORE UPDATE ON trading.symbols
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Trigger para sincronizar datos del ticker
CREATE OR REPLACE FUNCTION trading.sync_symbol_from_ticker()
RETURNS TRIGGER AS $$
BEGIN
SELECT symbol, name, type
INTO NEW.symbol, NEW.name, NEW.type
FROM market_data.tickers
WHERE id = NEW.ticker_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS symbol_sync ON trading.symbols;
CREATE TRIGGER symbol_sync
BEFORE INSERT OR UPDATE OF ticker_id ON trading.symbols
FOR EACH ROW
EXECUTE FUNCTION trading.sync_symbol_from_ticker();
-- Vista de simbolos activos
CREATE OR REPLACE VIEW trading.v_active_symbols AS
SELECT
s.id,
s.tenant_id,
s.ticker_id,
s.symbol,
s.name,
s.type,
t.pip_size,
t.typical_spread_pips,
s.spread_markup_pips,
(COALESCE(t.typical_spread_pips, 0) + COALESCE(s.spread_markup_pips, 0)) AS total_spread_pips,
t.current_bid,
t.current_ask,
s.min_lot_size,
s.max_lot_size,
COALESCE(s.max_leverage, t.max_leverage) AS max_leverage,
s.display_order
FROM trading.symbols s
JOIN market_data.tickers t ON s.ticker_id = t.id
WHERE s.is_enabled = TRUE
AND s.is_tradeable = TRUE
AND t.status = 'active'
ORDER BY s.display_order, s.symbol;
-- RLS Policy para multi-tenancy
ALTER TABLE trading.symbols ENABLE ROW LEVEL SECURITY;
CREATE POLICY symbols_tenant_isolation ON trading.symbols
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON trading.symbols TO trading_app;
GRANT SELECT ON trading.symbols TO trading_readonly;
GRANT SELECT ON trading.v_active_symbols TO trading_app;

View File

@ -0,0 +1,249 @@
-- ============================================================================
-- SCHEMA: trading
-- TABLE: strategies
-- DESCRIPTION: Estrategias de trading definidas
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para tipo de estrategia
DO $$ BEGIN
CREATE TYPE trading.strategy_type AS ENUM (
'manual', -- Estrategia manual (usuario decide)
'semi_auto', -- Semi-automatica (senales con confirmacion)
'fully_auto', -- Totalmente automatizada
'copy_trading' -- Copy trading de otro usuario/bot
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para metodologia de trading
DO $$ BEGIN
CREATE TYPE trading.trading_methodology AS ENUM (
'trend_following', -- Seguimiento de tendencia
'mean_reversion', -- Reversion a la media
'breakout', -- Rupturas
'scalping', -- Scalping
'swing', -- Swing trading
'position', -- Position trading
'arbitrage', -- Arbitraje
'news_based', -- Basado en noticias
'smc_ict', -- Smart Money Concepts / ICT
'harmonic', -- Patrones armonicos
'custom' -- Personalizada
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Estrategias
CREATE TABLE IF NOT EXISTS trading.strategies (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
created_by UUID REFERENCES users.users(id),
-- Informacion basica
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
short_description VARCHAR(500),
-- Clasificacion
type trading.strategy_type NOT NULL DEFAULT 'manual',
methodology trading.trading_methodology NOT NULL DEFAULT 'custom',
-- Configuracion de riesgo
risk_config JSONB NOT NULL DEFAULT '{
"risk_per_trade_percent": 2,
"max_daily_loss_percent": 5,
"max_drawdown_percent": 20,
"max_concurrent_trades": 3,
"risk_reward_min": 1.5
}'::JSONB,
-- Configuracion de entrada
entry_config JSONB NOT NULL DEFAULT '{
"order_type": "market",
"slippage_pips": 2,
"max_spread_pips": 5,
"entry_confirmation_required": false
}'::JSONB,
-- Configuracion de salida
exit_config JSONB NOT NULL DEFAULT '{
"use_trailing_stop": false,
"trailing_stop_pips": 20,
"partial_close_enabled": false,
"partial_close_percent": 50,
"breakeven_enabled": false,
"breakeven_pips": 10
}'::JSONB,
-- Simbolos y timeframes
allowed_symbols VARCHAR(20)[], -- NULL = todos
allowed_timeframes trading.timeframe[],
primary_timeframe trading.timeframe DEFAULT 'H1',
-- Horarios de trading
trading_hours JSONB DEFAULT '{
"enabled": false,
"sessions": ["london", "new_york"],
"avoid_news": true,
"news_buffer_minutes": 30
}'::JSONB,
-- Indicadores utilizados
indicators_config JSONB DEFAULT '[]'::JSONB,
-- Reglas de entrada (para estrategias automaticas)
entry_rules JSONB DEFAULT '[]'::JSONB,
-- Reglas de salida
exit_rules JSONB DEFAULT '[]'::JSONB,
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_public BOOLEAN NOT NULL DEFAULT FALSE, -- Visible para otros usuarios
is_premium BOOLEAN NOT NULL DEFAULT FALSE, -- Requiere suscripcion
-- Estadisticas
total_trades INTEGER NOT NULL DEFAULT 0,
winning_trades INTEGER NOT NULL DEFAULT 0,
losing_trades INTEGER NOT NULL DEFAULT 0,
win_rate DECIMAL(5, 2) DEFAULT 0,
profit_factor DECIMAL(10, 4) DEFAULT 0,
average_profit DECIMAL(15, 4) DEFAULT 0,
average_loss DECIMAL(15, 4) DEFAULT 0,
max_drawdown DECIMAL(15, 4) DEFAULT 0,
total_profit_loss DECIMAL(15, 4) DEFAULT 0,
-- Backtesting
last_backtest_at TIMESTAMPTZ,
backtest_results JSONB,
-- Version control
version INTEGER NOT NULL DEFAULT 1,
previous_version_id UUID,
-- Metadata
tags VARCHAR(50)[],
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT strategies_unique_slug UNIQUE (tenant_id, slug),
CONSTRAINT strategies_win_rate_check CHECK (win_rate BETWEEN 0 AND 100)
);
COMMENT ON TABLE trading.strategies IS
'Estrategias de trading con configuracion de riesgo, entrada y salida';
COMMENT ON COLUMN trading.strategies.risk_config IS
'Configuracion de gestion de riesgo de la estrategia';
-- Indices
CREATE INDEX IF NOT EXISTS idx_strategies_tenant
ON trading.strategies(tenant_id);
CREATE INDEX IF NOT EXISTS idx_strategies_creator
ON trading.strategies(created_by);
CREATE INDEX IF NOT EXISTS idx_strategies_type
ON trading.strategies(type);
CREATE INDEX IF NOT EXISTS idx_strategies_methodology
ON trading.strategies(methodology);
CREATE INDEX IF NOT EXISTS idx_strategies_active
ON trading.strategies(tenant_id, is_active)
WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_strategies_public
ON trading.strategies(is_public, win_rate DESC)
WHERE is_public = TRUE;
-- GIN index para tags
CREATE INDEX IF NOT EXISTS idx_strategies_tags_gin
ON trading.strategies USING GIN (tags);
-- Trigger para updated_at
DROP TRIGGER IF EXISTS strategy_updated_at ON trading.strategies;
CREATE TRIGGER strategy_updated_at
BEFORE UPDATE ON trading.strategies
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Funcion para calcular estadisticas de estrategia
CREATE OR REPLACE FUNCTION trading.recalculate_strategy_stats(p_strategy_id UUID)
RETURNS VOID AS $$
DECLARE
v_stats RECORD;
BEGIN
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE profit_loss > 0) AS wins,
COUNT(*) FILTER (WHERE profit_loss < 0) AS losses,
COALESCE(AVG(profit_loss) FILTER (WHERE profit_loss > 0), 0) AS avg_profit,
COALESCE(AVG(ABS(profit_loss)) FILTER (WHERE profit_loss < 0), 0) AS avg_loss,
COALESCE(SUM(profit_loss), 0) AS total_pnl
INTO v_stats
FROM trading.positions
WHERE strategy_id = p_strategy_id
AND status IN ('closed', 'stopped', 'target_hit');
UPDATE trading.strategies
SET total_trades = v_stats.total,
winning_trades = v_stats.wins,
losing_trades = v_stats.losses,
win_rate = CASE WHEN v_stats.total > 0
THEN (v_stats.wins::DECIMAL / v_stats.total * 100)
ELSE 0 END,
profit_factor = CASE WHEN v_stats.avg_loss > 0
THEN v_stats.avg_profit / v_stats.avg_loss
ELSE 0 END,
average_profit = v_stats.avg_profit,
average_loss = v_stats.avg_loss,
total_profit_loss = v_stats.total_pnl
WHERE id = p_strategy_id;
END;
$$ LANGUAGE plpgsql;
-- Vista de estrategias publicas
CREATE OR REPLACE VIEW trading.v_public_strategies AS
SELECT
id,
name,
description,
type,
methodology,
primary_timeframe,
total_trades,
win_rate,
profit_factor,
max_drawdown,
is_premium,
created_at
FROM trading.strategies
WHERE is_public = TRUE
AND is_active = TRUE
AND total_trades >= 10 -- Minimo de trades para mostrar
ORDER BY win_rate DESC, profit_factor DESC;
-- RLS Policy para multi-tenancy
ALTER TABLE trading.strategies ENABLE ROW LEVEL SECURITY;
CREATE POLICY strategies_tenant_isolation ON trading.strategies
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON trading.strategies TO trading_app;
GRANT SELECT ON trading.strategies TO trading_readonly;
GRANT SELECT ON trading.v_public_strategies TO trading_app;
GRANT EXECUTE ON FUNCTION trading.recalculate_strategy_stats TO trading_app;

View File

@ -0,0 +1,328 @@
-- ============================================================================
-- SCHEMA: trading
-- TABLE: bots
-- DESCRIPTION: Agentes de trading automatizado (Atlas, Orion, Nova)
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para estado del bot
DO $$ BEGIN
CREATE TYPE trading.bot_status AS ENUM (
'inactive', -- Inactivo
'starting', -- Iniciando
'running', -- Ejecutando
'paused', -- Pausado temporalmente
'stopping', -- Deteniendo
'error', -- Error
'maintenance' -- En mantenimiento
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para nivel de riesgo del bot
DO $$ BEGIN
CREATE TYPE trading.bot_risk_level AS ENUM (
'conservative', -- Conservador (bajo riesgo)
'moderate', -- Moderado
'aggressive', -- Agresivo
'ultra_aggressive' -- Ultra agresivo
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Bots de Trading
CREATE TABLE IF NOT EXISTS trading.bots (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
strategy_id UUID REFERENCES trading.strategies(id),
-- Identificacion
code VARCHAR(20) NOT NULL, -- 'ATLAS', 'ORION', 'NOVA'
name VARCHAR(100) NOT NULL,
description TEXT,
avatar_url TEXT,
-- Tipo y version
version VARCHAR(20) NOT NULL DEFAULT '1.0.0',
risk_level trading.bot_risk_level NOT NULL DEFAULT 'moderate',
-- Estado
status trading.bot_status NOT NULL DEFAULT 'inactive',
status_message TEXT,
last_status_change TIMESTAMPTZ DEFAULT NOW(),
-- Conexion MT4/MT5
mt_account_id VARCHAR(50),
mt_server VARCHAR(100),
mt_connected BOOLEAN NOT NULL DEFAULT FALSE,
mt_last_heartbeat TIMESTAMPTZ,
-- Configuracion de trading
trading_config JSONB NOT NULL DEFAULT '{
"enabled": false,
"symbols": ["EURUSD", "GBPUSD", "XAUUSD"],
"timeframes": ["H1", "H4"],
"max_trades_per_day": 5,
"max_concurrent_positions": 3,
"trading_hours": {
"enabled": false,
"start": "08:00",
"end": "20:00",
"timezone": "America/New_York"
}
}'::JSONB,
-- Configuracion de riesgo
risk_config JSONB NOT NULL DEFAULT '{
"capital_allocation": 10000,
"risk_per_trade_percent": 1,
"max_daily_loss_percent": 3,
"max_drawdown_percent": 10,
"lot_size_mode": "fixed",
"fixed_lot_size": 0.1,
"use_compounding": false
}'::JSONB,
-- Configuracion de senales
signal_config JSONB NOT NULL DEFAULT '{
"min_confidence": 70,
"require_confirmation": true,
"confirmation_timeframe": "M15",
"filter_by_trend": true,
"filter_by_volatility": true
}'::JSONB,
-- Capital y rendimiento
initial_capital DECIMAL(15, 2) NOT NULL DEFAULT 0,
current_capital DECIMAL(15, 2) NOT NULL DEFAULT 0,
allocated_capital DECIMAL(15, 2) NOT NULL DEFAULT 0,
available_margin DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Estadisticas de rendimiento
total_trades INTEGER NOT NULL DEFAULT 0,
winning_trades INTEGER NOT NULL DEFAULT 0,
losing_trades INTEGER NOT NULL DEFAULT 0,
win_rate DECIMAL(5, 2) DEFAULT 0,
profit_factor DECIMAL(10, 4) DEFAULT 0,
sharpe_ratio DECIMAL(10, 4) DEFAULT 0,
max_drawdown DECIMAL(15, 4) DEFAULT 0,
max_drawdown_percent DECIMAL(5, 2) DEFAULT 0,
-- P&L
total_profit DECIMAL(15, 4) NOT NULL DEFAULT 0,
total_loss DECIMAL(15, 4) NOT NULL DEFAULT 0,
net_profit DECIMAL(15, 4) NOT NULL DEFAULT 0,
daily_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0,
weekly_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0,
monthly_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0,
-- Rendimiento porcentual
total_return_percent DECIMAL(10, 4) DEFAULT 0,
daily_return_percent DECIMAL(10, 4) DEFAULT 0,
weekly_return_percent DECIMAL(10, 4) DEFAULT 0,
monthly_return_percent DECIMAL(10, 4) DEFAULT 0,
-- Posiciones actuales
open_positions_count INTEGER NOT NULL DEFAULT 0,
open_positions_value DECIMAL(15, 4) NOT NULL DEFAULT 0,
unrealized_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0,
-- Ultima actividad
last_trade_at TIMESTAMPTZ,
last_signal_at TIMESTAMPTZ,
last_error_at TIMESTAMPTZ,
last_error_message TEXT,
-- Subscribers (usuarios que invierten en este bot)
subscriber_count INTEGER NOT NULL DEFAULT 0,
total_aum DECIMAL(15, 2) NOT NULL DEFAULT 0, -- Assets Under Management
-- Disponibilidad
is_public BOOLEAN NOT NULL DEFAULT FALSE,
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
min_investment DECIMAL(15, 2) DEFAULT 100,
max_investment DECIMAL(15, 2),
-- Comisiones
performance_fee_percent DECIMAL(5, 2) DEFAULT 20, -- Fee sobre ganancias
management_fee_percent DECIMAL(5, 2) DEFAULT 0, -- Fee anual sobre AUM
-- Metadata
tags VARCHAR(50)[],
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
started_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT bots_unique_code UNIQUE (tenant_id, code),
CONSTRAINT bots_capital_check CHECK (current_capital >= 0),
CONSTRAINT bots_win_rate_check CHECK (win_rate BETWEEN 0 AND 100)
);
COMMENT ON TABLE trading.bots IS
'Agentes de trading automatizado con configuracion y estadisticas';
COMMENT ON COLUMN trading.bots.code IS
'Codigo unico del bot: ATLAS (conservador), ORION (moderado), NOVA (agresivo)';
-- Indices
CREATE INDEX IF NOT EXISTS idx_bots_tenant
ON trading.bots(tenant_id);
CREATE INDEX IF NOT EXISTS idx_bots_code
ON trading.bots(tenant_id, code);
CREATE INDEX IF NOT EXISTS idx_bots_status
ON trading.bots(status);
CREATE INDEX IF NOT EXISTS idx_bots_running
ON trading.bots(tenant_id, status)
WHERE status = 'running';
CREATE INDEX IF NOT EXISTS idx_bots_public
ON trading.bots(is_public, net_profit DESC)
WHERE is_public = TRUE;
CREATE INDEX IF NOT EXISTS idx_bots_performance
ON trading.bots(tenant_id, total_return_percent DESC);
-- Trigger para updated_at
DROP TRIGGER IF EXISTS bot_updated_at ON trading.bots;
CREATE TRIGGER bot_updated_at
BEFORE UPDATE ON trading.bots
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Trigger para registrar cambios de estado
CREATE OR REPLACE FUNCTION trading.log_bot_status_change()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status IS DISTINCT FROM OLD.status THEN
NEW.last_status_change := NOW();
IF NEW.status = 'running' AND OLD.status != 'running' THEN
NEW.started_at := NOW();
ELSIF NEW.status IN ('inactive', 'stopped') AND OLD.status = 'running' THEN
NEW.stopped_at := NOW();
ELSIF NEW.status = 'error' THEN
NEW.last_error_at := NOW();
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS bot_status_change ON trading.bots;
CREATE TRIGGER bot_status_change
BEFORE UPDATE OF status ON trading.bots
FOR EACH ROW
EXECUTE FUNCTION trading.log_bot_status_change();
-- Funcion para actualizar estadisticas del bot
CREATE OR REPLACE FUNCTION trading.recalculate_bot_stats(p_bot_id UUID)
RETURNS VOID AS $$
DECLARE
v_stats RECORD;
BEGIN
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE profit_loss > 0) AS wins,
COUNT(*) FILTER (WHERE profit_loss < 0) AS losses,
COALESCE(SUM(profit_loss) FILTER (WHERE profit_loss > 0), 0) AS total_profit,
COALESCE(SUM(ABS(profit_loss)) FILTER (WHERE profit_loss < 0), 0) AS total_loss,
COALESCE(SUM(profit_loss), 0) AS net_pnl
INTO v_stats
FROM trading.positions
WHERE bot_id = p_bot_id
AND status IN ('closed', 'stopped', 'target_hit');
UPDATE trading.bots
SET total_trades = v_stats.total,
winning_trades = v_stats.wins,
losing_trades = v_stats.losses,
win_rate = CASE WHEN v_stats.total > 0
THEN (v_stats.wins::DECIMAL / v_stats.total * 100)
ELSE 0 END,
profit_factor = CASE WHEN v_stats.total_loss > 0
THEN v_stats.total_profit / v_stats.total_loss
ELSE 0 END,
total_profit = v_stats.total_profit,
total_loss = v_stats.total_loss,
net_profit = v_stats.net_pnl,
total_return_percent = CASE WHEN initial_capital > 0
THEN (v_stats.net_pnl / initial_capital * 100)
ELSE 0 END
WHERE id = p_bot_id;
END;
$$ LANGUAGE plpgsql;
-- Vista de bots activos
CREATE OR REPLACE VIEW trading.v_active_bots AS
SELECT
id,
tenant_id,
code,
name,
description,
avatar_url,
risk_level,
status,
win_rate,
profit_factor,
total_return_percent,
max_drawdown_percent,
subscriber_count,
total_aum,
is_premium,
min_investment
FROM trading.bots
WHERE is_public = TRUE
AND status IN ('running', 'paused')
ORDER BY total_return_percent DESC;
-- Vista de rendimiento de bots
CREATE OR REPLACE VIEW trading.v_bot_performance AS
SELECT
b.id,
b.code,
b.name,
b.status,
b.total_trades,
b.win_rate,
b.profit_factor,
b.sharpe_ratio,
b.net_profit,
b.total_return_percent,
b.daily_return_percent,
b.weekly_return_percent,
b.monthly_return_percent,
b.max_drawdown_percent,
b.open_positions_count,
b.unrealized_pnl,
b.last_trade_at
FROM trading.bots b
ORDER BY b.tenant_id, b.total_return_percent DESC;
-- RLS Policy para multi-tenancy
ALTER TABLE trading.bots ENABLE ROW LEVEL SECURITY;
CREATE POLICY bots_tenant_isolation ON trading.bots
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON trading.bots TO trading_app;
GRANT SELECT ON trading.bots TO trading_readonly;
GRANT SELECT ON trading.v_active_bots TO trading_app;
GRANT SELECT ON trading.v_bot_performance TO trading_app;
GRANT EXECUTE ON FUNCTION trading.recalculate_bot_stats TO trading_app;

View File

@ -0,0 +1,323 @@
-- ============================================================================
-- SCHEMA: trading
-- TABLE: signals
-- DESCRIPTION: Senales de trading generadas por ML/bots
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para tipo de senal
DO $$ BEGIN
CREATE TYPE trading.signal_type AS ENUM (
'entry', -- Senal de entrada
'exit', -- Senal de salida
'scale_in', -- Agregar a posicion
'scale_out', -- Reducir posicion
'move_sl', -- Mover stop loss
'move_tp', -- Mover take profit
'alert' -- Alerta informativa
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de senal
DO $$ BEGIN
CREATE TYPE trading.signal_status AS ENUM (
'pending', -- Pendiente de validacion
'active', -- Activa y disponible
'executed', -- Ejecutada
'expired', -- Expirada sin ejecutar
'cancelled', -- Cancelada
'invalidated' -- Invalidada por condiciones
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para fuente de senal
DO $$ BEGIN
CREATE TYPE trading.signal_source AS ENUM (
'ml_model', -- Modelo ML
'technical', -- Analisis tecnico
'fundamental', -- Analisis fundamental
'sentiment', -- Analisis de sentimiento
'manual', -- Analista manual
'bot', -- Bot de trading
'copy_trade' -- Copy trading
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Senales de Trading
CREATE TABLE IF NOT EXISTS trading.signals (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
bot_id UUID REFERENCES trading.bots(id),
strategy_id UUID REFERENCES trading.strategies(id),
-- Clasificacion
type trading.signal_type NOT NULL DEFAULT 'entry',
source trading.signal_source NOT NULL DEFAULT 'ml_model',
status trading.signal_status NOT NULL DEFAULT 'pending',
-- Instrumento
symbol_id UUID REFERENCES trading.symbols(id),
symbol VARCHAR(20) NOT NULL,
timeframe trading.timeframe DEFAULT 'H1',
-- Direccion y precios
direction trading.trade_direction NOT NULL,
entry_price DECIMAL(15, 8) NOT NULL,
stop_loss DECIMAL(15, 8),
take_profit DECIMAL(15, 8),
take_profit_2 DECIMAL(15, 8), -- TP secundario
take_profit_3 DECIMAL(15, 8), -- TP terciario
-- Risk/Reward
risk_pips DECIMAL(10, 4),
reward_pips DECIMAL(10, 4),
risk_reward_ratio DECIMAL(10, 4),
-- Confianza y scoring
confidence DECIMAL(5, 2) NOT NULL DEFAULT 50 -- 0-100
CHECK (confidence BETWEEN 0 AND 100),
strength VARCHAR(20), -- 'weak', 'moderate', 'strong', 'very_strong'
quality_score DECIMAL(5, 2),
-- Modelo ML (si aplica)
model_id VARCHAR(100),
model_version VARCHAR(20),
model_prediction JSONB, -- Prediccion completa del modelo
-- Analisis tecnico
technical_analysis JSONB DEFAULT '{}'::JSONB, -- Indicadores al momento de la senal
pattern_detected VARCHAR(100), -- Patron identificado
key_levels JSONB, -- Niveles clave S/R
-- Contexto de mercado
market_context JSONB DEFAULT '{}'::JSONB, -- Condiciones de mercado
trend_direction VARCHAR(20),
volatility_level VARCHAR(20),
-- Validez
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_until TIMESTAMPTZ, -- NULL = sin expiracion
invalidation_price DECIMAL(15, 8), -- Precio que invalida la senal
-- Ejecucion
executed_at TIMESTAMPTZ,
executed_price DECIMAL(15, 8),
execution_slippage DECIMAL(10, 4),
position_id UUID, -- Posicion resultante
-- Resultado
result VARCHAR(20), -- 'win', 'loss', 'breakeven', 'partial'
result_pips DECIMAL(10, 4),
result_amount DECIMAL(15, 4),
-- Notificaciones
notification_sent BOOLEAN NOT NULL DEFAULT FALSE,
notification_sent_at TIMESTAMPTZ,
notification_channels JSONB DEFAULT '[]'::JSONB,
-- Usuarios que vieron/ejecutaron
view_count INTEGER NOT NULL DEFAULT 0,
execution_count INTEGER NOT NULL DEFAULT 0,
-- Metadata
tags VARCHAR(50)[],
notes TEXT,
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE trading.signals IS
'Senales de trading generadas por modelos ML, bots o analistas';
COMMENT ON COLUMN trading.signals.confidence IS
'Nivel de confianza de la senal (0-100%)';
COMMENT ON COLUMN trading.signals.invalidation_price IS
'Precio que invalida la senal antes de ser ejecutada';
-- Indices
CREATE INDEX IF NOT EXISTS idx_signals_tenant
ON trading.signals(tenant_id);
CREATE INDEX IF NOT EXISTS idx_signals_bot
ON trading.signals(bot_id);
CREATE INDEX IF NOT EXISTS idx_signals_symbol
ON trading.signals(symbol, generated_at DESC);
CREATE INDEX IF NOT EXISTS idx_signals_status
ON trading.signals(status);
CREATE INDEX IF NOT EXISTS idx_signals_active
ON trading.signals(tenant_id, status, generated_at DESC)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_signals_type_direction
ON trading.signals(type, direction);
CREATE INDEX IF NOT EXISTS idx_signals_confidence
ON trading.signals(confidence DESC)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_signals_valid_until
ON trading.signals(valid_until)
WHERE valid_until IS NOT NULL AND status = 'active';
CREATE INDEX IF NOT EXISTS idx_signals_generated
ON trading.signals(generated_at DESC);
-- GIN index para technical_analysis
CREATE INDEX IF NOT EXISTS idx_signals_ta_gin
ON trading.signals USING GIN (technical_analysis);
-- Trigger para updated_at
DROP TRIGGER IF EXISTS signal_updated_at ON trading.signals;
CREATE TRIGGER signal_updated_at
BEFORE UPDATE ON trading.signals
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Trigger para calcular risk/reward
CREATE OR REPLACE FUNCTION trading.calculate_signal_rr()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.entry_price IS NOT NULL AND NEW.stop_loss IS NOT NULL THEN
IF NEW.direction = 'long' THEN
NEW.risk_pips := (NEW.entry_price - NEW.stop_loss) * 10000; -- Para forex
IF NEW.take_profit IS NOT NULL THEN
NEW.reward_pips := (NEW.take_profit - NEW.entry_price) * 10000;
END IF;
ELSE
NEW.risk_pips := (NEW.stop_loss - NEW.entry_price) * 10000;
IF NEW.take_profit IS NOT NULL THEN
NEW.reward_pips := (NEW.entry_price - NEW.take_profit) * 10000;
END IF;
END IF;
IF NEW.risk_pips > 0 AND NEW.reward_pips IS NOT NULL THEN
NEW.risk_reward_ratio := NEW.reward_pips / NEW.risk_pips;
END IF;
END IF;
-- Determinar strength basado en confidence
NEW.strength := CASE
WHEN NEW.confidence >= 85 THEN 'very_strong'
WHEN NEW.confidence >= 70 THEN 'strong'
WHEN NEW.confidence >= 55 THEN 'moderate'
ELSE 'weak'
END;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS signal_calc_rr ON trading.signals;
CREATE TRIGGER signal_calc_rr
BEFORE INSERT OR UPDATE OF entry_price, stop_loss, take_profit, confidence ON trading.signals
FOR EACH ROW
EXECUTE FUNCTION trading.calculate_signal_rr();
-- Trigger para expirar senales
CREATE OR REPLACE FUNCTION trading.expire_old_signals()
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
UPDATE trading.signals
SET status = 'expired'
WHERE status = 'active'
AND valid_until IS NOT NULL
AND valid_until < NOW();
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$ LANGUAGE plpgsql;
-- Funcion para actualizar estadisticas del bot cuando se procesa senal
CREATE OR REPLACE FUNCTION trading.update_bot_on_signal()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.bot_id IS NOT NULL THEN
UPDATE trading.bots
SET last_signal_at = NOW()
WHERE id = NEW.bot_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS signal_bot_update ON trading.signals;
CREATE TRIGGER signal_bot_update
AFTER INSERT ON trading.signals
FOR EACH ROW
EXECUTE FUNCTION trading.update_bot_on_signal();
-- Vista de senales activas
CREATE OR REPLACE VIEW trading.v_active_signals AS
SELECT
s.id,
s.tenant_id,
s.bot_id,
b.name AS bot_name,
s.symbol,
s.timeframe,
s.type,
s.direction,
s.entry_price,
s.stop_loss,
s.take_profit,
s.risk_reward_ratio,
s.confidence,
s.strength,
s.pattern_detected,
s.valid_until,
s.generated_at
FROM trading.signals s
LEFT JOIN trading.bots b ON s.bot_id = b.id
WHERE s.status = 'active'
AND (s.valid_until IS NULL OR s.valid_until > NOW())
ORDER BY s.confidence DESC, s.generated_at DESC;
-- Vista de rendimiento de senales por simbolo
CREATE OR REPLACE VIEW trading.v_signal_performance AS
SELECT
symbol,
COUNT(*) AS total_signals,
COUNT(*) FILTER (WHERE result = 'win') AS wins,
COUNT(*) FILTER (WHERE result = 'loss') AS losses,
ROUND(AVG(confidence)::NUMERIC, 2) AS avg_confidence,
ROUND(AVG(risk_reward_ratio)::NUMERIC, 2) AS avg_rr,
ROUND((COUNT(*) FILTER (WHERE result = 'win')::DECIMAL / NULLIF(COUNT(*) FILTER (WHERE result IN ('win', 'loss')), 0) * 100)::NUMERIC, 2) AS win_rate
FROM trading.signals
WHERE status IN ('executed', 'expired')
AND result IS NOT NULL
GROUP BY symbol
ORDER BY win_rate DESC NULLS LAST;
-- RLS Policy para multi-tenancy
ALTER TABLE trading.signals ENABLE ROW LEVEL SECURITY;
CREATE POLICY signals_tenant_isolation ON trading.signals
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON trading.signals TO trading_app;
GRANT SELECT ON trading.signals TO trading_readonly;
GRANT SELECT ON trading.v_active_signals TO trading_app;
GRANT SELECT ON trading.v_signal_performance TO trading_app;
GRANT EXECUTE ON FUNCTION trading.expire_old_signals TO trading_app;

View File

@ -0,0 +1,395 @@
-- ============================================================================
-- SCHEMA: trading
-- TABLE: positions
-- DESCRIPTION: Posiciones de trading (abiertas y cerradas)
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Tabla de Posiciones de Trading
CREATE TABLE IF NOT EXISTS trading.positions (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Referencias
bot_id UUID REFERENCES trading.bots(id),
strategy_id UUID REFERENCES trading.strategies(id),
signal_id UUID REFERENCES trading.signals(id),
symbol_id UUID REFERENCES trading.symbols(id),
-- Identificadores externos
external_ticket VARCHAR(50), -- Ticket del broker/MT4
external_order_id VARCHAR(50),
-- Instrumento
symbol VARCHAR(20) NOT NULL,
-- Direccion y estado
direction trading.trade_direction NOT NULL,
status trading.position_status NOT NULL DEFAULT 'pending',
order_type trading.order_type NOT NULL DEFAULT 'market',
-- Tamaño
lot_size DECIMAL(10, 4) NOT NULL,
units INTEGER, -- Unidades/cantidad
-- Precios de entrada
requested_price DECIMAL(15, 8),
entry_price DECIMAL(15, 8),
slippage DECIMAL(15, 8),
-- Stop Loss
stop_loss DECIMAL(15, 8),
stop_loss_pips DECIMAL(10, 4),
original_stop_loss DECIMAL(15, 8),
trailing_stop_enabled BOOLEAN NOT NULL DEFAULT FALSE,
trailing_stop_distance DECIMAL(10, 4),
-- Take Profit
take_profit DECIMAL(15, 8),
take_profit_pips DECIMAL(10, 4),
take_profit_2 DECIMAL(15, 8),
take_profit_3 DECIMAL(15, 8),
-- Breakeven
breakeven_activated BOOLEAN NOT NULL DEFAULT FALSE,
breakeven_trigger_pips DECIMAL(10, 4),
breakeven_offset_pips DECIMAL(10, 4),
-- Precios de cierre
exit_price DECIMAL(15, 8),
exit_reason VARCHAR(50), -- 'manual', 'stop_loss', 'take_profit', 'trailing_stop', 'margin_call'
-- Profit/Loss
profit_loss DECIMAL(15, 4), -- P&L en moneda de cuenta
profit_loss_pips DECIMAL(10, 4),
profit_loss_percent DECIMAL(10, 4),
-- Comisiones y costos
commission DECIMAL(15, 4) DEFAULT 0,
swap DECIMAL(15, 4) DEFAULT 0, -- Swap overnight
spread_cost DECIMAL(15, 4) DEFAULT 0,
total_cost DECIMAL(15, 4) DEFAULT 0,
net_profit DECIMAL(15, 4), -- P&L - costos
-- Margen
margin_used DECIMAL(15, 4),
leverage_used INTEGER,
-- Risk management
risk_amount DECIMAL(15, 4), -- Monto en riesgo
risk_percent DECIMAL(5, 2), -- % de cuenta en riesgo
reward_amount DECIMAL(15, 4),
risk_reward_ratio DECIMAL(10, 4),
-- Tiempo
duration_seconds INTEGER,
duration_formatted VARCHAR(50), -- "2h 30m"
-- Partial closes
partial_closes JSONB DEFAULT '[]'::JSONB, -- [{ "lot_size": 0.05, "price": 1.1234, "pnl": 50, "at": "..." }]
remaining_lot_size DECIMAL(10, 4),
-- Escalado
scale_ins JSONB DEFAULT '[]'::JSONB, -- Adiciones a la posicion
average_entry_price DECIMAL(15, 8),
-- Precio actual (para posiciones abiertas)
current_price DECIMAL(15, 8),
unrealized_pnl DECIMAL(15, 4),
unrealized_pnl_pips DECIMAL(10, 4),
price_updated_at TIMESTAMPTZ,
-- Analisis post-trade
max_favorable_excursion DECIMAL(15, 4), -- MFE - max ganancia no realizada
max_adverse_excursion DECIMAL(15, 4), -- MAE - max perdida no realizada
mfe_price DECIMAL(15, 8),
mae_price DECIMAL(15, 8),
-- Screenshots/Evidence
entry_screenshot_url TEXT,
exit_screenshot_url TEXT,
-- Notas
notes TEXT,
tags VARCHAR(50)[],
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
requested_at TIMESTAMPTZ,
opened_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE trading.positions IS
'Posiciones de trading con tracking completo de entrada, salida y P&L';
COMMENT ON COLUMN trading.positions.max_favorable_excursion IS
'Maximum Favorable Excursion - maxima ganancia no realizada durante el trade';
-- Indices
CREATE INDEX IF NOT EXISTS idx_positions_tenant
ON trading.positions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_positions_user
ON trading.positions(user_id);
CREATE INDEX IF NOT EXISTS idx_positions_bot
ON trading.positions(bot_id)
WHERE bot_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_positions_strategy
ON trading.positions(strategy_id)
WHERE strategy_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_positions_signal
ON trading.positions(signal_id)
WHERE signal_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_positions_symbol
ON trading.positions(symbol);
CREATE INDEX IF NOT EXISTS idx_positions_status
ON trading.positions(status);
CREATE INDEX IF NOT EXISTS idx_positions_open
ON trading.positions(tenant_id, user_id, status)
WHERE status = 'open';
CREATE INDEX IF NOT EXISTS idx_positions_closed
ON trading.positions(tenant_id, closed_at DESC)
WHERE status IN ('closed', 'stopped', 'target_hit');
CREATE INDEX IF NOT EXISTS idx_positions_external
ON trading.positions(external_ticket)
WHERE external_ticket IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_positions_pnl
ON trading.positions(profit_loss DESC)
WHERE status IN ('closed', 'stopped', 'target_hit');
-- Trigger para updated_at
DROP TRIGGER IF EXISTS position_updated_at ON trading.positions;
CREATE TRIGGER position_updated_at
BEFORE UPDATE ON trading.positions
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Trigger para calcular campos al abrir posicion
CREATE OR REPLACE FUNCTION trading.calculate_position_on_open()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'open' AND OLD.status = 'pending' THEN
NEW.opened_at := NOW();
-- Calcular slippage
IF NEW.requested_price IS NOT NULL AND NEW.entry_price IS NOT NULL THEN
NEW.slippage := ABS(NEW.entry_price - NEW.requested_price);
END IF;
-- Calcular pips de SL/TP
IF NEW.stop_loss IS NOT NULL AND NEW.entry_price IS NOT NULL THEN
IF NEW.direction = 'long' THEN
NEW.stop_loss_pips := (NEW.entry_price - NEW.stop_loss) * 10000;
ELSE
NEW.stop_loss_pips := (NEW.stop_loss - NEW.entry_price) * 10000;
END IF;
END IF;
IF NEW.take_profit IS NOT NULL AND NEW.entry_price IS NOT NULL THEN
IF NEW.direction = 'long' THEN
NEW.take_profit_pips := (NEW.take_profit - NEW.entry_price) * 10000;
ELSE
NEW.take_profit_pips := (NEW.entry_price - NEW.take_profit) * 10000;
END IF;
END IF;
-- Calcular risk/reward ratio
IF NEW.stop_loss_pips > 0 AND NEW.take_profit_pips IS NOT NULL THEN
NEW.risk_reward_ratio := NEW.take_profit_pips / NEW.stop_loss_pips;
END IF;
NEW.remaining_lot_size := NEW.lot_size;
NEW.average_entry_price := NEW.entry_price;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS position_on_open ON trading.positions;
CREATE TRIGGER position_on_open
BEFORE UPDATE OF status ON trading.positions
FOR EACH ROW
EXECUTE FUNCTION trading.calculate_position_on_open();
-- Trigger para calcular campos al cerrar posicion
CREATE OR REPLACE FUNCTION trading.calculate_position_on_close()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status IN ('closed', 'stopped', 'target_hit') AND OLD.status = 'open' THEN
NEW.closed_at := NOW();
-- Calcular P&L en pips
IF NEW.exit_price IS NOT NULL AND NEW.entry_price IS NOT NULL THEN
IF NEW.direction = 'long' THEN
NEW.profit_loss_pips := (NEW.exit_price - NEW.entry_price) * 10000;
ELSE
NEW.profit_loss_pips := (NEW.entry_price - NEW.exit_price) * 10000;
END IF;
END IF;
-- Calcular duracion
IF NEW.opened_at IS NOT NULL THEN
NEW.duration_seconds := EXTRACT(EPOCH FROM (NEW.closed_at - NEW.opened_at))::INTEGER;
-- Formatear duracion
NEW.duration_formatted :=
CASE
WHEN NEW.duration_seconds < 60 THEN NEW.duration_seconds || 's'
WHEN NEW.duration_seconds < 3600 THEN (NEW.duration_seconds / 60) || 'm'
WHEN NEW.duration_seconds < 86400 THEN (NEW.duration_seconds / 3600) || 'h ' || ((NEW.duration_seconds % 3600) / 60) || 'm'
ELSE (NEW.duration_seconds / 86400) || 'd ' || ((NEW.duration_seconds % 86400) / 3600) || 'h'
END;
END IF;
-- Calcular costos totales
NEW.total_cost := COALESCE(NEW.commission, 0) + COALESCE(NEW.swap, 0) + COALESCE(NEW.spread_cost, 0);
NEW.net_profit := COALESCE(NEW.profit_loss, 0) - NEW.total_cost;
-- Determinar exit_reason basado en status
IF NEW.exit_reason IS NULL THEN
NEW.exit_reason := CASE NEW.status
WHEN 'stopped' THEN 'stop_loss'
WHEN 'target_hit' THEN 'take_profit'
ELSE 'manual'
END;
END IF;
-- Actualizar senal si existe
IF NEW.signal_id IS NOT NULL THEN
UPDATE trading.signals
SET status = 'executed',
executed_at = NEW.closed_at,
executed_price = NEW.exit_price,
result = CASE
WHEN NEW.profit_loss > 0 THEN 'win'
WHEN NEW.profit_loss < 0 THEN 'loss'
ELSE 'breakeven'
END,
result_pips = NEW.profit_loss_pips,
result_amount = NEW.profit_loss,
position_id = NEW.id
WHERE id = NEW.signal_id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS position_on_close ON trading.positions;
CREATE TRIGGER position_on_close
BEFORE UPDATE OF status ON trading.positions
FOR EACH ROW
EXECUTE FUNCTION trading.calculate_position_on_close();
-- Trigger para actualizar estadisticas de bot/strategy
CREATE OR REPLACE FUNCTION trading.update_stats_on_position_close()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status IN ('closed', 'stopped', 'target_hit') AND OLD.status = 'open' THEN
-- Actualizar bot stats
IF NEW.bot_id IS NOT NULL THEN
PERFORM trading.recalculate_bot_stats(NEW.bot_id);
END IF;
-- Actualizar strategy stats
IF NEW.strategy_id IS NOT NULL THEN
PERFORM trading.recalculate_strategy_stats(NEW.strategy_id);
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS position_stats_update ON trading.positions;
CREATE TRIGGER position_stats_update
AFTER UPDATE OF status ON trading.positions
FOR EACH ROW
EXECUTE FUNCTION trading.update_stats_on_position_close();
-- Vista de posiciones abiertas
CREATE OR REPLACE VIEW trading.v_open_positions AS
SELECT
p.id,
p.tenant_id,
p.user_id,
p.bot_id,
b.name AS bot_name,
p.symbol,
p.direction,
p.lot_size,
p.entry_price,
p.stop_loss,
p.take_profit,
p.current_price,
p.unrealized_pnl,
p.unrealized_pnl_pips,
p.risk_reward_ratio,
p.opened_at,
NOW() - p.opened_at AS time_open
FROM trading.positions p
LEFT JOIN trading.bots b ON p.bot_id = b.id
WHERE p.status = 'open'
ORDER BY p.opened_at DESC;
-- Vista de historial de trades
CREATE OR REPLACE VIEW trading.v_trade_history AS
SELECT
p.id,
p.tenant_id,
p.user_id,
p.symbol,
p.direction,
p.lot_size,
p.entry_price,
p.exit_price,
p.stop_loss,
p.take_profit,
p.profit_loss,
p.profit_loss_pips,
p.net_profit,
p.exit_reason,
p.duration_formatted,
p.opened_at,
p.closed_at
FROM trading.positions p
WHERE p.status IN ('closed', 'stopped', 'target_hit')
ORDER BY p.closed_at DESC;
-- RLS Policy para multi-tenancy
ALTER TABLE trading.positions ENABLE ROW LEVEL SECURITY;
CREATE POLICY positions_tenant_isolation ON trading.positions
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Los usuarios solo ven sus propias posiciones
CREATE POLICY positions_user_isolation ON trading.positions
FOR SELECT
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON trading.positions TO trading_app;
GRANT SELECT ON trading.positions TO trading_readonly;
GRANT SELECT ON trading.v_open_positions TO trading_app;
GRANT SELECT ON trading.v_trade_history TO trading_app;

View File

@ -0,0 +1,260 @@
-- ============================================================================
-- SCHEMA: trading
-- TABLE: price_alerts
-- DESCRIPTION: Alertas de precio configuradas por usuarios
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para tipo de alerta
DO $$ BEGIN
CREATE TYPE trading.alert_type AS ENUM (
'price_above', -- Precio sube por encima de
'price_below', -- Precio baja por debajo de
'price_cross', -- Precio cruza (cualquier direccion)
'percent_change', -- Cambio porcentual
'volume_spike', -- Spike de volumen
'volatility', -- Alerta de volatilidad
'indicator', -- Alerta de indicador tecnico
'pattern' -- Patron detectado
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de alerta
DO $$ BEGIN
CREATE TYPE trading.alert_status AS ENUM (
'active', -- Activa y monitoreando
'triggered', -- Disparada
'expired', -- Expirada sin disparar
'paused', -- Pausada por usuario
'deleted' -- Eliminada (soft delete)
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Alertas de Precio
CREATE TABLE IF NOT EXISTS trading.price_alerts (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
symbol_id UUID REFERENCES trading.symbols(id),
-- Simbolo
symbol VARCHAR(20) NOT NULL,
-- Tipo y estado
type trading.alert_type NOT NULL DEFAULT 'price_above',
status trading.alert_status NOT NULL DEFAULT 'active',
-- Condicion
target_price DECIMAL(15, 8), -- Precio objetivo
trigger_value DECIMAL(15, 8), -- Valor que dispara (precio, %, etc)
comparison VARCHAR(10) DEFAULT 'gte', -- 'gt', 'gte', 'lt', 'lte', 'eq', 'cross'
-- Para alertas de cambio porcentual
percent_change DECIMAL(10, 4),
time_window_minutes INTEGER, -- Ventana de tiempo para % change
base_price DECIMAL(15, 8), -- Precio base para calcular %
-- Para alertas de indicadores
indicator_name VARCHAR(50),
indicator_config JSONB,
indicator_threshold DECIMAL(15, 8),
-- Configuracion
is_recurring BOOLEAN NOT NULL DEFAULT FALSE, -- Se reactiva despues de disparar
cooldown_minutes INTEGER DEFAULT 60, -- Cooldown entre disparos
max_triggers INTEGER, -- Max veces que puede disparar (NULL = ilimitado)
trigger_count INTEGER NOT NULL DEFAULT 0,
-- Notificacion
notify_email BOOLEAN NOT NULL DEFAULT TRUE,
notify_push BOOLEAN NOT NULL DEFAULT TRUE,
notify_sms BOOLEAN NOT NULL DEFAULT FALSE,
notification_message TEXT, -- Mensaje personalizado
-- Sonido
play_sound BOOLEAN NOT NULL DEFAULT TRUE,
sound_name VARCHAR(50) DEFAULT 'default',
-- Vigencia
valid_from TIMESTAMPTZ DEFAULT NOW(),
valid_until TIMESTAMPTZ, -- NULL = sin expiracion
-- Ultimo trigger
last_triggered_at TIMESTAMPTZ,
last_triggered_price DECIMAL(15, 8),
-- Precio actual (cache)
current_price DECIMAL(15, 8),
price_updated_at TIMESTAMPTZ,
-- Notas
name VARCHAR(100), -- Nombre descriptivo opcional
notes TEXT,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE trading.price_alerts IS
'Alertas de precio configuradas por usuarios para monitorear instrumentos';
COMMENT ON COLUMN trading.price_alerts.is_recurring IS
'Si TRUE, la alerta se reactiva automaticamente despues de disparar';
-- Indices
CREATE INDEX IF NOT EXISTS idx_price_alerts_tenant
ON trading.price_alerts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_price_alerts_user
ON trading.price_alerts(user_id);
CREATE INDEX IF NOT EXISTS idx_price_alerts_symbol
ON trading.price_alerts(symbol);
CREATE INDEX IF NOT EXISTS idx_price_alerts_status
ON trading.price_alerts(status);
CREATE INDEX IF NOT EXISTS idx_price_alerts_active
ON trading.price_alerts(symbol, status, target_price)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_price_alerts_user_active
ON trading.price_alerts(user_id, status)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_price_alerts_expiring
ON trading.price_alerts(valid_until)
WHERE valid_until IS NOT NULL AND status = 'active';
-- Trigger para updated_at
DROP TRIGGER IF EXISTS price_alert_updated_at ON trading.price_alerts;
CREATE TRIGGER price_alert_updated_at
BEFORE UPDATE ON trading.price_alerts
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Funcion para verificar y disparar alertas
CREATE OR REPLACE FUNCTION trading.check_price_alerts(
p_symbol VARCHAR(20),
p_price DECIMAL(15, 8)
)
RETURNS TABLE (
alert_id UUID,
user_id UUID,
alert_type trading.alert_type,
notification_message TEXT
) AS $$
DECLARE
v_alert RECORD;
BEGIN
FOR v_alert IN
SELECT * FROM trading.price_alerts
WHERE symbol = p_symbol
AND status = 'active'
AND (valid_until IS NULL OR valid_until > NOW())
AND (last_triggered_at IS NULL OR last_triggered_at + (cooldown_minutes || ' minutes')::INTERVAL < NOW())
LOOP
-- Verificar condicion segun tipo
IF (v_alert.type = 'price_above' AND p_price >= v_alert.target_price) OR
(v_alert.type = 'price_below' AND p_price <= v_alert.target_price) OR
(v_alert.type = 'price_cross' AND
((v_alert.current_price < v_alert.target_price AND p_price >= v_alert.target_price) OR
(v_alert.current_price > v_alert.target_price AND p_price <= v_alert.target_price)))
THEN
-- Disparar alerta
UPDATE trading.price_alerts
SET status = CASE WHEN is_recurring THEN 'active' ELSE 'triggered' END,
last_triggered_at = NOW(),
last_triggered_price = p_price,
trigger_count = trigger_count + 1
WHERE id = v_alert.id;
-- Verificar max_triggers
IF v_alert.max_triggers IS NOT NULL AND v_alert.trigger_count + 1 >= v_alert.max_triggers THEN
UPDATE trading.price_alerts SET status = 'triggered' WHERE id = v_alert.id;
END IF;
RETURN QUERY SELECT
v_alert.id,
v_alert.user_id,
v_alert.type,
COALESCE(v_alert.notification_message,
v_alert.symbol || ' alcanzó ' || p_price::TEXT);
END IF;
END LOOP;
-- Actualizar precio actual en todas las alertas del simbolo
UPDATE trading.price_alerts
SET current_price = p_price,
price_updated_at = NOW()
WHERE symbol = p_symbol
AND status = 'active';
END;
$$ LANGUAGE plpgsql;
-- Funcion para expirar alertas
CREATE OR REPLACE FUNCTION trading.expire_old_alerts()
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
UPDATE trading.price_alerts
SET status = 'expired'
WHERE status = 'active'
AND valid_until IS NOT NULL
AND valid_until < NOW();
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$ LANGUAGE plpgsql;
-- Vista de alertas activas del usuario
CREATE OR REPLACE VIEW trading.v_my_price_alerts AS
SELECT
id,
symbol,
type,
status,
target_price,
current_price,
name,
notify_email,
notify_push,
is_recurring,
trigger_count,
last_triggered_at,
valid_until,
created_at
FROM trading.price_alerts
WHERE status IN ('active', 'paused')
ORDER BY created_at DESC;
-- RLS Policy para multi-tenancy
ALTER TABLE trading.price_alerts ENABLE ROW LEVEL SECURITY;
CREATE POLICY price_alerts_tenant_isolation ON trading.price_alerts
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY price_alerts_user_isolation ON trading.price_alerts
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON trading.price_alerts TO trading_app;
GRANT SELECT ON trading.price_alerts TO trading_readonly;
GRANT SELECT ON trading.v_my_price_alerts TO trading_app;
GRANT EXECUTE ON FUNCTION trading.check_price_alerts TO trading_app;
GRANT EXECUTE ON FUNCTION trading.expire_old_alerts TO trading_app;

View File

@ -0,0 +1,334 @@
-- ============================================================================
-- SCHEMA: trading
-- TABLE: watchlists, watchlist_items
-- DESCRIPTION: Listas de seguimiento de simbolos
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Tabla de Watchlists
CREATE TABLE IF NOT EXISTS trading.watchlists (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Informacion
name VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(7), -- Color hex para identificacion
icon VARCHAR(50),
-- Configuracion
is_default BOOLEAN NOT NULL DEFAULT FALSE, -- Watchlist por defecto
is_public BOOLEAN NOT NULL DEFAULT FALSE, -- Compartida publicamente
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- Fijada en UI
-- Ordenamiento
display_order INTEGER NOT NULL DEFAULT 0,
sort_by VARCHAR(50) DEFAULT 'symbol', -- 'symbol', 'change', 'volume', 'custom'
sort_direction VARCHAR(4) DEFAULT 'asc',
-- Columnas visibles
visible_columns JSONB DEFAULT '["symbol", "price", "change", "change_percent"]'::JSONB,
-- Estadisticas
item_count INTEGER NOT NULL DEFAULT 0,
-- Compartir
share_code VARCHAR(20), -- Codigo para compartir
shared_at TIMESTAMPTZ,
view_count INTEGER NOT NULL DEFAULT 0,
copy_count INTEGER NOT NULL DEFAULT 0,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT watchlists_unique_name UNIQUE (user_id, name)
);
COMMENT ON TABLE trading.watchlists IS
'Listas de seguimiento de simbolos creadas por usuarios';
-- Tabla de Items de Watchlist
CREATE TABLE IF NOT EXISTS trading.watchlist_items (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
watchlist_id UUID NOT NULL REFERENCES trading.watchlists(id) ON DELETE CASCADE,
symbol_id UUID REFERENCES trading.symbols(id),
ticker_id UUID REFERENCES market_data.tickers(id),
-- Simbolo
symbol VARCHAR(20) NOT NULL,
-- Orden personalizado
display_order INTEGER NOT NULL DEFAULT 0,
-- Notas del usuario
notes TEXT,
tags VARCHAR(50)[],
-- Alertas rapidas
alert_price_above DECIMAL(15, 8),
alert_price_below DECIMAL(15, 8),
alert_enabled BOOLEAN NOT NULL DEFAULT FALSE,
-- Precios de referencia del usuario
entry_price DECIMAL(15, 8), -- Precio de entrada planeado
target_price DECIMAL(15, 8), -- Precio objetivo
stop_price DECIMAL(15, 8), -- Stop loss planeado
-- Precio actual (cache)
current_price DECIMAL(15, 8),
price_change DECIMAL(15, 8),
price_change_percent DECIMAL(10, 4),
price_updated_at TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT watchlist_items_unique UNIQUE (watchlist_id, symbol)
);
COMMENT ON TABLE trading.watchlist_items IS
'Simbolos individuales dentro de una watchlist';
-- Indices para watchlists
CREATE INDEX IF NOT EXISTS idx_watchlists_tenant
ON trading.watchlists(tenant_id);
CREATE INDEX IF NOT EXISTS idx_watchlists_user
ON trading.watchlists(user_id);
CREATE INDEX IF NOT EXISTS idx_watchlists_default
ON trading.watchlists(user_id, is_default)
WHERE is_default = TRUE;
CREATE INDEX IF NOT EXISTS idx_watchlists_public
ON trading.watchlists(is_public, view_count DESC)
WHERE is_public = TRUE;
CREATE INDEX IF NOT EXISTS idx_watchlists_share_code
ON trading.watchlists(share_code)
WHERE share_code IS NOT NULL;
-- Indices para watchlist_items
CREATE INDEX IF NOT EXISTS idx_watchlist_items_watchlist
ON trading.watchlist_items(watchlist_id, display_order);
CREATE INDEX IF NOT EXISTS idx_watchlist_items_symbol
ON trading.watchlist_items(symbol);
CREATE INDEX IF NOT EXISTS idx_watchlist_items_alerts
ON trading.watchlist_items(alert_enabled, symbol)
WHERE alert_enabled = TRUE;
-- Trigger para updated_at en watchlists
DROP TRIGGER IF EXISTS watchlist_updated_at ON trading.watchlists;
CREATE TRIGGER watchlist_updated_at
BEFORE UPDATE ON trading.watchlists
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Trigger para updated_at en watchlist_items
DROP TRIGGER IF EXISTS watchlist_item_updated_at ON trading.watchlist_items;
CREATE TRIGGER watchlist_item_updated_at
BEFORE UPDATE ON trading.watchlist_items
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Trigger para actualizar item_count en watchlist
CREATE OR REPLACE FUNCTION trading.update_watchlist_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE trading.watchlists
SET item_count = item_count + 1
WHERE id = NEW.watchlist_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE trading.watchlists
SET item_count = item_count - 1
WHERE id = OLD.watchlist_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS watchlist_item_count ON trading.watchlist_items;
CREATE TRIGGER watchlist_item_count
AFTER INSERT OR DELETE ON trading.watchlist_items
FOR EACH ROW
EXECUTE FUNCTION trading.update_watchlist_count();
-- Trigger para asegurar solo una watchlist default por usuario
CREATE OR REPLACE FUNCTION trading.ensure_single_default_watchlist()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.is_default = TRUE THEN
UPDATE trading.watchlists
SET is_default = FALSE
WHERE user_id = NEW.user_id
AND id != NEW.id
AND is_default = TRUE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS watchlist_single_default ON trading.watchlists;
CREATE TRIGGER watchlist_single_default
BEFORE INSERT OR UPDATE OF is_default ON trading.watchlists
FOR EACH ROW
WHEN (NEW.is_default = TRUE)
EXECUTE FUNCTION trading.ensure_single_default_watchlist();
-- Funcion para copiar watchlist
CREATE OR REPLACE FUNCTION trading.copy_watchlist(
p_watchlist_id UUID,
p_new_user_id UUID,
p_new_name VARCHAR(100) DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_source RECORD;
v_new_id UUID;
v_tenant_id UUID;
BEGIN
-- Obtener watchlist origen
SELECT * INTO v_source FROM trading.watchlists WHERE id = p_watchlist_id;
IF v_source IS NULL THEN
RAISE EXCEPTION 'Watchlist not found';
END IF;
-- Obtener tenant del nuevo usuario
SELECT tenant_id INTO v_tenant_id FROM users.users WHERE id = p_new_user_id;
-- Crear nueva watchlist
INSERT INTO trading.watchlists (
tenant_id, user_id, name, description, color, icon,
sort_by, sort_direction, visible_columns
) VALUES (
v_tenant_id,
p_new_user_id,
COALESCE(p_new_name, v_source.name || ' (Copy)'),
v_source.description,
v_source.color,
v_source.icon,
v_source.sort_by,
v_source.sort_direction,
v_source.visible_columns
)
RETURNING id INTO v_new_id;
-- Copiar items
INSERT INTO trading.watchlist_items (
watchlist_id, symbol_id, ticker_id, symbol,
display_order, notes, tags,
entry_price, target_price, stop_price
)
SELECT
v_new_id, symbol_id, ticker_id, symbol,
display_order, notes, tags,
entry_price, target_price, stop_price
FROM trading.watchlist_items
WHERE watchlist_id = p_watchlist_id;
-- Incrementar contador de copias
UPDATE trading.watchlists
SET copy_count = copy_count + 1
WHERE id = p_watchlist_id;
RETURN v_new_id;
END;
$$ LANGUAGE plpgsql;
-- Vista de watchlists con items
CREATE OR REPLACE VIEW trading.v_watchlists_with_items AS
SELECT
w.id,
w.user_id,
w.name,
w.description,
w.color,
w.is_default,
w.is_pinned,
w.item_count,
w.display_order,
(
SELECT jsonb_agg(jsonb_build_object(
'symbol', wi.symbol,
'current_price', wi.current_price,
'price_change_percent', wi.price_change_percent,
'notes', wi.notes
) ORDER BY wi.display_order)
FROM trading.watchlist_items wi
WHERE wi.watchlist_id = w.id
) AS items
FROM trading.watchlists w
ORDER BY w.is_pinned DESC, w.display_order, w.name;
-- Vista de items con precios actualizados
CREATE OR REPLACE VIEW trading.v_watchlist_items_live AS
SELECT
wi.id,
wi.watchlist_id,
wi.symbol,
t.name AS symbol_name,
t.type AS symbol_type,
t.current_bid,
t.current_ask,
wi.current_price,
wi.price_change_percent,
wi.entry_price,
wi.target_price,
wi.stop_price,
wi.notes,
wi.display_order
FROM trading.watchlist_items wi
LEFT JOIN market_data.tickers t ON wi.symbol = t.symbol
ORDER BY wi.watchlist_id, wi.display_order;
-- RLS Policy para multi-tenancy en watchlists
ALTER TABLE trading.watchlists ENABLE ROW LEVEL SECURITY;
CREATE POLICY watchlists_tenant_isolation ON trading.watchlists
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY watchlists_user_isolation ON trading.watchlists
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- RLS para watchlist_items (hereda de watchlist via JOIN)
ALTER TABLE trading.watchlist_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY watchlist_items_via_watchlist ON trading.watchlist_items
FOR ALL
USING (
watchlist_id IN (
SELECT id FROM trading.watchlists
WHERE user_id = current_setting('app.current_user_id', true)::UUID
)
);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON trading.watchlists TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON trading.watchlist_items TO trading_app;
GRANT SELECT ON trading.watchlists TO trading_readonly;
GRANT SELECT ON trading.watchlist_items TO trading_readonly;
GRANT SELECT ON trading.v_watchlists_with_items TO trading_app;
GRANT SELECT ON trading.v_watchlist_items_live TO trading_app;
GRANT EXECUTE ON FUNCTION trading.copy_watchlist TO trading_app;

View File

@ -0,0 +1,464 @@
-- ============================================================================
-- SCHEMA: trading
-- TABLES: paper_trading_accounts, paper_trading_positions
-- DESCRIPTION: Sistema de paper trading (cuentas demo)
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- ============================================================================
-- TABLA: paper_trading_accounts
-- ============================================================================
CREATE TABLE IF NOT EXISTS trading.paper_trading_accounts (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Informacion de cuenta
name VARCHAR(100) NOT NULL DEFAULT 'Paper Account',
description TEXT,
-- Capital
initial_balance DECIMAL(15, 2) NOT NULL DEFAULT 10000,
current_balance DECIMAL(15, 2) NOT NULL DEFAULT 10000,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
-- Configuracion
leverage INTEGER NOT NULL DEFAULT 100,
margin_call_level DECIMAL(5, 2) DEFAULT 50, -- % de margen para margin call
stop_out_level DECIMAL(5, 2) DEFAULT 20, -- % de margen para stop out
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
-- Margen
used_margin DECIMAL(15, 2) NOT NULL DEFAULT 0,
free_margin DECIMAL(15, 2) NOT NULL DEFAULT 10000,
margin_level DECIMAL(10, 2), -- (Equity / Used Margin) * 100
-- Equity y P&L
equity DECIMAL(15, 2) NOT NULL DEFAULT 10000, -- Balance + Unrealized P&L
unrealized_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0,
realized_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0,
-- Estadisticas
total_trades INTEGER NOT NULL DEFAULT 0,
winning_trades INTEGER NOT NULL DEFAULT 0,
losing_trades INTEGER NOT NULL DEFAULT 0,
win_rate DECIMAL(5, 2) DEFAULT 0,
profit_factor DECIMAL(10, 4) DEFAULT 0,
max_drawdown DECIMAL(15, 4) DEFAULT 0,
max_drawdown_percent DECIMAL(5, 2) DEFAULT 0,
-- Rendimiento
total_profit DECIMAL(15, 4) NOT NULL DEFAULT 0,
total_loss DECIMAL(15, 4) NOT NULL DEFAULT 0,
net_profit DECIMAL(15, 4) NOT NULL DEFAULT 0,
return_percent DECIMAL(10, 4) DEFAULT 0,
-- Posiciones actuales
open_positions_count INTEGER NOT NULL DEFAULT 0,
-- Resets
reset_count INTEGER NOT NULL DEFAULT 0,
last_reset_at TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT paper_accounts_balance_check CHECK (initial_balance > 0)
);
COMMENT ON TABLE trading.paper_trading_accounts IS
'Cuentas de paper trading (demo) para practicar sin dinero real';
-- Indices para paper_trading_accounts
CREATE INDEX IF NOT EXISTS idx_paper_accounts_tenant
ON trading.paper_trading_accounts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_paper_accounts_user
ON trading.paper_trading_accounts(user_id);
CREATE INDEX IF NOT EXISTS idx_paper_accounts_active
ON trading.paper_trading_accounts(user_id, is_active)
WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_paper_accounts_default
ON trading.paper_trading_accounts(user_id, is_default)
WHERE is_default = TRUE;
-- ============================================================================
-- TABLA: paper_trading_positions
-- ============================================================================
CREATE TABLE IF NOT EXISTS trading.paper_trading_positions (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES trading.paper_trading_accounts(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Simbolo
symbol VARCHAR(20) NOT NULL,
symbol_id UUID REFERENCES trading.symbols(id),
-- Direccion y estado
direction trading.trade_direction NOT NULL,
status trading.position_status NOT NULL DEFAULT 'open',
order_type trading.order_type NOT NULL DEFAULT 'market',
-- Tamaño
lot_size DECIMAL(10, 4) NOT NULL,
-- Precios
entry_price DECIMAL(15, 8) NOT NULL,
current_price DECIMAL(15, 8),
exit_price DECIMAL(15, 8),
-- Stop Loss / Take Profit
stop_loss DECIMAL(15, 8),
take_profit DECIMAL(15, 8),
trailing_stop_enabled BOOLEAN NOT NULL DEFAULT FALSE,
trailing_stop_distance DECIMAL(10, 4),
-- P&L
profit_loss DECIMAL(15, 4),
profit_loss_pips DECIMAL(10, 4),
unrealized_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0,
-- Margen
margin_used DECIMAL(15, 4),
-- Comisiones simuladas
commission DECIMAL(15, 4) DEFAULT 0,
swap DECIMAL(15, 4) DEFAULT 0,
-- Analisis
max_favorable_excursion DECIMAL(15, 4),
max_adverse_excursion DECIMAL(15, 4),
-- Notas
notes TEXT,
tags VARCHAR(50)[],
-- Timestamps
opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
closed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE trading.paper_trading_positions IS
'Posiciones de paper trading asociadas a cuentas demo';
-- Indices para paper_trading_positions
CREATE INDEX IF NOT EXISTS idx_paper_positions_account
ON trading.paper_trading_positions(account_id);
CREATE INDEX IF NOT EXISTS idx_paper_positions_user
ON trading.paper_trading_positions(user_id);
CREATE INDEX IF NOT EXISTS idx_paper_positions_tenant
ON trading.paper_trading_positions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_paper_positions_symbol
ON trading.paper_trading_positions(symbol);
CREATE INDEX IF NOT EXISTS idx_paper_positions_status
ON trading.paper_trading_positions(status);
CREATE INDEX IF NOT EXISTS idx_paper_positions_open
ON trading.paper_trading_positions(account_id, status)
WHERE status = 'open';
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Trigger para updated_at en accounts
DROP TRIGGER IF EXISTS paper_account_updated_at ON trading.paper_trading_accounts;
CREATE TRIGGER paper_account_updated_at
BEFORE UPDATE ON trading.paper_trading_accounts
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Trigger para updated_at en positions
DROP TRIGGER IF EXISTS paper_position_updated_at ON trading.paper_trading_positions;
CREATE TRIGGER paper_position_updated_at
BEFORE UPDATE ON trading.paper_trading_positions
FOR EACH ROW
EXECUTE FUNCTION trading.update_trading_timestamp();
-- Trigger para asegurar solo una cuenta default
CREATE OR REPLACE FUNCTION trading.ensure_single_default_paper_account()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.is_default = TRUE THEN
UPDATE trading.paper_trading_accounts
SET is_default = FALSE
WHERE user_id = NEW.user_id
AND id != NEW.id
AND is_default = TRUE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS paper_account_single_default ON trading.paper_trading_accounts;
CREATE TRIGGER paper_account_single_default
BEFORE INSERT OR UPDATE OF is_default ON trading.paper_trading_accounts
FOR EACH ROW
WHEN (NEW.is_default = TRUE)
EXECUTE FUNCTION trading.ensure_single_default_paper_account();
-- Trigger para actualizar cuenta al abrir posicion
CREATE OR REPLACE FUNCTION trading.update_paper_account_on_position_open()
RETURNS TRIGGER AS $$
DECLARE
v_margin DECIMAL(15, 4);
v_account RECORD;
BEGIN
IF NEW.status = 'open' AND (OLD IS NULL OR OLD.status = 'pending') THEN
SELECT * INTO v_account FROM trading.paper_trading_accounts WHERE id = NEW.account_id;
-- Calcular margen requerido
v_margin := (NEW.lot_size * 100000 * NEW.entry_price) / v_account.leverage;
NEW.margin_used := v_margin;
-- Actualizar cuenta
UPDATE trading.paper_trading_accounts
SET used_margin = used_margin + v_margin,
free_margin = current_balance - (used_margin + v_margin),
open_positions_count = open_positions_count + 1,
margin_level = CASE WHEN (used_margin + v_margin) > 0
THEN (equity / (used_margin + v_margin)) * 100
ELSE NULL END
WHERE id = NEW.account_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS paper_position_open ON trading.paper_trading_positions;
CREATE TRIGGER paper_position_open
BEFORE INSERT OR UPDATE OF status ON trading.paper_trading_positions
FOR EACH ROW
EXECUTE FUNCTION trading.update_paper_account_on_position_open();
-- Trigger para actualizar cuenta al cerrar posicion
CREATE OR REPLACE FUNCTION trading.update_paper_account_on_position_close()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status IN ('closed', 'stopped', 'target_hit') AND OLD.status = 'open' THEN
NEW.closed_at := NOW();
-- Calcular P&L
IF NEW.direction = 'long' THEN
NEW.profit_loss_pips := (NEW.exit_price - NEW.entry_price) * 10000;
ELSE
NEW.profit_loss_pips := (NEW.entry_price - NEW.exit_price) * 10000;
END IF;
NEW.profit_loss := NEW.profit_loss_pips * NEW.lot_size * 10; -- Aproximacion simplificada
-- Actualizar cuenta
UPDATE trading.paper_trading_accounts
SET current_balance = current_balance + COALESCE(NEW.profit_loss, 0),
used_margin = used_margin - COALESCE(NEW.margin_used, 0),
free_margin = (current_balance + COALESCE(NEW.profit_loss, 0)) - (used_margin - COALESCE(NEW.margin_used, 0)),
open_positions_count = open_positions_count - 1,
total_trades = total_trades + 1,
winning_trades = winning_trades + CASE WHEN NEW.profit_loss > 0 THEN 1 ELSE 0 END,
losing_trades = losing_trades + CASE WHEN NEW.profit_loss < 0 THEN 1 ELSE 0 END,
total_profit = total_profit + CASE WHEN NEW.profit_loss > 0 THEN NEW.profit_loss ELSE 0 END,
total_loss = total_loss + CASE WHEN NEW.profit_loss < 0 THEN ABS(NEW.profit_loss) ELSE 0 END,
realized_pnl = realized_pnl + COALESCE(NEW.profit_loss, 0)
WHERE id = NEW.account_id;
-- Recalcular estadisticas
PERFORM trading.recalculate_paper_account_stats(NEW.account_id);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS paper_position_close ON trading.paper_trading_positions;
CREATE TRIGGER paper_position_close
BEFORE UPDATE OF status ON trading.paper_trading_positions
FOR EACH ROW
EXECUTE FUNCTION trading.update_paper_account_on_position_close();
-- ============================================================================
-- FUNCIONES
-- ============================================================================
-- Funcion para recalcular estadisticas de cuenta
CREATE OR REPLACE FUNCTION trading.recalculate_paper_account_stats(p_account_id UUID)
RETURNS VOID AS $$
DECLARE
v_account RECORD;
BEGIN
SELECT * INTO v_account FROM trading.paper_trading_accounts WHERE id = p_account_id;
UPDATE trading.paper_trading_accounts
SET win_rate = CASE WHEN total_trades > 0
THEN (winning_trades::DECIMAL / total_trades * 100)
ELSE 0 END,
profit_factor = CASE WHEN total_loss > 0
THEN total_profit / total_loss
ELSE 0 END,
net_profit = total_profit - total_loss,
return_percent = CASE WHEN initial_balance > 0
THEN ((current_balance - initial_balance) / initial_balance * 100)
ELSE 0 END,
equity = current_balance + unrealized_pnl
WHERE id = p_account_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para resetear cuenta paper trading
CREATE OR REPLACE FUNCTION trading.reset_paper_account(
p_account_id UUID,
p_new_balance DECIMAL(15, 2) DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_account RECORD;
BEGIN
SELECT * INTO v_account FROM trading.paper_trading_accounts WHERE id = p_account_id;
IF v_account IS NULL THEN
RETURN FALSE;
END IF;
-- Cerrar todas las posiciones abiertas
UPDATE trading.paper_trading_positions
SET status = 'cancelled',
closed_at = NOW()
WHERE account_id = p_account_id
AND status = 'open';
-- Resetear cuenta
UPDATE trading.paper_trading_accounts
SET current_balance = COALESCE(p_new_balance, initial_balance),
equity = COALESCE(p_new_balance, initial_balance),
used_margin = 0,
free_margin = COALESCE(p_new_balance, initial_balance),
margin_level = NULL,
unrealized_pnl = 0,
realized_pnl = 0,
total_trades = 0,
winning_trades = 0,
losing_trades = 0,
win_rate = 0,
profit_factor = 0,
max_drawdown = 0,
max_drawdown_percent = 0,
total_profit = 0,
total_loss = 0,
net_profit = 0,
return_percent = 0,
open_positions_count = 0,
reset_count = reset_count + 1,
last_reset_at = NOW()
WHERE id = p_account_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- VISTAS
-- ============================================================================
-- Vista de cuentas paper trading
CREATE OR REPLACE VIEW trading.v_paper_accounts AS
SELECT
pa.id,
pa.user_id,
pa.name,
pa.initial_balance,
pa.current_balance,
pa.currency,
pa.leverage,
pa.equity,
pa.unrealized_pnl,
pa.realized_pnl,
pa.return_percent,
pa.win_rate,
pa.profit_factor,
pa.total_trades,
pa.open_positions_count,
pa.is_default,
pa.is_active
FROM trading.paper_trading_accounts pa
WHERE pa.is_active = TRUE
ORDER BY pa.is_default DESC, pa.created_at DESC;
-- Vista de posiciones abiertas paper trading
CREATE OR REPLACE VIEW trading.v_paper_open_positions AS
SELECT
pp.id,
pp.account_id,
pa.name AS account_name,
pp.symbol,
pp.direction,
pp.lot_size,
pp.entry_price,
pp.current_price,
pp.stop_loss,
pp.take_profit,
pp.unrealized_pnl,
pp.margin_used,
pp.opened_at
FROM trading.paper_trading_positions pp
JOIN trading.paper_trading_accounts pa ON pp.account_id = pa.id
WHERE pp.status = 'open'
ORDER BY pp.opened_at DESC;
-- ============================================================================
-- RLS POLICIES
-- ============================================================================
ALTER TABLE trading.paper_trading_accounts ENABLE ROW LEVEL SECURITY;
CREATE POLICY paper_accounts_tenant_isolation ON trading.paper_trading_accounts
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY paper_accounts_user_isolation ON trading.paper_trading_accounts
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
ALTER TABLE trading.paper_trading_positions ENABLE ROW LEVEL SECURITY;
CREATE POLICY paper_positions_tenant_isolation ON trading.paper_trading_positions
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY paper_positions_user_isolation ON trading.paper_trading_positions
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT, INSERT, UPDATE, DELETE ON trading.paper_trading_accounts TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON trading.paper_trading_positions TO trading_app;
GRANT SELECT ON trading.paper_trading_accounts TO trading_readonly;
GRANT SELECT ON trading.paper_trading_positions TO trading_readonly;
GRANT SELECT ON trading.v_paper_accounts TO trading_app;
GRANT SELECT ON trading.v_paper_open_positions TO trading_app;
GRANT EXECUTE ON FUNCTION trading.recalculate_paper_account_stats TO trading_app;
GRANT EXECUTE ON FUNCTION trading.reset_paper_account TO trading_app;