02fabb483d
- 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
229 lines
9.0 KiB
Go
229 lines
9.0 KiB
Go
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 })
|
||
}
|