396 lines
13 KiB
PL/PgSQL
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;
|