Кабинет: курсы ЦБ, история стоимости портфеля, быстрые операции, контакты в профиле
Курсы валют. Оценивать замещающие и еврооблигации по курсу из копии учётной системы было нельзя: в 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,187 @@
|
||||
package lkgateway
|
||||
|
||||
// cbrfx.go — официальные курсы валют Банка России.
|
||||
//
|
||||
// Оценивать валютные выпуски по курсу из учётной системы нельзя: в копии DEPO он
|
||||
// от 31.05.2026, в SPECTRE — от 18.07.2026, то есть отстаёт на месяцы. Банк России
|
||||
// публикует курсы открыто и без ключа (XML, windows-1251), тем же каналом ими
|
||||
// пользуется Инвест-хаб. Забранные курсы складываем в lk.fx_rates: в закрытом
|
||||
// контуре без интернета кабинет продолжит считать по последнему известному курсу,
|
||||
// а для архива на дату курс нужен именно на тот день.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
)
|
||||
|
||||
// cbrDailyURL — «Курсы валют на дату» (без date_req — на сегодня).
|
||||
const cbrDailyURL = "https://www.cbr.ru/scripts/XML_daily.asp"
|
||||
|
||||
// lkFxSchema — курсы валют по дням: source = cbr | accounting.
|
||||
const lkFxSchema = `
|
||||
CREATE TABLE IF NOT EXISTS lk.fx_rates (
|
||||
day DATE NOT NULL, code VARCHAR(8) NOT NULL, rate DOUBLE PRECISION NOT NULL,
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'cbr', fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (day, code))`
|
||||
|
||||
type cbrDoc struct {
|
||||
Date string `xml:"Date,attr"`
|
||||
Valute []struct {
|
||||
CharCode string `xml:"CharCode"`
|
||||
Nominal string `xml:"Nominal"`
|
||||
Value string `xml:"Value"`
|
||||
} `xml:"Valute"`
|
||||
}
|
||||
|
||||
// cbrFetch — курсы ЦБ на дату (нулевая дата = на сегодня). Возвращает дату, на
|
||||
// которую курс установлен: в выходные ЦБ отдаёт курс ближайшего рабочего дня.
|
||||
func cbrFetch(ctx context.Context, day time.Time) (time.Time, map[string]float64, error) {
|
||||
url := cbrDailyURL
|
||||
if !day.IsZero() {
|
||||
url += "?date_req=" + day.Format("02/01/2006")
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return time.Time{}, nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "bj/1.0 (+depozitarij)")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return time.Time{}, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return time.Time{}, nil, fmt.Errorf("Банк России ответил %s", resp.Status)
|
||||
}
|
||||
var doc cbrDoc
|
||||
dec := xml.NewDecoder(io.LimitReader(resp.Body, 1<<20))
|
||||
// Ответ в windows-1251; коды валют латиницей, но без раскодировки Go
|
||||
// откажется разбирать документ целиком.
|
||||
dec.CharsetReader = func(enc string, in io.Reader) (io.Reader, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(enc)) {
|
||||
case "windows-1251", "cp1251":
|
||||
return charmap.Windows1251.NewDecoder().Reader(in), nil
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return time.Time{}, nil, err
|
||||
}
|
||||
at, _ := time.ParseInLocation("02.01.2006", doc.Date, time.Local)
|
||||
out := make(map[string]float64, len(doc.Valute))
|
||||
for _, v := range doc.Valute {
|
||||
code := strings.ToUpper(strings.TrimSpace(v.CharCode))
|
||||
val, err1 := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v.Value), ",", "."), 64)
|
||||
nom, err2 := strconv.ParseFloat(strings.TrimSpace(v.Nominal), 64)
|
||||
if code == "" || err1 != nil || val <= 0 {
|
||||
continue
|
||||
}
|
||||
if err2 != nil || nom <= 0 {
|
||||
nom = 1
|
||||
}
|
||||
out[code] = val / nom // курс за одну единицу валюты
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return at, nil, fmt.Errorf("Банк России не вернул ни одного курса")
|
||||
}
|
||||
return at, out, nil
|
||||
}
|
||||
|
||||
// ---- хранение ----
|
||||
|
||||
// lkFxSave — сложить курсы дня в базу (перезаписывая курс того же дня).
|
||||
func lkFxSave(ctx context.Context, rc *RuntimeConfig, day time.Time, source string, rates map[string]float64) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil || day.IsZero() || len(rates) == 0 {
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
for code, rate := range rates {
|
||||
_, _ = db.ExecContext(cctx, `INSERT INTO lk.fx_rates(day, code, rate, source, fetched_at)
|
||||
VALUES ($1,$2,$3,$4, now())
|
||||
ON CONFLICT (day, code) DO UPDATE SET rate = EXCLUDED.rate, source = EXCLUDED.source, fetched_at = now()`,
|
||||
day.Format("2006-01-02"), code, rate, source)
|
||||
}
|
||||
}
|
||||
|
||||
// lkFxLoad — последний известный курс каждой валюты не позже дня day
|
||||
// (нулевая дата = последний известный вообще).
|
||||
func lkFxLoad(ctx context.Context, rc *RuntimeConfig, day time.Time) map[string]LKFxRate {
|
||||
out := map[string]LKFxRate{}
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
upto := "9999-12-31"
|
||||
if !day.IsZero() {
|
||||
upto = day.Format("2006-01-02")
|
||||
}
|
||||
rows, err := db.QueryContext(cctx, `SELECT DISTINCT ON (code) code, day, rate
|
||||
FROM lk.fx_rates WHERE day <= $1 ORDER BY code, day DESC`, upto)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var code string
|
||||
var at time.Time
|
||||
var rate float64
|
||||
if rows.Scan(&code, &at, &rate) == nil && rate > 0 {
|
||||
out[strings.ToUpper(code)] = LKFxRate{Rate: rate, At: at}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- курс на сегодня ----
|
||||
|
||||
var cbrCache = struct {
|
||||
mu sync.Mutex
|
||||
at time.Time // когда ходили в ЦБ
|
||||
day time.Time // на какую дату курс
|
||||
m map[string]float64
|
||||
err string
|
||||
}{}
|
||||
|
||||
// cbrToday — курсы ЦБ на сегодня с кэшем на час. Ошибку запоминаем, чтобы
|
||||
// в закрытом контуре не ходить в недоступный интернет на каждой странице.
|
||||
func cbrToday(ctx context.Context, rc *RuntimeConfig) (map[string]LKFxRate, string) {
|
||||
cbrCache.mu.Lock()
|
||||
fresh := time.Since(cbrCache.at) < time.Hour
|
||||
m, day, cerr := cbrCache.m, cbrCache.day, cbrCache.err
|
||||
cbrCache.mu.Unlock()
|
||||
if !fresh {
|
||||
at, rates, err := cbrFetch(ctx, time.Time{})
|
||||
cbrCache.mu.Lock()
|
||||
cbrCache.at = time.Now()
|
||||
if err != nil {
|
||||
cbrCache.err = err.Error()
|
||||
} else {
|
||||
cbrCache.m, cbrCache.day, cbrCache.err = rates, at, ""
|
||||
}
|
||||
m, day, cerr = cbrCache.m, cbrCache.day, cbrCache.err
|
||||
cbrCache.mu.Unlock()
|
||||
if err == nil {
|
||||
lkFxSave(ctx, rc, at, "cbr", rates)
|
||||
}
|
||||
}
|
||||
out := make(map[string]LKFxRate, len(m))
|
||||
for code, rate := range m {
|
||||
out[code] = LKFxRate{Rate: rate, At: day}
|
||||
}
|
||||
return out, cerr
|
||||
}
|
||||
Reference in New Issue
Block a user