Кабинет клиента: портфель как у брокера — «Онлайн» по QUIK, «Архив» на дату и операции по SPECTRE

- lkclient.go: договор → код QUIK (БР-1545 → BR1545, +OUT), договор → D_B_CONTRACTS SPECTRE
- lkonline.go: позиции DepoLimits (T2, свежая строка на счёт), средняя цена покупки, деньги
  MoneyLimits по валютам, цены и P&L; стоимость, доли
- lkarchive.go: портфель на дату по остаткам OD_RESTS (REG_3 = договор, раздел 39, счета
  50/51/52/55 и 58.01/58.02; 58.05 НКД не показываем) с откатом по OD_TURNS после даты
  (индекс REST+WIRDATE, знак TYPE_ проверен: сумма оборотов = остаток); оценка по котировке
  OD_VALUES_RATES ≤ 90 дней (в рублях за бумагу), иначе балансовая; операции из OD_WIRING
  (покупка/продажа/ввод/вывод/погашение/конвертация, продажа — по счёту ВЫРУЧКА) и
  пополнения/выводы денег; рецепт — InvestControl Hub (build_portfolio_base, deals_report)
- API: /api/lk/portfolio2?view=online|archive&date=, /api/lk/ops (сделки + выплаты дохода);
  без клиента в QUIK онлайн падает на брокерский учёт за сегодня
- кабинет: разделы Портфель (вкладки Онлайн/Архив с датой), Операции, Поручения, Документы,
  Профиль; депозитарные реестр и доходы из клиентского вида убраны (/portfolio → /,
  /income → /operations); проверено на БР-704 и БР-1545
- настройка Fancy.SpectreDatabase (по умолчанию BJ_SPECTRE_DATA)

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 15:44:53 +03:00
parent 05d02f22e6
commit 02fabb483d
9 changed files with 1268 additions and 274 deletions
+173 -126
View File
@@ -71,33 +71,7 @@ func New(cfg Config) (*Server, error) {
}
return strconv.FormatFloat(f, 'f', -1, 64)
},
"fmtmoney": func(f float64) string { // 1 234 567,89 — с разделителем разрядов
neg := f < 0
if neg {
f = -f
}
whole := int64(f)
cents := int64((f-float64(whole))*100 + 0.5)
if cents >= 100 {
whole, cents = whole+1, 0
}
d := strconv.FormatInt(whole, 10)
var b strings.Builder
for i, c := range d {
if i > 0 && (len(d)-i)%3 == 0 {
b.WriteByte(' ')
}
b.WriteRune(c)
}
out := b.String()
if cents > 0 {
out += "," + fmt.Sprintf("%02d", cents)
}
if neg {
out = "−" + out
}
return out
},
"fmtmoney": fmtMoney,
"fmtpct": func(f float64) string {
s := strconv.FormatFloat(f, 'f', 2, 64)
if f > 0 {
@@ -105,6 +79,43 @@ func New(cfg Config) (*Server, error) {
}
return strings.ReplaceAll(s, ".", ",") + " %"
},
"fmtpct1": func(f float64) string { // 84,1%
return strings.ReplaceAll(strconv.FormatFloat(f, 'f', 1, 64), ".", ",") + "%"
},
"fmtsigned": func(f float64) string { // +1 234,56 / −1 234,56
if f > 0 {
return "+" + fmtMoney(f)
}
return fmtMoney(f)
},
"fmtprice": func(f float64) string { // цена: до 4 знаков, без хвостовых нулей, с пробелами разрядов
if f == 0 {
return "—"
}
s := strconv.FormatFloat(f, 'f', 4, 64)
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
whole, frac := s, ""
if i := strings.IndexByte(s, '.'); i >= 0 {
whole, frac = s[:i], s[i+1:]
}
neg := strings.HasPrefix(whole, "-")
whole = strings.TrimPrefix(whole, "-")
var b strings.Builder
for i, c := range whole {
if i > 0 && (len(whole)-i)%3 == 0 {
b.WriteByte(' ')
}
b.WriteRune(c)
}
out := b.String()
if frac != "" {
out += "," + frac
}
if neg {
out = "−" + out
}
return out
},
"shortid": func(s string) string {
if len(s) > 8 {
return s[:8] + "…"
@@ -368,6 +379,60 @@ type portfolioResp struct {
}
// operationRow — движение по счёту депо.
// lkPortfolio — портфель как у брокера (ответ /api/lk/portfolio2).
type lkPortfolio struct {
Source string `json:"source"`
AsOf string `json:"as_of"`
Total float64 `json:"total"`
Securities float64 `json:"securities_value"`
CashRUB float64 `json:"cash_rub"`
Cost float64 `json:"cost"`
PnL float64 `json:"pnl"`
PnLPct float64 `json:"pnl_pct"`
Priced int `json:"priced"`
Cash []lkCash `json:"cash"`
Positions []lkPosition `json:"positions"`
Note string `json:"note"`
}
type lkCash struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
}
type lkPosition struct {
ISIN string `json:"isin"`
Code string `json:"code"`
Name string `json:"name"`
Kind string `json:"kind"`
Quantity float64 `json:"quantity"`
AvgPrice float64 `json:"avg_price"`
Price float64 `json:"price"`
InPct bool `json:"in_pct"`
Value float64 `json:"value"`
Cost float64 `json:"cost"`
PnL float64 `json:"pnl"`
PnLPct float64 `json:"pnl_pct"`
DayPct float64 `json:"day_pct"`
Share float64 `json:"share"`
Currency string `json:"currency"`
Note string `json:"note"`
}
// lkOpRow — операция по брокерскому счёту (ответ /api/lk/ops).
type lkOpRow struct {
Date string `json:"date"`
Kind string `json:"kind"`
Side string `json:"side"`
Name string `json:"name"`
ISIN string `json:"isin"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
AmountRUB float64 `json:"amount_rub"`
}
type operationRow struct {
Date string `json:"date"`
DoneAt string `json:"done_at"`
@@ -622,60 +687,42 @@ func (s *Server) buildHandler() http.Handler {
return
}
}
pf, err := s.portfolio(r, sess)
if err != nil {
s.render(w, r, "board", map[string]any{"Nav": "b", "Error": err.Error()})
view := "online"
q := url.Values{}
today := time.Now().Format("2006-01-02")
date := r.URL.Query().Get("date")
if r.URL.Query().Get("view") == "archive" {
view = "archive"
if _, err := time.Parse("2006-01-02", date); err != nil || date > today {
date = today
}
q.Set("view", "archive")
q.Set("date", date)
}
var out struct {
Portfolio lkPortfolio `json:"portfolio"`
Contract string `json:"contract"`
FullName string `json:"full_name"`
}
path := "/api/lk/portfolio2"
if len(q) > 0 {
path += "?" + q.Encode()
}
data := map[string]any{"Nav": "b", "View": view, "Date": date, "Today": today}
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
data["Error"] = err.Error()
data["P"] = lkPortfolio{}
s.render(w, r, "portfolio", data)
return
}
total, free, blocked, _ := portfolioView(pf.Positions)
var cl struct {
Claims []claimRow `json:"claims"`
}
_ = s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl)
active, _ := claimsView(cl.Claims)
pos := pf.Positions
if len(pos) > 5 {
pos = pos[:5]
}
claims := cl.Claims
if len(claims) > 5 {
claims = claims[:5]
}
s.render(w, r, "board", map[string]any{
"Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName,
"Positions": pos, "Claims": claims,
"Total": total, "Free": free, "Blocked": blocked, "Active": active,
"Value": pf.TotalValue, "Priced": pf.Priced,
})
data["P"], data["Contract"], data["FullName"] = out.Portfolio, out.Contract, out.FullName
s.render(w, r, "portfolio", data)
})
// Реестр бумаг
mux.HandleFunc("/portfolio", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
pf, err := s.portfolio(r, sess)
if err != nil {
s.render(w, r, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
return
}
total, free, blocked, _ := portfolioView(pf.Positions)
updated := ""
if len(pf.Positions) > 0 {
updated = pf.Positions[0].UpdatedAt
}
s.render(w, r, "portfolio", map[string]any{
"Nav": "p", "Contract": pf.Contract, "FullName": pf.FullName,
"Positions": pf.Positions,
"Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated,
"Total": total, "Free": free, "Blocked": blocked,
"Value": pf.TotalValue, "Priced": pf.Priced,
})
http.Redirect(w, r, "/", http.StatusMovedPermanently)
})
// Карточка бумаги
mux.HandleFunc("/security", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
@@ -705,70 +752,41 @@ func (s *Server) buildHandler() http.Handler {
return
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
if from == "" {
from = time.Now().AddDate(-1, 0, 0).Format("2006-01-02")
}
if to == "" {
to = time.Now().Format("2006-01-02")
}
var out struct {
Operations []operationRow `json:"operations"`
Rows []lkOpRow `json:"operations"`
From string `json:"from"`
To string `json:"to"`
Note string `json:"note"`
}
path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
path := "/api/lk/ops?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
data := map[string]any{"Nav": "o", "From": from, "To": to}
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to})
data["Error"] = err.Error()
s.render(w, r, "operations", data)
return
}
var in, outq int
for _, o := range out.Operations {
if o.Quantity < 0 {
outq++
} else {
in++
var buy, sell, income float64
for _, o := range out.Rows {
switch o.Kind {
case "покупка", "ввод бумаг", "пополнение":
buy += o.AmountRUB
case "продажа", "погашение", "вывод бумаг", "вывод денег":
sell += o.AmountRUB
default:
if o.Side == "in" {
income += o.AmountRUB
}
}
}
s.render(w, r, "operations", map[string]any{"Nav": "o", "Operations": out.Operations,
"From": from, "To": to, "Total": len(out.Operations), "In": in, "Out": outq})
data["Rows"], data["From"], data["To"], data["Note"] = out.Rows, out.From, out.To, out.Note
data["BuyRUB"], data["SellRUB"], data["IncomeRUB"] = buy, sell, income
s.render(w, r, "operations", data)
})
// Доходы и налоги
mux.HandleFunc("/income", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
if from == "" {
from = time.Now().Format("2006") + "-01-01"
}
if to == "" {
to = time.Now().Format("2006-01-02")
}
var out struct {
Income []incomeRow `json:"income"`
Accrued float64 `json:"accrued"`
Paid float64 `json:"paid"`
Tax float64 `json:"tax"`
}
path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to})
return
}
var ca struct {
Actions []corpAction `json:"actions"`
}
_ = s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca)
acts := ca.Actions
if len(acts) > 8 {
acts = acts[:8]
}
s.render(w, r, "income", map[string]any{"Nav": "i", "Income": out.Income, "From": from, "To": to,
"Accrued": out.Accrued, "Paid": out.Paid, "Tax": out.Tax, "Actions": acts})
http.Redirect(w, r, "/operations", http.StatusMovedPermanently)
})
// Документы: готовые выписки, заявки клиента и заказ нового документа
mux.HandleFunc("/documents", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
@@ -1048,3 +1066,32 @@ func (s *Server) mode() string {
}
return "direct"
}
// fmtMoney — 1 234 567,89 с разделителем разрядов; отрицательные — с минусом.
func fmtMoney(f float64) string {
neg := f < 0
if neg {
f = -f
}
whole := int64(f)
cents := int64((f-float64(whole))*100 + 0.5)
if cents >= 100 {
whole, cents = whole+1, 0
}
d := strconv.FormatInt(whole, 10)
var b strings.Builder
for i, c := range d {
if i > 0 && (len(d)-i)%3 == 0 {
b.WriteByte(' ')
}
b.WriteRune(c)
}
out := b.String()
if cents > 0 {
out += "," + fmt.Sprintf("%02d", cents)
}
if neg {
out = "−" + out
}
return out
}