diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index 06c79394a..d0e7c72cb 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -17,8 +17,9 @@ jobs: persist-credentials: false - name: Pipelock Scan - uses: luckyPipewrench/pipelock@cef4f47eb99ffe00e20fa7d1423bff1a44742dbe # v2.4.0 + uses: luckyPipewrench/pipelock@818ca0a7af4dbcd56ada7fa57e2dc32f9e799e34 # v2.8.0 with: + version: '2.8.0' scan-diff: 'true' fail-on-findings: 'true' test-vectors: 'false' diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index f1631ec1c..f69e6f4be 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -310,23 +310,15 @@ class Api::V1::BaseController < ApplicationController # Set up Current context for API requests since we don't use session-based auth def setup_current_context_for_api - # For API requests, we need to create a minimal session-like object - # or find/create an actual session for this user to make Current.user work - if @current_user - # Try to find an existing session for this user, or create a temporary one - session = @current_user.sessions.first - if session - Current.session = session - else - # Create a temporary session for this API request - # This won't be persisted but will allow Current.user to work - session = @current_user.sessions.build( - user_agent: request.user_agent, - ip_address: request.ip - ) - Current.session = session - end - end + return unless @current_user + + # Build a fresh unsaved session so API requests never reuse an existing + # web session that may already carry impersonation state. + Current.session = @current_user.sessions.build( + user_agent: request.user_agent, + ip_address: request.ip + ) + Current.session.active_impersonator_session = nil end # Check if AI features are enabled for the current user diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index d1b76204e..f1159b28c 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -1060,14 +1060,18 @@ class ReportsController < ApplicationController return false end - # Find or create a session for this API request - # We need to find or create a persisted session so that Current.user delegation works properly - session = @current_user.sessions.first_or_create!( + unless @current_user.active? + render plain: "Invalid or expired API key", status: :unauthorized + return false + end + + # Build a fresh unsaved session so API key exports never reuse an existing + # web session that may already carry impersonation state. + Current.session = @current_user.sessions.build( user_agent: request.user_agent, ip_address: request.ip ) - - Current.session = session + Current.session.active_impersonator_session = nil # Verify the delegation chain works unless Current.user diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 6db240b89..11f0b1c72 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -31,6 +31,10 @@ class Settings::HostingsController < ApplicationController @show_tiingo_settings = enabled_securities.include?("tiingo") @show_eodhd_settings = enabled_securities.include?("eodhd") @show_alpha_vantage_settings = enabled_securities.include?("alpha_vantage") + # T-Invest doubles as a brand-logo source consulted regardless of the price + # provider, so its token is useful even when it's not enabled for prices. + # Always surface the token field, decoupled from the securities checklist. + @show_tinkoff_invest_settings = true # Only fetch provider data if we're showing the section if @show_twelve_data_settings @@ -110,6 +114,7 @@ class Settings::HostingsController < ApplicationController update_encrypted_setting(:tiingo_api_key) update_encrypted_setting(:eodhd_api_key) update_encrypted_setting(:alpha_vantage_api_key) + update_encrypted_setting(:tinkoff_invest_api_key) if hosting_params.key?(:syncs_include_pending) Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1" @@ -274,7 +279,7 @@ class Settings::HostingsController < ApplicationController private def hosting_params return ActionController::Parameters.new unless params.key?(:setting) - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :tiingo_api_key, :eodhd_api_key, :alpha_vantage_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :anthropic_access_token, :anthropic_base_url, :anthropic_model, :llm_provider, :llm_context_window, :llm_max_response_tokens, :llm_max_items_per_call, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id, securities_providers: []) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :tiingo_api_key, :eodhd_api_key, :alpha_vantage_api_key, :tinkoff_invest_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :anthropic_access_token, :anthropic_base_url, :anthropic_model, :llm_provider, :llm_context_window, :llm_max_response_tokens, :llm_max_items_per_call, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id, securities_providers: []) end def update_assistant_type diff --git a/app/models/coinstats_item/importer.rb b/app/models/coinstats_item/importer.rb index 2a5f77b32..b8fec5209 100644 --- a/app/models/coinstats_item/importer.rb +++ b/app/models/coinstats_item/importer.rb @@ -31,6 +31,7 @@ class CoinstatsItem::Importer linked_accounts = coinstats_item.coinstats_accounts .joins(:account_provider) .includes(:account) + .order(:created_at, :id) if linked_accounts.empty? Rails.logger.info "CoinstatsItem::Importer - No linked accounts to sync for item #{coinstats_item.id}" diff --git a/app/models/provider/moex_public.rb b/app/models/provider/moex_public.rb index d96c562a9..2a69ccc39 100644 --- a/app/models/provider/moex_public.rb +++ b/app/models/provider/moex_public.rb @@ -113,7 +113,10 @@ class Provider::MoexPublic < Provider SecurityInfo.new( symbol: instrument[:secid], name: instrument[:name], - links: "https://www.moex.com/en/issue.aspx?code=#{instrument[:secid]}", + # Deliberately no website: moex.com is the exchange, not the issuer, so + # persisting it as website_url makes Brandfetch render the exchange logo + # for every instrument and shadows the real per-issuer brand logo. + links: nil, logo_url: nil, description: nil, kind: instrument[:kind], diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index 1c4947db4..ebe3d615b 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -144,6 +144,14 @@ class Provider::Registry def moex_public Provider::MoexPublic.new end + + def tinkoff_invest + api_key = ENV["TINKOFF_INVEST_API_KEY"].presence || Setting.tinkoff_invest_api_key # pipelock:ignore + + return nil unless api_key.present? + + Provider::TinkoffInvest.new(api_key) + end end def initialize(concept) @@ -176,7 +184,7 @@ class Provider::Registry when :exchange_rates %i[twelve_data yahoo_finance moex_public] when :securities - %i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public moex_public] + %i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public moex_public tinkoff_invest] when :llm %i[openai anthropic] else diff --git a/app/models/provider/tinkoff_invest.rb b/app/models/provider/tinkoff_invest.rb new file mode 100644 index 000000000..9f1ba03b6 --- /dev/null +++ b/app/models/provider/tinkoff_invest.rb @@ -0,0 +1,345 @@ +# T-Bank (Tinkoff) Invest API securities provider, built on the token-based REST +# gateway (https://invest-public-api.tinkoff.ru/rest) over the public gRPC +# contract. Mirrors the keyed providers (Tiingo/TwelveData) for auth and the +# keyless market providers (MoexPublic/BinancePublic) for SslConfigurable + +# RateLimitable plumbing. +# +# Why it exists: ISS (MoexPublic) prices Russian instruments but carries no +# logos. T-Invest is the authoritative source of instrument *brand logos* +# (shares, ETF/БПИФ, bonds) via its CDN, and can also serve prices for those +# instruments when MOEX is not the selected provider. The app therefore uses it +# two ways: +# 1. As a securities price/search provider (when enabled in settings). +# 2. As a brand-logo source consulted by Security#import_provider_details +# regardless of which provider prices the security (see Security::Provided), +# so MOEX-priced holdings still get real logos. +# +# REST shape: every call is POST to +# /tinkoff.public.invest.api.contract.v1./ +# with a JSON body, Bearer-token auth, and a JSON response. Money is encoded as +# Quotation { units: "", nano: } => units + nano/1e9. +class Provider::TinkoffInvest < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # T-Invest unary REST limits are generous (hundreds/min); space calls lightly. + MIN_REQUEST_INTERVAL = 0.1 + + # gRPC-over-REST service path prefix. + API_NS = "tinkoff.public.invest.api.contract.v1".freeze + + # Brand-logo CDN. The API returns brand.logoName like "SBER.png"; the image is + # served at /.png — sizes x160/x320/x640. + LOGO_CDN = "https://invest-brands.cdn-tinkoff.ru".freeze + LOGO_SIZE = "x160".freeze + + # ISO 10383 operating MICs we surface. T-Invest mostly covers MOEX; SPB Exchange + # instruments map to XSPX. Anything else stays nil (resolver wildcard). + MOEX_MIC = "MISX".freeze + SPB_MIC = "XSPX".freeze + + # GetCandles caps the window per request by interval; for daily candles we page + # in ~1-year chunks and bound the loop defensively. + CANDLE_CHUNK_DAYS = 360 + MAX_CANDLE_PAGES = 60 + + INSTRUMENT_CACHE_TTL = 24.hours + SEARCH_CACHE_TTL = 5.minutes + + def initialize(api_key) + @api_key = api_key # pipelock:ignore + end + + def healthy? + with_provider_response do + post("InstrumentsService", "FindInstrument", query: "SBER", instrumentKind: "INSTRUMENT_TYPE_SHARE") + true + end + end + + def usage + with_provider_response do + UsageData.new(used: nil, limit: nil, utilization: nil, plan: "Token (read-only)") + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + query = symbol.to_s.strip + next [] if query.empty? + + find_instruments(query).filter_map do |row| + next nil unless surfaced_kind(row["instrumentType"]) + + Provider::SecurityConcept::Security.new( + symbol: row["ticker"].to_s, + name: (row["name"].presence || row["ticker"]).to_s, + logo_url: nil, # FindInstrument carries no brand; logos come from #fetch_security_info + exchange_operating_mic: mic_for(row["classCode"], row["exchange"]), + country_code: row["countryOfRisk"].presence, + currency: row["currency"].to_s.upcase.presence + ) + end.uniq { |s| [ s.symbol, s.exchange_operating_mic ] } + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + short = resolve_short(symbol, exchange_operating_mic) + raise Error, "Unknown T-Invest instrument: #{symbol}" if short.nil? + + detail = instrument_detail(short["uid"]) + + SecurityInfo.new( + symbol: short["ticker"].to_s, + name: (detail["name"].presence || short["name"]).to_s, + links: nil, # T-Invest exposes no issuer website + logo_url: logo_url(detail.dig("brand", "logoName")), + description: nil, + kind: surfaced_kind(short["instrumentType"]), + exchange_operating_mic: mic_for(short["classCode"], detail["exchange"]) + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic:, date:) + with_provider_response do + historical = fetch_security_prices( + symbol: symbol, + exchange_operating_mic: exchange_operating_mic, + start_date: date, + end_date: date + ) + + raise historical.error if historical.error.present? + raise InvalidSecurityPriceError, "No T-Invest price for #{symbol} on #{date}" if historical.data.blank? + + historical.data.find { |p| p.date == date } || + historical.data.select { |p| p.date <= date }.max_by(&:date) || + historical.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) + with_provider_response do + short = resolve_short(symbol, exchange_operating_mic) + raise Error, "Unknown T-Invest instrument: #{symbol}" if short.nil? + + uid = short["uid"] + bond = short["instrumentType"].to_s == "bond" + currency = short["currency"].to_s.upcase + mic = mic_for(short["classCode"], short["exchange"]) + # Bonds quote in % of par; multiply by nominal to get a money price. A + # missing/invalid nominal is a provider-data failure, not a zero price. + nominal = bond ? instrument_nominal(uid) : nil + if bond && (nominal.nil? || nominal <= 0) + raise InvalidSecurityPriceError, "Missing or invalid T-Invest bond nominal for #{symbol}" + end + + prices = candle_closes(uid, start_date, end_date).map do |date, close| + value = bond ? (close / 100) * nominal : close + Price.new(symbol: short["ticker"].to_s, date: date, price: value, currency: currency, exchange_operating_mic: mic) + end + + # The candle endpoint lags the live session; append the last price for a + # range reaching today. + if end_date >= Date.current + last = last_price(uid) + if last + value = bond ? (last / 100) * nominal : last + prices.reject! { |p| p.date == Date.current } + prices << Price.new(symbol: short["ticker"].to_s, date: Date.current, price: value, currency: currency, exchange_operating_mic: mic) + end + end + + prices.sort_by(&:date) + end + end + + def max_history_days + nil # GetCandles serves the instrument's full daily history (paged). + end + + private + + attr_reader :api_key + + # ================================ + # HTTP / parsing + # ================================ + + def base_url + ENV["TINKOFF_INVEST_URL"].presence || "https://invest-public-api.tinkoff.ru/rest" + end + + def post(service, method, body) + throttle_request + response = client.post("#{base_url}/#{API_NS}.#{service}/#{method}") do |req| + req.body = body.to_json + end + JSON.parse(response.body) + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.options.open_timeout = 5 + faraday.options.timeout = 20 + + faraday.request(:retry, { + max: 3, + interval: 0.5, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.headers["Authorization"] = "Bearer #{api_key}" + faraday.headers["Content-Type"] = "application/json" + faraday.headers["Accept"] = "application/json" + faraday.response :raise_error + end + end + + # Quotation { units, nano } -> BigDecimal. Absent/blank -> nil. + def quotation_to_d(q) + return nil if q.blank? + units = BigDecimal(q["units"].to_s.presence || "0") + nano = BigDecimal(q["nano"].to_s.presence || "0") + units + (nano / BigDecimal("1000000000")) + end + + # ================================ + # Instruments + # ================================ + + def find_instruments(query) + Rails.cache.fetch("tinkoff_invest:find:#{query.downcase}", expires_in: SEARCH_CACHE_TTL) do + body = post("InstrumentsService", "FindInstrument", query: query, apiTradeAvailableFlag: false) + body["instruments"] || [] + end + end + + # Best FindInstrument hit for a ticker/ISIN: exact ticker (or ISIN) match, + # preferring a requested MIC, then a surfaced kind, then any result. + def resolve_short(symbol, exchange_operating_mic) + key = symbol.to_s.strip + Rails.cache.fetch("tinkoff_invest:short:#{key.downcase}:#{exchange_operating_mic}", expires_in: INSTRUMENT_CACHE_TTL) do + rows = find_instruments(key) + up = key.upcase + + exact = rows.select { |r| r["ticker"].to_s.upcase == up || r["isin"].to_s.upcase == up } + pool = exact.any? ? exact : rows + pool = pool.select { |r| surfaced_kind(r["instrumentType"]) } if pool.any? { |r| surfaced_kind(r["instrumentType"]) } + + if exchange_operating_mic.present? + preferred = pool.find { |r| mic_for(r["classCode"], r["exchange"]) == exchange_operating_mic.upcase } + preferred || pool.first + else + pool.first + end + end + end + + # GetInstrumentBy(uid) — full instrument incl. brand/logo, exchange, nominal. + def instrument_detail(uid) + Rails.cache.fetch("tinkoff_invest:detail:#{uid}", expires_in: INSTRUMENT_CACHE_TTL) do + body = post("InstrumentsService", "GetInstrumentBy", idType: "INSTRUMENT_ID_TYPE_UID", id: uid) + body["instrument"] || {} + end + end + + def instrument_nominal(uid) + quotation_to_d(instrument_detail(uid)["nominal"]) + end + + # ================================ + # Prices + # ================================ + + def candle_closes(uid, start_date, end_date) + return [] if start_date > end_date + + closes = {} + window_start = start_date + pages = 0 + + while window_start <= end_date && pages < MAX_CANDLE_PAGES + window_end = [ window_start + CANDLE_CHUNK_DAYS, end_date ].min + body = post( + "MarketDataService", "GetCandles", + instrumentId: uid, + interval: "CANDLE_INTERVAL_DAY", + from: "#{window_start}T00:00:00Z", + to: "#{window_end}T23:59:59Z" + ) + + (body["candles"] || []).each do |c| + next unless c["isComplete"] + date = parse_time(c["time"]) + close = quotation_to_d(c["close"]) + closes[date] = close if date && close && close > 0 + end + + pages += 1 + window_start = window_end + 1 + end + + closes.sort.to_h.map { |date, close| [ date, close ] } + end + + def last_price(uid) + body = post("MarketDataService", "GetLastPrices", instrumentId: [ uid ]) + row = (body["lastPrices"] || []).first + return nil unless row + value = quotation_to_d(row["price"]) + value if value && value > 0 + end + + # ================================ + # Helpers + # ================================ + + def logo_url(logo_name) + return nil if logo_name.blank? + "#{LOGO_CDN}/#{logo_name.to_s.sub(/\.png\z/i, '')}#{LOGO_SIZE}.png" + end + + # T-Invest instrumentType -> the security kinds we surface (skip futures, + # currencies, options, etc.). + def surfaced_kind(instrument_type) + case instrument_type.to_s.downcase + when "share" then "stock" + when "etf" then "fund" + when "bond" then "bond" + end + end + + def mic_for(class_code, exchange) + ex = exchange.to_s.downcase + return MOEX_MIC if class_code.to_s.start_with?("TQ") || ex.include?("moex") + return SPB_MIC if ex.include?("spb") + nil + end + + def parse_time(raw) + return nil if raw.blank? + Date.parse(raw.to_s) + rescue Date::Error + nil + end + + # Preserve TinkoffInvest::Error subclasses through with_provider_response, + # mirroring MoexPublic/BinancePublic. + def default_error_transformer(error) + return error if error.is_a?(self.class::Error) + super + end +end diff --git a/app/models/security.rb b/app/models/security.rb index 40ae6b969..46ffd4f12 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -87,15 +87,20 @@ class Security < ApplicationRecord nil end - # Single source of truth for which logo URL the UI should render. Crypto - # and stocks share the same shape: prefer a freshly computed Brandfetch - # URL (honors current client_id + size) and fall back to any stored - # logo_url for the provider-returns-its-own-URL case (e.g. Tiingo S3). + # Single source of truth for which logo URL the UI should render. + # - Crypto keeps its dedicated Brandfetch-crypto shape. + # - When a website domain is known, Brandfetch (consistent client_id + size) + # wins, falling back to any stored logo_url. + # - With no domain, a stored provider logo (e.g. T-Invest's CDN for MOEX + # instruments) is authoritative and beats the ticker-only Brandfetch + # lettermark placeholder. def display_logo_url if crypto? self.class.brandfetch_crypto_url(crypto_base_asset).presence || logo_url.presence - else + elsif website_url.present? brandfetch_icon_url.presence || logo_url.presence + else + logo_url.presence || brandfetch_icon_url.presence end end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 1d7805422..71d517aac 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -221,38 +221,80 @@ module Security::Provided return end - if self.name.present? && (self.logo_url.present? || self.website_url.present?) && !clear_cache - return - end - - response = price_data_provider.fetch_security_info( - symbol: ticker, - exchange_operating_mic: exchange_operating_mic - ) - - if response.success? - # Only overwrite fields the provider actually returned, so providers that - # don't support metadata (e.g. Alpha Vantage) won't blank existing values. - attrs = {} - attrs[:name] = response.data.name if response.data.name.present? - attrs[:logo_url] = response.data.logo_url if response.data.logo_url.present? - attrs[:website_url] = response.data.links if response.data.links.present? - update(attrs) if attrs.any? - else - Rails.logger.warn("Failed to fetch security info for #{ticker} from #{price_data_provider.class.name}: #{response.error.message}") - DebugLogEntry.capture( - category: "security_metadata_fetch", - level: "warn", - message: "Failed to get security info", - source: self.class.name, - provider: price_data_provider, - metadata: { - security_id: self.id, - ticker: ticker, - provider_error: response.error.message - } + # Price-provider metadata (name/website/logo). Skip the refetch only when we + # already have a name plus a logo or website. This gate must be evaluated + # before any brand-logo enrichment, otherwise setting logo_url first would + # trip it and skip website_url backfill from providers that return links. + unless self.name.present? && (self.logo_url.present? || self.website_url.present?) && !clear_cache + response = price_data_provider.fetch_security_info( + symbol: ticker, + exchange_operating_mic: exchange_operating_mic ) + + if response.success? + # Only overwrite fields the provider actually returned, so providers that + # don't support metadata (e.g. Alpha Vantage) won't blank existing values. + attrs = {} + attrs[:name] = response.data.name if response.data.name.present? + attrs[:logo_url] = response.data.logo_url if response.data.logo_url.present? + attrs[:website_url] = response.data.links if response.data.links.present? + update(attrs) if attrs.any? + else + Rails.logger.warn("Failed to fetch security info for #{ticker} from #{price_data_provider.class.name}: #{response.error.message}") + DebugLogEntry.capture( + category: "security_metadata_fetch", + level: "warn", + message: "Failed to get security info", + source: self.class.name, + provider: price_data_provider, + metadata: { + security_id: self.id, + ticker: ticker, + provider_error: response.error.message + } + ) + end end + + # Brand logo, sourced independently of the price provider, so a security + # priced by a logo-less provider (e.g. MOEX/ISS) still gets a real logo. + # Runs after metadata so it never short-circuits website_url backfill. + import_brand_logo(clear_cache: clear_cache) + end + + # Enriches the security with a brand logo from a dedicated logo provider + # (T-Invest), independent of which provider supplies prices. No-op when the + # logo is already set, for crypto (own logo path), or when no logo provider / + # token is configured. Runs every details import while the logo is missing. + def import_brand_logo(clear_cache: false) + return if crypto? + return if logo_url.present? && !clear_cache + + provider = brand_logo_provider + return unless provider + + response = provider.fetch_security_info(symbol: ticker, exchange_operating_mic: exchange_operating_mic) + return unless response.success? && response.data.logo_url.present? + + update(logo_url: response.data.logo_url) + rescue => e + DebugLogEntry.capture( + category: "security_metadata_fetch", + level: "warn", + message: "Brand logo fetch failed", + source: self.class.name, + provider: brand_logo_provider, + metadata: { security_id: self.id, ticker: ticker, provider_error: e.message } + ) + end + + # The provider consulted for brand logos regardless of price provider. Returns + # nil unless a T-Invest token is configured. Uses the class-level lookup so it + # doesn't depend on (or interfere with) per-instance Registry expectations. + def brand_logo_provider + Provider::Registry.get_provider(:tinkoff_invest) + rescue Provider::Registry::Error + nil end def import_provider_prices(start_date:, end_date:, clear_cache: false) diff --git a/app/models/setting.rb b/app/models/setting.rb index e6879ed8d..142e60318 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -59,6 +59,7 @@ class Setting < RailsSettings::Base field :tiingo_api_key, type: :string, default: ENV["TIINGO_API_KEY"] field :eodhd_api_key, type: :string, default: ENV["EODHD_API_KEY"] field :alpha_vantage_api_key, type: :string, default: ENV["ALPHA_VANTAGE_API_KEY"] + field :tinkoff_invest_api_key, type: :string, default: ENV["TINKOFF_INVEST_API_KEY"] # Transparent encryption for API key fields. The `field` macro defines the # raw getter/setter on the class. By prepending this module we intercept @@ -73,6 +74,7 @@ class Setting < RailsSettings::Base tiingo_api_key eodhd_api_key alpha_vantage_api_key + tinkoff_invest_api_key openai_access_token anthropic_access_token external_assistant_token diff --git a/app/models/trade/create_form.rb b/app/models/trade/create_form.rb index 37cf3a44e..ce4a984db 100644 --- a/app/models/trade/create_form.rb +++ b/app/models/trade/create_form.rb @@ -37,11 +37,19 @@ class Trade::CreateForm end def create_trade + sec = security + + unless sec + entry = account.entries.build(entryable: Trade.new) + entry.errors.add(:base, I18n.t("trades.form.trade_requires_security")) + return entry + end + signed_qty = type == "sell" ? -qty.to_d : qty.to_d signed_amount = signed_qty * price.to_d + fee.to_d trade_entry = account.entries.new( - name: Trade.build_name(type, qty, security.ticker), + name: Trade.build_name(type, qty, sec.ticker), date: date, amount: signed_amount, currency: currency, @@ -50,7 +58,7 @@ class Trade::CreateForm price: price, fee: fee.to_d, currency: currency, - security: security, + security: sec, investment_activity_label: type.capitalize # "buy" → "Buy", "sell" → "Sell" ) ) diff --git a/app/views/settings/hostings/_provider_selection.html.erb b/app/views/settings/hostings/_provider_selection.html.erb index 4f91c53ac..f27d377f7 100644 --- a/app/views/settings/hostings/_provider_selection.html.erb +++ b/app/views/settings/hostings/_provider_selection.html.erb @@ -53,6 +53,7 @@ ["mfapi", t(".providers.mfapi"), t(".mfapi_hint")], ["binance_public", t(".providers.binance_public"), t(".binance_public_hint")], ["moex_public", t(".providers.moex_public"), t(".moex_public_hint")], + ["tinkoff_invest", t(".providers.tinkoff_invest"), t(".tinkoff_invest_hint")], ].each do |value, label, hint| %>