Strona główna / Artykuły

Nigdy nie łącz dwóch bilansów: zestaw narzędzi do analizy akcji z ograniczonym LLM

Stack do wyceny akcji, który nigdy nie łączy dwóch bilansów: dane z Yahoo, SEC i FMP łączone według priorytetu, mechanizm kontrolny waluty oraz lokalny model językowy LLM działający w swojej specyficznej roli.

Wydawca: REACTAPP.TOP10 min czytania2204 słów

Źródła: Never Average Two Balance Sheets

To studium przypadku dotyczy narzędzia do analizy akcji, które łączy lokalnego agenta opartego na Ollamie z silnikiem wyceny deterministyczną, którego model nie może ponownie obliczyć. Narzędzie ma na celu jedną odpowiedź: ile jest warta firma w porównaniu z jej obecnymi kosztami. Uruchamia sześć modeli wyceny i pięć wskaźników diagnostycznych, a następnie wydaje jedno orzeczenie w skali pięciostopniowej, obejmujące spółki z Indii, Europy i Stanów Zjednoczonych.

Pierwotna wersja składała się z jednego procesu w Pythonie liczącego 1700 linii kodu. Działał poprawnie, jednak przez wiele miesięcy nie był w stanie odpowiedzieć na jedno kluczowe pytanie dla narzędzia wyceny: jaki był werdykt dotyczący danej firmy w zeszłym marcu i co się od tamtej pory zmieniło? Brakowało historii, był tylko jeden dostawca danych, a pamięć tymczasowa znikała przy każdym ponownym uruchomieniu procesu. Tematem tego opracowania jest podział tego procesu na pięć usług oraz ocena korzyści wynikających z ustalenia odpowiednich granic pomiędzy nimi.

Wyznaczanie granic

Pięć usług ma wyraźnie rozdzielone obowiązki. Tylko marketdata ma dostęp do Internetu. Tylko valuation generuje liczbę. Tylko gateway jest dostępny dla ruchu z zewnątrz.

Zasięg skutków, a nie skalowanie

Mikrousługi używane w projektach hobbystycznych często wywołują uśmiech ironii. Motywacją w tym przypadku była praktyczność: różne rodzaje awarii były ze sobą powiązane, a każda z nich kosztowała czas. Ruch nie jest istotny, ponieważ korzysta z niego jedna osoba. Celem jest ograniczenie szkód. Przenazwanie pola w Yahoo wpływa na pojedynczą komórkę, która rejestruje ostrzeżenia. Zawieszony model nie powstrzymuje powrotu wyników analizy. Testowanie innego wskaźnika zniżek oznacza ponowne wdrożenie około 600 linii czystych funkcji bez operacji wejścia/wyjścia.

Kod wspólny znajduje się w bibliotece współdzielonej: konfiguracja, modele ORM, pamięć cache Redis, która jako fallback korzysta z pamięci procesu, klient HTTP, który próbuje ponownie, oraz fabryka, która wyposaża każdą usługę w te same ścieżki /health/live i /health/ready. Wszystkie pięć usług raportuje stan w identyczny sposób.

Jedna prośba od początku do końca

Wywołanie GET /api/analyze trafia do bramy, która kieruje je do pozostałych usług i łączy ich odpowiedzi. Sama nie oblicza niczego. Dokonuje się trzech wywołań wstecznych, dzięki czemu przeglądarce wystarcza tylko jedno:

@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

Ostatnia część tego procesu, czyli śledzenie pochodzenia danych wraz z liczbami, okazała się najcenniejszym rezultatem całej przeróbki.

Trzy źródła, bez żadnych średnich

Priorytet przeważa nad konsensusem

Monolit polegał wyłącznie na Yahoo Finance. Podstawowe dane Yahoo są zacofane i zawierają luki, a są one najgorsze właśnie tam, gdzie to najważniejsze: w przypadku mniejszych spółek z Indii i Europy.

Dodano jeszcze dwa opinie. API XBRL SEC EDGAR obejmuje amerykańskie firmy bez klucza API, a jego dane są dokładnie takie, jak zostały złożone, więc rozstrzyga w przypadku remisów. Financial Modeling Prep zapewnia globalne pokrycie i jest najsilniejszy tam, gdzie Yahoo jest najsłabszy.

Przy trzech źródłach istnieją trzy odpowiedzi, a ich średnienie to kusząca skrótka. Opieraj się temu. Połączenie dwóch bilansów daje dokument, który nikt nigdy nie złożył.

Kolejność priorytetów jest jasna:

# SEC is as-filed, so it outranks everything in the US.
PRIORITY_US    = ("sec", "fmp", "yahoo")
PRIORITY_OTHER = ("fmp", "yahoo")

Najwyższej priorytetowości źródło, które faktycznie posiada pole, wygrywa bezkonkurencyjnie. Wszystkie pozostałe wartości są zachowywane w celu wyświetlenia wraz z odpowiednią rozpiętością:

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

Wartość przekraczająca 5% jest oznaczana jako niezgodność i pokazywana obok liczby. Jeśli SEC podaje dochód netto na poziomie 130, a Yahoo – 100, to ukryte wartości 115 zamaskuje ten konflikt. Lepszym rozwiązaniem jest pokazanie obu wartości oraz informacja o tym, którą użył model.

Brama walutowa

Trudniejszą pułapką są waluty. Infosys notowany jest na giełdzie NSE i jego cena jest wyrażana w rupiach, mimo że jego rachunki są w dolarach. Ceny akcji na giełdzie w Londynie są podawane w pensach, podczas gdy kapitalizacja rynkowa – w funtach. Jeśli źródło publikujące dane w dolarach może dostarczyć informację dla akcji o cenie w rupiach, wskaźnik P/E okazuje się błędny o około 80 razy, a mimo to wydaje się wiarygodny.

Dlatego przed rozważeniem priorytetów przeprowadzana jest weryfikacja:

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()

Źródło, którego waluta raportowania różni się od waluty transakcyjnej, w ogóle nie ma prawa głosu przy polach wykazu. Zasada ta jest prosta i słuszna, ponieważ brak opinii jest gorszy niż uśrednianie dwóch walut.

Jedną z konsekwencji jest to, że każda wartość wielokrotności musi być przeliczana po wybraniu zwycięzców. Po połączeniu netto dochodów EDGAR z wskaźnikiem P/E Yahoo strona dostarcza sprzecznych danych, których nikt nie zauważa przez tygodnie. Dlatego wskaźniki P/E, P/B, P/S, EV/EBITDA, ROE oraz marże są ponownie obliczane lokalnie na podstawie wybranych danych wejściowych.

Przestańcie kodować bezpieczną stopę procentową w sposób sztywny

Dwie założenia w każdym modelu DCF

Discounted Cash Flow zależy od stopy wzrostu oraz stopy dyskontowej. W tym narzędziu dane dotyczące dyskontowania początkowo były literalnymi wartościami z słownika, zawierającymi bezpieczną stopę procentową, premię za ryzyko akcji oraz stawkę podatkową dla poszczególnych rynków:

"US": {"rf": 0.042, "erp": 0.045, "tax": 0.21},

Podane raz i nigdy nie sprawdzane, takie liczby tracą ważność na zawsze. Jako zamiennik wybierana jest jedna seria FRED dla każdego rynku: DGS10 dla Stanów Zjednoczonych, INDIRLTLT01STM dla Indii oraz seria OECD (IRLTLT01..M156N) dla Europy. Wyniki są przechowywane w pamięci tymczasowej przez jeden dzień, zapisywane wraz z datą pomiaru i ograniczane do rozsądnych wartości:

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
    ...

Zwróć uwagę na kształt tej funkcji. Każda integracja w systemie zwraca obniżoną wartość zamiast wzrostu. Brak klucza FRED oznacza statyczne stawki. Brak klucza FMP oznacza dwie źródła zamiast trzech. Brak Redis oznacza użycie słownika wewnątrz procesu. System informuje o wybranej ścieżce poprzez GET /api/providers, zamiast zawalać się w przypadku braku niezbędnego elementu.

Zachowywanie stawki dla każdej daty obserwacji ma większe znaczenie, niż się wydaje. Sześć miesięcy później, gdy decyzja się zmieni, można stwierdzić, czy to firma zmieniła swoją politykę, czy stawka dyskontowa.

Wartości pozycji są obliczane, a nie przechowywane

Służba portfela przechowuje tylko transakcje, a księga rachunkowa jest jedyną prawdą. Pozycje są na nowo obliczane na podstawie niej przy każdej prośbie: ilość, koszt początkowy, zyski realizowane i nierozrealizowane, dywidendy, wagi oraz XIRR dla każdej pozycji i całego konta.

To wymaga więcej pracy przy każdej prośbie i oczywiście jest słuszne. Jeśli błędnie wpiszesz dane handlowe w marcu i poprawisz je we wrześniu, historia sama się naprawi. Lepiej przechować tę pozycję – w ten sposób na zawsze naprawisz uzyskany wynik.

XIRR to jedyny uczciwy wskaźnik zwrotu dla portfela, który ciągle otrzymuje wpłaty, ponieważ proste odsetki za zysk przypisują ci zasługi za timing, którego nigdy nie wybrałeś. Narzędzie najpierw próbuje metody Newtona, odwołuje się do metody podziału, gdy ta nie daje rezultatu, i zwraca None, gdy przepływy pieniężne rzeczywiście nie pozwalają określić stopy procentowej.

Analityk, który nie może przełamać wyników analizy

Deterministyczny na obu końcach

Ostatnim elementem jest agent LangGraph wspierany lokalnym modelem Ollama. Celem nie było uczynienie go inteligentnym. Trudniejszym problemem projektowym było zapobieganie błędom wynikającym z pewności siebie, a radzą sobie z tym trzy ograniczenia.

Nigdy nie dotyka narzędzia. Każde pobieranie danych i obliczenie odbywa się w funkcji gather, zanim zostanie wywołany model. Model nie może żądać danych, więc nie może pętlić się, błądzić ani wymyślać źródła.

Czyta arkusz z danymi, a nie JSON. Arkusz zawiera około czterdziestu oznaczonych liczb w rozmiarze poniżej 4KB:

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)

Model z 8 miliardami parametrów dobrze radzi sobie z tym kompaktowym plikiem, natomiast słabo z plikiem o rozmiarze 40 KB. Warto zwrócić uwagę na drugi element: niezgodności znalezione podczas synchronizacji są uwzględniane w instrukcji, dzięki czemu model wie, które liczby są niepewne.

Otrzymuje co najwyżej jeden poziom swobody. Werdykt silnika stanowi punkt odniesienia:

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}'")

Model może się sprzeciwiać, ale nie może unieważnić modelu ilościowego, którego nie może ponownie obliczyć, a każda próba jest rejestrowana – sama w sobie stanowi sygnał wart analizy.

Podczas drugiej fazy model sprawdza swój własny projekt pod kątem nieudokumentowanych twierdzeń, wchodząc w błędne koło, jeśli krytyka mogłaby zmienić werdykt. Jeśli krytyka pozostaje aktualna po wyczerpaniu limitu błędnych pętli, podana ocena jest pomnożona przez 0,7.

Gdy nie uda się skontaktować z Ollamą, graf przechodzi do kroku finalise i zwraca werdykt silnika z atrybutem status: engine_only. Endpoint nadal zwraca kod 200. Brak modelu powoduje gorszą jakość odpowiedzi, a nie przerwę w działaniu.

Co kodują pliki manifestu

Wszystko znajduje się w jednym przestrzeni nazw, stockanalysis, na lokalnym klastrze k3s.

Proby są celowo nieświadome istnienia modelu:

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 }

Gdyby próba sprawdzenia żywotności miała testować Ollamę, model, którego po prostu nie pobraano, zostałby zarejestrowany jako awaria, a kubelet nieustannie restartowałby pod bez końca. Czy model jest sprawny, jest sprawdzane celowo za pomocą /api/agent/model.

Agent celowo ma jedną replikę. Jeden proces Ollamy na hostzie obsługuje jeden model, więc dodatkowe pody jedynie czekają w kolejce na tym samym GPU, jednocześnie podwajając zapotrzebowanie na pamięć. Automatyczne skalowanie w tym przypadku marnowałoby zasoby. To, co należy skalować, to valuation: proces bez stanu, czyste obliczenia, bez I/O.

Ruch z zewnątrz dociera wyłącznie do bramy. Pozostałe to usługi ClusterIP za polityką dostępu default-deny. Agent jest dostępny przez /api/agent/* i nie ma żadnej publicznej powierzchni, co sprawia, że ten proxy jest naturalnym miejscem na późniejszą autoryzację. Łączna liczba żądań do całej struktury wynosi około 1,1 Gi.

Cztery rzeczy, które się zepsuły, żadna z nich nie była związana z architekturą

  • containerd versus dockerd. Rancher Desktop instaluje interfejs CLI docker pod oboma środowiskami działania, więc wyświetlona strona z pomocą nic nie rozwiązuje. W przypadku containerd obrazy muszą zostać zaimportowane do przestrzeni nazw k8s.io. Jeśli zostały stworzone gdzie indziej, każdy pod utknie w stanie ImagePullBackOff, skarżąc się na rejestr, z którym nigdy nie nawiązano kontaktu.
  • CoreDNS nie może rozwiązać nazwy hosta. Adres URL http://host.rancher-desktop.internal:11434 używany dla OLLAMA_BASE_URL zawodzi, ponieważ ta nazwa hosta jest zdefiniowana w pliku hosts dystrybucji WSL, a nie w DNS klastra. Każda analiza zwróciłaby wartość engine_only bez żadnego błędu, co oznaczałoby działanie, ale w niepełnym zakresie – najgorszy rodzaj błędu. Rozwiązaniem jest rozwiązanie adresu bramy domyślnej WSL podczas implementacji.
  • Niewystarczająca ilość pamięci. Przy dwóch replikach każdej usługi łączna ilość żądań wynosiła 2,24 Gi, zanim uwzględniono własne pody k3s. Po użyciu po jednej replice ilość ta spadła do 1,05 Gi. Kolejna pułapka: aktualizacja typu rolling z ustawieniami maxSurge: 1, maxUnavailable: 0 powoduje zablokowanie implementacji z jedną repliką na jednym węźle, ponieważ nowy pod potrzebuje pamięci, której stary pod jeszcze nie zwolnił.
  • PowerShell 5.1 i kodowania. Program ten interpretuje pliki .ps1 jako ANSI, chyba że istnieje BOM. Użycie myślników w komentarzach powoduje efekt mojibake i powoduje awarię parsera.

Wszystkie cztery te elementy zajmują więcej czasu niż sama architektura.

Znane problemy

  • Tolerancja na rozbieżności wynosi 5% na poziomie globalnym. Powinna się różnić w zależności od pola – 5% różnicy w przychodach jest alarmująca, natomiast w kosztach odsetkowych stanowi jedynie szum.
  • Spis faktów agenta jest tworzony ręcznie przy użyciu formatowania ciągów tekstowych. Struktura jest poprawna, ale powinien być generowany na podstawie schematu publikowanego przez usługę wyceny, dzięki czemu oba elementy nie mogą się różnić.
  • Brak mechanizmu autoryzacji. Dla narzędzia przeznaczonego dla jednego użytkownika i działającego na localhost jest to akceptowalne, ale gdy przeniesie się ono w inne miejsce, już nie jest.

Podstawowy wzorzec

Każdy komponent powinien się degradować zamiast awarii i powinien wskazywać, co robił. Trzy źródła danych zmieniają się w dwa bez żadnych problemów. FRED przekształca się w statyczną tabelę. Redis staje się słownikiem. Ollama milknie, a silnik nadal udziela odpowiedzi.

W żadnym momencie system nie przesadza co do tego, co wie, a dla narzędzia, którego wyniki mogą wpłynąć na decyzje finansowe, to miało większe znaczenie niż jakakolwiek inna decyzja projektowa.