diff --git a/.github/workflows/portfolio-phase3-integration.yml b/.github/workflows/portfolio-phase3-integration.yml index c2ef0dc..93e156d 100644 --- a/.github/workflows/portfolio-phase3-integration.yml +++ b/.github/workflows/portfolio-phase3-integration.yml @@ -102,0 +103,4 @@ jobs: + src/jarvis_finance/api/routers/market.py \ + src/jarvis_finance/api/routers/system.py \ + src/jarvis_finance/api/schemas/manual_snapshot.py \ + src/jarvis_finance/api/schemas/market.py \ @@ -126,0 +131,5 @@ jobs: + src/jarvis_finance/services/asset_price_refresh.py \ + src/jarvis_finance/services/market_service.py \ + src/jarvis_finance/services/portfolio_analysis_v1.py \ + src/jarvis_finance/services/raiffeisen_manual_snapshot.py \ + src/jarvis_finance/services/system_ops.py \ @@ -145,0 +155,3 @@ jobs: + tests/unit/test_asset_price_refresh.py \ + tests/unit/test_portfolio_analysis_v1.py \ + tests/unit/test_raiffeisen_manual_snapshot.py \ @@ -169,0 +182,3 @@ jobs: + tests/unit/test_asset_price_refresh.py \ + tests/unit/test_portfolio_analysis_v1.py \ + tests/unit/test_raiffeisen_manual_snapshot.py \ diff --git a/docs/status/jarvis-finance-status-roadmap-v0.3.md b/docs/status/jarvis-finance-status-roadmap-v0.3.md index 1ace93b..757e549 100644 --- a/docs/status/jarvis-finance-status-roadmap-v0.3.md +++ b/docs/status/jarvis-finance-status-roadmap-v0.3.md @@ -840,0 +841,19 @@ Begründung: Der Budget-Branch ist nach der Release-Konsolidierung auf `main`. D + +--- + +## 13. Portfolio-Cockpit-Roadmap nach Sprint 23 + +### Sprint 24 — Research-Datenfundament + +- Externe Analystenratings mit Provider-, Zeitstempel-, Coverage- und Lizenzprovenienz. +- Newsaggregation mit Deduplizierung, Quellenlink, Publikationszeit und instrumentensicherem Mapping. +- Kurszielkonsens als reine Research-Information mit Anzahl Analysten, Streuung und Datenalter. +- Earnings-, Dividenden- und Corporate-Action-Kalender mit instrumentenbezogener Datenqualität. +- Keine Handelsausführung und keine automatische Buy-/Sell-Empfehlung. + +### Sprint 25 — Gated Unternehmensanalyse + +- Unternehmensbezogene Analystenampel auf dem in Sprint 24 verifizierten Research-Fundament. +- Nachvollziehbare Buy-/Hold-/Sell-Einordnung mit getrennten Bewertungs-, Risiko- und Datenqualitätsgates. +- Preview→Confirm→Audit für jede daraus abgeleitete Benutzeraktion. +- Automatisches Trading bleibt ausdrücklich ausserhalb von Sprint 25 und benötigt einen separaten, freigegebenen Sicherheits-Sprint. diff --git a/docs/status/sprint23-professional-portfolio-cockpit.md b/docs/status/sprint23-professional-portfolio-cockpit.md new file mode 100644 index 0000000..ac90da2 --- /dev/null +++ b/docs/status/sprint23-professional-portfolio-cockpit.md @@ -0,0 +1,72 @@ +# Sprint 23 – Ist-Analyse und API-Verträge + +## Verbindliche Basis + +- Ausgangs-SHA: `546a6a26d7835cecb36dd85f745cfb3669b39aa5` +- Ausgangsschema: 51 +- Zielschema: 52 +- Kanonische Services bleiben führend: Ledger, Cash, Portfolio-Analytics, Policy, FX, Daily Valuation und `modelled_wealth`. +- Keine Handelsaktion und keine zweite Performance-/Rendite-Engine. + +## Tatsächlich betroffene Hauptflächen + +### Backend + +- `services/modelled_wealth.py`: bestehende modellierte Wealth-Reihe, Anker-/Projektionsgrenze, weitere Anlagen und Korrekturmarker. +- `services/wealth_cockpit.py`: read-only Komposition von Wealth-Reihe und Portfolioanalyse v1. +- `services/portfolio_analysis_v1.py`: read-only Analyse über gespeicherte kanonische Bewertungen und aktive versionierte Policy. +- `services/raiffeisen_manual_snapshot.py`: append-only Preview→Confirm für einen datierten manuellen Quellensnapshot. +- `services/market_service.py`: bestehendes Aktien-/ETF-Batching mit Cache und begrenzter Parallelität. +- `services/asset_price_refresh.py`: kontrollierter Hintergrundjob mit isolierten Quellen, Fortschritt, Audit und genau einem Job-Wealth-Snapshot. +- `services/system_ops.py`: Runtime-Status ohne historisch fest codierte Ports. +- `storage/migrations.py`: Schema 52 für Bestätigungs-, Job-, Quellenfortschritts- und Job-Wealth-Snapshot-Lineage. + +### Frontend + +- `components/wealth/WealthCockpitPanel.vue`: Einbindung der gemeinsamen Chart-, Analyse- und Refresh-Komponenten. +- `components/wealth/WealthDevelopmentChart.vue`: gemeinsame PrimeVue/Chart.js-Liniengrafik mit Tabellen- und Tastaturalternative. +- `components/wealth/PortfolioAnalysisPanel.vue`: Policy-Abweichungen, Konzentration, Metadimensionen, Beiträge und maximal fünf Hinweise. +- `components/wealth/AssetRefreshControl.vue`: explizite Startaktion und gespeicherter Fortschritt pro Quelle. +- `api/portfolio.ts` und `api/marketRefresh.ts`: typisierte Verträge. + +## Chart-Iststand + +Die Sprint-22-Wealth-Grafik war eine lokal handgeschriebene SVG-/CSS-Darstellung. Das Repository enthielt Chart.js bereits als gesperrte Abhängigkeit; Sprint 23 führt daher keine zusätzliche Chartbibliothek ein, sondern migriert die Wealth-Reihe auf eine gemeinsame PrimeVue/Chart.js-Komponente. + +## API-Verträge + +### Manueller Raiffeisen-Quellensnapshot + +- `POST /api/portfolio/manual-snapshot/raiffeisen/preview` + - Request: Stichtag und drei exakte CHF-Quellwerte; keine vollständigen Identifikatoren. + - Read-only: betroffene maskierte Konten, bisheriger/neuer Wert, Quelle, Stichtag, erwartete Gesamtänderung und getrennte Mitgliedschaft. +- `POST /api/portfolio/manual-snapshot/raiffeisen/confirm` + - Request: identische Quellwerte plus Preview-, Confirmation- und Fingerprint-Bindung. + - Append-only: zwei getrennte Cash-Snapshots und genau ein separater Mitgliedschafts-Snapshot; null Transaktionen; Audit und Idempotenz. + +### Wealth-Cockpit + +- `GET /api/portfolio/wealth-cockpit?period=1m|3m|ytd|1y|all` + - Ausschliesslich gespeicherte kanonische Daten; keine Provideraufrufe und keine Writes. + - Liefert modellierte Gesamt-/Komponentenreihe, letzten bestätigten Anker, Korrekturmarker, Qualitätsangaben, Portfolioanalyse v1 und separat gegatete TTWROR/XIRR. + +### Assetpreis-Hintergrundjob + +- `POST /api/market/asset-price-refresh` + - Erzeugt nur einen gespeicherten `queued` Job und plant den Worker nach der HTTP-Antwort. +- `GET /api/market/asset-price-refresh/{job_id}` + - Gespeicherter Status; keine Provideraufrufe und keine Writes. + - Fortschritt und Fehler getrennt für Aktien/ETF, Krypto und FX. + +### Runtime-Status + +- `GET /api/system/status` + - Der aufrufende Backend-Endpunkt belegt die Backend-Erreichbarkeit selbst. + - API-/Frontend-Adresse wird aus Request bzw. Runtime-Konfiguration abgeleitet; keine historischen Portkonstanten. + +## Bewusste Grenzen + +- Keine Analystenratings, News, Kurszielkonsense oder Kalender in Sprint 23. +- Keine unternehmensbezogene Buy-/Sell-Empfehlung. +- Keine Handels- oder Bestandsmutation. +- Sprint 24 und 25 sind konkret in `docs/status/jarvis-finance-status-roadmap-v0.3.md` geplant. diff --git a/frontend/src/api/marketRefresh.ts b/frontend/src/api/marketRefresh.ts new file mode 100644 index 0000000..ab7cc5b --- /dev/null +++ b/frontend/src/api/marketRefresh.ts @@ -0,0 +1,28 @@ +import { apiGet, apiPost } from './client' + +export type AssetRefreshSourceStatus = { + source: 'equity' | 'crypto' | 'fx' + status: 'pending' | 'running' | 'complete' | 'failed' | 'skipped' + stale_candidates: number + updated_count: number + error_code: string | null + started_at: string | null + completed_at: string | null +} +export type AssetRefreshJob = { + job_id: string + status: 'queued' | 'running' | 'complete' | 'partial' | 'failed' + requested_at: string + completed_at: string | null + stale_before: string + progress: { completed: number; total: number } + sources: AssetRefreshSourceStatus[] + wealth_snapshot_created: boolean + audit_recorded: boolean + provider_calls_on_read: false +} + +export const startAssetRefreshJob = () => + apiPost('/api/market/asset-price-refresh', { stale_hours: 24 }) +export const getAssetRefreshJob = (jobId: string, refresh = true) => + apiGet(`/api/market/asset-price-refresh/${encodeURIComponent(jobId)}`, { refresh }) diff --git a/frontend/src/api/portfolio.ts b/frontend/src/api/portfolio.ts index 9724278..edc011b 100644 --- a/frontend/src/api/portfolio.ts +++ b/frontend/src/api/portfolio.ts @@ -136,0 +137,24 @@ export type ModelledValueQuality = 'confirmed' | 'modelled' | 'carried' | 'incom +export type WealthChartPeriod = '1m' | '3m' | 'ytd' | '1y' | 'all' +export type WealthComponentKey = 'postfinance' | 'truewealth' | 'crypto' | 'bank_cash' | 'other_assets' +export type ModelledWealthPoint = { + date: string + value_chf: string + quality: ModelledValueQuality + has_confirmed_anchor: boolean + has_modelled_value: boolean + excluded_account_count: number + components: Array<{ key: WealthComponentKey; label: string; value_chf: string | null; quality: ModelledValueQuality; source_date: string | null }> + event?: string | null + event_label?: string | null + correction_event?: string | null + events?: string[] +} +export type PortfolioAnalysisStatus = 'complete' | 'partial' | 'unavailable' +export type PortfolioAnalysis = { + status: PortfolioAnalysisStatus + 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 }> + concentrations: { top1_pct: string | null; top5_pct: string | null; top10_pct: string | null } + dimensions: Partial }>> + contributions: Array<{ key: string; label: string; value_chf: string | null; status?: string | null }> + hints: Array +} @@ -139 +163,2 @@ export type ModelledWealthDevelopment = { - period: { preset: 'since_anchor' | '1m' | '3m' | '1y' | 'all'; from: string; to: string } + period: { preset: 'since_anchor' | WealthChartPeriod; from: string; to: string } + last_confirmed_anchor_date?: string | null @@ -146,11 +171,3 @@ export type ModelledWealthDevelopment = { - points: Array<{ - date: string - value_chf: string - quality: ModelledValueQuality - has_confirmed_anchor: boolean - has_modelled_value: boolean - excluded_account_count: number - components: Array<{ key: 'postfinance' | 'truewealth' | 'crypto' | 'bank_cash'; label: string; value_chf: string | null; quality: ModelledValueQuality; source_date: string | null }> - }> - components: Array<{ key: 'postfinance' | 'truewealth' | 'crypto' | 'bank_cash'; 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 }> - correction_markers: Array<{ date: string; source_key: 'postfinance' | 'truewealth'; confirmed_value_chf: string; predecessor_model_value_chf: string; difference_chf: string }> + points: ModelledWealthPoint[] + 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 }> + correction_markers: Array<{ date: string; source_key: 'postfinance' | 'truewealth' | 'bank_cash'; confirmed_value_chf: string; predecessor_model_value_chf: string; difference_chf: string }> @@ -174,0 +192 @@ export type WealthCockpit = { + portfolio_analysis?: PortfolioAnalysis | null diff --git a/frontend/src/components/wealth/AssetRefreshControl.test.ts b/frontend/src/components/wealth/AssetRefreshControl.test.ts new file mode 100644 index 0000000..691f926 --- /dev/null +++ b/frontend/src/components/wealth/AssetRefreshControl.test.ts @@ -0,0 +1,43 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import AssetRefreshControl from './AssetRefreshControl.vue' +import { getAssetRefreshJob, startAssetRefreshJob, type AssetRefreshJob } from '@/api/marketRefresh' + +vi.mock('@/api/marketRefresh', () => ({ startAssetRefreshJob: vi.fn(), getAssetRefreshJob: vi.fn() })) +afterEach(() => { vi.clearAllMocks(); vi.useRealTimers() }) + +const job = (status: AssetRefreshJob['status'], sources: AssetRefreshJob['sources']): AssetRefreshJob => ({ + job_id: 'job-23', status, requested_at: '2026-08-27T10:00:00Z', completed_at: status === 'running' ? null : '2026-08-27T10:01:00Z', + stale_before: '2026-08-26T10:00:00Z', progress: { completed: status === 'running' ? 1 : 3, total: 3 }, sources, + wealth_snapshot_created: status !== 'running', audit_recorded: status !== 'running', provider_calls_on_read: false, +}) +const source = (name: 'equity' | 'crypto' | 'fx', status: 'pending' | 'running' | 'complete' | 'failed' | 'skipped', error: string | null = null) => ({ + source: name, status, stale_candidates: 2, updated_count: status === 'complete' ? 2 : 0, error_code: error, + started_at: status === 'pending' ? null : '2026-08-27T10:00:00Z', completed_at: ['complete', 'failed', 'skipped'].includes(status) ? '2026-08-27T10:01:00Z' : null, +}) + +describe('AssetRefreshControl', () => { + it('does not start or query a job on mount', async () => { + mount(AssetRefreshControl) + await flushPromises() + expect(startAssetRefreshJob).not.toHaveBeenCalled() + expect(getAssetRefreshJob).not.toHaveBeenCalled() + }) + + it('starts only on action and keeps source progress and errors isolated', async () => { + vi.useFakeTimers() + vi.mocked(startAssetRefreshJob).mockResolvedValue(job('running', [source('equity', 'running'), source('crypto', 'pending'), source('fx', 'pending')])) + vi.mocked(getAssetRefreshJob).mockResolvedValue(job('partial', [source('equity', 'complete'), source('crypto', 'failed', 'provider_unavailable'), source('fx', 'complete')])) + const wrapper = mount(AssetRefreshControl) + await wrapper.get('[data-testid="asset-refresh-start"]').trigger('click') + await flushPromises() + expect(startAssetRefreshJob).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-testid="asset-refresh-source-equity"]').text()).toContain('Läuft') + await vi.advanceTimersByTimeAsync(1000) + await flushPromises() + expect(getAssetRefreshJob).toHaveBeenCalledWith('job-23', true) + expect(wrapper.get('[data-testid="asset-refresh-source-equity"]').text()).toContain('Abgeschlossen') + expect(wrapper.get('[data-testid="asset-refresh-source-crypto"]').text()).toContain('provider_unavailable') + expect(wrapper.emitted('completed')).toHaveLength(1) + }) +}) diff --git a/frontend/src/components/wealth/AssetRefreshControl.vue b/frontend/src/components/wealth/AssetRefreshControl.vue new file mode 100644 index 0000000..5950cea --- /dev/null +++ b/frontend/src/components/wealth/AssetRefreshControl.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/wealth/PortfolioAnalysisPanel.test.ts b/frontend/src/components/wealth/PortfolioAnalysisPanel.test.ts new file mode 100644 index 0000000..506161e --- /dev/null +++ b/frontend/src/components/wealth/PortfolioAnalysisPanel.test.ts @@ -0,0 +1,44 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import PortfolioAnalysisPanel from './PortfolioAnalysisPanel.vue' +import type { PortfolioAnalysis } from '@/api/portfolio' + +const analysis: PortfolioAnalysis = { + status: 'partial', + allocation: [ + { key: 'cash', label: 'Cash', current_value_chf: '450000', current_pct: '48.9', target_pct: '40', lower_pct: '35', upper_pct: '45', deviation_pp: '8.9', deviation_chf: '82000', status: 'above_corridor' }, + { key: 'equity', label: 'Policy-Gruppe Aktien / ETF / True Wealth', current_value_chf: '380000', current_pct: '41.3', target_pct: '50', lower_pct: '45', upper_pct: '55', deviation_pp: '-8.7', deviation_chf: '-80000', status: 'below_corridor' }, + { key: 'etf', label: 'ETF', current_value_chf: '150000', current_pct: '16.3', target_pct: null, lower_pct: null, upper_pct: null, deviation_pp: null, deviation_chf: null, status: 'unavailable' }, + ], + concentrations: { top1_pct: '15', top5_pct: '42', top10_pct: '61' }, + dimensions: { + currency: { status: 'partial', rows: [{ label: 'CHF', pct: '70' }, { label: 'USD', pct: '20' }] }, + region: { status: 'partial', rows: [{ label: 'CH', pct: '30' }] }, + sector: { status: 'unavailable', rows: [] }, + }, + contributions: [ + { key: 'postfinance', label: 'PostFinance', value_chf: '1200', status: 'modelled' }, + { key: 'crypto', label: 'Krypto', value_chf: '-300', status: 'modelled' }, + ], + hints: [ + { priority: 1, text: 'Reduktion prüfen: Cash.' }, + { priority: 2, text: 'Erhöhung prüfen: Policy-Gruppe Aktien / ETF / True Wealth.' }, + { priority: 3, text: 'Daten ergänzen: ETF.' }, + ], +} + +describe('PortfolioAnalysisPanel', () => { + it('shows policy deviations, concentration, metadata quality and only portfolio-level actions', () => { + const wrapper = mount(PortfolioAnalysisPanel, { props: { analysis } }) + expect(wrapper.get('[data-testid="portfolio-allocation"]').text()).toContain('Über Ziel') + expect(wrapper.get('[data-testid="portfolio-allocation"]').text()).toContain('Unter Ziel') + expect(wrapper.get('[data-testid="portfolio-concentration"]').text()).toContain('Top 10') + expect(wrapper.get('[data-testid="portfolio-dimension-sector"]').text()).toContain('Nicht beurteilbar') + const hints = wrapper.get('[data-testid="portfolio-hints"]').text() + expect(hints).toContain('Reduktion prüfen') + expect(hints).toContain('Erhöhung prüfen') + expect(hints).toContain('Daten ergänzen') + expect(hints).not.toMatch(/\b(Buy|Sell)\b/i) + expect(wrapper.get('[data-testid="portfolio-contributions"]').text()).toContain('Modellierter Beitrag') + }) +}) diff --git a/frontend/src/components/wealth/PortfolioAnalysisPanel.vue b/frontend/src/components/wealth/PortfolioAnalysisPanel.vue new file mode 100644 index 0000000..aa9788e --- /dev/null +++ b/frontend/src/components/wealth/PortfolioAnalysisPanel.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/src/components/wealth/WealthCockpitPanel.test.ts b/frontend/src/components/wealth/WealthCockpitPanel.test.ts index e754a64..974a54f 100644 --- a/frontend/src/components/wealth/WealthCockpitPanel.test.ts +++ b/frontend/src/components/wealth/WealthCockpitPanel.test.ts @@ -4,0 +5 @@ import WealthCockpitPanel from './WealthCockpitPanel.vue' +import WealthDevelopmentChart from './WealthDevelopmentChart.vue' @@ -74 +75,3 @@ describe('WealthCockpitPanel modelled development', () => { - expect(wrapper.get('[data-testid="modelled-chart"] line').attributes('stroke-dasharray')).toBe('4 3') + const chart = wrapper.findComponent(WealthDevelopmentChart) + const datasets = (chart.vm as any).chartData.datasets + expect(datasets.some((dataset: any) => dataset.borderDash?.join(',') === '6,5')).toBe(true) @@ -80 +83 @@ describe('WealthCockpitPanel modelled development', () => { - expect(chartData.text()).toContain('bestätigter Komponentenanker') + diff --git a/frontend/src/components/wealth/WealthCockpitPanel.vue b/frontend/src/components/wealth/WealthCockpitPanel.vue index 61d9841..fdddcec 100644 --- a/frontend/src/components/wealth/WealthCockpitPanel.vue +++ b/frontend/src/components/wealth/WealthCockpitPanel.vue @@ -10,9 +10 @@ - + @@ -53,20 +45,2 @@ -

Geschätzte Entwicklung

Rein bestätigte Abschnitte sind durchgezogen; modellierte, fortgeschriebene oder gemischte Abschnitte gestrichelt.

{{ qualityLabel(modelled.current?.quality) }}
-
- - - - - -
● Bestätigte Komponente am Stichtag┄ Modelliert/Fortgeschrieben/Gemischt● Importkorrektur
-
- -
- Barrierefreie Datentabelle zur Grafik -

Die Tabelle nennt Datum, Wert und Qualitätsstatus jedes dargestellten Punkts. Korrekturen ersetzen keinen historischen Modellwert, sondern markieren die Differenz zu einem später bestätigten Anker.

-
- - - -
DatumWertQualitätEvidenz
{{ dateLabel(point.date) }}{{ money(point.value_chf) }}{{ qualityLabel(point.quality) }}{{ pointEvidence(point) }}
-
-
+

Geschätzte Entwicklung

Historie bis zum letzten bestätigten Anker durchgezogen; nur der anschliessende Abschnitt gestrichelt. Gemischte Komponentenqualität segmentiert die Gesamtlinie nicht.

{{ qualityLabel(modelled.current?.quality) }}
+ @@ -84 +57,0 @@ - @@ -90,0 +64,3 @@ + +

Portfolioanalyse v1

Portfolioanalyse nicht beurteilbar.

+ @@ -117 +93,4 @@ import AvailabilityState from '@/components/ui/AvailabilityState.vue' -import { getWealthCockpit, type ModelledValueQuality, type WealthCockpit } from '@/api/portfolio' +import AssetRefreshControl from './AssetRefreshControl.vue' +import PortfolioAnalysisPanel from './PortfolioAnalysisPanel.vue' +import WealthDevelopmentChart from './WealthDevelopmentChart.vue' +import { getWealthCockpit, type ModelledValueQuality, type WealthChartPeriod, type WealthCockpit } from '@/api/portfolio' @@ -120 +99 @@ const props = defineProps<{ refreshKey?: number }>() -const period = ref<'since_anchor' | '1m' | '3m' | '1y' | 'all'>('1m') +const period = ref('1m') @@ -135,13 +114 @@ function coverageLabel(value:string){return value==='complete'?'vollständig':va -function pointEvidence(point:{date:string;has_confirmed_anchor:boolean;has_modelled_value:boolean}) { const labels:string[]=[];if(point.has_confirmed_anchor)labels.push('bestätigter Komponentenanker');if(point.has_modelled_value)labels.push('tagesspezifisch modelliert');if(modelled.value?.correction_markers.some(marker=>marker.date===point.date))labels.push('Importkorrektur');return labels.length?labels.join(', '):'fortgeschrieben oder unvollständig' } - -const chartGeometry = computed(() => { - const rows=modelled.value?.points??[] - const values=rows.map(row=>Number(row.value_chf));const times=rows.map(row=>Date.parse(row.date)) - if(rows.length<2||values.some(value=>!Number.isFinite(value))||times.some(value=>!Number.isFinite(value)))return{points:[],segments:[],markers:[]} - const minV=Math.min(...values),maxV=Math.max(...values),spanV=maxV-minV||1,minT=Math.min(...times),maxT=Math.max(...times),spanT=maxT-minT||1 - const points=rows.map((row,index)=>({date:row.date,quality:row.quality,hasConfirmedAnchor:row.has_confirmed_anchor,hasModelledValue:row.has_modelled_value,x:2+(times[index]-minT)/spanT*96,y:38-(values[index]-minV)/spanV*34})) - const segments=points.slice(1).map((point,index)=>({key:`${points[index].date}-${point.date}`,x1:points[index].x,y1:points[index].y,x2:point.x,y2:point.y,dashed:points[index].quality!=='confirmed'||point.quality!=='confirmed'})) - const markers=(modelled.value?.correction_markers??[]).map(marker=>{const match=points.find(point=>point.date===marker.date);return match?{date:marker.date,source:marker.source_key,x:match.x,y:match.y}:null}).filter((row):row is NonNullable=>row!==null) - return{points,segments,markers} -}) -function componentSparkline(key:'postfinance'|'truewealth'|'crypto'|'bank_cash') { const rows=(modelled.value?.points??[]).map(row=>({date:row.date,value:row.components.find(item=>item.key===key)?.value_chf})).filter((row):row is {date:string;value:string}=>row.value!=null);if(rows.length<2)return'';const values=rows.map(row=>Number(row.value)),min=Math.min(...values),max=Math.max(...values),span=max-min||1;return rows.map((row,index)=>`${index/(rows.length-1)*100},${22-(Number(row.value)-min)/span*20}`).join(' ') } +async function changePeriod(value: WealthChartPeriod) { if (period.value === value) return; period.value = value; await load(false) } diff --git a/frontend/src/components/wealth/WealthDevelopmentChart.test.ts b/frontend/src/components/wealth/WealthDevelopmentChart.test.ts new file mode 100644 index 0000000..7812f25 --- /dev/null +++ b/frontend/src/components/wealth/WealthDevelopmentChart.test.ts @@ -0,0 +1,60 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import WealthDevelopmentChart from './WealthDevelopmentChart.vue' +import type { ModelledWealthDevelopment } from '@/api/portfolio' + +const components = (postfinance: string, other: string) => [ + { key: 'postfinance' as const, label: 'PostFinance', value_chf: postfinance, quality: 'confirmed' as const, source_date: '2026-08-01' }, + { key: 'truewealth' as const, label: 'True Wealth', value_chf: '500', quality: 'modelled' as const, source_date: '2026-08-01' }, + { key: 'crypto' as const, label: 'Krypto', value_chf: '100', quality: 'confirmed' as const, source_date: '2026-08-01' }, + { key: 'bank_cash' as const, label: 'Bank', value_chf: '200', quality: 'carried' as const, source_date: '2026-08-01' }, + { key: 'other_assets' as const, label: 'Weitere Anlagen', value_chf: other, quality: 'confirmed' as const, source_date: '2026-08-01' }, +] +const model: ModelledWealthDevelopment = { + status: 'available', period: { preset: '1m', from: '2026-08-01', to: '2026-08-03' }, last_confirmed_anchor_date: '2026-08-02', + anchor: { date: '2026-08-02', value_chf: '2100', quality: 'confirmed' }, baseline: { date: '2026-08-01', value_chf: '2000', quality: 'confirmed' }, current: { date: '2026-08-03', value_chf: '2200', quality: 'modelled' }, change_chf: '200', change_pct: '10', chart_visible: true, + points: [ + { date: '2026-08-01', value_chf: '2000', quality: 'confirmed', has_confirmed_anchor: true, has_modelled_value: false, excluded_account_count: 0, components: components('1000', '200'), event_label: 'Import' }, + { date: '2026-08-02', value_chf: '2100', quality: 'incomplete', has_confirmed_anchor: true, has_modelled_value: true, excluded_account_count: 0, components: components('1100', '200') }, + { date: '2026-08-03', value_chf: '2200', quality: 'modelled', has_confirmed_anchor: false, has_modelled_value: true, excluded_account_count: 0, components: components('1200', '200'), correction_event: 'Bestand korrigiert' }, + ], + components: [], correction_markers: [{ date: '2026-08-03', source_key: 'postfinance', confirmed_value_chf: '1200', predecessor_model_value_chf: '1190', difference_chf: '10' }], unknown_accounts: [], method: 'modelled_wealth_daily_v1', disclaimer: 'Modelliert', +} + +function mountChart() { + return mount(WealthDevelopmentChart, { props: { model, period: '1m' }, global: { stubs: { Chart: { template: '
' }, AvailabilityState: true } } }) +} + +describe('WealthDevelopmentChart', () => { + it('keeps history solid and dashes only points after the confirmed anchor', () => { + const wrapper = mountChart() + const data = (wrapper.vm as any).chartData + expect(data.datasets[0].borderDash).toEqual([]) + expect(data.datasets[0].data).toEqual([2000, 2100, null]) + expect(data.datasets[1].borderDash).toEqual([6, 5]) + expect(data.datasets[1].data).toEqual([null, 2100, 2200]) + expect(data.datasets.filter((dataset: any) => dataset.label === 'Gesamtwert')).toHaveLength(1) + }) + + it('offers all periods and views, de-CH CHF tooltips, keyboard output and the full table alternative', async () => { + const wrapper = mountChart() + for (const id of ['1m', '3m', 'ytd', '1y', 'all']) expect(wrapper.find(`[data-testid="wealth-period-${id}"]`).exists()).toBe(true) + for (const id of ['total', 'components', 'performance']) expect(wrapper.find(`[data-testid="wealth-mode-${id}"]`).exists()).toBe(true) + await wrapper.get('[data-testid="wealth-mode-components"]').trigger('click') + expect((wrapper.vm as any).chartData.datasets.some((dataset: any) => dataset.label === 'Weitere Anlagen')).toBe(true) + const options = (wrapper.vm as any).chartOptions + const title = options.plugins.tooltip.callbacks.title([{ label: '2026-08-03' }]) + const lines = options.plugins.tooltip.callbacks.afterBody([{ dataIndex: 2 }]) + expect(title).toBe('03.08.2026') + expect(lines.join(' ')).toContain("Gesamtwert: CHF 2'200.00") + expect(lines.join(' ')).toContain('Qualität: Modelliert') + expect(lines.join(' ')).toContain('Ereignis: Bestand korrigiert · Importkorrektur') + expect(options.scales.y.ticks.callback(2200)).toContain('CHF') + await wrapper.get('[data-testid="wealth-chart-keyboard"]').trigger('keydown', { key: 'ArrowRight' }) + expect(wrapper.get('[data-testid="wealth-chart-live"]').text()).toContain('02.08.2026') + const table = wrapper.get('[data-testid="wealth-data-table"]') + expect(table.text()).toContain('Weitere Anlagen') + expect(table.text()).toContain('Bestätigter Snapshot / Importanker') + expect(table.text()).toContain('Importkorrektur') + }) +}) diff --git a/frontend/src/components/wealth/WealthDevelopmentChart.vue b/frontend/src/components/wealth/WealthDevelopmentChart.vue new file mode 100644 index 0000000..816caa5 --- /dev/null +++ b/frontend/src/components/wealth/WealthDevelopmentChart.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/src/pages/PortfolioPage.test.ts b/frontend/src/pages/PortfolioPage.test.ts index 6ed0bda..39d239c 100644 --- a/frontend/src/pages/PortfolioPage.test.ts +++ b/frontend/src/pages/PortfolioPage.test.ts @@ -92 +92 @@ describe('PortfolioPage wealth cockpit', () => { - it('offers the Sprint-22 periods and keeps the model chart independent of performance verification', async () => { + it('offers the Sprint-23 periods and keeps the model chart independent of performance verification', async () => { @@ -96,3 +96,3 @@ describe('PortfolioPage wealth cockpit', () => { - expect(wrapper.findAll('select option').map((item) => item.text())).toEqual([ - 'Seit letztem Importanker', '1 Monat', '3 Monate', 'Jahr', 'Gesamt', - ]) + const chartButtons = wrapper.get('[data-testid="wealth-development-chart"]').findAll('button').map((item) => item.text()) + expect(chartButtons).toEqual(expect.arrayContaining(['1M', '3M', 'YTD', '1J', 'Max'])) + expect(chartButtons).toEqual(expect.arrayContaining(['Gesamt', 'Komponenten', 'Wertentwicklung (modelliert)'])) diff --git a/scripts/ci_portfolio_phase3_gate.py b/scripts/ci_portfolio_phase3_gate.py index 4b59014..4fadc28 100644 --- a/scripts/ci_portfolio_phase3_gate.py +++ b/scripts/ci_portfolio_phase3_gate.py @@ -18 +18 @@ SPRINT5_COMMIT = "480d7a1b72a950e864c4bb021d3f58af8f8c15f9" -EXPECTED_SCHEMA = 51 +EXPECTED_SCHEMA = 52 diff --git a/src/jarvis_finance/api/routers/market.py b/src/jarvis_finance/api/routers/market.py index 61611b3..0914960 100644 --- a/src/jarvis_finance/api/routers/market.py +++ b/src/jarvis_finance/api/routers/market.py @@ -5 +5 @@ from sqlite3 import Connection -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query @@ -8 +8,6 @@ from jarvis_finance.api.dependencies import get_db -from jarvis_finance.api.schemas.market import EquityCandlesResponse, MarketBatchUpdateResponse, MarketChartResponse, MarketQuoteResponse, MarketStatusResponse, QuoteRefreshRequest +from jarvis_finance.api.schemas.market import AssetPriceRefreshJobResponse, AssetPriceRefreshRequest, EquityCandlesResponse, MarketBatchUpdateResponse, MarketChartResponse, MarketQuoteResponse, MarketStatusResponse, QuoteRefreshRequest +from jarvis_finance.services.asset_price_refresh import ( + asset_price_refresh_status, + create_asset_price_refresh_job, + run_asset_price_refresh, +) @@ -25,0 +31,20 @@ router = APIRouter(tags=["market"]) +@router.post("/market/asset-price-refresh", response_model=AssetPriceRefreshJobResponse) +def asset_price_refresh_start( + request: AssetPriceRefreshRequest, + background_tasks: BackgroundTasks, + conn: Connection = Depends(get_db), +) -> dict: + try: + payload, db_path = create_asset_price_refresh_job(conn, stale_hours=request.stale_hours) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + background_tasks.add_task(run_asset_price_refresh, db_path, payload["job_id"]) + return payload + + +@router.get("/market/asset-price-refresh/{job_id}", response_model=AssetPriceRefreshJobResponse) +def asset_price_refresh_job_status(job_id: str, conn: Connection = Depends(get_db)) -> dict: + """Stored status only; it never invokes providers or writes.""" + return asset_price_refresh_status(conn, job_id) + + diff --git a/src/jarvis_finance/api/routers/overview.py b/src/jarvis_finance/api/routers/overview.py index 6a96190..08bb3b1 100644 --- a/src/jarvis_finance/api/routers/overview.py +++ b/src/jarvis_finance/api/routers/overview.py @@ -8,0 +9,6 @@ from jarvis_finance.api.schemas.overview import PortfolioSummary +from jarvis_finance.api.schemas.manual_snapshot import ( + ManualSnapshotConfirmRequest, + ManualSnapshotConfirmResponse, + ManualSnapshotPreviewRequest, + ManualSnapshotPreviewResponse, +) @@ -112,0 +119,4 @@ from jarvis_finance.services.wealth_cockpit import build_wealth_cockpit +from jarvis_finance.services.raiffeisen_manual_snapshot import ( + confirm_raiffeisen_manual_snapshot, + preview_raiffeisen_manual_snapshot, +) @@ -116,0 +127,25 @@ router = APIRouter(tags=["overview"]) +@router.post("/portfolio/manual-snapshot/raiffeisen/preview", response_model=ManualSnapshotPreviewResponse) +def raiffeisen_manual_snapshot_preview( + request: ManualSnapshotPreviewRequest, conn: Connection = Depends(get_db) +) -> dict: + """Read-only preview over the dated source facts supplied by the user.""" + try: + return preview_raiffeisen_manual_snapshot(conn, **request.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.post( + "/portfolio/manual-snapshot/raiffeisen/confirm", + response_model=ManualSnapshotConfirmResponse, + responses={409: {"description": "Preview baseline changed or confirmation id reused"}}, +) +def raiffeisen_manual_snapshot_confirm( + request: ManualSnapshotConfirmRequest, conn: Connection = Depends(get_db) +) -> dict: + try: + return confirm_raiffeisen_manual_snapshot(conn, **request.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + diff --git a/src/jarvis_finance/api/routers/system.py b/src/jarvis_finance/api/routers/system.py index 6806f04..1748c5c 100644 --- a/src/jarvis_finance/api/routers/system.py +++ b/src/jarvis_finance/api/routers/system.py @@ -3 +3,3 @@ from __future__ import annotations -from fastapi import APIRouter, HTTPException +from urllib.parse import urlsplit + +from fastapi import APIRouter, HTTPException, Request @@ -11,2 +13,12 @@ router = APIRouter(tags=["system"]) -def get_system_status() -> dict: - return system_status() +def get_system_status(request: Request) -> dict: + api_url = str(request.base_url).rstrip("/") + "/api" + origin = request.headers.get("origin") + if not origin: + referer = urlsplit(request.headers.get("referer", "")) + origin = f"{referer.scheme}://{referer.netloc}" if referer.scheme in {"http", "https"} and referer.netloc else None + frontend_url = origin if origin and origin.startswith(("http://", "https://")) else None + return system_status( + api_url=api_url, + frontend_url=frontend_url, + backend_reachable=True, + ) diff --git a/src/jarvis_finance/api/schemas/manual_snapshot.py b/src/jarvis_finance/api/schemas/manual_snapshot.py new file mode 100644 index 0000000..0f7d4a6 --- /dev/null +++ b/src/jarvis_finance/api/schemas/manual_snapshot.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class RaiffeisenManualSnapshotInput(BaseModel): + """Dated source facts only; no transaction reconstruction and no full identifiers.""" + + model_config = ConfigDict(extra="forbid") + snapshot_date: date + private_account_value_chf: Decimal = Field(ge=0, max_digits=18, decimal_places=2) + savings_account_value_chf: Decimal = Field(ge=0, max_digits=18, decimal_places=2) + membership_value_chf: Decimal = Field(ge=0, max_digits=18, decimal_places=2) + + @field_validator( + "private_account_value_chf", + "savings_account_value_chf", + "membership_value_chf", + ) + @classmethod + def two_decimal_places(cls, value: Decimal) -> Decimal: + return value.quantize(Decimal("0.01")) + + +class ManualSnapshotAffectedAccount(BaseModel): + model_config = ConfigDict(extra="forbid") + account_label: str + asset_kind: Literal["bank_cash", "membership_asset"] + previous_value_chf: str | None + new_value_chf: str + change_chf: str + previous_status: Literal["confirmed", "unknown", "not_created"] + + +class ManualSnapshotPreviewRequest(RaiffeisenManualSnapshotInput): + pass + + +class ManualSnapshotPreviewResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + preview_id: str + confirmation_id: str + input_fingerprint: str + source_kind: Literal["dated_manual_screenshot"] + snapshot_date: str + affected_accounts: list[ManualSnapshotAffectedAccount] + bank_cash_after_chf: str + separate_membership_asset_after_chf: str + known_wealth_before_chf: str + expected_known_wealth_after_chf: str + expected_total_wealth_change_chf: str + creates_transactions: Literal[False] + append_only: Literal[True] + expires_at: str + + +class ManualSnapshotConfirmRequest(RaiffeisenManualSnapshotInput): + preview_id: str = Field(min_length=8, max_length=128) + confirmation_id: str = Field(min_length=8, max_length=128) + input_fingerprint: str = Field(min_length=32, max_length=128) + + +class ManualSnapshotConfirmResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + status: Literal["confirmed", "already_applied"] + confirmation_id: str + snapshot_date: str + created_snapshot_count: int + created_transaction_count: Literal[0] + bank_cash_after_chf: str + separate_membership_asset_after_chf: str + known_wealth_after_chf: str + audit_recorded: bool diff --git a/src/jarvis_finance/api/schemas/market.py b/src/jarvis_finance/api/schemas/market.py index 92d1f29..04065e2 100644 --- a/src/jarvis_finance/api/schemas/market.py +++ b/src/jarvis_finance/api/schemas/market.py @@ -3 +3,32 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field +from typing import Literal + + +class AssetPriceRefreshRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + stale_hours: int = Field(default=24, ge=1, le=720) + + +class AssetPriceRefreshSourceStatus(BaseModel): + model_config = ConfigDict(extra="forbid") + source: Literal["equity", "crypto", "fx"] + status: Literal["pending", "running", "complete", "failed", "skipped"] + stale_candidates: int + updated_count: int + error_code: str | None + started_at: str | None + completed_at: str | None + + +class AssetPriceRefreshJobResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + job_id: str + status: Literal["queued", "running", "complete", "partial", "failed"] + requested_at: str + completed_at: str | None + stale_before: str + progress: dict[str, int] + sources: list[AssetPriceRefreshSourceStatus] + wealth_snapshot_created: bool + audit_recorded: bool + provider_calls_on_read: Literal[False] @@ -26,0 +58 @@ class QuoteRefreshRequest(BaseModel): + max_parallelism: int = Field(default=3, ge=1, le=4) diff --git a/src/jarvis_finance/api/schemas/wealth_cockpit.py b/src/jarvis_finance/api/schemas/wealth_cockpit.py index d3ceb7a..3261635 100644 --- a/src/jarvis_finance/api/schemas/wealth_cockpit.py +++ b/src/jarvis_finance/api/schemas/wealth_cockpit.py @@ -25 +25 @@ class ModelledWealthPeriod(BaseModel): - preset: Literal["since_anchor", "1m", "3m", "1y", "all"] + preset: Literal["since_anchor", "1m", "3m", "ytd", "1y", "all"] @@ -46 +46 @@ class ModelledPointComponent(BaseModel): - key: Literal["postfinance", "truewealth", "crypto", "bank_cash"] + key: Literal["postfinance", "truewealth", "crypto", "bank_cash", "other_assets"] @@ -68 +68 @@ class ModelledComponentSummary(BaseModel): - key: Literal["postfinance", "truewealth", "crypto", "bank_cash"] + key: Literal["postfinance", "truewealth", "crypto", "bank_cash", "other_assets"] @@ -82 +82 @@ class ModelledCorrectionMarker(BaseModel): - source_key: Literal["postfinance", "truewealth"] + source_key: Literal["postfinance", "truewealth", "bank_cash"] @@ -100,0 +101 @@ class ModelledWealthDevelopment(BaseModel): + last_confirmed_anchor_date: str | None @@ -125,0 +127,50 @@ class VerifiedPerformanceSummary(BaseModel): +class PortfolioAnalysisAllocation(BaseModel): + model_config = ConfigDict(extra="forbid") + key: str + label: str + current_value_chf: str | None + current_pct: str | None + target_pct: str | None + lower_pct: str | None + upper_pct: str | None + deviation_pp: str | None + deviation_chf: str | None + status: Literal["below_corridor", "within_corridor", "above_corridor", "unavailable"] + + +class PortfolioAnalysisDimensionRow(BaseModel): + model_config = ConfigDict(extra="forbid") + label: str + pct: str | None + + +class PortfolioAnalysisDimension(BaseModel): + model_config = ConfigDict(extra="forbid") + status: Literal["complete", "partial", "unavailable"] + rows: list[PortfolioAnalysisDimensionRow] + + +class PortfolioAnalysisContribution(BaseModel): + model_config = ConfigDict(extra="forbid") + key: str + label: str + value_chf: str | None + status: str | None = None + + +class PortfolioAnalysisHint(BaseModel): + model_config = ConfigDict(extra="forbid") + priority: int + text: str + + +class PortfolioAnalysisV1(BaseModel): + model_config = ConfigDict(extra="forbid") + status: Literal["complete", "partial", "unavailable"] + allocation: list[PortfolioAnalysisAllocation] + concentrations: dict[str, str | None] + dimensions: dict[str, PortfolioAnalysisDimension] + contributions: list[PortfolioAnalysisContribution] + hints: list[PortfolioAnalysisHint] + + @@ -221,0 +273 @@ class WealthCockpitResponse(BaseModel): + portfolio_analysis: PortfolioAnalysisV1 diff --git a/src/jarvis_finance/services/asset_price_refresh.py b/src/jarvis_finance/services/asset_price_refresh.py new file mode 100644 index 0000000..a63f32a --- /dev/null +++ b/src/jarvis_finance/services/asset_price_refresh.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import hashlib +import json +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import Path +from sqlite3 import Connection +from typing import Any, Callable + +from jarvis_finance.api.schemas.market import QuoteRefreshRequest +from jarvis_finance.audit.log import record_audit_event +from jarvis_finance.market.providers import CoinGeckoClient +from jarvis_finance.services.crypto_market_recovery import run_crypto_market_one_shot +from jarvis_finance.services.market_service import refresh_equity_fx, refresh_equity_quotes_batch +from jarvis_finance.services.modelled_wealth import build_modelled_wealth_development +from jarvis_finance.storage.database import connect + +SOURCES = ("equity", "crypto", "fx") +PROTECTED_TABLES = ( + "accounts", + "transactions", + "crypto_holdings", + "positions_snapshot", + "postfinance_snapshot_positions", + "truewealth_snapshot_positions", +) + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _database_path(conn: Connection) -> str: + row = next((row for row in conn.execute("PRAGMA database_list") if str(row[1]) == "main"), None) + if not row or not str(row[2] or ""): + raise ValueError("asset_refresh_requires_persistent_database") + return str(Path(str(row[2])).resolve()) + + +def _protected_fingerprint(conn: Connection) -> str: + payload: dict[str, list[dict[str, Any]]] = {} + available = { + str(row[0]) + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + } + for table in PROTECTED_TABLES: + if table not in available: + continue + rows = conn.execute(f'SELECT * FROM "{table}" ORDER BY rowid').fetchall() + payload[table] = [dict(row) for row in rows] + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + ).hexdigest() + + +def _status_payload(conn: Connection, job_id: str) -> dict[str, Any]: + job = conn.execute("SELECT * FROM asset_price_refresh_jobs WHERE job_id=?", (job_id,)).fetchone() + if not job: + raise ValueError("asset_price_refresh_job_not_found") + sources = [ + dict(row) + for row in conn.execute( + "SELECT * FROM asset_price_refresh_sources WHERE job_id=? ORDER BY CASE source WHEN 'equity' THEN 1 WHEN 'crypto' THEN 2 ELSE 3 END", + (job_id,), + ).fetchall() + ] + return { + "job_id": str(job["job_id"]), + "status": str(job["status"]), + "requested_at": str(job["requested_at"]), + "completed_at": str(job["completed_at"]) if job["completed_at"] else None, + "stale_before": str(job["stale_before"]), + "progress": {"completed": int(job["progress_completed"]), "total": int(job["progress_total"])}, + "sources": [ + { + "source": str(row["source"]), + "status": str(row["status"]), + "stale_candidates": int(row["stale_candidates"]), + "updated_count": int(row["updated_count"]), + "error_code": str(row["error_code"]) if row["error_code"] else None, + "started_at": str(row["started_at"]) if row["started_at"] else None, + "completed_at": str(row["completed_at"]) if row["completed_at"] else None, + } + for row in sources + ], + "wealth_snapshot_created": bool(job["wealth_snapshot_id"]), + "audit_recorded": bool(job["audit_id"]), + "provider_calls_on_read": False, + } + + +def create_asset_price_refresh_job(conn: Connection, *, stale_hours: int = 24) -> tuple[dict[str, Any], str]: + """Persist a queued job only. No provider call occurs before the HTTP response.""" + if conn.execute( + "SELECT 1 FROM asset_price_refresh_jobs WHERE status IN ('queued','running') LIMIT 1" + ).fetchone(): + raise ValueError("asset_price_refresh_job_already_running") + now = datetime.now(UTC) + job_id = f"asset-refresh-{uuid.uuid4().hex}" + stale_before = (now - timedelta(hours=max(1, min(stale_hours, 720)))).isoformat() + conn.execute( + """INSERT INTO asset_price_refresh_jobs( + job_id,status,requested_at,stale_before,progress_total,progress_completed + ) VALUES(?,'queued',?,?,3,0)""", + (job_id, now.isoformat(), stale_before), + ) + conn.executemany( + """INSERT INTO asset_price_refresh_sources( + job_id,source,status,stale_candidates,updated_count + ) VALUES(?,?,'pending',0,0)""", + [(job_id, source) for source in SOURCES], + ) + conn.commit() + return _status_payload(conn, job_id), _database_path(conn) + + +def _equity_source(conn: Connection) -> tuple[int, int]: + candidates = int( + conn.execute( + """SELECT COUNT(DISTINCT i.instrument_id) FROM instruments i + JOIN instrument_price_mappings m ON m.instrument_id=i.instrument_id AND m.mapping_status='mapped' + WHERE i.is_active=1 AND i.asset_class IN ('stock','equity','etf') + AND COALESCE(i.instrument_status,'active') NOT IN ('inactive','delisted','suspended','merged') + AND COALESCE(i.valuation_policy,'')!='exclude_from_auto_price_update'""" + ).fetchone()[0] + ) + response = refresh_equity_quotes_batch( + conn, + QuoteRefreshRequest( + provider="auto", only_missing=True, limit=500, max_retries=1, pacing_seconds=0.15 + ), + ) + if response.errors and response.updated == 0 and response.cached == 0: + raise RuntimeError("equity_provider_failed") + return candidates, int(response.updated) + + +def _crypto_source(conn: Connection) -> tuple[int, int]: + candidates = int( + conn.execute( + """SELECT COUNT(*) FROM crypto_assets a + WHERE a.is_active=1 AND EXISTS( + SELECT 1 FROM crypto_holdings h WHERE h.asset_id=a.asset_id AND CAST(h.quantity AS REAL)<>0 + )""" + ).fetchone()[0] + ) + if candidates == 0: + return 0, 0 + result = run_crypto_market_one_shot(conn, provider=CoinGeckoClient()) + if result.status != "complete": + raise RuntimeError("crypto_provider_" + result.status) + return candidates, int(result.price_stored) + + +def _fx_source(conn: Connection) -> tuple[int, int]: + stale_before = (datetime.now(UTC) - timedelta(hours=24)).isoformat() + rows = conn.execute( + """SELECT DISTINCT i.instrument_id FROM instruments i + JOIN instrument_price_mappings m ON m.instrument_id=i.instrument_id AND m.mapping_status='mapped' + WHERE i.is_active=1 AND upper(COALESCE(i.currency,'CHF'))<>'CHF' + AND i.asset_class IN ('stock','equity','etf') + AND NOT EXISTS( + SELECT 1 FROM fx_rates f + WHERE f.base_currency=upper(i.currency) AND f.quote_currency='CHF' + AND COALESCE(f.fetched_at,f.created_at)>=? + ) + ORDER BY i.instrument_id""", + (stale_before,), + ).fetchall() + updated = 0 + errors: list[str] = [] + for row in rows: + try: + result = refresh_equity_fx(conn, str(row["instrument_id"])) + updated += int(bool(result.get("updated") or result.get("status") in {"fresh", "complete"})) + except Exception as exc: # one instrument/provider failure must not abort other currencies + errors.append(type(exc).__name__) + if errors and updated == 0: + raise RuntimeError("fx_provider_failed") + return len(rows), updated + + +DEFAULT_RUNNERS: dict[str, Callable[[Connection], tuple[int, int]]] = { + "equity": _equity_source, + "crypto": _crypto_source, + "fx": _fx_source, +} + + +def run_asset_price_refresh( + db_path: str, + job_id: str, + *, + runners: dict[str, Callable[[Connection], tuple[int, int]]] | None = None, +) -> None: + """Background worker with source isolation, stored progress and mutation guard.""" + conn = connect(db_path) + selected = runners or DEFAULT_RUNNERS + try: + job = conn.execute("SELECT status FROM asset_price_refresh_jobs WHERE job_id=?", (job_id,)).fetchone() + if not job or str(job["status"]) != "queued": + return + conn.execute("UPDATE asset_price_refresh_jobs SET status='running' WHERE job_id=?", (job_id,)) + conn.commit() + protected_before = _protected_fingerprint(conn) + completed = 0 + failures = 0 + for source in SOURCES: + started = _now() + conn.execute( + "UPDATE asset_price_refresh_sources SET status='running',started_at=? WHERE job_id=? AND source=?", + (started, job_id, source), + ) + conn.commit() + candidates = updated = 0 + status = "complete" + error_code = None + try: + candidates, updated = selected[source](conn) + if candidates == 0: + status = "skipped" + except Exception as exc: + if conn.in_transaction: + conn.rollback() + status = "failed" + failures += 1 + error_code = str(exc)[:120] or type(exc).__name__ + completed += 1 + conn.execute( + """UPDATE asset_price_refresh_sources + SET status=?,stale_candidates=?,updated_count=?,error_code=?,completed_at=? + WHERE job_id=? AND source=?""", + (status, candidates, updated, error_code, _now(), job_id, source), + ) + conn.execute( + "UPDATE asset_price_refresh_jobs SET progress_completed=? WHERE job_id=?", + (completed, job_id), + ) + conn.commit() + if _protected_fingerprint(conn) != protected_before: + raise RuntimeError("protected_holdings_or_transactions_mutated") + + successful_sources = failures < len(SOURCES) + wealth_snapshot_id = None + if successful_sources: + model = build_modelled_wealth_development(conn, period="1m") + current = model.get("current") or {} + wealth_snapshot_id = f"wealth-refresh-{uuid.uuid4().hex}" + source_rows = [ + dict(row) + for row in conn.execute( + "SELECT source,status,stale_candidates,updated_count,error_code FROM asset_price_refresh_sources WHERE job_id=? ORDER BY source", + (job_id,), + ).fetchall() + ] + conn.execute( + """INSERT INTO aggregated_wealth_refresh_snapshots( + wealth_snapshot_id,job_id,captured_at,known_wealth_chf,quality_status,source_status_json + ) VALUES(?,?,?,?,?,?)""", + ( + wealth_snapshot_id, + job_id, + _now(), + current.get("value_chf"), + "complete" if failures == 0 else "partial", + json.dumps(source_rows, sort_keys=True), + ), + ) + final_status = "complete" if failures == 0 else "failed" if failures == len(SOURCES) else "partial" + audit_id = record_audit_event( + conn, + source="asset_price_refresh_job_v1", + action="asset_prices_refresh_completed", + entity_type="asset_price_refresh_job", + entity_id=job_id, + old_values={}, + new_values={ + "status": final_status, + "source_count": len(SOURCES), + "failed_source_count": failures, + "wealth_snapshot_created": bool(wealth_snapshot_id), + "holdings_mutated": False, + "transactions_mutated": False, + "trades_created": 0, + }, + created_by="system", + ) + conn.execute( + """UPDATE asset_price_refresh_jobs + SET status=?,completed_at=?,wealth_snapshot_id=?,audit_id=? + WHERE job_id=?""", + (final_status, _now(), wealth_snapshot_id, audit_id, job_id), + ) + conn.commit() + except Exception as exc: + if conn.in_transaction: + conn.rollback() + audit_id = record_audit_event( + conn, + source="asset_price_refresh_job_v1", + action="asset_prices_refresh_failed", + entity_type="asset_price_refresh_job", + entity_id=job_id, + old_values={}, + new_values={"status": "failed", "error_code": str(exc)[:120]}, + created_by="system", + ) + conn.execute( + "UPDATE asset_price_refresh_jobs SET status='failed',completed_at=?,audit_id=? WHERE job_id=?", + (_now(), audit_id, job_id), + ) + conn.commit() + finally: + conn.close() + + +def asset_price_refresh_status(conn: Connection, job_id: str) -> dict[str, Any]: + """Stored status only: no provider call, write or lazy refresh.""" + return _status_payload(conn, job_id) diff --git a/src/jarvis_finance/services/market_service.py b/src/jarvis_finance/services/market_service.py index d262048..27e33f6 100644 --- a/src/jarvis_finance/services/market_service.py +++ b/src/jarvis_finance/services/market_service.py @@ -3,0 +4 @@ from dataclasses import replace +from concurrent.futures import ThreadPoolExecutor, as_completed @@ -18,0 +20 @@ from jarvis_finance.market_data.prices import EquityPriceQuote, equity_price_pro +from jarvis_finance.storage.database import connect @@ -256,0 +259,27 @@ def refresh_equity_quote(conn: Connection, instrument_id: str, req: QuoteRefresh +def _persistent_database_path(conn: Connection) -> str | None: + row = next((row for row in conn.execute("PRAGMA database_list") if str(row[1]) == "main"), None) + return str(row[2]) if row and str(row[2] or "") else None + + +def _parallel_equity_worker( + db_path: str, instrument_id: str, req: QuoteRefreshRequest +) -> tuple[MarketQuoteResponse, int]: + worker = connect(db_path) + worker.execute("PRAGMA busy_timeout=10000") + try: + attempt = 0 + quote: MarketQuoteResponse | None = None + while attempt <= req.max_retries: + attempt += 1 + quote = refresh_equity_quote(worker, instrument_id, req) + if quote.latest_price is not None and quote.quality_status == "fresh": + break + if quote.quality_status not in {"rate_limited", "network_error"} or attempt > req.max_retries: + break + time.sleep(min(2 ** (attempt - 1), 4)) + assert quote is not None + return quote, attempt + finally: + worker.close() + + @@ -286,0 +316,18 @@ def refresh_equity_quotes_batch(conn: Connection, req: QuoteRefreshRequest) -> M + parallel_results: dict[str, tuple[MarketQuoteResponse, int]] = {} + db_path = _persistent_database_path(conn) + uncached_rows = [row for row, has_fresh in row_states if not (req.only_missing and has_fresh)] + if db_path and not req.dry_run and req.max_parallelism > 1 and len(uncached_rows) > 1: + with ThreadPoolExecutor(max_workers=min(req.max_parallelism, len(uncached_rows))) as executor: + futures = { + executor.submit(_parallel_equity_worker, db_path, str(row["instrument_id"]), req): str(row["instrument_id"]) + for row in uncached_rows + } + for future in as_completed(futures): + instrument_id = futures[future] + try: + parallel_results[instrument_id] = future.result() + except Exception as exc: + parallel_results[instrument_id] = ( + MarketQuoteResponse(quality_status="provider_error", warnings=[type(exc).__name__]), + 1, + ) @@ -293,17 +340,23 @@ def refresh_equity_quotes_batch(conn: Connection, req: QuoteRefreshRequest) -> M - elapsed = time.monotonic() - last_call_at - if last_call_at and elapsed < req.pacing_seconds: - time.sleep(req.pacing_seconds - elapsed) - attempt = 0 - quote: MarketQuoteResponse | None = None - while attempt <= req.max_retries: - attempt += 1 - processed += 1 - provider_calls += 1 - last_call_at = time.monotonic() - quote = refresh_equity_quote(conn, row["instrument_id"], req) - if quote.latest_price is not None and quote.quality_status == "fresh": - break - if quote.quality_status not in {"rate_limited", "network_error"} or attempt > req.max_retries: - break - time.sleep(min(2 ** (attempt - 1), 4)) - assert quote is not None + parallel = parallel_results.get(str(row["instrument_id"])) + if parallel: + quote, attempt = parallel + processed += attempt + provider_calls += attempt + else: + elapsed = time.monotonic() - last_call_at + if last_call_at and elapsed < req.pacing_seconds: + time.sleep(req.pacing_seconds - elapsed) + attempt = 0 + quote: MarketQuoteResponse | None = None + while attempt <= req.max_retries: + attempt += 1 + processed += 1 + provider_calls += 1 + last_call_at = time.monotonic() + quote = refresh_equity_quote(conn, row["instrument_id"], req) + if quote.latest_price is not None and quote.quality_status == "fresh": + break + if quote.quality_status not in {"rate_limited", "network_error"} or attempt > req.max_retries: + break + time.sleep(min(2 ** (attempt - 1), 4)) + assert quote is not None diff --git a/src/jarvis_finance/services/modelled_wealth.py b/src/jarvis_finance/services/modelled_wealth.py index c3e84b4..acde6ca 100644 --- a/src/jarvis_finance/services/modelled_wealth.py +++ b/src/jarvis_finance/services/modelled_wealth.py @@ -17 +17 @@ PERCENT = Decimal("0.0001") -MODEL_PERIODS = {"since_anchor", "1m", "3m", "1y", "all"} +MODEL_PERIODS = {"since_anchor", "1m", "3m", "ytd", "1y", "all"} @@ -28,0 +29 @@ COMPONENT_LABELS = { + "other_assets": "Weitere Anlagen", @@ -369,0 +371,74 @@ def _crypto_events(conn: Connection, *, through: str) -> dict[str, dict[str, Any +def _other_asset_events(conn: Connection, *, through: str) -> dict[str, dict[str, Any]]: + """Aggregate confirmed non-cash account values without inventing daily precision.""" + rows = conn.execute( + """WITH ranked AS ( + SELECT s.account_id,s.valuation_date,s.total_value_chf, + ROW_NUMBER() OVER( + PARTITION BY s.account_id,s.valuation_date + ORDER BY COALESCE(s.valuation_at,s.created_at) DESC,s.snapshot_id DESC + ) rn + FROM account_value_snapshots s + JOIN accounts a ON a.account_id=s.account_id + WHERE a.is_active=1 AND a.account_type IN ('other_asset','membership') + AND s.valuation_date<=? AND COALESCE(s.is_active,1)=1 + AND s.quality_status IN ('confirmed','ok','complete') + ) SELECT account_id,valuation_date,total_value_chf + FROM ranked WHERE rn=1 ORDER BY valuation_date,account_id""", + (through,), + ).fetchall() + latest: dict[str, Decimal] = {} + events: dict[str, dict[str, Any]] = {} + for row in rows: + try: + value = Decimal(str(row["total_value_chf"])) + except Exception: + continue + if not value.is_finite() or value < Decimal("0"): + continue + latest[str(row["account_id"])] = value + day = str(row["valuation_date"]) + events[day] = { + "value": sum(latest.values(), Decimal("0")), + "quality": "confirmed", + "source_date": day, + } + return events + + +def _manual_cash_correction_markers(conn: Connection, *, through: str) -> list[dict[str, str]]: + rows = conn.execute( + """SELECT snapshot_id,account_id,balance_date,amount_chf,created_at + FROM cash_account_snapshots + WHERE source='manual_screenshot_snapshot' AND balance_date<=? + ORDER BY balance_date,created_at,snapshot_id""", + (through,), + ).fetchall() + grouped: dict[str, dict[str, Decimal]] = {} + for row in rows: + previous = conn.execute( + """SELECT amount_chf FROM cash_account_snapshots + WHERE account_id=? AND ( + balance_date latest_anchor) and any( @@ -640 +720 @@ def build_modelled_wealth_development( - if "modelled" in qualities + if "modelled" in qualities and (latest_anchor is None or cursor > latest_anchor) @@ -677 +757 @@ def build_modelled_wealth_development( - for key in ("postfinance", "truewealth", "crypto"): + for key in ("postfinance", "truewealth", "crypto", "other_assets"): @@ -829,0 +910 @@ def build_modelled_wealth_development( + "last_confirmed_anchor_date": latest_anchor.isoformat() if latest_anchor else None, @@ -843 +924 @@ def build_modelled_wealth_development( - for marker in pf_markers + tw_markers + for marker in pf_markers + tw_markers + _manual_cash_correction_markers(conn, through=through) diff --git a/src/jarvis_finance/services/portfolio_analysis_v1.py b/src/jarvis_finance/services/portfolio_analysis_v1.py new file mode 100644 index 0000000..1073466 --- /dev/null +++ b/src/jarvis_finance/services/portfolio_analysis_v1.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from decimal import Decimal, InvalidOperation +from sqlite3 import Connection +from typing import Any + +from jarvis_finance.services.modelled_wealth import ( + _bank_accounts, + build_modelled_wealth_development, + effective_cash_evidence, +) +from jarvis_finance.services.portfolio_policy import active_policy + +ZERO = Decimal("0") +HUNDRED = Decimal("100") +MONEY = Decimal("0.01") +PCT = Decimal("0.01") + + +def _decimal(value: object) -> Decimal: + try: + result = Decimal(str(value or "0")) + return result if result.is_finite() else ZERO + except (InvalidOperation, ValueError): + return ZERO + + +def _money(value: Decimal | None) -> str | None: + return None if value is None else format(value.quantize(MONEY), "f") + + +def _pct(value: Decimal | None) -> str | None: + return None if value is None else format(value.quantize(PCT), "f") + + +def _latest_positions(conn: Connection, as_of: str) -> tuple[list[dict[str, Any]], str]: + row = conn.execute( + """SELECT as_of,quality_status,summary_json FROM portfolio_analysis_snapshots + WHERE as_of<=? ORDER BY as_of DESC,created_at DESC,analysis_snapshot_id DESC LIMIT 1""", + (as_of,), + ).fetchone() + if not row: + return [], "unavailable" + try: + summary = json.loads(str(row["summary_json"] or "{}")) + positions = [item for item in summary.get("positions", []) if isinstance(item, dict)] + except (TypeError, ValueError, json.JSONDecodeError): + return [], "unavailable" + metadata = { + str(item["instrument_id"]): dict(item) + for item in conn.execute( + "SELECT instrument_id,country,sector,currency,asset_class FROM instruments WHERE is_active=1" + ).fetchall() + } + result: list[dict[str, Any]] = [] + for item in positions: + value = _decimal(item.get("value_chf")) + if value <= ZERO: + continue + instrument = metadata.get(str(item.get("instrument_id") or ""), {}) + result.append( + { + **item, + "value": value, + "asset_class": str(item.get("asset_class") or instrument.get("asset_class") or "").lower(), + "currency": str(item.get("currency") or instrument.get("currency") or "").upper(), + "country": str(instrument.get("country") or "").strip(), + "sector": str(instrument.get("sector") or "").strip(), + } + ) + quality = "complete" if str(row["quality_status"]) == "complete" else "partial" + return result, quality + + +def _current_components(modelled: dict[str, Any]) -> dict[str, Decimal]: + return { + str(item["key"]): _decimal(item.get("current_value_chf")) + for item in modelled.get("components", []) + if item.get("current_value_chf") is not None + } + + +def _policy_rows(conn: Connection) -> dict[str, dict[str, Any]]: + configured = active_policy(conn) + policy = configured.get("policy") if configured.get("configured") else None + if not policy: + return {} + return {str(item["asset_class"]): item for item in policy.get("allocations", [])} + + +def _allocation_row( + *, key: str, label: str, value: Decimal, total: Decimal, policy: dict[str, Any] | None +) -> dict[str, Any]: + current_pct = value / total * HUNDRED if total > ZERO else None + if not policy or current_pct is None: + return { + "key": key, "label": label, "current_value_chf": _money(value), + "current_pct": _pct(current_pct), "target_pct": None, "lower_pct": None, + "upper_pct": None, "deviation_pp": None, "deviation_chf": None, + "status": "unavailable", + } + target = _decimal(policy.get("target_pct")) + lower = _decimal(policy.get("lower_pct")) + upper = _decimal(policy.get("upper_pct")) + deviation_pp = current_pct - target + deviation_chf = value - total * target / HUNDRED + status = "below_corridor" if current_pct < lower else "above_corridor" if current_pct > upper else "within_corridor" + return { + "key": key, "label": label, "current_value_chf": _money(value), + "current_pct": _pct(current_pct), "target_pct": _pct(target), + "lower_pct": _pct(lower), "upper_pct": _pct(upper), + "deviation_pp": _pct(deviation_pp), "deviation_chf": _money(deviation_chf), + "status": status, + } + + +def _dimension( + positions: list[dict[str, Any]], field: str, total: Decimal, *, include_chf: Decimal = ZERO +) -> dict[str, Any]: + values: dict[str, Decimal] = defaultdict(lambda: ZERO) + assessed = ZERO + if include_chf > ZERO and field == "currency": + values["CHF"] += include_chf + assessed += include_chf + for item in positions: + label = str(item.get(field) or "").strip() + if not label: + continue + values[label] += item["value"] + assessed += item["value"] + rows = [ + {"label": label, "pct": _pct(value / total * HUNDRED) if total > ZERO else None} + for label, value in sorted(values.items(), key=lambda item: (-item[1], item[0])) + ] + if total <= ZERO or not rows: + status = "unavailable" + else: + status = "complete" if assessed >= total - Decimal("0.01") else "partial" + return {"status": status, "rows": rows} + + +def _concentrations(values: list[Decimal], total: Decimal) -> dict[str, str | None]: + ordered = sorted((value for value in values if value > ZERO), reverse=True) + def share(limit: int) -> str | None: + return _pct(sum(ordered[:limit], ZERO) / total * HUNDRED) if total > ZERO and ordered else None + return {"top1_pct": share(1), "top5_pct": share(5), "top10_pct": share(10)} + + +def _prioritized_hints(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + priority = {"above_corridor": 1, "below_corridor": 2, "unavailable": 3, "within_corridor": 4} + prefix = { + "above_corridor": "Reduktion prüfen", + "below_corridor": "Erhöhung prüfen", + "unavailable": "Daten ergänzen", + "within_corridor": "Im Zielkorridor", + } + candidates = sorted(rows, key=lambda row: (priority.get(str(row["status"]), 9), str(row["label"]))) + return [ + {"priority": index, "text": f"{prefix.get(str(row['status']), 'Daten ergänzen')}: {row['label']}."} + for index, row in enumerate(candidates[:5], start=1) + ] + + +def build_portfolio_analysis_v1( + conn: Connection, *, as_of: str, modelled: dict[str, Any] | None = None +) -> dict[str, Any]: + """Read-only v1 analysis over canonical stored valuations and active versioned policy.""" + model = modelled or build_modelled_wealth_development(conn, period="1m", as_of=as_of) + components = _current_components(model) + positions, position_quality = _latest_positions(conn, as_of) + stock_value = sum((item["value"] for item in positions if item["asset_class"] in {"equity", "stock"}), ZERO) + etf_value = sum((item["value"] for item in positions if item["asset_class"] in {"etf", "fund"}), ZERO) + classified_pf = stock_value + etf_value + postfinance_total = components.get("postfinance", ZERO) + settlement_cash = max(ZERO, postfinance_total - classified_pf) + bank_cash = components.get("bank_cash", ZERO) + cash_value = bank_cash + settlement_cash + truewealth_value = components.get("truewealth", ZERO) + crypto_value = components.get("crypto", ZERO) + other_value = components.get("other_assets", ZERO) + total = cash_value + stock_value + etf_value + truewealth_value + crypto_value + other_value + policy = _policy_rows(conn) + + requested = [ + ("cash", "Cash", cash_value, policy.get("cash")), + ("stocks", "Aktien", stock_value, None), + ("etf", "ETF", etf_value, None), + ("truewealth", "True Wealth", truewealth_value, None), + ("crypto", "Krypto", crypto_value, policy.get("crypto")), + ("other", "Weitere Anlagen", other_value, policy.get("other")), + ] + allocation = [ + _allocation_row(key=key, label=label, value=value, total=total, policy=target) + for key, label, value, target in requested + ] + if policy.get("equity"): + allocation.append( + _allocation_row( + key="equity_policy_group", + label="Policy-Gruppe Aktien / ETF / True Wealth", + value=stock_value + etf_value + truewealth_value, + total=total, + policy=policy["equity"], + ) + ) + + concentration_values = [item["value"] for item in positions] + concentration_values.extend(value for value in (truewealth_value, crypto_value, other_value) if value > ZERO) + for account in _bank_accounts(conn): + evidence = effective_cash_evidence(conn, account_id=account["account_id"], as_of=as_of) + if evidence["value_chf"] is not None: + concentration_values.append(_decimal(evidence["value_chf"])) + if settlement_cash > ZERO: + concentration_values.append(settlement_cash) + + contributions = [] + for item in model.get("components", []): + value = item.get("change_chf") + if value is None or _decimal(value) == ZERO: + continue + contributions.append( + {"key": str(item["key"]), "label": str(item["label"]), "value_chf": _money(_decimal(value)), "status": "modelled"} + ) + positives = sorted((row for row in contributions if _decimal(row["value_chf"]) > ZERO), key=lambda row: _decimal(row["value_chf"]), reverse=True)[:2] + negatives = sorted((row for row in contributions if _decimal(row["value_chf"]) < ZERO), key=lambda row: _decimal(row["value_chf"]))[:2] + + status = "unavailable" if total <= ZERO else "partial" if position_quality != "complete" or any(row["status"] == "unavailable" for row in allocation) else "complete" + return { + "status": status, + "allocation": allocation, + "concentrations": _concentrations(concentration_values, total), + "dimensions": { + "currency": _dimension(positions, "currency", total, include_chf=cash_value + other_value), + "region": _dimension(positions, "country", total), + "sector": _dimension(positions, "sector", total), + }, + "contributions": positives + negatives, + "hints": _prioritized_hints(allocation), + } diff --git a/src/jarvis_finance/services/raiffeisen_manual_snapshot.py b/src/jarvis_finance/services/raiffeisen_manual_snapshot.py new file mode 100644 index 0000000..48dd7e1 --- /dev/null +++ b/src/jarvis_finance/services/raiffeisen_manual_snapshot.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import hashlib +import json +import uuid +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal +from sqlite3 import Connection +from typing import Any + +from jarvis_finance.audit.log import record_audit_event +from jarvis_finance.imports.common import stable_id +from jarvis_finance.services.modelled_wealth import build_modelled_wealth_development + +ZERO = Decimal("0") +SOURCE_KIND = "dated_manual_screenshot" +SOURCE_DB = "manual_screenshot_snapshot" +PRIVATE_SUFFIX = "5632" +SAVINGS_SUFFIX = "5031" + + +def _safe_label(account_name: str) -> str: + compact = account_name.replace(" ", "") + for suffix in (PRIVATE_SUFFIX, SAVINGS_SUFFIX): + if compact.endswith(suffix): + return f"Bankkonto ••••{suffix}" + return "Bankkonto" + + +@dataclass(frozen=True) +class _Target: + role: str + account_id: str | None + label: str + asset_kind: str + previous: Decimal | None + previous_status: str + new_value: Decimal + platform_id: str + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _fmt(value: Decimal) -> str: + return str(value.quantize(Decimal("0.01"))) + + +def _decimal(value: object) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return ZERO + + +def _hash(payload: object) -> str: + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + ).hexdigest() + + +def _platform_id(conn: Connection) -> str: + rows = conn.execute( + "SELECT platform_id FROM platforms WHERE lower(name) LIKE '%raiffeisen%' ORDER BY platform_id" + ).fetchall() + if len(rows) != 1: + raise ValueError("raiffeisen_platform_not_uniquely_mapped") + return str(rows[0]["platform_id"]) + + +def _cash_account_for_suffix(conn: Connection, platform_id: str, suffix: str) -> Any: + rows = conn.execute( + """SELECT account_id,account_name FROM accounts + WHERE platform_id=? AND account_type='cash' AND is_active=1 + ORDER BY account_id""", + (platform_id,), + ).fetchall() + matched = [row for row in rows if str(row["account_name"] or "").replace(" ", "").endswith(suffix)] + if len(matched) != 1: + raise ValueError(f"raiffeisen_cash_target_{suffix}_not_uniquely_mapped") + return matched[0] + + +def _membership_account(conn: Connection, platform_id: str) -> Any | None: + rows = conn.execute( + """SELECT account_id,account_name FROM accounts + WHERE platform_id=? AND is_active=1 + AND (account_type IN ('other_asset','membership') OR portfolio_bucket='other') + AND lower(account_name) LIKE '%genossenschaft%' + ORDER BY account_id""", + (platform_id,), + ).fetchall() + if len(rows) > 1: + raise ValueError("raiffeisen_membership_target_not_uniquely_mapped") + return rows[0] if rows else None + + +def _latest_cash_value(conn: Connection, account_id: str, as_of: str) -> Decimal | None: + row = conn.execute( + """SELECT amount_chf FROM cash_account_snapshots + WHERE account_id=? AND balance_date<=? + ORDER BY balance_date DESC,created_at DESC,snapshot_id DESC LIMIT 1""", + (account_id, as_of), + ).fetchone() + return _decimal(row["amount_chf"]) if row else None + + +def _latest_asset_value(conn: Connection, account_id: str, as_of: str) -> Decimal | None: + row = conn.execute( + """SELECT total_value_chf FROM account_value_snapshots + WHERE account_id=? AND valuation_date<=? AND is_active=1 + ORDER BY valuation_date DESC,created_at DESC,snapshot_id DESC LIMIT 1""", + (account_id, as_of), + ).fetchone() + return _decimal(row["total_value_chf"]) if row else None + + +def _input_values(payload: dict[str, Any]) -> dict[str, Decimal]: + return { + "private": _decimal(payload["private_account_value_chf"]), + "savings": _decimal(payload["savings_account_value_chf"]), + "membership": _decimal(payload["membership_value_chf"]), + } + + +def _targets(conn: Connection, payload: dict[str, Any]) -> list[_Target]: + snapshot_date = str(payload["snapshot_date"]) + platform_id = _platform_id(conn) + private = _cash_account_for_suffix(conn, platform_id, PRIVATE_SUFFIX) + savings = _cash_account_for_suffix(conn, platform_id, SAVINGS_SUFFIX) + membership = _membership_account(conn, platform_id) + values = _input_values(payload) + targets = [ + _Target( + role="private", + account_id=str(private["account_id"]), + label=_safe_label(str(private["account_name"])), + asset_kind="bank_cash", + previous=_latest_cash_value(conn, str(private["account_id"]), snapshot_date), + previous_status="confirmed" if _latest_cash_value(conn, str(private["account_id"]), snapshot_date) is not None else "unknown", + new_value=values["private"], + platform_id=platform_id, + ), + _Target( + role="savings", + account_id=str(savings["account_id"]), + label=_safe_label(str(savings["account_name"])), + asset_kind="bank_cash", + previous=_latest_cash_value(conn, str(savings["account_id"]), snapshot_date), + previous_status="confirmed" if _latest_cash_value(conn, str(savings["account_id"]), snapshot_date) is not None else "unknown", + new_value=values["savings"], + platform_id=platform_id, + ), + _Target( + role="membership", + account_id=str(membership["account_id"]) if membership else None, + label="Raiffeisen Genossenschaftsanteil", + asset_kind="membership_asset", + previous=_latest_asset_value(conn, str(membership["account_id"]), snapshot_date) if membership else None, + previous_status=("confirmed" if membership and _latest_asset_value(conn, str(membership["account_id"]), snapshot_date) is not None else "unknown" if membership else "not_created"), + new_value=values["membership"], + platform_id=platform_id, + ), + ] + if len({target.account_id for target in targets if target.account_id}) != len([target for target in targets if target.account_id]): + raise ValueError("raiffeisen_targets_not_distinct") + return targets + + +def _baseline(conn: Connection, payload: dict[str, Any], targets: list[_Target]) -> str: + details: list[dict[str, object]] = [] + snapshot_date = str(payload["snapshot_date"]) + for target in targets: + latest = None + if target.account_id: + table = "cash_account_snapshots" if target.asset_kind == "bank_cash" else "account_value_snapshots" + date_column = "balance_date" if target.asset_kind == "bank_cash" else "valuation_date" + value_column = "amount_chf" if target.asset_kind == "bank_cash" else "total_value_chf" + latest = conn.execute( + f"SELECT snapshot_id,{date_column} AS at,{value_column} AS value FROM {table} " + f"WHERE account_id=? AND {date_column}<=? " + + ("AND is_active=1 " if table == "account_value_snapshots" else "") + + f"ORDER BY {date_column} DESC,created_at DESC,snapshot_id DESC LIMIT 1", + (target.account_id, snapshot_date), + ).fetchone() + details.append( + { + "role": target.role, + "account_id": target.account_id, + "latest": dict(latest) if latest else None, + "new_value": _fmt(target.new_value), + } + ) + return _hash({"snapshot_date": snapshot_date, "targets": details}) + + +def _wealth_model(conn: Connection, as_of: str) -> dict[str, Any]: + return build_modelled_wealth_development(conn, as_of=as_of, period="all") + + +def _known_total(conn: Connection, as_of: str) -> Decimal: + model = _wealth_model(conn, as_of) + # A component correction must not promote a mixed-date/modelled point to a + # confirmed household anchor. Current is only a sparse-fixture fallback. + baseline = model.get("anchor") or model.get("current") + return _decimal(baseline.get("value_chf")) if isinstance(baseline, dict) else ZERO + + +def _bank_total(conn: Connection, as_of: str) -> Decimal: + model = _wealth_model(conn, as_of) + for component in model.get("components") or []: + if component.get("key") == "bank_cash": + return _decimal(component.get("current_value_chf")) + return ZERO + + +def _preview_payload(conn: Connection, payload: dict[str, Any]) -> tuple[dict[str, Any], list[_Target]]: + snapshot_date = str(payload["snapshot_date"]) + parsed_date = date.fromisoformat(snapshot_date) + if parsed_date > _now().date(): + raise ValueError("manual_snapshot_date_in_future") + targets = _targets(conn, payload) + input_fingerprint = _baseline(conn, payload, targets) + known_before = _known_total(conn, snapshot_date) + delta = sum((target.new_value - (target.previous or ZERO) for target in targets), ZERO) + cash_delta = sum( + ( + target.new_value - (target.previous or ZERO) + for target in targets + if target.asset_kind == "bank_cash" + ), + ZERO, + ) + bank_after = _bank_total(conn, snapshot_date) + cash_delta + membership_after = sum((target.new_value for target in targets if target.asset_kind == "membership_asset"), ZERO) + token = uuid.uuid4().hex + expires_at = (_now() + timedelta(hours=1)).isoformat() + preview = { + "preview_id": f"raiffeisen-preview-{token}", + "confirmation_id": f"raiffeisen-confirm-{token}", + "input_fingerprint": input_fingerprint, + "source_kind": SOURCE_KIND, + "snapshot_date": snapshot_date, + "affected_accounts": [ + { + "account_label": target.label, + "asset_kind": target.asset_kind, + "previous_value_chf": _fmt(target.previous) if target.previous is not None else None, + "new_value_chf": _fmt(target.new_value), + "change_chf": _fmt(target.new_value - (target.previous or ZERO)), + "previous_status": target.previous_status, + } + for target in targets + ], + "bank_cash_after_chf": _fmt(bank_after), + "separate_membership_asset_after_chf": _fmt(membership_after), + "known_wealth_before_chf": _fmt(known_before), + "expected_known_wealth_after_chf": _fmt(known_before + delta), + "expected_total_wealth_change_chf": _fmt(delta), + "creates_transactions": False, + "append_only": True, + "expires_at": expires_at, + } + return preview, targets + + +def preview_raiffeisen_manual_snapshot(conn: Connection, **payload: Any) -> dict[str, Any]: + """Pure preview over stored canonical baselines; this function never writes.""" + preview, _ = _preview_payload(conn, payload) + return preview + + +def _existing_confirmation( + conn: Connection, + confirmation_id: str, + input_fingerprint: str, + payload_hash: str, +) -> dict[str, Any] | None: + row = conn.execute( + "SELECT * FROM manual_snapshot_confirmations WHERE confirmation_id=?", + (confirmation_id,), + ).fetchone() + if not row: + return None + if ( + str(row["input_fingerprint"]) != input_fingerprint + or str(row["payload_hash"]) != payload_hash + ): + raise ValueError("confirmation_id_reused_with_different_input") + return { + "status": "already_applied", + "confirmation_id": confirmation_id, + "snapshot_date": str(row["snapshot_date"]), + "created_snapshot_count": int(row["created_snapshot_count"]), + "created_transaction_count": 0, + "bank_cash_after_chf": str(row["bank_cash_after_chf"]), + "separate_membership_asset_after_chf": str(row["separate_membership_asset_after_chf"]), + "known_wealth_after_chf": str(row["known_wealth_after_chf"]), + "audit_recorded": True, + } + + +def confirm_raiffeisen_manual_snapshot( + conn: Connection, + *, + preview_id: str, + confirmation_id: str, + input_fingerprint: str, + snapshot_date: date, + private_account_value_chf: Decimal, + savings_account_value_chf: Decimal, + membership_value_chf: Decimal, +) -> dict[str, Any]: + payload = { + "snapshot_date": snapshot_date.isoformat(), + "private_account_value_chf": private_account_value_chf, + "savings_account_value_chf": savings_account_value_chf, + "membership_value_chf": membership_value_chf, + } + payload_hash = _hash(payload) + existing = _existing_confirmation( + conn, + confirmation_id, + input_fingerprint, + payload_hash, + ) + if existing: + return existing + preview, targets = _preview_payload(conn, payload) + if _baseline(conn, payload, targets) != input_fingerprint: + raise ValueError("manual_snapshot_baseline_changed") + if not preview_id.startswith("raiffeisen-preview-") or confirmation_id != preview_id.replace("preview", "confirm", 1): + raise ValueError("manual_snapshot_confirmation_token_mismatch") + + now = _now().isoformat() + membership = next(target for target in targets if target.asset_kind == "membership_asset") + membership_account_id = membership.account_id or stable_id("account", "raiffeisen", "membership-share") + created = 0 + with conn: + if membership.account_id is None: + conn.execute( + """INSERT INTO accounts( + account_id,platform_id,account_name,account_type,currency,performance_included, + is_active,notes,created_at,updated_at,balance_mode,portfolio_bucket + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + membership_account_id, + membership.platform_id, + "Raiffeisen Genossenschaftsanteil", + "other_asset", + "CHF", + 0, + 1, + "Separates Mitgliedschaftsvermögen; kein frei verfügbares Bankguthaben.", + now, + now, + "snapshot", + "other", + ), + ) + audit_id = record_audit_event( + conn, + source=SOURCE_DB, + action="confirm_manual_source_snapshot", + entity_type="manual_source_snapshot", + entity_id=confirmation_id, + old_values={"input_fingerprint": input_fingerprint}, + new_values={ + "snapshot_date": snapshot_date.isoformat(), + "source_kind": SOURCE_KIND, + "snapshot_count": 3, + "transaction_count": 0, + }, + created_by="user", + ) + for target in targets: + account_id = membership_account_id if target.asset_kind == "membership_asset" else str(target.account_id) + if target.asset_kind == "bank_cash": + snapshot_id = stable_id("cash-snapshot", confirmation_id, target.role) + 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,semantic_identity + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + snapshot_id, + account_id, + "manual_balance", + snapshot_date.isoformat(), + _fmt(target.new_value), + "CHF", + _fmt(target.new_value), + SOURCE_DB, + "Datierter manueller Quellensnapshot; keine Transaktionsrekonstruktion.", + now, + "user", + audit_id, + stable_id("manual-source-snapshot", confirmation_id, target.role), + ), + ) + else: + snapshot_id = stable_id("account-value-snapshot", confirmation_id, target.role) + conn.execute( + """INSERT INTO account_value_snapshots( + snapshot_id,account_id,valuation_date,total_value_chf,currency,source_type, + quality_status,notes,created_at,updated_at,valuation_at,source_reference,is_active + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1)""", + ( + snapshot_id, + account_id, + snapshot_date.isoformat(), + _fmt(target.new_value), + "CHF", + SOURCE_DB, + "confirmed", + "Separates Mitgliedschaftsvermögen; kein frei verfügbares Bankguthaben.", + now, + None, + snapshot_date.isoformat(), + confirmation_id, + ), + ) + created += 1 + known_after = _decimal(preview["expected_known_wealth_after_chf"]) + bank_after = _decimal(preview["bank_cash_after_chf"]) + conn.execute( + """INSERT INTO manual_snapshot_confirmations( + confirmation_id,preview_id,input_fingerprint,payload_hash,snapshot_date,source_kind, + known_wealth_after_chf,bank_cash_after_chf,separate_membership_asset_after_chf, + created_snapshot_count,created_at,audit_id + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + confirmation_id, + preview_id, + input_fingerprint, + payload_hash, + snapshot_date.isoformat(), + SOURCE_KIND, + _fmt(known_after), + _fmt(bank_after), + _fmt(membership_value_chf), + created, + now, + audit_id, + ), + ) + return { + "status": "confirmed", + "confirmation_id": confirmation_id, + "snapshot_date": snapshot_date.isoformat(), + "created_snapshot_count": created, + "created_transaction_count": 0, + "bank_cash_after_chf": _fmt(bank_after), + "separate_membership_asset_after_chf": _fmt(membership_value_chf), + "known_wealth_after_chf": _fmt(known_after), + "audit_recorded": True, + } diff --git a/src/jarvis_finance/services/system_ops.py b/src/jarvis_finance/services/system_ops.py index 2b379d8..3509fcb 100644 --- a/src/jarvis_finance/services/system_ops.py +++ b/src/jarvis_finance/services/system_ops.py @@ -6,0 +7,2 @@ import subprocess +import urllib.request +from urllib.parse import urlsplit @@ -19 +21,2 @@ _ALLOWED_SCRIPT_NAMES = {"restart_backend.sh", "restart_frontend.sh", "restart_d -_ALLOWED_ENV = {"PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "JARVIS_FINANCE_RUNTIME_DIR"} +_ALLOWED_ENV = {"PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "JARVIS_FINANCE_RUNTIME_DIR", + "VITE_API_BASE_URL", "BACKEND_HOST", "BACKEND_PORT", "FRONTEND_PORT", "JARVIS_FINANCE_API_URL"} @@ -51,4 +53,0 @@ def _safe_env(runtime_dir: Path) -> dict[str, str]: - env["VITE_API_BASE_URL"] = "http://100.85.29.67:8000" - env["BACKEND_HOST"] = "0.0.0.0" - env["BACKEND_PORT"] = "8000" - env["FRONTEND_PORT"] = "5173" @@ -57,0 +57,8 @@ def _safe_env(runtime_dir: Path) -> dict[str, str]: +def _healthcheck(url: str) -> bool: + try: + with urllib.request.urlopen(url.rstrip("/") + "/health", timeout=0.5) as response: + return 200 <= int(response.status) < 300 + except Exception: + return False + + @@ -81 +88,8 @@ def _last_ops_action(runtime_dir: Path) -> dict[str, Any] | None: -def system_status(*, runtime_dir: Path | None = None, repo_root: Path | None = None, api_url: str = "http://100.85.29.67:8000") -> dict[str, Any]: +def system_status( + *, + runtime_dir: Path | None = None, + repo_root: Path | None = None, + api_url: str | None = None, + frontend_url: str | None = None, + backend_reachable: bool | None = None, +) -> dict[str, Any]: @@ -84,0 +99,10 @@ def system_status(*, runtime_dir: Path | None = None, repo_root: Path | None = N + configured_api = api_url or os.environ.get("JARVIS_FINANCE_API_URL") or os.environ.get("VITE_API_BASE_URL") + parsed = urlsplit(configured_api) if configured_api else None + backend_port = parsed.port if parsed and parsed.hostname else None + configured_frontend = frontend_url or os.environ.get("JARVIS_FINANCE_FRONTEND_URL") + frontend_parsed = urlsplit(configured_frontend) if configured_frontend else None + frontend_port = frontend_parsed.port if frontend_parsed and frontend_parsed.hostname else None + backend_running = backend_reachable if backend_reachable is not None else bool(configured_api and _healthcheck(configured_api)) + frontend_running = bool(configured_frontend and _healthcheck(configured_frontend.rstrip("/").removesuffix("/api"))) + if not frontend_running and frontend_port: + frontend_running = _port_open(frontend_port) @@ -88 +112 @@ def system_status(*, runtime_dir: Path | None = None, repo_root: Path | None = N - "api_url": api_url, + "api_url": configured_api, @@ -91,2 +115,2 @@ def system_status(*, runtime_dir: Path | None = None, repo_root: Path | None = N - "backend": {"status": "running" if _port_open(8000) else "offline", "port": 8000}, - "frontend": {"status": "running" if _port_open(5173) else "offline", "port": 5173}, + "backend": {"status": "running" if backend_running else "offline", "port": backend_port}, + "frontend": {"status": "running" if frontend_running else "offline", "port": frontend_port}, diff --git a/src/jarvis_finance/services/wealth_cockpit.py b/src/jarvis_finance/services/wealth_cockpit.py index a30390d..824fdf1 100644 --- a/src/jarvis_finance/services/wealth_cockpit.py +++ b/src/jarvis_finance/services/wealth_cockpit.py @@ -22,0 +23 @@ from jarvis_finance.services.modelled_wealth import build_modelled_wealth_develo +from jarvis_finance.services.portfolio_analysis_v1 import build_portfolio_analysis_v1 @@ -1227,0 +1229,3 @@ def build_wealth_cockpit( + portfolio_analysis = build_portfolio_analysis_v1( + conn, as_of=reference.isoformat(), modelled=modelled_development + ) @@ -1418,0 +1423 @@ def build_wealth_cockpit( + "portfolio_analysis": portfolio_analysis, diff --git a/src/jarvis_finance/storage/migrations.py b/src/jarvis_finance/storage/migrations.py index 90c34e3..ebeb14f 100644 --- a/src/jarvis_finance/storage/migrations.py +++ b/src/jarvis_finance/storage/migrations.py @@ -11,2 +11,2 @@ from .postfinance_schema import create_postfinance_ledger_import_v1 -MIGRATION_VERSION = 51 -MIGRATION_NAME = "051_current_source_coverage_and_truewealth_activity_v1" +MIGRATION_VERSION = 52 +MIGRATION_NAME = "052_professional_portfolio_cockpit_v1" @@ -2839,0 +2840,75 @@ def _create_crypto_reconciliation_cockpit_v1(conn: Connection) -> None: +def _create_professional_portfolio_cockpit_v1(conn: Connection) -> None: + """Add bounded manual-snapshot and controlled refresh job lineage.""" + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS manual_snapshot_confirmations ( + confirmation_id TEXT PRIMARY KEY, + preview_id TEXT NOT NULL UNIQUE, + input_fingerprint TEXT NOT NULL, + payload_hash TEXT NOT NULL, + snapshot_date TEXT NOT NULL, + source_kind TEXT NOT NULL, + known_wealth_after_chf TEXT NOT NULL, + bank_cash_after_chf TEXT NOT NULL, + separate_membership_asset_after_chf TEXT NOT NULL, + created_snapshot_count INTEGER NOT NULL CHECK(created_snapshot_count=3), + created_at TEXT NOT NULL, + audit_id TEXT NOT NULL UNIQUE REFERENCES audit_log(audit_id), + CHECK(source_kind='dated_manual_screenshot') + ); + CREATE TRIGGER IF NOT EXISTS manual_snapshot_confirmations_no_update + BEFORE UPDATE ON manual_snapshot_confirmations + BEGIN SELECT RAISE(ABORT, 'manual snapshot confirmations are immutable'); END; + CREATE TRIGGER IF NOT EXISTS manual_snapshot_confirmations_no_delete + BEFORE DELETE ON manual_snapshot_confirmations + BEGIN SELECT RAISE(ABORT, 'manual snapshot confirmations cannot be deleted'); END; + CREATE TABLE IF NOT EXISTS asset_price_refresh_jobs ( + job_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK(status IN ('queued','running','complete','partial','failed')), + requested_at TEXT NOT NULL, + completed_at TEXT, + stale_before TEXT NOT NULL, + progress_total INTEGER NOT NULL DEFAULT 3, + progress_completed INTEGER NOT NULL DEFAULT 0, + wealth_snapshot_id TEXT, + audit_id TEXT REFERENCES audit_log(audit_id) + ); + CREATE TABLE IF NOT EXISTS asset_price_refresh_sources ( + job_id TEXT NOT NULL REFERENCES asset_price_refresh_jobs(job_id), + source TEXT NOT NULL CHECK(source IN ('equity','crypto','fx')), + status TEXT NOT NULL CHECK(status IN ('pending','running','complete','failed','skipped')), + stale_candidates INTEGER NOT NULL DEFAULT 0, + updated_count INTEGER NOT NULL DEFAULT 0, + error_code TEXT, + started_at TEXT, + completed_at TEXT, + PRIMARY KEY(job_id,source) + ); + CREATE TABLE IF NOT EXISTS aggregated_wealth_refresh_snapshots ( + wealth_snapshot_id TEXT PRIMARY KEY, + job_id TEXT NOT NULL UNIQUE REFERENCES asset_price_refresh_jobs(job_id), + captured_at TEXT NOT NULL, + known_wealth_chf TEXT, + quality_status TEXT NOT NULL CHECK(quality_status IN ('complete','partial')), + source_status_json TEXT NOT NULL CHECK(json_valid(source_status_json)) + ); + CREATE INDEX IF NOT EXISTS idx_asset_price_refresh_jobs_requested + ON asset_price_refresh_jobs(requested_at DESC); + CREATE TRIGGER IF NOT EXISTS aggregated_wealth_refresh_snapshots_no_update + BEFORE UPDATE ON aggregated_wealth_refresh_snapshots + BEGIN SELECT RAISE(ABORT, 'wealth refresh snapshots are immutable'); END; + CREATE TRIGGER IF NOT EXISTS aggregated_wealth_refresh_snapshots_no_delete + BEFORE DELETE ON aggregated_wealth_refresh_snapshots + BEGIN SELECT RAISE(ABORT, 'wealth refresh snapshots cannot be deleted'); END; + CREATE TRIGGER IF NOT EXISTS sprint23_audit_no_update + BEFORE UPDATE ON audit_log + WHEN OLD.entity_type IN ('manual_source_snapshot','asset_price_refresh_job') + BEGIN SELECT RAISE(ABORT, 'sprint23 audit is immutable'); END; + CREATE TRIGGER IF NOT EXISTS sprint23_audit_no_delete + BEFORE DELETE ON audit_log + WHEN OLD.entity_type IN ('manual_source_snapshot','asset_price_refresh_job') + BEGIN SELECT RAISE(ABORT, 'sprint23 audit cannot be deleted'); END; + """ + ) + + @@ -2880,0 +2956 @@ def _apply_compat_migrations(conn: Connection) -> None: + _create_professional_portfolio_cockpit_v1(conn) diff --git a/tests/unit/test_asset_price_refresh.py b/tests/unit/test_asset_price_refresh.py new file mode 100644 index 0000000..1dfec4d --- /dev/null +++ b/tests/unit/test_asset_price_refresh.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path + +from jarvis_finance.services.asset_price_refresh import ( + asset_price_refresh_status, + create_asset_price_refresh_job, + run_asset_price_refresh, +) +from jarvis_finance.storage.database import connect +from jarvis_finance.storage.migrations import apply_migrations + + +def database(path: Path): + conn = connect(path) + apply_migrations(conn) + return conn + + +def test_refresh_job_is_queued_then_isolates_source_failure_and_creates_one_snapshot(tmp_path): + path = tmp_path / "finance.sqlite3" + conn = database(path) + queued, db_path = create_asset_price_refresh_job(conn, stale_hours=12) + assert queued["status"] == "queued" + assert all(row["status"] == "pending" for row in queued["sources"]) + assert queued["provider_calls_on_read"] is False + job_id = queued["job_id"] + conn.close() + + calls: list[str] = [] + + def success(source: str): + def runner(_conn): + calls.append(source) + return 2, 1 + return runner + + def failure(_conn): + calls.append("crypto") + raise RuntimeError("provider_unavailable") + + run_asset_price_refresh( + db_path, + job_id, + runners={"equity": success("equity"), "crypto": failure, "fx": success("fx")}, + ) + + conn = connect(path) + status = asset_price_refresh_status(conn, job_id) + assert calls == ["equity", "crypto", "fx"] + assert status["status"] == "partial" + assert status["progress"] == {"completed": 3, "total": 3} + assert status["wealth_snapshot_created"] is True + assert status["audit_recorded"] is True + assert [row["status"] for row in status["sources"]] == ["complete", "failed", "complete"] + assert conn.execute("SELECT COUNT(*) FROM aggregated_wealth_refresh_snapshots WHERE job_id=?", (job_id,)).fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM transactions").fetchone()[0] == 0 + conn.close() + + +def test_status_read_does_not_write_or_call_runner(tmp_path): + path = tmp_path / "finance.sqlite3" + conn = database(path) + queued, _ = create_asset_price_refresh_job(conn) + before = conn.total_changes + first = asset_price_refresh_status(conn, queued["job_id"]) + second = asset_price_refresh_status(conn, queued["job_id"]) + assert conn.total_changes == before + assert first == second + assert first["provider_calls_on_read"] is False + conn.close() diff --git a/tests/unit/test_budget_monthly_import_rule_learning_v1.py b/tests/unit/test_budget_monthly_import_rule_learning_v1.py index 67bd04c..45835d9 100644 --- a/tests/unit/test_budget_monthly_import_rule_learning_v1.py +++ b/tests/unit/test_budget_monthly_import_rule_learning_v1.py @@ -112 +112 @@ def test_schema_version_32_grocery_optimizer_tables_exist() -> None: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_budget_phase1.py b/tests/unit/test_budget_phase1.py index 6a87e7d..c987ee9 100644 --- a/tests/unit/test_budget_phase1.py +++ b/tests/unit/test_budget_phase1.py @@ -32 +32 @@ def test_budget_phase1_tables_seeds_and_text_decimal_columns_are_created_idempot - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_budget_phase11.py b/tests/unit/test_budget_phase11.py index 72c2d32..711308e 100644 --- a/tests/unit/test_budget_phase11.py +++ b/tests/unit/test_budget_phase11.py @@ -22 +22 @@ def test_budget_phase11_tables_tags_and_plan_items_are_created() -> None: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_budget_phase12_seed_review.py b/tests/unit/test_budget_phase12_seed_review.py index 6092dfe..a5f2d71 100644 --- a/tests/unit/test_budget_phase12_seed_review.py +++ b/tests/unit/test_budget_phase12_seed_review.py @@ -38 +38 @@ def test_phase12_schema_has_requested_budget_seed_candidates_table() -> None: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_budget_phase16_user_rules.py b/tests/unit/test_budget_phase16_user_rules.py index 305a10f..988da26 100644 --- a/tests/unit/test_budget_phase16_user_rules.py +++ b/tests/unit/test_budget_phase16_user_rules.py @@ -52 +52 @@ def test_phase16_schema_adds_rule_name_and_applies_card_category_rules() -> None - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_fixed_costs_subscriptions_v1.py b/tests/unit/test_fixed_costs_subscriptions_v1.py index 756f018..c24155f 100644 --- a/tests/unit/test_fixed_costs_subscriptions_v1.py +++ b/tests/unit/test_fixed_costs_subscriptions_v1.py @@ -51 +51 @@ def test_schema_38_creates_decimal_text_recurring_table_with_user_facing_fields( - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_grocery_matching_learning_v2.py b/tests/unit/test_grocery_matching_learning_v2.py index 2800a70..9156218 100644 --- a/tests/unit/test_grocery_matching_learning_v2.py +++ b/tests/unit/test_grocery_matching_learning_v2.py @@ -30 +30 @@ def db() -> sqlite3.Connection: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_grocery_price_providers_v1.py b/tests/unit/test_grocery_price_providers_v1.py index 49cf7c2..3ed44e3 100644 --- a/tests/unit/test_grocery_price_providers_v1.py +++ b/tests/unit/test_grocery_price_providers_v1.py @@ -32 +32 @@ def test_schema_version_33_provider_cache_columns_exist() -> None: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_household_import_v1_golden.py b/tests/unit/test_household_import_v1_golden.py index 23afc18..284a250 100644 --- a/tests/unit/test_household_import_v1_golden.py +++ b/tests/unit/test_household_import_v1_golden.py @@ -97 +97 @@ def test_schema_48_has_household_contract_and_three_unique_layers() -> None: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_household_review_corrections_v1.py b/tests/unit/test_household_review_corrections_v1.py index ef2175d..8d2d77b 100644 --- a/tests/unit/test_household_review_corrections_v1.py +++ b/tests/unit/test_household_review_corrections_v1.py @@ -198 +198 @@ def test_schema_48_adds_versioned_immutable_settlement_contract() -> None: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_portfolio_analysis_v1.py b/tests/unit/test_portfolio_analysis_v1.py new file mode 100644 index 0000000..b430eb6 --- /dev/null +++ b/tests/unit/test_portfolio_analysis_v1.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json + +from jarvis_finance.services.portfolio_analysis_v1 import build_portfolio_analysis_v1 +from jarvis_finance.services.portfolio_policy import confirm_policy, preview_policy +from jarvis_finance.storage.database import connect_memory +from jarvis_finance.storage.migrations import apply_migrations + +NOW = "2026-08-27T10:00:00Z" + + +def policy_payload(): + return { + "base_currency": "CHF", "effective_from": "2026-08-01", "horizon": "long", + "objective": "Kapitalerhalt und Wachstum", "liquidity_reserve": None, + "monthly_contribution": "0", "max_crypto_pct": "10", + "allocations": [ + {"asset_class": "cash", "target_pct": "40", "lower_pct": "35", "upper_pct": "45"}, + {"asset_class": "equity", "target_pct": "50", "lower_pct": "45", "upper_pct": "55"}, + {"asset_class": "crypto", "target_pct": "5", "lower_pct": "0", "upper_pct": "10"}, + {"asset_class": "other", "target_pct": "5", "lower_pct": "0", "upper_pct": "10"}, + ], + } + + +def modelled(): + components = [ + {"key": "postfinance", "label": "PostFinance", "current_value_chf": "350", "change_chf": "20"}, + {"key": "truewealth", "label": "True Wealth", "current_value_chf": "100", "change_chf": "10"}, + {"key": "crypto", "label": "Krypto", "current_value_chf": "50", "change_chf": "-5"}, + {"key": "bank_cash", "label": "Bankguthaben", "current_value_chf": "400", "change_chf": "0"}, + {"key": "other_assets", "label": "Weitere Anlagen", "current_value_chf": "20", "change_chf": "0"}, + ] + return {"components": components} + + +def test_analysis_uses_requested_buckets_active_policy_and_metadata_without_buy_sell_advice(): + conn = connect_memory() + apply_migrations(conn) + payload = policy_payload() + preview = preview_policy(conn, payload) + confirm_policy(conn, {**payload, **preview, "confirm": True}) + conn.execute( + """INSERT INTO instruments( + instrument_id,asset_class,name,currency,country,sector,is_active,created_at + ) VALUES('stock','stock','Aktie','CHF','CH','Industrie',1,?), + ('etf','etf','ETF','USD','US','Breit',1,?)""", + (NOW, NOW), + ) + conn.execute( + """INSERT INTO market_data_runs( + run_id,as_of,input_fingerprint,status,started_at,completed_at + ) VALUES('run','2026-08-27','fingerprint','complete',?,?)""", + (NOW, NOW), + ) + summary = { + "positions": [ + {"instrument_id": "stock", "name": "Aktie", "asset_class": "stock", "currency": "CHF", "value_chf": "100"}, + {"instrument_id": "etf", "name": "ETF", "asset_class": "etf", "currency": "USD", "value_chf": "200"}, + ] + } + conn.execute( + """INSERT INTO portfolio_analysis_snapshots( + analysis_snapshot_id,run_id,as_of,base_currency,total_value_chf,price_coverage_pct, + fx_coverage_pct,benchmark_coverage_pct,quality_status,reason_codes_json,summary_json,created_at + ) VALUES('analysis','run','2026-08-27','CHF','300','100','100','100','complete','[]',?,?)""", + (json.dumps(summary), NOW), + ) + conn.commit() + + result = build_portfolio_analysis_v1(conn, as_of="2026-08-27", modelled=modelled()) + + rows = {row["key"]: row for row in result["allocation"]} + assert {"cash", "stocks", "etf", "truewealth", "crypto", "other", "equity_policy_group"} <= set(rows) + assert rows["cash"]["status"] == "above_corridor" + assert rows["equity_policy_group"]["status"] == "below_corridor" + assert result["concentrations"]["top1_pct"] is not None + assert result["dimensions"]["currency"]["rows"] + assert result["dimensions"]["region"]["status"] == "partial" + assert 1 <= len(result["hints"]) <= 5 + hint_text = " ".join(item["text"] for item in result["hints"]) + assert "Reduktion prüfen" in hint_text + assert "Erhöhung prüfen" in hint_text + assert "Buy" not in hint_text and "Sell" not in hint_text + assert [row["value_chf"] for row in result["contributions"]] == ["20.00", "10.00", "-5.00"] diff --git a/tests/unit/test_portfolio_data_ingestion_reconciliation.py b/tests/unit/test_portfolio_data_ingestion_reconciliation.py index c1fb913..9bf0c93 100644 --- a/tests/unit/test_portfolio_data_ingestion_reconciliation.py +++ b/tests/unit/test_portfolio_data_ingestion_reconciliation.py @@ -141 +141 @@ def test_migrations_41_to_43_are_additive_and_ingestion_history_is_immutable( - assert MIGRATION_VERSION == 51 + assert MIGRATION_VERSION == 52 diff --git a/tests/unit/test_raiffeisen_manual_snapshot.py b/tests/unit/test_raiffeisen_manual_snapshot.py new file mode 100644 index 0000000..d9b387d --- /dev/null +++ b/tests/unit/test_raiffeisen_manual_snapshot.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import pytest + +from jarvis_finance.services.modelled_wealth import build_modelled_wealth_development +from jarvis_finance.services.performance_scope import set_performance_scope_classification +from jarvis_finance.services.raiffeisen_manual_snapshot import ( + confirm_raiffeisen_manual_snapshot, + preview_raiffeisen_manual_snapshot, +) +from jarvis_finance.storage.database import connect_memory +from jarvis_finance.storage.migrations import apply_migrations + +NOW = "2026-08-20T12:00:00+00:00" + + +def database(): + conn = connect_memory() + apply_migrations(conn) + conn.execute( + "INSERT INTO platforms(platform_id,name,platform_type,created_at) VALUES('bank','Raiffeisen','bank',?)", + (NOW,), + ) + for account_id, name in ( + ("private", "Privatkonto ••••5632"), + ("savings", "Sparkonto ••••5031"), + ): + conn.execute( + """INSERT INTO accounts( + account_id,platform_id,account_name,account_type,currency,performance_included, + is_active,created_at,balance_mode,portfolio_bucket + ) VALUES(?, 'bank', ?, 'cash','CHF',0,1,?,'snapshot','cash')""", + (account_id, name, NOW), + ) + for snapshot_id, account_id, value in ( + ("old-private", "private", "100.00"), + ("old-savings", "savings", "10.00"), + ): + conn.execute( + """INSERT INTO cash_account_snapshots( + snapshot_id,account_id,snapshot_type,balance_date,amount_original,currency, + amount_chf,source,created_at,created_by,semantic_identity + ) VALUES(?,?, 'manual_balance','2026-08-20',?,'CHF',?,'test',?,'test',?)""", + (snapshot_id, account_id, value, value, NOW, snapshot_id), + ) + conn.commit() + return conn + + +def source_facts(): + return { + "snapshot_date": date(2026, 8, 21), + "private_account_value_chf": Decimal("90.00"), + "savings_account_value_chf": Decimal("20.00"), + "membership_value_chf": Decimal("5.00"), + } + + +def test_preview_is_read_only_and_keeps_targets_separate(): + conn = database() + before = conn.total_changes + + preview = preview_raiffeisen_manual_snapshot(conn, **source_facts()) + + assert conn.total_changes == before + assert preview["bank_cash_after_chf"] == "110.00" + assert preview["separate_membership_asset_after_chf"] == "5.00" + assert preview["known_wealth_before_chf"] == "110.00" + assert preview["expected_known_wealth_after_chf"] == "115.00" + assert preview["expected_total_wealth_change_chf"] == "5.00" + assert preview["creates_transactions"] is False + assert [row["account_label"] for row in preview["affected_accounts"]] == [ + "Bankkonto ••••5632", + "Bankkonto ••••5031", + "Raiffeisen Genossenschaftsanteil", + ] + assert preview["affected_accounts"][2]["previous_status"] == "not_created" + + +def test_confirm_is_append_only_audited_and_idempotent(): + conn = database() + preview = preview_raiffeisen_manual_snapshot(conn, **source_facts()) + request = { + **source_facts(), + "preview_id": preview["preview_id"], + "confirmation_id": preview["confirmation_id"], + "input_fingerprint": preview["input_fingerprint"], + } + + result = confirm_raiffeisen_manual_snapshot(conn, **request) + replay = confirm_raiffeisen_manual_snapshot(conn, **request) + + assert result["status"] == "confirmed" + assert result["created_snapshot_count"] == 3 + assert result["created_transaction_count"] == 0 + assert result["known_wealth_after_chf"] == "115.00" + assert replay["status"] == "already_applied" + assert conn.execute("SELECT COUNT(*) FROM transactions").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM cash_account_snapshots WHERE source='manual_screenshot_snapshot'").fetchone()[0] == 2 + assert conn.execute("SELECT COUNT(*) FROM account_value_snapshots WHERE source_type='manual_screenshot_snapshot'").fetchone()[0] == 1 + membership = conn.execute( + "SELECT account_type,portfolio_bucket FROM accounts WHERE lower(account_name) LIKE '%genossenschaft%'" + ).fetchone() + assert dict(membership) == {"account_type": "other_asset", "portfolio_bucket": "other"} + assert conn.execute("SELECT COUNT(*) FROM audit_log WHERE entity_id=?", (preview["confirmation_id"],)).fetchone()[0] == 1 + with pytest.raises(ValueError, match="confirmation_id_reused_with_different_input"): + confirm_raiffeisen_manual_snapshot( + conn, + **{**request, "membership_value_chf": Decimal("6.00")}, + ) + + +def test_confirm_fails_closed_when_baseline_changed(): + conn = database() + preview = preview_raiffeisen_manual_snapshot(conn, **source_facts()) + conn.execute( + """INSERT INTO cash_account_snapshots( + snapshot_id,account_id,snapshot_type,balance_date,amount_original,currency,amount_chf, + source,created_at,created_by,semantic_identity + ) VALUES('changed','private','manual_balance','2026-08-21','95','CHF','95','test',?,'test','changed')""", + (NOW,), + ) + conn.commit() + with pytest.raises(ValueError, match="baseline_changed"): + confirm_raiffeisen_manual_snapshot( + conn, + **source_facts(), + preview_id=preview["preview_id"], + confirmation_id=preview["confirmation_id"], + input_fingerprint=preview["input_fingerprint"], + ) + + +def test_acceptance_sums_include_unchanged_bank_cash_and_keep_component_correction_after_anchor(): + conn = database() + conn.execute( + "UPDATE cash_account_snapshots SET amount_original='29042.53',amount_chf='29042.53' WHERE account_id='private'" + ) + conn.execute( + "UPDATE cash_account_snapshots SET amount_original='19.84',amount_chf='19.84' WHERE account_id='savings'" + ) + conn.execute( + """INSERT INTO accounts( + account_id,platform_id,account_name,account_type,currency,performance_included, + is_active,created_at,balance_mode,portfolio_bucket + ) VALUES('unchanged-bank','bank','Unchanged bank account','cash','CHF',0,1,?,'snapshot','cash')""", + (NOW,), + ) + conn.execute( + """INSERT INTO cash_account_snapshots( + snapshot_id,account_id,snapshot_type,balance_date,amount_original,currency, + amount_chf,source,created_at,created_by,semantic_identity + ) VALUES('unchanged-bank-value','unchanged-bank','manual_balance','2026-08-20', + '74900.12','CHF','74900.12','test',?,'test','unchanged-bank-value')""", + (NOW,), + ) + conn.execute( + "INSERT INTO platforms(platform_id,name,platform_type,created_at) VALUES('pf','PostFinance','broker',?)", + (NOW,), + ) + conn.execute( + """INSERT INTO accounts( + account_id,platform_id,account_name,account_type,currency,performance_included, + is_active,created_at,balance_mode,portfolio_bucket + ) VALUES('pf-depot','pf','PostFinance Depot','brokerage','CHF',0,1,?,'snapshot','equity')""", + (NOW,), + ) + set_performance_scope_classification( + conn, + account_id="pf-depot", + included=True, + classification_role="postfinance_etrading_depot", + source="test", + note="acceptance anchor", + classified_at=NOW, + ) + conn.execute( + """INSERT INTO account_value_snapshots( + snapshot_id,account_id,valuation_date,total_value_chf,currency,source_type, + quality_status,created_at,valuation_at,is_active + ) VALUES('pf-anchor','pf-depot','2026-08-26','523788.47','CHF', + 'postfinance_official_import','ok',?,'2026-08-26T12:00:00+00:00',1)""", + (NOW,), + ) + conn.commit() + facts = { + "snapshot_date": date(2026, 8, 27), + "private_account_value_chf": Decimal("29059.44"), + "savings_account_value_chf": Decimal("19.84"), + "membership_value_chf": Decimal("200.00"), + } + + preview = preview_raiffeisen_manual_snapshot(conn, **facts) + + assert preview["bank_cash_after_chf"] == "103979.40" + assert preview["separate_membership_asset_after_chf"] == "200.00" + assert preview["known_wealth_before_chf"] == "627750.96" + assert preview["expected_total_wealth_change_chf"] == "216.91" + assert preview["expected_known_wealth_after_chf"] == "627967.87" + confirmed = confirm_raiffeisen_manual_snapshot( + conn, + **facts, + preview_id=preview["preview_id"], + confirmation_id=preview["confirmation_id"], + input_fingerprint=preview["input_fingerprint"], + ) + assert confirmed["bank_cash_after_chf"] == "103979.40" + assert confirmed["known_wealth_after_chf"] == "627967.87" + model = build_modelled_wealth_development(conn, as_of="2026-08-27", period="all") + assert model["last_confirmed_anchor_date"] == "2026-08-26" diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py index 7e3a951..f576932 100644 --- a/tests/unit/test_schema.py +++ b/tests/unit/test_schema.py @@ -21 +21 @@ def test_schema_version_recorded() -> None: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_schema49_fk_safe_phase18.py b/tests/unit/test_schema49_fk_safe_phase18.py index 914da69..647b2db 100644 --- a/tests/unit/test_schema49_fk_safe_phase18.py +++ b/tests/unit/test_schema49_fk_safe_phase18.py @@ -79 +79 @@ def test_phase18_compatibility_is_idempotent_with_confirmed_transfer_child() -> - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 @@ -85 +85 @@ def test_fresh_database_reaches_schema_49_with_foreign_keys_enabled() -> None: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_sprint14_performance_contract.py b/tests/unit/test_sprint14_performance_contract.py index 9496615..634db75 100644 --- a/tests/unit/test_sprint14_performance_contract.py +++ b/tests/unit/test_sprint14_performance_contract.py @@ -203 +203 @@ def test_schema_46_defaults_new_accounts_out_of_performance_and_repeats_as_noop( - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 @@ -265 +265 @@ def test_performance_scope_migration_defaults_new_accounts_to_excluded_and_repea - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_sprint17d_annual_budget.py b/tests/unit/test_sprint17d_annual_budget.py index 0057557..946c696 100644 --- a/tests/unit/test_sprint17d_annual_budget.py +++ b/tests/unit/test_sprint17d_annual_budget.py @@ -42 +42 @@ def test_migration_49_is_additive_and_supports_immutable_budget_versions() -> No - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 diff --git a/tests/unit/test_sprint20b_performance_activation_daily_valuations.py b/tests/unit/test_sprint20b_performance_activation_daily_valuations.py index e748cbd..cdaf662 100644 --- a/tests/unit/test_sprint20b_performance_activation_daily_valuations.py +++ b/tests/unit/test_sprint20b_performance_activation_daily_valuations.py @@ -407 +407 @@ def test_schema_remains_49() -> None: - assert MIGRATION_VERSION == 51 + assert MIGRATION_VERSION == 52 diff --git a/tests/unit/test_sprint20c_performance_activation_hardening.py b/tests/unit/test_sprint20c_performance_activation_hardening.py index aaae80e..1872694 100644 --- a/tests/unit/test_sprint20c_performance_activation_hardening.py +++ b/tests/unit/test_sprint20c_performance_activation_hardening.py @@ -715 +715 @@ def test_schema_stays_49(): - assert MIGRATION_VERSION == 51 + assert MIGRATION_VERSION == 52 diff --git a/tests/unit/test_sprint20e_crypto_market_recovery.py b/tests/unit/test_sprint20e_crypto_market_recovery.py index 1409ee5..d8d82b9 100644 --- a/tests/unit/test_sprint20e_crypto_market_recovery.py +++ b/tests/unit/test_sprint20e_crypto_market_recovery.py @@ -496 +496 @@ def test_schema_remains_49() -> None: - assert MIGRATION_VERSION == 51 + assert MIGRATION_VERSION == 52 diff --git a/tests/unit/test_sprint20g1_crypto_reconciliation.py b/tests/unit/test_sprint20g1_crypto_reconciliation.py index ce4cfa6..2edb25e 100644 --- a/tests/unit/test_sprint20g1_crypto_reconciliation.py +++ b/tests/unit/test_sprint20g1_crypto_reconciliation.py @@ -54 +54 @@ def test_schema_50_and_snapshot_preview_confirm_replay_are_append_only(tmp_path: - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/worktrees/FinanceManager-sprint23__HERMES_CWD_8d46a20096ed__