feat(prices): add Moscow Exchange (MOEX ISS) securities + FX provider (#2394)

* feat(prices): add Moscow Exchange (MOEX ISS) securities + FX provider

Add Provider::MoexPublic, a keyless provider built on the free MOEX ISS API
(https://iss.moex.com/iss), modeled on Provider::BinancePublic.

Securities: shares, funds/ETF/БПИФ (e.g. LQDT), and bonds (OFZ + corporate).
Bonds are priced clean — LAST% × FACEVALUE / 100 in the instrument currency,
with per-row FACEVALUE for amortizing issues; NKD/accrued coupon excluded.

Exchange rates: also implements ExchangeRateConcept for RUB↔{USD,EUR,CNY} via
selt TOM instruments (USD000UTSTOM/EUR_RUB__TOM/CNYRUB_TOM); the selt quote is
X/RUB, inverted for RUB→X, nil for non-RUB-crossed pairs.

Details:
- Board/engine resolution via the ISS primary-board flag with a hardcoded
  priority fallback (TQBR, TQTF, TQOB, TQCB, …).
- Instrument currency from CURRENCYID/FACEUNIT (handles USD/CNY eurobonds &
  FX funds), normalizing legacy SUR/RUR → RUB; default RUB.
- Full history via from/till + start= pagination; current price fallback chain
  LAST → MARKETPRICE → LCURRENTPRICE → LCLOSEPRICE → PREVPRICE → latest history
  close.
- Bare SECID identity, exchange_operating_mic=MISX, country_code=nil (wildcard
  like Binance); search accepts .ME/.MOEX/.MISX/.MCX aliases and ISIN.
- RateLimitable throttling, SslConfigurable, Faraday retry/timeouts; all public
  methods wrapped in with_provider_response.

Wired into Provider::Registry for both :securities and :exchange_rates, the
hosting provider-selection UI, locales, and config/exchanges.yml (MISX).

Docker-tested (devcontainer, Ruby 3.4.9): 29 new tests green, full provider
suite + i18n green, rubocop clean; smoke-tested against live ISS (SBER price,
OFZ clean price, USD/RUB FX).

* fix(moex): address review — FX weekend lookback, dead branch, translated hints

- fetch_exchange_rate now fetches a 10-day lookback window (not just the exact
  day) so a weekend/holiday request resolves to the prior trading day's close,
  matching Yahoo's behavior (Codex P2).
- Remove dead identical if/else branches in history_row_price (CodeRabbit).
- Translate moex_public_hint into ca/fr/hu/vi/zh-CN instead of English copy
  (CodeRabbit).
- Add a test covering the FX prior-trading-day lookback.

* fix(moex): guard ISS date parsing; doc TQTE in board priority

Address maintainer review (jjmata):
- parse_iss_date wraps Date.parse so a malformed ISS TRADEDATE skips just that
  row (with a contextual log warning) instead of failing the whole history/FX
  fetch. Used in history_row_price and fx_history.
- Add TQTE to the BOARD_PRIORITY doc comment (it was in the constant but missing
  from the comment).
- Add a test covering the unparseable-date skip.
This commit is contained in:
Artem Danilov
2026-06-18 11:36:20 +03:00
committed by GitHub
parent 59b2e59a4a
commit b57bb938da
20 changed files with 1110 additions and 3 deletions

View File

@@ -0,0 +1,590 @@
# Moscow Exchange (MOEX) market-data provider built on the free, keyless ISS
# API (https://iss.moex.com/iss). Mirrors Provider::BinancePublic: no API key,
# public endpoints only, Faraday client with retry/timeouts, RateLimitable
# throttling, and SslConfigurable for self-hosted CA support.
#
# Covers Russian-market instruments that Yahoo dropped after 2022 — shares,
# funds/ETF/БПИФ (e.g. LQDT), and bonds (OFZ + corporate) — and doubles as an
# exchange-rate provider for RUB↔{USD,EUR,CNY} via the selt (FX) market.
#
# ISS responses are column-array JSON: each block is { "columns" => [...],
# "data" => [[...], ...] }. We index every row by lowercased column name so the
# code tolerates ISS reordering or casing differences across endpoints.
class Provider::MoexPublic < Provider
include SecurityConcept, ExchangeRateConcept, RateLimitable
extend SslConfigurable
Error = Class.new(Provider::Error)
InvalidSecurityPriceError = Class.new(Error)
RateLimitError = Class.new(Error)
# ISS is generous but we still space requests to be a good citizen.
MIN_REQUEST_INTERVAL = 0.15
# Moscow Exchange ISO 10383 operating MIC. Like BinancePublic we intentionally
# do NOT propagate a country code to search results — the resolver treats a nil
# candidate country as a wildcard, so any family resolves a MOEX pick.
MOEX_MIC = "MISX".freeze
# Hardcoded board preference, consulted only when ISS does not flag a primary
# board. Shares (TQBR), ETF/funds (TQTF/TQIF), OFZ (TQOB), corporate/exchange
# bonds (TQCB/TQIR), USD/EUR-settled boards (TQTD/TQOD/TQOE/TQTE), restructured
# (TQRD). Earlier = higher priority.
BOARD_PRIORITY = %w[TQBR TQTF TQIF TQOB TQCB TQIR TQRD TQTD TQOD TQOE TQTE].freeze
# selt FX instruments quoted as roubles per 1 unit of the foreign currency
# (X/RUB). TOM = tomorrow settlement, the liquid benchmark line.
FX_INSTRUMENTS = {
"USD" => "USD000UTSTOM",
"EUR" => "EUR_RUB__TOM",
"CNY" => "CNYRUB_TOM"
}.freeze
# ISS still emits the legacy "SUR"/"RUR" codes for the rouble alongside the
# modern "RUB"; normalize so Price/Rate currencies are ISO 4217.
CURRENCY_ALIASES = { "SUR" => "RUB", "RUR" => "RUB" }.freeze
# Search/MOEX-suffix aliases users paste (Yahoo's ".ME", common ".MOEX"/MIC
# forms). ISIN queries are handled natively by ISS `q=`.
ALIAS_SUFFIX = /\.(ME|MOEX|MISX|MCX)\z/i
# ISS history blocks page at 100 rows; keep a defensive cap so a misbehaving
# endpoint can't loop forever.
HISTORY_PAGE_SIZE = 100
MAX_HISTORY_PAGES = 500
SEARCH_CACHE_TTL = 5.minutes
INSTRUMENT_CACHE_TTL = 24.hours
# When a single FX date falls on a weekend/holiday, look back this many days so
# we can return the most recent prior trading-day quote instead of failing.
FX_RATE_LOOKBACK_DAYS = 10
def initialize
# No API key required — public market data only.
end
def healthy?
with_provider_response do
get_json("/index.json", "iss.meta" => "off")
true
end
end
def usage
with_provider_response do
UsageData.new(used: nil, limit: nil, utilization: nil, plan: "Free (no key required)")
end
end
# ================================
# Securities
# ================================
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
with_provider_response do
query = normalize_query(symbol)
next [] if query.empty?
rows = search_rows(query)
securities = rows.filter_map do |row|
next nil unless row_traded?(row)
next nil if security_kind(row["group"], row["type"]).nil?
Provider::SecurityConcept::Security.new(
symbol: row["secid"].to_s,
name: (row["shortname"].presence || row["secid"]).to_s,
logo_url: nil,
exchange_operating_mic: MOEX_MIC,
country_code: nil,
currency: normalize_currency(row["currencyid"].presence || row["faceunit"])
)
end
securities.uniq(&:symbol)
end
end
def fetch_security_info(symbol:, exchange_operating_mic:)
with_provider_response do
instrument = resolve_instrument(normalize_secid(symbol))
SecurityInfo.new(
symbol: instrument[:secid],
name: instrument[:name],
links: "https://www.moex.com/en/issue.aspx?code=#{instrument[:secid]}",
logo_url: nil,
description: nil,
kind: instrument[:kind],
exchange_operating_mic: MOEX_MIC
)
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 price found for #{symbol} on #{date}" if historical.data.blank?
# Exact date if present, else the nearest available close on or before it.
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
secid = normalize_secid(symbol)
instrument = resolve_instrument(secid)
bond = instrument[:market].to_s.downcase == "bonds"
prices = history_prices(secid, instrument, start_date, end_date, bond)
# The history endpoint does not carry the live/most-recent session, so for
# a range reaching today append the current marketdata price.
if end_date >= Date.current
current = current_price(secid, instrument, bond)
if current
prices.reject! { |p| p.date == current.date }
prices << current
end
end
# Illiquid / non-trading window with nothing returned — fall back to the
# most recent available close so the caller still gets a value.
if prices.empty?
fallback = latest_history_price(secid, instrument, bond, end_date)
prices << fallback if fallback
end
prices.sort_by(&:date)
end
end
def max_history_days
nil # ISS serves full history.
end
# ================================
# Exchange Rates
# ================================
def fetch_exchange_rate(from:, to:, date:)
with_provider_response do
# Fetch a short lookback window, not just the exact day, so a weekend or
# holiday request still resolves to the previous trading day's close.
rates = exchange_rates(from, to, date - FX_RATE_LOOKBACK_DAYS, date)
raise Error, "No MOEX FX rate for #{from}/#{to} on #{date}" if rates.blank?
rates.find { |r| r.date == date } ||
rates.select { |r| r.date <= date }.max_by(&:date) ||
rates.first
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
with_provider_response do
exchange_rates(from, to, start_date, end_date)
end
end
private
# ================================
# HTTP / parsing
# ================================
def base_url
ENV["MOEX_ISS_URL"].presence || "https://iss.moex.com/iss"
end
def get_json(path, params = {})
throttle_request
response = client.get("#{base_url}#{path}") do |req|
params.each { |k, v| req.params[k] = v }
end
JSON.parse(response.body)
end
def client
@client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
# Generous enough for a full history page but bounded so a hung ISS
# endpoint can't stall a worker indefinitely.
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.request :json
faraday.response :raise_error
faraday.headers["Accept"] = "application/json"
end
end
# Turns a column-array ISS block into an array of hashes keyed by lowercased
# column name, so callers reference columns by name regardless of ISS order
# or casing (search columns are lowercase; marketdata columns are uppercase).
def rows_from(body, block)
section = body[block] || {}
columns = section["columns"] || []
data = section["data"] || []
index = {}
columns.each_with_index { |col, i| index[col.to_s.downcase] = i }
data.map do |row|
hash = {}
index.each { |col, i| hash[col] = row[i] }
hash
end
end
# The /securities/<SECID>.json `description` block is a vertical key/value
# table (one property per row). Returns { "TYPE" => "common_share", ... }.
def description_map(body)
rows_from(body, "description").each_with_object({}) do |row, map|
map[row["name"].to_s.upcase] = row["value"]
end
end
# ================================
# Search
# ================================
def search_rows(query)
Rails.cache.fetch("moex_public:search:#{query}", expires_in: SEARCH_CACHE_TTL) do
body = get_json("/securities.json", "q" => query, "iss.meta" => "off")
rows_from(body, "securities")
end
end
def normalize_query(symbol)
symbol.to_s.strip.upcase.sub(ALIAS_SUFFIX, "")
end
def normalize_secid(symbol)
normalize_query(symbol)
end
def row_traded?(row)
(row["is_traded"] || row["is_trading"]).to_s == "1"
end
# Classifies an instrument into "stock"/"fund"/"bond" from its ISS
# group/type, returning nil for everything we don't surface (indices,
# futures, currencies). Used both to filter search and to label info.
def security_kind(group, type)
g = group.to_s.downcase
t = type.to_s.downcase
return "bond" if g.include?("bond") || t.include?("bond")
return "fund" if g.include?("etf") || g.include?("ppif") || g.include?("fund") || t.include?("etf") || t.include?("ppif")
return "stock" if g.include?("shares") || t.include?("share") || t.include?("_dr") || t.include?("depositary")
nil
end
def market_kind(market)
case market.to_s.downcase
when "bonds" then "bond"
when /index/ then "index"
else "stock"
end
end
# ================================
# Board / engine resolution
# ================================
# Resolves a SECID to its primary trading board plus engine/market, currency,
# display name, and kind. Cached 24h — reference data that rarely changes.
def resolve_instrument(secid)
cached = Rails.cache.fetch("moex_public:instrument:#{secid}", expires_in: INSTRUMENT_CACHE_TTL) do
body = get_json("/securities/#{secid}.json", "iss.meta" => "off")
desc = description_map(body)
boards = rows_from(body, "boards")
raise InvalidSecurityPriceError, "Unknown MOEX security: #{secid}" if boards.empty?
board = choose_board(boards)
kind = security_kind(desc["GROUP"] || desc["TYPE"], desc["TYPE"]) || market_kind(board["market"])
{
secid: secid,
engine: board["engine"].to_s,
market: board["market"].to_s,
board: board["boardid"].to_s,
currency: normalize_currency(board["currencyid"].presence || desc["FACEUNIT"].presence || desc["CURRENCYID"]),
name: (desc["SHORTNAME"].presence || desc["NAME"].presence || secid).to_s,
kind: kind
}
end
cached.symbolize_keys
end
def choose_board(boards)
traded = boards.select { |b| b["is_traded"].to_s == "1" }
pool = traded.any? ? traded : boards
pool.find { |b| b["is_primary"].to_s == "1" } ||
by_priority(pool) ||
pool.first
end
def by_priority(boards)
BOARD_PRIORITY.each do |boardid|
found = boards.find { |b| b["boardid"].to_s == boardid }
return found if found
end
nil
end
def market_securities_path(instrument, secid)
"/engines/#{instrument[:engine]}/markets/#{instrument[:market]}/boards/#{instrument[:board]}/securities/#{secid}.json"
end
def history_path(instrument, secid)
"/history/engines/#{instrument[:engine]}/markets/#{instrument[:market]}/boards/#{instrument[:board]}/securities/#{secid}.json"
end
# ================================
# Security prices
# ================================
# Live (or most-recent-session) price via the marketdata fallback chain.
# Bonds quote in % of par, so multiply by the instrument FACEVALUE.
def current_price(secid, instrument, bond)
body = get_json(
market_securities_path(instrument, secid),
"iss.meta" => "off", "iss.only" => "securities,marketdata"
)
sec = rows_from(body, "securities").first || {}
md = rows_from(body, "marketdata").first || {}
raw = md["last"].presence || md["marketprice"].presence || md["lcurrentprice"].presence ||
md["lcloseprice"].presence || sec["prevprice"].presence
return nil if raw.nil?
value = raw.to_f
return nil if value <= 0
value = bond_price(value, sec["facevalue"]) if bond
currency = normalize_currency(sec["currencyid"].presence || sec["faceunit"].presence || instrument[:currency])
Price.new(
symbol: secid,
date: Date.current,
price: value,
currency: currency,
exchange_operating_mic: MOEX_MIC
)
end
def history_prices(secid, instrument, start_date, end_date, bond)
return [] if start_date > end_date
prices = []
start = 0
pages = 0
loop do
body = get_json(
history_path(instrument, secid),
"iss.meta" => "off", "from" => start_date.to_s, "till" => end_date.to_s, "start" => start
)
rows = rows_from(body, "history")
break if rows.empty?
rows.each do |row|
price = history_row_price(secid, row, instrument, bond)
prices << price if price
end
pages += 1
break if rows.size < HISTORY_PAGE_SIZE || pages >= MAX_HISTORY_PAGES
start += rows.size
end
prices
end
# Fetches just the most recent close within a short lookback window — the
# fallback when neither history (for the requested range) nor live
# marketdata yielded anything.
def latest_history_price(secid, instrument, bond, end_date)
lookback_start = end_date - 14
history_prices(secid, instrument, lookback_start, end_date, bond).max_by(&:date)
end
def history_row_price(secid, row, instrument, bond)
date = parse_iss_date(row["tradedate"], context: secid)
return nil if date.nil?
raw = row["close"].presence || row["legalcloseprice"].presence
return nil if raw.nil?
value = raw.to_f
return nil if value <= 0
value = bond_price(value, row["facevalue"]) if bond
currency = normalize_currency(
(bond ? row["faceunit"].presence : nil) || row["currencyid"].presence || instrument[:currency]
)
Price.new(
symbol: secid,
date: date,
price: value,
currency: currency,
exchange_operating_mic: MOEX_MIC
)
end
# Clean price for bonds: percent-of-par × FACEVALUE / 100. NKD/accrued
# coupon is deliberately excluded (dirty price is out of scope). FACEVALUE
# is read per row so amortizing bonds price correctly across their life.
def bond_price(percent, facevalue)
face = facevalue.to_f
return percent if face <= 0 # no face value — leave the raw quote untouched
(percent / 100.0) * face
end
# ================================
# Exchange rates
# ================================
# Returns Rate[] for a RUB-crossed pair, or [] for anything else (other
# providers handle non-RUB pairs). selt is quoted X/RUB; we invert for RUB→X.
def exchange_rates(from, to, start_date, end_date)
pair = fx_pair(from, to)
return [] unless pair
instrument = FX_INSTRUMENTS.fetch(pair[:currency])
quotes = fx_history(instrument, start_date, end_date)
if end_date >= Date.current
current = fx_current(instrument)
if current
quotes.reject! { |q| q[:date] == current[:date] }
quotes << current
end
end
quotes.map do |quote|
rate = if pair[:invert]
(BigDecimal("1") / BigDecimal(quote[:value].to_s)).round(12)
else
quote[:value]
end
Rate.new(date: quote[:date], from: from, to: to, rate: rate)
end.sort_by(&:date)
end
# { currency: "USD", invert: false } for X→RUB; invert: true for RUB→X; nil
# when neither side is RUB or the foreign side is unsupported.
def fx_pair(from, to)
f = from.to_s.upcase
t = to.to_s.upcase
if f == "RUB" && FX_INSTRUMENTS.key?(t)
{ currency: t, invert: true }
elsif t == "RUB" && FX_INSTRUMENTS.key?(f)
{ currency: f, invert: false }
end
end
def fx_current(instrument)
body = get_json(
"/engines/currency/markets/selt/boards/CETS/securities/#{instrument}.json",
"iss.meta" => "off", "iss.only" => "marketdata"
)
md = rows_from(body, "marketdata").first || {}
raw = md["last"].presence || md["waprice"].presence || md["marketprice"].presence || md["lcloseprice"].presence
return nil if raw.nil?
value = raw.to_f
return nil if value <= 0
{ date: Date.current, value: value }
end
def fx_history(instrument, start_date, end_date)
return [] if start_date > end_date
quotes = []
start = 0
pages = 0
loop do
body = get_json(
"/history/engines/currency/markets/selt/boards/CETS/securities/#{instrument}.json",
"iss.meta" => "off", "from" => start_date.to_s, "till" => end_date.to_s, "start" => start
)
rows = rows_from(body, "history")
break if rows.empty?
rows.each do |row|
date = parse_iss_date(row["tradedate"], context: instrument)
next if date.nil?
raw = row["close"].presence || row["waprice"].presence
next if raw.nil?
value = raw.to_f
next if value <= 0
quotes << { date: date, value: value }
end
pages += 1
break if rows.size < HISTORY_PAGE_SIZE || pages >= MAX_HISTORY_PAGES
start += rows.size
end
quotes
end
# ================================
# Helpers
# ================================
# Parses an ISS TRADEDATE, skipping (rather than raising on) a malformed
# value so one bad row can't fail an entire history fetch. Logs the offending
# value with context for actionable diagnostics.
def parse_iss_date(raw, context:)
return nil if raw.blank?
Date.parse(raw.to_s)
rescue Date::Error
Rails.logger.warn("MoexPublic: skipping #{context} history row with unparseable date #{raw.inspect}")
nil
end
def normalize_currency(code)
return "RUB" if code.blank?
upcased = code.to_s.upcase
CURRENCY_ALIASES.fetch(upcased, upcased)
end
# Preserve MoexPublic::Error subclasses (e.g. InvalidSecurityPriceError)
# through with_provider_response, mirroring BinancePublic. The inherited
# RateLimitable transformer would otherwise downcast them to Error.
def default_error_transformer(error)
return error if error.is_a?(self.class::Error)
super
end
end

View File

@@ -140,6 +140,10 @@ class Provider::Registry
def binance_public
Provider::BinancePublic.new
end
def moex_public
Provider::MoexPublic.new
end
end
def initialize(concept)
@@ -170,9 +174,9 @@ class Provider::Registry
def available_providers
case concept
when :exchange_rates
%i[twelve_data yahoo_finance]
%i[twelve_data yahoo_finance moex_public]
when :securities
%i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public]
%i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public moex_public]
when :llm
%i[openai anthropic]
else

View File

@@ -14,7 +14,8 @@
<%= form.select :exchange_rate_provider,
[
[t(".providers.twelve_data"), "twelve_data"],
[t(".providers.yahoo_finance"), "yahoo_finance"]
[t(".providers.yahoo_finance"), "yahoo_finance"],
[t(".providers.moex_public"), "moex_public"]
],
{ label: t(".exchange_rate_provider_label") },
{
@@ -51,6 +52,7 @@
["alpha_vantage", t(".providers.alpha_vantage"), t(".requires_api_key_alpha_vantage")],
["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")],
].each do |value, label, hint| %>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox"

View File

@@ -316,3 +316,8 @@ XSAU:
XJSE:
name: Johannesburg
country: ZA
# Russia - Moscow Exchange (Operating MIC: MISX)
MISX:
name: Moscow Exchange
country: RU

View File

@@ -7,6 +7,7 @@ ca:
providers:
alpha_vantage: Alpha Vantage
binance_public: Binance
moex_public: MOEX
eodhd: EODHD
mfapi: MFAPI.in
tiingo: Tiingo

View File

@@ -12,3 +12,4 @@ de:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX

View File

@@ -12,3 +12,4 @@ en:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX

View File

@@ -12,3 +12,4 @@ fr:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX

View File

@@ -12,3 +12,4 @@ hu:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX

View File

@@ -12,3 +12,4 @@ nl:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX

View File

@@ -12,3 +12,4 @@ pt-BR:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX

View File

@@ -12,3 +12,4 @@ vi:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX

View File

@@ -12,3 +12,4 @@ zh-CN:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX

View File

@@ -153,6 +153,7 @@ ca:
uri_base_placeholder: https://api.openai.com/v1 (per defecte)
provider_selection:
binance_public_hint: gratuït, sense clau API — només cripto (BTC, ETH, etc.)
moex_public_hint: gratuït, sense clau API — accions, fons i bons russos (MOEX), inclou canvi RUB
description: Tria un únic proveïdor per obtenir tipus de canvi de divisa.
env_configured_message: La selecció de proveïdor està desactivada perquè hi
ha variables d'entorn configurades. Per activar la selecció aquí, elimina
@@ -165,6 +166,7 @@ ca:
providers:
alpha_vantage: Alpha Vantage
binance_public: Binance
moex_public: MOEX
eodhd: EODHD
mfapi: MFAPI.in
tiingo: Tiingo

View File

@@ -44,6 +44,7 @@ en:
requires_api_key_alpha_vantage: requires API key, 25 calls/day limit
mfapi_hint: free, no API key -- Indian mutual funds only
binance_public_hint: free, no API key -- crypto only (BTC, ETH, etc.)
moex_public_hint: free, no API key -- Russian stocks, funds & bonds (MOEX), incl. RUB FX
providers:
twelve_data: Twelve Data
yahoo_finance: Yahoo Finance
@@ -52,6 +53,7 @@ en:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX
assistant_settings:
title: AI Assistant
description: Choose how the chat assistant responds. Builtin uses your configured LLM provider directly. External delegates to a remote AI agent that can call back to Sure's financial tools via MCP.

View File

@@ -43,6 +43,7 @@ fr:
requires_api_key_alpha_vantage: "nécessite une clé API, limite de 25 appels/jour"
mfapi_hint: "gratuit, aucune clé API -- fonds communs indiens uniquement"
binance_public_hint: "gratuit, aucune clé API -- crypto uniquement (BTC, ETH, etc.)"
moex_public_hint: "gratuit, aucune clé API -- actions, fonds et obligations russes (MOEX), incl. change RUB"
providers:
twelve_data: "Twelve Data"
yahoo_finance: "Yahoo Finance"
@@ -51,6 +52,7 @@ fr:
alpha_vantage: "Alpha Vantage"
mfapi: "MFAPI.in"
binance_public: "Binance"
moex_public: "MOEX"
assistant_settings:
title: "Assistant IA"
description: "Choisissez comment l'assistant de discussion répond. Intégré utilise directement votre fournisseur LLM configuré. Externe délègue à un agent IA distant qui peut invoquer les outils financiers de Sure via MCP."

View File

@@ -44,6 +44,7 @@ hu:
requires_api_key_alpha_vantage: API-kulcs szükséges, 25 hívás/nap limit
mfapi_hint: ingyenes, nem kell API-kulcs csak indiai befektetési alapok
binance_public_hint: ingyenes, nem kell API-kulcs csak kriptó (BTC, ETH stb.)
moex_public_hint: ingyenes, nem kell API-kulcs orosz részvények, alapok és kötvények (MOEX), RUB devizával
providers:
twelve_data: Twelve Data
yahoo_finance: Yahoo Finance
@@ -52,6 +53,7 @@ hu:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX
assistant_settings:
title: AI-asszisztens
description: Válaszd ki, hogyan válaszoljon a csevegési asszisztens. A beépített mód közvetlenül a beállított LLM-szolgáltatót használja. A külső mód egy távoli AI-ügynökhöz továbbítja a kérést, amely MCP-n keresztül hozzáférhet a Sure pénzügyi eszközeihez.

View File

@@ -43,6 +43,7 @@ vi:
requires_api_key_alpha_vantage: yêu cầu khóa API, giới hạn 25 lần gọi/ngày
mfapi_hint: miễn phí, không cần khóa API -- chỉ quỹ tương hỗ Ấn Độ
binance_public_hint: miễn phí, không cần khóa API -- chỉ tiền điện tử (BTC, ETH, v.v.)
moex_public_hint: miễn phí, không cần khóa API -- cổ phiếu, quỹ và trái phiếu Nga (MOEX), bao gồm tỷ giá RUB
providers:
twelve_data: Twelve Data
yahoo_finance: Yahoo Finance
@@ -51,6 +52,7 @@ vi:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX
assistant_settings:
title: Trợ lý AI
description: Chọn cách trợ lý trò chuyện phản hồi. Tích hợp sẵn sử dụng nhà cung cấp LLM đã cấu hình trực tiếp. Bên ngoài ủy thác cho tác nhân AI từ xa có thể gọi lại các công cụ tài chính của Sure qua MCP.

View File

@@ -68,6 +68,7 @@ zh-CN:
alpha_vantage: Alpha Vantage
mfapi: MFAPI.in
binance_public: Binance
moex_public: MOEX
securities_provider_label: 证券(股票价格)数据提供商
title: 数据提供商选择
exchange_rate_title: 汇率提供商
@@ -81,6 +82,7 @@ zh-CN:
requires_api_key_alpha_vantage: Alpha Vantage 需要 API Key。
mfapi_hint: MFAPI.in 可用于部分印度基金数据。
binance_public_hint: Binance 公共接口可用于加密资产行情。
moex_public_hint: 免费,无需 API 密钥 -- 俄罗斯股票、基金和债券MOEX包括 RUB 外汇
show:
clear_cache: 清除数据缓存
clear_cache_warning: 清除数据缓存将移除所有汇率、证券价格、账户余额及其他数据。这不会删除账户、交易记录、分类或其他用户自有数据。

View File

@@ -0,0 +1,485 @@
require "test_helper"
class Provider::MoexPublicTest < ActiveSupport::TestCase
setup do
@provider = Provider::MoexPublic.new
@provider.stubs(:throttle_request)
end
# ================================
# Search
# ================================
test "search_securities returns a stock with MOEX MIC and nil country" do
stub_search("SBER", search_body(
row(secid: "SBER", shortname: "Sberbank", is_traded: "1", type: "common_share", group: "stock_shares", currencyid: "SUR")
))
response = @provider.search_securities("SBER")
assert response.success?
sec = response.data.first
assert_equal "SBER", sec.symbol
assert_equal "Sberbank", sec.name
assert_equal "MISX", sec.exchange_operating_mic
assert_nil sec.country_code, "MOEX picks must carry a nil country so any family resolves them"
assert_equal "RUB", sec.currency, "legacy SUR currency code must normalize to RUB"
end
test "search_securities returns a fund (LQDT)" do
stub_search("LQDT", search_body(
row(secid: "LQDT", shortname: "Liquidity", is_traded: "1", type: "etf_ppif", group: "stock_etf", currencyid: "RUB")
))
response = @provider.search_securities("LQDT")
assert_equal [ "LQDT" ], response.data.map(&:symbol)
end
test "search_securities returns an OFZ bond" do
stub_search("SU26238RMFS4", search_body(
row(secid: "SU26238RMFS4", shortname: "OFZ 26238", is_traded: "1", type: "ofz_bond", group: "stock_bonds", currencyid: "SUR")
))
response = @provider.search_securities("SU26238RMFS4")
assert_equal [ "SU26238RMFS4" ], response.data.map(&:symbol)
end
test "search_securities strips the .ME alias suffix before querying ISS" do
stub_search("SBER", search_body(
row(secid: "SBER", shortname: "Sberbank", is_traded: "1", type: "common_share", group: "stock_shares")
))
response = @provider.search_securities("SBER.ME")
assert response.success?
assert_equal [ "SBER" ], response.data.map(&:symbol)
end
test "search_securities strips the .MOEX alias suffix" do
stub_search("SBER", search_body(
row(secid: "SBER", shortname: "Sberbank", is_traded: "1", type: "common_share", group: "stock_shares")
))
assert_equal [ "SBER" ], @provider.search_securities("sber.moex").data.map(&:symbol)
end
test "search_securities matches an ISIN query natively" do
isin = "RU0009029540"
stub_search(isin, search_body(
row(secid: "SBER", shortname: "Sberbank", isin: isin, is_traded: "1", type: "common_share", group: "stock_shares")
))
assert_equal [ "SBER" ], @provider.search_securities(isin).data.map(&:symbol)
end
test "search_securities filters out non-traded instruments" do
stub_search("SBER", search_body(
row(secid: "SBERP_OLD", shortname: "delisted", is_traded: "0", type: "preferred_share", group: "stock_shares")
))
assert_empty @provider.search_securities("SBER").data
end
test "search_securities excludes indices and futures" do
stub_search("RTSI", search_body(
row(secid: "IMOEX", shortname: "MOEX Index", is_traded: "1", type: "common_index", group: "stock_index"),
row(secid: "RIH6", shortname: "RTS Future", is_traded: "1", type: "futures", group: "futures_forts")
))
assert_empty @provider.search_securities("RTSI").data
end
test "search_securities dedupes multiple board rows for the same SECID" do
stub_search("SBER", search_body(
row(secid: "SBER", shortname: "Sberbank", is_traded: "1", type: "common_share", group: "stock_shares", primary_boardid: "TQBR"),
row(secid: "SBER", shortname: "Sberbank", is_traded: "1", type: "common_share", group: "stock_shares", primary_boardid: "SMAL")
))
assert_equal [ "SBER" ], @provider.search_securities("SBER").data.map(&:symbol)
end
test "search_securities returns empty for a blank query without calling ISS" do
@provider.expects(:get_json).never
assert_empty @provider.search_securities(" ").data
end
# ================================
# Board / engine resolution
# ================================
test "resolve_instrument selects the ISS primary board" do
@provider.stubs(:get_json).with("/securities/SBER.json", anything).returns(instrument_body(
desc: { "SECID" => "SBER", "SHORTNAME" => "Sberbank", "TYPE" => "common_share", "GROUP" => "stock_shares", "FACEUNIT" => "SUR" },
boards: [
board_row(boardid: "SMAL", is_traded: "1", market: "shares", engine: "stock", is_primary: "0", currencyid: "SUR"),
board_row(boardid: "TQBR", is_traded: "1", market: "shares", engine: "stock", is_primary: "1", currencyid: "SUR")
]
))
instrument = @provider.send(:resolve_instrument, "SBER")
assert_equal "TQBR", instrument[:board]
assert_equal "shares", instrument[:market]
assert_equal "stock", instrument[:engine]
assert_equal "stock", instrument[:kind]
assert_equal "RUB", instrument[:currency]
end
test "resolve_instrument falls back to hardcoded board priority when no primary flag" do
@provider.stubs(:get_json).with("/securities/SU26238RMFS4.json", anything).returns(instrument_body(
desc: { "SECID" => "SU26238RMFS4", "SHORTNAME" => "OFZ 26238", "TYPE" => "ofz_bond", "GROUP" => "stock_bonds", "FACEUNIT" => "SUR" },
boards: [
board_row(boardid: "EQOB", is_traded: "1", market: "bonds", engine: "stock", is_primary: "0", currencyid: "SUR"),
board_row(boardid: "TQOB", is_traded: "1", market: "bonds", engine: "stock", is_primary: "0", currencyid: "SUR")
]
))
instrument = @provider.send(:resolve_instrument, "SU26238RMFS4")
assert_equal "TQOB", instrument[:board], "TQOB precedes EQOB in BOARD_PRIORITY"
assert_equal "bond", instrument[:kind]
end
# ================================
# Security info
# ================================
test "fetch_security_info maps kind and a MOEX issue link" do
@provider.stubs(:resolve_instrument).returns(bond_instrument)
response = @provider.fetch_security_info(symbol: "SU26238RMFS4", exchange_operating_mic: "MISX")
assert response.success?
assert_equal "bond", response.data.kind
assert_equal "MISX", response.data.exchange_operating_mic
assert_match(/moex\.com/, response.data.links)
end
# ================================
# Security prices
# ================================
test "fetch_security_prices uses LAST from the marketdata fallback chain" do
@provider.stubs(:resolve_instrument).returns(stock_instrument)
stub_history([])
stub_current_price(
securities: { "facevalue" => nil, "faceunit" => "SUR", "currencyid" => "SUR", "prevprice" => "300.0" },
marketdata: { "last" => "320.5", "marketprice" => "319.0", "lcloseprice" => "318.0", "waprice" => "319.2" }
)
response = @provider.fetch_security_price(symbol: "SBER", exchange_operating_mic: "MISX", date: Date.current)
assert response.success?
assert_in_delta 320.5, response.data.price
assert_equal "RUB", response.data.currency
assert_equal "MISX", response.data.exchange_operating_mic
end
test "fetch_security_prices falls back to PREVPRICE when marketdata is empty" do
@provider.stubs(:resolve_instrument).returns(stock_instrument)
stub_history([])
stub_current_price(
securities: { "facevalue" => nil, "currencyid" => "SUR", "prevprice" => "305.0" },
marketdata: { "last" => nil, "marketprice" => nil, "lcurrentprice" => nil, "lcloseprice" => nil, "waprice" => nil }
)
response = @provider.fetch_security_price(symbol: "SBER", exchange_operating_mic: "MISX", date: Date.current)
assert response.success?
assert_in_delta 305.0, response.data.price
end
test "fetch_security_prices reads past dates from the history endpoint" do
date = Date.current - 5
@provider.stubs(:resolve_instrument).returns(stock_instrument)
stub_history([ history_row(tradedate: date.to_s, close: "311.4") ])
response = @provider.fetch_security_price(symbol: "SBER", exchange_operating_mic: "MISX", date: date)
assert response.success?
assert_equal date, response.data.date
assert_in_delta 311.4, response.data.price
assert_equal "RUB", response.data.currency
end
test "fetch_security_prices paginates history via start=" do
start_date = Date.current - 200
end_date = Date.current - 10
page0 = Array.new(100) { |i| history_row(tradedate: (start_date + i).to_s, close: "100.0") }
page1 = Array.new(50) { |i| history_row(tradedate: (start_date + 100 + i).to_s, close: "101.0") }
@provider.stubs(:resolve_instrument).returns(stock_instrument)
@provider.stubs(:get_json).with(regexp_matches(%r{^/history/}), has_entry("start" => 0)).returns(history_block(page0))
@provider.stubs(:get_json).with(regexp_matches(%r{^/history/}), has_entry("start" => 100)).returns(history_block(page1))
response = @provider.fetch_security_prices(
symbol: "SBER", exchange_operating_mic: "MISX", start_date: start_date, end_date: end_date
)
assert response.success?
assert_equal 150, response.data.size
end
test "fetch_security_prices skips a row with an unparseable date instead of failing" do
good_date = Date.current - 5
@provider.stubs(:resolve_instrument).returns(stock_instrument)
stub_history([
history_row(tradedate: "not-a-date", close: "300.0"),
history_row(tradedate: good_date.to_s, close: "311.4")
])
response = @provider.fetch_security_prices(
symbol: "SBER", exchange_operating_mic: "MISX", start_date: Date.current - 7, end_date: good_date
)
assert response.success?
assert_equal [ good_date ], response.data.map(&:date)
end
test "fetch_security_prices converts bond percent-of-par to currency via FACEVALUE" do
date = Date.current - 5
@provider.stubs(:resolve_instrument).returns(bond_instrument)
stub_history([ history_row(tradedate: date.to_s, close: "98.5", facevalue: "1000.0", faceunit: "SUR") ])
response = @provider.fetch_security_price(symbol: "SU26238RMFS4", exchange_operating_mic: "MISX", date: date)
assert response.success?
assert_in_delta 985.0, response.data.price, 0.001, "98.5% of a 1000 par must be 985"
assert_equal "RUB", response.data.currency
end
test "fetch_security_prices stamps eurobond currency from ISS FACEUNIT" do
date = Date.current - 5
@provider.stubs(:resolve_instrument).returns(eurobond_instrument)
stub_history([ history_row(tradedate: date.to_s, close: "102.0", facevalue: "1000.0", faceunit: "USD") ])
response = @provider.fetch_security_price(symbol: "RU000A0JX0J2", exchange_operating_mic: "MISX", date: date)
assert_equal "USD", response.data.currency
assert_in_delta 1020.0, response.data.price, 0.001
end
test "fetch_security_prices reads per-row FACEVALUE for amortizing bonds" do
d1 = Date.current - 6
d2 = Date.current - 5
@provider.stubs(:resolve_instrument).returns(bond_instrument)
stub_history([
history_row(tradedate: d1.to_s, close: "100.0", facevalue: "1000.0", faceunit: "SUR"),
history_row(tradedate: d2.to_s, close: "100.0", facevalue: "700.0", faceunit: "SUR")
])
response = @provider.fetch_security_prices(
symbol: "RU000AMORT", exchange_operating_mic: "MISX", start_date: d1, end_date: d2
)
prices = response.data.sort_by(&:date)
assert_in_delta 1000.0, prices.first.price, 0.001
assert_in_delta 700.0, prices.last.price, 0.001, "amortized face value must drive the second day's clean price"
end
test "fetch_security_price raises InvalidSecurityPriceError when nothing is found" do
date = Date.current - 5
@provider.stubs(:resolve_instrument).returns(stock_instrument)
stub_history([])
response = @provider.fetch_security_price(symbol: "SBER", exchange_operating_mic: "MISX", date: date)
assert_not response.success?
assert_instance_of Provider::MoexPublic::InvalidSecurityPriceError, response.error
end
# ================================
# Exchange rates
# ================================
test "fetch_exchange_rate USD to RUB is the direct selt quote" do
date = Date.current - 5
stub_fx_history("USD000UTSTOM", [ fx_row(tradedate: date.to_s, close: "90.5") ])
response = @provider.fetch_exchange_rate(from: "USD", to: "RUB", date: date)
assert response.success?
assert_in_delta 90.5, response.data.rate.to_f
assert_equal "USD", response.data.from
assert_equal "RUB", response.data.to
end
test "fetch_exchange_rate looks back to the prior trading day on a non-trading date" do
non_trading_day = Date.current - 5
prior_trading_day = non_trading_day - 2
# ISS returns nothing for the weekend/holiday itself, only the earlier close.
stub_fx_history("USD000UTSTOM", [ fx_row(tradedate: prior_trading_day.to_s, close: "91.0") ])
response = @provider.fetch_exchange_rate(from: "USD", to: "RUB", date: non_trading_day)
assert response.success?
assert_equal prior_trading_day, response.data.date
assert_in_delta 91.0, response.data.rate.to_f
end
test "fetch_exchange_rate RUB to USD inverts the selt quote" do
date = Date.current - 5
stub_fx_history("USD000UTSTOM", [ fx_row(tradedate: date.to_s, close: "90.5") ])
response = @provider.fetch_exchange_rate(from: "RUB", to: "USD", date: date)
assert response.success?
assert_in_delta (1.0 / 90.5), response.data.rate.to_f, 0.0000001
end
test "fetch_exchange_rate supports EUR and CNY" do
date = Date.current - 5
stub_fx_history("EUR_RUB__TOM", [ fx_row(tradedate: date.to_s, close: "98.1") ])
stub_fx_history("CNYRUB_TOM", [ fx_row(tradedate: date.to_s, close: "12.4") ])
eur = @provider.fetch_exchange_rate(from: "EUR", to: "RUB", date: date)
cny = @provider.fetch_exchange_rate(from: "CNY", to: "RUB", date: date)
assert_in_delta 98.1, eur.data.rate.to_f
assert_in_delta 12.4, cny.data.rate.to_f
end
test "fetch_exchange_rates returns empty for a non-RUB-crossed pair" do
@provider.expects(:get_json).never
response = @provider.fetch_exchange_rates(
from: "USD", to: "EUR", start_date: Date.current - 5, end_date: Date.current - 1
)
assert response.success?
assert_empty response.data
end
test "fetch_exchange_rate fails (no crash) for a non-RUB-crossed pair" do
@provider.expects(:get_json).never
response = @provider.fetch_exchange_rate(from: "USD", to: "EUR", date: Date.current - 1)
assert_not response.success?
end
test "fetch_exchange_rates returns a sorted range of rates" do
start_date = Date.current - 5
end_date = Date.current - 1
rows = (0..4).map { |i| fx_row(tradedate: (start_date + i).to_s, close: (90 + i).to_s) }
stub_fx_history("USD000UTSTOM", rows)
response = @provider.fetch_exchange_rates(
from: "USD", to: "RUB", start_date: start_date, end_date: end_date
)
assert response.success?
assert_equal 5, response.data.size
assert_equal start_date, response.data.first.date
assert_equal end_date, response.data.last.date
end
# ================================
# Error / response wrapping
# ================================
test "search_securities wraps provider errors via with_provider_response" do
@provider.stubs(:get_json).raises(StandardError.new("ISS unreachable"))
response = @provider.search_securities("SBER")
assert_not response.success?
assert_instance_of Provider::MoexPublic::Error, response.error
end
test "max_history_days is nil (full history)" do
assert_nil @provider.max_history_days
end
# ================================
# Helpers
# ================================
private
# ----- instrument stubs -----
def stock_instrument
{ secid: "SBER", engine: "stock", market: "shares", board: "TQBR", currency: "RUB", name: "Sberbank", kind: "stock" }
end
def bond_instrument
{ secid: "SU26238RMFS4", engine: "stock", market: "bonds", board: "TQOB", currency: "RUB", name: "OFZ 26238", kind: "bond" }
end
def eurobond_instrument
{ secid: "RU000A0JX0J2", engine: "stock", market: "bonds", board: "TQOD", currency: "USD", name: "Eurobond", kind: "bond" }
end
# ----- column-array block builders -----
def block(columns, rows)
{ "columns" => columns, "data" => rows.map { |r| columns.map { |c| r[c] } } }
end
SEARCH_COLUMNS = %w[secid shortname isin is_traded type group primary_boardid currencyid faceunit].freeze
def row(**attrs)
attrs.transform_keys(&:to_s)
end
def search_body(*rows)
{ "securities" => block(SEARCH_COLUMNS, rows) }
end
def stub_search(query, body)
@provider.stubs(:get_json).with("/securities.json", has_entry("q" => query)).returns(body)
end
DESCRIPTION_COLUMNS = %w[name title value].freeze
BOARD_COLUMNS = %w[secid boardid title is_traded market engine is_primary currencyid].freeze
def board_row(**attrs)
{ "secid" => "SBER", "title" => attrs[:boardid] }.merge(attrs.transform_keys(&:to_s))
end
def instrument_body(desc:, boards:)
{
"description" => block(DESCRIPTION_COLUMNS, desc.map { |name, value| { "name" => name, "title" => name, "value" => value } }),
"boards" => block(BOARD_COLUMNS, boards)
}
end
PRICE_SECURITIES_COLUMNS = %w[secid facevalue faceunit currencyid prevprice].freeze
PRICE_MARKETDATA_COLUMNS = %w[secid last marketprice lcurrentprice lcloseprice waprice].freeze
def stub_current_price(securities:, marketdata:)
body = {
"securities" => block(PRICE_SECURITIES_COLUMNS, [ securities ]),
"marketdata" => block(PRICE_MARKETDATA_COLUMNS, [ marketdata ])
}
@provider.stubs(:get_json).with(regexp_matches(%r{^/engines/}), anything).returns(body)
end
HISTORY_COLUMNS = %w[tradedate secid close legalcloseprice facevalue faceunit currencyid].freeze
def history_row(**attrs)
attrs.transform_keys(&:to_s)
end
def history_block(rows)
{ "history" => block(HISTORY_COLUMNS, rows) }
end
def stub_history(rows)
@provider.stubs(:get_json).with(regexp_matches(%r{^/history/engines/(?!currency)}), anything).returns(history_block(rows))
end
FX_HISTORY_COLUMNS = %w[tradedate secid close waprice].freeze
def fx_row(**attrs)
attrs.transform_keys(&:to_s)
end
def stub_fx_history(instrument, rows)
body = { "history" => block(FX_HISTORY_COLUMNS, rows) }
@provider.stubs(:get_json).with(regexp_matches(%r{/history/engines/currency/.*#{Regexp.escape(instrument)}}), anything).returns(body)
end
end