From 02fabb483d38b8d20787814f37bd526be27a7ae3 Mon Sep 17 00:00:00 2001 From: zuevav Date: Fri, 18 Sep 2026 15:44:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0:=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D1=84=D0=B5=D0=BB=D1=8C=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D1=83=20=D0=B1=D1=80=D0=BE=D0=BA=D0=B5=D1=80=D0=B0=20=E2=80=94?= =?UTF-8?q?=20=C2=AB=D0=9E=D0=BD=D0=BB=D0=B0=D0=B9=D0=BD=C2=BB=20=D0=BF?= =?UTF-8?q?=D0=BE=20QUIK,=20=C2=AB=D0=90=D1=80=D1=85=D0=B8=D0=B2=C2=BB=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B4=D0=B0=D1=82=D1=83=20=D0=B8=20=D0=BE=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=20SPECTRE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL --- internal/lkgateway/lkapi.go | 75 ++++ internal/lkgateway/lkarchive.go | 517 ++++++++++++++++++++++ internal/lkgateway/lkclient.go | 102 +++++ internal/lkgateway/lkonline.go | 228 ++++++++++ internal/lkgateway/runtimeconfig.go | 1 + internal/lkgateway/zz_lkdata_live_test.go | 65 +++ modules/lk/lk.go | 299 +++++++------ modules/lk/lk_test.go | 62 ++- modules/lk/templates.go | 193 +++----- 9 files changed, 1268 insertions(+), 274 deletions(-) create mode 100644 internal/lkgateway/lkarchive.go create mode 100644 internal/lkgateway/lkclient.go create mode 100644 internal/lkgateway/lkonline.go create mode 100644 internal/lkgateway/zz_lkdata_live_test.go diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index 7b03ef7..3370c3d 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -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) { diff --git a/internal/lkgateway/lkarchive.go b/internal/lkgateway/lkarchive.go new file mode 100644 index 0000000..9392382 --- /dev/null +++ b/internal/lkgateway/lkarchive.go @@ -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 +} diff --git a/internal/lkgateway/lkclient.go b/internal/lkgateway/lkclient.go new file mode 100644 index 0000000..b6d8622 --- /dev/null +++ b/internal/lkgateway/lkclient.go @@ -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 +} diff --git a/internal/lkgateway/lkonline.go b/internal/lkgateway/lkonline.go new file mode 100644 index 0000000..c70f7e1 --- /dev/null +++ b/internal/lkgateway/lkonline.go @@ -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 }) +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 11005ea..546e9ec 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -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"` diff --git a/internal/lkgateway/zz_lkdata_live_test.go b/internal/lkgateway/zz_lkdata_live_test.go new file mode 100644 index 0000000..ece7eb7 --- /dev/null +++ b/internal/lkgateway/zz_lkdata_live_test.go @@ -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) + } + } +} diff --git a/modules/lk/lk.go b/modules/lk/lk.go index e360ee8..c77d1b9 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -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 +} diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go index 1422094..99a44e6 100644 --- a/modules/lk/lk_test.go +++ b/modules/lk/lk_test.go @@ -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) diff --git a/modules/lk/templates.go b/modules/lk/templates.go index 3d63293..f601e52 100644 --- a/modules/lk/templates.go +++ b/modules/lk/templates.go @@ -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} {{template "mark" .}}{{.Title}} @@ -141,86 +146,51 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} {{end}} -{{define "board"}}{{template "head" .}}{{template "nav" .}} +{{define "portfolio"}}{{template "head" .}}{{template "nav" .}}{{$p := .P}}
- {{if gt .Value 0.0}}Стоимость бумаг{{else}}Бумаг в реестре{{end}} -
{{if gt .Value 0.0}}{{fmtmoney .Value}} ₽{{else}}{{.Total}}{{end}}
-
{{with .FullName}}{{.}} · {{end}}договор {{.Contract}}{{if gt .Value 0.0}} · оценка по {{.Priced}} из {{.Total}} бумаг{{end}}
+ Стоимость портфеля{{if eq .View "archive"}} на {{$p.AsOf}}{{end}} +
{{fmtmoney $p.Total}} ₽
+
{{with .FullName}}{{.}} · {{end}}договор {{.Contract}}{{if and (ne .View "archive") $p.AsOf}} · данные на {{$p.AsOf}}{{end}}{{if $p.Note}} · {{$p.Note}}{{end}}
- Свободно к переводу{{.Free}} - С ограничением{{.Blocked}} - Поручений в работе{{.Active}} + Бумаги{{fmtmoney $p.Securities}} ₽ + Деньги{{fmtmoney $p.CashRUB}} ₽ + Результат{{if ne $p.Cost 0.0}}{{fmtsigned $p.PnL}} ₽ · {{fmtpct $p.PnLPct}}{{else}}—{{end}}
{{if .Flash}}
{{.Flash}}
{{end}} {{if .Error}}
{{.Error}}
{{end}} -
-

Последние поручения

все поручения
- {{if .Claims}}
- - {{range .Claims}} - - - - - - {{end}}
НомерДатаВид порученияБумагСостояние
{{shortid .GUID}}{{.CreatedAt}}Перевод в другой депозитарий{{.Securit}}{{.StatusRu}}{{with .Reason}}
{{.}}
{{end}}
- {{else}}

Поручений пока нет. Нажмите «Новое поручение», чтобы перевести бумаги в другой депозитарий.

{{end}} -
+
+ Онлайн + Архив + {{if eq .View "archive"}}
{{end}} + {{if eq .View "archive"}}Портфель по брокерскому учёту на выбранную дату{{else if eq $p.Source "quik"}}Позиции и деньги в торговой системе, цены биржи{{else}}Портфель по брокерскому учёту{{end}} +
+ + {{if $p.Cash}}
+

Денежные средства

+
{{range $p.Cash}}{{fmtmoney .Amount}} {{if eq .Currency "SUR"}}₽{{else}}{{.Currency}}{{end}}{{end}}
+
{{end}}
-

Реестр бумаг

весь реестр
- {{if .Positions}}
- - {{range .Positions}} - - +

Бумаги

{{len $p.Positions}} позиций{{if $p.Positions}}, с ценой {{$p.Priced}}{{end}}
+ {{if $p.Positions}}
ВыпускСчёт депоКоличествоСвободноОценкаСостояние
{{.SecurityName}}
{{.ISIN}}
{{if .AccountID}}{{.AccountID}}{{else}}—{{end}}
+ + {{range $p.Positions}} + - + + - + + {{end}}
БумагаКол-воСредняяЦенаСтоимостьРезультатДоля
{{if .ISIN}}{{.Name}}{{else}}{{.Name}}{{end}}
{{.Kind}}{{if .ISIN}} · {{.ISIN}}{{end}}{{if .Note}} · {{.Note}}{{end}}
{{fmtnum .Quantity}}{{fmtnum .Available}}{{if gt .AvgPrice 0.0}}{{fmtprice .AvgPrice}}{{if .InPct}}%{{end}}{{else}}—{{end}}{{if gt .Price 0.0}}{{fmtprice .Price}}{{if .InPct}}%{{end}}{{if ne .DayPct 0.0}}
{{fmtpct .DayPct}}
{{end}}{{else}}—{{end}}
{{template "money" .Value}}{{if not .Transferable}}не переводится{{else if gt .Blocked 0.0}}{{fmtnum .Blocked}} с ограничением{{else}}свободно{{end}}{{if ne .Cost 0.0}}{{fmtsigned .PnL}}
{{fmtpct .PnLPct}}
{{else}}—{{end}}
{{if gt .Share 0.0}}{{fmtpct1 .Share}}{{else}}—{{end}}
- {{else}}

Бумаг на счетах депо не найдено. Если это ошибка — напишите в депозитарий.

{{end}} + {{else}}

{{if eq .View "archive"}}На эту дату бумаг на счёте не было.{{else}}Бумаг на счёте нет.{{end}}

{{end}}
{{end}} -{{define "portfolio"}}{{template "head" .}}{{template "nav" .}} -
-
Реестр бумаг - {{if gt .Value 0.0}}
{{fmtmoney .Value}} ₽
{{end}} -
{{.Source}}{{with .UpdatedAt}} · {{.}}{{end}}{{if gt .Value 0.0}} · цены торговой системы{{end}}
-
- Выпусков{{.Total}} - Свободно к переводу{{.Free}} - С ограничением{{.Blocked}} -
-
-
- {{if .Flash}}
{{.Flash}}
{{end}} - {{if .Error}}
{{.Error}}
{{end}} - {{if .ReadOnly}}
Кабинет открыт в режиме просмотра: подача поручений по этому договору выключена депозитарием.
{{end}} - {{if .Positions}} -
- - {{range .Positions}} - - - - - - - - - - {{end}}
ВыпускСчёт депоКоличествоС ограничениемСвободноЦенаОценкаСостояние
{{.SecurityName}}
{{.ISIN}}{{with .SecurityCode}} · {{.}}{{end}}
{{if .AccountID}}{{.AccountID}}{{else}}—{{end}}{{fmtnum .Quantity}}{{if gt .Blocked 0.0}}{{fmtnum .Blocked}}{{else}}—{{end}}{{fmtnum .Available}}{{if gt .Price 0.0}}{{fmtmoney .Price}}{{if ne .ChangePct 0.0}}
{{fmtpct .ChangePct}}
{{end}}{{else}}—{{end}}
{{template "money" .Value}}{{if not .Transferable}}иностранный выпуск{{else if gt .Blocked 0.0}}частично ограничено{{else}}свободно{{end}}{{with .Note}}
{{.}}
{{end}}
{{if and .Transferable (gt .Available 0.0) (not $.ReadOnly)}}перевести{{end}}
-

С ограничением — бумаги в расчётах по сделке, под обременением или ограниченные в обращении: перевести их нельзя, пока ограничение не снято. - Иностранный выпуск — перевод между депозитариями по MOEX МОСТ работает только с российскими выпусками, код ISIN начинается на RU.

- {{else}}

Бумаг на счетах депо не найдено.

{{end}} -
{{end}} - {{define "transfer"}}{{template "head" .}}{{template "nav" .}}
{{if .Error}}
{{.Error}}
{{end}} @@ -520,81 +490,38 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
{{end}} {{define "operations"}}{{template "head" .}}{{template "nav" .}}
-
Операции по счетам депо -
{{.Total}}
-
за период {{.From}} — {{.To}}
+
+ Операции +
{{len .Rows}}
+
за период {{.From}} — {{.To}}
+
- Зачислений{{.In}} - Списаний{{.Out}} + Покупки{{fmtmoney .BuyRUB}} ₽ + Продажи{{fmtmoney .SellRUB}} ₽ + Выплаты{{fmtmoney .IncomeRUB}} ₽
{{if .Error}}
{{.Error}}
{{end}} -
-
-
- + {{if .Note}}
{{.Note}}
{{end}} + +
+
+
- {{if .Operations}}
- - {{range .Operations}} - - - - - - - - {{end}}
ДатаОперацияВыпускСчёт депоКоличествоСостояниеОснование
{{.Date}}{{if and .DoneAt (ne .DoneAt .Date)}}
проведено {{.DoneAt}}
{{end}}
{{.Kind}}{{with .Number}}
№ {{.}}
{{end}}
{{if .ISIN}}{{.Security}}
{{.ISIN}}
{{else}}{{.Security}}{{end}}
{{.Account}}{{if lt .Quantity 0.0}}{{fmtnum .Quantity}}{{else}}+{{fmtnum .Quantity}}{{end}}{{if eq .State "исполнено"}}исполнено{{else if eq .State "отменено"}}отменено{{else}}{{.State}}{{end}}{{if .Note}}{{.Note}}{{else}}—{{end}}
-

Показаны зачисления, списания и переводы между вашими счетами депо по данным учёта депозитария. Выписку за период можно заказать у депозитария.

- {{else}}

За выбранный период операций по счетам депо нет.

{{end}} -
{{end}} - -{{define "income"}}{{template "head" .}}{{template "nav" .}} -
-
Выплачено дохода -
{{fmtmoney .Paid}} ₽
-
за период {{.From}} — {{.To}}
-
- Начислено{{fmtmoney .Accrued}} ₽ - Удержан налог{{fmtmoney .Tax}} ₽ -
-
-
- {{if .Error}}
{{.Error}}
{{end}} -
-
-
- -
- {{if .Income}}
- - {{range .Income}} + {{if .Rows}}
ДатаВыпускВид доходаБумагНачисленоНалогК выплате
+ + {{range .Rows}} - - - - - - + + + + + {{end}}
ДатаОперацияИнструментКол-воЦенаСумма
{{.Date}}{{if .ISIN}}{{.Security}}
{{.ISIN}}
{{else}}{{.Security}}{{end}}
{{.Kind}}{{if gt .Quantity 0.0}}{{fmtnum .Quantity}}{{else}}—{{end}}{{fmtmoney .Accrued}}{{if gt .Tax 0.0}}{{fmtmoney .Tax}}{{else}}—{{end}}{{fmtmoney .Paid}} ₽{{.Kind}}{{if .ISIN}}{{.Name}}
{{.ISIN}}
{{else}}{{.Name}}{{end}}
{{if ne .Quantity 0.0}}{{fmtnum .Quantity}}{{else}}—{{end}}{{if gt .Price 0.0}}{{fmtprice .Price}}{{else}}—{{end}}{{if eq .Side "in"}}+{{else}}−{{end}}{{fmtmoney .AmountRUB}} ₽{{if and .Currency (ne .Currency "SUR")}}
{{fmtmoney .Amount}} {{.Currency}}
{{end}}
-

Суммы и удержанный налог — по данным учёта депозитария как налогового агента по выплатам эмитентов. По купонам государственных облигаций налог может удерживать брокер при выплате, тогда здесь он нулевой. Справку о доходах можно запросить у депозитария.

- {{else}}

За выбранный период выплат дохода нет.

{{end}} - - {{if .Actions}}
-

Корпоративные действия по вашим бумагам

-
- - {{range .Actions}} - - - - - - {{end}}
Дата фиксацииВыпускСобытиеДата операцииПримечание
{{if .FixDate}}{{.FixDate}}{{else}}—{{end}}{{if .ISIN}}{{.Security}}
{{.ISIN}}
{{else}}{{.Security}}{{end}}
{{.Kind}}{{if .OperDate}}{{.OperDate}}{{else}}—{{end}}{{if .Note}}{{.Note}}{{else}}—{{end}}
-

События по бумагам вашего портфеля из справочника депозитария. Если по действию нужно ваше решение — например, предъявить бумаги к выкупу по оферте, — подайте заявление через раздел «Документы» или обратитесь к своему менеджеру.

-
{{end}} + {{else}}

За выбранный период операций нет.

{{end}}
{{end}} + {{define "documents"}}{{template "head" .}}{{template "nav" .}}
Документы депозитария