Files
Bridge-and-Join-s/internal/lkgateway/lkonline.go
T
zuevav 02fabb483d Кабинет клиента: портфель как у брокера — «Онлайн» по 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
2026-09-18 15:44:53 +03:00

229 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })
}