    60|    market_prices.add_argument("--limit", type=int)
    61|    market_prices.add_argument("--dry-run", action="store_true")
    62|    market_prices.add_argument("--date", dest="price_date")
    63|    market_prices.add_argument("--mock-price", action="append", default=[], help="Test-only SYMBOL=PRICE")
    64|    catalog = sub.add_parser("prepare-instrument-catalog")
    65|    catalog.add_argument("--source", default="true_wealth")
    66|    catalog.add_argument("--limit", type=int)
    67|    candidates = sub.add_parser("generate-mapping-candidates")
    68|    candidates.add_argument("--source", default="true_wealth")
    69|    candidates.add_argument("--limit", type=int)
    70|    candidates.add_argument("--dry-run", action="store_true")
    71|    candidates.add_argument("--public-lookup", action="store_true")
    72|    candidates.add_argument("--export-template", action="store_true")
    73|    candidates.add_argument("--template-path")
    74|    candidates.add_argument("--dedupe-missing-provider-alerts", action="store_true")
    75|    sub.add_parser("backup-runtime-db")
    76|    restore = sub.add_parser("restore-runtime-db")
    77|    restore.add_argument("--from", dest="backup_file", required=True)
    78|    restore.add_argument("--yes", action="store_true")
    79|    verify = sub.add_parser("verify-backup")
    80|    verify.add_argument("--file", required=True)
    81|    args = parser.parse_args(argv)
    82|
    83|    if args.command in {"init-db", "migrate"}:
    84|        settings = load_settings()
    85|        ensure_runtime_dirs(settings)
    86|        conn = connect(settings.db_path)
    87|        apply_migrations(conn)
    88|        print(f"SCHEMA_VERSION={get_schema_version(conn)}")
    89|        return 0
    90|    if args.command == "git-safety-scan":
    91|        findings = scan_path(Path(args.path))
    92|        if findings:
    93|            for finding in findings:
    94|                print(f"{finding.path}: {finding.reason}")
    95|            return 1
    96|        assert_safe(Path(args.path))
    97|        print("GIT_SAFETY_OK")
    98|        return 0
    99|    if args.command == "update-crypto-prices":
   100|        settings = load_settings()
   101|        ensure_runtime_dirs(settings)
   102|        conn = connect(settings.db_path)
   103|        try:
   104|            apply_migrations(conn)
   105|            result = refresh_crypto_prices(
   106|                conn,
   107|                provider=CoinGeckoClient(
   108|                    max_retries=args.max_retries,
   109|                    initial_backoff_seconds=args.initial_backoff,
   110|                    max_backoff_seconds=args.max_backoff,
   111|                ),
   112|                currency=args.currency,
   113|                max_age_seconds=args.max_age_seconds,
   114|                only_missing=args.only_missing,
   115|                only_stale=args.only_stale,
   116|                only_symbol=args.only_symbol,
   117|                limit=args.limit,
   118|                dry_run=args.dry_run,
   119|                sleep_seconds=args.sleep_seconds,
   120|            )
   121|            print(
   122|                "CRYPTO_PRICE_UPDATE "
   123|                f"currency={result.currency} total_assets={result.total_assets} "
   124|                f"updated={result.updated_count} cached={result.cached_count} "
   125|                f"skipped={result.skipped_count} stale={result.stale_count} "
   126|                f"warnings={result.warning_count} errors={result.error_count} "
   127|                f"assets_still_missing_local_price={result.missing_local_price_count} "
   128|                f"dry_run={str(result.dry_run).lower()}"
   129|            )
   130|            return 1 if result.error_count else 0
   131|        finally:
   132|            conn.close()
   133|    if args.command == "update-fx-rates":
   134|        settings = load_settings()
   135|        ensure_runtime_dirs(settings)
   136|        conn = connect(settings.db_path)
   137|        try:
   138|            apply_migrations(conn)
   139|            if args.recheck_transactions:
   140|                result = recheck_transaction_fx_status(conn, source_type=args.source_type, dry_run=args.dry_run)
   141|                print(
   142|                    "FX_RECHECK "
   143|                    f"transactions={result.transaction_count} chf_positions={result.chf_positions} "
   144|                    f"foreign_currency_positions={result.foreign_currency_positions} "
   145|                    f"justified_missing_fx_alerts={result.justified_missing_fx_alerts} "
   146|                    f"false_positive_missing_fx_alerts={result.false_positive_missing_fx_alerts} "
   147|                    f"corrected_transactions={result.corrected_transactions} resolved_alerts={result.resolved_alerts} "
   148|                    f"audit_events={result.audit_events} dry_run={str(result.dry_run).lower()}"
   149|                )
   150|                return 0
   151|            if not args.rate_date and not args.latest:
   152|                parser.error("update-fx-rates requires --date or --latest unless --recheck-transactions is used")
   153|            mock_rates: dict[tuple[str, str, str], Decimal | None] = {}
   154|            for item in args.mock_rate:
   155|                base, rate = item.split("=", 1)
   156|                mock_rates[(base.upper(), "CHF", args.rate_date or "latest")] = Decimal(rate)
   157|            currencies = args.currencies or sorted({r["currency_original"] for r in conn.execute("SELECT DISTINCT currency_original FROM transactions WHERE currency_original IS NOT NULL").fetchall()})
   158|            provider = MockFxProvider(mock_rates)
   159|            result = update_fx_rates(conn, provider=provider, currencies=currencies, rate_date=args.rate_date or "latest", latest=args.latest, missing_only=args.missing_only, dry_run=args.dry_run)
   160|            print(
   161|                "FX_RATE_UPDATE "
   162|                f"updated={result.updated_count} cached={result.cached_count} skipped={result.skipped_count} "
   163|                f"warnings={result.warning_count} errors={result.error_count} resolved_alerts={result.resolved_alerts} "
   164|                f"dry_run={str(result.dry_run).lower()}"
   165|            )
   166|            return 1 if result.error_count else 0
   167|        finally:
   168|            conn.close()
   169|    if args.command == "update-market-prices":
   170|        settings = load_settings()
   171|        ensure_runtime_dirs(settings)
   172|        conn = connect(settings.db_path)
   173|        try:
   174|            apply_migrations(conn)
   175|            prices = {}
   176|            for item in args.mock_price:
   177|                symbol, price = item.split("=", 1)
   178|                prices[symbol] = Decimal(price)
   179|            result = refresh_market_prices(
   180|                conn,
   181|                provider=MockEquityPriceProvider(prices),
   182|                asset_class=args.asset_class,
   183|                price_date=args.price_date,
   184|                only_missing=args.only_missing,
   185|                only_stale=args.only_stale,
   186|                only_isin=args.only_isin,
   187|                limit=args.limit,
   188|                dry_run=args.dry_run,
   189|            )
   190|            print(
   191|                "MARKET_PRICE_UPDATE "
   192|                f"asset_class={result.asset_class} total_mappings={result.total_mappings} updated={result.updated_count} "
   193|                f"cached={result.cached_count} skipped={result.skipped_count} stale={result.stale_count} "
   194|                f"warnings={result.warning_count} errors={result.error_count} dry_run={str(result.dry_run).lower()}"
   195|            )
   196|            return 1 if result.error_count else 0
   197|        finally:
   198|            conn.close()
   199|    if args.command == "prepare-instrument-catalog":
   200|        settings = load_settings()
   201|        ensure_runtime_dirs(settings)
   202|        conn = connect(settings.db_path)
   203|        try:
   204|            apply_migrations(conn)
   205|            rows = conn.execute(
   206|                """
   207|                SELECT DISTINCT t.instrument_id
   208|                FROM transactions t JOIN instruments i ON i.instrument_id=t.instrument_id
   209|                WHERE t.transaction_type='initial_position_snapshot'
   210|                  AND t.source_type='broker_import_reviewed_snapshot'
   211|                  AND COALESCE(t.is_voided,0)=0
   212|                  AND t.instrument_id IS NOT NULL
   213|                ORDER BY i.isin, i.name
   214|                """
   215|            ).fetchall()
   216|            ids = [r["instrument_id"] for r in rows]
   217|            if args.limit is not None:
   218|                ids = ids[:args.limit]
   219|            summary = prepare_catalog_from_instruments(conn, instrument_ids=ids, source=args.source)
   220|            print(
   221|                "INSTRUMENT_CATALOG_PREP "
   222|                f"instruments_total={summary['instruments_total']} catalog_entries_created={summary['catalog_entries_created']} "
   223|                f"multiple_listings={summary['multiple_listings']} manual_review_required={summary['manual_review_required']} "
   224|                f"hedge_unknown={summary['hedge_unknown']} instrument_status_unknown={summary['instrument_status_unknown']} "
   225|                f"valuation_ready={summary['valuation_ready']}"
   226|            )
   227|            return 0
   228|        finally:
   229|            conn.close()
   230|    if args.command == "generate-mapping-candidates":
   231|        settings = load_settings()
   232|        ensure_runtime_dirs(settings)
   233|        conn = connect(settings.db_path)
   234|        try:
   235|            apply_migrations(conn)
   236|            if args.dedupe_missing_provider_alerts:
   237|                resolved = dedupe_missing_provider_symbol_alerts(conn)
   238|            else:
   239|                resolved = 0
   240|            provider = CompositePublicLookupProvider() if args.public_lookup else EmptyLookupProvider()
   241|            result = generate_mapping_candidates(conn, provider=provider, source=args.source, limit=args.limit, dry_run=args.dry_run)
   242|            template = None
   243|            if args.export_template:
   244|                template_path = args.template_path or str(settings.runtime_paths.exports_dir / "true_wealth_mapping_review_template.csv")
   245|                template = export_mapping_review_template(conn, output_path=template_path)
   246|            print(
   247|                "MAPPING_CANDIDATES "
   248|                f"instruments_total={result.instruments_total} candidates_found={result.candidates_found} "
   249|                f"high={result.high_confidence} medium={result.medium_confidence} low={result.low_confidence} "
   250|                f"instruments_without_candidate={result.instruments_without_candidate} "
   251|                f"hedge_unknown={result.instruments_with_hedge_unknown} instrument_status_unknown={result.instruments_with_status_unknown} "
   252|                f"provider_lookup_unavailable={result.provider_lookup_unavailable} ambiguous={result.ambiguous_provider_mapping} "
   253|                f"deduped_missing_provider_alerts={resolved} "
   254|                f"template_updated={str(bool(template)).lower()} template_instruments={template['instrument_count'] if template else 0} template_candidates={template['candidate_rows'] if template else 0} "
   255|                f"dry_run={str(result.dry_run).lower()}"
   256|            )
   257|            return 0
   258|        finally:
   259|            conn.close()
   260|    if args.command == "check-data-quality":
   261|        settings = load_settings()
   262|        ensure_runtime_dirs(settings)
   263|        conn = connect(settings.db_path)
   264|        try:
   265|            apply_migrations(conn)
   266|            result = check_crypto_data_quality(
   267|                conn,
   268|                currency=args.currency,
   269|                max_age_seconds=args.max_age_seconds,
   270|                resolve_fixed=args.resolve_fixed,
   271|                dry_run=args.dry_run,
   272|            )
   273|            print(
   274|                "DATA_QUALITY_CHECK "
   275|                f"scope={result.scope} active_crypto_assets={result.active_crypto_assets} "
   276|                f"fresh_price_assets={result.fresh_price_assets} "
   277|                f"active_stale_price_alerts_before={result.active_stale_price_alerts_before} "
   278|                f"active_stale_price_alerts_after={result.active_stale_price_alerts_after} "
   279|                f"resolved_alerts={result.resolved_alerts} "
   280|                f"warnings={result.warnings} errors={result.errors} "
   281|                f"dry_run={str(result.dry_run).lower()}"
   282|            )
   283|            return 1 if result.errors else 0
   284|        finally:
   285|            conn.close()
   286|    if args.command == "backup-runtime-db":
   287|        settings = load_settings()
   288|        ensure_runtime_dirs(settings)
   289|        result = backup_runtime_db(
   290|

Use offset=290 to continue reading (showing 60-289 of 328 lines)


   300|        reasons.append("Name passt fuzzy")
   301|    elif sim >= 0.55:
   302|        score += 5
   303|        reasons.append("Name teilweise ähnlich")
   304|    elif cand.name:
   305|        risks.append("Name unsicher")
   306|    hay = " ".join(x for x in [inst["name"], cand.name, cand.evidence_note] if x).lower()
   307|    hedge_hint = any(marker in hay for marker in HEDGE_MARKERS)
   308|    if (cand.hedge_status or "unknown") != "unknown":
   309|        score += 8
   310|        reasons.append("Hedge-Status vorhanden")
   311|    elif hedge_hint:
   312|        score += 3
   313|        reasons.append("Hedge-Hinweis gefunden")
   314|    else:
   315|        risks.append("Hedge unklar")
   316|    if (cand.instrument_status or "unknown") == "active":
   317|        score += 10
   318|        reasons.append("Instrument-Status aktiv")
   319|    elif (cand.instrument_status or "unknown") == "unknown":
   320|        risks.append("Instrument-Status unklar")
   321|    else:
   322|        score -= 25
   323|        risks.append("Instrument-Status nicht aktiv")
   324|    if cand.evidence_source == "openfigi_isin_mapping":
   325|        score += 10
   326|        reasons.append("OpenFIGI ISIN-Datenquelle")
   327|    elif cand.evidence_source == "yahoo_finance_search":
   328|        score -= 5
   329|        reasons.append("Yahoo Search Fallback")
   330|        if not cand_isin:
   331|            risks.append("Search-Fallback ohne ISIN-Evidence")
   332|    completeness = sum(bool(x) for x in [cand.provider, cand.provider_symbol, cand.exchange, cand.currency])
   333|    score += completeness * 3
   334|    if completeness < 4:
   335|        risks.append("Kandidatenfelder unvollständig")
   336|    if candidate_count > 1:
   337|        risks.append("mehrere Listings/Kandidaten vorhanden")
   338|    if cand.provider_symbol and not cand.exchange:
   339|        risks.append("Ticker ohne eindeutige Börse")
   340|    action = "needs_manual_review"
   341|    if score >= 80 and not any(flag in risks for flag in ["currency_mismatch", "Assetklasse abweichend", "kein ISIN-Match"]) and candidate_count == 1:
   342|        action = "select_candidate"
   343|    elif score < 25 or "currency_mismatch" in risks or "Assetklasse abweichend" in risks:
   344|        action = "reject_candidate"
   345|    elif not cand.provider_symbol or not cand.exchange or not cand.currency:
   346|        action = "insufficient_data"
   347|    return CandidateRanking(max(0, min(100, score)), "; ".join(reasons) or "Keine starken positiven Signale", ",".join(dict.fromkeys(risks)), action)
   348|
   349|
   350|def _review_status_for(confidence: str, count: int, inst, cand: ProviderCandidate) -> str:
   351|    # Even strong candidates remain review-required when multiple listings/currencies exist.
   352|    if count > 1:
   353|        return "needs_manual_review"
   354|    ranking = rank_candidate(inst, cand, candidate_count=count)
   355|    if ranking.recommended_action == "select_candidate" and confidence == "high":
   356|        return "proposed"
   357|    return "needs_manual_review"
   358|
   359|
   360|def upsert_candidate(conn: Connection, *, instrument_id: str, candidate: ProviderCandidate, confidence: str, review_status: str, evidence_note: str, candidate_count: int = 1) -> str:
   361|    inst = conn.execute("SELECT * FROM instruments WHERE instrument_id=?", (instrument_id,)).fetchone()
   362|    if inst is None:
   363|        raise ValueError(f"instrument not found: {instrument_id}")
   364|    hedged, hedged_to = _infer_hedge(inst["name"], candidate)
   365|    hedge_status = candidate.hedge_status if candidate.hedge_status in {"hedged", "unhedged", "unknown"} else "unknown"
   366|    if hedge_status == "unknown" and hedged is True:
   367|        hedge_status = "hedged"
   368|    elif hedge_status == "unknown" and hedged is False:
   369|        hedge_status = "unhedged"
   370|    candidate_id = stable_id("ipmc", instrument_id, candidate.provider, candidate.provider_symbol, candidate.exchange or "", candidate.currency or "")
   371|    ranking = rank_candidate(inst, candidate, candidate_count=candidate_count)
   372|    now = utc_now()
   373|    conn.execute(
   374|        """
   375|        INSERT INTO instrument_price_mapping_candidates(
   376|            candidate_id, instrument_id, isin, candidate_provider, candidate_provider_symbol,
   377|            candidate_exchange, candidate_currency, candidate_name, candidate_asset_class,
   378|            candidate_is_hedged, candidate_hedged_to_currency, candidate_hedge_status,
   379|            candidate_instrument_status, candidate_valuation_policy, ranking_score,
   380|            ranking_reason, risk_flags, recommended_action, confidence,
   381|            evidence_source, evidence_note, review_status, created_at, updated_at
   382|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   383|        ON CONFLICT(candidate_id) DO UPDATE SET
   384|            candidate_exchange=excluded.candidate_exchange,
   385|            candidate_currency=excluded.candidate_currency,
   386|            candidate_name=excluded.candidate_name,
   387|            candidate_asset_class=excluded.candidate_asset_class,
   388|            candidate_is_hedged=excluded.candidate_is_hedged,
   389|            candidate_hedged_to_currency=excluded.candidate_hedged_to_currency,
   390|            candidate_hedge_status=excluded.candidate_hedge_status,
   391|            candidate_instrument_status=excluded.candidate_instrument_status,
   392|            candidate_valuation_policy=excluded.candidate_valuation_policy,
   393|            ranking_score=excluded.ranking_score,
   394|            ranking_reason=excluded.ranking_reason,
   395|            risk_flags=excluded.risk_flags,
   396|            recommended_action=excluded.recommended_action,
   397|            confidence=excluded.confidence,
   398|            evidence_source=excluded.evidence_source,
   399|            evidence_note=excluded.evidence_note,
   400|            review_status=excluded.review_status,
   401|            updated_at=excluded.updated_at
   402|        """,
   403|        (
   404|            candidate_id, instrument_id, inst["isin"], candidate.provider, candidate.provider_symbol,
   405|            candidate.exchange, candidate.currency or inst["currency"], candidate.name, candidate.asset_class or (inst["asset_class"] or "").lower(),
   406|            1 if hedged else 0 if hedged is False else None, hedged_to, hedge_status,
   407|            candidate.instrument_status or "unknown", candidate.valuation_policy or "live_price",
   408|            ranking.ranking_score, ranking.ranking_reason, ranking.risk_flags, ranking.recommended_action,
   409|            confidence, candidate.evidence_source,
   410|            evidence_note or candidate.evidence_note, review_status, now, now,
   411|        ),
   412|    )
   413|    return candidate_id
   414|
   415|
   416|def generate_mapping_candidates(
   417|    conn: Connection,
   418|    *,
   419|    provider: InstrumentLookupProvider | None = None,
   420|    instrument_ids: list[str] | None = None,
   421|    source: str = "true_wealth",
   422|    limit: int | None = None,
   423|    dry_run: bool = False,
   424|) -> CandidateGenerationResult:
   425|    lookup = provider or EmptyLookupProvider()
   426|    ids = instrument_ids if instrument_ids is not None else _instrument_ids_for_true_wealth(conn, limit=limit)
   427|    result = CandidateGenerationResult(instruments_total=len(ids), dry_run=dry_run)
   428|    for instrument_id in ids:
   429|        inst = conn.execute("SELECT * FROM instruments WHERE instrument_id=?", (instrument_id,)).fetchone()
   430|        if inst is None:
   431|            continue
   432|        ensure_instrument_metadata_quality(conn, instrument_id=instrument_id)
   433|        if (inst["hedge_status"] or "unknown") == "unknown":
   434|            result.instruments_with_hedge_unknown += 1
   435|        if (inst["instrument_status"] or "unknown") == "unknown":
   436|            result.instruments_with_status_unknown += 1
   437|        try:
   438|            candidates = lookup.lookup(isin=inst["isin"], name=inst["name"], ticker=inst["ticker"], exchange=inst["exchange"], currency=inst["currency"])
   439|        except Exception as exc:
   440|            result.provider_lookup_unavailable += 1
   441|            result.warnings.append(instrument_id)
   442|            if not dry_run:
   443|                create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id="provider_lookup_unavailable", message="Provider lookup unavailable for instrument mapping candidates.", evidence={"provider": getattr(lookup, "name", "unknown"), "error_type": type(exc).__name__}, fingerprint=f"provider_lookup_unavailable:{getattr(lookup, 'name', 'unknown')}")
   444|            continue
   445|        if not candidates:
   446|            result.instruments_without_candidate += 1
   447|            if not dry_run:
   448|                create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id="missing_provider_symbol", message="No provider-symbol candidate found; manual mapping required.", evidence={"source": source, "provider": getattr(lookup, "name", "manual")}, fingerprint="missing_provider_symbol")
   449|            continue
   450|        if len(candidates) > 1:
   451|            result.ambiguous_provider_mapping += 1
   452|            if not dry_run:
   453|                create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id="ambiguous_provider_mapping", message="Multiple provider-symbol candidates require manual review.", evidence={"candidate_count": len(candidates), "provider": getattr(lookup, "name", "unknown")}, fingerprint=f"ambiguous_provider_mapping:{getattr(lookup, 'name', 'unknown')}:{len(candidates)}")
   454|        for cand in candidates:
   455|            confidence, note = _confidence(inst, cand)
   456|            if confidence == "high":
   457|                result.high_confidence += 1
   458|            elif confidence == "medium":
   459|                result.medium_confidence += 1
   460|            else:
   461|                result.low_confidence += 1
   462|            status = _review_status_for(confidence, len(candidates), inst, cand)
   463|            result.candidates_found += 1
   464|            if not dry_run:
   465|                upsert_candidate(conn, instrument_id=instrument_id, candidate=cand, confidence=confidence, review_status=status, evidence_note=note, candidate_count=len(candidates))
   466|    if not dry_run:
   467|        conn.commit()
   468|    return result
   469|
   470|
   471|def _candidate_from_row(row) -> ProviderCandidate:
   472|    return ProviderCandidate(
   473|        provider=row["candidate_provider"],
   474|        provider_symbol=row["candidate_provider_symbol"],
   475|        exchange=row["candidate_exchange"],
   476|        currency=row["candidate_currency"],
   477|        name=row["candidate_name"],
   478|        isin=row["isin"],
   479|        asset_class=row["candidate_asset_class"],
   480|        is_hedged=True if row["candidate_is_hedged"] == 1 else False if row["candidate_is_hedged"] == 0 else None,
   481|        hedged_to_currency=row["candidate_hedged_to_currency"],
   482|        hedge_status=row["candidate_hedge_status"] or "unknown",
   483|        instrument_status=row["candidate_instrument_status"] or "unknown",
   484|        valuation_policy=row["candidate_valuation_policy"] or "live_price",
   485|        evidence_source=row["evidence_source"] or "provider_lookup",
   486|        evidence_note=row["evidence_note"] or "",
   487|    )
   488|
   489|
   490|def recalculate_candidate_rankings(conn: Connection, *, instrument_ids: list[str] | None = None) -> dict[str, int]:
   491|    clauses = []
   492|    params: list[object] = []
   493|    if instrument_ids:
   494|        clauses.append("c.instrument_id IN (" + ",".join("?" for _ in instrument_ids) + ")")
   495|        params.extend(instrument_ids)
   496|    where = " WHERE " + " AND ".join(clauses) if clauses else ""
   497|    rows = conn.execute(
   498|        """
   499|        SELECT c.*, i.asset_class AS instrument_asset_class, i.name AS instrument_name,
   500|               i.ticker AS instrument_ticker, i.exchange AS instrument_exchange,
   501|               i.currency AS instrument_currency, i.trading_currency AS instrument_trading_currency,
   502|               i.hedge_status AS instrument_hedge_status, i.instrument_status AS source_instrument_status
   503|        FROM instrument_price_mapping_candidates c
   504|        JOIN instruments i ON i.instrument_id=c.instrument_id
   505|        """ + where,
   506|        tuple(params),
   507|    ).fetchall()
   508|    counts: dict[str, int] = {}
   509|    for row in rows:
   510|        counts[row["instrument_id"]] = counts.get(row["instrument_id"], 0) + 1
   511|    summary = {"candidates_total": len(rows), "rank_high": 0, "rank_medium": 0, "rank_low": 0, "multiple_listings_same_isin": 0, "currency_mismatch": 0, "hedge_status_unknown": 0, "instrument_status_unknown": 0, "candidate_review_required": 0}
   512|    by_isin: dict[str, set[tuple[str, str]]] = {}
   513|    for row in rows:
   514|        inst = {
   515|            "isin": row["isin"],
   516|            "asset_class": row["instrument_asset_class"],
   517|            "name": row["instrument_name"],
   518|            "ticker": row["instrument_ticker"],
   519|            "exchange": row["instrument_exchange"],
   520|            "currency": row["instrument_currency"],
   521|            "trading_currency": row["instrument_trading_currency"],
   522|        }
   523|        cand = _candidate_from_row(row)
   524|        ranking = rank_candidate(inst, cand, candidate_count=counts[row["instrument_id"]])
   525|        if ranking.ranking_score >= 75:
   526|            summary["rank_high"] += 1
   527|        elif ranking.ranking_score >= 50:
   528|            summary["rank_medium"] += 1
   529|        else:
   530|            summary["rank_low"] += 1
   531|        risk_flags = set(filter(None, ranking.risk_flags.split(",")))
   532|        if "currency_mismatch" in risk_flags:
   533|            summary["currency_mismatch"] += 1
   534|        if (row["candidate_hedge_status"] or "unknown") == "unknown":
   535|            summary["hedge_status_unknown"] += 1
   536|        if (row["candidate_instrument_status"] or "unknown") == "unknown":
   537|            summary["instrument_status_unknown"] += 1
   538|        if row["review_status"] in {"proposed", "needs_manual_review"}:
   539|            summary["candidate_review_required"] += 1
   540|        if row["isin"]:
   541|            by_isin.setdefault(row["isin"], set()).add((row["candidate_exchange"] or "", row["candidate_currency"] or ""))
   542|        conn.execute(
   543|            """
   544|            UPDATE instrument_price_mapping_candidates
   545|            SET ranking_score=?, ranking_reason=?, risk_flags=?, recommended_action=?, updated_at=?
   546|            WHERE candidate_id=?
   547|            """,
   548|            (ranking.ranking_score, ranking.ranking_reason, ranking.risk_flags, ranking.recommended_action, utc_now(), row["candidate_id"]),
   549|        )
   550|    summary["multiple_listings_same_isin"] = sum(1 for listings in by_isin.values() if len(listings) > 1)
   551|    for instrument_id, count in counts.items():
   552|        if count:
   553|            create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id="candidate_review_required", message="Provider-symbol candidates exist but require manual selection before valuation.", evidence={"candidate_count": count}, fingerprint="candidate_review_required")
   554|    conn.commit()
   555|    return summary
   556|
   557|
   558|def dedupe_missing_provider_symbol_alerts(conn: Connection) -> int:
   559|    """Keep one active mapping-quality alert per rule/instrument; resolve duplicate active rows."""
   560|    now = utc_now()
   561|    resolved = 0
   562|    rows = conn.execute(
   563|        """
   564|        SELECT rule_id, entity_id, GROUP_CONCAT(alert_id) AS ids, COUNT(*) AS c
   565|        FROM alerts
   566|        WHERE status='active'
   567|          AND rule_id IN ('missing_provider_symbol','ambiguous_provider_mapping','provider_lookup_unavailable')
   568|          AND entity_type='instrument'
   569|        GROUP BY rule_id, entity_id
   570|        HAVING COUNT(*) > 1
   571|        """
   572|    ).fetchall()
   573|    for row in rows:
   574|        ids = [x for x in (row["ids"] or "").split(",") if x]
   575|        for alert_id in ids[1:]:
   576|            conn.execute("UPDATE alerts SET status='resolved', resolved_at=?, last_seen_at=? WHERE alert_id=?", (now, now, alert_id))
   577|            resolved += 1
   578|    conn.commit()
   579|    return resolved
   580|
   581|
   582|def export_mapping_review_template(conn: Connection, *, output_path: str | Path) -> dict[str, int | str]:
   583|    path = Path(output_path).expanduser().resolve()
   584|    path.parent.mkdir(parents=True, exist_ok=True)
   585|    rows = conn.execute(
   586|        """
   587|        SELECT i.instrument_id, i.asset_class, i.name, i.isin, i.ticker, i.exchange, i.currency,
   588|               c.candidate_provider, c.candidate_provider_symbol, c.candidate_exchange,
   589|               c.candidate_currency, c.confidence, c.ranking_score, c.ranking_reason,
   590|               c.risk_flags, c.recommended_action, c.candidate_hedge_status,
   591|               c.candidate_instrument_status, c.evidence_source, c.evidence_note,
   592|               c.review_status
   593|        FROM instruments i
   594|        LEFT JOIN instrument_price_mapping_candidates c ON c.instrument_id=i.instrument_id
   595|        WHERE i.instrument_id IN (
   596|            SELECT DISTINCT instrument_id FROM transactions
   597|            WHERE transaction_type='initial_position_snapshot'
   598|              AND source_type='broker_import_reviewed_snapshot'
   599|              AND COALESCE(is_voided,0)=0
   600|              AND instrument_id IS NOT NULL
   601|        )
   602|        ORDER BY i.isin, i.name, c.ranking_score DESC, c.confidence, c.candidate_provider
   603|        """
   604|    ).fetchall()
   605|    fields = [
   606|        "instrument_id", "asset_class", "name", "isin", "ticker", "exchange", "currency",
   607|        "candidate_provider", "candidate_provider_symbol", "candidate_exchange", "candidate_currency",
   608|        "confidence", "ranking_score", "ranking_reason", "risk_flags", "recommended_action", "hedge_status_candidate", "instrument_status_candidate", "evidence_source",
   609|        "evidence_note", "review_status", "reviewer_decision", "reviewer_note",
   610|    ]
   611|    with path.open("w", newline="", encoding="utf-8") as handle:
   612|        writer = csv.DictWriter(handle, fieldnames=fields)
   613|        writer.writeheader()
   614|        for row in rows:
   615|            writer.writerow({
   616|                "instrument_id": row["instrument_id"], "asset_class": row["asset_class"], "name": row["name"],
   617|                "isin": row["isin"], "ticker": row["ticker"], "exchange": row["exchange"], "currency": row["currency"],
   618|                "candidate_provider": row["candidate_provider"], "candidate_provider_symbol": row["candidate_provider_symbol"],
   619|                "candidate_exchange": row["candidate_exchange"], "candidate_currency": row["candidate_currency"],
   620|                "confidence": row["confidence"], "ranking_score": row["ranking_score"] or 0,
   621|                "ranking_reason": row["ranking_reason"] or "", "risk_flags": row["risk_flags"] or "",
   622|                "recommended_action": row["recommended_action"] or "needs_manual_review", "hedge_status_candidate": row["candidate_hedge_status"],
   623|                "instrument_status_candidate": row["candidate_instrument_status"], "evidence_source": row["evidence_source"],
   624|                "evidence_note": row["evidence_note"], "review_status": row["review_status"],
   625|                "reviewer_decision": "", "reviewer_note": "",
   626|            })
   627|    return {"output_path": str(path), "rows": len(rows), "candidate_rows": sum(1 for r in rows if r["candidate_provider_symbol"]), "instrument_count": len({r["instrument_id"] for r in rows})}
   628|
   629|
   630|def export_mapping_decision_pack(conn: Connection, *, output_path: str | Path, top_n: int = 5) -> dict[str, int | str | bool]:
   631|    path = Path(output_path).expanduser().resolve()
   632|    path.parent.mkdir(parents=True, exist_ok=True)
   633|    instruments = conn.execute(
   634|        """
   635|        SELECT DISTINCT i.instrument_id, i.name, i.isin, i.ticker, i.exchange, i.currency
   636|        FROM instruments i
   637|        JOIN transactions t ON t.instrument_id=i.instrument_id
   638|        WHERE t.transaction_type='initial_position_snapshot'
   639|          AND t.source_type='broker_import_reviewed_snapshot'
   640|          AND COALESCE(t.is_voided,0)=0
   641|        ORDER BY i.isin, i.name
   642|        """
   643|    ).fetchall()
   644|    lines = ["# Mapping Candidate Decision Pack", "", "Runtime-only review aid. Contains no quantities, values, prices, account balances or portfolio weights.", ""]
   645|    total_candidates = 0
   646|    instruments_with_top = 0
   647|    for inst in instruments:
   648|        candidates = conn.execute(
   649|            """
   650|            SELECT candidate_provider, candidate_provider_symbol, candidate_exchange, candidate_currency,
   651|                   candidate_name, isin, candidate_hedge_status, candidate_instrument_status,
   652|                   confidence, ranking_score, ranking_reason, risk_flags, recommended_action
   653|            FROM instrument_price_mapping_candidates
   654|            WHERE instrument_id=? AND review_status IN ('proposed','needs_manual_review')
   655|            ORDER BY ranking_score DESC, CASE confidence WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, candidate_provider
   656|            """,
   657|            (inst["instrument_id"],),
   658|        ).fetchall()
   659|        total_candidates += len(candidates)
   660|        if candidates:
   661|            instruments_with_top += 1
   662|        lines.extend([f"## Instrument {inst['instrument_id']}", "", f"- Name: {inst['name'] or ''}", f"- ISIN: {inst['isin'] or ''}", f"- Source ticker: {inst['ticker'] or ''}", f"- Source exchange: {inst['exchange'] or ''}", f"- Source currency: {inst['currency'] or ''}", f"- Candidate count: {len(candidates)}", "", "### Top Candidates", ""])
   663|        for idx, row in enumerate(candidates[:top_n], start=1):
   664|            lines.extend([
   665|                f"#### Candidate {idx}",
   666|                f"- Provider: {row['candidate_provider'] or ''}",
   667|                f"- Provider-Symbol: {row['candidate_provider_symbol'] or ''}",
   668|                f"- Name: {row['candidate_name'] or ''}",
   669|                f"- ISIN: {row['isin'] or ''}",
   670|                f"- Exchange: {row['candidate_exchange'] or ''}",
   671|                f"- Währung: {row['candidate_currency'] or ''}",
   672|                f"- Closing/Price Currency: {row['candidate_currency'] or ''}",
   673|                f"- Hedge-Status: {row['candidate_hedge_status'] or 'unknown'}",
   674|                f"- Instrument-Status: {row['candidate_instrument_status'] or 'unknown'}",
   675|                f"- Confidence: {row['confidence'] or ''}",
   676|                f"- Ranking Score: {row['ranking_score'] or 0}",
   677|                f"- Recommended Action: {row['recommended_action'] or 'needs_manual_review'}",
   678|                f"- Ranking Reason: {row['ranking_reason'] or ''}",
   679|                f"- Risk Flags: {row['risk_flags'] or ''}",
   680|                "- Reviewer Decision: ",
   681|                "- Reviewer Note: ",
   682|                "",
   683|            ])
   684|    path.write_text("\n".join(lines), encoding="utf-8")
   685|    return {"output_path": str(path), "instrument_count": len(instruments), "candidate_rows": total_candidates, "instruments_with_top_candidates": instruments_with_top, "top_candidates_present": instruments_with_top == len(instruments) if instruments else False}
   686|
   687|
   688|def select_mapping_candidate(conn: Connection, *, candidate_id: str, note: str, created_by: str = "system") -> str:
   689|    if not note.strip():
   690|        raise ValueError("candidate selection requires a note")
   691|    cand = conn.execute("SELECT * FROM instrument_price_mapping_candidates WHERE candidate_id=?", (candidate_id,)).fetchone()
   692|    if cand is None:
   693|        raise ValueError(f"candidate not found: {candidate_id}")
   694|    mapping_id = confirm_instrument_price_mapping(
   695|        conn,
   696|        instrument_id=cand["instrument_id"],
   697|        provider=cand["candidate_provider"],
   698|        provider_symbol=cand["candidate_provider_symbol"],
   699|        provider_market=cand["candidate_exchange"],
   700|        confidence=cand["confidence"],
   701|        note=note,
   702|        exchange=cand["candidate_exchange"],
   703|        currency=cand["candidate_currency"],
   704|        hedge_status=cand["candidate_hedge_status"] if cand["candidate_hedge_status"] != "unknown" else None,
   705|        hedged_to_currency=cand["candidate_hedged_to_currency"],
   706|        instrument_status=cand["candidate_instrument_status"] if cand["candidate_instrument_status"] != "unknown" else None,
   707|        valuation_policy=cand["candidate_valuation_policy"] or ("live_price" if cand["candidate_instrument_status"] == "active" else None),
   708|        trading_currency=cand["candidate_currency"],
   709|        created_by=created_by,
   710|    )
   711|    now = utc_now()
   712|    try:
   713|        from jarvis_finance.market_data.catalog import CatalogEntryInput, upsert_catalog_entry
   714|        inst = conn.execute("SELECT * FROM instruments WHERE instrument_id=?", (cand["instrument_id"],)).fetchone()
   715|        upsert_catalog_entry(
   716|            conn,
   717|            CatalogEntryInput(
   718|                asset_class=(cand["candidate_asset_class"] or inst["asset_class"] or "equity"),
   719|                name=cand["candidate_name"] or inst["name"],
   720|                isin=cand["isin"] or inst["isin"],
   721|                ticker=inst["ticker"],
   722|                exchange=cand["candidate_exchange"],
   723|                trading_currency=cand["candidate_currency"],
   724|                instrument_currency=cand["candidate_currency"] or inst["currency"],
   725|                provider=cand["candidate_provider"],
   726|                provider_symbol=cand["candidate_provider_symbol"],
   727|                provider_market=cand["candidate_exchange"],
   728|                is_currency_hedged=True if cand["candidate_hedge_status"] == "hedged" else False if cand["candidate_hedge_status"] == "unhedged" else None,
   729|                hedged_to_currency=cand["candidate_hedged_to_currency"],
   730|                hedge_status=cand["candidate_hedge_status"] or "unknown",
   731|                instrument_status=cand["candidate_instrument_status"] or "unknown",
   732|                valuation_policy=cand["candidate_valuation_policy"] or "live_price",
   733|                source="mapping_candidate_review",
   734|                source_confidence=cand["confidence"] or "low",
   735|                notes="Created/updated from confirmed mapping candidate; no portfolio amounts included.",
   736|            ),
   737|            note=note,
   738|            created_by=created_by,
   739|        )
   740|    except Exception:
   741|        # Mapping confirmation already wrote the authoritative mapping; catalog update is best-effort and audited by upsert when it succeeds.
   742|        pass
   743|    conn.execute("UPDATE instrument_price_mapping_candidates SET review_status='selected', updated_at=? WHERE candidate_id=?", (now, candidate_id))
   744|    conn.execute("UPDATE instrument_price_mapping_candidates SET review_status='rejected', updated_at=? WHERE instrument_id=? AND candidate_id<>? AND review_status IN ('proposed','needs_manual_review')", (now, cand["instrument_id"], candidate_id))
   745|    record_audit_event(
   746|        conn,
   747|        source="instrument_price_mapping_candidate",
   748|        action="select_mapping_candidate",
   749|        entity_type="instrument_price_mapping_candidate",
   750|        entity_id=candidate_id,
   751|        new_values={"mapping_id": mapping_id, "instrument_id": cand["instrument_id"], "provider": cand["candidate_provider"], "provider_symbol": cand["candidate_provider_symbol"]},
   752|        user_text_note=note,
   753|        confirmed=True,
   754|        created_by=created_by,
   755|    )
   756|    resolve_instrument_alerts(conn, instrument_id=cand["instrument_id"], rule_ids=["missing_provider_symbol", "ambiguous_provider_mapping", "candidate_review_required"])
   757|    conn.commit()
   758|    return mapping_id
   759|
   760|
   761|def reject_mapping_candidate(conn: Connection, *, candidate_id: str, note: str, created_by: str = "system") -> None:
   762|    if not note.strip():
   763|        raise ValueError("candidate rejection requires a note")
   764|    cand = conn.execute("SELECT * FROM instrument_price_mapping_candidates WHERE candidate_id=?", (candidate_id,)).fetchone()
   765|    if cand is None:
   766|        raise ValueError(f"candidate not found: {candidate_id}")
   767|    now = utc_now()
   768|    conn.execute("UPDATE instrument_price_mapping_candidates SET review_status='rejected', updated_at=? WHERE candidate_id=?", (now, candidate_id))
   769|    record_audit_event(
   770|        conn,
   771|        source="instrument_price_mapping_candidate",
   772|        action="reject_mapping_candidate",
   773|        entity_type="instrument_price_mapping_candidate",
   774|        entity_id=candidate_id,
   775|        old_values={"review_status": cand["review_status"]},
   776|        new_values={"review_status": "rejected"},
   777|        user_text_note=note,
   778|        confirmed=True,
   779|        created_by=created_by,
   780|    )
   781|    conn.commit()
   782|
   783|
   784|def get_mapping_candidate_summary(conn: Connection) -> dict[str, int]:
   785|    rows = conn.execute("SELECT confidence, COUNT(*) AS c FROM instrument_price_mapping_candidates GROUP BY confidence").fetchall()
   786|    out = {"total": 0, "high": 0, "medium": 0, "low": 0}
   787|    for row in rows:
   788|        key = row["confidence"] or "low"
   789|        if key in out:
   790|            out[key] += int(row["c"] or 0)
   791|        out["total"] += int(row["c"] or 0)
   792|    return out
   793|


     1|from __future__ import annotations
     2|
     3|from decimal import Decimal
     4|
     5|from jarvis_finance.dashboard import data
     6|from jarvis_finance.dashboard.components.tables import show_table
     7|from jarvis_finance.equity.manage import add_equity_initial_snapshot, add_equity_transaction, add_manual_position_from_catalog, create_manual_catalog_entry, get_equity_management_options
     8|from jarvis_finance.fx.overrides import set_manual_fx_override
     9|from jarvis_finance.market_data.candidates import reject_mapping_candidate, select_mapping_candidate
    10|
    11|PAGE_TITLE = "Equity/ETF Manage"
    12|
    13|
    14|def _account_label(row: dict[str, str]) -> str:
    15|    return f"{row['platform_name']} / {row['account_name']} [{row['currency']} ]"
    16|
    17|
    18|def _instrument_label(row: dict[str, str]) -> str:
    19|    suffix = row.get("isin") or row.get("ticker") or row["instrument_id"]
    20|    return f"{row['name']} ({suffix})"
    21|
    22|
    23|def _selected(rows: list[dict[str, str]], label: str | None, label_fn) -> dict[str, str] | None:
    24|    if not label:
    25|        return None
    26|    for row in rows:
    27|        if label_fn(row) == label:
    28|            return row
    29|    return None
    30|
    31|
    32|def render(st, conn) -> None:
    33|    st.title("Equity/ETF Manage")
    34|    st.caption("Auditierbare manuelle Aktien-/ETF-Erfassung. Dashboard liest lokal; keine Broker-Dateien oder Live-APIs beim Rendern.")
    35|    st.warning("Keine stillen Überschreibungen: Initial Snapshots, Transaktionen und Korrekturen werden als bestätigte Ledger-Ereignisse mit Audit-Log gespeichert.")
    36|
    37|    options = get_equity_management_options(conn)
    38|    account_labels = [_account_label(row) for row in options["accounts"]]
    39|    instrument_labels = [_instrument_label(row) for row in options["instruments"]]
    40|    tabs = st.tabs(["Instrument Search / Add Position", "Hinzufügen", "Kaufen", "Partial Sell", "Full Sell", "Dividende", "ETF-Ausschüttung", "Fee", "Korrigieren", "Review"])
    41|
    42|    with tabs[0]:
    43|        st.subheader("Instrument Search / Add Position")
    44|        st.caption("Lokaler Katalog zuerst; externe Lookups nur über freigegebene Provider. Mehrere Listings bleiben Auswahlpflicht.")
    45|        summary = data.get_instrument_catalog_summary(conn)
    46|        st.write(summary)
    47|        query = st.text_input("Suche nach ISIN, Symbol, Name, Provider-Symbol, Exchange oder Currency", key="catalog_search_query")
    48|        class_filter = st.selectbox("Filter", ["", "equity", "etf", "crypto", "cash"], key="catalog_search_class")
    49|        catalog_rows = data.get_instrument_catalog_entries(conn, query=query or None, asset_class=class_filter or None)
    50|        show_table(st, catalog_rows)
    51|        st.divider()
    52|        st.subheader("Katalogeintrag manuell anlegen")
    53|        c_asset = st.selectbox("Assetklasse", ["equity", "etf"], key="catalog_new_asset")
    54|        c_name = st.text_input("Name", key="catalog_new_name")
    55|        c_isin = st.text_input("ISIN", key="catalog_new_isin")
    56|        c_ticker = st.text_input("Ticker/Symbol", key="catalog_new_ticker")
    57|        c_exchange = st.text_input("Exchange", key="catalog_new_exchange")
    58|        c_currency = st.selectbox("Trading/Instrument Currency", ["CHF", "EUR", "USD"], key="catalog_new_currency")
    59|        c_provider = st.text_input("Provider", value="manual", key="catalog_new_provider")
    60|        c_provider_symbol = st.text_input("Provider-Symbol", key="catalog_new_provider_symbol")
    61|        c_hedge = st.selectbox("Hedge Status", ["unknown", "hedged", "unhedged"], key="catalog_new_hedge")
    62|        c_status = st.selectbox("Instrument Status", ["unknown", "active", "suspended", "delisted", "merged", "inactive"], key="catalog_new_status")
    63|        c_policy = st.selectbox("Valuation Policy", ["live_price", "last_known_price", "manual_value", "exclude_from_auto_price_update"], key="catalog_new_policy")
    64|        c_note = st.text_area("Katalog-Notiz (Pflicht)", key="catalog_new_note")
    65|        if st.button("Katalogeintrag speichern", key="catalog_new_save"):
    66|            try:
    67|                cid = create_manual_catalog_entry(conn, asset_class=c_asset, name=c_name, isin=c_isin, ticker=c_ticker, exchange=c_exchange, currency=c_currency, provider=c_provider, provider_symbol=c_provider_symbol, hedge_status=c_hedge, instrument_status=c_status, valuation_policy=c_policy, note=c_note)
    68|                st.success("Katalogeintrag gespeichert; Audit-Log geschrieben.")
    69|                st.write({"catalog_entry_id": cid})
    70|            except Exception as exc:
    71|                st.error(f"Katalogspeicherung abgelehnt: {exc}")
    72|        st.divider()
    73|        st.subheader("Position aus Katalog hinzufügen")
    74|        entry_labels = [f"{r.get('catalog_entry_id')} | {r.get('ticker') or ''} | {r.get('name')} | {r.get('isin') or ''} | {r.get('exchange') or ''} | {r.get('trading_currency') or ''}" for r in catalog_rows]
    75|        entry_label = st.selectbox("Katalog-Kandidat auswählen", entry_labels, key="catalog_position_entry") if entry_labels else None
    76|        pos_account_label = st.selectbox("Depot/Konto", account_labels, key="catalog_position_account") if account_labels else None
    77|        pos_type = st.selectbox("Positionstyp", ["initial_snapshot", "buy", "manual_adjustment"], key="catalog_position_type")
    78|        pos_qty = st.text_input("Menge als Decimal-Text", key="catalog_position_qty")
    79|        pos_date = st.date_input("Datum", key="catalog_position_date")
    80|        pos_currency = st.selectbox("Währung", ["CHF", "EUR", "USD"], key="catalog_position_currency")
    81|        pos_cost = st.text_input("Einstandspreis / Cost Basis optional", key="catalog_position_cost")
    82|        pos_fx_status = st.selectbox("FX-Status", ["not_needed", "missing", "manual_override", "manual_override_required"], key="catalog_position_fx_status")
    83|        pos_fx_rate = st.text_input("FX-Rate optional", key="catalog_position_fx_rate")
    84|        pos_fx_source = st.text_input("FX-Quelle optional", key="catalog_position_fx_source")
    85|        pos_note = st.text_area("Review-/Bestätigungsnotiz", key="catalog_position_note")
    86|        pos_confirm = st.checkbox("Ich bestätige diese einzelne Katalog-Position", key="catalog_position_confirm")
    87|        selected_pos_account = _selected(options["accounts"], pos_account_label, _account_label)
    88|        if st.button("Position aus Katalog speichern", key="catalog_position_save", type="primary"):
    89|            try:
    90|                if not entry_label or not selected_pos_account:
    91|                    raise ValueError("catalog entry and account are required")
    92|                result = add_manual_position_from_catalog(conn, catalog_entry_id=entry_label.split(" | ", 1)[0], account_id=selected_pos_account["account_id"], position_type=pos_type, quantity_text=pos_qty, trade_date=str(pos_date), currency=pos_currency, cost_basis_original_text=pos_cost or None, fx_status=pos_fx_status, fx_rate_to_chf_text=pos_fx_rate or None, fx_source=pos_fx_source or None, note=pos_note, confirm=bool(pos_confirm))
    93|                st.success("Position gespeichert und auditierbar erfasst.")
    94|                st.write({"instrument_id": result.instrument_id, "transaction_id": result.transaction_id, "warnings": result.warnings})
    95|            except Exception as exc:
    96|                st.error(f"Positionsspeicherung abgelehnt: {exc}")
    97|
    98|    with tabs[1]:
    99|        st.subheader("Aktie/ETF als Initial Snapshot hinzufügen")
   100|        account_label = st.selectbox("Depot/Konto", account_labels, key="equity_add_account") if account_labels else None
   101|        asset_class = st.selectbox("Assetklasse", ["stock", "ETF"], key="equity_add_class")
   102|        name = st.text_input("Name", key="equity_add_name")
   103|        isin = st.text_input("ISIN (primäre Identifikation)", key="equity_add_isin")
   104|        ticker = st.text_input("Ticker optional", key="equity_add_ticker")
   105|        exchange = st.text_input("Exchange optional", key="equity_add_exchange")
   106|        currency = st.selectbox("Währung", ["CHF", "EUR", "USD"], key="equity_add_currency")
   107|        quantity = st.text_input("Menge als Decimal-Text", key="equity_add_qty")
   108|        cost_basis = st.text_input("Einstandspreis / Cost Basis Originalwährung", key="equity_add_cost")
   109|        fx_rate = st.text_input("FX-Kurs zu CHF (bei EUR/USD)", key="equity_add_fx")
   110|        fx_source = st.text_input("FX-Quelle", key="equity_add_fx_source")
   111|        fx_status = st.selectbox("FX-Status", ["ok", "missing"], key="equity_add_fx_status")
   112|        snapshot_date = st.date_input("Snapshot-Datum", key="equity_add_date")
   113|        category = st.selectbox("Kategorie", ["Core", "Opportunity", "Unknown"], key="equity_add_category")
   114|        note = st.text_area("Notiz (Pflicht bei unvollständiger Historie)", key="equity_add_note")
   115|        with st.expander("ETF-spezifische Felder"):
   116|            ter = st.text_input("TER optional", key="equity_add_ter")
   117|            distribution_policy = st.text_input("ausschüttend/thesaurierend optional", key="equity_add_distribution")
   118|            index_name = st.text_input("Index optional", key="equity_add_index")
   119|            fund_domicile = st.text_input("Fondsdomizil optional", key="equity_add_domicile")
   120|            benchmark = st.text_input("Benchmark optional", key="equity_add_benchmark")
   121|        confirm = st.checkbox("Ich bestätige diese einzelne Änderung", key="equity_add_confirm")
   122|        selected_account = _selected(options["accounts"], account_label, _account_label)
   123|        st.write({"Aktion": "initial_position_snapshot", "Name": name, "ISIN": isin or "<fehlt>", "Ticker": ticker or "<fehlt>", "Währung": currency, "Datum": str(snapshot_date), "Hinweis": "Keine Massenänderung; Speicherung erst nach Bestätigung."})
   124|        if st.button("Bestätigt speichern", key="equity_add_save", type="primary"):
   125|            try:
   126|                if not selected_account:
   127|                    raise ValueError("account is required")
   128|                result = add_equity_initial_snapshot(
   129|                    conn,
   130|                    account_id=selected_account["account_id"],
   131|                    asset_class=asset_class,
   132|                    name=name,
   133|                    isin=isin,
   134|                    ticker=ticker,
   135|                    exchange=exchange,
   136|                    currency=currency,
   137|                    quantity_text=quantity,
   138|                    cost_basis_original_text=cost_basis,
   139|                    snapshot_date=str(snapshot_date),
   140|                    category=category,
   141|                    note=note,
   142|                    fx_rate_to_chf_text=fx_rate or None,
   143|                    fx_source=fx_source or None,
   144|                    fx_status=fx_status,
   145|                    ter_text=ter or None,
   146|                    distribution_policy=distribution_policy or None,
   147|                    index_name=index_name or None,
   148|                    fund_domicile=fund_domicile or None,
   149|                    benchmark=benchmark or None,
   150|                    confirm=bool(confirm),
   151|                )
   152|                st.success("Initial Snapshot gespeichert und auditierbar erfasst.")
   153|                st.write({"instrument_id": result.instrument_id, "transaction_id": result.transaction_id, "warnings": result.warnings})
   154|            except Exception as exc:
   155|                st.error(f"Speichern abgelehnt: {exc}")
   156|
   157|    def transaction_form(tab, title: str, tx_type: str) -> None:
   158|        with tab:
   159|            st.subheader(title)
   160|            account_label = st.selectbox("Depot/Konto", account_labels, key=f"equity_{tx_type}_account") if account_labels else None
   161|            instrument_label = st.selectbox("Instrument", instrument_labels, key=f"equity_{tx_type}_instrument") if instrument_labels else None
   162|            date_value = st.date_input("Datum", key=f"equity_{tx_type}_date")
   163|            qty_default = "0" if tx_type in {"dividend", "etf_distribution", "fee"} else ""
   164|            quantity = st.text_input("Menge als Decimal-Text", value=qty_default, key=f"equity_{tx_type}_qty")
   165|            gross = st.text_input("Brutto/Cost/Proceeds Originalwährung", key=f"equity_{tx_type}_gross")
   166|            currency = st.selectbox("Währung", ["CHF", "EUR", "USD"], key=f"equity_{tx_type}_currency")
   167|            fx_rate = st.text_input("FX-Kurs zu CHF (bei EUR/USD)", key=f"equity_{tx_type}_fx")
   168|            fx_source = st.text_input("FX-Quelle", key=f"equity_{tx_type}_fx_source")
   169|            fx_status = st.selectbox("FX-Status", ["ok", "missing"], key=f"equity_{tx_type}_fx_status")
   170|            note = st.text_area("Notiz / Grund", key=f"equity_{tx_type}_note")
   171|            confirm = st.checkbox("Ich bestätige diese Änderung", key=f"equity_{tx_type}_confirm")
   172|            selected_account = _selected(options["accounts"], account_label, _account_label)
   173|            selected_instrument = _selected(options["instruments"], instrument_label, _instrument_label)
   174|            st.write({"Aktion": tx_type, "Instrument": instrument_label, "Datum": str(date_value), "FX-Status": fx_status})
   175|            if st.button("Bestätigt speichern", key=f"equity_{tx_type}_save"):
   176|                try:
   177|                    if not selected_account or not selected_instrument:
   178|                        raise ValueError("account and instrument are required")
   179|                    result = add_equity_transaction(
   180|                        conn,
   181|                        account_id=selected_account["account_id"],
   182|                        instrument_id=selected_instrument["instrument_id"],
   183|                        transaction_type=tx_type,
   184|                        trade_date=str(date_value),
   185|                        quantity_text=quantity,
   186|                        gross_amount_original_text=gross,
   187|                        currency=currency,
   188|                        fx_rate_to_chf_text=fx_rate or None,
   189|                        fx_source=fx_source or None,
   190|                        fx_status=fx_status,
   191|                        note=note,
   192|                        confirm=bool(confirm),
   193|                    )
   194|                    st.success("Transaktion gespeichert und auditierbar erfasst.")
   195|                    st.write({"transaction_id": result.transaction_id, "warnings": result.warnings})
   196|                except Exception as exc:
   197|                    st.error(f"Speichern abgelehnt: {exc}")
   198|
   199|    transaction_form(tabs[2], "Kauf erfassen", "buy")
   200|    transaction_form(tabs[3], "Teilverkauf erfassen", "partial_sell")
   201|    transaction_form(tabs[4], "Vollverkauf erfassen", "full_sell")
   202|    transaction_form(tabs[5], "Dividende erfassen", "dividend")
   203|    transaction_form(tabs[6], "ETF-Ausschüttung erfassen", "etf_distribution")
   204|    transaction_form(tabs[7], "Fee erfassen", "fee")
   205|    transaction_form(tabs[8], "Korrektur als manual_adjustment", "manual_adjustment")
   206|
   207|    with tabs[9]:
   208|        st.subheader("Aktuelle Equity/ETF Übersicht")
   209|        show_table(st, data.get_positions(conn))
   210|        st.divider()
   211|        st.subheader("Instrument Price Mapping Review")
   212|        st.caption("Runtime-Review: Kandidaten ansehen und manuell bestätigen. Keine Live-API und keine Positionsänderung beim Rendern.")
   213|        review_rows = data.get_instrument_mapping_review(conn)
   214|        show_table(st, review_rows)
   215|        candidates = data.get_instrument_mapping_candidates(conn)
   216|        show_table(st, candidates)
   217|        candidate_labels = [f"{c['candidate_id']} | {c['candidate_provider']} | {c['candidate_provider_symbol']} | {c['candidate_exchange']} | {c['candidate_currency']} | {c['confidence']} | {c['review_status']}" for c in candidates]
   218|        selected_candidate_label = st.selectbox("Mapping-Kandidat auswählen", candidate_labels, key="mapping_candidate_select") if candidate_labels else None
   219|        mapping_note = st.text_area("Bestätigungsnotiz für Mapping-Auswahl", key="mapping_candidate_note")
   220|        if st.button("Ausgewählten Mapping-Kandidaten bestätigen", key="mapping_candidate_confirm"):
   221|            try:
   222|                if not selected_candidate_label:
   223|                    raise ValueError("candidate is required")
   224|                candidate_id = selected_candidate_label.split(" | ", 1)[0]
   225|                mapping_id = select_mapping_candidate(conn, candidate_id=candidate_id, note=mapping_note, created_by="dashboard")
   226|                st.success("Mapping-Kandidat bestätigt; Audit-Log geschrieben.")
   227|                st.write({"mapping_id": mapping_id})
   228|            except Exception as exc:
   229|                st.error(f"Mapping-Auswahl abgelehnt: {exc}")
   230|        if st.button("Ausgewählten Mapping-Kandidaten ablehnen", key="mapping_candidate_reject"):
   231|            try:
   232|                if not selected_candidate_label:
   233|                    raise ValueError("candidate is required")
   234|                candidate_id = selected_candidate_label.split(" | ", 1)[0]
   235|                reject_mapping_candidate(conn, candidate_id=candidate_id, note=mapping_note, created_by="dashboard")
   236|                st.success("Mapping-Kandidat abgelehnt; Audit-Log geschrieben.")
   237|            except Exception as exc:
   238|                st.error(f"Mapping-Ablehnung abgelehnt: {exc}")
   239|        st.caption("Manuell erfassen: neuen Katalogeintrag im Tab 'Instrument Search / Add Position' speichern. Keine Portfolio-Transaktion wird durch Mapping Review geändert.")
   240|        st.divider()
   241|        st.subheader("FX Override Review")
   242|        st.caption("Historische FX-Rates nur manuell mit Quelle/Notiz erfassen. Keine Rates ohne bewusste Speicherung.")
   243|        fx_rows = data.get_fx_override_requirements(conn)
   244|        show_table(st, fx_rows)
   245|        fx_labels = [f"{r['base_currency']}/{r['quote_currency']} {r['rate_date']} ({r['fx_status']})" for r in fx_rows]
   246|        fx_label = st.selectbox("FX-Paar/Datum", fx_labels, key="fx_override_select") if fx_labels else None
   247|        fx_rate = st.text_input("FX-Rate zu CHF", key="fx_override_rate")
   248|        fx_note = st.text_area("FX-Quelle/Notiz (Pflicht)", key="fx_override_note")
   249|        if st.button("Manual FX Override speichern", key="fx_override_save"):
   250|            try:
   251|                if not fx_label:
   252|                    raise ValueError("fx pair/date is required")
   253|                pair, date_part = fx_label.split(" ", 1)
   254|                base, quote = pair.split("/", 1)
   255|                rate_date = date_part.split(" ", 1)[0]
   256|                set_manual_fx_override(conn, base_currency=base, quote_currency=quote, rate_date=rate_date, rate=Decimal(fx_rate), note=fx_note, created_by="dashboard")
   257|                st.success("Manual FX Override gespeichert und auditierbar erfasst.")
   258|            except Exception as exc:
   259|                st.error(f"FX Override abgelehnt: {exc}")
   260|


     1|from __future__ import annotations
     2|
     3|import json
     4|from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
     5|from pathlib import Path
     6|from sqlite3 import Connection
     7|
     8|from jarvis_finance.config.settings import Settings
     9|from jarvis_finance.crypto.holdings import calculate_crypto_holdings
    10|from jarvis_finance.ledger.cash import calculate_cash_balances
    11|from jarvis_finance.ledger.positions import calculate_positions
    12|from jarvis_finance.market.providers import is_price_stale
    13|
    14|MONEY = Decimal("0.01")
    15|
    16|
    17|def d(value: object, default: str = "0") -> Decimal:
    18|    if value is None or str(value).strip() == "":
    19|        return Decimal(default)
    20|    try:
    21|        return Decimal(str(value))
    22|    except InvalidOperation:
    23|        return Decimal(default)
    24|
    25|
    26|def dec_text(value: Decimal | int | str | None, places: str | None = None) -> str:
    27|    if value is None:
    28|        return ""
    29|    value = d(value)
    30|    if places:
    31|        value = value.quantize(Decimal(places), rounding=ROUND_HALF_UP)
    32|    return format(value, "f")
    33|
    34|
    35|def money_text(value: Decimal | None, currency: str = "CHF") -> str:
    36|    if value is None:
    37|        return f"n/a {currency}"
    38|    return f"{dec_text(value, '0.01')} {currency}"
    39|
    40|
    41|def rowdicts(rows) -> list[dict[str, str]]:
    42|    return [{k: ("" if row[k] is None else str(row[k])) for k in row.keys()} for row in rows]
    43|
    44|
    45|def db_exists(path: str | Path) -> bool:
    46|    return Path(path).expanduser().exists()
    47|
    48|
    49|def runtime_outside_repo(settings: Settings, repo_root: Path) -> bool:
    50|    try:
    51|        settings.runtime_paths.base_dir.resolve().relative_to(repo_root.resolve())
    52|        return False
    53|    except ValueError:
    54|        return True
    55|
    56|
    57|def latest_crypto_price(conn: Connection, asset_id: str, currency: str = "CHF", *, max_age_seconds: int = 86_400) -> dict[str, str] | None:
    58|    row = conn.execute(
    59|        """
    60|        SELECT price, price_currency, fetched_at, provider_timestamp, quality_status, provider
    61|        FROM crypto_prices
    62|        WHERE asset_id=? AND price_currency=?
    63|        ORDER BY COALESCE(provider_timestamp, fetched_at, '') DESC, fetched_at DESC LIMIT 1
    64|        """,
    65|        (asset_id, currency.upper()),
    66|    ).fetchone()
    67|    if not row:
    68|        return None
    69|    item = dict(row)
    70|    ts = item.get("provider_timestamp") or item.get("fetched_at")
    71|    if item.get("quality_status") == "fresh" and is_price_stale(ts, max_age_seconds=max_age_seconds):
    72|        item["quality_status"] = "stale"
    73|    return item
    74|
    75|
    76|def get_crypto_overview(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
    77|    holdings = calculate_crypto_holdings(conn)
    78|    rows: list[dict[str, str]] = []
    79|    for asset_id, holding in sorted(holdings.total_by_asset.items()):
    80|        qty = holding.quantity
    81|        asset = conn.execute("SELECT * FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
    82|        if not asset:
    83|            continue
    84|        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
    85|        price_dec = d(price["price"]) if price and price["price"] else None
    86|        value = qty * price_dec if price_dec is not None else None
    87|        rows.append({
    88|            "asset_id": asset_id,
    89|            "coin_name": asset["coin_name"],
    90|            "symbol": asset["symbol"],
    91|            "coingecko_id": asset["coingecko_id"] or "",
    92|            "quantity": dec_text(qty),
    93|            "latest_price_chf": dec_text(price_dec, "0.01") if price_dec is not None else "",
    94|            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
    95|            "price_quality": price["quality_status"] if price else "missing",
    96|            "fetched_at": price["fetched_at"] if price else "",
    97|        })
    98|    return rows
    99|
   100|
   101|def get_crypto_by_wallet(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
   102|    holdings = calculate_crypto_holdings(conn)
   103|    rows: list[dict[str, str]] = []
   104|    for (wallet_id, asset_id), holding in sorted(holdings.wallet_holdings.items()):
   105|        wallet = conn.execute("SELECT wallet_name, wallet_type, platform_provider FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
   106|        asset = conn.execute("SELECT coin_name, symbol, coingecko_id FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
   107|        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
   108|        price_dec = d(price["price"]) if price and price["price"] else None
   109|        value = holding.quantity * price_dec if price_dec is not None else None
   110|        rows.append({
   111|            "wallet_id": wallet_id,
   112|            "wallet_name": wallet["wallet_name"] if wallet else wallet_id,
   113|            "wallet_type": wallet["wallet_type"] if wallet else "",
   114|            "provider": wallet["platform_provider"] if wallet else "",
   115|            "coin_name": asset["coin_name"] if asset else asset_id,
   116|            "symbol": asset["symbol"] if asset else "",
   117|            "coingecko_id": asset["coingecko_id"] if asset else "",
   118|            "quantity": dec_text(holding.quantity),
   119|            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
   120|            "wallet_value_chf": dec_text(value, "0.01") if value is not None else "",
   121|            "price_quality": price["quality_status"] if price else "missing",
   122|            "price_fetched_at": price["fetched_at"] if price else "",
   123|            "verification_status": holding.verification_status,
   124|            "last_verified_at": holding.last_verified_at or "",
   125|        })
   126|    return rows
   127|
   128|
   129|def get_wallets(conn: Connection) -> list[dict[str, str]]:
   130|    return rowdicts(conn.execute(
   131|        """
   132|        SELECT wallet_id, wallet_name, wallet_type, platform_provider AS provider,
   133|               network_chain, last_verified_at, is_active, notes
   134|        FROM crypto_wallets ORDER BY wallet_name
   135|        """
   136|    ).fetchall())
   137|
   138|
   139|def get_latest_crypto_transactions(conn: Connection, limit: int = 20) -> list[dict[str, str]]:
   140|    return rowdicts(conn.execute(
   141|        """
   142|        SELECT ct.transaction_datetime, ct.transaction_type, ca.symbol, ct.quantity, ct.fee_quantity,
   143|               fw.wallet_name AS from_wallet, tw.wallet_name AS to_wallet, ct.currency_original,
   144|               ct.amount_chf, ct.confirmation_status, ct.source
   145|        FROM crypto_transactions ct
   146|        LEFT JOIN crypto_assets ca ON ca.asset_id=ct.asset_id
   147|        LEFT JOIN crypto_wallets fw ON fw.wallet_id=ct.from_wallet_id
   148|        LEFT JOIN crypto_wallets tw ON tw.wallet_id=ct.to_wallet_id
   149|        ORDER BY ct.transaction_datetime DESC, ct.created_at DESC LIMIT ?
   150|        """, (limit,)
   151|    ).fetchall())
   152|
   153|
   154|def get_platform_overview(conn: Connection) -> list[dict[str, str]]:
   155|    return rowdicts(conn.execute(
   156|        """
   157|        SELECT p.name AS platform, p.platform_type, a.account_name, a.account_type, a.currency,
   158|               a.performance_included, a.is_active
   159|        FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
   160|        ORDER BY p.name, a.account_name
   161|        """
   162|    ).fetchall())
   163|
   164|
   165|def get_cash_overview(conn: Connection) -> list[dict[str, str]]:
   166|    result = calculate_cash_balances(conn)
   167|    rows: list[dict[str, str]] = []
   168|    for (account_id, currency), bal in sorted(result.balances.items()):
   169|        row = conn.execute(
   170|            "SELECT a.account_name, p.name AS platform FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id WHERE a.account_id=?",
   171|            (account_id,),
   172|        ).fetchone()
   173|        rows.append({
   174|            "platform": row["platform"] if row else "",
   175|            "account_name": row["account_name"] if row else account_id,
   176|            "currency": currency,
   177|            "amount_original": dec_text(bal.amount_original, "0.01"),
   178|            "amount_chf": dec_text(bal.amount_chf, "0.01") if bal.amount_chf is not None else "",
   179|            "quality_status": bal.quality_status,
   180|        })
   181|    return rows
   182|
   183|
   184|def get_positions(conn: Connection) -> list[dict[str, str]]:
   185|    result = calculate_positions(conn)
   186|    rows: list[dict[str, str]] = []
   187|    for (account_id, instrument_id), pos in sorted(result.positions.items()):
   188|        row = conn.execute(
   189|            """
   190|            SELECT a.account_name, p.name AS platform, i.name, i.ticker, i.isin, i.asset_class, i.currency,
   191|                   i.hedge_status, i.instrument_status, i.valuation_policy, i.corporate_action_status
   192|            FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
   193|            JOIN instruments i ON i.instrument_id=? WHERE a.account_id=?
   194|            """,
   195|            (instrument_id, account_id),
   196|        ).fetchone()
   197|        rows.append({
   198|            "platform": row["platform"] if row else "",
   199|            "account_name": row["account_name"] if row else account_id,
   200|            "asset_class": row["asset_class"] if row else "",
   201|            "name": row["name"] if row else instrument_id,
   202|            "ticker": row["ticker"] if row else "",
   203|            "isin": row["isin"] if row else "",
   204|            "quantity": dec_text(pos.quantity),
   205|            "cost_basis_chf": dec_text(pos.cost_basis_chf, "0.01"),
   206|            "market_value_chf": dec_text(pos.market_value_chf, "0.01") if pos.market_value_chf is not None else "",
   207|            "price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
   208|            "provider_symbol_status": "mapped" if conn.execute("SELECT 1 FROM instrument_price_mappings WHERE instrument_id=? AND mapping_status='mapped' AND provider_symbol IS NOT NULL LIMIT 1", (instrument_id,)).fetchone() else "missing_provider_symbol",
   209|            "hedge_status": row["hedge_status"] if row else pos.hedge_status,
   210|            "instrument_status": row["instrument_status"] if row else pos.instrument_status,
   211|            "valuation_policy": row["valuation_policy"] if row else pos.valuation_policy,
   212|            "fx_status": "ok" if "missing_fx" not in pos.quality_warnings else "missing_fx",
   213|            "market_price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
   214|            "valuation_timestamp_status": pos.valuation_timestamp_status,
   215|            "corporate_action_status": row["corporate_action_status"] if row else pos.corporate_action_status,
   216|            "valuation_status": pos.valuation_status,
   217|            "total_return_chf": dec_text(pos.total_return_chf, "0.01") if pos.total_return_chf is not None else "",
   218|            "total_return_status": "complete" if pos.total_return_chf is not None else "incomplete",
   219|            "quality_warnings": ",".join(pos.quality_warnings),
   220|            "data_quality_status": pos.data_quality_status,
   221|        })
   222|    return rows
   223|
   224|
   225|def get_ledger_transactions(conn: Connection, limit: int = 200) -> list[dict[str, str]]:
   226|    return rowdicts(conn.execute(
   227|        """
   228|        SELECT t.trade_date, p.name AS platform, a.account_name, t.transaction_type,
   229|               i.name AS instrument, i.ticker, t.quantity, t.gross_amount_original,
   230|               t.net_amount_original, t.currency_original, t.fx_status, t.quality_status,
   231|               t.source_type, t.source_id
   232|        FROM transactions t
   233|        LEFT JOIN accounts a ON a.account_id=t.account_id
   234|        LEFT JOIN platforms p ON p.platform_id=a.platform_id
   235|        LEFT JOIN instruments i ON i.instrument_id=t.instrument_id
   236|        ORDER BY t.trade_date DESC, t.created_at DESC LIMIT ?
   237|        """, (limit,)
   238|    ).fetchall())
   239|
   240|
   241|def get_watchlist(conn: Connection) -> list[dict[str, str]]:
   242|    return rowdicts(conn.execute("SELECT name, asset_class, reason, status, target_entry_price, target_entry_currency, investment_case, bear_case, next_review_date FROM watchlist ORDER BY name").fetchall())
   243|
   244|
   245|def get_alerts(conn: Connection) -> list[dict[str, str]]:
   246|    return rowdicts(conn.execute(
   247|        """
   248|        SELECT priority, status, category, entity_type, entity_id, rule_id, message,
   249|               occurrence_count, last_seen_at, resolved_at, muted_until, fingerprint, dedup_key, created_at
   250|        FROM alerts
   251|        ORDER BY CASE priority WHEN 'kritisch' THEN 0 WHEN 'warnung' THEN 1 ELSE 2 END, status, created_at DESC
   252|        """
   253|    ).fetchall())
   254|
   255|
   256|def get_audit_events(conn: Connection, limit: int = 200) -> list[dict[str, str]]:
   257|    rows = conn.execute(
   258|        """
   259|        SELECT timestamp, source, action, entity_type, entity_id, old_values_json, new_values_json,
   260|               confirmed, created_by, quality_status
   261|        FROM audit_log ORDER BY timestamp DESC LIMIT ?
   262|        """, (limit,)
   263|    ).fetchall()
   264|    out: list[dict[str, str]] = []
   265|    for row in rows:
   266|        item = {k: ("" if row[k] is None else str(row[k])) for k in row.keys()}
   267|        for key in ("old_values_json", "new_values_json"):
   268|            if item[key]:
   269|                try:
   270|                    item[key] = json.dumps(json.loads(item[key]), ensure_ascii=False, indent=2, sort_keys=True)
   271|                except json.JSONDecodeError:
   272|                    pass
   273|        out.append(item)
   274|    return out
   275|
   276|
   277|def get_data_quality(conn: Connection) -> list[dict[str, str]]:
   278|    items: list[dict[str, str]] = []
   279|    checks = [
   280|        ("fehlende FX", "SELECT COUNT(*) AS c FROM transactions WHERE fx_status='missing'", "kritisch"),
   281|        ("fehlende/stale FX Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('missing_fx','stale_fx','historical_fx_unavailable','manual_override_required')", "kritisch"),
   282|        ("historical FX unavailable", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='historical_fx_unavailable'", "kritisch"),
   283|        ("manual override required", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='manual_override_required'", "kritisch"),
   284|        ("fehlende Provider-Symbole", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='missing_provider_symbol'", "warnung"),
   285|        ("provider lookup unavailable", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='provider_lookup_unavailable'", "warnung"),
   286|        ("ambiguous provider mapping", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='ambiguous_provider_mapping'", "warnung"),
   287|        ("instrument status unknown", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='instrument_status_unknown'", "warnung"),
   288|        ("manual FX override required", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('manual_override_required','manual_fx_override_required')", "kritisch"),
   289|        ("fehlende Market Prices", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='missing_market_price'", "warnung"),
   290|        ("stale Market Prices", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='stale_market_price'", "warnung"),
   291|        ("ambiguous Instrument Mapping", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='ambiguous_instrument_mapping'", "warnung"),
   292|        ("price currency mismatch", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='price_currency_mismatch'", "warnung"),
   293|        ("hedge status unknown", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='hedge_status_unknown'", "warnung"),
   294|        ("stale valuation", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='stale_valuation'", "warnung"),
   295|        ("corporate action suspected", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='corporate_action_suspected'", "warnung"),
   296|        ("split/corporate action review", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='split_or_corporate_action_review_required'", "warnung"),
   297|        ("delisted/suspended", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='delisted_or_suspended'", "warnung"),
   298|        ("snapshot_only Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='snapshot_only'", "info"),
   299|        ("cost_basis_uncertain Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='cost_basis_uncertain'", "warnung"),
   300|        ("fehlende CoinGecko-ID", "SELECT COUNT(*) AS c FROM crypto_assets WHERE coingecko_id IS NULL OR coingecko_id='' ", "warnung"),
   301|        ("fehlende Crypto-Preise", "SELECT COUNT(*) AS c FROM crypto_assets ca LEFT JOIN crypto_prices cp ON cp.asset_id=ca.asset_id WHERE cp.crypto_price_id IS NULL", "warnung"),
   302|        ("fehlende ISIN", "SELECT COUNT(*) AS c FROM instrument_mappings WHERE asset_class IN ('equity','etf') AND (isin IS NULL OR isin='') AND mapping_status!='ignored'", "warnung"),
   303|        ("Catalog Instrumente ohne ISIN", "SELECT COUNT(*) AS c FROM instrument_catalog_entries WHERE asset_class IN ('equity','stock','etf') AND (isin IS NULL OR isin='')", "warnung"),
   304|        ("Catalog Ticker ohne Exchange", "SELECT COUNT(*) AS c FROM instrument_catalog_entries WHERE COALESCE(ticker,'')<>'' AND COALESCE(exchange,'')=''", "warnung"),
   305|        ("Catalog mehrere Listings gleicher ISIN", "SELECT COUNT(*) AS c FROM (SELECT isin FROM instrument_catalog_entries WHERE COALESCE(isin,'')<>'' GROUP BY isin HAVING COUNT(DISTINCT COALESCE(exchange,'') || ':' || COALESCE(trading_currency,''))>1)", "info"),
   306|        ("Manual Review needed", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='manual_review_needed'", "warnung"),
   307|        ("Mapping-Kandidaten unbestätigt", "SELECT COUNT(*) AS c FROM instrument_price_mapping_candidates WHERE review_status IN ('proposed','needs_manual_review')", "warnung"),
   308|        ("Instrumente mit mehreren Mapping-Kandidaten", "SELECT COUNT(*) AS c FROM (SELECT instrument_id FROM instrument_price_mapping_candidates WHERE review_status IN ('proposed','needs_manual_review') GROUP BY instrument_id HAVING COUNT(*) > 1)", "warnung"),
   309|        ("Valuation not ready", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('missing_provider_symbol','missing_market_price','missing_fx','hedge_status_unknown','instrument_status_unknown')", "warnung"),
   310|        ("fehlende FX in Import-Reviews", "SELECT COUNT(*) AS c FROM instrument_mappings WHERE quality_flags_json LIKE '%missing_fx%' AND mapping_status!='ignored'", "warnung"),
   311|        ("snapshot_only Positionen", "SELECT COUNT(*) AS c FROM instrument_mappings WHERE quality_flags_json LIKE '%snapshot_only%' AND mapping_status!='ignored'", "info"),
   312|        ("cost_basis_uncertain", "SELECT COUNT(*) AS c FROM instrument_mappings WHERE quality_flags_json LIKE '%cost_basis_uncertain%' AND mapping_status!='ignored'", "warnung"),
   313|        ("aktive Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active'", "warnung"),
   314|        ("resolved Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='resolved'", "ok"),
   315|        ("alte aktive Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND last_seen_at < datetime('now', '-7 days')", "info"),
   316|    ]
   317|    for label, sql, severity in checks:
   318|        row = conn.execute(sql).fetchone()
   319|        count = int(row["c"] or 0) if row else 0
   320|        sev = severity if count else "ok"
   321|        items.append({"check": label, "count": str(count), "severity": sev})
   322|    for flag_row in get_review_quality_flag_counts(conn):
   323|        count = int(flag_row["open_items"] or 0)
   324|        items.append({"check": f"Review Flag: {flag_row['quality_flag']}", "count": str(count), "severity": "warnung" if count else "ok"})
   325|    for ready in get_review_readiness_counts(conn):
   326|        count = int(ready["count"] or 0)
   327|        sev = "ok" if ready["readiness_status"] in {"ready_for_import", "ignored"} else ("warnung" if count else "ok")
   328|        items.append({"check": f"Review Readiness: {ready['readiness_status']}", "count": str(count), "severity": sev})
   329|    for source in get_ready_for_import_by_source(conn):
   330|        if int(source["total"] or 0) and int(source["ready_for_import"] or 0) == 0:
   331|            items.append({"check": f"Quelle ohne ready_for_import: {source['source_platform']}", "count": source["total"], "severity": "warnung"})
   332|    missing_snapshot = conn.execute("SELECT COUNT(*) AS c FROM broker_import_dry_runs WHERE snapshot_date_status='missing'").fetchone()
   333|    count = int(missing_snapshot["c"] or 0) if missing_snapshot else 0
   334|    items.append({"check": "Dry Runs ohne Snapshot Date", "count": str(count), "severity": "warnung" if count else "ok"})
   335|    return items
   336|
   337|
   338|def get_instrument_catalog_entries(conn: Connection, query: str | None = None, asset_class: str | None = None) -> list[dict[str, str]]:
   339|    if query:
   340|        from jarvis_finance.market_data.catalog import search_local_catalog
   341|        return [r.__dict__ for r in search_local_catalog(conn, query, asset_class)]
   342|    sql = """
   343|        SELECT catalog_entry_id, asset_class, name, isin, ticker, exchange, trading_currency,
   344|               instrument_currency, provider, provider_symbol, provider_market, hedge_status,
   345|               instrument_status, valuation_policy, source, source_confidence, last_verified_at
   346|        FROM instrument_catalog_entries
   347|    """
   348|    params: list[object] = []
   349|    if asset_class:
   350|        sql += " WHERE asset_class=?"
   351|        params.append(asset_class.lower())
   352|    sql += " ORDER BY name, exchange, trading_currency LIMIT 100"
   353|    return rowdicts(conn.execute(sql, tuple(params)).fetchall())
   354|
   355|
   356|def get_instrument_catalog_summary(conn: Connection) -> dict[str, str]:
   357|    total = conn.execute("SELECT COUNT(*) AS c FROM instrument_catalog_entries").fetchone()["c"]
   358|    multi = conn.execute("SELECT COUNT(*) AS c FROM (SELECT isin FROM instrument_catalog_entries WHERE COALESCE(isin,'')<>'' GROUP BY isin HAVING COUNT(DISTINCT COALESCE(exchange,'') || ':' || COALESCE(trading_currency,''))>1)").fetchone()["c"]
   359|    no_provider = conn.execute("SELECT COUNT(*) AS c FROM instrument_catalog_entries WHERE COALESCE(provider_symbol,'')='' ").fetchone()["c"]
   360|    manual = conn.execute("SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('manual_review_needed','missing_provider_symbol','ambiguous_provider_mapping')").fetchone()["c"]
   361|    return {"catalog_entries": str(total or 0), "multiple_listings": str(multi or 0), "without_provider_symbol": str(no_provider or 0), "manual_review_required": str(manual or 0)}
   362|
   363|
   364|def get_instrument_mapping_review(conn: Connection) -> list[dict[str, str]]:
   365|    rows = conn.execute(
   366|        """
   367|        SELECT i.instrument_id, i.isin, i.name, i.ticker, i.exchange, i.currency,
   368|               i.hedge_status, i.instrument_status, i.valuation_policy,
   369|               COALESCE(m.mapping_status, 'missing_provider_symbol') AS mapping_status,
   370|               COALESCE(m.provider, '') AS provider,
   371|               COALESCE(m.provider_symbol, '') AS provider_symbol,
   372|               COUNT(c.candidate_id) AS candidate_count
   373|        FROM instruments i
   374|        JOIN transactions t ON t.instrument_id=i.instrument_id
   375|        LEFT JOIN instrument_price_mappings m ON m.instrument_id=i.instrument_id AND m.mapping_status='mapped'
   376|        LEFT JOIN instrument_price_mapping_candidates c ON c.instrument_id=i.instrument_id AND c.review_status IN ('proposed','needs_manual_review')
   377|        WHERE t.transaction_type='initial_position_snapshot'
   378|          AND t.source_type='broker_import_reviewed_snapshot'
   379|          AND COALESCE(t.is_voided,0)=0
   380|        GROUP BY i.instrument_id
   381|        ORDER BY i.isin, i.name
   382|        """
   383|    ).fetchall()
   384|    return rowdicts(rows)
   385|
   386|
   387|def get_instrument_mapping_candidates(conn: Connection, instrument_id: str | None = None) -> list[dict[str, str]]:
   388|    if instrument_id:
   389|        rows = conn.execute(
   390|            """
   391|            SELECT candidate_id, instrument_id, isin, candidate_provider, candidate_provider_symbol,
   392|                   candidate_exchange, candidate_currency, candidate_name, candidate_asset_class,
   393|                   candidate_is_hedged, candidate_hedged_to_currency, candidate_hedge_status,
   394|                   candidate_instrument_status, candidate_valuation_policy, confidence,
   395|                   evidence_source, evidence_note, review_status, created_at
   396|            FROM instrument_price_mapping_candidates WHERE instrument_id=?
   397|            ORDER BY CASE confidence WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, candidate_provider
   398|            """,
   399|            (instrument_id,),
   400|        ).fetchall()
   401|    else:
   402|        rows = conn.execute(
   403|            """
   404|            SELECT candidate_id, instrument_id, isin, candidate_provider, candidate_provider_symbol,
   405|                   candidate_exchange, candidate_currency, candidate_name, candidate_asset_class,
   406|                   candidate_is_hedged, candidate_hedged_to_currency, candidate_hedge_status,
   407|                   candidate_instrument_status, candidate_valuation_policy, confidence,
   408|                   evidence_source, evidence_note, review_status, created_at
   409|            FROM instrument_price_mapping_candidates
   410|            ORDER BY instrument_id, CASE confidence WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, candidate_provider
   411|            """
   412|        ).fetchall()
   413|    return rowdicts(rows)
   414|
   415|
   416|def get_fx_override_requirements(conn: Connection) -> list[dict[str, str]]:
   417|    rows = conn.execute(
   418|        """
   419|        SELECT t.currency_original AS base_currency, 'CHF' AS quote_currency, t.trade_date AS rate_date,
   420|               COUNT(DISTINCT t.instrument_id) AS instrument_count,
   421|               CASE WHEN fr.fx_rate_id IS NULL THEN 'missing_fx' ELSE fr.quality_status END AS fx_status
   422|        FROM transactions t
   423|        LEFT JOIN fx_rates fr ON fr.base_currency=t.currency_original AND fr.quote_currency='CHF' AND fr.rate_date=t.trade_date
   424|        WHERE t.transaction_type='initial_position_snapshot'
   425|          AND t.source_type='broker_import_reviewed_snapshot'
   426|          AND COALESCE(t.is_voided,0)=0
   427|          AND t.currency_original IS NOT NULL
   428|          AND t.currency_original <> 'CHF'
   429|        GROUP BY t.currency_original, t.trade_date, fx_status
   430|        ORDER BY t.currency_original, t.trade_date
   431|        """
   432|    ).fetchall()
   433|    return rowdicts(rows)
   434|
   435|
   436|def get_import_wizard_summary(conn: Connection) -> dict[str, str]:
   437|    from jarvis_finance.imports.broker_mapping import build_import_wizard_summary
   438|
   439|    return build_import_wizard_summary(conn)
   440|
   441|
   442|def get_manual_review_items(conn: Connection) -> list[dict[str, str]]:
   443|    from jarvis_finance.imports.broker_mapping import get_manual_review_queue
   444|
   445|    return get_manual_review_queue(conn)
   446|
   447|
   448|def get_broker_dry_runs(conn: Connection, limit: int = 20) -> list[dict[str, str]]:
   449|    from jarvis_finance.imports.broker_mapping import get_dry_run_sessions
   450|
   451|    return get_dry_run_sessions(conn, limit=limit)
   452|
   453|
   454|def get_safe_mode_status(conn: Connection) -> dict[str, str]:
   455|    backup = conn.execute("SELECT created_at FROM audit_log WHERE action LIKE '%backup%' ORDER BY created_at DESC LIMIT 1").fetchone()
   456|    return {
   457|        "db_mode": "Runtime",
   458|        "write_mode": "Read-only",
   459|        "last_backup_at": backup["created_at"] if backup else "nicht verfügbar",
   460|        "git_safety_status": "zuletzt OK / CLI prüfen",
   461|        "data_location": "Echte Daten liegen außerhalb Git",
   462|    }
   463|
   464|
   465|def get_broker_review_items(conn: Connection, limit: int = 100, **filters) -> list[dict[str, str]]:
   466|    from jarvis_finance.imports.broker_mapping import get_broker_review_items as _items
   467|
   468|    return _items(conn, limit=limit, **filters)
   469|
   470|
   471|def get_review_readiness_counts(conn: Connection) -> list[dict[str, str]]:
   472|    from jarvis_finance.imports.broker_mapping import get_review_readiness_counts as _counts
   473|
   474|    return _counts(conn)
   475|
   476|
   477|def get_ready_for_import_by_source(conn: Connection) -> list[dict[str, str]]:
   478|    from jarvis_finance.imports.broker_mapping import get_ready_for_import_by_source as _counts
   479|
   480|    return _counts(conn)
   481|
   482|
   483|def get_review_quality_flag_counts(conn: Connection) -> list[dict[str, str]]:
   484|    from jarvis_finance.imports.broker_mapping import aggregate_review_quality_flags
   485|
   486|    return [{"quality_flag": k, "open_items": str(v)} for k, v in sorted(aggregate_review_quality_flags(conn).items())]
   487|
   488|
   489|def get_command_center_summary(conn: Connection) -> dict[str, str]:
   490|    cash_rows = get_cash_overview(conn)
   491|    crypto_rows = get_crypto_overview(conn)
   492|    cash_total = sum(d(r["amount_chf"]) for r in cash_rows if r["amount_chf"])
   493|    crypto_total = sum(d(r["market_value_chf"]) for r in crypto_rows if r["market_value_chf"])
   494|    alert_counts = conn.execute("SELECT priority, COUNT(*) AS c FROM alerts WHERE status='active' GROUP BY priority").fetchall()
   495|    quality = get_data_quality(conn)
   496|    return {
   497|        "base_currency": "CHF",
   498|        "cash_total_chf": dec_text(cash_total, "0.01"),
   499|        "crypto_total_chf": dec_text(crypto_total, "0.01"),
   500|        "total_value_chf": dec_text(cash_total + crypto_total, "0.01"),
   501|        "active_alerts": ", ".join(f"{r['priority']}: {r['c']}" for r in alert_counts) or "0",
   502|        "data_quality_status": "kritisch" if any(i["severity"] == "kritisch" and i["count"] != "0" for i in quality) else ("warnung" if any(i["severity"] == "warnung" and i["count"] != "0" for i in quality) else "ok"),
   503|        "crypto_price_data_as_of": max((r["fetched_at"] for r in crypto_rows if r.get("fetched_at")), default=""),
   504|    }
   505|


Explanation:
**Considering migration fixes and functionality**

I need to add a CLI parser for rank/export functionality. I'll also look into fixing the migration syntax, which seems to be okay for now. It’s crucial to inspect around the index line for any issues. There's a potential risk with using German labels instead of the specific strings that were requested, so I’ll keep that in mind. Additionally, I should check for any existing test fixtures and consider running some searches to gather more information.