Кабинет: структура портфеля и ближайшие выплаты за 90 дней (купоны, оферты, погашения, дивиденды)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
@@ -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) плюс выплаты дохода (учёт).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -709,6 +761,7 @@ func (s *Server) buildHandler() http.Handler {
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {{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" .}}
|
||||
|
||||
Reference in New Issue
Block a user