Кабинет: верная оценка валютных выпусков и читаемые числа
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user