trading-platform-database-v2/ddl/schemas/trading/tables/005_positions.sql

396 lines
13 KiB
PL/PgSQL

-- ============================================================================
-- 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;