mirror of
https://github.com/we-promise/sure.git
synced 2026-07-20 16:55:25 +00:00
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:
590
app/models/provider/moex_public.rb
Normal file
590
app/models/provider/moex_public.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -316,3 +316,8 @@ XSAU:
|
||||
XJSE:
|
||||
name: Johannesburg
|
||||
country: ZA
|
||||
|
||||
# Russia - Moscow Exchange (Operating MIC: MISX)
|
||||
MISX:
|
||||
name: Moscow Exchange
|
||||
country: RU
|
||||
|
||||
@@ -7,6 +7,7 @@ ca:
|
||||
providers:
|
||||
alpha_vantage: Alpha Vantage
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
eodhd: EODHD
|
||||
mfapi: MFAPI.in
|
||||
tiingo: Tiingo
|
||||
|
||||
@@ -12,3 +12,4 @@ de:
|
||||
alpha_vantage: Alpha Vantage
|
||||
mfapi: MFAPI.in
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
|
||||
@@ -12,3 +12,4 @@ en:
|
||||
alpha_vantage: Alpha Vantage
|
||||
mfapi: MFAPI.in
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
|
||||
@@ -12,3 +12,4 @@ fr:
|
||||
alpha_vantage: Alpha Vantage
|
||||
mfapi: MFAPI.in
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
|
||||
@@ -12,3 +12,4 @@ hu:
|
||||
alpha_vantage: Alpha Vantage
|
||||
mfapi: MFAPI.in
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
|
||||
@@ -12,3 +12,4 @@ nl:
|
||||
alpha_vantage: Alpha Vantage
|
||||
mfapi: MFAPI.in
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
|
||||
@@ -12,3 +12,4 @@ pt-BR:
|
||||
alpha_vantage: Alpha Vantage
|
||||
mfapi: MFAPI.in
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
|
||||
@@ -12,3 +12,4 @@ vi:
|
||||
alpha_vantage: Alpha Vantage
|
||||
mfapi: MFAPI.in
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
|
||||
@@ -12,3 +12,4 @@ zh-CN:
|
||||
alpha_vantage: Alpha Vantage
|
||||
mfapi: MFAPI.in
|
||||
binance_public: Binance
|
||||
moex_public: MOEX
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: 清除数据缓存将移除所有汇率、证券价格、账户余额及其他数据。这不会删除账户、交易记录、分类或其他用户自有数据。
|
||||
|
||||
485
test/models/provider/moex_public_test.rb
Normal file
485
test/models/provider/moex_public_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user