Кабинет: оценка портфеля по ценам QUIK и карточка бумаги из справочника депозитария

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-17 23:03:31 +03:00
parent f057b89d2e
commit 26785ff3ba
8 changed files with 614 additions and 21 deletions
+53 -5
View File
@@ -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
+5
View File
@@ -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 — позиции клиента модуля ЛК по номеру договора (тестовые клиенты;
+151
View File
@@ -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
}
+39
View File
@@ -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)
}
}
}
+180
View File
@@ -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, &regNum, &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 "ценная бумага"
}
+97 -5
View File
@@ -53,6 +53,40 @@ func New(cfg Config) (*Server, error) {
}
return strconv.FormatFloat(f, 'f', -1, 64)
},
"fmtmoney": func(f float64) string { // 1 234 567,89 — с разделителем разрядов
neg := f < 0
if neg {
f = -f
}
whole := int64(f)
cents := int64((f-float64(whole))*100 + 0.5)
if cents >= 100 {
whole, cents = whole+1, 0
}
d := strconv.FormatInt(whole, 10)
var b strings.Builder
for i, c := range d {
if i > 0 && (len(d)-i)%3 == 0 {
b.WriteByte(' ')
}
b.WriteRune(c)
}
out := b.String()
if cents > 0 {
out += "," + fmt.Sprintf("%02d", cents)
}
if neg {
out = "−" + out
}
return out
},
"fmtpct": func(f float64) string {
s := strconv.FormatFloat(f, 'f', 2, 64)
if f > 0 {
s = "+" + s
}
return strings.ReplaceAll(s, ".", ",") + " %"
},
"shortid": func(s string) string {
if len(s) > 8 {
return s[:8] + "…"
@@ -126,6 +160,9 @@ type position struct {
Available float64 `json:"available"`
UpdatedAt string `json:"updated_at"`
Transferable bool `json:"transferable"`
Price float64 `json:"price"`
Value float64 `json:"value"`
ChangePct float64 `json:"change_pct"`
Note string `json:"note"`
}
@@ -175,11 +212,42 @@ func claimsView(rows []claimRow) (active, done int) {
// portfolioResp — ответ /api/lk/portfolio.
type portfolioResp struct {
Contract string `json:"contract"`
FullName string `json:"full_name"`
Positions []position `json:"positions"`
Source string `json:"source"`
ReadOnly bool `json:"read_only"`
Contract string `json:"contract"`
FullName string `json:"full_name"`
Positions []position `json:"positions"`
Source string `json:"source"`
ReadOnly bool `json:"read_only"`
TotalValue float64 `json:"total_value"`
Priced int `json:"priced"`
}
// securityCard — карточка бумаги из ядра.
type securityCard struct {
ISIN string `json:"isin"`
Name string `json:"name"`
Kind string `json:"kind"`
Issuer string `json:"issuer"`
RegNumber string `json:"reg_number"`
CFI string `json:"cfi"`
Face float64 `json:"face"`
Maturity string `json:"maturity"`
Accepted bool `json:"accepted"`
QualOnly bool `json:"qual_only"`
QualNote string `json:"qual_note"`
Price float64 `json:"price"`
PriceIn string `json:"price_in"`
Board string `json:"board"`
ChangePct float64 `json:"change_pct"`
Position *position `json:"position"`
Payments []struct {
Date string `json:"date"`
Kind string `json:"kind"`
Amount float64 `json:"amount"`
Percent float64 `json:"percent"`
FixDate string `json:"fix_date"`
Paid bool `json:"paid"`
} `json:"payments"`
Note string `json:"note"`
}
// portfolio — реестр клиента из ядра.
@@ -324,6 +392,7 @@ func (s *Server) Handler() http.Handler {
"Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName,
"Positions": pos, "Claims": claims, "Flash": r.URL.Query().Get("m"),
"Total": total, "Free": free, "Blocked": blocked, "Active": active,
"Value": pf.TotalValue, "Priced": pf.Priced,
})
})
@@ -349,9 +418,32 @@ func (s *Server) Handler() http.Handler {
"Positions": pf.Positions, "Flash": r.URL.Query().Get("m"),
"Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated,
"Total": total, "Free": free, "Blocked": blocked,
"Value": pf.TotalValue, "Priced": pf.Priced,
})
})
// Карточка бумаги
mux.HandleFunc("/security", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
isin := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("isin")))
if isin == "" {
http.Redirect(w, r, "/portfolio", http.StatusSeeOther)
return
}
var out struct {
Security securityCard `json:"security"`
}
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil {
s.render(w, r, "security", map[string]any{"Nav": "p", "Error": err.Error(), "ISIN": isin})
return
}
s.render(w, r, "security", map[string]any{"Nav": "p", "S": out.Security, "ISIN": isin})
})
// Профиль: договор, счета депо, доступ
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
+23 -3
View File
@@ -28,8 +28,8 @@ func fakeBJ(t *testing.T) *httptest.Server {
})
mux.HandleFunc("/api/lk/portfolio", func(w http.ResponseWriter, r *http.Request) {
if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" {
_ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "source": "депозитарный учёт", "positions": []any{
map[string]any{"isin": "RU0009029540", "security_name": "Сбербанк, ао", "account_id": "ВТНКЦ00000001", "quantity": 400, "blocked": 100, "available": 300, "transferable": true, "updated_at": "17.09.2026 21:58"},
_ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "source": "депозитарный учёт", "total_value": 110268.0, "priced": 1, "positions": []any{
map[string]any{"isin": "RU0009029540", "security_name": "Сбербанк, ао", "account_id": "ВТНКЦ00000001", "quantity": 400, "blocked": 100, "available": 300, "transferable": true, "updated_at": "17.09.2026 21:58", "price": 275.67, "value": 110268, "change_pct": -1.53},
map[string]any{"isin": "US0378331005", "security_name": "Apple Inc.", "account_id": "В000000044", "quantity": 12, "blocked": 0, "available": 0, "transferable": false, "note": "иностранная бумага — перевод через M2M недоступен"},
}})
}
@@ -42,6 +42,17 @@ func fakeBJ(t *testing.T) *httptest.Server {
}})
}
})
mux.HandleFunc("/api/lk/security", func(w http.ResponseWriter, r *http.Request) {
if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" {
_ = json.NewEncoder(w).Encode(map[string]any{"security": map[string]any{
"isin": "RU0009029540", "name": "Сбербанк РФ-АО3", "kind": "акция", "issuer": "ПАО Сбербанк",
"reg_number": "10301481B", "cfi": "ESVXFR", "face": 3, "accepted": true, "price": 275.67, "price_in": "₽ за штуку",
"board": "TQBR", "change_pct": -1.53,
"position": map[string]any{"isin": "RU0009029540", "quantity": 400, "available": 300, "value": 110268, "transferable": true},
"payments": []any{map[string]any{"date": "18.10.2026", "kind": "дивиденды", "amount": 34.5}},
}})
}
})
mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) {
if keyOK(w, r) {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
@@ -154,11 +165,14 @@ func TestBoardPages(t *testing.T) {
return rec.Body.String()
}
board := get("/")
for _, want := range []string{"Бумаг в реестре", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} {
for _, want := range []string{"Стоимость бумаг", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} {
if !strings.Contains(board, want) {
t.Errorf("на табло нет %q", want)
}
}
if !strings.Contains(board, "110 268 ₽") {
t.Errorf("на табло нет оценки портфеля")
}
reg := get("/portfolio")
for _, want := range []string{"ВТНКЦ00000001", "иностранный выпуск", "депозитарный учёт", "перевести"} {
if !strings.Contains(reg, want) {
@@ -175,6 +189,12 @@ func TestBoardPages(t *testing.T) {
t.Errorf("в бланке поручения нет %q", want)
}
}
card := get("/security?isin=RU0009029540")
for _, want := range []string{"ПАО Сбербанк", "Код CFI", "275,67", "дивиденды", "Перевести эту бумагу"} {
if !strings.Contains(card, want) {
t.Errorf("в карточке бумаги нет %q", want)
}
}
prof := get("/profile")
if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") {
t.Errorf("профиль: %s", prof)
+66 -8
View File
@@ -88,6 +88,8 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
@media(max-width:600px){main{padding:18px 14px 28px}.board{padding:18px 14px}.board .big{font-size:32px}.top{padding:0 14px}.sheet{padding:16px 14px}}
</style></head><body>{{end}}
{{define "money"}}{{if gt . 0.0}}{{fmtmoney .}} ₽{{else}}—{{end}}{{end}}
{{define "mark"}}<svg viewBox="0 0 28 28" width="21" height="21" fill="none" stroke="#e8a33d" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="6" cy="14" r="3"/><circle cx="22" cy="14" r="3"/><path d="M9 14h10"/><path d="M11 10.5h6M11 17.5h6"/></svg>{{end}}
{{define "nav"}}<header class="top">
@@ -138,9 +140,9 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
{{define "board"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div>
<span class="lbl" style="color:var(--dim)">Бумаг в реестре</span>
<div class="big" style="margin-top:8px">{{.Total}}</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">{{with .FullName}}{{.}} · {{end}}договор {{.Contract}}</div>
<span class="lbl" style="color:var(--dim)">{{if gt .Value 0.0}}Стоимость бумаг{{else}}Бумаг в реестре{{end}}</span>
<div class="big" style="margin-top:8px">{{if gt .Value 0.0}}{{fmtmoney .Value}} ₽{{else}}{{.Total}}{{end}}</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">{{with .FullName}}{{.}} · {{end}}договор {{.Contract}}{{if gt .Value 0.0}} · оценка по {{.Priced}} из {{.Total}} бумаг{{end}}</div>
</div>
<div class="stats">
<span class="stat"><span class="lbl" style="color:var(--dim)">Свободно к переводу</span><span class="v">{{.Free}}</span></span>
@@ -168,12 +170,13 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
<section class="sec">
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Реестр бумаг</h2><a href="/portfolio" style="font-size:13px;text-decoration:none">весь реестр</a></div>
{{if .Positions}}<div class="tw"><table>
<thead><tr><th>Выпуск</th><th>Счёт депо</th><th class="num">Количество</th><th class="num">Свободно</th><th>Состояние</th></tr></thead>
<thead><tr><th>Выпуск</th><th>Счёт депо</th><th class="num">Количество</th><th class="num">Свободно</th><th class="num">Оценка</th><th>Состояние</th></tr></thead>
<tbody>{{range .Positions}}<tr>
<td><b>{{.SecurityName}}</b><div class="n" style="font-size:12.5px;color:var(--mut)">{{.ISIN}}</div></td>
<td><a href="/security?isin={{.ISIN}}" style="text-decoration:none;font-weight:600">{{.SecurityName}}</a><div class="n" style="font-size:12.5px;color:var(--mut)">{{.ISIN}}</div></td>
<td class="n" style="font-size:13px">{{if .AccountID}}{{.AccountID}}{{else}}—{{end}}</td>
<td class="num">{{fmtnum .Quantity}}</td>
<td class="num">{{fmtnum .Available}}</td>
<td class="num">{{template "money" .Value}}</td>
<td>{{if not .Transferable}}<span class="chip">не переводится</span>{{else if gt .Blocked 0.0}}<span class="chip go">{{fmtnum .Blocked}} с ограничением</span>{{else}}<span class="chip ok">свободно</span>{{end}}</td>
</tr>{{end}}</tbody></table></div>
{{else}}<p class="empty">Бумаг на счетах депо не найдено. Если это ошибка — напишите в депозитарий.</p>{{end}}
@@ -183,7 +186,8 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
{{define "portfolio"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div><span class="lbl" style="color:var(--dim)">Реестр бумаг</span>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">{{.Source}}{{with .UpdatedAt}} · {{.}}{{end}}</div></div>
{{if gt .Value 0.0}}<div class="big" style="margin-top:8px;font-size:32px">{{fmtmoney .Value}} ₽</div>{{end}}
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">{{.Source}}{{with .UpdatedAt}} · {{.}}{{end}}{{if gt .Value 0.0}} · цены торговой системы{{end}}</div></div>
<div class="stats">
<span class="stat"><span class="lbl" style="color:var(--dim)">Выпусков</span><span class="v">{{.Total}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Свободно к переводу</span><span class="v">{{.Free}}</span></span>
@@ -196,13 +200,15 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
{{if .ReadOnly}}<div class="msg">Кабинет открыт в режиме просмотра: подача поручений по этому договору выключена депозитарием.</div>{{end}}
{{if .Positions}}
<div class="tw"><table>
<thead><tr><th>Выпуск</th><th>Счёт депо</th><th class="num">Количество</th><th class="num">С ограничением</th><th class="num">Свободно</th><th>Состояние</th><th></th></tr></thead>
<thead><tr><th>Выпуск</th><th>Счёт депо</th><th class="num">Количество</th><th class="num">С ограничением</th><th class="num">Свободно</th><th class="num">Цена</th><th class="num">Оценка</th><th>Состояние</th><th></th></tr></thead>
<tbody>{{range .Positions}}<tr>
<td><b>{{.SecurityName}}</b><div class="n" style="font-size:12.5px;color:var(--mut)">{{.ISIN}}{{with .SecurityCode}} · {{.}}{{end}}</div></td>
<td><a href="/security?isin={{.ISIN}}" style="text-decoration:none;font-weight:600">{{.SecurityName}}</a><div class="n" style="font-size:12.5px;color:var(--mut)">{{.ISIN}}{{with .SecurityCode}} · {{.}}{{end}}</div></td>
<td class="n" style="font-size:13px">{{if .AccountID}}{{.AccountID}}{{else}}—{{end}}</td>
<td class="num">{{fmtnum .Quantity}}</td>
<td class="num">{{if gt .Blocked 0.0}}<span style="color:var(--amber-tx)">{{fmtnum .Blocked}}</span>{{else}}—{{end}}</td>
<td class="num">{{fmtnum .Available}}</td>
<td class="num">{{if gt .Price 0.0}}{{fmtmoney .Price}}{{if ne .ChangePct 0.0}}<div style="font-size:12px;{{if gt .ChangePct 0.0}}color:var(--ok-tx){{else}}color:var(--err-tx){{end}}">{{fmtpct .ChangePct}}</div>{{end}}{{else}}—{{end}}</td>
<td class="num">{{template "money" .Value}}</td>
<td>{{if not .Transferable}}<span class="chip">иностранный выпуск</span>{{else if gt .Blocked 0.0}}<span class="chip go">частично ограничено</span>{{else}}<span class="chip ok">свободно</span>{{end}}{{with .Note}}<div class="mut">{{.}}</div>{{end}}</td>
<td style="text-align:right">{{if and .Transferable (gt .Available 0.0) (not $.ReadOnly)}}<a href="/order?isin={{.ISIN}}" class="lbl" style="text-decoration:none">перевести</a>{{end}}</td>
</tr>{{end}}</tbody></table></div>
@@ -364,4 +370,56 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
<p class="mut">В кабинете клиента сотрудник готовит поручение, подписывает его клиент. Каждый вход сотрудника записывается в журнал.</p>
</main></body></html>{{end}}
{{define "security"}}{{template "head" .}}{{template "nav" .}}
{{$s := .S}}
<section class="board"><div class="board-row">
<div>
<a href="/portfolio" class="lbl" style="color:var(--dim);text-decoration:none">← Реестр</a>
<div style="font-size:23px;font-weight:700;margin-top:8px">{{if $s.Name}}{{$s.Name}}{{else}}{{.ISIN}}{{end}}</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">{{.ISIN}}{{with $s.Kind}} · {{.}}{{end}}{{with $s.RegNumber}} · {{.}}{{end}}</div>
</div>
<div class="stats">
{{with $s.Position}}<span class="stat"><span class="lbl" style="color:var(--dim)">В портфеле</span><span class="v">{{fmtnum .Quantity}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Свободно</span><span class="v">{{fmtnum .Available}}</span></span>{{end}}
{{if gt $s.Price 0.0}}<span class="stat"><span class="lbl" style="color:var(--dim)">Цена</span><span class="v">{{fmtmoney $s.Price}}</span></span>{{end}}
{{with $s.Position}}{{if gt .Value 0.0}}<span class="stat"><span class="lbl" style="color:var(--dim)">Оценка</span><span class="v">{{fmtmoney .Value}} ₽</span></span>{{end}}{{end}}
</div>
</div></section>
<main>
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
{{with $s.Note}}<div class="msg">{{.}}</div>{{end}}
<div class="grid2">
<section class="sec">
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Выпуск</h2>
<div class="rows">
{{with $s.Issuer}}<div class="row"><span>Эмитент</span><span style="text-align:right">{{.}}</span></div>{{end}}
{{with $s.Kind}}<div class="row"><span>Вид бумаги</span><span>{{.}}</span></div>{{end}}
{{if gt $s.Face 0.0}}<div class="row"><span>Номинал</span><span class="n">{{fmtmoney $s.Face}} ₽</span></div>{{end}}
{{with $s.Maturity}}<div class="row"><span>Погашение</span><span class="n">{{.}}</span></div>{{end}}
{{with $s.RegNumber}}<div class="row"><span>Государственная регистрация</span><span class="n">{{.}}</span></div>{{end}}
{{with $s.CFI}}<div class="row"><span>Код CFI</span><span class="n">{{.}}</span></div>{{end}}
<div class="row"><span>Обслуживание в депозитарии</span><span>{{if $s.Accepted}}принята{{else}}не принята{{end}}</span></div>
<div class="row"><span>Для квалифицированных инвесторов</span><span>{{if $s.QualOnly}}да{{else}}ограничений нет{{end}}</span></div>
{{if gt $s.Price 0.0}}<div class="row"><span>Цена{{with $s.Board}} · {{.}}{{end}}</span><span class="n">{{fmtmoney $s.Price}}{{with $s.PriceIn}} <span style="color:var(--mut)">{{.}}</span>{{end}}</span></div>{{end}}
</div>
{{with $s.Position}}{{if and .Transferable (gt .Available 0.0)}}
<a href="/order?isin={{$s.ISIN}}" class="btn main" style="margin-top:16px">Перевести эту бумагу</a>{{end}}{{end}}
</section>
<section class="sec">
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">График выплат и событий</h2>
{{if $s.Payments}}<div class="tw"><table>
<thead><tr><th>Дата</th><th>Событие</th><th class="num">На бумагу</th><th class="num">Ставка</th><th>Состояние</th></tr></thead>
<tbody>{{range $s.Payments}}<tr>
<td class="n">{{.Date}}</td>
<td>{{.Kind}}{{with .FixDate}}<div class="mut">фиксация {{.}}</div>{{end}}</td>
<td class="num">{{if gt .Amount 0.0}}{{fmtmoney .Amount}} ₽{{else}}—{{end}}</td>
<td class="num">{{if gt .Percent 0.0}}{{fmtpct .Percent}}{{else}}—{{end}}</td>
<td>{{if .Paid}}<span class="chip ok">выплачено</span>{{else}}<span class="chip">ожидается</span>{{end}}</td>
</tr>{{end}}</tbody></table></div>
{{else}}<p class="empty">Выплат по этой бумаге в справочнике депозитария нет.</p>{{end}}
<p class="mut" style="margin-top:14px">Суммы приведены на одну бумагу по данным справочника депозитария. Фактическая выплата и удержанный налог появятся в операциях по счёту.</p>
</section>
</div>
</main></body></html>{{end}}
`