    80|    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
    81|        ...
    82|
    83|
    84|class UnavailableCatalogLookupProvider:
    85|    name = "unavailable"
    86|
    87|    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
    88|        raise LookupUnavailable("external instrument lookup is not configured")
    89|
    90|
    91|class _HttpJsonLookupProvider:
    92|    name = "provider"
    93|    api_key_env: tuple[str, ...] = ()
    94|
    95|    def __init__(self, *, api_key: str | None = None, timeout_seconds: float = 8.0, min_interval_seconds: float = 0.2) -> None:
    96|        self.api_key = api_key or next((os.environ.get(k) for k in self.api_key_env if os.environ.get(k)), None)
    97|        self.timeout_seconds = timeout_seconds
    98|        self.min_interval_seconds = min_interval_seconds
    99|        self._last_request_at = 0.0
   100|
   101|    def _throttle(self) -> None:
   102|        elapsed = time.monotonic() - self._last_request_at
   103|        if elapsed < self.min_interval_seconds:
   104|            time.sleep(self.min_interval_seconds - elapsed)
   105|        self._last_request_at = time.monotonic()
   106|
   107|    def _get_json(self, url: str, *, headers: dict[str, str] | None = None) -> object:
   108|        self._throttle()
   109|        req = request.Request(url, headers=headers or {})
   110|        with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup
   111|            return json.loads(response.read().decode("utf-8"))
   112|
   113|    def _post_json(self, url: str, payload: object, *, headers: dict[str, str] | None = None) -> object:
   114|        self._throttle()
   115|        data = json.dumps(payload).encode("utf-8")
   116|        req = request.Request(url, data=data, headers={"Content-Type": "application/json", **(headers or {})})
   117|        with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup
   118|            return json.loads(response.read().decode("utf-8"))
   119|
   120|
   121|class OpenFigiLookupProvider(_HttpJsonLookupProvider):
   122|    name = "openfigi"
   123|    api_key_env = ("OPENFIGI_API_KEY", "JARVIS_OPENFIGI_API_KEY")
   124|
   125|    def _request(self, query: str) -> object:
   126|        if not self.api_key:
   127|            raise LookupUnavailable("openfigi_api_key_missing")
   128|        headers = {"X-OPENFIGI-APIKEY": self.api_key}
   129|        return self._post_json("https://api.openfigi.com/v3/search", {"query": query}, headers=headers)
   130|
   131|    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
   132|        payload = self._request(query)
   133|        rows = payload.get("data", []) if isinstance(payload, dict) else payload if isinstance(payload, list) else []
   134|        results: list[CatalogSearchResult] = []
   135|        for item in rows[:10]:
   136|            if not isinstance(item, dict):
   137|                continue
   138|            name = str(item.get("name") or item.get("securityDescription") or "").strip()
   139|            ticker = str(item.get("ticker") or "").strip().upper() or None
   140|            exchange = str(item.get("exchCode") or item.get("marketSector") or "").strip().upper() or None
   141|            if not name and not ticker:
   142|                continue
   143|            sec_type = str(item.get("securityType") or item.get("marketSecDes") or "").lower()
   144|            inferred_class = asset_class or ("etf" if "fund" in sec_type or "etf" in sec_type else "stock")
   145|            results.append(CatalogSearchResult(None, inferred_class, name or ticker or query, ticker=ticker, exchange=exchange, provider="openfigi", provider_symbol=str(item.get("compositeFIGI") or item.get("figi") or ticker or "") or None, confidence="medium", source="openfigi", evidence_note="OpenFIGI search result; manual selection required"))
   146|        return results
   147|
   148|
   149|class FinnhubSymbolLookupProvider(_HttpJsonLookupProvider):
   150|    name = "finnhub"
   151|    api_key_env = ("FINNHUB_API_KEY", "JARVIS_FINNHUB_API_KEY")
   152|
   153|    def _request(self, query: str) -> object:
   154|        if not self.api_key:
   155|            raise LookupUnavailable("finnhub_api_key_missing")
   156|        url = "https://finnhub.io/api/v1/search?" + parse.urlencode({"q": query, "token": self.api_key})
   157|        return self._get_json(url)
   158|
   159|    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
   160|        payload = self._request(query)
   161|        rows = payload.get("result", []) if isinstance(payload, dict) else []
   162|        results: list[CatalogSearchResult] = []
   163|        for item in rows[:10]:
   164|            if not isinstance(item, dict):
   165|                continue
   166|            symbol = str(item.get("symbol") or "").strip().upper()
   167|            description = str(item.get("description") or symbol or query).strip()
   168|            if not symbol:
   169|                continue
   170|            inferred_class = asset_class or ("etf" if "etf" in description.lower() else "stock")
   171|            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"))
   172|        return results
   173|
   174|
   175|class FmpSearchProvider(_HttpJsonLookupProvider):
   176|    name = "fmp"
   177|    api_key_env = ("FMP_API_KEY", "FINANCIAL_MODELING_PREP_API_KEY", "JARVIS_FMP_API_KEY")
   178|
   179|    def _request(self, query: str) -> object:
   180|        if not self.api_key:
   181|            raise LookupUnavailable("fmp_api_key_missing")
   182|        url = "https://financialmodelingprep.com/api/v3/search?" + parse.urlencode({"query": query, "limit": 10, "apikey": self.api_key})
   183|        return self._get_json(url)
   184|
   185|    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
   186|        payload = self._request(query)
   187|        rows = payload if isinstance(payload, list) else []
   188|        results: list[CatalogSearchResult] = []
   189|        for item in rows[:10]:
   190|            if not isinstance(item, dict):
   191|                continue
   192|            symbol = str(item.get("symbol") or "").strip().upper()
   193|            name = str(item.get("name") or symbol or query).strip()
   194|            if not symbol and not name:
   195|                continue
   196|            inferred_class = asset_class or ("etf" if "etf" in name.lower() else "stock")
   197|            results.append(CatalogSearchResult(None, inferred_class, name, ticker=symbol.split(".")[0] if symbol else None, exchange=str(item.get("exchangeShortName") or item.get("exchange") or "").strip().upper() or None, trading_currency=str(item.get("currency") or "").strip().upper() or None, provider="fmp", provider_symbol=symbol or None, country=str(item.get("country") or "").strip().upper() or None, confidence="low", source="fmp", evidence_note="Financial Modeling Prep search result; manual selection required"))
   198|        return results
   199|
   200|
   201|def default_instrument_lookup_providers() -> list[CatalogLookupProvider]:
   202|    return [OpenFigiLookupProvider(), FinnhubSymbolLookupProvider(), FmpSearchProvider()]
   203|
   204|
   205|def _validate_entry(entry: CatalogEntryInput) -> None:
   206|    if entry.asset_class.lower() not in ASSET_CLASSES:
   207|        raise ValueError("invalid asset_class")
   208|    if not entry.name.strip():
   209|        raise ValueError("name is required")
   210|    if entry.source_confidence not in CONFIDENCES:
   211|        raise ValueError("invalid source_confidence")
   212|    if entry.hedge_status not in HEDGE_STATUSES:
   213|        raise ValueError("invalid hedge_status")
   214|    if entry.instrument_status not in INSTRUMENT_STATUSES:
   215|        raise ValueError("invalid instrument_status")
   216|    if entry.valuation_policy not in VALUATION_POLICIES:
   217|        raise ValueError("invalid valuation_policy")
   218|
   219|
   220|def upsert_catalog_entry(conn: Connection, entry: CatalogEntryInput, *, note: str, created_by: str = "system") -> str:
   221|    if not note.strip():
   222|        raise ValueError("catalog entry upsert requires a note")
   223|    _validate_entry(entry)
   224|    isin = (entry.isin or "").strip().upper() or None
   225|    ticker = (entry.ticker or "").strip().upper() or None
   226|    exchange = (entry.exchange or "").strip().upper() or None
   227|    trading_currency = (entry.trading_currency or "").strip().upper() or None
   228|    instrument_currency = (entry.instrument_currency or trading_currency or "").strip().upper() or None
   229|    provider = (entry.provider or "manual").strip().lower()
   230|    provider_symbol = (entry.provider_symbol or "").strip() or None
   231|    provider_market = (entry.provider_market or exchange or "").strip().upper() or None
   232|    catalog_entry_id = stable_id("catalog", entry.asset_class.lower(), isin or "", ticker or "", exchange or "", trading_currency or "", provider, provider_symbol or "", entry.name)
   233|    now = utc_now()
   234|    old = conn.execute("SELECT * FROM instrument_catalog_entries WHERE catalog_entry_id=?", (catalog_entry_id,)).fetchone()
   235|    conn.execute(
   236|        """
   237|        INSERT INTO instrument_catalog_entries(
   238|            catalog_entry_id, asset_class, name, normalized_name, isin, ticker, exchange,
   239|            trading_currency, instrument_currency, provider, provider_symbol, provider_market,
   240|            country, sector, issuer, fund_type, is_currency_hedged, hedged_to_currency,
   241|            hedge_status, instrument_status, valuation_policy, source, source_confidence,
   242|            last_verified_at, notes, created_at, updated_at
   243|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   244|        ON CONFLICT(catalog_entry_id) DO UPDATE SET
   245|            name=excluded.name,
   246|            normalized_name=excluded.normalized_name,
   247|            provider_symbol=excluded.provider_symbol,
   248|            provider_market=excluded.provider_market,
   249|            country=excluded.country,
   250|            sector=excluded.sector,
   251|            issuer=excluded.issuer,
   252|            fund_type=excluded.fund_type,
   253|            is_currency_hedged=excluded.is_currency_hedged,
   254|            hedged_to_currency=excluded.hedged_to_currency,
   255|            hedge_status=excluded.hedge_status,
   256|            instrument_status=excluded.instrument_status,
   257|            valuation_policy=excluded.valuation_policy,
   258|            source=excluded.source,
   259|            source_confidence=excluded.source_confidence,
   260|            last_verified_at=excluded.last_verified_at,
   261|            notes=excluded.notes,
   262|            updated_at=excluded.updated_at
   263|        """,
   264|        (
   265|            catalog_entry_id, entry.asset_class.lower(), entry.name.strip(), normalize_name(entry.name), isin, ticker, exchange,
   266|            trading_currency, instrument_currency, provider, provider_symbol, provider_market,
   267|            entry.country, entry.sector, entry.issuer, entry.fund_type, 1 if entry.is_currency_hedged else 0 if entry.is_currency_hedged is False else None,
   268|            (entry.hedged_to_currency or "").strip().upper() or None, entry.hedge_status, entry.instrument_status,
   269|            entry.valuation_policy, entry.source, entry.source_confidence, now, entry.notes, now, now,
   270|        ),
   271|    )
   272|    record_audit_event(
   273|        conn,
   274|        source="instrument_catalog",
   275|        action="upsert_catalog_entry",
   276|        entity_type="instrument_catalog_entry",
   277|        entity_id=catalog_entry_id,
   278|        old_values=dict(old) if old else None,
   279|        new_values={"asset_class": entry.asset_class.lower(), "isin": isin, "ticker": ticker, "exchange": exchange, "provider": provider, "provider_symbol": provider_symbol},
   280|        user_text_note=note,
   281|        confirmed=True,
   282|        created_by=created_by,
   283|    )
   284|    conn.commit()
   285|    return catalog_entry_id
   286|
   287|
   288|def search_local_catalog(conn: Connection, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
   289|    q = (query or "").strip()
   290|    if not q:
   291|        return []
   292|    clauses = ["(upper(COALESCE(isin,''))=upper(?) OR upper(COALESCE(ticker,''))=upper(?) OR upper(COALESCE(provider_symbol,''))=upper(?) OR normalized_name LIKE ? OR upper(COALESCE(exchange,''))=upper(?) OR upper(COALESCE(trading_currency,''))=upper(?))"]
   293|    params: list[object] = [q, q, q, f"%{normalize_name(q)}%", q, q]
   294|    if asset_class:
   295|        ac = asset_class.lower()
   296|        if ac == "equity":
   297|            clauses.append("asset_class IN ('equity','stock')")
   298|        else:
   299|            clauses.append("asset_class=?")
   300|            params.append(ac)
   301|    rows = conn.execute(
   302|        "SELECT * FROM instrument_catalog_entries WHERE " + " AND ".join(clauses) + " ORDER BY source_confidence, name, exchange, trading_currency LIMIT 50",
   303|        tuple(params),
   304|    ).fetchall()
   305|    return [
   306|        CatalogSearchResult(
   307|            catalog_entry_id=row["catalog_entry_id"], asset_class=row["asset_class"], name=row["name"], isin=row["isin"], ticker=row["ticker"], exchange=row["exchange"],
   308|            trading_currency=row["trading_currency"], instrument_currency=row["instrument_currency"], provider=row["provider"], provider_symbol=row["provider_symbol"],
   309|            provider_market=row["provider_market"], country=row["country"], hedge_status=row["hedge_status"] or "unknown", instrument_status=row["instrument_status"] or "unknown",
   310|            confidence=row["source_confidence"] or "low", source="local_catalog", evidence_note="local catalog match",
   311|        )
   312|        for row in rows
   313|    ]
   314|
   315|
   316|def search_instruments(conn: Connection, query: str, asset_class: str | None = None, *, providers: list[CatalogLookupProvider] | None = None) -> tuple[list[CatalogSearchResult], list[str]]:
   317|    results = search_local_catalog(conn, query, asset_class)
   318|    warnings: list[str] = []
   319|    for provider in providers or []:
   320|        try:
   321|            external = provider.search(query=query, asset_class=asset_class)
   322|        except LookupUnavailable as exc:
   323|            warning = str(exc) or "provider_lookup_unavailable"
   324|            if warning == "external instrument lookup is not configured":
   325|                warning = "provider_lookup_unavailable"
   326|            warnings.append(warning)
   327|            create_alert(conn, priority="info", category="market_data", entity_type="instrument_catalog", entity_id="external_lookup", rule_id=warning, message="External instrument catalog provider is not configured or unavailable.", evidence={"provider": getattr(provider, "name", "unknown")}, fingerprint=f"catalog_provider_lookup_unavailable:{getattr(provider, 'name', 'unknown')}:{warning}")
   328|            continue
   329|        except Exception:
   330|            warnings.append("provider_lookup_unavailable")
   331|            create_alert(conn, priority="warnung", category="market_data", entity_type="instrument_catalog", entity_id="external_lookup", rule_id="provider_lookup_unavailable", message="External instrument catalog provider unavailable.", evidence={"provider": getattr(provider, "name", "unknown")}, fingerprint=f"catalog_provider_lookup_unavailable:{getattr(provider, 'name', 'unknown')}")
   332|            continue
   333|        for item in external:
   334|            if item.catalog_entry_id is None:
   335|                try:
   336|                    catalog_id = upsert_catalog_entry(
   337|                        conn,
   338|                        CatalogEntryInput(
   339|                            asset_class=item.asset_class,
   340|                            name=item.name,
   341|                            isin=item.isin,
   342|                            ticker=item.ticker,
   343|                            exchange=item.exchange,
   344|                            trading_currency=item.trading_currency,
   345|                            instrument_currency=item.instrument_currency or item.trading_currency,
   346|                            provider=item.provider or getattr(provider, "name", "external"),
   347|                            provider_symbol=item.provider_symbol,
   348|                            provider_market=item.provider_market,
   349|                            country=item.country,
   350|                            source=item.source,
   351|                            source_confidence=item.confidence if item.confidence in CONFIDENCES else "low",
   352|                            notes=item.evidence_note,
   353|                        ),
   354|                        note=f"Cache explicit instrument search result from {getattr(provider, 'name', 'external')}",
   355|                        created_by="dashboard_search",
   356|                    )
   357|                    item.catalog_entry_id = catalog_id
   358|                except Exception:
   359|                    warnings.append("provider_result_cache_failed")
   360|            results.append(item)
   361|    conn.commit()
   362|    return results, warnings
   363|
   364|
   365|def prepare_catalog_from_instruments(conn: Connection, *, instrument_ids: list[str], source: str = "true_wealth", note: str = "prepare catalog from existing instruments") -> dict[str, int]:
   366|    summary = {"instruments_total": 0, "catalog_entries_created": 0, "multiple_listings": 0, "manual_review_required": 0, "hedge_unknown": 0, "instrument_status_unknown": 0, "valuation_ready": 0}
   367|    seen_isin: dict[str, set[tuple[str | None, str | None]]] = {}
   368|    for instrument_id in instrument_ids:
   369|        row = conn.execute("SELECT * FROM instruments WHERE instrument_id=?", (instrument_id,)).fetchone()
   370|

Use offset=370 to continue reading (showing 80-369 of 412 lines)


     1|from __future__ import annotations
     2|
     3|from pathlib import Path
     4|from sqlite3 import Connection
     5|
     6|from jarvis_finance.config.settings import ensure_runtime_dirs, find_repo_root, load_settings
     7|from jarvis_finance.dashboard.demo_data import load_synthetic_demo_data
     8|from jarvis_finance.dashboard.pages import PAGES
     9|from jarvis_finance.dashboard import data as dashboard_data
    10|from jarvis_finance.storage.database import connect
    11|from jarvis_finance.storage.migrations import apply_migrations
    12|
    13|USER_PAGE_TITLES = ["Command Center", "Crypto", "Wallets", "Aktien & ETFs", "Position hinzufügen", "Reports"]
    14|ADMIN_PAGE_TITLES = ["Ledger", "Audit", "Alerts", "Data Quality", "Import Wizard", "Settings", "Crypto Manage", "Equity/ETF Manage", "Manual Review Queue", "Watchlist"]
    15|TITLE_ALIASES = {"Settings / Data Quality": "Settings", "Data Quality Center": "Data Quality", "Equities/ETFs": "Aktien & ETFs"}
    16|
    17|
    18|def safe_connect_existing_db(db_path: Path) -> Connection | None:
    19|    if not db_path.exists():
    20|        return None
    21|    conn = connect(db_path)
    22|    apply_migrations(conn)
    23|    return conn
    24|
    25|
    26|def create_demo_db(db_path: Path) -> None:
    27|    db_path.parent.mkdir(parents=True, exist_ok=True)
    28|    conn = connect(db_path)
    29|    try:
    30|        load_synthetic_demo_data(conn)
    31|    finally:
    32|        conn.close()
    33|
    34|
    35|def get_user_navigation_titles() -> list[str]:
    36|    return list(USER_PAGE_TITLES)
    37|
    38|
    39|def get_admin_navigation_titles() -> list[str]:
    40|    return list(ADMIN_PAGE_TITLES)
    41|
    42|
    43|def _display_title(page) -> str:
    44|    return TITLE_ALIASES.get(page.PAGE_TITLE, page.PAGE_TITLE)
    45|
    46|
    47|def _page_by_display_title() -> dict[str, object]:
    48|    return {_display_title(page): page for page in PAGES}
    49|
    50|
    51|def run_app() -> None:
    52|    import streamlit as st
    53|
    54|    st.set_page_config(page_title="JARVIS Finance", layout="wide")
    55|    st.sidebar.title("JARVIS Finance")
    56|    st.sidebar.caption("Investment Dashboard · lokal · auditierbar")
    57|
    58|    try:
    59|        repo_root = find_repo_root()
    60|        settings = load_settings(repo_root=repo_root)
    61|    except Exception as exc:
    62|        st.error(f"Settings-/Runtime-Ladeprüfung fehlgeschlagen: {exc}")
    63|        st.info("Prüfe JARVIS_FINANCE_RUNTIME_DIR. Runtime muss ausserhalb des Repos liegen.")
    64|        return
    65|
    66|    ensure_runtime_dirs(settings)
    67|    db_path = settings.db_path
    68|    conn = safe_connect_existing_db(db_path)
    69|
    70|    if conn is None:
    71|        st.title("JARVIS Finance Dashboard")
    72|        st.error(f"Keine lokale SQLite-DB gefunden: {db_path}")
    73|        st.info("Das Dashboard erstellt oder verändert keine echte Datenbank automatisch.")
    74|        st.caption("Für lokale Entwicklung kann explizit eine synthetische Dummy-DB aus tests/fixtures geladen werden.")
    75|        if st.button("Synthetische Dummy-DB jetzt erstellen", type="primary"):
    76|            create_demo_db(db_path)
    77|            st.success("Synthetische Dummy-DB erstellt. Bitte Seite neu laden.")
    78|        return
    79|
    80|    try:
    81|        page_map = _page_by_display_title()
    82|        mode = st.sidebar.radio("Modus", ["User Mode", "Admin / Review / Debug"], index=0)
    83|        st.sidebar.markdown("---")
    84|        if mode == "User Mode":
    85|            names = [name for name in USER_PAGE_TITLES if name in page_map]
    86|            requested = st.session_state.pop("jarvis_requested_page", None) if hasattr(st, "session_state") else None
    87|            default_index = names.index(requested) if requested in names else 0
    88|            selected = st.sidebar.radio("Hauptnavigation", names, index=default_index)
    89|            st.sidebar.caption("Technische IDs und Rohdaten sind im User Mode ausgeblendet.")
    90|        else:
    91|            user_default = [name for name in USER_PAGE_TITLES if name in page_map]
    92|            admin_names = [name for name in ADMIN_PAGE_TITLES if name in page_map]
    93|            selected = st.sidebar.radio("Admin / Daten", admin_names or user_default, index=0)
    94|            st.sidebar.warning("Admin Mode: technische IDs, Rohdaten und Review-Details sichtbar.")
    95|        st.session_state["jarvis_dashboard_mode"] = mode
    96|        st.session_state["jarvis_admin_mode"] = mode != "User Mode"
    97|        edit_enabled = bool(st.sidebar.toggle("Bearbeiten aktivieren", value=bool(st.session_state.get("jarvis_edit_mode", False))))
    98|        st.session_state["jarvis_edit_mode"] = edit_enabled
    99|        if edit_enabled:
   100|            st.sidebar.warning("Edit Mode aktiv: Änderungen schreiben in die Runtime-DB und benötigen Review/Bestätigung.")
   101|        else:
   102|            st.sidebar.info("Read-only Mode: Schreibaktionen sind deaktiviert.")
   103|
   104|        st.sidebar.write(f"DB: `{db_path}`")
   105|        safe = dashboard_data.get_safe_mode_status(conn)
   106|        st.sidebar.info(f"DB-Modus: {safe['db_mode']} | Write Mode: {safe['write_mode']}")
   107|        st.sidebar.caption(f"Backup: {safe['last_backup_at']}")
   108|        st.sidebar.warning("Keine Live-API-Abfragen beim Seitenreload. Preise nur aus lokaler DB/Cache. Echte Daten liegen außerhalb Git.")
   109|        page = page_map[selected]
   110|        page.render(st, conn)
   111|    finally:
   112|        conn.close()
   113|
   114|
   115|if __name__ == "__main__":
   116|    run_app()
   117|


   430|        currency="EUR",
   431|        cost_basis_original_text="50.00",
   432|        fx_status="missing",
   433|        note="synthetic missing FX accepted for review",
   434|        confirm=True,
   435|    )
   436|    tx_missing = conn.execute("SELECT * FROM transactions WHERE transaction_id=?", (missing.transaction_id,)).fetchone()
   437|    assert tx_missing["fx_status"] == "missing"
   438|    assert tx_missing["gross_amount_chf"] is None
   439|    assert "missing_fx" in missing.warnings
   440|
   441|
   442|def test_manual_wizard_supports_buy_sell_dividend_and_no_dashboard_live_api_or_ids() -> None:
   443|    conn = setup_conn()
   444|    account_id = seed_account(conn, "Synthetic Trading Broker")
   445|    catalog_id = create_manual_catalog_entry(
   446|        conn,
   447|        asset_class="stock",
   448|        name="Synthetic Tradable",
   449|        currency="CHF",
   450|        isin="CH4444444444",
   451|        ticker="TRD",
   452|        exchange="SIX",
   453|        note="synthetic tradable source",
   454|    )
   455|    snap = add_manual_position_from_catalog(conn, catalog_entry_id=catalog_id, account_id=account_id, position_type="initial_snapshot", quantity_text="5", trade_date="2025-12-31", currency="CHF", cost_basis_original_text="500", note="synthetic starting point", confirm=True)
   456|    add_manual_position_from_catalog(conn, catalog_entry_id=catalog_id, account_id=account_id, position_type="buy", quantity_text="1", trade_date="2026-01-05", currency="CHF", cost_basis_original_text="110", note="synthetic buy", confirm=True)
   457|    add_manual_position_from_catalog(conn, catalog_entry_id=catalog_id, account_id=account_id, position_type="partial_sell", quantity_text="2", trade_date="2026-01-10", currency="CHF", cost_basis_original_text="220", note="synthetic sell", confirm=True)
   458|    add_manual_position_from_catalog(conn, catalog_entry_id=catalog_id, account_id=account_id, position_type="dividend", quantity_text="0", trade_date="2026-02-01", currency="CHF", cost_basis_original_text="4.50", note="synthetic dividend", confirm=True)
   459|    assert calculate_positions(conn).positions[(account_id, snap.instrument_id)].quantity == Decimal("4")
   460|
   461|    from pathlib import Path
   462|    page = Path("src/jarvis_finance/dashboard/views/page_13_equity_manage.py").read_text()
   463|    assert "search_instrument_candidates(" in page
   464|    assert "Provider-Suche explizit" in page
   465|    wizard_source = page.split("with tabs[1]:", 1)[0]
   466|    assert "st.write({\"instrument_id\"" not in wizard_source
   467|    assert "st.write({\"catalog_entry_id\"" not in wizard_source
   468|
   469|
   470|def test_provider_lookup_classes_skip_missing_keys_and_cache_results(monkeypatch) -> None:
   471|    from jarvis_finance.market_data.catalog import OpenFigiLookupProvider, search_instruments
   472|
   473|    conn = setup_conn()
   474|    monkeypatch.delenv("OPENFIGI_API_KEY", raising=False)
   475|    results, warnings = search_instruments(conn, "US0378331005", providers=[OpenFigiLookupProvider()])
   476|    assert results == []
   477|    assert "openfigi_api_key_missing" in warnings
   478|
   479|    class FakeOpenFigi(OpenFigiLookupProvider):
   480|        def _request(self, query: str):
   481|            return [{"name": "Apple Inc", "ticker": "AAPL", "exchCode": "US", "securityType": "Common Stock", "marketSecDes": "Equity", "compositeFIGI": "BBG000B9XRY4"}]
   482|
   483|    results, warnings = search_instruments(conn, "Apple", providers=[FakeOpenFigi(api_key="runtime-only-key")])
   484|    assert warnings == []
   485|    assert results[0].source == "openfigi"
   486|    assert conn.execute("SELECT COUNT(*) AS c FROM instrument_catalog_entries WHERE provider='openfigi'").fetchone()["c"] == 1
   487|
   488|
   489|def test_search_candidates_uses_external_providers_only_on_explicit_click(monkeypatch) -> None:
   490|    from jarvis_finance.equity import manage
   491|    from jarvis_finance.market_data.catalog import CatalogSearchResult
   492|
   493|    conn = setup_conn()
   494|    calls = {"n": 0}
   495|
   496|    class FakeProvider:
   497|        name = "fake_provider"
   498|        def search(self, *, query, asset_class=None):
   499|            calls["n"] += 1
   500|            return [CatalogSearchResult(None, asset_class or "stock", "Synthetic Provider AG", isin="CH5555555555", ticker="SPV", exchange="SIX", trading_currency="CHF", provider="fake", confidence="medium", source="fake_provider")]
   501|
   502|    monkeypatch.setattr(manage, "default_instrument_lookup_providers", lambda: [FakeProvider()])
   503|    search_instrument_candidates(conn, query="SPV", include_external=False)
   504|    assert calls["n"] == 0
   505|    result = search_instrument_candidates(conn, query="SPV", include_external=True)
   506|    assert calls["n"] == 1
   507|    assert result["results"][0]["data_source"] == "fake_provider"
   508|


Explanation:
**Evaluating testing needs**

I’m thinking I need more tests, perhaps for the position page without IDs and focusing on rendering without an API. It might be enough to store provider cache in a table. Additionally, I wonder if we should avoid displaying technical IDs; even if the page source has internal identifiers, they shouldn't be visible in User Mode. There's an existing test for page 13, but not for page 17, so I'll need to add more tests. Plus, I need to check if FakeStreamlit supports expanders.


     1|from __future__ import annotations
     2|
     3|from decimal import Decimal
     4|
     5|from jarvis_finance.crypto.assets import create_crypto_asset
     6|from jarvis_finance.crypto.holdings import create_initial_holding_snapshot
     7|from jarvis_finance.crypto.wallets import create_wallet
     8|from jarvis_finance.dashboard import data as dashboard_data
     9|from jarvis_finance.dashboard.demo_data import load_synthetic_demo_data
    10|from jarvis_finance.dashboard.pages import page_12_crypto_manage
    11|from jarvis_finance.market.providers import PriceQuote, store_crypto_price
    12|from jarvis_finance.storage.database import connect_memory
    13|from jarvis_finance.storage.migrations import apply_migrations
    14|
    15|
    16|class FakeStreamlit:
    17|    def __init__(self, button_presses: set[str] | None = None) -> None:
    18|        self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = []
    19|        self.sidebar = self
    20|        self.session_state = {}
    21|        self.button_presses = button_presses or set()
    22|
    23|    def __getattr__(self, name: str):
    24|        def recorder(*args, **kwargs):
    25|            self.calls.append((name, args, kwargs))
    26|            if name == "button":
    27|                label = str(args[0]) if args else str(kwargs.get("label", ""))
    28|                return label in self.button_presses and not bool(kwargs.get("disabled", False))
    29|            if name == "checkbox":
    30|                return False
    31|            if name == "toggle":
    32|                return bool(kwargs.get("value", False))
    33|            if name in {"text_input", "text_area", "date_input"}:
    34|                return ""
    35|            if name in {"selectbox", "radio", "pills"}:
    36|                options = args[1] if len(args) > 1 else kwargs.get("options", [])
    37|                return list(options)[0] if options else None
    38|            if name in {"tabs", "columns"}:
    39|                values = args[0] if args else []
    40|                count = values if isinstance(values, int) else len(values)
    41|                return [self for _ in range(count)]
    42|            if name == "expander":
    43|                return self
    44|            return None
    45|        return recorder
    46|
    47|    def __enter__(self):
    48|        return self
    49|
    50|    def __exit__(self, *args):
    51|        return False
    52|
    53|
    54|def setup_demo_conn():
    55|    conn = connect_memory()
    56|    apply_migrations(conn)
    57|    load_synthetic_demo_data(conn)
    58|    return conn
    59|
    60|
    61|def setup_multi_wallet_crypto_conn():
    62|    conn = connect_memory()
    63|    apply_migrations(conn)
    64|    w1 = create_wallet(conn, wallet_name="MetaMask Demo", wallet_type="Software Wallet", platform_provider="MetaMask", last_verified_at="2026-01-31T10:00:00Z")
    65|    w2 = create_wallet(conn, wallet_name="SwissBorg Demo", wallet_type="Exchange", platform_provider="SwissBorg", last_verified_at="2026-01-31T11:00:00Z")
    66|    eth = create_crypto_asset(conn, coin_name="Ethereum", symbol="ETH", coingecko_id="ethereum")
    67|    btc = create_crypto_asset(conn, coin_name="Bitcoin", symbol="BTC", coingecko_id="bitcoin")
    68|    create_initial_holding_snapshot(conn, asset_id=eth, wallet_id=w1, quantity=Decimal("2"), verification_status="verified", last_verified_at="2026-01-31T10:00:00Z", note="synthetic eth split")
    69|    create_initial_holding_snapshot(conn, asset_id=eth, wallet_id=w2, quantity=Decimal("2"), verification_status="verified", last_verified_at="2026-01-31T11:00:00Z", note="synthetic eth split")
    70|    create_initial_holding_snapshot(conn, asset_id=btc, wallet_id=w1, quantity=Decimal("0.00000001"), verification_status="stale", last_verified_at="2026-01-01T00:00:00Z", note="synthetic btc tiny")
    71|    store_crypto_price(conn, asset_id=eth, quote=PriceQuote("ethereum", "CHF", Decimal("2500.00"), provider="synthetic-cache", provider_timestamp="2026-02-01T00:00:00Z"))
    72|    store_crypto_price(conn, asset_id=btc, quote=PriceQuote("bitcoin", "CHF", Decimal("40000.00"), provider="synthetic-cache", provider_timestamp="2026-02-01T00:00:00Z"))
    73|    return conn
    74|
    75|TECHNICAL_FIELDS = {
    76|    "asset_id",
    77|    "wallet_id",
    78|    "instrument_id",
    79|    "entity_id",
    80|    "rule_id",
    81|    "source_id",
    82|    "provider_symbol_status",
    83|    "hedge_status",
    84|    "dedup_key",
    85|    "fingerprint",
    86|}
    87|
    88|
    89|def test_user_mode_crypto_and_portfolio_tables_are_clean_and_formatted() -> None:
    90|    conn = setup_multi_wallet_crypto_conn()
    91|    crypto_rows = dashboard_data.get_crypto_coin_summary(conn)
    92|    assert crypto_rows
    93|    visible_crypto = dashboard_data.visible_user_columns(crypto_rows[0])
    94|    assert len(visible_crypto) <= 8
    95|    assert TECHNICAL_FIELDS.isdisjoint(visible_crypto)
    96|    assert crypto_rows[0]["kurs_chf"].startswith("CHF ")
    97|    assert "'" in dashboard_data.chf_text(Decimal("12345.67"))
    98|    assert dashboard_data.translate_status("missing_market_price") == "Preis fehlt"
    99|    assert dashboard_data.translate_status("fresh") == "Aktuell"
   100|
   101|    portfolio = dashboard_data.get_portfolio_positions_user(conn)
   102|    assert portfolio == [] or TECHNICAL_FIELDS.isdisjoint(portfolio[0])
   103|    assert all(len(row) <= 8 for row in portfolio)
   104|
   105|
   106|def test_chart_data_for_crypto_coin_and_wallet_distribution_is_correct() -> None:
   107|    conn = setup_multi_wallet_crypto_conn()
   108|    coin_chart = dashboard_data.get_crypto_coin_allocation_chart(conn)
   109|    wallet_chart = dashboard_data.get_crypto_wallet_allocation_chart(conn)
   110|    assert [row["label"] for row in coin_chart][:2] == ["Ethereum", "Bitcoin"]
   111|    assert coin_chart[0]["value_chf"] == "10000.00"
   112|    assert {row["label"] for row in wallet_chart} == {"MetaMask Demo", "SwissBorg Demo"}
   113|    assert all("asset_id" not in row and "wallet_id" not in row for row in coin_chart + wallet_chart)
   114|
   115|
   116|def test_coin_detail_and_wallet_detail_are_selectable_read_models() -> None:
   117|    conn = setup_multi_wallet_crypto_conn()
   118|    eth = next(row for row in dashboard_data.get_crypto_coin_summary(conn) if row["symbol"] == "ETH")
   119|    detail = dashboard_data.get_crypto_coin_detail(conn, eth["_asset_id"], price_max_age_seconds=999999999)
   120|    assert detail["coin"] == "Ethereum"
   121|    assert detail["gesamtwert_chf"] == "CHF 10'000.00"
   122|    assert detail["preisstatus"] == "Aktuell"
   123|    assert len(detail["wallets"]) == 2
   124|    assert {"Bestand hinzufügen", "Bestand korrigieren", "Transfer", "Bestand auf 0 setzen", "Audit / Verlauf anzeigen"} <= set(detail["actions"])
   125|    assert TECHNICAL_FIELDS.isdisjoint(detail)
   126|    assert TECHNICAL_FIELDS.isdisjoint(detail["wallets"][0])
   127|
   128|    wallet = dashboard_data.get_wallet_user_overview(conn)[0]
   129|    wallet_detail = dashboard_data.get_wallet_detail(conn, wallet["_wallet_id"])
   130|    assert wallet_detail["wallet_name"]
   131|    assert wallet_detail["coins"]
   132|    assert {"Coin in Wallet hinzufügen", "Bestand korrigieren", "Transfer aus Wallet", "Wallet verifizieren", "Wallet bearbeiten", "Wallet deaktivieren"} <= set(wallet_detail["actions"])
   133|    assert TECHNICAL_FIELDS.isdisjoint(wallet_detail)
   134|
   135|
   136|def test_alert_cards_are_readable_and_do_not_expose_rule_ids() -> None:
   137|    conn = setup_demo_conn()
   138|    cards = dashboard_data.get_alert_cards(conn, limit=5)
   139|    summary = dashboard_data.get_alert_summary_cards(conn)
   140|    assert {"kritisch", "wichtig", "info"} <= set(summary)
   141|    assert cards
   142|    assert {"title", "description", "affected", "severity", "actions"} <= set(cards[0])
   143|    assert TECHNICAL_FIELDS.isdisjoint(cards[0])
   144|    assert not cards[0]["title"].startswith("missing_")
   145|
   146|
   147|def test_crypto_manage_renders_action_cards_not_all_forms_by_default() -> None:
   148|    conn = setup_multi_wallet_crypto_conn()
   149|    st = FakeStreamlit()
   150|    page_12_crypto_manage.render(st, conn)
   151|    buttons = [args[0] for name, args, _kwargs in st.calls if name == "button" and args]
   152|    text_inputs = [args[0] for name, args, _kwargs in st.calls if name == "text_input" and args]
   153|    assert {"Coin hinzufügen", "Bestand korrigieren", "Transfer zwischen Wallets", "Bestand entfernen / auf 0 setzen", "Neues Wallet", "Verlauf ansehen"} <= set(buttons)
   154|    assert len(text_inputs) <= 3
   155|
   156|
   157|def test_admin_mode_can_still_access_technical_fields() -> None:
   158|    conn = setup_multi_wallet_crypto_conn()
   159|    assert "asset_id" in dashboard_data.get_crypto_coin_summary(conn, admin_mode=True)[0]
   160|    assert "wallet_id" in dashboard_data.get_wallet_user_overview(conn, admin_mode=True)[0]
   161|


patch

from jarvis_finance.dashboard.pages import page_00_command_center, page_04_crypto, page_05_wallets, page_08_reports
from jarvis_finance.dashboard.views import page_17_position_add
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_dashboard_usability_simplification.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_dashboard_usability_simplification.py
@@ -5,6 +5,9 @@
 from jarvis_finance.dashboard import data as dashboard_data
 from jarvis_finance.dashboard import main as dashboard_main
 from jarvis_finance.dashboard.pages import page_00_command_center, page_04_crypto, page_05_wallets, page_08_reports
+from jarvis_finance.dashboard.views import page_17_position_add
+from jarvis_finance.storage.database import connect_memory
+from jarvis_finance.storage.migrations import apply_migrations
 from test_dashboard_ux_sprint2 import FakeStreamlit, setup_multi_wallet_crypto_conn, TECHNICAL_FIELDS


def test_position_add_page_is_visible_wizard_and_does_not_call_provider_on_render() -> None:
    conn = connect_memory()
    apply_migrations(conn)
    st = FakeStreamlit()
    page_17_position_add.render(st, conn)
    text = _visible_text(st)
    assert "Position hinzufügen" in text
    assert "1. Assetklasse wählen" in text
    assert "2. Instrument suchen" in text
    assert "Suchen" in text
    assert "Manuell anlegen" in text
    assert "Position speichern" in text
    assert "catalog_entry_id" not in text
    assert "instrument_id" not in text
    assert "source_id" not in text

def test_command_center_is_quiet_and_has_max_three_clean_hints() -> None:


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_dashboard_usability_simplification.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_dashboard_usability_simplification.py
@@ -24,6 +24,23 @@
     assert "Watchlist" not in dashboard_main.get_user_navigation_titles()
     assert "Equities/ETFs" not in dashboard_main.get_user_navigation_titles()
     assert sorted(Path("src/jarvis_finance/dashboard/pages").glob("[0-9][0-9]_*.py")) == []
+
+
+def test_position_add_page_is_visible_wizard_and_does_not_call_provider_on_render() -> None:
+    conn = connect_memory()
+    apply_migrations(conn)
+    st = FakeStreamlit()
+    page_17_position_add.render(st, conn)
+    text = _visible_text(st)
+    assert "Position hinzufügen" in text
+    assert "1. Assetklasse wählen" in text
+    assert "2. Instrument suchen" in text
+    assert "Suchen" in text
+    assert "Manuell anlegen" in text
+    assert "Position speichern" in text
+    assert "catalog_entry_id" not in text
+    assert "instrument_id" not in text
+    assert "source_id" not in text

 def test_command_center_is_quiet_and_has_max_three_clean_hints() -> None:


def test_provider_lookup_classes_skip_missing_keys_and_cache_results(monkeypatch) -> None:
    from jarvis_finance.market_data.catalog import OpenFigiLookupProvider, default_instrument_lookup_providers, search_instruments

    assert [p.name for p in default_instrument_lookup_providers()] == ["openfigi", "finnhub", "fmp"]

def test_provider_lookup_classes_skip_missing_keys_and_cache_results(monkeypatch) -> None:
    from jarvis_finance.market_data.catalog import OpenFigiLookupProvider, search_instruments


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_equity_manage.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_equity_manage.py
@@ -468,8 +468,9 @@

 def test_provider_lookup_classes_skip_missing_keys_and_cache_results(monkeypatch) -> None:
-    from jarvis_finance.market_data.catalog import OpenFigiLookupProvider, search_instruments
-
+    from jarvis_finance.market_data.catalog import OpenFigiLookupProvider, default_instrument_lookup_providers, search_instruments
+
+    assert [p.name for p in default_instrument_lookup_providers()] == ["openfigi", "finnhub", "fmp"]
     conn = setup_conn()
     monkeypatch.delenv("OPENFIGI_API_KEY", raising=False)
     results, warnings = search_instruments(conn, "US0378331005", providers=[OpenFigiLookupProvider()])

/home/agent/.hermes/repos/FinanceManager/tests/unit/test_equity_manage.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


terminal

Output: ..                                                                       [100%]


Explanation:
**Inspecting todos and pages**

I think I need to mark some todos for clarity. Additionally, I’m considering inspecting the differences in the `__init__` of the `PAGES` from `dashboard.pages`. This might help me understand how it imports views. So, I'll take a closer look at what’s happening in the `__init__` of those pages. It seems like a step toward figuring everything out more cohesively!

todo

Zentrale Position-hinzufügen-Seite/Wizard im User Mode anbinden

Provider-Suche OpenFIGI/Finnhub/FMP mit Runtime-Secrets, Cache, Fehlerhandling integrieren


Navigation/Command-Center/User-Mode Auffindbarkeit prüfen und Tests dafür ergänzen

Tests für Navigation, Provider-Safety, Wizard, Audit und keine Live-API ergänzen

Compile, pytest, Git-Safety, diff-check, Commit und Push ausführen


     1|from __future__ import annotations
     2|
     3|# Compatibility imports only. Streamlit auto-navigation sees this package as empty
     4|# because executable page modules live in dashboard.views, not dashboard.pages.
     5|from jarvis_finance.dashboard.views import *  # noqa: F401,F403
     6|


Explanation:
**Evaluating provider keys**

I think I need to check if the actual provider keys are accessible. Instead of printing them out, I’ll use a terminal environment check. I might also need to look into the runtime environment file, particularly for the secrets in the `.env` file. Right now, it seems like the current providers are only using `os.environ`. I should consider loading the `.env` file and inspecting the settings in the runtime directory.


     1|from __future__ import annotations
     2|
     3|import os
     4|from dataclasses import dataclass
     5|from pathlib import Path
     6|
     7|from .paths import DEFAULT_RUNTIME_DIR, RuntimePaths, is_relative_to
     8|
     9|
    10|@dataclass(frozen=True)
    11|class Settings:
    12|    env: str = "local"
    13|    base_currency: str = "CHF"
    14|    timezone: str = "Europe/Zurich"
    15|    runtime_paths: RuntimePaths = RuntimePaths.from_base(DEFAULT_RUNTIME_DIR)
    16|    log_level: str = "INFO"
    17|    block_runtime_inside_repo: bool = True
    18|
    19|    @property
    20|    def db_path(self) -> Path:
    21|        return self.runtime_paths.db_path
    22|
    23|
    24|def find_repo_root(start: Path | None = None) -> Path:
    25|    current = (start or Path.cwd()).resolve()
    26|    for candidate in [current, *current.parents]:
    27|        if (candidate / ".git").exists() or (candidate / "pyproject.toml").exists():
    28|            return candidate
    29|    return current
    30|
    31|
    32|def load_settings(repo_root: Path | None = None, environ: dict[str, str] | None = None) -> Settings:
    33|    env = environ or os.environ
    34|    runtime_dir = Path(env.get("JARVIS_FINANCE_RUNTIME_DIR", str(DEFAULT_RUNTIME_DIR))).expanduser()
    35|    paths = RuntimePaths.from_base(runtime_dir)
    36|    db_override = env.get("JARVIS_FINANCE_DB_PATH")
    37|    if db_override:
    38|        object.__setattr__(paths, "db_path", Path(db_override).expanduser().resolve())
    39|    settings = Settings(
    40|        env=env.get("JARVIS_FINANCE_ENV", "local"),
    41|        base_currency=env.get("JARVIS_FINANCE_BASE_CURRENCY", "CHF"),
    42|        timezone=env.get("JARVIS_FINANCE_TIMEZONE", "Europe/Zurich"),
    43|        runtime_paths=paths,
    44|        log_level=env.get("JARVIS_FINANCE_LOG_LEVEL", "INFO"),
    45|        block_runtime_inside_repo=env.get("JARVIS_FINANCE_BLOCK_RUNTIME_INSIDE_REPO", "1") not in {"0", "false", "False"},
    46|    )
    47|    validate_settings(settings, repo_root=repo_root)
    48|    return settings
    49|
    50|
    51|def validate_settings(settings: Settings, repo_root: Path | None = None) -> None:
    52|    root = (repo_root or find_repo_root()).resolve()
    53|    if settings.block_runtime_inside_repo and is_relative_to(settings.runtime_paths.base_dir, root):
    54|        raise ValueError(f"Runtime directory must be outside the Git repository: {settings.runtime_paths.base_dir}")
    55|    if settings.base_currency != "CHF":
    56|        raise ValueError("MVP 1 base currency must be CHF")
    57|
    58|
    59|def ensure_runtime_dirs(settings: Settings) -> None:
    60|    settings.runtime_paths.ensure_dirs()
    61|


     1|from __future__ import annotations
     2|
     3|from dataclasses import dataclass
     4|from pathlib import Path
     5|
     6|DEFAULT_RUNTIME_DIR = Path("~/jarvis_runtime/finance-system").expanduser()
     7|
     8|
     9|@dataclass(frozen=True)
    10|class RuntimePaths:
    11|    base_dir: Path
    12|    data_dir: Path
    13|    imports_dir: Path
    14|    exports_dir: Path
    15|    reports_dir: Path
    16|    backups_dir: Path
    17|    secrets_dir: Path
    18|    logs_dir: Path
    19|    db_path: Path
    20|
    21|    @classmethod
    22|    def from_base(cls, base_dir: str | Path = DEFAULT_RUNTIME_DIR) -> "RuntimePaths":
    23|        base = Path(base_dir).expanduser().resolve()
    24|        return cls(
    25|            base_dir=base,
    26|            data_dir=base / "data",
    27|            imports_dir=base / "imports",
    28|            exports_dir=base / "exports",
    29|            reports_dir=base / "reports",
    30|            backups_dir=base / "backups",
    31|            secrets_dir=base / "secrets",
    32|            logs_dir=base / "logs",
    33|            db_path=base / "data" / "finance.sqlite3",
    34|        )
    35|
    36|    def ensure_dirs(self) -> None:
    37|        for path in [
    38|            self.data_dir,
    39|            self.imports_dir,
    40|            self.exports_dir,
    41|            self.reports_dir,
    42|            self.backups_dir,
    43|            self.secrets_dir,
    44|            self.logs_dir,
    45|        ]:
    46|            path.mkdir(parents=True, exist_ok=True)
    47|
    48|
    49|def is_relative_to(path: Path, parent: Path) -> bool:
    50|    try:
    51|        path.resolve().relative_to(parent.resolve())
    52|        return True
    53|    except ValueError:
    54|        return False
    55|


patch

import time
from dataclasses import dataclass, field
from pathlib import Path
from sqlite3 import Connection

import time
from dataclasses import dataclass, field
from sqlite3 import Connection


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/market_data/catalog.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/market_data/catalog.py
@@ -5,6 +5,7 @@
 import re
 import time
 from dataclasses import dataclass, field
+from pathlib import Path
 from sqlite3 import Connection
 from typing import Protocol
 from urllib import parse, request

/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/market_data/catalog.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


class UnavailableCatalogLookupProvider:
    name = "unavailable"

    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
        raise LookupUnavailable("external instrument lookup is not configured")

def _runtime_secret_value(names: tuple[str, ...]) -> str | None:
    for name in names:
        if os.environ.get(name):
            return os.environ[name]
    runtime_dir = Path(os.environ.get("JARVIS_FINANCE_RUNTIME_DIR", "~/jarvis_runtime/finance-system")).expanduser()
    for candidate in [runtime_dir / "secrets" / ".env", runtime_dir / ".env"]:
        if not candidate.exists():
            continue
        for line in candidate.read_text(encoding="utf-8").splitlines():
            raw = line.strip()
            if not raw or raw.startswith("#") or "=" not in raw:
                continue
            key, value = raw.split("=", 1)
            key = key.strip().removeprefix("export ").strip()
            if key in names:
                return value.strip().strip('"').strip("'") or None
    return None

class _HttpJsonLookupProvider:

class UnavailableCatalogLookupProvider:
    name = "unavailable"

    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
        raise LookupUnavailable("external instrument lookup is not configured")

class _HttpJsonLookupProvider:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/market_data/catalog.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/market_data/catalog.py
@@ -87,6 +87,25 @@

     def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
         raise LookupUnavailable("external instrument lookup is not configured")
+
+
+def _runtime_secret_value(names: tuple[str, ...]) -> str | None:
+    for name in names:
+        if os.environ.get(name):
+            return os.environ[name]
+    runtime_dir = Path(os.environ.get("JARVIS_FINANCE_RUNTIME_DIR", "~/jarvis_runtime/finance-system")).expanduser()
+    for candidate in [runtime_dir / "secrets" / ".env", runtime_dir / ".env"]:
+        if not candidate.exists():
+            continue
+        for line in candidate.read_text(encoding="utf-8").splitlines():
+            raw = line.strip()
+            if not raw or raw.startswith("#") or "=" not in raw:
+                continue
+            key, value = raw.split("=", 1)
+            key = key.strip().removeprefix("export ").strip()
+            if key in names:
+                return value.strip().strip('"').strip("'") or None
+    return None

 class _HttpJsonLookupProvider:


self.api_key = api_key or _runtime_secret_value(self.api_key_env)

self.api_key = api_key or next((os.environ.get(k) for k in self.api_key_env if os.environ.get(k)), None)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/market_data/catalog.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/market_data/catalog.py
@@ -113,7 +113,7 @@
     api_key_env: tuple[str, ...] = ()

     def __init__(self, *, api_key: str | None = None, timeout_seconds: float = 8.0, min_interval_seconds: float = 0.2) -> None:
-        self.api_key = api_key or next((os.environ.get(k) for k in self.api_key_env if os.environ.get(k)), None)
+        self.api_key = api_key or _runtime_secret_value(self.api_key_env)
         self.timeout_seconds = timeout_seconds
         self.min_interval_seconds = min_interval_seconds
         self._last_request_at = 0.0


def test_provider_lookup_classes_skip_missing_keys_and_cache_results(monkeypatch, tmp_path) -> None:

def test_provider_lookup_classes_skip_missing_keys_and_cache_results(monkeypatch) -> None:


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_equity_manage.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_equity_manage.py
@@ -467,7 +467,7 @@
     assert "st.write({\"catalog_entry_id\"" not in wizard_source

-def test_provider_lookup_classes_skip_missing_keys_and_cache_results(monkeypatch) -> None:
+def test_provider_lookup_classes_skip_missing_keys_and_cache_results(monkeypatch, tmp_path) -> None:
     from jarvis_finance.market_data.catalog import OpenFigiLookupProvider, default_instrument_lookup_providers, search_instruments

     assert [p.name for p in default_instrument_lookup_providers()] == ["openfigi", "finnhub", "fmp"]


    monkeypatch.delenv("OPENFIGI_API_KEY", raising=False)
    monkeypatch.delenv("JARVIS_OPENFIGI_API_KEY", raising=False)
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    (tmp_path / "secrets").mkdir()
    (tmp_path / "secrets" / ".env").write_text("OPENFIGI_API_KEY=runtime-file-key\n", encoding="utf-8")
    assert OpenFigiLookupProvider().api_key == "runtime-file-key"
    (tmp_path / "secrets" / ".env").unlink()
    results, warnings = search_instruments(conn, "US0378331005", providers=[OpenFigiLookupProvider()])

    monkeypatch.delenv("OPENFIGI_API_KEY", raising=False)
    results, warnings = search_instruments(conn, "US0378331005", providers=[OpenFigiLookupProvider()])


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_equity_manage.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_equity_manage.py
@@ -473,6 +473,12 @@
     assert [p.name for p in default_instrument_lookup_providers()] == ["openfigi", "finnhub", "fmp"]
     conn = setup_conn()
     monkeypatch.delenv("OPENFIGI_API_KEY", raising=False)
+    monkeypatch.delenv("JARVIS_OPENFIGI_API_KEY", raising=False)
+    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
+    (tmp_path / "secrets").mkdir()
+    (tmp_path / "secrets" / ".env").write_text("OPENFIGI_API_KEY=runtime-file-key\n", encoding="utf-8")
+    assert OpenFigiLookupProvider().api_key == "runtime-file-key"
+    (tmp_path / "secrets" / ".env").unlink()
     results, warnings = search_instruments(conn, "US0378331005", providers=[OpenFigiLookupProvider()])
     assert results == []
     assert "openfigi_api_key_missing" in warnings


terminal

python -m compileall src tests && pytest -q


Output:
Listing 'src'...
Listing 'src/jarvis_finance'...
Listing 'src/jarvis_finance/audit'...
Listing 'src/jarvis_finance/cli'...
Listing 'src/jarvis_finance/config'...
Listing 'src/jarvis_finance/crypto'...
Listing 'src/jarvis_finance/dashboard'...
Listing 'src/jarvis_finance/dashboard/components'...
Listing 'src/jarvis_finance/dashboard/pages'...
Listing 'src/jarvis_finance/dashboard/views'...
Listing 'src/jarvis_finance/equity'...
Listing 'src/jarvis_finance/fx'...
Listing 'src/jarvis_finance/imports'...
Listing 'src/jarvis_finance/ledger'...
Listing 'src/jarvis_finance/market'...
Listing 'src/jarvis_finance/market_data'...
Compiling 'src/jarvis_finance/market_data/catalog.py'...
Listing 'src/jarvis_finance/models'...
Listing 'src/jarvis_finance/quality'...
Listing 'src/jarvis_finance/reports'...
Listing 'src/jarvis_finance/reports/templates'...
Listing 'src/jarvis_finance/runtime'...
Listing 'src/jarvis_finance/storage'...
Listing 'src/jarvis_finance.egg-info'...
Listing 'tests'...
Listing 'tests/fixtures'...
Listing 'tests/integration'...
Listing 'tests/unit'...
Compiling 'tests/unit/test_dashboard_usability_simplification.py'...
Compiling 'tests/unit/test_equity_manage.py'...
........................................................................ [ 27%]
........................................................................ [ 54%]
........................................................................ [ 82%]
..............................................                           [100%]
262 passed in 13.94s


Explanation:
**Ensuring Git safety**

I'm considering whether I need to focus on Git safety. Before that, I should check the `git diff` command, since there might not be any issues. It seems I also need to verify if the Runtime DB accounts are necessary, but maybe not. The user wants functionality, so I may not need to worry about the docx import path issues. It looks like running `git diff --check` and checking the provider key status is essential.

python -m compileall src tests && pytest -q && git diff --check && PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan . && git status --short && python - <<'PY'
import os
from pathlib import Path
names = {
 'OpenFIGI': ('OPENFIGI_API_KEY','JARVIS_OPENFIGI_API_KEY'),
 'Finnhub': ('FINNHUB_API_KEY','JARVIS_FINNHUB_API_KEY'),
 'Financial Modeling Prep': ('FMP_API_KEY','FINANCIAL_MODELING_PREP_API_KEY','JARVIS_FMP_API_KEY'),
}
runtime = Path(os.environ.get('JARVIS_FINANCE_RUNTIME_DIR','~/jarvis_runtime/finance-system')).expanduser()
texts=[]
for p in (runtime/'secrets'/'.env', runtime/'.env'):
    if p.exists():
        texts += p.read_text(encoding='utf-8').splitlines()
for label, keys in names.items():
    present = any(os.environ.get(k) for k in keys)
    if not present:
        for line in texts:
            raw=line.strip()
            if raw and not raw.startswith('#') and '=' in raw:
                key=raw.split('=',1)[0].strip().removeprefix('export ').strip()
                if key in keys:
                    present=True
                    break
    print(f'{label}: {"configured" if present else "missing"}')
PY


Output:
Listing 'src'...
Listing 'src/jarvis_finance'...
Listing 'src/jarvis_finance/audit'...
Listing 'src/jarvis_finance/cli'...
Listing 'src/jarvis_finance/config'...
Listing 'src/jarvis_finance/crypto'...
Listing 'src/jarvis_finance/dashboard'...
Listing 'src/jarvis_finance/dashboard/components'...
Listing 'src/jarvis_finance/dashboard/pages'...
Listing 'src/jarvis_finance/dashboard/views'...
Listing 'src/jarvis_finance/equity'...
Listing 'src/jarvis_finance/fx'...
Listing 'src/jarvis_finance/imports'...
Listing 'src/jarvis_finance/ledger'...
Listing 'src/jarvis_finance/market'...
Listing 'src/jarvis_finance/market_data'...
Listing 'src/jarvis_finance/models'...
Listing 'src/jarvis_finance/quality'...
Listing 'src/jarvis_finance/reports'...
Listing 'src/jarvis_finance/reports/templates'...
Listing 'src/jarvis_finance/runtime'...
Listing 'src/jarvis_finance/storage'...
Listing 'src/jarvis_finance.egg-info'...
Listing 'tests'...
Listing 'tests/fixtures'...
Listing 'tests/integration'...
Listing 'tests/unit'...
........................................................................ [ 27%]
........................................................................ [ 54%]
........................................................................ [ 82%]
..............................................                           [100%]
262 passed in 14.10s
.pytest_cache/CACHEDIR.TAG: runtime_cache_file_blocked
.pytest_cache/README.md: runtime_cache_file_blocked
.pytest_cache/.gitignore: runtime_cache_file_blocked
.pytest_cache/v/cache/nodeids: runtime_cache_file_blocked
.pytest_cache/v/cache/lastfailed: runtime_cache_file_blocked


rm -rf .pytest_cache && git diff --check && PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan . && git status --short && python - <<'PY'
import os
from pathlib import Path
names = {
 'OpenFIGI': ('OPENFIGI_API_KEY','JARVIS_OPENFIGI_API_KEY'),
 'Finnhub': ('FINNHUB_API_KEY','JARVIS_FINNHUB_API_KEY'),
 'Financial Modeling Prep': ('FMP_API_KEY','FINANCIAL_MODELING_PREP_API_KEY','JARVIS_FMP_API_KEY'),
}
runtime = Path(os.environ.get('JARVIS_FINANCE_RUNTIME_DIR','~/jarvis_runtime/finance-system')).expanduser()
texts=[]
for p in (runtime/'secrets'/'.env', runtime/'.env'):
    if p.exists():
        texts += p.read_text(encoding='utf-8').splitlines()
for label, keys in names.items():
    present = any(os.environ.get(k) for k in keys)
    if not present:
        for line in texts:
            raw=line.strip()
            if raw and not raw.startswith('#') and '=' in raw:
                key=raw.split('=',1)[0].strip().removeprefix('export ').strip()
                if key in keys:
                    present=True
                    break
    print(f'{label}: {"configured" if present else "missing"}')
PY


Output:
GIT_SAFETY_OK
 M src/jarvis_finance/dashboard/main.py
 M src/jarvis_finance/dashboard/views/__init__.py
 M src/jarvis_finance/dashboard/views/page_00_command_center.py
 M src/jarvis_finance/equity/manage.py
 M src/jarvis_finance/market_data/catalog.py
 M tests/unit/test_dashboard_functional_ux_stabilization.py
 M tests/unit/test_dashboard_usability_simplification.py
 M tests/unit/test_equity_manage.py
?? src/jarvis_finance/dashboard/views/page_17_position_add.py
OpenFIGI: missing
Finnhub: missing
Financial Modeling Prep: missing


git diff --stat && git diff -- src/jarvis_finance/dashboard/main.py src/jarvis_finance/dashboard/views/page_17_position_add.py src/jarvis_finance/market_data/catalog.py src/jarvis_finance/equity/manage.py tests/unit/test_dashboard_usability_simplification.py tests/unit/test_equity_manage.py | sed -n '1,260p'


Output:
src/jarvis_finance/dashboard/main.py               |   8 +-
 src/jarvis_finance/dashboard/views/__init__.py     |   2 +
 .../dashboard/views/page_00_command_center.py      |  10 +-
 src/jarvis_finance/equity/manage.py                |  11 +-
 src/jarvis_finance/market_data/catalog.py          | 177 ++++++++++++++++++++-
 .../test_dashboard_functional_ux_stabilization.py  |  27 +++-
 .../test_dashboard_usability_simplification.py     |  22 ++-
 tests/unit/test_equity_manage.py                   |  47 ++++++
 8 files changed, 288 insertions(+), 16 deletions(-)
diff --git a/src/jarvis_finance/dashboard/main.py b/src/jarvis_finance/dashboard/main.py
index 05f1a34..d712104 100644
--- a/src/jarvis_finance/dashboard/main.py
+++ b/src/jarvis_finance/dashboard/main.py
@@ -10,9 +10,9 @@ from jarvis_finance.dashboard import data as dashboard_data
 from jarvis_finance.storage.database import connect
 from jarvis_finance.storage.migrations import apply_migrations

-USER_PAGE_TITLES = ["Command Center", "Crypto", "Wallets", "Reports"]
+USER_PAGE_TITLES = ["Command Center", "Crypto", "Wallets", "Aktien & ETFs", "Position hinzufügen", "Reports"]
 ADMIN_PAGE_TITLES = ["Ledger", "Audit", "Alerts", "Data Quality", "Import Wizard", "Settings", "Crypto Manage", "Equity/ETF Manage", "Manual Review Queue", "Watchlist"]
-TITLE_ALIASES = {"Settings / Data Quality": "Settings", "Data Quality Center": "Data Quality"}
+TITLE_ALIASES = {"Settings / Data Quality": "Settings", "Data Quality Center": "Data Quality", "Equities/ETFs": "Aktien & ETFs"}

 def safe_connect_existing_db(db_path: Path) -> Connection | None:
@@ -83,7 +83,9 @@ def run_app() -> None:
         st.sidebar.markdown("---")
         if mode == "User Mode":
             names = [name for name in USER_PAGE_TITLES if name in page_map]
-            selected = st.sidebar.radio("Hauptnavigation", names, index=0)
+            requested = st.session_state.pop("jarvis_requested_page", None) if hasattr(st, "session_state") else None
+            default_index = names.index(requested) if requested in names else 0
+            selected = st.sidebar.radio("Hauptnavigation", names, index=default_index)
             st.sidebar.caption("Technische IDs und Rohdaten sind im User Mode ausgeblendet.")
         else:
             user_default = [name for name in USER_PAGE_TITLES if name in page_map]
diff --git a/src/jarvis_finance/equity/manage.py b/src/jarvis_finance/equity/manage.py
index 7edc0e4..12a2d25 100644
--- a/src/jarvis_finance/equity/manage.py
+++ b/src/jarvis_finance/equity/manage.py
@@ -6,7 +6,7 @@ from sqlite3 import Connection

 from jarvis_finance.audit.log import record_audit_event
 from jarvis_finance.imports.common import stable_id, utc_now
-from jarvis_finance.market_data.catalog import CatalogEntryInput, upsert_catalog_entry
+from jarvis_finance.market_data.catalog import CatalogEntryInput, default_instrument_lookup_providers, search_instruments, upsert_catalog_entry
 from jarvis_finance.market_data.instruments import ensure_instrument_metadata_quality
 from jarvis_finance.ledger.positions import calculate_positions
 from jarvis_finance.quality.alerts import create_alert
@@ -351,12 +351,10 @@ def search_instrument_candidates(
     include_external: bool = False,
 ) -> dict[str, object]:
     """Search local instrument catalog. External providers are only used when explicitly enabled by caller."""
-    from jarvis_finance.market_data.catalog import search_instruments, UnavailableCatalogLookupProvider
-
     q = str(query or "").strip()
     if not q:
         return {"results": [], "warnings": [], "selection_required": True, "provider_lookup_attempted": False}
-    providers = [UnavailableCatalogLookupProvider()] if include_external else []
+    providers = default_instrument_lookup_providers() if include_external else []
     results, warnings = search_instruments(conn, q, asset_class, providers=providers)
     filtered = []
     for r in results:
@@ -366,7 +364,8 @@ def search_instrument_candidates(
             continue
         if provider and (r.provider or "").lower() != provider.lower():
             continue
-        # country is prepared for future provider-enriched catalog entries; current result object may not include it.
+        if country and (getattr(r, "country", None) or "").upper() != country.upper():
+            continue
         row = {
             "catalog_entry_id": r.catalog_entry_id or "",
             "name": r.name,
@@ -375,8 +374,10 @@ def search_instrument_candidates(
             "exchange": r.exchange or "",
             "currency": r.trading_currency or r.instrument_currency or "",
             "asset_class": r.asset_class,
+            "country": getattr(r, "country", None) or "",
             "data_source": r.source,
             "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": "",
diff --git a/src/jarvis_finance/market_data/catalog.py b/src/jarvis_finance/market_data/catalog.py
index 4dc3036..944c4b0 100644
--- a/src/jarvis_finance/market_data/catalog.py
+++ b/src/jarvis_finance/market_data/catalog.py
@@ -1,9 +1,14 @@
 from __future__ import annotations

+import json
+import os
 import re
+import time
 from dataclasses import dataclass, field
+from pathlib import Path
 from sqlite3 import Connection
 from typing import Protocol
+from urllib import parse, request

 from jarvis_finance.audit.log import record_audit_event
 from jarvis_finance.imports.common import stable_id, utc_now
@@ -62,6 +67,7 @@ class CatalogSearchResult:
     provider: str | None = None
     provider_symbol: str | None = None
     provider_market: str | None = None
+    country: str | None = None
     hedge_status: str = "unknown"
     instrument_status: str = "unknown"
     confidence: str = "low"
@@ -83,6 +89,139 @@ class UnavailableCatalogLookupProvider:
         raise LookupUnavailable("external instrument lookup is not configured")

+def _runtime_secret_value(names: tuple[str, ...]) -> str | None:
+    for name in names:
+        if os.environ.get(name):
+            return os.environ[name]
+    runtime_dir = Path(os.environ.get("JARVIS_FINANCE_RUNTIME_DIR", "~/jarvis_runtime/finance-system")).expanduser()
+    for candidate in [runtime_dir / "secrets" / ".env", runtime_dir / ".env"]:
+        if not candidate.exists():
+            continue
+        for line in candidate.read_text(encoding="utf-8").splitlines():
+            raw = line.strip()
+            if not raw or raw.startswith("#") or "=" not in raw:
+                continue
+            key, value = raw.split("=", 1)
+            key = key.strip().removeprefix("export ").strip()
+            if key in names:
+                return value.strip().strip('"').strip("'") or None
+    return None
+
+
+class _HttpJsonLookupProvider:
+    name = "provider"
+    api_key_env: tuple[str, ...] = ()
+
+    def __init__(self, *, api_key: str | None = None, timeout_seconds: float = 8.0, min_interval_seconds: float = 0.2) -> None:
+        self.api_key = api_key or _runtime_secret_value(self.api_key_env)
+        self.timeout_seconds = timeout_seconds
+        self.min_interval_seconds = min_interval_seconds
+        self._last_request_at = 0.0
+
+    def _throttle(self) -> None:
+        elapsed = time.monotonic() - self._last_request_at
+        if elapsed < self.min_interval_seconds:
+            time.sleep(self.min_interval_seconds - elapsed)
+        self._last_request_at = time.monotonic()
+
+    def _get_json(self, url: str, *, headers: dict[str, str] | None = None) -> object:
+        self._throttle()
+        req = request.Request(url, headers=headers or {})
+        with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup
+            return json.loads(response.read().decode("utf-8"))
+
+    def _post_json(self, url: str, payload: object, *, headers: dict[str, str] | None = None) -> object:
+        self._throttle()
+        data = json.dumps(payload).encode("utf-8")
+        req = request.Request(url, data=data, headers={"Content-Type": "application/json", **(headers or {})})
+        with request.urlopen(req, timeout=self.timeout_seconds) as response:  # noqa: S310 - explicit user-triggered provider lookup
+            return json.loads(response.read().decode("utf-8"))
+
+
+class OpenFigiLookupProvider(_HttpJsonLookupProvider):
+    name = "openfigi"
+    api_key_env = ("OPENFIGI_API_KEY", "JARVIS_OPENFIGI_API_KEY")
+
+    def _request(self, query: str) -> object:
+        if not self.api_key:
+            raise LookupUnavailable("openfigi_api_key_missing")
+        headers = {"X-OPENFIGI-APIKEY": self.api_key}
+        return self._post_json("https://api.openfigi.com/v3/search", {"query": query}, headers=headers)
+
+    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
+        payload = self._request(query)
+        rows = payload.get("data", []) if isinstance(payload, dict) else payload if isinstance(payload, list) else []
+        results: list[CatalogSearchResult] = []
+        for item in rows[:10]:
+            if not isinstance(item, dict):
+                continue
+            name = str(item.get("name") or item.get("securityDescription") or "").strip()
+            ticker = str(item.get("ticker") or "").strip().upper() or None
+            exchange = str(item.get("exchCode") or item.get("marketSector") or "").strip().upper() or None
+            if not name and not ticker:
+                continue
+            sec_type = str(item.get("securityType") or item.get("marketSecDes") or "").lower()
+            inferred_class = asset_class or ("etf" if "fund" in sec_type or "etf" in sec_type else "stock")
+            results.append(CatalogSearchResult(None, inferred_class, name or ticker or query, ticker=ticker, exchange=exchange, provider="openfigi", provider_symbol=str(item.get("compositeFIGI") or item.get("figi") or ticker or "") or None, confidence="medium", source="openfigi", evidence_note="OpenFIGI search result; manual selection required"))
+        return results
+
+
+class FinnhubSymbolLookupProvider(_HttpJsonLookupProvider):
+    name = "finnhub"
+    api_key_env = ("FINNHUB_API_KEY", "JARVIS_FINNHUB_API_KEY")
+
+    def _request(self, query: str) -> object:
+        if not self.api_key:
+            raise LookupUnavailable("finnhub_api_key_missing")
+        url = "https://finnhub.io/api/v1/search?" + parse.urlencode({"q": query, "token": self.api_key})
+        return self._get_json(url)
+
+    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 []
+        results: list[CatalogSearchResult] = []
+        for item in rows[:10]:
+            if not isinstance(item, dict):
+                continue
+            symbol = str(item.get("symbol") or "").strip().upper()
+            description = str(item.get("description") or symbol or query).strip()
+            if not symbol:
+                continue
+            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"))
+        return results
+
+
+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:
+        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)
+
+    def search(self, *, query: str, asset_class: str | None = None) -> list[CatalogSearchResult]:
+        payload = self._request(query)
+        rows = payload if isinstance(payload, list) else []
+        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()
+            if not symbol and not name:
+                continue
+            inferred_class = asset_class or ("etf" if "etf" in name.lower() else "stock")
+            results.append(CatalogSearchResult(None, inferred_class, name, ticker=symbol.split(".")[0] if symbol else None, exchange=str(item.get("exchangeShortName") or item.get("exchange") or "").strip().upper() or None, trading_currency=str(item.get("currency") or "").strip().upper() or None, provider="fmp", provider_symbol=symbol or None, country=str(item.get("country") or "").strip().upper() or None, confidence="low", source="fmp", evidence_note="Financial Modeling Prep search result; manual selection required"))
+        return results
+
+
+def default_instrument_lookup_providers() -> list[CatalogLookupProvider]:
+    return [OpenFigiLookupProvider(), FinnhubSymbolLookupProvider(), FmpSearchProvider()]
+
+
 def _validate_entry(entry: CatalogEntryInput) -> None:
     if entry.asset_class.lower() not in ASSET_CLASSES:
         raise ValueError("invalid asset_class")
@@ -187,7 +326,7 @@ def search_local_catalog(conn: Connection, query: str, asset_class: str | None =
         CatalogSearchResult(
             catalog_entry_id=row["catalog_entry_id"], asset_class=row["asset_class"], name=row["name"], isin=row["isin"], ticker=row["ticker"], exchange=row["exchange"],
             trading_currency=row["trading_currency"], instrument_currency=row["instrument_currency"], provider=row["provider"], provider_symbol=row["provider_symbol"],
-            provider_market=row["provider_market"], hedge_status=row["hedge_status"] or "unknown", instrument_status=row["instrument_status"] or "unknown",
+            provider_market=row["provider_market"], country=row["country"], hedge_status=row["hedge_status"] or "unknown", instrument_status=row["instrument_status"] or "unknown",
             confidence=row["source_confidence"] or "low", source="local_catalog", evidence_note="local catalog match",
         )
         for row in rows
@@ -200,11 +339,45 @@ def search_instruments(conn: Connection, query: str, asset_class: str | None = N
     for provider in providers or []:
         try:
             external = provider.search(query=query, asset_class=asset_class)
+        except LookupUnavailable as exc:
+            warning = str(exc) or "provider_lookup_unavailable"
+            if warning == "external instrument lookup is not configured":
+                warning = "provider_lookup_unavailable"
+            warnings.append(warning)


git add src/jarvis_finance/dashboard/main.py src/jarvis_finance/dashboard/views/__init__.py src/jarvis_finance/dashboard/views/page_00_command_center.py src/jarvis_finance/dashboard/views/page_17_position_add.py src/jarvis_finance/equity/manage.py src/jarvis_finance/market_data/catalog.py tests/unit/test_dashboard_functional_ux_stabilization.py tests/unit/test_dashboard_usability_simplification.py tests/unit/test_equity_manage.py && git diff --cached --check && PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan . && git status --short && git commit -m "Improve manual position entry UX"


Output:
GIT_SAFETY_OK
M  src/jarvis_finance/dashboard/main.py
M  src/jarvis_finance/dashboard/views/__init__.py
M  src/jarvis_finance/dashboard/views/page_00_command_center.py
A  src/jarvis_finance/dashboard/views/page_17_position_add.py
M  src/jarvis_finance/equity/manage.py
M  src/jarvis_finance/market_data/catalog.py
M  tests/unit/test_dashboard_functional_ux_stabilization.py
M  tests/unit/test_dashboard_usability_simplification.py
M  tests/unit/test_equity_manage.py
[main 195c952] Improve manual position entry UX
 9 files changed, 455 insertions(+), 16 deletions(-)
 create mode 100644 src/jarvis_finance/dashboard/views/page_17_position_add.py


python - <<'PY'
from pathlib import Path
import csv, os, subprocess, tempfile
p=Path('/home/agent/jarvis_runtime/finance-system/secrets/Github_token_FinanceManager.csv')
rows=list(csv.reader(p.read_text().splitlines()))
token=None
for row in rows:
    for cell in row:
        cell=cell.strip()
        if cell.startswith('ghp_') or cell.startswith('github_pat_'):
            token=cell
            break
    if token:
        break
if not token:
    raise SystemExit('token_missing')
with tempfile.NamedTemporaryFile('w', delete=False) as f:
    f.write('#!/bin/sh\n')
    f.write('case "$1" in *Username*) printf "%s" "x-access-token" ;; *Password*) printf "%s" "$GITHUB_TOKEN" ;; esac\n')
    helper=f.name
os.chmod(helper,0o700)
env=os.environ.copy(); env['GIT_ASKPASS']=helper; env['GITHUB_TOKEN']=token; env['GIT_TERMINAL_PROMPT']='0'
try:
    r=subprocess.run(['git','push','origin','main'],cwd='/home/agent/.hermes/repos/FinanceManager',env=env,text=True,capture_output=True,timeout=120)
    print('push_exit', r.returncode)
    print(r.stdout)
    print(r.stderr)
    if r.returncode:
        raise SystemExit(r.returncode)
finally:
    try: os.remove(helper)
    except FileNotFoundError: pass
PY
rm -rf .pytest_cache
PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan .
git status --short
git rev-parse --short HEAD


Output:
push_exit 0

To https://github.com/Gamexgit/FinanceManager.git
   43b0443..195c952  main -> main

GIT_SAFETY_OK
195c952


Explanation:
**Evaluating git status**

I want the git status output to be empty, but it doesn't seem to show that line, which is puzzling. Maybe I need to ensure there's no table in the output. I also should verify the current status to make sure it's clean, but maybe the status showing empty is part of that. There's definitely a need to complete my to-do list regarding this!


Erledigt, Sir.

## Manual Position Entry & Instrument Search UX Sprint

Commit: `195c952`
Push: erfolgreich nach `origin/main`
Git Status: clean
Git-Safety: `GIT_SAFETY_OK`

## Wo findest du die Funktion im Dashboard?

Im normalen **User Mode** jetzt direkt sichtbar:

- **Command Center**
- **Crypto**
- **Wallets**
- **Aktien & ETFs**
- **Position hinzufügen**
- **Reports**

Zusätzlich gibt es im **Command Center** eine Schnellaktion:

- **+ Position hinzufügen**

Diese führt zur zentralen Wizard-Seite. Kein Suchen mehr im Admin-/Debug-Bereich. Welch revolutionärer Luxus: ein Menüpunkt, der tatsächlich auffindbar ist.

## Neue zentrale Seite

Neue Seite:

**Position hinzufügen**

Geführter Wizard:

1. Assetklasse wählen
   - Aktie
   - ETF
   - Crypto
   - Cash

2. Instrument suchen
   - ISIN
   - Name
   - Ticker
   - optionale Filter:
     - Assetklasse
     - Exchange
     - Währung
     - Land
     - Provider

3. Instrument auswählen
   - Trefferliste ohne technische IDs
   - Ticker allein wird nicht automatisch als eindeutig behandelt
   - mehrere Treffer verlangen bewusste Auswahl

4. Position erfassen
   - Plattform/Depot:
     - PostFinance
     - True Wealth
     - Raiffeisen
     - Anderes Konto
   - Menge
   - Snapshot-/Buchungsdatum
   - Transaktionstyp
   - Cost Basis optional
   - Gebühren optional
   - FX-Status/Kurs/Quelle optional
   - Notiz

5. Review

6. Bestätigt speichern

## DOCX-Importpfad

Produktiver Aktien-/ETF-Import aus PostFinance-/True-Wealth-DOCX bleibt gestoppt.

Die Review Queue bleibt als Review-/Erinnerungsfläche erhalten, ist aber **nicht** der Hauptweg und schreibt keine produktiven Initial Snapshots.

## ISIN-Suche

Ja, funktioniert.

Regel:

- Exakter ISIN-Treffer kann als **Eindeutig** angezeigt werden.
- ISIN bleibt die bevorzugte Identifikation für Aktien/ETFs.

## Name-/Ticker-Suche

Ja, funktioniert.

Regeln:

- Name-Suche zeigt Kandidatenliste.
- Ticker allein wird als **Manuell prüfen** behandelt.
- Ticker allein führt nicht zu automatischer Übernahme.
- Mehrere Treffer verlangen bewusste Auswahl.

## Provider eingebunden

Provider-Reihenfolge implementiert:

1. Lokale Instrumente in SQLite
2. OpenFIGI
3. Finnhub Symbol Lookup
4. Financial Modeling Prep Search
5. Manuelle Anlage

Provider werden **nur bei explizitem Such-Klick** und aktivierter Provider-Suche abgefragt. Kein Live-API-Call beim normalen Seitenrendern.

## Provider mangels API-Key aktuell deaktiviert

Beim aktuellen Runtime-Check:

- OpenFIGI: API-Key fehlt
- Finnhub: API-Key fehlt
- Financial Modeling Prep: API-Key fehlt

Keys werden unterstützt über Environment oder Runtime-Secrets:

- `~/jarvis_runtime/finance-system/secrets/.env`
- alternativ `~/jarvis_runtime/finance-system/.env`

Unterstützte Variablen:

- OpenFIGI:
  - `OPENFIGI_API_KEY`
  - `JARVIS_OPENFIGI_API_KEY`

- Finnhub:
  - `FINNHUB_API_KEY`
  - `JARVIS_FINNHUB_API_KEY`

- Financial Modeling Prep:
  - `FMP_API_KEY`
  - `FINANCIAL_MODELING_PREP_API_KEY`
  - `JARVIS_FMP_API_KEY`

Keine Keys wurden ins Repo geschrieben oder im Chat ausgegeben.

## Caching

Provider-Ergebnisse werden bei expliziter Suche in den lokalen Instrument-Katalog geschrieben. Danach sind sie lokal suchbar, ohne erneute Provider-Abfrage.

Provider-Fehler erzeugen Warnungen und Data-Quality-/Market-Data-Alerts, aber keinen Dashboard-Crash.

## Manuelle Anlage

Ja, möglich.

Regeln:

- ISIN stark empfohlen
- ISIN und Ticker dürfen nicht beide leer sein
- manuelle Anlage ohne ISIN bleibt prüfpflichtig
- Instrument wird lokal im Katalog angelegt
- danach kann es gesucht und bewusst ausgewählt werden

## Initial Snapshot speichern

Ja, möglich.

Über **Position hinzufügen → Aktie/ETF → Instrument auswählen → Transaktionstyp: Initial Snapshot**.

Sicherheitsregeln:

- Review vor Speicherung Pflicht
- Bestätigung per Checkbox Pflicht
- Depot/Konto Pflicht
- Instrumentauswahl Pflicht
- keine stillen Überschreibungen
- bestehende Position wird gegen doppelte Initial Snapshots geschützt
- Audit-Log wird geschrieben

## Audit-Log getestet

Ja.

Bestehende und neue Tests decken ab:

- manuelle Instrumentanlage mit Audit
- Initial Snapshot mit Audit
- Ledger-Ereignis-Erzeugung
- Review/Confirm vor Speicherung
- Data-Quality-Hinweise bei fehlender Cost Basis / fehlendem FX

## Tests

Ausgeführt:

- `python -m compileall src tests`
- `pytest -q`
- `git diff --check`
- `PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan .`
- `git status --short`

Ergebnis:

- Compile: erfolgreich
- pytest: `262 passed`
- git diff check: sauber
- Git-Safety: `GIT_SAFETY_OK`
- Git Status nach Push: clean

## Commit / Push

- Commit: `195c952`
- Message: `Improve manual position entry UX`
- Push: erfolgreich nach `main`

## Offene Grenzen

- Provider-Suche ist implementiert, aber mangels API-Keys aktuell faktisch deaktiviert.
- OpenFIGI liefert nicht zuverlässig ISIN in der einfachen Search-Antwort; Treffer bleiben daher bewusst manuell prüfpflichtig.
- Crypto/Cash sind auf der neuen zentralen Seite sichtbar vorbereitet, aber in diesem Sprint noch nicht als vollständiger Wizard umgesetzt.
- Gebühren werden aktuell in Review/Notiz berücksichtigt; noch kein eigener Gebühren-Ledger-Flow auf der zentralen Seite.
- Automatischer FX-Abruf ist noch nicht angebunden; fehlender FX blockiert nicht, sondern erzeugt Data-Quality-Hinweis.
- Externe Provider-Abfragen sind bewusst klickbasiert, nicht live beim Dashboard-Rendern.