diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index 02eb77b..747eb12 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -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) плюс выплаты дохода (учёт). diff --git a/internal/lkgateway/lkupcoming.go b/internal/lkgateway/lkupcoming.go new file mode 100644 index 0000000..1b35e4f --- /dev/null +++ b/internal/lkgateway/lkupcoming.go @@ -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 +} diff --git a/modules/lk/lk.go b/modules/lk/lk.go index d09a447..cf76b72 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -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) }) diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go index 6f8f3c8..131f6d2 100644 --- a/modules/lk/lk_test.go +++ b/modules/lk/lk_test.go @@ -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 { diff --git a/modules/lk/templates.go b/modules/lk/templates.go index 05684ad..921cc20 100644 --- a/modules/lk/templates.go +++ b/modules/lk/templates.go @@ -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} {{if eq .View "archive"}}Портфель по брокерскому учёту на выбранную дату{{else if eq $p.Source "quik"}}Позиции и деньги в торговой системе, цены биржи{{else}}Портфель по брокерскому учёту{{end}} + {{if .Structure}}
+

Структура портфеля

+
{{range .Structure}}{{end}}
+
{{range .Structure}}{{.Kind}} {{fmtpct1 .Share}} {{fmtmoney .Value}} ₽{{end}}
+
{{end}} + {{if $p.Cash}}

Денежные средства

{{range $p.Cash}}{{fmtmoney .Amount}} {{if eq .Currency "SUR"}}₽{{else}}{{.Currency}}{{end}}{{end}}
@@ -189,6 +199,20 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} {{end}} {{else}}

{{if eq .View "archive"}}На эту дату бумаг на счёте не было.{{else}}Бумаг на счёте нет.{{end}}

{{end}}
+ + {{if and (ne .View "archive") .Upcoming}}
+

Ближайшие выплаты · 90 дней

{{if gt .UpcomingTotal 0.0}}к получению ≈ {{fmtmoney .UpcomingTotal}} ₽{{end}}
+
+ + {{range .Upcoming}} + + + + + + {{end}}
ДатаБумагаСобытиеНа бумагуВсего
{{.Date}}{{.Security}}
{{.ISIN}} · {{fmtnum .Quantity}} шт.
{{.Kind}}{{if gt .Percent 0.0}}
{{fmtpct1 .Percent}} годовых
{{end}}
{{if gt .PerUnit 0.0}}{{fmtmoney .PerUnit}} ₽{{else}}по решению эмитента{{end}}{{if gt .Total 0.0}}{{fmtmoney .Total}} ₽{{else}}—{{end}}
+

Суммы до удержания налога, по данным справочника депозитария; оферта и погашение — при предъявлении бумаг.

+
{{end}} {{end}} {{define "transfer"}}{{template "head" .}}{{template "nav" .}}