package lkgateway // security_card.go — карточка ценной бумаги для кабинета клиента: реквизиты выпуска из // справочника депозитария (OD_VALUES + действующая версия OD_SHARES), график купонов и // оферт (OD_COUPONS, OD_OFFERS), цена из базы экспорта QUIK и позиция клиента. import ( "context" "database/sql" "fmt" "strings" "time" ) // SecurityPayment — событие по бумаге: купон, оферта, погашение. type SecurityPayment struct { Date string `json:"date"` // ДД.ММ.ГГГГ Kind string `json:"kind"` // купон | оферта | погашение Amount float64 `json:"amount,omitempty"` // на одну бумагу Percent float64 `json:"percent,omitempty"` // ставка купона, % годовых FixDate string `json:"fix_date,omitempty"` Paid bool `json:"paid,omitempty"` } // SecurityCard — карточка бумаги. type SecurityCard struct { ISIN string `json:"isin"` Name string `json:"name"` Kind string `json:"kind,omitempty"` // акция | облигация | пай | бумага Issuer string `json:"issuer,omitempty"` // эмитент RegNumber string `json:"reg_number,omitempty"` CFI string `json:"cfi,omitempty"` Face float64 `json:"face,omitempty"` // номинал Maturity string `json:"maturity,omitempty"` // дата погашения Accepted bool `json:"accepted"` // принята на обслуживание QualOnly bool `json:"qual_only,omitempty"` // только для квалифицированных инвесторов QualNote string `json:"qual_note,omitempty"` Price float64 `json:"price,omitempty"` PriceIn string `json:"price_in,omitempty"` // «₽ за штуку» | «% от номинала» Board string `json:"board,omitempty"` // класс торгов ChangePct float64 `json:"change_pct,omitempty"` // изменение к закрытию Position *ClientPosition `json:"position,omitempty"` Payments []SecurityPayment `json:"payments,omitempty"` Note string `json:"note,omitempty"` // почему чего-то нет } // securityCard — собирает карточку. Позиция клиента передаётся снаружи (её уже знает // обработчик портфеля), справочные данные читаются из учётной системы. func securityCard(ctx context.Context, rc *RuntimeConfig, isin string, pos *ClientPosition) (SecurityCard, error) { isin = strings.ToUpper(strings.TrimSpace(isin)) card := SecurityCard{ISIN: isin, Position: pos} if pos != nil { card.Name = pos.SecurityName } if isin == "" { return card, fmt.Errorf("не указана бумага") } if px := lkPrices(ctx, rc, []string{isin}); len(px) > 0 { p := px[isin] card.Price, card.Board, card.ChangePct = p.Price, p.Board, p.ChangePc card.PriceIn = "₽ за штуку" if p.InPct { card.PriceIn = "% от номинала" } if card.Name == "" { card.Name = p.Name } } fs := rc.Snapshot().Fancy if strings.TrimSpace(fs.Server) == "" { card.Note = "справочник депозитария недоступен" return card, nil } db, dctx, cancel, errS := depoOpen(fs) if errS != "" { card.Note = errS return card, nil } defer cancel() defer db.Close() _ = dctx var valID int64 var name, cfi string var vType, isIn sql.NullInt64 err := db.QueryRowContext(dctx, `SELECT TOP 1 ID, ISNULL(NAME,''), ISNULL(CFI,''), V_TYPE, IS_IN FROM OD_VALUES WHERE ISIN=@p1 ORDER BY ID`, sql.Named("p1", isin)).Scan(&valID, &name, &cfi, &vType, &isIn) if err != nil { card.Note = "бумаги нет в справочнике депозитария" return card, nil } if name != "" { card.Name = name } card.CFI, card.Accepted = cfi, isIn.Valid && isIn.Int64 == 1 card.Kind = securityKind(cfi) var issuer sql.NullInt64 var nominal sql.NullFloat64 var regNum sql.NullString var death sql.NullTime err = db.QueryRowContext(dctx, `SELECT TOP 1 ISSUER, CAST(NOMINAL AS float), NUM_REG, DEATHDATE FROM OD_SHARES WHERE ID=@p1 AND E_DATE > GETDATE() ORDER BY B_DATE DESC`, sql.Named("p1", valID)).Scan(&issuer, &nominal, ®Num, &death) if err == nil { card.Face = nominal.Float64 card.RegNumber = strings.TrimSpace(regNum.String) if death.Valid && death.Time.Year() < 9000 { card.Maturity = death.Time.Format("02.01.2006") } if issuer.Valid && issuer.Int64 > 0 { var iname string if db.QueryRowContext(dctx, `SELECT TOP 1 ISNULL(NAME,'') FROM OD_FACES WHERE ID=@p1 OR SELF_ID=@p1 ORDER BY ISNULL(LAST_FLAG,0) DESC, ID DESC`, sql.Named("p1", issuer.Int64)).Scan(&iname) == nil { card.Issuer = strings.TrimSpace(iname) } } } card.QualOnly, card.QualNote = depoSecurityQualOnly(dctx, db, valID) // График: купоны за полгода назад и вперёд, ближайшие оферты, погашение. // Дата выплаты купона: P_DATE, а пока купон не выплачен — конец купонного периода. rows, err := db.QueryContext(dctx, `SELECT TOP 12 ISNULL(P_DATE, E_DATE) AS PAY_DATE, CAST(ISNULL(SUMMA,0) AS float), CAST(ISNULL(PERCENT_,0) AS float), FIX_DATE, ISNULL(IS_PAY,0) FROM OD_COUPONS WHERE SHARE=@p1 AND ISNULL(P_DATE, E_DATE) >= DATEADD(month,-3,GETDATE()) ORDER BY PAY_DATE`, sql.Named("p1", valID)) if err == nil { for rows.Next() { var pd, fx sql.NullTime var summa, pct float64 var isPay sql.NullInt64 if rows.Scan(&pd, &summa, &pct, &fx, &isPay) != nil || !pd.Valid { continue } p := SecurityPayment{Date: pd.Time.Format("02.01.2006"), Kind: "купон", Amount: summa, Percent: pct, Paid: pd.Time.Before(time.Now())} if fx.Valid && fx.Time.Year() < 9000 { p.FixDate = fx.Time.Format("02.01.2006") } card.Payments = append(card.Payments, p) } rows.Close() } orows, err := db.QueryContext(dctx, `SELECT TOP 3 P_DATE, CAST(ISNULL(O_PRICE,0) AS float) FROM OD_OFFERS WHERE SHARE=@p1 AND CANCEL_DATE IS NULL AND P_DATE >= GETDATE() ORDER BY P_DATE`, sql.Named("p1", valID)) if err == nil { for orows.Next() { var pd sql.NullTime var price float64 if orows.Scan(&pd, &price) == nil && pd.Valid { card.Payments = append(card.Payments, SecurityPayment{Date: pd.Time.Format("02.01.2006"), Kind: "оферта", Amount: price}) } } orows.Close() } if card.Maturity != "" { card.Payments = append(card.Payments, SecurityPayment{Date: card.Maturity, Kind: "погашение", Amount: card.Face}) } return card, nil } // securityKind — вид бумаги по коду CFI (первая буква классификации). func securityKind(cfi string) string { if cfi == "" { return "" } switch strings.ToUpper(cfi[:1]) { case "E": return "акция" case "D": return "облигация" case "C": return "пай фонда" case "R": return "право" } return "ценная бумага" }