Кабинет: структура портфеля и ближайшие выплаты за 90 дней (купоны, оферты, погашения, дивиденды)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
+62
-3
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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