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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 15:44:53 +03:00
parent 05d02f22e6
commit 02fabb483d
9 changed files with 1268 additions and 274 deletions
+75
View File
@@ -7,6 +7,7 @@ import (
"io"
"log"
"net/http"
"sort"
"strings"
"time"
)
@@ -271,6 +272,80 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
lkJSON(w, http.StatusOK, map[string]any{"operations": ops, "from": q.Get("from"), "to": q.Get("to")})
})
// Портфель клиента как у брокера: view=online — по экспорту QUIK (что есть сейчас), если
// в QUIK клиента нет — по брокерскому учёту на сегодня; view=archive&date=YYYY-MM-DD —
// портфель на дату по брокерскому учёту (SPECTRE).
mux.HandleFunc("/api/lk/portfolio2", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
}
u, ok := current(w, r)
if !ok {
return
}
fancy := rc.Snapshot().Fancy
q := r.URL.Query()
var pf *LKPortfolio
var err error
switch q.Get("view") {
case "archive":
day := time.Now()
if d, e := time.ParseInLocation("2006-01-02", q.Get("date"), time.Local); e == nil {
day = d
}
pf, err = lkArchivePortfolio(r.Context(), fancy, u.Contract, day)
default:
pf, err = lkOnlinePortfolio(r.Context(), rc, u.Contract)
if err == nil && len(pf.Positions) == 0 && len(pf.Cash) == 0 {
// В торговой системе клиента нет (не торгует через QUIK) — текущий портфель из учёта.
if ar, e := lkArchivePortfolio(r.Context(), fancy, u.Contract, time.Now()); e == nil {
ar.Note = "по данным брокерского учёта на сегодня"
pf = ar
}
}
}
if err != nil {
lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
pf.Note = strings.TrimSpace(pf.Note)
lkJSON(w, http.StatusOK, map[string]any{"portfolio": pf, "contract": u.Contract, "full_name": u.FullName})
})
// Операции по брокерскому счёту: сделки и движения бумаг (SPECTRE) плюс выплаты дохода (учёт).
mux.HandleFunc("/api/lk/ops", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
}
u, ok := current(w, r)
if !ok {
return
}
fancy := rc.Snapshot().Fancy
q := r.URL.Query()
fromT, toT := parseRange(q.Get("from"), q.Get("to"))
rows, err := lkArchiveOperations(r.Context(), fancy, u.Contract, fromT, toT)
if err != nil {
rows = nil // без брокерского учёта показываем хотя бы выплаты
}
if inc, _, e := lkIncome(r.Context(), fancy, u.Contract, q.Get("from"), q.Get("to")); e == nil {
for _, p := range inc {
rows = append(rows, LKOperationRow{Date: p.Date, Kind: p.Kind, Side: "in", Name: p.Security, ISIN: p.ISIN,
Quantity: p.Quantity, Amount: p.Paid, Currency: "SUR", AmountRUB: p.Paid})
}
}
sort.SliceStable(rows, func(i, j int) bool {
di, _ := time.Parse("02.01.2006", rows[i].Date)
dj, _ := time.Parse("02.01.2006", rows[j].Date)
return di.After(dj)
})
resp := map[string]any{"operations": rows, "from": fromT.Format("2006-01-02"), "to": toT.Format("2006-01-02")}
if err != nil {
resp["note"] = err.Error()
}
lkJSON(w, http.StatusOK, resp)
})
// Выплаты дохода и удержанный налог.
mux.HandleFunc("/api/lk/income", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
+517
View File
@@ -0,0 +1,517 @@
package lkgateway
// lkarchive.go — портфель клиента на дату и операции по брокерскому учёту (SPECTRE).
//
// SPECTRE — бухгалтерское ядро Fancy. Позиции клиента лежат в остатках OD_RESTS по регистру
// REG_3 = договор (D_B_CONTRACTS.DOC), в портфельном разделе плана счетов (OD_BALANCES.PART=39):
// деньги — счета 50/51/52/55 (остаток D в валюте VALUE_ID), бумаги — 58.xx (количество A,
// бумага REG_2), НКД — отдельный счёт 58.05 (не показываем: он в цене). OD_RESTS — текущий
// остаток; позиция на прошлую дату = остаток минус обороты OD_TURNS после этой даты
// (TYPE_ = +1 дебет / −1 кредит, что подтверждено сверкой: сумма оборотов = остаток).
// Оценка — по котировке OD_VALUES_RATES не старше 90 дней на дату (правило признаваемой
// котировки), иначе — балансовая стоимость EQ_D. Рецепт взят из InvestControl Hub
// (service.build_portfolio_base, deals_report) и проверен там на договорах ДУ-489 и БР-704.
//
// Операции — из проводок OD_WIRING по портфельным счетам договора: покупка, продажа, ввод и
// вывод бумаг, погашение, конвертация; выручка при продаже берётся со счёта ВЫРУЧКА.
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
// spectreRecognizeDays — котировка старше этого срока не считается рыночной.
const spectreRecognizeDays = 90
type spectreRest struct {
id int64
numS, sysName string
num1 int
secID, curID int64
a, d, eqD float64
isin, secName, cur string
class int // OD_SHARES.CLASS: 1 акция, 2 облигация, 7 депозитарная расписка …
nominal float64
}
// lkArchivePortfolio — портфель на дату по SPECTRE. date — в местном времени, конец дня включительно.
func lkArchivePortfolio(ctx context.Context, s FancyMSSQLSettings, contract string, date time.Time) (*LKPortfolio, error) {
c, ok := spectreContractFor(ctx, s, contract)
if !ok {
return nil, fmt.Errorf("договор %s в брокерском учёте не найден", lkContractNumber(contract))
}
conn, err := spectreOpen(s)
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
day := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
today := time.Now()
isPast := day.Before(time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC))
rows, err := conn.QueryContext(cctx, `SELECT r.ID, b.NUM_S, ISNULL(b.SYS_NAME,''), b.NUM_1, ISNULL(r.REG_2,0), ISNULL(r.VALUE_ID,0),
CAST(ISNULL(r.A,0) AS float), CAST(ISNULL(r.D,0) AS float), CAST(ISNULL(r.EQ_D,0) AS float),
ISNULL(vs.ISIN,''), ISNULL(vs.NAME,''), ISNULL(vc.SYSNAME,''), ISNULL(sh.CLASS,0), CAST(ISNULL(sh.NOMINAL,0) AS float)
FROM OD_RESTS r JOIN OD_BALANCES b ON b.ID = r.BAL_ACC
LEFT JOIN OD_VALUES vs ON vs.ID = r.REG_2
LEFT JOIN OD_VALUES vc ON vc.ID = r.VALUE_ID
LEFT JOIN OD_SHARES sh ON sh.ID = r.REG_2
WHERE r.REG_3 = @c AND b.IS_BAL = 1 AND b.PART = 39 AND b.NUM_1 IN (50,51,52,55,58)
ORDER BY b.NUM_1, vs.NAME`, sql.Named("c", c.DOC))
if err != nil {
return nil, fmt.Errorf("остатки: %v", err)
}
var rests []spectreRest
for rows.Next() {
var r spectreRest
if rows.Scan(&r.id, &r.numS, &r.sysName, &r.num1, &r.secID, &r.curID, &r.a, &r.d, &r.eqD,
&r.isin, &r.secName, &r.cur, &r.class, &r.nominal) == nil {
rests = append(rests, r)
}
}
rows.Close()
// Прошлая дата: откатываем остатки на обороты после конца этого дня.
if isPast && len(rests) > 0 {
ids := make([]string, 0, len(rests))
for _, r := range rests {
ids = append(ids, fmt.Sprint(r.id))
}
trows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT REST,
CAST(SUM(CASE WHEN TYPE_ = 1 THEN AMOUNT_ ELSE -AMOUNT_ END) AS float),
CAST(SUM(CASE WHEN TYPE_ = 1 THEN VALUE_ ELSE -VALUE_ END) AS float),
CAST(SUM(CASE WHEN TYPE_ = 1 THEN EQ_ ELSE -EQ_ END) AS float)
FROM OD_TURNS WHERE REST IN (%s) AND WIRDATE >= @d AND IS_PLAN = 'F' AND YEAR(WIRDATE) < 9000
GROUP BY REST`, strings.Join(ids, ",")), sql.Named("d", day.AddDate(0, 0, 1)))
if err != nil {
return nil, fmt.Errorf("обороты: %v", err)
}
adj := map[int64][3]float64{}
for trows.Next() {
var id int64
var a, v, e float64
if trows.Scan(&id, &a, &v, &e) == nil {
adj[id] = [3]float64{a, v, e}
}
}
trows.Close()
for i := range rests {
if x, ok := adj[rests[i].id]; ok {
rests[i].a -= x[0]
rests[i].d -= x[1]
rests[i].eqD -= x[2]
}
}
}
out := &LKPortfolio{Source: "spectre", AsOf: day.Format("02.01.2006")}
fx := spectreFx(cctx, conn, day)
byISIN := map[string]*LKPosition{}
cash := map[string]float64{}
for _, r := range rests {
isCash := r.num1 != 58
if isCash {
if abs(r.d) < 0.005 {
continue
}
cur := spectreCurCode(r.cur)
cash[cur] += r.d
if cur == "SUR" {
out.CashRUB += r.d
} else {
out.CashRUB += r.d * fx(r.curID)
}
continue
}
if strings.Contains(strings.ToUpper(r.sysName), "НКД") || strings.Contains(strings.ToUpper(r.sysName), "СКД") {
continue // купонный доход сидит в цене, отдельной строкой клиенту не нужен
}
if abs(r.a) < 1e-9 {
continue
}
key := r.isin
if key == "" {
key = fmt.Sprintf("id:%d", r.secID)
}
pos := byISIN[key]
if pos == nil {
pos = &LKPosition{ISIN: r.isin, Name: r.secName, Kind: spectreKind(r.class), Currency: spectreCurCode(r.cur)}
byISIN[key] = pos
}
pos.Quantity += r.a
pos.Cost += r.eqD
// Оценка: рыночная котировка на дату, если не старше 90 дней, иначе балансовая.
// Котировки OD_VALUES_RATES уже в рублях за одну бумагу (и для валютных выпусков —
// проверено: НОВАТЭК 001P-03 с номиналом 100 USD котируется как 7 967 ₽).
price, age := spectreQuote(cctx, conn, r.secID, day)
if price > 0 && age <= spectreRecognizeDays {
pos.Value += r.a * price
pos.Price = price
} else {
pos.Value += r.eqD
pos.Note = "оценка по балансовой стоимости: нет свежей котировки"
}
}
for _, p := range byISIN {
p.PnL = p.Value - p.Cost
if p.Cost != 0 {
p.PnLPct = p.PnL / p.Cost * 100
}
if p.Quantity != 0 && p.Cost != 0 {
p.AvgPrice = p.Cost / p.Quantity
}
out.Positions = append(out.Positions, *p)
}
for cur, amt := range cash {
if abs(amt) >= 0.005 {
out.Cash = append(out.Cash, LKCash{Currency: cur, Amount: amt})
}
}
sort.Slice(out.Cash, func(i, j int) bool { return out.Cash[i].Currency < out.Cash[j].Currency })
out.Priced = 0
for _, p := range out.Positions {
if p.Price > 0 {
out.Priced++
}
}
lkPortfolioTotals(out)
if c.Closed {
out.Note = "договор закрыт"
}
return out, nil
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
// spectreCurCode — код валюты в терминах кабинета (рубли — SUR, как в QUIK).
func spectreCurCode(sys string) string {
switch strings.ToUpper(strings.TrimSpace(sys)) {
case "RUB", "RUR", "SUR", "":
return "SUR"
}
return strings.ToUpper(strings.TrimSpace(sys))
}
// spectreKind — вид бумаги по классу OD_SHARES.CLASS.
func spectreKind(class int) string {
switch class {
case 1:
return "акция"
case 2:
return "облигация"
case 3, 4:
return "пай"
case 7:
return "депозитарная расписка"
}
return "бумага"
}
// spectreQuote — котировка бумаги на дату (не позже) и её возраст в днях.
func spectreQuote(ctx context.Context, conn *sql.DB, secID int64, day time.Time) (float64, int) {
var ofic sql.NullTime
var rate3, pfr, closeP sql.NullFloat64
err := conn.QueryRowContext(ctx, `SELECT TOP 1 OFICDATE, CAST(RATE3 AS float), CAST(PFR_PRICE AS float), CAST(CLOSE_ AS float)
FROM OD_VALUES_RATES WHERE VALUE_ID = @v AND OFICDATE <= @d AND (RATE3 > 0 OR PFR_PRICE > 0 OR CLOSE_ > 0)
ORDER BY OFICDATE DESC`, sql.Named("v", secID), sql.Named("d", day.AddDate(0, 0, 1))).Scan(&ofic, &rate3, &pfr, &closeP)
if err != nil {
return 0, 1 << 30
}
price := pfr.Float64
if price <= 0 {
price = rate3.Float64
}
if price <= 0 {
price = closeP.Float64
}
age := 1 << 30
if ofic.Valid {
age = int(day.Sub(ofic.Time).Hours() / 24)
if age < 0 {
age = 0
}
}
return price, age
}
// spectreFx — курс валюты к рублю на дату (MARKET=0), с кэшем на запрос.
func spectreFx(ctx context.Context, conn *sql.DB, day time.Time) func(curID int64) float64 {
cache := map[int64]float64{}
return func(curID int64) float64 {
if curID == 0 {
return 1
}
if v, ok := cache[curID]; ok {
return v
}
var rate sql.NullFloat64
_ = conn.QueryRowContext(ctx, `SELECT TOP 1 CAST(RATE AS float) FROM OD_VALUES_RATES WHERE VALUE_ID = @v AND MARKET = 0 AND OFICDATE <= @d AND RATE > 0 ORDER BY OFICDATE DESC`,
sql.Named("v", curID), sql.Named("d", day.AddDate(0, 0, 1))).Scan(&rate)
v := rate.Float64
if v <= 0 {
v = 1
}
cache[curID] = v
return v
}
}
// LKOperationRow — операция по брокерскому счёту для ленты кабинета.
type LKOperationRow struct {
Date string `json:"date"`
Kind string `json:"kind"` // покупка | продажа | ввод бумаг | вывод бумаг | погашение | конвертация | замещение | пополнение | вывод денег
Side string `json:"side"` // in | out
Name string `json:"name"` // бумага или «Денежные средства»
ISIN string `json:"isin,omitempty"`
Quantity float64 `json:"quantity,omitempty"`
Price float64 `json:"price,omitempty"` // цена за бумагу в валюте
Amount float64 `json:"amount,omitempty"` // сумма в валюте операции
Currency string `json:"currency,omitempty"`
AmountRUB float64 `json:"amount_rub"` // сумма в рублях
}
// lkArchiveOperations — сделки и движения по портфельным счетам договора за период.
func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract string, from, to time.Time) ([]LKOperationRow, error) {
c, ok := spectreContractFor(ctx, s, contract)
if !ok {
return nil, fmt.Errorf("договор %s в брокерском учёте не найден", lkContractNumber(contract))
}
conn, err := spectreOpen(s)
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
// Счета бумаг (ПОРТФЕЛЬ) и денег (касса) договора.
rows, err := conn.QueryContext(cctx, `SELECT r.ID, b.NUM_1, ISNULL(b.SYS_NAME,'') FROM OD_RESTS r JOIN OD_BALANCES b ON b.ID = r.BAL_ACC
WHERE r.REG_3 = @c AND b.PART = 39 AND ((b.NUM_1 = 58 AND b.SYS_NAME LIKE N'ПОРТФЕЛЬ%') OR b.NUM_1 IN (50,51,52,55))`, sql.Named("c", c.DOC))
if err != nil {
return nil, err
}
var secIDs, cashIDs []string
secSet := map[int64]bool{}
for rows.Next() {
var id int64
var num1 int
var sys string
if rows.Scan(&id, &num1, &sys) == nil {
if num1 == 58 {
secIDs = append(secIDs, fmt.Sprint(id))
secSet[id] = true
} else {
cashIDs = append(cashIDs, fmt.Sprint(id))
}
}
}
rows.Close()
var out []LKOperationRow
toEnd := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
fromDay := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
if len(secIDs) > 0 {
in := strings.Join(secIDs, ",")
wrows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), CAST(ISNULL(w.D_AMOUNT,0) AS float), CAST(ISNULL(w.K_AMOUNT,0) AS float),
ISNULL(w.NAME,''), ISNULL(sv.NAME,''), ISNULL(sv.ISIN,''), ISNULL(mv.SYSNAME,'')
FROM OD_WIRING w
JOIN OD_RESTS sr ON sr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END
LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2
LEFT JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.K_REST ELSE w.D_REST END
LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
AND w.WIRDATE >= @f AND w.WIRDATE < @t AND (w.D_AMOUNT <> 0 OR w.K_AMOUNT <> 0)
ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
if err != nil {
return nil, fmt.Errorf("проводки: %v", err)
}
type key struct{ day, kind, isin, cur string }
agg := map[key]*LKOperationRow{}
var order []key
for wrows.Next() {
var wd time.Time
var eq, dSum, kSum, dAmt, kAmt float64
var dRest, kRest int64
var wname, secName, isin, cur string
if wrows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &dAmt, &kAmt, &wname, &secName, &isin, &cur) != nil {
continue
}
onDebit := secSet[dRest]
kind := spectreDealKind(wname, onDebit)
if kind == "" {
continue
}
qty, sum := kAmt, dSum
if onDebit {
qty, sum = dAmt, kSum
}
k := key{wd.Format("2006-01-02"), kind, isin, spectreCurCode(cur)}
a := agg[k]
if a == nil {
side := "out"
if onDebit {
side = "in"
}
a = &LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: secName, ISIN: isin, Currency: k.cur}
agg[k] = a
order = append(order, k)
}
a.Quantity += qty
a.Amount += sum
a.AmountRUB += eq
}
wrows.Close()
// При продаже счёт ПОРТФЕЛЬ списывает бумагу по себестоимости; фактическая выручка —
// на счёте ВЫРУЧКА. Клиенту показываем именно её.
proceeds := spectreProceeds(cctx, conn, c.DOC, fromDay, toEnd)
for _, k := range order {
a := agg[k]
if a.Kind == "продажа" || a.Kind == "погашение" {
if p, ok := proceeds[k.isin+"|"+k.day]; ok && p[1] != 0 {
a.Amount, a.AmountRUB = p[0], p[1]
}
}
if a.Quantity != 0 && a.Amount != 0 {
a.Price = a.Amount / a.Quantity
}
out = append(out, *a)
}
}
// Деньги: пополнения и выводы — проводки по кассовым счетам с чужой стороной вне портфеля договора.
if len(cashIDs) > 0 {
in := strings.Join(cashIDs, ",")
crows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(w.NAME,''), ISNULL(mv.SYSNAME,'')
FROM OD_WIRING w
JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END
LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
AND w.WIRDATE >= @f AND w.WIRDATE < @t
ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
if err == nil {
cashSet := map[string]bool{}
for _, id := range cashIDs {
cashSet[id] = true
}
for crows.Next() {
var wd time.Time
var eq, dSum, kSum float64
var dRest, kRest int64
var wname, cur string
if crows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &wname, &cur) != nil {
continue
}
low := strings.ToLower(wname)
var kind, side string
switch {
case strings.Contains(low, "ввод") && strings.Contains(low, "ден"), strings.Contains(low, "пополнен"), strings.Contains(low, "зачисление денеж"):
kind, side = "пополнение", "in"
case strings.Contains(low, "вывод") && strings.Contains(low, "ден"), strings.Contains(low, "списание денеж") && !strings.Contains(low, "комисс"):
kind, side = "вывод денег", "out"
default:
continue
}
sum := kSum
if cashSet[fmt.Sprint(dRest)] {
sum = dSum
}
out = append(out, LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: "Денежные средства",
Amount: sum, Currency: spectreCurCode(cur), AmountRUB: eq})
}
crows.Close()
}
}
sort.SliceStable(out, func(i, j int) bool {
di, _ := time.Parse("02.01.2006", out[i].Date)
dj, _ := time.Parse("02.01.2006", out[j].Date)
return di.After(dj)
})
return out, nil
}
// spectreDealKind — вид операции по названию проводки (рецепт хаба). Пусто — не показываем.
func spectreDealKind(name string, onDebit bool) string {
n := strings.ToLower(name)
switch {
case strings.Contains(n, "замещени") || strings.Contains(n, "обмен"):
return "замещение"
case strings.Contains(n, "конвертац") || strings.Contains(n, "слияни"):
return "конвертация"
case strings.Contains(n, "погашен"):
return "погашение"
case strings.Contains(n, "ввод") && strings.Contains(n, "цб"):
return "ввод бумаг"
case strings.Contains(n, "вывод") && strings.Contains(n, "цб"):
return "вывод бумаг"
case strings.Contains(n, "заём") || strings.Contains(n, "займ") || strings.Contains(n, "репо"):
return "" // РЕПО и займы клиенту в ленте не показываем
}
if onDebit {
return "покупка"
}
return "продажа"
}
// spectreProceeds — выручка от продаж по (ISIN|день): [сумма в валюте, сумма в рублях].
func spectreProceeds(ctx context.Context, conn *sql.DB, doc int64, from, to time.Time) map[string][2]float64 {
out := map[string][2]float64{}
rows, err := conn.QueryContext(ctx, `SELECT r.ID FROM OD_RESTS r JOIN OD_BALANCES b ON b.ID = r.BAL_ACC WHERE r.REG_3 = @c AND b.SYS_NAME = N'ВЫРУЧКА'`, sql.Named("c", doc))
if err != nil {
return out
}
var ids []string
idSet := map[int64]bool{}
for rows.Next() {
var id int64
if rows.Scan(&id) == nil {
ids = append(ids, fmt.Sprint(id))
idSet[id] = true
}
}
rows.Close()
if len(ids) == 0 {
return out
}
in := strings.Join(ids, ",")
wrows, err := conn.QueryContext(ctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(sv.ISIN,'')
FROM OD_WIRING w
JOIN OD_RESTS sr ON sr.ID = CASE WHEN w.K_REST IN (%[1]s) THEN w.K_REST ELSE w.D_REST END
LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
AND w.WIRDATE >= @f AND w.WIRDATE < @t AND w.EQ_ <> 0`, in), sql.Named("f", from), sql.Named("t", to))
if err != nil {
return out
}
defer wrows.Close()
for wrows.Next() {
var wd time.Time
var eq, dSum, kSum float64
var dRest, kRest int64
var isin string
if wrows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &isin) != nil {
continue
}
sum := dSum
if idSet[kRest] {
sum = kSum
}
k := isin + "|" + wd.Format("2006-01-02")
v := out[k]
v[0] += sum
v[1] += eq
out[k] = v
}
return out
}
+102
View File
@@ -0,0 +1,102 @@
package lkgateway
// lkclient.go — как один и тот же клиент называется в разных системах.
//
// В учётных записях кабинета клиент задан номером договора, как в DEPO: «БР-1545», «БРИИС-514».
// В экспорте QUIK тот же клиент — код латиницей без дефиса: BR1545, BRIIS514; внебиржевой
// раздел — с суффиксом OUT. В SPECTRE договор — строка D_B_CONTRACTS.NAME вида «БР-1545 ФИО».
import (
"context"
"database/sql"
"strings"
"sync"
"time"
)
// lkContractNumber — номер договора без ФИО: «БР-1545 Иванов Иван» → «БР-1545».
func lkContractNumber(contract string) string {
c := strings.TrimSpace(contract)
if i := strings.IndexByte(c, ' '); i > 0 {
c = c[:i]
}
return strings.ToUpper(c)
}
// lkQuikCodes — коды клиента в QUIK по номеру договора: основной и внебиржевой раздел.
func lkQuikCodes(contract string) []string {
num := lkContractNumber(contract)
if num == "" {
return nil
}
repl := strings.NewReplacer("-", "", "А", "A", "Б", "B", "В", "V", "Д", "D", "И", "I", "С", "S", "Р", "R", "У", "U", "Т", "T", "К", "K", "М", "M", "Н", "N", "О", "O", "Е", "E", "П", "P", "Ф", "F", "Ц", "C")
code := repl.Replace(num)
return []string{code, code + "OUT"}
}
// spectreContract — договор клиента в SPECTRE.
type spectreContract struct {
DOC int64
Investor int64
Name string
Open time.Time
Closed bool
}
var spectreContractCache = struct {
mu sync.Mutex
m map[string]spectreContract
at map[string]time.Time
}{m: map[string]spectreContract{}, at: map[string]time.Time{}}
// spectreOpen — подключение к копии SPECTRE тем же логином, что и к DEPO.
func spectreOpen(s FancyMSSQLSettings) (*sql.DB, error) {
db := strings.TrimSpace(s.SpectreDB())
return fancyOpen(s, db)
}
// SpectreDB — база SPECTRE рядом с DEPO: по умолчанию BJ_SPECTRE_DATA.
func (s FancyMSSQLSettings) SpectreDB() string {
if v := strings.TrimSpace(s.SpectreDatabase); v != "" {
return v
}
return "BJ_SPECTRE_DATA"
}
// spectreContractFor — договор SPECTRE по номеру договора клиента (кэш 10 минут).
func spectreContractFor(ctx context.Context, s FancyMSSQLSettings, contract string) (spectreContract, bool) {
num := lkContractNumber(contract)
if num == "" || strings.TrimSpace(s.Server) == "" {
return spectreContract{}, false
}
spectreContractCache.mu.Lock()
if c, ok := spectreContractCache.m[num]; ok && time.Since(spectreContractCache.at[num]) < 10*time.Minute {
spectreContractCache.mu.Unlock()
return c, c.DOC != 0
}
spectreContractCache.mu.Unlock()
conn, err := spectreOpen(s)
if err != nil {
return spectreContract{}, false
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var c spectreContract
var open, closed sql.NullTime
var state sql.NullInt64
err = conn.QueryRowContext(cctx, `SELECT TOP 1 DOC, INVESTOR, NAME, C_OPEN, C_CLOSE, C_STATE FROM D_B_CONTRACTS
WHERE UPPER(NAME) = @n OR UPPER(NAME) LIKE @n + ' %' ORDER BY C_STATE DESC, DOC DESC`, sql.Named("n", num)).
Scan(&c.DOC, &c.Investor, &c.Name, &open, &closed, &state)
if err == nil {
c.Name = strings.TrimSpace(c.Name)
if open.Valid {
c.Open = open.Time
}
c.Closed = closed.Valid && closed.Time.Year() < 9000 && closed.Time.Before(time.Now())
}
spectreContractCache.mu.Lock()
spectreContractCache.m[num], spectreContractCache.at[num] = c, time.Now()
spectreContractCache.mu.Unlock()
return c, err == nil && c.DOC != 0
}
+228
View File
@@ -0,0 +1,228 @@
package lkgateway
// lkonline.go — портфель клиента «сейчас» по экспорту биржевой информации из QUIK.
//
// Это то, что клиент видит у брокера: позиции с количеством, средней ценой покупки, текущей
// ценой и результатом, деньги по валютам, стоимость портфеля. Источник — таблицы экспорта
// DepoLimits (позиции), MoneyLimits (деньги), Securities/Params (бумаги и цены). Клиент
// определяется кодом QUIK, полученным из номера договора (lkQuikCodes).
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// LKPortfolio — портфель для кабинета (онлайн или на дату — одна форма).
type LKPortfolio struct {
Source string `json:"source"` // quik | spectre
AsOf string `json:"as_of"` // на какой момент
Total float64 `json:"total"` // стоимость портфеля, ₽ (бумаги + деньги в рублях)
Securities float64 `json:"securities_value"` // стоимость бумаг, ₽
CashRUB float64 `json:"cash_rub"` // деньги в рублях
Cost float64 `json:"cost"` // вложено (по средней цене / балансовой стоимости), ₽
PnL float64 `json:"pnl"` // результат по позициям, ₽
PnLPct float64 `json:"pnl_pct"` // результат, %
Priced int `json:"priced"` // позиций с ценой
Cash []LKCash `json:"cash"`
Positions []LKPosition `json:"positions"`
Note string `json:"note,omitempty"`
}
// LKCash — деньги в одной валюте.
type LKCash struct {
Currency string `json:"currency"` // SUR, USD, CNY …
Amount float64 `json:"amount"`
}
// LKPosition — позиция по бумаге.
type LKPosition struct {
ISIN string `json:"isin"`
Code string `json:"code,omitempty"` // код в торговой системе
Name string `json:"name"`
Kind string `json:"kind"` // акция | облигация | фонд | …
Quantity float64 `json:"quantity"`
AvgPrice float64 `json:"avg_price,omitempty"` // средняя цена покупки (за бумагу, в тех же единицах, что и Price)
Price float64 `json:"price,omitempty"` // текущая цена
InPct bool `json:"in_pct,omitempty"` // цены в процентах номинала (облигации)
Value float64 `json:"value"` // стоимость, ₽
Cost float64 `json:"cost,omitempty"` // вложено, ₽
PnL float64 `json:"pnl"` // результат, ₽
PnLPct float64 `json:"pnl_pct"` // результат, %
DayPct float64 `json:"day_pct,omitempty"` // изменение за день, %
Share float64 `json:"share"` // доля в портфеле, %
Currency string `json:"currency,omitempty"`
Note string `json:"note,omitempty"`
}
// lkKindByClass — вид бумаги по классу QUIK.
func lkKindByClass(class string) string {
switch {
case class == "TQBR", class == "TQPI", strings.HasPrefix(class, "SPBXM"):
return "акция"
case class == "TQOB", class == "TQCB", class == "TQIR", class == "TQRD", strings.HasSuffix(class, "_BND"):
return "облигация"
case class == "TQTF", class == "TQIF", class == "TQTD":
return "фонд"
case class == "CETS":
return "валюта"
}
return "бумага"
}
// lkOnlinePortfolio — портфель по экспорту QUIK. Ошибка — только если базы экспорта нет.
func lkOnlinePortfolio(ctx context.Context, rc *RuntimeConfig, contract string) (*LKPortfolio, error) {
codes := lkQuikCodes(contract)
if len(codes) == 0 {
return nil, fmt.Errorf("не определён номер договора")
}
p, err := nprDB(rc)
if err != nil {
return nil, fmt.Errorf("база экспорта QUIK недоступна: %v", err)
}
cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
out := &LKPortfolio{Source: "quik"}
// Позиции: свежая строка по каждому (клиент, бумага, торговый счёт), затем сумма по бумаге.
rows, err := p.Query(cctx, `
WITH d AS (
SELECT DISTINCT ON ("ClientCode","SecCode","Trdacc") "SecCode","CurrentBal","AwgPositionPrice","LoadDate"
FROM "DepoLimits" WHERE "ClientCode" = ANY($1) AND "LimitKind"='T2'
ORDER BY "ClientCode","SecCode","Trdacc","LoadDate" DESC)
SELECT "SecCode", SUM("CurrentBal")::float8,
COALESCE(SUM("CurrentBal"*"AwgPositionPrice")/NULLIF(SUM("CurrentBal"),0),0)::float8, MAX("LoadDate")
FROM d WHERE "CurrentBal" <> 0 GROUP BY "SecCode" ORDER BY "SecCode"`, codes)
if err != nil {
return nil, fmt.Errorf("позиции: %v", err)
}
type raw struct {
code string
qty, avg float64
}
var pos []raw
var asOf time.Time
for rows.Next() {
var r raw
var ld *time.Time
if rows.Scan(&r.code, &r.qty, &r.avg, &ld) == nil {
pos = append(pos, r)
if ld != nil && ld.After(asOf) {
asOf = *ld
}
}
}
rows.Close()
// Бумаги: ISIN, имя, класс (основные режимы торгов в приоритете), номинал.
secByCode := map[string]struct {
isin, name, class string
face float64
}{}
if len(pos) > 0 {
codesList := make([]string, 0, len(pos))
for _, r := range pos {
codesList = append(codesList, r.code)
}
srows, err := p.Query(cctx, `
SELECT DISTINCT ON ("SecCode") "SecCode", upper(COALESCE("ISINCode",'')), COALESCE("SecShortName","SecName",''), "ClassCode", COALESCE("FaceValue",0)::float8
FROM "Securities" WHERE "SecCode" = ANY($1)
ORDER BY "SecCode", CASE "ClassCode" WHEN 'TQBR' THEN 1 WHEN 'TQOB' THEN 2 WHEN 'TQCB' THEN 3 WHEN 'TQTF' THEN 4 WHEN 'TQIF' THEN 5 WHEN 'TQRD' THEN 6 ELSE 9 END`, codesList)
if err == nil {
for srows.Next() {
var code, isin, name, class string
var face float64
if srows.Scan(&code, &isin, &name, &class, &face) == nil {
secByCode[code] = struct {
isin, name, class string
face float64
}{isin, name, class, face}
}
}
srows.Close()
}
}
isins := make([]string, 0, len(pos))
for _, r := range pos {
if s, ok := secByCode[r.code]; ok && s.isin != "" {
isins = append(isins, s.isin)
}
}
prices := lkPrices(ctx, rc, isins)
for _, r := range pos {
s := secByCode[r.code]
lp := LKPosition{ISIN: s.isin, Code: r.code, Name: s.name, Kind: lkKindByClass(s.class), Quantity: r.qty, AvgPrice: r.avg, Currency: "SUR"}
if lp.Name == "" {
lp.Name = r.code
}
if px, ok := prices[s.isin]; ok && px.Price > 0 {
lp.Price, lp.InPct, lp.DayPct = px.Price, px.InPct, px.ChangePc
lp.Value = px.Value(r.qty)
out.Priced++
if r.avg > 0 {
// средняя цена в тех же единицах, что и котировка: для облигаций — процент номинала
cost := SecurityPrice{Price: r.avg, Face: px.Face, InPct: px.InPct}
lp.Cost = cost.Value(r.qty)
lp.PnL = lp.Value - lp.Cost
if lp.Cost != 0 {
lp.PnLPct = lp.PnL / lp.Cost * 100
}
}
} else {
lp.Note = "нет биржевой цены"
}
out.Positions = append(out.Positions, lp)
}
// Деньги по валютам.
crows, err := p.Query(cctx, `
WITH m AS (
SELECT DISTINCT ON ("ClientCode","CurrCode","Tag") "CurrCode","CurrentBal","LoadDate"
FROM "MoneyLimits" WHERE "ClientCode" = ANY($1) AND "LimitKind"='T2'
ORDER BY "ClientCode","CurrCode","Tag","LoadDate" DESC)
SELECT "CurrCode", SUM("CurrentBal")::float8, MAX("LoadDate") FROM m GROUP BY "CurrCode" HAVING SUM("CurrentBal") <> 0 ORDER BY "CurrCode"`, codes)
if err == nil {
for crows.Next() {
var c LKCash
var ld *time.Time
if crows.Scan(&c.Currency, &c.Amount, &ld) == nil {
out.Cash = append(out.Cash, c)
if c.Currency == "SUR" || c.Currency == "RUB" {
out.CashRUB += c.Amount
}
if ld != nil && ld.After(asOf) {
asOf = *ld
}
}
}
crows.Close()
}
if !asOf.IsZero() {
out.AsOf = asOf.Format("02.01.2006")
}
lkPortfolioTotals(out)
return out, nil
}
// lkPortfolioTotals — итоги и доли.
func lkPortfolioTotals(p *LKPortfolio) {
p.Securities, p.Cost, p.PnL = 0, 0, 0
for _, pos := range p.Positions {
p.Securities += pos.Value
p.Cost += pos.Cost
p.PnL += pos.PnL
}
p.Total = p.Securities + p.CashRUB
if p.Cost != 0 {
p.PnLPct = p.PnL / p.Cost * 100
}
for i := range p.Positions {
if p.Total > 0 {
p.Positions[i].Share = p.Positions[i].Value / p.Total * 100
}
}
sort.SliceStable(p.Positions, func(i, j int) bool { return p.Positions[i].Value > p.Positions[j].Value })
}
+1
View File
@@ -184,6 +184,7 @@ type FancyMSSQLSettings struct {
// Трансферные таблицы FTV_M2M* в базе DEPO (depotables.go).
DepoDatabase string `json:"depo_database"` // база DEPO; пусто = BJ_DEPO_DATA
SpectreDatabase string `json:"spectre_database,omitempty"` // база SPECTRE (брокерский учёт); пусто = BJ_SPECTRE_DATA
IISContractMarker string `json:"iis_contract_marker,omitempty"` // начало названия договора ИИС в DEPO (через запятую); пусто = БРИИС
DepoTablesMode string `json:"depo_tables_mode"` // auto (создаёт bj) | script (администратор СУБД)
DepoTablesCheck string `json:"depo_tables_check"`
+65
View File
@@ -0,0 +1,65 @@
//go:build live
package lkgateway
import (
"context"
"testing"
"time"
)
func TestLiveCabinetData(t *testing.T) {
rc, _ := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
globalRC = rc
fs := rc.Snapshot().Fancy
ctx := context.Background()
for _, c := range []string{"БР-1545", "БР-704"} {
t0 := time.Now()
on, err := lkOnlinePortfolio(ctx, rc, c)
if err != nil {
t.Logf("%s онлайн: ERR %v", c, err)
} else {
t.Logf("%s ОНЛАЙН (%v) на %s: всего %.2f ₽, бумаги %.2f, деньги %.2f, вложено %.2f, P&L %.2f (%.1f%%), позиций %d/%d", c, time.Since(t0).Round(time.Millisecond), on.AsOf, on.Total, on.Securities, on.CashRUB, on.Cost, on.PnL, on.PnLPct, on.Priced, len(on.Positions))
for i, p := range on.Positions {
if i > 5 {
break
}
t.Logf(" %-14s %-28.28s %-10s %10.0f × ср.%.4g → цена %.4g (%s) = %.2f ₽, P&L %.2f (%.1f%%) %s", p.ISIN, p.Name, p.Kind, p.Quantity, p.AvgPrice, p.Price, map[bool]string{true: "%", false: "₽"}[p.InPct], p.Value, p.PnL, p.PnLPct, p.Note)
}
for _, m := range on.Cash {
t.Logf(" деньги %s %.2f", m.Currency, m.Amount)
}
}
for _, d := range []time.Time{time.Now(), time.Date(2025, 12, 31, 0, 0, 0, 0, time.Local), time.Date(2024, 6, 30, 0, 0, 0, 0, time.Local)} {
t0 = time.Now()
ar, err := lkArchivePortfolio(ctx, fs, c, d)
if err != nil {
t.Logf("%s архив %s: ERR %v", c, d.Format("02.01.2006"), err)
continue
}
t.Logf("%s АРХИВ на %s (%v): всего %.2f ₽, бумаги %.2f, деньги %.2f, бал.стоимость %.2f, P&L %.2f, позиций %d (с котировкой %d)", c, ar.AsOf, time.Since(t0).Round(time.Millisecond), ar.Total, ar.Securities, ar.CashRUB, ar.Cost, ar.PnL, len(ar.Positions), ar.Priced)
for i, p := range ar.Positions {
if i > 6 {
break
}
t.Logf(" %-14s %-28.28s %-10s %10.2f × бал.%.4g → кот.%.4g (%s) = %.2f ₽ %s", p.ISIN, p.Name, p.Kind, p.Quantity, p.AvgPrice, p.Price, map[bool]string{true: "%", false: "₽"}[p.InPct], p.Value, p.Note)
}
for _, m := range ar.Cash {
t.Logf(" деньги %s %.2f", m.Currency, m.Amount)
}
}
t0 = time.Now()
ops, err := lkArchiveOperations(ctx, fs, c, time.Date(2026, 1, 1, 0, 0, 0, 0, time.Local), time.Now())
if err != nil {
t.Logf("%s операции: ERR %v", c, err)
continue
}
t.Logf("%s ОПЕРАЦИИ с 01.01.2026 (%v): %d", c, time.Since(t0).Round(time.Millisecond), len(ops))
for i, o := range ops {
if i > 8 {
break
}
t.Logf(" %s %-12s %-3s %-28.28s %10.0f × %.4g = %.2f %s (%.2f ₽)", o.Date, o.Kind, o.Side, o.Name, o.Quantity, o.Price, o.Amount, o.Currency, o.AmountRUB)
}
}
}