Кабинет клиента: портфель как у брокера — «Онлайн» по 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
+75
View File
@@ -7,6 +7,7 @@ import (
"io"
"log"
"net/http"
"sort"
"strings"
"time"
)
@@ -271,6 +272,80 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
lkJSON(w, http.StatusOK, map[string]any{"operations": ops, "from": q.Get("from"), "to": q.Get("to")})
})
// Портфель клиента как у брокера: view=online — по экспорту QUIK (что есть сейчас), если
// в QUIK клиента нет — по брокерскому учёту на сегодня; view=archive&date=YYYY-MM-DD —
// портфель на дату по брокерскому учёту (SPECTRE).
mux.HandleFunc("/api/lk/portfolio2", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
}
u, ok := current(w, r)
if !ok {
return
}
fancy := rc.Snapshot().Fancy
q := r.URL.Query()
var pf *LKPortfolio
var err error
switch q.Get("view") {
case "archive":
day := time.Now()
if d, e := time.ParseInLocation("2006-01-02", q.Get("date"), time.Local); e == nil {
day = d
}
pf, err = lkArchivePortfolio(r.Context(), fancy, u.Contract, day)
default:
pf, err = lkOnlinePortfolio(r.Context(), rc, u.Contract)
if err == nil && len(pf.Positions) == 0 && len(pf.Cash) == 0 {
// В торговой системе клиента нет (не торгует через QUIK) — текущий портфель из учёта.
if ar, e := lkArchivePortfolio(r.Context(), fancy, u.Contract, time.Now()); e == nil {
ar.Note = "по данным брокерского учёта на сегодня"
pf = ar
}
}
}
if err != nil {
lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
pf.Note = strings.TrimSpace(pf.Note)
lkJSON(w, http.StatusOK, map[string]any{"portfolio": pf, "contract": u.Contract, "full_name": u.FullName})
})
// Операции по брокерскому счёту: сделки и движения бумаг (SPECTRE) плюс выплаты дохода (учёт).
mux.HandleFunc("/api/lk/ops", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
}
u, ok := current(w, r)
if !ok {
return
}
fancy := rc.Snapshot().Fancy
q := r.URL.Query()
fromT, toT := parseRange(q.Get("from"), q.Get("to"))
rows, err := lkArchiveOperations(r.Context(), fancy, u.Contract, fromT, toT)
if err != nil {
rows = nil // без брокерского учёта показываем хотя бы выплаты
}
if inc, _, e := lkIncome(r.Context(), fancy, u.Contract, q.Get("from"), q.Get("to")); e == nil {
for _, p := range inc {
rows = append(rows, LKOperationRow{Date: p.Date, Kind: p.Kind, Side: "in", Name: p.Security, ISIN: p.ISIN,
Quantity: p.Quantity, Amount: p.Paid, Currency: "SUR", AmountRUB: p.Paid})
}
}
sort.SliceStable(rows, func(i, j int) bool {
di, _ := time.Parse("02.01.2006", rows[i].Date)
dj, _ := time.Parse("02.01.2006", rows[j].Date)
return di.After(dj)
})
resp := map[string]any{"operations": rows, "from": fromT.Format("2006-01-02"), "to": toT.Format("2006-01-02")}
if err != nil {
resp["note"] = err.Error()
}
lkJSON(w, http.StatusOK, resp)
})
// Выплаты дохода и удержанный налог.
mux.HandleFunc("/api/lk/income", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
+517
View File
@@ -0,0 +1,517 @@
package lkgateway
// lkarchive.go — портфель клиента на дату и операции по брокерскому учёту (SPECTRE).
//
// SPECTRE — бухгалтерское ядро Fancy. Позиции клиента лежат в остатках OD_RESTS по регистру
// REG_3 = договор (D_B_CONTRACTS.DOC), в портфельном разделе плана счетов (OD_BALANCES.PART=39):
// деньги — счета 50/51/52/55 (остаток D в валюте VALUE_ID), бумаги — 58.xx (количество A,
// бумага REG_2), НКД — отдельный счёт 58.05 (не показываем: он в цене). OD_RESTS — текущий
// остаток; позиция на прошлую дату = остаток минус обороты OD_TURNS после этой даты
// (TYPE_ = +1 дебет / −1 кредит, что подтверждено сверкой: сумма оборотов = остаток).
// Оценка — по котировке OD_VALUES_RATES не старше 90 дней на дату (правило признаваемой
// котировки), иначе — балансовая стоимость EQ_D. Рецепт взят из InvestControl Hub
// (service.build_portfolio_base, deals_report) и проверен там на договорах ДУ-489 и БР-704.
//
// Операции — из проводок OD_WIRING по портфельным счетам договора: покупка, продажа, ввод и
// вывод бумаг, погашение, конвертация; выручка при продаже берётся со счёта ВЫРУЧКА.
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
// spectreRecognizeDays — котировка старше этого срока не считается рыночной.
const spectreRecognizeDays = 90
type spectreRest struct {
id int64
numS, sysName string
num1 int
secID, curID int64
a, d, eqD float64
isin, secName, cur string
class int // OD_SHARES.CLASS: 1 акция, 2 облигация, 7 депозитарная расписка …
nominal float64
}
// lkArchivePortfolio — портфель на дату по SPECTRE. date — в местном времени, конец дня включительно.
func lkArchivePortfolio(ctx context.Context, s FancyMSSQLSettings, contract string, date time.Time) (*LKPortfolio, error) {
c, ok := spectreContractFor(ctx, s, contract)
if !ok {
return nil, fmt.Errorf("договор %s в брокерском учёте не найден", lkContractNumber(contract))
}
conn, err := spectreOpen(s)
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
day := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
today := time.Now()
isPast := day.Before(time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC))
rows, err := conn.QueryContext(cctx, `SELECT r.ID, b.NUM_S, ISNULL(b.SYS_NAME,''), b.NUM_1, ISNULL(r.REG_2,0), ISNULL(r.VALUE_ID,0),
CAST(ISNULL(r.A,0) AS float), CAST(ISNULL(r.D,0) AS float), CAST(ISNULL(r.EQ_D,0) AS float),
ISNULL(vs.ISIN,''), ISNULL(vs.NAME,''), ISNULL(vc.SYSNAME,''), ISNULL(sh.CLASS,0), CAST(ISNULL(sh.NOMINAL,0) AS float)
FROM OD_RESTS r JOIN OD_BALANCES b ON b.ID = r.BAL_ACC
LEFT JOIN OD_VALUES vs ON vs.ID = r.REG_2
LEFT JOIN OD_VALUES vc ON vc.ID = r.VALUE_ID
LEFT JOIN OD_SHARES sh ON sh.ID = r.REG_2
WHERE r.REG_3 = @c AND b.IS_BAL = 1 AND b.PART = 39 AND b.NUM_1 IN (50,51,52,55,58)
ORDER BY b.NUM_1, vs.NAME`, sql.Named("c", c.DOC))
if err != nil {
return nil, fmt.Errorf("остатки: %v", err)
}
var rests []spectreRest
for rows.Next() {
var r spectreRest
if rows.Scan(&r.id, &r.numS, &r.sysName, &r.num1, &r.secID, &r.curID, &r.a, &r.d, &r.eqD,
&r.isin, &r.secName, &r.cur, &r.class, &r.nominal) == nil {
rests = append(rests, r)
}
}
rows.Close()
// Прошлая дата: откатываем остатки на обороты после конца этого дня.
if isPast && len(rests) > 0 {
ids := make([]string, 0, len(rests))
for _, r := range rests {
ids = append(ids, fmt.Sprint(r.id))
}
trows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT REST,
CAST(SUM(CASE WHEN TYPE_ = 1 THEN AMOUNT_ ELSE -AMOUNT_ END) AS float),
CAST(SUM(CASE WHEN TYPE_ = 1 THEN VALUE_ ELSE -VALUE_ END) AS float),
CAST(SUM(CASE WHEN TYPE_ = 1 THEN EQ_ ELSE -EQ_ END) AS float)
FROM OD_TURNS WHERE REST IN (%s) AND WIRDATE >= @d AND IS_PLAN = 'F' AND YEAR(WIRDATE) < 9000
GROUP BY REST`, strings.Join(ids, ",")), sql.Named("d", day.AddDate(0, 0, 1)))
if err != nil {
return nil, fmt.Errorf("обороты: %v", err)
}
adj := map[int64][3]float64{}
for trows.Next() {
var id int64
var a, v, e float64
if trows.Scan(&id, &a, &v, &e) == nil {
adj[id] = [3]float64{a, v, e}
}
}
trows.Close()
for i := range rests {
if x, ok := adj[rests[i].id]; ok {
rests[i].a -= x[0]
rests[i].d -= x[1]
rests[i].eqD -= x[2]
}
}
}
out := &LKPortfolio{Source: "spectre", AsOf: day.Format("02.01.2006")}
fx := spectreFx(cctx, conn, day)
byISIN := map[string]*LKPosition{}
cash := map[string]float64{}
for _, r := range rests {
isCash := r.num1 != 58
if isCash {
if abs(r.d) < 0.005 {
continue
}
cur := spectreCurCode(r.cur)
cash[cur] += r.d
if cur == "SUR" {
out.CashRUB += r.d
} else {
out.CashRUB += r.d * fx(r.curID)
}
continue
}
if strings.Contains(strings.ToUpper(r.sysName), "НКД") || strings.Contains(strings.ToUpper(r.sysName), "СКД") {
continue // купонный доход сидит в цене, отдельной строкой клиенту не нужен
}
if abs(r.a) < 1e-9 {
continue
}
key := r.isin
if key == "" {
key = fmt.Sprintf("id:%d", r.secID)
}
pos := byISIN[key]
if pos == nil {
pos = &LKPosition{ISIN: r.isin, Name: r.secName, Kind: spectreKind(r.class), Currency: spectreCurCode(r.cur)}
byISIN[key] = pos
}
pos.Quantity += r.a
pos.Cost += r.eqD
// Оценка: рыночная котировка на дату, если не старше 90 дней, иначе балансовая.
// Котировки OD_VALUES_RATES уже в рублях за одну бумагу (и для валютных выпусков —
// проверено: НОВАТЭК 001P-03 с номиналом 100 USD котируется как 7 967 ₽).
price, age := spectreQuote(cctx, conn, r.secID, day)
if price > 0 && age <= spectreRecognizeDays {
pos.Value += r.a * price
pos.Price = price
} else {
pos.Value += r.eqD
pos.Note = "оценка по балансовой стоимости: нет свежей котировки"
}
}
for _, p := range byISIN {
p.PnL = p.Value - p.Cost
if p.Cost != 0 {
p.PnLPct = p.PnL / p.Cost * 100
}
if p.Quantity != 0 && p.Cost != 0 {
p.AvgPrice = p.Cost / p.Quantity
}
out.Positions = append(out.Positions, *p)
}
for cur, amt := range cash {
if abs(amt) >= 0.005 {
out.Cash = append(out.Cash, LKCash{Currency: cur, Amount: amt})
}
}
sort.Slice(out.Cash, func(i, j int) bool { return out.Cash[i].Currency < out.Cash[j].Currency })
out.Priced = 0
for _, p := range out.Positions {
if p.Price > 0 {
out.Priced++
}
}
lkPortfolioTotals(out)
if c.Closed {
out.Note = "договор закрыт"
}
return out, nil
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
// spectreCurCode — код валюты в терминах кабинета (рубли — SUR, как в QUIK).
func spectreCurCode(sys string) string {
switch strings.ToUpper(strings.TrimSpace(sys)) {
case "RUB", "RUR", "SUR", "":
return "SUR"
}
return strings.ToUpper(strings.TrimSpace(sys))
}
// spectreKind — вид бумаги по классу OD_SHARES.CLASS.
func spectreKind(class int) string {
switch class {
case 1:
return "акция"
case 2:
return "облигация"
case 3, 4:
return "пай"
case 7:
return "депозитарная расписка"
}
return "бумага"
}
// spectreQuote — котировка бумаги на дату (не позже) и её возраст в днях.
func spectreQuote(ctx context.Context, conn *sql.DB, secID int64, day time.Time) (float64, int) {
var ofic sql.NullTime
var rate3, pfr, closeP sql.NullFloat64
err := conn.QueryRowContext(ctx, `SELECT TOP 1 OFICDATE, CAST(RATE3 AS float), CAST(PFR_PRICE AS float), CAST(CLOSE_ AS float)
FROM OD_VALUES_RATES WHERE VALUE_ID = @v AND OFICDATE <= @d AND (RATE3 > 0 OR PFR_PRICE > 0 OR CLOSE_ > 0)
ORDER BY OFICDATE DESC`, sql.Named("v", secID), sql.Named("d", day.AddDate(0, 0, 1))).Scan(&ofic, &rate3, &pfr, &closeP)
if err != nil {
return 0, 1 << 30
}
price := pfr.Float64
if price <= 0 {
price = rate3.Float64
}
if price <= 0 {
price = closeP.Float64
}
age := 1 << 30
if ofic.Valid {
age = int(day.Sub(ofic.Time).Hours() / 24)
if age < 0 {
age = 0
}
}
return price, age
}
// spectreFx — курс валюты к рублю на дату (MARKET=0), с кэшем на запрос.
func spectreFx(ctx context.Context, conn *sql.DB, day time.Time) func(curID int64) float64 {
cache := map[int64]float64{}
return func(curID int64) float64 {
if curID == 0 {
return 1
}
if v, ok := cache[curID]; ok {
return v
}
var rate sql.NullFloat64
_ = conn.QueryRowContext(ctx, `SELECT TOP 1 CAST(RATE AS float) FROM OD_VALUES_RATES WHERE VALUE_ID = @v AND MARKET = 0 AND OFICDATE <= @d AND RATE > 0 ORDER BY OFICDATE DESC`,
sql.Named("v", curID), sql.Named("d", day.AddDate(0, 0, 1))).Scan(&rate)
v := rate.Float64
if v <= 0 {
v = 1
}
cache[curID] = v
return v
}
}
// LKOperationRow — операция по брокерскому счёту для ленты кабинета.
type LKOperationRow struct {
Date string `json:"date"`
Kind string `json:"kind"` // покупка | продажа | ввод бумаг | вывод бумаг | погашение | конвертация | замещение | пополнение | вывод денег
Side string `json:"side"` // in | out
Name string `json:"name"` // бумага или «Денежные средства»
ISIN string `json:"isin,omitempty"`
Quantity float64 `json:"quantity,omitempty"`
Price float64 `json:"price,omitempty"` // цена за бумагу в валюте
Amount float64 `json:"amount,omitempty"` // сумма в валюте операции
Currency string `json:"currency,omitempty"`
AmountRUB float64 `json:"amount_rub"` // сумма в рублях
}
// lkArchiveOperations — сделки и движения по портфельным счетам договора за период.
func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract string, from, to time.Time) ([]LKOperationRow, error) {
c, ok := spectreContractFor(ctx, s, contract)
if !ok {
return nil, fmt.Errorf("договор %s в брокерском учёте не найден", lkContractNumber(contract))
}
conn, err := spectreOpen(s)
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
// Счета бумаг (ПОРТФЕЛЬ) и денег (касса) договора.
rows, err := conn.QueryContext(cctx, `SELECT r.ID, b.NUM_1, ISNULL(b.SYS_NAME,'') FROM OD_RESTS r JOIN OD_BALANCES b ON b.ID = r.BAL_ACC
WHERE r.REG_3 = @c AND b.PART = 39 AND ((b.NUM_1 = 58 AND b.SYS_NAME LIKE N'ПОРТФЕЛЬ%') OR b.NUM_1 IN (50,51,52,55))`, sql.Named("c", c.DOC))
if err != nil {
return nil, err
}
var secIDs, cashIDs []string
secSet := map[int64]bool{}
for rows.Next() {
var id int64
var num1 int
var sys string
if rows.Scan(&id, &num1, &sys) == nil {
if num1 == 58 {
secIDs = append(secIDs, fmt.Sprint(id))
secSet[id] = true
} else {
cashIDs = append(cashIDs, fmt.Sprint(id))
}
}
}
rows.Close()
var out []LKOperationRow
toEnd := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
fromDay := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
if len(secIDs) > 0 {
in := strings.Join(secIDs, ",")
wrows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), CAST(ISNULL(w.D_AMOUNT,0) AS float), CAST(ISNULL(w.K_AMOUNT,0) AS float),
ISNULL(w.NAME,''), ISNULL(sv.NAME,''), ISNULL(sv.ISIN,''), ISNULL(mv.SYSNAME,'')
FROM OD_WIRING w
JOIN OD_RESTS sr ON sr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END
LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2
LEFT JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.K_REST ELSE w.D_REST END
LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
AND w.WIRDATE >= @f AND w.WIRDATE < @t AND (w.D_AMOUNT <> 0 OR w.K_AMOUNT <> 0)
ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
if err != nil {
return nil, fmt.Errorf("проводки: %v", err)
}
type key struct{ day, kind, isin, cur string }
agg := map[key]*LKOperationRow{}
var order []key
for wrows.Next() {
var wd time.Time
var eq, dSum, kSum, dAmt, kAmt float64
var dRest, kRest int64
var wname, secName, isin, cur string
if wrows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &dAmt, &kAmt, &wname, &secName, &isin, &cur) != nil {
continue
}
onDebit := secSet[dRest]
kind := spectreDealKind(wname, onDebit)
if kind == "" {
continue
}
qty, sum := kAmt, dSum
if onDebit {
qty, sum = dAmt, kSum
}
k := key{wd.Format("2006-01-02"), kind, isin, spectreCurCode(cur)}
a := agg[k]
if a == nil {
side := "out"
if onDebit {
side = "in"
}
a = &LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: secName, ISIN: isin, Currency: k.cur}
agg[k] = a
order = append(order, k)
}
a.Quantity += qty
a.Amount += sum
a.AmountRUB += eq
}
wrows.Close()
// При продаже счёт ПОРТФЕЛЬ списывает бумагу по себестоимости; фактическая выручка —
// на счёте ВЫРУЧКА. Клиенту показываем именно её.
proceeds := spectreProceeds(cctx, conn, c.DOC, fromDay, toEnd)
for _, k := range order {
a := agg[k]
if a.Kind == "продажа" || a.Kind == "погашение" {
if p, ok := proceeds[k.isin+"|"+k.day]; ok && p[1] != 0 {
a.Amount, a.AmountRUB = p[0], p[1]
}
}
if a.Quantity != 0 && a.Amount != 0 {
a.Price = a.Amount / a.Quantity
}
out = append(out, *a)
}
}
// Деньги: пополнения и выводы — проводки по кассовым счетам с чужой стороной вне портфеля договора.
if len(cashIDs) > 0 {
in := strings.Join(cashIDs, ",")
crows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(w.NAME,''), ISNULL(mv.SYSNAME,'')
FROM OD_WIRING w
JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END
LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
AND w.WIRDATE >= @f AND w.WIRDATE < @t
ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
if err == nil {
cashSet := map[string]bool{}
for _, id := range cashIDs {
cashSet[id] = true
}
for crows.Next() {
var wd time.Time
var eq, dSum, kSum float64
var dRest, kRest int64
var wname, cur string
if crows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &wname, &cur) != nil {
continue
}
low := strings.ToLower(wname)
var kind, side string
switch {
case strings.Contains(low, "ввод") && strings.Contains(low, "ден"), strings.Contains(low, "пополнен"), strings.Contains(low, "зачисление денеж"):
kind, side = "пополнение", "in"
case strings.Contains(low, "вывод") && strings.Contains(low, "ден"), strings.Contains(low, "списание денеж") && !strings.Contains(low, "комисс"):
kind, side = "вывод денег", "out"
default:
continue
}
sum := kSum
if cashSet[fmt.Sprint(dRest)] {
sum = dSum
}
out = append(out, LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: "Денежные средства",
Amount: sum, Currency: spectreCurCode(cur), AmountRUB: eq})
}
crows.Close()
}
}
sort.SliceStable(out, func(i, j int) bool {
di, _ := time.Parse("02.01.2006", out[i].Date)
dj, _ := time.Parse("02.01.2006", out[j].Date)
return di.After(dj)
})
return out, nil
}
// spectreDealKind — вид операции по названию проводки (рецепт хаба). Пусто — не показываем.
func spectreDealKind(name string, onDebit bool) string {
n := strings.ToLower(name)
switch {
case strings.Contains(n, "замещени") || strings.Contains(n, "обмен"):
return "замещение"
case strings.Contains(n, "конвертац") || strings.Contains(n, "слияни"):
return "конвертация"
case strings.Contains(n, "погашен"):
return "погашение"
case strings.Contains(n, "ввод") && strings.Contains(n, "цб"):
return "ввод бумаг"
case strings.Contains(n, "вывод") && strings.Contains(n, "цб"):
return "вывод бумаг"
case strings.Contains(n, "заём") || strings.Contains(n, "займ") || strings.Contains(n, "репо"):
return "" // РЕПО и займы клиенту в ленте не показываем
}
if onDebit {
return "покупка"
}
return "продажа"
}
// spectreProceeds — выручка от продаж по (ISIN|день): [сумма в валюте, сумма в рублях].
func spectreProceeds(ctx context.Context, conn *sql.DB, doc int64, from, to time.Time) map[string][2]float64 {
out := map[string][2]float64{}
rows, err := conn.QueryContext(ctx, `SELECT r.ID FROM OD_RESTS r JOIN OD_BALANCES b ON b.ID = r.BAL_ACC WHERE r.REG_3 = @c AND b.SYS_NAME = N'ВЫРУЧКА'`, sql.Named("c", doc))
if err != nil {
return out
}
var ids []string
idSet := map[int64]bool{}
for rows.Next() {
var id int64
if rows.Scan(&id) == nil {
ids = append(ids, fmt.Sprint(id))
idSet[id] = true
}
}
rows.Close()
if len(ids) == 0 {
return out
}
in := strings.Join(ids, ",")
wrows, err := conn.QueryContext(ctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(sv.ISIN,'')
FROM OD_WIRING w
JOIN OD_RESTS sr ON sr.ID = CASE WHEN w.K_REST IN (%[1]s) THEN w.K_REST ELSE w.D_REST END
LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
AND w.WIRDATE >= @f AND w.WIRDATE < @t AND w.EQ_ <> 0`, in), sql.Named("f", from), sql.Named("t", to))
if err != nil {
return out
}
defer wrows.Close()
for wrows.Next() {
var wd time.Time
var eq, dSum, kSum float64
var dRest, kRest int64
var isin string
if wrows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &isin) != nil {
continue
}
sum := dSum
if idSet[kRest] {
sum = kSum
}
k := isin + "|" + wd.Format("2006-01-02")
v := out[k]
v[0] += sum
v[1] += eq
out[k] = v
}
return out
}
+102
View File
@@ -0,0 +1,102 @@
package lkgateway
// lkclient.go — как один и тот же клиент называется в разных системах.
//
// В учётных записях кабинета клиент задан номером договора, как в DEPO: «БР-1545», «БРИИС-514».
// В экспорте QUIK тот же клиент — код латиницей без дефиса: BR1545, BRIIS514; внебиржевой
// раздел — с суффиксом OUT. В SPECTRE договор — строка D_B_CONTRACTS.NAME вида «БР-1545 ФИО».
import (
"context"
"database/sql"
"strings"
"sync"
"time"
)
// lkContractNumber — номер договора без ФИО: «БР-1545 Иванов Иван» → «БР-1545».
func lkContractNumber(contract string) string {
c := strings.TrimSpace(contract)
if i := strings.IndexByte(c, ' '); i > 0 {
c = c[:i]
}
return strings.ToUpper(c)
}
// lkQuikCodes — коды клиента в QUIK по номеру договора: основной и внебиржевой раздел.
func lkQuikCodes(contract string) []string {
num := lkContractNumber(contract)
if num == "" {
return nil
}
repl := strings.NewReplacer("-", "", "А", "A", "Б", "B", "В", "V", "Д", "D", "И", "I", "С", "S", "Р", "R", "У", "U", "Т", "T", "К", "K", "М", "M", "Н", "N", "О", "O", "Е", "E", "П", "P", "Ф", "F", "Ц", "C")
code := repl.Replace(num)
return []string{code, code + "OUT"}
}
// spectreContract — договор клиента в SPECTRE.
type spectreContract struct {
DOC int64
Investor int64
Name string
Open time.Time
Closed bool
}
var spectreContractCache = struct {
mu sync.Mutex
m map[string]spectreContract
at map[string]time.Time
}{m: map[string]spectreContract{}, at: map[string]time.Time{}}
// spectreOpen — подключение к копии SPECTRE тем же логином, что и к DEPO.
func spectreOpen(s FancyMSSQLSettings) (*sql.DB, error) {
db := strings.TrimSpace(s.SpectreDB())
return fancyOpen(s, db)
}
// SpectreDB — база SPECTRE рядом с DEPO: по умолчанию BJ_SPECTRE_DATA.
func (s FancyMSSQLSettings) SpectreDB() string {
if v := strings.TrimSpace(s.SpectreDatabase); v != "" {
return v
}
return "BJ_SPECTRE_DATA"
}
// spectreContractFor — договор SPECTRE по номеру договора клиента (кэш 10 минут).
func spectreContractFor(ctx context.Context, s FancyMSSQLSettings, contract string) (spectreContract, bool) {
num := lkContractNumber(contract)
if num == "" || strings.TrimSpace(s.Server) == "" {
return spectreContract{}, false
}
spectreContractCache.mu.Lock()
if c, ok := spectreContractCache.m[num]; ok && time.Since(spectreContractCache.at[num]) < 10*time.Minute {
spectreContractCache.mu.Unlock()
return c, c.DOC != 0
}
spectreContractCache.mu.Unlock()
conn, err := spectreOpen(s)
if err != nil {
return spectreContract{}, false
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var c spectreContract
var open, closed sql.NullTime
var state sql.NullInt64
err = conn.QueryRowContext(cctx, `SELECT TOP 1 DOC, INVESTOR, NAME, C_OPEN, C_CLOSE, C_STATE FROM D_B_CONTRACTS
WHERE UPPER(NAME) = @n OR UPPER(NAME) LIKE @n + ' %' ORDER BY C_STATE DESC, DOC DESC`, sql.Named("n", num)).
Scan(&c.DOC, &c.Investor, &c.Name, &open, &closed, &state)
if err == nil {
c.Name = strings.TrimSpace(c.Name)
if open.Valid {
c.Open = open.Time
}
c.Closed = closed.Valid && closed.Time.Year() < 9000 && closed.Time.Before(time.Now())
}
spectreContractCache.mu.Lock()
spectreContractCache.m[num], spectreContractCache.at[num] = c, time.Now()
spectreContractCache.mu.Unlock()
return c, err == nil && c.DOC != 0
}
+228
View File
@@ -0,0 +1,228 @@
package lkgateway
// lkonline.go — портфель клиента «сейчас» по экспорту биржевой информации из QUIK.
//
// Это то, что клиент видит у брокера: позиции с количеством, средней ценой покупки, текущей
// ценой и результатом, деньги по валютам, стоимость портфеля. Источник — таблицы экспорта
// DepoLimits (позиции), MoneyLimits (деньги), Securities/Params (бумаги и цены). Клиент
// определяется кодом QUIK, полученным из номера договора (lkQuikCodes).
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// LKPortfolio — портфель для кабинета (онлайн или на дату — одна форма).
type LKPortfolio struct {
Source string `json:"source"` // quik | spectre
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,omitempty"`
}
// LKCash — деньги в одной валюте.
type LKCash struct {
Currency string `json:"currency"` // SUR, USD, CNY …
Amount float64 `json:"amount"`
}
// LKPosition — позиция по бумаге.
type LKPosition struct {
ISIN string `json:"isin"`
Code string `json:"code,omitempty"` // код в торговой системе
Name string `json:"name"`
Kind string `json:"kind"` // акция | облигация | фонд | …
Quantity float64 `json:"quantity"`
AvgPrice float64 `json:"avg_price,omitempty"` // средняя цена покупки (за бумагу, в тех же единицах, что и Price)
Price float64 `json:"price,omitempty"` // текущая цена
InPct bool `json:"in_pct,omitempty"` // цены в процентах номинала (облигации)
Value float64 `json:"value"` // стоимость, ₽
Cost float64 `json:"cost,omitempty"` // вложено, ₽
PnL float64 `json:"pnl"` // результат, ₽
PnLPct float64 `json:"pnl_pct"` // результат, %
DayPct float64 `json:"day_pct,omitempty"` // изменение за день, %
Share float64 `json:"share"` // доля в портфеле, %
Currency string `json:"currency,omitempty"`
Note string `json:"note,omitempty"`
}
// lkKindByClass — вид бумаги по классу QUIK.
func lkKindByClass(class string) string {
switch {
case class == "TQBR", class == "TQPI", strings.HasPrefix(class, "SPBXM"):
return "акция"
case class == "TQOB", class == "TQCB", class == "TQIR", class == "TQRD", strings.HasSuffix(class, "_BND"):
return "облигация"
case class == "TQTF", class == "TQIF", class == "TQTD":
return "фонд"
case class == "CETS":
return "валюта"
}
return "бумага"
}
// lkOnlinePortfolio — портфель по экспорту QUIK. Ошибка — только если базы экспорта нет.
func lkOnlinePortfolio(ctx context.Context, rc *RuntimeConfig, contract string) (*LKPortfolio, error) {
codes := lkQuikCodes(contract)
if len(codes) == 0 {
return nil, fmt.Errorf("не определён номер договора")
}
p, err := nprDB(rc)
if err != nil {
return nil, fmt.Errorf("база экспорта QUIK недоступна: %v", err)
}
cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
out := &LKPortfolio{Source: "quik"}
// Позиции: свежая строка по каждому (клиент, бумага, торговый счёт), затем сумма по бумаге.
rows, err := p.Query(cctx, `
WITH d AS (
SELECT DISTINCT ON ("ClientCode","SecCode","Trdacc") "SecCode","CurrentBal","AwgPositionPrice","LoadDate"
FROM "DepoLimits" WHERE "ClientCode" = ANY($1) AND "LimitKind"='T2'
ORDER BY "ClientCode","SecCode","Trdacc","LoadDate" DESC)
SELECT "SecCode", SUM("CurrentBal")::float8,
COALESCE(SUM("CurrentBal"*"AwgPositionPrice")/NULLIF(SUM("CurrentBal"),0),0)::float8, MAX("LoadDate")
FROM d WHERE "CurrentBal" <> 0 GROUP BY "SecCode" ORDER BY "SecCode"`, codes)
if err != nil {
return nil, fmt.Errorf("позиции: %v", err)
}
type raw struct {
code string
qty, avg float64
}
var pos []raw
var asOf time.Time
for rows.Next() {
var r raw
var ld *time.Time
if rows.Scan(&r.code, &r.qty, &r.avg, &ld) == nil {
pos = append(pos, r)
if ld != nil && ld.After(asOf) {
asOf = *ld
}
}
}
rows.Close()
// Бумаги: ISIN, имя, класс (основные режимы торгов в приоритете), номинал.
secByCode := map[string]struct {
isin, name, class string
face float64
}{}
if len(pos) > 0 {
codesList := make([]string, 0, len(pos))
for _, r := range pos {
codesList = append(codesList, r.code)
}
srows, err := p.Query(cctx, `
SELECT DISTINCT ON ("SecCode") "SecCode", upper(COALESCE("ISINCode",'')), COALESCE("SecShortName","SecName",''), "ClassCode", COALESCE("FaceValue",0)::float8
FROM "Securities" WHERE "SecCode" = ANY($1)
ORDER BY "SecCode", CASE "ClassCode" WHEN 'TQBR' THEN 1 WHEN 'TQOB' THEN 2 WHEN 'TQCB' THEN 3 WHEN 'TQTF' THEN 4 WHEN 'TQIF' THEN 5 WHEN 'TQRD' THEN 6 ELSE 9 END`, codesList)
if err == nil {
for srows.Next() {
var code, isin, name, class string
var face float64
if srows.Scan(&code, &isin, &name, &class, &face) == nil {
secByCode[code] = struct {
isin, name, class string
face float64
}{isin, name, class, face}
}
}
srows.Close()
}
}
isins := make([]string, 0, len(pos))
for _, r := range pos {
if s, ok := secByCode[r.code]; ok && s.isin != "" {
isins = append(isins, s.isin)
}
}
prices := lkPrices(ctx, rc, isins)
for _, r := range pos {
s := secByCode[r.code]
lp := LKPosition{ISIN: s.isin, Code: r.code, Name: s.name, Kind: lkKindByClass(s.class), Quantity: r.qty, AvgPrice: r.avg, Currency: "SUR"}
if lp.Name == "" {
lp.Name = r.code
}
if px, ok := prices[s.isin]; ok && px.Price > 0 {
lp.Price, lp.InPct, lp.DayPct = px.Price, px.InPct, px.ChangePc
lp.Value = px.Value(r.qty)
out.Priced++
if r.avg > 0 {
// средняя цена в тех же единицах, что и котировка: для облигаций — процент номинала
cost := SecurityPrice{Price: r.avg, Face: px.Face, InPct: px.InPct}
lp.Cost = cost.Value(r.qty)
lp.PnL = lp.Value - lp.Cost
if lp.Cost != 0 {
lp.PnLPct = lp.PnL / lp.Cost * 100
}
}
} else {
lp.Note = "нет биржевой цены"
}
out.Positions = append(out.Positions, lp)
}
// Деньги по валютам.
crows, err := p.Query(cctx, `
WITH m AS (
SELECT DISTINCT ON ("ClientCode","CurrCode","Tag") "CurrCode","CurrentBal","LoadDate"
FROM "MoneyLimits" WHERE "ClientCode" = ANY($1) AND "LimitKind"='T2'
ORDER BY "ClientCode","CurrCode","Tag","LoadDate" DESC)
SELECT "CurrCode", SUM("CurrentBal")::float8, MAX("LoadDate") FROM m GROUP BY "CurrCode" HAVING SUM("CurrentBal") <> 0 ORDER BY "CurrCode"`, codes)
if err == nil {
for crows.Next() {
var c LKCash
var ld *time.Time
if crows.Scan(&c.Currency, &c.Amount, &ld) == nil {
out.Cash = append(out.Cash, c)
if c.Currency == "SUR" || c.Currency == "RUB" {
out.CashRUB += c.Amount
}
if ld != nil && ld.After(asOf) {
asOf = *ld
}
}
}
crows.Close()
}
if !asOf.IsZero() {
out.AsOf = asOf.Format("02.01.2006")
}
lkPortfolioTotals(out)
return out, nil
}
// lkPortfolioTotals — итоги и доли.
func lkPortfolioTotals(p *LKPortfolio) {
p.Securities, p.Cost, p.PnL = 0, 0, 0
for _, pos := range p.Positions {
p.Securities += pos.Value
p.Cost += pos.Cost
p.PnL += pos.PnL
}
p.Total = p.Securities + p.CashRUB
if p.Cost != 0 {
p.PnLPct = p.PnL / p.Cost * 100
}
for i := range p.Positions {
if p.Total > 0 {
p.Positions[i].Share = p.Positions[i].Value / p.Total * 100
}
}
sort.SliceStable(p.Positions, func(i, j int) bool { return p.Positions[i].Value > p.Positions[j].Value })
}
+1
View File
@@ -184,6 +184,7 @@ type FancyMSSQLSettings struct {
// Трансферные таблицы FTV_M2M* в базе DEPO (depotables.go).
DepoDatabase string `json:"depo_database"` // база DEPO; пусто = BJ_DEPO_DATA
SpectreDatabase string `json:"spectre_database,omitempty"` // база SPECTRE (брокерский учёт); пусто = BJ_SPECTRE_DATA
IISContractMarker string `json:"iis_contract_marker,omitempty"` // начало названия договора ИИС в DEPO (через запятую); пусто = БРИИС
DepoTablesMode string `json:"depo_tables_mode"` // auto (создаёт bj) | script (администратор СУБД)
DepoTablesCheck string `json:"depo_tables_check"`
+65
View File
@@ -0,0 +1,65 @@
//go:build live
package lkgateway
import (
"context"
"testing"
"time"
)
func TestLiveCabinetData(t *testing.T) {
rc, _ := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
globalRC = rc
fs := rc.Snapshot().Fancy
ctx := context.Background()
for _, c := range []string{"БР-1545", "БР-704"} {
t0 := time.Now()
on, err := lkOnlinePortfolio(ctx, rc, c)
if err != nil {
t.Logf("%s онлайн: ERR %v", c, err)
} else {
t.Logf("%s ОНЛАЙН (%v) на %s: всего %.2f ₽, бумаги %.2f, деньги %.2f, вложено %.2f, P&L %.2f (%.1f%%), позиций %d/%d", c, time.Since(t0).Round(time.Millisecond), on.AsOf, on.Total, on.Securities, on.CashRUB, on.Cost, on.PnL, on.PnLPct, on.Priced, len(on.Positions))
for i, p := range on.Positions {
if i > 5 {
break
}
t.Logf(" %-14s %-28.28s %-10s %10.0f × ср.%.4g → цена %.4g (%s) = %.2f ₽, P&L %.2f (%.1f%%) %s", p.ISIN, p.Name, p.Kind, p.Quantity, p.AvgPrice, p.Price, map[bool]string{true: "%", false: "₽"}[p.InPct], p.Value, p.PnL, p.PnLPct, p.Note)
}
for _, m := range on.Cash {
t.Logf(" деньги %s %.2f", m.Currency, m.Amount)
}
}
for _, d := range []time.Time{time.Now(), time.Date(2025, 12, 31, 0, 0, 0, 0, time.Local), time.Date(2024, 6, 30, 0, 0, 0, 0, time.Local)} {
t0 = time.Now()
ar, err := lkArchivePortfolio(ctx, fs, c, d)
if err != nil {
t.Logf("%s архив %s: ERR %v", c, d.Format("02.01.2006"), err)
continue
}
t.Logf("%s АРХИВ на %s (%v): всего %.2f ₽, бумаги %.2f, деньги %.2f, бал.стоимость %.2f, P&L %.2f, позиций %d (с котировкой %d)", c, ar.AsOf, time.Since(t0).Round(time.Millisecond), ar.Total, ar.Securities, ar.CashRUB, ar.Cost, ar.PnL, len(ar.Positions), ar.Priced)
for i, p := range ar.Positions {
if i > 6 {
break
}
t.Logf(" %-14s %-28.28s %-10s %10.2f × бал.%.4g → кот.%.4g (%s) = %.2f ₽ %s", p.ISIN, p.Name, p.Kind, p.Quantity, p.AvgPrice, p.Price, map[bool]string{true: "%", false: "₽"}[p.InPct], p.Value, p.Note)
}
for _, m := range ar.Cash {
t.Logf(" деньги %s %.2f", m.Currency, m.Amount)
}
}
t0 = time.Now()
ops, err := lkArchiveOperations(ctx, fs, c, time.Date(2026, 1, 1, 0, 0, 0, 0, time.Local), time.Now())
if err != nil {
t.Logf("%s операции: ERR %v", c, err)
continue
}
t.Logf("%s ОПЕРАЦИИ с 01.01.2026 (%v): %d", c, time.Since(t0).Round(time.Millisecond), len(ops))
for i, o := range ops {
if i > 8 {
break
}
t.Logf(" %s %-12s %-3s %-28.28s %10.0f × %.4g = %.2f %s (%.2f ₽)", o.Date, o.Kind, o.Side, o.Name, o.Quantity, o.Price, o.Amount, o.Currency, o.AmountRUB)
}
}
}
+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
}
+47 -15
View File
@@ -83,6 +83,32 @@ func fakeBJ(t *testing.T) *httptest.Server {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
})
mux.HandleFunc("/api/lk/portfolio2", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) {
return
}
view := r.URL.Query().Get("view")
asOf := "18.09.2026"
if view == "archive" {
asOf = "31.12.2025"
}
_ = json.NewEncoder(w).Encode(map[string]any{"contract": "BR-1", "full_name": "Клиент Тестовый", "portfolio": map[string]any{
"source": map[bool]string{true: "spectre", false: "quik"}[view == "archive"], "as_of": asOf,
"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}},
}})
})
mux.HandleFunc("/api/lk/ops", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) {
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"from": "2025-09-18", "to": "2026-09-18", "operations": []map[string]any{
{"date": "10.07.2026", "kind": "продажа", "side": "out", "name": "НОВАТЭК 001P-03", "isin": "RU000A10AUX8", "quantity": 1700, "price": 7793.07, "amount": 13248214.78, "currency": "SUR", "amount_rub": 13248214.78},
{"date": "06.03.2026", "kind": "покупка", "side": "in", "name": "Сбербанк, ао", "isin": "RU0009029540", "quantity": 100, "price": 238.1, "amount": 23810, "currency": "SUR", "amount_rub": 23810},
{"date": "12.05.2026", "kind": "дивиденды", "side": "in", "name": "Сбербанк, ао", "isin": "RU0009029540", "quantity": 336, "amount": 494.6, "currency": "SUR", "amount_rub": 494.6},
}})
})
mux.HandleFunc("/api/lk/admin/bootstrap", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) {
return
@@ -280,19 +306,31 @@ func TestBoardPages(t *testing.T) {
return rec.Body.String()
}
board := get("/")
for _, want := range []string{"Стоимость бумаг", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} {
for _, want := range []string{"Стоимость портфеля", "Новое поручение", "Сбербанк, ао", "Онлайн", "Архив", "Денежные средства", "12 698,28 ₽", "15,87 %", "84,1%"} {
if !strings.Contains(board, want) {
t.Errorf("на табло нет %q", want)
t.Errorf("в портфеле нет %q", want)
if i := strings.Index(board, "Результат"); i > 0 {
t.Logf("фрагмент: %s", board[i:min(i+400, len(board))])
}
}
}
if !strings.Contains(board, "110 268 ₽") {
t.Errorf("на табло нет оценки портфеля")
t.Errorf("стоимость портфеля не показана: %s", board)
}
reg := get("/portfolio")
for _, want := range []string{"ВТНКЦ00000001", "иностранный выпуск", "депозитарный учёт", "перевести"} {
if !strings.Contains(reg, want) {
t.Errorf("в реестре нет %q", want)
}
arch := get("/?view=archive&date=2025-12-31")
if !strings.Contains(arch, "на 31.12.2025") || !strings.Contains(arch, `value="2025-12-31"`) {
t.Errorf("архив на дату не показан")
}
// старые адреса ведут на новые разделы
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/portfolio", nil))
if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/" {
t.Errorf("/portfolio должен вести на портфель: %d %s", rec.Code, rec.Header().Get("Location"))
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/income", nil))
if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/operations" {
t.Errorf("/income должен вести на операции: %d %s", rec.Code, rec.Header().Get("Location"))
}
orders := get("/orders")
if !strings.Contains(orders, "Поручения депозитарию") || !strings.Contains(orders, "Перевод в другой депозитарий") {
@@ -311,17 +349,11 @@ func TestBoardPages(t *testing.T) {
}
}
ops := get("/operations")
for _, want := range []string{"Операции по счетам депо", "зачисление", "Ликвидность, пай", "Отчёт клиринговой организации"} {
for _, want := range []string{"Операции", "продажа", "покупка", "дивиденды", "НОВАТЭК 001P-03", "−13 248 214,78 ₽", "+494,60 ₽", "Продажи"} {
if !strings.Contains(ops, want) {
t.Errorf("в операциях нет %q", want)
}
}
inc := get("/income")
for _, want := range []string{"Выплачено дохода", "494,60 ₽", "Удержан налог", "дивиденды"} {
if !strings.Contains(inc, want) {
t.Errorf("в доходах нет %q", want)
}
}
prof := get("/profile")
if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") {
t.Errorf("профиль: %s", prof)
+60 -133
View File
@@ -33,6 +33,13 @@ a{color:var(--acc)}a:hover{color:#1e3395}
.top .who{color:var(--dim);font-size:12px;white-space:nowrap}
.top .exit{color:var(--dim);text-decoration:none}
.lnk{background:none;border:0;padding:0;font:inherit;color:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}
.tabs{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:0 0 16px}
.tab{font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;padding:8px 14px;border:1px solid var(--line-2);border-radius:3px;text-decoration:none;color:var(--ink-2)}
.tab.on{background:var(--ink);color:#f3f0e7;border-color:var(--ink)}
.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)}
.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)}
.board{background:var(--board-2);color:#f3f0e7;border-bottom:3px solid var(--amber);padding:22px 20px}
@@ -96,11 +103,9 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
<a class="brand" href="/" title="На табло" style="text-decoration:none;color:inherit">{{template "mark" .}}<span class="lbl" style="font-size:12px;letter-spacing:.14em">{{.Title}}</span></a>
<nav>
{{if .IsAdmin}}<a href="/clients" class="lbl {{if eq .Nav "a"}}on{{end}}">Клиенты</a>{{end}}
<a href="/" class="lbl {{if eq .Nav "b"}}on{{end}}">Табло</a>
<a href="/portfolio" class="lbl {{if eq .Nav "p"}}on{{end}}">Реестр</a>
<a href="/orders" class="lbl {{if eq .Nav "c"}}on{{end}}">Поручения</a>
<a href="/" class="lbl {{if eq .Nav "b"}}on{{end}}">Портфель</a>
<a href="/operations" class="lbl {{if eq .Nav "o"}}on{{end}}">Операции</a>
<a href="/income" class="lbl {{if eq .Nav "i"}}on{{end}}">Доходы</a>
<a href="/orders" class="lbl {{if eq .Nav "c"}}on{{end}}">Поручения</a>
<a href="/documents" class="lbl {{if eq .Nav "d"}}on{{end}}">Документы</a>
<a href="/profile" class="lbl {{if eq .Nav "u"}}on{{end}}">Профиль</a>
</nav>
@@ -141,86 +146,51 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
</div>
</body></html>{{end}}
{{define "board"}}{{template "head" .}}{{template "nav" .}}
{{define "portfolio"}}{{template "head" .}}{{template "nav" .}}{{$p := .P}}
<section class="board"><div class="board-row">
<div>
<span class="lbl" style="color:var(--dim)">{{if gt .Value 0.0}}Стоимость бумаг{{else}}Бумаг в реестре{{end}}</span>
<div class="big" style="margin-top:8px">{{if gt .Value 0.0}}{{fmtmoney .Value}} ₽{{else}}{{.Total}}{{end}}</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">{{with .FullName}}{{.}} · {{end}}договор {{.Contract}}{{if gt .Value 0.0}} · оценка по {{.Priced}} из {{.Total}} бумаг{{end}}</div>
<span class="lbl" style="color:var(--dim)">Стоимость портфеля{{if eq .View "archive"}} на {{$p.AsOf}}{{end}}</span>
<div class="big" style="margin-top:8px">{{fmtmoney $p.Total}} ₽</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">{{with .FullName}}{{.}} · {{end}}договор {{.Contract}}{{if and (ne .View "archive") $p.AsOf}} · данные на {{$p.AsOf}}{{end}}{{if $p.Note}} · {{$p.Note}}{{end}}</div>
</div>
<div class="stats">
<span class="stat"><span class="lbl" style="color:var(--dim)">Свободно к переводу</span><span class="v">{{.Free}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">С ограничением</span><span class="v" style="color:var(--amber)">{{.Blocked}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Поручений в работе</span><span class="v">{{.Active}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Бумаги</span><span class="v">{{fmtmoney $p.Securities}} ₽</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Деньги</span><span class="v">{{fmtmoney $p.CashRUB}} ₽</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Результат</span><span class="v" style="color:{{if lt $p.PnL 0.0}}#f0a090{{else if gt $p.PnL 0.0}}#8fd19e{{else}}inherit{{end}}">{{if ne $p.Cost 0.0}}{{fmtsigned $p.PnL}} ₽ · {{fmtpct $p.PnLPct}}{{else}}—{{end}}</span></span>
</div>
</div></section>
<main>
{{if .Flash}}<div class="msg ok">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
<section class="sec">
<div class="sec-h"><h1 class="lbl" style="font-size:12px;letter-spacing:.14em">Последние поручения</h1><a href="/orders" style="font-size:13px;text-decoration:none">все поручения</a></div>
{{if .Claims}}<div class="tw"><table>
<thead><tr><th>Номер</th><th>Дата</th><th>Вид поручения</th><th class="num">Бумаг</th><th>Состояние</th></tr></thead>
<tbody>{{range .Claims}}<tr>
<td class="n">{{shortid .GUID}}</td>
<td class="n">{{.CreatedAt}}</td>
<td>Перевод в другой депозитарий</td>
<td class="num">{{.Securit}}</td>
<td><span class="chip {{if eq .Status "done"}}ok{{else if eq .Status "rejected"}}err{{else if eq .Status "declined"}}err{{else if eq .Status "error"}}err{{else}}go{{end}}">{{.StatusRu}}</span>{{with .Reason}}<div class="mut">{{.}}</div>{{end}}</td>
</tr>{{end}}</tbody></table></div>
{{else}}<p class="empty">Поручений пока нет. Нажмите «Новое поручение», чтобы перевести бумаги в другой депозитарий.</p>{{end}}
</section>
<div class="tabs">
<a class="tab {{if ne .View "archive"}}on{{end}}" href="/">Онлайн</a>
<a class="tab {{if eq .View "archive"}}on{{end}}" href="/?view=archive">Архив</a>
{{if eq .View "archive"}}<form method="get" action="/" class="tabform"><input type="hidden" name="view" value="archive"><input type="date" name="date" value="{{.Date}}" max="{{.Today}}"><button class="btn sm" type="submit">Показать</button></form>{{end}}
<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 $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>
</section>{{end}}
<section class="sec">
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Реестр бумаг</h2><a href="/portfolio" style="font-size:13px;text-decoration:none">весь реестр</a></div>
{{if .Positions}}<div class="tw"><table>
<thead><tr><th>Выпуск</th><th>Счёт депо</th><th class="num">Количество</th><th class="num">Свободно</th><th class="num">Оценка</th><th>Состояние</th></tr></thead>
<tbody>{{range .Positions}}<tr>
<td><a href="/security?isin={{.ISIN}}" style="text-decoration:none;font-weight:600">{{.SecurityName}}</a><div class="n" style="font-size:12.5px;color:var(--mut)">{{.ISIN}}</div></td>
<td class="n" style="font-size:13px">{{if .AccountID}}{{.AccountID}}{{else}}—{{end}}</td>
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Бумаги</h2><span class="mut" style="font-size:12.5px">{{len $p.Positions}} позиций{{if $p.Positions}}, с ценой {{$p.Priced}}{{end}}</span></div>
{{if $p.Positions}}<div class="tw"><table>
<thead><tr><th>Бумага</th><th class="num">Кол-во</th><th class="num">Средняя</th><th class="num">Цена</th><th class="num">Стоимость</th><th class="num">Результат</th><th class="num">Доля</th></tr></thead>
<tbody>{{range $p.Positions}}<tr>
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none;font-weight:600">{{.Name}}</a>{{else}}<b>{{.Name}}</b>{{end}}<div class="kind">{{.Kind}}{{if .ISIN}} · <span class="n" style="text-transform:none;letter-spacing:0">{{.ISIN}}</span>{{end}}{{if .Note}} · {{.Note}}{{end}}</div></td>
<td class="num">{{fmtnum .Quantity}}</td>
<td class="num">{{fmtnum .Available}}</td>
<td class="num">{{if gt .AvgPrice 0.0}}{{fmtprice .AvgPrice}}{{if .InPct}}%{{end}}{{else}}—{{end}}</td>
<td class="num">{{if gt .Price 0.0}}{{fmtprice .Price}}{{if .InPct}}%{{end}}{{if ne .DayPct 0.0}}<div class="{{if lt .DayPct 0.0}}neg{{else}}pos{{end}}" style="font-size:11.5px">{{fmtpct .DayPct}}</div>{{end}}{{else}}—{{end}}</td>
<td class="num">{{template "money" .Value}}</td>
<td>{{if not .Transferable}}<span class="chip">не переводится</span>{{else if gt .Blocked 0.0}}<span class="chip go">{{fmtnum .Blocked}} с ограничением</span>{{else}}<span class="chip ok">свободно</span>{{end}}</td>
<td class="num">{{if ne .Cost 0.0}}<span class="{{if lt .PnL 0.0}}neg{{else if gt .PnL 0.0}}pos{{end}}">{{fmtsigned .PnL}}<div style="font-size:11.5px">{{fmtpct .PnLPct}}</div></span>{{else}}—{{end}}</td>
<td class="num">{{if gt .Share 0.0}}{{fmtpct1 .Share}}{{else}}—{{end}}</td>
</tr>{{end}}</tbody></table></div>
{{else}}<p class="empty">Бумаг на счетах депо не найдено. Если это ошибка — напишите в депозитарий.</p>{{end}}
{{else}}<p class="empty">{{if eq .View "archive"}}На эту дату бумаг на счёте не было.{{else}}Бумаг на счёте нет.{{end}}</p>{{end}}
</section>
</main></body></html>{{end}}
{{define "portfolio"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div><span class="lbl" style="color:var(--dim)">Реестр бумаг</span>
{{if gt .Value 0.0}}<div class="big" style="margin-top:8px;font-size:32px">{{fmtmoney .Value}} ₽</div>{{end}}
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">{{.Source}}{{with .UpdatedAt}} · {{.}}{{end}}{{if gt .Value 0.0}} · цены торговой системы{{end}}</div></div>
<div class="stats">
<span class="stat"><span class="lbl" style="color:var(--dim)">Выпусков</span><span class="v">{{.Total}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Свободно к переводу</span><span class="v">{{.Free}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">С ограничением</span><span class="v" style="color:var(--amber)">{{.Blocked}}</span></span>
</div>
</div></section>
<main>
{{if .Flash}}<div class="msg ok">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
{{if .ReadOnly}}<div class="msg">Кабинет открыт в режиме просмотра: подача поручений по этому договору выключена депозитарием.</div>{{end}}
{{if .Positions}}
<div class="tw"><table>
<thead><tr><th>Выпуск</th><th>Счёт депо</th><th class="num">Количество</th><th class="num">С ограничением</th><th class="num">Свободно</th><th class="num">Цена</th><th class="num">Оценка</th><th>Состояние</th><th></th></tr></thead>
<tbody>{{range .Positions}}<tr>
<td><a href="/security?isin={{.ISIN}}" style="text-decoration:none;font-weight:600">{{.SecurityName}}</a><div class="n" style="font-size:12.5px;color:var(--mut)">{{.ISIN}}{{with .SecurityCode}} · {{.}}{{end}}</div></td>
<td class="n" style="font-size:13px">{{if .AccountID}}{{.AccountID}}{{else}}—{{end}}</td>
<td class="num">{{fmtnum .Quantity}}</td>
<td class="num">{{if gt .Blocked 0.0}}<span style="color:var(--amber-tx)">{{fmtnum .Blocked}}</span>{{else}}—{{end}}</td>
<td class="num">{{fmtnum .Available}}</td>
<td class="num">{{if gt .Price 0.0}}{{fmtmoney .Price}}{{if ne .ChangePct 0.0}}<div style="font-size:12px;{{if gt .ChangePct 0.0}}color:var(--ok-tx){{else}}color:var(--err-tx){{end}}">{{fmtpct .ChangePct}}</div>{{end}}{{else}}—{{end}}</td>
<td class="num">{{template "money" .Value}}</td>
<td>{{if not .Transferable}}<span class="chip">иностранный выпуск</span>{{else if gt .Blocked 0.0}}<span class="chip go">частично ограничено</span>{{else}}<span class="chip ok">свободно</span>{{end}}{{with .Note}}<div class="mut">{{.}}</div>{{end}}</td>
<td style="text-align:right">{{if and .Transferable (gt .Available 0.0) (not $.ReadOnly)}}<a href="/order?isin={{.ISIN}}" class="lbl" style="text-decoration:none">перевести</a>{{end}}</td>
</tr>{{end}}</tbody></table></div>
<p class="mut" style="margin-top:18px"><b>С ограничением</b> — бумаги в расчётах по сделке, под обременением или ограниченные в обращении: перевести их нельзя, пока ограничение не снято.
<b>Иностранный выпуск</b> — перевод между депозитариями по MOEX МОСТ работает только с российскими выпусками, код ISIN начинается на RU.</p>
{{else}}<p class="empty">Бумаг на счетах депо не найдено.</p>{{end}}
</main></body></html>{{end}}
{{define "transfer"}}{{template "head" .}}{{template "nav" .}}
<main>
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
@@ -520,81 +490,38 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
</main></body></html>{{end}}
{{define "operations"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div><span class="lbl" style="color:var(--dim)">Операции по счетам депо</span>
<div class="big" style="margin-top:8px;font-size:32px">{{.Total}}</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">за период {{.From}} — {{.To}}</div></div>
<div>
<span class="lbl" style="color:var(--dim)">Операции</span>
<div class="big" style="margin-top:8px">{{len .Rows}}</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">за период {{.From}} — {{.To}}</div>
</div>
<div class="stats">
<span class="stat"><span class="lbl" style="color:var(--dim)">Зачислений</span><span class="v">{{.In}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Списаний</span><span class="v">{{.Out}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Покупки</span><span class="v">{{fmtmoney .BuyRUB}} ₽</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Продажи</span><span class="v">{{fmtmoney .SellRUB}} ₽</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Выплаты</span><span class="v">{{fmtmoney .IncomeRUB}} ₽</span></span>
</div>
</div></section>
<main>
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
<form method="get" action="/operations" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:18px">
<div><label for="from">Период с</label><input id="from" type="date" name="from" value="{{.From}}" style="width:auto"></div>
<div><label for="to">по</label><input id="to" type="date" name="to" value="{{.To}}" style="width:auto"></div>
<button class="btn" type="submit">Показать</button>
{{if .Note}}<div class="msg ok">{{.Note}}</div>{{end}}
<form method="get" action="/operations" class="tabform" style="margin:0 0 18px">
<div><label for="from">Период с</label><input id="from" type="date" name="from" value="{{.From}}"></div>
<div><label for="to">по</label><input id="to" type="date" name="to" value="{{.To}}"></div>
<button class="btn sm" type="submit" style="align-self:flex-end">Показать</button>
</form>
{{if .Operations}}<div class="tw"><table>
<thead><tr><th>Дата</th><th>Операция</th><th>Выпуск</th><th>Счёт депо</th><th class="num">Количество</th><th>Состояние</th><th>Основание</th></tr></thead>
<tbody>{{range .Operations}}<tr>
<td class="n">{{.Date}}{{if and .DoneAt (ne .DoneAt .Date)}}<div class="mut">проведено {{.DoneAt}}</div>{{end}}</td>
<td>{{.Kind}}{{with .Number}}<div class="n mut">№ {{.}}</div>{{end}}</td>
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none">{{.Security}}</a><div class="n mut">{{.ISIN}}</div>{{else}}{{.Security}}{{end}}</td>
<td class="n" style="font-size:13px">{{.Account}}</td>
<td class="num">{{if lt .Quantity 0.0}}<span style="color:var(--err-tx)">{{fmtnum .Quantity}}</span>{{else}}<span style="color:var(--ok-tx)">+{{fmtnum .Quantity}}</span>{{end}}</td>
<td>{{if eq .State "исполнено"}}<span class="chip ok">исполнено</span>{{else if eq .State "отменено"}}<span class="chip err">отменено</span>{{else}}<span class="chip go">{{.State}}</span>{{end}}</td>
<td class="mut">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
</tr>{{end}}</tbody></table></div>
<p class="mut" style="margin-top:18px">Показаны зачисления, списания и переводы между вашими счетами депо по данным учёта депозитария. Выписку за период можно заказать у депозитария.</p>
{{else}}<p class="empty">За выбранный период операций по счетам депо нет.</p>{{end}}
</main></body></html>{{end}}
{{define "income"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div><span class="lbl" style="color:var(--dim)">Выплачено дохода</span>
<div class="big" style="margin-top:8px">{{fmtmoney .Paid}} ₽</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">за период {{.From}} — {{.To}}</div></div>
<div class="stats">
<span class="stat"><span class="lbl" style="color:var(--dim)">Начислено</span><span class="v">{{fmtmoney .Accrued}} ₽</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Удержан налог</span><span class="v" style="color:var(--amber)">{{fmtmoney .Tax}} ₽</span></span>
</div>
</div></section>
<main>
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
<form method="get" action="/income" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:18px">
<div><label for="from">Период с</label><input id="from" type="date" name="from" value="{{.From}}" style="width:auto"></div>
<div><label for="to">по</label><input id="to" type="date" name="to" value="{{.To}}" style="width:auto"></div>
<button class="btn" type="submit">Показать</button>
</form>
{{if .Income}}<div class="tw"><table>
<thead><tr><th>Дата</th><th>Выпуск</th><th>Вид дохода</th><th class="num">Бумаг</th><th class="num">Начислено</th><th class="num">Налог</th><th class="num">К выплате</th></tr></thead>
<tbody>{{range .Income}}<tr>
{{if .Rows}}<div class="tw"><table>
<thead><tr><th>Дата</th><th>Операция</th><th>Инструмент</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead>
<tbody>{{range .Rows}}<tr>
<td class="n">{{.Date}}</td>
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none">{{.Security}}</a><div class="n mut">{{.ISIN}}</div>{{else}}{{.Security}}{{end}}</td>
<td>{{.Kind}}</td>
<td class="num">{{if gt .Quantity 0.0}}{{fmtnum .Quantity}}{{else}}—{{end}}</td>
<td class="num">{{fmtmoney .Accrued}}</td>
<td class="num">{{if gt .Tax 0.0}}{{fmtmoney .Tax}}{{else}}—{{end}}</td>
<td class="num" style="font-weight:700">{{fmtmoney .Paid}} ₽</td>
<td><span class="chip {{if eq .Side "in"}}ok{{else}}go{{end}}">{{.Kind}}</span></td>
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none;font-weight:600">{{.Name}}</a><div class="n" style="font-size:12px;color:var(--mut)">{{.ISIN}}</div>{{else}}{{.Name}}{{end}}</td>
<td class="num">{{if ne .Quantity 0.0}}{{fmtnum .Quantity}}{{else}}—{{end}}</td>
<td class="num">{{if gt .Price 0.0}}{{fmtprice .Price}}{{else}}—{{end}}</td>
<td class="num"><span class="{{if eq .Side "in"}}pos{{else}}neg{{end}}">{{if eq .Side "in"}}+{{else}}−{{end}}{{fmtmoney .AmountRUB}} ₽</span>{{if and .Currency (ne .Currency "SUR")}}<div style="font-size:11.5px;color:var(--mut)">{{fmtmoney .Amount}} {{.Currency}}</div>{{end}}</td>
</tr>{{end}}</tbody></table></div>
<p class="mut" style="margin-top:18px">Суммы и удержанный налог — по данным учёта депозитария как налогового агента по выплатам эмитентов. По купонам государственных облигаций налог может удерживать брокер при выплате, тогда здесь он нулевой. Справку о доходах можно запросить у депозитария.</p>
{{else}}<p class="empty">За выбранный период выплат дохода нет.</p>{{end}}
{{if .Actions}}<section class="sec" style="margin-top:30px">
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Корпоративные действия по вашим бумагам</h2>
<div class="tw"><table>
<thead><tr><th>Дата фиксации</th><th>Выпуск</th><th>Событие</th><th>Дата операции</th><th>Примечание</th></tr></thead>
<tbody>{{range .Actions}}<tr>
<td class="n">{{if .FixDate}}{{.FixDate}}{{else}}—{{end}}</td>
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none">{{.Security}}</a><div class="n mut">{{.ISIN}}</div>{{else}}{{.Security}}{{end}}</td>
<td>{{.Kind}}</td>
<td class="n">{{if .OperDate}}{{.OperDate}}{{else}}—{{end}}</td>
<td class="mut">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
</tr>{{end}}</tbody></table></div>
<p class="mut" style="margin-top:14px">События по бумагам вашего портфеля из справочника депозитария. Если по действию нужно ваше решение — например, предъявить бумаги к выкупу по оферте, — подайте заявление через раздел «Документы» или обратитесь к своему менеджеру.</p>
</section>{{end}}
{{else}}<p class="empty">За выбранный период операций нет.</p>{{end}}
</main></body></html>{{end}}
{{define "documents"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div><span class="lbl" style="color:var(--dim)">Документы депозитария</span>