Output:
.../dashboard/views/page_17_position_add.py        |  25 +++-
 src/jarvis_finance/equity/manage.py                |   8 +-
 src/jarvis_finance/market_data/catalog.py          | 130 ++++++++++++++++++---
 src/jarvis_finance/market_data/prices.py           |  19 ++-
 tests/unit/test_equity_manage.py                   |  94 ++++++++++++++-
 5 files changed, 235 insertions(+), 41 deletions(-)
diff --git a/src/jarvis_finance/dashboard/views/page_17_position_add.py b/src/jarvis_finance/dashboard/views/page_17_position_add.py
index 2f35ec0..053efce 100644
--- a/src/jarvis_finance/dashboard/views/page_17_position_add.py
+++ b/src/jarvis_finance/dashboard/views/page_17_position_add.py
@@ -96,11 +96,11 @@ def render(st, conn) -> None:
     st.caption("Keys werden nicht ins Repo geschrieben, nicht geloggt und nicht im Dashboard angezeigt.")

     st.subheader("1. Assetklasse wählen")
-    asset_choice = st.selectbox("Assetklasse", ["Aktie", "ETF", "Crypto", "Cash"], key="position_add_asset_choice")
+    asset_choice = st.selectbox("Assetklasse", ["Alle", "Aktie", "ETF", "Crypto", "Cash"], key="position_add_asset_choice")
     if asset_choice in {"Crypto", "Cash"}:
         st.warning("Für Crypto/Cash bitte vorerst die bestehenden Crypto-/Wallet-Funktionen nutzen. Dieser Sprint macht Aktie und ETF vollständig nutzbar.")
         return
-    asset_class = "stock" if asset_choice == "Aktie" else "etf"
+    asset_class = None if asset_choice == "Alle" else "stock" if asset_choice == "Aktie" else "etf"

     st.subheader("2. Instrument suchen")
     query = st.text_input("ISIN, Name oder Ticker", key="position_add_query")
@@ -140,7 +140,23 @@ def render(st, conn) -> None:
     if warnings:
         st.warning("; ".join(warnings))
     results = list(search_state.get("results", []))
-    show_table(st, _search_rows(results), empty_message="Noch keine Suche ausgeführt oder kein Treffer. Nutze 'Lokal suchen', 'Online suchen' oder 'Manuell anlegen'.")
+    if results:
+        for label, predicate in [
+            ("Lokale Treffer", lambda row: "local" in str(row.get("data_source", "")).lower()),
+            ("OpenFIGI Treffer", lambda row: "openfigi" in str(row.get("data_source", "")).lower()),
+            ("FMP Treffer", lambda row: "fmp" in str(row.get("data_source", "")).lower()),
+        ]:
+            grouped = [row for row in results if predicate(row)]
+            st.caption(f"{label}: {len(grouped)}")
+            if grouped:
+                show_table(st, _search_rows(grouped))
+        other = [row for row in results if not any(token in str(row.get("data_source", "")).lower() for token in ("local", "openfigi", "fmp"))]
+        if other:
+            st.caption(f"Weitere Treffer: {len(other)}")
+            show_table(st, _search_rows(other))
+        st.caption("Manuell anlegen: verfügbar, falls lokale/Online-Treffer nicht eindeutig passen.")
+    else:
+        show_table(st, [], empty_message="Noch keine Suche ausgeführt oder kein Treffer. Nutze 'Lokal suchen', 'Online suchen' oder 'Manuell anlegen'.")

     st.subheader("3. Instrument auswählen")
     selected = None
@@ -176,7 +192,8 @@ def render(st, conn) -> None:
             try:
                 if not manual_isin and not manual_ticker:
                     raise ValueError("ISIN oder Ticker ist erforderlich")
-                create_manual_catalog_entry(conn, asset_class=asset_class, name=manual_name, currency=manual_currency, isin=manual_isin, ticker=manual_ticker, exchange=manual_exchange, note=manual_note)
+                manual_asset_class = asset_class or "stock"
+                create_manual_catalog_entry(conn, asset_class=manual_asset_class, name=manual_name, currency=manual_currency, isin=manual_isin, ticker=manual_ticker, exchange=manual_exchange, note=manual_note)
                 st.success("Instrument lokal gespeichert. Jetzt lokal suchen und bewusst auswählen.")
             except Exception as exc:
                 st.error(f"Manuelle Anlage abgelehnt: {exc}")
diff --git a/src/jarvis_finance/equity/manage.py b/src/jarvis_finance/equity/manage.py
index 76d4bf9..ebc9bba 100644
--- a/src/jarvis_finance/equity/manage.py
+++ b/src/jarvis_finance/equity/manage.py
@@ -384,10 +384,10 @@ def search_instrument_candidates(
             "mapping_status": "exact_isin_match" if r.isin and q.upper() == (r.isin or "").upper() else ("manual_selection_required" if (r.ticker or "").upper() == q.upper() else "candidate"),
             "trust_status": "Eindeutig" if r.isin and q.upper() == (r.isin or "").upper() else ("Manuell prüfen" if (r.ticker or "").upper() == q.upper() else ("Wahrscheinlich" if r.confidence == "medium" else "Unsicher")),
             "confidence": r.confidence,
-            "last_price": "",
-            "price_date": "",
-            "price_currency": "",
-            "price_source": "",
+            "last_price": r.last_price or "",
+            "price_date": r.price_date or "",
+            "price_currency": r.price_currency or "",
+            "price_source": r.price_source or "",
             "selection_required": "yes",
         }
         inst = None
diff --git a/src/jarvis_finance/market_data/catalog.py b/src/jarvis_finance/market_data/catalog.py
index d5c9c79..8765a97 100644
--- a/src/jarvis_finance/market_data/catalog.py
+++ b/src/jarvis_finance/market_data/catalog.py
@@ -75,6 +75,10 @@ class CatalogSearchResult:
     confidence: str = "low"
     source: str = "local_catalog"
     evidence_note: str = ""
+    last_price: str | None = None
+    price_currency: str | None = None
+    price_date: str | None = None
+    price_source: str | None = None

 class CatalogLookupProvider(Protocol):
@@ -135,8 +139,26 @@ class _HttpJsonLookupProvider:
         except error.HTTPError as exc:
             if exc.code in {401, 403}:
                 raise LookupUnavailable(f"{self.name}_auth_failed") from exc
+            if exc.code == 402:
+                raise LookupUnavailable(f"{self.name}_endpoint_restricted") from exc
             if exc.code == 429:
-                raise LookupUnavailable(f"{self.name}_rate_limited") from exc
+                reset = exc.headers.get("ratelimit-reset") if exc.headers else None
+                try:
+                    wait = min(max(float(reset or "1"), 1.0), 3.0)
+                except ValueError:
+                    wait = 1.0
+                time.sleep(wait)
+                try:
+                    with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup retry after 429
+                        return json.loads(response.read().decode("utf-8"))
+                except error.HTTPError as retry_exc:
+                    if retry_exc.code == 429:
+                        raise LookupUnavailable(f"{self.name}_rate_limited") from retry_exc
+                    if retry_exc.code in {401, 403}:
+                        raise LookupUnavailable(f"{self.name}_auth_failed") from retry_exc
+                    if retry_exc.code == 402:
+                        raise LookupUnavailable(f"{self.name}_endpoint_restricted") from retry_exc
+                    raise LookupUnavailable(f"{self.name}_provider_error") from retry_exc
             raise LookupUnavailable(f"{self.name}_provider_error") from exc
         except error.URLError as exc:
             raise LookupUnavailable(f"{self.name}_network_error") from exc
@@ -151,8 +173,26 @@ class _HttpJsonLookupProvider:
         except error.HTTPError as exc:
             if exc.code in {401, 403}:
                 raise LookupUnavailable(f"{self.name}_auth_failed") from exc
+            if exc.code == 402:
+                raise LookupUnavailable(f"{self.name}_endpoint_restricted") from exc
             if exc.code == 429:
-                raise LookupUnavailable(f"{self.name}_rate_limited") from exc
+                reset = exc.headers.get("ratelimit-reset") if exc.headers else None
+                try:
+                    wait = min(max(float(reset or "1"), 1.0), 3.0)
+                except ValueError:
+                    wait = 1.0
+                time.sleep(wait)
+                try:
+                    with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup retry after 429
+                        return json.loads(response.read().decode("utf-8"))
+                except error.HTTPError as retry_exc:
+                    if retry_exc.code == 429:
+                        raise LookupUnavailable(f"{self.name}_rate_limited") from retry_exc
+                    if retry_exc.code in {401, 403}:
+                        raise LookupUnavailable(f"{self.name}_auth_failed") from retry_exc
+                    if retry_exc.code == 402:
+                        raise LookupUnavailable(f"{self.name}_endpoint_restricted") from retry_exc
+                    raise LookupUnavailable(f"{self.name}_provider_error") from retry_exc
             raise LookupUnavailable(f"{self.name}_provider_error") from exc
         except error.URLError as exc:
             raise LookupUnavailable(f"{self.name}_network_error") from exc
@@ -249,42 +289,84 @@ class FmpSearchProvider(_HttpJsonLookupProvider):
     name = "fmp"
     api_key_env = ("FMP_API_KEY", "FINANCIAL_MODELING_PREP_API_KEY", "JARVIS_FMP_API_KEY")

-    def _request(self, query: str) -> object:
+    def _get_stable(self, endpoint: str, params: dict[str, str]) -> object:
         if not self.api_key:
             raise LookupUnavailable("fmp_api_key_missing")
-        url = "https://financialmodelingprep.com/api/v3/search?" + parse.urlencode({"query": query, "limit": 10, "apikey": self.api_key})
-        return self._get_json(url)
+        url = "https://financialmodelingprep.com/stable/" + endpoint.lstrip("/") + "?" + parse.urlencode({**params, "apikey": self.api_key})
+        return self._get_json(url, headers={"Accept": "application/json"})
+
+    def _request_many(self, query: str) -> list[dict[str, object]]:
+        q = (query or "").strip()
+        if not q:
+            return []
+        endpoints: list[tuple[str, dict[str, str]]] = []
+        if ISIN_RE.match(q.upper()):
+            endpoints.append(("search-isin", {"isin": q.upper()}))
+        if len(q.split()) == 1 and len(q) <= 12:
+            endpoints.append(("search-symbol", {"query": q.upper()}))
+            endpoints.append(("search-exchange-variants", {"symbol": q.upper()}))
+        endpoints.append(("search-name", {"query": q}))
+        seen: set[tuple[str, str, str]] = set()
+        rows: list[dict[str, object]] = []
+        last_error: LookupUnavailable | None = None
+        for endpoint, params in endpoints:
+            try:
+                payload = self._get_stable(endpoint, params)
+            except LookupUnavailable as exc:
+                if "endpoint_restricted" not in str(exc):
+                    last_error = exc
+                continue
+            items = payload if isinstance(payload, list) else payload.get("data", []) if isinstance(payload, dict) else []
+            for item in items[:10]:
+                if not isinstance(item, dict):
+                    continue
+                symbol = str(item.get("symbol") or item.get("ticker") or "").strip().upper()
+                name = str(item.get("name") or item.get("companyName") or item.get("securityName") or "").strip()
+                exch = str(item.get("exchangeShortName") or item.get("exchange") or item.get("exchangeCode") or "").strip().upper()
+                key = (symbol, name.lower(), exch)
+                if key in seen or not (symbol or name):
+                    continue
+                seen.add(key)
+                item = dict(item)
+                item["_fmp_endpoint"] = endpoint
+                rows.append(item)
+        if not rows and last_error is not None:
+            raise last_error
+        return rows

     def _profile(self, symbol: str) -> dict[str, object]:
         if not symbol or not self.api_key:
             return {}
         try:
-            url = f"https://financialmodelingprep.com/api/v3/profile/{parse.quote(symbol)}?" + parse.urlencode({"apikey": self.api_key})
-            payload = self._get_json(url)
+            payload = self._get_stable("profile", {"symbol": symbol})
         except LookupUnavailable:
             return {}
         if isinstance(payload, list) and payload and isinstance(payload[0], dict):
             return payload[0]
+        if isinstance(payload, dict):
+            data = payload.get("data")
+            if isinstance(data, list) and data and isinstance(data[0], dict):
+                return data[0]
+            return payload
         return {}

     def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
-        payload = self._request(query)
-        rows = payload if isinstance(payload, list) else []
+        rows = self._request_many(query)
         results: list[CatalogSearchResult] = []
-        for item in rows[:10]:
-            if not isinstance(item, dict):
-                continue
-            symbol = str(item.get("symbol") or "").strip().upper()
-            name = str(item.get("name") or symbol or query).strip()
+        for item in rows[:25]:
+            symbol = str(item.get("symbol") or item.get("ticker") or "").strip().upper()
+            name = str(item.get("name") or item.get("companyName") or item.get("securityName") or symbol or query).strip()
             if not symbol and not name:
                 continue
             profile = self._profile(symbol) if symbol else {}
-            isin = str(profile.get("isin") or item.get("isin") or "").strip().upper() or None
-            exchange = str(item.get("exchangeShortName") or item.get("exchange") or profile.get("exchangeShortName") or profile.get("exchange") or "").strip().upper() or None
-            currency = str(item.get("currency") or profile.get("currency") or "").strip().upper() or None
+            isin = str(item.get("isin") or profile.get("isin") or "").strip().upper() or None
+            exchange = str(item.get("exchangeShortName") or item.get("exchange") or item.get("exchangeCode") or profile.get("exchangeShortName") or profile.get("exchange") or "").strip().upper() or None
+            currency = str(item.get("currency") or profile.get("currency") or profile.get("priceCurrency") or "").strip().upper() or None
             is_etf = bool(profile.get("isEtf")) or "etf" in name.lower() or "fund" in name.lower()
             inferred_class = asset_class or ("etf" if is_etf else "stock")
             confidence = "medium" if isin or (symbol and exchange) else "low"
+            raw_price = profile.get("price") or profile.get("lastPrice") or profile.get("mktCapPrice")
+            last_price = str(raw_price).strip() if raw_price not in {None, "", 0, "0"} else None
             results.append(
                 CatalogSearchResult(
                     None,
@@ -300,7 +382,11 @@ class FmpSearchProvider(_HttpJsonLookupProvider):
                     country=str(profile.get("country") or item.get("country") or "").strip().upper() or None,
                     confidence=confidence,
                     source="fmp",
-                    evidence_note="Financial Modeling Prep profile/search result; manual selection required",
+                    evidence_note="Financial Modeling Prep stable search/profile result; manual selection required",
+                    last_price=last_price,
+                    price_currency=currency,
+                    price_date=utc_now()[:10] if last_price else None,
+                    price_source="fmp profile" if last_price else None,
                 )
             )
         return results
@@ -320,6 +406,8 @@ def _provider_error_category(message: str) -> str:
         return "rate_limited"
     if "network_error" in text:
         return "network_error"


todo

Compile, pytest, Git-Safety, diff-check, status

Commit und Push, falls Änderungen


Aktuellen Provider-Code gegen Anforderungen prüfen

OpenFIGI/FMP echte Endpunkte, Normalisierung, Cache, Preisvorschau ggf. anpassen

Wizard-UI und expliziten Preisupdate-Workflow verifizieren/anpassen

Mock-Tests ergänzen/aktualisieren und echten aggregierten Smoke-Test ausführen

Commit und Push, falls Änderungen


terminal

git add src/jarvis_finance/dashboard/views/page_17_position_add.py src/jarvis_finance/equity/manage.py src/jarvis_finance/market_data/catalog.py src/jarvis_finance/market_data/prices.py tests/unit/test_equity_manage.py && git commit -m "feat: add live equity instrument providers" && git rev-parse HEAD


Output:
[main 9f1ead2] feat: add live equity instrument providers
 5 files changed, 235 insertions(+), 41 deletions(-)
9f1ead219519f11a594ff1731cd7eeefb56f72fb


python - <<'PY'
from pathlib import Path
import subprocess
p=Path.home()/'jarvis_runtime/finance-system/secrets/Github_token_FinanceManager.csv'
lines=[x.strip() for x in p.read_text(errors='replace').splitlines() if x.strip()]
token=None
for line in lines:
    for part in line.replace(';', ',').split(','):
        part=part.strip().strip('"')
        if part.startswith(('ghp_', 'github_pat_')):
            token=part
            break
    if token:
        break
if not token:
    raise SystemExit('TOKEN_MISSING')
remote=subprocess.check_output(['git','remote','get-url','origin'], text=True).strip()
if remote.startswith('https://github.com/'):
    push_url='https://x-access-token:'+token+'@github.com/'+remote.removeprefix('https://github.com/')
else:
    push_url=remote
subprocess.run(['git','push',push_url,'main'], check=True)
print('PUSH_OK')
print(subprocess.check_output(['git','log','-1','--oneline'], text=True).strip())
PY


Output:
To https://github.com/Gamexgit/FinanceManager.git
   a870bb3..9f1ead2  main -> main
PUSH_OK
9f1ead2 feat: add live equity instrument providers


todo

Commit und Push, falls Änderungen


Sir, Provider-Sprint ist umgesetzt, getestet, committed und gepusht.

## Ergebnis

- **OpenFIGI integriert:** ja
- **OpenFIGI Base URL verwendet:** ja — `https://api.openfigi.com/v3`
- **OpenFIGI echter Test erfolgreich:** nein, Key wird von OpenFIGI mit `auth_failed` / HTTP `401` abgelehnt
- **FMP integriert:** ja
- **FMP echter Test erfolgreich:** ja
- **Providerstatus im Dashboard sichtbar:** ja
- **Online-Suche funktioniert:** ja, insbesondere über FMP `search-symbol`, `search-name`, `profile`
- **Preisvorschau funktioniert:** ja, über FMP `stable/profile`
- **Equity/ETF Preisupdate vorbereitet:** ja — CLI `update-equity-prices`
- **Keine Position gespeichert:** ja
- **Keine produktiven DOCX-Imports:** ja
- **Keine echten Portfolio-Daten verwendet:** ja

## Implementiert

### OpenFIGI

- Echter Provider über:
  - `POST https://api.openfigi.com/v3/mapping`
  - `POST https://api.openfigi.com/v3/search`
- Header:
  - `X-OPENFIGI-APIKEY`
  - `Content-Type: application/json`
- Unterstützt:
  - ISIN Mapping
  - Ticker + Exchange Mapping
  - Name-/Keyword-Search
- Rate-Limit-Handling:
  - `429` wird sauber als `rate_limited` klassifiziert
  - kurzer Backoff über Rate-Limit-Header, falls vorhanden
- Keine harte Dashboard-Exception.
- Keine automatische Übernahme.
- Treffer werden in lokales Kandidatenformat normalisiert.

### FMP

- Umgestellt auf echte `stable`-Endpunkte:
  - `/stable/search-symbol`
  - `/stable/search-name`
  - `/stable/search-isin`
  - `/stable/search-exchange-variants`
  - `/stable/profile`
- Preisvorschau über:
  - `/stable/profile?symbol=...`
- Ergebnisnormalisierung in dasselbe interne Kandidatenformat.
- FMP-Preisvorschau fließt in Wizard-Auswahl ein.
- Subscription-/Tarif-Limits wie HTTP `402` werden nicht mehr als harter Crash behandelt.

### Wizard „Position hinzufügen“

Verbessert:

- Assetklasse:
  - Alle
  - Aktie
  - ETF
- Suchfeld:
  - ISIN / Name / Ticker
- Filter:
  - Währung
  - Exchange
  - Land
  - Provider
- Buttons:
  - Lokal suchen
  - Online suchen
  - Manuell anlegen
- Trefferanzeige gruppiert:
  - Lokale Treffer
  - OpenFIGI Treffer
  - FMP Treffer
  - Weitere Treffer
  - Manuell anlegen bleibt sichtbar/verfügbar
- User Mode zeigt weiterhin keine technischen IDs / FIGI-Rohfelder.
- Ticker allein wird nicht automatisch übernommen.
- Speicherung erst nach Review + Bestätigung.

### Cache

- Online-Treffer werden bei expliziter Suche lokal im Instrument-Katalog gecacht.
- Dashboard-Render löst keine Provider-Abfragen aus.
- Provider-Abfrage nur bei Klick auf **Online suchen**.

### Preisupdate

Expliziter Workflow ist vorhanden und getestet:

```bash
python -m jarvis_finance.cli.main update-equity-prices --dry-run --limit 1
```

Ergebnis im Dry Run:

- stock: OK
- etf: OK
- keine Positionen verändert

## Echter Provider-Smoke-Test

Aggregiert, ohne Keys oder echte Portfolio-Daten:

- Secrets-Datei vorhanden: ja
- Secrets-Datei restriktiv: ja
- OpenFIGI-Key vorhanden: ja
- FMP-Key vorhanden: ja
- OpenFIGI erreichbar: ja
- OpenFIGI Kandidaten gefunden: nein
- OpenFIGI Fehlerkategorie: `auth_failed`
- FMP erreichbar: ja
- FMP Kandidaten gefunden: ja
- FMP Preisvorschau möglich: ja
- Keine Position gespeichert: ja
- Keine Portfolio-Daten verwendet: ja

Wichtige Einschränkung:

- OpenFIGI lehnt den aktuell gespeicherten Key weiterhin mit `401` ab.
- Interessanterweise ist die API grundsätzlich erreichbar; der Fehler ist spezifisch Auth/Key-Akzeptanz.
- FMP funktioniert für Name/Symbol/Profile.
- FMP `search-isin` und `search-exchange-variants` können je nach Tarif `402 Restricted Endpoint` liefern; das wird nun sauber abgefangen. Natürlich, weil „stable“ offensichtlich ein Euphemismus ist.

## Tests

- Compile: OK
- pytest: **282 passed**
- Git-Safety: **GIT_SAFETY_OK**
- `git diff --check`: OK
- `git status`: clean

Ergänzte/abgedeckte Tests u.a.:

- OpenFIGI-Key fehlt → kein Crash
- OpenFIGI-Key im Header, nicht Query
- OpenFIGI Mapping nach ISIN
- OpenFIGI 429 sauber behandelt
- FMP-Key fehlt → kein Crash
- FMP Symbol Search
- FMP Name Search
- FMP ISIN Search via Mock
- FMP Profile Preisvorschau
- FMP stable endpoints
- Preisvorschau ohne Preis zeigt nicht `0`
- Lokale Suche vor Online
- Keine Online-Suche beim Rendern/local-only
- Provider-Konflikt → manuelle Prüfung
- API-Key wird nicht geloggt/returned
- User Mode ohne technische IDs
- Git-Safety sauber

## Git

- Commit: `9f1ead219519f11a594ff1731cd7eeefb56f72fb`
- Message: `feat: add live equity instrument providers`
- Push-Status: OK — `main -> origin/main`

## Offene Grenzen

- OpenFIGI-Key wird vom Provider aktuell mit `401` abgelehnt, obwohl lokal vorhanden und korrekt geladen.
- FMP Free-/aktueller Tarif erlaubt nicht alle gewünschten Stable-Endpunkte; `search-isin` und Exchange-Variants können eingeschränkt sein.
- OpenFIGI bleibt Mapping-/Identifier-Provider, FMP ist aktuell der praktische Provider für Suche + Preisvorschau.
- Keine automatische Speicherung bei Mehrdeutigkeit — absichtlich. Sicherheit vor Bequemlichkeit, wie zivilisierte Menschen es nennen.