Кабинет клиента: портфель как у брокера — «Онлайн» по 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:
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user