Главная / Статьи

Никогда не суммируйте два баланса: стек для анализа акций с ограниченным ИИ

Система оценки акций, которая никогда не использует среднее значение двух балансов: данные Yahoo, SEC и FMP объединяются в зависимости от приоритета, применяется фильтр валюты, а локальный большой языковой модель работает в своей зоне ответственности.

Издатель: REACTAPP.TOP10 мин чтения2204 слов

Источники: 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 для Индии и серия ОЭСР (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 и защищены политикой входа с принципом по умолчанию отклонять запросы. К агенту обращаются через /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 — в молчание; при этом движок всё равно выдает свой ответ.

Система ни в коем случае не преувеличивает объём своих знаний, и для инструмента, результаты работы которого могут повлиять на принятие решений с реальными финансовыми последствиями, это имеет большее значение, чем любой другой дизайнерский выбор.