     1	from __future__ import annotations
     2	
     3	from sqlite3 import Connection
     4	
     5	from fastapi import APIRouter, Depends, HTTPException, Query
     6	
     7	from jarvis_finance.api.dependencies import get_db
     8	from jarvis_finance.api.schemas.overview import PortfolioSummary
     9	from jarvis_finance.api.schemas.manual_snapshot import (
    10	    ManualSnapshotConfirmRequest,
    11	    ManualSnapshotConfirmResponse,
    12	    ManualSnapshotPreviewRequest,
    13	    ManualSnapshotPreviewResponse,
    14	)
    15	from jarvis_finance.api.schemas.performance_activation import (
    16	    DailyValuationJobStatus,
    17	    PerformanceBackfillConfirmRequest,
    18	    PerformanceBackfillConfirmResponse,
    19	    PerformanceBackfillPreviewRequest,
    20	    PerformanceBackfillPreviewResponse,
    21	    PerformanceReclassificationPreviewRequest,
    22	    PerformanceReclassificationPreviewResponse,
    23	    PerformanceSetupResponse,
    24	    PerformanceSourceActivationConfirmRequest,
    25	    PerformanceSourceActivationConfirmResponse,
    26	    PerformanceSourceActivationPreviewRequest,
    27	    PerformanceSourceActivationPreviewResponse,
    28	    TrueWealthActivationPackagePreviewRequest,
    29	    TrueWealthActivationPackagePreviewResponse,
    30	    TrueWealthBankPaymentConfirmRequest,
    31	    TrueWealthBankPaymentConfirmResponse,
    32	    TrueWealthBankPaymentPreviewRequest,
    33	    TrueWealthBankPaymentPreviewResponse,
    34	    TrueWealthCashflowConfirmRequest,
    35	    TrueWealthCashflowConfirmResponse,
    36	    TrueWealthCashflowPreviewRequest,
    37	    TrueWealthCashflowPreviewResponse,
    38	    TrueWealthModelPreviewResponse,
    39	    TrueWealthPerformanceViewResponse,
    40	    TrueWealthRecipientRuleActivationRequest,
    41	    TrueWealthRecipientRuleActivationResponse,
    42	)
    43	from jarvis_finance.api.schemas.portfolio_advisor import PortfolioAdvisorSnapshot
    44	from jarvis_finance.api.schemas.portfolio_data import (
    45	    DataSourcesResponse,
    46	    IngestionConfirmRequest,
    47	    IngestionConfirmResponse,
    48	    IngestionHistoryResponse,
    49	    IngestionPreviewRequest,
    50	    IngestionPreviewResponse,
    51	    ReconciliationResponse,
    52	)
    53	from jarvis_finance.api.schemas.portfolio_performance import (
    54	    PerformanceCoverageResponse,
    55	    PortfolioPerformanceResponse,
    56	)
    57	from jarvis_finance.api.schemas.portfolio_policy import (
    58	    ActivePortfolioPolicyResponse,
    59	    PolicyConfirmRequest,
    60	    PolicyConfirmResponse,
    61	    PolicyEvaluationResponse,
    62	    PolicyHistoryItem,
    63	    PolicyPreviewRequest,
    64	    PolicyPreviewResponse,
    65	    PortfolioPolicyResponse,
    66	)
    67	from jarvis_finance.api.schemas.reconciliation import ReconciliationSnapshotResponse
    68	from jarvis_finance.api.schemas.wealth_cockpit import WealthCockpitResponse
    69	from jarvis_finance.services.finance_command_center import build_finance_command_center
    70	from jarvis_finance.services.performance_activation import (
    71	    build_daily_valuation_job_status,
    72	    confirm_performance_backfill,
    73	    confirm_performance_source_activation,
    74	    preview_performance_backfill,
    75	    preview_performance_source_activation,
    76	)
    77	from jarvis_finance.services.performance_hardening import (
    78	    build_activation_setup_overview,
    79	    build_postfinance_component_preview,
    80	    confirm_truewealth_cashflow_period,
    81	    preview_performance_reclassification,
    82	    preview_truewealth_cashflow_period,
    83	)
    84	from jarvis_finance.services.portfolio_advisor import get_portfolio_advisor_snapshot
    85	from jarvis_finance.services.portfolio_analytics import build_portfolio_analytics
    86	from jarvis_finance.services.portfolio_data import (
    87	    build_portfolio_reconciliation,
    88	    confirm_ingestion,
    89	    ingestion_history,
    90	    list_data_sources,
    91	    preview_ingestion,
    92	)
    93	from jarvis_finance.services.portfolio_performance import (
    94	    build_performance_coverage,
    95	    build_portfolio_performance,
    96	)
    97	from jarvis_finance.services.portfolio_policy import (
    98	    active_policy,
    99	    confirm_policy,
   100	    evaluate_policy,
   101	    policy_detail,
   102	    policy_history,
   103	    preview_policy,
   104	)
   105	from jarvis_finance.services.portfolio_service import get_overview
   106	from jarvis_finance.services.reconciliation_snapshot import build_reconciliation_snapshot
   107	from jarvis_finance.services.truewealth_productization import (
   108	    activate_truewealth_recipient_rule,
   109	    confirm_truewealth_bank_payments,
   110	    preview_truewealth_bank_payments,
   111	)
   112	from jarvis_finance.services.truewealth_valuation import (
   113	    build_truewealth_activation_package_preview,
   114	    build_truewealth_model_preview,
   115	    build_truewealth_performance_view,
   116	    public_truewealth_model_preview,
   117	)
   118	from jarvis_finance.services.wealth_cockpit import build_wealth_cockpit
   119	from jarvis_finance.services.raiffeisen_manual_snapshot import (
   120	    confirm_raiffeisen_manual_snapshot,
   121	    preview_raiffeisen_manual_snapshot,
   122	)
   123	
   124	router = APIRouter(tags=["overview"])
   125	
   126	
   127	@router.post("/portfolio/manual-snapshot/raiffeisen/preview", response_model=ManualSnapshotPreviewResponse)
   128	def raiffeisen_manual_snapshot_preview(
   129	    request: ManualSnapshotPreviewRequest, conn: Connection = Depends(get_db)
   130	) -> dict:
   131	    """Read-only preview over the dated source facts supplied by the user."""
   132	    try:
   133	        return preview_raiffeisen_manual_snapshot(conn, **request.model_dump())
   134	    except ValueError as exc:
   135	        raise HTTPException(status_code=409, detail=str(exc)) from exc
   136	
   137	
   138	@router.post(
   139	    "/portfolio/manual-snapshot/raiffeisen/confirm",
   140	    response_model=ManualSnapshotConfirmResponse,
   141	    responses={409: {"description": "Preview baseline changed or confirmation id reused"}},
   142	)
   143	def raiffeisen_manual_snapshot_confirm(
   144	    request: ManualSnapshotConfirmRequest, conn: Connection = Depends(get_db)
   145	) -> dict:
   146	    try:
   147	        return confirm_raiffeisen_manual_snapshot(conn, **request.model_dump())
   148	    except ValueError as exc:
   149	        raise HTTPException(status_code=409, detail=str(exc)) from exc
   150	
   151	
   152	@router.get("/overview/finance-command-center")
   153	def finance_command_center(month: str | None = None, conn: Connection = Depends(get_db)) -> dict:
   154	    try:
   155	        return build_finance_command_center(conn, month=month)
   156	    except ValueError as exc:
   157	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   158	
   159	
   160	@router.get("/overview", response_model=PortfolioSummary)
   161	def overview(conn: Connection = Depends(get_db)) -> PortfolioSummary:
   162	    return get_overview(conn)
   163	
   164	
   165	@router.get("/portfolio/wealth-cockpit", response_model=WealthCockpitResponse)
   166	def wealth_cockpit(
   167	    period: str = Query(
   168	        default="1m",
   169	        pattern="^(since_anchor|1m|3m|1y|ytd|previous_year|12m|all)$",
   170	    ),
   171	    as_of: str | None = None,
   172	    data_cutoff: str | None = None,
   173	    conn: Connection = Depends(get_db),
   174	) -> dict:
   175	    """Read-only household wealth view composed exclusively from stored canonical data."""
   176	    try:
   177	        return build_wealth_cockpit(
   178	            conn,
   179	            period=period,
   180	            as_of=as_of,
   181	            data_cutoff=data_cutoff,
   182	        )
   183	    except ValueError as exc:
   184	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   185	
   186	
   187	@router.get("/portfolio/reconciliation-snapshot", response_model=ReconciliationSnapshotResponse)
   188	def reconciliation_snapshot(conn: Connection = Depends(get_db)) -> ReconciliationSnapshotResponse:
   189	    """Read-only local quality view; it never imports, corrects, or confirms data."""
   190	    return ReconciliationSnapshotResponse.model_validate(build_reconciliation_snapshot(conn))
   191	
   192	
   193	@router.get("/portfolio/data-sources", response_model=DataSourcesResponse)
   194	def portfolio_data_sources(conn: Connection = Depends(get_db)) -> dict:
   195	    """Read source availability and redacted ingestion status without fetching providers."""
   196	    return list_data_sources(conn)
   197	
   198	
   199	@router.post("/portfolio/ingestion/preview", response_model=IngestionPreviewResponse)
   200	def portfolio_ingestion_preview(request: IngestionPreviewRequest, conn: Connection = Depends(get_db)) -> dict:
   201	    """Pure preview over already-normalized local records; no source is fetched or mutated."""
   202	    try:
   203	        return preview_ingestion(conn, request.model_dump())
   204	    except ValueError as exc:
   205	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   206	
   207	
   208	@router.post(
   209	    "/portfolio/ingestion/confirm",
   210	    response_model=IngestionConfirmResponse,
   211	    responses={409: {"description": "Abgelaufene oder veränderte Preview"}},
   212	)
   213	def portfolio_ingestion_confirm(request: IngestionConfirmRequest, conn: Connection = Depends(get_db)) -> dict:
   214	    """The only portfolio-ingestion write path: atomic, idempotent and audited."""
   215	    try:
   216	        return confirm_ingestion(conn, request.model_dump())
   217	    except ValueError as exc:
   218	        message = str(exc)
   219	        status = 409 if any(word in message.lower() for word in ("abgelaufen", "veraltet", "verändert", "ausgangsrevision", "wiederverwendet")) else 400
   220	        raise HTTPException(status_code=status, detail=message) from exc
   221	
   222	
   223	@router.get("/portfolio/ingestion/history", response_model=IngestionHistoryResponse)
   224	def portfolio_ingestion_history(
   225	    limit: int = Query(default=50, ge=1, le=100),
   226	    offset: int = Query(default=0, ge=0),
   227	    conn: Connection = Depends(get_db),
   228	) -> dict:
   229	    return ingestion_history(conn, limit=limit, offset=offset)
   230	
   231	
   232	@router.get("/portfolio/reconciliation", response_model=ReconciliationResponse)
   233	def portfolio_reconciliation(
   234	    as_of: str,
   235	    data_cutoff: str | None = None,
   236	    account_id: str | None = None,
   237	    base_currency: str = "CHF",
   238	    absolute_tolerance: str = "0.01",
   239	    relative_tolerance: str = "0.001",
   240	    limit: int = Query(default=200, ge=1, le=500),
   241	    offset: int = Query(default=0, ge=0),
   242	    conn: Connection = Depends(get_db),
   243	) -> dict:
   244	    """Read-only reproducible quantity, valuation, account-total and coverage reconciliation."""
   245	    try:
   246	        return build_portfolio_reconciliation(
   247	            conn, as_of=as_of, data_cutoff=data_cutoff, account_id=account_id,
   248	            base_currency=base_currency, absolute_tolerance=absolute_tolerance,
   249	            relative_tolerance=relative_tolerance, limit=limit, offset=offset,
   250	        )
   251	    except ValueError as exc:
   252	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   253	
   254	
   255	@router.get("/portfolio/performance/coverage", response_model=PerformanceCoverageResponse)
   256	def portfolio_performance_coverage(
   257	    from_date: str | None = Query(default=None, alias="from"),
   258	    to_date: str | None = Query(default=None, alias="to"),
   259	    conn: Connection = Depends(get_db),
   260	) -> dict:
   261	    """Role-bound coverage matrix; never estimates unavailable performance."""
   262	    if (from_date is None) != (to_date is None):
   263	        raise HTTPException(status_code=400, detail="Performance coverage requires both from and to")
   264	    try:
   265	        return build_performance_coverage(conn, from_date=from_date, to_date=to_date)
   266	    except ValueError as exc:
   267	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   268	
   269	
   270	@router.get("/portfolio/performance/daily-job", response_model=DailyValuationJobStatus)
   271	def portfolio_daily_valuation_job(conn: Connection = Depends(get_db)) -> dict:
   272	    """Read-only scheduler/run diagnosis; never invokes a provider or writes a snapshot."""
   273	    return build_daily_valuation_job_status(conn)
   274	
   275	
   276	@router.get("/portfolio/performance/setup", response_model=PerformanceSetupResponse)
   277	def portfolio_performance_setup(conn: Connection = Depends(get_db)) -> dict:
   278	    """Single compact read-only activation setup projection."""
   279	    return build_activation_setup_overview(conn)
   280	
   281	
   282	@router.get("/portfolio/performance/postfinance-components")
   283	def portfolio_postfinance_component_preview(
   284	    day: str,
   285	    conn: Connection = Depends(get_db),
   286	) -> dict:
   287	    try:
   288	        return build_postfinance_component_preview(conn, day=day)
   289	    except ValueError as exc:
   290	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   291	
   292	
   293	@router.post(
   294	    "/portfolio/performance/reclassification/preview",
   295	    response_model=PerformanceReclassificationPreviewResponse,
   296	)
   297	def portfolio_performance_reclassification_preview(
   298	    request: PerformanceReclassificationPreviewRequest,
   299	    conn: Connection = Depends(get_db),
   300	) -> dict:
   301	    try:
   302	        return preview_performance_reclassification(conn, **request.model_dump())
   303	    except ValueError as exc:
   304	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   305	
   306	
   307	@router.post(
   308	    "/portfolio/performance/truewealth-cashflows/preview",
   309	    response_model=TrueWealthCashflowPreviewResponse,
   310	)
   311	def truewealth_cashflow_period_preview(
   312	    request: TrueWealthCashflowPreviewRequest,
   313	    conn: Connection = Depends(get_db),
   314	) -> dict:
   315	    try:
   316	        return preview_truewealth_cashflow_period(conn, **request.model_dump())
   317	    except ValueError as exc:
   318	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   319	
   320	
   321	@router.post(
   322	    "/portfolio/performance/truewealth-cashflows/confirm",
   323	    response_model=TrueWealthCashflowConfirmResponse,
   324	    responses={409: {"description": "Preview changed or confirmation id reused"}},
   325	)
   326	def truewealth_cashflow_period_confirm(
   327	    request: TrueWealthCashflowConfirmRequest,
   328	    conn: Connection = Depends(get_db),
   329	) -> dict:
   330	    try:
   331	        return confirm_truewealth_cashflow_period(conn, request.model_dump())
   332	    except ValueError as exc:
   333	        message = str(exc)
   334	        status = 409 if any(
   335	            word in message.lower()
   336	            for word in ("stale", "different payload", "already used")
   337	        ) else 400
   338	        raise HTTPException(status_code=status, detail=message) from exc
   339	
   340	
   341	@router.get(
   342	    "/portfolio/performance/truewealth/view",
   343	    response_model=TrueWealthPerformanceViewResponse,
   344	)
   345	def truewealth_performance_view(
   346	    requested_from: str,
   347	    requested_to: str,
   348	    conn: Connection = Depends(get_db),
   349	) -> dict:
   350	    try:
   351	        return build_truewealth_performance_view(
   352	            conn, requested_from=requested_from, requested_to=requested_to
   353	        )
   354	    except ValueError as exc:
   355	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   356	
   357	
   358	@router.get(
   359	    "/portfolio/performance/truewealth/model-preview",
   360	    response_model=TrueWealthModelPreviewResponse,
   361	)
   362	def truewealth_model_preview(as_of: str, conn: Connection = Depends(get_db)) -> dict:
   363	    try:
   364	        return public_truewealth_model_preview(build_truewealth_model_preview(conn, as_of=as_of))
   365	    except ValueError as exc:
   366	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   367	
   368	
   369	@router.post(
   370	    "/portfolio/performance/truewealth/activation-package-preview",
   371	    response_model=TrueWealthActivationPackagePreviewResponse,
   372	)
   373	def truewealth_activation_package_preview(
   374	    request: TrueWealthActivationPackagePreviewRequest,
   375	    conn: Connection = Depends(get_db),
   376	) -> dict:
   377	    try:
   378	        payload = request.model_dump()
   379	        bank = preview_truewealth_bank_payments(
   380	            conn,
   381	            **{
   382	                key: payload[key]
   383	                for key in (
   384	                    "period_from", "period_to", "recipient_name",
   385	                    "recipient_account_identity", "counterparty_hashes",
   386	                    "reference_tokens", "collision_decisions", "attestation",
   387	                )
   388	            },
   389	        )
   390	        return build_truewealth_activation_package_preview(
   391	            conn,
   392	            bank_preview=bank,
   393	            performance_from=payload["performance_from"],
   394	            performance_to=payload["performance_to"],
   395	            model_as_of=payload["model_as_of"],
   396	        )
   397	    except ValueError as exc:
   398	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   399	
   400	
   401	@router.post(
   402	    "/portfolio/performance/truewealth-bank-payments/preview",
   403	    response_model=TrueWealthBankPaymentPreviewResponse,
   404	)
   405	def truewealth_bank_payment_preview(
   406	    request: TrueWealthBankPaymentPreviewRequest,
   407	    conn: Connection = Depends(get_db),
   408	) -> dict:
   409	    """Read only: scan already imported Raiffeisen rows; never fetch or mutate."""
   410	    try:
   411	        return preview_truewealth_bank_payments(conn, **request.model_dump())
   412	    except ValueError as exc:
   413	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   414	
   415	
   416	@router.post(
   417	    "/portfolio/performance/truewealth-bank-payments/confirm",
   418	    response_model=TrueWealthBankPaymentConfirmResponse,
   419	    responses={409: {"description": "Preview changed or confirmation id reused"}},
   420	)
   421	def truewealth_bank_payment_confirm(
   422	    request: TrueWealthBankPaymentConfirmRequest,
   423	    conn: Connection = Depends(get_db),
   424	) -> dict:
   425	    try:
   426	        return confirm_truewealth_bank_payments(conn, request.model_dump())
   427	    except ValueError as exc:
   428	        message = str(exc)
   429	        status = 409 if any(word in message.lower() for word in ("stale", "already used", "became stale")) else 400
   430	        raise HTTPException(status_code=status, detail=message) from exc
   431	
   432	
   433	@router.post(
   434	    "/portfolio/performance/truewealth-recipient-rule/activate",
   435	    response_model=TrueWealthRecipientRuleActivationResponse,
   436	)
   437	def truewealth_recipient_rule_activate(
   438	    request: TrueWealthRecipientRuleActivationRequest,
   439	    conn: Connection = Depends(get_db),
   440	) -> dict:
   441	    """Separate rule activation boundary; historical confirmation never calls it."""
   442	    try:
   443	        return activate_truewealth_recipient_rule(conn, request.model_dump())
   444	    except ValueError as exc:
   445	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   446	
   447	
   448	@router.post(
   449	    "/portfolio/performance/activation/preview",
   450	    response_model=PerformanceSourceActivationPreviewResponse,
   451	)
   452	def portfolio_performance_activation_preview(
   453	    request: PerformanceSourceActivationPreviewRequest,
   454	    conn: Connection = Depends(get_db),
   455	) -> dict:
   456	    """Read-only source-contract preview; it never creates scopes, coverage or values."""
   457	    try:
   458	        return preview_performance_source_activation(conn, **request.model_dump())
   459	    except ValueError as exc:
   460	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   461	
   462	
   463	@router.post(
   464	    "/portfolio/performance/activation/confirm",
   465	    response_model=PerformanceSourceActivationConfirmResponse,
   466	    responses={409: {"description": "Preview changed or expired"}},
   467	)
   468	def portfolio_performance_activation_confirm(
   469	    request: PerformanceSourceActivationConfirmRequest,
   470	    conn: Connection = Depends(get_db),
   471	) -> dict:
   472	    """Explicit, fingerprint-bound source activation without financial snapshot writes."""
   473	    try:
   474	        return confirm_performance_source_activation(conn, request.model_dump())
   475	    except ValueError as exc:
   476	        status = 409 if "stale" in str(exc).lower() else 400
   477	        raise HTTPException(status_code=status, detail=str(exc)) from exc
   478	
   479	
   480	@router.post(
   481	    "/portfolio/performance/backfill/preview",
   482	    response_model=PerformanceBackfillPreviewResponse,
   483	)
   484	def portfolio_performance_backfill_preview(
   485	    request: PerformanceBackfillPreviewRequest,
   486	    conn: Connection = Depends(get_db),
   487	) -> dict:
   488	    """Pure local preview; provider calls and financial writes are forbidden here."""
   489	    try:
   490	        return preview_performance_backfill(conn, **request.model_dump())
   491	    except ValueError as exc:
   492	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   493	
   494	
   495	@router.post(
   496	    "/portfolio/performance/backfill/confirm",
   497	    response_model=PerformanceBackfillConfirmResponse,
   498	    responses={409: {"description": "Preview changed or expired"}},
   499	)
   500	def portfolio_performance_backfill_confirm(
   501	    request: PerformanceBackfillConfirmRequest,
   502	    conn: Connection = Depends(get_db),
   503	) -> dict:
   504	    """Explicit, fingerprint-bound, audited and idempotent backfill write boundary."""
   505	    try:
   506	        return confirm_performance_backfill(conn, request.model_dump())
   507	    except ValueError as exc:
   508	        status = 409 if "stale" in str(exc).lower() else 400
   509	        raise HTTPException(status_code=status, detail=str(exc)) from exc
   510	
   511	
   512	@router.get(
   513	    "/portfolio/performance",
   514	    response_model=PortfolioPerformanceResponse,
   515	    responses={400: {"description": "Ungültiger Performance-Zeitraum oder Parameter"}},
   516	)
   517	def portfolio_performance(
   518	    from_date: str = Query(alias="from"),
   519	    to_date: str = Query(alias="to"),
   520	    method: str = Query(default="both"),
   521	    account_id: str | None = None,
   522	    scope: str = Query(default="portfolio", pattern="^(portfolio|postfinance|truewealth|crypto)$"),
   523	    base_currency: str = Query(default="CHF"),
   524	    data_cutoff: str | None = None,
   525	    conn: Connection = Depends(get_db),
   526	) -> dict:
   527	    """Pure read-only calculation from stored activities and versioned snapshots."""
   528	
   529	    try:
   530	        if account_id is not None and scope != "portfolio":
   531	            raise ValueError("Konto und fachlicher Scope dürfen nicht gleichzeitig gewählt werden")
   532	        role_map = {
   533	            "postfinance": ("postfinance_etrading_depot", "postfinance_etrading_cash"),
   534	            "truewealth": ("canonical_truewealth_total_value",),
   535	            "crypto": ("crypto_portfolio",),
   536	        }
   537	        selected_account_ids: list[str] | None = None
   538	        if scope != "portfolio":
   539	            roles = role_map[scope]
   540	            placeholders = ",".join("?" for _ in roles)
   541	            selected_account_ids = [
   542	                str(row[0])
   543	                for row in conn.execute(
   544	                    f"""SELECT account_id FROM performance_scope_classifications
   545	                        WHERE included=1 AND decision_version='investment_performance_scope_v1'
   546	                          AND classification_role IN ({placeholders})
   547	                        ORDER BY account_id""",
   548	                    roles,
   549	                ).fetchall()
   550	            ]
   551	        return build_portfolio_performance(
   552	            conn,
   553	            from_date=from_date,
   554	            to_date=to_date,
   555	            method=method,
   556	            account_id=account_id,
   557	            selected_account_ids=selected_account_ids,
   558	            base_currency=base_currency,
   559	            data_cutoff=data_cutoff,
   560	        )
   561	    except ValueError as exc:
   562	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   563	
   564	
   565	@router.get("/portfolio/analytics")
   566	def portfolio_analytics(
   567	    period: str = Query(default="1y", pattern="^(1m|3m|6m|1y|2y)$"),
   568	    conn: Connection = Depends(get_db),
   569	) -> dict:
   570	    """Read-only market/FX, benchmark and risk projection from stored confirmed snapshots."""
   571	    return build_portfolio_analytics(conn, period=period)
   572	
   573	
   574	@router.get("/portfolio/policy", response_model=ActivePortfolioPolicyResponse)
   575	def portfolio_policy(conn: Connection = Depends(get_db)) -> dict:
   576	    return active_policy(conn)
   577	
   578	
   579	@router.get("/portfolio/policy/history", response_model=list[PolicyHistoryItem])
   580	def portfolio_policy_history(conn: Connection = Depends(get_db)) -> list[dict]:
   581	    return policy_history(conn)
   582	
   583	
   584	@router.post("/portfolio/policy/preview", response_model=PolicyPreviewResponse)
   585	def portfolio_policy_preview(request: PolicyPreviewRequest, conn: Connection = Depends(get_db)) -> dict:
   586	    return preview_policy(conn, request.model_dump())
   587	
   588	
   589	@router.post("/portfolio/policy/confirm", response_model=PolicyConfirmResponse)
   590	def portfolio_policy_confirm(request: PolicyConfirmRequest, conn: Connection = Depends(get_db)) -> dict:
   591	    try:
   592	        return confirm_policy(conn, request.model_dump())
   593	    except ValueError as exc:
   594	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   595	
   596	
   597	@router.get("/portfolio/policy/evaluation", response_model=PolicyEvaluationResponse)
   598	def portfolio_policy_evaluation(conn: Connection = Depends(get_db)) -> dict:
   599	    return evaluate_policy(conn)
   600	
   601	
   602	@router.get(
   603	    "/portfolio/policy/{policy_id}",
   604	    response_model=PortfolioPolicyResponse,
   605	    responses={404: {"description": "Policy-Version nicht gefunden"}},
   606	)
   607	def portfolio_policy_detail(policy_id: str, conn: Connection = Depends(get_db)) -> dict:
   608	    policy = policy_detail(conn, policy_id)
   609	    if policy is None:
   610	        raise HTTPException(status_code=404, detail="Policy-Version nicht gefunden")
   611	    return policy
   612	
   613	
   614	@router.get("/portfolio/advisor", response_model=PortfolioAdvisorSnapshot)
   615	def portfolio_advisor(conn: Connection = Depends(get_db)) -> PortfolioAdvisorSnapshot:
   616	    return get_portfolio_advisor_snapshot(conn)
     1	from __future__ import annotations
     2	
     3	from sqlite3 import Connection
     4	
     5	from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
     6	
     7	from jarvis_finance.api.dependencies import get_db
     8	from jarvis_finance.api.schemas.market import AssetPriceRefreshJobResponse, AssetPriceRefreshRequest, EquityCandlesResponse, MarketBatchUpdateResponse, MarketChartResponse, MarketQuoteResponse, MarketStatusResponse, QuoteRefreshRequest
     9	from jarvis_finance.services.asset_price_refresh import (
    10	    asset_price_refresh_status,
    11	    create_asset_price_refresh_job,
    12	    run_asset_price_refresh,
    13	)
    14	from jarvis_finance.services.market_service import (
    15	    get_crypto_chart,
    16	    get_crypto_quote,
    17	    get_equity_candles,
    18	    get_equity_chart,
    19	    get_equity_quote,
    20	    get_market_status,
    21	    refresh_crypto_quote,
    22	    refresh_crypto_quotes_batch,
    23	    refresh_equity_fx,
    24	    refresh_equity_quote,
    25	    refresh_equity_quotes_batch,
    26	)
    27	
    28	router = APIRouter(tags=["market"])
    29	
    30	
    31	@router.post("/market/asset-price-refresh", response_model=AssetPriceRefreshJobResponse)
    32	def asset_price_refresh_start(
    33	    request: AssetPriceRefreshRequest,
    34	    background_tasks: BackgroundTasks,
    35	    conn: Connection = Depends(get_db),
    36	) -> dict:
    37	    try:
    38	        payload, db_path = create_asset_price_refresh_job(conn, stale_hours=request.stale_hours)
    39	    except ValueError as exc:
    40	        raise HTTPException(status_code=409, detail=str(exc)) from exc
    41	    background_tasks.add_task(run_asset_price_refresh, db_path, payload["job_id"])
    42	    return payload
    43	
    44	
    45	@router.get("/market/asset-price-refresh/{job_id}", response_model=AssetPriceRefreshJobResponse)
    46	def asset_price_refresh_job_status(job_id: str, conn: Connection = Depends(get_db)) -> dict:
    47	    """Stored status only; it never invokes providers or writes."""
    48	    return asset_price_refresh_status(conn, job_id)
    49	
    50	
    51	@router.get("/market/status", response_model=MarketStatusResponse)
    52	def market_status(conn: Connection = Depends(get_db)) -> MarketStatusResponse:
    53	    return get_market_status(conn)
    54	
    55	
    56	@router.post("/market/equity/update-quotes", response_model=MarketQuoteResponse | MarketBatchUpdateResponse)
    57	def market_equity_update_quote(request: QuoteRefreshRequest, instrument_id: str | None = Query(default=None), conn: Connection = Depends(get_db)) -> MarketQuoteResponse | MarketBatchUpdateResponse:
    58	    if instrument_id:
    59	        return refresh_equity_quote(conn, instrument_id, request)
    60	    return refresh_equity_quotes_batch(conn, request)
    61	
    62	
    63	@router.post("/market/equity/update-quotes/dry-run", response_model=MarketBatchUpdateResponse)
    64	def market_equity_update_quotes_dry_run(request: QuoteRefreshRequest, conn: Connection = Depends(get_db)) -> MarketBatchUpdateResponse:
    65	    """Call every eligible provider without persisting quotes or valuations."""
    66	    return refresh_equity_quotes_batch(conn, request.model_copy(update={"dry_run": True, "only_missing": False}))
    67	
    68	
    69	@router.post("/market/equity/update-fx")
    70	def market_equity_update_fx(instrument_id: str, conn: Connection = Depends(get_db)) -> dict[str, object]:
    71	    return refresh_equity_fx(conn, instrument_id)
    72	
    73	
    74	@router.post("/market/crypto/update-live-stats", response_model=MarketQuoteResponse | MarketBatchUpdateResponse)
    75	def market_crypto_update_live_stats(request: QuoteRefreshRequest, asset_id: str | None = Query(default=None), conn: Connection = Depends(get_db)) -> MarketQuoteResponse | MarketBatchUpdateResponse:
    76	    if asset_id:
    77	        return refresh_crypto_quote(conn, asset_id, request)
    78	    return refresh_crypto_quotes_batch(conn, request)
    79	
    80	
    81	@router.get("/equity/{instrument_id}/quote", response_model=MarketQuoteResponse)
    82	def equity_quote(instrument_id: str, conn: Connection = Depends(get_db)) -> MarketQuoteResponse:
    83	    return get_equity_quote(conn, instrument_id)
    84	
    85	
    86	@router.get("/equity/{instrument_id}/chart", response_model=MarketChartResponse)
    87	def equity_chart(instrument_id: str, range: str = "1d", interval: str = "5m", conn: Connection = Depends(get_db)) -> MarketChartResponse:
    88	    return get_equity_chart(conn, instrument_id, range=range, interval=interval)
    89	
    90	
    91	@router.get("/equity/{instrument_id}/candles", response_model=EquityCandlesResponse)
    92	def equity_candles(instrument_id: str, range: str = "1d", interval: str = "5m", refresh: bool = False, conn: Connection = Depends(get_db)) -> EquityCandlesResponse:
    93	    return get_equity_candles(conn, instrument_id, range=range, interval=interval, refresh=refresh)
    94	
    95	
    96	@router.get("/crypto/{asset_id}/live-stats", response_model=MarketQuoteResponse)
    97	def crypto_live_stats(asset_id: str, currency: str = "CHF", conn: Connection = Depends(get_db)) -> MarketQuoteResponse:
    98	    return get_crypto_quote(conn, asset_id, currency=currency)
    99	
   100	
   101	@router.get("/crypto/{asset_id}/chart", response_model=MarketChartResponse)
   102	def crypto_chart(asset_id: str, range: str = "1d", interval: str = "5m", currency: str = "CHF", conn: Connection = Depends(get_db)) -> MarketChartResponse:
   103	    return get_crypto_chart(conn, asset_id, range=range, interval=interval, currency=currency)
     1	from __future__ import annotations
     2	
     3	from pydantic import BaseModel, ConfigDict, Field
     4	from typing import Literal
     5	
     6	
     7	class AssetPriceRefreshRequest(BaseModel):
     8	    model_config = ConfigDict(extra="forbid")
     9	    stale_hours: int = Field(default=24, ge=1, le=720)
    10	
    11	
    12	class AssetPriceRefreshSourceStatus(BaseModel):
    13	    model_config = ConfigDict(extra="forbid")
    14	    source: Literal["equity", "crypto", "fx"]
    15	    status: Literal["pending", "running", "complete", "failed", "skipped"]
    16	    stale_candidates: int
    17	    updated_count: int
    18	    error_code: str | None
    19	    started_at: str | None
    20	    completed_at: str | None
    21	
    22	
    23	class AssetPriceRefreshJobResponse(BaseModel):
    24	    model_config = ConfigDict(extra="forbid")
    25	    job_id: str
    26	    status: Literal["queued", "running", "complete", "partial", "failed"]
    27	    requested_at: str
    28	    completed_at: str | None
    29	    stale_before: str
    30	    progress: dict[str, int]
    31	    sources: list[AssetPriceRefreshSourceStatus]
    32	    wealth_snapshot_created: bool
    33	    audit_recorded: bool
    34	    provider_calls_on_read: Literal[False]
    35	
    36	
    37	class MarketStatusResponse(BaseModel):
    38	    equity_latest_update: str | None = None
    39	    crypto_latest_update: str | None = None
    40	    equity_cached_points: int = 0
    41	    crypto_cached_points: int = 0
    42	    mapped_equity_instruments: int = 0
    43	    mapped_crypto_assets: int = 0
    44	    render_provider_calls: bool = False
    45	    warnings: list[str] = Field(default_factory=list)
    46	
    47	
    48	class QuoteRefreshRequest(BaseModel):
    49	    provider: str = "auto"
    50	    currency: str = "CHF"
    51	    range: str = "1d"
    52	    interval: str = "5m"
    53	    limit: int = Field(default=100, ge=1, le=500)
    54	    price_date: str | None = None
    55	    only_missing: bool = True
    56	    stale_before: str | None = None
    57	    max_retries: int = Field(default=2, ge=0, le=3)
    58	    pacing_seconds: float = Field(default=0.6, ge=0, le=5)
    59	    max_parallelism: int = Field(default=3, ge=1, le=4)
    60	    dry_run: bool = False
    61	
    62	
    63	class MarketBatchUpdateResponse(BaseModel):
    64	    action: str
    65	    provider: str
    66	    mode: str = "apply"
    67	    requested_at: str | None = None
    68	    completed_at: str | None = None
    69	    total: int = 0
    70	    updated: int = 0
    71	    skipped: int = 0
    72	    warnings: list[str] = Field(default_factory=list)
    73	    errors: list[str] = Field(default_factory=list)
    74	    target_date: str | None = None
    75	    result_price_date_from: str | None = None
    76	    result_price_date_to: str | None = None
    77	    eligible_total: int = 0
    78	    limit_applied: bool = False
    79	    provider_calls: int = 0
    80	    would_update: int = 0
    81	    persistence_performed: bool = False
    82	    cached: int = 0
    83	    processed: int = 0
    84	    valued: int = 0
    85	    coverage_total: int = 0
    86	    complete: bool = False
    87	    results: list[dict[str, str | int | bool | None]] = Field(default_factory=list)
    88	    render_provider_calls: bool = False
    89	
    90	
    91	class MarketQuoteResponse(BaseModel):
    92	    latest_price: str | None = None
    93	    currency: str | None = None
    94	    change_abs: str | None = None
    95	    change_pct: str | None = None
    96	    open: str | None = None
    97	    high: str | None = None
    98	    low: str | None = None
    99	    close: str | None = None
   100	    volume: str | None = None
   101	    provider: str | None = None
   102	    provider_symbol: str | None = None
   103	    fetched_at: str | None = None
   104	    quality_status: str = "missing"
   105	    warnings: list[str] = Field(default_factory=list)
   106	    chart_points: list[dict[str, str | None]] = Field(default_factory=list)
   107	
   108	
   109	class ChartPoint(BaseModel):
   110	    timestamp: str
   111	    price: str
   112	    currency: str
   113	    provider: str | None = None
   114	    quality_status: str | None = None
   115	
   116	
   117	class MarketChartResponse(BaseModel):
   118	    latest_price: str | None = None
   119	    currency: str | None = None
   120	    change_abs: str | None = None
   121	    change_pct: str | None = None
   122	    open: str | None = None
   123	    high: str | None = None
   124	    low: str | None = None
   125	    close: str | None = None
   126	    volume: str | None = None
   127	    provider: str | None = None
   128	    provider_symbol: str | None = None
   129	    fetched_at: str | None = None
   130	    quality_status: str = "missing"
   131	    chart_points: list[ChartPoint] = Field(default_factory=list)
   132	    warnings: list[str] = Field(default_factory=list)
   133	
   134	
   135	class EquityCandle(BaseModel):
   136	    time: str
   137	    open: str
   138	    high: str
   139	    low: str
   140	    close: str
   141	    volume: str | None = None
   142	
   143	
   144	class EquityCandlesResponse(BaseModel):
   145	    instrument_id: str | None = None
   146	    symbol: str | None = None
   147	    provider_symbol: str | None = None
   148	    range: str = "1d"
   149	    interval: str = "5m"
   150	    provider: str = "yfinance"
   151	    quality_status: str = "missing"
   152	    candles: list[EquityCandle] = Field(default_factory=list)
   153	    volume: list[int | None] = Field(default_factory=list)
   154	    currency: str | None = None
   155	    exchange_timezone: str | None = None
   156	    fetched_at: str | None = None
   157	    warnings: list[str] = Field(default_factory=list)
     1	import { apiGet, apiPost } from './client'
     2	
     3	export type AssetRefreshSourceStatus = {
     4	  source: 'equity' | 'crypto' | 'fx'
     5	  status: 'pending' | 'running' | 'complete' | 'failed' | 'skipped'
     6	  stale_candidates: number
     7	  updated_count: number
     8	  error_code: string | null
     9	  started_at: string | null
    10	  completed_at: string | null
    11	}
    12	export type AssetRefreshJob = {
    13	  job_id: string
    14	  status: 'queued' | 'running' | 'complete' | 'partial' | 'failed'
    15	  requested_at: string
    16	  completed_at: string | null
    17	  stale_before: string
    18	  progress: { completed: number; total: number }
    19	  sources: AssetRefreshSourceStatus[]
    20	  wealth_snapshot_created: boolean
    21	  audit_recorded: boolean
    22	  provider_calls_on_read: false
    23	}
    24	
    25	export const startAssetRefreshJob = () =>
    26	  apiPost<AssetRefreshJob>('/api/market/asset-price-refresh', { stale_hours: 24 })
    27	export const getAssetRefreshJob = (jobId: string, refresh = true) =>
    28	  apiGet<AssetRefreshJob>(`/api/market/asset-price-refresh/${encodeURIComponent(jobId)}`, { refresh })

__HERMES_CWD_8d46a20096ed__/home/agent/.hermes/worktrees/FinanceManager-sprint23__HERMES_CWD_8d46a20096ed__
