Кабинет: оценка портфеля по ценам 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
|
pos = p2
|
||||||
}
|
}
|
||||||
|
total, priced := lkApplyPrices(r.Context(), rc, pos)
|
||||||
lkJSON(w, http.StatusOK, map[string]any{
|
lkJSON(w, http.StatusOK, map[string]any{
|
||||||
"contract": u.Contract,
|
"contract": u.Contract,
|
||||||
"full_name": u.FullName,
|
"full_name": u.FullName,
|
||||||
"positions": pos,
|
"positions": pos,
|
||||||
"source": source,
|
"source": source,
|
||||||
"read_only": readOnly,
|
"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) {
|
mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !auth(w, r) {
|
if !auth(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ type ClientPosition struct {
|
|||||||
// Transferable — бумагу можно перевести через M2M (только российские выпуски).
|
// Transferable — бумагу можно перевести через M2M (только российские выпуски).
|
||||||
Transferable bool `json:"transferable"`
|
Transferable bool `json:"transferable"`
|
||||||
Note string `json:"note"`
|
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 — позиции клиента модуля ЛК по номеру договора (тестовые клиенты;
|
// 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 "ценная бумага"
|
||||||
|
}
|
||||||
+97
-5
@@ -53,6 +53,40 @@ func New(cfg Config) (*Server, error) {
|
|||||||
}
|
}
|
||||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
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 {
|
"shortid": func(s string) string {
|
||||||
if len(s) > 8 {
|
if len(s) > 8 {
|
||||||
return s[:8] + "…"
|
return s[:8] + "…"
|
||||||
@@ -126,6 +160,9 @@ type position struct {
|
|||||||
Available float64 `json:"available"`
|
Available float64 `json:"available"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Transferable bool `json:"transferable"`
|
Transferable bool `json:"transferable"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
ChangePct float64 `json:"change_pct"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,11 +212,42 @@ func claimsView(rows []claimRow) (active, done int) {
|
|||||||
|
|
||||||
// portfolioResp — ответ /api/lk/portfolio.
|
// portfolioResp — ответ /api/lk/portfolio.
|
||||||
type portfolioResp struct {
|
type portfolioResp struct {
|
||||||
Contract string `json:"contract"`
|
Contract string `json:"contract"`
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
Positions []position `json:"positions"`
|
Positions []position `json:"positions"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
ReadOnly bool `json:"read_only"`
|
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 — реестр клиента из ядра.
|
// portfolio — реестр клиента из ядра.
|
||||||
@@ -324,6 +392,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
"Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName,
|
"Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName,
|
||||||
"Positions": pos, "Claims": claims, "Flash": r.URL.Query().Get("m"),
|
"Positions": pos, "Claims": claims, "Flash": r.URL.Query().Get("m"),
|
||||||
"Total": total, "Free": free, "Blocked": blocked, "Active": active,
|
"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"),
|
"Positions": pf.Positions, "Flash": r.URL.Query().Get("m"),
|
||||||
"Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated,
|
"Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated,
|
||||||
"Total": total, "Free": free, "Blocked": blocked,
|
"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) {
|
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
|
||||||
sess := sessionW(w, r)
|
sess := sessionW(w, r)
|
||||||
|
|||||||
+23
-3
@@ -28,8 +28,8 @@ func fakeBJ(t *testing.T) *httptest.Server {
|
|||||||
})
|
})
|
||||||
mux.HandleFunc("/api/lk/portfolio", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/lk/portfolio", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" {
|
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{
|
_ = 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"},
|
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 недоступен"},
|
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) {
|
mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if keyOK(w, r) {
|
if keyOK(w, r) {
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||||
@@ -154,11 +165,14 @@ func TestBoardPages(t *testing.T) {
|
|||||||
return rec.Body.String()
|
return rec.Body.String()
|
||||||
}
|
}
|
||||||
board := get("/")
|
board := get("/")
|
||||||
for _, want := range []string{"Бумаг в реестре", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} {
|
for _, want := range []string{"Стоимость бумаг", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} {
|
||||||
if !strings.Contains(board, want) {
|
if !strings.Contains(board, want) {
|
||||||
t.Errorf("на табло нет %q", want)
|
t.Errorf("на табло нет %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(board, "110 268 ₽") {
|
||||||
|
t.Errorf("на табло нет оценки портфеля")
|
||||||
|
}
|
||||||
reg := get("/portfolio")
|
reg := get("/portfolio")
|
||||||
for _, want := range []string{"ВТНКЦ00000001", "иностранный выпуск", "депозитарный учёт", "перевести"} {
|
for _, want := range []string{"ВТНКЦ00000001", "иностранный выпуск", "депозитарный учёт", "перевести"} {
|
||||||
if !strings.Contains(reg, want) {
|
if !strings.Contains(reg, want) {
|
||||||
@@ -175,6 +189,12 @@ func TestBoardPages(t *testing.T) {
|
|||||||
t.Errorf("в бланке поручения нет %q", want)
|
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")
|
prof := get("/profile")
|
||||||
if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") {
|
if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") {
|
||||||
t.Errorf("профиль: %s", prof)
|
t.Errorf("профиль: %s", prof)
|
||||||
|
|||||||
+66
-8
@@ -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}}
|
@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}}
|
</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 "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">
|
{{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" .}}
|
{{define "board"}}{{template "head" .}}{{template "nav" .}}
|
||||||
<section class="board"><div class="board-row">
|
<section class="board"><div class="board-row">
|
||||||
<div>
|
<div>
|
||||||
<span class="lbl" style="color:var(--dim)">Бумаг в реестре</span>
|
<span class="lbl" style="color:var(--dim)">{{if gt .Value 0.0}}Стоимость бумаг{{else}}Бумаг в реестре{{end}}</span>
|
||||||
<div class="big" style="margin-top:8px">{{.Total}}</div>
|
<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}}</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>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<span class="stat"><span class="lbl" style="color:var(--dim)">Свободно к переводу</span><span class="v">{{.Free}}</span></span>
|
<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">
|
<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>
|
<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>
|
{{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>
|
<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="n" style="font-size:13px">{{if .AccountID}}{{.AccountID}}{{else}}—{{end}}</td>
|
||||||
<td class="num">{{fmtnum .Quantity}}</td>
|
<td class="num">{{fmtnum .Quantity}}</td>
|
||||||
<td class="num">{{fmtnum .Available}}</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>
|
<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>
|
</tr>{{end}}</tbody></table></div>
|
||||||
{{else}}<p class="empty">Бумаг на счетах депо не найдено. Если это ошибка — напишите в депозитарий.</p>{{end}}
|
{{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" .}}
|
{{define "portfolio"}}{{template "head" .}}{{template "nav" .}}
|
||||||
<section class="board"><div class="board-row">
|
<section class="board"><div class="board-row">
|
||||||
<div><span class="lbl" style="color:var(--dim)">Реестр бумаг</span>
|
<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">
|
<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">{{.Total}}</span></span>
|
||||||
<span class="stat"><span class="lbl" style="color:var(--dim)">Свободно к переводу</span><span class="v">{{.Free}}</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 .ReadOnly}}<div class="msg">Кабинет открыт в режиме просмотра: подача поручений по этому договору выключена депозитарием.</div>{{end}}
|
||||||
{{if .Positions}}
|
{{if .Positions}}
|
||||||
<div class="tw"><table>
|
<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>
|
<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="n" style="font-size:13px">{{if .AccountID}}{{.AccountID}}{{else}}—{{end}}</td>
|
||||||
<td class="num">{{fmtnum .Quantity}}</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">{{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">{{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>{{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>
|
<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>
|
</tr>{{end}}</tbody></table></div>
|
||||||
@@ -364,4 +370,56 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
|||||||
|
|
||||||
<p class="mut">В кабинете клиента сотрудник готовит поручение, подписывает его клиент. Каждый вход сотрудника записывается в журнал.</p>
|
<p class="mut">В кабинете клиента сотрудник готовит поручение, подписывает его клиент. Каждый вход сотрудника записывается в журнал.</p>
|
||||||
</main></body></html>{{end}}
|
</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}}
|
||||||
`
|
`
|
||||||
|
|||||||
Reference in New Issue
Block a user