-- Миграция: схема npr с историей НПР1/НПР2/УДС через триггер на "Portfolio". -- -- Применять ПОСЛЕ того, как таблицы QUIK-экспортёра развёрнуты штатным скриптом -- quikexport_11/scripts/postgre/exporter_pg.sql -- в ту же БД. В эталонной "Portfolio" уже есть все нужные колонки -- (PortfolioValue, InitMargin, MinMargin, CorrectedMargin, FundsLevel, Status, RiskLevel), -- поэтому отдельного ALTER TABLE не требуется. -- -- Политика записи: триггер пишет в npr.history только когда -- (а) клиент появился впервые, или -- (б) сменилась «зона» УДС (CALL → WARN → OK → INF), или -- (в) прошло более 30 мин с последней записи по этому клиенту (heartbeat). -- Это резко сокращает объём истории — фиксируются моменты смены состояния, -- а не каждый тик экспортёра. Дополняется политикой хранения (retention). BEGIN; -- (б) Схема для истории НПР/УДС. CREATE SCHEMA IF NOT EXISTS npr; -- (в) Таблица истории. Append-only: свой BIGSERIAL PK, без UNIQUE по клиенту. CREATE TABLE IF NOT EXISTS npr.history ( id BIGSERIAL PRIMARY KEY, ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), firm_id varchar(12) NOT NULL, client_code varchar(12) NOT NULL, limit_kind varchar(5) NOT NULL, curr_code varchar(4) NOT NULL, margin_type varchar(4) NULL, portfolio_value decimal(28, 8) NULL, init_margin float NULL, min_margin float NULL, corrected_margin float NULL, rcv1 float NULL, rcv2 float NULL, funds_level decimal(19, 2) NULL, status int NULL, risk_level int NULL ); -- (г) Индекс для выборок «история по клиенту за период». CREATE INDEX IF NOT EXISTS ix_npr_hist_client_ts ON npr.history (client_code, ts DESC); -- «Пульс» от экспортёра: триггер обновляет last_tick на каждом тике (не чаще раза в 10 сек). -- Нужен отдельно от истории, чтобы UI видел «данные от QUIK приходят» даже когда зоны не меняются. CREATE TABLE IF NOT EXISTS npr.heartbeat ( id int PRIMARY KEY DEFAULT 1, last_tick timestamptz NOT NULL DEFAULT NOW(), tick_count bigint NOT NULL DEFAULT 0 ); INSERT INTO npr.heartbeat (id) VALUES (1) ON CONFLICT DO NOTHING; -- Кэш «последнее записанное состояние» — нужен триггеру, чтобы понять, -- изменилась ли зона относительно ранее сохранённой строки. Одна запись на клиентский PK. CREATE TABLE IF NOT EXISTS npr.last_seen ( firm_id varchar(12) NOT NULL, client_code varchar(12) NOT NULL, limit_kind varchar(5) NOT NULL, curr_code varchar(12) NOT NULL, tag varchar(4) NOT NULL, last_ts timestamptz NOT NULL, last_zone text NOT NULL, last_uds numeric(19,2), PRIMARY KEY (firm_id, client_code, limit_kind, curr_code, tag) ); -- Зона УДС: CALL / WARN / OK / INF / UNKNOWN. Используется и триггером, и отчётами. CREATE OR REPLACE FUNCTION npr._zone(fl numeric) RETURNS text AS $$ SELECT CASE WHEN fl IS NULL THEN 'UNKNOWN' WHEN fl >= 9.99 THEN 'INF' WHEN fl <= -9.99 THEN 'CALL' WHEN fl < 0 THEN 'CALL' WHEN fl < 1 THEN 'CALL' WHEN fl < 1.5 THEN 'WARN' ELSE 'OK' END; $$ LANGUAGE sql IMMUTABLE; -- (д) Триггер-функция: новая запись в history только при смене зоны или раз в 30 мин. -- Фильтр МД / МД+ / С применяется сразу, чтобы не тратить ресурсы на остальных. CREATE OR REPLACE FUNCTION npr.trg_portfolio_to_history() RETURNS trigger LANGUAGE plpgsql AS $BODY$ DECLARE new_zone text; last_ts timestamptz; last_zone text; should_write boolean := false; BEGIN -- Пульс: обновляем не чаще раза в 10 сек, чтобы не делать тысяч UPDATE на один тик экспортёра. UPDATE npr.heartbeat SET last_tick = NOW(), tick_count = tick_count + 1 WHERE id = 1 AND last_tick < NOW() - INTERVAL '10 seconds'; IF NEW."MarginType" NOT IN ('МД','МД+','С') THEN RETURN NEW; END IF; new_zone := npr._zone(NEW."FundsLevel"); SELECT ls.last_ts, ls.last_zone INTO last_ts, last_zone FROM npr.last_seen ls WHERE ls.firm_id = NEW."FirmId" AND ls.client_code = NEW."ClientCode" AND ls.limit_kind = NEW."LimitKind" AND ls.curr_code = NEW."CurrCode" AND ls.tag = NEW."Tag"; IF last_ts IS NULL THEN should_write := true; -- впервые видим клиента ELSIF last_zone IS DISTINCT FROM new_zone THEN should_write := true; -- переход зоны ELSIF NOW() - last_ts > INTERVAL '30 minutes' THEN should_write := true; -- heartbeat END IF; IF should_write THEN INSERT INTO npr.history ( firm_id, client_code, limit_kind, curr_code, margin_type, portfolio_value, init_margin, min_margin, corrected_margin, rcv1, rcv2, funds_level, status, risk_level ) VALUES ( NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."MarginType", NEW."PortfolioValue", NEW."InitMargin", NEW."MinMargin", NEW."CorrectedMargin", NEW."RCV1", NEW."RCV2", NEW."FundsLevel", NEW."Status", NEW."RiskLevel" ); INSERT INTO npr.last_seen (firm_id, client_code, limit_kind, curr_code, tag, last_ts, last_zone, last_uds) VALUES (NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."Tag", NOW(), new_zone, NEW."FundsLevel") ON CONFLICT (firm_id, client_code, limit_kind, curr_code, tag) DO UPDATE SET last_ts = EXCLUDED.last_ts, last_zone = EXCLUDED.last_zone, last_uds = EXCLUDED.last_uds; END IF; RETURN NEW; END; $BODY$; -- (е) Триггер на "Portfolio" — AFTER INSERT OR UPDATE. DROP TRIGGER IF EXISTS portfolio_to_npr_history ON "Portfolio"; CREATE TRIGGER portfolio_to_npr_history AFTER INSERT OR UPDATE ON "Portfolio" FOR EACH ROW EXECUTE FUNCTION npr.trg_portfolio_to_history(); -- (е2) Повторный INSERT в "Portfolio" → UPDATE без ошибки. -- Экспортёр каждый цикл сначала делает подготовленный INSERT по всем строкам -- и по дубликату ключа переходит к UPDATE. В PostgreSQL 11 каждая ошибка -- подготовленного INSERT оставляет ~20 КБ в памяти сессии: ~10 тыс. строк/мин -- дают ≈200 МБ/мин, backend дорастает до OOM и роняет весь кластер (14.09.2026). -- AFTER UPDATE триггер истории выше срабатывает как обычно. CREATE OR REPLACE FUNCTION npr.trg_portfolio_insert_as_update() RETURNS trigger LANGUAGE plpgsql AS $BODY$ BEGIN UPDATE "Portfolio" SET "MarginType" = NEW."MarginType", "Status" = NEW."Status", "PortfolioValue" = NEW."PortfolioValue", "InitMargin" = NEW."InitMargin", "MinMargin" = NEW."MinMargin", "CorrectedMargin" = NEW."CorrectedMargin", "RCV1" = NEW."RCV1", "RCV2" = NEW."RCV2", "RiskLevel" = NEW."RiskLevel", "FundsLevel" = NEW."FundsLevel" WHERE "FirmId" = NEW."FirmId" AND "ClientCode" = NEW."ClientCode" AND "LimitKind" = NEW."LimitKind" AND "CurrCode" = NEW."CurrCode" AND "Tag" = NEW."Tag"; IF FOUND THEN RETURN NULL; END IF; RETURN NEW; END; $BODY$; DROP TRIGGER IF EXISTS portfolio_insert_as_update ON "Portfolio"; CREATE TRIGGER portfolio_insert_as_update BEFORE INSERT ON "Portfolio" FOR EACH ROW EXECUTE FUNCTION npr.trg_portfolio_insert_as_update(); -- (ж) Прикладная вьюха: УДС с обработкой QUIK-сентинелов и маржинальное требование. -- QUIK кладёт УДС в диапазон [-9.99, 9.99]: 9.99 = «бесконечность» (капнут сверху), -- -9.99 = маржин-колл / принудзакрытие (капнут снизу). CREATE OR REPLACE VIEW npr.v_npr AS SELECT ts, firm_id, client_code, margin_type, portfolio_value, init_margin, min_margin, rcv1, rcv2, CASE WHEN funds_level >= 9.99 THEN NULL ELSE funds_level END AS uds, CASE WHEN funds_level >= 9.99 THEN 'INF' WHEN funds_level <= -9.99 THEN 'CALL' ELSE NULL END AS uds_flag, GREATEST(-rcv1, 0) AS margin_requirement FROM npr.history; COMMIT; -- =========================================================================== -- Retention-политика: cron-файл /etc/cron.d/npr-retention чистит записи > 2 лет -- раз в месяц: -- 0 3 1 * * postgres psql -d quik_export -c \ -- "DELETE FROM npr.history WHERE ts < NOW() - INTERVAL '2 years';" -- Если нужно партиционирование по месяцам — отдельная миграция. -- =========================================================================== -- npr.v_current (текущее состояние по клиентам; снято с рабочей базы 14.09.2026) CREATE OR REPLACE VIEW npr.v_current AS SELECT p."FirmId" AS firm_id, p."ClientCode" AS client_code, p."LimitKind" AS limit_kind, p."CurrCode" AS curr_code, p."Tag" AS tag, p."MarginType" AS margin_type, p."RCV1" AS rcv1, p."RCV2" AS rcv2, p."FundsLevel" AS funds_level, p."PortfolioValue" AS portfolio_value, p."InitMargin" AS init_margin, p."MinMargin" AS min_margin, p."CorrectedMargin" AS corrected_margin, p."Status" AS status, p."RiskLevel" AS risk_level, CASE WHEN p."FundsLevel" >= 9.99 THEN NULL::numeric ELSE p."FundsLevel" END AS uds, CASE WHEN p."FundsLevel" >= 9.99 THEN 'INF'::text WHEN p."FundsLevel" <= '-9.99'::numeric THEN 'CALL'::text ELSE NULL::text END AS uds_flag, GREATEST(- p."RCV1", 0::double precision) AS margin_requirement, npr._zone(p."FundsLevel") AS zone, hb.last_tick AS ts FROM "Portfolio" p CROSS JOIN ( SELECT heartbeat.last_tick FROM npr.heartbeat WHERE heartbeat.id = 1) hb WHERE p."MarginType"::text = ANY (ARRAY['МД'::text, 'МД+'::text, 'С'::text, 'C'::text]); GRANT SELECT ON npr.v_current TO quik_ro;