Кабинет клиента: портфель как у брокера — «Онлайн» по QUIK, «Архив» на дату и операции по SPECTRE

- lkclient.go: договор → код QUIK (БР-1545 → BR1545, +OUT), договор → D_B_CONTRACTS SPECTRE
- lkonline.go: позиции DepoLimits (T2, свежая строка на счёт), средняя цена покупки, деньги
  MoneyLimits по валютам, цены и P&L; стоимость, доли
- lkarchive.go: портфель на дату по остаткам OD_RESTS (REG_3 = договор, раздел 39, счета
  50/51/52/55 и 58.01/58.02; 58.05 НКД не показываем) с откатом по OD_TURNS после даты
  (индекс REST+WIRDATE, знак TYPE_ проверен: сумма оборотов = остаток); оценка по котировке
  OD_VALUES_RATES ≤ 90 дней (в рублях за бумагу), иначе балансовая; операции из OD_WIRING
  (покупка/продажа/ввод/вывод/погашение/конвертация, продажа — по счёту ВЫРУЧКА) и
  пополнения/выводы денег; рецепт — InvestControl Hub (build_portfolio_base, deals_report)
- API: /api/lk/portfolio2?view=online|archive&date=, /api/lk/ops (сделки + выплаты дохода);
  без клиента в QUIK онлайн падает на брокерский учёт за сегодня
- кабинет: разделы Портфель (вкладки Онлайн/Архив с датой), Операции, Поручения, Документы,
  Профиль; депозитарные реестр и доходы из клиентского вида убраны (/portfolio → /,
  /income → /operations); проверено на БР-704 и БР-1545
- настройка Fancy.SpectreDatabase (по умолчанию BJ_SPECTRE_DATA)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 15:44:53 +03:00
parent 05d02f22e6
commit 02fabb483d
9 changed files with 1268 additions and 274 deletions
+228
View File
@@ -0,0 +1,228 @@
package lkgateway
// lkonline.go — портфель клиента «сейчас» по экспорту биржевой информации из QUIK.
//
// Это то, что клиент видит у брокера: позиции с количеством, средней ценой покупки, текущей
// ценой и результатом, деньги по валютам, стоимость портфеля. Источник — таблицы экспорта
// DepoLimits (позиции), MoneyLimits (деньги), Securities/Params (бумаги и цены). Клиент
// определяется кодом QUIK, полученным из номера договора (lkQuikCodes).
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// LKPortfolio — портфель для кабинета (онлайн или на дату — одна форма).
type LKPortfolio struct {
Source string `json:"source"` // quik | spectre
AsOf string `json:"as_of"` // на какой момент
Total float64 `json:"total"` // стоимость портфеля, ₽ (бумаги + деньги в рублях)
Securities float64 `json:"securities_value"` // стоимость бумаг, ₽
CashRUB float64 `json:"cash_rub"` // деньги в рублях
Cost float64 `json:"cost"` // вложено (по средней цене / балансовой стоимости), ₽
PnL float64 `json:"pnl"` // результат по позициям, ₽
PnLPct float64 `json:"pnl_pct"` // результат, %
Priced int `json:"priced"` // позиций с ценой
Cash []LKCash `json:"cash"`
Positions []LKPosition `json:"positions"`
Note string `json:"note,omitempty"`
}
// LKCash — деньги в одной валюте.
type LKCash struct {
Currency string `json:"currency"` // SUR, USD, CNY …
Amount float64 `json:"amount"`
}
// LKPosition — позиция по бумаге.
type LKPosition struct {
ISIN string `json:"isin"`
Code string `json:"code,omitempty"` // код в торговой системе
Name string `json:"name"`
Kind string `json:"kind"` // акция | облигация | фонд | …
Quantity float64 `json:"quantity"`
AvgPrice float64 `json:"avg_price,omitempty"` // средняя цена покупки (за бумагу, в тех же единицах, что и Price)
Price float64 `json:"price,omitempty"` // текущая цена
InPct bool `json:"in_pct,omitempty"` // цены в процентах номинала (облигации)
Value float64 `json:"value"` // стоимость, ₽
Cost float64 `json:"cost,omitempty"` // вложено, ₽
PnL float64 `json:"pnl"` // результат, ₽
PnLPct float64 `json:"pnl_pct"` // результат, %
DayPct float64 `json:"day_pct,omitempty"` // изменение за день, %
Share float64 `json:"share"` // доля в портфеле, %
Currency string `json:"currency,omitempty"`
Note string `json:"note,omitempty"`
}
// lkKindByClass — вид бумаги по классу QUIK.
func lkKindByClass(class string) string {
switch {
case class == "TQBR", class == "TQPI", strings.HasPrefix(class, "SPBXM"):
return "акция"
case class == "TQOB", class == "TQCB", class == "TQIR", class == "TQRD", strings.HasSuffix(class, "_BND"):
return "облигация"
case class == "TQTF", class == "TQIF", class == "TQTD":
return "фонд"
case class == "CETS":
return "валюта"
}
return "бумага"
}
// lkOnlinePortfolio — портфель по экспорту QUIK. Ошибка — только если базы экспорта нет.
func lkOnlinePortfolio(ctx context.Context, rc *RuntimeConfig, contract string) (*LKPortfolio, error) {
codes := lkQuikCodes(contract)
if len(codes) == 0 {
return nil, fmt.Errorf("не определён номер договора")
}
p, err := nprDB(rc)
if err != nil {
return nil, fmt.Errorf("база экспорта QUIK недоступна: %v", err)
}
cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
out := &LKPortfolio{Source: "quik"}
// Позиции: свежая строка по каждому (клиент, бумага, торговый счёт), затем сумма по бумаге.
rows, err := p.Query(cctx, `
WITH d AS (
SELECT DISTINCT ON ("ClientCode","SecCode","Trdacc") "SecCode","CurrentBal","AwgPositionPrice","LoadDate"
FROM "DepoLimits" WHERE "ClientCode" = ANY($1) AND "LimitKind"='T2'
ORDER BY "ClientCode","SecCode","Trdacc","LoadDate" DESC)
SELECT "SecCode", SUM("CurrentBal")::float8,
COALESCE(SUM("CurrentBal"*"AwgPositionPrice")/NULLIF(SUM("CurrentBal"),0),0)::float8, MAX("LoadDate")
FROM d WHERE "CurrentBal" <> 0 GROUP BY "SecCode" ORDER BY "SecCode"`, codes)
if err != nil {
return nil, fmt.Errorf("позиции: %v", err)
}
type raw struct {
code string
qty, avg float64
}
var pos []raw
var asOf time.Time
for rows.Next() {
var r raw
var ld *time.Time
if rows.Scan(&r.code, &r.qty, &r.avg, &ld) == nil {
pos = append(pos, r)
if ld != nil && ld.After(asOf) {
asOf = *ld
}
}
}
rows.Close()
// Бумаги: ISIN, имя, класс (основные режимы торгов в приоритете), номинал.
secByCode := map[string]struct {
isin, name, class string
face float64
}{}
if len(pos) > 0 {
codesList := make([]string, 0, len(pos))
for _, r := range pos {
codesList = append(codesList, r.code)
}
srows, err := p.Query(cctx, `
SELECT DISTINCT ON ("SecCode") "SecCode", upper(COALESCE("ISINCode",'')), COALESCE("SecShortName","SecName",''), "ClassCode", COALESCE("FaceValue",0)::float8
FROM "Securities" WHERE "SecCode" = ANY($1)
ORDER BY "SecCode", CASE "ClassCode" WHEN 'TQBR' THEN 1 WHEN 'TQOB' THEN 2 WHEN 'TQCB' THEN 3 WHEN 'TQTF' THEN 4 WHEN 'TQIF' THEN 5 WHEN 'TQRD' THEN 6 ELSE 9 END`, codesList)
if err == nil {
for srows.Next() {
var code, isin, name, class string
var face float64
if srows.Scan(&code, &isin, &name, &class, &face) == nil {
secByCode[code] = struct {
isin, name, class string
face float64
}{isin, name, class, face}
}
}
srows.Close()
}
}
isins := make([]string, 0, len(pos))
for _, r := range pos {
if s, ok := secByCode[r.code]; ok && s.isin != "" {
isins = append(isins, s.isin)
}
}
prices := lkPrices(ctx, rc, isins)
for _, r := range pos {
s := secByCode[r.code]
lp := LKPosition{ISIN: s.isin, Code: r.code, Name: s.name, Kind: lkKindByClass(s.class), Quantity: r.qty, AvgPrice: r.avg, Currency: "SUR"}
if lp.Name == "" {
lp.Name = r.code
}
if px, ok := prices[s.isin]; ok && px.Price > 0 {
lp.Price, lp.InPct, lp.DayPct = px.Price, px.InPct, px.ChangePc
lp.Value = px.Value(r.qty)
out.Priced++
if r.avg > 0 {
// средняя цена в тех же единицах, что и котировка: для облигаций — процент номинала
cost := SecurityPrice{Price: r.avg, Face: px.Face, InPct: px.InPct}
lp.Cost = cost.Value(r.qty)
lp.PnL = lp.Value - lp.Cost
if lp.Cost != 0 {
lp.PnLPct = lp.PnL / lp.Cost * 100
}
}
} else {
lp.Note = "нет биржевой цены"
}
out.Positions = append(out.Positions, lp)
}
// Деньги по валютам.
crows, err := p.Query(cctx, `
WITH m AS (
SELECT DISTINCT ON ("ClientCode","CurrCode","Tag") "CurrCode","CurrentBal","LoadDate"
FROM "MoneyLimits" WHERE "ClientCode" = ANY($1) AND "LimitKind"='T2'
ORDER BY "ClientCode","CurrCode","Tag","LoadDate" DESC)
SELECT "CurrCode", SUM("CurrentBal")::float8, MAX("LoadDate") FROM m GROUP BY "CurrCode" HAVING SUM("CurrentBal") <> 0 ORDER BY "CurrCode"`, codes)
if err == nil {
for crows.Next() {
var c LKCash
var ld *time.Time
if crows.Scan(&c.Currency, &c.Amount, &ld) == nil {
out.Cash = append(out.Cash, c)
if c.Currency == "SUR" || c.Currency == "RUB" {
out.CashRUB += c.Amount
}
if ld != nil && ld.After(asOf) {
asOf = *ld
}
}
}
crows.Close()
}
if !asOf.IsZero() {
out.AsOf = asOf.Format("02.01.2006")
}
lkPortfolioTotals(out)
return out, nil
}
// lkPortfolioTotals — итоги и доли.
func lkPortfolioTotals(p *LKPortfolio) {
p.Securities, p.Cost, p.PnL = 0, 0, 0
for _, pos := range p.Positions {
p.Securities += pos.Value
p.Cost += pos.Cost
p.PnL += pos.PnL
}
p.Total = p.Securities + p.CashRUB
if p.Cost != 0 {
p.PnLPct = p.PnL / p.Cost * 100
}
for i := range p.Positions {
if p.Total > 0 {
p.Positions[i].Share = p.Positions[i].Value / p.Total * 100
}
}
sort.SliceStable(p.Positions, func(i, j int) bool { return p.Positions[i].Value > p.Positions[j].Value })
}