{"record":{"id":"17a12d0fd2a66366","repo":"we-promise/sure","slug":"missing-or-invalid-t-invest-bond-nominal-for-sym","errorCode":null,"errorMessage":"Missing or invalid T-Invest bond nominal for #{symbol}","messagePattern":"Missing or invalid T-Invest bond nominal for #(.+?)","errorType":"exception","errorClass":"Provider::TinkoffInvest::InvalidSecurityPriceError","httpStatus":null,"severity":"error","filePath":"app/models/provider/tinkoff_invest.rb","lineNumber":150,"sourceCode":"  def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:)\n    with_provider_response do\n      short = resolve_short(symbol, exchange_operating_mic)\n      raise Error, \"Unknown T-Invest instrument: #{symbol}\" if short.nil?\n\n      uid = short[\"uid\"]\n      bond = short[\"instrumentType\"].to_s == \"bond\"\n      currency = short[\"currency\"].to_s.upcase\n      mic = mic_for(short[\"classCode\"], short[\"exchange\"])\n\n      # Bonds quote in % of par; multiply by nominal to get a money price. A\n      # missing/invalid nominal is a provider-data failure, not a zero price.\n      nominal = nil\n      amortizing = false\n      if bond\n        info = bond_info(uid)\n        nominal = info[:nominal]\n        amortizing = info[:amortizing]\n        raise InvalidSecurityPriceError, \"Missing or invalid T-Invest bond nominal for #{symbol}\" if nominal.nil? || nominal <= 0\n      end\n\n      ticker = short[\"ticker\"].to_s\n      build = ->(date, raw) { Price.new(symbol: ticker, date: date, price: (bond ? (raw / 100) * nominal : raw), currency: currency, exchange_operating_mic: mic) }\n\n      # BondBy returns only the CURRENT nominal. For an amortizing bond the par\n      # shrinks over time, so applying today's nominal to historical percent-of-\n      # par closes would underprice them — skip the candle history and return\n      # just the live price. Fixed-par bonds and equities use full history.\n      prices = (bond && amortizing) ? [] : candle_closes(uid, start_date, end_date).map { |date, close| build.call(date, close) }\n\n      # The candle endpoint lags the live session; append the last price for a\n      # range reaching today.\n      if end_date >= Date.current\n        last = last_price(uid)\n        if last\n          prices.reject! { |p| p.date == Date.current }\n          prices << build.call(Date.current, last)","sourceCodeStart":132,"sourceCodeEnd":168,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/tinkoff_invest.rb#L132-L168","documentation":"For bonds, T-Invest quotes prices as percent of par, so the client multiplies by the nominal fetched from InstrumentsService/BondBy (quotation_to_d on ins['nominal']). If BondBy returns no nominal or a non-positive value, building a money price is impossible; the code raises InvalidSecurityPriceError instead of producing a wrong (zero/par-less) price, per the comment: a missing nominal is a provider-data failure, not a zero price. The amortization flag from the same call only affects whether history candles are used.","triggerScenarios":"fetch_security_prices/fetch_security_price for a bond whose BondBy payload lacks 'nominal' (null/zero/negative quotation) -- e.g. a newly issued bond before metadata is populated, an odd lot/off-board listing, or an upstream schema gap for certain bond types (e.g. OFZ with unusual par handling).","commonSituations":"New bond issues synced before T-Invest populates BondBy metadata; regional/corporate bonds with sparse metadata; upstream API change renaming the nominal field so quotation_to_d gets nil.","solutions":["Retry later -- missing metadata for new issues usually fills in; the instrument cache TTL will refresh BondBy data","Skip the bond from pricing and flag it (treat as provider-data gap) rather than retrying every sync","If it affects many bonds, capture the raw BondBy body in a debug log and check whether T-Invest renamed/omitted 'nominal' (e.g. use GetInstrumentBy fields as a fallback source for par)"],"exampleFix":"# before\nbegin\n  price = provider.fetch_security_price(symbol: isin, exchange_operating_mic: mic, date: date)\nrescue => e\n  raise\nend\n\n# after\nbegin\n  price = provider.fetch_security_price(symbol: isin, exchange_operating_mic: mic, date: date)\nrescue Provider::TinkoffInvest::InvalidSecurityPriceError => e\n  security.update!(price_sync_failed_at: Time.current, price_sync_error: e.message)\n  Rails.logger.warn(\"Skipping bond pricing, provider data gap: #{e.message}\")\nend","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"begin\n  price = provider.fetch_security_price(symbol:, exchange_operating_mic:, date:)\nrescue Provider::TinkoffInvest::InvalidSecurityPriceError => e\n  security.update!(price_sync_error: e.message) # provider-data gap for this bond; skip, don't retry-loop\nend","preventionTips":["Treat bond-nominal failures as data gaps: back off (hours/days), not seconds","Alert when the same bond fails repeatedly so support can escalate missing metadata to the provider"],"tags":["tinkoff-invest","t-invest","bond","nominal","price-data","provider-data"],"backgroundTag":"invalid-provider-data","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}