-- ============================================================ -- Migration 012: Atomic ENTRY Lifecycle Persistence -- Date: 2026-02-16 -- Purpose: -- 1) Add explicit trade_lifecycle and positions tables for traceability. -- 2) Harden orders/trade_history uniqueness to drive idempotent RPC inserts. -- 3) Expose fn_persist_entry_lifecycle for exchange-first persistence. -- ============================================================ BEGIN; -- -------------------------------------------------------------------------------- -- Ensure lifecycle table captures profile + trade scope -- -------------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS trade_lifecycle ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), profile_id uuid NOT NULL REFERENCES trade_profiles(id) ON DELETE CASCADE, trade_id text NOT NULL, entry_order_id text NOT NULL, current_stage text NOT NULL DEFAULT 'ENTRY', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT trade_lifecycle_profile_trade_unique UNIQUE (profile_id, trade_id) ); CREATE INDEX IF NOT EXISTS idx_trade_lifecycle_entry_order ON trade_lifecycle(entry_order_id); -- -------------------------------------------------------------------------------- -- Positions table for lifecycle-driven snapshots -- -------------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS positions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), profile_id uuid NOT NULL, trade_id text NOT NULL, entry_order_id text NOT NULL, symbol text NOT NULL, side text NOT NULL, quantity numeric NOT NULL DEFAULT 0, avg_price numeric NOT NULL DEFAULT 0, status text NOT NULL DEFAULT 'OPEN', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT positions_profile_trade_unique UNIQUE (profile_id, trade_id), CONSTRAINT positions_lifecycle_fk FOREIGN KEY (profile_id, trade_id) REFERENCES trade_lifecycle(profile_id, trade_id) ON DELETE CASCADE, CONSTRAINT chk_positions_side CHECK (side IN ('BUY','SELL')) ); CREATE INDEX IF NOT EXISTS idx_positions_profile_trade ON positions (profile_id, trade_id); CREATE INDEX IF NOT EXISTS idx_positions_symbol ON positions (symbol); -- -------------------------------------------------------------------------------- -- Harden orders + trade_history uniqueness for idempotent child inserts -- -------------------------------------------------------------------------------- CREATE UNIQUE INDEX IF NOT EXISTS uq_orders_order_id ON orders (order_id) WHERE order_id IS NOT NULL; ALTER TABLE IF EXISTS trade_history ADD COLUMN IF NOT EXISTS lifecycle_marker text; CREATE UNIQUE INDEX IF NOT EXISTS uq_trade_history_lifecycle_marker ON trade_history (lifecycle_marker) WHERE lifecycle_marker IS NOT NULL; COMMIT; -- -------------------------------------------------------------------------------- -- Entry persistence RPC -- -------------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION fn_persist_entry_lifecycle( p_profile_id uuid, p_trade_id text, p_order_id text, p_symbol text, p_side text, p_type text, p_quantity numeric, p_price numeric, p_status text, p_timestamp bigint, p_stop_loss numeric DEFAULT NULL, p_take_profit numeric DEFAULT NULL, p_source text DEFAULT 'BOT', p_reason text DEFAULT 'Entry lifecycle persisted' ) RETURNS jsonb AS $$ DECLARE lifecycle_row trade_lifecycle%ROWTYPE; entry_marker text := format('entry:%s', p_trade_id); BEGIN BEGIN INSERT INTO trade_lifecycle ( profile_id, trade_id, entry_order_id, current_stage, created_at, updated_at ) VALUES ( p_profile_id, p_trade_id, p_order_id, 'ENTRY', now(), now() ) RETURNING * INTO lifecycle_row; EXCEPTION WHEN unique_violation THEN UPDATE trade_lifecycle SET updated_at = now() WHERE profile_id = p_profile_id AND trade_id = p_trade_id; END; INSERT INTO orders ( order_id, profile_id, trade_id, symbol, type, side, qty, quantity, price, status, timestamp, stop_loss, take_profit, action, source ) VALUES ( p_order_id, p_profile_id, p_trade_id, p_symbol, p_type, upper(p_side), COALESCE(p_quantity, 0), COALESCE(p_quantity, 0), COALESCE(p_price, 0), p_status, p_timestamp, p_stop_loss, p_take_profit, 'ENTRY', p_source ) ON CONFLICT (order_id) DO NOTHING; INSERT INTO positions ( profile_id, trade_id, entry_order_id, symbol, side, quantity, avg_price, status, created_at, updated_at ) VALUES ( p_profile_id, p_trade_id, p_order_id, p_symbol, upper(p_side), 0, COALESCE(p_price, 0), 'OPEN', now(), now() ) ON CONFLICT (profile_id, trade_id) DO NOTHING; INSERT INTO trade_history ( trade_id, profile_id, symbol, side, size, entry_price, exit_price, pnl, pnl_percent, reason, timestamp, stop_loss, take_profit, source, lifecycle_marker ) VALUES ( p_trade_id, p_profile_id, p_symbol, upper(p_side), COALESCE(p_quantity, 0), COALESCE(p_price, 0), NULL, 0, 0, p_reason, p_timestamp, p_stop_loss, p_take_profit, p_source, entry_marker ) ON CONFLICT (lifecycle_marker) DO NOTHING; IF lifecycle_row IS NULL THEN SELECT * INTO lifecycle_row FROM trade_lifecycle WHERE profile_id = p_profile_id AND trade_id = p_trade_id; END IF; RETURN row_to_json(lifecycle_row); END; $$ LANGUAGE plpgsql;