Кабинет: структура портфеля и ближайшие выплаты за 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
+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" .}}