Ніколи не об’єднуйте два баланси: стек для аналізу акцій із обмеженим ШІ
Система оцінки акцій, яка ніколи не об’єднує два баланси: дані Yahoo, SEC та FMP об’єднуються за пріоритетом, є механізм контролю валюти та локальний LLM, який працює у своїй сфері.
Джерела: Never Average Two Balance Sheets
У цьому кейс-стаді вивчається інструмент аналізу акцій, який прив’язує локального агента на основі Ollama до детерміністичного механізму оцінки, який модель не може перерозрахувати. Інструмент спрямований на одну відповідь: яка вартість бізнесу порівняно з його сучасними витратами. Він запускає шість моделей оцінки та п’ять діагностичних показників, а потім видає єдиний вердикт за п’ятибальною шкалою, що стосується компаній в Індії, Європі та США.
Початкова версія була єдиним процесом на Python із 1 700 рядків коду. Він працював нормально, проте протягом кількох місяців не міг відповісти на єдине запитання, яке є ключовим для інструменту оцінки: який був вердикт щодо певної компанії у березні минулого року та що змінилося з того часу? Не було історії даних, лише один постачальник даних, а кеш зникав щоразу після перезапуску процесу. Темою цього матеріалу є розділення цього процесу на п’ять сервісів та аналіз того, яку користь приносить кожен з них.
Визначення меж
Ці п’ять сервісів мають чітко розділені обов’язки. Лише marketdata має доступ до Інтернету. Лише valuation генерує числові значення. Лише gateway підключений до зовнішнього трафіку.
Радіус ураження, а не масштаб
Мікросервіси для хобі-проєктів часто викликають іронію. Мотивація тут була практичною: різні види збоїв перепліталися, і кожен з них коштував часу. Кількість трафіку не є важливою, оскільки ним користується одна людина. Метою є обмеження пошкоджень. Зміна назви поля в Yahoo впливає лише на один под, який записує попередження. Завислий модель не заважає завершенню аналізу. Тестування іншого коефіцієнта знижки означає перезавантаження приблизно 600 рядків функцій без використання введення-виведення.
Загальний код знаходиться у спільній бібліотеці: конфігурація, моделі ORM, кеш Redis, який у разі проблем використовує оперативну пам’ять процесу, HTTP-клієнт, який повторює спроби, та фабрика, яка оснащує кожен сервіс однаковими маршрутами /health/live та /health/ready. Усі п’ять сервісів повідомляють про свій стан однаковим чином.
Одна запитування від початку до кінця
Запит GET /api/analyze надходить до шлюзу, який передає його іншим сервісам та об’єднує їхні відповіді. Сам шлюз нічого не обчислює. Відбувається три запити до попередніх сервісів, щоб браузеру потрібен був лише один:
@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
Остання частина цього процесу — відстеження походження даних разом із цифрами — виявилася найціннішим результатом усього процесу переписування.
Три джерела, і нічого не усереднюється
Пріоритет важливіший за консенсус
Моноліт покладався лише на Yahoo Finance. Фундаментальні показники Yahoo відстають та мають прогалини, і саме там, де це найважливіше, вони найслабкіші: серед менших компаній в Індії та Європі.
Було додано ще два думки. API XBRL SEC EDGAR підтримує американські компанії без API-ключа, і його цифри є точно такими, як вони були подані, тож це допомагає вирішувати ситуації з рівними результатами. Financial Modeling Prep забезпечує глобальний охоплення та є найсильнішим там, де Yahoo найслабкіший.
З трьома джерелами є три відповіді, і усереднення їх є спокусливим швидким рішенням. Опиріться йому. Якщо поєднати два баланси, результатом буде документ, який ніхто ніколи не подавав.
Порядок пріоритетів є чітким:
# SEC is as-filed, so it outranks everything in the US.
PRIORITY_US = ("sec", "fmp", "yahoo")
PRIORITY_OTHER = ("fmp", "yahoo")
Джерело з найвищим пріоритетом, яке дійсно має відповідне поле, перемагає без конкуренції. Усі інші значення зберігаються для відображення разом із відповідним розкидом:
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
Будь-яке значення, що перевищує 5%, позначається як розбіжність та відображається поруч із числом. Якщо SEC повідомляє про чистий дохід у розмірі 130, а Yahoo — 100, тоді приховане значення 115 замаскує цю суперечність. Кращим варіантом є відображення обох значень та інформація про те, яке з них використалося в моделі.
Проблема валют
Ще складніша проблема пов’язана з валютами. Компанія Infosys котується на біржі NSE та її акції цінуються у рупіях, проте її бухгалтерські записи ведуться у доларах. Ціни акцій у Лондоні вказуються у пенсах, тоді як їхня ринкова капіталізація — у фунтах. Якщо дозволити джерелу, яке подає дані у доларах, вказати значення для акцій, ціновані у рупіях, коефіцієнт P/E може виявитися приблизно в 80 разів неправильним, проте все одно здаватися правдоподібним.
Тому перед розглядом пріоритетів виконується перевірка:
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()
Джерело, чия валюта звітності відрізняється від валюти торгівлі, зовсім не має права голосу щодо полів звіту. Це правило просте та правильне, адже відсутність думки краща, ніж середнє значення двох валют.
Одна з наслідків: кожен коефіцієнт потрібно перераховувати заново після вибору переможців. Якщо поєднати чистий дохід EDGAR із коефіцієнтом P/E Yahoo, сторінка почне давати суперечливі результати, які ніхто не помітить протягом тижнів. Тому коефіцієнти P/E, P/B, P/S, EV/EBITDA, ROE та інші маржі перераховуються локально на основі обраних даних.
Припиніть жорстке закодування безризикової ставки
Дві припущення в кожному методі DCF
Дисконтований потік грошових коштів залежить від темпів зростання та ставки дисконту. У цьому інструменті параметри дисконту спочатку були представлені у вигляді слів з словника, що містили безризикову ставку, премію за ризик акцій та податкову ставку для кожного ринку:
"US": {"rf": 0.042, "erp": 0.045, "tax": 0.21},
Як тільки такі числа вводяться один раз і більше не переглядаються, вони остаточно стають застарілими. Для заміни використовується по одній серії FRED на кожен ринок: DGS10 для США, INDIRLTLT01STM для Індії та серія OECD (IRLTLT01..M156N) для Європи. Результати зберігаються у кеші протягом дня, додається дата спостереження, а значення обмежуються розумними межами:
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
...
Зверніть увагу на форму тієї функції. Кожна інтеграція в системі повертає знижений результат замість підвищеного. Відсутність ключа FRED означає використання статичних ставок. Відсутність ключа FMP означає використання двох джерел замість трьох. Відсутність Redis означає використання словника всередині процесу. Система повідомляє про обраний шлях через GET /api/providers, замість того щоб зазнавати невдачі при відсутності якоїсь додаткової функції.
Зберігання ставки за кожною датою спостереження має більше значення, ніж здається. Через шість місяців, коли рішення зміниться, ви зможете з’ясувати, чи змінилась ситуація в бізнесі, чи змінилась ставка знижки.
Дані про позиції обчислюються, а не зберігаються
Сервіс портфеля зберігає лише транзакції, і лише бухгалтерський облік є єдиною правдою. Позиції перераховуються з нього за кожним запитом: кількість, вартісна основа, реалізовані та нереалізовані прибутки, дивіденди, ваги та показник XIRR для кожної позиції та всього рахунку.
Це вимагає більше роботи за кожен запит та, звісно, є правильним підходом. Введіть неправильні дані у березні, виправте їх у вересні — і історія сама себе виправить. Краще зберігати початкові дані, щоб постійно коригувати похідні значення.
XIRR — це єдиний чесний показник дохідності для портфеля, який постійно отримує депозити, адже просте відсоткове зростання враховує час, який ви ніколи не обирали. Алгоритм спочатку використовує метод Ньютона, переходить на метод бісекції, коли цей метод не допомагає, та повертає None, якщо грошові потоки насправді не дозволяють визначити ставку.
Аналітик, який не може проігнорувати результати аналізу
Детерміністичний підхід з обох боків
Останнім елементом є агент LangGraph, підтримуваний локальною моделлю Ollama. Метою ніколи не було зробити його розумним. Складнішою була проблема проектування — запобігання помилкам, і для цього використано три обмеження.
Воно ніколи не торкається інструментів. Кожне отримання даних та обчислення відбувається у функції gather, ще до запуску моделі. Модель не може запитувати дані, тому вона не може виконувати цикли, блукати чи вигадувати джерела.
Воно читає таблицю фактів, а не JSON. У цій таблиці міститься близько сорока позначених чисел об’ємом менше 4 КБ:
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)
Модель з 8 мільярдами параметрів добре справляється з тим компактним файлом, але погано — з файлом розміром 40 КБ. Варто звернути увагу на другий момент: розбіжності, виявлені під час узгодження, включаються до запиту, тож модель знає, які числа є невизначеними.
Вона отримує щонайбільше один рівень свободи. Рішення двигуна є орієнтиром:
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}'")
Модель може сперечатися, але не може скасувати кількісну модель, яку не може перерозрахувати, причому кожна спроба фіксується — це саме по собі сигнал, який варто проаналізувати.
Під час другого проходу модель перевіряє власний проєкт на наявність непідтверджених тверджень; якщо критика може змінити вердикт, вона повторюється. Якщо після вичерпання ліміту повторень критика залишається актуальною, вказана міра переконаності множиться на 0,7.
Коли до Ollama неможливо дістатися, граф відразу переходить до етапу finalise та повертає вердикт двигуна з позначкою status: engine_only. Кінцева точка все одно повертає код 200. Відсутність моделі призводить до гіршої якості відповіді, але не до переривання роботи.
Що кодують маніфести
Усе знаходиться в одному просторі імен stockanalysis у локальному кластері k3s.
Зонди навмисно не мають уявлення про модель:
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 }
Якби перевірка живості мала тестувати Ollama, модель, яку ще не завантажили, була б зафіксована як збій, і kubelet продовжував би без кінця перезапускати под. Чи є модель справною, перевіряється навмисно через /api/agent/model.
Агент навмисно має один репліку. Один процес Ollama на хості обслуговує одну модель, тому додаткові поди лише чекають у черзі на тій самій GPU, що подвоює потреби в пам’яті. Автомасштабування в цьому випадку призведе до марнування ресурсів. Те, що потрібно масштабувати, — це valuation: безстановий процес чистих обчислень без введення-виведення.
Зовнішній трафік потрапляє лише до гейтвею. Решта — це сервіси ClusterIP за політикою входу типу default-deny. Агент доступний через /api/agent/* і не має публічного інтерфейсу, тому цей проксі є природним місцем для автентифікації пізніше. Загальна кількість запитів до цієї структури становить близько 1,1 ГіБ.
Чотири речі, які зламалися, жодна з них — архітектурна
- containerd проти dockerd. Rancher Desktop встановлює CLI
dockerпід обома середовищами виконання, тож екран допомоги, що відображається, нічого не вирішує. З containerd зображення потрібно імпортувати в простір іменk8s.io. Якщо вони створені в іншому місці, кожен под залишається у станіImagePullBackOff, скаржачись на реєстр, з яким так і не було встановлено зв’язку. - CoreDNS не може розрішити хоста. URL
http://host.rancher-desktop.internal:11434, який використовується дляOLLAMA_BASE_URL, не працює, оскільки цей ім’я хоста визначене у файлі hosts дистрибутива WSL, а не в DNS кластера. Кожен аналіз повертав биengine_onlyбез жодних помилок, що означало б працювання, але неповне — найгірший тип багу. Рішенням є розрішення адреси шлюзу за замовчуванням WSL під час розгортання. - Недостатньо пам’яті. З двома репліками кожної служби загальний обсяг запитів становив 2,24 ГіГабайта ще до врахування власних подів k3s. Якщо залишити по одній репліці, цей показник знижується до 1,05 ГіГабайта. Ще одна проблема: роликове оновлення з параметрами
maxSurge: 1, maxUnavailable: 0призводить до заблокування розгортання з однією реплікою на одному вузлі, оскільки новий под потребує пам’яті, яку старий под ще не звільнив. - PowerShell 5.1 та кодування. Він розуміє файли
.ps1у форматі ANSI, якщо тільки немає BOM. Дефіси в коментарях перетворюються на ефект „моджібаке“, що призводить до збою парсера.
Усі чотири аспекти вимагають більше часу, ніж сама архітектура.
Відомі проблеми
- Допустима похибка узгодження становить 5% у глобальному масштабі. Вона мала б варіюватися залежно від поля, адже різниця у 5% у доходах є тривожною, тоді як у витратах на відсотки це є незначним фактором.
- Інформаційний лист агента створюється вручну з використанням форматування рядків. Структура правильна, але її слід генерувати зі схеми, яку публікує сервіс оцінки, щоб обидві версії не відрізнялися.
- Немає механізму автентифікації. Для інструменту для одного користувача, який працює лише на localhost, це прийнятно, але як тільки він переміщується в інше місце, це вже не так.
Основна схема
Кожен компонент має поступово втрачати функціональність замість того, щоб зламатися, і має вказувати, що він робив. Три джерела даних без проблем зменшуються до двох. FRED перетворюється на статичну таблицю. Redis перетворюється на словник. Ollama замовкає, але двигун все одно надає свою відповідь.
Система жодним чином не перебільшує те, що знає, і для інструменту, чий результат може вплинути на рішення з реальними грошима, це має перевагу над усіма іншими варіантами проектування.