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}