From 9712b4feda34f013f75e7ae771ace3e9f29c977d Mon Sep 17 00:00:00 2001 From: zuevav Date: Fri, 18 Sep 2026 18:15:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82:=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=BF=D1=83=D1=81=D0=BA=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D0=BC=D1=8B=D0=B5=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prices.go: у выпуска берётся валюта номинала (FaceUnit); «замещающие» и еврооблигации котируются в процентах от валютного номинала, поэтому тело переводится в рубли по курсу, НКД экспорт отдаёт уже в рублях. Без курса оценка не выдумывается - lkfx.go: официальные курсы валют из учётной системы (OD_VALUES_RATES, MARKET=0; DEPO, затем SPECTRE — берём свежее), кэш на час; дата курса показывается под таблицей - БР-719 онлайн: было 17 млн ₽ (валютные считались рублёвыми), стало 522 млн ₽ — сходится с архивом брокерского учёта (НОВАТЭК 001Р-02: 162,65 млн против 162,32 млн на дату среза) - вёрстка: отступ между числовыми колонками (числа больше не слипаются), больше воздуха между показателями в шапке, кнопка «Новое поручение» компактнее, цены от 10 — два знака - структура портфеля: плитки с долей и суммой вместо строки в одну линию; блок денег показывается, только когда валют несколько Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL --- internal/lkgateway/lkfx.go | 91 ++++++++++++++++++++++++++++++++++ internal/lkgateway/lkonline.go | 32 ++++++++++-- internal/lkgateway/prices.go | 24 +++++++-- modules/lk/lk.go | 24 ++++++--- modules/lk/templates.go | 29 +++++++---- 5 files changed, 178 insertions(+), 22 deletions(-) create mode 100644 internal/lkgateway/lkfx.go diff --git a/internal/lkgateway/lkfx.go b/internal/lkgateway/lkfx.go new file mode 100644 index 0000000..45e82c4 --- /dev/null +++ b/internal/lkgateway/lkfx.go @@ -0,0 +1,91 @@ +package lkgateway + +// lkfx.go — курсы валют к рублю для оценки валютных выпусков. +// +// Номинал «замещающих» и еврооблигаций в экспорте QUIK задан в валюте (FaceUnit = USD, EUR, +// CNY), а цена — в процентах от него. Чтобы показать стоимость в рублях, нужен курс. В базе +// экспорта курсов нет (валютный рынок не выгружается), поэтому берём официальный курс из +// учётной системы: OD_VALUES_RATES с MARKET = 0 — сначала DEPO, затем SPECTRE, и берём более +// свежий. Дата курса показывается клиенту: курс может отставать, и выдавать оценку за +// сегодняшнюю нельзя. + +import ( + "context" + "database/sql" + "strings" + "sync" + "time" +) + +// LKFxRate — курс валюты к рублю и дата, на которую он установлен. +type LKFxRate struct { + Rate float64 + At time.Time +} + +var fxCache = struct { + mu sync.Mutex + at time.Time + m map[string]LKFxRate +}{} + +// lkFxRates — курсы валют (ключ — код валюты в верхнем регистре), кэш на час. +func lkFxRates(ctx context.Context, s FancyMSSQLSettings) map[string]LKFxRate { + fxCache.mu.Lock() + if fxCache.m != nil && time.Since(fxCache.at) < time.Hour { + m := fxCache.m + fxCache.mu.Unlock() + return m + } + fxCache.mu.Unlock() + out := map[string]LKFxRate{} + if strings.TrimSpace(s.Server) == "" { + return out + } + for _, db := range []string{s.DepoDB(), s.SpectreDB()} { + conn, err := fancyOpen(s, db) + if err != nil { + continue + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + rows, err := conn.QueryContext(cctx, `SELECT UPPER(LTRIM(RTRIM(v.SYSNAME))), r.OFICDATE, CAST(r.RATE AS float) + FROM OD_VALUES_RATES r JOIN OD_VALUES v ON v.ID = r.VALUE_ID + WHERE v.V_TYPE = 1 AND r.MARKET = 0 AND r.RATE > 0 + AND r.OFICDATE = (SELECT MAX(x.OFICDATE) FROM OD_VALUES_RATES x WHERE x.VALUE_ID = r.VALUE_ID AND x.MARKET = 0 AND x.RATE > 0)`) + if err == nil { + for rows.Next() { + var code string + var at sql.NullTime + var rate float64 + if rows.Scan(&code, &at, &rate) != nil || rate <= 0 { + continue + } + cur := LKFxRate{Rate: rate} + if at.Valid { + cur.At = at.Time + } + // из двух баз берём курс посвежее + if old, ok := out[code]; !ok || cur.At.After(old.At) { + out[code] = cur + } + } + rows.Close() + } + cancel() + conn.Close() + } + fxCache.mu.Lock() + fxCache.m, fxCache.at = out, time.Now() + fxCache.mu.Unlock() + return out +} + +// lkFxFor — курс валюты к рублю: рубль — единица, неизвестная валюта — 0 (оценивать нельзя). +func lkFxFor(rates map[string]LKFxRate, cur string) LKFxRate { + c := strings.ToUpper(strings.TrimSpace(cur)) + switch c { + case "", "SUR", "RUB", "RUR": + return LKFxRate{Rate: 1} + } + return rates[c] +} diff --git a/internal/lkgateway/lkonline.go b/internal/lkgateway/lkonline.go index c70f7e1..f510fda 100644 --- a/internal/lkgateway/lkonline.go +++ b/internal/lkgateway/lkonline.go @@ -11,6 +11,7 @@ import ( "context" "fmt" "sort" + "strconv" "strings" "time" ) @@ -151,6 +152,7 @@ func lkOnlinePortfolio(ctx context.Context, rc *RuntimeConfig, contract string) } } prices := lkPrices(ctx, rc, isins) + fxUsed := map[string]SecurityPrice{} for _, r := range pos { s := secByCode[r.code] @@ -161,10 +163,19 @@ func lkOnlinePortfolio(ctx context.Context, rc *RuntimeConfig, contract string) 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 { + if lp.Value > 0 { + out.Priced++ + } + switch { + case lp.Value <= 0 && px.FaceCur != "": + lp.Note = "номинал в " + px.FaceCur + ": нет курса для оценки" + case px.FX > 0 && px.FX != 1: + lp.Note = "номинал в " + px.FaceCur + fxUsed[px.FaceCur] = px // курс покажем одной строкой под таблицей + } + if r.avg > 0 && lp.Value > 0 { // средняя цена в тех же единицах, что и котировка: для облигаций — процент номинала - cost := SecurityPrice{Price: r.avg, Face: px.Face, InPct: px.InPct} + cost := SecurityPrice{Price: r.avg, Face: px.Face, InPct: px.InPct, FaceCur: px.FaceCur, FX: px.FX} lp.Cost = cost.Value(r.qty) lp.PnL = lp.Value - lp.Cost if lp.Cost != 0 { @@ -203,10 +214,25 @@ func lkOnlinePortfolio(ctx context.Context, rc *RuntimeConfig, contract string) if !asOf.IsZero() { out.AsOf = asOf.Format("02.01.2006") } + if len(fxUsed) > 0 { + var parts []string + for cur, px := range fxUsed { + p := cur + " " + fmtRate(px.FX) + " ₽" + if !px.FXAt.IsZero() { + p += " от " + px.FXAt.Format("02.01.2006") + } + parts = append(parts, p) + } + sort.Strings(parts) + out.Note = "выпуски с валютным номиналом пересчитаны по курсу " + strings.Join(parts, ", ") + } lkPortfolioTotals(out) return out, nil } +// fmtRate — курс валюты с двумя знаками. +func fmtRate(v float64) string { return strconv.FormatFloat(v, 'f', 2, 64) } + // lkPortfolioTotals — итоги и доли. func lkPortfolioTotals(p *LKPortfolio) { p.Securities, p.Cost, p.PnL = 0, 0, 0 diff --git a/internal/lkgateway/prices.go b/internal/lkgateway/prices.go index 70501dc..ed2d0ad 100644 --- a/internal/lkgateway/prices.go +++ b/internal/lkgateway/prices.go @@ -26,6 +26,11 @@ type SecurityPrice struct { Accrued float64 `json:"accrued,omitempty"` // НКД на бумагу InPct bool `json:"in_pct,omitempty"` // цена в процентах от номинала ChangePc float64 `json:"change_pc,omitempty"` // изменение к закрытию, % + // FaceCur — валюта номинала (USD, EUR, CNY…); пусто или SUR — рубли. У «замещающих» + // и еврооблигаций номинал валютный, поэтому тело переводится в рубли по курсу FX. + FaceCur string `json:"face_cur,omitempty"` + FX float64 `json:"fx,omitempty"` // курс валюты номинала к рублю (1 для рублей) + FXAt time.Time `json:"fx_at,omitempty"` // дата курса } // Value — стоимость количества бумаг по этой цене. @@ -34,7 +39,16 @@ func (p SecurityPrice) Value(qty float64) float64 { return 0 } if p.InPct && p.Face > 0 { - return qty*p.Face*p.Price/100 + qty*p.Accrued + fx := p.FX + if fx <= 0 { + if p.FaceCur == "" || strings.EqualFold(p.FaceCur, "SUR") || strings.EqualFold(p.FaceCur, "RUB") { + fx = 1 + } else { + return 0 // валютный номинал без курса: оценку не выдумываем + } + } + // НКД экспорт отдаёт в рублях, тело — в процентах от номинала в валюте выпуска. + return qty*p.Face*p.Price/100*fx + qty*p.Accrued } return qty * p.Price } @@ -71,13 +85,14 @@ func lkPrices(ctx context.Context, rc *RuntimeConfig, isins []string) map[string } cctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() + fxRates := lkFxRates(ctx, rc.Snapshot().Fancy) // для выпусков с валютным номиналом // Одна цена на бумагу: берём класс основных торгов (акции TQBR, ОФЗ TQOB, // корпоративные облигации TQCB, фонды TQTF/TQIF), иначе — класс с торгами. rows, err := p.Query(cctx, ` SELECT DISTINCT ON (s."ISINCode") upper(s."ISINCode"), s."ClassCode", COALESCE(s."SecShortName", s."SecName", ''), COALESCE(p.last, 0)::float8, COALESCE(p.prevprice, 0)::float8, - COALESCE(s."FaceValue", 0)::float8, COALESCE(p.accruedint, 0)::float8 + COALESCE(s."FaceValue", 0)::float8, COALESCE(p.accruedint, 0)::float8, COALESCE(s."FaceUnit", '') FROM "Securities" s JOIN "Params" p ON p."SecCode" = s."SecCode" AND p."ClassCode" = s."ClassCode" WHERE s."ISINCode" <> '' AND (p.last > 0 OR p.prevprice > 0) ORDER BY s."ISINCode", @@ -90,7 +105,7 @@ func lkPrices(ctx context.Context, rc *RuntimeConfig, isins []string) map[string all := map[string]SecurityPrice{} for rows.Next() { var sp SecurityPrice - if rows.Scan(&sp.ISIN, &sp.Board, &sp.Name, &sp.Price, &sp.Prev, &sp.Face, &sp.Accrued) != nil { + if rows.Scan(&sp.ISIN, &sp.Board, &sp.Name, &sp.Price, &sp.Prev, &sp.Face, &sp.Accrued, &sp.FaceCur) != nil { continue } if sp.Price <= 0 { @@ -100,6 +115,9 @@ func lkPrices(ctx context.Context, rc *RuntimeConfig, isins []string) map[string if sp.Prev > 0 && sp.Price > 0 { sp.ChangePc = (sp.Price - sp.Prev) / sp.Prev * 100 } + if fx := lkFxFor(fxRates, sp.FaceCur); fx.Rate > 0 { + sp.FX, sp.FXAt = fx.Rate, fx.At + } all[sp.ISIN] = sp } rows.Close() diff --git a/modules/lk/lk.go b/modules/lk/lk.go index 8703c11..5ce9850 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -94,15 +94,21 @@ func New(cfg Config) (*Server, error) { } return fmtMoney(f) }, - "fmtprice": func(f float64) string { // цена: до 4 знаков, без хвостовых нулей, с пробелами разрядов + "fmtprice": func(f float64) string { // цена: крупные — 2 знака, мелкие — до 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:] + digits := 4 + if f >= 10 || f <= -10 { + digits = 2 + } + str := strconv.FormatFloat(f, 'f', digits, 64) + if strings.Contains(str, ".") { + str = strings.TrimRight(strings.TrimRight(str, "0"), ".") + } + whole, frac := str, "" + if i := strings.IndexByte(str, '.'); i >= 0 { + whole, frac = str[:i], str[i+1:] } neg := strings.HasPrefix(whole, "-") whole = strings.TrimPrefix(whole, "-") @@ -781,6 +787,12 @@ func (s *Server) buildHandler() http.Handler { } data["P"], data["Contract"], data["FullName"] = out.Portfolio, out.Contract, out.FullName data["Structure"], data["Upcoming"] = portfolioStructure(out.Portfolio), out.Upcoming + // Рубли и так видны в шапке и структуре — отдельный блок нужен, когда валют несколько. + showCash := len(out.Portfolio.Cash) > 1 + if len(out.Portfolio.Cash) == 1 && out.Portfolio.Cash[0].Currency != "SUR" { + showCash = true + } + data["ShowCash"] = showCash var upTotal float64 for _, u := range out.Upcoming { upTotal += u.Total diff --git a/modules/lk/templates.go b/modules/lk/templates.go index f2dd935..023ec0e 100644 --- a/modules/lk/templates.go +++ b/modules/lk/templates.go @@ -39,18 +39,22 @@ a{color:var(--acc)}a:hover{color:#1e3395} .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)} -.sbar{display:flex;height:14px;border-radius:3px;overflow:hidden;background:var(--line);gap:2px} -.sbar span{display:block;height:100%;min-width:2px} -.slegend{display:flex;gap:18px;flex-wrap:wrap;margin-top:10px;font-size:13.5px} -.slegend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:7px;vertical-align:-1px} +.sbar{display:flex;height:18px;border-radius:3px;overflow:hidden;background:var(--line);gap:2px} +.sbar span{display:block;height:100%;min-width:3px} +.slegend{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px} +.seg{display:flex;flex-direction:column;gap:3px;padding:9px 14px 10px;background:var(--sheet);border:1px solid var(--line-2);border-left-width:3px;border-radius:3px;min-width:150px} +.seg .t{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--ink-2)} +.seg .p{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:19px;font-weight:700;line-height:1.1} +.seg .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:12.5px;color:var(--mut)} +.seg i{display:inline-block;width:9px;height:9px;border-radius:2px} .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{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:0 11px;background:var(--amber);color:var(--ink);text-decoration:none;border:0;border-radius:3px;cursor:pointer;font:inherit;font-size:10px} .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} .board .big{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:40px;font-weight:700;letter-spacing:-.02em;line-height:1;position:relative;display:inline-block} .board .big::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:rgba(0,0,0,.28)} .board-row{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:20px;max-width:1180px;margin:0 auto} -.stats{display:flex;gap:28px;flex-wrap:wrap} +.stats{display:flex;gap:14px 46px;flex-wrap:wrap} .stat{display:flex;flex-direction:column;gap:3px} .stat .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:22px} .imp{background:var(--board);color:#f3f0e7;padding:10px 20px;font-size:13px;display:flex;gap:10px;align-items:center;flex-wrap:wrap} @@ -62,7 +66,7 @@ h1{font-size:23px;margin:0 0 6px;letter-spacing:-.01em}h2{font-size:15px;margin: table{width:100%;border-collapse:collapse;font-size:14px} th{font-family:var(--mono);font-size:10.5px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--mut);text-align:left;padding:0 12px 9px 0;border-bottom:1px solid var(--line-2);white-space:nowrap} td{padding:12px 12px 12px 0;border-bottom:1px solid var(--line);vertical-align:middle} -td.num,th.num{text-align:right;padding-right:0;font-family:var(--mono);font-variant-numeric:tabular-nums} +td.num,th.num{text-align:right;padding-right:0;padding-left:28px;white-space:nowrap;font-family:var(--mono);font-variant-numeric:tabular-nums} tr.grp td{background:#e4e1d8;border-bottom:1px solid var(--line-2);padding:7px 0 7px 8px} .sheet{background:var(--sheet);border:1px solid var(--line-2);padding:20px} .chip{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:3px;font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;background:var(--neu-bg);color:var(--ink-2)} @@ -155,7 +159,7 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
Стоимость портфеля{{if eq .View "archive"}} на {{$p.AsOf}}{{end}}
{{fmtmoney $p.Total}} ₽
-
{{with .FullName}}{{.}} · {{end}}договор {{.Contract}}{{if and (ne .View "archive") $p.AsOf}} · данные на {{$p.AsOf}}{{end}}{{if $p.Note}} · {{$p.Note}}{{end}}
+
{{with .FullName}}{{.}} · {{end}}договор {{.Contract}}{{if and (ne .View "archive") $p.AsOf}} · данные на {{$p.AsOf}}{{end}}
Бумаги{{fmtmoney $p.Securities}} ₽ @@ -176,10 +180,14 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} {{if .Structure}}

Структура портфеля

{{range .Structure}}{{end}}
-
{{range .Structure}}{{.Kind}} {{fmtpct1 .Share}} {{fmtmoney .Value}} ₽{{end}}
+
{{range .Structure}} + {{.Kind}} + {{fmtpct1 .Share}} + {{fmtmoney .Value}} ₽ + {{end}}
{{end}} - {{if $p.Cash}}
+ {{if and .ShowCash $p.Cash}}

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

{{range $p.Cash}}{{fmtmoney .Amount}} {{if eq .Currency "SUR"}}₽{{else}}{{.Currency}}{{end}}{{end}}
{{end}} @@ -198,6 +206,7 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} {{if gt .Share 0.0}}{{fmtpct1 .Share}}{{else}}—{{end}} {{end}}
{{else}}

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

{{end}} + {{with $p.Note}}

{{.}}

{{end}} {{if and (ne .View "archive") .Upcoming}}