Кабинет: курсы ЦБ, история стоимости портфеля, быстрые операции, контакты в профиле
Курсы валют. Оценивать замещающие и еврооблигации по курсу из копии учётной системы было нельзя: в DEPO он от 31.05.2026, в SPECTRE — от 18.07.2026. Берём официальный курс Банка России (открытый XML, без ключа — тем же каналом пользуется Инвест-хаб) и складываем по дням в lk.fx_rates: без интернета кабинет считает по последнему известному курсу, а не отказывается оценивать. Источник переключается в настройках («Данные кабинета»), там же кнопка «Обновить курсы сейчас». Портфель БР-719: 560,3 млн ₽ против 522 млн по старому курсу. История стоимости. Ни QUIK, ни SPECTRE её не хранят, поэтому раз в день после закрытия торгов считаем стоимость каждого портфеля по экспорту QUIK и пишем строку в lk.portfolio_daily (257 портфелей за 14 секунд). Кабинет рисует по ним график на странице портфеля — SVG собирается на сервере, скриптов в кабинете по-прежнему нет. Режим по умолчанию — «автоматически»: ведём, если настроен экспорт QUIK, потому что задним числом историю не восстановить. Операции. Связка «JOIN OD_RESTS ON sr.ID = CASE WHEN w.D_REST IN (…)» заставляла SQL Server разбирать список счетов на каждой строке журнала проводок: год данных читался 6–7 секунд. Обе стороны проводки выбираются заранее двумя ветвями UNION ALL, индексы IX_WIRING_D_REST_WIRDATE и IX_WIRING_K_REST_WIRDATE отрабатывают поиском — 89 мс на тех же 64 строках. Период по умолчанию вернули к году, ответ кэшируется на пять минут. Профиль: почта, телефон и прошлый вход из анкеты — клиент видит, куда с ним свяжется депозитарий, и заметит устаревшие данные. Правка только через менеджера. Заказ документов из кабинета убран совсем (форма была убрана раньше, теперь нет и обработчика). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
package lkgateway
|
||||
|
||||
// lksnapshot.go — ежедневный срез стоимости портфелей для графика в кабинете.
|
||||
//
|
||||
// Ни QUIK, ни SPECTRE не хранят историю стоимости: экспорт QUIK показывает позиции
|
||||
// «сейчас», SPECTRE — остатки на дату, но переоценивать каждый день задним числом
|
||||
// дорого и всё равно неточно (котировки прошлых дней в копии не полны). Поэтому раз
|
||||
// в день, после закрытия торгов, считаем стоимость каждого портфеля по экспорту QUIK
|
||||
// и складываем одну строку на договор в lk.portfolio_daily. Из этих строк кабинет
|
||||
// рисует график: чем дольше работает система, тем длиннее история.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const lkSnapshotSchema = `
|
||||
CREATE TABLE IF NOT EXISTS lk.portfolio_daily (
|
||||
day DATE NOT NULL, contract VARCHAR(64) NOT NULL,
|
||||
value DOUBLE PRECISION NOT NULL DEFAULT 0, cash DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
positions INT NOT NULL DEFAULT 0, priced INT NOT NULL DEFAULT 0,
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'quik', created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (day, contract))`
|
||||
|
||||
// lkSnapshotEnabled — ведём ли историю стоимости. По умолчанию (режим не выбран)
|
||||
// ведём, если настроен экспорт QUIK: история накапливается только вперёд, и
|
||||
// упущенные дни потом не восстановить.
|
||||
func lkSnapshotEnabled(rc *RuntimeConfig) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(rc.Snapshot().LK.SnapshotMode)) {
|
||||
case "on":
|
||||
return true
|
||||
case "off":
|
||||
return false
|
||||
}
|
||||
_, err := nprDB(rc)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// lkSnapshotTime — когда снимаем срез (по умолчанию 20:30, после закрытия основной сессии).
|
||||
func lkSnapshotTime(s LKSettings) (hour, min int) {
|
||||
hour, min = 20, 30
|
||||
v := strings.TrimSpace(s.SnapshotAt)
|
||||
if len(v) == 5 && v[2] == ':' {
|
||||
h := int(v[0]-'0')*10 + int(v[1]-'0')
|
||||
m := int(v[3]-'0')*10 + int(v[4]-'0')
|
||||
if h >= 0 && h < 24 && m >= 0 && m < 60 {
|
||||
hour, min = h, m
|
||||
}
|
||||
}
|
||||
return hour, min
|
||||
}
|
||||
|
||||
// LKDailyPoint — точка графика стоимости портфеля.
|
||||
type LKDailyPoint struct {
|
||||
Day string `json:"day"` // 2026-09-18
|
||||
Value float64 `json:"value"` // бумаги, ₽
|
||||
Cash float64 `json:"cash"` // деньги в рублях
|
||||
Total float64 `json:"total"` // бумаги + деньги
|
||||
Positions int `json:"positions"`
|
||||
}
|
||||
|
||||
// lkSnapshotHistory — история стоимости портфеля за последние days дней.
|
||||
func lkSnapshotHistory(ctx context.Context, rc *RuntimeConfig, contract string, days int) ([]LKDailyPoint, error) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
num := lkContractNumber(contract)
|
||||
if num == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if days <= 0 || days > 3650 {
|
||||
days = 365
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
rows, err := db.QueryContext(cctx, `SELECT day, value, cash, positions FROM lk.portfolio_daily
|
||||
WHERE contract = $1 AND day >= current_date - $2::int ORDER BY day`, num, days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []LKDailyPoint
|
||||
for rows.Next() {
|
||||
var p LKDailyPoint
|
||||
var day time.Time
|
||||
if rows.Scan(&day, &p.Value, &p.Cash, &p.Positions) != nil {
|
||||
continue
|
||||
}
|
||||
p.Day = day.Format("2006-01-02")
|
||||
p.Total = p.Value + p.Cash
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// lkSnapshotSave — записать срез одного портфеля за день (повторный запуск перезаписывает).
|
||||
func lkSnapshotSave(ctx context.Context, rc *RuntimeConfig, day time.Time, contract string, p *LKPortfolio) error {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.ExecContext(cctx, `INSERT INTO lk.portfolio_daily(day, contract, value, cash, positions, priced, source)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (day, contract) DO UPDATE SET value = EXCLUDED.value, cash = EXCLUDED.cash,
|
||||
positions = EXCLUDED.positions, priced = EXCLUDED.priced, source = EXCLUDED.source, created_at = now()`,
|
||||
day.Format("2006-01-02"), lkContractNumber(contract), p.Securities, p.CashRUB,
|
||||
len(p.Positions), p.Priced, p.Source)
|
||||
return err
|
||||
}
|
||||
|
||||
// lkSnapshotContracts — чьи портфели снимаем: договоры, по которым в экспорте QUIK
|
||||
// есть ненулевые позиции. Берём коды клиентов из экспорта и переводим их в номера
|
||||
// договоров депозитария (BR746 → БР-746), иначе пришлось бы перебирать 1300 договоров.
|
||||
func lkSnapshotContracts(ctx context.Context, rc *RuntimeConfig) []string {
|
||||
p, err := nprDB(rc)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
rows, err := p.Query(cctx, `SELECT DISTINCT "ClientCode" FROM "DepoLimits"
|
||||
WHERE "LimitKind" = 'T2' AND "CurrentBal" <> 0 AND "ClientCode" <> ''`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
live := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var code string
|
||||
if rows.Scan(&code) == nil {
|
||||
live[strings.ToUpper(strings.TrimSpace(code))] = true
|
||||
}
|
||||
}
|
||||
// номер договора знает только депозитарий: сопоставляем его коды с живыми в QUIK
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, full := range depoSuggestContracts(ctx, rc.Snapshot().Fancy) {
|
||||
num := lkContractNumber(full)
|
||||
if num == "" || seen[num] {
|
||||
continue
|
||||
}
|
||||
for _, code := range lkQuikCodes(num) {
|
||||
if live[strings.ToUpper(code)] {
|
||||
seen[num] = true
|
||||
out = append(out, num)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lkSnapshotRun — один проход: считает и сохраняет срез за день по всем портфелям.
|
||||
func lkSnapshotRun(ctx context.Context, rc *RuntimeConfig, day time.Time) (int, error) {
|
||||
contracts := lkSnapshotContracts(ctx, rc)
|
||||
saved := 0
|
||||
for _, num := range contracts {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return saved, ctx.Err()
|
||||
default:
|
||||
}
|
||||
pf, err := lkOnlinePortfolio(ctx, rc, num)
|
||||
if err != nil || pf == nil {
|
||||
continue
|
||||
}
|
||||
if err := lkSnapshotSave(ctx, rc, day, num, pf); err == nil {
|
||||
saved++
|
||||
}
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
// lkSnapshotLoop — раз в день в назначенное время снимает срез. Если сервер был
|
||||
// выключен и срез за сегодня не снят, а время уже прошло — снимаем при старте.
|
||||
func (s *Server) lkSnapshotLoop(ctx context.Context) {
|
||||
t := time.NewTicker(5 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
if !lkSnapshotEnabled(s.rc) {
|
||||
continue
|
||||
}
|
||||
set := s.rc.Snapshot()
|
||||
hour, min := lkSnapshotTime(set.LK)
|
||||
now := time.Now()
|
||||
if now.Hour() < hour || (now.Hour() == hour && now.Minute() < min) {
|
||||
continue
|
||||
}
|
||||
day := now.Truncate(24 * time.Hour)
|
||||
if lkSnapshotDone(ctx, s.rc, now) {
|
||||
continue
|
||||
}
|
||||
n, err := lkSnapshotRun(ctx, s.rc, day)
|
||||
if err != nil {
|
||||
log.Printf("кабинет: срез стоимости портфелей прерван: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("кабинет: срез стоимости портфелей за %s — %d договоров", now.Format("02.01.2006"), n)
|
||||
}
|
||||
}
|
||||
|
||||
// lkSnapshotDone — срез за этот день уже снят.
|
||||
func lkSnapshotDone(ctx context.Context, rc *RuntimeConfig, day time.Time) bool {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return true // без базы срез всё равно не сохранить, и жаловаться каждые 5 минут незачем
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var n int
|
||||
if db.QueryRowContext(cctx, `SELECT count(*) FROM lk.portfolio_daily WHERE day = $1`,
|
||||
day.Format("2006-01-02")).Scan(&n) != nil {
|
||||
return true
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
Reference in New Issue
Block a user