Vai al contenuto

๐Ÿ’ฑ FX Plugin Guide

How to create a new FX Rate Provider to fetch exchange rates from a new central bank or data source.

Base class: FXRateProvider (in backend/app/services/fx.py) Plugin folder: backend/app/services/fx_providers/<name>/ โ€” the code in plugin.py, beside an __init__.py holding only its docstring (see Plugin Folder) Registry: FXProviderRegistry


๐Ÿค– Tip: Let an LLM scaffold the provider

Paste this entire page into an LLM with the central bank or API documentation and use this prompt:

Suggested prompt

Here is the LibreFolio FX Rate Provider specification: [paste this page]

I want to add an FX provider for [SOURCE_NAME] (e.g., a central bank or FX data API).
Their API works like this: [brief description or paste API docs / sample response]
Base currency: [e.g., EUR]
Supported currencies: [e.g., USD, GBP, JPY, CHF]

Write a complete Python FXRateProvider implementation. Follow all conventions:
@register_provider, correct fetch_rates return type, multi_unit_currencies if needed,
description_i18n, warning_i18n if applicable. Use asyncio.to_thread for sync HTTP calls.

After scaffolding, test with a manual sync from the FX settings page. Save it as fx_providers/<name>/plugin.py, beside an __init__.py holding only a docstring, and restart โ€” it will appear automatically as a selectable provider.


๐Ÿ”„ Flow

The system calls provider methods in two distinct phases:

graph TD
    subgraph "Phase 1 โ€” Exploration"
        E1["get_supported_currencies()"] --> E2["Returns: list[str]<br/><small>e.g., ['USD','GBP','JPY','CHF']</small>"]
        E2 --> E3["Frontend builds<br/>currency graph<br/><small>Available pairs for routing</small>"]
    end

    subgraph "Phase 2 โ€” Sync"
        S1["fetch_rates(<br/>date_range,<br/>currencies,<br/>base_currency)"] --> S2["Returns raw rates<br/><small>dict[currency โ†’<br/>(date, base, quote, rate)[]]</small>"]
        S2 --> S3["Service normalizes<br/><small>Alphabetical base&lt;quote<br/>Multi-unit รท100</small>"]
        S3 --> S4["Stored in<br/>FX_RATE table"]
    end

    E3 ~~~ S1

    style E1 fill:#e3f2fd,stroke:#1565c0
    style E2 fill:#e3f2fd,stroke:#1565c0
    style E3 fill:#e3f2fd,stroke:#1565c0
    style S1 fill:#e8f5e9,stroke:#2e7d32
    style S2 fill:#fff3e0,stroke:#e65100
    style S3 fill:#fff3e0,stroke:#e65100
    style S4 fill:#f3e5f5,stroke:#7b1fa2

Phase 1 happens when a user adds a new currency pair โ€” the system queries providers to discover which currencies they support.

Phase 2 happens during sync โ€” the system requests actual rate data for specific date ranges.

Plugin responsibility: Fetch raw rates from the provider's API. Apply multi-unit adjustment (e.g., JPY รท 100). Return data in the provider's native format (no inversion, no alphabetical ordering). Core responsibility: Normalize for storage (alphabetical base < quote), skip duplicates, handle fallback chains.


๐Ÿ“‹ ABC Methods

โœ… Required (Abstract)

Method Signature Description
code @property โ†’ str Provider code (e.g., "ECB", "FED")
name @property โ†’ str Display name (e.g., "European Central Bank")
base_currency @property โ†’ str Primary base currency (e.g., "EUR" for ECB)
get_supported_currencies() async โ†’ list[str] List of available quote currencies (can be static or dynamic)
fetch_rates(date_range, currencies, base_currency) async โ†’ dict[str, list[tuple]] Fetch rates for date range. Returns {currency: [(date, base, quote, rate), ...]}

๐Ÿ”ง Optional (Override)

Method Default Description
icon None Provider icon URL
base_currencies [base_currency] List of supported bases (single-base for now)
description Auto-generated Provider description
description_i18n {"en": description} Multilingual descriptions
warning_i18n {} Data-quality warnings shown in UI (โš ๏ธ triangle on routes)
docs_url Generic providers list URL to documentation page
test_currencies ["USD","EUR","GBP","JPY","CHF"] Currencies that must be available for tests
multi_unit_currencies set() Currencies quoted per 100 units (e.g., JPY, SEK). Plugin must normalize รท100.
generate_static_url(path) โ€” Helper to build /api/v1/uploads/plugin/fx/{path}

๐Ÿ’ป Implementation Example

# backend/app/services/fx_providers/my_central_bank/plugin.py

from datetime import date
from decimal import Decimal
from backend.app.services.fx import FXRateProvider
from backend.app.services.provider_registry import register_provider, FXProviderRegistry

@register_provider(FXProviderRegistry)
class MyCentralBankProvider(FXRateProvider):

    @property
    def code(self) -> str:
        return "MCB"

    @property
    def name(self) -> str:
        return "My Central Bank"

    @property
    def base_currency(self) -> str:
        return "MYC"  # This bank quotes all rates as 1 MYC = X foreign

    @property
    def icon(self) -> str:
        return self.generate_static_url("mcb/logo.svg")

    @property
    def multi_unit_currencies(self) -> set[str]:
        return {"JPY"}  # JPY quoted per 100 units by this bank

    async def get_supported_currencies(self) -> list[str]:
        # Return list of currencies this provider can fetch
        return ["USD", "EUR", "GBP", "JPY", "CHF"]

    async def fetch_rates(
        self,
        date_range: tuple[date, date],
        currencies: list[str],
        base_currency: str | None = None,
    ) -> dict[str, list[tuple[date, str, str, Decimal]]]:
        # Fetch from your API
        start, end = date_range
        result = {}
        for cur in currencies:
            raw_data = await self._call_api(cur, start, end)
            rates = []
            for row in raw_data:
                rate = Decimal(str(row["rate"]))
                # Multi-unit adjustment (plugin responsibility)
                if cur in self.multi_unit_currencies:
                    rate = rate / 100
                rates.append((row["date"], self.base_currency, cur, rate))
            result[cur] = rates
        return result

๐Ÿ—‚๏ธ Plugin Folder

A provider is a folder of fx_providers/. Restart the app: FXProviderRegistry loads its plugin.py as backend.app.services.fx_providers.my_central_bank.plugin. Import and patch the provider's names from that module โ€” from backend.app.services.fx_providers.manual.plugin import MANUAL_PRIORITY, patch("backend.app.services.fx_providers.ecb.plugin.httpx"): the package re-exports nothing, so a patch aimed at it fails. A helper only this provider uses sits beside plugin.py as _*.py. Discovery, the layout and the gate that keeps it (./dev.py test utils gate-plugin-layout) are in Plugin Folder Layout.

FX providers have no catalogue keys, so no i18n/ folder: the texts a provider shows travel with it through the API, in every language it declares (description_i18n, warning_i18n).

๐Ÿ–ผ๏ธ Static Assets (Icons)

Place provider icons in the family's backend/app/services/fx_providers/static/mcb/, not in the provider's folder:

fx_providers/
โ”œโ”€โ”€ static/
โ”‚   โ””โ”€โ”€ mcb/
โ”‚       โ””โ”€โ”€ logo.svg    โ† served at /api/v1/uploads/plugin/fx/mcb/logo.svg
โ””โ”€โ”€ my_central_bank/
    โ”œโ”€โ”€ __init__.py     โ† its docstring and nothing else
    โ””โ”€โ”€ plugin.py       โ† the code above