Ніколі не ўсярэдняйце два балансы: стэк аналізу акцый з абмежаванай LLM
Стэк ацэнкі акцый, які ніколі не ўсярэднівае два балансы: даныя 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 астаюць позаду і маюць прасоў, і яны найслабейшыя самэ там, дзе гэта найважлівейша: у меньшых індыйскіх і еврапейскіх компаніях.
Было дадана ўсьмо ўсьмо два дадатковыя мненні. XBRL API ад 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. У гэй табліцы знаходзіцца або чатырохдзесят пазначаных цыфр у розмерэ менейшым за 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)
Мадэль з 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: без стану, чысты вычысленні, без I/O.
Звантракцы з зовнішняго свету падаюць толькі на гейтвей. Рэшта — гэта службы ClusterIP, якія знаходзяцца за правіламі входу default-deny. Агент абслужваецца праз /api/agent/* і не мае публічнага інтэрфейсу, таму гэты проксі стае натуральным месцам для аўтэнтыфікацыі пазней. Аб’ём усіх запытоў да ціх сэрвісаў становіць апошнія 1,1 Гіб.
Чатыры рэчы, якія зламаліся, ні адна з іх — архітэктурная
- containerd протыпа dockerd. Rancher Desktop установляе
dockerCLI пад обаія рантаймы, таму экран дапамогі, які выдруковываецца, нічога не раз’ясняе. З 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 ператвараецца ў мовчанне, а двігун усё раве дае сваю адпаведзь.
Сістэма ніколі не перацінюе тое, што яна знае, і для інструмента, чыя выходныя даны можуць вплынуць на рашэнні, якое мае фінансавыя наследкі, гэта мае першоаследнае значэнне пры выборэ дыяграматыкі.