Кабинет: оценка портфеля по ценам QUIK и карточка бумаги из справочника депозитария
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -236,15 +236,63 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
}
|
||||
pos = p2
|
||||
}
|
||||
total, priced := lkApplyPrices(r.Context(), rc, pos)
|
||||
lkJSON(w, http.StatusOK, map[string]any{
|
||||
"contract": u.Contract,
|
||||
"full_name": u.FullName,
|
||||
"positions": pos,
|
||||
"source": source,
|
||||
"read_only": readOnly,
|
||||
"contract": u.Contract,
|
||||
"full_name": u.FullName,
|
||||
"positions": pos,
|
||||
"source": source,
|
||||
"read_only": readOnly,
|
||||
"total_value": total, // оценка портфеля, 0 — цен нет
|
||||
"priced": priced, // по скольким бумагам есть цена
|
||||
})
|
||||
})
|
||||
|
||||
// Карточка бумаги: реквизиты выпуска, цена, позиция клиента, график выплат.
|
||||
mux.HandleFunc("/api/lk/security", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
u, ok := current(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
isin := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("isin")))
|
||||
if isin == "" {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не указана бумага"})
|
||||
return
|
||||
}
|
||||
var pos *ClientPosition
|
||||
if dp, err := depoClientPositions(r.Context(), rc.Snapshot().Fancy, u.Contract); err == nil && len(dp) > 0 {
|
||||
for _, p := range depoPositionsAsClient(dp) {
|
||||
if strings.EqualFold(p.ISIN, isin) {
|
||||
cp := p
|
||||
pos = &cp
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if p2, err2 := lkClientPositions(r.Context(), rc, u.Contract); err2 == nil {
|
||||
for _, p := range markTransferable(p2) {
|
||||
if strings.EqualFold(p.ISIN, isin) {
|
||||
cp := p
|
||||
pos = &cp
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if pos != nil {
|
||||
one := []ClientPosition{*pos}
|
||||
lkApplyPrices(r.Context(), rc, one)
|
||||
pos = &one[0]
|
||||
}
|
||||
card, err := securityCard(r.Context(), rc, isin, pos)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{"security": card})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
|
||||
@@ -131,6 +131,11 @@ type ClientPosition struct {
|
||||
// Transferable — бумагу можно перевести через M2M (только российские выпуски).
|
||||
Transferable bool `json:"transferable"`
|
||||
Note string `json:"note"`
|
||||
// Оценка по данным торговой системы (пакет «Цены»); 0 — цены нет.
|
||||
Price float64 `json:"price,omitempty"`
|
||||
PriceAt string `json:"price_board,omitempty"` // класс торгов, откуда цена
|
||||
Value float64 `json:"value,omitempty"` // стоимость позиции
|
||||
ChangePct float64 `json:"change_pct,omitempty"` // изменение цены к закрытию, %
|
||||
}
|
||||
|
||||
// lkClientPositions — позиции клиента модуля ЛК по номеру договора (тестовые клиенты;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package lkgateway
|
||||
|
||||
// prices.go — рыночные цены бумаг из базы экспорта QUIK (таблицы Securities и Params,
|
||||
// пакет данных «Цены для оценки портфеля»). Нужны кабинету, чтобы показать стоимость
|
||||
// позиции и портфеля. Подключение берём из настроек «НПР / УДС» — это та же база
|
||||
// экспорта; без неё кабинет просто не показывает оценку.
|
||||
//
|
||||
// Цена облигации приходит в процентах от номинала, акции и пая — в рублях за штуку.
|
||||
// Оценка: облигация = количество × номинал × цена / 100 + НКД × количество.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityPrice — цена одной бумаги на момент среза.
|
||||
type SecurityPrice struct {
|
||||
ISIN string `json:"isin"`
|
||||
Board string `json:"board"` // класс QUIK, откуда взята цена
|
||||
Name string `json:"name,omitempty"` // краткое наименование в торговой системе
|
||||
Price float64 `json:"price"` // последняя цена (для облигаций — процент номинала)
|
||||
Prev float64 `json:"prev,omitempty"` // цена предыдущего закрытия
|
||||
Face float64 `json:"face,omitempty"` // номинал
|
||||
Accrued float64 `json:"accrued,omitempty"` // НКД на бумагу
|
||||
InPct bool `json:"in_pct,omitempty"` // цена в процентах от номинала
|
||||
ChangePc float64 `json:"change_pc,omitempty"` // изменение к закрытию, %
|
||||
}
|
||||
|
||||
// Value — стоимость количества бумаг по этой цене.
|
||||
func (p SecurityPrice) Value(qty float64) float64 {
|
||||
if p.Price <= 0 || qty == 0 {
|
||||
return 0
|
||||
}
|
||||
if p.InPct && p.Face > 0 {
|
||||
return qty*p.Face*p.Price/100 + qty*p.Accrued
|
||||
}
|
||||
return qty * p.Price
|
||||
}
|
||||
|
||||
// pricesCache — цены живут минуту: страницу кабинета открывают часто, а цикл экспорта — 30 с.
|
||||
var (
|
||||
priceMu sync.Mutex
|
||||
priceAt time.Time
|
||||
priceLast map[string]SecurityPrice
|
||||
)
|
||||
|
||||
// lkPrices — цены по списку ISIN. Пустая карта, если база экспорта не настроена или
|
||||
// пакет «Цены» не включён: кабинет в этом случае показывает реестр без оценки.
|
||||
func lkPrices(ctx context.Context, rc *RuntimeConfig, isins []string) map[string]SecurityPrice {
|
||||
out := map[string]SecurityPrice{}
|
||||
if rc == nil || len(isins) == 0 {
|
||||
return out
|
||||
}
|
||||
priceMu.Lock()
|
||||
if time.Since(priceAt) < time.Minute && priceLast != nil {
|
||||
for _, isin := range isins {
|
||||
if p, ok := priceLast[strings.ToUpper(isin)]; ok {
|
||||
out[p.ISIN] = p
|
||||
}
|
||||
}
|
||||
priceMu.Unlock()
|
||||
return out
|
||||
}
|
||||
priceMu.Unlock()
|
||||
|
||||
p, err := nprDB(rc)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
// Одна цена на бумагу: берём класс основных торгов (акции TQBR, ОФЗ TQOB,
|
||||
// корпоративные облигации TQCB, фонды TQTF/TQIF), иначе — класс с торгами.
|
||||
rows, err := p.Query(cctx, `
|
||||
SELECT DISTINCT ON (s."ISINCode")
|
||||
upper(s."ISINCode"), s."ClassCode", COALESCE(s."SecShortName", s."SecName", ''),
|
||||
COALESCE(p.last, 0)::float8, COALESCE(p.prevprice, 0)::float8,
|
||||
COALESCE(s."FaceValue", 0)::float8, COALESCE(p.accruedint, 0)::float8
|
||||
FROM "Securities" s JOIN "Params" p ON p."SecCode" = s."SecCode" AND p."ClassCode" = s."ClassCode"
|
||||
WHERE s."ISINCode" <> '' AND (p.last > 0 OR p.prevprice > 0)
|
||||
ORDER BY s."ISINCode",
|
||||
CASE s."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,
|
||||
p.last DESC`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
all := map[string]SecurityPrice{}
|
||||
for rows.Next() {
|
||||
var sp SecurityPrice
|
||||
if rows.Scan(&sp.ISIN, &sp.Board, &sp.Name, &sp.Price, &sp.Prev, &sp.Face, &sp.Accrued) != nil {
|
||||
continue
|
||||
}
|
||||
if sp.Price <= 0 {
|
||||
sp.Price = sp.Prev
|
||||
}
|
||||
sp.InPct = sp.Face > 0 && (sp.Board == "TQOB" || sp.Board == "TQCB" || strings.HasSuffix(sp.Board, "_BND"))
|
||||
if sp.Prev > 0 && sp.Price > 0 {
|
||||
sp.ChangePc = (sp.Price - sp.Prev) / sp.Prev * 100
|
||||
}
|
||||
all[sp.ISIN] = sp
|
||||
}
|
||||
rows.Close()
|
||||
if len(all) == 0 {
|
||||
return out
|
||||
}
|
||||
priceMu.Lock()
|
||||
priceLast, priceAt = all, time.Now()
|
||||
priceMu.Unlock()
|
||||
for _, isin := range isins {
|
||||
if sp, ok := all[strings.ToUpper(isin)]; ok {
|
||||
out[sp.ISIN] = sp
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lkPriceReset — сбросить кэш цен (после смены настроек подключения).
|
||||
func lkPriceReset() {
|
||||
priceMu.Lock()
|
||||
priceLast, priceAt = nil, time.Time{}
|
||||
priceMu.Unlock()
|
||||
}
|
||||
|
||||
// lkApplyPrices — проставляет позициям цену и стоимость, возвращает оценку портфеля
|
||||
// и число бумаг, по которым цена нашлась.
|
||||
func lkApplyPrices(ctx context.Context, rc *RuntimeConfig, pos []ClientPosition) (total float64, priced int) {
|
||||
if len(pos) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
isins := make([]string, 0, len(pos))
|
||||
for _, p := range pos {
|
||||
if p.ISIN != "" {
|
||||
isins = append(isins, p.ISIN)
|
||||
}
|
||||
}
|
||||
px := lkPrices(ctx, rc, isins)
|
||||
for i := range pos {
|
||||
sp, ok := px[strings.ToUpper(pos[i].ISIN)]
|
||||
if !ok || sp.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
pos[i].Price, pos[i].PriceAt, pos[i].ChangePct = sp.Price, sp.Board, sp.ChangePc
|
||||
pos[i].Value = sp.Value(pos[i].Quantity)
|
||||
total += pos[i].Value
|
||||
priced++
|
||||
}
|
||||
return total, priced
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build live
|
||||
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Цены из базы экспорта QUIK и карточка бумаги из справочника депозитария.
|
||||
func TestLivePricesAndCard(t *testing.T) {
|
||||
rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalRC = rc
|
||||
ctx := context.Background()
|
||||
isins := []string{"RU0009029540", "RU000A1014L8", "RU000A10EYV6", "RU000A1038V6"}
|
||||
px := lkPrices(ctx, rc, isins)
|
||||
if len(px) == 0 {
|
||||
t.Fatal("цены не получены: проверьте подключение к базе экспорта и пакет «Цены»")
|
||||
}
|
||||
for _, i := range isins {
|
||||
p := px[i]
|
||||
t.Logf("%s: %s %.4f (пред. %.4f, номинал %.2f, НКД %.4f, %%=%v) → 100 шт. = %.2f ₽",
|
||||
i, p.Board, p.Price, p.Prev, p.Face, p.Accrued, p.InPct, p.Value(100))
|
||||
}
|
||||
for _, i := range []string{"RU000A10EYV6", "RU0009029540"} {
|
||||
card, err := securityCard(ctx, rc, i, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("карточка %s: %v", i, err)
|
||||
}
|
||||
t.Logf("%s · %s · %s · эмитент %q · номинал %.2f · погашение %s · CFI %s · на обслуживании %v · квал %v · выплат %d",
|
||||
card.ISIN, card.Name, card.Kind, card.Issuer, card.Face, card.Maturity, card.CFI, card.Accepted, card.QualOnly, len(card.Payments))
|
||||
for _, p := range card.Payments {
|
||||
t.Logf(" %s %s %.2f %.2f%%", p.Date, p.Kind, p.Amount, p.Percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package lkgateway
|
||||
|
||||
// security_card.go — карточка ценной бумаги для кабинета клиента: реквизиты выпуска из
|
||||
// справочника депозитария (OD_VALUES + действующая версия OD_SHARES), график купонов и
|
||||
// оферт (OD_COUPONS, OD_OFFERS), цена из базы экспорта QUIK и позиция клиента.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityPayment — событие по бумаге: купон, оферта, погашение.
|
||||
type SecurityPayment struct {
|
||||
Date string `json:"date"` // ДД.ММ.ГГГГ
|
||||
Kind string `json:"kind"` // купон | оферта | погашение
|
||||
Amount float64 `json:"amount,omitempty"` // на одну бумагу
|
||||
Percent float64 `json:"percent,omitempty"` // ставка купона, % годовых
|
||||
FixDate string `json:"fix_date,omitempty"`
|
||||
Paid bool `json:"paid,omitempty"`
|
||||
}
|
||||
|
||||
// SecurityCard — карточка бумаги.
|
||||
type SecurityCard struct {
|
||||
ISIN string `json:"isin"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind,omitempty"` // акция | облигация | пай | бумага
|
||||
Issuer string `json:"issuer,omitempty"` // эмитент
|
||||
RegNumber string `json:"reg_number,omitempty"`
|
||||
CFI string `json:"cfi,omitempty"`
|
||||
Face float64 `json:"face,omitempty"` // номинал
|
||||
Maturity string `json:"maturity,omitempty"` // дата погашения
|
||||
Accepted bool `json:"accepted"` // принята на обслуживание
|
||||
QualOnly bool `json:"qual_only,omitempty"` // только для квалифицированных инвесторов
|
||||
QualNote string `json:"qual_note,omitempty"`
|
||||
|
||||
Price float64 `json:"price,omitempty"`
|
||||
PriceIn string `json:"price_in,omitempty"` // «₽ за штуку» | «% от номинала»
|
||||
Board string `json:"board,omitempty"` // класс торгов
|
||||
ChangePct float64 `json:"change_pct,omitempty"` // изменение к закрытию
|
||||
|
||||
Position *ClientPosition `json:"position,omitempty"`
|
||||
Payments []SecurityPayment `json:"payments,omitempty"`
|
||||
Note string `json:"note,omitempty"` // почему чего-то нет
|
||||
}
|
||||
|
||||
// securityCard — собирает карточку. Позиция клиента передаётся снаружи (её уже знает
|
||||
// обработчик портфеля), справочные данные читаются из учётной системы.
|
||||
func securityCard(ctx context.Context, rc *RuntimeConfig, isin string, pos *ClientPosition) (SecurityCard, error) {
|
||||
isin = strings.ToUpper(strings.TrimSpace(isin))
|
||||
card := SecurityCard{ISIN: isin, Position: pos}
|
||||
if pos != nil {
|
||||
card.Name = pos.SecurityName
|
||||
}
|
||||
if isin == "" {
|
||||
return card, fmt.Errorf("не указана бумага")
|
||||
}
|
||||
if px := lkPrices(ctx, rc, []string{isin}); len(px) > 0 {
|
||||
p := px[isin]
|
||||
card.Price, card.Board, card.ChangePct = p.Price, p.Board, p.ChangePc
|
||||
card.PriceIn = "₽ за штуку"
|
||||
if p.InPct {
|
||||
card.PriceIn = "% от номинала"
|
||||
}
|
||||
if card.Name == "" {
|
||||
card.Name = p.Name
|
||||
}
|
||||
}
|
||||
|
||||
fs := rc.Snapshot().Fancy
|
||||
if strings.TrimSpace(fs.Server) == "" {
|
||||
card.Note = "справочник депозитария недоступен"
|
||||
return card, nil
|
||||
}
|
||||
db, dctx, cancel, errS := depoOpen(fs)
|
||||
if errS != "" {
|
||||
card.Note = errS
|
||||
return card, nil
|
||||
}
|
||||
defer cancel()
|
||||
defer db.Close()
|
||||
_ = dctx
|
||||
|
||||
var valID int64
|
||||
var name, cfi string
|
||||
var vType, isIn sql.NullInt64
|
||||
err := db.QueryRowContext(dctx, `SELECT TOP 1 ID, ISNULL(NAME,''), ISNULL(CFI,''), V_TYPE, IS_IN FROM OD_VALUES WHERE ISIN=@p1 ORDER BY ID`,
|
||||
sql.Named("p1", isin)).Scan(&valID, &name, &cfi, &vType, &isIn)
|
||||
if err != nil {
|
||||
card.Note = "бумаги нет в справочнике депозитария"
|
||||
return card, nil
|
||||
}
|
||||
if name != "" {
|
||||
card.Name = name
|
||||
}
|
||||
card.CFI, card.Accepted = cfi, isIn.Valid && isIn.Int64 == 1
|
||||
card.Kind = securityKind(cfi)
|
||||
|
||||
var issuer sql.NullInt64
|
||||
var nominal sql.NullFloat64
|
||||
var regNum sql.NullString
|
||||
var death sql.NullTime
|
||||
err = db.QueryRowContext(dctx, `SELECT TOP 1 ISSUER, CAST(NOMINAL AS float), NUM_REG, DEATHDATE FROM OD_SHARES
|
||||
WHERE ID=@p1 AND E_DATE > GETDATE() ORDER BY B_DATE DESC`, sql.Named("p1", valID)).Scan(&issuer, &nominal, ®Num, &death)
|
||||
if err == nil {
|
||||
card.Face = nominal.Float64
|
||||
card.RegNumber = strings.TrimSpace(regNum.String)
|
||||
if death.Valid && death.Time.Year() < 9000 {
|
||||
card.Maturity = death.Time.Format("02.01.2006")
|
||||
}
|
||||
if issuer.Valid && issuer.Int64 > 0 {
|
||||
var iname string
|
||||
if db.QueryRowContext(dctx, `SELECT TOP 1 ISNULL(NAME,'') FROM OD_FACES WHERE ID=@p1 OR SELF_ID=@p1 ORDER BY ISNULL(LAST_FLAG,0) DESC, ID DESC`,
|
||||
sql.Named("p1", issuer.Int64)).Scan(&iname) == nil {
|
||||
card.Issuer = strings.TrimSpace(iname)
|
||||
}
|
||||
}
|
||||
}
|
||||
card.QualOnly, card.QualNote = depoSecurityQualOnly(dctx, db, valID)
|
||||
|
||||
// График: купоны за полгода назад и вперёд, ближайшие оферты, погашение.
|
||||
// Дата выплаты купона: P_DATE, а пока купон не выплачен — конец купонного периода.
|
||||
rows, err := db.QueryContext(dctx, `SELECT TOP 12 ISNULL(P_DATE, E_DATE) AS PAY_DATE, CAST(ISNULL(SUMMA,0) AS float),
|
||||
CAST(ISNULL(PERCENT_,0) AS float), FIX_DATE, ISNULL(IS_PAY,0)
|
||||
FROM OD_COUPONS WHERE SHARE=@p1 AND ISNULL(P_DATE, E_DATE) >= DATEADD(month,-3,GETDATE())
|
||||
ORDER BY PAY_DATE`, sql.Named("p1", valID))
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var pd, fx sql.NullTime
|
||||
var summa, pct float64
|
||||
var isPay sql.NullInt64
|
||||
if rows.Scan(&pd, &summa, &pct, &fx, &isPay) != nil || !pd.Valid {
|
||||
continue
|
||||
}
|
||||
p := SecurityPayment{Date: pd.Time.Format("02.01.2006"), Kind: "купон", Amount: summa, Percent: pct,
|
||||
Paid: pd.Time.Before(time.Now())}
|
||||
if fx.Valid && fx.Time.Year() < 9000 {
|
||||
p.FixDate = fx.Time.Format("02.01.2006")
|
||||
}
|
||||
card.Payments = append(card.Payments, p)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
orows, err := db.QueryContext(dctx, `SELECT TOP 3 P_DATE, CAST(ISNULL(O_PRICE,0) AS float) FROM OD_OFFERS
|
||||
WHERE SHARE=@p1 AND CANCEL_DATE IS NULL AND P_DATE >= GETDATE() ORDER BY P_DATE`, sql.Named("p1", valID))
|
||||
if err == nil {
|
||||
for orows.Next() {
|
||||
var pd sql.NullTime
|
||||
var price float64
|
||||
if orows.Scan(&pd, &price) == nil && pd.Valid {
|
||||
card.Payments = append(card.Payments, SecurityPayment{Date: pd.Time.Format("02.01.2006"), Kind: "оферта", Amount: price})
|
||||
}
|
||||
}
|
||||
orows.Close()
|
||||
}
|
||||
if card.Maturity != "" {
|
||||
card.Payments = append(card.Payments, SecurityPayment{Date: card.Maturity, Kind: "погашение", Amount: card.Face})
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// securityKind — вид бумаги по коду CFI (первая буква классификации).
|
||||
func securityKind(cfi string) string {
|
||||
if cfi == "" {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToUpper(cfi[:1]) {
|
||||
case "E":
|
||||
return "акция"
|
||||
case "D":
|
||||
return "облигация"
|
||||
case "C":
|
||||
return "пай фонда"
|
||||
case "R":
|
||||
return "право"
|
||||
}
|
||||
return "ценная бумага"
|
||||
}
|
||||
Reference in New Issue
Block a user