da12a9ec1c
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
12 KiB
PL/PgSQL
251 lines
12 KiB
PL/PgSQL
-- Миграция: схема 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;
|