EN DE

API und MCP

Portfolixir stellt den unterstützten lokalen Workflow über die JSON-API unter /api/v1 bereit. Der MCP-Begleitdienst in mcp-server/ ist bewusst dünn: MCP-Tools rufen ausschließlich die JSON-API auf und greifen nicht direkt auf die Datenbank zu.

Authentifizierung

API-Anfragen benötigen ein lokales Bearer-Token:

Authorization: Bearer <PORTFOLIXIR_API_TOKEN>

Benannte Tokens (E25). PORTFOLIXIR_API_TOKENS fügt weitere Tokens als name=token-Einträge hinzu, durch Kommas getrennt (scripts=<token>; ein Name hat 1 bis 32 Zeichen aus a-z, 0-9, _ und -). Ein Schreibzugriff mit einem davon wird mit diesem Namen als actor_label im Journal verbucht (GET /api/v1/journal), und eine damit ausgeführte Zusammenführung hält den Namen in ihrem Protokoll fest (GET /api/v1/merges), genommen aus dem Eintrag, zu dem das vorgelegte Token passt, nie aus der Anfrage. PORTFOLIXIR_API_TOKEN bleibt der Standard und wird wie bisher ohne Label verbucht, es sei denn, PORTFOLIXIR_API_PRINCIPAL benennt ihn; das Compose-Deployment nennt ihn so mcp, die Schreibzugriffe des Begleitdienstes lauten also mcp. Jeder Eintrag wird beim Start wie das eine Token geprüft, und ein Name oder ein Token darf nur einmal vorkommen. Ein Name ordnet einen Schreibzugriff zu, er beschränkt nicht, was das Token darf: Jedes Token hat dieselbe volle Befugnis. Warum, und die Grenze für gespeicherten Text weiter unten, entscheidet ADR-0054 (englisch).

Der MCP-Begleitdienst nutzt PORTFOLIXIR_API_TOKEN, um Portfolixir unter PORTFOLIXIR_API_BASE_URL aufzurufen, und folgt dort keiner Weiterleitung: Eine 3xx-Antwort wird als ApiRedirectError abgelehnt, der die Anfrage und die Variable nennt, damit der Body einer Anfrage und das Token nie dorthin erneut gesendet werden, wohin die Weiterleitung zeigt. Richten Sie PORTFOLIXIR_API_BASE_URL auf die Adresse, unter der die API ohne Weiterleitung antwortet. PORTFOLIXIR_MCP_TOKEN ist für den HTTP-Transport erforderlich, damit sich lokale HTTP-Clients beim Begleitdienst authentifizieren können. Es folgt der Regel des API-Tokens: Der Begleitdienst startet nicht mit einem Token, das kürzer als 32 Bytes oder ein Platzhalter ist, und nennt dabei die Variable. Ein PORTFOLIXIR_MCP_PORT, das keine ganze Zahl von 1 bis 65535 ist, oder ein Port, den der Listener nicht bekommt (ein anderer Prozess hält ihn), stoppt den Begleitdienst mit dem Exit-Status 1 und einer Zeile, die die Variable und ihren Wert nennt oder die Adresse und die Ursache, etwa EADDRINUSE. Ein leeres PORTFOLIXIR_MCP_HOST bindet 127.0.0.1 wie ein nicht gesetztes, nie jede Schnittstelle (#1137), und ebenso ein leeres Klammerpaar ([]), das wie ein leerer Wert als Loopback-Standard gilt; eine IPv6-Adresse, an die er bindet, gilt in Klammern ([::1]:4001), dem Host, den ein Client für die URL schickt, die der Begleitdienst ausgibt. Wiederholt falsche Tokens von einer verbindenden Adresse sperren sie für ein wachsendes Intervall, in dem jedes weitere falsche Token von ihr mit 429 und Retry-After beantwortet wird und die Sperre verlängert. Das Token wird zuerst verglichen: Ein richtiges Token kommt durch, solange seine Adresse gesperrt ist (#974), sodass hinter dem veröffentlichten Port, wo jeder Client über die Docker-Bridge verbindet, ein Rater oder ein veralteter Client dort den Agenten nicht aussperrt. Die Bearer-Prüfung der API selbst verhält sich genauso, solange jedes konfigurierte Token die Mindestlänge von 32 Bytes erreicht; ein aus dem Quellcode gestarteter Server, der nur PORTFOLIXIR_API_TOKEN liest, hält es an keine, und mit einem kürzeren Token prüft sie zuerst die Sperre. Vor allem anderen prüft der Begleitdienst den Host-Header genau, Name und Port: Eine Anfrage unter einem Host, auf den der Listener nicht antwortet (die Loopback-Namen und seine gebundene Adresse mit seinem Port sowie die Namen in PORTFOLIXIR_MCP_ALLOWED_HOSTS), wird mit 403 beantwortet, bevor ihr Origin oder ihr Token betrachtet wird, und zählt als kein Fehlversuch. Die Loopback-Namen sind 127.0.0.1, localhost und [::1], jeweils nur mit dem Port des Listeners. Ein Name in PORTFOLIXIR_MCP_ALLOWED_HOSTS gilt mit diesem Port und ohne Port, wie ihn ein Proxy auf Port 443 durchreicht; ein Eintrag mit eigenem Port gilt nur unter diesem Port, etwa 127.0.0.1:14001 für eine Compose-Zuordnung, die den Begleitdienst auf einem anderen Host-Port als seinem eigenen veröffentlicht. Die Namen, der des gebundenen Hosts und die in PORTFOLIXIR_MCP_ALLOWED_HOSTS, werden in Kleinbuchstaben verglichen, wie ein Browser sie schickt, sodass ein Eintrag mit Großbuchstaben die Prüfung des Begleitdienstes und die des MCP-SDK besteht. Das Origin einer Browser-Anfrage wird danach als Ganzes verglichen, Schema, Name und Port (#956): Es gilt als http:// auf einem Loopback-Namen oder der gebundenen Adresse mit dem Port des Listeners oder als http:// oder https:// auf einem Host-Wert aus PORTFOLIXIR_MCP_ALLOWED_HOSTS. Jedes andere Origin, eine Seite auf einem anderen Port derselben Maschine eingeschlossen, wird mit 403 beantwortet (origin not allowed), bevor sein Token betrachtet wird. Um einen MCP-Client im Browser zuzulassen, der von einem anderen Origin ausgeliefert wird, nenne Host und Port dieses Origins in PORTFOLIXIR_MCP_ALLOWED_HOSTS: localhost:6274 lässt einen Client zu, der unter http://localhost:6274 ausgeliefert wird. Ein Client, der kein Browser ist, sendet kein Origin und besteht diese Prüfung. Das Token wird geprüft, bevor der Body der Anfrage gelesen wird. Die Fehler, die der Begleitdienst selbst beantwortet, ein unbekannter Pfad eingeschlossen, haben die Form der API, {"errors": {"detail": "Bad Request"}}, ohne Stacktrace und ohne lokalen Pfad; die Ablehnungen des MCP-Protokolls selbst auf /mcp behalten dessen JSON-RPC-Fehlerform.

Datenregeln

Alle Antworten nutzen JSON-Umschläge mit entweder data oder errors. Finanz-Decimals werden als Strings serialisiert, einschließlich Mengen, Preise, Gebühren, Steuern, Kurs-Schlusswerte und monetärer Summen. Request-Payloads für diese Werte sollten ebenfalls Strings senden.

Die Fehler, die der Server selbst statt eines Endpunkts beantwortet, nutzen denselben Umschlag mit ihrem eigenen Status: ein unlesbarer Body ist 400, ein Body über der Größengrenze des Servers 413, eine unbekannte Route 404, ein interner Fehler 500, jeweils als {"errors": {"detail": "Bad Request"}} mit der englischen Statusbezeichnung.

DELETE /api/v1/securities/:id ist die Erfolgs-Ausnahme: es liefert 204 No Content mit leerem Body. Clients sollten für diese erfolgreiche Löschantwort keinen JSON-Body parsen.

IDs. Jede ID ist eine positive Ganzzahl, die in ein PostgreSQL-bigint passt (höchstens 9223372036854775807). Eine Pfad-ID, die fehlerhaft, unbekannt oder jenseits dieser Grenze ist, liefert 404; eine ID jenseits der Grenze in einem Query-String oder einem JSON-Body (in beliebiger Tiefe, als Zahl oder als String) liefert 422 mit dem Namen des Schlüssels — derselbe Feldfehler, den eine fehlerhafte ID erhält. Keine ID wird je mit 500 beantwortet. Die freie attributes-Map eines Wertpapiers sowie die Textfelder online_id und resource_id sind keine IDs und werden nicht geprüft.

Die Seiten halten dasselbe Versprechen: Ein ID-förmiger Query-Parameter jenseits der Grenze (id, *_id, *_ids, view) wird durch eine Weiterleitung auf dieselbe Seite ohne ihn verworfen, und eine Pfad-ID jenseits der Grenze (/securities/:id, /classifications/:id) leitet auf die Übersicht um, gleich was der Query-String enthält — nie ein Serverfehler. Jeder andere ID- oder Ganzzahl-Parameter einer Seite wird nach derselben ID-Regel gelesen und gilt als nicht angegeben, wenn er den Wert nicht fassen kann: /snapshots?snapshot= öffnet den neuesten Snapshot und /tax?year= (ein Jahr außerhalb von 1–9999) das voreingestellte Jahr. Ein Ereignis, das eine Seite oder einen ihrer Dialoge mit einer Nutzlast erreicht, die kein Objekt ist, mit einer ID jenseits der Grenze in beliebiger Tiefe (dieselbe Regel, die die API mit 422 beantwortet), mit einem Feld in falscher Form oder mit einem unbekannten Namen, ändert nichts: Die Seite bleibt, wie sie war.

Begrenzte Ganzzahlen. offset auf der Wertpapierliste nimmt höchstens 1000000 an, days auf den Research-Log-Abfragen unreviewed und expiring höchstens 3650 (zehn Jahre); ein Wert über der Grenze oder einer, der keine nicht-negative Ganzzahl ist, liefert 422 mit dem Namen des Parameters. limit behält seinen Vertrag (gekappt und zurückgemeldet), ebenso days bei den Wertpapier-Terminen. Ein Jahr außerhalb von 1–9999 gilt als fehlerhaftes Jahr.

Datumsangaben. Jedes Datum, das ein Schreibzugriff speichert — das date einer Buchung, valid_from einer Regelversion und valid_until einer Stilllegung, as_of eines Snapshots, die Daten eines Research-Log-Eintrags, eines Termins, eines Steuerprofils, eines ISIN-Wechsels, eines Kurses und eines Splits — ist ein ISO-8601-Kalenderdatum (YYYY-MM-DD) von 1900-01-01 bis 2999-12-31. Ein Datum außerhalb dieses Bereichs oder in anderer Form (ein Objekt aus Teilen, ein Datum mit Uhrzeit) liefert 422 mit dem Namen des Felds und speichert nichts; das gespeicherte, gemeldete und im Journal festgehaltene Datum ist also das gesendete. Ein Portfolio-Performance-Import nennt eine Zeile mit einem Datum außerhalb des Bereichs in der Vorschau, statt sie zu buchen. Ein Datumsfilter eines Lesezugriffs — from und to bei den Buchungen, Kursen und Trades, as_of bei den Kennzahlen eines Wertpapiers und den Regeln — folgt derselben Regel: Alles andere liefert 422 mit dem Namen des Parameters.

Buchungsbeträge. Die Geldfelder und Preise einer Buchung (gross_amount, price, fees, taxes, security_amount, settlement_amount, settlement_fx_rate) halten 6 Nachkommastellen, ihre quantity 12 (ADR-0016). Ein feinerer Wert wird vor der Prüfung kaufmännisch auf diese Stellenzahl gerundet; gespeichert, gemeldet und im Journal festgehalten wird also der gerundete Wert, und ein positiver Betrag, der auf 0 rundet, liefert 422. Ein Wert mit mehr als 14 Stellen vor dem Komma (eine Menge mit mehr als 18) liefert 422 mit dem Namen des Felds, statt in der Datenbank zu scheitern.

Andere gespeicherte Beträge. Dieselbe Regel gilt für jeden anderen Betrag, den ein Schreibzugriff speichert: den close eines Kurses (6 Nachkommastellen), einen Wechselkurs (15) sowie die Geldfelder (6) und Sätze (4) der Steuer-Schreibzugriffe — die Töpfe und einbehaltenen Steuern einer Steuerbescheinigung, amount_granted eines Freistellungsauftrags, die Freibeträge und Sätze eines Steuerjahrs, church_tax_rate eines Profils — sowie coupon_rate und face_value einer Anleihe (6). Ein feinerer Wert wird vor der Prüfung kaufmännisch auf seine Stellenzahl gerundet; ein positiver close, der auf 0 rundet, liefert also 422 und wird nie als Null gespeichert. Ein Geldwert mit mehr als 14 Stellen vor dem Komma liefert 422 mit dem Namen des Felds.

Text. Ein Name, eine Kennung oder jeder andere einzeilige Text, den ein Schreibzugriff speichert, ist höchstens so lang wie seine Spalte — 255 Zeichen, sofern keine engere Grenze genannt ist (der Name eines Buckets oder einer Ansicht 100, der eines Plans oder Snapshots 120), gezählt in Unicode-Codepunkten, der Einheit der Datenbank — und enthält weder Steuerzeichen noch Zeilenumbrüche. Freitext (die notes einer Buchung, ein Research-Log-Eintrag, die note eines Termins oder einer Regelversion, eine Beschreibung) behält Tabulatoren und Zeilenumbrüche, aber kein anderes Steuerzeichen, auch kein NUL, und ist höchstens 10000 Zeichen lang (der body eines Research-Log-Eintrags 20000, die description einer Kategorie 2000), gezählt in Codepunkten; auch die Datenbank lehnt einen längeren Wert ab. Kein Text, einzeilig oder frei, trägt ein unsichtbares Zeichen (E25): ein Unicode-Tag-Zeichen (U+E0000–U+E007F), ein Steuerzeichen der Schreibrichtung (U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069), ein anderes unsichtbares Formatzeichen (das weiche Trennzeichen U+00AD, U+180E, das Leerzeichen und die Verbinder der Breite null U+200B–U+200D, U+2060–U+2065, U+206A–U+206F, die Byte-Order-Mark U+FEFF, U+FFF9–U+FFFB, U+1BCA0–U+1BCA3, U+1D173–U+1D17A) oder eine Folge von zwei oder mehr Variantenselektoren (ein einzelner, die Darstellung eines Emojis, ist erlaubt). Ein Verbinder der Breite null U+200D ist erlaubt, wo er zwei Piktogramme verbindet, wie in einem Emoji aus mehreren (eine Person am Laptop, eine Familie, die Regenbogenflagge): Das Zeichen vor ihm ist ein Piktogramm oder ein VS16 direkt nach einem, und das Zeichen nach ihm ist eines. Die drei Flaggen der Landesteile (England, Schottland, Wales) bestehen aus Tag-Zeichen und bleiben abgelehnt, ebenso der Nicht-Verbinder der Breite null U+200C, den Wörter mancher Schriften (Persisch, mehrere indische Schriften) tragen. Sie werden als nichts angezeigt, erreichen einen Agenten aber unverändert; der Fehler nennt sie darum mit Codepunkt: must not contain invisible characters (U+200B); retype the text without them. Die eine Ausnahme ist der Name eines Wertpapiers: Seine Formatzeichen werden wie bisher beim Speichern entfernt, und nur eine Folge von Variantenselektoren wird abgelehnt. Alles andere liefert 422 mit dem Namen des Felds, nie einen Serverfehler. Ein Portfolio-Performance-Import nennt eine Zeile, deren Namen oder Notiz gegen dieselbe Regel verstoßen, in der Vorschau. Die freie Zuordnung attributes eines Wertpapiers erfüllt die Regel in jeder Tiefe: Jeder Schlüssel ist einzeiliger Text mit höchstens 255 Zeichen, und jeder Textwert, auch in einem verschachtelten Objekt oder einer Liste, ist Freitext; sonst liefert der Schreibzugriff 422 auf attributes. Die Zuordnung, wie sie gespeichert wird — zusammengeführt mit den vorhandenen Attributen —, ist als kompaktes JSON höchstens 65536 Byte groß; ein Schreibzugriff darüber liefert 422 auf attributes und speichert nichts. Eine Änderung, die nichts ändert, schreibt keine Zeile und hinterlässt keinen Journaleintrag. Eine Eigenschaft eines Suchanbieters, die gegen die Regel verstößt, wird verworfen, bevor sie die Attribute erreicht. Ein Textfilter eines Lesezugriffs — query bei den Wertpapieren, resource_type und resource_id im Journal, holder, institution und jurisdiction bei den Steuer-Lesezugriffen — ist einzeiliger Text mit höchstens 255 Zeichen; alles andere, auch eine Liste, liefert 422 mit dem Namen des Parameters.

Eingepackte Rümpfe. Ein Schreibzugriff, dessen Attribute unter einem Schlüssel reisen — {"transaction": {…}}, {"view": {…}}, {"rule": {…}} und ihre Geschwister — liefert 422 mit dem Namen dieses Schlüssels, wenn sein Wert kein JSON-Objekt ist (ein String, eine Zahl, eine Liste).

Delta-Reads (FR-38). Die beiden wiederkehrenden Sync-Reads — GET /api/v1/transactions und GET /api/v1/securities — akzeptieren ?since=<ISO8601> (Datetime mit Offset, naive UTC-Datetime oder ein reines Datum als Tagesbeginn, UTC) und liefern dann nur die Zeilen, die strikt nach diesem Zeitpunkt angelegt oder geändert wurden (nach updated_at). Die Antwort spiegelt since, trägt as_of — als nächstes since verwenden — und eine delta_note mit der Semantik. as_of liegt eine Sekunde vor dem Lesezeitpunkt oder vor dem Beginn der ältesten Transaktion, die geschrieben hat und noch offen ist, je nachdem, was früher liegt: Eine Zeile wird gestempelt, wenn ihre Transaktion sie schreibt, nicht wenn diese committet, sodass ein Cursor zum Lesezeitpunkt eine Zeile überspränge, die ein langer Schreibvorgang (ein Import) nach dem Read committet. Der nächste Read kann eine Zeile daher erneut liefern, überspringt aber keine. Löschungen sind in einem Delta-Read nicht repräsentiert; wer Löschungen erkennen muss, macht einen vollen Read. Ein ungültiges since ist ein 422. Delta-Reads sind pull-only: Push-Zustellung (Webhooks an einen konfigurierten Endpunkt) ist eine separate, weiterhin gegatete Entscheidung (B3.7) und bewusst nicht Teil dieser Oberfläche.

Welche Reads since tragen (Issue #830, Sprint 14 D-5). since ist ein Zeilen-Delta-Parameter und passt deshalb nur auf Reads, die eine Sammlung von Zeilen mit Änderungsstempel liefern. Die Familie zerfällt in drei Teile:

  • Tragen ihn — die Zeilensammlungen, die ein geplanter Lauf pollt: GET /api/v1/transactions, GET /api/v1/securities, GET /api/v1/securities/:security_id/notes (nach inserted_at: das Research-Log ist append-only, ein Eintrag ändert sich nach dem Schreiben nie; der abgeleitete thesis_state deckt weiterhin das ganze Log ab), GET /api/v1/securities/:security_id/events (nach updated_at, ein verschobener Termin kommt also als seine geänderte Zeile zurück) sowie GET /api/v1/portfolios/:portfolio_id/targets und /position_targets (eine Zielzeile gilt als geändert, wenn sie oder ihr Plan nach dem Schnitt geändert wurde — das Aktivieren einer anderen Planversion tauscht die steuernden Zeilen, ohne eine davon zu bearbeiten; der Roll-up effective_targets des Positions-Reads deckt immer den ganzen Plan ab, und min_drift greift nach dem Schnitt). Ihre MCP-Zwillinge nehmen denselben since-String: portfolixir.transactions.list, portfolixir.securities.list, portfolixir.notes.list, portfolixir.events.list, portfolixir.targets.list und portfolixir.targets.list_positions. Die Konfigurationssammlungen (Portfolios, Konten und Depots, Buckets, Sichten, Klassifikationen, Pläne, Steuerparameter, -profile und Freistellungsaufträge, Snapshots) tragen ihn nicht: Sie sind klein und vom Betreiber gepflegt, und mehrere tragen pro Zeile Werte, die aus anderen Tabellen abgeleitet sind (ein Kontosaldo, die Veraltung einer Steuerbescheinigung) und sich ändern, ohne dass sich das updated_at der Zeile ändert.
  • Abgeleitete Projektionen tragen ihn nicht — sie brauchen einen anderen Mechanismus: Bewertung, Allokation, Performance, Benchmark, Erträge und Risiko haben keine Zeilen und damit kein updated_at. „Hat sich die Bewertung seit meinem letzten Read geändert” ist eine Frage nach der Version ihrer Basis, die die Instanz intern bereits führt (ADR-0039), aber nicht exponiert; sie zu beantworten ist ein Conditional-Read-Mechanismus (ein ETag oder ein Basis-Token), der nicht gebaut ist.
  • Zeitabgeleitete Queues dürfen ihn nicht tragen: /notes/unreviewed, /notes/expiring, /notes/uncorroborated, /events/upcoming, /events/stale und /events/unconfirmed. Ihre Mitgliedschaft ändert sich, weil Zeit vergeht, ohne dass sich eine Zeile ändert — eine Position wird über Nacht ungeprüft, ein Termin rückt in den Horizont. Ein Schnitt auf updated_at würde genau die Zeilen stillschweigend verwerfen, die ein Poller braucht; diese Reads ignorieren since deshalb wie jeden Parameter, den sie nicht definieren (volle Antwort, kein Delta-Umschlag), und ihre MCP-Tools akzeptieren ihn nicht.

Die menschliche Sicht desselben Schnitts (Issue #731) liegt auf /transactions?since= und /securities?since= als Geändert-seit-Chips: gleicher Parametername, gleiche akzeptierte Formen, gleicher Strikt-nach-updated_at-Schnitt — ein Link, den der Agent weitergibt, öffnet also genau die Scheibe, die er gelesen hat. Die eine Abweichung ist bewusst: wo die API ein ungültiges since mit 422 ablehnt, degradieren die Seiten zur ungefilterten Liste — ein veraltetes Lesezeichen darf nie stillschweigend verengen, was der Betreiber sieht.

Wertpapiere

  • GET /api/v1/securities listet Wertpapiere. Zeilen kommen standardmäßig als schlanke Projektion — die feste Whitelist id, name, ticker_symbol, isin, wkn, currency_code, asset_class — damit Routineabfragen klein bleiben; projection=full liefert den vollständigen Datensatz (Notizen, Feed-Konfiguration, Attribute, Zeitstempel). Ein optionales fields= (Issue #732, erweitert FR-37, kommagetrennt) wählt eine schlanke Feldauswahl, aufgelöst gegen die Feldliste der vollen Projektion; ein gesetztes fields= ersetzt projection=, denn eine schlanke Feldauswahl ist selbst eine Projektion, Feld für Feld gewählt. Ein unbekannter Name ist ein 422, nie ein stiller Fallback. Optionale Query-Parameter: query, sort, direction, holding_status (all, held oder not_held), logo_status (missing oder present — die bloße Logo-Bedingung; missing schließt Zeilen aus, die ausdrücklich auf „kein Logo“ gesetzt sind, und schließt ein gespeichertes Logo ein, dessen Datei fehlt, wie auch immer es gesperrt ist, Issue #933; present ist ein gespeichertes Logo, dessen Datei nicht fehlt), data_quality (stale_quote — kein Kurs neuer als 7 Tage, einschließlich nie bepreister Wertpapiere; missing_quote — gar kein Kurs, die engere Menge darin; missing_logo — kein hinterlegtes Logo und nicht auf „kein Logo“ gesetzt, oder ein gespeichertes Logo, dessen Datei fehlt, die Menge, die die Übersicht „Wertpapiere ohne Logo“ zeigt; missing_fx — Issue #717: bepreist, aber ohne gespeicherten Kurs von seiner Währung zum EUR-Hub, das Speichern des Kurses leert also die Menge. Eine Benchmark und ein stillgelegtes Wertpapier sind in keiner der ersten drei Mengen, die sie schon in der Abfrage auslassen, vor limit/offset: Das Stilllegen eines ausverkauften oder delisteten Wertpapiers nimmt es aus allen drei heraus, Reaktivieren bringt es in die zurück, auf die es passt. missing_fx behält beide; two_scales — Issue #1068: jede Anleihe, die der Zwei-Skalen-Wächter nennt, in beiden Richtungen und über den ganzen Katalog, die Menge, die die Datenqualitätszeile der Übersicht zählt; Regel und welche Wertpapiere als Anleihe gelten, siehe bond.two_scales unten. Auch sie behält eine Benchmark und ein stillgelegtes Wertpapier, denn deren Zahlen sind so falsch wie zuvor; implausible_quote — Issue #1101: jedes gehaltene Wertpapier, bei dem ein Kauf, ein Verkauf oder eine bepreiste Einlieferung einen gespeicherten Kurs hat, am Tag der Buchung oder der letzte innerhalb von 7 Tagen davor, der unter der Hälfte oder über dem Doppelten des Preises je Stück der Buchung in der Währung des Wertpapiers liegt (eine Buchung in anderer Währung mit security_amount ÷ quantity), gelesen auf der Split-Basis der Buchung (ADR-0028 §2), sodass ein erfasster Split nicht genannt wird und ein nicht erfasster schon; eine Buchung zum Preis 0 oder ohne Kurs im Fenster wird nicht verglichen, und ein Wertpapier, das two_scales nennt, wird nicht noch einmal gezählt. Es ist die Menge, die die Datenqualitätszeile der Übersicht zählt, und behält eine gehaltene Benchmark oder ein gehaltenes stillgelegtes Wertpapier. Darunter ergänzt der Umschlag findings, je gelistetem Wertpapier eines — security_id, kind (buy, sell oder inbound_delivery), date, price, currency_code, quote_date, close (auf der Split-Basis der Buchung), ratio (Kurs ÷ Preis, kaufmännisch auf 6 Stellen gerundet) und bookings_outside, wobei die letzte Buchung außerhalb des Bandes die genannte ist — und computation_basis, das input_series, reference, window, gaps, threshold und assumptions nennt), projection (slim/full) und limit/offset zur Paginierung (limit eine positive Ganzzahl, Standard 5000, max. 20000, seit #771; offset nichtnegativ). Nutze diese, um große Kataloge zu paginieren, statt die ganze Tabelle auf einmal zu holen. Eine data_quality-Menge wird gefiltert, bevor limit/offset sie paginieren, sodass nur ihre letzte Seite kurz ist (Issue #1113). Die menschliche Sicht dieser Verengungen ist die One-Tap-Chipzeile auf der Wertpapierseite (Issue #717): ihre Chips fahren auf demselben URL-Zustand (holding=, dq=, filter[]=asset_class:is_nil, plus cur[]= und class[]= für die Währungs- und Effektivklassen-Familien), sodass ein vorgefilterter Link und ein API-Read dieselbe Menge beschreiben. is_benchmark=true beschränkt die Abfrage auf die als Benchmark markierten Wertpapiere (ADR-0046: die Referenzreihen des Benchmark-Vergleichs), is_benchmark=false lässt sie weg; das Kennzeichen ist ein Feld der vollen Projektion und von fields=, und POST/PATCH nehmen es an. is_retired=true beschränkt die Abfrage auf die stillgelegten Wertpapiere (Issue #1103): Ein Wertpapier, das aus den Hygiene-Mengen stillgelegt wurde, ist so wieder zu finden und mit PATCH {"security": {"is_retired": false}} zu reaktivieren; is_retired=false lässt sie weg. Beide Kennzeichen nehmen true oder false (leer zählt als nicht gesetzt, alles andere ist ein 422, das das Kennzeichen nennt) und verengen zusammen mit den anderen Parametern: Neben einer data_quality-Menge, die solche Wertpapiere auslässt, trifft is_retired=true oder is_benchmark=true nichts. Gehalten (holding_status=held und jeder andere Gehalten-Filter: die unreviewten Positionen des Research-Logs und held_only der Ereignisse) bedeutet eines: eine von null verschiedene Nettomenge über alle Depots, bewegt durch buy, sell, inbound_delivery und outbound_delivery — ein eingeliefertes Depot ist ab seiner Ankunft gehalten; ein security_transfer zwischen eigenen Depots gleicht sich zu null aus.

    Ein gespeichertes Logo, dessen Datei fehlt (Issue #933), meldet die Logo-Abfrage GET /api/v1/securities/:security_id/logo mit file_missing: true und has_logo: false, während path, source und locked wie gespeichert bleiben: Die Prüfung beim Start der Instanz fand im Logo-Verzeichnis keine Datei zum gespeicherten Pfad. Ein gefundenes Logo wird erneut gesucht, ein manuelles bleibt gesperrt und wartet darauf, erneut gesetzt zu werden. Speichern oder Entfernen eines Logos hebt die Markierung auf, ebenso der nächste Start, sobald die Datei zurück ist.

  • POST /api/v1/securities legt ein Wertpapier mit einem security-Objekt an. asset_class ist ein stabiler String-Code: equity, etf, fund, government_bond, bond, crypto, commodity, index, other, plus die Zertifikat-/Hebel-Codes warrant, knock_out, factor_certificate, discount_certificate, bonus_certificate, express_certificate, reverse_convertible. Lass es leer, damit die Klasse beim Lesen aus Name/ISIN/Ticker inferiert wird. Um eine Position aus der Allokations-Steuerbasis (den 100 %) und der Drift-Tabelle herauszuhalten, während sie in den Bewertungssummen und der Performance bleibt — z. B. ein als Wertspeicher gehaltener Bitcoin —, die Position mit einem Bucket versehen und diesen Bucket aus einer Ansicht ausschließen; die Allokation dann unter dieser Ansicht lesen.
  • GET /api/v1/securities/:id liefert ein Wertpapier, einschließlich seiner identifier_aliases — der über den ISIN-Wechsel-Endpunkt unten aufgezeichneten früheren ISINs (jeweils mit id, former_isin, changed_on, note). Ein Wertpapier, das eine Zusammenführung entfernt hat, antwortet 404 mit errors.merged_into {"kind": "security", "id": …}, dem Wertpapier, auf dem seine Historie jetzt liegt — über jede spätere Zusammenführung bis zum lebenden verfolgt —, und einem Detail, das beide nennt (ADR-0050 §12); eine ID, die keine Zusammenführung nennt, antwortet mit dem einfachen 404, ebenso eine, deren Kette an einer seither gelöschten Zeile endet (ein Überlebender darf gelöscht werden, sobald er nichts mehr hält) — ohne merged_into, mit einem Detail, das diese Zeile nennt. Jede Route unter /api/v1/securities/:security_id/ antwortet einer zusammengeführten ID ebenso — die Lesezugriffe auf Kurse, Trades, Kennzahlen, Notizen, Termine und Logo, ihre Schreibzugriffe, der ISIN-Wechsel und das Löschen eines Kennzeichen-Alias.
  • PATCH /api/v1/securities/:id aktualisiert ein Wertpapier mit einem security-Objekt. Das Boolean treat_quotes_as_raw (Standard false) ist die ADR-0028-Notluke für Anbieter, die ihre Historie nach einem Aktiensplit nie rückwirkend anpassen: Mit gesetztem Flag werden die synchronisierten Kurszeilen des Wertpapiers als roh (wie gehandelt) behandelt, sodass die Split-Anpassungsfaktoren auch auf sie wirken. Das Boolean is_retired legt ein ausverkauftes oder delistetes Wertpapier still, false hebt das auf: Ein stillgelegtes Wertpapier verlässt die Mengen stale_quote, missing_quote und missing_logo, und sein veralteter Kurs zählt in der Performance nicht mehr als Preismessung, was seine TTWROR-Historie neu feststellen kann (#610). Wie jede Änderung eines Wertpapiers wird sie unter dem aufrufenden Token journalisiert. Der currency_code eines Wertpapiers friert ein, sobald es eine Transaktion oder einen Kurs hat (ADR-0050 §11): Eine Änderung liefert dann 422 mit errors.currency_code, das beides zählt, etwa ["is frozen once referenced (120 quotes, 3 transactions)"], und nichts wird geschrieben. Die gespeicherte Währung erneut zu senden ist keine Änderung, und die übrigen Felder bleiben änderbar.
  • DELETE /api/v1/securities/:id löscht ein Wertpapier, wenn nichts darauf verweist. Liest eine Policy-Regel es, antwortet der Aufruf mit 409 Conflict und errors.policy_rules. Buchungen, Kurshistorie, Recherche-Notizen, Wertpapier-Ereignisse oder Regelversionen ergeben 409 Conflict mit errors.referenced_by (die verweisenden Tabellen, gezählt, etwa {"transactions": 3, "security_quotes": 120}), errors.remedy und errors.remedy_route: merge für ein Duplikat, dessen Route die Zusammenführungs-Vorschau GET /api/v1/securities/:id/merge_preview?target_id= ist, ergänzt um die ID des Wertpapiers, das bleibt, oder retire, wenn Recherche-Notizen oder Regelversionen darauf verweisen, weil eine Zusammenführung sie nicht mitnehmen kann (die Route ist PATCH /api/v1/securities/:id mit is_retired: true). Bevor ein unreferenziertes Wertpapier gelöscht wird, werden seine Kategorie-Zuordnungen, Positionsziele, Positions-Bucket-Overrides und ISIN-Aliasse entfernt, jeweils journalisiert; keine Datenbank-Kaskade entfernt sie (ADR-0050 §11). Ein bereits gelöschtes Wertpapier liefert 404. Ein Löschen, das einen Wettlauf mit einem gleichzeitigen Schreibvorgang verliert und danach keinen Verweis mehr findet, antwortet mit 409 Conflict und allein errors.detail und löscht nichts: den Datensatz neu lesen und erneut versuchen (#954).
  • GET /api/v1/securities/search durchsucht konfigurierte Online-Wertpapieranbieter. Query-Parameter: query; optional type mit security oder crypto. Jedes Feld eines Treffers stammt vom Anbieter und wird auf seinen Typ geprüft und in der Größe begrenzt: Ein Feld mit falschem Typ oder über seiner Grenze fehlt, ein Treffer ohne verwendbaren Namen entfällt, ein Handelsplatz behält nur begrenzte Felder und skalare Eigenschaften, und raw trägt nur type und market_cap_rank, nie den ganzen Eintrag des Anbieters. Die Attribute, die ein Treffer in ein Wertpapier schreibt, sind genauso begrenzt, und ein Name oder eine Kursquellen-URL über 255 Zeichen ist bei jedem Schreiben eines Wertpapiers ein 422.

ISIN-Wechsel (Identifier-Aliasse)

Wenn eine Kapitalmaßnahme einem bestehenden Wertpapier eine neue ISIN gibt, den Wechsel aufzeichnen, statt die ISIN direkt zu editieren: Die frühere ISIN wird ein journalisierter Alias, und das ISIN-Matching des Imports prüft erst aktuelle ISINs, dann die Aliasse — Re-Importe alter Exporte (frühere ISIN) und neuer Exporte (neue ISIN) treffen so weiter dasselbe Wertpapier, statt ein Duplikat anzulegen (ADR-0029). Eine bloße Umbenennung braucht keinen ISIN-Wechsel — sie ist nur eine Namensänderung.

  • POST /api/v1/securities/:security_id/isin-change zeichnet den Wechsel mit einem isin_change-Objekt auf: Pflichtfeld new_isin (normalisiert auf getrimmte Großschreibung), optional changed_on (ISO-Datum, Standard heute) und note. Liefert das aktualisierte Wertpapier einschließlich seiner identifier_aliases. Eine new_isin, die nicht zwölf Zeichen in der Form einer ISIN hat (zwei Buchstaben, neun Buchstaben oder Ziffern und eine Prüfziffer) oder deren Prüfziffer nicht stimmt, liefert 422 auf new_isin, eine note über 255 Zeichen 422 auf note. Abgelehnt mit 422 und benanntem Konflikt, wenn new_isin der aktuellen ISIN entspricht, auf einem anderen Wertpapier live ist oder als frühere ISIN eines anderen Wertpapiers aufgezeichnet ist; ein Wechsel zurück auf eine eigene frühere ISIN verbraucht diesen Alias (ein Revert). Jeder Wertpapier-ISIN-Schreibpfad — Anlegen, Aktualisieren und der Anlege-Pfad des Imports — lehnt symmetrisch eine ISIN ab, die als Alias existiert, und benennt das Alias-Wertpapier. Ein Konflikt benennt das andere Wertpapier mit seiner id (security #12), nie mit seinem gespeicherten Namen (siehe „Ein Datensatz in einem Satz“ unten).
  • Ein Kennzeichen, das PATCH /api/v1/securities/:id an einem bestehenden Wertpapier ändert, erfüllt dieselben Katalogregeln, sonst 422 mit dem Feldnamen: eine isin in ISIN-Form mit stimmender Prüfziffer, eine wkn aus sechs Buchstaben oder Ziffern, ein ticker_symbol nur aus druckbarem ASCII. Ein Doppelgänger (ein Buchstabe aus einer anderen Schrift, ein unsichtbares Zeichen, eine falsche Prüfziffer) ersetzt so nie das Kennzeichen, das die Exporte tragen. Den gespeicherten Wert erneut zu senden ist keine Änderung, und ein neues Wertpapier behält, womit es angelegt wird. Der name eines Wertpapiers wird ohne Unicode-Formatzeichen gespeichert (Nullbreiten-Leerzeichen und -Verbinder, Steuerzeichen der Schreibrichtung).
  • DELETE /api/v1/securities/:security_id/identifier_aliases/:id löscht einen aufgezeichneten Alias (journalisiert), wenn ein ISIN-Wechsel versehentlich aufgezeichnet wurde; liefert 204 No Content oder 404, wenn der Alias nicht zu dem Wertpapier gehört.

Beispiel-Payload für einen ISIN-Wechsel:

{
  "isin_change": {
    "new_isin": "IE000XZSV718",
    "changed_on": "2026-07-01",
    "note": "merger rename"
  }
}

Beispiel-Payload zum Anlegen:

{
  "security": {
    "name": "Example ETF",
    "ticker_symbol": "EXM",
    "currency_code": "EUR"
  }
}

Ein doppeltes Wertpapier zusammenführen (ADR-0050 §9)

Eine zweite Kopie eines Instruments — ein Export mit neuerer ISIN, der importiert wurde, bevor der Wechsel aufgezeichnet war, ein von Hand angelegtes Wertpapier, das der nächste Import noch einmal anlegte — wird repariert, indem man das Duplikat (die Quelle) in das Wertpapier zusammenführt, das bleibt (das Ziel). Der Dialog des Operators auf der Wertpapierseite (Zusammenführen in… im Zeilenmenü) liest diese Vorschau und bestätigt mit demselben plan_digest, sodass beide denselben Plan sehen.

  • GET /api/v1/securities/:id/merge_preview?target_id= zeigt die Zusammenführung des Wertpapiers in target_id als Vorschau — ein Lesen, das nichts schreibt (portfolixir.securities.merge_preview). Beide müssen in derselben Währung gehandelt werden, beide oder keines ein Benchmark sein, und das Ziel darf nicht stillgelegt sein, solange die Quelle lebt; solange die Quelle Kurse hat, müssen beide ihre synchronisierten Kurse gleich behandeln (treat_quotes_as_raw). Eine Quelle mit Recherche-Notizen oder eine, die eine eigene Regel liest, wird abgelehnt, weil eine Notiz weder wandern noch verschwinden kann und eine Regelversion ihr Subjekt behält (research_notes, policy_rules mit errors.policy_rules); wo die Zusammenführung in die andere Richtung gelänge, sagt das Detail es. Jede Position der Quelle in einem Depot muss ihre Ansichtszugehörigkeit behalten (position_buckets_mismatch). Ein Split der Quelle, den das Ziel im selben Portfolio am selben Tag mit demselben Verhältnis trägt, fällt zusammen; ein anderes Verhältnis wird abgelehnt (split_ratio_mismatch), ebenso ein Split, der einer Seite fehlt, während sie davor eine Buchung oder einen Kurs hat (split_event_mismatch), oder ein Split, der Buchungen neu skalieren würde, die er vorher nicht skaliert hat (split_linearity), oder ein Split, den die Zusammenführung verschieben müsste und der noch einen Import-Hash aus einer Umwandlung vor der Import-Hash-Artprüfung trägt (legacy_hashed_split, mit errors.splits; seine Art zurücksetzen oder ihn löschen — einen, den die Zusammenführung zusammenlegt, löscht sie und legt seinen Hash still). Eine ISIN der Quelle mit falscher Prüfziffer wird abgelehnt (invalid_source_isin): Die Zusammenführung würde sie auf das Ziel schreiben, was der Katalog für ein gespeichertes Wertpapier ablehnt; sie ist daher zuerst an der Quelle zu korrigieren oder zu leeren. Schließlich muss jede Identität beider Wertpapiere nach der Zusammenführung das Ziel finden (identity_unresolvable, mit errors.unresolvable): die gespeicherte Identität, die Identität, die der Portfolio-Performance-Import beim Anlegen des Wertpapiers aufgezeichnet hat (Name, ISIN, WKN, Ticker und Währung — eine Datei löst über das auf, was sie trägt, nicht über Kennzeichen, die seither dazukamen), jede frühere ISIN und, für jedes Wertpapier, das zuvor in eines der beiden zusammengeführt wurde (und in jene, die ganze Kette hinab), seine gespeicherte und seine importierte Identität (merged_stored, merged_imported, unter der ID des weggefallenen Wertpapiers): Ein Überlebender, der seinerseits zusammengeführt wird, muss jeden früheren Import weiter zur Historie führen. Ein nur über den Namen importiertes Wertpapier, das danach einen Ticker bekam und dessen Name vom Ziel abweicht, ist so ein Fall; ebenso ein Name, den ein anderes lebendes Wertpapier auch trägt. Jede Ablehnung ist ein 409 Conflict mit errors.code, errors.detail und errors.guards; eine unbekannte Quelle antwortet 404, eine schon zusammengeführte 409 already_merged mit errors.merged_into, eine fehlende target_id 422. Das 200 enthält:
    • plan_digest, den Digest, den die Zusammenführung nimmt;
    • source und target, jeweils mit Name, Währung, isin, wkn, ticker_symbol, feed, asset_class, Flags, transaction_count und split_events; guards; reverse, ob die andere Richtung gelänge;
    • key_equal_pairs und choice_required wie bei einer Konto-Zusammenführung, und splits (collapsed, moved) mit den split_events davor und danach;
    • position_buckets: je Depot, in dem die Quelle hält oder einen Override trägt, beide wirksamen Bucket-Mengen, beide Overrides und die action;
    • quotes: source_count, moved_count (Kurse der Quelle an Tagen ohne Kurs des Ziels; sie wandern und behalten ihre Quelle), collision_count (Tage, an denen beide einen haben: Der Kurs des Ziels gewinnt, der Schlusskurs der Quelle geht ins Protokoll der Zusammenführung) und manual_collisions, jeder kollidierende, von Hand erfasste Kurs der Quelle mit date, source_close, target_close und target_source;
    • configuration: category_assignments (je Klassifizierung der Quelle move, wo das Ziel dort keine hat, sonst drop — die des Ziels gewinnt — mit beiden Kategorien) und position_targets (jedes Positionsziel der Quelle in einem aktiven, Entwurfs- oder archivierten Plan, mit Plan, plan_status, Kategorie und target_weight, und move oder drop mit dem reason collides — das Ziel hat in dem Plan schon eine Zeile — oder stale — die Zeile läge nicht mehr unter der Kategorie des Ziels);
    • events: die Termine, die wandern, und possible_duplicates, ein Termin der Quelle und einer des Ziels gleicher Art am selben Tag (beide bleiben);
    • identifiers: choice_required (beide tragen eine ISIN), dann after_by_identity_choice mit keep_target_isin und adopt_source_isin — oder after, wenn es nichts zu wählen gibt —, jeweils isin, wkn, ticker_symbol, feed, name, asset_class und former_isins des Ziels danach; adopted, was das Ziel von der Quelle übernimmt (eine fehlende WKN, einen fehlenden Ticker oder Feed, eine ISIN, die nur die Quelle trägt); differences, jeden Wert der Quelle, der stattdessen dem Ziel folgt (Name, Anlageklasse, Logo, eine WKN, ein Ticker oder Feed, den das Ziel schon hat, und eine WKN oder ein Ticker, den die Regeln des Katalogs bei einer Änderung ablehnen — nie übernommen, mit null für das Ziel); aliases_reassigned, die früheren ISINs der Quelle;
    • outcome_by_collapse_key_equal mit "false" und "true": die transaction_count des Ziels danach, moved_transaction_ids, deleted (collapsed_duplicate oder collapsed_split), positions (je Depot, in dem die Quelle hält: source, target und after, jeweils quantity, cost_basis, avg_cost und realized_result), rounding_differences, cash_accounts und flow_changes, mit positions_basis wie bei einer Depot-Zusammenführung; reimport_note sagt, was die Zusammenführung für den nächsten Import bedeutet.

    Jede Stückzahl, jeder Kurs, jedes Gewicht und jede Dezimalzahl ist ein String. Der Digest deckt beide Wertpapiere, jede Buchung beider, ihre Kurse, Kategorie-Zuordnungen, Positionsziele, Termine und früheren ISINs mit ihrem updated_at, die Identitäten, die die Importe aufgezeichnet haben, jede Zahl und die Guards ab; die Wahlen gehören nicht dazu, sodass ein Paar einen Digest hat, und ein Kurs, den der Sync zwischen Vorschau und Zusammenführung speichert, ein geänderter Plan ist.

  • POST /api/v1/securities/:id/merge mit {"target_id": …, "plan_digest": …, "collapse_key_equal": …, "identity_choice": …, "isin_changed_on": …} führt unter dem Token zusammen (portfolixir.securities.merge). collapse_key_equal ist Pflicht, wenn die Vorschau key_equal_pairs nennt, und identity_choice, wenn beide Wertpapiere eine ISIN tragen — ohne sie jeweils ein 422, und nie vorausgewählt: Frag den Operator. keep_target_isin behält die ISIN des Ziels und zeichnet die der Quelle als frühere ISIN des Ziels auf; adopt_source_isin gibt dem Ziel die ISIN der Quelle und zeichnet seine alte als frühere ISIN auf (die Reparatur des Duplikats in falscher Reihenfolge aus ADR-0029 §3, zusammen mit collapse_key_equal: true). isin_changed_on (YYYY-MM-DD, optional) ist das changed_on dieser früheren ISIN, sonst das Datum der Zusammenführung. Eine unbekannte identity_choice oder ein isin_changed_on, das kein Datum ist, antwortet 422. Sie antwortet 201 Created mit dem Protokoll der Zusammenführung (kind security, portfolio_id null; sein manifest nennt jede verschobene oder gelöschte Buchung, jeden verschobenen Kurs und jeden verworfenen mit seinem Schlusskurs und dem des Ziels, der gewann, die verschobenen oder verworfenen Zuordnungen, Positionsziele und Termine, die umgehängten und angelegten früheren ISINs, die übernommenen Kennzeichen, die Unterschiede und die Wahlen — collapse_key_equal nur, wo die Vorschau key_equal_pairs nannte, identity_choice und isin_changed_on nur, wo die Zusammenführung diese Wahl getroffen hat, weil beide Wertpapiere eine ISIN trugen, sonst null, was die Anfrage auch schickte) und already_applied: false. In einer Transaktion, ein Audit-Journal-Eintrag je Zeile: Mit true werden die gepaarten Buchungen der Quelle gelöscht und ihre Inhalts-Hashes stillgelegt; ein Split, den das Ziel am selben Tag im selben Portfolio trägt, wird gelöscht; jede andere Buchung geht auf das Ziel über; die Stückzahl jedes Depots wird an jedem Tag gegen die Buchungen beider Wertpapiere geprüft (409 identity_check_failed sonst); der Bucket-Plan wird geschrieben; die Kurse füllen die Lücken des Ziels — nicht journalisiert, das Protokoll der Zusammenführung ist ihr Nachweis — und die abgeleiteten Werte beider Wertpapiere werden verworfen; Zuordnungen, Positionsziele und Termine wandern oder entfallen, wie die Vorschau sie genannt hat; die früheren ISINs der Quelle gehen an das Ziel, ihre ISIN wird nach der Wahl geschrieben, WKN, Ticker und Feed dort, wo sie dem Ziel fehlen; die Quelle wird gelöscht; und die Identitäten werden am Katalog, wie die Zusammenführung ihn hinterlassen hat, noch einmal geprüft (409 identity_unresolvable mit errors.unresolvable rollt sie sonst zurück). Danach bucht ein Portfolio-Performance-Import, der die Quelle über eines ihrer Kennzeichen nennt, auf das Ziel, und ein erneut angewendeter, schon importierter Export legt nichts an. Ein geänderter Plan antwortet 409 plan_changed mit der frischen Vorschau in errors.preview; eine Wiederholung einer abgeschlossenen Zusammenführung desselben Paars antwortet 200 mit dem ursprünglichen Protokoll und already_applied: true; eine in ein anderes Wertpapier zusammengeführte Quelle 409 already_merged. Ein Rückgängigmachen gibt es nicht.

Research-Log (ADR-0044)

Was Betreiber oder Agent über ein Wertpapier wissen, wird als nur anhängbare datierte Einträge festgehalten — das Research-Log des Wertpapiers — und der aktuelle Thesenstand wird daraus abgeleitet, nie daneben gepflegt. Einträge werden nie geändert und nie gelöscht: Ein widerlegter Befund wird zurückgezogen, indem eine retraction angehängt wird, die ihn ersetzt; beide bleiben lesbar, sodass der nächste Lauf zuerst den Widerruf sieht statt eine erledigte Prämisse erneut zu prüfen. Es gibt absichtlich kein PATCH und kein DELETE für einen Eintrag.

Jeder Eintrag trägt kind (thesis, evidence, invalidation_check, event_result, risk, retraction, decision), body, source_url (eine http(s)-URL mit höchstens 255 Zeichen; andere Schemata und längere Links sind ein 422), source_quality (primary, secondary_multi, awareness, unverified — gesetzt, nicht geraten), as_of (das Stichdatum der Aussage, getrennt von inserted_at: ein heute geschriebener Eintrag über das letzte Quartal trägt das Datum des Quartals), author (operator, agent, local_model; die API setzt standardmäßig agent), machine_generated (ein extrahierter Eintrag ist ein Vorschlag bis zur Bestätigung und muss seine source_url tragen), supersedes_id (der frühere Eintrag desselben Wertpapiers, den dieser ersetzt; Pflicht bei einem Widerruf), valid_until (eine datierte Sperre wie ein Lock-up oder eine selbst auferlegte Kaufsperre) und — nur bei thesis-Einträgen — conviction (low, medium, high), invalidation_condition und time_stop. Jeder Eintrag in einer Antwort trägt zudem superseded_by_ids und ein superseded-Flag, sodass ein ersetzter Eintrag als ersetzt gezeigt statt verborgen wird. Alle festen Wertemengen werden geprüft; ein unbekannter Wert ist ein 422 mit dem Feldnamen, und aus Eingaben entsteht nie ein Atom.

  • GET /api/v1/securities/:security_id/notes — das Log, neueste zuerst (nach as_of, dann Schreibzeit), mit dem abgeleiteten thesis_state und einer log_note, die den Nur-anhängen-Kontrakt benennt. limit behält die neuesten Einträge (Standard 1000, max. 10000); thesis_state leitet sich immer aus dem ganzen Log ab, und die Antwort nennt das angewandte limit.
  • POST /api/v1/securities/:security_id/notes — hängt einen Eintrag aus einem note-Objekt an (201); journalisiert unter dem API-Token-Akteur. Ein as_of nach heute (dem Kalendertag der Instanz) liefert 422 auf as_of („must not be in the future“): Das Log hängt nur an, ein vertipptes Jahr in der Zukunft ließe sich nie zurücknehmen. valid_until und time_stop dürfen in der Zukunft liegen. body fasst höchstens 20000 Zeichen, invalidation_condition höchstens 10000 (Unicode-Codepoints); ein längerer Wert liefert 422 mit dem Feld, und auch die Datenbank lehnt ihn ab.
  • GET /api/v1/notes/unreviewed?days=N — gehaltene Wertpapiere (Nettostückzahl ungleich null über alle Depots), deren neuester Eintrag älter als N Tage ist (Standard 90) oder die keinen haben; Zeilen tragen last_entry_as_of und days_since_last_entry (null, wenn nie geprüft). Ein Eintrag zählt als Prüfung an seinem as_of, aber nicht später als am Tag nach seinem Anlegen; ein Eintrag, der vor der Ablehnung mit einem as_of in der Zukunft gespeichert wurde, hält seine Position also nicht aus dieser Liste. last_reviewed_at des Thesenstands folgt derselben Regel, und der Eintrag selbst behält sein as_of. limit behält die am längsten überfälligen Positionen (Standard 1000, max. 10000).
  • GET /api/v1/notes/uncorroborated — Einträge, deren source_quality nicht primary ist, neueste zuerst; ersetzte Einträge werden übersprungen, sofern nicht include_superseded=true; optional security_id. limit behält die neuesten Einträge (Standard 1000, max. 10000).
  • GET /api/v1/notes/expiring?days=N — Einträge, deren valid_until in die nächsten N Tage fällt (Standard 30), früheste zuerst, mit days_until_expiry; aufgehobene (ersetzte) Sperren werden übersprungen; optional security_id. limit behält die am frühesten ablaufenden Einträge (Standard 1000, max. 10000).

Der Thesenstand (thesis_state im Wertpapier-Detail und im Log-Read) ist die B4.1-Projektion: status (none, intact, retracted), der aktuelle Thesentext, conviction, invalidation_condition, time_stop, as_of, last_reviewed_at und last_reviewed_by (der neueste thesis- oder invalidation_check-Eintrag), derived_from_entry_id (der Thesen-Eintrag, aus dem er liest) und retracted_by_entry_id (der Widerruf, dessen body den Grund trägt), dazu ein basis-Satz, der die Ableitung benennt. Die neueste These, die keine andere These ersetzt, ist die aktuelle; ein Widerruf, der sie ersetzt, setzt retracted.

Beispiel-Payload zum Anhängen:

{
  "note": {
    "kind": "retraction",
    "body": "10-Q am 2026-08-02 geprüft: kein Lieferantenstreit offengelegt. Zurückgezogen.",
    "source_url": "https://example.invalid/sec/10-q",
    "source_quality": "primary",
    "as_of": "2026-08-02",
    "supersedes_id": 41
  }
}

Die menschliche Sicht ist der Tab Research im Wertpapier-Detailbereich auf /securities/:id: der Thesenstand oben, die Einträge neueste zuerst mit sichtbarer Art und Quellenqualität, ersetzte Einträge als ersetzt markiert, Widerrufe lesbar und ein Formular, das einen Eintrag als Betreiber anhängt.

Wertpapier-Termine (ADR-0048)

Ein Wertpapier-Termin ist eine datierte Aussage darüber, dass einem Wertpapier etwas bevorsteht oder zugestoßen ist: Geschäftszahlen, ein Ex-Dividenden- oder Zahltag, das Ende einer Haltefrist, eine Indexüberprüfung, eine Hauptversammlung, eine Behördenentscheidung, eine Prognoseanpassung.

Es ist keine Kapitalmaßnahme. Ein Split verändert eine Position und ist deshalb ein Ledger-Ereignis (ADR-0028); ein Termin für Geschäftszahlen verändert nichts, bis sich ein Kurs bewegt — und eine Kursbewegung ist bereits ein Kurs. security_events ist eine eigene Tabelle, und die Ledger-Projektion sieht keine dieser Zeilen. Wird aus dem Termin eine Buchung — die Dividende wird tatsächlich gezahlt —, läuft die Buchung wie immer über das Ledger und der Termin wird als confirmed markiert: ein Termin wird nie in eine Transaktion umgewandelt.

Der Katalog, nicht der Bestand. Termine hängen an security_id und an nichts sonst, und die katalogweiten Reads umfassen standardmäßig jedes Wertpapier. held_only=true grenzt ein und ist nie die Voreinstellung: ein aus der Positionsliste abgeleiteter Kalender kann für ein noch nicht gehaltenes Wertpapier keinen Termin führen — und das ist genau das Wertpapier, dessen Termine zählen.

Ein Datum wird qualifiziert. timing ist exact, estimated, window (zwischen date und date_end, nur hier erlaubt) oder month (der Monat ist bekannt, der Tag nicht). Eine Schätzung darf nicht wie eine Meldung aussehen.

Änderbar und journalisiert, bewusst nicht append-only: ein verschobener Termin macht das alte Datum nicht zu einem zweiten Fakt, sondern falsch. Eine Korrektur ist ein PATCH auf derselben Zeile; die Änderungshistorie liegt im Audit-Journal.

Die Reads:

  • GET /api/v1/securities/:security_id/events — die Termine eines Wertpapiers, die nächsten zuerst, mit limit.
  • GET /api/v1/events/upcoming?days=N — alles, was in den nächsten N Tagen (Standard 30) im gesamten Katalog ansteht. Ein window- oder month-Termin ist fällig, sobald irgendein Tag, auf den er fallen kann, im Horizont liegt. Optional kind, held_only, limit. days ist begrenzt wie limit: Werte über zehn Jahre werden gekappt und gekappt zurückgemeldet.
  • GET /api/v1/events/unconfirmed — Termine, deren Zeitraum vorbei ist und die niemand bestätigt hat. Optional security_id, kind, held_only, limit; bewusst kein days, denn ein unbestätigter vergangener Termin gehört in die Liste, egal wie alt er ist.
  • GET /api/v1/events/stale?days=N — Termine, deren checked_at älter als N Tage ist oder die nie geprüft wurden (days_since_checked ist dann null). Ein Termin, dessen gespeichertes checked_at nach morgen liegt (vor der Ablehnung unten geschrieben), steht ebenfalls darin, mit negativem days_since_checked.

Die Writes: POST /api/v1/securities/:security_id/events (201), PATCH /api/v1/security_events/:id und DELETE /api/v1/security_events/:id (204). Auf beiden schreibenden Wegen liefert ein checked_at nach morgen (Kalendertag der Instanz plus ein Tag für Zeitzonen) 422 auf checked_at, und nichts wird geschrieben. Die note eines Termins fasst höchstens 10000 Zeichen (Unicode-Codepoints); eine längere liefert 422 auf note. source_quality verwendet dieselben vier Werte wie das Research-Log. Ein Termin trägt kein Geld.

Was diese Fläche nicht ist. Nichts ruft einen Kalender ab — Eintrag von Hand oder durch den Agenten. Der Fälligkeits-Read wird abgefragt: es gibt keine Benachrichtigung und keine Zustellung nach außen, und keine Regel liest diese Zeilen.

Abgeleitete Kennzahlen (ADR-0047)

Stufe (a) der Scope-Leiter: die Preiskennzahlen eines Wertpapiers, beim Lesen aus der bereits gespeicherten Kurshistorie abgeleitet. Nichts wird gespeichert, nichts abgerufen.

  • GET /api/v1/securities/:security_id/metrics — sma_50 und sma_200 mit der distance_pct des letzten Schlusskurses zu jedem; volatility und max_drawdown über die Fenster 30d, 90d und 365d; momentum über 3m, 6m und 12m; distance_to_extremes, das 52-Wochen-Hoch und -Tief mit ihren Daten und dem Abstand zu beiden. Der Drawdown trägt peak_date, trough_date und recovery_date (null, solange die Reihe unter dem Hoch liegt). Optionales as_of (ISO-Datum, Standard heute) begrenzt die Reihe: Schlusskurse danach werden nicht gelesen. Ein unbekanntes Wertpapier ist 404, ein ungültiges as_of ist 422.

Die Reihe ist die des Wertpapiers selbst. Gelesen werden die gespeicherten Schlusskurse in der Anzeigebasis nach ADR-0028 §2 (splitbereinigt beim Lesen; die gespeicherten Zeilen bleiben unverändert) und in der eigenen Währung des Wertpapiers — bewusst nicht in die Basiswährung umgerechnet, denn eine Preiskennzahl ist eine Aussage über das Instrument, und eine Umrechnung würde den Wechselkurspfad hineinfalten.

Eine Lücke erzeugt keine Beobachtung, niemals eine Null. Renditen werden zwischen aufeinanderfolgenden gespeicherten Schlusskursen gebildet; ein Tag ohne Kurs wird nicht fortgeschrieben und anschließend differenziert. Jede Kennzahl trägt daher ihre observations und das window, über das sie gemessen wurde, und die Antwort trägt einmal computation_basis (input_series, window, reference, gaps, assumptions).

Unterhalb ihres Minimums verweigert eine Kennzahl. value ist null mit insufficient_data: true und der vorhandenen Beobachtungszahl, bei 200 — eine Lückenmarkierung, kein Fehler. Eine Volatilität, deren Quadratwurzel jenseits dessen liegt, was der eine Gleitkommaschritt tragen kann — eine Größenordnung, die nur unplausible gespeicherte Schlusskurse erreichen —, ist null ohne insufficient_data: undefiniert, nicht zu wenige Daten, und nie ein Fehler.

Jede Kennzahl sagt, was sie gebraucht hätte (ADR-0047 §6, ergänzt am 2026-09-19): required trägt die Mindestzahl an observations auf jeder Kennzahl, ob berechnet oder verweigert — n für sma_n, 20 für volatility, 2 für max_drawdown und momentum, 1 für distance_to_extremes. Für Momentum und Extremwerte ist die Zahl nur die Untergrenze; die Abdeckungsregel (ein Kurs an jedem Ende des Fensters) bleibt in computation_basis.gaps. Ein verweigerter sma_n trägt window: null, weil seine Spanne erst aus den jüngsten n Kursen entsteht.

Die Volatilität ist die Grundgesamtheits-Standardabweichung der einfachen Tagesrenditen des Fensters, annualisiert mit √252; Abstände, Momentum und Volatilität sind Verhältniszahlen statt Prozentwerte (0.05 ist +5 %), gerundet auf sechs Nachkommastellen.

Diese Fläche berichtet, sie bewertet nicht. Es gibt kein Signal, keine Empfehlung, kein Rating, keinen Score und keine Handlung in der Antwort. Eine Regel über einer Kennzahl ist FR-43 und bleibt verschlossen.

Anleihen: Stammdaten und Anleihe-Lesung (ADR-0052)

Die Stammdaten einer Anleihe sind sechs nullbare Felder des Wertpapiers, setzbar mit POST /api/v1/securities und PATCH /api/v1/securities/:id, in jeder Wertpapier-Antwort enthalten (und mit fields= wählbar):

  • coupon_rate — der Kupon in Prozent vom Nennwert pro Jahr, ein Decimal-String von 0 bis 100 ("2.5", nicht "0.025");
  • coupon_frequency — annual oder semi_annual;
  • maturity_date und issue_date — ISO-Daten, die Fälligkeit nach dem Emissionstag;
  • face_value — die Stückelung, ein Decimal-String über 0;
  • face_value_currency_code — die Währung der Stückelung, ein ISO-4217-Code aus der Menge, die currency_code annimmt.

coupon_rate und face_value halten 6 Nachkommastellen, nach der Regel, der jeder gespeicherte Betrag folgt (Andere gespeicherte Beträge oben): Ein feinerer Wert wird vor der Prüfung kaufmännisch gerundet, "3.1234567" wird also als "3.123457" gespeichert und gemeldet. Gesendet werden sie als Strings, wie jedes Decimal; eine JSON-Zahl wird so umgewandelt, wie jedes Decimal-Feld eine umwandelt.

null löscht ein Feld, Pflicht ist keines, ein unmöglicher Wert ist ein 422, der sein Feld nennt, ohne dass etwas geschrieben wird, und die Felder bleiben erhalten, wenn die Anlageklasse wechselt. Gelesen werden sie, solange die effektive Anlageklasse bond oder government_bond ist, und — seit Issue #1068 — solange das Wertpapier keine Anlageklasse hat, weder gespeichert noch abgeleitet (sein asset_class ist null, und die Namens- und Logo-Regeln leiten keine ab), ein maturity_date oder eine coupon_rate trägt und einen Namen hat, den die Inferenz nicht als strukturiertes Produkt liest: Die Namensinferenz erkennt Namen von Staatsanleihen und, seit Issue #1127, die ausdrücklichen Anleihe-Wörter (Anleihe, Schuldverschreibung, Pfandbrief, Notes, Obligation), also macht erst dieser Stammdatensatz eine Unternehmensanleihe ohne ein solches Wort und ohne Klasse als Anleihe kenntlich. Ein Wertpapier mit einer anderen Klasse, gespeichert oder abgeleitet, ist keine Anleihe, welche Stammdaten es auch trägt; eine Anleihe, deren Name die Rechtsform ihres Emittenten und kein Anleihe-Wort trägt, wird beim Anlegen als equity gespeichert, ebenso eine mit Anleihe-Wort, die vor Issue #1127 gespeichert wurde, und braucht asset_class bond. Die MCP-Tools portfolixir.securities.create und portfolixir.securities.update nehmen sie entgegen (das Update auch null).

GET /api/v1/securities/:id eines solchen Wertpapiers trägt bond, beim Lesen berechnet und nie gespeichert (Stufe (a) der Scope-Leiter); für jedes andere Wertpapier und in Listen und Schreibantworten ist es null:

  • as_of und quantity (die gehaltenen Stück über alle Depots);
  • nominal_held — amount, Stück × 100, in currency_code (der des Nennwerts, sonst der des Wertpapiers): Ein Portfolio-Performance-Export bucht die Stückzahl einer prozentnotierten Anleihe als ein Hundertstel des Nominals, darum ist ein Kurs zugleich Prozent vom Nennwert und Preis je Stück;
  • remaining_term — days von as_of bis zur Fälligkeit, years (Tage ÷ 365, kaufmännisch auf sechs Nachkommastellen gerundet), whole_years und whole_months, matured ab dem Fälligkeitstag;
  • current_yield — Kupon ÷ Kurs, und yield_to_maturity — die lineare Näherung (Kupon + (100 − Kurs) ÷ Restlaufzeit in Jahren) ÷ Kurs, ohne Zinseszins; jeweils eine Verhältniszahl, kaufmännisch auf sechs Nachkommastellen gerundet (0.025707 ist 2,5707 %) und wie jedes Decimal ohne nachgestellte Nullen gesendet ("0.03685", "0"), mit dem verwendeten price (value, date, source: quote für den letzten gespeicherten Kurs, trade für den letzten eigenen Handelspreis, solange es keinen gibt);
  • two_scales — null oder der Befund, dass die Anleihe auf zwei Skalen bepreist ist: ihre direction, ihr latest_quote, die Zahl der unit_scale_bookings und die last_unit_scale_booking sowie die rule.
    • direction: "forward" — ein letzter Kurs vom 20- bis 500-Fachen eines gebuchten Preises je Stück (eines Kaufs oder einer mit Preis erfassten Einlieferung): Kurse um 100 neben Buchungen um 1. Jeder Geldbetrag einer solchen Anleihe ist hundertfach zu hoch, und die TTWROR zeigt es nicht.
    • direction: "reverse" (Issue #1068) — ein letzter Kurs von 1/500 bis 1/20 eines gebuchten Preises je Stück, beide Enden eingeschlossen, und selbst höchstens 5: Kurse um 1 neben Buchungen um 100. Die gespeicherten Kurse sind kein Prozent vom Nennwert, und die Anleihe zählt hundertfach zu niedrig in jeder Summe.

    Die beiden Buchungsschlüssel behalten in beiden Richtungen ihre Namen und enthalten die Buchungen im Band; liegen Buchungen einer Anleihe in beiden Bändern, wird der Befund forward gemeldet. Die rule nennt beide Bänder und welche Wertpapiere als Anleihe gelesen werden. Umgerechnet wird nichts, in keiner Richtung.

Jede Kennzahl trägt ihre eigene computation_basis (input_series, window, reference, gaps, assumptions). Eine Zahl ohne ihren Eingang ist null mit insufficient_data: true und den fehlenden Eingängen in missing (coupon_rate, maturity_date, price); die Renditen einer fälligen Anleihe sind null mit matured: true. Eine Rendite, deren Kurs der letzte eigene Handelspreis von höchstens 5 ist — das Spiegelbild des Zwei-Skalen-Bands, 100 ÷ 20, der Preis je Stück einer Buchung, die das Nominal als Stückzahl erfasst hat —, ist null mit price_on_unit_scale: true, weder insufficient_data noch matured: Ein solcher Preis ist kein Prozent vom Nennwert, Kupon ÷ Kurs wäre also keine Rendite. Ein gespeicherter Kurs ist in jeder Höhe ein Prozentkurs und wird immer verwendet; bei jeder anderen Rendite ist price_on_unit_scale false. Stückzinsen, Gebühren und Steuern sind nicht enthalten; die Lesung berichtet, sie bewertet nicht.

Logos

Jedes Wertpapier kann ein Logo tragen, automatisch ermittelt (CoinGecko für Krypto, Wikipedia und danach companieslogo.com für Aktien, ETFs und Fonds, das Logo des Emittenten für ein strukturiertes oder Hebelprodukt) oder von Hand gesetzt. Ein manuelles Logo oder ein ausdrückliches „kein Logo“ sperrt das Wertpapier, sodass die Logo-Suche im Hintergrund die Wahl nie überschreibt.

  • GET /api/v1/securities/:security_id/logo liefert den Logo-Status: { "data": { "security_id", "path", "source", "has_logo", "locked", "file_missing" } }. source ist coingecko, wikipedia, companieslogo oder manual; solange es kein Logo gibt, ist es null. file_missing (Issue #933) ist true, wenn die Prüfung beim Start der Instanz im Logo-Verzeichnis keine Datei zum gespeicherten path fand; has_logo ist dann false, während path, source und locked wie gespeichert bleiben. Ein so markiertes gefundenes Logo wird erneut gesucht, ein manuelles bleibt gesperrt und wartet darauf, erneut gesetzt zu werden, und logo_status=missing und data_quality=missing_logo führen beide. Speichern oder Entfernen eines Logos hebt die Markierung auf, ebenso der nächste Start, sobald die Datei zurück ist.
  • PUT /api/v1/securities/:security_id/logo setzt ein manuelles Logo aus einer Bild-URL ({ "logo": { "url": "https://…" } } oder { "url": "https://…" }). Das Bild wird einmal heruntergeladen, geprüft (png/jpg/jpeg/webp, höchstens 256 KiB) und lokal gespeichert; das Wertpapier wird auf die manuelle Wahl gesperrt. Eine fehlende URL liefert 422 mit errors.url; ein Bild, das sich nicht laden lässt oder die Prüfung nicht besteht, liefert 422 mit errors.logo, einer festen Meldung, die weder Adresse noch Status nennt.
  • DELETE /api/v1/securities/:security_id/logo entfernt das Logo und hält die ausdrückliche Entscheidung „kein Logo“ fest (die Zeile zeigt wieder ihre Initialen oder ihre Flagge); auch das sperrt das Wertpapier gegen die Suche.
  • POST /api/v1/securities/:security_id/logo/discover startet die automatische Suche erneut („Erneut suchen“). Die Antwort enthält ein result: updated, no_source oder failed. Gesperrte Wertpapiere bleiben unberührt und antworten mit no_source.

Kurse

  • GET /api/v1/securities/:security_id/quotes listet die Kurshistorie eines Wertpapiers. Optionale Query-Parameter: from und to, als ISO-Daten formatiert. Ungültige Datumsfilter liefern 422 Unprocessable Entity mit Feldfehlern. Jede Zeile beschreibt ihren Split-Status selbst (ADR-0028): close ist der gespeicherte Wert (wird nie verändert), adjusted_close der split-bereinigte Anzeigewert, basis die Speicherbasis der Zeile (raw für wie gehandelt erfasste manuelle Zeilen, provider_mirror für rückwirkend angepasste Sync-Zeilen) und adjusted, ob ein Split-Faktor angewendet wurde. Charts und Bewertungen nutzen adjusted_close; Audits prüfen gegen close. Ein Wertpapier, dessen Anbieter nie rückwirkend anpasst, lässt sich mit treat_quotes_as_raw markieren (siehe Wertpapiere), was die Roh-Basis für seine synchronisierten Zeilen erzwingt. limit behält die neuesten Zeilen des Fensters, weiterhin aufsteigend (Standard 20000, max. 50000; null, negativ oder nicht numerisch ist ein 422).
  • PUT /api/v1/securities/:security_id/quotes führt manuelle Kurszeilen ein (Upsert). Jede Zeile wird mit der Quelle manual gespeichert, gleich welche source sie nennt: Ein über die API geschriebener Kurs ist ein manueller Kurs, und die Anbieterquellen setzt allein die Kurssynchronisierung. Ein manueller Kurs hat Vorrang vor Anbieterdaten, deshalb ersetzt das Schreiben eine gespeicherte Zeile jeder Quelle an seinen Daten, und die Synchronisierung lässt eine manuelle Zeile stehen, bis sie freigegeben wird (unten). Das Schreiben wird unter dem Token journalisiert (resource_type=security_quotes, unter der Id des Wertpapiers, Operation upsert), mit den ersetzten gespeicherten Zeilen — ihren Kursen und Quellen — als Vorher-Abbild. Die Antwort ist {"upserted": n, "replaced": [Daten]}: upserted zählt die Zeilen, die nun wie übergeben gespeichert sind, replaced nennt die ISO-Daten, deren gespeicherte Zeile das Schreiben geändert hat (ein neues Datum steht nicht darin). Ein Schreiben, das nichts ändert, hinterlässt keinen Journaleintrag. Jede Kurszeile, manuell oder synchronisiert, ist begrenzt: ein close, der auf seine 6 Nachkommastellen gerundet positiv ist und höchstens 14 Stellen vor dem Komma hat, an einem date, das nicht nach morgen liegt (dem Kalendertag der Instanz plus einem Tag für Zeitzonen). Eine Zeile außerhalb der Grenze liefert 422 mit dem Feld, und eine Synchronisierung verwirft einen solchen Anbieterpunkt, statt den Lauf scheitern zu lassen. Die Lesepfade für den jüngsten Kurs (der Bewertungskurs, der jüngste Kurs im Katalog und die Prüfung auf veraltete Kurse) nutzen nie eine gespeicherte Zeile nach dieser Grenze. Ein Stapel nennt jedes Datum einmal und enthält nur Kursobjekte: Ein wiederholtes Datum liefert 422 mit errors.date, das es nennt, eine Zeile, die kein Objekt ist, 422 auf quotes, und nichts wird geschrieben.
  • POST /api/v1/securities/:security_id/quotes/release gibt die manuellen Kurse eines Wertpapiers von from bis to (beide Pflicht, einschließlich, im Body oder in der Query) an die Anbieterdaten zurück: Die manuellen Zeilen des Zeitraums werden entfernt, unter dem Token journalisiert (Operation delete) mit den freigegebenen Zeilen als Vorher-Abbild, und die Antwort ist {"security_id", "from", "to", "released": [Daten]}. Anbieterzeilen im Zeitraum bleiben, und ein Zeitraum ohne manuelle Zeilen ändert nichts und schreibt keinen Eintrag. Die nächste Kurssynchronisierung speichert den Anbieterkurs für ein freigegebenes Datum; ein Wertpapier ohne Anbieter behält dafür keinen Kurs. Ein fehlendes oder ungültiges Datum liefert 422 mit dem Feld, to vor from 422 auf to, ein unbekanntes Wertpapier 404. Der Operator gibt im Tab „Kurse“ des Wertpapiers frei (Freigeben…, Sprint 17), über dasselbe Schreiben.
  • GET /api/v1/securities/:security_id/quotes/manual liest die manuellen Kurse eines Wertpapiers über seine ganze gespeicherte Historie — der Lesezugriff, auf dem eine Freigabe steht (Sprint 17, portfolixir.quotes.manual): count, first und last (das erste und letzte manuelle Datum, null ohne eines), stored_count (jeder gespeicherte Kurs, gleich welcher Quelle), stretches — jeder Abschnitt eine Folge manueller Kurse ohne Kurs einer anderen Quelle dazwischen, {"from", "to", "count"}, aufsteigend, die jüngsten limit davon (die Listen-Familie: Standard 100, gedeckelt bei 1000, in meta.limit gespiegelt) —, stretch_count (alle) und sync_adapter: ob die Kurssynchronisierung das Wertpapier abfragen kann — ein Adapter für seinen Anbieter, der es abfragen kann (Yahoo braucht einen Ticker), die Prüfungen, die sie vor jedem Abruf macht. Bei false behält ein freigegebenes Datum keinen Kurs; bei true kann ein Abruf trotzdem scheitern. from und/oder to (einschließlich; eine fehlende Grenze ist offen) fügen range {"from", "to", "count"} hinzu: die manuellen Kurse, die eine Freigabe genau dieses Zeitraums entfernen würde. to vor from liefert 422 auf to, ein ungültiges Datum 422 mit dem Feld, ein unbekanntes Wertpapier 404.
  • POST /api/v1/securities/:security_id/sync_quotes löst die Kurssynchronisierung eines Wertpapiers aus. Die Antwort enthält status (ok, skipped oder error); übersprungene und Fehler-Antworten können einen reason wie missing_ticker oder no_provider_adapter enthalten, und persist_failed, wenn die geholten Kurse nicht gespeichert werden konnten. Eine Historie beliebiger Länge wird in einer Synchronisierung gespeichert, und in der geplanten Synchronisierung ist ein scheiterndes Wertpapier der Fehler dieses Wertpapiers, während die übrigen weiter synchronisiert werden. Eine Synchronisierung eines Wertpapiers läuft zur Zeit: Während eine läuft, gleich über welchen Weg, liefert eine zweite 409 Conflict und ruft keinen Anbieter auf. Die Kurshistorie eines neu angelegten Wertpapiers wird im Hintergrund über eine Warteschlange geholt, ein Wertpapier nach dem anderen, ob es über die API, die Seite oder einen Import entstand.

Beispiel-Payload für Kurs-Upsert:

{
  "quotes": [
    {
      "date": "2026-05-15",
      "close": "123.45"
    }
  ]
}

Beispiel-Antwort für Kurs-Upsert, dessen erstes Datum eine synchronisierte Zeile ersetzt hat:

{
  "data": {
    "upserted": 2,
    "replaced": ["2026-05-15"]
  }
}

Beispiel-Antwort für Kurssynchronisierung:

{
  "data": {
    "status": "skipped",
    "reason": "missing_ticker"
  }
}

Portfolios und Konten

Portfolio-Writes sind veraltet (ADR-0024) — nur Kompatibilität; nutze Buckets/Ansichten zur Gruppierung. Portfolios wurden zu internen Kompatibilitätsdatensätzen herabgestuft: die UI gruppiert ausschließlich über Buckets und Ansichten, und Depots/Geldkonten brauchen keine portfolio_id mehr (ein deterministisches internes Standard-Portfolio wird automatisch gebunden). POST /api/v1/portfolios und PATCH /api/v1/portfolios/:portfolio_id funktionieren weiter, antworten aber mit dem Response-Header Deprecation: true. Sunset-Hinweis: nach zwei Releases ohne externe Portfolio-Writes verschmilzt eine Folge-Story die Datensätze in Buckets und Ansichten (das Exit-Kriterium des ADR) — plane Migrationen auf POST /api/v1/buckets und POST /api/v1/views jetzt. Jeder hier geschriebene Datensatz bleibt in der schreibgeschützten Admin-Liste „Portfoliodatensätze (Kompatibilität)“ der UI sichtbar, nichts wird unsichtbar.

  • GET /api/v1/portfolios listet Portfolios (Kompatibilitätsdatensätze).
  • POST /api/v1/portfolios legt ein Portfolio mit einem portfolio-Objekt an. Veraltet — antwortet mit Deprecation: true; bevorzuge Buckets/Ansichten.
  • GET /api/v1/cash_accounts listet Geldkonten. Jedes trägt einen balance (Decimal-String, in der eigenen Währung des Kontos), der beim Lesen aus dem Ledger abgeleitet wird: Beträge werden als positive Größen gespeichert und der Transaktions-type impliziert die Richtung (Einzahlungen, Dividenden, Zinsen, Steuererstattungen und Verkäufe fügen Cash hinzu; Entnahmen, Gebühren, Steuern und Käufe entfernen es; eine Geldübertragung belastet ihr Konto und schreibt dem Gegenkonto gut). Ein balance_adjustment-Snapshot (siehe unten) verankert den Saldo an einem genannten absoluten Betrag zu seinem Datum, wonach nur spätere Buchungen ihn anpassen.
  • POST /api/v1/cash_accounts/:id/balance erfasst einen absoluten Saldo-Snapshot für ein Konto (ADR-0009): den aktuellen Saldo zu einem Datum, statt jede Buchung zu spiegeln. Body {"date": "2026-06-01", "amount": "4250.00"} (notes optional); amount ist ein Decimal-String und darf negativ sein (ein Überziehungskredit). Es speichert eine balance_adjustment-Transaktion und gibt sie zurück. Der Saldo verankert sich dann an diesem Betrag, und nur Buchungen mit einem Datum strikt nach dem Snapshot verändern ihn, sodass Geld zwischen eigenen Konten zu verschieben keine Übertragungsbuchung braucht. Unbekannte Konten liefern 404 Not Found.
  • POST /api/v1/cash_accounts legt ein Geldkonto mit einem cash_account-Objekt an. portfolio_id ist optional (ADR-0024): fehlt sie, wird das Konto an das deterministische interne Standard-Portfolio gebunden; eine explizite id gewinnt weiterhin (Kompatibilität). Das optionale liquidity_role (Standard free_cash) klassifiziert das Konto: free_cash ist echtes verfügbares Cash; credit_line ist eine Überziehungs-/Lombard-Linie, deren negativer Saldo eine Verbindlichkeit ist und deren ungenutzter Rahmen nie Liquidität ist (sie zählt nie zum verfügbaren Cash, auch nicht mit positivem Saldo — der Typ schlägt das Vorzeichen); reserve ist ein sichtbarer, aber ausgeschlossener Topf. Nur free_cash-Konten mit nicht-negativem Saldo gehen in das verfügbare Cash der Bewertung und ihre cash_quote ein. Ein unbekannter Wert wird mit 422 Unprocessable Entity abgelehnt. Ein name, den ein anderes Geldkonto im Portfolio als Namen oder als einen seiner früheren Namen trägt, antwortet 422 mit errors.name, weil ein Import, der ihn nennt, schon auf jenes Konto bucht (ADR-0050 §4).
  • Jede Nutzlast eines Geld- oder Wertpapierkontos trägt former_names (ADR-0050 §4), eine Liste von Strings: die Namen, unter denen das Konto bekannt war. Der Portfolio-Performance-Import löst den Kontonamen einer Datei zuerst über den aktuellen Namen auf, dann über die früheren Namen, sodass eine Zeile, die einen früheren Namen nennt, auf dieses Konto bucht. Zwei Konten einer Art in einem Portfolio teilen nie einen aktuellen oder früheren Namen; Namen, die zwei Konten schon vor dieser Regel teilten, lösen auf keines der beiden auf, und der Import wartet, bis der Betreiber eines wählt. Ein Name wird ohne führende und abschließende Leerzeichen gespeichert, so wie der Import ihn liest; ein Name, der sich von dem eines anderen Kontos nur durch sie unterscheidet, ist dieser Name.
  • GET /api/v1/cash_accounts/:id liefert ein Geldkonto. Ein Konto, das eine Zusammenführung entfernt hat, antwortet 404 mit errors.merged_into {"kind": "cash_account", "id": …}, dem Konto, auf dem seine Historie jetzt liegt, über jede spätere Zusammenführung verfolgt (ADR-0050 §12).
  • PATCH /api/v1/cash_accounts/:id aktualisiert ein Geldkonto (name, currency_code, notes, liquidity_role); portfolio_id kann nicht geändert werden. Der currency_code friert ein, sobald eine Transaktion über eines ihrer beiden Konten auf das Konto verweist oder ein Wertpapierkonto es verknüpft (ADR-0050 §11): Eine Änderung liefert dann 422 mit errors.currency_code, das die Verweise zählt, etwa ["is frozen once referenced (1 securities account, 12 transactions)"], und nichts wird geschrieben — gebuchte Historie wird nie umdenominiert. Eine Umbenennung behält den bisherigen Namen in former_names, und die Rückbenennung auf einen früheren Namen verbraucht ihn. Solange ein anderes Geldkonto im Portfolio den bisherigen Namen noch als aktuellen Namen trägt, wird der bisherige Name nicht behalten: Ein Import, der ihn nennt, bucht auf jenes andere Konto (jenes Konto zusammenführen oder umbenennen, um das zu ändern). Ein neuer Name, den ein anderes Geldkonto als aktuellen oder früheren Namen trägt, antwortet 422 mit errors.name.
  • DELETE /api/v1/cash_accounts/:id/former_names?name= entfernt einen früheren Namen, journalisiert, und antwortet mit dem Konto (portfolixir.cash_accounts.remove_former_name). Ein Import, der ‘' noch nennt, legt dann ein neues Konto an. Ein Name, den das Konto nicht trägt, antwortet `404`, ein fehlender `name` `422`. Ein Name, der vor der Ablehnung unsichtbarer Zeichen gespeichert wurde, darf in der Schreibweise angegeben werden, in der der MCP-Begleitdienst ihn auflistet, jedes solche Zeichen als `[U+XXXX]`; ein gespeicherter Name aus genau diesen Buchstaben wird zuerst getroffen (E25).
  • DELETE /api/v1/cash_accounts/:id löscht ein Geldkonto, auf das keine Transaktion über eines ihrer beiden Konten verweist und das kein Wertpapierkonto verknüpft. Sonst liefert es 409 Conflict mit errors.referenced_by (etwa {"transactions": 12, "securities_accounts": 1}), errors.remedy merge und errors.remedy_route, der Zusammenführungs-Vorschau GET /api/v1/cash_accounts/:id/merge_preview?target_id=, ergänzt um die ID des Kontos, das bleibt: Eine Zusammenführung verschiebt die Historie, ein Löschen verwirft sie nie. Die Bucket-Verknüpfungen eines unreferenzierten Kontos werden vorher entfernt, journalisiert (ADR-0050 §11). Ein Löschen, das einen Wettlauf mit einem gleichzeitigen Schreibvorgang verliert und danach keinen Verweis mehr findet, antwortet mit 409 Conflict und allein errors.detail und löscht nichts: den Datensatz neu lesen und erneut versuchen (#954).
  • GET /api/v1/cash_accounts/:id/merge_preview?target_id= zeigt die Zusammenführung des Kontos (der Quelle) in target_id (das Ziel, das Konto, das bleibt) als Vorschau — ein Lesen, das nichts schreibt (ADR-0050 §7, §10; portfolixir.cash_accounts.merge_preview). Beide Konten müssen Portfolio, Währung, Liquiditätsrolle und Bucket-Menge teilen; sonst antwortet sie 409 Conflict mit errors.code (same_account, not_live, portfolio_mismatch, currency_mismatch, liquidity_role_mismatch, buckets_mismatch, legacy_hashed_anchor für einen gesetzten Saldo, der noch einen Import-Hash aus der Zeit vor der Import-Hash-Artprüfung trägt und angepasst oder verschoben werden müsste, oder unstorable_anchor für einen gesetzten Saldo, dessen angepasster Betrag mehr als die 6 Nachkommastellen der Betragsspalte bräuchte — der Saldo des anderen Kontos trägt den Bruchteil eines Kaufs, der ohne Betrag gebucht wurde; erst diesen Betrag erfassen, dann die Vorschau erneut abrufen), errors.detail und errors.guards; bei den letzten beiden nennt errors.anchors jeden gesetzten Saldo (id, date, cash_account_id), und bei unstorable_anchor nennt errors.bookings die Käufe, die ohne Betrag gebucht wurden. Eine unbekannte Quelle antwortet 404, eine bereits zusammengeführte 409 already_merged mit errors.merged_into, eine fehlende target_id 422. Die 200-Antwort trägt:
    • plan_digest, den Digest, den die Zusammenführung erwartet;
    • source und target, jeweils mit balance (die Faltung aller Buchungen, wie GET /api/v1/cash_accounts sie meldet), transaction_count, bucket_ids und former_names; guards; linked_depots (die der Quelle, die zum Ziel wechseln);
    • internal_transfers: die Umbuchungen zwischen beiden, die die Zusammenführung löscht — beide Seiten werden ein Konto;
    • key_equal_pairs: eine Buchung der Quelle, deren Tag, Art und Beträge denen einer Buchung des Ziels gleichen, eins zu eins gepaart, kleinste ID zuerst, und choice_required, sobald es eine gibt;
    • former_names: die Namen, die das Ziel hinzugewinnt (appended), die, die schon ein anderes Konto trägt (not_kept, mit held_by), und die Liste des Ziels danach (after);
    • outcome_by_collapse_key_equal mit "false" (beide Buchungen eines Paars behalten) und "true" (die der Quelle löschen): balance und transaction_count des Ziels danach, moved_transaction_ids, deleted (jeweils mit reason: internal_transfer, collapsed_duplicate oder folded_anchor), restated_anchors (jeder gesetzte Saldo, der danach auf dem Ziel steht, als stated + other_balance = after: der Saldo des anderen Kontos am Ende dieses Tages, aus seinen Buchungen vor der Zusammenführung; an einem Tag mit gesetzten Salden auf beiden Konten trägt der letzte des Ziels beide, die übrigen entfallen), flow_changes (die externen Flüsse, die das Entfernen der Duplikate streicht — kind removed — oder in einen späteren gesetzten Saldo des Kontos verschiebt, auf dem die Buchung stand — kind absorbed, der Quelle selbst oder bei einer entfernten Umbuchung des dritten Kontos —, je mit cash_account_id, der transaction_id des Saldos oder der Buchung, date, change und collapsed_transaction_id), other_accounts und positions (was eine entfernte Umbuchung oder ein entfernter Kauf anderswo ändert);
    • balance_basis, die Rechengrundlage der Salden und der angepassten gesetzten Salden: Ein Saldo ist die Faltung jeder Buchung des Kontos, wie GET /api/v1/cash_accounts ihn meldet; ein angepasster gesetzter Saldo ist sein genannter Betrag plus der Saldo des anderen Kontos am Ende dieses Tages, aus dessen Buchungen vor der Zusammenführung;
    • reimport_note, was die Zusammenführung für den nächsten Portfolio-Performance-Import bedeutet: Die Namen der Quelle führen dorthin (außer einem, den ein anderes Konto noch trägt, former_names.not_kept), und ein erneut angewendeter Export legt nichts an.

    Jeder Dezimalwert ist ein String. Der Digest umfasst beide Konten, jede Buchung, auf die eines verweist, mit ihrem updated_at, jede Zahl und die Prüfungen; die Wahl gehört nicht dazu, ein Paar hat also einen Digest.

  • POST /api/v1/cash_accounts/:id/merge mit {"target_id": …, "plan_digest": …, "collapse_key_equal": …} führt unter dem Token zusammen (portfolixir.cash_accounts.merge). collapse_key_equal ist Pflicht, sobald die Vorschau key_equal_pairs aufführt — ohne antwortet sie 422 und nennt, wie viele —, und nie vorbelegt: den Operator fragen. Die Antwort ist 201 Created mit dem Zusammenführungsprotokoll (id, kind, source_id, target_id, portfolio_id, source_snapshot, manifest — jede verschobene, angepasste oder gelöschte Buchung, die umgehängten Depots, die entfernten Bucket-Verknüpfungen, die angehängten Namen, die Wahl, null, wo die Vorschau keine key_equal_pairs nannte —, plan_digest, actor_type, actor_label, inserted_at) und already_applied: false. In einer Transaktion, ein Audit-Journal-Eintrag je Zeile: Die Umbuchungen zwischen beiden und, mit true, die gepaarten Buchungen der Quelle werden gelöscht, ihre Inhalts-Hashes stillgelegt; jeder gesetzte Saldo wird angepasst, wie die Vorschau es sagte; jede andere Buchung der Quelle und ihre verknüpften Depots wechseln zum Ziel; der zusammengeführte Saldo wird an jedem Tag, an dem eines der Konten eine Buchung hat, und heute gegen die Summe beider Konten geprüft (sonst rollt 409 identity_check_failed die Zusammenführung zurück — eine Prüfung auf einen Fehler, nie eine erwartete Antwort); die Bucket-Verknüpfungen der Quelle werden entfernt und die Quelle gelöscht; ihr Name und ihre früheren Namen werden frühere Namen des Ziels, außer einem Namen, den ein anderes Geldkonto noch als Namen oder früheren Namen trägt: Er wird nicht übernommen (former_names.not_kept in der Vorschau) und führt einen Import weiter zu jenem Konto. Hat sich seit der Vorschau eine Buchung, eine Zahl oder eine Prüfung geändert, antwortet sie 409 mit errors.code plan_changed und der frischen Vorschau in errors.preview und schreibt nichts. Eine Wiederholung einer abgeschlossenen Zusammenführung desselben Paars antwortet 200 mit dem ursprünglichen Protokoll und already_applied: true und journalisiert nichts; eine Quelle, die schon in ein anderes Konto zusammengeführt wurde, antwortet 409 already_merged mit errors.merged_into. Ein fehlender plan_digest oder eine fehlende target_id oder ein collapse_key_equal, der kein Boolean ist, antwortet 422. Ein Rückgängigmachen gibt es nicht: Protokoll und die Vorher-Bilder des Journals rekonstruieren, was eine Zusammenführung getan hat. Der Operator führt über das Zeilenmenü auf Konten & Depots zusammen (Zusammenführen in…), das diese Vorschau zeigt und sie mit ihrem Digest anwendet.
  • GET /api/v1/securities_accounts listet Depots/Wertpapierkonten.
  • POST /api/v1/securities_accounts legt ein Depot/Wertpapierkonto mit einem securities_account-Objekt an. portfolio_id ist optional (ADR-0024): fehlt sie, wird das Depot an das deterministische interne Standard-Portfolio gebunden. Ein name, den ein anderes Depot im Portfolio als Namen oder als einen seiner früheren Namen trägt, antwortet 422 mit errors.name (ADR-0050 §4).
  • GET /api/v1/securities_accounts/:id liefert ein Wertpapierkonto. Ein Depot, das eine Zusammenführung entfernt hat, antwortet 404 mit errors.merged_into {"kind": "securities_account", "id": …}, über jede spätere Zusammenführung verfolgt (ADR-0050 §12).
  • PATCH /api/v1/securities_accounts/:id aktualisiert ein Wertpapierkonto (name, notes, cash_account_id); portfolio_id kann nicht geändert werden. Eine Umbenennung behält den bisherigen Namen in former_names nach denselben Regeln wie bei einem Geldkonto: Die Rückbenennung verbraucht ihn, ein bisheriger Name, den ein anderes Depot noch als aktuellen Namen trägt, wird nicht behalten, und ein Name, den ein anderes Depot als aktuellen oder früheren Namen trägt, antwortet 422 mit errors.name.
  • DELETE /api/v1/securities_accounts/:id/former_names?name= entfernt einen früheren Namen eines Depots, journalisiert, und antwortet mit dem Depot (portfolixir.securities_accounts.remove_former_name). Ein Import, der ‘' noch nennt, legt dann ein neues Depot an. Der Name wird wie beim Geldkonto getroffen, die Schreibweise `[U+XXXX]` eingeschlossen.
  • DELETE /api/v1/securities_accounts/:id löscht ein Wertpapierkonto, auf das keine Transaktion über eines ihrer beiden Konten verweist. Sonst liefert es 409 Conflict mit errors.referenced_by, errors.remedy merge und errors.remedy_route, der Zusammenführungs-Vorschau GET /api/v1/securities_accounts/:id/merge_preview?target_id=. Die Standard-Buckets und Positions-Overrides eines unreferenzierten Depots werden vorher entfernt, journalisiert: ein Eintrag für die Standardmenge, einer je Position. Ein Löschen, das einen Wettlauf mit einem gleichzeitigen Schreibvorgang verliert und danach keinen Verweis mehr findet, antwortet mit 409 Conflict und allein errors.detail und löscht nichts: den Datensatz neu lesen und erneut versuchen (#954).
  • GET /api/v1/securities_accounts/:id/merge_preview?target_id= zeigt die Vorschau, das Depot (die Quelle) in target_id (das Ziel, das Depot, das bleibt) zusammenzuführen — ein Lesen, das nichts schreibt (ADR-0050 §7, §10; portfolixir.securities_accounts.merge_preview). Beide Depots müssen im selben Portfolio liegen und dieselbe Standard-Bucket-Menge haben, und jede Position muss ihre Ansichts-Zugehörigkeit behalten: Für jedes Wertpapier, das die Quelle hält, müssen, wo auch das Ziel es hält, beide wirksamen Bucket-Mengen gleich sein; wo das Ziel es nicht hält, wird die Menge der Quelle übertragen. Sonst antwortet sie 409 Conflict mit errors.code (same_account, not_live, portfolio_mismatch, buckets_mismatch oder position_buckets_mismatch, dessen errors.detail jede Position und beide Bucket-Mengen nennt — oder einen zu übertragenden Override mit mehr als einem Scope-Bucket, den die Position im Ziel nicht aufnehmen kann), errors.detail und errors.guards. Eine unbekannte Quelle antwortet 404, eine schon zusammengeführte Quelle 409 already_merged mit errors.merged_into, eine fehlende target_id 422. Die 200 enthält:
    • plan_digest, den Digest, den die Zusammenführung nimmt;
    • source und target, je mit cash_account_id, bucket_ids (der Standardmenge), former_names und transaction_count; guards;
    • internal_transfers: die Wertpapierumbuchungen zwischen beiden, die die Zusammenführung löscht — beide Seiten werden ein Depot;
    • key_equal_pairs: eine Buchung der Quelle, deren Tag, Art, Wertpapier, Verrechnungskonto und Beträge denen einer Buchung des Ziels gleichen, eins zu eins gepaart, niedrigste ID zuerst, jede mit beiden Depot-Seiten (securities_account_id, counter_securities_account_id), und choice_required, wenn es eine gibt;
    • position_buckets: je Wertpapier, das die Quelle hält oder für das sie einen Override trägt, beide wirksamen Bucket-Mengen, beide Overrides (null für eine Position, die die Standardmenge ihres Depots erbt, [] für einen bewusst leeren) und die action: carry (der Override der Quelle geht auf das Ziel über), drop_redundant (das Ziel zeigt die Position schon in denselben Buckets), drop_unheld (die Quelle hält keine Buchung davon), clear_target (der Override des Ziels für ein Wertpapier, das es nicht hält, wird entfernt, damit die verschobenen Buchungen die Standardmenge behalten) oder none;
    • former_names: appended, not_kept und die Liste after des Ziels;
    • outcome_by_collapse_key_equal mit "false" und "true": die transaction_count des Ziels danach, moved_transaction_ids, deleted (je mit reason: internal_transfer oder collapsed_duplicate), positions (jedes Wertpapier, das die Quelle hält, je mit source, target und after, je quantity, cost_basis, avg_cost und realized_result; target ist null, wo das Ziel keine Buchung davon hält), rounding_differences, cash_accounts (jedes Verrechnungskonto, das eine entfernte gleiche Buchung ändert, mit balance_before und balance_after) und flow_changes (jeder Fluss, den eine entfernte Buchung in einen späteren gesetzten Saldo ihres Verrechnungskontos verschiebt, kind absorbed, mit cash_account_id, der transaction_id des Saldos, date, change und collapsed_transaction_id, wie in der Vorschau einer Geldkonto-Zusammenführung) und other_depots (jedes dritte Depot, das eine entfernte Umbuchung nennt, je Wertpapier mit securities_account_name, security_name, quantity_before und quantity_after: Das Entfernen einer Umbuchung ändert auch seinen Bestand);
    • positions_basis, die Rechengrundlage dieser Zahlen: Die Stückzahl ist die Positionsfaltung, in der jeder Split die Position einmal skaliert, gerundet auf die Stückzahl-Genauigkeit 6 (ADR-0028 §3); cost_basis und avg_cost sind der gleitende Durchschnitt der Kosten, den GET /api/v1/portfolios/:id/holdings nennt, in der Währung des Wertpapiers, ohne Gebühren und Steuern — nach der Zusammenführung bilden die Käufe beider Depots einen gemeinsamen Durchschnitt, die Kosten ändern sich also zu Recht, und ein Verkauf, den das Ziel zwischen zwei Käufen getätigt hat, verbraucht danach den gemeinsamen Durchschnitt; realized_result ist über die Verkäufe der Position die Stückzahl jedes Verkaufs mal sein Kurs in der Währung des Wertpapiers, abzüglich der Kosten, die er zum laufenden Durchschnitt entnommen hat, und null, wo dieser Kurs oder diese Kosten nicht ableitbar sind; rounding_differences nennt je Position und Split-Datum jeden Split eines betroffenen Wertpapiers, bei dem die gemeinsam einmal gerundete Position am Ende des Split-Tags von den zwei getrennt gerundeten abweicht, mit beiden Stückzahlen und ihrer Differenz — erwartet und nie eine Ablehnung, und an keine Schranke je Split gebunden: Wo Splits aufeinander aufbauen, skaliert der nächste die Differenz, die einer hinterlässt (ADR-0050, Ergänzung vom 2026-10-08);
    • reimport_note, was die Zusammenführung für den nächsten Portfolio-Performance-Import bedeutet, wie bei einem Geldkonto.

    Jede Stückzahl und jede Dezimalzahl ist ein String. Der Digest deckt beide Depots ab, jede Buchung, die eines von beiden nennt, und die Splits des Portfolios für deren Wertpapiere mit ihrem updated_at, jede Zahl (den Bucket-Plan eingeschlossen) und die Prüfungen; die Wahl gehört nicht dazu, ein Paar hat also einen Digest.

  • POST /api/v1/securities_accounts/:id/merge mit {"target_id": …, "plan_digest": …, "collapse_key_equal": …} führt unter dem Token zusammen (portfolixir.securities_accounts.merge). collapse_key_equal ist Pflicht, wenn die Vorschau key_equal_pairs nennt — ohne antwortet sie 422 mit ihrer Anzahl — und ist nie vorausgewählt: Frag den Operator. Sie antwortet 201 Created mit dem Protokoll der Zusammenführung (wie bei einem Geldkonto; sein manifest nennt jede verschobene oder gelöschte Buchung, die Overrides carried, dropped und cleared, die entfernten Standard-Buckets, die angehängten Namen, die Rundungsdifferenzen und die Wahl, null, wo die Vorschau keine key_equal_pairs nannte) und already_applied: false. In einer Transaktion, ein Audit-Journal-Eintrag je Zeile: Die Umbuchungen zwischen beiden und, mit true, die gepaarten Buchungen der Quelle werden gelöscht und ihre Inhalts-Hashes stillgelegt; jede andere Buchung der Quelle geht auf das Ziel über, auf der Depot-Seite, die die Quelle nennt, und behält ihr Verrechnungskonto — das Ziel behält sein eigenes verknüpftes Verrechnungskonto, das der Quelle bleibt als eigenes Konto bestehen; die Stückzahl jedes Wertpapiers im Ziel wird an jedem Tag, an dem eines der beiden eine Buchung hat, an jedem Split-Tag und heute gegen die Buchungen beider Depots geprüft (409 identity_check_failed rollt die Zusammenführung sonst zurück — eine Prüfung auf einen Fehler, nie eine erwartete Antwort); der Bucket-Plan wird geschrieben, ein Journal-Eintrag je Position; die Standard-Buckets der Quelle werden entfernt und die Quelle gelöscht; ihr Name und ihre früheren Namen werden frühere Namen des Ziels, außer einem Namen, den ein anderes Depot noch trägt (former_names.not_kept). Ein geänderter Plan antwortet 409 plan_changed mit der frischen Vorschau in errors.preview; eine Wiederholung einer abgeschlossenen Zusammenführung desselben Paars antwortet 200 mit dem ursprünglichen Protokoll und already_applied: true; eine in ein anderes Depot zusammengeführte Quelle 409 already_merged; ein fehlender plan_digest oder eine fehlende target_id oder ein collapse_key_equal, der kein Boolean ist, 422. Ein Rückgängigmachen gibt es nicht. Der Operator führt über das Menü der Depotzeile auf Konten & Depots zusammen (Zusammenführen in…), mit derselben Vorschau.
  • GET /api/v1/merges listet die Zusammenführungsprotokolle, das neueste zuerst (inserted_at, dann id) — der Audit-Lesezugriff auf einen zerstörenden Schreibvorgang (ADR-0050 §12; portfolixir.merges.list). Jedes Protokoll trägt id, kind (cash_account, securities_account oder security), source {id, name} (der Name, den die Zusammenführung festgehalten hat, weil die Quelle nicht mehr existiert), target {id, name, merged_into} (merged_into ist null, solange das Ziel existiert, sonst die id, in die eine spätere Zusammenführung es überführt hat, bis zum lebenden Ende verfolgt), portfolio_id (null für ein Wertpapier), actor_type, actor_label (bei einem API-Token der Name des Token-Eintrags, der sie ausgeführt hat, wie im Journal), inserted_at und manifest_summary: das manifest des Protokolls, in dem jede Liste durch ihre Anzahl ersetzt ist — verschobene, angepasste und gelöschte Buchungen, angehängte Namen, verschobene und verworfene Kurse —, neben transactions.deleted (weiterhin jede Buchung, die die Zusammenführung entfernt hat) transactions.deleted_by_reason, dieselben Zeilen je Grund gezählt (internal_transfer, collapsed_duplicate, folded_anchor, collapsed_split; nur die vorkommenden Gründe, {} wenn keine), und die Wahl des Operators, wie gegeben — collapse_key_equal nur, wo die Zusammenführung gleiche Buchungspaare hatte, bei einer Wertpapier-Zusammenführung identity_choice und isin_changed_on nur, wo sie diese Wahl getroffen hat (ein Protokoll einer früheren Version kann sie tragen, wie sie geschickt wurden). meta trägt order, count und limit. limit folgt der Listen-Familie: Standard 100, gedeckelt bei 1000, und null, eine negative Zahl oder keine Zahl antwortet 422. Der Operator liest dieselben Protokolle mit denselben Zahlen unter Zusammenführungen am Ende von „Konten & Depots“ (Sprint 17; das Datum in der Zeile „zusammengeführt aus“ des Überlebenden führt zu seinem Eintrag).

Beispiel-Payloads für Konten:

{
  "portfolio": {
    "name": "Household Portfolio",
    "base_currency_code": "EUR"
  }
}
{
  "cash_account": {
    "portfolio_id": 1,
    "name": "Settlement EUR",
    "currency_code": "EUR"
  }
}
{
  "securities_account": {
    "portfolio_id": 1,
    "cash_account_id": 1,
    "name": "Main Depot"
  }
}

Transaktionen und Bestände

  • GET /api/v1/transactions listet Transaktionen. Optionale Filter: from/to (ISO-Daten, inklusive), portfolio_id, security_id, securities_account_id. Ungültige Filter liefern 422 Unprocessable Entity mit dem betreffenden Feld. Ein optionales fields= (FR-37, kommagetrennt) wählt eine schlanke Feldauswahl: Jede Zeile trägt dann genau die angefragten Felder. Die Namen werden gegen die Feldliste des Serializers validiert — ein unbekannter Name ist ein 422, nie ein stiller Fallback. Die menschliche Sicht von fields= (Issue #732) ist die Spaltenwahl auf der Transaktionshistorie und dem Bestände-Panel: dieselben Projektionen, Spalte für Spalte auf der Seite gewählt — und die Wertpapierliste, deren Spaltenwahl älter als FR-37 ist, bekam die Gegenrichtung als eigenes fields= (siehe Wertpapiere). Ein optionales running_balance_for=<cash_account_id> ergänzt jede Zeile um einen running_balance — den Saldo dieses Verrechnungskontos nach der Buchung, als Decimal-String in der Kontowährung — plus ein running_balance_basis auf oberster Ebene, das Konto, Währung und Berechnungsgrundlage benennt. Zwei Eigenschaften, von denen die Zahl abhängt: Der Lauf umfasst immer die gesamte Historie des Kontos, eine eingeschränkte Abfrage (ein from, ein Filter) zeigt also echte Salden statt einer Teilsumme; und eine Zeile, die das Konto nicht bewegt, trägt null statt den vorherigen Wert zu wiederholen, was sich wie “hier ist nichts passiert” läse. Ein unbekanntes oder nicht-numerisches Konto ist ein 422 mit dem Feld running_balance_for. Das ist das API- und MCP-Gegenstück zur Saldospalte auf der Transaktionsseite. limit behält die neuesten Zeilen (Standard 10000, max. 50000; null, negativ oder nicht numerisch ist ein 422).
  • POST /api/v1/transactions legt eine Transaktion beliebiger buchbarer Art mit einem transaction-Objekt an (die pro Buchungsart erforderlichen Felder werden serverseitig validiert). Die buchbaren type-Werte sind buy, sell, dividend, interest, deposit, removal, fee, tax, tax_refund, cash_transfer, inbound_delivery, outbound_delivery und security_transfer (balance_adjustment wird über den dedizierten Kontostand-Snapshot-Endpunkt geschrieben, split über die Split-Routen weiter unten). Buchungssemantik, die man vor dem ersten Schreiben kennen sollte: gross_amount einer Dividende ist der NETTO-Geldzufluss auf dem Konto — einbehaltene Steuern gehören in taxes, der Einnahmenbericht rekonstruiert brutto als netto plus einbehaltene Steuer. Eine ohne price erfasste Einlieferung (inbound_delivery) geht mit Einstand null in die Kostenbasis ein — der Anschaffungskurs sollte mitgegeben werden, wenn er bekannt ist; eine Auslieferung (outbound_delivery) entnimmt den Einstand zum laufenden Durchschnitt, ihr Kurs ist rein informativ. Bei einer Abgleichdifferenz sollte die fehlende Buchung der richtigen Art nachgetragen werden — Kontostand-Snapshots und unbepreiste Einlieferungen sind letzte Mittel, die Zahlen richtig aussehen lassen und dabei den Einstand verzerren. Beträge sind positive Größen — die Buchungsart bestimmt die Richtung; nur balance_adjustment darf einen negativen (absoluten) Betrag tragen. Eine Steuererstattung — etwa die bei einem Verlustverkauf gutgeschriebene Steuer — ist deshalb nie ein negativer taxes-Wert auf dem Verkauf: Der Verkauf wird mit den tatsächlich einbehaltenen Steuern (oder 0) gebucht, dazu eine separate tax_refund-Transaktion, deren positiver gross_amount der dem Konto gutgeschriebene Betrag ist (cash_account_id und gross_amount sind ihre Pflichtfelder; das Changeset lehnt ein negatives taxes mit genau diesem Hinweis ab). Ein Wertpapier, das über ein Geldkonto in einer anderen Währung abgerechnet wird (zum Beispiel ein USD-Wertpapier über ein EUR-Konto), wird in der eigenen Währung des Wertpapiers gebucht und trägt die Felder zur währungsübergreifenden Abrechnung: security_amount (Handelsbetrag in der Wertpapierwährung), settlement_amount (der Handelsbetrag in der Kontowährung, vor Gebühren und Steuern, die ebenfalls in der Kontowährung erfasst werden) und settlement_fx_rate (Einheiten der Kontowährung je einer Einheit der Wertpapierwährung). Fehlt der Kurs, werden jedoch beide Beträge geliefert, wird er als settlement_amount / security_amount abgeleitet (der tatsächliche Kurs des Brokers); eine Währungsabweichung ohne Kurs und ohne Beträge zur Ableitung wird abgelehnt. Die Einstandsbasis bleibt in der Wertpapierwährung, sodass die positionsbezogene G/V währungsehrlich ist. Alle drei sind Decimal-Strings und bei Buchungen in gleicher Währung null. Geldbetrag und Abrechnung müssen übereinstimmen (#395): Das gross_amount eines währungsübergreifenden Kaufs (der gezahlte Betrag, einschließlich Gebühren und Steuern) muss settlement_amount + fees + taxes sein, das eines Verkaufs (der erhaltene Betrag) settlement_amount - fees - taxes, auf 0,01 genau bei voller Genauigkeit verglichen; sonst antwortet der Schreibzugriff mit 422 und einem gross_amount-Fehler, der den abgeleiteten Betrag nennt. Ohne gross_amount bucht das Hauptbuch quantity × price (bei einem Kauf zuzüglich, bei einem Verkauf abzüglich Gebühren und Steuern) als Geldbetrag, und genau das wird verglichen: Ein Handel, der in der Währung des Wertpapiers bepreist und ohne Geldbetrag gesendet wird, antwortet mit 422 auf gross_amount und nennt den Betrag, der gebucht würde, und den, den die Abrechnung ergibt — sende den Betrag, den der Broker abgerechnet hat. Die Prüfung läuft beim Anlegen und bei einem PATCH, das gross_amount, settlement_amount, fees, taxes oder type ändert, oder bei einer Buchung ohne gross_amount ihre quantity oder ihren price — ein PATCH der Notiz oder des Datums einer älteren Buchung wird deswegen nie abgelehnt.
  • GET /api/v1/transactions/:id liefert eine Transaktion.
  • PATCH /api/v1/transactions/:id aktualisiert eine Transaktion (z. B. um eine falsch importierte Buchung zu korrigieren); die Validierung je Art gilt weiter. Eine importierte Zeile behält ihren Inhalts-Hash, und ein Saldo-Snapshot oder ein Split trägt nie einen (ADR-0050 §1): Wird der type einer importierten Zeile auf balance_adjustment oder split geändert, antwortet die API mit 422 auf errors.type. Eine gespeicherte Split-Zeile ändert nur ihre notes (E25 S6): Eine Änderung an date, security_id, portfolio_id, type oder am Verhältnis antwortet mit 422 und nennt das Feld, denn ein Split wird über POST /api/v1/splits gebucht, dessen Prüfungen eine allgemeine Änderung umgehen würde. Ein falscher Split wird als Ganzes gelöscht (DELETE /api/v1/splits/:transaction_id, unten) und neu gebucht.
  • DELETE /api/v1/transactions/:id löscht eine Transaktion. Da Trades und Bestände abgeleitet sind, korrigiert oder entfernt das Korrigieren oder Entfernen der Transaktion auch sie. Mit einer importierten Buchung geht ihr Inhalts-Hash, ein erneuter Import derselben Datei bucht sie also wieder (einen Hash legt nur eine Zusammenführung still, ADR-0050 §3). An einer Split-Zeile löscht der Aufruf nur diese Zeile; die übrigen Zeilen des Splits halten das Ereignis.
  • POST /api/v1/splits/preview zeigt eine Aktiensplit-Buchung (ADR-0028) als Vorschau, ohne etwas zu schreiben. Die Anfrage trägt security_id, das Wirksamkeitsdatum date (ISO, nicht in der Zukunft) und das Verhältnis als Paar positiver Ganzzahlen ratio_numerator/ratio_denominator (10:1 vorwärts, 1:10 Reverse-Split; auf kleinste Terme normalisiert, 10:5 wird also als 2:1 gebucht). Die Antwort zeigt je Portfolio mit Bestand die Stückzahl unmittelbar vor und nach dem Wirksamkeitsdatum sowie den resultierenden aktuellen Bestand (alles Decimal-Strings; die Verhältnis-Teile bleiben Ganzzahlen), plus warnings: effective_date_before_history bedeutet, dass das Wirksamkeitsdatum vor der frühesten erfassten Transaktion des Wertpapiers liegt — die gespeicherten Stückzahlen können bereits post-split sein (der Split-Assistent von Portfolio Performance schreibt die Historie destruktiv um), eine Buchung würde dann doppelt anpassen. Vor dem Buchen die Vorschau prüfen. Die Vorschau zeigt außerdem die gespeicherten Schlusskurse rund um das Wirksamkeitsdatum (quotes_around) und einen quote_basis_check (Fehlklassifikations-Wächter, ADR-0028 §2): ein sichtbarer Sprung deutet auf eine Roh-Serie hin, Kontinuität auf eine bereits angepasste; steht das im Widerspruch zur Klassifikation über die source der Zeilen, warnt die Vorschau mit quote_basis_contradiction (für Sync-Serien, die nie rückwirkend anpassen, das Flag treat_quotes_as_raw des Wertpapiers setzen statt blind zu buchen), und bei zu wenigen Kursen auf einer Seite meldet sie insufficient_quotes_to_verify_basis, statt eine saubere Prüfung zu suggerieren.
  • POST /api/v1/splits bucht den Split: ein Aufruf fächert das Ereignis über alle Portfolios mit Bestand am Wirksamkeitsdatum auf — eine journalisierte split-Zeile je Portfolio, atomar eingefügt — und liefert die erzeugten Transaktionen (201, reguläres Transaktionsformat). Ein Portfolio ohne Bestand am Wirksamkeitsdatum erhält keine Zeile. Ein zweiter Split am selben Tag für dasselbe Wertpapier wird mit 422 an date abgelehnt und benennt das bestehende Ereignis, seine Transaktion und sein Portfolio mit ihren ids (ein wiederholter Timeout kann den multiplikativen Effekt nicht verdoppeln); ein Datum in der Zukunft und ein Wertpapier ohne Bestand am Wirksamkeitsdatum werden ebenfalls mit 422 abgelehnt. Die Splits eines Wertpapiers, jeder mit seinem eigenen Betrag gezählt (2:1 und 1:2 zählen beide 2), dürfen sich einschließlich des neuen auf höchstens 10^12 multiplizieren; ein Verhältnis darüber liefert bei Vorschau und Buchung 422 an ratio, und nichts wird geschrieben (E25 S4). Der generische Endpunkt POST /api/v1/transactions lehnt die Art split ab — nur diese beiden Routen buchen einen Split.
  • DELETE /api/v1/splits/:transaction_id löscht einen Split so, wie er gebucht wurde, als eine Tatsache (Sprint 18 U1, #912): von einer beliebigen seiner Zeilen aus jede split-Zeile mit demselben Wertpapier, Datum und gekürzten Verhältnis — eine je Portfolio — in einer Transaktion, jede Zeile mit ihrem Vorher-Bild journalisiert. Die Antwort ist 200 mit data.transactions, den gelöschten Zeilen im regulären Transaktionsformat, nach Portfolio geordnet. Danach trägt kein Portfolio das Ereignis mehr: Bestände zählen ohne ihn, die Kurs-Lesepfade bereinigen nicht mehr um ihn, und POST /api/v1/splits bucht das korrigierte Verhältnis am selben Tag. Eine unbekannte oder schon gelöschte Zeile liefert 404; eine Buchung anderer Art liefert 422 an transaction_id und nennt DELETE /api/v1/transactions/:id; scheitert eine Zeile, wird nichts gelöscht. Die übrigen Splits des Wertpapiers bleiben.
  • GET /api/v1/portfolios/:portfolio_id/holdings listet abgeleitete Bestände eines Portfolios, eine Zeile je (Depot, Wertpapier). Jede Zeile trägt quantity, einen gleitenden Durchschnitt avg_cost und cost_basis (preisbasiert, sodass Gebühren und Steuern nicht in die Stückkosten einfließen), den latest_price, market_value und unrealized_pnl_abs/unrealized_pnl_pct gegen diesen Preis, plus security_name und currency_code. Diese Größen sind in der eigenen Währung des Wertpapiers — durch das Kostenpaar der Ledger-Faltung erzwungen (ADR-0033), nicht länger eine Annahme; ein Bestand, dessen Wertpapier keinen Kurs hat, liefert null für Preis, Marktwert und G/V. Jede Zeile trägt zusätzlich die ADR-0033-Zerlegung des G/V in Basiswährung: base_cost (der tatsächlich gezahlte Abrechnungsbetrag, mit seiner base_currency), price_return_abs/price_return_pct (die eigene Kursbewegung des Wertpapiers, zum heutigen Kurs umgerechnet), currency_return_abs/currency_return_pct (der Wechselkurseffekt auf den ursprünglich investierten Betrag) und total_return_base_abs/total_return_base_pct — wobei total = price + currency Decimal-exakt gilt. Eine Zeile, deren Zerlegung nicht ableitbar ist, meldet decomposed: false mit einem undecomposed_reason ("missing_native_cost" — kein Wertpapierwährungs-Leg in der erfassten Buchung, dann sind auch cost_basis/avg_cost/G/V null; "missing_base_cost" — das Abrechnungs-Leg ist nicht in der Basiswährung; "missing_fx" — kein gespeicherter aktueller Kurs; "no_price") und niemals eine geratene Zahl. Die Antwort ist selbstbeschreibend (FR-13): sie trägt currency_basis: "security_currency" plus eine currency_basis_note, die benennt, welches Feld in welcher Währung ist, und ein as_of-Datum. Seit Issue #1142 behält ein Bestand mit cost_basis "0" (Stücke, die ohne Kosten eingeliefert wurden, etwa aus einem Spin-off, oder zu einem Kurs von 0 gekauft) sein unrealized_pnl_abs und liest unrealized_pnl_pct null, nie "0": Eine Rendite auf keine Kosten ist unbestimmt, und 0 läse sich als unverändert. computation_basis.unrealized_pnl_pct der Antwort nennt die Regel. Ebenso liest ein Bestand mit base_cost "0" price_return_pct, currency_return_pct und total_return_base_pct null neben seinen drei Beträgen, und computation_basis.decomposition_pct nennt die Regel. Bestände werden beim Lesen abgeleitet, ohne gespeicherten Snapshot, daher ist as_of das Lesedatum. Unbekannte Portfolios liefern 404 Not Found. Optionale Filter: security_id, securities_account_id. Ein optionales fields= (FR-37, kommagetrennt) wählt eine schlanke Feldauswahl je Zeile, validiert gegen die Feldliste des Serializers; ein unbekannter Name ist ein 422.
  • GET /api/v1/realized_gains (Issue #724) liefert das Realisiert-Rollup des Cash-flow-Bereichs: FIFO-gematchte realisierte G&V über alle Wertpapiere und Portfolios, gruppiert nach dem Schlussdatum jedes Verkaufs in eine Jahres-/Monatsmatrix in der Basiswährung. FX-Basis ist D-1 (signiert 2026-08-20): jeder Verkauf konvertiert über den EUR-Hub zum jüngsten gespeicherten Kurs seines eigenen Schlusstags; ein Verkauf ohne gespeicherten Kurs zu diesem Datum wird aus jeder konvertierten Summe ausgeschlossen und benannt (excluded: count + securities) — nie zum Kurs eines Nachbardatums konvertiert, nie still verworfen. Die Payload trägt computation_basis (Serie, Fenster, Referenz, Lücken) und eine conversion_note; die menschliche Sicht ist /cashflow?tab=realized. limit begrenzt die Jahresmatrix auf ihre neuesten Jahre (Standard 100, max. 1000); computation_basis.window nennt den Schnitt, wenn Jahre wegfielen, und die Antwort nennt das angewandte limit.

    Seit Issue #807 trägt die Payload zusätzlich trades — die abgeschlossenen Rundläufe selbst, neuester Schluss zuerst, je mit security_id, security_name, open_date, close_date, holding_period_days, quantity, basis, proceeds, realized_pnl_abs/_pct in der Währung des Trades und realized_base in der Basiswährung — und summary: realized_total, hit_rate (der Anteil der Trades mit streng positivem Ergebnis; ein Nullergebnis zählt nicht als Treffer) und average_holding_period_days. Die drei Zahlen stammen aus derselben konvertierten Menge wie die Matrix; ohne abgeschlossene Trades sind hit_rate und average_holding_period_days null statt 0. limit schneidet nur die Jahre der Matrix.

    Seit Issue #984 trägt jeder Trade zusätzlich annualized_return: die geldgewichtete Rendite des Rundlaufs pro Jahr, als Bruch ("0.304701" sind 30,47 % im Jahr). Es ist der XIRR-Löser aus ADR-0034 §2 (Act/365) über die eigenen Zahlungen des Trades — der Kauf jedes verbrauchten Lots an seinem Eröffnungstag mit seinen anteiligen Kosten (Stückzahl × Kaufkurs plus Kaufgebühren und -steuern anteilig zur entnommenen Stückzahl) und der Erlös des Verkaufs am Schlusstag — in der Währung des Trades; annualisiert wird also realized_pnl_pct, nicht realized_base. Gerundet auf 6 Stellen, und ein Verlust bleibt über −1. Der Wert ist null mit annualized_return_reason no_cost_basis, wenn basis des Trades "0" ist (seit Issue #1142; dieser Grund kommt zuerst, denn ohne Kosten gibt es nichts zu annualisieren, wie lange der Trade auch gehalten wurde), null mit holding_period_under_365_days, wenn die holding_period_days des Trades unter 365 liegen (ADR-0034 §2 annualisiert kein Fenster unter einem Jahr: 5 % in 14 Tagen wären rund 257 % im Jahr), und null mit dem Grund des Lösers (no_sign_change, darunter ein Totalverlust; no_root; amount_out_of_range), wenn kein Satz die Zahlungen löst. Dividenden und Zinsen während der Haltedauer sind nicht enthalten. computation_basis.annualized_return nennt die Regel.

    Seit Issue #1142 liest ein Trade mit basis "0" — ein Kauf zu einem Kurs von 0 ohne Gebühren oder Steuern, eine Bonus- oder Gratisaktie — realized_pnl_pct null, nie "0": realized_pnl_abs und realized_base nennen das Ergebnis weiter, aber eine Rendite auf keine Kosten ist unbestimmt, und 0 läse sich als unveränderter Trade. computation_basis.realized_pnl_pct nennt die Regel.

    Seit Issue #984 nennt die Payload außerdem die Verkäufe, denen der FIFO-Matcher keinen Kauf zuordnen konnte, in unmatched_sells: ein count und, neueste zuerst, je Verkauf security_id, security_name, date und die quantity, die kein Lot deckte. Stücke aus einer Einlieferung eröffnen keinen Lot, ihr Verkauf ist also kein abgeschlossener Trade; ein Verkauf über die gekauften Stücke hinaus schließt, was er kann, und der Rest steht hier. Beides fehlt in den Kennzahlen, der Liste und der Matrix; computation_basis.unmatched_sells sagt es. Der Trades-Read des Wertpapiers unten führt dieselben Verkäufe als orphan_sells.

    Seit Issue #1108 (die Ergänzung von ADR-0015 vom 07.10.2026) werden die Gebühren und Steuern eines Trades in der Währung seines Verrechnungskontos gelesen, der Währung des Geldbeins, zu dem sie gehören: Ein währungsübergreifender Trade, gebucht in der Wertpapierwährung, rechnet sie zu seinem eigenen gespeicherten settlement_fx_rate (Gebühr ÷ Kurs, Kontoeinheiten je einer Wertpapiereinheit) in diese um, nie zu einem Hub-Kurs, bevor sie in basis und proceeds eingehen. basis, proceeds, realized_pnl_abs und realized_pnl_pct stehen also im currency_code des Trades, und realized_base ist das Geld, das die Runde bewegt hat, wenn Broker- und Hub-Kurs übereinstimmen: 10 Stück, gekauft zu 100 USD für 800,00 EUR mit 6,00 EUR Gebühren und Steuern und verkauft zu 120 USD für 960,00 EUR mit 3,00 EUR Gebühren, lesen basis "1007.5", proceeds "1196.25", realized_pnl_abs "188.75" und realized_base "151". Ein Trade in der Kontowährung (die Form eines Portfolio- Performance-Imports), in einer dritten Währung oder ohne gespeicherten Kurs addiert sie wie erfasst. Ein geschlossener Trade, der ein Lot in einer anderen Währung als sein Verkauf schließt, addiert die Beträge wie gebucht, unumgerechnet, in zwei Währungen unter einem currency_code: Issue #1198, eine offene Entscheidung. computation_basis.fees_and_taxes nennt die Regel und diesen Fall.

  • GET /api/v1/external_flows (Issue #725) liefert das Ein-/Auszahlungs-Rollup: die gebuchten externen Cash-Flüsse (deposit und removal) über alle Portfolios, je Jahr und Monat mit Einzahlungen, Auszahlungen und Netto. Bewusst enger als das invested_capital des Performance-Laufs, das zusätzlich ein-/ausgelieferte Wertpapiere zum Marktwert und Saldo-Snapshot-Residuen zählt — der Unterschied steht in computation_basis.excludes. FX-Basis wie in der Schwester-Facette: EUR-Hub zum Kurs des eigenen Buchungstags, unkonvertierbare Flüsse ausgeschlossen und nach Verrechnungskonto benannt. Jeder Fluss wird in der Währung seines Verrechnungskontos gelesen, nie in der der Buchung: eine in einer anderen Währung mit gespeichertem Abrechnungskurs (ADR-0015) erfasste Einzahlung ist das Geld, das dem Konto gutgeschrieben wurde, und computation_basis.currency nennt die Regel (Issue #1107). Die menschliche Sicht ist /cashflow?tab=flows. limit begrenzt die Jahresmatrix auf ihre neuesten Jahre (Standard 100, max. 1000); computation_basis.window nennt den Schnitt, wenn Jahre wegfielen, und die Antwort nennt das angewandte limit.
  • GET /api/v1/costs (Issue #726) liefert das Kosten-Rollup: Gebühren und Steuern über alle Portfolios, nur auf Übersichtsebene, je Jahr und Monat mit Jahressummen für Gebühren, Steuern und beides zusammen. Die Serie summiert die Gebühren- und Steuer-Nebenbeträge jeder Transaktion plus die eigenständigen fee-/tax-Buchungen; tax_refund wird gegen die Steuern verrechnet. Bruttobeträge werden nie summiert — das Brutto eines Kaufs enthält seine Nebenbeträge, das eines Verkaufs ist um sie gemindert, eine Bruttosumme beschriebe also etwas anderes. Diese Regel steht in computation_basis.series. FX-Basis wie in den Schwester-Facetten: EUR-Hub zum Kurs des eigenen Buchungstags, unkonvertierbare Kosten ausgeschlossen und nach Währung benannt. Seit Issue #1107 wird jeder Kostenbetrag in der Währung seines Verrechnungskontos gelesen, der Währung des Geldbeins, zu dem er gehört, so wie der Performance-Lauf die Handelskosten liest: Die Gebühren und Steuern eines währungsübergreifenden Trades werden aus der Kontowährung umgerechnet, nie aus der Wertpapierwährung, und ein unkonvertierbarer Betrag wird nach der Währung des Verrechnungskontos benannt; eine Buchung ohne Verrechnungskonto wird in ihrer eigenen gelesen. computation_basis.currency nennt die Regel. Die menschliche Sicht ist /cashflow?tab=costs. limit begrenzt die Jahresmatrix auf ihre neuesten Jahre (Standard 100, max. 1000); computation_basis.window nennt den Schnitt, wenn Jahre wegfielen, und die Antwort nennt das angewandte limit.
  • GET /api/v1/holdings/by_security liefert die globale Bewertung je Wertpapier über alle Portfolios hinweg: eine holdings-Zeile je aktuell gehaltenem Wertpapier mit security_id (eine Ganzzahl), Gesamt-quantity und aktuellem market_value, umgerechnet in den EUR-Hub, plus ein valued-Flag. valued ist false (und market_value ist null), wenn das Wertpapier weder einen Kurs noch einen Handelspreis hat oder kein Wechselkurspfad nach EUR existiert, sodass ein fehlender Kurs oder Kurs einen Wert nie stillschweigend verfälscht. Jede Zeile trägt außerdem den aufgelösten nativen latest_price mit price_currency und price_source sowie einen unvalued_reason, der sagt, warum eine Zeile unbewertet ist: "no_price" (nichts auflösbar) oder "missing_fx" (der Preis ist bekannt, aber kein gespeicherter Kurspfad erreicht EUR); null, wenn bewertet. Die Zeilen sind nach security_id sortiert. Die Antwort ist selbstbeschreibend: ein currency auf oberster Ebene mit "EUR", ein as_of-Lesedatum (der Bericht wird beim Lesen abgeleitet, daher ist as_of das heutige Datum, kein gespeicherter Zeitpunkt) und ein note, das die Hub-Umrechnung beschreibt. Dies ist das portfolioübergreifende Gegenstück in Basiswährung zur Bestandsliste eines einzelnen Portfolios (die in der eigenen Währung jedes Wertpapiers ohne FX bleibt); für Summen und Gewichte eines Portfolios nutze stattdessen den Bewertungs-Endpunkt.
  • GET /api/v1/holdings/negative liefert den Datenqualitätsbericht zu negativen Beständen: jede (Depot, Wertpapier)-Position mit abgeleiteter Menge unter null — Import-Altlasten aus nicht modellierten Kapitalmaßnahmen — als rows (mit depot_name, security_name, isin, portfolio_id und der negativen quantity als Decimal-String) plus totals mit der Gesamtmenge jedes gelisteten Wertpapiers über alle Depots, sodass Transfer-Altlasten (negativ in einem Depot, positiv in einem anderen) von einer wirklich negativen Gesamtmenge unterscheidbar sind. Selbstbeschreibend mit as_of-Lesedatum und note. Nichts wird automatisch repariert; korrigiere die Transaktionshistorie des Wertpapiers.
  • POST /api/v1/holdings/reconcile vergleicht eine vom Nutzer gelieferte externe Positionsliste (Brokerauszug oder Depotübersicht, clientseitig in Zeilen geparst) mit den aus dem Ledger abgeleiteten Beständen — strikt lesend: die Liste kommt ausschließlich im Request-Body an, wird nie gespeichert oder geloggt, und es werden keine Daten von irgendwoher geholt (ADR-0029 §6, FR-35). Jede Zeile ist {identifier, quantity} mit optionaler currency und optionalem festnagelndem security_id; quantity muss ein kanonischer Dezimal-String mit Punkt sein (alles andere — Komma-Dezimalzahlen, Tausendertrennzeichen, Exponenten — ist ein 422, das die Zeile benennt; Locale-Parsing ist Aufgabe des Clients), und eine leere rows-Liste ist ein 422. Identifier laufen durch dieselbe Identitätsleiter wie der Import: ein ISIN-förmiger String (Format und Prüfziffer) matcht nur über die ISIN-Stufe (aktuelle ISINs zuerst, dann erfasste frühere ISINs — matched_via: "former_isin"); jeder andere String wird gegen WKN, Ticker+Währung und Name+Währung geprüft, mit der Genau-eine-Regel über die Vereinigung dieser Stufen — ein String, der die WKN des einen und den Ticker eines anderen Wertpapiers trifft, landet unter ambiguous mit den Kandidaten, nie als stille Wahl, und eine Zeile ohne Währung kann nicht über Ticker oder Name matchen (unmatched mit Grund currency_required). Die Antwort ist selbstbeschreibend (basis mit as_of, scope und einer Delta-Notiz) und liefert: matched-Zeilen (eine je Wertpapier — Zeilen, die auf dasselbe Wertpapier auflösen, werden aggregiert, externe Mengen summiert, die beitragenden Zeilen gelistet) mit der matched_via-Stufe (isin, former_isin, wkn, ticker, name oder pinned), ledger_quantity, external_quantity und delta (extern - Ledger) als exakte Decimal-Strings — Ticker-/Name-Treffer tragen weak_match: true und den Hinweis, das Wertpapier vor jeder Buchung zu bestätigen —, ambiguous- und unmatched-Zeilen sowie missing_from_list (gehaltene Ledger-Positionen, die die externe Liste nicht abdeckt). Die eingebettete guidance ist Teil des Vertrags: eine Differenz wird durch Buchen der fehlenden Transaktion der richtigen Art gelöst; Saldo-Snapshots und unbepreiste Einlieferungen sind letzte Mittel, die die Kostenbasis verzerren. Optionaler Scope: portfolio_id oder view (eine View-Id; sich gegenseitig ausschließend — beide zugleich sind ein 422), Standard ist die gesamte Instanz; ein unbekanntes Portfolio oder eine unbekannte View ist ein 404.
  • GET /api/v1/portfolios/:portfolio_id/valuation liefert eine Live-Bewertung eines Portfolios: jede gehaltene Position bepreist aus ihrem letzten Kurs-Schlusswert, ein total_value und das weight jeder bewerteten Position (ihr Anteil am Gesamtwert). Der Marktwert jeder Position wird aus gespeicherten Wechselkursen in die base_currency des Portfolios (Top-Level-Feld) umgerechnet; je Position zeigt security_currency die native Währung. Ein Wertpapier ohne jeden Kurs wird mit dem zuletzt eigenen Handelspreis bepreist (price_source: "trade", gezählt im Top-Level trade_priced_count); eine bepreiste Position trägt price_source: "quote". Jede Position trägt price_date, das Datum, von dem ihr Preis stammt, und das Top-Level stale_priced_count zählt die Positionen, deren Kurs älter ist als die Datenqualitäts-Schwelle (dieselbe Tageszahl wie ?dq=stale_quote) — das Mittel ist das is_retired-Kennzeichen des Wertpapiers, auf das der Performance-Lauf reagiert, oder eine Kurssynchronisation; eine eingestellte Position wird deshalb nicht gezählt, ihr alter Schlusskurs ist erwartet. Das Top-Level newest_quote_date ist das Datum des neuesten gespeicherten Kurses über die bepreisten, nicht eingestellten Positionen (null, wenn keine per Kurs bepreist ist) — der Aktualitäts-Fakt, den die Kennzahlenleiste der Übersicht zeigt; die View-Bewertung trägt beide Felder ebenfalls. Eine Position mit weder Preis oder ohne Wechselkurspfad zur Basiswährung wird mit valued: false, price_source: null und null für Marktwert und Gewicht zurückgegeben, sodass ein fehlender Preis oder Kurs den Gesamtwert nie verzerrt. Unbekannte Portfolios liefern 404 Not Found. Gewichte sind rohe Anteile (market_value / total_value), ausgegeben in voller Decimal-Präzision; da sie normalisierte Verhältnisse sind, müssen sie sich nicht exakt zu 1 summieren (für die Anzeige runden). Marktwerte und total_value sind exakt. Die Bewertung trägt auch Cash: cash_balances listet jedes Geldkonto (balance in eigener Währung, plus base_value/valued nach Umrechnung in die Basiswährung, sein liquidity_role und ein deployable-Flag), total_cash ist die Basiswährungssumme der bewerteten Geldkonten (sodass der negative Saldo einer gezogenen Kreditlinie ihn weiterhin mindert), und total_with_cash ist total_value + total_cash. cash_quote ist der Anteil des verfügbaren Cash am Portfolio: verfügbares Cash ist die Summe der free_cash-Konten mit nicht-negativem Saldo (deployable: true), und die Quote wird berechnet, als gäbe es die anderen Konten nicht (counting_cash / (total_value + counting_cash), 0, wenn noch nichts zu bewerten ist) — sodass ein Reserve-Konto oder eine Kreditlinie gelistet und in total_cash bleibt, ohne je Schein-Liquidität zu melden. Die Antwort liefert außerdem counting_cash (Decimal-String) — das verfügbare Cash, das in die Quote eingeht — sodass ein Konsument die cash_quote selbst rekonstruieren kann. Ein Konto, dessen Währung keinen Kurspfad zur Basis hat, wird valued: false gemeldet und aus total_cash ausgeschlossen, spiegelnd, wie unbepreisbare Positionen behandelt werden. unvalued_cash_count (#1081) zählt solche Konten mit einem Saldo ungleich null — das Geld, das total_cash und total_with_cash auslassen; ein leeres steht mit valued: false in der Liste und zählt nicht — in jeder Form der Abfrage, mit und ohne include_positions, sodass auch die Roll-up-Abfrage die Lücke nennt; die valuation_note nennt die Regel. Die Antwort ist selbstbeschreibend (FR-13): sie trägt ein as_of-Datum (das Lesedatum — die Bewertung wird live ohne gespeicherten Snapshot berechnet) und eine valuation_note, die angibt, dass Summen in base_currency über den EUR-Hub vorliegen und dass die je Position geführten Felder price_source und valued die Preis-Aktualität anzeigen. Ein optionales include_positions=false (FR-37) liefert nur den Roll-up — Summen, Cash-Salden und Cash-Quote ohne die Positionszeilen; die Antwort benennt die gelieferte Form über positions_included.
  • GET /api/v1/portfolios/:portfolio_id/performance liefert die echte zeitgewichtete Rendite (TTWROR) des Portfolios, berechnet auf die Portfolio-Performance-Art: das Portfolio wird täglich bewertet (Kurse am oder vor jedem Tag, zu den Kursen jenes Tages umgerechnet, plus Cash), externe Flüsse — Einzahlungen, Entnahmen, Lieferungen und Saldo-Snapshot-Sprünge — werden neutralisiert, und tägliche Renditen werden geometrisch verkettet (siehe ADR-0010). Optionale Query-Parameter: period (ytd, 1y, 3y, 5y, max — Standard max; ein unbekannter Zeitraum liefert 422 Unprocessable Entity), year=YYYY für ein einzelnes Kalenderjahr, from=/to= (ISO-Daten, beide erforderlich, from <= to) für einen freien Zeitraum — beide ehrlich auf die vorhandene Historie begrenzt, ein rückwärtiger oder fehlerhafter Zeitraum liefert 422 — und series=true, um die täglichen Punkte aufzunehmen (date, value, flow, cumulative_ttwror). Die Antwort trägt ttwror, start_date/end_date, start_value/end_value, net_external_flows als Decimal-Strings und suspect_dates — Daten von Buchungen älter als 1970 (Import-Tippfehler), deren Effekte am ersten plausiblen Tag angewendet wurden. Neben ttwror trägt die Antwort auch die geldgewichtete Rendite irr — die einzelne annualisierte Rate, die die datierten externen Flüsse und den Endwert des Zeitraums auf null abzinst (NPV(r) = Σ cf/(1+r)^(days/365) = 0), die Zahl, die Portfolio Performance neben TTWROR zeigt. Es ist ein Decimal-String oder null, wenn keine Rate existiert (weniger als zwei Flüsse, alle Flüsse mit gleichem Vorzeichen oder der Solver konvergiert nicht) oder ein Betrag außerhalb des Bereichs liegt, den der eine Gleitkommaschritt des Solvers trägt, was nur unplausible gespeicherte Daten erreichen; die Abfrage scheitert an einem solchen Betrag nie, und computation_basis.gaps nennt diese Fälle (E25 S4). Wertpapiere ohne Kurse werden mit dem zuletzt eigenen Handelspreis bepreist (siehe den Bewertungs-Endpunkt). Unbekannte Portfolios liefern 404 Not Found. Da der tägliche Walk aus einem dauerhaft materialisierten abgeleiteten Wert bedient werden kann (ADR-0039), schweigt die Antwort nie über ihre Frische: as_of (ISO-8601-Zeitstempel) ist der Berechnungszeitpunkt des Walks — möglicherweise älter als die Anfrage, wenn sich die zugrunde liegenden Daten seither nicht geändert haben — und stale (Boolean) markiert einen überholten Wert, der ausgeliefert wird, während ein frischer berechnet wird; ein gespeicherter Wert wird von jedem Schreibvorgang invalidiert, der ihn beeinflussen kann, stale: false bedeutet also aktuell gegenüber dem Ledger. Die Antwort nennt außerdem die Berechnungsbasis der Metrik (computation_basis): Eingangsreihe, wirksames Fenster, Referenzreihe (null — TTWROR/IRR haben keine) und den Umgang mit Lücken. Ein Verrechnungskonto in einer Fremdwährung zählt an jedem Tag null, an dem es Geld hält, während seine Währung keinen Kurspfad zur Basiswährung hat, und am Tag des ersten Kurses kommt sein ganzer Saldo in den Endwert, ohne ein Fluss zu sein: unvalued_cash_accounts nennt jedes solche Konto des Scopes, das an einem Fenstertag oder am Tag vor dem Fenster, dessen Schluss der Anfangswert ist, null zählte (#1055, ADR-0051 §10) — cash_account_id, name, currency_code, balance (sein nativer Saldo am letzten solchen Tag, ein Decimal-String in dieser Währung, nie umgerechnet), unvalued_days (seine Tage, der Tag vor dem Fenster (der Anfangswert) eingeschlossen), unvalued_reason (no_rate), unvalued_through_end (true, wenn es am letzten Tag des Fensters noch null zählte) und first_rate_date (das ISO-Datum, an dem der erste Wechselkurspfad der Währung kam, wenn es im Fenster liegt und das Konto am Tag davor Geld hielt und null zählte, sonst null) — nach Name sortiert, [], wenn jeder Saldo bewertet war; computation_basis.gaps nennt das Feld.
  • GET /api/v1/portfolios/:portfolio_id/performance/benchmark liefert den Benchmark-Vergleich (ADR-0046, FR-9): die eigenen externen Flüsse des Portfolios in eine Benchmark nachgebucht — die Antwort auf „war der Aufwand das wert?”. benchmark= ist Pflicht — rate:<decimal> für einen festen effektiven Jahreszins, der täglich von Basis 1 aufzinst (Act/365; rate:0.02 sind 2 % p. a., die Tagesgeld-Baseline und in v1 auch die Ausdrucksform der Inflation; akzeptiert zwischen -0.999999 und 10, dem Definitionsbereich des IRR-Solvers), oder security:<id> für ein Katalog-Wertpapier mit gesetztem is_benchmark (Liste über GET /api/v1/securities?is_benchmark=true; jedes andere Wertpapier liefert 422, {"errors": {"benchmark": ["is not a benchmark security"]}}). Ein Wertpapier, das eine Zusammenführung entfernt hat, wird ebenso abgelehnt; ist der Überlebende am lebenden Ende seiner Zusammenführungskette selbst eine gesetzte Benchmark, ergänzt der Body merged_into {"kind": "security", "id": …} mit ihm, wie ihn die Abfragen zusammengeführter IDs nennen, und ein neuer Versuch mit dieser ID vergleicht (#959). Ein Überlebender ohne is_benchmark, eine Kette, die an einer inzwischen gelöschten Zeile endet, und eine ID, die keine Zusammenführung nennt, antworten ohne ihn. Der View- und der view-lose Vergleich unten antworten genauso. period, year, from/to, view= und series=true verhalten sich wie beim Performance-Endpunkt. Die Antwort trägt benchmark (kind, dann annual_rate oder security_id/name/ currency_code), requested_window und window — die Tage, die der Vergleich tatsächlich abdeckt, mit window.rebase_day, dem Schlusskurs, auf den die Benchmark rebasiert wird (der Schlusskurs vor dem Fenster oder der erste Tag des Fensters, wenn es ohne Wert beginnt): ein Fluss vor dem ersten bepreisten Tag der Benchmark (ein Schlusskurs und ein Kurspfad zur Basiswährung; ein gespeicherter Schlusskurs von 0 ist kein Preis) wird nicht an seinem eigenen Tag nachgebucht — er geht über den Anfangswert des Fensters ein und wird in excluded_flows (date, flow) benannt — und beide Seiten werden über das abgedeckte Fenster verkettet — und dann die beiden Vergleiche, die Portfolio Performance zeigt. bought_once ist flussneutral: benchmark_return, die Benchmark auf rebase_day rebasiert, neben portfolio_ttwror über dasselbe Fenster; mit series=true zusätzlich die täglichen cumulative_return-Punkte für ein Chart-Overlay. savings_plan ist flussgleich: invested_capital, portfolio_end_value und benchmark_end_value — Anfangswert des Fensters und jeder externe Fluss zum Kurs jenes Tages in die Benchmark investiert (fester Zins: zu pari) — end_value_delta (real minus synthetisch, die Zahl, die die Frage beantwortet), portfolio_irr und benchmark_irr (der XIRR von ADR-0034 auf identischen datierten Flüssen), portfolio_mwr und benchmark_mwr (die nicht annualisierten Periodenraten, die ein Fenster unter einem Jahr stattdessen liest) und benchmark_units. Alle Finanzwerte sind Decimal-Strings; as_of/stale tragen die Frische des Walks (ADR-0039) und computation_basis nennt Eingangsreihe, Fenster, Referenz, Lückenbehandlung und die assumptions — das synthetische Portfolio ist reibungsfrei (frictionless: true: keine Gebühren, keine Steuern), was den Vergleich gegen das reale Portfolio verzerrt. Die Basis nennt ein Benchmark-Wertpapier als security <id> (<Währung>), nie mit seinem gespeicherten Namen, der nur als Datum in benchmark.name steht (E25). Ein fehlendes oder fehlerhaftes benchmark ist 422; ein Portfolio ohne Buchungen oder eine Benchmark ohne Kurs im Fenster antwortet mit null-Werten, window.start_date: null und jedem Fluss in excluded_flows. Ein Verrechnungskonto, dessen Saldo vor dem ersten Wechselkurs seiner Währung null zählte — der Sprung, den dieser Kurs bringt, steckt in portfolio_ttwror und end_value_delta —, wird in unvalued_cash_accounts genannt, den Einträgen des Performance-Endpunkts für das abgedeckte Fenster ([] ohne eines; #1055). Nichts wird gespeichert: der Vergleich wird beim Lesen abgeleitet und wie der Walk memoisiert, von dem er abhängt.
  • GET /api/v1/portfolios/:portfolio_id/performance/contribution liefert die Beitragsanalyse (FR-41, ADR-0051): welche Position wie viel zum Geldergebnis des Zeitraums beigetragen hat, der Zahl „+x EUR im Zeitraum“ neben der TTWROR. period, year, from/to und view= verhalten sich wie beim Performance-Endpunkt, ebenso seine Fehler (fehlerhafte view oder fehlerhafter Zeitraum 422, unbekanntes Portfolio oder unbekannte View 404). Jede Zeile von positions trägt security_id, name, isin, start_value, end_value, net_flows, income, costs und contribution — end_value − start_value − net_flows + income − costs, in der Basiswährung, die Währungsbewegung eingeschlossen — dazu held_at_start, held_at_end, unvalued_days und unvalued_reason (no_price, no_rate oder null). costs sind die Gebühren und Steuern der eigenen Käufe und Verkäufe der Position, umgerechnet aus der Währung des Verrechnungskontos des jeweiligen Handels, in der sie erfasst sind (#1051); ein Handel ohne Kurs für seine Preiswährung fließt mit seinem Geldbetrag abzüglich dieser Kosten zu. Eine im Fenster gekaufte und wieder verkaufte Position steht in der Tabelle, obwohl sie an keinem Ende gehalten wird. Ein Tag, an dem eine gehaltene Position keinen Preis oder keinen Kurspfad hat, zählt null, wie im Walk; die Position bleibt in der Summe und wird mit ihren Tagen benannt. Ein Fremdwährungssaldo vor dem ersten Wechselkurs seiner Währung zählt ebenso null, und der erste Wechselkurs bringt seinen ganzen Wert in cash_currency_effect: unvalued_cash_accounts nennt das Konto, genau wie der Performance-Endpunkt desselben Scopes und Fensters, mit seinem nativen balance, seinen unvalued_days (der Tag vor dem Fenster eingeschlossen), unvalued_through_end und dem first_rate_date (#1055). Die Zeilen sind nach Beitrag sortiert, der größte zuerst, ohne Anteil, Rang oder Etikett. remainder hält, was keine Position hält, jede Zeile aus ihren eigenen Buchungen summiert und nie ein Ausgleichsposten: interest (jede Zinsbuchung, Anleihekupons eingeschlossen), standalone_fees_and_taxes (Gebühren-, Steuer- und Steuererstattungsbuchungen, die kein Trade trägt) und cash_currency_effect (die Neubewertung von Fremdwährungsguthaben, die Abrechnungsdifferenz eines Trades zwischen seinem Geldbein und Preis × Stückzahl plus Kosten und was ein Geldübertrag zwischen Währungen hinterlässt). totals trägt result (end_value − start_value − net_external_flows des Performance-Endpunkts über dasselbe Fenster), positions und remainder, und positions + remainder = result, exakt in Decimal, solange die Umrechnungsquotienten abbrechen, sonst auf 34 signifikante Stellen. Die Antwort trägt portfolio_id, view_id (null ohne View; eine view spiegelt zusätzlich view: {id, name}), period, base_currency, start_date/end_date, as_of/stale und computation_basis mit assumptions; alle Finanzwerte sind Decimal-Strings. Ein Fenster ohne gewalkten Tag — ein künftiges Jahr, ein Zeitraum vor der Historie — liefert start_date: null, keine Positionen und "0" als Zeilen und Summen, wie der Performance-Endpunkt es liefert. Nichts wird gespeichert.
  • GET /api/v1/portfolios/:portfolio_id/income liefert den retrospektiven Ertragsbericht: die bereits im Ledger gebuchten Dividenden und Zinsen, auf drei Arten aggregiert (keine Prognose — der Dividendenkalender ist eine separate Funktion). annual ist eine Liste von Jahren (neueste zuerst), jedes mit months (eine Map mit Monatszahl-Schlüsseln "1"–"12", jeweils mit dividends und interest) und je Jahr dividends_total, interest_total und total. positions ist die Pro-Position-Tabelle: security_id, security_name, security_currency (die ursprüngliche Buchungswährung), gross, tax (die einbehaltene Steuer, aus den auf der Transaktion gespeicherten TAX-Einheiten der Dividende), net (gross - tax), payment_count und last_payment. transactions ist das Detail je Transaktion für eine Jahres-Aufschlüsselung (kind, date, year, security_id/security_name, currency, das native native_gross/ native_tax/native_net, das gross/tax/net in Basiswährung und converted). Das Brutto einer Dividende ist ihr Netto-Cash (gross_amount) plus die einbehaltene Steuer; Zinsen tragen keine Quellensteuer. Alle Beträge sind Decimal-Strings in der base_currency des Portfolios, umgerechnet über den EUR-Hub zum gespeicherten Kurs des jeweiligen Buchungsdatums (dieselbe Mechanik wie der Bewertungs-Endpunkt), mit beibehaltener ursprünglicher Währung; unconverted_count zählt Buchungen ohne Kurspfad (zur Parität umgerechnet), und conversion_note nennt die Basis. Bargeld und einbehaltene Steuer einer Buchung werden in der Währung ihres Verrechnungskontos gelesen, der Währung, in der sie gutgeschrieben wurden (die Ergänzung von ADR-0015 vom 07.10.2026): Die Dividende eines USD-Wertpapiers, einem EUR-Konto gutgeschrieben, wird aus Euro umgerechnet, und ihre transactions-Zeile nennt in currency EUR, die Währung ihrer native_*-Beträge, während ihre positions-Zeile die Buchungswährung in security_currency behält. Unbekannte Portfolios liefern 404 Not Found. Seit ADR-0020 gehört ein SOLL-Zielplan zu einer Sicht (View): Die Lese-/Schreib-Endpunkte für Ziele akzeptieren ein optionales view (eine View-id). Wird es weggelassen (oder als null gesendet), adressiert es den portfolioweiten Gesamt-Plan — das Verhalten vor Einführung der Views. Eine View trägt ihren eigenen Plan, sodass dieselbe Klassifizierung pro View einen anderen Plan halten kann, ohne dass sich die Pläne übereinander summieren. Ein fehlerhaftes view liefert 422 Unprocessable Entity ({"view": ["is invalid"]}) und eine unbekannte View-id liefert 404 Not Found — derselbe strukturierte Vertrag wie bei den Analyse-Endpunkten.
  • GET /api/v1/portfolios/:portfolio_id/targets listet die gespeicherten Zielgewichte eines Portfolios (die SOLL-Seite der Allokation). Optionales classification_id schränkt die Liste auf einen Baum ein; optionales view wählt den Plan (weggelassen = Gesamt). Unbekannte Portfolios liefern 404 Not Found.
  • PUT /api/v1/portfolios/:portfolio_id/targets führt Zielgewichte für eine Klassifizierung ein (Upsert). Der Body ist {"classification_id": id, "targets": [{"category_id": id, "target_weight": "0.25"}]} und kann ein optionales "view": id tragen, um den Plan dieser View zu schreiben (weggelassen = Gesamt). Jedes target_weight ist ein String-Bruch in [0, 1] mit höchstens 6 Nachkommastellen (vier in Prozent); ein feineres Gewicht liefert 422 auf target_weight, und die Datenbank lehnt es ebenfalls ab, sofern die Instanz beim Upgrade nicht schon ein feineres Gewicht hielt (das Upgrade protokolliert dann, wie viele). Das Duplizieren eines Plans oder sein Speichern im SOLL-Editor rundet ein solches gespeichertes Gewicht kaufmännisch auf 6 Nachkommastellen. Ein Gewicht, das wegen seiner Genauigkeit oder als Wert außerhalb von [0, 1] abgelehnt wird, nennt auch seine Zeile, wie der Plan-Editor die abgelehnte Zeile voranstellt: errors.row ist {"category_id": id, "security_id": id}, mit security_id null bei einer Kategoriezeile, neben errors.target_weight (Issue #1143). Ziele müssen sich nicht zu 1 summieren. Nur die übergebenen Kategorien werden geändert. Eine Kategorie aus einem anderen Baum liefert 422 Unprocessable Entity, und eine unbekannte Klassifizierung liefert 404 Not Found. Ein Stapel nennt jede Kategoriezeile einmal und trägt höchstens eine Zeile je Kategorie und eine je in der Klassifizierung zugeordnetem Wertpapier, nie mehr als 10000 Zeilen; eine wiederholte Kategoriezeile oder ein größerer Stapel liefert 422 (errors.detail nennt die Kategorie, errors.targets die Grenze) und schreibt nichts. Positions-Soll (ADR-0030): Ein Zieleintrag, der zusätzlich eine "security_id" trägt, setzt ein Gewicht auf diese einzelne Position unter der Kategorie (das Wertpapier muss darunter stehen, sonst 422); ein Kategorieeintrag (ohne security_id) und seine Positionseinträge werden nebeneinander gespeichert. Eine angegebene security_id ist eine positive Ganzzahl oder null (null = Kategoriezeile) — alles andere (ein nicht-numerischer String, eine Gleitkommazahl) liefert 422, statt zu einem Schreibvorgang auf die Kategorie umgedeutet zu werden. Ein Plan trägt höchstens eine Positionszeile je Wertpapier: Ein Wertpapier unter einer zweiten Kategorie abzulegen oder dasselbe Paar (category, security) zweimal in einem Stapel zu nennen, liefert 422. Die Datenbank hält diese Regel ebenfalls, sodass auch ein Schreibvorgang, der das Rennen um dasselbe Wertpapier unter einer anderen Kategorie verliert, 422 liefert und nichts speichert. Jedes serialisierte Ziel trägt security_id (null bei einer Kategoriezeile). Eine Planversion (Issue #1133): Ein optionales "plan_id": id schreibt diese Planversion — einen Entwurf aus POST /api/v1/plans/:id/duplicate oder den aktiven Plan — statt des aktiven Plans der View, sodass die Kategorie- und Positionsziele eines Entwurfs über die API bearbeitet werden wie im SOLL-Editor und er aktiviert wird, wenn er fertig ist; die Lesezugriffe folgen weiter dem aktiven Plan. Der Plan muss einer dieses Portfolios sein, dieser Klassifizierung und, wenn view angegeben ist, dieser View, und er darf nicht archived sein: Eine archivierte Version steuert nichts und wird wiederverwendet, indem man sie aktiviert oder in einen Entwurf dupliziert. Eine fehlerhafte id, ein unbekannter Plan oder der eines anderen Portfolios, einer anderen Klassifizierung, einer anderen View und ein archivierter liefern je 422 auf plan_id (is invalid, is not a plan of this portfolio, is a plan of another classification, is a plan of another view, is archived: activate it, or duplicate it into a draft) und schreiben nichts.
  • DELETE /api/v1/portfolios/:portfolio_id/targets/:category_id entfernt das Kategorie-Zielgewicht eines Portfolios für eine Kategorie und liefert {deleted} (die Zahl der entfernten Zeilen). Die Positionszeilen der Kategorie bleiben bestehen. Optionales view wählt den Plan (weggelassen = Gesamt); optionales plan_id adressiert eine Planversion wie beim PUT, mit denselben 422.
  • GET /api/v1/portfolios/:portfolio_id/position_targets listet die SOLL-Ziele eines Portfolios auf Positionsebene (ADR-0030): {"position_targets": [...], "effective_targets": [...]}. Jede position_targets-Zeile ist ein Ziel auf einem Wertpapier unter einer Kategorie (mit security_id und security_name); jeder effective_targets-Eintrag ist der Roll-up einer Kategorie — explicit (das Gewicht der Kategoriezeile oder null), position_sum (die Summe der direkt unter ihr abgelegten Positionszeilen — die Zeilen von Unterkategorien rollen in ihre eigene Kategorie auf), effective (das aufgelöste Steuergewicht — die Positionssumme gewinnt) und conflict (true, wenn explizites Gewicht und Positionssumme voneinander abweichen, sodass die Abweichung sichtbar wird). Jede Positionszeile trägt zudem stale (true, wenn ihr Wertpapier nicht mehr unter der gespeicherten Kategorie steht — umklassifiziert oder nicht zugeordnet; die Zeile zählt weiter dort, wo sie abgelegt wurde, und das erneute Ablegen ist die Abhilfe) und jeder Roll-up has_stale. Gewichte sind Decimal-Strings. Optionales classification_id / view grenzen ein wie oben. Lese-Ergonomie (FR-37, Issue #740): min_drift= — die Schwelle der Allokationsabfrage, gleich geschrieben (ein nicht-negativer Decimal-String auf |drift_weight|) — liefert nur die Positionszeilen, deren Drift sie erreicht; drift_weight ist das tatsächliche Gewicht des Wertpapiers in der Steuerbasis minus sein Positionsziel, genau wie die Allokation es rechnet (ein Prädikat, zwei Oberflächen). Behaltene Zeilen tragen drift_weight, Zeilen ohne Drift werden mitgefiltert, und die Antwort benennt das angewandte min_drift, position_targets_total (die Zeilenzahl vor dem Filter) und einen Satz drift_basis. Ohne min_drift tragen die Zeilen kein drift_weight, und die Form ist unverändert. Eine ungültige Schwelle ist ein 422.
  • DELETE /api/v1/portfolios/:portfolio_id/position_targets/:category_id/:security_id entfernt ein Positionsziel und liefert {deleted}. Die Kategoriezeile und die übrigen Positionen der Kategorie bleiben unberührt. Optionales view wählt den Plan; optionales plan_id adressiert eine Planversion wie beim PUT.
  • GET /api/v1/portfolios/:portfolio_id/plans listet die SOLL-Planversionen eines Portfolios (ADR-0027): zuerst die aktive, dann Entwürfe und archivierte Pläne, jeweils mit name, status (active / draft / archived), ihrem Geltungsbereich (view_id, classification_id) und cash_target_weight als Decimal-String. Optionales classification_id schränkt auf einen Baum ein; ein Wert, der keine id ist, wird ignoriert, und alle Pläne werden gelistet. Nur der aktive Plan eines Geltungsbereichs steuert die Allokation.
  • POST /api/v1/plans/:id/duplicate kopiert eine Planversion (Kategorie- und Positionsziele sowie das Cash-Ziel) in einen neuen Entwurf desselben Geltungsbereichs und liefert ihn mit 201 Created. Ein optionaler Body {"name": "Plan 2027"} benennt die Kopie (Standard: "<source name> (copy)").
  • POST /api/v1/plans/:id/activate macht eine Entwurfs- oder archivierte Version zum aktiven Plan ihres Geltungsbereichs und archiviert den bisher aktiven Plan in derselben Transaktion. Den bereits aktiven Plan zu aktivieren ist ein No-op.
  • PATCH /api/v1/plans/:id benennt eine Planversion um ({"name": "..."}).
  • DELETE /api/v1/plans/:id löscht eine Planversion (gleich welchen Status) samt ihrer Kategorie- und Positionsziele. Wird der aktive Plan gelöscht, bleibt der Geltungsbereich ohne Plan (die Allokation fällt auf nur IST zurück). Jedes Ziel wird als eigene target-Löschung vor der target_plan-Löschung des Plans journalisiert.
  • GET /api/v1/portfolios/:portfolio_id/category-results?classification_id=<id> — das Ergebnis je Kategorie (ADR-0041 Teil eins, #712): invested (die Summe der Einstandskosten der Mitglieder in Basiswährung), current_value, result_abs und result_pct je Kategorie, dazu die positions, die es ergeben, und die excluded-Zeilen, die es nicht abdecken konnte. Die Basis steht in einer Zeile (basis: "current_composition", ausgeschrieben in basis_note): eine Aussage über die Positionen, die heute unter jeder Kategorie stehen, ohne Zeitraum und ohne Stichtag, und keine zeitgewichtete Renditereihe. result_pct ist Σ Ergebnis ÷ Σ investiert, nie ein Mittel der Prozente der Mitglieder. Ein Mitglied, dessen Ergebnis nicht ableitbar ist, fehlt auf beiden Seiten der Summe und steht mit seinem reason unter excluded; es zählt nie als null. MCP: portfolixir.portfolios.category_results.

    Der View-Bereich (#901) kommt in den zwei Formen der Performance-Familie (ADR-0051 §6). ?view=<id> grenzt die Portfolio-Abfrage auf die zur View passenden Positionen des Portfolios ein und spiegelt sie als view: {id, name}; eine fehlerhafte View ist ein 422, eine unbekannte ein 404. Die View-Abfrage unten reicht über jedes Portfolio, und GET /api/v1/category-results über jedes Portfolio ohne View (#1091). Jede Antwort nennt den Bereich, über den sie gerechnet wurde: scope (portfolio, view oder all), portfolio_id, view_id, base_currency (die Währung jedes Geldwerts: die des Portfolios bei der Portfolio-Abfrage, EUR bei der View-Abfrage und bei der Abfrage über jedes Portfolio, scope: "all") und einen Schlusssatz von basis_note, der ihn benennt. Das MCP-Tool nimmt denselben Bereich: portfolio_id, das view eingrenzt, view allein für die View über jedes Portfolio, oder keines von beiden für jedes Portfolio.

    Jede Form nennt einmal, was sie auslässt (#1091): excluded_members führt jedes ausgeschlossene Mitglied genau einmal über den ganzen Baum, gleich in wie viele Ebenen es einrollt, nach Namen sortiert ohne Rücksicht auf Groß- und Kleinschreibung (bei Gleichstand nach security_id), mit security_id, security_name, category_id (die Kategorie, unter der es steht), reason und native_costs — [{amount, currency}], die Einstandskosten in der Währung, in der sie bezahlt wurden, Beträge als Decimal-Strings, wenn nur diese Währung das Mitglied aus einer EUR-Summe hält, sonst []. Ein Wertpapier in einem EUR- und einem Nicht-EUR-Portfolio bleibt ganz heraus und trägt einen Eintrag je Währung, die Währung des Ergebnisses zuerst, dann die übrigen nach Code. Jede basis_note nennt die Liste; die excluded-Zeilen je Kategorie bleiben unverändert.

  • GET /api/v1/portfolios/:portfolio_id/allocation liefert die SOLL/IST-Aufschlüsselung für eine Klassifizierung (erforderlicher classification_id-Query-Parameter; ein fehlender liefert 422 Unprocessable Entity). Für jede Kategorie meldet es parent_id und depth (die Kategorien bilden einen Baum), color, own_market_value (direkt zugeordnete Positionen), market_value (ihr ganzer aufgerollter Teilbaum), actual_weight (der aufgerollte Anteil an total_value), target_weight, drift_weight (actual_weight - target_weight: positiv = übergewichtet, negativ = untergewichtet; ADR-0023) und drift_value (die Drift in Basiswährung neu ausgewiesen — wie viel zu verkaufen (positiv) oder zu kaufen (negativ) ist, um das Ziel zu erreichen). Jede Zeile trägt zudem child_target_sum (Decimal-String): die beratende Summe der Ziele ihrer direkten Kinder, oder null, wenn kein direktes Kind ein Ziel trägt — ein Konsistenzhinweis, den die UI gegen das eigene target_weight der Zeile abgleichen kann. Eine einem Kind zugeordnete Position zählt zu diesem Kind und jedem Vorfahren, sodass eine übergeordnete Kategorie mit Ziel gegen ihren Teilbaum verglichen wird, statt 0 % zu zeigen; die Zeilen kommen in Baumreihenfolge zurück (Eltern vor ihren Kindern). Da Eltern ihre Kinder aggregieren, summieren sich die actual_weight-Werte je Kategorie bewusst nicht über die Ebenen zu 1 — nur die Blätter plus unassigned tun es. Jede Kategorie (und unassigned) trägt außerdem positions: die Aufschlüsselung je Wertpapier ihres eigenen (direkt zugeordneten) Werts — security_id, security_name, quantity, market_value, weight, plus die reinen Anzeige-Hinweise fürs Rebalancing (ADR-0023): drift_value (der proportionale Anteil der Position an der Kategorie-Drift) und rebalance_quantity (indikative Stückzahl, die zum impliziten Stückpreis der Bewertung zu verkaufen (positiv) oder zu kaufen (negativ) wäre; ohne Gebühren-/Steuermodell, nie eine Order). Beide Hinweise sind ohne Plan, für unassigned-Positionen ohne eigenes Positions-Soll und für eine mit 0 bewertete Position ohne eigenes Soll null, die keinen Anteil an der Drift ihrer Kategorie hat (E25 S4); die Seite zeigt „—“ und sortiert eine solche Zeile hinter die Zeilen mit Drift. Mit den Positionszeilen nennt computation_basis im Payload die Grundlage des Drift-Anteils (drift_value) und diese Lücken. Einträge kommen größte zuerst, Wertpapiere über Depots zusammengeführt; das ist es, was der äußerste Ring des Sunburst rendert. Positions-Soll (ADR-0030 Slice 2a): die positions einer Kategorie sind die Vereinigung ihrer gehaltenen Positionen und der Positions-Ziel-Zeilen des aktiven Plans, je Wertpapier zusammengeführt. Jeder Eintrag trägt zusätzlich target_weight (sein Positions-Soll, null ohne eines), drift_weight (IST-Gewicht − SOLL-Gewicht, ADR-0023-Vorzeichen) und held. Ein Eintrag mit eigenem Soll leitet drift_value und rebalance_quantity aus dieser eigenen Drift ab statt aus dem Kategorie-Anteil. Eine Position mit Soll > 0 ohne Bestand erscheint mit IST 0 (held: false, Menge/Wert/Gewicht "0") und voller Untergewichts-Drift — „hier muss gekauft werden” — mit ihrer indikativen Stückzahl zum letzten gespeicherten Kurs (null ohne Kurs; keiner wird erfunden); quote_date nennt das Datum dieses Kurses (null, wenn der Hinweis nicht kursbasiert ist). held heißt Bestand vorhanden: ein gehaltenes Wertpapier ohne ermittelbaren Preis wird nie als ohne Bestand gemeldet (es bleibt auf den Unbewertet-Flächen). Jeder Eintrag trägt zudem stale (true, wenn seine abgelegte Positions-Ziel-Zeile nicht mehr zur aktuellen Kategorie des Wertpapiers passt). Eine Position wird nur ausgeblendet, wenn ihr Soll 0/fehlend ist und ihr Bestand null. Jede Kategorie-Zeile trägt zudem conflict (explizites Gewicht und Positions-Summe weichen ab — die Summe steuert) und has_stale (eine hier abgelegte Positions-Zeile ist veraltet), und die Aufschlüsselung trägt deep_target_sum — die Summe der effektiven Ziele auf der obersten gezielten Ebene je Teilbaum, die eine top_level_target_sum von 0 über einem tiefer gesteckten Plan erklärt. Gehaltene, aber im Baum nicht zugeordnete Wertpapiere werden in unassigned summiert; auch unassigned-Einträge tragen ihr Positions-Soll. Gewichte sind Anteile der Steuerbasis: der Gesamtwert der bewerteten Positionen (eingeschränkt durch die aktive view, sofern angegeben), plus das verfügbare Cash (free_cash-Konten mit nicht-negativem Saldo). total_value ist hier diese Steuerbasis (nicht die volle Bewertung). Die Antwort trägt ein cash-Objekt — market_value (das zählende Cash), actual_weight (sein Anteil an total_value), target_weight (das Cash-Ziel des Plans der aktiven View oder 0, wenn nicht gesetzt; siehe die Cash-Ziel-Endpunkte unten), drift_weight (actual_weight - target_weight, ADR-0023), drift_value (in Basiswährung neu ausgewiesen) und distributed (Boolean) — sodass Cash in derselben Drift-Logik wie die Kategorien gesteuert wird. Ist die aktive Klassifizierung der eingebaute Währungs-Baum, wird das Cash jedes Geldkontos seiner eigenen Währungskategorie zugeordnet statt als eigene Cash-Zeile zu erscheinen (EUR-Cash → EUR-Kategorie, USD-Cash → USD usw.); in diesem Fall ist cash.distributed true und Konsumenten sollten die separate Cash-Zeile weglassen. Da Cash Teil der 100 %-Basis ist, schrumpfen die Kategorie-Prozentsätze entsprechend, sobald Cash vorhanden ist. Der top_level_target_sum ist die Summe der Ziele der Wurzelkategorien plus das Cash-Ziel (außer im Währungs-Baum, wo Cash in Kategorien verteilt wird), verglichen mit 1. Um einen Bestand aus der Steuerbasis herauszuhalten, während er weiterhin zum Gesamtvermögen zählt, den Bestand mit einem Bucket versehen und diesen Bucket aus der view ausschließen, unter der die Allokation gelesen wird — er fällt dann aus den eingeschränkten Positionen. Seit ADR-0020 spiegelt die SOLL-Seite den Plan der aktiven View wider: Mit view=<id> werden die Zielgewichte, das Cash-Ziel und der top_level_target_sum dieser View ausgewiesen (ohne view der Gesamt-Plan), sodass die Drift-Tabelle pro View gegen einen kohärenten 100 %-Plan steuert. Die target_weight-Werte der Kategorien sind die effektiven Ziele (ADR-0030): die Summe der Positions-Zeilen einer Kategorie, sobald welche existieren (Positionen sind die Quelle der Wahrheit), sonst ihr explizites Kategorien-Gewicht — die Σ-Werte verwenden dieselben effektiven Zahlen. Für die rohen Positions-Ziel-Zeilen und den Roll-up je Kategorie (die Pflege-Sicht) dient der position_targets-Endpunkt oben. Unbekannte Portfolios oder Klassifizierungen liefern 404 Not Found. Lese-Ergonomie (FR-37): include_positions=false lässt die Positionszeilen je Kategorie (und in unassigned) für einen reinen Roll-up weg, und min_drift=<decimal> (eine absolute Drift-Schwelle, z. B. 0.02) liefert nur die Kategoriezeilen, deren |drift_weight| sie erreicht — ziellose Kategorien tragen keine Drift und werden mitgefiltert; behaltene Zeilen kommen flach zurück (ein Vorfahre unter der Schwelle fehlt). Die Antwort benennt ihre eigene Basis: positions_included, das angewandte min_drift und categories_total (die Zeilenzahl vor dem Filter). Ungültige Werte sind ein 422, ebenso ein Wert, der keine endliche Dezimalzahl ist (NaN, Infinity), hier und bei position_targets. Die Allokationsseite trägt denselben Filter als Abweichungs-Chips (ein gemeinsames Prädikat, die beiden Oberflächen können also keine unterschiedlichen Kategorien auswählen); die Chips sprechen Prozentpunkte, ≥ 5 pp auf dem Bildschirm ist hier also min_drift=0.05. Dieselbe Schwelle, gleich geschrieben, gilt eine Ebene tiefer (#740): GET /api/v1/portfolios/:portfolio_id/position_targets?min_drift=<decimal> liefert nur die Positions-Ziel-Zeilen, deren |drift_weight| sie erreicht — drift_weight ist das tatsächliche Gewicht des Wertpapiers in der Steuerbasis minus sein Positionsziel, genau wie die Allokation es rechnet; behaltene Zeilen tragen drift_weight, Zeilen ohne Drift werden mitgefiltert, und die Antwort benennt min_drift, position_targets_total (die Zeilenzahl vor dem Filter) und drift_basis. Ohne min_drift ist die Form unverändert. tax_context=true (#667) hängt zusätzlich die steuerfreien Trim-Budgets des laufenden Jahres an — ein Eintrag je Inhaber-Identität mit erfassten Auszügen (Schreibweisen einer Person, die sich nur in Groß- und Kleinschreibung unterscheiden, sind ein Eintrag; Institute werden ebenso abgeglichen), jeweils mit seiner aktivitätsbewussten staleness — sodass der Steuer-Spielraum dort lesbar ist, wo die Trim-Entscheidung fällt; der Block benennt, dass er je (Inhaber, Steuerjahr) über Institute rollt und nie auf Portfolio oder View eingeschränkt ist.
  • GET /api/v1/portfolios/:portfolio_id/risk liefert eine Risiko-/Konzentrationssicht für ein Portfolio über die Steuerbasis (der Gesamtwert der bewerteten Positionen, eingeschränkt durch die aktive view — dieselbe Basis wie die Allocation-Drift). Ein über mehrere Depots gehaltenes Wertpapier wird zu einer Einzeltitel-Position zusammengeführt. Gewichte, Caps und der HHI liegen alle auf einer 0-100-Prozentskala (Decimal-Strings, volle Präzision, keine Rundung):
    • steerable_basis ist die Basis, deren Anteil die Gewichte sind, und base_currency die Basiswährung des Portfolios.
    • top_holdings sind die größten Einzeltitel-Positionen, größte zuerst, Standard N = 10 (überschreibbar mit dem top_n-Query-Parameter). top_n folgt dem Vertrag der Listen-Abfragen: höchstens 1000, ein größerer Wert wird gekappt, und die Antwort nennt das angewandte top_n (E25 S4). Jeder Eintrag trägt security_id, security_name, asset_class, market_value, weight und einen severity (ok/warn/hard). Der severity ist instrumententyp-abhängig: eine Einzelaktie warnt über 7 und wird hart über 10; ein ETF (die Anlageklasse etf) warnt über 25 und wird nie hart. Überschreibe die Standardwerte mit den Query-Parametern stock_thresholds[warn]/stock_thresholds[hard] und etf_thresholds[warn].
    • hhi trägt den Herfindahl-Hirschman-Index der Einzeltitel-Gewichte (value = Summe der quadrierten Prozentgewichte, auf der 0-10000-Skala) plus ein band: low (< 1500), moderate ([1500, 2500]) oder concentrated (> 2500). Überschreibe die Schwellen mit hhi_bands[low] und hhi_bands[high].
    • asset_class_violations sind opt-in Anlageklassen-Cap-Verletzungen: es gibt keine voreingestellten Standardwerte, Caps werden pro Aufruf mit dem Query-Parameter asset_class_caps[<asset_class>] (ein Prozentwert, z. B. asset_class_caps[equity]=50) konfiguriert. Nur Klassen, deren aktuelles Prozentgewicht den Cap übersteigt, kommen zurück, je mit asset_class, current_weight, cap und overage (aktuell − Cap, in Prozentpunkten).

    Die Sicht ist eine reine Lese-Ableitung der Live-Bewertung und der Anlageklassen-Klassifizierung — nichts wird gespeichert, sie ist also deterministisch beim Lesen. Eine ungültige Überschreibung (z. B. ein nicht-positiver top_n) liefert 422 Unprocessable Entity; unbekannte Portfolios liefern 404 Not Found.

    Die abgeleiteten Kennzahlen des Portfolios reisen auf derselben Abfrage (ADR-0047, FR-40, Stufe (a) der Scope-Leiter) unter metrics, additiv. Die Risiko-Familie hat genau einen Endpunkt: es gibt kein /views/:id/risk, die Sicht kommt über den view-Parameter.

    • volatility, max_drawdown (mit peak_date, trough_date, recovery_date) und risk_adjusted_return, je über 30d, 90d und 365d. Sie lesen die flussbereinigten Tagesrenditefaktoren der TTWROR-Kette — nie die Wertänderung von Tag zu Tag —, eine Einzahlung oder Entnahme ist also keine Rendite: ein Portfolio, dessen Kurse sich nie bewegen, hat Volatilität und Drawdown von genau 0, egal wie viel Geld fließt. Ein Tag ohne Renditebasis erzeugt keine Beobachtung statt einer Nullrendite. Die Volatilität ist die Grundgesamtheits-Standardabweichung der Tagesrenditen, annualisiert mit √365 (Kalenderreihe); der Drawdown läuft über den verketteten Renditeindex.
    • risk_adjusted_return ist die mittlere tägliche Überrendite über dem risikofreien Anteil, mal 365, geteilt durch die annualisierte Volatilität. Der Query-Parameter risk_free_rate ist ein Decimal-Bruch (0.02 = 2 % p. a.), täglich verzinst wie der Festzins- Benchmark aus ADR-0046, gleich begrenzt, und standardmäßig 0 — dann ist die Zahl Rendite je Risikoeinheit, und computation_basis.reference sagt das. Kein Zins wird erschlossen, geladen oder gespeichert. Ein ungültiger Zins ist ein 422. Bei einer Volatilität von genau 0 ist der Quotient null ohne insufficient_data: undefiniert, nicht zu wenige Daten.
    • Eine Zahl, deren Quadratwurzel jenseits dessen liegt, was der eine Gleitkommaschritt tragen kann — eine Größenordnung, die nur unplausible gespeicherte Kurse oder Wechselkurse erreichen —, ist ebenfalls null ohne insufficient_data: diese Volatilität und die risikobereinigte Rendite daneben oder dieses Korrelationspaar. Die Abfrage scheitert an solchen Daten nie und liefert über ihnen nie eine Zahl; die Risiko-Seite zeigt ein solches Paar als „nicht berechenbar“ mit seinen Beobachtungen.
    • correlations ist die Pearson-Matrix der Tagesrenditen höchstens der 20 führenden Top-N-Einzeltitel (leading_names nennt, über wie viele sie lief; die Zahl der Paare wächst mit dem Quadrat der Titel, darum ist die Matrix begrenzt, die Liste nicht, und computation_basis sagt es), mit security_ids in Top-N-Reihenfolge und pairs mit security_id_a, security_id_b, value, über ein 365d-Fenster. Die Kurse werden zuerst in die Basiswährung umgerechnet, und ein Paar liest nur Tage, an denen beide Wertpapiere einen Kurs haben. Ein Wertpapier ohne gespeicherten Wechselkurspfad fehlt in der Matrix und steht in excluded mit reason: "no_rate_path".
    • Jede Kennzahl trägt window, observations, required und insufficient_data; unterhalb des Minimums — 20 Beobachtungen für Volatilität und risikobereinigte Rendite, 2 Indexpunkte für den Drawdown, 60 gemeinsame Renditen je Korrelationspaar — ist value null bei 200. metrics.computation_basis trägt die Berechnungsgrundlage einmal.
    • Die Abfrage berichtet, sie bewertet nicht. Kein Schlüssel in metrics ist ein Signal, eine Empfehlung, ein Rating, ein Score oder eine Handlung.
  • GET /api/v1/portfolios/:portfolio_id/cash_target liest das Cash-Ziel eines Plans, den SOLL-Cash-Anteil an der 100 %-Basis der Allokation (Wertpapiere + zählendes Cash). Die Antwort ist {"cash_target_weight": "0.05"} (ein String-Bruch in [0, 1] oder null, wenn keines gesteuert wird). Optionales view wählt den Plan (weggelassen = der Gesamt-Plan). Unbekannte Portfolios liefern 404 Not Found, ein fehlerhaftes view liefert 422 und eine unbekannte View-id 404.
  • PUT /api/v1/portfolios/:portfolio_id/cash_target setzt (oder löscht mit null) das Cash-Ziel eines Plans. Der Body ist {"cash_target_weight": "0.05"} und kann ein optionales "view": id tragen (weggelassen = Gesamt). Es gibt den gespeicherten Wert zurück. Gewichte außerhalb des Bereichs und Gewichte mit mehr als 6 Nachkommastellen liefern 422 Unprocessable Entity auf cash_target_weight, neben errors.row "cash", der Zeile, die der Plan-Editor nennt (Issue #1143). Das Cash-Ziel speist die cash-Zeile der Allokation und den top_level_target_sum der adressierten View.
  • PATCH /api/v1/portfolios/:portfolio_id patcht die Stammdaten eines Portfolios. Veraltet (ADR-0024) — antwortet mit Deprecation: true; nur Kompatibilität, nutze Buckets/Ansichten zur Gruppierung. Der Body ist {"portfolio": {...}}. Umzug des Cash-Ziels (ADR-0020): Das Cash-Ziel ist vom Portfolio-Objekt auf den View-gebundenen SOLL-Plan gewandert und wird über die beiden cash_target-Endpunkte oben bedient. Aus Kompatibilitätsgründen stellt das Portfolio-Objekt weiterhin cash_target_weight bereit — einen String-Bruch in [0, 1] (z. B. "0.05" für 5 %) oder null, um die Steuerung einer Cash-Quote zu beenden — und das Patchen liest/schreibt das Cash-Ziel des Gesamt-Plans (view weggelassen). Ein Client, der nur das alte Feld kennt, funktioniert also unverändert weiter; nutze PUT /cash_target?view=<id> für ein View-spezifisches Cash-Ziel. Gewichte außerhalb des Bereichs liefern 422 Unprocessable Entity; unbekannte Portfolios liefern 404 Not Found. Das Cash-Ziel wird nur geschrieben, wenn der Rumpf cash_target_weight trägt, in derselben Transaktion wie der Rest des Patches: Ein Patch ohne das Feld (etwa ein Umbenennen) lässt das gespeicherte Cash-Ziel und sein Journal unberührt, und ein abgelehntes Schreiben des Cash-Ziels antwortet mit 422, ohne dass etwas geschrieben wird, auch nicht der Rest des Patches. Die Antwort trägt das Cash-Ziel, wie es nach dem Schreiben gespeichert ist. Das cash_target_weight ist auch in den von GET/POST /api/v1/portfolios zurückgegebenen Portfolio-Objekten enthalten (das Gesamt-Cash-Ziel).
  • GET /api/v1/securities/:security_id/trades liefert FIFO-gematchte Trades eines Wertpapiers: offene Lots, geschlossene Round-Trips (mit realisiertem G/V und Haltedauer in Tagen) und etwaige verwaiste Verkäufe. Jedes offene Lot trägt buy_price (wie erfasst, Transaktionswährung) plus buy_price_native — die Basis in Wertpapierwährung, gegen die sein unrealized_pnl_* gerechnet wird (ADR-0033) — und dieselben Zerlegungsfelder in Basiswährung wie die Bestandszeilen (base_cost, price_return_*, currency_return_*, total_return_base_*, decomposed/undecomposed_reason, gegen den EUR-Hub, da FIFO-Lots je Wertpapier über Portfolios hinweg gematcht werden). Ein Lot ohne ableitbares Wertpapierwährungs-Leg meldet null-G/V statt eines blinden währungsübergreifenden Vergleichs. Die Antwort ist selbstbeschreibend (FR-13): sie trägt method: "fifo", sodass ein Client nie annehmen muss, wie Lots gegen Verkäufe gepaart wurden. Optionales from/to (ISO-Daten) filtert jedes Bein nach seinem eigenen Datum: offene Lots nach Eröffnungsdatum, geschlossene Round-Trips nach Schlussdatum, verwaiste Verkäufe nach Verkaufsdatum. Es gibt kein limit: from/to sind die Grenze (der FIFO-Matcher braucht die ganze Historie, und jedes Bein wird danach nach seinem eigenen Datum gefiltert), genannt im basis der Antwort. Seit Issue #984 trägt jeder geschlossene Round-Trip annualized_return und annualized_return_reason, dieselbe Zahl nach derselben Regel wie das Realisiert-Rollup oben, und die Antwort trägt computation_basis.annualized_return. Seit Issue #1108 stehen buy_fees und buy_taxes eines offenen Lots und buy_fees, buy_taxes, sell_fees und sell_taxes eines geschlossenen Round-Trips in seinem currency_code, wie basis und proceeds: Ein währungsübergreifender Trade, gebucht in der Wertpapierwährung, rechnet sie zu seinem eigenen gespeicherten settlement_fx_rate aus der Währung des Verrechnungskontos um, wie beim Realisiert-Rollup oben beschrieben, und computation_basis.fees_and_taxes nennt die Regel. Seit Issue #1142 liest ein geschlossener Round-Trip mit basis "0" realized_pnl_pct null mit annualized_return_reason no_cost_basis, wie im Realisiert-Rollup, und ein offener Lot, dessen Kosten (quantity × buy_price_native) "0" sind, behält sein unrealized_pnl_abs und liest unrealized_pnl_pct null, nie "0"; computation_basis.realized_pnl_pct und computation_basis.unrealized_pnl_pct nennen die Regeln. Wie beim Bestands-Endpunkt liest ein offener Lot mit base_cost "0" die drei Prozentwerte der Zerlegung ebenfalls null, und computation_basis.decomposition_pct nennt die Regel.
  • GET /api/v1/snapshots listet Depot-Snapshot-Marker (ADR-0027): jeder ist ein name, ein Geltungsbereich (view_id, null = alles) und ein as_of-Datum. Ein Snapshot kopiert keine Finanzdaten — die Bestände, die er repräsentiert, werden bei Bedarf aus dem Buchungsjournal abgeleitet. limit behält die neuesten Snapshots (Standard 1000, max. 10000); die Antwort nennt das angewandte limit.
  • POST /api/v1/snapshots legt einen Marker an ({"name": "...", "as_of": "2026-02-15", "view_id": 3}; view_id optional). Ein as_of in der Zukunft oder ein doppelter Name im selben Geltungsbereich liefert 422 Unprocessable Entity.
  • DELETE /api/v1/snapshots/:id löscht einen Marker; Transaktionen und Bestände bleiben unberührt.
  • GET /api/v1/portfolios/:portfolio_id/snapshots/:id/comparison beantwortet „Wäre ich besser gefahren, hätte ich behalten, was ich hatte?“: Die eingefrorenen Bestände des Snapshots werden buy-and-hold über die gespeicherte Kurshistorie bewertet (täglich, Wechselkurse über den EUR-Hub) und der echten TTWROR des Geltungsbereichs seit dem Stichtag gegenübergestellt. Die Antwort trägt as_of_value, current_value, snapshot_return, real_ttwror, eine tägliche series (snapshot_value, snapshot_indexed, real_indexed), eine Liste gaps.unvalued_securities der Wertpapiere, die mangels Kurs oder Wechselkurs zum Stichtag ausgeschlossen sind, und eine selbstbeschreibende basis (brutto, in v1 nur Kursentwicklung; sie trägt das window, die base_currency sowie costs_removed / costs_kept, die nennen, welche Kostenarten aus der Rendite genommen wurden). Alle Finanzwerte sind Decimal-Strings.

    Die beiden Seiten stehen nicht auf derselben Basis, und der Payload sagt das (#708, Ergänzung zu ADR-0027): Die eingefrorene Seite zahlt nie etwas, während die echte TTWROR nach den tatsächlich gebuchten Gebühren und Steuern gerechnet ist. Die Antwort trägt deshalb zusätzlich real_ttwror_before_costs (derselbe Lauf, in dem die Gebühren und Steuern der Handelsgeschäfte des Zeitfensters als externe Flüsse gewertet werden — eigenständige Gebühren- und Steuerbuchungen und die Quellensteuer auf Dividenden bleiben bewusst in der Rendite, weil der eingefrorene Halter sie ebenso gezahlt hätte), transaction_costs (deren Summe in der Basiswährung) und cost_recovery mit state: recovered, partly_recovered (vor Kosten vorne, nach Kosten noch nicht; outstanding nennt den Abstand), not_recovered (auch vor Kosten hinten, die Kosten sind also nicht der Grund) oder not_comparable (die eingefrorene Seite hat zum Stichtag keinen Wert — etwa ein Snapshot nur aus Cash —, es gibt also nichts, vor dem die echte Seite liegen könnte; die Kosten werden trotzdem gemeldet). Lesen Sie die drei zusammen: real_ttwror_before_costs allein schmeichelt.

Erfasste Steuerbescheinigungen (ADR-0031)

Diese Töpfe werden erfasst, nicht hergeleitet — und der Grund ist nicht ein fehlendes FIFO. Portfolixir ordnet Lots bereits nach FIFO zu (GET /api/v1/securities/:id/trades), und das ergibt einen Rohgewinn. Ein Rohgewinn ist kein Steuertopf: Teilfreistellung, Vorabpauschale, die chronologische Verrechnung des Freistellungsauftrags und der bescheinigte Verlustvortrag aus Vorjahren stehen gar nicht in den Transaktionsdaten, und die Töpfe werden je meldendem Institut geführt, das Portfolixir nicht modelliert. Ein hergeleiteter Topf wäre falsch, und zwar unsichtbar falsch — deshalb übernehmen diese Endpunkte eine Bankabrechnung und berechnen nie eine aus den Beständen. Jeder Geldwert ist ein Decimal-String als positiver Betrag: Ein Verlusttopf ist das zur Verrechnung verfügbare Verlustvolumen, nicht die negative Zahl, die die Abrechnung druckt, und eine negative Eingabe wird abgelehnt statt still umgedreht. Nichts hier ist Steuerberatung, und maßgeblich bleibt die erfasste Abrechnung. (Eine Steuer, die einem Geldkonto tatsächlich wieder gutgeschrieben wird — etwa nach einem Verlustverkauf —, ist ein Ledger-Ereignis, kein Topf: Sie wird als eigene tax_refund-Transaktion gebucht, wie oben unter „Transaktionen und Bestände“ beschrieben.)

  • GET /api/v1/tax/parameters listet die jahresbezogenen gesetzlichen Parameter (optionales jurisdiction, derzeit nur DE): capital_gains_tax_rate, solidarity_surcharge_rate und die Höchstbeträge saver_allowance_single / saver_allowance_joint. Sätze sind Decimal-String-Brüche ("0.25", nie "25"). Hinterlegt für 2009–2026; ein Jahr ohne Zeile fehlt schlicht — die API setzt nie den Höchstbetrag eines Nachbarjahres ein.
  • PUT /api/v1/tax/parameters legt die Parameter eines Jahres an oder ersetzt sie ({"parameters": {"tax_year": 2027, ...}}).
  • GET /api/v1/tax/profiles listet die zeitlich gültigen Steuerprofile einer Person (Pflichtparameter holder; fehlt er, folgt 422 Unprocessable Entity), das neueste valid_from zuerst. Für ein Datum gilt die Zeile mit dem größten valid_from an oder vor diesem Datum — nie ein exakter Treffer. Die Kirchensteuerpflicht steht standardmäßig auf nicht pflichtig mit Satz 0.
  • POST /api/v1/tax/profiles erfasst ein Profil ab einem Datum ({"profile": {"holder": "...", "valid_from": "2024-01-01"}}). Ein church_tax_rate ungleich null auf einem nicht pflichtigen Profil wird abgelehnt.
  • PATCH /api/v1/tax/profiles/:id korrigiert eine Profilzeile. Um eine Änderung der Situation festzuhalten, wird eine neue Zeile mit späterem valid_from angelegt — Bearbeiten schreibt die Geschichte um, Hinzufügen nicht.
  • DELETE /api/v1/tax/profiles/:id löscht eine Profilzeile. Bereits erfasste Snapshots behalten ihren eingefrorenen Kirchensteuersatz und bleiben unberührt.
  • GET /api/v1/tax/allowance_orders listet die Freistellungsaufträge, die die steuerpflichtige Person erteilt hat (optionales holder, institution, tax_year; Freitextfilter ignorieren Groß- und Kleinschreibung, sodass comdirect und Comdirect ein Institut sind). Jedes holder und institution, das die Steuer-Endpunkte schreiben oder nach dem sie filtern, wird auf eine Weise normalisiert — nach NFC zusammengesetzt, unsichtbare Formatzeichen entfernt, jede Folge von Unicode-Leerzeichen ein Leerzeichen, ein Wert, der nur aus solchen Zeichen besteht, ein 422 — und auf beiden Seiten über die Groß-/Kleinschreibungsfaltung der Datenbank abgeglichen, die Faltung der eindeutigen Indizes (E25 S6).
  • PUT /api/v1/tax/allowance_orders erfasst oder ersetzt den erteilten Betrag für ein (holder, institution, tax_year) ({"allowance_order": {...}}).
  • DELETE /api/v1/tax/allowance_orders/:id löscht einen hinterlegten Auftrag.
  • GET /api/v1/tax/statement_snapshots listet erfasste Bescheinigungen (optionales holder, institution, tax_year), das neueste as_of zuerst. Jede Zeile trägt die elf erfassten Zahlen plus allowance_remaining, tax_free_trim_budget, expected_capital_gains_tax, das as_of, auf dem sie beruhen, ein stale-Flag und die beratenden findings. Jede Zeile trägt außerdem die aktivitätsbewusste staleness-Bewertung (#667): Veraltung als Funktion der Aktivität, nicht nur des Kalenders. Sie nennt ihre eigene Berechnungsbasis — age_days gegen age_threshold_days (90), activity_since_count (die Ledger-Transaktionen der activity_kinds — sell, dividend, interest, tax, tax_refund — mit Datum strikt nach as_of), die beiden Teilwarnungen und die kombinierte warning, dazu einen basis-Hinweis, der festhält, dass Buchungen keinem Institut und keinem Steuerjahr zugeordnet werden. warning ist das Signal zum Handeln; das bloße stale-Flag kippt am Tag nach jedem as_of und bleibt aus Kompatibilitätsgründen erhalten.
  • POST /api/v1/tax/statement_snapshots erfasst eine Bescheinigung ({"statement_snapshot": {"institution": "...", "holder": "...", "tax_year": 2025, "as_of": "2025-12-31", ...}}). as_of darf nicht in der Zukunft liegen. Ohne church_tax_rate gilt der Satz des zum as_of geltenden Profils der Person; der aufgelöste Satz wird dann auf der Zeile eingefroren, sodass eine spätere Profiländerung nie eine erfasste Übertragung umschreibt. Dasselbe (institution, holder, tax_year, as_of) erneut zu erfassen ist ein 422, kein stilles Duplikat. Die Quelle einer erfassten Steuerbescheinigung setzt das System (manual); eine source im Body einer Anlage oder einer Korrektur wird ignoriert.
  • GET /api/v1/tax/trim_budget rollt die jeweils neueste Bescheinigung je Institut zu einer Person und einem Jahr auf (Pflichtparameter holder und tax_year); Institute werden nach der Groß-/Kleinschreibungsfaltung der Datenbank gruppiert, sodass eine spätere Bescheinigung unter einer anderen Schreibweise derselben Bank die frühere ersetzt. Die Antwort nennt, welche institutions sie abdeckt, das as_of ihres ältesten Bestandteils, die summierten allowance_granted und allowance_used, aus denen der Füllstand auf der Steuern-Seite gelesen wird (neben allowance_remaining), und complete: false mit missing_institutions, wenn ein hinterlegter Freistellungsauftrag für das Jahr keine erfasste Bescheinigung hat — die Summe ist dann ein Teilbild und sagt das. Die Antwort trägt dieselbe staleness-Bewertung, berechnet gegen das (älteste) as_of des Rollups.
  • GET /api/v1/tax/statement_snapshots/:id liest eine erfasste Bescheinigung.
  • PATCH /api/v1/tax/statement_snapshots/:id korrigiert eine erfasste Bescheinigung an Ort und Stelle — der Fall der neu ausgestellten Bescheinigung.
  • DELETE /api/v1/tax/statement_snapshots/:id löscht eine erfasste Bescheinigung.

Prüfbefunde sind beratend und werden beim Lesen berechnet. Ein Befund nennt die recorded und die expected Zahl und die gap; er schlägt nie einen korrigierten Wert vor und blockiert nie einen Schreibvorgang. Zwei Regeln sind dagegen hart und kommen als 422-Changeset-Fehler zurück: verbrauchter Freistellungsauftrag über dem erteilten, und einbehaltene Kirchensteuer bei einem Kirchensteuersatz von null.

Eigene Regeln (ADR-0049)

Eine eigene Regel ist ein gespeicherter Maßstab über eine Zahl, die das Produkt ohnehin liefert: „kein Einzeltitel über 10 %”, „Barmittel nie unter 5 %”, „die Kategorie Anleihen innerhalb von ±3 Prozentpunkten ihres Ziels”, „die 90-Tage-Volatilität des Portfolios unter 15 %”. Sie wird als Objekt gespeichert, statt als Fließtext in einem geplanten Prompt zu stehen — dort, wo solche Grenzen bisher auseinanderliefen. Der Betreiber legt Regeln auf der Seite Risiko an, ein Agent über die API mit seinem Token; eine Regel ist also eine gespeicherte Regel, wer sie auch geschrieben hat: Das Audit-Journal (resource_type policy_rule und policy_rule_version) sagt, wer jede Regel und jede Version geschrieben hat, und die rules_note der Liste verweist dorthin. Jede Version trägt zudem author (E25): operator für eine auf der Seite Risiko gespeicherte Version, agent für eine mit einem API- oder MCP-Token geschriebene, abgeleitet aus dem Zugang des Schreibzugriffs und nie aus dem Body gelesen; ein Befund trägt den author der gültigen Version. Eine Version von vor den Autoren erhält den Autor, den ihr Anlegen im Journal nennt, null nur ohne einen solchen Eintrag. Risiko markiert die Regeln des Agenten mit dem Wort „Agent”; ab wann eine Regel gilt, hängt nicht von ihrem Autor ab.

Was eine Regel ist. Eine Aussage über eine benannte Kennzahl, für einen Bezug, in einem Auswertungskontext:

  • der Kontext ist das Portfolio plus eine optionale view_id (null ist der portfolioweite Kontext, wie bei Zielplänen); die Kennzahl wird auf der steuerbaren Basis dieses Kontexts gelesen;
  • der Bezug ist basis, security (security_id), category (classification_id + category_id), view (subject_view_id) oder cash;
  • die Kennzahl ist eine dieser, und jede wird aus einer Antwort gelesen, die das Produkt schon liefert — Regeln berechnen keine eigene Zahl:

    measure Bezüge Skala der Grenzen
    weight security, category, cash, view Prozent, 0–100
    drift category, security Prozentpunkte, −100–100 (Ist − Ziel des aktiven Plans)
    hhi basis 0–10000
    volatility basis, mit window (30d, 90d, 365d) Prozent, annualisiert, ≥ 0
    max_drawdown basis, mit window Prozent im eigenen Vorzeichen, −100–0

    Ein security-Bezug für drift nennt zusätzlich die classification_id, deren Plan sein Positionsziel trägt. Ein view-Bezug ist die Art, einen Bucket zu begrenzen: „der spekulative Bucket bleibt unter 5 % von allem” ist eine weight-Obergrenze auf der View, die diesen Bucket auswählt, ausgewertet im portfolioweiten Kontext.

  • die Art ist cap (verletzt strikt oberhalb von threshold), floor (strikt unterhalb) oder band (außerhalb von [lower, upper]) — dieselbe Lesart einer Linie wie die Risikolinse;
  • der Schweregrad ist warn oder hard; name und note sind die Worte der Regel, wer sie auch geschrieben hat, und werden nie ausgewertet. Der name ist eine Bezeichnung der Regel, nicht Teil ihrer Identität: Er lässt sich jederzeit ohne neue Version ändern (siehe das Umbenennen unten) und muss nicht eindeutig sein.

Grenzen sind Decimal-Strings (ADR-0016). Eine Aussage, die nicht zu ihrer Kennzahl passt — ein Bezug außerhalb der Tabelle, ein fehlendes oder überflüssiges window, ein Band mit lower > upper, eine Grenze außerhalb der Skala — ist ein 422, das Feld unter version benannt.

Versioniert und mit Gültigkeitsdatum. Eine Regel hat eine feste Identität und eine oder mehrere Versionen, jede der Maßstab ihres eigenen Zeitraums [valid_from, valid_until]. Eine Änderung legt eine Version an ab valid_from (standardmäßig heute, nie früher) und beendet die vorige am Tag davor; beide bleiben lesbar, sodass „welcher Maßstab galt am Tag D” eine Abfrage ist (as_of=) und keine Recherche im Audit-Journal. Eine Version, die schon gegolten hat, wird nie geändert und nie gelöscht — die Datenbank lehnt es ab, ebenso zwei sich überschneidende Versionen einer Regel. Eine erst geplante Version wird ersetzt, indem man eine Version ab demselben Datum anlegt. Die Datenbank lehnt außerdem bei jeder Version eine Änderung ihrer Regel, ihrer Aussage oder ihres Startdatums ab, bei jeder Regel eine Änderung ihres Portfolios oder ihrer View, und ein TRUNCATE beider Tabellen. Das Enddatum einer Version und der Name einer Regel bleiben schreibbar, über das Beenden, die Änderung und das Umbenennen. Jeder Schreibvorgang wird journalisiert.

Die Lesezugriffe:

  • GET /api/v1/portfolios/:portfolio_id/policy_rules — die Regeln des Portfolios, jede mit status (in_force, scheduled oder retired, bezogen auf as_of), version_in_force und next_version. as_of (ISO-Datum, standardmäßig heute) liest den Maßstab eines Tages; include_retired=true nimmt beendete Regeln dazu; view beschränkt auf einen Auswertungskontext (ohne: alle Kontexte); since ist die Zeilen-Delta (eine Regel gilt als geändert, wenn ihre Zeile oder eine ihrer Versionen geändert wurde); limit (Standard 1000, höchstens 10000).
  • GET /api/v1/policy_rules/:id — eine Regel mit ihrer ganzen Versionsgeschichte, älteste zuerst.

Die Schreibzugriffe:

  • POST /api/v1/portfolios/:portfolio_id/policy_rules — Rumpf {"rule": {"name", "view_id", "version": {…}}}; legt die Regel mit ihrer ersten Version an (201).
  • POST /api/v1/policy_rules/:id/versions — Rumpf {"version": {…}}; die Änderung (201). Auf beiden fasst die note einer Version höchstens 10000 Zeichen (Unicode-Codepoints); eine längere liefert 422 auf note.
  • PATCH /api/v1/policy_rules/:id — Rumpf {"name": "…"}; das Umbenennen, eine Änderung an der Regel selbst außerhalb der Versionen: Es entsteht keine Version, keine wird geändert, und der neue Name gilt für die Regel mit allen Versionen (200, die Regel mit ihren Versionen). Das Journal hält den bisherigen Namen und wer ihn geändert hat. Auch bei einer beendeten Regel erlaubt. Gelesen wird nur name, wie bei PATCH /api/v1/plans/:id: Ein leerer, fehlender oder nicht-textueller Name ist ein 422 auf name; ein Feld der Aussage, eine version, die Versionsschlüssel der eigenen Leseform der Regel (version_in_force, next_version, versions) oder der Kontext (view_id, portfolio_id) im selben Rumpf ist ein 422, das jedes solche Feld nennt, und nichts wird geschrieben — eine neue Linie ist eine neue Version, und eine Regel in einem anderen Kontext ist eine neue Regel. Jeder andere Schlüssel wird ignoriert. Eine Regel, die seit dem Lesen gelöscht wurde, ist ein 404.
  • POST /api/v1/policy_rules/:id/retire — optional valid_until; beendet die geltende Version standardmäßig gestern (heute Abend, wenn sie erst heute begann) und verwirft danach geplante Versionen. Die Regel bleibt mit include_retired=true lesbar. Eine Regel, deren keine Version je gegolten hat, oder eine schon beendete, ist ein 409.
  • DELETE /api/v1/policy_rules/:id — nur, solange keine Version je gegolten hat (204); sonst 409, und der Ausweg ist, sie zu beenden.

Eine neue Version, ein Beenden und ein Löschen halten die Regel jeweils, während sie deren Versionen lesen; zwei davon auf derselben Regel kommen daher nacheinander dran: Eine Version, die hinzukommt, während ein Beenden läuft, wartet darauf und wird gegen die beendete Regel geprüft, statt sie zu überdauern. Eine Regel, die seit dem Lesen gelöscht wurde, ist bei allen drei ein 404.

Was eine Regel liest, ist geschützt. Das Löschen eines Wertpapiers, einer Kategorie, einer Klassifizierung oder einer View, auf die eine Regelversion (oder der Kontext einer Regel) verweist, antwortet mit 409 und errors.policy_rules — id, name und status jeder Regel — sowie einem detail, das jede Regel mit ihrer id und ihrem Status nennt (policy rule(s): #7 (in_force)), nie mit ihrem Namen (#965), und den Ausweg nennt. Eine Version, die gegolten hat, behält ihren Bezug als Aufzeichnung dessen, was der Maßstab war; eine Regel zu beenden stoppt ihre Auswertung, gibt das Objekt aber nicht frei. Nur eine Regel, deren keine Version je gegolten hat, lässt sich löschen, und das gibt es frei. Ein Wertpapier bleibt wie bisher stilllegbar (is_retired).

Die Befunde. GET /api/v1/portfolios/:portfolio_id/policy_findings wertet die heute geltenden Regeln eines Auswertungskontexts (view; ohne: die portfolioweiten Regeln) über die Zahlen aus, die das Produkt schon liefert, und antwortet mit einem Befund je Regel, sortiert verletzt, nicht bestimmbar, eingehalten:

  • state ist breached (strikt jenseits der Linie), ok oder undetermined — die Zahl ließ sich nicht lesen. Nicht bestimmbar ist nie bestanden und wird standardmäßig nie ausgefiltert; es trägt seinen Grund reason: insufficient_data (eine verweigerte Portfolio-Kennzahl, mit required und observations aus ADR-0047), undefined, no_active_plan oder no_target (eine Abweichung ohne Ziel), empty_basis (ein Gewicht von nichts ist nicht 0 %), unvalued (der Gegenstand wird gehalten, seine Position lässt sich aber nicht bewerten, etwa ein Kurs ohne gespeicherten Wechselkurs), subject_not_found oder not_measured. Ein Wertpapier, das man schlicht nicht hält, hat das Gewicht 0 — das ist ein Messwert.
  • jeder Befund trägt Identität und Worte der Regel, die Version, die Grenzen, den gemessenen value und den vorzeichenbehafteten Abstand distance zur nächsten Linie (Wert − Linie, auf der Skala der Kennzahl) sowie seine eigene computation_basis mit der gelesenen Quelle; die Antwort trägt summary (Anzahl je Zustand) und eine computation_basis mit Eingangsreihe, Fenster, Bezug und Umgang mit Lücken.
  • status schränkt auf eine kommagetrennte Menge von Zuständen ein: status=breached ist die abrufbare Alarmliste. Sie wird abgerufen; nichts wird irgendwohin geschickt. Ein unbekannter Zustand ist ein 422; since gilt bewusst nicht, weil Befunde eine abgeleitete Projektion sind und keine Zeilen.

Die Kennzahlen werden gelesen, nie berechnet: Einzeltitelgewicht und HHI der Risikolinse, Kategorie- und Barmittelgewicht und die Abweichung des aktiven Plans aus der Aufteilung (Anteile × 100), die Bewertung für den Anteil einer View, und Volatilität und maximaler Drawdown der Portfolio-Kennzahlen im Fenster der Regel (Verhältnisse × 100). Die Abfrage ist unter der Datenversion des Portfolios und seinem Regelzähler zwischengespeichert; eine geänderte Grenze wird beim nächsten Lesen ausgewertet.

Was diese Schnittstelle nicht ist. Eine Regel ist ein Maßstab, nie eine Anweisung, und ein Befund trägt keine Handlung — keine Stückzahl, kein Handelsverb, keinen Vorschlag; ein Meta-Test prüft die gerenderte Antwort darauf. Hier wird kein Trade platziert, vorgeschlagen oder bemessen, nichts wird irgendwohin geschickt, und keine Regel wird über einen Zeitraum vor ihrem valid_from zurückgerechnet (das wäre Backtesting, Stufe (d) der Scope-Leiter).

Wechselkurse

  • GET /api/v1/exchange_rates listet gespeicherte Wechselkurse. Kurse werden gegen den EUR-Hub gehalten (1 base_currency = rate quote_currency); andere Paare werden durch Triangulation abgeleitet, und GBX (Pence) wird als GBP × 100 behandelt. limit behält die jüngsten Kurse (Standard 50000, max. 200000). Jeder gespeicherte Wechselkurs ist wie ein Kurs begrenzt: positiv und nicht nach morgen datiert. Eine Synchronisierung verwirft einen Anbieterkurs außerhalb der Grenze, statt den Lauf scheitern zu lassen, und die Lesepfade für den jüngsten Wechselkurs nutzen nie eine gespeicherte Zeile nach dieser Grenze.
  • POST /api/v1/exchange_rates/sync holt Kurse vom konfigurierten Anbieter (standardmäßig EZB) und liefert {provider, status, upserted, scope}. scope=latest (Standard) holt den täglichen Feed — die heutigen Kurse, nichts aus der Vergangenheit. scope=history (Issue #737, Sprint-9-D-1) führt das einmalige Backfill der historischen EZB-Reihe (eurofxref-hist.xml) über denselben Upsert-Pfad aus: jeden veröffentlichten Tag auf einmal, sodass eine datierte Umrechnung (ein realisierter Gewinn, eine Kosten- oder Flussbuchung, die die Cashflow-Facetten wegen eines fehlenden Buchungstagskurses ausgeschlossen und benannt haben) ihren Kurs findet. Die Regel zur Kursverfügbarkeit bleibt unverändert — ein Tag, den die EZB nicht veröffentlicht hat (ein Wochenende, eine nicht gelistete Währung), bleibt ausgeschlossen und benannt; das Backfill füllt Daten, es lockert die Basis „exakter Buchungstagskurs“ nicht. Ein unbekannter scope ist ein 422, ein Anbieter ohne Historie antwortet mit 422 und benennt scope, ein Anbieterfehler oder Kurse, die die Datenbank nicht speichern kann, liefern 502 Bad Gateway, und nichts wird gespeichert. Ein Backfill läuft zur Zeit: Während einer läuft, liefert ein zweiter 409 Conflict. Die menschliche Sicht ist die Schaltfläche Historische Kurse nachladen in den Ausschluss-Hinweisen auf /cashflow. Seit Issue #1120 (Sprint-20-D-5) führt die Instanz dieses Backfill auch von selbst aus, einmal, nach ihrem Start-Abgleich und nach einem Import, wenn eine Buchung oder ein Verrechnungskonto in einer Nicht-EUR-Währung vor dem frühesten gespeicherten Kurs dieser Währung liegt, außer die Hintergrund-Downloads sind abgeschaltet (PORTFOLIXIR_BACKGROUND_FETCH=off). scope=history bleibt für einen Lauf von Hand: wo der automatische den Anbieter nicht erreicht hat, auf einer Instanz mit abgeschalteten Hintergrund-Downloads oder aus jedem anderen Grund für ein erneutes Holen; während der automatische läuft, antwortet es wie jedes zweite Backfill mit 409. Es kommt kein Parameter hinzu, und kein Tool ändert sich.

Klassifizierungen

Klassifizierungsbäume ordnen Wertpapiere wie Ordner. Integrierte Bäume (asset_class, currency) werden automatisch abgeleitet und ihre Struktur ist gesperrt; das Bearbeiten der Struktur eines integrierten Baums liefert 422 Unprocessable Entity. Die Mitgliedschaft des Anlageklassen-Baums ist jedoch nur eine Sicht auf das asset_class-Feld jedes Wertpapiers: in der UI wird ein Wertpapier zwischen seinen Kategorien gezogen (was dieses Feld setzt), und derselbe Effekt wird über die API mit PATCH /api/v1/securities/:id ({"security": {"asset_class": "etf"}}) oder dem MCP-Tool securities.update erzielt. Setze es auf leer/null für „automatisch”, was die Klasse beim Lesen aus Name/ISIN/Ticker neu inferiert. Der Währungsbaum bleibt intrinsisch und kann nicht neu zugeordnet werden.

  • GET /api/v1/classifications listet jede Klassifizierung als Baum mit ihren categories und assignments ({security_id, category_id}). Integrierte Bäume tragen built_in: true und einen key.
  • POST /api/v1/classifications legt eine eigene Klassifizierung aus einem classification-Objekt an (name, optional position, description).
  • PATCH /api/v1/classifications/:id aktualisiert das classification-Objekt einer eigenen Klassifizierung (name, position, description — alle optional).
  • DELETE /api/v1/classifications/:id löscht eine eigene Klassifizierung mit ihren Kategorien, ihren gespeicherten Zuordnungen und jedem Plan darauf samt dessen Zielen. Jede dieser Zeilen wird als eigene Löschung journalisiert, bevor die Klassifizierung gelöscht wird; keine Zeile verschwindet allein durch eine Datenbank-Kaskade.
  • POST /api/v1/classifications/:classification_id/categories fügt einer eigenen Klassifizierung eine category hinzu (name, optional color, description, parent_id, position).
  • PATCH /api/v1/classifications/:classification_id/categories/:id patcht eine category (name, color, description, parent_id, position — alle optional). Die classification_id der Kategorie kann so nicht geändert werden. Bei beiden Schreibzugriffen muss parent_id eine Kategorie derselben Klassifizierung sein, die weder die Kategorie selbst noch eine ihrer Unterkategorien ist; jede andere Oberkategorie liefert 422 an parent_id, und nichts wird geschrieben, sodass ein Baum nie im Kreis läuft (E25 S4). Ein Baum hat höchstens 32 Ebenen: Eine Oberkategorie, unter der die geschriebene Kategorie oder die tiefste Kategorie des Teilbaums, den ein PATCH verschiebt, unter Ebene 32 läge, liefert ebenfalls 422 an parent_id und nennt die Ebene („would put a category on level 33; a classification has at most 32 levels“, Issue #940).
  • DELETE /api/v1/classifications/:classification_id/categories/:id löscht eine Kategorie mit den Kategorien darunter, den dort zugeordneten Wertpapieren und den dort abgelegten Zielen; jede Zeile wird als eigene Löschung journalisiert, die untersten Kategorien zuerst und die Kategorie selbst zuletzt.
  • PUT /api/v1/classifications/:classification_id/assignments ordnet ein Wertpapier einer Kategorie zu (security_id, category_id) und ersetzt jede bestehende Zuordnung dieses Wertpapiers in der Klassifizierung. Die Antwort trägt einen status von created, moved oder unchanged plus previous_category_id.
  • PUT /api/v1/classifications/:classification_id/assignments/bulk ordnet viele Wertpapiere in einem Aufruf einer Kategorie zu (category_id, security_ids) und liefert {assigned, category_id, security_ids}.
  • DELETE /api/v1/classifications/:classification_id/assignments/:security_id entfernt die Zuordnung eines Wertpapiers aus der Klassifizierung.

Beispiel-Payload für eine Transaktion:

{
  "transaction": {
    "portfolio_id": 1,
    "securities_account_id": 1,
    "security_id": 1,
    "type": "buy",
    "date": "2026-05-15",
    "quantity": "10.00000000",
    "price": "123.45",
    "fees": "1.50",
    "taxes": "0",
    "currency_code": "EUR"
  }
}

Buckets und Views

Buckets sind überlappende Tags, die auf Bestände (Depots, Geldkonten und einzelne Wertpapier-Positionen) angewendet werden, um Vermögen tag-basiert einzugrenzen. Views sind benannte, globale Filter über diese Buckets: ein Bestand passt, wenn er eingeschlossen ist (immer unter include_all, sonst wenn er einen der Include-Buckets der View trägt) und keinen der Exclude-Buckets der View trägt — Exclude gewinnt immer. Bucket-Definitions- und Zuordnungs-Schreibvorgänge werden journalisiert (ADR-0017). Die Definition einer View ebenfalls (ADR-0018 §5 in der Fassung von Sprint 16), weil eine gültige Richtlinienregel sie liest: PATCH /api/v1/views/:id, PUT /api/v1/views/:id/buckets und DELETE /api/v1/views/:id hinterlassen je einen Eintrag mit resource_type=view unter der View, dessen before und after die ganze Definition tragen — name, include_all, include_bucket_ids und exclude_bucket_ids; wer die gespeicherte Definition erneut sendet, hinterlässt keinen. Das Anlegen einer View wird nicht journalisiert: Noch keine Regel kann sie lesen.

  • GET /api/v1/buckets listet Buckets (id, name, color).
  • POST /api/v1/buckets legt einen Bucket aus einem bucket-Objekt an (name erforderlich, optionales color). Ein leerer oder doppelter Name ergibt 422.
  • GET /api/v1/buckets/:id liefert einen Bucket; unbekannte ids ergeben 404.
  • PATCH /api/v1/buckets/:id ändert name/color eines Buckets.
  • DELETE /api/v1/buckets/:id löscht einen Bucket (204 No Content). Zuvor wird er aus jeder View und jeder Zuordnung, die ihn nennt, über deren journalisierten Schreibweg entfernt, sodass jeder betroffene Eigentümer seinen eigenen Eintrag erhält: die Sets jeder View vorher und nachher, jedes Standard-Set eines Depots und Set eines Geldkontos, jede Positions-Zuordnung. Eine Position, deren bestimmte Buckets nur aus diesem bestehen, bleibt ausdrücklich leer („keine Buckets“) und erbt nicht vom Depot; sie gerät so in keine View, in der sie vorher nicht war. Der delete-Eintrag des Buckets trägt seine Zeile und jede Zugehörigkeit, die er hatte (memberships: view_include, view_exclude, depot_defaults, cash_accounts, position_overrides). Auch ein Bucket, den die View einer Richtlinienregel liest, wird gelöscht; ein schon gelöschter Bucket antwortet mit 404. Das Entfernen des Buckets aus einem Set prüft die exklusive Scope-Dimension nicht erneut, sodass ein Set, das gespeichert wurde, bevor diese Regel galt, das Löschen nie blockiert; ein Set, aus dem der Bucket nicht entfernt werden kann, antwortet mit 422, und nichts wird gelöscht.
  • GET /api/v1/views listet Views. Jede View trägt include_all, das aufgelöste include-Set (das Literal "all" unter include_all, sonst eine Liste von Bucket-ids) und die exclude-Liste von Bucket-ids.
  • POST /api/v1/views legt eine View aus einem view-Objekt an (name erforderlich, optionales include_all, Standard true).
  • GET /api/v1/views/:id liefert eine View mit ihrem aufgelösten Filter.
  • PATCH /api/v1/views/:id ändert name/include_all einer View.
  • DELETE /api/v1/views/:id löscht eine View und ihre Bucket-Sets (204); die auf sie bezogenen Pläne samt Zielen und ihre Depot-Schnappschüsse werden je als eigene Löschung journalisiert, bevor die View gelöscht wird.
  • PUT /api/v1/views/:id/buckets ersetzt die Include-/Exclude-Bucket-Sets einer View. Body: {"include": [..], "exclude": [..]} (beide optional, Standard [], Listen von Bucket-ids). Eine fehlerhafte id-Liste ergibt 422; ein Bucket, der in einer Liste zweimal steht, zählt einmal.
  • GET /api/v1/views/:view_id/valuation liefert die Live-Bewertung einer View über alle Portfolios (ADR-0024) in der Form der Portfolio-Bewertung mit view_id statt portfolio_id; jedes zur View passende Konto zählt genau einmal, overlap nennt die Konten mit mehreren eingeschlossenen Buckets, und unvalued_cash_count zählt die zur View passenden Geldkonten ohne Kurspfad mit einem Saldo ungleich null, wie bei der Portfolio-Bewertung. include_positions=false (FR-37, #740 — derselbe Parameter wie bei der Portfolio-Bewertung) liefert nur den Roll-up: Summen, Cash-Salden und Cash-Quote ohne die Positionszeilen; die Antwort benennt positions_included. Ein ungültiger Wert ist ein 422.
  • GET /api/v1/valuation liefert die Summe über jedes Portfolio ohne View (#1007): die unbegrenzte Vereinigung aller Depots, Positionen und Geldkonten, jedes einmal gezählt, immer in EUR, und die erste Zahl, die eine frische Instanz beantworten kann, da sie noch keine View hat. Sie entspricht der Summe der Übersicht, wenn das erste Portfolio die Basiswährung EUR hat und keine Standard-Ansicht gesetzt ist; die Übersicht bewertet ihre Karte in dieser Währung und folgt der Standard-Ansicht, sonst können beide Zahlen abweichen. Die Form ist die der View-Bewertung mit view_id: null und ohne view-Spiegelung (overlap meldet keine Überschneidung, matches_no_accounts ist false), und die valuation_note benennt den Bereich. include_positions=false liefert nur den Roll-up, wie bei der View-Abfrage; ein ungültiger Wert ist ein 422. MCP: portfolixir.views.valuation ohne id. Performance, Benchmark-Vergleich und Beitragsanalyse haben dieselbe Form ohne View (#1056), unten nach ihren View-Abfragen aufgeführt.
  • GET /api/v1/views/:view_id/performance liefert TTWROR und geldgewichtete Rendite (IRR) der View über alle Portfolios: exakt der deduplizierte Konten-Scope, den auch die View-Bewertung abdeckt, sodass Gesamtwert und Rendite immer über dieselben Konten sprechen. Geld, das die View-Grenze überquert, zählt als externer Fluss (ADR-0019); Geld zwischen zwei Konten innerhalb der View saldiert sich. ?period= (ytd|1y|3y|5y|max, Standard max), ?year=YYYY, ?from=/?to= (freier Zeitraum) und ?series=true verhalten sich wie beim Portfolio-Performance-Endpunkt; die Antwort spiegelt dessen Form mit view_id statt portfolio_id, alle Finanzwerte sind Decimal-Strings. Unbekannte und fehlerhafte View-ids liefern 404, ein fehlerhafter Zeitraum 422.
  • GET /api/v1/views/:view_id/performance/benchmark liefert den Benchmark-Vergleich der View über alle Portfolios (ADR-0046 §3): derselbe deduplizierte Konten-Scope, den View-Bewertung und View-Performance abdecken, sodass Gesamtwert, Rendite und Benchmark der View über dieselben Konten sprechen. benchmark= ist Pflicht, period, year, from/to und series=true verhalten sich wie beim Portfolio-Benchmark-Endpunkt; die Antwort spiegelt dessen Form mit view_id statt portfolio_id. Unbekannte und fehlerhafte View-ids liefern 404, ein fehlerhafter Zeitraum oder eine fehlerhafte Benchmark 422.
  • GET /api/v1/views/:view_id/performance/contribution liefert die Beitragsanalyse der View über alle Portfolios (FR-41, ADR-0051 §6): derselbe deduplizierte Konten-Scope, den die View-Performance abdeckt, in EUR, sodass totals.result deren Geldergebnis ist und eine in mehreren Portfolios gehaltene Position eine Zeile ist. period, year und from/to verhalten sich wie beim Portfolio-Beitragsendpunkt; die Antwort hat dessen Form mit portfolio_id: null, view_id und der gespiegelten View. Unbekannte und fehlerhafte View-ids liefern 404, ein fehlerhafter Zeitraum 422.
  • GET /api/v1/performance, GET /api/v1/performance/benchmark und GET /api/v1/performance/contribution sind die drei Abfragen oben ohne View (#1056): jedes Konto jedes Portfolios, jedes einmal gezählt, der Bereich, den die Vermögensseite „Alles“ nennt, lesbar, bevor es eine View gibt. Jede nimmt die Parameter ihrer View-Abfrage (benchmark= bleibt beim Vergleich Pflicht) und antwortet in deren Form mit view_id: null (und portfolio_id: null bei der Beitragsanalyse) und ohne view-Spiegelung; ein fehlerhafter Zeitraum ist ein 422, eine fehlende oder abgelehnte Benchmark ein 422 auf benchmark. Keine nimmt einen Bereich: view= und portfolio_id= werden ignoriert, wie bei GET /api/v1/valuation. Sie antworten in EUR, über den EUR-Hub, wie die View-Abfragen und GET /api/v1/valuation. Die Vermögensseite rechnet ihre Alles-Zahlen in der Basiswährung des ersten Portfolios, sodass beide abweichen, wenn diese nicht EUR ist, und jede Antwort sagt das: computation_basis.input_series schließt mit dem Bereich (jedes Konto jedes Portfolios, keine View) und der Währung. Eine View mit include_all und ohne Ausschluss liefert dieselben Zahlen. MCP: portfolixir.views.performance, portfolixir.views.benchmark und portfolixir.views.contribution ohne id.
  • GET /api/v1/views/:view_id/category-results?classification_id=<id> liefert das Ergebnis je Kategorie (ADR-0041) der zur View passenden Positionen über alle Portfolios, jedes Konto einmal gezählt (#901). Die Form ist die der Portfolio-Abfrage mit scope: "view", portfolio_id: null und gespiegelter View. Die Werte sind in EUR: Der investierte Betrag eines Mitglieds ist der tatsächlich gezahlte Abrechnungsbetrag (ADR-0033), also hat ein Mitglied aus einem Portfolio mit anderer Basiswährung als EUR keine EUR-Kosten zum Addieren und fehlt mit missing_base_cost, statt über Währungen hinweg summiert zu werden. Unbekannte und fehlerhafte View-ids liefern 404, eine fehlende classification_id 422, eine unbekannte 404. MCP: portfolixir.portfolios.category_results mit view und ohne portfolio_id.
  • GET /api/v1/category-results?classification_id=<id> liefert das Ergebnis je Kategorie jedes Portfolios, ohne View (#1091): den Roll-up, den die Klassifizierungsseite zeigt, in der Form der Portfolio-Abfrage mit scope: "all", portfolio_id: null, view_id: null, base_currency: "EUR" und ohne view-Spiegelung. Die EUR-Regel der View-Abfrage gilt: Ein Mitglied aus einem Portfolio mit anderer Basiswährung als EUR fehlt mit missing_base_cost, und excluded_members nennt es mit seinen Einstandskosten in der Währung, in der sie bezahlt wurden; basis_note schließt mit dem Bereich. Sie nimmt keinen Bereich: view= und portfolio_id= werden ignoriert, wie bei GET /api/v1/valuation. Eine fehlende classification_id ist ein 422, eine unbekannte ein 404. MCP: portfolixir.portfolios.category_results ohne portfolio_id und ohne view.
  • PUT /api/v1/securities_accounts/:id/buckets ersetzt das Standard-Bucket-Set eines Depots (die Buckets, die jede Position erbt, sofern nicht überschrieben). Body: {"bucket_ids": [..]}.
  • PUT /api/v1/cash_accounts/:id/buckets ersetzt das Bucket-Set eines Geldkontos. Body: {"bucket_ids": [..]}.
  • PUT /api/v1/securities_accounts/:id/positions/:security_id/buckets setzt die Positions-Überschreibung für ein Wertpapier in einem Depot. Ein leeres bucket_ids speichert den explizit-leeren Zustand (bewusst keine Buckets), unterschieden vom Erben des Depot-Standards; die Überschreibung gewinnt immer gegenüber dem Depot-Standard. Die Antwort nennt das aufgelöste override (inherit, explicit_empty oder explicit) und die effective_bucket_ids.
  • DELETE /api/v1/securities_accounts/:id/positions/:security_id/buckets setzt die Überschreibung zurück, sodass die Position wieder den Depot-Standard erbt.

Die vier Zuordnungs-Schreibvorgänge oben halten das Depot oder Geldkonto, während sie sein Set ersetzen. Zwei Schreibvorgänge auf dasselbe Konto kommen daher nacheinander dran: Es bleibt das Set dessen, der zuletzt festschreibt, nie eine Mischung aus beiden. Ein Konto, das gelöscht wird, während der Schreibvorgang wartet, antwortet mit 404 und es wird nichts geschrieben. Die beiden Schreibvorgänge der Positions-Überschreibung halten nach dem Depot auch das Wertpapier; ein Wertpapier, das gelöscht wird, während sie warten, antwortet ebenso mit 404 (#919).

Die Analyse-Endpunkte akzeptieren einen optionalen view-Query-Parameter (eine View-id), um das Ergebnis auf die Bestände der View einzugrenzen:

  • GET /api/v1/portfolios/:portfolio_id/valuation?view=<id>
  • GET /api/v1/portfolios/:portfolio_id/allocation?classification_id=<id>&view=<id>
  • GET /api/v1/portfolios/:portfolio_id/performance?view=<id>
  • GET /api/v1/portfolios/:portfolio_id/performance/contribution?view=<id>
  • GET /api/v1/portfolios/:portfolio_id/risk?view=<id>
  • GET /api/v1/portfolios/:portfolio_id/category-results?classification_id=<id>&view=<id>

Bei gesetztem view spiegelt die Antwort die aktive View als view: {id, name} wider (FR-13); der Aufruf ohne View ist unverändert und trägt kein view-Feld. Eine fehlerhafte View-id ergibt 422, eine unbekannte 404. Derselbe view-Scope (und derselbe 422/404-Vertrag) gilt für die SOLL-Ziel-Endpunkte — GET/PUT /api/v1/portfolios/:portfolio_id/targets, DELETE /api/v1/portfolios/:portfolio_id/targets/:category_id und die Cash-Ziel-Endpunkte GET/PUT /api/v1/portfolios/:portfolio_id/cash_target —, wo eine View den SOLL-Plan wählt (weggelassen = der Gesamt-Plan). Der Bestände- Endpunkt (GET /api/v1/portfolios/:portfolio_id/holdings) ist nicht view-eingegrenzt: er liefert die Roh-Zeilen pro (Depot, Wertpapier) in der jeweiligen Wertpapierwährung, sodass ein Client das Buckets/Views-Modell selbst anhand von securities_account_id und security_id jeder Zeile anwenden kann.

Einstellungen

Ein minimaler Schlüssel-Wert-Speicher trägt die nutzerseitigen Voreinstellungen (ADR-0024). Heute gibt es eine: die Standard-Ansicht, mit der Vermögensseite und Übersicht öffnen, wenn in der UI keine Ansicht ausdrücklich gewählt wurde. Finanzielle Decimals kommen hier nicht vor.

  • GET /api/v1/settings/default_view liefert die aktuelle Voreinstellung: {"data": {"view_id": null, "view": null}} wenn keine gesetzt ist (die eingebaute Alles-Sicht), sonst die id plus ein view: {id, name}-Echo.
  • PUT /api/v1/settings/default_view setzt sie. Body: {"view_id": <id>} mit einer existierenden View-id, oder {"view_id": null} zum Zurücksetzen auf Alles. Eine unbekannte View-id liefert 404 (nichts wird geschrieben); eine fehlerhafte view_id liefert 422. Die Antwort entspricht dem GET-Format.

Importe

Portfolio-Performance-Importe (CSV/JSON v1) laufen ausschließlich über die Import-Ansicht: Es gibt absichtlich keinen Import-Endpunkt unter /api/v1 und kein MCP-Tool dafür — der Vorschau-dann-Anwenden-Schritt mit seinen Zuordnungsentscheidungen ist eine Betreiber-Handlung (ADR-0029). Was API- und MCP-Konsumenten wissen müssen, ist die Bewahrungsgarantie beim erneuten Import, denn alles, was ein Agent über diese API schreibt, liegt neben der importierten Historie:

  • Dasselbe Export erneut anwenden ist ein No-op per Inhalts-Hash. Jede bereits vorhandene Transaktionszeile wird als Duplikat übersprungen; kein Wertpapier wird doppelt angelegt; die Antwort des Anwendens meldet die übersprungene Anzahl. Der Hash ist eindeutig (zwei verschiedene Zeilen teilen nie einen), und jeder Hash, der vorher gespeichert wurde, wird weiter gefunden. Eine von einer Zeile abgespaltene Steuererstattung wird mit dieser Zeile gehasht und über ihre eigenen Hashes und ihren wirtschaftlichen Schlüssel geprüft, sodass zwei gleiche Erstattungen zweier verschiedener Verkäufe beide gebucht werden, eine bereits importierte Erstattung unter beiden Formeln gefunden wird und eine Erstattung, die zu einer schon importierten Zeile hinzukam, gebucht wird; eine Erstattung, deren Zeile nicht importiert wird, wird mit ihr übersprungen.
  • Eine Umbenennung ist sicher für den nächsten Import (ADR-0050 §3, §4). Der Hash wird geprüft, bevor irgendetwas aufgelöst wird, und ein Verrechnungskonto oder Depot entsteht erst mit seiner ersten neuen Buchung, sodass eine Umbenennung über PATCH /api/v1/cash_accounts/:id oder PATCH /api/v1/securities_accounts/:id (portfolixir.cash_accounts.update, portfolixir.securities_accounts.update, deren Beschreibungen das sagen) kein leeres Konto unter dem alten Namen hinterlässt. Die Umbenennung behält den alten Namen in former_names, und der Import löst den Kontonamen einer Datei zuerst über den aktuellen Namen auf, dann über die früheren Namen, sodass auch ein Export, der sich in Portfolio Performance verändert hat (andere Nachkommastellen, eine bearbeitete Buchung), auf das umbenannte Konto bucht und nichts doppelt. Was außen bleibt: Ein alter Name, den ein anderes Konto noch als aktuellen Namen trägt, bucht auf jenes Konto, und eine Umbenennung, die älter ist als das Audit-Journal der Konten, hat nichts zum Merken hinterlassen. Ändert der Operator eine Vorbelegung in der Vorschau auf ein Konto anderen Namens, wird die Zuordnung standardmäßig als früherer Name dieses Kontos gemerkt. Ist der Name früherer Name eines anderen Kontos, verschiebt die Importseite ihn erst, nachdem seine Zeile das vor dem Bestätigen gesagt hat, und das Ergebnis nennt das Konto, das ihn abgab; DELETE /api/v1/cash_accounts/:id/former_names?name= (oder das Gegenstück unter securities_accounts) entfernt einen früheren Namen weiterhin von Hand. Eine Umbuchung, deren beide Seiten auf ein Konto führen, wird übersprungen und aufgeführt, nie ein gescheiterter Import.
  • Eine Zusammenführung von Geldkonten ist sicher für den nächsten Import (ADR-0050 §2, §7). Nach POST /api/v1/cash_accounts/:id/merge legt ein erneut angewendeter, schon importierter Export nichts an, byte-gleich oder verändert: Die verschobenen Buchungen behalten ihre Inhalts-Hashes, jede Buchung, die die Zusammenführung gelöscht hat, hat ihren Hash stillgelegt, der Name der Quelle führt als früherer Name zum Ziel, und eine Umbuchung zwischen beiden wird als intern übersprungen. Neue Zeilen eines späteren Exports, die das zusammengeführte Konto nennen, werden einmal gebucht, auf das Ziel. Zwei Grenzen werden genannt, nicht versteckt: Eine neue Zeile, deren wirtschaftlicher Schlüssel einer vorhandenen Buchung des Ziels gleicht, gilt als diese Buchung (gemeldet mit der Ebene economics), und eine Zeile, die auf oder vor einem gesetzten Saldo datiert ist, den die Zusammenführung angepasst hat, wird gebucht, aber von diesem Saldo aufgefangen — der Import führt sie als hinter einem angepassten gesetzten Saldo gebucht auf, mit dem Saldo.
  • Eine Zusammenführung von Depots ist sicher für den nächsten Import (ADR-0050 §2, §7). Nach POST /api/v1/securities_accounts/:id/merge legt ein erneut angewendeter, schon importierter Export nichts an, byte-gleich oder verändert: Die verschobenen Buchungen behalten ihre Inhalts-Hashes, jede Buchung, die die Zusammenführung gelöscht hat, hat ihren Hash stillgelegt, der Name des Quelldepots führt als früherer Name zum Ziel, und eine Wertpapierumbuchung zwischen beiden wird als intern übersprungen. Neue Zeilen eines späteren Exports, die das zusammengeführte Depot nennen, werden einmal gebucht, auf das Ziel. Eine neue Zeile, deren wirtschaftlicher Schlüssel einer vorhandenen Buchung des Ziels gleicht, gilt als diese Buchung (gemeldet mit der Ebene economics).
  • Eine Zusammenführung von Wertpapieren ist sicher für den nächsten Import (ADR-0050 §2, §9). Nach POST /api/v1/securities/:id/merge legt ein erneut angewendeter, schon importierter Export nichts an, byte-gleich oder verändert, welche ISIN er auch trägt und bei jeder der beiden Identitätswahlen: Die verschobenen Buchungen behalten ihre Inhalts-Hashes, jede Buchung, die die Zusammenführung gelöscht hat, hat ihren Hash stillgelegt, und jede Identität der Quelle — ihre ISIN (jetzt die des Ziels oder eine frühere ISIN des Ziels), ihre früheren ISINs, die Identität, die ihr Import aufgezeichnet hat — führt zum Ziel. Neue Zeilen eines späteren Exports, die das zusammengeführte Wertpapier über eine davon nennen, werden einmal gebucht, auf das Ziel. Die Zusammenführung wird abgelehnt, statt eine Identität unaufgelöst zu lassen (identity_unresolvable).
  • Was einen erneuten Import unverändert übersteht, gleiche ids, exakte Decimal-Werte: Klassifizierungs-Zuordnungen; jede Zielplan-Version mit ihren Kategorie- und Positionszielen sowie dem Cash-Ziel; note und attributes jedes Wertpapiers einschließlich eigener Schlüssel; das Research-Log (/api/v1/securities/:id/notes — die nur anhängbaren Einträge und der daraus abgeleitete thesis_state, ADR-0044); die Wertpapier-Termine (/api/v1/securities/:id/events — jeder datierte Kalenderfakt mit seiner Qualifizierung, seiner Bestätigung und seinem checked_at, ADR-0048 §7); die eigenen Regeln (/api/v1/portfolios/:id/policy_rules — jede Regel und jede Version mit ihren Bezugs-ids, Grenzen und Zeiträumen, ADR-0049 §8); Wertpapier-ids und updated_at. Festgehalten in test/portfolixir/imports/reimport_preservation_test.exs seit Issue #664 (Research-Log ergänzt durch #748, Wertpapier-Termine durch #829, eigene Regeln durch #864).
  • Ein veränderter erneuter Import (eine Umbenennung, ein erfasster ISIN-Wechsel, der über einen Alias oder eine explizite Zuordnung aufgelöst wird) hält dieselbe Garantie für die zugeordneten Wertpapiere; nur die wirklich neuen Buchungen landen.
  • Dort ausgesprochen, wo ein Agent liest (Issue #831). Die Beschreibungen der acht MCP-Reads, die die Garantie schützt — portfolixir.notes.list, .unreviewed, .uncorroborated, .expiring und portfolixir.events.list, .upcoming, .unconfirmed, .stale — tragen den Satz „A Portfolio Performance re-import does not destroy the research log or the security events”, sodass ein Agent die Antwort in der Tool-Liste findet, die er ohnehin liest, statt auf dieser Seite. Der veränderte Pfad hält neben den Terminen auch das Research-Log fest. Die Regel-Reads (portfolixir.policy_rules.list, .get und portfolixir.portfolios.policy_findings) tragen den eigenen Satz „A Portfolio Performance re-import does not destroy the policy rules”, und der veränderte Pfad hält eine Regel über das Wertpapier mit neuer ISIN fest.
  • Nicht abgedeckt: eine in der Quelle geänderte Buchung. Eine bearbeitete Transaktion hasht anders und wird als neue Zeile neben der alten importiert; die alte Buchung wird über PATCH/DELETE /api/v1/transactions/:id entfernt oder korrigiert. Die Garantie betrifft, was Portfolixir rund um die Historie pflegt, nicht den Abgleich zweier Versionen der Historie selbst.

Ein Research-Log, ein Kalender, eine Regel, ein Plan oder eine Zuordnung „verschwindet“ also nie beim nächsten Import; ein Agent, der etwas anderes beobachtet, hat einen Defekt gefunden, keine dokumentierte Grenze.

Kontraktversion

Die Oberfläche sagt, was sie ist, damit ein Konsument bemerkt, wenn sie sich ändert (ADR-0044 §8). Eine Tool-Beschreibung wird einmal beim Verbinden gelesen; dieser Read ist der Weg, auf dem ein Agent erfährt, dass die Liste oder die Beschreibungen sich bewegt haben.

  • GET /api/v1/contract liefert das Kontrakt-Manifest: version (eine ganze Zahl, die des neuesten Eintrags), last_changed_at (ISO-Datum), endpoints_total, tools_total und entries neueste zuerst — jeder mit version, date, summary, den endpoints ("VERB /api/v1/pfad") und tools, die die Änderung hinzugefügt hat, parameters (ein Satz je Parameter, der zu einem bestehenden Read kam) sowie etwaigen removed_endpoints / removed_tools. Optionales since=JJJJ-MM-TT verengt die Einträge auf die streng nach diesem Tag datierten und beantwortet changed — die ?since=-Idee auf den Kontrakt statt auf die Zeilen angewandt: last_changed_at speichern, damit abfragen und Tool-Liste und Beschreibungen neu lesen, wenn changed true ist. Ein ungültiges since ist ein 422.
  • Das Manifest wird im Code gepflegt (PortfolixirWeb.Api.V1.Contract), und ein Meta-Test bindet das /api/v1-Inventar des Routers und das Tool- und das Prompt-Inventar des MCP-Begleitdienstes in beiden Richtungen daran, sodass eine ohne Manifest-Eintrag hinzugefügte, umbenannte oder entfernte Route, ein solches Tool oder ein solcher MCP-Prompt den Build scheitern lässt; ein Eintrag nennt einen Prompt in seinem summary und seinen parameters. Der erste Eintrag hält die Sprint-9-Ergänzungen fest — das Research-Log, den Thesenstand, die Parameter include_positions / min_drift auf View-Ebene und Positionsebene (#740), den historischen Backfill-Scope (#737) und diesen Read.

Audit-Journal

Jeder finanzielle Schreibvorgang (Anlegen, Ändern, Löschen) wird in einem append-only Audit-Journal in derselben Datenbanktransaktion wie der Schreibvorgang selbst festgehalten, sodass jede Änderung — auch Löschungen — nachvollziehbar und zurechenbar bleibt. Marktdaten-Synchronisierung (Kurse und Wechselkurse) ist betriebliche Datenpflege und wird bewusst nicht journalisiert. Ein Kurs, den jemand schreibt, ist keine Synchronisierung: Der Kurs-Upsert und die Freigabe manueller Kurse werden unter resource_type=security_quotes journalisiert, unter der Id des Wertpapiers, mit den ersetzten oder freigegebenen Zeilen als Vorher-Abbild.

  • GET /api/v1/journal listet Journal-Einträge, neueste zuerst. Jeder Eintrag trägt actor_type (owner_ui, api_token_rw, api_token_ro, import_session, system_job) und ein optionales actor_label (bei einem API-Token der Name seines PORTFOLIXIR_API_TOKENS-Eintrags; null beim unbenannten Standard), die operation (create, update, delete, upsert), den betroffenen resource_type/resource_id sowie die before/after-Schnappschüsse (Decimal-Werte sind Strings). Optionale Filter: resource_type, resource_id, actor_type, operation, limit (Standard 100, max. 1000) und include_scenarios (true, um persistierte Was-wäre-wenn-Schreibvorgänge einzuschließen; standardmäßig nur echte Schreibvorgänge). Die Antwort ist selbstbeschreibend: ein meta-Objekt nennt den as_of-Zeitpunkt, die Sortierung order (inserted_at:desc,id:desc), die Anzahl count und die angewandten filters. SOLL-Ziel-Schreibvorgänge (Kategorie- und Positionszeilen gleichermaßen) werden unter resource_type=target journalisiert, Schreibvorgänge an Planversionen unter resource_type=target_plan.
  • Das before einer Änderung oder Löschung ist die Zeile, wie sie gespeichert war, als der Schreibvorgang sie gesperrt hat, und das after einer Änderung die Zeile, wie sie danach gespeichert ist — Dezimalwerte in der Skala ihrer Spalte. Zwei Schreibvorgänge auf Grundlage eines Lesevorgangs reihen sich daher aneinander: Das before des zweiten ist das after des ersten. Ein Schreibvorgang auf einen inzwischen gelöschten Datensatz antwortet mit 404 und hinterlässt keinen Eintrag.
  • Der Eintrag einer Bucket-Zuordnung (depot_bucket_assignment, cash_account_bucket_assignment, position_bucket_override) bildet die Menge ab, wie sie gespeichert ist, ihre bucket_ids nach id geordnet: before ist die Menge, als der Schreibvorgang ihr Konto gesperrt hat, after die Menge, die er gespeichert hat. Bei einer Positionsausnahme ist before null, wo die Position die Menge ihres Depots geerbt hat, und [], wo sie keine Buckets hatte. Wer die gespeicherte Menge erneut sendet, hinterlässt keinen Eintrag, und ebenso, wer die Ausnahme einer Position aufhebt, die schon erbt (#953).

Jeder Schreibkontext mit Finanzdaten journalisiert: Die Einführung, die ADR-0017 in Schritten geplant hat, ist abgeschlossen. Jede Tabelle mit Finanzdatensätzen trägt einen Guard-Trigger, der einen Schreibvorgang ohne Akteur ablehnt; ein Schreibpfad, der das Journal vergisst, scheitert also, statt eine Lücke zu hinterlassen. Die Einträge stehen unter diesen resource_type-Codes:

  • Wertpapiere und ihre ISIN-Aliasse: security, security_identifier_alias; die Kurse, die jemand schreibt: security_quotes;
  • Portfolios, Geldkonten und Depots: portfolio, cash_account, securities_account;
  • Buchungen, auch die eines angewandten Imports (Akteur import_session): transaction;
  • Klassifizierungen: classification, category, security_category_assignment;
  • SOLL-Pläne: target, target_plan;
  • Steuerdaten: tax_parameters, tax_profile, allowance_order, tax_statement_snapshot;
  • das Research-Log und die Wertpapier-Termine: security_note, security_event;
  • eigene Regeln: policy_rule, policy_rule_version;
  • die Zusammenführungen des Lebenszyklus: merge_record, retired_import_hash;
  • Buckets, Views und Depot-Snapshot-Marker: bucket, view, depot_bucket_assignment, cash_account_bucket_assignment, position_bucket_override, snapshot.

Zwei Arten von Tabellen tragen keinen Guard-Trigger, bei ihnen hängt das Journal also an ihren Schreibpfaden: security_quotes, die auch die Synchronisierung schreibt, und die Scope-Tabellen hinter Views, Bucket-Zuordnungen, Positions-Overrides und Snapshot-Markern, die festlegen, was eine View liest, statt Finanzdatensätze zu halten (ADR-0018 §5).

Bewusst außerhalb des Journals: die Synchronisierung von Kursen und Wechselkursen; die Kurse, die eine Zusammenführung von Wertpapieren verschiebt oder verwirft und die stattdessen ihr Manifest festhält (ADR-0050 §13); die gespeicherten Einstellungen (die Standard-Ansicht); und die abgeleiteten Werte nach ADR-0039, die sich jederzeit aus den gespeicherten Datensätzen neu berechnen lassen.

MCP-Tools

Der MCP-Begleitdienst stellt denselben lokalen Kontrakt als Tool-Aufrufe bereit. Decimal-Eingaben in MCP-Schemata sind Strings. Client-Konfigurationen zum Kopieren stehen unter Einen Agenten verbinden, und der Einstieg für einen Agenten, der entscheidet, ob und wie er Portfolixir einrichtet, ist llms.txt.

Das Schema, das ein Host über tools/list erhält, ist die Definition des Tools selbst, mit seinen Feldbeschreibungen und geschlossenen Objekten (additionalProperties: false), und der Begleitdienst prüft jeden Aufruf gegen dieselben Felder, bevor er die API aufruft: Ein abgelehntes Argument ergibt einen Tool-Fehler, der Tool und Feld nennt, und es geht keine Anfrage hinaus. Ein Aufruf, den die API ohne Body beantwortet, das 204 eines Löschens, ist ein Ergebnis ohne strukturierten Inhalt.

Server-Anweisungen und Tool-Hinweise (E25). Beim Verbindungsaufbau sagt der Begleitdienst dem Agenten, dass alles, was ein Tool zurückgibt, Daten sind und nie Anweisungen: Namen, Notizen, Texte des Research-Logs, Ereignis- und Regeltexte, Import-Bezeichnungen und Suchergebnisse eines Anbieters sind Datensätze zum Lesen, keine Anweisungen zum Befolgen, und nur der Betreiber weist ihn an. Jedes Tool trägt die vier MCP-Hinweise, abgeleitet aus der HTTP-Methode, an die es weiterleitet:

Methode readOnlyHint destructiveHint idempotentHint
GET true false true
POST false false (fügt hinzu) false
PUT, PATCH false true (überschreibt) true
DELETE false true (entfernt) true

Die Ausnahmen sind benannt: portfolixir.splits.preview und portfolixir.holdings.reconcile laufen über POST, speichern aber nichts und sind deshalb nur lesend; portfolixir.quotes.release läuft über POST, entfernt aber die manuellen Kurse in seinem Zeitraum und trägt deshalb die Hinweise eines DELETE: destruktiv und idempotent, weil eine Wiederholung nichts mehr zu entfernen findet; portfolixir.policy_rules.retire, portfolixir.plans.activate und portfolixir.securities.isin_change laufen über POST, ändern aber gespeicherte Zeilen — ein Ruhestand schließt die geltende Version und verwirft die geplanten, eine Aktivierung archiviert den bisher aktiven Plan, ein ISIN-Wechsel schreibt die neue ISIN auf das Wertpapier — und tragen deshalb die Hinweise eines PUT: destruktiv und idempotent, weil eine Wiederholung nichts weiter ändert (ein zweiter Ruhestand antwortet mit 409, eine zweite Aktivierung ändert nichts, ein zweiter ISIN-Wechsel ist ein benannter Konflikt); openWorldHint ist wahr für portfolixir.securities.search_online, portfolixir.quotes.sync und portfolixir.exchange_rates.sync, die einen externen Anbieter erreichen, und für portfolixir.securities.create, das bei eingeschalteter Anreicherung der Instanz einen Kursnachlauf beim Anbieter und eine Logo-Suche anstößt. Die nur anfügenden Schreibvorgänge, portfolixir.notes.append und die Versionen einer Regel, sind nicht destruktiv, und ihre Beschreibungen sagen, dass das Angefügte dauerhaft ist.

Ohne Rückfrage freigebbare Lesezugriffe. Ein Host darf jedes Tool mit readOnlyHint: true ohne Rückfrage ausführen: Keines davon verändert die Instanz. portfolixir.securities.search_online gehört dazu, sendet seine Anfrage aber an den konfigurierten Anbieter; lassen Sie es hinter einer Rückfrage, wenn Ihnen das wichtig ist. Ein Host, der vor jedem anderen Tool fragt, mindestens aber vor jedem mit destructiveHint: true, behält jeden Schreibvorgang im Blick.

Nur-Lese-Modus. Mit PORTFOLIXIR_MCP_READ_ONLY=true läuft der Begleitdienst nur lesend: tools/list listet dann nur die Tools mit readOnlyHint: true, und ein Aufruf jedes anderen Tools, gelistet oder nicht, wird als Tool-Fehler abgelehnt, der den Schalter nennt, bevor eine Anfrage hinausgeht. Standardmäßig ist er aus, und jeder andere Wert als true, false, 1, 0 oder leer stoppt den Begleitdienst mit dem Namen der Variable. Der Schalter schränkt den Begleitdienst ein, nicht das Token: PORTFOLIXIR_API_TOKEN behält seine volle Befugnis über die API.

Tool-Profile. PORTFOLIXIR_MCP_PROFILE nimmt read, book oder full (Standard), in beliebiger Schreibweise. read ist der Nur-Lese-Modus oben. full listet und ruft jedes Tool. book listet die Lesezugriffe, jedes Anlegen und die ersetzenden Schreibzugriffe und lässt die Admin-Menge weg, eine ausdrückliche Liste im Begleitdienst (mcp-server/src/profiles.ts), gezogen nach einer Linie, wie sie umgesetzt ist: entfernende Tools sind Admin, ersetzende Schreibzugriffe bleiben in book. Jedes Entfernen ist Admin (jedes Löschen, das Entfernen eines früheren Namens und eines ISIN-Alias, das Aufheben einer Klassifikationszuordnung, das Löschen einer Positions-Überschreibung, die Freigabe manueller Kurse), auch wo ein späteres Schreiben eine gleiche Zeile zurückbringen könnte, ebenso die drei Zusammenführungen, die ISIN-Änderung und das Stilllegen einer Regel. Ein ersetzender Schreibzugriff (ein Ändern, ein Upsert, eine Bucket-Menge, das Aktivieren eines Plans) bleibt in book, weil derselbe Schreibzugriff mit dem früheren Wert ihn rückgängig macht; eine enger gesendete Bucket-Menge oder eine auf [] gesetzte Positions-Überschreibung bleibt ebenfalls, das Löschen der Überschreibung ist Admin. Zwei behaltene Schreibzugriffe hinterlassen einen Rest, den ihr Gegenstück nicht beseitigt: eine Rückbenennung stellt den Namen wieder her, behält aber den Zwischennamen als früheren Namen, den kein neues Konto annehmen darf und auf den ein späterer Import bucht, bis portfolixir.cash_accounts.remove_former_name (oder der Depot-Zwilling), ein Admin-Tool, ihn entfernt; und ein Upsert über einem Datum mit Anbieterdaten lässt dieses Datum manuell, was nur portfolixir.quotes.release, ein Admin-Tool, zurückgibt. Die Schreibzugriffe, die book behält, stehen als zweite Liste neben der Admin-Menge, jeder mit seinem Grund, und ein Test des Begleitdienstes schlägt für jedes Tool mit destructiveHint: true fehl, das in keiner der beiden steht. Unter book wird ein Aufruf eines Admin-Tools, gelistet oder nicht, als Tool-Fehler abgelehnt, der das Profil und PORTFOLIXIR_MCP_PROFILE=book nennt, bevor eine Anfrage hinausgeht, und die Server-Anweisungen nennen das Profil in einem Satzteil. PORTFOLIXIR_MCP_READ_ONLY=true ist read: allein oder neben PORTFOLIXIR_MCP_PROFILE=read stimmt es überein, und neben PORTFOLIXIR_MCP_PROFILE=book oder full stoppt es den Begleitdienst und nennt beide Variablen; PORTFOLIXIR_MCP_READ_ONLY=false, der ausgelieferte Standard, heißt nur, dass der Schalter aus ist, und widerspricht nie. Jeder andere Wert stoppt den Begleitdienst mit dem Namen der Variable. Ein Profil schränkt den Begleitdienst ein, nicht das API-Token. Es hält einen unbeaufsichtigten Agentenlauf davon ab, über den Begleitdienst zu löschen oder zusammenzuführen; ein Agent, der die API mit dem Token erreicht, kann weiterhin jede Route aufrufen.

Bereichs-Zwillinge. Bewertung, Performance, Benchmark-Vergleich und Beitragsanalyse gibt es in zwei Bereichen, und jedes Tool eines Paares nennt das andere in seiner Beschreibung mit dem Unterschied (Sprint 17, D-6): portfolixir.portfolios.* gilt für einen Portfolio-Datensatz in seiner Basiswährung, sein view grenzt innerhalb dieses Portfolios ein; portfolixir.views.* gilt für eine View über jedes Portfolio, jedes Konto einmal gezählt, in EUR. Die Summe über jedes Portfolio braucht keine View: portfolixir.views.valuation ohne id liest sie (GET /api/v1/valuation, #1007), immer in EUR über jedes Konto — die Summe der Übersicht, wenn das erste Portfolio die Basiswährung EUR hat und keine Standard-Ansicht gesetzt ist —, also ist eine Summe von Portfolio-Bewertungen nie der Weg dorthin. Auch die Tools für View-Performance, View-Benchmark und View-Beitragsanalyse brauchen keine View: Ohne id liest jedes jedes Konto in EUR (GET /api/v1/performance, /performance/benchmark, /performance/contribution, #1056), also ist eine Rendite oder ein Beitrag über alles eine Abfrage, nie eine Summe von Portfolios — Renditen mehrerer Portfolios addieren sich nie. Die Vermögensseite rechnet ihre Alles-Zahlen in der Basiswährung des ersten Portfolios, sodass sie von diesen abweichen, wenn diese nicht EUR ist, wie die computation_basis jeder Antwort sagt.

Prompts. Der Begleitdienst bietet zwei MCP-Prompts (prompts/list, prompts/get), unter jedem Profil dieselben, und jeder trägt den Rahmen ohne Beratung in seinem eigenen Text: Das System bereitet Entscheidungen vor, der Betreiber führt sie aus, und nichts platziert, schlägt vor oder bemisst einen Trade.

  • first_setup prüft die Instanz (portfolixir.contract.get) und nennt das aktive Profil, liest, was vorhanden ist, schlägt Cash-Konten, Depots, Buckets und Views vor (keine für die Summe über jedes Portfolio, die keine View braucht), erklärt, wie Daten hineinkommen, und schreibt nichts ohne die Bestätigung des Betreibers.
  • import_converter (optionales Argument export_file) leitet den Agenten des Nutzers an, auf dem Rechner des Betreibers einen Konverter von einem Bank- oder Broker-Export zu schreiben und auszuführen, der eine Portfolio-Performance-CSV-v1-Datei, die der Betreiber auf der Import-Seite ablegt, erzeugt; die Seite zeigt sie in der Vorschau und übernimmt sie idempotent. Er nennt das Format genau: die Kopfzeile, ;, Daten als YYYY-MM-DD, deutsche Zahlen, die deutschen Typ-Bezeichnungen, Betrag als Bargeldwirkung jeder Zeile, Konto und Gegenkonto je Typ, die Regel nur in EUR und die JSON-v1-Variante für andere Währungen und für ISINs, mit je einem synthetischen Beispiel, das ein Test durch die echte Import-Seite führt. Er nennt die Zeilen, die die Vorschau ablehnt: eine Umbuchung ohne beide Seiten, einen Kauf oder Verkauf ohne sein Verrechnungskonto, eine ISIN ohne gültige Prüfziffer und, in JSON v1, eine Währung außer denen, die der Wertpapier-Dialog anbietet. Er bindet den Agenten: kein Broker-Sync, kein Netzwerk- und kein Modellaufruf, nur synthetische Beispiele, und die umgewandelten Zeilen einzeln über portfolixir.transactions.create zu buchen, ist kein Ersatz, weil das die Vorschau und die Idempotenz über den Inhalts-Hash übergeht.

Die Prompts gibt es nur über MCP: Ein Prompt ist eine Anweisung an den Agenten des Nutzers, und die API hat nichts, dem sie ihn liefern könnte.

Unsichtbare Zeichen. Jeder Schreibzugriff lehnt die Zeichen ab, die der Betreiber nicht sehen kann (siehe „Text“ oben), aber eine Zeile von vor dieser Regel kann sie noch tragen. Der Begleitdienst ist der Ort, an dem sie sichtbar werden: Jeder String einer API-Antwort, ein Wert oder ein Schlüssel in jeder Tiefe, erreicht den Agenten mit jedem solchen Zeichen als [U+XXXX] ([U+200B] für ein Leerzeichen der Breite null) — dieselbe Schreibweise, die die Oberfläche dem Betreiber für diese Zeile zeigt, und die Server-Anweisungen sagen es. Die JSON-API selbst liefert gespeicherten Text, wie er gespeichert ist. Ein Text, den der Agent zurückschreibt, trägt die Kürzel als die sichtbaren Buchstaben, die sie sind.

Ein Datensatz in einem Satz (#965). Ein Satz, den die App schreibt, benennt einen anderen Datensatz mit seiner Art und id, nie mit seinem gespeicherten Namen, damit ein Name, der sich wie eine Anweisung liest, nie in den eigenen Worten der App steht: Eine Ablehnung eines ISIN-Wechsels oder Alias nennt security #12, eine Ablehnung wegen eines früheren Namens beim Schreiben eines Geldkontos oder Depots nennt cash account #5 oder securities account #5 (errors.former_names: „include the name of cash account #5 in this portfolio“), und das Detail einer Prüfung beim Zusammenführen von Wertpapieren nennt Regeln, Depots, Buckets und Portfolios mit ihren ids (policy rule(s): #7 (in_force), In depot #3, in portfolio #1) und den Namen einer Identität als „its recorded name“, und ein erneut gebuchter Split nennt das Portfolio des bestehenden Ereignisses mit seiner id (for portfolio #1). Ein Löschen, das abgelehnt wird, weil eine Richtlinienregel das Objekt liest (409), nennt die Regeln ebenso, und das Detail einer Prüfung beim Zusammenführen von Geldkonten oder Depots nennt Buckets und eine Position mit ihren ids (the buckets #3, #7, security #12 sits in the bucket #9). Der Name ist ein Feld des Datensatzes, über seine id zu lesen (der eines Depots über GET /api/v1/securities_accounts/:id, eines Buckets über GET /api/v1/buckets/:id, einer Regel über GET /api/v1/policy_rules/:id, eines Wertpapiers über GET /api/v1/securities/:id, eines Portfolios über GET /api/v1/portfolios). Die errors einer Ablehnung tragen einen Namen neben dem Detail nur an zwei Stellen: errors.policy_rules[].name, mit id und status jeder Regel, und errors.unresolvable[].ref.name, den erfassten Namen der Identität; ein Depot, ein Portfolio und ein Bucket stehen dort nur mit ihren ids. Die Oberfläche von Portfolixir nennt ihn weiter beim Namen.

Ein Schreibvorgang ohne Antwort. Jeder API-Aufruf hat eine Frist von 30 Sekunden. Ein Lesezugriff, der sie verpasst — ein GET oder eines der über POST laufenden Tools, die nichts ändern (readOnlyHint: true) —, ändert nichts und ergibt ApiReadTimeoutError, darf also wiederholt werden; jeder andere Aufruf, der sie verpasst, ergibt ApiOutcomeUnknownError: Der Begleitdienst hat aufgehört zu warten, der Server kann den Schreibvorgang aber trotzdem übernommen haben, also lesen Sie vor einer Wiederholung neu, was er geändert hätte. Eine blinde Wiederholung eines Schreibvorgangs, der einen Datensatz anfügt, kann ein Duplikat speichern, und jedes solche Tool (jeder nicht idempotente Schreibvorgang) sagt das in seiner Beschreibung; die Server-Anweisungen sagen es einmal für jeden Schreibvorgang.

Ein Schreibvorgang, dessen Verbindung scheitert, ohne dass sich zeigt, dass die Anfrage nie abging (ein Reset, ein Socket, der sich schließt, während die Antwort ankommt, ein TLS-Fehler, ein Fehler ohne Code), ergibt ebenso ApiOutcomeUnknownError, mit dem Code des Fehlers in der Meldung: Die Anfrage kann gesendet worden sein, und der Server kann sie übernommen haben. Ein Fehler, der zeigt, dass die Anfrage die API nie erreicht hat (Verbindung abgelehnt, Name nicht aufgelöst, Host oder Netz nicht erreichbar, Adressfamilie nicht unterstützt oder keine lokale Adresse verfügbar, Verbindungsaufbau selbst abgelaufen, eine Basis-URL, die sich nicht als URL lesen lässt), bleibt ein gewöhnlicher Fehler und darf wiederholt werden, ebenso jeder Fehler eines Lesezugriffs. Löst ein Name in mehrere Adressen auf, gilt die Anfrage nur dann als nie gesendet, wenn der Verbindungsaufbau an jeder von ihnen so gescheitert ist; sonst nennt die Meldung den Code jeder Adresse.

Ein Gateway-Fehler oder eine Antwort, die kein JSON ist. Hinter einem Reverse Proxy ergibt ein Schreibvorgang, der mit 502, 504 oder Cloudflares 520 oder 524 beantwortet wird, ApiOutcomeUnknownError: Die Meldung sagt, dass das Gateway anstelle des Servers mit diesem Status geantwortet hat, und der Server kann den Schreibvorgang trotzdem übernommen haben, also lesen Sie vor einer Wiederholung neu, was er geändert hätte. Die Ausnahme ist ein 502 in der eigenen JSON-Fehlerhülle der API, einem Body mit genau einem Schlüssel, errors, der ein nicht leeres Objekt enthält: Die API antwortet so mit 502, wenn der Wechselkursanbieter bei portfolixir.exchange_rates.sync scheitert, und nichts wird gespeichert; das ist also die Antwort des Servers, am Schreibvorgang ist nichts unbekannt, und sie bleibt der gewöhnliche Fehler Portfolixir API request failed: 502 {…} mit der Angabe der API. Ein leeres errors-Objekt oder eines neben weiteren Schlüsseln stammt von einem Gateway. Keinen der drei anderen Status liefert die API, sie stammen also von einem Gateway, gleich welcher Body. Ein Lesezugriff, der mit einem der vier beantwortet wird, ändert nichts; er bleibt ein gewöhnlicher Fehler, der den Status nennt oder sagt, dass die Antwort keinen Body hatte, und sagt, dass eine Wiederholung sicher ist. Ein 503 gilt nicht als unbekannter Ausgang: Ein Proxy, der 503 liefert, hat die Anfrage meist nicht weitergeleitet, manche Proxys (Envoy, Istio) antworten aber erst nach der Weiterleitung mit 503, also lesen Sie vor der Wiederholung eines Schreibvorgangs, der ein 503 erhielt, neu. Eine Antwort, deren Body kein JSON ist (eine HTML-Fehlerseite, das 413 eines Proxys), wird nie als Parse-Fehler weitergereicht: Ihr Fehler nennt den Status und zitiert höchstens 120 Zeichen des Bodys, so gezählt, wie die Meldung sie trägt, JSON-Escapes eingerechnet, der Leerraum von JSON zusammengefasst und unsichtbare Zeichen sowie jedes andere Leerzeichen (ein geschütztes Leerzeichen, ein Seitenvorschub) ausgeschrieben, mit der Länge des Bodys in Bytes, wenn er gekürzt wurde. Das API-Token des Begleiters und jeder Bearer-Wert lauten in diesem Zitat und in einem zitierten JSON-Fehler [redacted], damit eine Proxy-Seite, die die Header der Anfrage zurückspiegelt, das Token nicht weitergeben kann. Ein Schreibvorgang, der mit 2xx und einem solchen Body beantwortet wird, ergibt ebenfalls ApiOutcomeUnknownError: Die API kann ihn übernommen haben, und ihre Antwort lässt sich nicht lesen.

  • portfolixir.contract.get — der Kontraktversions-Read (ADR-0044 §8): was die Oberfläche bietet und wann sie sich zuletzt geändert hat, abfragbar mit since=.
  • portfolixir.securities.list
  • portfolixir.securities.get — vollständiger Datensatz eines Wertpapiers einschließlich seiner identifier_aliases (aufgezeichnete frühere ISINs) und seines abgeleiteten thesis_state (ADR-0044), für eine Anleihe ihre Lesung bond mit der computation_basis jeder Kennzahl und dem Zwei-Skalen-Befund (ADR-0052); ein zusammengeführtes Wertpapier antwortet 404 mit errors.merged_into, und die Beschreibung sagt das (ADR-0050 §12).
  • portfolixir.securities.create — nimmt die Stammdaten einer Anleihe entgegen (#330, ADR-0052).
  • portfolixir.securities.update — Beschreibung und currency_code-Eigenschaft nennen das Einfrieren der Währung, die Eigenschaft mit Verweis auf ADR-0050 §11; nimmt die Stammdaten einer Anleihe entgegen, null löscht ein Feld, und is_retired: true nimmt ein ausverkauftes oder delistetes Wertpapier aus den Mengen stale_quote, missing_quote und missing_logo, false hebt das auf. Es ist zugleich das Mittel, das portfolixir.securities.delete nennt, wenn Recherche-Notizen oder Regelversionen auf ein Wertpapier verweisen. Das Tool gehört zum Profil book.
  • portfolixir.securities.delete — nennt portfolixir.securities.update mit is_retired für das Mittel retire.
  • portfolixir.securities.isin_change — zeichnet einen Kapitalmaßnahmen-ISIN-Wechsel auf, damit Importe über die frühere ISIN weiter zuordnen (ADR-0029).
  • portfolixir.securities.delete_isin_alias — journalisiertes Löschen eines aufgezeichneten Früher-ISIN-Alias.
  • portfolixir.securities.merge_preview — die Vorschau einer Wertpapier-Zusammenführung, ein Lesen (ADR-0050 §9, §10): die Positionen je Depot für beide Ausgänge der Frage nach den gleichen Buchungen, die Kurse mit den manuellen Kollisionen, Konfiguration und Termine, die Kennzeichen nach jeder Identitätswahl und der plan_digest.
  • portfolixir.securities.merge — die Wertpapier-Zusammenführung unter einem freigegebenen Digest; als destruktiv und idempotent markiert (eine Wiederholung antwortet mit dem ursprünglichen Protokoll). Die Beschreibung sagt, dass identity_choice Pflicht ist, wenn beide eine ISIN tragen, und nie vorausgewählt, was jeder Wert tut, dass die Kurse die Lücken des Ziels füllen und bei einer Kollision der des Ziels gewinnt, dass Konfiguration und Termine wandern, und was die Zusammenführung für den nächsten Import bedeutet: Jede Identität der Quelle führt zum Ziel, ein späterer Import, der sie nennt, bucht dorthin, ein erneut angewendeter Export legt nichts an, und eine Zusammenführung, die eine Identität unaufgelöst ließe, wird abgelehnt.
  • portfolixir.securities.search_online
  • portfolixir.events.list, portfolixir.events.create, portfolixir.events.update, portfolixir.events.delete — der Kalender eines Wertpapiers und seine drei Schreibzugriffe (ADR-0048).
  • portfolixir.events.upcoming — was in den nächsten N Tagen im gesamten Katalog ansteht; held_only grenzt ein und ist nie die Voreinstellung.
  • portfolixir.events.unconfirmed — vergangene Termine, die niemand bestätigt hat.
  • portfolixir.events.stale — Termine, die niemand seit N Tagen erneut geprüft hat.
  • portfolixir.securities.metrics — die abgeleiteten Preiskennzahlen eines Wertpapiers (ADR-0047) über seine eigene splitbereinigte Kursreihe: gleitende Durchschnitte, Volatilität, maximaler Drawdown, Momentum und der Abstand zu den 52-Wochen-Extremen, jede mit Fenster und Beobachtungszahl.
  • portfolixir.notes.list — das Research-Log eines Wertpapiers, neueste zuerst, mit dem abgeleiteten Thesenstand; die Beschreibung benennt, dass Einträge nie verschwinden (ADR-0044).
  • portfolixir.notes.append — der einzige Schreibzugriff auf das Log; ein Widerruf mit supersedes_id zieht einen Befund zurück.
  • portfolixir.notes.unreviewed — gehaltene Positionen ohne Eintrag seit N Tagen.
  • portfolixir.notes.uncorroborated — Einträge, deren Quellenqualität nicht primary ist.
  • portfolixir.notes.expiring — datierte Sperren, die in N Tagen ablaufen.
  • portfolixir.quotes.sync
  • portfolixir.quotes.list
  • portfolixir.quotes.upsert — jede Zeile wird als manuell gespeichert; das Schema bietet nur source: manual, und die Antwort nennt die ersetzten Daten.
  • portfolixir.quotes.manual — die manuellen Kurse eines Wertpapiers über die ganze Historie: Anzahl, erstes und letztes Datum, die Abschnitte, die Anzahl eines Zeitraums und ob die Synchronisierung ein freigegebenes Datum wieder füllen kann; ein Lesen.
  • portfolixir.quotes.release — die journalisierte Freigabe der manuellen Kurse eines Zeitraums an die Anbieterdaten; das Bedienelement des Operators ist Freigeben… im Tab „Kurse“ des Wertpapiers.
  • portfolixir.portfolios.list — veraltet (ADR-0024): die Beschreibung verweist auf Buckets/Ansichten.
  • portfolixir.portfolios.create — veraltet (ADR-0024): nur Kompatibilität; bevorzuge portfolixir.buckets.create / portfolixir.views.create.
  • portfolixir.cash_accounts.list
  • portfolixir.cash_accounts.create
  • portfolixir.cash_accounts.update — Beschreibung und currency_code-Eigenschaft nennen das Einfrieren der Währung (ADR-0050 §11).
  • portfolixir.cash_accounts.delete
  • portfolixir.cash_accounts.set_balance
  • portfolixir.cash_accounts.remove_former_name — entfernt einen früheren Namen (ADR-0050 §4); die Beschreibung sagt, was das kostet: Ein Import, der ihn noch nennt, legt dann ein neues Konto an.
  • portfolixir.cash_accounts.merge_preview — die Vorschau einer Zusammenführung, ein Lesen (ADR-0050 §7, §10): beide Ausgänge der Frage nach den gleichen Buchungen und der plan_digest.
  • portfolixir.cash_accounts.merge — die Zusammenführung unter einem freigegebenen Digest; als destruktiv und idempotent markiert (eine Wiederholung antwortet mit dem ursprünglichen Protokoll). Die Beschreibung sagt, was die Zusammenführung für den nächsten Import bedeutet: Die Namen der Quelle werden frühere Namen des Ziels, ein späterer Import, der sie nennt, bucht dorthin — außer einem Namen, den ein anderes Konto noch trägt; er wird nicht übernommen —, und ein erneut angewendeter Export legt nichts an.
  • portfolixir.securities_accounts.list
  • portfolixir.securities_accounts.create
  • portfolixir.securities_accounts.update
  • portfolixir.securities_accounts.delete
  • portfolixir.securities_accounts.remove_former_name — dasselbe für ein Depot.
  • portfolixir.securities_accounts.merge_preview — die Vorschau einer Depot-Zusammenführung, ein Lesen (ADR-0050 §7, §10): jede betroffene Position vorher und danach für beide Ausgänge der Frage nach den gleichen Buchungen, der Bucket-Plan, positions_basis und der plan_digest.
  • portfolixir.securities_accounts.merge — die Depot-Zusammenführung unter einem freigegebenen Digest; als destruktiv und idempotent markiert (eine Wiederholung antwortet mit dem ursprünglichen Protokoll). Die Beschreibung sagt, dass Buchungen ihr Verrechnungskonto behalten, dass jede Position ihre Ansichts-Zugehörigkeit behält, und was die Zusammenführung für den nächsten Import bedeutet: Die Namen der Quelle werden frühere Namen des Ziels, ein späterer Import, der sie nennt, bucht dorthin — außer einem Namen, den ein anderes Depot noch trägt; er wird nicht übernommen —, und ein erneut angewendeter Export legt nichts an.
  • portfolixir.transactions.list
  • portfolixir.transactions.create
  • portfolixir.transactions.update
  • portfolixir.transactions.delete — eine Zeile; an einem Split nennt es portfolixir.splits.delete
  • portfolixir.splits.preview
  • portfolixir.splits.create
  • portfolixir.splits.delete — den ganzen Split von einer beliebigen seiner Zeilen aus, die Zeile jedes Portfolios in einem journalisierten Schritt (#912); ein Admin-Werkzeug, das Profil book lässt es also weg
  • portfolixir.holdings.list
  • portfolixir.cashflow.realized_gains — das #724-Rollup mit erklärter FX-Basis und Ausschluss-und-Benennung bei Kurslücken, und der annualisierten Rendite je Trade (#984)
  • portfolixir.cashflow.external_flows — das #725-Rollup mit erklärtem Unterschied zum investierten Kapital
  • portfolixir.cashflow.costs — das #726-Rollup mit erklärter Nebenbeträge-statt-Brutto-Regel
  • portfolixir.holdings.by_security
  • portfolixir.holdings.negative
  • portfolixir.holdings.reconcile — rein lesender Vergleich einer eingefügten externen Positionsliste mit dem Ledger; die Tool-Beschreibung lenkt den Agenten darauf, die fehlende Transaktion der richtigen Art zu buchen statt Saldo-Snapshots oder unbepreiste Einlieferungen zu nutzen.
  • portfolixir.portfolios.valuation — Bereichs-Zwilling von portfolixir.views.valuation (siehe „Bereichs-Zwillinge“ oben). Beide reichen unvalued_cash_count durch, mit und ohne include_positions (#1081).
  • portfolixir.exchange_rates.list
  • portfolixir.exchange_rates.sync — scope=latest (täglicher Feed) oder scope=history (das einmalige historische Backfill, Issue #737).
  • portfolixir.classifications.list
  • portfolixir.classifications.create
  • portfolixir.classifications.categories.create
  • portfolixir.classifications.update
  • portfolixir.classifications.delete — ein Aufruf entfernt den Baum mit jeder Kategorie, jeder Zuordnung eines Wertpapiers darin, jedem Zielgewicht auf seinen Kategorien und jedem Zielplan dazu; die Beschreibung nennt jedes davon und dass das Audit-Journal jede dieser Zeilen als eigene Löschung vor der Klassifizierung festhält (E25).
  • portfolixir.classifications.categories.update
  • portfolixir.classifications.categories.delete — ein Aufruf entfernt die Kategorie mit ihren Unterkategorien in jeder Tiefe, den Zuordnungen zu einer davon und den Zielgewichten auf einer davon; das Journal hält jede als eigene Löschung fest, die Kategorie selbst zuletzt.
  • portfolixir.classifications.assign
  • portfolixir.classifications.assign_bulk
  • portfolixir.classifications.unassign
  • portfolixir.trades.list
  • portfolixir.targets.list
  • portfolixir.targets.set
  • portfolixir.targets.delete
  • portfolixir.targets.list_positions
  • portfolixir.targets.delete_position
  • portfolixir.portfolios.allocation
  • portfolixir.portfolios.category_results — ein Portfolio, das view eingrenzt, view allein für die View über jedes Portfolio (#901), oder keines von beiden für jedes Portfolio in EUR (#1091).
  • portfolixir.portfolios.risk
  • portfolixir.policy_rules.list — die gespeicherten Regeln mit der am as_of geltenden Version (ADR-0049); die Beschreibung weist den Agenten an, sie hier zu lesen, statt sie zu wiederholen, nennt jede eine gespeicherte Regel, wer sie auch geschrieben hat, und verweist für ihren Autor auf das Audit-Journal.
  • portfolixir.policy_rules.get — eine Regel mit ihrer ganzen Versionsgeschichte.
  • portfolixir.policy_rules.create — legt eine Regel mit ihrer ersten Version an; die Beschreibung enthält die Kennzahl-Tabelle und die Skalen und sagt, dass eine Version dauerhaft ist, sobald sie gilt: Die Regel lässt sich dann nur noch beenden.
  • portfolixir.policy_rules.add_version — die Änderung: eine neue Version, nie ein Überschreiben, und dauerhaft, sobald sie gilt.
  • portfolixir.policy_rules.rename — nur der Name; die Beschreibung sagt, dass Umbenennen keine Version anlegt und die Versionen unverändert bleiben.
  • portfolixir.policy_rules.retire — beendet die geltende Version; alles bleibt lesbar.
  • portfolixir.policy_rules.delete — nur für eine Regel, an der nie gemessen wurde.
  • portfolixir.portfolios.policy_findings — wurde eine Linie überschritten? Die heute geltenden Regeln als Befunde; status=breached ist die Alarmliste, und die Beschreibung sagt, dass nicht bestimmbar nie bestanden ist.
  • portfolixir.portfolios.cash_target
  • portfolixir.portfolios.set_cash_target
  • portfolixir.portfolios.income
  • portfolixir.portfolios.performance — Bereichs-Zwilling von portfolixir.views.performance.
  • portfolixir.portfolios.benchmark — Bereichs-Zwilling von portfolixir.views.benchmark.
  • portfolixir.portfolios.contribution — Bereichs-Zwilling von portfolixir.views.contribution; welche Position wie viel zum Geldergebnis eines Zeitraums beigetragen hat (FR-41).
  • portfolixir.journal.list
  • portfolixir.merges.list — die Zusammenführungsprotokolle, das neueste zuerst, jedes mit dem, was wohin ging, wer es tat, wann, und dem zusammengefassten Manifest (ADR-0050 §12), die entfernten Buchungen auch je Grund; ein Lesen — die Liste des Operators ist Zusammenführungen auf „Konten & Depots“.
  • portfolixir.buckets.list
  • portfolixir.buckets.get
  • portfolixir.buckets.create
  • portfolixir.buckets.update
  • portfolixir.buckets.delete
  • portfolixir.views.list
  • portfolixir.views.get
  • portfolixir.views.create
  • portfolixir.views.update
  • portfolixir.views.delete — ein Aufruf entfernt die View mit ihren Bucket-Mengen, jedem auf sie bezogenen Zielplan und jedem in ihrem Bereich angelegten Depot-Snapshot; das Journal hält jede als eigene Löschung vor der View fest.
  • portfolixir.views.set_buckets
  • portfolixir.views.valuation — Bereichs-Zwilling von portfolixir.portfolios.valuation; ohne id die Summe aller Konten (#1007).
  • portfolixir.views.performance — Bereichs-Zwilling von portfolixir.portfolios.performance.
  • portfolixir.views.benchmark — Bereichs-Zwilling von portfolixir.portfolios.benchmark.
  • portfolixir.views.contribution — Bereichs-Zwilling von portfolixir.portfolios.contribution.
  • portfolixir.securities_accounts.set_buckets
  • portfolixir.cash_accounts.set_buckets
  • portfolixir.securities_accounts.set_position_buckets
  • portfolixir.securities_accounts.clear_position_buckets
  • portfolixir.settings.get_default_view
  • portfolixir.settings.set_default_view
  • portfolixir.plans.list
  • portfolixir.plans.duplicate
  • portfolixir.plans.activate
  • portfolixir.plans.rename
  • portfolixir.plans.delete
  • portfolixir.snapshots.list
  • portfolixir.snapshots.create
  • portfolixir.snapshots.delete
  • portfolixir.snapshots.comparison
  • portfolixir.tax_parameters.list
  • portfolixir.tax_parameters.upsert
  • portfolixir.tax_profiles.list
  • portfolixir.tax_profiles.create
  • portfolixir.tax_profiles.update
  • portfolixir.tax_profiles.delete
  • portfolixir.allowance_orders.list
  • portfolixir.allowance_orders.put
  • portfolixir.allowance_orders.delete
  • portfolixir.tax_snapshots.list
  • portfolixir.tax_snapshots.get
  • portfolixir.tax_snapshots.create
  • portfolixir.tax_snapshots.update
  • portfolixir.tax_snapshots.delete
  • portfolixir.tax_snapshots.trim_budget

portfolixir.views.performance berechnet die passende portfolioübergreifende TTWROR/IRR für denselben Konten-Scope; Geld, das die View-Grenze überquert, wird als externer Fluss behandelt (ADR-0019). Ohne id liest es die Rendite jedes Kontos, in EUR (#1056).

portfolixir.portfolios.benchmark und portfolixir.views.benchmark sind die Zwillinge der beiden Benchmark-Endpunkte (ADR-0046): benchmark ist rate:<decimal> oder security:<id>, die Zeitraum-, View- und Series-Parameter sind die der Performance-Tools, und die Antwort trägt beide Vergleiche, das abgedeckte Fenster, die ausgeschlossenen Flüsse und die Berechnungsbasis mit der benannten Reibungsfreiheits-Annahme. Das View-Tool ohne id vergleicht jedes Konto, in EUR (#1056). Eine abgelehnte Benchmark kommt als 422-Body der API zurück, bei einem zusammengeführten Wertpapier, dessen Überlebender eine Benchmark ist, mit merged_into (#959).

portfolixir.portfolios.contribution und portfolixir.views.contribution sind die Zwillinge der beiden Beitragsendpunkte (FR-41, ADR-0051): die Zeitraum- und View-Parameter sind die der Performance-Tools (ein series gibt es nicht), und die Antwort trägt den Beitrag jeder Position, die drei Restzeilen, die Summen — positions plus remainder ist result, das Geldergebnis des Performance-Endpunkts — und die Berechnungsbasis mit ihren benannten Annahmen. Beide sind Lesezugriffe, also listet jedes Profil sie. Das View-Tool ohne id liest jedes Konto, in EUR (#1056).

portfolixir.settings.get_default_view / portfolixir.settings.set_default_view lesen und setzen die Standard-Ansicht-Voreinstellung (ADR-0024): eine view_id pinnt eine Ansicht, null (oder weglassen) setzt auf die eingebaute Alles-Sicht zurück.

Die Tools portfolixir.portfolios.valuation, portfolixir.portfolios.allocation, portfolixir.portfolios.performance, portfolixir.portfolios.contribution, portfolixir.portfolios.risk und portfolixir.portfolios.category_results akzeptieren ein optionales view (eine View-id), das das Ergebnis auf die Bestände der Bucket-View eingrenzt; die Antwort spiegelt dann die aktive View wider. portfolixir.portfolios.category_results nimmt view auch ohne portfolio_id: die View über jedes Portfolio, in EUR (#901); ohne beide liest es jedes Portfolio, in EUR (#1091).

Seit ADR-0020 akzeptieren auch die SOLL-Ziel-Tools (portfolixir.targets.list, portfolixir.targets.set, portfolixir.targets.delete) und die Cash-Ziel-Tools (portfolixir.portfolios.cash_target zum Lesen, portfolixir.portfolios.set_cash_target zum Setzen oder Löschen) ein optionales view (eine View-id), das den SOLL-Plan wählt; ohne view wird der portfolioweite Gesamt-Plan adressiert. Das Cash-Ziel ist vom Portfolio-Objekt auf den Plan gewandert, aber portfolixir.portfolios.set_cash_target ohne view steuert weiterhin das Gesamt-Cash-Ziel und hat damit dieselbe Wirkung wie das alte Portfolio-Feld cash_target_weight. Alle Cash-Ziele und Zielgewichte werden als Decimal-Strings ausgegeben und akzeptiert.

Seit ADR-0030 (#481) tragen dieselben Tools das SOLL auf Positionsebene: Ein Eintrag von portfolixir.targets.set, der eine security_id hinzufügt, setzt ein Gewicht auf diese einzelne Position unter ihrer Kategorie, portfolixir.targets.list_positions liest die Positionszeilen und den effektiven Roll-up jeder Kategorie (explizites Gewicht, Positionssumme, effektives Steuergewicht und ein conflict-Flag, das eine Abweichung zwischen explizitem Gewicht und Positionen sichtbar macht — dazu die Flags stale je Zeile und has_stale je Kategorie für Zeilen, deren Wertpapier nicht mehr unter der gespeicherten Kategorie steht), und portfolixir.targets.delete_position entfernt ein Positionsziel. Aufrufe nur mit Kategorien bleiben unverändert.

Seit ADR-0027 verwalten die Plan-Tools (portfolixir.plans.list, portfolixir.plans.duplicate, portfolixir.plans.activate, portfolixir.plans.rename, portfolixir.plans.delete) benannte Plan-Versionen: den aktiven Plan in einen Entwurf duplizieren, den Entwurf bearbeiten, indem seine id als plan_id an portfolixir.targets.set, portfolixir.targets.delete und portfolixir.targets.delete_position geht (Issue #1133; ohne sie schreiben sie den aktiven Plan der View, und ein archivierter Plan wird abgelehnt), oder im SOLL-Editor, und ihn dann aktivieren. Die Snapshot-Tools (portfolixir.snapshots.list, portfolixir.snapshots.create, portfolixir.snapshots.delete, portfolixir.snapshots.comparison) frieren einen Depotstand als Marker ein und lesen den kontrafaktischen Vergleich; jeder Finanzwert im Vergleich ist ein Decimal-String, und die Antwort benennt ihre Basis (brutto, nur Kursentwicklung).