     1|from __future__ import annotations
     2|
     3|from datetime import UTC, datetime
     4|from decimal import Decimal, InvalidOperation
     5|import json
     6|from sqlite3 import Connection
     7|from uuid import uuid4
     8|
     9|from fastapi import HTTPException
    10|
    11|from jarvis_finance.api.schemas.positions import (
    12|    AccountValueConfirmRequest,
    13|    AccountValuePreviewRequest,
    14|    ActionItem,
    15|    AuditEntry,
    16|    CashConfirmRequest,
    17|    CashPositionDetail,
    18|    CashPreviewRequest,
    19|    CleanupPreviewResponse,
    20|    ConfirmResponse,
    21|    ContainerConfirmRequest,
    22|    ContainerPreviewRequest,
    23|    CryptoActionConfirmRequest,
    24|    CryptoActionPreviewRequest,
    25|    EquityDividendConfirmRequest,
    26|    EquityDividendPreviewRequest,
    27|    EquitySellConfirmRequest,
    28|    EquitySellPreviewRequest,
    29|    GenericConfirmRequest,
    30|    InstrumentSearchCandidate,
    31|    InstrumentSearchRequest,
    32|    PositionConfirmRequest,
    33|    PositionPreviewRequest,
    34|    PreviewResponse,
    35|    PricePoint,
    36|    ProviderSearchResponse,
    37|    ReferenceAccount,
    38|    RemovePositionPreviewResponse,
    39|)
    40|from jarvis_finance.equity.cleanup import apply_review_test_equity_cleanup, identify_review_test_equity_artifacts
    41|from jarvis_finance.fx.rates import resolve_fx_rate_to_chf
    42|from jarvis_finance.imports.common import stable_id
    43|from jarvis_finance.market_data.catalog import (
    44|    CatalogSearchResult,
    45|    FmpSearchProvider,
    46|    FinnhubSymbolLookupProvider,
    47|    MassiveLookupProvider,
    48|    OpenFigiLookupProvider,
    49|    TwelveDataLookupProvider,
    50|    search_instruments as search_catalog_instruments,
    51|)
    52|from jarvis_finance.market_data.cache import get_crypto_chart_points, get_equity_chart_points
    53|from jarvis_finance.services.api_helpers import decimal_text, optional_decimal_text
    54|from jarvis_finance.services.cash_service import get_cash_summary
    55|from jarvis_finance.services.equity_service import get_equity_position
    56|
    57|
    58|def _now() -> str:
    59|    return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
    60|
    61|
    62|def _uuid(prefix: str) -> str:
    63|    return f"{prefix}_{uuid4().hex[:16]}"
    64|
    65|
    66|def _decimal(value: str, field: str) -> Decimal:
    67|    try:
    68|        parsed = Decimal(str(value))
    69|    except (InvalidOperation, ValueError) as exc:
    70|        raise HTTPException(status_code=422, detail=f"Invalid decimal for {field}") from exc
    71|    if parsed <= 0:
    72|        raise HTTPException(status_code=422, detail=f"{field} must be positive")
    73|    return parsed
    74|
    75|
    76|def _decimal_non_negative(value: str | None, field: str) -> Decimal:
    77|    if value in (None, ""):
    78|        return Decimal("0")
    79|    try:
    80|        parsed = Decimal(str(value))
    81|    except (InvalidOperation, ValueError) as exc:
    82|        raise HTTPException(status_code=422, detail=f"Invalid decimal for {field}") from exc
    83|    if parsed < 0:
    84|        raise HTTPException(status_code=422, detail=f"{field} must be zero or positive")
    85|    return parsed
    86|
    87|
    88|def _normalise_transaction_type(value: str, asset_class: str | None = None) -> str:
    89|    key = value.strip().lower().replace(" ", "_").replace("-", "_")
    90|    if key in {"initial_snapshot", "initial"}:
    91|        return "initial_cash_snapshot" if asset_class == "cash" else "initial_position_snapshot"
    92|    mapping = {
    93|        "kauf": "buy",
    94|        "buy": "buy",
    95|        "cash_einzahlung": "cash_deposit",
    96|        "cash_deposit": "cash_deposit",
    97|        "einzahlung": "cash_deposit",
    98|        "cash_auszahlung": "cash_withdrawal",
    99|        "cash_withdrawal": "cash_withdrawal",
   100|        "auszahlung": "cash_withdrawal",
   101|        "korrektur": "manual_cash_correction",
   102|        "cash_korrektur": "manual_cash_correction",
   103|        "manual_cash_correction": "manual_cash_correction",
   104|    }
   105|    return mapping.get(key, key)
   106|
   107|
   108|def _audit(conn: Connection, *, action: str, entity_type: str, entity_id: str, payload: dict, note: str, quality_status: str = "ok") -> str:
   109|    audit_id = _uuid("audit")
   110|    ts = _now()
   111|    conn.execute(
   112|        """
   113|        INSERT INTO audit_log(audit_id, timestamp, source, action, entity_type, entity_id, old_values_json, new_values_json, user_text_note, confirmed, confirmation_timestamp, created_by, quality_status, created_at)
   114|        VALUES (?, ?, 'vue_dashboard', ?, ?, ?, NULL, ?, ?, 1, ?, 'user', ?, ?)
   115|        """,
   116|        (audit_id, ts, action, entity_type, entity_id, json.dumps(payload, sort_keys=True), note, ts, quality_status, ts),
   117|    )
   118|    return audit_id
   119|
   120|
   121|def _is_truewealth_account_row(row) -> bool:
   122|    label = f"{row['platform']} {row['account_name']}".lower()
   123|    account_type = str(row["account_type"] or "").lower()
   124|    return account_type in {"robo_portfolio", "managed_portfolio"} or ("true" in label and "wealth" in label and any(token in label for token in {"gesamtwert", "total", "managed"}))
   125|
   126|
   127|def _account_portfolio_meta(row) -> tuple[str, str, bool]:
   128|    account_type = str(row["account_type"] or "").lower()
   129|    if _is_truewealth_account_row(row):
   130|        return "manual_total_value", "truewealth", False
   131|    if account_type == "brokerage":
   132|        return "positions", "postfinance_equity" if "postfinance" in f"{row['platform']} {row['account_name']}".lower() else "equity", True
   133|    if account_type == "cash":
   134|        return "cash_balance", "cash", False
   135|    return "positions", "other", True
   136|
   137|
   138|def _is_manual_total_value_account(conn: Connection, account_id: str) -> bool:
   139|    row = conn.execute(
   140|        """
   141|        SELECT a.account_id, p.name AS platform, a.account_name, a.account_type, a.currency
   142|        FROM accounts a JOIN platforms p ON p.platform_id = a.platform_id
   143|        WHERE a.account_id=?
   144|        """,
   145|        (account_id,),
   146|    ).fetchone()
   147|    if not row:
   148|        return False
   149|    valuation_mode, _bucket, allow_position_adds = _account_portfolio_meta(row)
   150|    return valuation_mode == "manual_total_value" and not allow_position_adds
   151|
   152|
   153|def list_reference_accounts(conn: Connection) -> list[ReferenceAccount]:
   154|    rows = conn.execute(
   155|        """
   156|        SELECT a.account_id, p.name AS platform, a.account_name, a.account_type, a.currency
   157|        FROM accounts a JOIN platforms p ON p.platform_id = a.platform_id
   158|        WHERE a.is_active = 1
   159|        ORDER BY p.name, a.account_name
   160|        """
   161|    ).fetchall()
   162|    accounts: list[ReferenceAccount] = []
   163|    for r in rows:
   164|        valuation_mode, bucket, allow_position_adds = _account_portfolio_meta(r)
   165|        account_type = "managed_portfolio" if bucket == "truewealth" else r["account_type"]
   166|        accounts.append(ReferenceAccount(account_id=r["account_id"], label=f"{r['platform']} · {r['account_name']}", account_type=account_type, currency=r["currency"], valuation_mode=valuation_mode, portfolio_bucket=bucket, allow_position_adds=allow_position_adds))
   167|    return accounts
   168|
   169|
   170|def search_instruments(conn: Connection, request: InstrumentSearchRequest) -> list[InstrumentSearchCandidate]:
   171|    q = f"%{request.query.strip().lower()}%"
   172|    asset = request.asset_class.lower()
   173|    allowed = ("stock", "etf") if asset in {"stock", "equity", "etf"} else (asset,)
   174|    rows = conn.execute(
   175|        f"""
   176|        SELECT instrument_id, asset_class, name, ticker, isin, exchange, currency
   177|        FROM instruments
   178|        WHERE is_active = 1 AND lower(asset_class) IN ({','.join('?' for _ in allowed)})
   179|          AND (lower(name) LIKE ? OR lower(ticker) LIKE ? OR lower(isin) LIKE ?)
   180|        ORDER BY name
   181|        LIMIT 12
   182|        """,
   183|        (*allowed, q, q, q),
   184|    ).fetchall()
   185|    return [
   186|        InstrumentSearchCandidate(
   187|            candidate_id=r["instrument_id"],
   188|            label=r["name"],
   189|            asset_class=r["asset_class"],
   190|            isin=r["isin"] or "",
   191|            ticker=r["ticker"] or "",
   192|            exchange=r["exchange"] or "",
   193|            currency=r["currency"] or "",
   194|            provider="local",
   195|            confidence_label="Lokal gespeichert",
   196|            review_required=False,
   197|        )
   198|        for r in rows
   199|    ]
   200|
   201|
   202|def _chf_amount(amount: str | None, rate: Decimal | None) -> str | None:
   203|    if amount in (None, "") or rate is None:
   204|        return None
   205|    return decimal_text(Decimal(str(amount)) * rate, 2)
   206|
   207|
   208|def _fx_for_explicit_save(conn: Connection, *, currency: str, trade_date: str | None) -> tuple[str, str | None, str | None, str | None]:
   209|    cur = currency.upper()
   210|    result = resolve_fx_rate_to_chf(conn, base_currency=cur, rate_date=trade_date, persist=True, resolve_fixed=True)
   211|    if result.rate is None:
   212|        return "missing", None, None, result.warning or "FX fehlt: Speichern bleibt möglich, CHF-Wert bleibt leer."
   213|    status = "not_needed" if cur == "CHF" else "ok"
   214|    source = result.source
   215|    return status, format(result.rate, "f"), source, None
   216|
   217|
   218|def _provider_search_providers():
   219|    return [FmpSearchProvider(), FinnhubSymbolLookupProvider(), TwelveDataLookupProvider(), OpenFigiLookupProvider(), MassiveLookupProvider()]
   220|
   221|
   222|_DERIVATIVE_TOKENS = ("CDR", "ADR", "GDR", "WARRANT", "CERTIFICATE", "DERIVATIVE", "RIGHT", "NOTE")
   223|_MAIN_EXCHANGES = {"NASDAQ", "NYSE", "NYSEARCA", "SIX", "XETRA", "LSE", "TSX"}
   224|
   225|
   226|def _is_derivative_like(result: CatalogSearchResult) -> bool:
   227|    text = " ".join([result.name or "", result.security_type or "", result.evidence_note or ""]).upper()
   228|    return any(token in text for token in _DERIVATIVE_TOKENS)
   229|
   230|
   231|def _confidence_label(result: CatalogSearchResult) -> str:
   232|    if "provider conflict" in (result.evidence_note or "").lower():
   233|        return "Manuell prüfen"
   234|    if result.confidence == "high":
   235|        return "Eindeutig"
   236|    if result.confidence == "medium":
   237|        return "Wahrscheinlich"
   238|    if result.ticker or result.isin:
   239|        return "Unsicher"
   240|    return "Manuell prüfen"
   241|
   242|
   243|def _search_rank(result: CatalogSearchResult, request: InstrumentSearchRequest) -> tuple[int, int, str]:
   244|    q = request.query.strip().upper()
   245|    currency = (request.currency or "").upper()
   246|    exchange = (request.exchange or "").upper()
   247|    result_currency = (result.trading_currency or result.instrument_currency or "").upper()
   248|    result_exchange = (result.exchange or result.provider_market or "").upper()
   249|    rank = 100
   250|    if result.isin and result.isin.upper() == q:
   251|        rank = 0
   252|    elif result.ticker and result.ticker.upper() == q and (not exchange or result_exchange == exchange) and (not currency or result_currency == currency):
   253|        rank = 1
   254|    elif result.ticker and result.ticker.upper() == q:
   255|        rank = 2
   256|    elif normalize_text(result.name) == normalize_text(request.query):
   257|        rank = 3
   258|    elif q in normalize_text(result.name).upper().split():
   259|        rank = 4
   260|    else:
   261|

Use offset=261 to continue reading (showing 1-260 of 1117 lines)


     1|from __future__ import annotations
     2|
     3|from fastapi import FastAPI
     4|from fastapi.middleware.cors import CORSMiddleware
     5|
     6|from jarvis_finance.api.routers import budget, cash, crypto, equity, health, market, overview, positions, reports, system
     7|
     8|LOCAL_ORIGINS = [
     9|    "http://localhost:5173",
    10|    "http://127.0.0.1:5173",
    11|    "http://100.85.29.67:5173",
    12|    "http://agent.tailbd371b.ts.net:5173",
    13|    "http://localhost:8503",
    14|    "http://127.0.0.1:8503",
    15|]
    16|
    17|
    18|def create_app() -> FastAPI:
    19|    app = FastAPI(
    20|        title="JARVIS Finance API",
    21|        version="0.1.0",
    22|        description="Read-only FastAPI v0 skeleton for the future Vue User Dashboard.",
    23|    )
    24|    app.add_middleware(
    25|        CORSMiddleware,
    26|        allow_origins=LOCAL_ORIGINS,
    27|        allow_credentials=False,
    28|        allow_methods=["GET", "POST"],
    29|        allow_headers=["*"],
    30|    )
    31|    for router in (health.router, overview.router, crypto.router, equity.router, cash.router, reports.router, positions.router, budget.router, system.router, market.router):
    32|        app.include_router(router, prefix="/api")
    33|    return app
    34|
    35|
    36|app = create_app()
    37|


     1|import { apiGet, apiPost } from './client'
     2|import type { CashPreviewRequest, CashSummary, CashPositionDetail, ConfirmResponse, PreviewResponse } from './types'
     3|export const getCashSummary = (refresh = false) => apiGet<CashSummary>('/api/cash/summary', { refresh })
     4|
     5|export function getCashPosition(id: string) { return apiGet<CashPositionDetail>(`/api/cash/positions/${encodeURIComponent(id)}`, { refresh: true }) }
     6|export const previewCash = (payload: CashPreviewRequest) => apiPost<PreviewResponse>('/api/cash/preview', payload)
     7|export const confirmCash = (payload: CashPreviewRequest & { preview_id: string; confirm: boolean }) => apiPost<ConfirmResponse>('/api/cash/confirm', payload)
     8|


Explanation:
**Searching for account snapshots**

I need to find existing account value snapshots. My focus is on being precise, so I'll look for exact matches. I want to make sure I'm gathering the correct information, as accuracy is key here. Searching specifically should yield better results and help me provide the user with the most relevant data. I'll keep iterating until I find what I need or realize if I'm on the wrong track!


terminal

python - <<'PY'
from pathlib import Path
for p in Path('src').rglob('*.py'):
    s=p.read_text(errors='ignore')
    if 'account_value_snapshots' in s or 'truewealth' in s.lower() or 'manual_total_value' in s:
        print(p)
PY


Output:
src/jarvis_finance/services/budget_imports.py
src/jarvis_finance/services/manual_entry_service.py
src/jarvis_finance/services/portfolio_service.py
src/jarvis_finance/storage/migrations.py
src/jarvis_finance/imports/broker_parsers.py
src/jarvis_finance/api/schemas/overview.py


python - <<'PY'
from pathlib import Path
p=Path('src/jarvis_finance/services/manual_entry_service.py')
lines=p.read_text().splitlines()
for i,l in enumerate(lines,1):
    if 'account_value' in l or 'true' in l.lower() or 'Cash' in l or 'cash_' in l:
        print(i,l[:180])
PY


Output:
16     CashConfirmRequest,
17     CashPositionDetail,
18     CashPreviewRequest,
54 from jarvis_finance.services.cash_service import get_cash_summary
91         return "initial_cash_snapshot" if asset_class == "cash" else "initial_position_snapshot"
95         "cash_einzahlung": "cash_deposit",
96         "cash_deposit": "cash_deposit",
97         "einzahlung": "cash_deposit",
98         "cash_auszahlung": "cash_withdrawal",
99         "cash_withdrawal": "cash_withdrawal",
100         "auszahlung": "cash_withdrawal",
101         "korrektur": "manual_cash_correction",
102         "cash_korrektur": "manual_cash_correction",
103         "manual_cash_correction": "manual_cash_correction",
116         (audit_id, ts, action, entity_type, entity_id, json.dumps(payload, sort_keys=True), note, ts, quality_status, ts),
121 def _is_truewealth_account_row(row) -> bool:
124     return account_type in {"robo_portfolio", "managed_portfolio"} or ("true" in label and "wealth" in label and any(token in label for token in {"gesamtwert", "total", "managed"})
129     if _is_truewealth_account_row(row):
130         return "manual_total_value", "truewealth", False
132         return "positions", "postfinance_equity" if "postfinance" in f"{row['platform']} {row['account_name']}".lower() else "equity", True
134         return "cash_balance", "cash", False
135     return "positions", "other", True
165         account_type = "managed_portfolio" if bucket == "truewealth" else r["account_type"]
210     result = resolve_fx_rate_to_chf(conn, base_currency=cur, rate_date=trade_date, persist=True, resolve_fixed=True)
330     return True
446             review_required=True,
459     return ProviderSearchResponse(candidates=candidates, warnings=sorted(set(ui_warnings)), selection_required=True)
482 def preview_cash(conn: Connection, request: CashPreviewRequest) -> PreviewResponse:
493 def confirm_cash(conn: Connection, request: CashConfirmRequest) -> ConfirmResponse:
512         INSERT OR IGNORE INTO cash_balances(cash_balance_id, account_id, balance_date, currency, amount_original, fx_rate_to_chf, amount_chf, source_type, quality_status, notes, cr
517     audit_id = _audit(conn, action="cash_confirm", entity_type="cash_position", entity_id=f"{request.account_id}:{request.currency.upper()}", payload=request.model_dump(), note=req
519     return ConfirmResponse(status="confirmed", entity_id=f"{request.account_id}:{request.currency.upper()}", audit_id=audit_id, message="Cash gespeichert")
528         raise HTTPException(status_code=422, detail="TrueWealth/managed portfolio is a manually valued Gesamtwert-Konto; Einzelpositionen sind hier nicht erlaubt.")
571 def get_cash_position_detail(conn: Connection, position_id: str) -> CashPositionDetail:
572     position = next((p for p in get_cash_summary(conn).positions if p.id == position_id), None)
578             FROM cash_balances cb JOIN accounts a ON a.account_id = cb.account_id JOIN platforms p ON p.platform_id = a.platform_id
585             raise HTTPException(status_code=404, detail="Cash position not found")
590         row = conn.execute("SELECT MAX(created_at) AS last_change FROM cash_balances WHERE account_id=? AND currency=?", (account_id, currency)).fetchone()
592     return CashPositionDetail(**payload, last_change=last_change, available_actions=[ActionItem(label="Einzahlung", enabled=True), ActionItem(label="Auszahlung", enabled=True), Act
681         "available_actions": [a.model_dump() for a in [ActionItem(label="Kaufen", enabled=True), ActionItem(label="Verkaufen", enabled=True), ActionItem(label="Dividende/Ausschüttu
804         raise HTTPException(status_code=422, detail="confirm=true required")
810             result = add_crypto_position(conn, coin_name=coin_name, symbol=symbol, wallet_id=request.wallet_id, quantity_text=request.quantity, effective_date=request.effective_dat
813             result = add_crypto_position(conn, coin_name=coin_name, symbol=symbol, wallet_id=request.wallet_id, quantity_text=request.quantity, effective_date=request.effective_dat
818             result = reduce_crypto_position(conn, asset_id=asset_id, wallet_id=request.wallet_id, reduction_quantity_text=request.quantity, effective_date=request.effective_date, n
824             result = adjust_crypto_position(conn, asset_id=asset_id, wallet_id=request.wallet_id, new_quantity_text=target_quantity, effective_date=request.effective_date, note=req
831             result = transfer_crypto_position(conn, asset_id=asset_id, from_wallet_id=request.wallet_id, to_wallet_id=request.target_wallet_id, quantity_text=request.quantity, fee_
870 def _cash_account_exists(conn: Connection, account_id: str | None) -> bool:
918     if _cash_account_exists(conn, request.cash_account_id):
919         conn.execute("INSERT INTO cash_balances(cash_balance_id, account_id, balance_date, currency, amount_original, fx_rate_to_chf, amount_chf, source_type, quality_status, notes
932     if not _cash_account_exists(conn, request.cash_account_id):
933         raise HTTPException(status_code=404, detail="Cash account not found")
957     conn.execute("INSERT INTO cash_balances(cash_balance_id, account_id, balance_date, currency, amount_original, fx_rate_to_chf, amount_chf, source_type, quality_status, notes, cr
1081 def preview_account_value(conn: Connection, account_id: str, request: AccountValuePreviewRequest) -> PreviewResponse:
1088 def confirm_account_value(conn: Connection, account_id: str, request: AccountValueConfirmRequest) -> ConfirmResponse:
1091     preview_account_value(conn, account_id, request)
1097         INSERT INTO account_value_snapshots(snapshot_id, account_id, valuation_date, total_value_chf, currency, source_type, quality_status, notes, created_at)
1102     audit_id = _audit(conn, action="account_value_confirm", entity_type="account", entity_id=account_id, payload={"snapshot_id": snapshot_id, "valuation_date": request.valuation_da
1110     has_history = int(conn.execute("SELECT COUNT(*) FROM transactions WHERE account_id=?", (account_id,)).fetchone()[0] or 0) + int(conn.execute("SELECT COUNT(*) FROM cash_balances


   470|        return
   471|    price_date = _now()[:10]
   472|    price_id = stable_id("mprice", instrument_id, price_date, catalog["last_price"], currency, "candidate")
   473|    conn.execute(
   474|        """
   475|        INSERT OR IGNORE INTO market_prices(market_price_id, instrument_id, price_date, close, currency, provider, quality_status, created_at)
   476|        VALUES (?, ?, ?, ?, ?, ?, 'ok', ?)
   477|        """,
   478|        (price_id, instrument_id, price_date, str(catalog["last_price"]), currency, catalog["provider"] or catalog["source"] or "provider_search", _now()),
   479|    )
   480|
   481|
   482|def preview_cash(conn: Connection, request: CashPreviewRequest) -> PreviewResponse:
   483|    _decimal(request.amount, "amount")
   484|    account = conn.execute("SELECT account_id FROM accounts WHERE account_id=?", (request.account_id,)).fetchone()
   485|    if not account:
   486|        raise HTTPException(status_code=404, detail="Account not found")
   487|    fx_status = "not_needed" if request.currency.upper() == "CHF" else "missing"
   488|    warnings = [] if fx_status == "not_needed" else ["FX fehlt: Speichern bleibt möglich, CHF-Wert wird leer markiert."]
   489|    amount_chf = decimal_text(request.amount, 2) if fx_status == "not_needed" else None
   490|    return PreviewResponse(preview_id=_uuid("preview"), summary=f"{request.transaction_type} {request.currency.upper()} {request.amount}", amount_chf=amount_chf, fx_status=fx_status, warnings=warnings)
   491|
   492|
   493|def confirm_cash(conn: Connection, request: CashConfirmRequest) -> ConfirmResponse:
   494|    if not request.confirm:
   495|        raise HTTPException(status_code=400, detail="Explicit confirm required")
   496|    preview = preview_cash(conn, request)
   497|    balance_id = _uuid("tx")
   498|    ts = _now()
   499|    fx_rate = "1" if request.currency.upper() == "CHF" else None
   500|    quality = "ok" if request.currency.upper() == "CHF" else "missing_fx"
   501|    amount = str(_decimal(request.amount, "amount"))
   502|    transaction_type = _normalise_transaction_type(request.transaction_type, "cash")
   503|    conn.execute(
   504|        """
   505|        INSERT INTO transactions(transaction_id, transaction_type, account_id, trade_date, gross_amount_original, net_amount_original, currency_original, fx_rate_to_chf, fx_status, gross_amount_chf, net_amount_chf, source_type, is_confirmed, quality_status, notes, created_at)
   506|        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vue_manual_cash', 1, ?, ?, ?)
   507|        """,
   508|        (balance_id, transaction_type, request.account_id, request.booking_date, amount, amount, request.currency.upper(), fx_rate, "ok" if fx_rate else "missing", preview.amount_chf, preview.amount_chf, quality, request.note, ts),
   509|    )
   510|    conn.execute(
   511|        """
   512|        INSERT OR IGNORE INTO cash_balances(cash_balance_id, account_id, balance_date, currency, amount_original, fx_rate_to_chf, amount_chf, source_type, quality_status, notes, created_at)
   513|        VALUES (?, ?, ?, ?, ?, ?, ?, 'vue_manual_cash', ?, ?, ?)
   514|        """,
   515|        (_uuid("cash"), request.account_id, request.booking_date, request.currency.upper(), amount, fx_rate, preview.amount_chf, quality, request.note, ts),
   516|    )
   517|    audit_id = _audit(conn, action="cash_confirm", entity_type="cash_position", entity_id=f"{request.account_id}:{request.currency.upper()}", payload=request.model_dump(), note=request.note, quality_status=quality)
   518|    conn.commit()
   519|    return ConfirmResponse(status="confirmed", entity_id=f"{request.account_id}:{request.currency.upper()}", audit_id=audit_id, message="Cash gespeichert")
   520|
   521|
   522|def preview_position(conn: Connection, request: PositionPreviewRequest) -> PreviewResponse:
   523|    _decimal(request.quantity, "quantity")
   524|    account = conn.execute("SELECT account_id FROM accounts WHERE account_id=?", (request.account_id,)).fetchone()
   525|    if not account:
   526|        raise HTTPException(status_code=404, detail="Account not found")
   527|    if _is_manual_total_value_account(conn, request.account_id):
   528|        raise HTTPException(status_code=422, detail="TrueWealth/managed portfolio is a manually valued Gesamtwert-Konto; Einzelpositionen sind hier nicht erlaubt.")
   529|    inst = _instrument_preview_row(conn, request)
   530|    if not inst:
   531|        raise HTTPException(status_code=404, detail="Instrument not found")
   532|    currency = request.currency.upper()
   533|    fx_status, fx_rate, _fx_source, fx_warning = _fx_for_explicit_save(conn, currency=currency, trade_date=request.trade_date)
   534|    warnings = [fx_warning] if fx_warning else []
   535|    amount_chf = _chf_amount(request.cost_basis_original, Decimal(fx_rate) if fx_rate else None)
   536|    return PreviewResponse(preview_id=_uuid("preview"), asset_class=request.asset_class.lower(), summary=f"{inst['name']} · {request.quantity} · {currency}", amount_chf=amount_chf, fx_status=fx_status, warnings=warnings)
   537|
   538|
   539|def confirm_position(conn: Connection, request: PositionConfirmRequest) -> ConfirmResponse:
   540|    if not request.confirm:
   541|        raise HTTPException(status_code=400, detail="Explicit confirm required")
   542|    preview_position(conn, request)
   543|    instrument_id = _ensure_instrument_from_request(conn, request)
   544|    if instrument_id is None:
   545|        raise HTTPException(status_code=404, detail="Instrument not found")
   546|    _cache_candidate_price(conn, instrument_id=instrument_id, candidate_id=request.candidate_id)
   547|    tx_id = _uuid("tx")
   548|    ts = _now()
   549|    cost = request.cost_basis_original or "0"
   550|    fx_status, fx_rate, fx_source, _fx_warning = _fx_for_explicit_save(conn, currency=request.currency, trade_date=request.trade_date)
   551|    gross_chf = _chf_amount(cost, Decimal(fx_rate) if fx_rate else None)
   552|    quality_parts = []
   553|    if not request.cost_basis_original:
   554|        quality_parts.append("cost_basis_missing")
   555|    if fx_status == "missing":
   556|        quality_parts.append("missing_fx")
   557|    quality = ";".join(quality_parts) or "ok"
   558|    transaction_type = _normalise_transaction_type(request.transaction_type)
   559|    conn.execute(
   560|        """
   561|        INSERT INTO transactions(transaction_id, transaction_type, account_id, instrument_id, trade_date, quantity, gross_amount_original, net_amount_original, currency_original, fx_rate_to_chf, fx_source, fx_status, gross_amount_chf, net_amount_chf, source_type, is_confirmed, quality_status, notes, created_at)
   562|        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vue_manual_position', 1, ?, ?, ?)
   563|        """,
   564|        (tx_id, transaction_type, request.account_id, instrument_id, request.trade_date, str(_decimal(request.quantity, "quantity")), cost, cost, request.currency.upper(), fx_rate, fx_source, fx_status, gross_chf, gross_chf, quality, request.note, ts),
   565|    )
   566|    audit_id = _audit(conn, action="position_confirm", entity_type="position", entity_id=f"{request.account_id}:{instrument_id}", payload=request.model_dump(), note=request.note, quality_status=quality)
   567|    conn.commit()
   568|    return ConfirmResponse(status="confirmed", entity_id=tx_id, audit_id=audit_id, message="Position gespeichert")
   569|
   570|
   571|def get_cash_position_detail(conn: Connection, position_id: str) -> CashPositionDetail:
   572|    position = next((p for p in get_cash_summary(conn).positions if p.id == position_id), None)
   573|    account_id, currency = position_id.split(":", 1)
   574|    if not position:
   575|        row = conn.execute(
   576|            """
   577|            SELECT p.name AS platform, a.account_name, cb.currency, cb.amount_original, cb.amount_chf, cb.quality_status, MAX(cb.created_at) AS last_change
   578|            FROM cash_balances cb JOIN accounts a ON a.account_id = cb.account_id JOIN platforms p ON p.platform_id = a.platform_id
   579|            WHERE cb.account_id=? AND cb.currency=?
   580|            GROUP BY cb.account_id, cb.currency
   581|            """,
   582|            (account_id, currency),
   583|        ).fetchone()
   584|        if not row:
   585|            raise HTTPException(status_code=404, detail="Cash position not found")
   586|        payload = {"id": position_id, "platform": row["platform"], "account_label": row["account_name"], "currency": row["currency"], "amount": optional_decimal_text(row["amount_original"]) or "0", "amount_chf": optional_decimal_text(row["amount_chf"], 2), "status": row["quality_status"] or "ok"}
   587|        last_change = row["last_change"]
   588|    else:
   589|        payload = position.model_dump()
   590|        row = conn.execute("SELECT MAX(created_at) AS last_change FROM cash_balances WHERE account_id=? AND currency=?", (account_id, currency)).fetchone()
   591|        last_change = row["last_change"] if row else None
   592|    return CashPositionDetail(**payload, last_change=last_change, available_actions=[ActionItem(label="Einzahlung", enabled=True), ActionItem(label="Auszahlung", enabled=True), ActionItem(label="Korrektur", enabled=True), ActionItem(label="Verlauf", enabled=True)])
   593|
   594|
   595|def get_equity_position_detail(conn: Connection, position_id: str):
   596|    account_id, instrument_id = position_id.split(":", 1)
   597|    try:
   598|        base = get_equity_position(conn, position_id)
   599|        payload = base.model_dump()
   600|    except HTTPException:
   601|        row = conn.execute(
   602|            """
   603|            SELECT i.name, i.ticker, i.isin, i.asset_class, i.currency, a.account_name
   604|            FROM instruments i JOIN accounts a ON a.account_id = ?
   605|            WHERE i.instrument_id = ?
   606|            """,
   607|            (account_id, instrument_id),
   608|        ).fetchone()
   609|        if not row:
   610|

Use offset=610 to continue reading (showing 470-609 of 1117 lines)


  1070|        raise HTTPException(status_code=422, detail="Account name required")
  1071|    account_type = (request.account_type or "brokerage").lower()
  1072|    platform_id = _ensure_platform(conn, request.platform_name or "Anderes Konto", account_type=account_type, currency=request.currency)
  1073|    account_id = _uuid("acct")
  1074|    now = _now()
  1075|    conn.execute("INSERT INTO accounts(account_id, platform_id, account_name, account_type, currency, is_active, notes, created_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?)", (account_id, platform_id, name, account_type, request.currency.upper(), request.note, now))
  1076|    audit_id = _audit(conn, action="account_create_confirm", entity_type="account", entity_id=account_id, payload={"account_name": name, "account_type": account_type}, note=request.note, quality_status="ok")
  1077|    conn.commit()
  1078|    return ConfirmResponse(status="confirmed", entity_id=account_id, audit_id=audit_id, message="Konto erstellt")
  1079|
  1080|
  1081|def preview_account_value(conn: Connection, account_id: str, request: AccountValuePreviewRequest) -> PreviewResponse:
  1082|    if not _is_manual_total_value_account(conn, account_id):
  1083|        raise HTTPException(status_code=422, detail="Account is not a manually valued managed portfolio")
  1084|    value = _decimal(request.total_value_chf, "total_value_chf")
  1085|    return PreviewResponse(preview_id=_uuid("preview"), asset_class="managed_portfolio", summary=f"Gesamtwert CHF per {request.valuation_date}", amount_chf=optional_decimal_text(str(value), 2), fx_status="not_needed", warnings=[])
  1086|
  1087|
  1088|def confirm_account_value(conn: Connection, account_id: str, request: AccountValueConfirmRequest) -> ConfirmResponse:
  1089|    if not request.confirm:
  1090|        raise HTTPException(status_code=400, detail="Explicit confirm required")
  1091|    preview_account_value(conn, account_id, request)
  1092|    value = _decimal(request.total_value_chf, "total_value_chf")
  1093|    snapshot_id = _uuid("acctval")
  1094|    now = _now()
  1095|    conn.execute(
  1096|        """
  1097|        INSERT INTO account_value_snapshots(snapshot_id, account_id, valuation_date, total_value_chf, currency, source_type, quality_status, notes, created_at)
  1098|        VALUES (?, ?, ?, ?, 'CHF', 'manual_total_value', 'ok', ?, ?)
  1099|        """,
  1100|        (snapshot_id, account_id, request.valuation_date, optional_decimal_text(str(value), 2), request.note, now),
  1101|    )
  1102|    audit_id = _audit(conn, action="account_value_confirm", entity_type="account", entity_id=account_id, payload={"snapshot_id": snapshot_id, "valuation_date": request.valuation_date}, note=request.note, quality_status="ok")
  1103|    conn.commit()
  1104|    return ConfirmResponse(status="confirmed", entity_id=snapshot_id, audit_id=audit_id, message="Gesamtwert gespeichert · Audit geschrieben")
  1105|
  1106|
  1107|def confirm_deactivate_account(conn: Connection, account_id: str, request: GenericConfirmRequest) -> ConfirmResponse:
  1108|    if not request.confirm:
  1109|        raise HTTPException(status_code=400, detail="Explicit confirm required")
  1110|    has_history = int(conn.execute("SELECT COUNT(*) FROM transactions WHERE account_id=?", (account_id,)).fetchone()[0] or 0) + int(conn.execute("SELECT COUNT(*) FROM cash_balances WHERE account_id=?", (account_id,)).fetchone()[0] or 0)
  1111|    now = _now()
  1112|    conn.execute("UPDATE accounts SET is_active=0, updated_at=? WHERE account_id=?", (now, account_id))
  1113|    action = "account_deactivate_confirm"
  1114|    message = "Konto deaktiviert" if has_history else "Konto deaktiviert"
  1115|    audit_id = _audit(conn, action=action, entity_type="account", entity_id=account_id, payload={"history_rows": has_history}, note=request.note, quality_status="ok")
  1116|    conn.commit()
  1117|    return ConfirmResponse(status="confirmed", entity_id=account_id, audit_id=audit_id, message=message)
  1118|


Explanation:
**Considering migration info**

I need to make sure I have the migration info covered regarding the account_value creation process. It's essential to clarify the necessary steps and details around this. I’ll look into what particular lines of information are required. Getting this right will help ensure a smooth migration, so I'll pay attention to all relevant aspects while gathering this data. I want to support whatever needs to be done properly.

python - <<'PY'
from pathlib import Path
s=Path('src/jarvis_finance/storage/migrations.py').read_text()
for token in ['account_value_snapshots','cash_balances','accounts ADD COLUMN']:
 print('\nTOKEN',token)
 for i,l in enumerate(s.splitlines(),1):
  if token in l:
   print(i,l[:180])
PY


Output:
TOKEN account_value_snapshots
1486         CREATE TABLE IF NOT EXISTS account_value_snapshots (
1498         CREATE INDEX IF NOT EXISTS idx_account_value_snapshots_account_date ON account_value_snapshots(account_id, valuation_date, created_at);

TOKEN cash_balances

TOKEN accounts ADD COLUMN


  1460|            fetched_at TEXT NOT NULL,
  1461|            UNIQUE(instrument_id, provider, provider_symbol, range_key, interval_key, timestamp)
  1462|        );
  1463|        CREATE INDEX IF NOT EXISTS idx_equity_intraday_candles_lookup ON equity_intraday_candles(instrument_id, range_key, interval_key, fetched_at);
  1464|        CREATE TABLE IF NOT EXISTS crypto_price_points (
  1465|            point_id TEXT PRIMARY KEY,
  1466|            asset_id TEXT NOT NULL REFERENCES crypto_assets(asset_id),
  1467|            provider TEXT NOT NULL,
  1468|            provider_symbol TEXT,
  1469|            timestamp TEXT NOT NULL,
  1470|            price TEXT NOT NULL,
  1471|            currency TEXT NOT NULL,
  1472|            interval TEXT NOT NULL DEFAULT 'quote',
  1473|            source_quality TEXT NOT NULL DEFAULT 'fresh',
  1474|            fetched_at TEXT NOT NULL,
  1475|            UNIQUE(asset_id, provider, provider_symbol, timestamp, interval, currency)
  1476|        );
  1477|        CREATE INDEX IF NOT EXISTS idx_crypto_price_points_asset_time ON crypto_price_points(asset_id, currency, timestamp);
  1478|        """
  1479|    )
  1480|    _add_missing_columns(conn, "crypto_assets", {"binance_symbol": "TEXT", "binance_mapping_status": "TEXT NOT NULL DEFAULT 'missing'"})
  1481|
  1482|
  1483|def _create_account_value_snapshot_tables(conn: Connection) -> None:
  1484|    conn.executescript(
  1485|        """
  1486|        CREATE TABLE IF NOT EXISTS account_value_snapshots (
  1487|            snapshot_id TEXT PRIMARY KEY,
  1488|            account_id TEXT NOT NULL REFERENCES accounts(account_id),
  1489|            valuation_date TEXT NOT NULL,
  1490|            total_value_chf TEXT NOT NULL,
  1491|            currency TEXT NOT NULL DEFAULT 'CHF',
  1492|            source_type TEXT NOT NULL DEFAULT 'manual_total_value',
  1493|            quality_status TEXT NOT NULL DEFAULT 'ok',
  1494|            notes TEXT,
  1495|            created_at TEXT NOT NULL,
  1496|            updated_at TEXT
  1497|        );
  1498|        CREATE INDEX IF NOT EXISTS idx_account_value_snapshots_account_date ON account_value_snapshots(account_id, valuation_date, created_at);
  1499|        """
  1500|    )
  1501|
  1502|
  1503|def _apply_compat_migrations(conn: Connection) -> None:
  1504|    _add_missing_instrument_columns(conn)
  1505|    for table, text_columns in TEXT_AFFINITY_COLUMNS.items():
  1506|        _rebuild_table_with_text_columns(conn, table, text_columns)
  1507|    _create_broker_bank_mapping_tables(conn)
  1508|    _create_broker_import_review_items(conn)
  1509|    _create_broker_import_execution_plans(conn)
  1510|    _add_transaction_void_columns(conn)
  1511|    _create_fx_market_data_tables(conn)
  1512|    _create_market_quote_chart_tables(conn)
  1513|    _create_account_value_snapshot_tables(conn)
  1514|    _create_instrument_import_candidates(conn)
  1515|    _create_budget_phase1_tables(conn)
  1516|    _create_budget_phase11_tables(conn)
  1517|    _create_budget_phase14_tables(conn)
  1518|    _create_budget_phase15_tables(conn)
  1519|    _create_budget_phase18_tables(conn)
  1520|    _create_budget_phase19_tables(conn)
  1521|    _create_budget_import_production_v1_tables(conn)
  1522|    _create_budget_categories_ux_fix_tables(conn)
  1523|    _create_budget_planning_forecast_v1_tables(conn)
  1524|    _create_budget_fixed_costs_subscriptions_v1_tables(conn)
  1525|    _create_budget_monthly_import_rule_learning_v1_tables(conn)
  1526|    _create_grocery_optimizer_v1_tables(conn)
  1527|    _add_grocery_price_provider_v1_columns(conn)
  1528|    _create_grocery_matching_learning_v2_tables(conn)
  1529|
  1530|
  1531|def apply_migrations(conn: Connection) -> None:
  1532|    conn.executescript(INITIAL_SCHEMA_SQL)
  1533|    existing_initial = conn.execute("SELECT 1 FROM schema_migrations WHERE version = 1").fetchone()
  1534|    if not existing_initial:
  1535|        conn.execute(
  1536|            "INSERT INTO schema_migrations(version, name, applied_at, checksum) VALUES (?, ?, ?, ?)",
  1537|            (1, "001_initial_schema", utc_now(), checksum_sql(INITIAL_SCHEMA_SQL)),
  1538|        )
  1539|    _apply_compat_migrations(conn)
  1540|    existing = conn.execute("SELECT 1 FROM schema_migrations WHERE version = ?", (MIGRATION_VERSION,)).fetchone()
  1541|    if not existing:
  1542|        conn.execute(
  1543|            "INSERT INTO schema_migrations(version, name, applied_at, checksum) VALUES (?, ?, ?, ?)",
  1544|            (MIGRATION_VERSION, MIGRATION_NAME, utc_now(), checksum_sql(MIGRATION_NAME)),
  1545|        )
  1546|    conn.commit()
  1547|


Explanation:
**Planning schema development**

I need to start working on the initial schema, possibly for accounts and cash balances. I'm considering adding compatible columns that would help enhance functionality. It looks like I also need to implement thorough testing to ensure everything works as it should. Oh, and I can't forget about defining the frontend API types as well. Time to get organized and make sure each part is well thought out!


     1|export type QualityStatus = 'ok' | 'warning' | 'critical'
     2|export interface HealthStatus { status: string; app: string; api_version: string; mode: string }
     3|export interface RuntimeStatus { db_available: boolean; db_path: string; base_currency: string; runtime_outside_repo: boolean; write_mode: string }
     4|export interface ProviderStatus { provider: string; status: string; message: string }
     5|export interface Overview { total_value_chf: string; crypto_value_chf: string; equity_value_chf: string; postfinance_equity_value_chf?: string; truewealth_value_chf?: string; cash_value_chf: string; unpriced_positions_count: number; critical_alerts_count: number; last_price_update: string | null; data_quality_status: QualityStatus }
     6|export interface CryptoPosition { asset_id: string; name: string; symbol: string; quantity_total: string; price_chf: string | null; market_value_chf: string | null; portfolio_share_pct: string; wallet_count: number; price_status: string; last_price_update: string | null; coingecko_url: string | null }
     7|export interface WalletAllocation { wallet_id: string; wallet: string; quantity: string; market_value_chf: string | null }
     8|export interface CoinGeckoInfo { market_cap_rank?: number | null; market_cap?: string | null; volume_24h?: string | null; change_24h_pct?: string | null; change_7d_pct?: string | null; homepage?: string | null; image_url?: string | null }
     9|export interface CryptoPositionDetail extends CryptoPosition { wallet_allocations: WalletAllocation[]; coingecko_info?: CoinGeckoInfo | null; available_actions?: ActionItem[]; price_history?: PricePoint[]; read_only_note: string }
    10|export interface EquityPosition { position_id: string; instrument_id?: string | null; name: string; ticker: string; isin: string; account: string; asset_class: string; quantity: string; currency: string; price: string | null; market_value_chf: string | null; status: string }
    11|export interface CashPosition { id: string; platform: string; account_label: string; currency: string; amount: string; amount_chf: string | null; status: string }
    12|export interface CashSummary { base_currency: string; cash_chf: string; positions: CashPosition[] }
    13|export interface WalletSummary { wallet_id: string; name: string; wallet_type: string; provider: string; coin_count: number; market_value_chf: string | null; status: string }
    14|export interface WalletCoin { asset_id: string; name: string; symbol: string; quantity: string; market_value_chf: string | null; status: string }
    15|export interface WalletDetail extends WalletSummary { coins: WalletCoin[] }
    16|export interface FinanceCommandCenter { purpose: string; month: string; kpis: Record<string, any>; monthly_close: { checklist: Record<string, any> }; import_status: Array<Record<string, any>>; budget_status_compact: Record<string, any>; fixed_costs_compact: Record<string, any>; portfolio_crypto_compact: Record<string, any>; todos: Array<Record<string, any>>; data_quality: Record<string, any> }
    17|export interface ReportMetadata { report_id: string; report_type: string; title: string; format: string; generated_at: string; data_quality_status: string; location_label: string; path_display: string }
    18|
    19|
    20|export interface ActionItem { label: string; enabled: boolean; reason?: string | null }
    21|export interface PricePoint { date: string; value: string; currency: string; provider?: string | null; quality_status?: string | null }
    22|export interface MarketChartPoint { timestamp: string; price: string; currency: string; provider?: string | null; quality_status?: string | null }
    23|export interface MarketQuoteResponse { latest_price: string | null; currency: string | null; change_abs: string | null; change_pct: string | null; open: string | null; high: string | null; low: string | null; close: string | null; volume: string | null; provider: string | null; provider_symbol: string | null; fetched_at: string | null; quality_status: string; warnings: string[]; chart_points: MarketChartPoint[] }
    24|export interface MarketChartResponse extends MarketQuoteResponse { chart_points: MarketChartPoint[] }
    25|export interface EquityCandle { time: string; open: string; high: string; low: string; close: string; volume?: string | null }
    26|export interface EquityCandlesResponse { instrument_id?: string | null; symbol: string | null; provider_symbol?: string | null; range: string; interval: string; provider: string; quality_status: string; candles: EquityCandle[]; volume: Array<number | null>; currency: string | null; exchange_timezone: string | null; fetched_at: string | null; warnings: string[] }
    27|export interface EquityFxUpdateResponse { action: string; instrument_id: string; currencies: string[]; updated: number; warnings: string[]; render_provider_calls: boolean }
    28|export interface EquityPositionDetail extends EquityPosition { fx_status: string; cost_basis_status: string; day_change_chf: string | null; last_trade_date: string | null; provider: string | null; source_url: string | null; provider_symbol?: string | null; exchange?: string | null; price_currency?: string | null; fx_rate_to_chf?: string | null; data_source?: string | null; available_actions: ActionItem[]; price_history: PricePoint[] }
    29|export interface CashPositionDetail extends CashPosition { last_change: string | null; available_actions: ActionItem[] }
    30|export interface InstrumentSearchCandidate { candidate_id: string; label: string; asset_class: string; isin: string; ticker: string; exchange: string; currency: string; provider: string; provider_symbol?: string; exchange_name?: string; mic?: string; country?: string; security_type?: string; last_price?: string | null; price_currency?: string | null; price_date?: string | null; confidence_label: string; review_required: boolean }
    31|export interface ReferenceAccount { account_id: string; label: string; account_type: string; currency: string; valuation_mode?: string; portfolio_bucket?: string; allow_position_adds?: boolean }
    32|export interface PreviewResponse { preview_id: string; asset_class?: string | null; summary: string; amount_chf?: string | null; fx_status: string; warnings: string[] }
    33|export interface ConfirmResponse { status: string; entity_id: string; audit_id: string; message: string }
    34|export interface RemovePositionPreview { position_id: string; transaction_count: number; safe_to_remove: boolean; reason: string; affected_transactions: number; consequences: string[] }
    35|export interface ProviderSearchResponse { candidates: InstrumentSearchCandidate[]; warnings: string[]; selection_required: boolean }
    36|export interface ContainerPreviewRequest { name?: string | null; wallet_type?: string | null; platform_name?: string | null; account_name?: string | null; account_type?: string | null; currency?: string; note?: string }
    37|export interface ContainerConfirmRequest extends ContainerPreviewRequest { preview_id?: string | null; confirm: boolean }
    38|export interface PositionPreviewRequest { asset_class: string; account_id: string; instrument_id?: string | null; candidate_id?: string | null; name?: string | null; ticker?: string | null; isin?: string | null; quantity: string; currency: string; trade_date: string; transaction_type: string; cost_basis_original?: string | null; note: string }
    39|export interface CashPreviewRequest { account_id: string; currency: string; amount: string; transaction_type: string; booking_date: string; note: string }
    40|export interface CryptoActionRequest { asset_id?: string | null; coin_name?: string | null; symbol?: string | null; wallet_id: string; target_wallet_id?: string | null; quantity: string; fee_quantity?: string | null; transaction_type: string; effective_date: string; note: string; coingecko_id?: string | null }
    41|export interface CryptoActionConfirmRequest extends CryptoActionRequest { preview_id: string; confirm: boolean }
    42|export interface EquitySellRequest { position_id: string; cash_account_id?: string | null; quantity: string; price_original: string; currency: string; trade_date: string; fees_original?: string | null; note: string }
    43|export interface EquitySellConfirmRequest extends EquitySellRequest { preview_id: string; confirm: boolean }
    44|export interface EquityDividendRequest { position_id: string; cash_account_id: string; gross_amount: string; currency: string; swiss_tax?: string | null; foreign_tax?: string | null; other_deductions?: string | null; payment_date: string; note: string }
    45|export interface EquityDividendConfirmRequest extends EquityDividendRequest { preview_id: string; confirm: boolean }
    46|


Explanation:
**Considering implementation details**

I'm thinking about implementing a script, possibly needing a database schema and considering using row_factory. It seems I should set up tests using SQLite, which requires creating table schemas. I want to take a closer look at the database helper to see how it all fits together. It's a bit of a puzzle, but I’m piecing it out slowly to ensure everything works smoothly!


     1|from __future__ import annotations
     2|
     3|import sqlite3
     4|from pathlib import Path
     5|
     6|
     7|def connect(db_path: str | Path) -> sqlite3.Connection:
     8|    path = Path(db_path).expanduser()
     9|    path.parent.mkdir(parents=True, exist_ok=True)
    10|    conn = sqlite3.connect(path, check_same_thread=False)
    11|    conn.row_factory = sqlite3.Row
    12|    conn.execute("PRAGMA foreign_keys = ON")
    13|    return conn
    14|
    15|
    16|def connect_memory() -> sqlite3.Connection:
    17|    conn = sqlite3.connect(":memory:", check_same_thread=False)
    18|    conn.row_factory = sqlite3.Row
    19|    conn.execute("PRAGMA foreign_keys = ON")
    20|    return conn
    21|


Ich habe die relevante Codebasis gefunden (`~/.hermes/repos/FinanceManager`). Bestehende Basis: es gibt bereits `account_value_snapshots` für TrueWealth-artige Gesamtwerte und eine Cash-API, aber noch keinen Cash-`balance_mode`, keine Manual-Balance-Snapshots pro Cash-Konto und keine Cash-/TrueWealth-Verwaltungs-DTOs. Ich schreibe jetzt die RED-Tests dagegen.

from __future__ import annotations

from decimal import Decimal

import pytest
from fastapi import HTTPException

from jarvis_finance.api.schemas.positions import (
    AccountValueConfirmRequest,
    AccountValuePreviewRequest,
    CashSnapshotConfirmRequest,
    CashSnapshotPreviewRequest,
)
from jarvis_finance.services.cash_service import (
    CANONICAL_CASH_ACCOUNTS,
    confirm_cash_snapshot,
    ensure_canonical_cash_accounts,
    get_cash_summary,
    preview_cash_snapshot,
)
from jarvis_finance.services.manual_entry_service import confirm_account_value, preview_account_value, preview_position
from jarvis_finance.services.portfolio_service import get_overview
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations

def db():
    conn = connect_memory()
    apply_migrations(conn)
    return conn

def test_canonical_cash_accounts_are_present_with_expected_modes_and_audit():
    conn = db()

    result = ensure_canonical_cash_accounts(conn, created_by="test")

    assert result["created"] == 6
    assert result["duplicates"] == []
    rows = conn.execute(
        """
        SELECT a.account_name, a.account_type, a.currency, a.balance_mode, p.name AS platform
        FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
        WHERE a.account_type='cash'
        ORDER BY a.account_name
        """
    ).fetchall()
    by_name = {row["account_name"]: row for row in rows}
    assert set(by_name) == {item["account_name"] for item in CANONICAL_CASH_ACCOUNTS}
    assert by_name["Raiffeisen"]["balance_mode"] == "csv_calculated"
    assert by_name["AKB Haushaltskonto"]["balance_mode"] == "csv_calculated"
    assert by_name["PostFinance Cash-Konto"]["balance_mode"] == "manual"
    assert by_name["PostFinance Cash-Konto"]["platform"] == "PostFinance Cash"
    assert all(row["currency"] == "CHF" for row in rows)
    audits = conn.execute("SELECT action FROM audit_log WHERE action='cash_account_seed_confirm'").fetchall()
    assert len(audits) == 6

def test_duplicate_cash_accounts_return_consolidation_preview_without_silent_merge():
    conn = db()
    ensure_canonical_cash_accounts(conn)
    pf_platform = conn.execute("SELECT platform_id FROM platforms WHERE name='PostFinance Cash'").fetchone()[0]
    conn.execute(
        "INSERT INTO accounts(account_id, platform_id, account_name, account_type, currency, balance_mode, created_at) VALUES ('acct_dup_pf_cash', ?, 'PostFinance Cash-Konto', 'cash', 'CHF', 'manual', '2026-01-01')",
        (pf_platform,),
    )
    conn.commit()

    result = ensure_canonical_cash_accounts(conn)

    assert result["created"] == 0
    assert result["duplicates"] == ["PostFinance Cash-Konto"]
    count = conn.execute("SELECT COUNT(*) FROM accounts WHERE account_name='PostFinance Cash-Konto'").fetchone()[0]
    assert count == 2

def test_manual_cash_snapshot_preview_confirm_audit_and_dashboard_value_for_manual_account():
    conn = db()
    ensure_canonical_cash_accounts(conn)
    account_id = conn.execute("SELECT account_id FROM accounts WHERE account_name='PostFinance Cash-Konto'").fetchone()[0]

    preview = preview_cash_snapshot(conn, CashSnapshotPreviewRequest(account_id=account_id, snapshot_type="manual_balance", balance_date="2026-05-20", amount_chf="1234.56", note="Abgleich"))
    assert preview.amount_chf == "1234.56"
    assert preview.fx_status == "not_needed"

    confirmed = confirm_cash_snapshot(conn, CashSnapshotConfirmRequest(account_id=account_id, snapshot_type="manual_balance", balance_date="2026-05-20", amount_chf="1234.56", note="Abgleich", preview_id=preview.preview_id, confirm=True))

    assert confirmed.status == "confirmed"
    assert confirmed.audit_id.startswith("audit_")
    summary = get_cash_summary(conn)
    row = next(item for item in summary.positions if item.account_label == "PostFinance Cash-Konto")
    assert row.balance_mode == "manual"
    assert row.manual_balance_chf == "1234.56"
    assert row.used_value_chf == "1234.56"
    assert row.calculated_balance_chf == "0.00"
    assert row.difference_chf == "1234.56"
    assert row.last_manual_reconciliation == "2026-05-20"

def test_csv_calculated_account_uses_calculated_value_and_manual_as_control_difference():
    conn = db()
    ensure_canonical_cash_accounts(conn)
    account_id = conn.execute("SELECT account_id FROM accounts WHERE account_name='Raiffeisen'").fetchone()[0]
    confirm_cash_snapshot(conn, CashSnapshotConfirmRequest(account_id=account_id, snapshot_type="csv_anchor_balance", balance_date="2026-05-01", amount_chf="1000.00", note="Start", preview_id="p", confirm=True))
    conn.execute(
        "INSERT INTO transactions(transaction_id, transaction_type, account_id, trade_date, gross_amount_original, net_amount_original, currency_original, fx_rate_to_chf, fx_status, gross_amount_chf, net_amount_chf, source_type, is_confirmed, quality_status, created_at) VALUES ('tx_csv_1', 'csv_import', ?, '2026-05-05', '250.00', '250.00', 'CHF', '1', 'ok', '250.00', '250.00', 'csv_import', 1, 'ok', '2026-05-05')",
        (account_id,),
    )
    confirm_cash_snapshot(conn, CashSnapshotConfirmRequest(account_id=account_id, snapshot_type="manual_balance", balance_date="2026-05-20", amount_chf="1300.00", note="Kontrolle", preview_id="p", confirm=True))

    row = next(item for item in get_cash_summary(conn).positions if item.account_label == "Raiffeisen")

    assert row.balance_mode == "csv_calculated"
    assert row.csv_anchor_balance_chf == "1000.00"
    assert row.calculated_balance_chf == "1250.00"
    assert row.manual_balance_chf == "1300.00"
    assert row.used_value_chf == "1250.00"
    assert row.difference_chf == "50.00"
    assert row.status == "Abgleich offen"

def test_truewealth_manual_value_history_and_position_block():
    conn = db()
    platform_id = "plat_truewealth"
    account_id = "acct_truewealth"
    conn.execute("INSERT INTO platforms(platform_id, name, platform_type, default_currency, created_at) VALUES (?, 'TrueWealth', 'roboadvisor', 'CHF', '2026-01-01')", (platform_id,))
    conn.execute("INSERT INTO accounts(account_id, platform_id, account_name, account_type, currency, created_at) VALUES (?, ?, 'TrueWealth Gesamtwert', 'managed_portfolio', 'CHF', '2026-01-01')", (account_id, platform_id))
    conn.execute("INSERT INTO instruments(instrument_id, asset_class, name, currency, created_at) VALUES ('inst_vti', 'etf', 'Do not allow', 'USD', '2026-01-01')")
    conn.commit()

    preview = preview_account_value(conn, account_id, AccountValuePreviewRequest(valuation_date="2026-05-20", total_value_chf="55555.55", note="Monat"))
    assert preview.amount_chf == "55555.55"
    confirmed = confirm_account_value(conn, account_id, AccountValueConfirmRequest(valuation_date="2026-05-20", total_value_chf="55555.55", note="Monat", preview_id=preview.preview_id, confirm=True))
    assert confirmed.audit_id.startswith("audit_")
    overview = get_overview(conn)
    assert overview.truewealth_value_chf == "55555.55"
    assert overview.total_value_chf == "55555.55"

    with pytest.raises(HTTPException) as exc:
        preview_position(conn, type("Req", (), {"account_id": account_id, "quantity": "1", "currency": "USD", "trade_date": "2026-05-20", "asset_class": "etf", "instrument_id": "inst_vti", "candidate_id": None, "name": None, "ticker": None, "isin": None, "cost_basis_original": "1", "transaction_type": "buy"})())
    assert "Einzelpositionen" in exc.value.detail

def test_portfolio_uses_cash_balance_modes_without_double_counting_truewealth():
    conn = db()
    ensure_canonical_cash_accounts(conn)
    manual_id = conn.execute("SELECT account_id FROM accounts WHERE account_name='AKB gemeinsames Konto'").fetchone()[0]
    csv_id = conn.execute("SELECT account_id FROM accounts WHERE account_name='AKB Haushaltskonto'").fetchone()[0]
    confirm_cash_snapshot(conn, CashSnapshotConfirmRequest(account_id=manual_id, snapshot_type="manual_balance", balance_date="2026-05-20", amount_chf="2000.00", note="", preview_id="p", confirm=True))
    confirm_cash_snapshot(conn, CashSnapshotConfirmRequest(account_id=csv_id, snapshot_type="csv_anchor_balance", balance_date="2026-05-01", amount_chf="3000.00", note="", preview_id="p", confirm=True))

    overview = get_overview(conn)

    assert overview.cash_value_chf == "5000.00"
    assert overview.truewealth_value_chf == "0.00"
    assert Decimal(overview.total_value_chf) >= Decimal("5000.00")


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/tests/unit/test_cash_truewealth_management.py">
ERROR [11:5] "CashSnapshotConfirmRequest" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [12:5] "CashSnapshotPreviewRequest" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [15:5] "CANONICAL_CASH_ACCOUNTS" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [16:5] "confirm_cash_snapshot" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [17:5] "ensure_canonical_cash_accounts" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [19:5] "preview_cash_snapshot" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [92:16] Cannot access attribute "balance_mode" for class "CashPosition"
  Attribute "balance_mode" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [93:16] Cannot access attribute "manual_balance_chf" for class "CashPosition"
  Attribute "manual_balance_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [94:16] Cannot access attribute "used_value_chf" for class "CashPosition"
  Attribute "used_value_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [95:16] Cannot access attribute "calculated_balance_chf" for class "CashPosition"
  Attribute "calculated_balance_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [96:16] Cannot access attribute "difference_chf" for class "CashPosition"
  Attribute "difference_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [97:16] Cannot access attribute "last_manual_reconciliation" for class "CashPosition"
  Attribute "last_manual_reconciliation" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [113:16] Cannot access attribute "balance_mode" for class "CashPosition"
  Attribute "balance_mode" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [114:16] Cannot access attribute "csv_anchor_balance_chf" for class "CashPosition"
  Attribute "csv_anchor_balance_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [115:16] Cannot access attribute "calculated_balance_chf" for class "CashPosition"
  Attribute "calculated_balance_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [116:16] Cannot access attribute "manual_balance_chf" for class "CashPosition"
  Attribute "manual_balance_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [117:16] Cannot access attribute "used_value_chf" for class "CashPosition"
  Attribute "used_value_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [118:16] Cannot access attribute "difference_chf" for class "CashPosition"
  Attribute "difference_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [140:32] Argument of type "Req" cannot be assigned to parameter "request" of type "PositionPreviewRequest" in function "preview_position"
  "Req" is not assignable to "PositionPreviewRequest" [reportArgumentType] (Pyright)
</diagnostics>


Output:
==================================== ERRORS ====================================
________ ERROR collecting tests/unit/test_cash_truewealth_management.py ________
ImportError while importing test module '/home/agent/.hermes/repos/FinanceManager/tests/unit/test_cash_truewealth_management.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../../.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/unit/test_cash_truewealth_management.py:8: in <module>
    from jarvis_finance.api.schemas.positions import (
E   ImportError: cannot import name 'CashSnapshotConfirmRequest' from 'jarvis_finance.api.schemas.positions' (/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/schemas/positions.py)
=========================== short test summary info ============================
ERROR tests/unit/test_cash_truewealth_management.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.35s


RED bestätigt: der Test bricht erwartungsgemäß bei fehlenden Cash-Snapshot-DTOs/Services ab. Jetzt GREEN: minimale Datenmodell-/Service-Erweiterung.


     1|from __future__ import annotations
     2|
     3|from pydantic import BaseModel
     4|
     5|
     6|class AuditEvent(BaseModel):
     7|    audit_id: str
     8|    timestamp: str
     9|    entity_type: str
    10|    entity_id: str
    11|    action: str
    12|    summary: str
    13|    created_by: str = ""
    14|


     1|from __future__ import annotations
     2|
     3|from decimal import Decimal, InvalidOperation
     4|
     5|from jarvis_finance.api.schemas.common import Money, Quantity
     6|from jarvis_finance.dashboard import data as dashboard_data
     7|
     8|
     9|def decimal_text(value: object | None, places: int | None = None) -> str:
    10|    if value is None or str(value).strip() == "":
    11|        return "0" if places is None else "0." + ("0" * places)
    12|    try:
    13|        d = dashboard_data.d(value)
    14|    except (InvalidOperation, ValueError):
    15|        d = Decimal("0")
    16|    if places is not None:
    17|        quant = Decimal("1").scaleb(-places)
    18|        d = d.quantize(quant)
    19|    return format(d, "f")
    20|
    21|
    22|def optional_decimal_text(value: object | None, places: int | None = None) -> str | None:
    23|    if value is None or str(value).strip() == "":
    24|        return None
    25|    text = decimal_text(value, places)
    26|    return text
    27|
    28|
    29|def money_chf(value: object | None, *, display: str | None = None) -> Money | None:
    30|    if value is None or str(value).strip() == "":
    31|        return None
    32|    return Money(amount=decimal_text(value, 2), currency="CHF", display=display or f"CHF {decimal_text(value, 2)}")
    33|
    34|
    35|def money_chf_required(value: object | None) -> Money:
    36|    return money_chf(value) or Money(amount="0.00", currency="CHF", display="CHF 0.00")
    37|
    38|
    39|def quantity(value: object | None, unit: str, *, display: str | None = None) -> Quantity:
    40|    amount = decimal_text(value)
    41|    return Quantity(amount=amount, unit=unit, display=display or f"{amount} {unit}".strip())
    42|
    43|
    44|def user_quality_status(value: object | None) -> str:
    45|    raw = str(value or "").strip().lower()
    46|    if raw in {"ok", "fresh", "aktuell", "resolved", "not_needed"}:
    47|        return "ok"
    48|    if raw in {"critical", "kritisch", "error", "failed", "missing"}:
    49|        return "critical"
    50|    return "warning"
    51|
    52|
    53|def status_label(value: object | None) -> str:
    54|    raw = str(value or "").strip().lower()
    55|    mapping = {
    56|        "ok": "Aktuell",
    57|        "fresh": "Aktuell",
    58|        "not_needed": "Nicht nötig",
    59|        "missing": "Fehlt",
    60|        "stale": "Veraltet",
    61|        "warning": "Warnung",
    62|        "critical": "Kritisch",
    63|        "incomplete": "Unvollständig",
    64|        "partial": "Teilweise",
    65|    }
    66|    return mapping.get(raw, str(value or "Unbekannt"))
    67|


from pathlib import Path
root=Path('/home/agent/.hermes/repos/FinanceManager')
# Patch schemas positions
p=root/'src/jarvis_finance/api/schemas/positions.py'
s=p.read_text()
s=s.replace("class CashPosition(BaseModel):\n    id: str\n    platform: str\n    account_label: str\n    currency: str\n    amount: str\n    amount_chf: str | None = None\n    status: str\n", "class CashSnapshot(BaseModel):\n    snapshot_id: str\n    snapshot_type: str\n    balance_date: str\n    amount_chf: str\n    source: str\n    note: str = \"\"\n    difference_to_previous_chf: str | None = None\n    audit_hint: str | None = None\n\n\nclass CashPosition(BaseModel):\n    id: str\n    account_id: str = \"\"\n    platform: str\n    account_label: str\n    account_type: str = \"cash\"\n    currency: str\n    balance_mode: str = \"manual\"\n    amount: str\n    amount_chf: str | None = None\n    calculated_balance_chf: str = \"0.00\"\n    manual_balance_chf: str | None = None\n    csv_anchor_balance_chf: str | None = None\n    used_value_chf: str = \"0.00\"\n    difference_chf: str = \"0.00\"\n    last_manual_reconciliation: str | None = None\n    last_imported_booking: str | None = None\n    status: str\n")
s=s.replace("class CashPositionDetail(CashPosition):\n    last_change: str | None = None\n    available_actions: list[ActionItem]\n", "class CashPositionDetail(CashPosition):\n    last_change: str | None = None\n    history: list[CashSnapshot] = []\n    available_actions: list[ActionItem]\n")
s=s.replace("class CashConfirmRequest(CashPreviewRequest):\n    preview_id: str\n    confirm: bool\n", "class CashConfirmRequest(CashPreviewRequest):\n    preview_id: str\n    confirm: bool\n\n\nclass CashSnapshotPreviewRequest(BaseModel):\n    account_id: str\n    snapshot_type: str\n    balance_date: str\n    amount_chf: str\n    note: str = \"\"\n\n\nclass CashSnapshotConfirmRequest(CashSnapshotPreviewRequest):\n    preview_id: str\n    confirm: bool\n")
p.write_text(s)
# Patch overview schema
p=root/'src/jarvis_finance/api/schemas/overview.py'
s=p.read_text()
s=s.replace("class CashSummary(BaseModel):\n    base_currency: str\n    cash_chf: str\n    positions: list[CashPosition]\n", "class CashSummary(BaseModel):\n    base_currency: str\n    cash_chf: str\n    manual_accounts_count: int = 0\n    csv_calculated_accounts_count: int = 0\n    open_reconciliation_count: int = 0\n    last_reconciliation_at: str | None = None\n    positions: list[CashPosition]\n")
p.write_text(s)
# Patch migrations
p=root/'src/jarvis_finance/storage/migrations.py'
s=p.read_text()
s=s.replace('MIGRATION_VERSION = 37\nMIGRATION_NAME = "037_equity_intraday_candles_v1"', 'MIGRATION_VERSION = 38\nMIGRATION_NAME = "038_cash_account_snapshots_v1"')
s=s.replace('    _create_account_value_snapshot_tables(conn)\n', '    _create_account_value_snapshot_tables(conn)\n    _create_cash_account_snapshot_tables(conn)\n')
insert='''\n\ndef _create_cash_account_snapshot_tables(conn: Connection) -> None:\n    _add_missing_columns(conn, "accounts", {\n        "balance_mode": "TEXT NOT NULL DEFAULT 'manual'",\n        "portfolio_bucket": "TEXT NOT NULL DEFAULT 'cash'",\n    })\n    conn.executescript(\n        """\n        CREATE TABLE IF NOT EXISTS cash_account_snapshots (\n            snapshot_id TEXT PRIMARY KEY,\n            account_id TEXT NOT NULL REFERENCES accounts(account_id),\n            snapshot_type TEXT NOT NULL,\n            balance_date TEXT NOT NULL,\n            amount_original TEXT NOT NULL,\n            currency TEXT NOT NULL DEFAULT 'CHF',\n            amount_chf TEXT NOT NULL,\n            source TEXT NOT NULL,\n            note TEXT,\n            created_at TEXT NOT NULL,\n            created_by TEXT NOT NULL DEFAULT 'user',\n            audit_id TEXT\n        );\n        CREATE INDEX IF NOT EXISTS idx_cash_account_snapshots_account_type_date ON cash_account_snapshots(account_id, snapshot_type, balance_date, created_at);\n        """\n    )\n'''
s=s.replace('\n\ndef _apply_compat_migrations(conn: Connection) -> None:\n', insert+'\n\ndef _apply_compat_migrations(conn: Connection) -> None:\n')
p.write_text(s)
print('patched schemas/migrations')


Output: patched schemas/migrations


Explanation:
**Determining cash service requirements**

I need to rewrite the cash service, and to do that, I should understand the semantics of cash balances. Maybe I’ll need to sum them up or gather a cash overview. It feels like the first step is clarifying what exactly the cash balances represent before diving into rewriting. I wonder how those elements connect and how I can get a clearer picture to inform my changes. Let's see!

python - <<'PY'
from pathlib import Path
p=Path('src/jarvis_finance/dashboard/data.py')
for i,l in enumerate(p.read_text().splitlines(),1):
 if 'def get_cash_overview' in l or 'cash_balances' in l:
  print(i,l[:160])
PY


Output:
10 from jarvis_finance.ledger.cash import calculate_cash_balances
535 def get_cash_overview(conn: Connection) -> list[dict[str, str]]:
536     result = calculate_cash_balances(conn)


   520|        """, (limit,)
   521|    ).fetchall())
   522|
   523|
   524|def get_platform_overview(conn: Connection) -> list[dict[str, str]]:
   525|    return rowdicts(conn.execute(
   526|        """
   527|        SELECT p.name AS platform, p.platform_type, a.account_name, a.account_type, a.currency,
   528|               a.performance_included, a.is_active
   529|        FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
   530|        ORDER BY p.name, a.account_name
   531|        """
   532|    ).fetchall())
   533|
   534|
   535|def get_cash_overview(conn: Connection) -> list[dict[str, str]]:
   536|    result = calculate_cash_balances(conn)
   537|    rows: list[dict[str, str]] = []
   538|    for (account_id, currency), bal in sorted(result.balances.items()):
   539|        row = conn.execute(
   540|            "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=?",
   541|            (account_id,),
   542|        ).fetchone()
   543|        rows.append({
   544|            "account_id": account_id,
   545|            "platform": row["platform"] if row else "",
   546|            "account_name": row["account_name"] if row else account_id,
   547|            "currency": currency,
   548|            "amount_original": dec_text(bal.amount_original, "0.01"),
   549|            "amount_chf": dec_text(bal.amount_chf, "0.01") if bal.amount_chf is not None else "",
   550|            "quality_status": bal.quality_status,
   551|        })
   552|    return rows
   553|
   554|
   555|def get_positions(conn: Connection) -> list[dict[str, str]]:
   556|    result = calculate_positions(conn)
   557|    rows: list[dict[str, str]] = []
   558|    for (account_id, instrument_id), pos in sorted(result.positions.items()):
   559|        row = conn.execute(
   560|            """
   561|            SELECT a.account_name, p.name AS platform, i.name, i.ticker, i.isin, i.asset_class, i.currency,
   562|                   i.hedge_status, i.instrument_status, i.valuation_policy, i.corporate_action_status,
   563|                   mp.close AS latest_price, mp.currency AS latest_price_currency, mp.price_date AS latest_price_date,
   564|                   mp.provider AS latest_price_provider
   565|            FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
   566|            JOIN instruments i ON i.instrument_id=?
   567|            LEFT JOIN market_prices mp ON mp.market_price_id = (
   568|                SELECT market_price_id FROM market_prices
   569|                WHERE instrument_id=i.instrument_id AND quality_status IN ('fresh','ok')
   570|                ORDER BY price_date DESC, created_at DESC LIMIT 1
   571|            )
   572|            WHERE a.account_id=?
   573|            """,
   574|            (instrument_id, account_id),
   575|        ).fetchone()
   576|        rows.append({
   577|            "account_id": account_id,
   578|            "instrument_id": instrument_id,
   579|            "platform": row["platform"] if row else "",
   580|            "account_name": row["account_name"] if row else account_id,
   581|            "asset_class": row["asset_class"] if row else "",
   582|            "name": row["name"] if row else instrument_id,
   583|            "ticker": row["ticker"] if row else "",
   584|            "isin": row["isin"] if row else "",
   585|            "quantity": dec_text(pos.quantity),
   586|            "cost_basis_chf": dec_text(pos.cost_basis_chf, "0.01"),
   587|            "market_price_original": dec_text(pos.market_price_original, "0.01") if pos.market_price_original is not None else "",
   588|            "price_currency": row["latest_price_currency"] if row else "",
   589|            "price_date": date_text(row["latest_price_date"] if row else ""),
   590|            "price_provider": row["latest_price_provider"] if row else "",
   591|            "market_value_chf": dec_text(pos.market_value_chf, "0.01") if pos.market_value_chf is not None else "",
   592|            "price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
   593|            "provider_symbol_status": "mapped" if conn.execute("SELECT 1 FROM instrument_price_mappings WHERE instrument_id=? AND mapping_status IN ('mapped','confirmed','user_confirmed') AND provider_symbol IS NOT NULL LIMIT 1", (instrument_id,)).fetchone() else "missing_provider_symbol",
   594|            "hedge_status": row["hedge_status"] if row else pos.hedge_status,
   595|            "instrument_status": row["instrument_status"] if row else pos.instrument_status,
   596|            "valuation_policy": row["valuation_policy"] if row else pos.valuation_policy,
   597|            "fx_status": "ok" if "missing_fx" not in pos.quality_warnings else "missing_fx",
   598|            "market_price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
   599|            "valuation_timestamp_status": pos.valuation_timestamp_status,
   600|            "corporate_action_status": row["corporate_action_status"] if row else pos.corporate_action_status,
   601|            "valuation_status": pos.valuation_status,
   602|            "total_return_chf": dec_text(pos.total_return_chf, "0.01") if pos.total_return_chf is not None else "",
   603|            "total_return_status": "complete" if pos.total_return_chf is not None else "incomplete",
   604|            "quality_warnings": ",".join(pos.quality_warnings),
   605|            "data_quality_status": pos.data_quality_status,
   606|        })
   607|    return rows
   608|
   609|
   610|def get_portfolio_positions_user(conn: Connection) -> list[dict[str, str]]:
   611|    rows: list[dict[str, str]] = []
   612|    for row in get_positions(conn):
   613|        if row.get("asset_class", "").lower() not in {"stock", "equity", "etf"}:
   614|            continue
   615|        rows.append({
   616|            "name": row.get("name", ""),
   617|            "ticker": row.get("ticker", ""),
   618|            "isin": row.get("isin", ""),
   619|            "depot": row.get("account_name", ""),
   620|            "assettyp": "ETF" if row.get("asset_class", "").lower() == "etf" else "Aktie",
   621|            "menge": row.get("quantity", ""),
   622|            "kurs": row.get("market_price_original") or "Preis noch nicht verfügbar",
   623|            "kurswaehrung": row.get("price_currency", ""),
   624|            "marktwert_chf": chf_text(row.get("market_value_chf")) if row.get("market_value_chf") else "nicht bewertet",
   625|            "einstand_chf": chf_text(row.get("cost_basis_chf")) if row.get("cost_basis_chf") else "unvollständig",
   626|            "p_l_chf": chf_text(row.get("total_return_chf")) if row.get("total_return_chf") else "nicht berechenbar",
   627|            "status": translate_status(row.get("price_status") or row.get("data_quality_status")),
   628|        })
   629|    return rows
   630|

Use offset=630 to continue reading (showing 520-629 of 1145 lines)


from __future__ import annotations

from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
import json
from sqlite3 import Connection
from uuid import uuid4

from fastapi import HTTPException

from jarvis_finance.api.schemas.overview import CashSummary
from jarvis_finance.api.schemas.positions import (
    ActionItem,
    CashPosition,
    CashPositionDetail,
    CashSnapshot,
    CashSnapshotConfirmRequest,
    CashSnapshotPreviewRequest,
    ConfirmResponse,
    PreviewResponse,
)
from jarvis_finance.services.api_helpers import decimal_text

CANONICAL_CASH_ACCOUNTS = [
    {"platform": "Raiffeisen", "account_name": "Raiffeisen", "balance_mode": "csv_calculated"},
    {"platform": "PostFinance Cash", "account_name": "PostFinance Cash-Konto", "balance_mode": "manual"},
    {"platform": "AKB", "account_name": "AKB Zinsbereitstellungskonto", "balance_mode": "manual"},
    {"platform": "AKB", "account_name": "AKB gemeinsames Konto", "balance_mode": "manual"},
    {"platform": "AKB", "account_name": "AKB Aargauer-Sparkonto", "balance_mode": "manual"},
    {"platform": "AKB", "account_name": "AKB Haushaltskonto", "balance_mode": "csv_calculated"},
]

def _now() -> str:
    return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")

def _uuid(prefix: str) -> str:
    return f"{prefix}_{uuid4().hex[:16]}"

def _decimal(value: str, field: str) -> Decimal:
    try:
        parsed = Decimal(str(value))
    except (InvalidOperation, ValueError) as exc:
        raise HTTPException(status_code=422, detail=f"Invalid decimal for {field}") from exc
    if parsed < 0:
        raise HTTPException(status_code=422, detail=f"{field} must be zero or positive")
    return parsed

def _audit(conn: Connection, *, action: str, entity_type: str, entity_id: str, payload: dict, note: str, created_by: str = "user") -> str:
    audit_id = _uuid("audit")
    ts = _now()
    conn.execute(
        """
        INSERT INTO audit_log(audit_id, timestamp, source, action, entity_type, entity_id, old_values_json, new_values_json, user_text_note, confirmed, confirmation_timestamp, created_by, quality_status, created_at)
        VALUES (?, ?, 'vue_dashboard', ?, ?, ?, NULL, ?, ?, 1, ?, ?, 'ok', ?)
        """,
        (audit_id, ts, action, entity_type, entity_id, json.dumps(payload, sort_keys=True), note, ts, created_by, ts),
    )
    return audit_id

def _ensure_platform(conn: Connection, name: str) -> str:
    row = conn.execute("SELECT platform_id FROM platforms WHERE lower(name)=lower(?)", (name,)).fetchone()
    if row:
        return row["platform_id"]
    platform_id = _uuid("plat")
    conn.execute(
        "INSERT INTO platforms(platform_id, name, platform_type, default_currency, created_at) VALUES (?, ?, 'bank', 'CHF', ?)",
        (platform_id, name, _now()),
    )
    return platform_id

def ensure_canonical_cash_accounts(conn: Connection, *, created_by: str = "user") -> dict:
    created: list[str] = []
    existing_names = [item["account_name"] for item in CANONICAL_CASH_ACCOUNTS]
    duplicates = [
        row["account_name"]
        for row in conn.execute(
            f"""
            SELECT account_name, COUNT(*) AS n
            FROM accounts
            WHERE account_type='cash' AND account_name IN ({','.join('?' for _ in existing_names)})
            GROUP BY account_name HAVING COUNT(*) > 1
            """,
            existing_names,
        ).fetchall()
    ]
    for item in CANONICAL_CASH_ACCOUNTS:
        rows = conn.execute("SELECT account_id FROM accounts WHERE account_type='cash' AND account_name=?", (item["account_name"],)).fetchall()
        if len(rows) > 1:
            continue
        platform_id = _ensure_platform(conn, item["platform"])
        if rows:
            account_id = rows[0]["account_id"]
            conn.execute(
                "UPDATE accounts SET platform_id=?, currency='CHF', balance_mode=?, portfolio_bucket='cash', updated_at=? WHERE account_id=?",
                (platform_id, item["balance_mode"], _now(), account_id),
            )
        else:
            account_id = _uuid("acct")
            conn.execute(
                """
                INSERT INTO accounts(account_id, platform_id, account_name, account_type, currency, balance_mode, portfolio_bucket, is_active, created_at)
                VALUES (?, ?, ?, 'cash', 'CHF', ?, 'cash', 1, ?)
                """,
                (account_id, platform_id, item["account_name"], item["balance_mode"], _now()),
            )
            created.append(item["account_name"])
            _audit(conn, action="cash_account_seed_confirm", entity_type="cash_account", entity_id=account_id, payload=item, note="Canonical cash account ensured", created_by=created_by)
    conn.commit()
    return {"created": len(created), "created_accounts": created, "duplicates": duplicates}

def _latest_snapshot(conn: Connection, account_id: str, snapshot_type: str):
    return conn.execute(
        """
        SELECT * FROM cash_account_snapshots
        WHERE account_id=? AND snapshot_type=?
        ORDER BY balance_date DESC, created_at DESC
        LIMIT 1
        """,
        (account_id, snapshot_type),
    ).fetchone()

def _cash_movements_after_anchor(conn: Connection, account_id: str, anchor_date: str | None) -> Decimal:
    if not anchor_date:
        return Decimal("0")
    row = conn.execute(
        """
        SELECT COALESCE(SUM(CAST(net_amount_chf AS TEXT)), '0') AS movement
        FROM transactions
        WHERE account_id=? AND COALESCE(is_voided,0)=0 AND COALESCE(is_confirmed,1)=1
          AND source_type LIKE '%csv%'
          AND trade_date > ?
        """,
        (account_id, anchor_date),
    ).fetchone()
    return Decimal(str(row["movement"] or "0"))

def _calculated_balance(conn: Connection, account_id: str) -> Decimal:
    anchor = _latest_snapshot(conn, account_id, "csv_anchor_balance")
    if anchor:
        return Decimal(str(anchor["amount_chf"])) + _cash_movements_after_anchor(conn, account_id, anchor["balance_date"])
    row = conn.execute(
        "SELECT COALESCE(SUM(CAST(amount_chf AS TEXT)), '0') AS amount FROM cash_balances WHERE account_id=?",
        (account_id,),
    ).fetchone()
    return Decimal(str(row["amount"] or "0"))

def _last_imported_booking(conn: Connection, account_id: str) -> str | None:
    row = conn.execute("SELECT MAX(trade_date) AS last_date FROM transactions WHERE account_id=? AND source_type LIKE '%csv%'", (account_id,)).fetchone()
    return row["last_date"] if row else None

def list_cash_positions(conn: Connection) -> list[CashPosition]:
    rows = conn.execute(
        """
        SELECT a.account_id, a.account_name, a.account_type, a.currency, COALESCE(a.balance_mode,'manual') AS balance_mode, p.name AS platform
        FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
        WHERE a.is_active=1 AND a.account_type='cash'
        ORDER BY p.name, a.account_name
        """
    ).fetchall()
    positions: list[CashPosition] = []
    for row in rows:
        account_id = row["account_id"]
        manual = _latest_snapshot(conn, account_id, "manual_balance")
        anchor = _latest_snapshot(conn, account_id, "csv_anchor_balance")
        calculated = _calculated_balance(conn, account_id)
        manual_amount = Decimal(str(manual["amount_chf"])) if manual else None
        mode = row["balance_mode"] or "manual"
        used = manual_amount if mode == "manual" and manual_amount is not None else calculated
        difference = (manual_amount - calculated) if manual_amount is not None else Decimal("0")
        status = "Aktuell"
        if manual_amount is not None and difference != 0:
            status = "Abgleich offen"
        positions.append(
            CashPosition(
                id=f"{account_id}:{row['currency']}",
                account_id=account_id,
                platform=row["platform"],
                account_label=row["account_name"],
                account_type=row["account_type"],
                currency=row["currency"],
                balance_mode=mode,
                amount=decimal_text(used, 2),
                amount_chf=decimal_text(used, 2),
                calculated_balance_chf=decimal_text(calculated, 2),
                manual_balance_chf=decimal_text(manual_amount, 2) if manual_amount is not None else None,
                csv_anchor_balance_chf=decimal_text(anchor["amount_chf"], 2) if anchor else None,
                used_value_chf=decimal_text(used, 2),
                difference_chf=decimal_text(difference, 2),
                last_manual_reconciliation=manual["balance_date"] if manual else None,
                last_imported_booking=_last_imported_booking(conn, account_id),
                status=status,
            )
        )
    return positions

def get_cash_summary(conn: Connection) -> CashSummary:
    ensure_canonical_cash_accounts(conn)
    positions = list_cash_positions(conn)
    total = sum(Decimal(p.used_value_chf) for p in positions)
    last_dates = [p.last_manual_reconciliation for p in positions if p.last_manual_reconciliation]
    return CashSummary(
        base_currency="CHF",
        cash_chf=decimal_text(total, 2),
        manual_accounts_count=sum(1 for p in positions if p.balance_mode == "manual"),
        csv_calculated_accounts_count=sum(1 for p in positions if p.balance_mode == "csv_calculated"),
        open_reconciliation_count=sum(1 for p in positions if p.status == "Abgleich offen"),
        last_reconciliation_at=max(last_dates) if last_dates else None,
        positions=positions,
    )

def preview_cash_snapshot(conn: Connection, request: CashSnapshotPreviewRequest) -> PreviewResponse:
    row = conn.execute("SELECT account_id, account_type FROM accounts WHERE account_id=?", (request.account_id,)).fetchone()
    if not row or row["account_type"] != "cash":
        raise HTTPException(status_code=404, detail="Cash account not found")
    if request.snapshot_type not in {"manual_balance", "csv_anchor_balance", "calculated_balance", "reconciliation"}:
        raise HTTPException(status_code=422, detail="Invalid snapshot_type")
    amount = _decimal(request.amount_chf, "amount_chf")
    return PreviewResponse(preview_id=_uuid("preview"), asset_class="cash", summary=f"{request.snapshot_type} CHF per {request.balance_date}", amount_chf=decimal_text(amount, 2), fx_status="not_needed", warnings=[])

def confirm_cash_snapshot(conn: Connection, request: CashSnapshotConfirmRequest) -> ConfirmResponse:
    if not request.confirm:
        raise HTTPException(status_code=400, detail="Explicit confirm required")
    preview_cash_snapshot(conn, request)
    amount = _decimal(request.amount_chf, "amount_chf")
    snapshot_id = _uuid("cashsnap")
    now = _now()
    source = "manual" if request.snapshot_type in {"manual_balance", "reconciliation"} else "csv_import" if request.snapshot_type == "csv_anchor_balance" else "system_calculated"
    audit_id = _audit(conn, action=f"cash_{request.snapshot_type}_confirm", entity_type="cash_account", entity_id=request.account_id, payload=request.model_dump(), note=request.note)
    conn.execute(
        """
        INSERT INTO cash_account_snapshots(snapshot_id, account_id, snapshot_type, balance_date, amount_original, currency, amount_chf, source, note, created_at, created_by, audit_id)
        VALUES (?, ?, ?, ?, ?, 'CHF', ?, ?, ?, ?, 'user', ?)
        """,
        (snapshot_id, request.account_id, request.snapshot_type, request.balance_date, decimal_text(amount, 2), decimal_text(amount, 2), source, request.note, now, audit_id),
    )
    conn.commit()
    return ConfirmResponse(status="confirmed", entity_id=snapshot_id, audit_id=audit_id, message="Kontostand gespeichert · Audit geschrieben")

def get_cash_position_detail(conn: Connection, position_id: str) -> CashPositionDetail:
    account_id, _currency = position_id.split(":", 1)
    position = next((p for p in get_cash_summary(conn).positions if p.account_id == account_id), None)
    if not position:
        raise HTTPException(status_code=404, detail="Cash position not found")
    rows = conn.execute(
        """
        SELECT snapshot_id, snapshot_type, balance_date, amount_chf, source, COALESCE(note,'') AS note, audit_id
        FROM cash_account_snapshots
        WHERE account_id=?
        ORDER BY balance_date DESC, created_at DESC
        LIMIT 30
        """,
        (account_id,),
    ).fetchall()
    history: list[CashSnapshot] = []
    previous: Decimal | None = None
    for row in reversed(rows):
        current = Decimal(str(row["amount_chf"]))
        diff = None if previous is None else decimal_text(current - previous, 2)
        previous = current
        history.append(CashSnapshot(snapshot_id=row["snapshot_id"], snapshot_type=row["snapshot_type"], balance_date=row["balance_date"], amount_chf=decimal_text(current, 2), source=row["source"], note=row["note"], difference_to_previous_chf=diff, audit_hint="Audit vorhanden" if row["audit_id"] else None))
    history = list(reversed(history))
    return CashPositionDetail(**position.model_dump(), last_change=position.last_manual_reconciliation, history=history, available_actions=[ActionItem(label="Kontostand aktualisieren", enabled=True), ActionItem(label="CSV-Anfangsstand setzen", enabled=True), ActionItem(label="Abgleich bestätigen", enabled=True), ActionItem(label="Verlauf anzeigen", enabled=True)])


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/cash_service.py">
ERROR [16:5] "CashSnapshot" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [17:5] "CashSnapshotConfirmRequest" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [18:5] "CashSnapshotPreviewRequest" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [187:17] No parameter named "account_id" [reportCallIssue] (Pyright)
ERROR [190:17] No parameter named "account_type" [reportCallIssue] (Pyright)
ERROR [192:17] No parameter named "balance_mode" [reportCallIssue] (Pyright)
ERROR [195:17] No parameter named "calculated_balance_chf" [reportCallIssue] (Pyright)
ERROR [196:17] No parameter named "manual_balance_chf" [reportCallIssue] (Pyright)
ERROR [197:17] No parameter named "csv_anchor_balance_chf" [reportCallIssue] (Pyright)
ERROR [198:17] No parameter named "used_value_chf" [reportCallIssue] (Pyright)
ERROR [199:17] No parameter named "difference_chf" [reportCallIssue] (Pyright)
ERROR [200:17] No parameter named "last_manual_reconciliation" [reportCallIssue] (Pyright)
ERROR [201:17] No parameter named "last_imported_booking" [reportCallIssue] (Pyright)
ERROR [211:27] Cannot access attribute "used_value_chf" for class "CashPosition"
  Attribute "used_value_chf" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [212:72] Cannot access attribute "last_manual_reconciliation" for class "CashPosition"
  Attribute "last_manual_reconciliation" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [212:21] Cannot access attribute "last_manual_reconciliation" for class "CashPosition"
  Attribute "last_manual_reconciliation" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [216:9] No parameter named "manual_accounts_count" [reportCallIssue] (Pyright)
ERROR [217:9] No parameter named "csv_calculated_accounts_count" [reportCallIssue] (Pyright)
ERROR [218:9] No parameter named "open_reconciliation_count" [reportCallIssue] (Pyright)
ERROR [219:9] No parameter named "last_reconciliation_at" [reportCallIssue] (Pyright)
... and 5 more
</diagnostics>