Кабинет: структура портфеля и ближайшие выплаты за 90 дней (купоны, оферты, погашения, дивиденды)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 16:02:48 +03:00
parent c32df3752e
commit c32f399acc
5 changed files with 231 additions and 6 deletions
+5 -1
View File
@@ -310,7 +310,11 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
return
}
pf.Note = strings.TrimSpace(pf.Note)
lkJSON(w, http.StatusOK, map[string]any{"portfolio": pf, "contract": u.Contract, "full_name": u.FullName})
resp := map[string]any{"portfolio": pf, "contract": u.Contract, "full_name": u.FullName}
if q.Get("view") != "archive" {
resp["upcoming"] = lkUpcoming(r.Context(), fancy, pf.Positions, 90) // ближайшие выплаты за 90 дней
}
lkJSON(w, http.StatusOK, resp)
})
// Операции по брокерскому счёту: сделки и движения бумаг (SPECTRE) плюс выплаты дохода (учёт).
+138
View File
@@ -0,0 +1,138 @@
package lkgateway
// lkupcoming.go — ближайшие выплаты по бумагам портфеля: купоны, оферты, погашения,
// дивиденды. Суммы на бумагу — из справочника депозитария (OD_COUPONS, OD_OFFERS, OD_SHARES),
// умножаются на количество в портфеле клиента. Дивиденды по корпоративным действиям (U_CA_ACTS,
// DVCA) даются без суммы: размер известен только после решения эмитента.
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
// LKUpcoming — одна ожидаемая выплата.
type LKUpcoming struct {
Date string `json:"date"`
ISIN string `json:"isin"`
Security string `json:"security"`
Kind string `json:"kind"` // купон | оферта | погашение | дивиденды
PerUnit float64 `json:"per_unit,omitempty"` // на одну бумагу, ₽
Quantity float64 `json:"quantity"`
Total float64 `json:"total,omitempty"` // на весь пакет клиента, ₽
Percent float64 `json:"percent,omitempty"`
sortKey time.Time
}
// lkUpcoming — выплаты по позициям в ближайшие days дней.
func lkUpcoming(ctx context.Context, s FancyMSSQLSettings, positions []LKPosition, days int) []LKUpcoming {
if strings.TrimSpace(s.Server) == "" || len(positions) == 0 {
return nil
}
qty := map[string]float64{}
names := map[string]string{}
var isins []string
for _, p := range positions {
isin := strings.ToUpper(strings.TrimSpace(p.ISIN))
if isin == "" || p.Quantity <= 0 {
continue
}
if _, ok := qty[isin]; !ok {
isins = append(isins, isin)
}
qty[isin] += p.Quantity
names[isin] = p.Name
}
if len(isins) == 0 {
return nil
}
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
from := time.Now().Truncate(24 * time.Hour)
to := from.AddDate(0, 0, days)
params := make([]string, 0, len(isins))
args := []any{sql.Named("f", from), sql.Named("t", to)}
for i, isin := range isins {
params = append(params, fmt.Sprintf("@i%d", i))
args = append(args, sql.Named(fmt.Sprintf("i%d", i), isin))
}
in := strings.Join(params, ",")
var out []LKUpcoming
add := func(isin string, d time.Time, kind string, per, pct float64) {
u := LKUpcoming{Date: d.Format("02.01.2006"), ISIN: isin, Security: names[isin], Kind: kind, PerUnit: per, Quantity: qty[isin], Percent: pct, sortKey: d}
if per > 0 {
u.Total = per * qty[isin]
}
out = append(out, u)
}
// Купоны: дата выплаты — P_DATE, до выплаты — конец периода. IS_PAY здесь означает
// «купон выплачиваемый» (не «уже выплачен»), поэтому по нему не фильтруем.
rows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT UPPER(v.ISIN), ISNULL(c.P_DATE, c.E_DATE), CAST(ISNULL(c.SUMMA,0) AS float), CAST(ISNULL(c.PERCENT_,0) AS float)
FROM OD_COUPONS c JOIN OD_VALUES v ON v.ID = c.SHARE
WHERE UPPER(v.ISIN) IN (%s) AND ISNULL(c.P_DATE, c.E_DATE) >= @f AND ISNULL(c.P_DATE, c.E_DATE) <= @t`, in), args...)
if err == nil {
for rows.Next() {
var isin string
var d sql.NullTime
var summa, pct float64
if rows.Scan(&isin, &d, &summa, &pct) == nil && d.Valid {
add(isin, d.Time, "купон", summa, pct)
}
}
rows.Close()
}
// Оферты.
rows, err = conn.QueryContext(cctx, fmt.Sprintf(`SELECT UPPER(v.ISIN), o.P_DATE, CAST(ISNULL(o.O_PRICE,0) AS float)
FROM OD_OFFERS o JOIN OD_VALUES v ON v.ID = o.SHARE
WHERE UPPER(v.ISIN) IN (%s) AND o.CANCEL_DATE IS NULL AND o.P_DATE >= @f AND o.P_DATE <= @t`, in), args...)
if err == nil {
for rows.Next() {
var isin string
var d sql.NullTime
var price float64
if rows.Scan(&isin, &d, &price) == nil && d.Valid {
add(isin, d.Time, "оферта", price, 0)
}
}
rows.Close()
}
// Погашения: по номиналу в дату DEATHDATE.
rows, err = conn.QueryContext(cctx, fmt.Sprintf(`SELECT UPPER(v.ISIN), sh.DEATHDATE, CAST(ISNULL(sh.NOMINAL,0) AS float)
FROM OD_SHARES sh JOIN OD_VALUES v ON v.ID = sh.ID
WHERE UPPER(v.ISIN) IN (%s) AND sh.E_DATE > GETDATE() AND sh.DEATHDATE >= @f AND sh.DEATHDATE <= @t`, in), args...)
if err == nil {
for rows.Next() {
var isin string
var d sql.NullTime
var nominal float64
if rows.Scan(&isin, &d, &nominal) == nil && d.Valid {
add(isin, d.Time, "погашение", nominal, 0)
}
}
rows.Close()
}
// Дивиденды (корпоративные действия DVCA): дата фиксации или выплаты, суммы нет.
rows, err = conn.QueryContext(cctx, fmt.Sprintf(`SELECT UPPER(v.ISIN), ISNULL(a.OPER_DATE, a.FIX_DATE)
FROM U_CA_ACTS a JOIN OD_VALUES v ON v.ID = a.SHARE
WHERE UPPER(v.ISIN) IN (%s) AND UPPER(ISNULL(a.KD_TYPE,'')) = 'DVCA' AND ISNULL(a.OPER_DATE, a.FIX_DATE) >= @f AND ISNULL(a.OPER_DATE, a.FIX_DATE) <= @t`, in), args...)
if err == nil {
for rows.Next() {
var isin string
var d sql.NullTime
if rows.Scan(&isin, &d) == nil && d.Valid && d.Time.Year() < 9000 {
add(isin, d.Time, "дивиденды", 0, 0)
}
}
rows.Close()
}
sort.Slice(out, func(i, j int) bool { return out[i].sortKey.Before(out[j].sortKey) })
return out
}
+62 -3
View File
@@ -425,6 +425,58 @@ type lkPosition struct {
Note string `json:"note"`
}
// lkUpcoming — ожидаемая выплата по бумаге портфеля.
type lkUpcoming struct {
Date string `json:"date"`
ISIN string `json:"isin"`
Security string `json:"security"`
Kind string `json:"kind"`
PerUnit float64 `json:"per_unit"`
Quantity float64 `json:"quantity"`
Total float64 `json:"total"`
Percent float64 `json:"percent"`
}
// lkSegment — доля вида активов в портфеле для полосы структуры.
type lkSegment struct {
Kind string
Value float64
Share float64
Color string
}
// portfolioStructure — структура портфеля по видам активов (бумаги по виду + деньги).
func portfolioStructure(p lkPortfolio) []lkSegment {
if p.Total <= 0 {
return nil
}
colors := map[string]string{"акция": "#e8a33d", "облигация": "#4f7cac", "фонд": "#5f9e7a", "пай": "#5f9e7a", "депозитарная расписка": "#b07aa1", "деньги": "#8e8879"}
byKind := map[string]float64{}
for _, pos := range p.Positions {
k := pos.Kind
if k == "" || k == "бумага" {
k = "прочее"
}
byKind[k] += pos.Value
}
if p.CashRUB > 0 {
byKind["деньги"] += p.CashRUB
}
var out []lkSegment
for k, v := range byKind {
if v <= 0 {
continue
}
c := colors[k]
if c == "" {
c = "#c9c3b4"
}
out = append(out, lkSegment{Kind: k, Value: v, Share: v / p.Total * 100, Color: c})
}
sort.Slice(out, func(i, j int) bool { return out[i].Value > out[j].Value })
return out
}
// lkOpRow — операция по брокерскому счёту (ответ /api/lk/ops).
type lkOpRow struct {
Date string `json:"date"`
@@ -706,9 +758,10 @@ func (s *Server) buildHandler() http.Handler {
q.Set("date", date)
}
var out struct {
Portfolio lkPortfolio `json:"portfolio"`
Contract string `json:"contract"`
FullName string `json:"full_name"`
Portfolio lkPortfolio `json:"portfolio"`
Contract string `json:"contract"`
FullName string `json:"full_name"`
Upcoming []lkUpcoming `json:"upcoming"`
}
path := "/api/lk/portfolio2"
if len(q) > 0 {
@@ -722,6 +775,12 @@ func (s *Server) buildHandler() http.Handler {
return
}
data["P"], data["Contract"], data["FullName"] = out.Portfolio, out.Contract, out.FullName
data["Structure"], data["Upcoming"] = portfolioStructure(out.Portfolio), out.Upcoming
var upTotal float64
for _, u := range out.Upcoming {
upTotal += u.Total
}
data["UpcomingTotal"] = upTotal
s.render(w, r, "portfolio", data)
})
+2 -2
View File
@@ -97,7 +97,7 @@ func fakeBJ(t *testing.T) *httptest.Server {
"total": 110268.0, "securities_value": 92698.28, "cash_rub": 17569.72, "cost": 80000.0, "pnl": 12698.28, "pnl_pct": 15.87, "priced": 1,
"cash": []map[string]any{{"currency": "SUR", "amount": 17569.72}},
"positions": []map[string]any{{"isin": "RU0009029540", "name": "Сбербанк, ао", "kind": "акция", "quantity": 336, "avg_price": 238.1, "price": 275.887, "value": 92698.28, "cost": 80000, "pnl": 12698.28, "pnl_pct": 15.87, "day_pct": 1.2, "share": 84.1}},
}})
}, "upcoming": []map[string]any{{"date": "10.10.2026", "isin": "RU0009029540", "security": "Сбербанк, ао", "kind": "дивиденды", "quantity": 336}}})
})
mux.HandleFunc("/api/lk/ops", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) {
@@ -306,7 +306,7 @@ func TestBoardPages(t *testing.T) {
return rec.Body.String()
}
board := get("/")
for _, want := range []string{"Стоимость портфеля", "Новое поручение", "Сбербанк, ао", "Онлайн", "Архив", "Денежные средства", "12 698,28 ₽", "15,87 %", "84,1%"} {
for _, want := range []string{"Стоимость портфеля", "Новое поручение", "Сбербанк, ао", "Онлайн", "Архив", "Денежные средства", "12 698,28 ₽", "15,87 %", "84,1%", "Структура портфеля", "деньги", "Ближайшие выплаты", "по решению эмитента"} {
if !strings.Contains(board, want) {
t.Errorf("в портфеле нет %q", want)
if i := strings.Index(board, "Результат"); i > 0 {
+24
View File
@@ -39,6 +39,10 @@ a{color:var(--acc)}a:hover{color:#1e3395}
.tabform{display:flex;gap:8px;align-items:center;margin-left:8px}
.tabform input[type=date]{padding:7px 10px;border:1px solid var(--line-2);border-radius:3px;background:#fff;font:inherit;font-size:14px}
.pos{color:var(--ok-tx)}.neg{color:var(--err-tx)}
.sbar{display:flex;height:14px;border-radius:3px;overflow:hidden;background:var(--line);gap:2px}
.sbar span{display:block;height:100%;min-width:2px}
.slegend{display:flex;gap:18px;flex-wrap:wrap;margin-top:10px;font-size:13.5px}
.slegend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:7px;vertical-align:-1px}
.kind{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut)}
.cta{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;background:var(--amber);color:var(--ink);text-decoration:none;border:0;border-radius:3px;cursor:pointer;font:inherit}
.cta:hover{filter:brightness(1.05);color:var(--ink)}
@@ -169,6 +173,12 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
<span class="mut" style="margin-left:auto;font-size:12.5px">{{if eq .View "archive"}}Портфель по брокерскому учёту на выбранную дату{{else if eq $p.Source "quik"}}Позиции и деньги в торговой системе, цены биржи{{else}}Портфель по брокерскому учёту{{end}}</span>
</div>
{{if .Structure}}<section class="sec" style="margin-bottom:18px">
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Структура портфеля</h2></div>
<div class="sbar">{{range .Structure}}<span style="width:{{printf "%.2f" .Share}}%;background:{{.Color}}" title="{{.Kind}}: {{fmtmoney .Value}} ₽"></span>{{end}}</div>
<div class="slegend">{{range .Structure}}<span><i style="background:{{.Color}}"></i>{{.Kind}} <b class="n">{{fmtpct1 .Share}}</b> <span class="mut n">{{fmtmoney .Value}} ₽</span></span>{{end}}</div>
</section>{{end}}
{{if $p.Cash}}<section class="sec" style="margin-bottom:18px">
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Денежные средства</h2></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">{{range $p.Cash}}<span class="chip" style="font-size:13px;text-transform:none;letter-spacing:0"><b class="n">{{fmtmoney .Amount}}</b>&nbsp;{{if eq .Currency "SUR"}}₽{{else}}{{.Currency}}{{end}}</span>{{end}}</div>
@@ -189,6 +199,20 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
</tr>{{end}}</tbody></table></div>
{{else}}<p class="empty">{{if eq .View "archive"}}На эту дату бумаг на счёте не было.{{else}}Бумаг на счёте нет.{{end}}</p>{{end}}
</section>
{{if and (ne .View "archive") .Upcoming}}<section class="sec" style="margin-top:22px">
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Ближайшие выплаты · 90 дней</h2>{{if gt .UpcomingTotal 0.0}}<span class="n" style="font-size:13px">к получению ≈ {{fmtmoney .UpcomingTotal}} ₽</span>{{end}}</div>
<div class="tw"><table>
<thead><tr><th>Дата</th><th>Бумага</th><th>Событие</th><th class="num">На бумагу</th><th class="num">Всего</th></tr></thead>
<tbody>{{range .Upcoming}}<tr>
<td class="n">{{.Date}}</td>
<td><a href="/security?isin={{.ISIN}}" style="text-decoration:none;font-weight:600">{{.Security}}</a><div class="n" style="font-size:12px;color:var(--mut)">{{.ISIN}} · {{fmtnum .Quantity}} шт.</div></td>
<td><span class="chip {{if eq .Kind "погашение"}}go{{else if eq .Kind "оферта"}}go{{else}}ok{{end}}">{{.Kind}}</span>{{if gt .Percent 0.0}}<div class="mut" style="font-size:12px">{{fmtpct1 .Percent}} годовых</div>{{end}}</td>
<td class="num">{{if gt .PerUnit 0.0}}{{fmtmoney .PerUnit}} ₽{{else}}<span class="mut">по решению эмитента</span>{{end}}</td>
<td class="num">{{if gt .Total 0.0}}<b>{{fmtmoney .Total}} ₽</b>{{else}}—{{end}}</td>
</tr>{{end}}</tbody></table></div>
<p class="mut" style="margin-top:10px;font-size:12.5px">Суммы до удержания налога, по данным справочника депозитария; оферта и погашение — при предъявлении бумаг.</p>
</section>{{end}}
</main></body></html>{{end}}
{{define "transfer"}}{{template "head" .}}{{template "nav" .}}