Output:
diff --git a/src/jarvis_finance/cli/main.py b/src/jarvis_finance/cli/main.py
index 46b6203..bf98845 100644
--- a/src/jarvis_finance/cli/main.py
+++ b/src/jarvis_finance/cli/main.py
@@ -5,13 +5,13 @@ from decimal import Decimal
 from pathlib import Path

 from jarvis_finance.config.settings import ensure_runtime_dirs, load_settings
-from jarvis_finance.fx.providers import MockFxProvider
+from jarvis_finance.fx.providers import FrankfurterFxProvider, MockFxProvider, TwelveDataFxProvider, fx_provider_by_name
 from jarvis_finance.fx.rates import recheck_transaction_fx_status, update_fx_rates
 from jarvis_finance.imports.instrument_candidates import qwen_resolve_staged_candidates, stage_docx_candidates
 from jarvis_finance.market.providers import CoinGeckoClient, refresh_crypto_prices
 from jarvis_finance.market_data.candidates import CompositePublicLookupProvider, EmptyLookupProvider, dedupe_missing_provider_symbol_alerts, export_mapping_decision_pack, export_mapping_review_template, generate_mapping_candidates, recalculate_candidate_rankings
 from jarvis_finance.market_data.catalog import prepare_catalog_from_instruments
-from jarvis_finance.market_data.prices import FmpEquityPriceProvider, MockEquityPriceProvider, refresh_market_prices
+from jarvis_finance.market_data.prices import FmpEquityPriceProvider, MockEquityPriceProvider, equity_price_provider_by_name, refresh_market_prices
 from jarvis_finance.quality.data_quality import check_crypto_data_quality
 from jarvis_finance.quality.git_safety import assert_safe, scan_path
 from jarvis_finance.runtime.backup import backup_runtime_db, restore_runtime_db, verify_backup
@@ -50,6 +50,7 @@ def main(argv: list[str] | None = None) -> int:
     fx.add_argument("--missing-only", action="store_true")
     fx.add_argument("--dry-run", action="store_true")
     fx.add_argument("--currency", action="append", dest="currencies")
+    fx.add_argument("--provider", choices=["frankfurter", "twelvedata", "mock"], default="frankfurter")
     fx.add_argument("--mock-rate", action="append", default=[], help="Test-only BASE=RATE, e.g. USD=0.9")
     fx.add_argument("--recheck-transactions", action="store_true")
     fx.add_argument("--source-type")
@@ -70,6 +71,8 @@ def main(argv: list[str] | None = None) -> int:
     equity_prices.add_argument("--limit", type=int)
     equity_prices.add_argument("--dry-run", action="store_true")
     equity_prices.add_argument("--date", dest="price_date")
+    equity_prices.add_argument("--provider", choices=["fmp", "twelvedata", "finnhub", "massive", "eodhd", "mock"], default="fmp")
+    equity_prices.add_argument("--allow-low-volume", action="store_true", help="Allow explicit low-volume providers such as EODHD")
     equity_prices.add_argument("--mock-price", action="append", default=[], help="Test-only SYMBOL=PRICE")
     catalog = sub.add_parser("prepare-instrument-catalog")
     catalog.add_argument("--source", default="true_wealth")
@@ -180,7 +183,12 @@ def main(argv: list[str] | None = None) -> int:
                 base, rate = item.split("=", 1)
                 mock_rates[(base.upper(), "CHF", args.rate_date or "latest")] = Decimal(rate)
             currencies = args.currencies or sorted({r["currency_original"] for r in conn.execute("SELECT DISTINCT currency_original FROM transactions WHERE currency_original IS NOT NULL").fetchall()})
-            provider = MockFxProvider(mock_rates)
+            if args.mock_rate or args.provider == "mock":
+                provider = MockFxProvider(mock_rates)
+            elif args.provider == "twelvedata":
+                provider = TwelveDataFxProvider()
+            else:
+                provider = FrankfurterFxProvider()
             result = update_fx_rates(conn, provider=provider, currencies=currencies, rate_date=args.rate_date or "latest", latest=args.latest, missing_only=args.missing_only, dry_run=args.dry_run)
             print(
                 "FX_RATE_UPDATE "
@@ -201,7 +209,14 @@ def main(argv: list[str] | None = None) -> int:
             for item in args.mock_price:
                 symbol, price = item.split("=", 1)
                 prices[symbol.upper()] = Decimal(price)
-            provider = MockEquityPriceProvider(prices) if prices or args.command == "update-market-prices" else FmpEquityPriceProvider()
+            if args.command == "update-market-prices":
+                provider = MockEquityPriceProvider(prices)
+            elif prices or args.provider == "mock":
+                provider = MockEquityPriceProvider(prices)
+            elif args.provider == "eodhd" and not args.allow_low_volume:
+                parser.error("EODHD is low-volume; pass --provider eodhd --allow-low-volume explicitly")
+            else:
+                provider = equity_price_provider_by_name(args.provider)
             asset_classes = ["stock", "etf"] if args.command == "update-equity-prices" and args.asset_class == "all" else ["stock" if args.asset_class == "equity" else args.asset_class]
             totals = []
             exit_code = 0
diff --git a/src/jarvis_finance/fx/providers.py b/src/jarvis_finance/fx/providers.py
index 2efee73..e7de654 100644
--- a/src/jarvis_finance/fx/providers.py
+++ b/src/jarvis_finance/fx/providers.py
@@ -1,6 +1,10 @@
 from __future__ import annotations

-from decimal import Decimal
+import json
+from decimal import Decimal, InvalidOperation
+from urllib import error, parse, request
+
+from jarvis_finance.market_data.catalog import _runtime_secret_value

 class HistoricalFxUnavailable(RuntimeError):
@@ -22,3 +26,89 @@ class MockFxProvider:
         if key in self.unavailable:
             raise HistoricalFxUnavailable("historical FX unavailable/paywalled")
         return self.rates.get(key)
+
+
+class FrankfurterFxProvider:
+    name = "frankfurter"
+
+    def __init__(self, *, timeout_seconds: float = 8.0) -> None:
+        self.timeout_seconds = timeout_seconds
+
+    def get_rate(self, base_currency: str, quote_currency: str, rate_date: str | None = None) -> Decimal | None:
+        base = base_currency.upper(); quote = quote_currency.upper()
+        if base == quote:
+            return Decimal("1")
+        endpoint = "latest" if not rate_date or rate_date == "latest" else rate_date
+        url = f"https://api.frankfurter.app/{endpoint}?" + parse.urlencode({"from": base, "to": quote})
+        try:
+            req = request.Request(url, headers={"Accept": "application/json"})
+            with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit CLI FX update
+                payload = json.loads(response.read().decode("utf-8"))
+        except error.HTTPError as exc:
+            if exc.code == 429:
+                raise HistoricalFxUnavailable("frankfurter_rate_limited", status="rate_limited") from exc
+            raise HistoricalFxUnavailable("frankfurter_provider_error") from exc
+        except error.URLError as exc:
+            raise HistoricalFxUnavailable("frankfurter_network_error") from exc
+        try:
+            value = payload.get("rates", {}).get(quote)
+            return Decimal(str(value)) if value not in {None, ""} else None
+        except (InvalidOperation, ValueError, AttributeError):
+            return None
+
+
+class TwelveDataFxProvider:
+    name = "twelvedata"
+
+    def __init__(self, *, api_key: str | None = None, timeout_seconds: float = 8.0) -> None:
+        self.api_key = api_key or _runtime_secret_value(("TWELVEDATA_API_KEY", "TWELVE_DATA_API_KEY", "JARVIS_TWELVEDATA_API_KEY", "JARVIS_TWELVE_DATA_API_KEY"))
+        self.timeout_seconds = timeout_seconds
+
+    def get_rate(self, base_currency: str, quote_currency: str, rate_date: str | None = None) -> Decimal | None:
+        base = base_currency.upper(); quote = quote_currency.upper()
+        if base == quote:
+            return Decimal("1")
+        if not self.api_key:
+            raise HistoricalFxUnavailable("twelvedata_api_key_missing", status="manual_override_required")
+        symbol = f"{base}/{quote}"
+        endpoint = "exchange_rate" if not rate_date or rate_date == "latest" else "time_series"
+        params: dict[str, str] = {"symbol": symbol, "apikey": self.api_key}
+        if endpoint == "time_series":
+            params.update({"interval": "1day", "start_date": rate_date, "end_date": rate_date, "outputsize": "1"})
+        url = "https://api.twelvedata.com/" + endpoint + "?" + parse.urlencode(params)
+        try:
+            req = request.Request(url, headers={"Accept": "application/json"})
+            with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit CLI FX update
+                payload = json.loads(response.read().decode("utf-8"))
+        except error.HTTPError as exc:
+            if exc.code in {401, 403}:
+                raise HistoricalFxUnavailable("twelvedata_auth_failed", status="forbidden") from exc
+            if exc.code == 429:
+                raise HistoricalFxUnavailable("twelvedata_rate_limited", status="rate_limited") from exc
+            raise HistoricalFxUnavailable("twelvedata_provider_error") from exc
+        except error.URLError as exc:
+            raise HistoricalFxUnavailable("twelvedata_network_error") from exc
+        if isinstance(payload, dict) and str(payload.get("status") or "").lower() == "error":
+            raise HistoricalFxUnavailable("twelvedata_provider_error")
+        value = None
+        if endpoint == "exchange_rate" and isinstance(payload, dict):
+            value = payload.get("rate")
+        elif isinstance(payload, dict):
+            values = payload.get("values", [])
+            if values and isinstance(values[0], dict):
+                value = values[0].get("close")
+        try:
+            return Decimal(str(value)) if value not in {None, ""} else None
+        except (InvalidOperation, ValueError):
+            return None
+
+
+def fx_provider_by_name(name: str):
+    key = (name or "").lower().replace("_", "").replace("-", "")
+    if key == "frankfurter":
+        return FrankfurterFxProvider()
+    if key in {"twelvedata", "twelvedata"}:
+        return TwelveDataFxProvider()
+    if key == "mock":
+        return MockFxProvider({})
+    raise ValueError("unknown fx provider")
diff --git a/src/jarvis_finance/market_data/catalog.py b/src/jarvis_finance/market_data/catalog.py
index 8765a97..236a2be 100644
--- a/src/jarvis_finance/market_data/catalog.py
+++ b/src/jarvis_finance/market_data/catalog.py
@@ -269,6 +269,24 @@ class FinnhubSymbolLookupProvider(_HttpJsonLookupProvider):
         url = "https://finnhub.io/api/v1/search?" + parse.urlencode({"q": query, "token": self.api_key})
         return self._get_json(url)

+    def _get_profile(self, symbol: str) -> dict[str, object]:
+        if not self.api_key or not symbol:
+            return {}
+        try:
+            payload = self._get_json("https://finnhub.io/api/v1/stock/profile2?" + parse.urlencode({"symbol": symbol, "token": self.api_key}))
+        except LookupUnavailable:
+            return {}
+        return payload if isinstance(payload, dict) else {}
+
+    def _get_quote(self, symbol: str) -> dict[str, object]:
+        if not self.api_key or not symbol:
+            return {}
+        try:
+            payload = self._get_json("https://finnhub.io/api/v1/quote?" + parse.urlencode({"symbol": symbol, "token": self.api_key}))
+        except LookupUnavailable:
+            return {}
+        return payload if isinstance(payload, dict) else {}
+
     def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
         payload = self._request(query)
         rows = payload.get("result", []) if isinstance(payload, dict) else []
@@ -280,8 +298,12 @@ class FinnhubSymbolLookupProvider(_HttpJsonLookupProvider):
             description = str(item.get("description") or symbol or query).strip()
             if not symbol:
                 continue
+            profile = self._get_profile(symbol)
+            quote = self._get_quote(symbol)
             inferred_class = asset_class or ("etf" if "etf" in description.lower() else "stock")
-            results.append(CatalogSearchResult(None, inferred_class, description, ticker=symbol.split(".")[0], exchange=None, provider="finnhub", provider_symbol=symbol, confidence="low", source="finnhub", evidence_note="Finnhub symbol lookup; manual selection required"))
+            price = quote.get("c") or quote.get("pc")
+            last_price = str(price).strip() if price not in {None, "", 0, "0"} else None
+            results.append(CatalogSearchResult(None, inferred_class, description, ticker=symbol.split(".")[0], exchange=str(profile.get("exchange") or "").strip().upper() or None, trading_currency=str(profile.get("currency") or "").strip().upper() or None, provider="finnhub", provider_symbol=symbol, country=str(profile.get("country") or "").strip().upper() or None, confidence="medium" if profile else "low", source="finnhub", evidence_note="Finnhub symbol/profile/quote lookup; manual selection required", last_price=last_price, price_currency=str(profile.get("currency") or "USD").upper() if last_price else None, price_date=utc_now()[:10] if last_price else None, price_source="finnhub quote" if last_price else None))
         return results

@@ -392,8 +414,94 @@ class FmpSearchProvider(_HttpJsonLookupProvider):
         return results

+class TwelveDataLookupProvider(_HttpJsonLookupProvider):
+    name = "twelvedata"
+    api_key_env = ("TWELVEDATA_API_KEY", "TWELVE_DATA_API_KEY", "JARVIS_TWELVEDATA_API_KEY", "JARVIS_TWELVE_DATA_API_KEY")
+
+    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
+        if not self.api_key:
+            raise LookupUnavailable("twelvedata_api_key_missing")
+        url = "https://api.twelvedata.com/symbol_search?" + parse.urlencode({"symbol": query, "apikey": self.api_key})
+        payload = self._get_json(url)
+        if isinstance(payload, dict) and str(payload.get("status") or "").lower() == "error":
+            raise LookupUnavailable("twelvedata_provider_error")
+        rows = payload.get("data", []) if isinstance(payload, dict) else []
+        results: list[CatalogSearchResult] = []
+        for item in rows[:10]:
+            if not isinstance(item, dict):
+                continue
+            symbol = str(item.get("symbol") or item.get("instrument_name") or "").strip().upper()
+            name = str(item.get("instrument_name") or item.get("name") or symbol or query).strip()
+            if not symbol and not name:
+                continue
+            instrument_type = str(item.get("instrument_type") or "").lower()
+            inferred_class = asset_class or ("etf" if "etf" in instrument_type or "fund" in name.lower() else "stock")
+            results.append(CatalogSearchResult(None, inferred_class, name, ticker=symbol.split(":")[0] if symbol else None, exchange=str(item.get("exchange") or item.get("mic_code") or "").strip().upper() or None, trading_currency=str(item.get("currency") or "").strip().upper() or None, provider="twelvedata", provider_symbol=symbol or None, provider_market=str(item.get("exchange") or "").strip().upper() or None, country=str(item.get("country") or "").strip().upper() or None, confidence="medium" if symbol else "low", source="twelvedata", evidence_note="Twelve Data symbol search result; manual selection required"))
+        return results
+
+
+class MassiveLookupProvider(_HttpJsonLookupProvider):
+    name = "massive"
+    api_key_env = ("MASSIVE_API_KEY", "JARVIS_MASSIVE_API_KEY")
+
+    def _massive_json(self, path: str, params: dict[str, str] | None = None) -> object:
+        if not self.api_key:
+            raise LookupUnavailable("massive_api_key_missing")
+        qs = ("?" + parse.urlencode(params or {})) if params else ""
+        url = "https://api.massive.com" + path + qs
+        try:
+            return self._get_json(url, headers={"Accept": "application/json", "Authorization": f"Bearer {self.api_key}"})
+        except LookupUnavailable as exc:
+            if "auth_failed" not in str(exc):
+                raise


Explanation:
**Clarifying file editing rules**

I’m looking into file editing rules, and it seems that using sed and awk is discouraged by the developer. For reading and diffing, piping sed might be okay, but it's a bit unclear. I also need to address duplicate imports like the unused Fmp and fx_provider and get that sorted. Finally, I should inspect the price file parts to ensure everything looks good. So much to keep in mind!


     1|from __future__ import annotations
     2|
     3|from dataclasses import dataclass, field
     4|from decimal import Decimal, InvalidOperation
     5|from sqlite3 import Connection
     6|from typing import Protocol
     7|from urllib import error, parse, request
     8|import json
     9|
    10|from jarvis_finance.imports.common import stable_id, utc_now
    11|from jarvis_finance.market_data.catalog import _runtime_secret_value
    12|from jarvis_finance.market_data.instruments import resolve_instrument_alerts, update_instrument_metadata
    13|from jarvis_finance.quality.alerts import create_alert
    14|
    15|
    16|@dataclass(frozen=True)
    17|class EquityPriceQuote:
    18|    provider_symbol: str
    19|    currency: str
    20|    close: Decimal | None
    21|    provider: str = "mock"
    22|    provider_market: str | None = None
    23|    price_timestamp: str | None = None
    24|    adjusted_close: Decimal | None = None
    25|    quality_status: str = "fresh"
    26|    error_message: str | None = None
    27|
    28|
    29|@dataclass
    30|class MarketPriceRefreshResult:
    31|    asset_class: str
    32|    total_mappings: int = 0
    33|    updated_count: int = 0
    34|    skipped_count: int = 0
    35|    excluded_count: int = 0
    36|    cached_count: int = 0
    37|    stale_count: int = 0
    38|    warning_count: int = 0
    39|    error_count: int = 0
    40|    dry_run: bool = False
    41|    warnings: list[str] = field(default_factory=list)
    42|    errors: list[str] = field(default_factory=list)
    43|
    44|
    45|class EquityPriceProvider(Protocol):
    46|    name: str
    47|    def get_price(self, provider_symbol: str, *, price_date: str | None = None) -> EquityPriceQuote: ...
    48|
    49|
    50|class MockEquityPriceProvider:
    51|    name = "mock"
    52|
    53|    def __init__(self, prices: dict[str, Decimal | str | None], *, currency: str = "USD", timestamps: dict[str, str] | None = None) -> None:
    54|        self.prices = {k: (Decimal(str(v)) if v is not None else None) for k, v in prices.items()}
    55|        self.currency = currency.upper()
    56|        self.timestamps = timestamps or {}
    57|
    58|    def get_price(self, provider_symbol: str, *, price_date: str | None = None) -> EquityPriceQuote:
    59|        if provider_symbol not in self.prices or self.prices[provider_symbol] is None:
    60|            return EquityPriceQuote(provider_symbol=provider_symbol, currency=self.currency, close=None, quality_status="missing", error_message="price missing", provider=self.name)
    61|        return EquityPriceQuote(provider_symbol=provider_symbol, currency=self.currency, close=self.prices[provider_symbol], price_timestamp=self.timestamps.get(provider_symbol), provider=self.name)
    62|
    63|
    64|class FmpEquityPriceProvider:
    65|    name = "fmp"
    66|
    67|    def __init__(self, *, api_key: str | None = None, timeout_seconds: float = 8.0) -> None:
    68|        self.api_key = api_key or _runtime_secret_value(("FMP_API_KEY", "FINANCIAL_MODELING_PREP_API_KEY", "JARVIS_FMP_API_KEY"))
    69|        self.timeout_seconds = timeout_seconds
    70|
    71|    def _get_json(self, path: str, params: dict[str, str]) -> object:
    72|        if not self.api_key:
    73|            raise RuntimeError("fmp_api_key_missing")
    74|        path = path if path.startswith("/") else "/" + path
    75|        url = "https://financialmodelingprep.com" + path + "?" + parse.urlencode({**params, "apikey": self.api_key})
    76|        req = request.Request(url, headers={"Accept": "application/json"})
    77|        try:
    78|            with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup
    79|                return json.loads(response.read().decode("utf-8"))
    80|        except error.HTTPError as exc:
    81|            if exc.code in {401, 403}:
    82|                raise RuntimeError("fmp_auth_failed") from exc
    83|            if exc.code == 429:
    84|                raise RuntimeError("fmp_rate_limited") from exc
    85|            raise RuntimeError("fmp_provider_error") from exc
    86|        except error.URLError as exc:
    87|            raise RuntimeError("fmp_network_error") from exc
    88|
    89|    def get_price(self, provider_symbol: str, *, price_date: str | None = None) -> EquityPriceQuote:
    90|        symbol = (provider_symbol or "").strip().upper()
    91|        if not symbol:
    92|            return EquityPriceQuote(provider_symbol=symbol, currency="", close=None, provider=self.name, quality_status="missing", error_message="provider_symbol_missing")
    93|        try:
    94|            payload = self._get_json("/stable/profile", {"symbol": symbol})
    95|        except RuntimeError as exc:
    96|            return EquityPriceQuote(provider_symbol=symbol, currency="", close=None, provider=self.name, quality_status="missing", error_message=str(exc))
    97|        row = payload[0] if isinstance(payload, list) and payload and isinstance(payload[0], dict) else payload if isinstance(payload, dict) else {}
    98|        if isinstance(row.get("data"), list) and row["data"] and isinstance(row["data"][0], dict):
    99|            row = row["data"][0]
   100|        price = row.get("price") or row.get("lastPrice")
   101|        try:
   102|            close = Decimal(str(price)) if price not in {None, ""} else None
   103|        except (InvalidOperation, ValueError):
   104|            close = None
   105|        if close is None or close <= 0:
   106|            return EquityPriceQuote(provider_symbol=symbol, currency="", close=None, provider=self.name, quality_status="missing", error_message="price_missing")
   107|        currency = str(row.get("currency") or row.get("priceCurrency") or "").upper()
   108|        return EquityPriceQuote(provider_symbol=symbol, currency=currency or "USD", close=close, provider=self.name, price_timestamp=utc_now(), quality_status="fresh")
   109|
   110|
   111|class _HttpEquityPriceProvider:
   112|    name = "provider"
   113|    api_key_env: tuple[str, ...] = ()
   114|
   115|    def __init__(self, *, api_key: str | None = None, timeout_seconds: float = 8.0) -> None:
   116|        self.api_key = api_key or _runtime_secret_value(self.api_key_env)
   117|        self.timeout_seconds = timeout_seconds
   118|
   119|    def _json_url(self, url: str, *, headers: dict[str, str] | None = None) -> object:
   120|        req = request.Request(url, headers=headers or {"Accept": "application/json"})
   121|        try:
   122|            with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit CLI/user provider lookup
   123|                return json.loads(response.read().decode("utf-8"))
   124|        except error.HTTPError as exc:
   125|            if exc.code in {401, 403}:
   126|                raise RuntimeError(f"{self.name}_auth_failed") from exc
   127|            if exc.code == 402:
   128|                raise RuntimeError(f"{self.name}_endpoint_restricted") from exc
   129|            if exc.code == 429:
   130|                raise RuntimeError(f"{self.name}_rate_limited") from exc
   131|            raise RuntimeError(f"{self.name}_provider_error") from exc
   132|        except error.URLError as exc:
   133|            raise RuntimeError(f"{self.name}_network_error") from exc
   134|
   135|    @staticmethod
   136|    def _decimal_price(value: object) -> Decimal | None:
   137|        try:
   138|            price = Decimal(str(value)) if value not in {None, ""} else None
   139|        except (InvalidOperation, ValueError):
   140|            return None
   141|        return price if price is not None and price > 0 else None
   142|
   143|    def _missing(self, symbol: str, message: str) -> EquityPriceQuote:
   144|        return EquityPriceQuote(provider_symbol=symbol, currency="", close=None, provider=self.name, quality_status="missing", error_message=message)
   145|
   146|
   147|class FinnhubEquityPriceProvider(_HttpEquityPriceProvider):
   148|    name = "finnhub"
   149|    api_key_env = ("FINNHUB_API_KEY", "JARVIS_FINNHUB_API_KEY")
   150|
   151|    def get_price(self, provider_symbol: str, *, price_date: str | None = None) -> EquityPriceQuote:
   152|        symbol = (provider_symbol or "").strip().upper()
   153|        if not symbol:
   154|            return self._missing(symbol, "provider_symbol_missing")
   155|        if not self.api_key:
   156|            return self._missing(symbol, "finnhub_api_key_missing")
   157|        try:
   158|            payload = self._json_url("https://finnhub.io/api/v1/quote?" + parse.urlencode({"symbol": symbol, "token": self.api_key}))
   159|            profile = self._json_url("https://finnhub.io/api/v1/stock/profile2?" + parse.urlencode({"symbol": symbol, "token": self.api_key}))
   160|        except RuntimeError as exc:
   161|            return self._missing(symbol, str(exc))
   162|        row = payload if isinstance(payload, dict) else {}
   163|        price = self._decimal_price(row.get("c") or row.get("pc"))
   164|        if price is None:
   165|            return self._missing(symbol, "price_missing")
   166|        currency = str(profile.get("currency") or "USD").upper() if isinstance(profile, dict) else "USD"
   167|        return EquityPriceQuote(provider_symbol=symbol, currency=currency or "USD", close=price, provider=self.name, price_timestamp=utc_now(), quality_status="fresh")
   168|
   169|
   170|class TwelveDataEquityPriceProvider(_HttpEquityPriceProvider):
   171|    name = "twelvedata"
   172|    api_key_env = ("TWELVEDATA_API_KEY", "TWELVE_DATA_API_KEY", "JARVIS_TWELVEDATA_API_KEY", "JARVIS_TWELVE_DATA_API_KEY")
   173|
   174|    def get_price(self, provider_symbol: str, *, price_date: str | None = None) -> EquityPriceQuote:
   175|        symbol = (provider_symbol or "").strip().upper()
   176|        if not symbol:
   177|            return self._missing(symbol, "provider_symbol_missing")
   178|        if not self.api_key:
   179|            return self._missing(symbol, "twelvedata_api_key_missing")
   180|        try:
   181|            payload = self._json_url("https://api.twelvedata.com/quote?" + parse.urlencode({"symbol": symbol, "apikey": self.api_key}))
   182|        except RuntimeError as exc:
   183|            return self._missing(symbol, str(exc))
   184|        row = payload if isinstance(payload, dict) else {}
   185|        if str(row.get("status") or "").lower() == "error":
   186|            return self._missing(symbol, "twelvedata_provider_error")
   187|        price = self._decimal_price(row.get("close") or row.get("previous_close") or row.get("price"))
   188|        if price is None:
   189|            return self._missing(symbol, "price_missing")
   190|        currency = str(row.get("currency") or "USD").upper()
   191|        ts = str(row.get("datetime") or row.get("timestamp") or utc_now())
   192|        return EquityPriceQuote(provider_symbol=symbol, currency=currency or "USD", close=price, provider=self.name, price_timestamp=ts, quality_status="fresh")
   193|
   194|
   195|class MassiveEquityPriceProvider(_HttpEquityPriceProvider):
   196|    name = "massive"
   197|    api_key_env = ("MASSIVE_API_KEY", "JARVIS_MASSIVE_API_KEY")
   198|
   199|    def _massive_json(self, path: str) -> object:
   200|        if not self.api_key:
   201|            raise RuntimeError("massive_api_key_missing")
   202|        base = "https://api.massive.com" + path
   203|        try:
   204|            return self._json_url(base, headers={"Accept": "application/json", "Authorization": f"Bearer {self.api_key}"})
   205|        except RuntimeError as exc:
   206|            if "auth_failed" not in str(exc):
   207|                raise
   208|            sep = "&" if "?" in base else "?"
   209|            return self._json_url(base + sep + parse.urlencode({"apiKey": self.api_key}), headers={"Accept": "application/json"})
   210|
   211|    def get_price(self, provider_symbol: str, *, price_date: str | None = None) -> EquityPriceQuote:
   212|        symbol = (provider_symbol or "").strip().upper()
   213|        if not symbol:
   214|            return self._missing(symbol, "provider_symbol_missing")
   215|        try:
   216|            payload = self._massive_json(f"/v2/aggs/ticker/{parse.quote(symbol)}/prev")
   217|        except RuntimeError as exc:
   218|            return self._missing(symbol, str(exc))
   219|        results = payload.get("results", []) if isinstance(payload, dict) else []
   220|        row = results[0] if results and isinstance(results[0], dict) else {}
   221|        price = self._decimal_price(row.get("c") or row.get("vw"))
   222|        if price is None:
   223|            return self._missing(symbol, "price_missing")
   224|        return EquityPriceQuote(provider_symbol=symbol, currency="USD", close=price, provider=self.name, price_timestamp=utc_now(), quality_status="fresh")
   225|
   226|
   227|class EodhdEquityPriceProvider(_HttpEquityPriceProvider):
   228|    name = "eodhd"
   229|    api_key_env = ("EODHD_API_KEY", "JARVIS_EODHD_API_KEY")
   230|
   231|    def get_price(self, provider_symbol: str, *, price_date: str | None = None) -> EquityPriceQuote:
   232|        symbol = (provider_symbol or "").strip().upper()
   233|        if not symbol:
   234|            return self._missing(symbol, "provider_symbol_missing")
   235|        if not self.api_key:
   236|            return self._missing(symbol, "eodhd_api_key_missing")
   237|        eod_symbol = symbol if "." in symbol else f"{symbol}.US"
   238|        try:
   239|            payload = self._json_url("https://eodhd.com/api/real-time/" + parse.quote(eod_symbol) + "?" + parse.urlencode({"api_token": self.api_key, "fmt": "json"}))
   240|        except RuntimeError as exc:
   241|            return self._missing(symbol, str(exc))
   242|        row = payload if isinstance(payload, dict) else {}
   243|        price = self._decimal_price(row.get("close") or row.get("previousClose"))
   244|        if price is None:
   245|            return self._missing(symbol, "price_missing")
   246|        return EquityPriceQuote(provider_symbol=symbol, currency=str(row.get("currency") or "USD").upper(), close=price, provider=self.name, price_timestamp=utc_now(), quality_status="fresh")
   247|
   248|
   249|def equity_price_provider_by_name(name: str) -> EquityPriceProvider:
   250|    key = (name or "").lower().replace("-", "")
   251|    providers = {
   252|        "fmp": FmpEquityPriceProvider,
   253|        "finnhub": FinnhubEquityPriceProvider,
   254|        "twelvedata": TwelveDataEquityPriceProvider,
   255|        "twelvedata": TwelveDataEquityPriceProvider,
   256|        "massive": MassiveEquityPriceProvider,
   257|        "eodhd": EodhdEquityPriceProvider,
   258|    }
   259|    if key not in providers:
   260|        raise ValueError("unknown equity price provider")
   261|    return providers[key]()
   262|
   263|
   264|def _resolve_market_alerts(conn: Connection, *, instrument_id: str) -> int:
   265|    return resolve_instrument_alerts(conn, instrument_id=instrument_id, rule_ids=["missing_market_price", "stale_market_price"])
   266|
   267|
   268|def _previous_price(conn: Connection, *, instrument_id: str, price_date: str, provider: str):
   269|    return conn.execute(
   270|        """
   271|        SELECT * FROM market_prices
   272|        WHERE instrument_id=? AND provider=? AND price_date<? AND close IS NOT NULL AND close!=''
   273|        ORDER BY price_date DESC, created_at DESC LIMIT 1
   274|        """,
   275|        (instrument_id, provider, price_date),
   276|    ).fetchone()
   277|
   278|
   279|def _corporate_action_status(conn: Connection, *, instrument_id: str, price_date: str, provider: str, close: Decimal | None) -> str:
   280|    if close is None or close <= 0:
   281|        return "not_checked"
   282|    prev = _previous_price(conn, instrument_id=instrument_id, price_date=price_date, provider=provider)
   283|    if not prev or not prev["close"]:
   284|        return "not_checked"
   285|    old = Decimal(str(prev["close"]))
   286|    if old <= 0:
   287|        return "not_checked"
   288|    change = abs((close - old) / old)
   289|    if change > Decimal("0.25"):
   290|        create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id="corporate_action_suspected", message="Large local price move detected; corporate action review required before high-confidence valuation.", evidence={"previous_price_date": prev["price_date"], "price_date": price_date}, fingerprint="corporate_action_suspected")
   291|        create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id="split_or_corporate_action_review_required", message="Split/corporate-action review required; no automatic split correction is applied.", evidence={"previous_price_date": prev["price_date"], "price_date": price_date}, fingerprint="split_or_corporate_action_review_required")
   292|        update_instrument_metadata(conn, instrument_id=instrument_id, corporate_action_status="suspected", split_or_corporate_action_review_required=True, note="Automatic C2 heuristic detected >25% local price move; review required.")
   293|        return "suspected"
   294|    return "none_known"
   295|
   296|
   297|def store_market_price(
   298|    conn: Connection,
   299|    *,
   300|    instrument_id: str,
   301|    price_date: str,
   302|    close: Decimal | None,
   303|    currency: str,
   304|    provider: str,
   305|    provider_symbol: str | None,
   306|    provider_market: str | None = None,
   307|    price_timestamp: str | None = None,
   308|    adjusted_close: Decimal | None = None,
   309|    quality_status: str = "fresh",
   310|    error_message: str | None = None,
   311|) -> str:
   312|    now = utc_now()
   313|    corp_status = _corporate_action_status(conn, instrument_id=instrument_id, price_date=price_date, provider=provider, close=close) if quality_status == "fresh" else "not_checked"
   314|    market_price_id = stable_id("marketprice", instrument_id, price_date, provider, provider_symbol or "", now)
   315|    conn.execute(
   316|        """
   317|        INSERT INTO market_prices(
   318|            market_price_id, instrument_id, price_date, price_timestamp, close, adjusted_close,
   319|            currency, provider, provider_symbol, quality_status, created_at, provider_market, error_message,
   320|            corporate_action_status
   321|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   322|        ON CONFLICT(instrument_id, price_date, provider) DO UPDATE SET
   323|            price_timestamp=excluded.price_timestamp,
   324|            close=excluded.close,
   325|            adjusted_close=excluded.adjusted_close,
   326|            currency=excluded.currency,
   327|            provider_symbol=excluded.provider_symbol,
   328|            quality_status=excluded.quality_status,
   329|            created_at=excluded.created_at,
   330|            provider_market=excluded.provider_market,
   331|            error_message=excluded.error_message,
   332|            corporate_action_status=excluded.corporate_action_status
   333|        """,
   334|        (market_price_id, instrument_id, price_date, price_timestamp, format(close, "f") if close is not None else "", format(adjusted_close, "f") if adjusted_close is not None else None, currency.upper(), provider, provider_symbol, quality_status, now, provider_market, error_message, corp_status),
   335|    )
   336|    if quality_status in {"missing", "stale", "error", "conflict"} or close is None:
   337|        rule = "stale_market_price" if quality_status == "stale" else "missing_market_price"
   338|        create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id=rule, message="Instrument local market price is not fresh.", evidence={"provider": provider, "provider_symbol": provider_symbol, "price_date": price_date, "quality_status": quality_status}, fingerprint=f"{rule}:{provider}:{provider_symbol}")
   339|    elif quality_status == "fresh":
   340|        _resolve_market_alerts(conn, instrument_id=instrument_id)
   341|    conn.commit()
   342|    return market_price_id
   343|
   344|
   345|def refresh_market_prices(
   346|    conn: Connection,
   347|    *,
   348|    provider: EquityPriceProvider,
   349|    asset_class: str,
   350|    price_date: str | None = None,
   351|    only_missing: bool = False,
   352|    only_stale: bool = False,
   353|    only_isin: str | None = None,
   354|    limit: int | None = None,
   355|    dry_run: bool = False,
   356|) -> MarketPriceRefreshResult:
   357|    asset = asset_class.lower()
   358|    result = MarketPriceRefreshResult(asset_class=asset, dry_run=dry_run)
   359|    query = """
   360|        SELECT m.*, i.asset_class, i.isin, i.instrument_status AS current_instrument_status, i.valuation_policy AS current_valuation_policy FROM instrument_price_mappings m
   361|        JOIN instruments i ON i.instrument_id=m.instrument_id
   362|        WHERE LOWER(i.asset_class)=? AND m.mapping_status='mapped' AND m.provider_symbol IS NOT NULL
   363|        ORDER BY i.isin, m.provider_symbol
   364|    """
   365|    mappings = conn.execute(query, (asset,)).fetchall()
   366|    if only_isin:
   367|        mappings = [m for m in mappings if (m["isin"] or "").upper() == only_isin.upper()]
   368|    if limit is not None:
   369|        mappings = mappings[:limit]
   370|    result.total_mappings = len(mappings)
   371|    effective_date = price_date or utc_now()[:10]
   372|    for mapping in mappings:
   373|        if mapping["current_instrument_status"] in {"delisted", "suspended", "merged", "inactive"} or mapping["current_valuation_policy"] == "exclude_from_auto_price_update":
   374|            result.excluded_count += 1
   375|            result.skipped_count += 1
   376|            if not dry_run:
   377|                create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=mapping["instrument_id"], rule_id="delisted_or_suspended", message="Instrument excluded from automatic market-price update.", evidence={"instrument_status": mapping["current_instrument_status"], "valuation_policy": mapping["current_valuation_policy"]}, fingerprint=f"excluded:{mapping['current_instrument_status']}:{mapping['current_valuation_policy']}")
   378|            continue
   379|        latest = conn.execute("SELECT * FROM market_prices WHERE instrument_id=? AND quality_status='fresh' ORDER BY price_date DESC, created_at DESC LIMIT 1", (mapping["instrument_id"],)).fetchone()
   380|        if only_missing and latest:
   381|            result.cached_count += 1
   382|            continue
   383|        if only_stale and latest and latest["price_date"] >= effective_date:
   384|            result.cached_count += 1
   385|            continue
   386|        quote = provider.get_price(mapping["provider_symbol"], price_date=effective_date)
   387|        status = quote.quality_status
   388|        if not dry_run:
   389|            store_market_price(conn, instrument_id=mapping["instrument_id"], price_date=effective_date, close=quote.close, currency=quote.currency or mapping["currency"], provider=quote.provider or provider.name, provider_symbol=mapping["provider_symbol"], provider_market=mapping["provider_market"], price_timestamp=quote.price_timestamp, adjusted_close=quote.adjusted_close, quality_status=status, error_message=quote.error_message)
   390|        if quote.close is not None and status == "fresh":
   391|            result.updated_count += 1
   392|        elif status in {"missing", "stale"}:
   393|            result.warning_count += 1
   394|            result.warnings.append(mapping["instrument_id"])
   395|            if status == "stale":
   396|                result.stale_count += 1
   397|            if status == "missing" and not dry_run:
   398|                update_instrument_metadata(conn, instrument_id=mapping["instrument_id"], instrument_status="suspected_inactive", note="Provider returned missing price; suspected inactive requires manual review.")
   399|        else:
   400|            result.error_count += 1
   401|            result.errors.append(mapping["instrument_id"])
   402|    if not dry_run:
   403|        conn.commit()
   404|    return result
   405|


Explanation:
**Considering code improvements**

I definitely need to add some tests, and I also have to fix those key duplicates. It seems like I might need to execute some code for patching, but first, I should understand the structure of the tests. I think a search for relevant information will help me get a better grasp of how to proceed. There’s quite a bit to untangle here, but I’m on it!


     1|from __future__ import annotations
     2|
     3|from decimal import Decimal
     4|
     5|import pytest
     6|
     7|from jarvis_finance.fx.rates import get_fx_rate_to_chf, recheck_transaction_fx_status, update_fx_rates, upsert_fx_rate
     8|from jarvis_finance.fx.overrides import set_manual_fx_override
     9|from jarvis_finance.fx.providers import MockFxProvider
    10|from jarvis_finance.market_data.prices import MockEquityPriceProvider, refresh_market_prices, store_market_price
    11|from jarvis_finance.market_data.mappings import confirm_instrument_price_mapping, ensure_instrument_price_mapping_quality
    12|from jarvis_finance.ledger.positions import calculate_positions
    13|from jarvis_finance.storage.database import connect_memory
    14|from jarvis_finance.storage.migrations import apply_migrations
    15|
    16|
    17|def setup_conn():
    18|    conn = connect_memory()
    19|    apply_migrations(conn)
    20|    conn.execute("INSERT INTO platforms(platform_id,name,platform_type,default_currency,created_at) VALUES('p1','Broker','broker','CHF','now')")
    21|    conn.execute("INSERT INTO accounts(account_id,platform_id,account_name,account_type,currency,created_at) VALUES('a1','p1','Account','brokerage','CHF','now')")
    22|    conn.execute("INSERT INTO instruments(instrument_id,asset_class,name,ticker,isin,exchange,currency,created_at) VALUES('chf1','ETF','CHF ETF','CHETF','CH0000000001','SWX','CHF','now')")
    23|    conn.execute("INSERT INTO instruments(instrument_id,asset_class,name,ticker,isin,exchange,currency,created_at) VALUES('usd1','ETF','USD ETF','USETF','US0000000001','NYSE','USD','now')")
    24|    return conn
    25|
    26|
    27|def test_chf_fx_is_not_needed_and_missing_alert_can_be_resolved() -> None:
    28|    conn = setup_conn()
    29|    conn.execute("""
    30|        INSERT INTO transactions(transaction_id, transaction_type, account_id, instrument_id, trade_date, quantity, currency_original, fx_status, source_type, is_confirmed, quality_status, created_at)
    31|        VALUES('tx-chf','initial_position_snapshot','a1','chf1','2025-12-31','1','CHF','missing','test',1,'warning','now')
    32|    """)
    33|    conn.execute("INSERT INTO alerts(alert_id,priority,category,entity_type,entity_id,rule_id,message,status,created_at) VALUES('al-chf','kritisch','ledger','instrument','chf1','missing_fx','missing','active','now')")
    34|
    35|    assert get_fx_rate_to_chf(conn, base_currency='CHF') == Decimal('1')
    36|    row = conn.execute("SELECT fx_rate_to_chf, fx_source, fx_status FROM transactions WHERE transaction_id='tx-chf'").fetchone()
    37|    assert row['fx_rate_to_chf'] == '1'
    38|    assert row['fx_source'] == 'not_needed'
    39|    assert row['fx_status'] == 'not_needed'
    40|
    41|
    42|def test_recheck_transaction_fx_status_corrects_chf_false_positive_with_audit() -> None:
    43|    conn = setup_conn()
    44|    conn.execute("""
    45|        INSERT INTO transactions(transaction_id, transaction_type, account_id, instrument_id, trade_date, quantity, currency_original, fx_status, source_type, is_confirmed, quality_status, created_at)
    46|        VALUES('tx-chf','initial_position_snapshot','a1','chf1','2025-12-31','1','CHF','missing','true_wealth',1,'warning','now')
    47|    """)
    48|    conn.execute("INSERT INTO alerts(alert_id,priority,category,entity_type,entity_id,rule_id,message,status,created_at) VALUES('al-chf','kritisch','ledger','instrument','chf1','missing_fx','missing','active','now')")
    49|
    50|    result = recheck_transaction_fx_status(conn, source_type='true_wealth')
    51|
    52|    assert result.transaction_count == 1
    53|    assert result.chf_positions == 1
    54|    assert result.false_positive_missing_fx_alerts == 1
    55|    assert result.corrected_transactions == 1
    56|    assert result.resolved_alerts == 1
    57|    assert result.audit_events == 1
    58|    assert conn.execute("SELECT status FROM alerts WHERE alert_id='al-chf'").fetchone()['status'] == 'resolved'
    59|    assert conn.execute("SELECT COUNT(*) AS c FROM audit_log WHERE action='set_chf_fx_not_needed'").fetchone()['c'] == 1
    60|
    61|
    62|def test_update_fx_rates_stores_usd_eur_and_resolves_missing_fx() -> None:
    63|    conn = setup_conn()
    64|    conn.execute("""
    65|        INSERT INTO transactions(transaction_id, transaction_type, account_id, instrument_id, trade_date, quantity, currency_original, fx_status, source_type, is_confirmed, quality_status, created_at)
    66|        VALUES('tx-usd','initial_position_snapshot','a1','usd1','2025-12-31','1','USD','missing','test',1,'warning','now')
    67|    """)
    68|    conn.execute("INSERT INTO alerts(alert_id,priority,category,entity_type,entity_id,rule_id,message,status,created_at) VALUES('al-usd','kritisch','ledger','instrument','usd1','missing_fx','missing','active','now')")
    69|    provider = MockFxProvider({('USD', 'CHF', '2025-12-31'): Decimal('0.9000'), ('EUR', 'CHF', '2025-12-31'): Decimal('0.9500')})
    70|
    71|    result = update_fx_rates(conn, provider=provider, currencies=['USD', 'EUR'], rate_date='2025-12-31', resolve_fixed=True)
    72|
    73|    assert result.updated_count == 2
    74|    assert get_fx_rate_to_chf(conn, base_currency='USD', rate_date='2025-12-31') == Decimal('0.9000')
    75|    assert conn.execute("SELECT status FROM alerts WHERE alert_id='al-usd'").fetchone()["status"] == "resolved"
    76|
    77|
    78|def test_manual_fx_override_requires_note_and_audit() -> None:
    79|    conn = setup_conn()
    80|    with pytest.raises(ValueError, match="note"):
    81|        set_manual_fx_override(conn, base_currency='USD', quote_currency='CHF', rate_date='2025-12-31', rate=Decimal('0.9'), note='')
    82|
    83|    rate_id = set_manual_fx_override(conn, base_currency='USD', quote_currency='CHF', rate_date='2025-12-31', rate=Decimal('0.9'), note='bank statement')
    84|
    85|    assert rate_id
    86|    assert conn.execute("SELECT COUNT(*) AS n FROM audit_log WHERE action='manual_fx_override'").fetchone()["n"] == 1
    87|
    88|
    89|def test_instrument_price_mapping_requires_provider_symbol_and_exchange_review() -> None:
    90|    conn = setup_conn()
    91|    conn.execute("UPDATE instruments SET exchange=NULL WHERE instrument_id='usd1'")
    92|
    93|    result = ensure_instrument_price_mapping_quality(conn, instrument_id='usd1')
    94|
    95|    assert result.mapping_status == 'missing_provider_symbol'
    96|    assert 'ticker_without_exchange' in result.quality_flags
    97|    assert conn.execute("SELECT COUNT(*) AS n FROM alerts WHERE rule_id='missing_provider_symbol' AND status='active'").fetchone()["n"] == 1
    98|    assert conn.execute("SELECT COUNT(*) AS n FROM alerts WHERE rule_id='ambiguous_instrument_mapping' AND status='active'").fetchone()["n"] == 1
    99|
   100|
   101|def test_confirm_instrument_price_mapping_writes_audit() -> None:
   102|    conn = setup_conn()
   103|
   104|    mapping_id = confirm_instrument_price_mapping(conn, instrument_id='usd1', provider='mock', provider_symbol='USETF.N', provider_market='NYSE', confidence='manual', note='confirmed from broker')
   105|
   106|    mapping = conn.execute("SELECT * FROM instrument_price_mappings WHERE mapping_id=?", (mapping_id,)).fetchone()
   107|    assert mapping['mapping_status'] == 'mapped'
   108|    assert mapping['provider_symbol'] == 'USETF.N'
   109|    assert conn.execute("SELECT COUNT(*) AS n FROM audit_log WHERE action='confirm_instrument_price_mapping'").fetchone()["n"] == 1
   110|
   111|
   112|def test_refresh_market_prices_with_mock_provider_stores_and_resolves_alert() -> None:
   113|    conn = setup_conn()
   114|    confirm_instrument_price_mapping(conn, instrument_id='usd1', provider='mock', provider_symbol='USETF.N', provider_market='NYSE', confidence='manual', note='test mapping')
   115|    conn.execute("INSERT INTO alerts(alert_id,priority,category,entity_type,entity_id,rule_id,message,status,created_at) VALUES('mp1','warnung','market_data','instrument','usd1','missing_market_price','missing','active','now')")
   116|    provider = MockEquityPriceProvider({'USETF.N': Decimal('10.25')})
   117|
   118|    result = refresh_market_prices(conn, provider=provider, asset_class='etf', price_date='2026-01-02')
   119|
   120|    assert result.updated_count == 1
   121|    price = conn.execute("SELECT close, quality_status FROM market_prices WHERE instrument_id='usd1'").fetchone()
   122|    assert price['close'] == '10.25'
   123|    assert price['quality_status'] == 'fresh'
   124|    assert conn.execute("SELECT status FROM alerts WHERE alert_id='mp1'").fetchone()["status"] == "resolved"
   125|
   126|
   127|def test_missing_and_stale_market_price_alerts_are_deduped() -> None:
   128|    conn = setup_conn()
   129|    confirm_instrument_price_mapping(conn, instrument_id='usd1', provider='mock', provider_symbol='USETF.N', provider_market='NYSE', confidence='manual', note='test mapping')
   130|    provider = MockEquityPriceProvider({'USETF.N': None})
   131|
   132|    result = refresh_market_prices(conn, provider=provider, asset_class='etf', price_date='2026-01-02')
   133|    result2 = refresh_market_prices(conn, provider=provider, asset_class='etf', price_date='2026-01-02')
   134|
   135|    assert result.warning_count == 1
   136|    assert result2.warning_count == 1
   137|    assert conn.execute("SELECT COUNT(*) AS n FROM alerts WHERE rule_id='missing_market_price' AND status='active'").fetchone()["n"] == 1
   138|
   139|
   140|def test_position_valuation_requires_market_price_and_fx_without_fake_total_return() -> None:
   141|    conn = setup_conn()
   142|    conn.execute("""
   143|        INSERT INTO transactions(transaction_id, transaction_type, account_id, instrument_id, trade_date, quantity, currency_original, fx_status, source_type, is_confirmed, quality_status, created_at)
   144|        VALUES('tx-usd','initial_position_snapshot','a1','usd1','2025-12-31','2','USD','missing','test',1,'warning','now')
   145|    """)
   146|    store_market_price(conn, instrument_id='usd1', price_date='2026-01-02', close=Decimal('10'), currency='USD', provider='mock', provider_symbol='USETF.N', quality_status='fresh')
   147|
   148|    pos = calculate_positions(conn).positions[('a1', 'usd1')]
   149|
   150|    assert pos.market_value_chf is None
   151|    assert pos.total_return_chf is None
   152|    assert 'missing_fx' in pos.quality_warnings
   153|
   154|    upsert_fx_rate(conn, base_currency='USD', quote_currency='CHF', rate_date='2026-01-02', rate=Decimal('0.9'), provider='mock', rate_type='close', quality_status='fresh')
   155|    pos2 = calculate_positions(conn).positions[('a1', 'usd1')]
   156|    assert pos2.market_value_chf is not None
   157|    assert pos2.total_return_chf is None  # cost basis still incomplete for snapshot-only import
   158|    assert 'cost_basis_uncertain' in pos2.quality_warnings
   159|


Explanation:
**Figuring out tests**

I want to focus on how to append tests and whether the CLI tests can run directly from the main function. It seems like there doesn’t need to be any actual keys involved. I’m wondering if I need to monkeypatch the _runtime, considering it imports a function that may read from the environment file. For the _runtime to work effectively, it might need to access the top catalog and read the .env file. That’s tricky!


     1|from __future__ import annotations
     2|
     3|import json
     4|import os
     5|import re
     6|import time
     7|from dataclasses import dataclass, field
     8|from pathlib import Path
     9|from sqlite3 import Connection
    10|from typing import Protocol
    11|from urllib import error, parse, request
    12|
    13|ISIN_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$")
    14|
    15|from jarvis_finance.audit.log import record_audit_event
    16|from jarvis_finance.imports.common import stable_id, utc_now
    17|from jarvis_finance.market_data.candidates import ProviderCandidate, LookupUnavailable
    18|from jarvis_finance.market_data.instruments import ensure_instrument_metadata_quality
    19|from jarvis_finance.quality.alerts import create_alert
    20|
    21|ASSET_CLASSES = {"equity", "stock", "etf", "crypto", "cash", "other"}
    22|CONFIDENCES = {"high", "medium", "low"}
    23|HEDGE_STATUSES = {"hedged", "unhedged", "unknown"}
    24|INSTRUMENT_STATUSES = {"active", "suspended", "delisted", "merged", "inactive", "unknown", "suspected_inactive"}
    25|VALUATION_POLICIES = {"live_price", "last_known_price", "manual_value", "exclude_from_auto_price_update"}
    26|
    27|
    28|def normalize_name(value: str | None) -> str:
    29|    text = (value or "").strip().lower()
    30|    return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9]+", " ", text)).strip()
    31|
    32|
    33|@dataclass(frozen=True)
    34|class CatalogEntryInput:
    35|    asset_class: str
    36|    name: str
    37|    isin: str | None = None
    38|    ticker: str | None = None
    39|    exchange: str | None = None
    40|    trading_currency: str | None = None
    41|    instrument_currency: str | None = None
    42|    provider: str | None = None
    43|    provider_symbol: str | None = None
    44|    provider_market: str | None = None
    45|    country: str | None = None
    46|    sector: str | None = None
    47|    issuer: str | None = None
    48|    fund_type: str | None = None
    49|    is_currency_hedged: bool | None = None
    50|    hedged_to_currency: str | None = None
    51|    hedge_status: str = "unknown"
    52|    instrument_status: str = "unknown"
    53|    valuation_policy: str = "live_price"
    54|    source: str = "manual"
    55|    source_confidence: str = "low"
    56|    notes: str | None = None
    57|
    58|
    59|@dataclass
    60|class CatalogSearchResult:
    61|    catalog_entry_id: str | None
    62|    asset_class: str
    63|    name: str
    64|    isin: str | None = None
    65|    ticker: str | None = None
    66|    exchange: str | None = None
    67|    trading_currency: str | None = None
    68|    instrument_currency: str | None = None
    69|    provider: str | None = None
    70|    provider_symbol: str | None = None
    71|    provider_market: str | None = None
    72|    country: str | None = None
    73|    hedge_status: str = "unknown"
    74|    instrument_status: str = "unknown"
    75|    confidence: str = "low"
    76|    source: str = "local_catalog"
    77|    evidence_note: str = ""
    78|    last_price: str | None = None
    79|    price_currency: str | None = None
    80|    price_date: str | None = None
    81|    price_source: str | None = None
    82|
    83|
    84|class CatalogLookupProvider(Protocol):
    85|    name: str
    86|
    87|    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
    88|        ...
    89|
    90|
    91|class UnavailableCatalogLookupProvider:
    92|    name = "unavailable"
    93|
    94|    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
    95|        raise LookupUnavailable("external instrument lookup is not configured")
    96|
    97|
    98|def _runtime_secret_value(names: tuple[str, ...]) -> str | None:
    99|    for name in names:
   100|        if os.environ.get(name):
   101|            return os.environ[name]
   102|    runtime_dir = Path(os.environ.get("JARVIS_FINANCE_RUNTIME_DIR", "~/jarvis_runtime/finance-system")).expanduser()
   103|    for candidate in [runtime_dir / "secrets" / ".env", runtime_dir / ".env"]:
   104|        if not candidate.exists():
   105|            continue
   106|        for line in candidate.read_text(encoding="utf-8").splitlines():
   107|            raw = line.strip()
   108|            if not raw or raw.startswith("#") or "=" not in raw:
   109|                continue
   110|            key, value = raw.split("=", 1)
   111|            key = key.strip().removeprefix("export ").strip()
   112|            if key in names:
   113|                return value.strip().strip('"').strip("'") or None
   114|    return None
   115|
   116|
   117|class _HttpJsonLookupProvider:
   118|    name = "provider"
   119|    api_key_env: tuple[str, ...] = ()
   120|
   121|

Use offset=121 to continue reading (showing 1-120 of 868 lines)


Explanation:
**Considering code testing**

I need to avoid using the real environment in my tests, so maybe I'll monkeypatch the JARVIS_FINANCE_RUNTIME_DIR temporarily. First, I should add a test file. Before that, I’ll fix some minor code issues like the duplicate fx_provider_by_name and unused cli and Fmp imports. I wonder if these are critical? Maybe I should run a linter like pyright, but compiling seems to be okay. I'll also need to check if _HttpJsonLookupProvider handles error 402 correctly.


   117|class _HttpJsonLookupProvider:
   118|    name = "provider"
   119|    api_key_env: tuple[str, ...] = ()
   120|
   121|    def __init__(self, *, api_key: str | None = None, timeout_seconds: float = 8.0, min_interval_seconds: float = 0.2) -> None:
   122|        self.api_key = api_key or _runtime_secret_value(self.api_key_env)
   123|        self.timeout_seconds = timeout_seconds
   124|        self.min_interval_seconds = min_interval_seconds
   125|        self._last_request_at = 0.0
   126|
   127|    def _throttle(self) -> None:
   128|        elapsed = time.monotonic() - self._last_request_at
   129|        if elapsed < self.min_interval_seconds:
   130|            time.sleep(self.min_interval_seconds - elapsed)
   131|        self._last_request_at = time.monotonic()
   132|
   133|    def _get_json(self, url: str, *, headers: dict[str, str] | None = None) -> object:
   134|        self._throttle()
   135|        req = request.Request(url, headers=headers or {})
   136|        try:
   137|            with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup
   138|                return json.loads(response.read().decode("utf-8"))
   139|        except error.HTTPError as exc:
   140|            if exc.code in {401, 403}:
   141|                raise LookupUnavailable(f"{self.name}_auth_failed") from exc
   142|            if exc.code == 402:
   143|                raise LookupUnavailable(f"{self.name}_endpoint_restricted") from exc
   144|            if exc.code == 429:
   145|                reset = exc.headers.get("ratelimit-reset") if exc.headers else None
   146|                try:
   147|                    wait = min(max(float(reset or "1"), 1.0), 3.0)
   148|                except ValueError:
   149|                    wait = 1.0
   150|                time.sleep(wait)
   151|                try:
   152|                    with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup retry after 429
   153|                        return json.loads(response.read().decode("utf-8"))
   154|                except error.HTTPError as retry_exc:
   155|                    if retry_exc.code == 429:
   156|                        raise LookupUnavailable(f"{self.name}_rate_limited") from retry_exc
   157|                    if retry_exc.code in {401, 403}:
   158|                        raise LookupUnavailable(f"{self.name}_auth_failed") from retry_exc
   159|                    if retry_exc.code == 402:
   160|                        raise LookupUnavailable(f"{self.name}_endpoint_restricted") from retry_exc
   161|                    raise LookupUnavailable(f"{self.name}_provider_error") from retry_exc
   162|            raise LookupUnavailable(f"{self.name}_provider_error") from exc
   163|        except error.URLError as exc:
   164|            raise LookupUnavailable(f"{self.name}_network_error") from exc
   165|
   166|    def _post_json(self, url: str, payload: object, *, headers: dict[str, str] | None = None) -> object:
   167|        self._throttle()
   168|        data = json.dumps(payload).encode("utf-8")
   169|        req = request.Request(url, data=data, headers={"Content-Type": "application/json", **(headers or {})})
   170|        try:
   171|            with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup
   172|                return json.loads(response.read().decode("utf-8"))
   173|        except error.HTTPError as exc:
   174|            if exc.code in {401, 403}:
   175|                raise LookupUnavailable(f"{self.name}_auth_failed") from exc
   176|            if exc.code == 402:
   177|                raise LookupUnavailable(f"{self.name}_endpoint_restricted") from exc
   178|            if exc.code == 429:
   179|                reset = exc.headers.get("ratelimit-reset") if exc.headers else None
   180|                try:
   181|                    wait = min(max(float(reset or "1"), 1.0), 3.0)
   182|                except ValueError:
   183|                    wait = 1.0
   184|                time.sleep(wait)
   185|                try:
   186|                    with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup retry after 429
   187|                        return json.loads(response.read().decode("utf-8"))
   188|                except error.HTTPError as retry_exc:
   189|                    if retry_exc.code == 429:
   190|                        raise LookupUnavailable(f"{self.name}_rate_limited") from retry_exc
   191|                    if retry_exc.code in {401, 403}:
   192|                        raise LookupUnavailable(f"{self.name}_auth_failed") from retry_exc
   193|                    if retry_exc.code == 402:
   194|                        raise LookupUnavailable(f"{self.name}_endpoint_restricted") from retry_exc
   195|                    raise LookupUnavailable(f"{self.name}_provider_error") from retry_exc
   196|            raise LookupUnavailable(f"{self.name}_provider_error") from exc
   197|        except error.URLError as exc:
   198|            raise LookupUnavailable(f"{self.name}_network_error") from exc
   199|
   200|
   201|class OpenFigiLookupProvider(_HttpJsonLookupProvider):
   202|    name = "openfigi"
   203|    api_key_env = ("OPENFIGI_API_KEY", "JARVIS_OPENFIGI_API_KEY")
   204|
   205|    def _request(self, query: str) -> object:
   206|        if not self.api_key:
   207|

Use offset=207 to continue reading (showing 117-206 of 868 lines)


from __future__ import annotations

import json
from decimal import Decimal
from io import BytesIO
from urllib.error import HTTPError

import pytest

from jarvis_finance.cli.main import main
from jarvis_finance.fx.providers import FrankfurterFxProvider, TwelveDataFxProvider
from jarvis_finance.market_data.catalog import (
    MassiveLookupProvider,
    OpenFigiLookupProvider,
    TwelveDataLookupProvider,
    _runtime_secret_value,
    default_instrument_lookup_providers,
    instrument_provider_statuses,
)
from jarvis_finance.market_data.candidates import LookupUnavailable
from jarvis_finance.market_data.mappings import confirm_instrument_price_mapping
from jarvis_finance.market_data.prices import (
    EodhdEquityPriceProvider,
    FinnhubEquityPriceProvider,
    MassiveEquityPriceProvider,
    TwelveDataEquityPriceProvider,
    equity_price_provider_by_name,
    refresh_market_prices,
)
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations

class FakeResponse:
    def __init__(self, payload: object, headers: dict[str, str] | None = None) -> None:
        self.payload = payload
        self.headers = headers or {}

    def __enter__(self):
        return self

    def __exit__(self, *args):
        return False

    def read(self) -> bytes:
        return json.dumps(self.payload).encode("utf-8")

def setup_conn():
    conn = connect_memory()
    apply_migrations(conn)
    conn.execute("INSERT INTO platforms(platform_id,name,platform_type,default_currency,created_at) VALUES('p1','Broker','broker','CHF','now')")
    conn.execute("INSERT INTO accounts(account_id,platform_id,account_name,account_type,currency,created_at) VALUES('a1','p1','Account','brokerage','CHF','now')")
    conn.execute("INSERT INTO instruments(instrument_id,asset_class,name,ticker,isin,exchange,currency,created_at) VALUES('usd1','ETF','USD ETF','VTI','US9229087690','NYSE','USD','now')")
    return conn

def test_runtime_secret_aliases_include_new_provider_key_names(monkeypatch, tmp_path) -> None:
    monkeypatch.delenv("OPENFIGI_API_KEY", raising=False)
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    secrets = tmp_path / "secrets"
    secrets.mkdir()
    env_file = secrets / ".env"
    env_file.write_text("TWELVEDATA_API_KEY=td\nMASSIVE_API_KEY=mass\nJARVIS_OPENFIGI_API_KEY=figi\n", encoding="utf-8")
    env_file.chmod(0o600)

    assert _runtime_secret_value(("TWELVEDATA_API_KEY", "TWELVE_DATA_API_KEY")) == "td"
    assert _runtime_secret_value(("MASSIVE_API_KEY", "JARVIS_MASSIVE_API_KEY")) == "mass"
    assert _runtime_secret_value(("OPENFIGI_API_KEY", "JARVIS_OPENFIGI_API_KEY")) == "figi"

def test_openfigi_401_is_auth_failed_without_crash(monkeypatch) -> None:
    def fail(*args, **kwargs):
        raise HTTPError("https://api.openfigi.com/v3/mapping", 401, "Unauthorized", {}, BytesIO())

    monkeypatch.setattr("jarvis_finance.market_data.catalog.request.urlopen", fail)
    provider = OpenFigiLookupProvider(api_key="synthetic", min_interval_seconds=0)

    with pytest.raises(LookupUnavailable, match="openfigi_auth_failed"):
        provider.search(query="US9229087690", asset_class="etf")

def test_openfigi_isin_mapping_mock_returns_candidate(monkeypatch) -> None:
    provider = OpenFigiLookupProvider(api_key="synthetic", min_interval_seconds=0)
    monkeypatch.setattr(provider, "_post_json", lambda *a, **k: [{"data": [{"figi": "BBG000", "ticker": "VTI", "name": "Vanguard Total Stock Market ETF", "exchCode": "US", "currency": "USD"}]}])

    results = provider.search(query="US9229087690", asset_class="etf")

    assert results
    assert results[0].provider == "openfigi"
    assert results[0].ticker == "VTI"

def test_provider_priority_and_dashboard_status_without_key_leak(monkeypatch, tmp_path) -> None:
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    providers = [p.name for p in default_instrument_lookup_providers()]
    assert providers == ["openfigi", "fmp", "finnhub", "twelvedata", "massive"]

    rows = instrument_provider_statuses(probe=False)
    labels = {row["Provider"]: row for row in rows}
    assert labels["EODHD"]["Hinweis"].startswith("EODHD_API_KEY; Low-volume")
    assert labels["Frankfurter FX"]["Status"] == "aktiv"
    assert labels["Stooq"]["Status"] == "verfügbar"
    rendered = json.dumps(rows)
    assert "apikey" not in rendered.lower()
    assert "synthetic" not in rendered.lower()

def test_finnhub_lookup_mock_enriches_profile_and_quote(monkeypatch) -> None:
    calls: list[str] = []

    def fake_get_json(self, url: str, **kwargs):
        calls.append(url)
        if "/search?" in url:
            return {"result": [{"symbol": "VTI", "description": "Vanguard Total Stock Market ETF"}]}
        if "/stock/profile2?" in url:
            return {"currency": "USD", "exchange": "NYSE ARCA", "country": "US"}
        if "/quote?" in url:
            return {"c": 250.12}
        return {}

    monkeypatch.setattr("jarvis_finance.market_data.catalog.FinnhubSymbolLookupProvider._get_json", fake_get_json)
    from jarvis_finance.market_data.catalog import FinnhubSymbolLookupProvider

    result = FinnhubSymbolLookupProvider(api_key="synthetic", min_interval_seconds=0).search(query="VTI", asset_class="etf")[0]

    assert result.provider == "finnhub"
    assert result.exchange == "NYSE ARCA"
    assert result.last_price == "250.12"
    assert len(calls) == 3

def test_twelvedata_symbol_lookup_and_key_alias(monkeypatch) -> None:
    monkeypatch.setenv("TWELVEDATA_API_KEY", "synthetic")
    monkeypatch.setattr("jarvis_finance.market_data.catalog.TwelveDataLookupProvider._get_json", lambda self, url, **kwargs: {"data": [{"symbol": "VTI", "instrument_name": "Vanguard Total Stock", "exchange": "NYSE", "currency": "USD", "country": "United States", "instrument_type": "ETF"}]})

    result = TwelveDataLookupProvider(min_interval_seconds=0).search(query="VTI", asset_class="etf")[0]

    assert result.provider == "twelvedata"
    assert result.ticker == "VTI"
    assert result.trading_currency == "USD"

def test_massive_reference_lookup_mock(monkeypatch) -> None:
    def fake_massive_json(self, path: str, params=None):
        if path == "/v3/reference/tickers":
            return {"results": [{"ticker": "VTI", "name": "Vanguard Total Stock Market ETF", "primary_exchange": "ARCX", "type": "ETF"}]}
        return {"results": {"ticker": "VTI", "currency_name": "USD", "locale": "us"}}

    monkeypatch.setattr(MassiveLookupProvider, "_massive_json", fake_massive_json)
    result = MassiveLookupProvider(api_key="synthetic", min_interval_seconds=0).search(query="VTI", asset_class="etf")[0]

    assert result.provider == "massive"
    assert result.ticker == "VTI"
    assert result.exchange == "ARCX"

def test_equity_price_providers_mock_quotes(monkeypatch) -> None:
    monkeypatch.setattr(FinnhubEquityPriceProvider, "_json_url", lambda self, url, **kwargs: {"c": 250.12} if "/quote?" in url else {"currency": "USD"})
    monkeypatch.setattr(TwelveDataEquityPriceProvider, "_json_url", lambda self, url, **kwargs: {"close": "250.13", "currency": "USD", "datetime": "2026-05-16"})
    monkeypatch.setattr(MassiveEquityPriceProvider, "_massive_json", lambda self, path: {"results": [{"c": 250.14}]})

    assert FinnhubEquityPriceProvider(api_key="synthetic").get_price("VTI").close == Decimal("250.12")
    assert TwelveDataEquityPriceProvider(api_key="synthetic").get_price("VTI").close == Decimal("250.13")
    assert MassiveEquityPriceProvider(api_key="synthetic").get_price("VTI").close == Decimal("250.14")
    assert isinstance(equity_price_provider_by_name("twelvedata"), TwelveDataEquityPriceProvider)

def test_eodhd_is_explicit_low_volume_provider_not_default(monkeypatch) -> None:
    assert "eodhd" not in [p.name for p in default_instrument_lookup_providers()]
    assert isinstance(equity_price_provider_by_name("eodhd"), EodhdEquityPriceProvider)
    with pytest.raises(SystemExit):
        main(["update-equity-prices", "--provider", "eodhd", "--dry-run", "--limit", "0"])

def test_refresh_market_prices_uses_explicit_provider_only() -> None:
    conn = setup_conn()
    confirm_instrument_price_mapping(conn, instrument_id="usd1", provider="twelvedata", provider_symbol="VTI", provider_market="NYSE", confidence="manual", note="test mapping")

    class Provider:
        name = "twelvedata"
        calls = 0

        def get_price(self, provider_symbol: str, *, price_date: str | None = None):
            Provider.calls += 1
            from jarvis_finance.market_data.prices import EquityPriceQuote
            return EquityPriceQuote(provider_symbol=provider_symbol, currency="USD", close=Decimal("250.13"), provider="twelvedata", quality_status="fresh")

    result = refresh_market_prices(conn, provider=Provider(), asset_class="etf", price_date="2026-05-16")

    assert result.updated_count == 1
    assert Provider.calls == 1
    assert conn.execute("SELECT provider, close FROM market_prices WHERE instrument_id='usd1'").fetchone()["provider"] == "twelvedata"

def test_frankfurter_and_twelvedata_fx_mocks(monkeypatch) -> None:
    monkeypatch.setattr("jarvis_finance.fx.providers.request.urlopen", lambda *a, **k: FakeResponse({"rates": {"CHF": 0.89}}))
    assert FrankfurterFxProvider().get_rate("USD", "CHF", "latest") == Decimal("0.89")

    monkeypatch.setattr("jarvis_finance.fx.providers.request.urlopen", lambda *a, **k: FakeResponse({"rate": "0.90"}))
    assert TwelveDataFxProvider(api_key="synthetic").get_rate("USD", "CHF", "latest") == Decimal("0.90")
    assert TwelveDataFxProvider(api_key="synthetic").get_rate("CHF", "CHF", "latest") == Decimal("1")


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/tests/unit/test_provider_integration_v2.py">
ERROR [23:5] "EodhdEquityPriceProvider" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [24:5] "FinnhubEquityPriceProvider" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [25:5] "MassiveEquityPriceProvider" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [26:5] "TwelveDataEquityPriceProvider" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [27:5] "equity_price_provider_by_name" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [74:85] Argument of type "dict[Any, Any]" cannot be assigned to parameter "hdrs" of type "Message[str, str]" in function "__init__" [reportArgumentType] (Pyright)
</diagnostics>


patch

from jarvis_finance.fx.providers import FrankfurterFxProvider, MockFxProvider, TwelveDataFxProvider, fx_provider_by_name


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
@@ -5,7 +5,7 @@
 from pathlib import Path

 from jarvis_finance.config.settings import ensure_runtime_dirs, load_settings
-from jarvis_finance.fx.providers import FrankfurterFxProvider, MockFxProvider, TwelveDataFxProvider, fx_provider_by_name
+from jarvis_finance.fx.providers import FrankfurterFxProvider, MockFxProvider, TwelveDataFxProvider
 from jarvis_finance.fx.rates import recheck_transaction_fx_status, update_fx_rates
 from jarvis_finance.imports.instrument_candidates import qwen_resolve_staged_candidates, stage_docx_candidates
 from jarvis_finance.market.providers import CoinGeckoClient, refresh_crypto_prices


from jarvis_finance.market_data.prices import MockEquityPriceProvider, equity_price_provider_by_name, refresh_market_prices

from jarvis_finance.market_data.prices import FmpEquityPriceProvider, MockEquityPriceProvider, equity_price_provider_by_name, refresh_market_prices


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
@@ -11,7 +11,7 @@
 from jarvis_finance.market.providers import CoinGeckoClient, refresh_crypto_prices
 from jarvis_finance.market_data.candidates import CompositePublicLookupProvider, EmptyLookupProvider, dedupe_missing_provider_symbol_alerts, export_mapping_decision_pack, export_mapping_review_template, generate_mapping_candidates, recalculate_candidate_rankings
 from jarvis_finance.market_data.catalog import prepare_catalog_from_instruments
-from jarvis_finance.market_data.prices import FmpEquityPriceProvider, MockEquityPriceProvider, equity_price_provider_by_name, refresh_market_prices
+from jarvis_finance.market_data.prices import MockEquityPriceProvider, equity_price_provider_by_name, refresh_market_prices
 from jarvis_finance.quality.data_quality import check_crypto_data_quality
 from jarvis_finance.quality.git_safety import assert_safe, scan_path
 from jarvis_finance.runtime.backup import backup_runtime_db, restore_runtime_db, verify_backup

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py">
ERROR [14:72] "equity_price_provider_by_name" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
</diagnostics>


    if key == "twelvedata":
        return TwelveDataFxProvider()

    if key in {"twelvedata", "twelvedata"}:
        return TwelveDataFxProvider()


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/fx/providers.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/fx/providers.py
@@ -107,7 +107,7 @@
     key = (name or "").lower().replace("_", "").replace("-", "")
     if key == "frankfurter":
         return FrankfurterFxProvider()
-    if key in {"twelvedata", "twelvedata"}:
+    if key == "twelvedata":
         return TwelveDataFxProvider()
     if key == "mock":
         return MockFxProvider({})


        "twelvedata": TwelveDataEquityPriceProvider,
        "twelvedata": TwelveDataEquityPriceProvider,
        "massive": MassiveEquityPriceProvider,


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/market_data/prices.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/market_data/prices.py
@@ -251,7 +251,6 @@
     providers = {
         "fmp": FmpEquityPriceProvider,
         "finnhub": FinnhubEquityPriceProvider,
-        "twelvedata": TwelveDataEquityPriceProvider,
         "twelvedata": TwelveDataEquityPriceProvider,
         "massive": MassiveEquityPriceProvider,
         "eodhd": EodhdEquityPriceProvider,


Explanation:
**Running tests on patches**

I'm feeling a bit confused about the patch diff I kept. It's not really clear, and I'm worried I might have overlooked something important. So, I think I need to run a subset of tests to see if everything still works as intended. This way, I can ensure that I’m on the right track and nothing got missed in the changes. Let's get started with those tests!

terminal

python -m compileall -q src tests && pytest -q tests/unit/test_provider_integration_v2.py tests/unit/test_fx_market_data_phase_c.py


Output:
....................                                                     [100%]
20 passed in 0.29s


Explanation:
**Inspecting dashboard status**

I’m considering running a full test after the smoke tests. It seems like I need to aggregate some real smoke tests first. But before that, I should inspect the dashboard, possibly checking the provider status function. I wonder if I need to avoid any render calls. Maybe searching for instrument provider statuses could give me more insight. I'll sort this out to ensure everything is functioning properly!


Explanation:
**Investigating search issues**

I'm noticing that the search isn't yielding results, which seems odd. I wonder if something's being ignored in the process. Maybe I should inspect the UI files instead. It might help to dive into the search file dashboard to see if there's something I can adjust or fix. I'll keep at it to find out what's going wrong here! It's important to get this sorted out.


pwd && python - <<'PY'
from pathlib import Path
root=Path('/home/agent/.hermes/repos/FinanceManager/src')
for p in root.rglob('*dashboard*'):
 print(p)
PY


Output:
/home/agent/.hermes/repos/FinanceManager
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard


Explanation:
**Investigating UI files**

I need to use the terminal in Python to list files properly. This seems like a straightforward step, but it’s important for me to ensure I’m checking the right directories. I’ll focus on the search_files target to inspect the UI files. It’s all about making sure I’m thorough, so I get the information I need without missing anything crucial. Let's make sure this process is efficient and effective!


from pathlib import Path
for p in Path('/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard').rglob('*.py'):
    txt=p.read_text()
    if any(x in txt for x in ['provider','OpenFIGI','FMP','Finnhub','Data Provider','status']):
        print(p)
        for i,l in enumerate(txt.splitlines(),1):
            if any(x in l for x in ['provider','OpenFIGI','FMP','Finnhub','Data Provider']):
                print(i,l[:180])


Output:
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
12 from jarvis_finance.market.providers import is_price_stale
73     "missing_provider_symbol": "Mapping fehlt",
117     hidden = {"asset_id", "wallet_id", "instrument_id", "entity_id", "rule_id", "source_id", "provider_symbol_status", "hedge_status", "dedup_key", "fingerprint", "raw_json"}
136         SELECT COALESCE(MAX(provider_timestamp), MAX(fetched_at)) AS ts
165             "last_price_update": (price["provider_timestamp"] or price["fetched_at"]) if price else "",
189         SELECT price, price_currency, fetched_at, provider_timestamp, quality_status, provider
192         ORDER BY COALESCE(provider_timestamp, fetched_at, '') DESC, fetched_at DESC LIMIT 1
199     ts = item.get("provider_timestamp") or item.get("fetched_at")
297         wallet = conn.execute("SELECT wallet_name, wallet_type, platform_provider, notes FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
344             "provider": wallet["platform_provider"] or "",
432         "provider": wallet["provider"],
471         wallet = conn.execute("SELECT wallet_name, wallet_type, platform_provider FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
480             "provider": wallet["platform_provider"] if wallet else "",
498         SELECT wallet_id, wallet_name, wallet_type, platform_provider AS provider,
574             "provider_symbol_status": "mapped" if conn.execute("SELECT 1 FROM instrument_price_mappings WHERE instrument_id=? AND mapping_status IN ('mapped','confirmed','user_conf
675         "missing_provider_symbol": "Marktdaten-Zuordnung fehlt",
694         "missing_provider_symbol": "Für diese Position fehlt oder fehlt noch die bestätigte Marktdaten-Zuordnung.",
774         ("fehlende Provider-Symbole", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='missing_provider_symbol'", "warnung"),
775         ("provider lookup unavailable", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='provider_lookup_unavailable'", "warnung"),
776         ("ambiguous_provider_mapping", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='ambiguous_provider_mapping'", "warnung"),
781         ("provider_symbol_missing", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='missing_provider_symbol'", "warnung"),
783         ("valuation_not_ready", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('missing_provider_symbol','missing_market_price','missing_fx','hedge_status_
806         ("Valuation not ready", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('missing_provider_symbol','missing_market_price','missing_fx','hedge_status_
843                instrument_currency, provider, provider_symbol, provider_market, hedge_status,
858     no_provider = conn.execute("SELECT COUNT(*) AS c FROM instrument_catalog_entries WHERE COALESCE(provider_symbol,'')='' ").fetchone()["c"]
859     manual = conn.execute("SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('manual_review_needed','missing_provider_symbol','ambiguous_provider_mapping')").f
860     return {"catalog_entries": str(total or 0), "multiple_listings": str(multi or 0), "without_provider_symbol": str(no_provider or 0), "manual_review_required": str(manual or 0)}
868                COALESCE(m.mapping_status, 'missing_provider_symbol') AS mapping_status,
869                COALESCE(m.provider, '') AS provider,
870                COALESCE(m.provider_symbol, '') AS provider_symbol,
893                proposed_currency, proposed_asset_class, provider, provider_symbol,
927             SELECT candidate_id, instrument_id, isin, candidate_provider, candidate_provider_symbol,
940             ORDER BY ranking_score DESC, CASE confidence WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, candidate_provider
947             SELECT candidate_id, instrument_id, isin, candidate_provider, candidate_provider_symbol,
960             ORDER BY instrument_id, ranking_score DESC, CASE confidence WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, candidate_provider
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/main.py
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/demo_data.py
20 from jarvis_finance.market.providers import PriceQuote, store_crypto_price
85         store_crypto_price(conn, asset_id=row["asset_id"], quote=PriceQuote(row["coingecko_id"], "CHF", price, provider="synthetic-cache", provider_timestamp="2026-02-01T00:00:00Z"
91             INSERT INTO market_prices(market_price_id, instrument_id, price_date, close, currency, provider, quality_status, created_at)
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_05_wallets.py
64         provider = st.text_input("Provider optional")
73                 wallet_id = create_wallet(conn, wallet_name=name, wallet_type=wallet_type, platform_provider=provider or None, network_chain=chain or None, notes=note or None)
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_01_portfolio.py
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_03_equities_etfs.py
22     if row.get("provider_symbol_status") not in {"mapped", "", None}:
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_13_equity_manage.py
55         provider_filter = st.text_input("Provider optional", key="wizard_search_provider")
65                 provider=provider_filter or None,
373         provider_filter = st.selectbox("Filter Provider", [""] + sorted({c.get("candidate_provider", "") for c in candidates_all if c.get("candidate_provider")}), key="mapping_cand
378         candidates = [c for c in candidates_all if (not provider_filter or c.get("candidate_provider") == provider_filter) and (not exchange_filter or c.get("candidate_exchange") =
380         candidate_labels = [f"{c['candidate_id']} | {c['candidate_provider']} | {c['candidate_provider_symbol']} | {c['candidate_exchange']} | {c['candidate_currency']} | score={c.
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_00_command_center.py
9 from jarvis_finance.market.providers import CoinGeckoClient, refresh_crypto_prices
66         result = refresh_crypto_prices(conn, provider=CoinGeckoClient(max_retries=2, initial_backoff_seconds=1.0, max_backoff_seconds=8.0), currency="CHF", only_missing=True, sleep
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_17_position_add.py
11 from jarvis_finance.market_data.catalog import instrument_provider_statuses
15 SUPPORTED_PROVIDER_KEYS = "OPENFIGI_API_KEY, FMP_API_KEY, FINNHUB_API_KEY, EODHD_API_KEY"
93     show_table(st, instrument_provider_statuses())
110     provider_filter = st.text_input("Provider optional", key="position_add_provider")
120             provider=provider_filter or None,
132             provider=provider_filter or None,
138     search_state = st.session_state.get("position_add_search_results", {"results": [], "warnings": [], "selection_required": True, "provider_lookup_attempted": False})
146             ("OpenFIGI Treffer", lambda row: "openfigi" in str(row.get("data_source", "")).lower()),
147             ("FMP Treffer", lambda row: "fmp" in str(row.get("data_source", "")).lower()),
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_15_manual_review_queue.py
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_08_reports.py
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_04_crypto.py
9 from jarvis_finance.market.providers import CoinGeckoClient, refresh_crypto_prices
136         result = refresh_crypto_prices(conn, provider=CoinGeckoClient(max_retries=2, initial_backoff_seconds=1.0, max_backoff_seconds=8.0), currency="CHF", only_missing=True, sleep
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/views/page_12_crypto_manage.py
158         provider = st.text_input("Schritt 2 — Provider")
167                 wallet_id = create_wallet(conn, wallet_name=name, wallet_type=wallet_type, platform_provider=provider or None, network_chain=chain or None, notes=note or None)


Explanation:
**Figuring out necessary keys**

I need the supported keys for page 17. The current call to `instrument_provider_statuses()` with default `probe=True` seems to be causing some issues with live rendering, which feels like a violation. I think I should apply a patch to set `probe=False` and include the necessary keys. It might also be worth looking into Button probes. I’ll need to read the file to gather more information.