AI Analytics
Описание
Модуль aiAnalytics в apps/backend отдаёт рыночную аналитику по крипторынку и отдельным токенам. Redis-only: своих таблиц БД и миграций у модуля нет.
Исторически модуль умел две вещи — обзор рынка и AI-разбор пула ликвидности Uniswap v3. Токен-ветка (список/поиск/карточка/график/новости/AI-разбор токена) достроена позже поверх нового единого HTTP-клиента CoinGecko.
Полное ТЗ токен-ветки — tz/active/token-analytics. Дизайн-документ с проверенными фактами о CoinGecko и обоснованием решений — docs/superpowers/specs/2026-07-12-token-analytics-design.md в корне репозитория.
Карта эндпоинтов
Все роуты защищены JwtAuthGuard + ApiBearerAuth (в проекте роут публичен по умолчанию, поэтому защита ставится явно на каждом контроллере).
AiAnalyticsController — старые (без изменений в поведении)
| Метод | Путь | Источник | TTL | Кеш-ключ |
|---|---|---|---|---|
GET | /ai-analytics/market/outlook | Fear&Greed (alternative.me) + AI (altseason, структурный JSON) | 3600 c (AI_ANALYTICS_OUTLOOK_TTL_SEC) | ai:outlook:v1 |
POST | /ai-analytics/pool/analytics | Uniswap v3 subgraph + AI (Markdown) | 900 c (AI_ANALYTICS_POOL_TTL_SEC) | ai:pool:{network}:{poolId} |
market/outlook собирает Fear&Greed, глобальную статистику и топ-100 монет (MarketDataService), считает altseason-индикатор и просит модель сформулировать обзор рынка структурным JSON. pool/analytics берёт метрики пула из Uniswap v3 subgraph (UniswapV3GraphService), метаданные обоих токенов — через TokenDataService, и просит модель написать Markdown-разбор пула. Оба сервиса используют общий OPENAI_CLIENT и общее правило «фолбэк не кешируется».
TokenAnalyticsController — токен-ветка
| Метод | Путь | Источник | TTL | Кеш-ключ |
|---|---|---|---|---|
GET | /ai-analytics/market/global | CoinGecko /global + Fear&Greed | 60 c (AI_ANALYTICS_MARKET_TTL_SEC) | ai:cg:v1:global (без провенанса наружу, см. ниже) |
GET | /ai-analytics/tokens | CoinGecko /coins/markets | 60 c | ai:cg:v1:markets:{order}:{page}:{perPage}:{category|-}:{ids|-} |
GET | /ai-analytics/tokens/search | CoinGecko /search | 300 c | ai:cg:v1:search:{normalizedQuery} |
GET | /ai-analytics/tokens/trending | CoinGecko /search/trending | 300 c | ai:cg:v1:trending |
GET | /ai-analytics/tokens/:id | CoinGecko /coins/{id} | 300 c | ai:cg:v1:coin:{id} |
GET | /ai-analytics/tokens/:id/chart | CoinGecko /coins/{id}/market_chart | 300 c | ai:cg:v1:chart:{id}:{days} |
GET | /ai-analytics/tokens/:id/news | CryptoCompare (общий фид) | 600 c | ai:news:v1:feed |
POST | /ai-analytics/tokens/:id/analytics | LLM (структурный JSON + Markdown) | 3600 c | ai:token-analytics:v1:{id} |
Итого 2 старых + 8 новых = 10 эндпоинтов.
/tokens/search и /tokens/trending объявлены в контроллере до /tokens/:id. Оба слова проходят под регекс идентификатора CoinGecko (token-analytics.controller.ts, комментарий прямо над роутами), поэтому если поменять порядок — Nest начнёт матчить их как id, и GET /tokens/search?query=pepe превратится в запрос карточки несуществующего токена search.
Каждый ответ токен-ветки несёт провенанс — cached, stale, fetchedAt (ISO). Единственное исключение — /market/global: он использует MarketDataService, который отдаёт уже маппленые значения и не прокидывает, откуда они взялись. Вписывать cached/stale наугад значило бы врать в Swagger-контракте, поэтому у MarketGlobalResponseDto их просто нет (зафиксировано техдолгом в дизайн-документе).
CoinGeckoClient — единственная точка выхода
apps/backend/src/aiAnalytics/services/coingecko.client.ts — единственное место в модуле, которое ходит в CoinGecko. И TokenMarketService, и мигрировавшие MarketDataService/TokenDataService вызывают только его.
export interface CoinGeckoResult<T> {
data: T;
fetchedAt: number; // epoch ms — когда тело реально получено из CoinGecko
stale: boolean; // апстрим недоступен, отдан просроченный конверт
cached: boolean; // данные из Redis, а не из свежего запроса наружу
}
get<T>(
path: string,
params: Record<string, unknown>,
opts: { cacheKey: string; ttlSec: number; staleTtlSec: number },
): Promise<CoinGeckoResult<T>>;
Клиент возвращает провенанс, а не голое значение — иначе сервисы не смогут проставить cached/stale в ответе.
cacheKey — единственный ключ и кеша, и in-flight дедупа: ни path, ни params в него не подмешиваются. Вызывающий обязан закодировать в ключе всё, что различает ответы. Два разных запроса с одинаковым cacheKey схлопнутся в один, и второй получит чужой ответ — поэтому, например, TokenMarketService.marketsCacheKey() собирает ключ из order/page/perPage/category/ids, а не полагается на дефолты.
Порядок работы get()
- Конверт из Redis. Значение хранится как
{ data, fetchedAt }. Redis-TTL самой записи равенstaleTtlSec, а «свежесть» — отдельная проверка в коде:now - fetchedAt < ttlSec * 1000. Резидентность конверта в Redis и есть окно stale — они не расходятся, потому чтоcacheSetпишет именно сstaleTtlSec. - In-flight дедуп по
cacheKey—Map<string, Promise<CoinGeckoResult<T>>>. Ретраи живут внутри дедуплицированного промиса: конкурентные вызовы разделяют и успех, и отказ; запись изMapснимается в.finally(). - Fetch с жёстким дедлайном. Выход берётся из
ProxyPoolService.next(). Каждая попытка идёт с axiostimeoutиAbortControllerна том же бюджете — одногоtimeoutнедостаточно: за прокси-агентом он не покрывает фазу установления соединения, и промис может не зарезолвиться никогда, навсегда заклинив in-flight дедуп. - Закрытая классификация ошибки — ни одна не выходит из клиента неклассифицированной (см. таблицу ниже). Порядок веток в
classify()значим — он и есть контракт. - Stale-on-error. Только ретраибельная корзина имеет право на подмену просроченным конвертом.
Классификация ошибок
classify() — switch-подобная цепочка if, где порядок веток — часть контракта:
| Корзина | Cooldown выхода | Ретрай | Stale-подмена | Наружу |
|---|---|---|---|---|
Нет HTTP-ответа, помечено как транспортное (axios.isAxiosError или строковый .code: ECONNRESET, ETIMEDOUT, ERR_CANCELED…) | да | да | да | после исчерпания попыток — stale или 503 market_data_unavailable |
403, 408, 425, 429, любой 5xx | да | да | да | то же |
404 | нет | нет | нет | 404 token_not_found |
Тело содержит error_code 10012 (окно истории вне плана) — на любом статусе, не только 401 | нет | нет | нет | 400 days_out_of_range |
401 без 10012 | нет | нет | нет | 503 market_data_unavailable + лог уровня error (это misconfiguration ключа/плана, а не вина вызывающего) |
Прочие 4xx | нет | нет | нет | 502 market_data_upstream_error |
Нет HTTP-ответа и не транспортная (наш баг — TypeError и т.п.) | — (пул не трогается вообще) | нет | нет | пробрасывается как есть, без обёртки в HTTP-исключение |
Два инварианта, которые легко случайно нарушить при правках:
404/4xxне о тправляют выход на cooldown — иначе один запрос с несуществующимidвыкосил бы весь пул выходов.- Постоянная ошибка никогда не подменяется stale-данными. Право на stale — только у ретраибельной корзины (429/5xx/сеть). На запрос, который в принципе не может стать валидным (например, несуществующий токен), клиент не имеет права молча отдать старый чужой ответ.
Код ошибки CoinGecko читается из тела на любом статусе, а не только при 401: по наблюдению на практике одно и то же тело error_code встречалось с разными HTTP-статусами. errorCodeOf() понимает обе формы тела:
{"status":{"error_code":10005}}
{"error":{"status":{"error_code":10012}}}
А 404 отдаёт тело {"error":"coin not found"}, где error — строка, а не объект. errorCodeOf проверяет форму явно (typeof error === 'object'), а не полагается на то, что обращение к .status строки просто даст undefined.
403 от api.coingecko.com отдельно залогирован как «вероятен бан IP на Cloudflare»: CoinGecko отдаёт рейт-лимиты через 429, а невалидный ключ/план — через 401; так что 403 почти всегда значит блокировку на Cloudflare перед апстримом — ровно тот случай, ради которого написан пул выходов.
Пул выходов — ProxyPoolService
apps/backend/src/aiAnalytics/services/proxy-pool.service.ts — не «пул прокси», а пул выходов: [...прокси, DIRECT_EXIT], где DIRECT_EXIT ('__direct__') — псевдовыход с agent: undefined, то есть обычный прямой запрос с продового IP.
Зачем DIRECT в пуле, а не отдельная ветка «нет прокси — идём напрямую». Без этого «пул пуст» и «все прокси только что упали и на cooldown» были бы неразличимы — оба случая молча вели бы к неограниченному долблению апстрима с одного и того же IP, ровно того, который CoinGecko банит. Когда DIRECT — рядовой элемент пула с тем же cooldown-механизмом, отказ прямого запроса тоже выводит его из ротации на MARKET_PROXY_COOLDOWN_MS, а next() возвращает null только когда на cooldown буквально всё, включая DIRECT.
next(): PickedExit | null {
const now = Date.now();
for (let i = 0; i < this.exits.length; i++) {
const key = this.exits[(this.cursor + i) % this.exits.length];
const cooldown = this.cooldowns.get(key);
if (cooldown && cooldown.until > now) continue;
this.cursor = (this.cursor + i + 1) % this.exits.length;
return { key, agent: key === DIRECT_EXIT ? undefined : this.agentFor(key), pickedAt: now };
}
return null;
}
Зачем pickedAt-гвард. reportSuccess(key, pickedAt) снимает cooldown с выхода, но только если cooldown был поставлен до того, как этот выход был выдан вызывающему (cooldown.since > pickedAt → выход не трогаем):
reportSuccess(key: string, pickedAt: number): void {
const cooldown = this.cooldowns.get(key);
if (!cooldown) return;
if (cooldown.since > pickedAt) return;
this.cooldowns.delete(key);
}
Без гварда: медленный запрос А получает выход, зависает на 10+ секунд; тем временем запрос Б получает тот же выход (он ещё не на cooldown), быстро ловит 429, ставит cooldown; наконец приходит успешный ответ на А — и его reportSuccess слепо снял бы cooldown, который Б поставил только что и по делу. Поздний успех воскресил бы выход, который параллельный запрос только что убил.
Источник списка прокси — getEnv('MARKET_PROXIES'), а не файл. getEnv (src/shared/utils/getEnv.util.ts) сначала читает /run/secrets/{name}, затем process.env[name]. Файл вида config/proxies.json, читаемый относительно cwd, был бы хрупок: в продовом контейнере cwd другой (turborepo prune + docker/Dockerfile.nest копируют не то дерево, откуда запускается процесс), и такого файла там просто не оказалось бы. Пустая переменная → пул из одного DIRECT — поведение без прокси не отличается от того, что было д о пула.
Агенты https-proxy-agent создаются лениво и кешируются по URL прокси; в логах креды всегда редактируются через redactExit() (https://user:pass@host → https://***@host).
Стратегия кеша
Redis для aiAnalytics — тот же самый инстанс, который CacheModule регистрирует глобально на уровне AppModule (app.module.ts, один CacheModule.register({ store: redisStore, ... }) на весь backend). Это значит, что ключи модуля живут в одном keyspace с auth/сессиями и всем остальным, что использует CACHE_MANAGER. Следствие: нельзя заводить ключи неограниченной кардинальности с длинным TTL — они вытесняют чужие горячие данные и разрастают общий Redis бесконтрольно.
Класс ключа → кардинальность → stale-окно
| Класс ключа | Примеры | Кардинальность | staleTtlSec |
|---|---|---|---|
| Ключи с фиксированным числом вариантов | /global, /search/trending, лента новостей (ai:news:v1:feed), дефолтная страница списка (market_cap_desc, стр. 1, 50/стр, без фильтров), пул сортировки топ-250 | единицы штук на всё приложение | AI_ANALYTICS_STALE_TTL_SEC = 86400 |
| Ключи, кардинальность которых задаёт клиент | /coins/{id}, /coins/{id}/market_chart, /search?query=, произвольные страницы/фильтры списка (ids=, нестандартный page/perPage/category) | неограниченная (любой id, любой текст поиска) | AI_ANALYTICS_STALE_SHORT_TTL_SEC = 900 |
TokenMarketService.getList() сама решает, какое окно применить: страница ровно market_cap_desc/1/50 без category/ids — это дефолт, ей достаётся длинное окно; любая другая комбинация параметров — короткое (isDefaultPage в token-market.service.ts).
Почему ключи версионированы ai:cg:v1:*
При rolling-деплое старые и новые поды одновременно читают и пишут один и тот же Redis. Миграция MarketDataService/TokenDataService на CoinGeckoClient дважды меняет форму значения под ключом:
- было: под ключом лежало уже маппленое значение (
GlobalStats,CoinMarket[]) под голым TTL; - стало: клиент кладёт конверт (
{ data, fetchedAt }) с сырым телом CoinGecko.
Старый под, читающий ключ, который новый под только что перезаписал в новой форме (или наоборот), получил бы данные, которые не проходят его собственный маппинг. Поэтому все ключи CoinGeckoClient версионированы префиксом ai:cg:v1: и никогда не переиспользуют дореформенные имена (ai:market:global, ai:market:coins:{perPage} — старые ключи, которые новый код больше не трогает). Аналогично TokenDataService при миграции перешёл с ai:token:v1:* на ai:token:v2:*.
По той же логике мигрировавшие сервисы попутно чинят обнаруженный при миграции дефект: getGlobal()/getCoins() раньше брали TTL из AI_ANALYTICS_FNG_TTL_SEC (переменной индекса страха и жадности) просто потому, что своей переменной не было — теперь у них AI_ANALYTICS_MARKET_TTL_SEC.
Проверенные вживую ограничения CoinGecko
Всё в таблице — не документация вендора, а факты, воспроизведённые живыми запросами при проектировании (docs/superpowers/specs/2026-07-12-token-analytics-design.md, раздел «Проверенные факты»). Каждая строка — причина конкретного решения в коде.
| Ограничение | Как проявляется | Как обработано в коде |
|---|---|---|
Keyless-тариф ловит 429 уже на 5–6-м запросе подряд | серия запросов подряд с одного IP | Redis-кеш + in-flight дедуп + пул выходов + stale-on-error; живой тест намеренно разносит запросы паузой (LIVE_PACE_MS) |
days=max недоступен даже на платном Demo-ключе | market_chart?days=max → 401, тело с error_code: 10012 | ChartQueryDto.days — закрытый enum 1|7|14|30|90|180|365, max не входит; 10012 на любом статусе классифицируется отдельной корзиной badRange → 400 days_out_of_range |
/coins/markets не умеет сортировать по изменению цены — order знает только market_cap_*/volume_*/id_* | нет параметра для «отсортируй по 24ч-изменению» | sort/dir — свои query-параметры; сервис тянет один кешированный пул market_cap_desc, per_page=250 и сортирует в памяти (token-market.service.ts: getSortedList). Задокументированное следствие: ранжирование работает только внутри топ-250 по капитализации |
Без параметра price_change_percentage ответ /coins/markets вообще не содержит полей 1h/7d/30d | пустые/отсутствующие поля изменения цены | price_change_percentage=1h,24h,7d,30d — жёстко зашитая серверная константа в запросе, клиентом не управляется и в ключ кеша не входит |
community_data не содержит twitter_followers | поле в ответе CoinGecko отсутствует как класс | TokenCommunityDto намеренно не включает twitterFollowers — вечный null был бы ложью в Swagger-контракте |
/search/trending: item.data.market_cap и item.data.total_volume — форматированные строки ("$24,588,336") | Number("$24,588,336") → NaN | mapTrending() эти поля вообще не выносит в DTO; выносится только item.data.price (число) и price_change_percentage_24h.usd |
| Длина спарклайна не фиксирована | в одном ответе bitcoin → 169 точек, ethereum → 168 | индикаторы (indicators.util.ts) не хардкодят длину ряда — требуют length >= period + 1, иначе возвращают null |
| Тело ошибки — два разных формата | {"status":{"error_code":...}} и {"error":{"status":{"error_code":...}}} | errorCodeOf() читает обе формы; чего не узнал — падает в общую ветку «прочие 4xx» |
404 — тело {"error":"coin not found"}, где error — строка | наивный код упал бы на body.error.status.error_code | errorCodeOf() явно проверяет typeof error === 'object' перед обращением к .status |
Новости — общий фид, а не запрос на токен
TokenNewsService (services/token-news.service.ts) держит один кеш ленты новостей CryptoCompare на всё приложение (ai:news:v1:feed), а не отдельный запрос под каждый токен.
Почему не пер-токенный запрос. У CryptoCompare параметр categories — фиксированный справочник (BTC, ETH, MINING, REGULATION…), а не произвольный тикер. Для длинного хвоста токенов (pepe, wif и т.п.) категории просто не существует — запрос с такой категорией молча вернул бы пусто или ошибку. Поэтому фид тянется целиком один раз, а фильтрация под конкретный токен идёт в памяти на бэкенде.
Матчинг (TokenNewsService.matches()), в порядке приоритета:
categoriesэлемента ленты (pipe-separated) содержитSYMBOL;- иначе
tagsсодержитSYMBOL; - иначе
titleсодержитSYMBOLкак отдельное слово, либо содержит полноеnameтокена.
/** Тикер как отдельное слово — чтобы ARB не совпал с «Arbitrage». */
function hasWord(haystack: string, word: string): boolean {
if (!word) return false;
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b${escaped}\\b`, 'i').test(haystack);
}
Проверка на границу слова (\b) — не косметика: без неё тикер ARB (Arbitrum) совпал бы по подстроке с любой статьёй про «Arbitrage», и токен получил бы чужие новости.
matchedBySymbol: false — если ни один элемент ленты не совпал ни по одному из трёх правил, эндпоинт всё равно возвращает 200 с первыми 12 элементами общей лен ты, но с флагом matchedBySymbol: false. Это не ошибка и не пустой ответ: фронт обязан использовать флаг, чтобы честно подписать блок «Новости рынка», а не выдать общую ленту за новости конкретного токена.
Другие детали устойчивости фида:
- ключ
CRYPTOCOMPARE_API_KEYобязателен даже для free-tier — keyless server-side запрос отдаёт401; без ключа фид сразу деградирует в пустой список, наружу не ходили и кеш не пишется; - при ошибке CryptoCompare кладёт в
Dataобъект, а не массив —?? []этого не ловит, поэтому код явно проверяетArray.isArray; - пустая лента не кешируется — иначе один транзиентный пустой ответ залип бы пустыми новостями для всех токенов приложения на весь TTL;
published_onприходит в секундах; проверка не ограничиваетсяNumber.isFinite— валидируется получившаясяDate, потому что классическая ошибка апстрима «миллисекунды вместо секунд» дала быInvalid Date, аtoISOString()на нём бросаетRangeError, роняя общий фид целиком.