
===== frontend/src/api/portfolio.ts =====
     1	import type { FinanceCommandCenter, Overview, PortfolioAdvisorSnapshot, ReconciliationSnapshot, PortfolioPolicyEvaluation } from './types'
     2	import { apiGet, apiPost } from './client'
     3	import { currentLocalMonth } from '@/utils/month'
     4	
     5	export type PolicyAllocationInput = { asset_class: 'cash' | 'equity' | 'crypto' | 'other'; target_pct: string; lower_pct: string; upper_pct: string }
     6	export type PortfolioPolicyDraft = { base_currency: 'CHF' | 'EUR' | 'USD'; effective_from: string; horizon?: string | null; objective?: string | null; liquidity_reserve?: string | null; monthly_contribution?: string | null; max_single_position_pct?: string | null; max_crypto_pct?: string | null; rebalance_tolerance_pct?: string | null; min_transaction_amount?: string | null; benchmarks?: Record<string, unknown>[]; restrictions?: string[]; allocations: PolicyAllocationInput[] }
     7	export type PortfolioPolicy = PortfolioPolicyDraft & { policy_id: string; version: number; is_active: boolean; previous_policy_id: string | null; audit_id: string; created_at: string }
     8	export type PolicyPreview = { preview_id: string; confirmation_id: string; valid: boolean; errors: string[]; allocations: PolicyAllocationInput[]; summary: string }
     9	export type PolicyConfirm = { policy_id: string; version: number; audit_id: string; idempotent: boolean }
    10	export type PolicyHistoryItem = Pick<PortfolioPolicy, 'policy_id' | 'version' | 'is_active' | 'effective_from' | 'previous_policy_id' | 'base_currency' | 'horizon' | 'objective' | 'audit_id' | 'created_at'>
    11	export type PerformanceQuality = { status: 'complete' | 'partial' | 'unavailable'; reason_codes: string[]; coverage_from: string | null; coverage_to: string | null }
    12	export type PortfolioPerformance = {
    13	  period: { from: string; to: string }
    14	  scope: { kind: 'portfolio' | 'account'; account_id: string | null }
    15	  method: 'twr' | 'mwr' | 'both'
    16	  base_currency: 'CHF' | 'EUR' | 'USD'
    17	  valuation_as_of: string | null
    18	  data_cutoff: string
    19	  engine_version: string
    20	  ttwror_version?: 'ttwror_daily_v1'
    21	  xirr_version?: 'xirr_v1'
    22	  attribution_version?: 'performance_attribution_chf_v1'
    23	  cost_basis_version: 'fifo_v1'
    24	  input_fingerprint: string
    25	  quality: { overall: PerformanceQuality; ttwror?: PerformanceQuality; xirr?: PerformanceQuality; twr: PerformanceQuality; mwr: PerformanceQuality; cost_basis: PerformanceQuality; pnl: PerformanceQuality }
    26	  summary: { opening_value: string | null; closing_value: string | null; net_external_cashflows: string | null; fees: string | null; taxes: string | null; ttwror_cumulative?: string | null; ttwror_annualized?: string | null; xirr_annualized?: string | null; investment_result?: string | null; twr: string | null; mwr: string | null; realized_pnl: string | null; unrealized_pnl: string | null; remaining_cost_basis: string | null }
    27	  time_series: { at: string; value: string }[]
    28	  ttwror_series?: { at: string; value: string }[]
    29	  cumulative_external_cashflows?: { at: string; value: string }[]
    30	  external_cashflows: { at: string; kind: 'external_deposit' | 'external_withdrawal'; amount: string }[]
    31	  attribution?: { market_price: string | null; fx: string | null; dividends_and_interest: string | null; fees: string | null; taxes: string | null; other_effects: string | null; unattributed_residual: string | null; investment_result: string | null; tolerance_chf: string; status: 'complete' | 'partial' | 'unavailable'; reason_codes: string[] }
    32	  cost_basis: { method: 'fifo_v1'; remaining_quantity: string | null; remaining_cost_basis: string | null; realized_pnl: string | null; unrealized_pnl: string | null; lineage_count: number }
    33	  sources: string[]
    34	}
    35	export type PerformanceCoverageRow = { scope: 'portfolio' | 'postfinance' | 'truewealth' | 'crypto'; label: string; reliable_from: string | null; valuation_from: string | null; valuation_to: string | null; valuation_dates: number; position_dates: number; ttwror_status: 'complete' | 'partial' | 'unavailable'; xirr_status: 'complete' | 'partial' | 'unavailable'; attribution_status: 'complete' | 'partial' | 'unavailable'; scope_classification_status?: 'complete' | 'partial' | 'unavailable'; cashflow_coverage_status?: 'complete' | 'partial' | 'unavailable'; reason_codes: string[] }
    36	export type PerformanceCoverage = { decision_version: 'investment_performance_scope_v1'; expected_scopes: ('postfinance' | 'truewealth' | 'crypto')[]; status: 'complete' | 'partial' | 'unavailable'; rows: PerformanceCoverageRow[] }
    37	export type DailyValuationSourceStatus = {
    38	  source_key: string
    39	  label: string
    40	  source_enabled: boolean
    41	  operational_status: 'active' | 'paused' | 'partial' | 'failed'
    42	  last_confirmed_date: string | null
    43	  last_successful_at: string | null
    44	  last_run_at: string | null
    45	  status: string
    46	  reason_codes: string[]
    47	  price_as_of: string | null
    48	  price_age_seconds: number | null
    49	  price_provider: string | null
    50	  price_currency: string | null
    51	  fx_provider: string | null
    52	  valued_assets: number
    53	  missing_assets: number
    54	  total_value_chf: string | null
    55	  next_action: string
    56	}
    57	export type DailyValuationJobStatus = { job_key: string; configured: boolean; enabled: boolean; activation_required: boolean; schedule: string; next_run: string | null; sources: DailyValuationSourceStatus[] }
    58	export type PerformanceSetupSource = {
    59	  source: 'truewealth' | 'crypto' | 'postfinance'
    60	  label: string
    61	  status: 'not_ready' | 'review_inputs' | 'ready'
    62	  earliest_possible_start: string | null
    63	  opening_value: { date: string; value_chf: string } | null
    64	  closing_value: { date: string; value_chf: string } | null
    65	  cashflow_coverage: string
    66	  next_action: string
    67	  available_metrics: string[]
    68	  diagnostics: {
    69	    reason_codes: string[]
    70	    valuation_dates: number
    71	    position_dates: number
    72	    reclassification_status: string | null
    73	    component_status: string | null
    74	  }
    75	}
    76	export type PerformanceSetup = { status: 'not_ready' | 'review_inputs' | 'ready'; source_order: Array<'truewealth' | 'crypto' | 'postfinance'>; sources: PerformanceSetupSource[]; timer_enabled: boolean }
    77	export type TrueWealthCashflowEntry = { date: string; direction: 'deposit' | 'withdrawal'; amount: string; currency: string; amount_chf?: string | null; fx_source?: string | null; evidence_reference?: string | null }
    78	export type TrueWealthCashflowDraft = { mode: 'external_cashflows' | 'no_external_flows'; coverage_from: string; coverage_to: string; entries: TrueWealthCashflowEntry[]; csv_text: string | null; attestation: string }
    79	export type TrueWealthCashflowPreview = { preview_id: string; input_fingerprint: string; account_id: string; mode: TrueWealthCashflowDraft['mode']; coverage_from: string; coverage_to: string; entries: Array<Record<string, string | null>>; entries_to_write: Array<Record<string, string | null>>; entry_count: number; duplicate_count: number; effective_coverage_from: string; effective_coverage_to: string; attestation: string; expected_changes: { cashflow_transactions: number; coverage_records: number; valuation_snapshots: number }; status: string; reason_codes: string[] }
    80	export type TrueWealthPerformanceView = { scope: 'truewealth'; requested_period: { from: string; to: string }; available_period: { from: string; to: string } | null; earliest_source_date: string | null; first_performance_anchor: string | null; points: Array<{ date: string; value_chf: string; kind: 'confirmed' | 'modelled'; source: string; anchor: boolean }>; latest_confirmed_value: { date: string; value_chf: string; kind: 'confirmed' | 'modelled'; source: string; anchor: boolean } | null; latest_modelled_value: { date: string; value_chf: string; kind: 'confirmed' | 'modelled'; source: string; anchor: boolean } | null; status: 'available' | 'not_available'; reason_codes: string[] }
    81	export type TrueWealthModelPreview = { as_of: string; account_id: string; status: 'ready' | 'blocked'; quality_status?: 'provisional' | 'unavailable' | null; reason_codes: string[]; modelled_value_chf: string | null; anchor_date: string | null; net_external_cashflows_chf: string | null; latest_price_fx_date: string | null; latest_modelled_value: { valuation_at: string; value_original: string; source: 'truewealth_modelled_daily'; quality_status: string } | null; input_fingerprint: string }
    82	export type PortfolioAnalyticsMetric = number | string | null
    83	export type PortfolioAnalyticsStatus = 'complete' | 'partial' | 'unavailable'
    84	export type ReadinessStatus = 'ready' | 'partial' | 'not_ready' | 'not_applicable'
    85	export type WealthSourceRow = {
    86	  key: string
    87	  label: string
    88	  provider_label?: string
    89	  kind: string
    90	  source_role?: 'account' | 'canonical_value' | 'liability'
    91	  performance_scope?: 'postfinance' | 'truewealth' | 'crypto' | null
    92	  current_value_chf: string | null
    93	  current_value_status: ReadinessStatus
    94	  change_chf: string | null
    95	  net_contributions_chf: string | null
    96	  return_pct: string | null
    97	  as_of: string | null
    98	  freshness_status: string
    99	  freshness_reason_code?: string
   100	  expected_as_of?: string | null
   101	  reconciliation_status: string
   102	  performance_status: ReadinessStatus
   103	  value_basis: 'confirmed' | 'modelled' | 'unavailable'
   104	  last_activity_day: string | null
   105	  last_confirmed_snapshot: string | null
   106	  imported_at: string | null
   107	  coverage_from: string | null
   108	  coverage_to: string | null
   109	  coverage_status: 'complete' | 'partial' | 'stale' | 'unavailable'
   110	  new_rows: number
   111	  duplicate_rows: number
   112	  review_rows: number
   113	  next_action: string | null
   114	  performance_blocker: string | null
   115	}
   116	export type WealthReadinessMetric = {
   117	  key: string
   118	  label: string
   119	  status: ReadinessStatus
   120	  included_sources: string[]
   121	  missing_sources: string[]
   122	  as_of: string | null
   123	  period: { preset: string; from: string; to: string }
   124	  blocker: string | null
   125	  action: string | null
   126	  reason_code: string | null
   127	}
   128	export type WealthDiagnostic = {
   129	  dimension: 'current_value' | 'freshness' | 'reconciliation' | 'performance' | 'policy'
   130	  affected_sources: string[]
   131	  message: string
   132	  action: string
   133	  reason_code: string
   134	  prominent: boolean
   135	}
   136	export type ModelledValueQuality = 'confirmed' | 'modelled' | 'carried' | 'incomplete' | 'unavailable'
   137	export type WealthChartPeriod = '1m' | '3m' | 'ytd' | '1y' | 'all'
   138	export type WealthComponentKey = 'postfinance' | 'truewealth' | 'crypto' | 'bank_cash' | 'other_assets'
   139	export type ModelledWealthPoint = {
   140	  date: string
   141	  value_chf: string
   142	  quality: ModelledValueQuality
   143	  has_confirmed_anchor: boolean
   144	  has_modelled_value: boolean
   145	  excluded_account_count: number
   146	  components: Array<{ key: WealthComponentKey; label: string; value_chf: string | null; quality: ModelledValueQuality; source_date: string | null }>
   147	  event?: string | null
   148	  event_label?: string | null
   149	  correction_event?: string | null
   150	  events?: string[]
   151	}
   152	export type PortfolioAnalysisStatus = 'complete' | 'partial' | 'unavailable'
   153	export type PortfolioAnalysis = {
   154	  status: PortfolioAnalysisStatus
   155	  allocation: Array<{ key: string; label: string; current_value_chf: string | null; current_pct: string | null; target_pct: string | null; lower_pct: string | null; upper_pct: string | null; deviation_pp: string | null; deviation_chf: string | null; status: string }>
   156	  concentrations: { top1_pct: string | null; top5_pct: string | null; top10_pct: string | null }
   157	  dimensions: Partial<Record<'currency' | 'region' | 'sector', { status: PortfolioAnalysisStatus; rows: Array<{ label: string; pct: string | null }> }>>
   158	  contributions: Array<{ key: string; label: string; value_chf: string | null; status?: string | null }>
   159	  hints: Array<string | { priority?: number; text: string }>
   160	}
   161	export type ModelledWealthDevelopment = {
   162	  status: 'available' | 'unavailable'
   163	  period: { preset: 'since_anchor' | WealthChartPeriod; from: string; to: string }
   164	  last_confirmed_anchor_date?: string | null
   165	  anchor: { date: string; value_chf: string; quality: ModelledValueQuality } | null
   166	  baseline: { date: string; value_chf: string; quality: ModelledValueQuality } | null
   167	  current: { date: string; value_chf: string; quality: ModelledValueQuality } | null
   168	  change_chf: string | null
   169	  change_pct: string | null
   170	  chart_visible: boolean
   171	  points: ModelledWealthPoint[]
   172	  components: Array<{ key: WealthComponentKey; label: string; current_value_chf: string | null; change_chf: string | null; change_pct: string | null; quality: ModelledValueQuality; as_of: string | null; unknown_account_count: number }>
   173	  correction_markers: Array<{ date: string; source_key: 'postfinance' | 'truewealth' | 'bank_cash'; confirmed_value_chf: string; predecessor_model_value_chf: string; difference_chf: string }>
   174	  unknown_accounts: Array<{ key: string; label: string; reason_code: string }>
   175	  method: 'modelled_wealth_daily_v1'
   176	  disclaimer: string
   177	}
   178	export type VerifiedPerformanceSummary = {
   179	  status: 'verified' | 'not_verified'
   180	  label: string
   181	  ttwror_status: ReadinessStatus
   182	  xirr_status: ReadinessStatus
   183	  ttwror_pct: string | null
   184	  xirr_pct: string | null
   185	}
   186	export type WealthCockpit = {
   187	  scope_label: string
   188	  not_net_worth: boolean
   189	  period: { preset: 'since_anchor' | '1m' | '3m' | '1y' | 'ytd' | 'previous_year' | '12m' | 'all'; from: string; to: string }
   190	  data_cutoff: string
   191	  modelled_development: ModelledWealthDevelopment
   192	  portfolio_analysis?: PortfolioAnalysis | null
   193	  verified_performance: VerifiedPerformanceSummary
   194	  kpis: Array<{ key: string; label: string; value_chf?: string | null; value_pct?: string | null; value_date?: string | null; status: string }>
   195	  totals: { captured_wealth_chf: string; investments_chf: string; bank_cash_chf: string; complete: boolean }
   196	  history: { status: string; points: Array<{ at: string; value_chf: string }>; household_cashflow_events: Array<{ at: string; kind: string; amount_chf: string }>; investment_cashflow_events: Array<{ at: string; kind: string; amount: string }>; reason: string }
   197	  distribution: Array<{ key: string; label: string; value_chf: string | null }>
   198	  sources: WealthSourceRow[]
   199	  readiness: {
   200	    dimensions: Record<'current_value' | 'freshness' | 'reconciliation' | 'performance' | 'policy', { status: ReadinessStatus; reason_code: string | null }>
   201	    metrics: WealthReadinessMetric[]
   202	  }
   203	  diagnostics: WealthDiagnostic[]
   204	  performance_coverage: PerformanceCoverage
   205	  policy: { configured: boolean; version: number | null; rows: Array<{ asset_class: string; current_pct: string | null; target_pct: string; lower_pct: string; upper_pct: string; deviation_pct_points: string | null; status: string }>; contribution: null | { monthly_target_chf: string; annual_target_chf: string; invested_ytd_chf: string | null; expected_year_end_chf: string | null; difference_to_target_chf: string | null; status: string } }
   206	  planning: { free_plannable_chf: string | null; available: boolean; link: string; included_in_wealth: false }
   207	  data_quality: { freshness_status: string; reconciliation_status: string; performance_status: string; performance_reasons: string[]; missing_areas: string[]; unassigned: Array<{ label: string; value_chf: string; as_of: string }> }
   208	  hints: string[]
   209	  method: { wealth_change: string; investment_result: string; return: string }
   210	}
   211	export type PortfolioAnalyticsDistribution = { label: string; pct: PortfolioAnalyticsMetric }
   212	export type PortfolioAnalytics = {
   213	  as_of: string | null
   214	  status: PortfolioAnalyticsStatus
   215	  freshness: string
   216	  notice: string | null
   217	  coverage: { price_pct: PortfolioAnalyticsMetric; fx_pct: PortfolioAnalyticsMetric; benchmark_pct: PortfolioAnalyticsMetric }
   218	  valuation: { total_chf: PortfolioAnalyticsMetric }
   219	  performance: { status: PortfolioAnalyticsStatus; twr_pct: PortfolioAnalyticsMetric; benchmark_pct: PortfolioAnalyticsMetric; difference_pct: PortfolioAnalyticsMetric; return_type: string | null; normalized: { date: string; portfolio: number | string; benchmark: number | string }[]; deprecated?: boolean; source?: 'portfolio_performance_v2'; valuation_points?: { date: string; value: number | string }[]; benchmark_points?: { date: string; value: number | string }[]; reason_codes?: string[] }
   220	  risk: { status: PortfolioAnalyticsStatus; largest_position: PortfolioAnalyticsDistribution | PortfolioAnalyticsMetric; top5_pct: PortfolioAnalyticsMetric; cash_pct: PortfolioAnalyticsMetric; asset_classes: PortfolioAnalyticsDistribution[]; currencies: PortfolioAnalyticsDistribution[]; policy_breaches: string[]; volatility_pct: PortfolioAnalyticsMetric; max_drawdown_pct: PortfolioAnalyticsMetric; reason_codes: string[] }
   221	  missing_instruments: { instrument_id: string; isin?: string; label?: string; reason_code: string }[]
   222	  reason_codes?: string[]
   223	}
   224	export type PortfolioDataSource = { source_key: string; label: string; available: boolean; configured: boolean; ingestion_supported: boolean; activity_support: boolean; valuation_support: boolean; capabilities: string[]; record_count: number; last_successful_confirmed_at: string | null; last_attempt_at: string | null; last_attempt_status: string | null; known_gaps: string[] }
   225	export type IngestionCounts = { discovered: number; new: number; unchanged: number; duplicate: number; ambiguous: number; blocked: number; versioned: number }
   226	export type PortfolioIngestionSource = 'canonical_transactions' | 'legacy_account_values' | 'cash_account_snapshots' | 'postfinance_etrading'
   227	export type PostFinanceOperationCounts = { buys: number; sells: number; distributions: number; fees: number; taxes: number; deposits: number; withdrawals: number; positions: number; cash_balances: number }
   228	export type IngestionPreview = { source_key: PortfolioIngestionSource; scope_kind: 'portfolio' | 'account'; account_id: string | null; period_from: string; period_to: string; data_cutoff: string; preview_id: string; confirmation_id: string; preview_created_at: string; expires_at: string; source_revision: string; input_fingerprint: string; payload_hash: string; counts: IngestionCounts; planned_activities: Record<string, unknown>[]; planned_valuations: Record<string, unknown>[]; planned_corrections: Record<string, unknown>[]; quality_impact: { status: 'complete' | 'partial' | 'unavailable'; reason_codes: string[] }; items: { source_record_ref: string; record_kind: 'activity' | 'valuation'; disposition: keyof Omit<IngestionCounts, 'discovered'>; account_ref: string; account_label: string; as_of: string; summary: Record<string, unknown> }[]; truncated: boolean; document_type?: string | null; file_hash?: string | null; operation_counts?: PostFinanceOperationCounts | null; baseline_only?: boolean | null; expected_changes?: { positions: number; cash: number; account_valuations: number } | null; performance_impact?: { history_before?: string; twr?: string; mwr?: string; reason_codes?: string[] } | null }
   229	export type IngestionHistory = { items: { batch_id: string; source_key: string; scope_kind: string; scope_id: string | null; period_from: string; period_to: string; data_cutoff: string; input_fingerprint: string; status: string; confirmed_at: string; counts: IngestionCounts }[]; limit: number; offset: number; total: number }
   230	export type CoverageMetric = { covered: number; total: number; ratio: string | null }
   231	export type PortfolioReconciliation = { as_of: string; data_cutoff: string; base_currency: string; status: 'matched' | 'within_tolerance' | 'mismatch' | 'not_comparable' | 'unavailable'; reason_codes: string[]; tolerance: { version: string; absolute: string; relative: string }; coverage: Record<string, CoverageMetric>; differences: { difference_ref: string; account_ref: string; account_label: string; instrument_ref: string; instrument_label: string; quantity: { ledger: string | null; reported: string | null; difference: string | null; status: string }; valuation: { derived: string | null; reported: string | null; difference: string | null; relative_difference: string | null; status: string; position_as_of: string | null; price_as_of: string | null; fx_as_of: string | null; price_source: string | null; fx_source: string | null }; reason_codes: string[] }[]; account_totals: { account_ref: string; account_label: string; total_value_only: boolean; positions_value: string | null; cash_value: string | null; derived_total: string | null; reported_total: string | null; difference: string | null; status: string; as_of: string | null; reason_codes: string[] }[]; input_fingerprint: string; sources: string[]; limit: number; offset: number; total: number }
   232	
   233	export const getOverview = (refresh = false) => apiGet<Overview>('/api/overview', { refresh })
   234	export const getWealthCockpit = (period: WealthCockpit['period']['preset'] = '1m', refresh = false) => apiGet<WealthCockpit>(`/api/portfolio/wealth-cockpit?period=${encodeURIComponent(period)}`, { refresh })
   235	export const getReconciliationSnapshot = (refresh = false) => apiGet<ReconciliationSnapshot>('/api/portfolio/reconciliation-snapshot', { refresh })
   236	export const getFinanceCommandCenter = (month = currentLocalMonth(), refresh = false) => apiGet<FinanceCommandCenter>(`/api/overview/finance-command-center?month=${encodeURIComponent(month)}`, { refresh })
   237	export const getPortfolioAdvisor = (refresh = false) => apiGet<PortfolioAdvisorSnapshot>('/api/portfolio/advisor', { refresh })
   238	export const getPortfolioAnalytics = (period = '1y', refresh = false) => apiGet<PortfolioAnalytics>(`/api/portfolio/analytics?period=${encodeURIComponent(period)}`, { refresh })
   239	export const getPortfolioPerformanceCoverage = (from?: string, to?: string, refresh = false) => {
   240	  const query = new URLSearchParams()
   241	  if (from) query.set('from', from)
   242	  if (to) query.set('to', to)
   243	  return apiGet<PerformanceCoverage>(`/api/portfolio/performance/coverage${query.size ? `?${query.toString()}` : ''}`, { refresh })
   244	}
   245	export const getDailyValuationJobStatus = (refresh = false) => apiGet<DailyValuationJobStatus>('/api/portfolio/performance/daily-job', { refresh })
   246	export const getPerformanceSetup = (refresh = false) => apiGet<PerformanceSetup>('/api/portfolio/performance/setup', { refresh })
   247	export const getTrueWealthPerformanceView = (requestedFrom: string, requestedTo: string, refresh = false) => apiGet<TrueWealthPerformanceView>(`/api/portfolio/performance/truewealth/view?requested_from=${encodeURIComponent(requestedFrom)}&requested_to=${encodeURIComponent(requestedTo)}`, { refresh })
   248	export const getTrueWealthModelPreview = (asOf: string, refresh = false) => apiGet<TrueWealthModelPreview>(`/api/portfolio/performance/truewealth/model-preview?as_of=${encodeURIComponent(asOf)}`, { refresh })
   249	export const previewTrueWealthCashflowPeriod = (body: TrueWealthCashflowDraft) => apiPost<TrueWealthCashflowPreview>('/api/portfolio/performance/truewealth-cashflows/preview', body)
   250	export const confirmTrueWealthCashflowPeriod = (preview: TrueWealthCashflowPreview, draft: TrueWealthCashflowDraft, confirmationId: string) => apiPost<{ confirmation_id: string; input_fingerprint: string; account_id: string; mode: TrueWealthCashflowDraft['mode']; coverage_from: string; coverage_to: string; written_cashflows: number; valuation_snapshots_written: number; idempotent: boolean }>('/api/portfolio/performance/truewealth-cashflows/confirm', { ...draft, preview_id: preview.preview_id, input_fingerprint: preview.input_fingerprint, confirmation_id: confirmationId, confirm: true })
   251	export const getPortfolioPolicyEvaluation = (refresh = false) => apiGet<PortfolioPolicyEvaluation>('/api/portfolio/policy/evaluation', { refresh })
   252	export const getPortfolioPolicy = (refresh = false) => apiGet<{ configured: boolean; policy: PortfolioPolicy | null }>('/api/portfolio/policy', { refresh })
   253	export const getPortfolioPolicyHistory = (refresh = false) => apiGet<PolicyHistoryItem[]>('/api/portfolio/policy/history', { refresh })
   254	export const getPortfolioPolicyDetail = (policyId: string, refresh = false) => apiGet<PortfolioPolicy>(`/api/portfolio/policy/${encodeURIComponent(policyId)}`, { refresh })
   255	export const getPortfolioPerformance = (params: { from: string; to: string; method: 'twr' | 'mwr' | 'both'; scope?: 'portfolio' | 'postfinance' | 'truewealth' | 'crypto'; baseCurrency?: 'CHF' | 'EUR' | 'USD'; accountId?: string; dataCutoff?: string }, refresh = false) => {
   256	  const query = new URLSearchParams({ from: params.from, to: params.to, method: params.method, base_currency: params.baseCurrency ?? 'CHF' })
   257	  if (params.accountId) query.set('account_id', params.accountId)
   258	  if (params.scope) query.set('scope', params.scope)
   259	  if (params.dataCutoff) query.set('data_cutoff', params.dataCutoff)
   260	  return apiGet<PortfolioPerformance>(`/api/portfolio/performance?${query.toString()}`, { refresh })
   261	}
   262	export const previewPortfolioPolicy = (body: PortfolioPolicyDraft) => apiPost<PolicyPreview>('/api/portfolio/policy/preview', body)
   263	export const confirmPortfolioPolicy = (body: PortfolioPolicyDraft & { preview_id: string; confirmation_id: string; confirm: true }) => apiPost<PolicyConfirm>('/api/portfolio/policy/confirm', body)
   264	export const getPortfolioDataSources = (refresh = false) => apiGet<{ sources: PortfolioDataSource[]; data_cutoff: string; input_fingerprint: string }>('/api/portfolio/data-sources', { refresh })
   265	export type IngestionPreviewInput = { source_key: PortfolioIngestionSource; scope_kind: 'portfolio'; period_from: string; period_to: string; data_cutoff?: string; file_name?: string; content_base64?: string }
   266	export type PostFinanceUpload = { file_name: string; content_base64: string }
   267	export const previewPortfolioIngestion = (body: IngestionPreviewInput) => apiPost<IngestionPreview>('/api/portfolio/ingestion/preview', body)
   268	export const confirmPortfolioIngestion = (preview: IngestionPreview, upload?: PostFinanceUpload) => apiPost<{ batch_id: string; audit_id: string; idempotent: boolean; written_records?: number; payload_hash?: string }>('/api/portfolio/ingestion/confirm', { source_key: preview.source_key, scope_kind: preview.scope_kind, account_id: preview.account_id, period_from: preview.period_from, period_to: preview.period_to, data_cutoff: preview.data_cutoff, preview_id: preview.preview_id, confirmation_id: preview.confirmation_id, preview_created_at: preview.preview_created_at, source_revision: preview.source_revision, input_fingerprint: preview.input_fingerprint, payload_hash: preview.payload_hash, confirm: true, ...(upload ?? {}) })
   269	export const getPortfolioIngestionHistory = (refresh = false) => apiGet<IngestionHistory>('/api/portfolio/ingestion/history?limit=20&offset=0', { refresh })
   270	export const getPortfolioReconciliation = (asOf: string, refresh = false) => apiGet<PortfolioReconciliation>(`/api/portfolio/reconciliation?as_of=${encodeURIComponent(asOf)}&base_currency=CHF&absolute_tolerance=0.01&relative_tolerance=0.001&limit=200&offset=0`, { refresh })

===== frontend/src/api/marketRefresh.ts =====
     1	import { apiGet, apiPost } from './client'
     2	
     3	export type AssetRefreshSourceStatus = {
     4	  source: 'equity' | 'crypto' | 'fx'
     5	  status: 'pending' | 'running' | 'complete' | 'failed' | 'skipped'
     6	  stale_candidates: number
     7	  updated_count: number
     8	  error_code: string | null
     9	  started_at: string | null
    10	  completed_at: string | null
    11	}
    12	export type AssetRefreshJob = {
    13	  job_id: string
    14	  status: 'queued' | 'running' | 'complete' | 'partial' | 'failed'
    15	  requested_at: string
    16	  completed_at: string | null
    17	  stale_before: string
    18	  progress: { completed: number; total: number }
    19	  sources: AssetRefreshSourceStatus[]
    20	  wealth_snapshot_created: boolean
    21	  audit_recorded: boolean
    22	  provider_calls_on_read: false
    23	}
    24	
    25	export const startAssetRefreshJob = () =>
    26	  apiPost<AssetRefreshJob>('/api/market/asset-price-refresh', { stale_hours: 24 })
    27	export const getAssetRefreshJob = (jobId: string, refresh = true) =>
    28	  apiGet<AssetRefreshJob>(`/api/market/asset-price-refresh/${encodeURIComponent(jobId)}`, { refresh })

===== frontend/src/components/wealth/WealthCockpitPanel.vue =====
     1	<template>
     2	  <div class="space-y-5" data-testid="wealth-cockpit">
     3	    <section class="rounded-2xl border border-slate-200 bg-white/90 p-4 sm:p-5">
     4	      <div class="flex flex-wrap items-start justify-between gap-4">
     5	        <div class="max-w-3xl">
     6	          <p class="text-xs font-bold uppercase tracking-[0.18em] text-cyan-700">Vermögen</p>
     7	          <h2 class="mt-1 text-2xl font-bold text-slate-950">Modellierte Wertentwicklung</h2>
     8	          <p class="mt-1 text-sm text-slate-600">Geschätzte Entwicklung aus bestätigten Ankern, gespeicherten Tagesbewertungen und fortgeschriebenen bekannten Kontoständen. Keine verifizierte TTWROR oder XIRR.</p>
     9	        </div>
    10	        <AssetRefreshControl @completed="load(true)" />
    11	      </div>
    12	      <AvailabilityState v-if="error" class="mt-4" state="error" title="Vermögens-Cockpit nicht verfügbar" :description="error" />
    13	      <AvailabilityState v-else-if="loading && !cockpit" class="mt-4" state="loading" />
    14	      <template v-else-if="modelled">
    15	        <div class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4" data-testid="modelled-kpis">
    16	          <article class="rounded-xl border border-slate-200 bg-slate-50 p-4">
    17	            <p class="text-sm font-medium text-slate-600">Aktueller modellierter Gesamtwert</p>
    18	            <p class="mt-2 text-xl font-bold text-slate-950">{{ moneyOrUnavailable(modelled.current?.value_chf) }}</p>
    19	            <div class="mt-2 flex flex-wrap items-center gap-2 text-xs"><span :class="qualityClass(modelled.current?.quality)" data-testid="modelled-current-badge">{{ qualityLabel(modelled.current?.quality) }}</span><span class="text-slate-500">Stand {{ modelled.current ? dateLabel(modelled.current.date) : 'nicht verfügbar' }}</span></div>
    20	          </article>
    21	          <article class="rounded-xl border border-slate-200 bg-slate-50 p-4">
    22	            <p class="text-sm font-medium text-slate-600">Vergleichbarer Ausgangswert</p>
    23	            <p class="mt-2 text-xl font-bold text-slate-950">{{ moneyOrUnavailable(modelled.baseline?.value_chf) }}</p>
    24	            <p class="mt-2 text-xs text-slate-500">{{ modelled.baseline ? `${qualityLabel(modelled.baseline.quality)} · ${dateLabel(modelled.baseline.date)}` : 'Kein Ausgangswert' }}</p>
    25	            <p v-if="modelled.anchor" class="mt-1 text-xs text-slate-500">Wert am letzten bestätigten Importstichtag: {{ dateLabel(modelled.anchor.date) }} · {{ qualityLabel(modelled.anchor.quality) }}</p>
    26	          </article>
    27	          <article class="rounded-xl border border-slate-200 bg-slate-50 p-4">
    28	            <p class="text-sm font-medium text-slate-600">Veränderung in CHF</p>
    29	            <p class="mt-2 text-xl font-bold text-slate-950">{{ signedMoney(modelled.change_chf) }}</p>
    30	            <p class="mt-2 text-xs text-slate-500">Seit dem gewählten Ausgangspunkt</p>
    31	          </article>
    32	          <article class="rounded-xl border border-slate-200 bg-slate-50 p-4">
    33	            <p class="text-sm font-medium text-slate-600">Geschätzte Entwicklung</p>
    34	            <p class="mt-2 text-xl font-bold text-slate-950">{{ signedPercent(modelled.change_pct) }}</p>
    35	            <p class="mt-2 text-xs text-slate-500">Nicht als verifizierte Rendite verwenden</p>
    36	          </article>
    37	        </div>
    38	        <p v-if="modelled.unknown_accounts.length" class="mt-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950" data-testid="modelled-unknown-accounts">Bekannte Teilsumme: {{ modelled.unknown_accounts.length }} Position(en) oder Konto/Konten ohne belastbare Bewertung sind nicht enthalten. Die übrige Entwicklung bleibt sichtbar.</p>
    39	      </template>
    40	    </section>
    41	
    42	    <template v-if="cockpit && modelled">
    43	      <section class="grid gap-5 xl:grid-cols-[1.65fr_1fr]">
    44	        <article class="min-w-0 rounded-2xl border border-slate-200 bg-white/90 p-4 sm:p-5" data-testid="modelled-history">
    45	          <div class="flex flex-wrap items-start justify-between gap-2"><div><h3 class="text-lg font-semibold text-slate-950">Geschätzte Entwicklung</h3><p class="text-sm text-slate-600">Historie bis zum letzten bestätigten Anker durchgezogen; nur der anschliessende Abschnitt gestrichelt. Gemischte Komponentenqualität segmentiert die Gesamtlinie nicht.</p></div><span :class="qualityClass(modelled.current?.quality)">{{ qualityLabel(modelled.current?.quality) }}</span></div>
    46	          <WealthDevelopmentChart class="mt-4" :model="modelled" :period="period" @period-change="changePeriod" />
    47	          <ul v-if="modelled.correction_markers.length" class="mt-3 space-y-2 text-sm" data-testid="correction-markers"><li v-for="marker in modelled.correction_markers" :key="`${marker.date}-${marker.source_key}`" class="rounded-lg border border-amber-200 bg-amber-50 p-2">Importkorrektur · bestätigte Komponente {{ dateLabel(marker.date) }} · Haushaltsanker bleibt unverändert · Abgleich {{ signedMoney(marker.difference_chf) }}</li></ul>
    48	        </article>
    49	
    50	        <article class="rounded-2xl border border-slate-200 bg-white/90 p-4 sm:p-5" data-testid="modelled-components">
    51	          <h3 class="text-lg font-semibold text-slate-950">Aufteilung</h3>
    52	          <p class="text-sm text-slate-600">PostFinance erscheint konsolidiert inklusive Settlement-Cash.</p>
    53	          <div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
    54	            <article v-for="item in modelled.components" :key="item.key" class="rounded-xl border border-slate-200 p-3" :data-testid="`modelled-component-${item.key}`">
    55	              <div class="flex items-start justify-between gap-2"><div><h4 class="font-semibold text-slate-950">{{ item.label }}</h4><p class="text-xs text-slate-500">{{ item.key === 'bank_cash' ? 'Kontostandentwicklung' : 'Geschätzte Entwicklung' }}</p></div><span :class="qualityClass(item.quality)">{{ qualityLabel(item.quality) }}</span></div>
    56	              <p class="mt-2 text-lg font-bold">{{ moneyOrUnavailable(item.current_value_chf) }}</p>
    57	              <p class="text-xs text-slate-600">{{ signedMoney(item.change_chf) }} · {{ signedPercent(item.change_pct) }}</p>
    58	              <p class="mt-1 text-xs text-slate-500">Stand {{ item.as_of ? dateLabel(item.as_of) : 'nicht verfügbar' }}<template v-if="item.unknown_account_count"> · {{ item.unknown_account_count }} Position(en)/Konto/Konten nicht enthalten</template></p>
    59	            </article>
    60	          </div>
    61	        </article>
    62	      </section>
    63	
    64	      <PortfolioAnalysisPanel v-if="cockpit.portfolio_analysis" :analysis="cockpit.portfolio_analysis" />
    65	      <section v-else class="rounded-2xl border border-slate-200 bg-white/90 p-5" data-testid="portfolio-analysis-unavailable"><h3 class="font-semibold text-slate-950">Portfolioanalyse v1</h3><p class="mt-2 text-sm text-slate-600">Portfolioanalyse nicht beurteilbar.</p></section>
    66	
    67	      <details class="rounded-2xl border border-cyan-200 bg-cyan-50/60 p-4 sm:p-5" data-testid="verified-performance">
    68	        <summary class="flex min-h-11 cursor-pointer items-center justify-between gap-3 font-semibold"><span>Verifizierte Anlagerendite</span><span>{{ cockpit.verified_performance.label }}</span></summary>
    69	        <p class="mt-3 text-sm text-slate-700">Die bestehende strenge TTWROR-/XIRR-Berechnung bleibt unabhängig von der modellierten Standardansicht.</p>
    70	        <div class="mt-3 grid gap-3 sm:grid-cols-2"><article class="rounded-xl bg-white p-3"><strong>TTWROR</strong><p>{{ cockpit.verified_performance.ttwror_status === 'ready' ? percentRatio(cockpit.verified_performance.ttwror_pct) : 'Noch nicht verifiziert' }}</p></article><article class="rounded-xl bg-white p-3"><strong>XIRR</strong><p>{{ cockpit.verified_performance.xirr_status === 'ready' ? percentRatio(cockpit.verified_performance.xirr_pct) : 'Noch nicht verifiziert' }}</p></article></div>
    71	      </details>
    72	
    73	      <details class="rounded-2xl border border-slate-200 bg-white/90 p-4 sm:p-5" data-testid="wealth-diagnostics">
    74	        <summary class="flex min-h-11 cursor-pointer items-center font-semibold">Daten &amp; Diagnose · {{ cockpit.diagnostics.length }} Hinweise</summary>
    75	        <p class="mt-3 text-sm text-slate-600">Coverage, Importmetadaten und technische Performancegates sind hier gesammelt und standardmässig eingeklappt.</p>
    76	        <div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3" data-testid="diagnostic-sources">
    77	          <article v-for="source in cockpit.sources" :key="source.key" class="rounded-xl border border-slate-200 p-3 text-sm">
    78	            <div class="flex items-start justify-between gap-2"><strong>{{ source.label }}</strong><span>{{ qualityLabel(source.value_basis === 'confirmed' ? 'confirmed' : source.value_basis === 'modelled' ? 'modelled' : 'unavailable') }}</span></div>
    79	            <p class="mt-2">{{ moneyOrUnavailable(source.current_value_chf) }} · Stand {{ source.as_of ? dateLabel(source.as_of) : 'unbekannt' }}</p>
    80	            <p class="mt-2 text-xs text-slate-600">Coverage {{ coverageLabel(source.coverage_status) }} · {{ source.new_rows }} neu · {{ source.duplicate_rows }} doppelt · {{ source.review_rows }} prüfen</p>
    81	            <p v-if="source.performance_blocker" class="mt-2 text-xs text-amber-900">{{ source.performance_blocker }}</p>
    82	          </article>
    83	        </div>
    84	        <div class="mt-4 space-y-3"><article v-for="item in cockpit.diagnostics" :key="`${item.dimension}-${item.reason_code}`" class="rounded-xl border border-slate-200 p-3 text-sm"><strong>{{ item.message }}</strong><p v-if="item.action" class="mt-1 text-slate-600">{{ item.action }}</p><details class="mt-2"><summary class="cursor-pointer text-xs font-semibold text-slate-500">Technische Details</summary><code class="mt-1 block break-all text-xs text-slate-500">{{ item.reason_code }}</code></details></article></div>
    85	      </details>
    86	    </template>
    87	  </div>
    88	</template>
    89	
    90	<script setup lang="ts">
    91	import { computed, onMounted, ref, watch } from 'vue'
    92	import AvailabilityState from '@/components/ui/AvailabilityState.vue'
    93	import AssetRefreshControl from './AssetRefreshControl.vue'
    94	import PortfolioAnalysisPanel from './PortfolioAnalysisPanel.vue'
    95	import WealthDevelopmentChart from './WealthDevelopmentChart.vue'
    96	import { getWealthCockpit, type ModelledValueQuality, type WealthChartPeriod, type WealthCockpit } from '@/api/portfolio'
    97	
    98	const props = defineProps<{ refreshKey?: number }>()
    99	const period = ref<WealthChartPeriod>('1m')
   100	const cockpit = ref<WealthCockpit | null>(null)
   101	const loading = ref(false)
   102	const error = ref<string | null>(null)
   103	const modelled = computed(() => cockpit.value?.modelled_development ?? null)
   104	
   105	function money(value: string | null | undefined) { const n=Number(value); return Number.isFinite(n) ? new Intl.NumberFormat('de-CH',{style:'currency',currency:'CHF'}).format(n) : 'Nicht verfügbar' }
   106	function moneyOrUnavailable(value: string | null | undefined) { return value == null ? 'Nicht verfügbar' : money(value) }
   107	function signedMoney(value: string | null | undefined) { const n=Number(value); if(value==null||!Number.isFinite(n))return 'Nicht verfügbar'; const formatted=money(String(Math.abs(n))); return n>0?`+ ${formatted}`:n<0?`− ${formatted}`:formatted }
   108	function signedPercent(value: string | null | undefined) { const n=Number(value); if(value==null||!Number.isFinite(n))return 'Nicht verfügbar'; const formatted=new Intl.NumberFormat('de-CH',{minimumFractionDigits:1,maximumFractionDigits:2}).format(Math.abs(n)); return `${n>0?'+ ':n<0?'− ':''}${formatted} %` }
   109	function percentRatio(value: string | null | undefined) { const n=Number(value); return value!=null&&Number.isFinite(n)?signedPercent(String(n*100)):'Noch nicht verifiziert' }
   110	function dateLabel(value: string) { const parsed=new Date(value.length===10?`${value}T00:00:00Z`:value); return Number.isNaN(parsed.valueOf())?value:new Intl.DateTimeFormat('de-CH',{dateStyle:'medium',timeZone:'Europe/Zurich'}).format(parsed) }
   111	function qualityLabel(value: ModelledValueQuality | undefined) { return ({confirmed:'Bestätigt',modelled:'Modelliert',carried:'Fortgeschrieben',incomplete:'Unvollständig',unavailable:'Nicht verfügbar'} as Record<string,string>)[value??'unavailable'] }
   112	function qualityClass(value: ModelledValueQuality | undefined) { const base='inline-flex rounded-full px-2 py-1 text-xs font-semibold'; return `${base} ${value==='confirmed'?'bg-emerald-100 text-emerald-800':value==='modelled'?'bg-cyan-100 text-cyan-800':value==='carried'?'bg-blue-100 text-blue-800':value==='incomplete'?'bg-amber-100 text-amber-900':'bg-slate-100 text-slate-700'}` }
   113	function coverageLabel(value:string){return value==='complete'?'vollständig':value==='partial'?'teilweise':value==='stale'?'veraltet':'nicht verfügbar'}
   114	async function changePeriod(value: WealthChartPeriod) { if (period.value === value) return; period.value = value; await load(false) }
   115	async function load(refresh=false){loading.value=true;error.value=null;try{cockpit.value=await getWealthCockpit(period.value,refresh)}catch(err){error.value=err instanceof Error?err.message:'Vermögensdaten konnten nicht geladen werden'}finally{loading.value=false}}
   116	watch(()=>props.refreshKey,()=>{void load(true)})
   117	onMounted(()=>{void load()})
   118	defineExpose({load,cockpit})
   119	</script>

===== frontend/src/components/wealth/WealthDevelopmentChart.vue =====
     1	<template>
     2	  <section class="wealth-chart" data-testid="wealth-development-chart">
     3	    <div class="flex flex-wrap items-center justify-between gap-3">
     4	      <div class="flex flex-wrap gap-1" role="group" aria-label="Zeitraum" data-testid="wealth-chart-periods">
     5	        <button v-for="item in periods" :key="item.value" type="button" :aria-pressed="period === item.value" :data-testid="`wealth-period-${item.value}`" :class="controlClass(period === item.value)" @click="$emit('period-change', item.value)">{{ item.label }}</button>
     6	      </div>
     7	      <div class="flex flex-wrap gap-1" role="group" aria-label="Darstellung" data-testid="wealth-chart-modes">
     8	        <button v-for="item in modes" :key="item.value" type="button" :aria-pressed="mode === item.value" :data-testid="`wealth-mode-${item.value}`" :class="controlClass(mode === item.value)" @click="mode = item.value">{{ item.label }}</button>
     9	      </div>
    10	    </div>
    11	
    12	    <div v-if="points.length >= 2" class="mt-4 rounded-xl border border-slate-200 bg-white p-2 sm:p-3" data-testid="modelled-chart">
    13	      <div tabindex="0" role="application" aria-label="Vermögensentwicklung. Mit Pfeil links und rechts Datenpunkte lesen." class="h-72 outline-none focus-visible:ring-2 focus-visible:ring-cyan-600 sm:h-80" data-testid="wealth-chart-keyboard" @keydown.left.prevent="moveSelection(-1)" @keydown.right.prevent="moveSelection(1)">
    14	        <Chart type="line" :data="chartData" :options="chartOptions" class="h-full w-full" data-testid="wealth-chart-canvas" />
    15	      </div>
    16	      <p class="sr-only" aria-live="polite" data-testid="wealth-chart-live">{{ selectedSummary }}</p>
    17	      <div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-600" data-testid="wealth-chart-legend">
    18	        <template v-if="model.last_confirmed_anchor_date || model.anchor?.date">
    19	          <span><span class="font-bold text-cyan-700">━</span> Historie bis Haushaltsanker</span>
    20	          <span><span class="font-bold text-cyan-700">┄</span> Nach letztem bestätigten Haushaltsanker</span>
    21	        </template>
    22	        <span v-else><span class="font-bold text-cyan-700">┄</span> Vollständig modelliert · kein bestätigter Haushaltsanker</span>
    23	        <span>● Bestätigter Snapshot / Importanker</span>
    24	        <span class="text-amber-800">◆ Korrektur</span>
    25	      </div>
    26	    </div>
    27	    <AvailabilityState v-else class="mt-4" state="partial" title="Noch keine darstellbare Reihe" description="Die Grafik erscheint, sobald mindestens zwei bestätigte oder modellierte Tageswerte vorliegen." />
    28	
    29	    <details class="mt-4 rounded-xl border border-slate-200 bg-slate-50/70 p-3" data-testid="modelled-chart-data">
    30	      <summary class="min-h-11 cursor-pointer py-2 text-sm font-semibold text-slate-800">Barrierefreie Datentabelle zur Grafik</summary>
    31	      <p class="mb-3 text-xs text-slate-600">Alternative zur Grafik mit Datum, Gesamtwert, Veränderung, Komponenten, Qualität und Ereignissen.</p>
    32	      <div class="overflow-x-auto">
    33	        <table class="min-w-[52rem] w-full text-left text-xs" data-testid="wealth-data-table">
    34	          <thead><tr class="border-b border-slate-300"><th class="p-2">Datum</th><th class="p-2">Gesamtwert</th><th class="p-2">Veränderung</th><th v-for="component in componentKeys" :key="component" class="p-2">{{ componentLabel(component) }}</th><th class="p-2">Qualität</th><th class="p-2">Ereignis</th></tr></thead>
    35	          <tbody><tr v-for="(point, index) in points" :key="point.date" class="border-b border-slate-200"><td class="p-2">{{ dateLabel(point.date) }}</td><td class="p-2">{{ money(point.value_chf) }}</td><td class="p-2">{{ changeLabel(index) }}</td><td v-for="component in componentKeys" :key="component" class="p-2">{{ moneyOrDash(componentValue(point, component)) }}</td><td class="p-2">{{ qualityLabel(point.quality) }}</td><td class="p-2">{{ eventLabel(point) }}</td></tr></tbody>
    36	        </table>
    37	      </div>
    38	    </details>
    39	  </section>
    40	</template>
    41	
    42	<script setup lang="ts">
    43	import { computed, ref } from 'vue'
    44	import Chart from 'primevue/chart'
    45	import type { ChartData, ChartOptions } from 'chart.js'
    46	import AvailabilityState from '@/components/ui/AvailabilityState.vue'
    47	import type { ModelledWealthDevelopment, ModelledWealthPoint, WealthChartPeriod } from '@/api/portfolio'
    48	
    49	const props = defineProps<{ model: ModelledWealthDevelopment; period: WealthChartPeriod }>()
    50	defineEmits<{ 'period-change': [period: WealthChartPeriod] }>()
    51	type Mode = 'total' | 'components' | 'performance'
    52	type ComponentKey = ModelledWealthPoint['components'][number]['key']
    53	const mode = ref<Mode>('total')
    54	const selectedIndex = ref(0)
    55	const periods: Array<{ value: WealthChartPeriod; label: string }> = [{ value: '1m', label: '1M' }, { value: '3m', label: '3M' }, { value: 'ytd', label: 'YTD' }, { value: '1y', label: '1J' }, { value: 'all', label: 'Max' }]
    56	const modes: Array<{ value: Mode; label: string }> = [{ value: 'total', label: 'Gesamt' }, { value: 'components', label: 'Komponenten' }, { value: 'performance', label: 'Wertentwicklung (modelliert)' }]
    57	const palette: Record<string, string> = { total: '#0e7490', postfinance: '#2563eb', truewealth: '#7c3aed', crypto: '#c2410c', bank_cash: '#0f766e', other_assets: '#64748b' }
    58	const points = computed(() => props.model.points)
    59	const componentKeys = computed<ComponentKey[]>(() => {
    60	  const preferred: ComponentKey[] = ['postfinance', 'truewealth', 'crypto', 'bank_cash', 'other_assets']
    61	  return preferred.filter(key => points.value.some(point => point.components.some(component => component.key === key)))
    62	})
    63	
    64	function controlClass(active: boolean) { return ['min-h-11 rounded-lg border px-3 py-2 text-sm font-semibold', active ? 'border-cyan-600 bg-cyan-50 text-cyan-900' : 'border-slate-300 bg-white text-slate-700 hover:bg-slate-50'] }
    65	function number(value: string | null | undefined) { const parsed = Number(value); return value == null || !Number.isFinite(parsed) ? null : parsed }
    66	function money(value: string | number | null | undefined) { const parsed = number(value == null ? null : String(value)); return parsed == null ? 'Nicht verfügbar' : new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(parsed) }
    67	function moneyOrDash(value: string | null | undefined) { return value == null ? '—' : money(value) }
    68	function dateLabel(value: string) { const parsed = new Date(`${value.slice(0, 10)}T00:00:00Z`); return Number.isNaN(parsed.valueOf()) ? value : new Intl.DateTimeFormat('de-CH', { dateStyle: 'medium', timeZone: 'Europe/Zurich' }).format(parsed) }
    69	function qualityLabel(value: string) { return ({ confirmed: 'Bestätigt', modelled: 'Modelliert', carried: 'Fortgeschrieben', incomplete: 'Unvollständig', unavailable: 'Nicht verfügbar' } as Record<string, string>)[value] ?? value }
    70	function componentLabel(key: ComponentKey) { return ({ postfinance: 'PostFinance', truewealth: 'True Wealth', crypto: 'Krypto', bank_cash: 'Bank', other_assets: 'Weitere Anlagen' } as Record<string, string>)[key] ?? key }
    71	function componentValue(point: ModelledWealthPoint, key: ComponentKey) { return point.components.find(item => item.key === key)?.value_chf ?? null }
    72	function change(index: number) { if (index === 0) return null; const current = number(points.value[index]?.value_chf); const previous = number(points.value[index - 1]?.value_chf); return current == null || previous == null ? null : current - previous }
    73	function changeLabel(index: number) { const value = change(index); return value == null ? '—' : `${value > 0 ? '+' : ''}${money(value)}` }
    74	function pointEvents(point: ModelledWealthPoint) { return [point.event, point.event_label, point.correction_event, ...(point.events ?? [])].filter((value): value is string => typeof value === 'string' && value.trim().length > 0) }
    75	function eventLabel(point: ModelledWealthPoint) { const labels = [...pointEvents(point)]; const householdAnchor = props.model.last_confirmed_anchor_date ?? props.model.anchor?.date ?? null; if (point.date === householdAnchor) labels.unshift('Bestätigter Haushaltsanker'); else if (point.has_confirmed_anchor) labels.unshift('Bestätigter Komponenten-Snapshot'); if (props.model.correction_markers.some(marker => marker.date === point.date)) labels.push('Importkorrektur'); return [...new Set(labels)].join(' · ') || '—' }
    76	function performanceValue(value: string | null, base: number | null) { const current = number(value); return current == null || base == null || base === 0 ? null : (current / base - 1) * 100 }
    77	function valuesFor(key: 'total' | ComponentKey) { const raw = points.value.map(point => key === 'total' ? point.value_chf : componentValue(point, key)); if (mode.value !== 'performance') return raw.map(number); const base = raw.map(number).find(value => value != null) ?? null; return raw.map(value => performanceValue(value, base)) }
    78	function split(values: Array<number | null>) {
    79	  const anchor = props.model.last_confirmed_anchor_date ?? props.model.anchor?.date ?? null
    80	  const before = values.map((value, index) => anchor && points.value[index].date <= anchor ? value : null)
    81	  const after = values.map((value, index) => !anchor || points.value[index].date >= anchor ? value : null)
    82	  return { before, after, hasProjection: Boolean(!anchor || points.value.some(point => point.date > anchor)) }
    83	}
    84	function lineDatasets(key: 'total' | ComponentKey, label: string) {
    85	  const color = palette[key] ?? '#64748b'; const { before, after, hasProjection } = split(valuesFor(key))
    86	  const common = { label, borderColor: color, backgroundColor: key === 'total' ? 'rgba(14,116,144,.10)' : 'transparent', borderWidth: key === 'total' ? 2.5 : 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.25, spanGaps: false }
    87	  return [
    88	    { ...common, data: before, fill: key === 'total' && mode.value === 'total' ? 'origin' : false, borderDash: [] },
    89	    ...(hasProjection ? [{ ...common, label: props.model.last_confirmed_anchor_date || props.model.anchor?.date ? `${label} nach Anker` : `${label} modelliert`, data: after, fill: false, borderDash: [6, 5] }] : []),
    90	  ]
    91	}
    92	const chartData = computed<ChartData<'line'>>(() => {
    93	  const lines = mode.value === 'components' ? componentKeys.value.flatMap(key => lineDatasets(key, componentLabel(key))) : lineDatasets('total', mode.value === 'performance' ? 'Wertentwicklung' : 'Gesamtwert')
    94	  const confirmed = points.value.map(point => point.has_confirmed_anchor || point.quality === 'confirmed' ? (mode.value === 'performance' ? performanceValue(point.value_chf, number(points.value[0]?.value_chf)) : number(point.value_chf)) : null)
    95	  const corrections = points.value.map(point => props.model.correction_markers.some(marker => marker.date === point.date) ? (mode.value === 'performance' ? performanceValue(point.value_chf, number(points.value[0]?.value_chf)) : number(point.value_chf)) : null)
    96	  return { labels: points.value.map(point => point.date), datasets: [...lines, { label: 'Bestätigte Snapshots / Importanker', data: confirmed, showLine: false, pointRadius: 4, pointHoverRadius: 6, pointBackgroundColor: '#ffffff', pointBorderColor: '#0e7490', pointBorderWidth: 2 }, { label: 'Korrekturen', data: corrections, showLine: false, pointStyle: 'rectRot', pointRadius: 6, pointBackgroundColor: '#f59e0b', pointBorderColor: '#92400e' }] }
    97	})
    98	function tooltipLines(index: number) { const point = points.value[index]; if (!point) return []; return [`Gesamtwert: ${money(point.value_chf)}`, `Veränderung: ${changeLabel(index)}`, ...componentKeys.value.map(key => `${componentLabel(key)}: ${moneyOrDash(componentValue(point, key))}`), `Qualität: ${qualityLabel(point.quality)}`, `Ereignis: ${eventLabel(point)}`] }
    99	const chartOptions = computed<ChartOptions<'line'>>(() => ({ responsive: true, maintainAspectRatio: false, interaction: { intersect: false, mode: 'index' }, animation: false, plugins: { legend: { display: mode.value === 'components', position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, filter: item => !item.text.includes('nach Anker') && !['Korrekturen', 'Bestätigte Snapshots / Importanker'].includes(item.text) } }, tooltip: { callbacks: { title: items => items[0] ? dateLabel(String(items[0].label)) : '', label: () => '', afterBody: items => items[0] ? tooltipLines(items[0].dataIndex) : [] } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: 8 } }, y: { ticks: { callback: value => mode.value === 'performance' ? `${new Intl.NumberFormat('de-CH', { maximumFractionDigits: 1 }).format(Number(value))} % modelliert` : new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF', maximumFractionDigits: 0 }).format(Number(value)) } } } }))
   100	function moveSelection(delta: number) { selectedIndex.value = Math.max(0, Math.min(points.value.length - 1, selectedIndex.value + delta)) }
   101	const selectedSummary = computed(() => { const point = points.value[selectedIndex.value]; return point ? `${dateLabel(point.date)}, ${tooltipLines(selectedIndex.value).join(', ')}` : '' })
   102	defineExpose({ chartData, chartOptions, mode })
   103	</script>

===== frontend/src/components/wealth/PortfolioAnalysisPanel.vue =====
     1	<template>
     2	  <section class="rounded-2xl border border-slate-200 bg-white/90 p-4 sm:p-5" data-testid="portfolio-analysis">
     3	    <div class="flex flex-wrap items-start justify-between gap-3">
     4	      <div><p class="text-xs font-bold uppercase tracking-[0.18em] text-cyan-700">Portfolioanalyse v1</p><h3 class="mt-1 text-xl font-semibold text-slate-950">Allokation und Policy</h3><p class="mt-1 text-sm text-slate-600">Sachliche Einordnung auf Basis der verfügbaren Daten. Fehlende Dimensionen bleiben als nicht beurteilbar sichtbar.</p></div>
     5	      <span :class="statusClass(analysis.status)" data-testid="portfolio-analysis-status">{{ statusLabel(analysis.status) }}</span>
     6	    </div>
     7	
     8	    <div class="mt-4 overflow-x-auto" data-testid="portfolio-allocation">
     9	      <table class="min-w-[48rem] w-full text-left text-sm">
    10	        <thead><tr class="border-b border-slate-300"><th class="p-2">Anlageklasse</th><th class="p-2">Ist</th><th class="p-2">Policy-Korridor</th><th class="p-2">Ziel</th><th class="p-2">Ist–Ziel</th><th class="p-2">Status</th></tr></thead>
    11	        <tbody><tr v-for="row in analysis.allocation" :key="row.key" class="border-b border-slate-200"><th class="p-2 font-semibold">{{ row.label }}</th><td class="p-2">{{ percent(row.current_pct) }} · {{ money(row.current_value_chf) }}</td><td class="p-2">{{ percent(row.lower_pct) }}–{{ percent(row.upper_pct) }}</td><td class="p-2">{{ percent(row.target_pct) }}</td><td class="p-2">{{ signedPp(row.deviation_pp) }} · {{ signedMoney(row.deviation_chf) }}</td><td class="p-2"><span :class="statusClass(row.status)">{{ statusLabel(row.status) }}</span></td></tr><tr v-if="!analysis.allocation.length"><td colspan="6" class="p-4 text-slate-600">Allokation nicht beurteilbar.</td></tr></tbody>
    12	      </table>
    13	    </div>
    14	
    15	    <div class="mt-5 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
    16	      <article class="rounded-xl border border-slate-200 p-4" data-testid="portfolio-concentration"><h4 class="font-semibold text-slate-950">Konzentration</h4><dl class="mt-3 grid grid-cols-3 gap-2 text-sm"><div v-for="item in concentrationRows" :key="item.label"><dt class="text-slate-500">{{ item.label }}</dt><dd class="mt-1 font-semibold">{{ percent(item.value) }}</dd></div></dl></article>
    17	      <article v-for="dimension in dimensions" :key="dimension.key" class="rounded-xl border border-slate-200 p-4" :data-testid="`portfolio-dimension-${dimension.key}`"><div class="flex items-center justify-between gap-2"><h4 class="font-semibold text-slate-950">{{ dimension.label }}</h4><span :class="statusClass(dimension.status)">{{ statusLabel(dimension.status) }}</span></div><ul v-if="dimension.rows.length" class="mt-3 space-y-2 text-sm"><li v-for="row in dimension.rows" :key="row.label" class="flex justify-between gap-3"><span>{{ row.label }}</span><strong>{{ percent(row.pct) }}</strong></li></ul><p v-else class="mt-3 text-sm text-slate-600">Soweit verfügbar nicht beurteilbar.</p></article>
    18	    </div>
    19	
    20	    <article class="mt-5 rounded-xl border border-slate-200 p-4" data-testid="portfolio-contributions"><h4 class="font-semibold text-slate-950">Beiträge</h4><div v-if="analysis.contributions.length" class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-4"><div v-for="item in analysis.contributions" :key="item.key"><p class="text-xs text-slate-500">{{ item.label }}</p><p class="font-semibold">{{ money(item.value_chf) }}</p><p v-if="item.status" class="text-xs text-slate-500">{{ statusLabel(item.status) }}</p></div></div><p v-else class="mt-2 text-sm text-slate-600">Beiträge nicht beurteilbar.</p></article>
    21	
    22	    <article class="mt-5 rounded-xl border border-cyan-200 bg-cyan-50/60 p-4" data-testid="portfolio-hints"><h4 class="font-semibold text-slate-950">Priorisierte Hinweise</h4><ol v-if="hints.length" class="mt-3 list-decimal space-y-2 pl-5 text-sm text-slate-800"><li v-for="(hint, index) in hints" :key="`${index}-${hint}`">{{ hint }}</li></ol><p v-else class="mt-2 text-sm text-slate-600">Keine Hinweise verfügbar.</p></article>
    23	  </section>
    24	</template>
    25	
    26	<script setup lang="ts">
    27	import { computed } from 'vue'
    28	import type { PortfolioAnalysis } from '@/api/portfolio'
    29	const props = defineProps<{ analysis: PortfolioAnalysis }>()
    30	const dimensions = computed(() => [
    31	  { key: 'currency', label: 'Währung', ...(props.analysis.dimensions.currency ?? { status: 'unavailable', rows: [] }) },
    32	  { key: 'region', label: 'Region', ...(props.analysis.dimensions.region ?? { status: 'unavailable', rows: [] }) },
    33	  { key: 'sector', label: 'Sektor', ...(props.analysis.dimensions.sector ?? { status: 'unavailable', rows: [] }) },
    34	])
    35	const concentrationRows = computed(() => [{ label: 'Top 1', value: props.analysis.concentrations.top1_pct }, { label: 'Top 5', value: props.analysis.concentrations.top5_pct }, { label: 'Top 10', value: props.analysis.concentrations.top10_pct }])
    36	const hints = computed(() => props.analysis.hints.slice(0, 5).map(hint => typeof hint === 'string' ? hint : hint.text))
    37	function parsed(value: string | number | null | undefined) { const number = Number(value); return value == null || !Number.isFinite(number) ? null : number }
    38	function money(value: string | number | null | undefined) { const number = parsed(value); return number == null ? 'Nicht beurteilbar' : new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(number) }
    39	function percent(value: string | number | null | undefined) { const number = parsed(value); return number == null ? 'Nicht beurteilbar' : `${new Intl.NumberFormat('de-CH', { maximumFractionDigits: 1 }).format(number)} %` }
    40	function signedMoney(value: string | number | null | undefined) { const number = parsed(value); return number == null ? 'Nicht beurteilbar' : `${number > 0 ? '+' : ''}${money(number)}` }
    41	function signedPp(value: string | number | null | undefined) { const number = parsed(value); return number == null ? 'Nicht beurteilbar' : `${number > 0 ? '+' : ''}${new Intl.NumberFormat('de-CH', { maximumFractionDigits: 1 }).format(number)} pp` }
    42	function statusLabel(value: string | null | undefined) { return ({ within_corridor: 'Im Korridor', below_corridor: 'Unter Ziel', above_corridor: 'Über Ziel', complete: 'Beurteilbar', partial: 'Teilweise beurteilbar', unavailable: 'Nicht beurteilbar', modelled: 'Modellierter Beitrag', on_track: 'Im Plan', behind: 'Unter Plan', ahead: 'Über Plan' } as Record<string, string>)[value ?? 'unavailable'] ?? value ?? 'Nicht beurteilbar' }
    43	function statusClass(value: string | null | undefined) { const base = 'inline-flex rounded-full px-2 py-1 text-xs font-semibold'; return `${base} ${['within_corridor', 'complete', 'on_track'].includes(value ?? '') ? 'bg-emerald-100 text-emerald-800' : ['above_corridor', 'below_corridor', 'behind'].includes(value ?? '') ? 'bg-amber-100 text-amber-900' : 'bg-slate-100 text-slate-700'}` }
    44	</script>

===== frontend/src/components/wealth/AssetRefreshControl.vue =====
     1	<template>
     2	  <section class="rounded-xl border border-slate-200 bg-slate-50 p-4" data-testid="asset-refresh-control">
     3	    <div class="flex flex-wrap items-start justify-between gap-3">
     4	      <div><h3 class="font-semibold text-slate-950">Assetpreise</h3><p class="mt-1 text-sm text-slate-600">Kontrollierte Aktualisierung aller konfigurierten Preisquellen. Fehler einer Quelle stoppen die übrigen Quellen nicht.</p></div>
     5	      <button type="button" class="min-h-11 rounded-lg bg-cyan-700 px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60" data-testid="asset-refresh-start" :disabled="starting || running" @click="start">{{ starting ? 'Wird gestartet…' : running ? 'Aktualisierung läuft…' : 'Alle Assetpreise aktualisieren' }}</button>
     6	    </div>
     7	    <p v-if="error" class="mt-3 rounded-lg bg-rose-50 p-3 text-sm text-rose-800" role="alert" data-testid="asset-refresh-error">{{ error }}</p>
     8	    <div v-if="job" class="mt-4" aria-live="polite" data-testid="asset-refresh-status">
     9	      <p class="text-sm font-semibold">Jobstatus: {{ statusLabel(job.status) }}</p>
    10	      <ul class="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3" data-testid="asset-refresh-sources">
    11	        <li v-for="source in job.sources" :key="source.source" class="rounded-lg border border-slate-200 bg-white p-3 text-sm" :data-testid="`asset-refresh-source-${source.source}`">
    12	          <div class="flex items-center justify-between gap-2"><strong>{{ sourceLabel(source.source) }}</strong><span :class="source.status === 'failed' ? 'text-rose-700' : 'text-slate-600'">{{ statusLabel(source.status) }}</span></div>
    13	          <div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200" role="progressbar" :aria-label="`${sourceLabel(source.source)} Fortschritt`" :aria-valuenow="progress(source)" aria-valuemin="0" aria-valuemax="100"><div class="h-full rounded-full bg-cyan-600" :style="{ width: `${progress(source)}%` }" /></div>
    14	          <p class="mt-2 text-xs text-slate-600">{{ source.updated_count }} aktualisiert · {{ source.stale_candidates }} geprüft</p>
    15	          <p v-if="source.error_code" class="mt-1 text-xs text-rose-700">{{ source.error_code }}</p>
    16	        </li>
    17	      </ul>
    18	    </div>
    19	  </section>
    20	</template>
    21	
    22	<script setup lang="ts">
    23	import { computed, onBeforeUnmount, ref } from 'vue'
    24	import { getAssetRefreshJob, startAssetRefreshJob, type AssetRefreshJob, type AssetRefreshSourceStatus } from '@/api/marketRefresh'
    25	const emit = defineEmits<{ completed: [] }>()
    26	const job = ref<AssetRefreshJob | null>(null)
    27	const starting = ref(false)
    28	const error = ref<string | null>(null)
    29	let pollTimer: ReturnType<typeof setTimeout> | null = null
    30	const running = computed(() => job.value?.status === 'queued' || job.value?.status === 'running')
    31	function statusLabel(value: string) { return ({ queued: 'Eingeplant', pending: 'Ausstehend', running: 'Läuft', complete: 'Abgeschlossen', skipped: 'Aktuell', partial: 'Teilweise abgeschlossen', failed: 'Fehlgeschlagen' } as Record<string, string>)[value] ?? value }
    32	function sourceLabel(value: AssetRefreshSourceStatus['source']) { return ({ equity: 'Aktien / ETF', crypto: 'Krypto', fx: 'FX' } as const)[value] }
    33	function progress(source: AssetRefreshSourceStatus) { return ['complete', 'failed', 'skipped'].includes(source.status) ? 100 : source.status === 'running' ? 50 : 0 }
    34	async function start() { if (starting.value || running.value) return; starting.value = true; error.value = null; try { job.value = await startAssetRefreshJob(); if (running.value) schedulePoll(); else emit('completed') } catch (cause) { error.value = cause instanceof Error ? cause.message : 'Aktualisierung konnte nicht gestartet werden.' } finally { starting.value = false } }
    35	function schedulePoll() { if (pollTimer) clearTimeout(pollTimer); pollTimer = setTimeout(() => { void poll() }, 1000) }
    36	async function poll() { if (!job.value) return; try { job.value = await getAssetRefreshJob(job.value.job_id, true); if (running.value) schedulePoll(); else emit('completed') } catch (cause) { error.value = cause instanceof Error ? cause.message : 'Jobstatus konnte nicht geladen werden.' } }
    37	onBeforeUnmount(() => { if (pollTimer) clearTimeout(pollTimer) })
    38	defineExpose({ start, poll, job })
    39	</script>

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