Vermeiden Sie es, zwei Bilanzen zu durchschnittlichen Werten zusammenzufassen: Eine Aktienanalyse-Plattform mit einem begrenzten LLM
Ein Bewertungsframework für Aktien, das niemals zwei Bilanzen durchschnittlich berechnet: Yahoo-, SEC- und FMP-Daten werden nach Priorität zusammengeführt, es gibt ein Währungskontrollsystem sowie ein lokales LLM, das in seiner eigenen Aufgabebereich bleibt.
Quellen: Never Average Two Balance Sheets
Diese Fallstudie befasst sich mit einem Aktienanalysewerkzeug, das einen lokalen, auf Ollama basierenden Agenten an einen deterministischen Bewertungsmotor bindet, den das Modell nicht neu berechnen kann. Das Werkzeug zielt auf eine einzige Antwort ab: Der Wert eines Unternehmens im Vergleich zu seinen heutigen Kosten. Es führt sechs Bewertungsmodelle sowie fünf Diagnosewerte durch und gibt anschließend ein einziges Urteil auf einer fünfstufigen Skala ab, wobei Unternehmen aus Indien, Europa und den USA berücksichtigt werden.
Die ursprüngliche Version bestand aus einem einzigen Python-Prozess mit 1.700 Zeilen. Er funktionierte einwandfrei, konnte jedoch nach Monaten immer noch nicht die entscheidende Frage für ein Bewertungswerkzeug beantworten: Wie lautete das Urteil über ein bestimmtes Unternehmen im letzten März und was hat sich seitdem geändert? Es gab keine Historie, nur einen einzigen Datenanbieter sowie einen Cache, der jedes Mal verschwand, wenn der Prozess neu gestartet wurde. Thema dieser Studie ist die Aufteilung in fünf Dienste sowie die Abwägung, welchen Nutzen jede Trennung bringt.
Die Grenzen ziehen
Die fünf Dienste haben sich in ihren Aufgaben deutlich voneinander getrennt. Nur marketdata hat Internetzugang. Nur valuation erzeugt eine Zahl. Nur gateway ist externen Verkehrs ausgesetzt.
Explosionsradius statt Skalierung
Mikrodienste für Hobbyprojekte werden oft belächelt. Die Motivation hier war praktisch: Verschiedene Arten von Fehlern vermischten sich, und jeder einzelne kostete Zeit. Der Verkehr spielt keine Rolle, da nur eine Person ihn nutzt. Das Ziel ist es, Schäden einzudämmen. Ein umgenanntes Feld bei Yahoo beeinflusst nur einen einzelnen Pod, der Warnungen protokolliert. Ein hängendes Modell verhindert nicht, dass eine Analyse weiterläuft. Das Testen einer anderen Rabattrate bedeutet, etwa 600 Zeilen reiner, i/O-freier Funktionen erneut zu deployen.
Der gemeinsame Code befindet sich in einer gemeinsamen Bibliothek: Konfigurationen, ORM-Modelle, ein Redis-Cache, der auf Prozessspeicher zurückgreift, ein HTTP-Client, der Versuche wiederholt, sowie eine Fabrik, die jedem Service die gleichen /health/live und /health/ready-Routen zur Verfügung stellt. Fünf Services melden ihren Status auf exakt dieselbe Weise.
Eine Anfrage von Anfang bis Ende
Ein Aufruf von GET /api/analyze erreicht das Gateway, das die Anfrage an die anderen Services weiterleitet und die Antworten zusammenfügt. Es berechnet selbst nichts. Es finden drei Anfragen an die Vorverarbeitungsservices statt, sodass der Browser nur eine benötigt:
@app.get("/api/analyze")
def analyze(q: str, market: str = "US", refresh: bool = False):
snap = _up("marketdata", "/snapshot",
params={"q": q, "market": market, "refresh": refresh})
rates = _up("marketdata", f"/rates/{market}")
result = _up("valuation", "/analyze", method="POST", json={
"symbol": snap["symbol"], "market": market,
"snapshot": snap["data"], "rates": rates, "persist": True,
})
# Provenance travels with the numbers, so the UI can show who said what.
result["sources"] = snap.get("sources", [])
result["disagreements"] = snap.get("disagreements", [])
return result
Der letzte Teil dieses Ablaufs – die Herkunftsangaben, die zusammen mit den Zahlen erfasst werden – erwies sich als das wertvollste Ergebnis der gesamten Überarbeitung.
Drei Quellen, und nichts wird durchschnittlich
Priorität vor Konsens
Der Monolith verließ sich ausschließlich auf Yahoo Finance. Yahoos Grunddaten sind veraltet und weisen Lücken auf, und genau dort, wo es am wichtigsten ist – bei kleineren Unternehmen in Indien und Europa – ist Yahoo am schwächsten.
Zwei weitere Meinungen wurden hinzugefügt. SEC EDGARs XBRL-API deckt US-Unternehmen ohne API-Schlüssel ab, und die dortigen Zahlen entsprechen genau den eingereichten Daten, wodurch Unentschieden gelöst werden. Financial Modeling Prep bietet eine globale Abdeckung und ist dort am stärksten, wo Yahoo am schwächsten ist.
Mit drei Quellen gibt es drei Antworten, und ihr Durchschnitt bilden zu wollen ist der verlockende Kurzweg. Widerstehen Sie diesem Drang. Wenn man zwei Bilanzen mischt, entsteht ein Dokument, das niemals eingereicht wurde.
Die Prioritätsreihenfolge ist eindeutig:
# SEC is as-filed, so it outranks everything in the US.
PRIORITY_US = ("sec", "fmp", "yahoo")
PRIORITY_OTHER = ("fmp", "yahoo")
Die Quelle mit der höchsten Priorität, die tatsächlich ein Feld besitzt, gewinnt eindeutig. Alle anderen Werte werden zusammen mit dem relativen Unterschied zur Anzeige beibehalten:
def _dispersion(values: list[float]) -> float | None:
"""Relative spread across sources. 0.0 means they agree exactly."""
clean = [v for v in values if v is not None]
if len(clean) < 2:
return None
scale = abs(statistics.median(clean))
if scale < 1e-9:
return None if max(map(abs, clean)) < 1e-9 else 1.0
return (max(clean) - min(clean)) / scale
Jeder Wert über 5 % wird als Meinungsverschiedenheit markiert und neben der Zahl angezeigt. Wenn die SEC einen Nettogewinn von 130 angibt und Yahoo 100, würde ein stillschweigend angegebener Wert von 115 den Konflikt verbergen. Besser ist es, beide Werte zu sehen und zu wissen, welchen der Algorithmus verwendet hat.
Die Währungshürde
Eine schwierigere Fallstrick betrifft Währungen. Infosys ist an der NSE notiert und wird in Rupien bewertet, doch ihre Bücher sind in Dollar geführt. Londoner Aktienkurse werden in Pence angegeben, während die Marktkapitalisierung in Pfund ausgedrückt wird. Wenn einer Quelle, die in Dollar berichtet, erlaubt wird, einen Angabefeld für eine in Rupien bewertete Aktie einzugeben, liegt der Kursverhältnis-Wert etwa 80-mal falsch, erscheint aber dennoch glaubwürdig.
Deshalb wird bereits vor der Berücksichtigung der Priorität eine Überprüfung durchgeführt:
for name, data in sources.items():
rc = (data.get("reporting_currency")
or data.get("currency") or base_currency or "").upper()
allowed_statements[name] = (not base_currency) or rc == base_currency.upper()
Eine Quelle, deren Berichtswährung von der Handelswährung abweicht, erhält überhaupt kein Stimmrecht bei den Angabefeldern. Die Regel ist einfach und richtig, denn die Ablehnung einer Meinung ist besser als die Durchschnittsberechnung aus zwei Währungen.
Eine Folge davon ist, dass alle Werte neu berechnet werden müssen, sobald die Gewinner ausgewählt sind. Wenn man EDGARS Nettogewinn mit Yahoos Kurs-Gewinn-Verhältnis mischt, stimmt die Seite selbst mit sich selbst nicht überein – und das bleibt wochenlang unbemerkt. Daher werden Kurs-Gewinn-Verhältnisse, Kurs-Bilanz-Verhältnisse, Kurs-Umsatz-Verhältnisse, EV/EBITDA, ROE sowie die Marge jeweils lokal neu aus den ausgewählten Eingaben berechnet.
Hören Sie auf, den risikofreien Zinssatz fest einzubauen
Zwei Annahmen hinter jeder DCF-Berechnung
Ein diskontierter Cashflow hängt von einer Wachstumsrate und einem Diskontsatz ab. In diesem Tool begannen die Diskonteingaben ursprünglich als Wörterbuchwerte, die einen risikofreien Zinssatz, ein Aktienrisikoprämien und eine Steuerquote pro Markt enthielten:
"US": {"rf": 0.042, "erp": 0.045, "tax": 0.21},
Wenn solche Zahlen einmal eingegeben und danach nicht mehr angepasst werden, veralten sie endgültig. Als Ersatz wird für jeden Markt eine FRED-Serie verwendet: DGS10 für die USA, INDIRLTLT01STM für Indien sowie eine OECD-Serie (IRLTLT01..M156N) für Europa. Die Ergebnisse werden einen Tag lang im Cache gespeichert, zusammen mit dem Datums der Messung, und auf angemessene Grenzen beschränkt:
def risk_free(market: str, fallback: float) -> dict:
"""{rate, source, observed_on, series}. Never raises."""
series_id = SERIES.get(market)
static = {"rate": fallback, "source": "static",
"observed_on": None, "series": None}
if not series_id or not enabled():
return static
...
Achten Sie auf die Form dieser Funktion. Jede Integration im System liefert eine herabgestufte Antwort anstelle eines Anstiegs. Ohne FRED-Schlüssel gelten statische Zinssätze. Ohne FMP-Schlüssel werden statt drei zwei Quellen verwendet. Ohne Redis wird ein in-Prozess-Wörterbuch genutzt. Das System gibt an, welchen Weg es über GET /api/providers eingeschlagen hat, anstatt bei Fehlen eines wünschenswerten Elements zu versagen.
Das Speichern des Zinssatzes für jeden Beobachtungstermin ist wichtiger, als es scheint. Sechs Monate später, wenn sich eine Entscheidung geändert hat, kann man erkennen, ob sich das Unternehmen bewegt hat oder der Rabattzins.
Positionen werden berechnet, niemals gespeichert
Der Portfolio-Service speichert nur Transaktionen und nichts weiter, und das Buchhaltungssystem ist die einzige Quelle der Wahrheit. Die Positionen werden bei jeder Anfrage neu berechnet: Menge, Kostenbasis, realisierte und unrealisierte Gewinne, Dividenden, Gewichte sowie XIRR für jede Position und das gesamte Konto.
Das erfordert mehr Arbeit pro Anfrage und ist offensichtlich richtig. Trägt man im März einen Wert falsch ein, korrigiert man ihn im September – die Historie „repariert“ sich somit selbst. Speichert man hingegen die Position, würde man eine abgeleitete Zahl ewig beibehalten.
XIRR ist die einzige ehrliche Renditeangabe für ein Portfolio, das ständig Einzahlungen erhält, denn ein einfacher Prozentsatzgewinn belohnt einen für eine Zeitpunktwahl, die man nie getroffen hat. Der Solver versucht zunächst die Newton-Methode, greift bei Fehlschlägen auf die Bisektionsmethode zurück und gibt None zurück, wenn die Cashflows tatsächlich keine Rendite bestimmen können.
Ein Analyst, der die Analyse nicht überschreiben kann
Deterministisch an beiden Enden
Der letzte Bestandteil ist ein LangGraph-Agent, der von einem lokalen Ollama-Modell unterstützt wird. Es war nie das Ziel, ihn intelligent zu gestalten. Das schwierigere Konstruktionsproblem bestand darin, zuverlässige Fehler zu vermeiden – dafür sorgen drei Einschränkungen.
Es berührt niemals ein Werkzeug. Jeder Abruf und jede Berechnung erfolgt in gather, bevor das Modell aufgerufen wird. Das Modell kann keine Daten anfordern, daher kann es weder Schleifen ausführen, umherirren noch eine Quelle erfinden.
Es liest eine Factsheet, nicht JSON. Die Factsheet enthält etwa vierzig beschriftete Zahlen in weniger als 4 KB:
VALUATION
blended fair value: 1402.11 INR (confidence medium, model spread 0.43)
per model: dcf=1610.22, epv=1104.50, graham=1288.31, gordon=1377.90
margin of safety: -8.4% - Trading above fair value
growth assumed: 11.0% (basis: revenue YoY 11.0%) discount rate: 11.2%
SOURCE DISAGREEMENTS (treat these figures as uncertain):
net_income: fmp=261.0B, yahoo=248.5B (spread 4.9%, using fmp)
Ein Modell mit 8B Parametern bewältigt diese kompakte Tabelle gut, während es mit einer 40KB-Datenmenge schlecht zurechtkommt. Der zweite Punkt ist bemerkenswert: Bei der Abgleichung gefundenen Unstimmigkeiten werden im Prompt mit aufgenommen, sodass das Modell weiß, welche Zahlen ungewiss sind.
Es erhält höchstens einen Grad an Freiheit. Das Urteil des Engines dient als Orientierungshilfe:
def _clamp_to_engine(model_verdict, engine_verdict):
gap = SCALE.index(model_verdict) - SCALE.index(engine_verdict)
if abs(gap) <= 1:
return model_verdict, None
capped = SCALE[SCALE.index(engine_verdict) + (1 if gap > 0 else -1)]
return capped, (f"model said '{model_verdict}', more than one rung from "
f"the engine's '{engine_verdict}' - capped at '{capped}'")
Das Modell darf zwar argumentieren, darf aber ein quantitatives Modell, das es nicht neu berechnen kann, nicht außer Kraft setzen; jeder Versuch wird aufgezeichnet, was an sich ein zu analysierender Signalwert darstellt.
Bei einem zweiten Durchlauf prüft das Modell seinen eigenen Entwurf auf unbegründete Behauptungen und wiederholt den Vorgang, falls die Kritik das Urteil ändern würde. Bleibt die Kritik auch nach Ablauf des Wiederholungsbudgets bestehen, wird die angegebene Gewissheit mit 0,7 multipliziert.
Wenn Ollama nicht erreichbar ist, springt das System direkt zu finalise und gibt das Urteil des Engines mit status: engine_only zurück. Der Endpunkt gibt weiterhin 200 an. Ein fehlendes Modell führt zu einer minderwertigen Antwort, nicht zu einem Ausfall.
Was die Manifeste kodieren
Alles befindet sich im selben Namespace stockanalysis auf einem lokalen k3s-Cluster.
Die Proben sind absichtlich nicht über das Modell informiert:
startupProbe: { httpGet: { path: /health/live, port: http },
failureThreshold: 30, periodSeconds: 5 }
readinessProbe: { httpGet: { path: /health/ready, port: http }, periodSeconds: 15 }
livenessProbe: { httpGet: { path: /health/live, port: http }, periodSeconds: 30 }
Falls der Lebensfähigkeits-Test Ollama überprüfen sollte, würde ein Modell, das einfach noch nicht heruntergeladen wurde, als Absturz registriert werden, und kubelet würde den Pod endlos neu starten. Ob das Modell funktionsfähig ist, wird absichtlich über /api/agent/model abgefragt.
Der Agent verwendet absichtlich nur ein Replikat. Ein Ollama-Prozess auf dem Host bedient ein Modell, wodurch zusätzliche Pods lediglich in der Warteschlange auf derselben GPU warten und die Speichernachfrage verdoppeln. Automatisches Skalieren würde Ressourcen verschwenden. Was skaliert werden muss, ist valuation: es handelt sich dabei um einen zustandslosen, reinen Rechenvorgang ohne Eingabe/Ausgabe.
Außenstehender Verkehr erreicht nur das Gateway. Der Rest sind ClusterIP-Dienste hinter einer Policy, die Standardanfragen ablehnt. Der Agent wird über /api/agent/* geroutet und verfügt über keine öffentliche Schnittstelle, wodurch dieser Proxy der natürliche Ort für spätere Authentifizierung ist. Die Gesamtanzahl der Anfragen an den Stack beträgt etwa 1,1 Gi.
Vier Dinge, die kaputtgingen – keines davon architektonischer Natur
- containerd versus dockerd. Rancher Desktop installiert eine
dockerCLI unter beiden Laufzeiten, wodurch eine angezeigte Hilfeseite nichts klärt. Bei containerd müssen Bilder in den Namespacek8s.ioimportiert werden. Wenn sie anderswo erstellt wurden, bleibt jedes Pod inImagePullBackOffstecken, da es sich über einen Registry beschwert, mit dem nie Kontakt aufgenommen wurde. - CoreDNS kann den Host nicht auflösen. Die URL
http://host.rancher-desktop.internal:11434, die fürOLLAMA_BASE_URLverwendet wird, funktioniert nicht, da dieser Hostname im hosts-Datei der WSL-Distribution definiert ist und nicht im Cluster-DNS. Jede Analyse hätte ohne Fehlerengine_onlyzurückgegeben – das Programm läuft zwar, ist aber unvollständig, was die schlimmste Art von Fehler darstellt. Die Lösung besteht darin, die Standard-Gateway-Adresse von WSL während der Bereitstellung aufzulösen. - Nicht genügend Speicher. Mit zwei Replikaten pro Service betrugen die Anfragen insgesamt 2,24 GiB, bevor auch noch die eigenen Pods von k3s mitgezählt wurden. Mit jeweils nur einer Replik sank dieser Wert auf 1,05 GiB. Ein weiterer Haken: Eine rolling Update mit
maxSurge: 1, maxUnavailable: 0führt dazu, dass eine Ein-Replikaten-Bereitstellung auf einem Knoten blockiert wird, da der neue Pod Speicher benötigt, den der alte Pod noch nicht freigegeben hat. - PowerShell 5.1 und Kodierungen. Es liest
.ps1-Dateien als ANSI, es sei denn, es gibt ein BOM. Doppeltstriche in Kommentaren werden zu Mojibake und führen dazu, dass der Parser abstürzt.
Alle vier Aspekte erfordern mehr Zeit als die Architektur selbst.
Bekannte Probleme
- Die Toleranz für Abweichungen beträgt global 5 %. Sie sollte je nach Feld variieren, da eine 5%-Abweichung bei den Einnahmen besorgniserregend ist, während sie bei den Zinsausgaben unbedeutend ist.
- Die Factsheet des Agents wird manuell mit String-Formatierung erstellt. Die Struktur ist in Ordnung, sollte aber aus dem Schema generiert werden, das der Bewertungsdienst veröffentlicht, damit sich die beiden nicht voneinander entfernen können.
- Es gibt keine Authentifizierung. Für ein Einzelpersonen-Tool, das an localhost gebunden ist, ist das vertretbar, sobald es jedoch an einen anderen Ort verlegt wird, ist es es nicht mehr.
Das zugrundeliegende Muster
Jedes Komponente sollte sich abnutzen anstatt zu versagen und sollte angeben, was es getan hat. Drei Datenquellen reduzieren sich problemlos auf zwei. FRED wird zu einer statischen Tabelle. Redis wird zu einem Wörterbuch. Ollama wird still – doch der Engine liefert weiterhin ihre Antwort.
Zu keinem Zeitpunkt übertreibt das System das, was es weiß, und für ein Werkzeug, dessen Ausgabe Entscheidungen mit finanziellen Konsequenzen beeinflussen könnte, war das wichtiger als jede andere Gestaltungsentscheidung.