mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 11:04:14 +00:00
* Binance as securities provider * Disable twelve data crypto results * Add logo support and new currency pairs * FIX importer fallback * Add price clamping and optiimize retrieval * Review * Update adding-a-securities-provider.md * day gap miss fix * New fixes * Brandfetch doesn't support crypto. add new CDN * Update _investment_performance.html.erb
494 lines
14 KiB
Markdown
494 lines
14 KiB
Markdown
# Adding a New Securities Price Provider
|
|
|
|
This guide covers every step needed to add a new securities price provider (like Tiingo, EODHD, etc.) to the application.
|
|
|
|
## Architecture Overview
|
|
|
|
```text
|
|
User searches ticker in combobox
|
|
→ SecuritiesController#index
|
|
→ Security.search_provider (queries all enabled providers concurrently)
|
|
→ Provider::YourProvider#search_securities
|
|
→ Returns results with provider key attached
|
|
→ User selects one → price_provider stored on Security record
|
|
|
|
Account sync / price fetch
|
|
→ Security#price_data_provider (looks up provider by security.price_provider)
|
|
→ Provider::YourProvider#fetch_security_prices
|
|
→ Security::Price::Importer gap-fills and upserts into DB
|
|
```
|
|
|
|
Key files:
|
|
- Provider class: `app/models/provider/your_provider.rb`
|
|
- Registry: `app/models/provider/registry.rb`
|
|
- Settings: `app/models/setting.rb`
|
|
- Provider resolution: `app/models/security/provided.rb`
|
|
- Price import: `app/models/security/price/importer.rb`
|
|
- Market data sync: `app/models/account/market_data_importer.rb`
|
|
|
|
## Step 1: Create the Provider Class
|
|
|
|
Create `app/models/provider/your_provider.rb`:
|
|
|
|
```ruby
|
|
class Provider::YourProvider < Provider
|
|
include SecurityConcept
|
|
|
|
# Include if your provider has rate limits
|
|
include RateLimitable
|
|
|
|
# Custom error classes
|
|
Error = Class.new(Provider::Error)
|
|
RateLimitError = Class.new(Error)
|
|
|
|
# Rate limiting (only if you included RateLimitable)
|
|
MIN_REQUEST_INTERVAL = 1.0 # seconds between requests
|
|
|
|
def initialize(api_key)
|
|
@api_key = api_key # pipelock:ignore
|
|
end
|
|
|
|
# --- Required Methods ---
|
|
|
|
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
|
|
with_provider_response do
|
|
response = client.get("#{base_url}/search", params: { q: symbol })
|
|
parsed = JSON.parse(response.body)
|
|
|
|
parsed.map do |result|
|
|
SecurityConcept::Security.new(
|
|
symbol: result["ticker"],
|
|
name: result["name"],
|
|
logo_url: result["logo"],
|
|
exchange_operating_mic: map_exchange_to_mic(result["exchange"]),
|
|
country_code: result["country"],
|
|
currency: result["currency"]
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def fetch_security_info(symbol:, exchange_operating_mic:)
|
|
with_provider_response do
|
|
response = client.get("#{base_url}/info/#{symbol}")
|
|
parsed = JSON.parse(response.body)
|
|
|
|
SecurityConcept::SecurityInfo.new(
|
|
symbol: parsed["ticker"],
|
|
name: parsed["name"],
|
|
links: parsed["website"],
|
|
logo_url: parsed["logo"],
|
|
description: parsed["description"],
|
|
kind: parsed["type"], # e.g. "common stock", "etf"
|
|
exchange_operating_mic: exchange_operating_mic
|
|
)
|
|
end
|
|
end
|
|
|
|
def fetch_security_price(symbol:, exchange_operating_mic:, date:)
|
|
with_provider_response do
|
|
response = client.get("#{base_url}/price/#{symbol}", params: { date: date.to_s })
|
|
parsed = JSON.parse(response.body)
|
|
|
|
SecurityConcept::Price.new(
|
|
symbol: symbol,
|
|
date: Date.parse(parsed["date"]),
|
|
price: parsed["close"].to_f,
|
|
currency: parsed["currency"],
|
|
exchange_operating_mic: exchange_operating_mic
|
|
)
|
|
end
|
|
end
|
|
|
|
def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:)
|
|
with_provider_response do
|
|
response = client.get("#{base_url}/prices/#{symbol}", params: {
|
|
start: start_date.to_s,
|
|
end: end_date.to_s
|
|
})
|
|
parsed = JSON.parse(response.body)
|
|
|
|
parsed.map do |row|
|
|
SecurityConcept::Price.new(
|
|
symbol: symbol,
|
|
date: Date.parse(row["date"]),
|
|
price: row["close"].to_f,
|
|
currency: row["currency"],
|
|
exchange_operating_mic: exchange_operating_mic
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Optional: limit how far back the importer fetches history.
|
|
# nil = unlimited. Free tiers often have limits.
|
|
def max_history_days
|
|
365
|
|
end
|
|
|
|
# Optional: health check for admin UI
|
|
def healthy?
|
|
with_provider_response do
|
|
response = client.get("#{base_url}/status")
|
|
JSON.parse(response.body)["status"] == "ok"
|
|
end
|
|
end
|
|
|
|
# Optional: usage stats for admin UI
|
|
def usage
|
|
with_provider_response do
|
|
Provider::UsageData.new(
|
|
used: daily_request_count,
|
|
limit: MAX_REQUESTS_PER_DAY,
|
|
utilization: (daily_request_count.to_f / MAX_REQUESTS_PER_DAY * 100).round(1),
|
|
plan: "Free"
|
|
)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def base_url
|
|
"https://api.yourprovider.com/v1"
|
|
end
|
|
|
|
def client
|
|
@client ||= Faraday.new do |conn|
|
|
conn.headers["Authorization"] = "Bearer #{@api_key}"
|
|
conn.response :raise_error
|
|
end
|
|
end
|
|
|
|
# Map provider's exchange names to ISO 10383 MIC codes.
|
|
# This is critical — the app uses MIC codes everywhere.
|
|
def map_exchange_to_mic(exchange_name)
|
|
{
|
|
"NASDAQ" => "XNAS",
|
|
"NYSE" => "XNYS",
|
|
"LSE" => "XLON",
|
|
"XETRA" => "XETR",
|
|
"AMS" => "XAMS"
|
|
# Add all exchanges your provider returns
|
|
}[exchange_name]
|
|
end
|
|
end
|
|
```
|
|
|
|
### Data Structures (defined in `SecurityConcept`)
|
|
|
|
All methods must return these exact types (wrapped in `with_provider_response`):
|
|
|
|
```ruby
|
|
SecurityConcept::Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code, :currency)
|
|
SecurityConcept::SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic)
|
|
SecurityConcept::Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic)
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
All public methods must be wrapped in `with_provider_response`:
|
|
|
|
```ruby
|
|
def some_method
|
|
with_provider_response do
|
|
# Your logic. Raise on errors.
|
|
# The block return value becomes response.data
|
|
end
|
|
end
|
|
```
|
|
|
|
Callers always receive a `Provider::Response`:
|
|
```ruby
|
|
response = provider.fetch_security_price(...)
|
|
response.success? # true/false
|
|
response.data # the return value from the block
|
|
response.error # Provider::Error instance (on failure)
|
|
```
|
|
|
|
### Rate Limiting
|
|
|
|
If your provider has rate limits, include `RateLimitable` and use cache-based atomic counters:
|
|
|
|
```ruby
|
|
include RateLimitable
|
|
|
|
MIN_REQUEST_INTERVAL = 1.5 # seconds between requests
|
|
|
|
MAX_REQUESTS_PER_DAY = 20
|
|
|
|
private
|
|
|
|
def throttle_request
|
|
super # enforces MIN_REQUEST_INTERVAL
|
|
enforce_daily_limit!
|
|
end
|
|
|
|
def enforce_daily_limit!
|
|
cache_key = "your_provider:daily:#{Date.current}"
|
|
count = Rails.cache.increment(cache_key, 1, expires_in: 1.day, initial: 0)
|
|
raise RateLimitError, "Daily limit reached" if count > MAX_REQUESTS_PER_DAY
|
|
end
|
|
```
|
|
|
|
### Exchange Mapping
|
|
|
|
Every provider has its own naming for exchanges. You must map them to ISO 10383 MIC codes. Define bidirectional maps:
|
|
|
|
```ruby
|
|
# Provider exchange name → MIC code (for parsing search results)
|
|
PROVIDER_TO_MIC = { "NASDAQ" => "XNAS", ... }.freeze
|
|
|
|
# MIC code → Provider exchange name (for building API requests)
|
|
MIC_TO_PROVIDER = PROVIDER_TO_MIC.invert.freeze
|
|
```
|
|
|
|
See `config/exchanges.yml` for the full list of MIC codes and their display names.
|
|
|
|
### Currency Handling
|
|
|
|
Some providers don't return currency in every response. Common pattern: cache currency from search results and reuse later:
|
|
|
|
```ruby
|
|
def search_securities(symbol, **opts)
|
|
with_provider_response do
|
|
results = api_call(...)
|
|
results.each do |r|
|
|
Rails.cache.write("your_provider:currency:#{r[:ticker].upcase}", r[:currency], expires_in: 24.hours)
|
|
end
|
|
# ...
|
|
end
|
|
end
|
|
|
|
def fetch_security_prices(symbol:, **)
|
|
with_provider_response do
|
|
# ...
|
|
currency = Rails.cache.read("your_provider:currency:#{symbol.upcase}") || fallback_currency(exchange)
|
|
# ...
|
|
end
|
|
end
|
|
```
|
|
|
|
## Step 2: Register in the Provider Registry
|
|
|
|
Edit `app/models/provider/registry.rb`:
|
|
|
|
**Add to `available_providers`** (around line 144):
|
|
|
|
```ruby
|
|
def available_providers
|
|
case concept
|
|
when :exchange_rates
|
|
%i[twelve_data yahoo_finance]
|
|
when :securities
|
|
%i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi your_provider]
|
|
# ...
|
|
end
|
|
end
|
|
```
|
|
|
|
**Add the factory method** (private section, around line 85):
|
|
|
|
```ruby
|
|
def your_provider
|
|
api_key = ENV["YOUR_PROVIDER_API_KEY"].presence || Setting.your_provider_api_key # pipelock:ignore
|
|
return nil unless api_key.present?
|
|
Provider::YourProvider.new(api_key)
|
|
end
|
|
```
|
|
|
|
If your provider needs no API key (like Yahoo Finance or MFAPI):
|
|
|
|
```ruby
|
|
def your_provider
|
|
Provider::YourProvider.new
|
|
end
|
|
```
|
|
|
|
## Step 3: Add Settings
|
|
|
|
Edit `app/models/setting.rb`:
|
|
|
|
**Add the API key field** (around line 40):
|
|
|
|
```ruby
|
|
field :your_provider_api_key, type: :string, default: ENV["YOUR_PROVIDER_API_KEY"]
|
|
```
|
|
|
|
**Add to encrypted fields** (in `EncryptedSettingFields`, around line 55):
|
|
|
|
```ruby
|
|
ENCRYPTED_FIELDS = %i[
|
|
twelve_data_api_key
|
|
tiingo_api_key
|
|
eodhd_api_key
|
|
alpha_vantage_api_key
|
|
your_provider_api_key # ← add here
|
|
openai_access_token
|
|
external_assistant_token
|
|
]
|
|
```
|
|
|
|
## Step 4: Add to the Settings UI
|
|
|
|
### Hostings Controller
|
|
|
|
Edit `app/controllers/settings/hostings_controller.rb`:
|
|
|
|
**In `show`** — add a flag to control visibility:
|
|
|
|
```ruby
|
|
@show_your_provider_settings = enabled_securities.include?("your_provider")
|
|
```
|
|
|
|
**In `update`** — handle the API key:
|
|
|
|
```ruby
|
|
update_encrypted_setting(:your_provider_api_key)
|
|
```
|
|
|
|
### Hostings View
|
|
|
|
Edit the settings view to add your provider's checkbox and API key field. Follow the existing pattern for Tiingo/EODHD (checkbox in the provider selection list, API key input shown when enabled).
|
|
|
|
## Step 5: Add Translations
|
|
|
|
Edit `config/locales/views/settings/hostings/en.yml`:
|
|
|
|
**Add provider name** (under `provider_selection.providers`):
|
|
|
|
```yaml
|
|
providers:
|
|
twelve_data: "Twelve Data"
|
|
yahoo_finance: "Yahoo Finance"
|
|
tiingo: "Tiingo"
|
|
eodhd: "EODHD"
|
|
alpha_vantage: "Alpha Vantage"
|
|
mfapi: "MFAPI.in"
|
|
your_provider: "Your Provider" # ← add here
|
|
```
|
|
|
|
**Add hint text** (under `provider_selection`):
|
|
|
|
```yaml
|
|
your_provider_hint: "requires API key, N calls/day limit"
|
|
```
|
|
|
|
**Add settings section** (for the API key input):
|
|
|
|
```yaml
|
|
your_provider_settings:
|
|
title: "Your Provider"
|
|
description: "Get your API key from https://yourprovider.com/dashboard"
|
|
label: "API Key"
|
|
env_configured_message: "The YOUR_PROVIDER_API_KEY environment variable is set."
|
|
```
|
|
|
|
Also add a display name in `config/locales/en.yml` under `securities.providers`:
|
|
|
|
```yaml
|
|
securities:
|
|
providers:
|
|
your_provider: "Your Provider"
|
|
```
|
|
|
|
This is used in the combobox dropdown to show which provider each search result comes from.
|
|
|
|
## Step 6: Test
|
|
|
|
### Manual Testing
|
|
|
|
```ruby
|
|
# In rails console:
|
|
provider = Provider::YourProvider.new("your_api_key")
|
|
|
|
# Search
|
|
response = provider.search_securities("AAPL")
|
|
response.success? # => true
|
|
response.data # => [SecurityConcept::Security(...), ...]
|
|
|
|
# Price
|
|
response = provider.fetch_security_price(symbol: "AAPL", exchange_operating_mic: "XNAS", date: Date.current)
|
|
response.data.price # => 150.25
|
|
response.data.currency # => "USD"
|
|
|
|
# Historical prices
|
|
response = provider.fetch_security_prices(symbol: "AAPL", exchange_operating_mic: "XNAS", start_date: 30.days.ago.to_date, end_date: Date.current)
|
|
response.data.size # => ~30
|
|
```
|
|
|
|
### Enable and Search
|
|
|
|
```ruby
|
|
# Enable the provider
|
|
Setting.securities_providers = "your_provider"
|
|
|
|
# Search via the app's multi-provider system
|
|
results = Security.search_provider("AAPL")
|
|
results.map { |s| [s.ticker, s.price_provider] }
|
|
# => [["AAPL", "your_provider"]]
|
|
|
|
# Create a security with your provider
|
|
security = Security::Resolver.new("AAPL", exchange_operating_mic: "XNAS", price_provider: "your_provider").resolve
|
|
security.price_provider # => "your_provider"
|
|
|
|
# Import prices
|
|
security.import_provider_prices(start_date: 30.days.ago.to_date, end_date: Date.current)
|
|
security.prices.count
|
|
```
|
|
|
|
## How It All Connects
|
|
|
|
### Search Flow
|
|
|
|
When a user types in the securities combobox:
|
|
|
|
1. `SecuritiesController#index` calls `Security.search_provider(query)` (`app/models/security/provided.rb`)
|
|
2. `search_provider` queries **all enabled providers concurrently** using `Concurrent::Promises` with an 8-second timeout per provider
|
|
3. Results are deduplicated (key: `ticker|exchange|provider`) and ranked by relevance
|
|
4. Each result's `ComboboxOption#id` is `"TICKER|EXCHANGE|PROVIDER"` (e.g., `"AAPL|XNAS|your_provider"`)
|
|
5. When the user selects one, `price_provider` is stored on the Security record
|
|
|
|
### Price Fetch Flow
|
|
|
|
When prices are needed:
|
|
|
|
1. `Security#price_data_provider` looks up the provider by `security.price_provider`
|
|
2. If the assigned provider is unavailable (disabled in settings), returns `nil` — the security is skipped, not silently switched
|
|
3. If no provider assigned, falls back to the first enabled provider
|
|
4. `Security::Price::Importer` calls `fetch_security_prices` and gap-fills missing dates using LOCF (last observation carried forward)
|
|
5. Prices are upserted in batches of 200
|
|
|
|
### Provider Resolution Priority
|
|
|
|
```text
|
|
security.price_provider present?
|
|
├── YES → Security.provider_for(price_provider)
|
|
│ ├── Provider enabled & configured → use it
|
|
│ └── Provider unavailable → return nil (skip security)
|
|
└── NO → Security.providers.first (first enabled provider)
|
|
```
|
|
|
|
## Checklist
|
|
|
|
- [ ] Provider class at `app/models/provider/your_provider.rb`
|
|
- [ ] Inherits from `Provider`
|
|
- [ ] Includes `SecurityConcept`
|
|
- [ ] Implements `search_securities`, `fetch_security_info`, `fetch_security_price`, `fetch_security_prices`
|
|
- [ ] Returns correct `Data.define` types
|
|
- [ ] All methods wrapped in `with_provider_response`
|
|
- [ ] Exchange names mapped to MIC codes
|
|
- [ ] Currency handling (cached or mapped)
|
|
- [ ] Rate limiting if applicable
|
|
- [ ] Registry entry in `app/models/provider/registry.rb`
|
|
- [ ] Added to `available_providers` for `:securities`
|
|
- [ ] Private factory method with ENV + Setting fallback
|
|
- [ ] Setting field in `app/models/setting.rb` (if API key needed)
|
|
- [ ] `field :your_provider_api_key`
|
|
- [ ] Added to `ENCRYPTED_FIELDS`
|
|
- [ ] Settings UI in hostings controller/view
|
|
- [ ] Translations in `config/locales/`
|
|
- [ ] Provider name in `provider_selection.providers`
|
|
- [ ] Provider name in `securities.providers` (for combobox display)
|
|
- [ ] Hint text and settings section
|
|
- [ ] Tested: search, single price, historical prices, info
|