Кабинет клиента: портфель как у брокера — «Онлайн» по 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:
+173
-126
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user