💰 Asset Pricing & Metadata Architecture
The Asset system in LibreFolio manages financial instruments (Stocks, ETFs, Bonds, Crypto, etc.), fetches their prices from external providers, and maintains their metadata.
📦 Canonical Package and Compatibility
Asset pricing services live in backend/app/services/asset_sources/. Each module owns one
responsibility:
| Module | Responsibility |
|---|---|
core.py |
Provider contract and shared infrastructure: AssetSourceProvider, AssetSourceError, history start-date types, the five cache singletons, provider-parameter hashing, error sanitization, the OHLC guard, and _run_provider_in_thread. |
provider_management.py |
Provider assignment, removal and lookup; parametric-provider data invalidation; and the non-persisting provider probe. |
metadata.py |
Explicit provider metadata refresh, metadata caching, and application of returned patches through AssetCRUDService. |
price_store.py |
Price and event upserts/deletes, currency-change market-data wipes, OHLC integrity checks, and bounded write chunks. |
price_query.py |
DB-backed history/event queries, backward fill, FX and signal preparation, plus current-price lookup and today's OHLC write-back. |
refresh.py |
Provider-driven price refresh and its explicit PREPARE/FETCH/PERSIST phases. |
crud.py |
Asset create, list, patch, delete, and merge operations. |
search.py |
Cross-provider batch search, SSE search, query caching, link-finder fallback, and URL resolution. |
manager.py |
The single AssetSourceManager class, composed from the provider, metadata, store, query, and refresh operation mixins. |
These are behavioral boundaries, not only file boundaries: refresh.py orchestrates remote
acquisition, price_store.py owns reusable price/event persistence primitives, and
price_query.py owns database-backed reads plus the separate live-quote path.
backend/app/services/asset_sources/__init__.py exposes the canonical public objects.
backend/app/services/asset_source.py remains a thin Release 2 compatibility facade: it
re-exports those same class, error, type, and service objects rather than wrapping, copying, or
subclassing them.
Canonical imports and legacy compatibility
New code should import the provider base class and error from
backend.app.services.asset_sources.core. The thread runner and all cache patch points also
belong to that module. Existing public imports from
backend.app.services.asset_source remain supported during Release 2 and resolve to the
identical objects.
🧱 Core Components
1️⃣ AssetSourceProvider (Plugin Base Class)
Abstract base class for all asset pricing plugins, canonically defined in
backend/app/services/asset_sources/core.py. Each provider auto-registers via
@register_provider(AssetProviderRegistry).
| Property / Method | Required | Default | Description |
|---|---|---|---|
provider_code |
✅ | — | Unique identifier (e.g., "yfinance") |
provider_name |
✅ | — | Human-readable name |
test_cases |
✅ | — | Test data for automated testing |
test_search_query |
✅ | — | Search query for tests (None if search unsupported) |
get_current_value() |
✅ | — | Fetch latest price → FACurrentValue |
get_history_value() |
✅ | — | Fetch historical OHLCV → FAHistoricalData |
validate_params() |
✅ | no-op | Validate provider-specific configuration |
params_schema |
— | [] |
Schema for dynamic form generation in the frontend |
get_asset_url() |
— | None |
URL to the provider's page for this asset |
resolvable_url_domains |
— | [] |
Domains accepted by resolve_url; non-empty means supports_url_resolution = True |
resolve_url(url) |
— | None |
Interactive-search hook: provider page URL → one search-item dict, list of dicts, or None |
accepted_identifier_types |
— | [TICKER, ISIN] |
Input types accepted by this provider |
supports_history |
— | True |
False for current-price-only providers |
search() |
— | raises NOT_SUPPORTED |
Search for assets by name/ticker/ISIN |
fetch_asset_metadata() |
— | None |
Fetch asset metadata (type, sector, identifiers) |
provider_help_url |
— | None |
URL to the provider's documentation page |
supports_search heuristic
The list_providers endpoint determines search support via instance.test_search_query is not None — a local property check, not an HTTP call. This avoids cold-start latency on GET /assets/provider.
2️⃣ AssetSourceManager
The stable manager class in asset_sources/manager.py composes the responsibility-specific
operation mixins:
- Provider Assignment: Link an asset to a provider (e.g., "AAPL" → "yfinance").
- Price Sync: 3-phase pipeline: PREPARE → FETCH → PERSIST (see Data Flow below).
- Price Query: DB-only bulk query with backward-fill via
POST /assets/prices/query. - Current Price: Bulk live-price endpoint via
POST /assets/prices/current. Calls each asset's providerget_current_value()with DB fallback. Polled by the Assets list and the asset detail page for their live prices. - Event Sync: Persist asset events from providers into the
asset_eventstable, filtered byprovider_assignment_id(manual events survive sync). - Probe: Dry-run provider config testing via
probe_provider_config().
3️⃣ AssetProviderRegistry
Uses the Registry Pattern for auto-discovery of provider plugins.
4️⃣ AssetEvent Table
Stores asset-level events produced by providers or created manually by users. Events are distinct from portfolio transactions:
- Events describe what happens to the asset globally (DIVIDEND, INTEREST, SPLIT, etc.)
- Transactions describe what happens in a user's portfolio (BUY, SELL)
Dedup strategy: Events with a provider_assignment_id (auto-generated) are replaced per
(asset_id, date, type, provider_assignment_id) key during sync. Events with
provider_assignment_id = NULL are user-created manual events and are never replaced by a
provider upsert.
See Asset Events for full details.
🔄 Data Flow: Sync Pipeline
When the user triggers a sync (or the scheduler does), bulk_refresh_prices() executes a 3-phase pipeline:
graph TD
API["POST /assets/prices/sync"] --> Manager["AssetSourceManager.bulk_refresh_prices()"]
subgraph "Phase 1 — PREPARE"
Manager --> Prepare["refresh._prepare_refresh_items"]
Prepare --> DB1[("Caller AsyncSession<br/>(read-only)")]
Prepare --> Registry["AssetProviderRegistry"]
end
subgraph "Phase 2 — FETCH (parallel)"
Prepare --> Fetch["refresh._fetch_refresh_items<br/>(no database access)"]
Fetch --> Thread["core._run_provider_in_thread"]
Thread --> P1["Provider A"]
Thread --> P2["Provider B"]
end
subgraph "Phase 3 — PERSIST"
Fetch --> Persist["refresh._persist_refresh_items"]
Persist --> Store["price_store operations"]
Store --> DB2[("One AsyncSession<br/>per asset")]
end
Persist -- "FARefreshResult per asset" --> API
style P1 fill:#e3f2fd,stroke:#1565c0
style P2 fill:#e3f2fd,stroke:#1565c0
style DB1 fill:#f3e5f5,stroke:#7b1fa2
style DB2 fill:#f3e5f5,stroke:#7b1fa2
- PREPARE uses the caller's
AsyncSessionfor reads only. It resolves assets, assignments, provider instances, validated parameters, andresumedates into immutable prepared records. It performs no provider I/O and no writes. - FETCH receives no database session. It uses an
asyncio.Semaphore, the core history and current caches, andcore._run_provider_in_thread()for provider history/current calls. Price data and best-effort provider events remain in memory. - PERSIST creates a distinct
AsyncSessionfor each asset. It compares fetched points with stored rows, then delegates price/event writes toprice_store.py.
When the range reaches today, FETCH asks a provider's history only up to yesterday, then calls
get_current_value() and adds its value as the close of its as_of_date (today when the provider
gives none), replacing any history point of that date; PERSIST stores it with the rest. A provider
without history (supports_history = False, such as the CSS Scraper) therefore builds its series
one sync at a time, one point per day; outside syncs, the
current-quote write-back stores the same day's point.
Persistence intentionally does not claim one atomic transaction for the whole refresh.
bulk_upsert_prices() commits bounded chunks of
PRICE_UPSERT_CHUNK_SIZE = 1000; event upserts commit their own bounded chunks; and
last_fetch_at is committed separately afterwards. This keeps SQLite writer-lock windows bounded
and preserves truthful partial results when one step fails. Provider-generated events remain
scoped by provider_assignment_id, so manual events are not replaced.
📊 Data Flow: Price Query
POST /assets/prices/query is the primary read endpoint for price data. It reads directly from the database (no provider calls) and applies backward-fill for gaps.
graph LR
FE["Frontend"] -- "POST /assets/prices/query" --> API["API Endpoint"]
API --> Manager["AssetSourceManager.get_prices_bulk()"]
Manager -- "DB-only reads" --> DB[(Database)]
DB -- "Raw prices" --> Manager
Manager -- "Backward-fill gaps" --> Manager
Manager -- "FAPriceQueryResponse" --> FE
style DB fill:#f3e5f5,stroke:#7b1fa2
Each query item can request:
include_price: true— price history with backward-fillinclude_events: true— asset events in the date range
get_prices_bulk() never invokes a provider. Provider acquisition belongs to
refresh.py through POST /assets/prices/sync; the query path remains DB-only while retaining
its warm-up, event, FX-conversion, signal-computation, and final response-slicing passes.
With target_currency, the conversion pass converts every close not already in that currency
through one convert_bulk() call per result, scales open, high and low by the same factor, keeps
the native values in the original_* fields, and records the rate's date in
backward_fill_info.fx_rate_date / fx_days_back. A point whose rate cannot be resolved keeps its
native price and currency, and the result's errors lists the failures, deduplicated and capped at
ten plus one "… and N more" line. The query never registers an FX pair: the asset page reports
the pair through its requiredFxPairs banners, and its comparison loader drops those points (see
Chart internals).
💹 Current Quotes and Today's OHLC
get_current_prices_bulk() is deliberately separate from the DB-only historical query. It checks
the core current-price cache, calls an assigned provider through
core._run_provider_in_thread(), and falls back to the latest PriceHistory row if the provider
fails or is absent.
A successful provider quote dated today is written back to today's OHLC row:
- a missing row is created with
open = high = low = close = valueandvolume = None; - an existing row keeps its volume, widens
low/high, fillsopenonly when it is missing, and overwritesclosewith the latest quote; - a
db:last_knownfallback is never written as if it were a fresh quote.
The write-back is best-effort: a commit failure is rolled back and logged without discarding the current-price response.
🧪 Provider Probe
POST /assets/provider/probe allows dry-run testing of a provider configuration without persisting anything.
Operations (selectable per request):
| Operation | What it tests |
|---|---|
current_price |
Fetches latest price → validates provider can reach the asset |
history |
Fetches the last 7 days of data → validates historical data availability |
metadata |
Fetches asset metadata → validates identifier resolution |
Each operation returns success, execution_time_ms, and operation-specific data. The frontend
runs current_price + history for the Test Configuration button in the provider assignment
section, and automatically after a search result is picked; it counts NO_DATA and
NOT_IMPLEMENTED as soft failures (a ⚠️ that still passes the test). The asset modal's
Ask Provider, and the comparison that follows a search selection, run metadata alone and
diff its patch_data against the form in the browser: nothing is written until the asset is saved.
🔎 Interactive Search Stack
GET /assets/provider/search and GET /assets/provider/search/stream use a three-layer stack:
- On-site provider search —
provider.search(query)runs first. web_link_finder— only if on-site search returns 0 items, the provider opts intoresolvable_url_domains, and the finder is enabled. It uses theddgslibrary by default to find provider-domain candidate URLs.resolve_url— each candidate URL is resolved into one search-item dict, a list of dicts, orNone. Lists are flattened and de-duplicated by(identifier, language).
The identifier post-filter keeps only resolved items whose identifier matches a known hint/query term when such a match exists. This is how a broad external result set is narrowed to an exact ISIN from a broker report.
Interactive only
The link-finder and resolve_url run only during interactive search. Price sync, current-price
fetches, and historical fetches never call external search; they use stored identifiers and
provider_params only.
📊 Backward Fill Logic
Financial markets are closed on weekends and holidays. To provide a continuous price series for charts, LibreFolio uses a backward-fill strategy.
If a price is requested for a date where no data exists (e.g., Sunday), the system looks back to find the most recent available price (e.g., Friday's close) and uses that. The backward_fill_info field in FAPricePoint indicates the actual date and staleness (days_back).
Backward fill reuses stored market prices only, and valuation adds no separate
last-purchase-price fallback: the portfolio engine values each holding through the
Price Resolver alone. On a day without a
market price, the resolver falls back on the asset's own trades (BUY, SELL, priced ADJUSTMENT)
when one is the latest observation, as for a fund with no published NAV, and the holding then
reports valuation_source = LAST_TRADE_PRICE.
⚡ Cache & Performance
- Canonical cache ownership:
asset_sources/core.pyowns the history, current, metadata, search-result, and search-query singleton caches. Responsibility modules dereference these objects throughcore, preserving one identity and one monkeypatch point. _asset_current_cache: caches provider current-value results. The live-query path keys by provider code, identifier, and identifier type; refresh keys also include the provider-parameter hash so parameter changes cannot reuse stale data.- Provider Pre-warm:
_prewarm_provider_caches()runs asynchronously in the application lifespan, instantiating registered providers and allowing providers with internal data caches to warm them. supports_searchcheck: Usestest_search_query is not None(local property), avoiding cold-start HTTP calls onGET /assets/provider.- Bulk historical queries:
get_prices_bulk()reads only the database. It loads the main in-range price set in one query, then performs DB-only seed, event, FX, and signal work as requested.
🌐 API Endpoints Summary
| Endpoint | Method | Description |
|---|---|---|
POST /api/v1/assets |
POST | Bulk create assets |
PATCH /api/v1/assets |
PATCH | Bulk update assets |
GET /api/v1/assets |
GET | Bulk read by IDs (with metadata) |
GET /api/v1/assets/all |
GET | All active assets |
GET /api/v1/assets/query |
GET | Filtered list (search, type, currency, active) |
DELETE /api/v1/assets |
DELETE | Bulk delete (cascade provider+prices) |
GET /api/v1/assets/provider |
GET | List providers (with params_schema) |
GET /api/v1/assets/provider/search |
GET | Multi-provider parallel search |
GET /api/v1/assets/provider/search/stream |
GET | SSE streaming search results |
POST /api/v1/assets/provider/probe |
POST | Dry-run provider config test |
POST /api/v1/assets/provider |
POST | Bulk assign providers |
DELETE /api/v1/assets/provider |
DELETE | Bulk remove providers |
GET /api/v1/assets/provider/assignments |
GET | Read provider assignments |
POST /api/v1/assets/provider/refresh |
POST | Refresh metadata from provider |
POST /api/v1/assets/prices |
POST | Bulk upsert prices |
DELETE /api/v1/assets/prices |
DELETE | Bulk delete price ranges |
POST /api/v1/assets/prices/query |
POST | Bulk price query (DB-only, backward-fill) |
POST /api/v1/assets/prices/current |
POST | Bulk current quotes with DB fallback and today's OHLC write-back |
POST /api/v1/assets/prices/sync |
POST | Bulk refresh prices from provider |
GET /api/v1/assets/prices/signals |
GET | Signal catalog: the visible plugins compatible with Asset prices |
POST /api/v1/assets/events |
POST | Bulk upsert manual events (the asset's currency only) |
DELETE /api/v1/assets/events |
DELETE | Bulk delete events by id; an event a transaction uses comes back in_use |
POST /api/v1/assets/events/query |
POST | Bulk event query, each event with its id and is_auto flag |
GET /api/v1/assets/events |
GET | Events by id |
Interactive search internals
The two provider/search endpoints are the entry point of a three-layer stack (on-site
search → ddgs web link-finder → resolve_url). See
Asset Search & Link-Finder for the orchestration, the hints
two-stage query, and the identifier post-filter.
🔗 Related Documentation
- 📊 Assets & Pricing ER Diagram — Database schema
- 🔎 Asset Search & Link-Finder — Three-layer interactive search (
ddgsmetasearch last resort) - 📅 Asset Events — Event types, dedup strategy, auto-generation
- 🔌 System Providers — CSS Scraper & Scheduled Investment
- 📦 Providers Overview — All available providers
- 📈 Asset Plugin Guide — How to create a new provider