Security Price Import Pipeline#
The pipeline fetches, gap-fills, and persists daily Security::Price rows for every online security. It runs as a scheduled background job and also fires inline during individual account syncs when a price is missing. Two jobs drive it:
| Job | Schedule | Purpose |
|---|---|---|
ImportMarketDataJob | Weekdays 5:00 PM EST (0 22 * * 1-5) | Bulk-imports prices and exchange rates for all users |
SecurityHealthCheckJob | Weekdays 2:00 AM EST (0 2 * * 1-5) | Validates that each security's provider can still return a price |
During an individual account sync, Account::Syncer#import_market_data also calls into this pipeline on-demand for any prices not yet in the DB; errors there are swallowed and reported to Sentry so a market data failure doesn't abort the sync.
Scheduled Import: ImportMarketDataJob → MarketDataImporter#
ImportMarketDataJob#perform passes mode: and clear_cache: to MarketDataImporter.new(...).import_all, which runs two steps:
import_security_prices— iteratesSecurity.onlineand callssecurity.import_provider_prices(start_date:, end_date:)for each record .import_exchange_rates— fetches currency-pair rates needed when account or entry currency differs from the family's base currency.
Start-date logic (full vs. snapshot mode) :
:full(default, daily job) — start date is the earliest trade date for that security, covering full history.:snapshot— alwaysSNAPSHOT_DAYS = 31days ago; used for lightweight refreshes.
End date is always today in EST to avoid querying a US-based API for a future date .
Per-Security Import: Security#import_provider_prices → Security::Price::Importer#
Security#import_provider_prices (defined in the Security::Provided concern) constructs a Security::Price::Importer and calls import_provider_prices on it. The importer:
- Short-circuits if all expected non-provisional prices for the date range already exist in the DB .
- Calls the provider via
security_provider.fetch_security_prices(...). On failure: logs, records aDebugLogEntry, stores@provider_error, and returns an empty hash. - Gap-fills with LOCF (Last Observation Carried Forward) — iterates
fill_start_date..end_dateand carries the previous valid price forward for any date without a provider value . - Tags provisional — LOCF-filled prices within the last
PROVISIONAL_LOOKBACK_DAYS = 7days are markedprovisional: trueand re-fetched on the next run . - Upserts in batches of 200 via
Security::Price.upsert_all, keyed on(security_id, date, currency).
Pre-listing / IPO gap handling: When the provider has no data before the user's start date (e.g., a 2023 Reddit trade before the 2024 IPO), the importer advances fill_start_date to the earliest valid provider price and stores that in security.first_provider_price_on, so future syncs skip the pre-listing range entirely .
clear_cache mode: Ignores existing DB prices in favour of fresh provider data. Used for manual refreshes and can rediscover earlier provider history .
Security::Price Model#
Security::Price validates presence of date, price, and currency, and enforces uniqueness on (date, security_id, currency) . The refetchable_provisional scope returns provisional rows within the last N days (default 7) — this is what the short-circuit check queries to decide whether re-fetching is needed.
Health Checks: Security::HealthChecker#
SecurityHealthCheckJob calls Security::HealthChecker.check_all, which processes two scopes :
- Never-checked — no daily cap; processed in full immediately.
- Due for re-check — not checked in the last 7 days; at most
DAILY_BATCH_SIZE = 1000per run, oldest-first.
For each security, run_check fetches today's price via provider.fetch_security_price(...). Alpha Vantage is exempt: the check is skipped to conserve its tight daily API quota .
Failure Handling and Offline Promotion#
| Outcome | Action |
|---|---|
| Success | Reset failed_fetch_count → 0, offline → false |
| Failure, cumulative count ≤ 5 | Increment failed_fetch_count, record failed_fetch_at |
Failure, count > MAX_CONSECUTIVE_FAILURES = 5 | Set offline: true, offline_reason: "health_check_failed", delete all existing prices in a transaction |
The offline flag is the gate in MarketDataImporter.import_security_prices; offline securities are silently skipped via Security.online.find_each . The provider_status method surfaces :ok, :stale, :offline, :no_provider, or :provider_unavailable for UI and diagnostics.
Key Files#
| File | Role |
|---|---|
app/jobs/import_market_data_job.rb | Scheduled entry point; passes options to MarketDataImporter |
app/models/market_data_importer.rb | Orchestrates bulk price and exchange-rate import |
app/models/security/provided.rb | import_provider_prices, price_data_provider, provider_status |
app/models/security/price/importer.rb | Core import logic: provider fetch, LOCF gap-fill, provisional tagging, upsert |
app/models/security/price.rb | Security::Price model; refetchable_provisional scope |
app/models/security/health_checker.rb | Health check; offline promotion after 5 consecutive failures |
app/jobs/security_health_check_job.rb | Scheduled entry point for health checks |
config/schedule.yml | Cron schedules for all background jobs |