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" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql" "sort" "strings" "sync" "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) // Мы читаем копию учёта; она может отставать. Позже её последнего дня данных нет — // показываем портфель на последний день копии и говорим об этом. note := "" if last := spectreAsOf(cctx, conn); !last.IsZero() && day.After(last) { day = last note = "данные брокерского учёта доступны по " + last.Format("02.01.2006") } 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, fsql.Arch01, 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(fsql.Arch02, 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"), Note: note} 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 = strings.TrimSpace(strings.TrimPrefix(out.Note+"; договор закрыт", "; ")) } return out, nil } // spectreAsOf — последний день, за который в копии учёта есть документы (кэш 10 минут). var spectreAsOfCache struct { mu sync.Mutex at time.Time v time.Time } func spectreAsOf(ctx context.Context, conn *sql.DB) time.Time { spectreAsOfCache.mu.Lock() defer spectreAsOfCache.mu.Unlock() if time.Since(spectreAsOfCache.at) < 10*time.Minute { return spectreAsOfCache.v } var d sql.NullTime _ = conn.QueryRowContext(ctx, fsql.Arch03).Scan(&d) v := time.Time{} if d.Valid { v = time.Date(d.Time.Year(), d.Time.Month(), d.Time.Day(), 0, 0, 0, 0, time.UTC) } spectreAsOfCache.at, spectreAsOfCache.v = time.Now(), v return v } 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, fsql.Arch04, 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, fsql.Arch05, 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 — сделки и движения по портфельным счетам договора за период. // Лента операций за период не меняется в течение дня, а год данных из журнала // проводок читается секундами: держим готовый ответ пять минут. var opsCache = struct { mu sync.Mutex m map[string]opsCached }{m: map[string]opsCached{}} type opsCached struct { at time.Time rows []LKOperationRow } func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract string, from, to time.Time) ([]LKOperationRow, error) { key := lkContractNumber(contract) + "|" + from.Format("2006-01-02") + "|" + to.Format("2006-01-02") opsCache.mu.Lock() c, ok := opsCache.m[key] opsCache.mu.Unlock() if ok && time.Since(c.at) < 5*time.Minute { return c.rows, nil } rows, err := lkArchiveOperationsLoad(ctx, s, contract, from, to) if err == nil { opsCache.mu.Lock() opsCache.m[key] = opsCached{at: time.Now(), rows: rows} opsCache.mu.Unlock() } return rows, err } func lkArchiveOperationsLoad(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, fsql.Arch06, 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) // Три обхода журнала проводок независимы, а каждый по году данных идёт секунды: // выполняем их разом, иначе клиент ждёт их сумму. var wg sync.WaitGroup var mu sync.Mutex var cashOut []LKOperationRow var proceeds map[string][2]float64 wg.Add(1) go func() { defer wg.Done() proceeds = spectreProceeds(cctx, conn, c.DOC, fromDay, toEnd) }() wg.Add(1) go func() { defer wg.Done() cashOut = spectreCashOps(cctx, conn, cashIDs, fromDay, toEnd) }() if len(secIDs) > 0 { in := strings.Join(secIDs, ",") // Сторона проводки выбирается заранее двумя ветвями UNION ALL: связка // «JOIN … ON sr.ID = CASE WHEN w.D_REST IN (…)» заставляла SQL Server // разбирать список счетов на каждой строке журнала — год данных читался // 6–7 секунд, сейчас — десятки миллисекунд (оба индекса по D_REST/K_REST // с WIRDATE отрабатывают поиском). wrows, err := conn.QueryContext(cctx, fmt.Sprintf(fsql.Arch07, 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() // При продаже счёт ПОРТФЕЛЬ списывает бумагу по себестоимости; фактическая выручка — // на счёте ВЫРУЧКА. Клиенту показываем именно её. wg.Wait() 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) } } wg.Wait() mu.Lock() out = append(out, cashOut...) mu.Unlock() 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 } // spectreCashOps — пополнения и выводы денег по кассовым счетам договора. func spectreCashOps(ctx context.Context, conn *sql.DB, cashIDs []string, fromDay, toEnd time.Time) []LKOperationRow { if len(cashIDs) == 0 { return nil } var out []LKOperationRow in := strings.Join(cashIDs, ",") crows, err := conn.QueryContext(ctx, fmt.Sprintf(fsql.Arch08, in), sql.Named("f", fromDay), sql.Named("t", toEnd)) if err != nil { return nil } defer crows.Close() 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}) } return out } // 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, fsql.Arch09, 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(fsql.Arch10, 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 }