Перейти к основному содержимому

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/outlookFear&Greed (alternative.me) + AI (altseason, структурный JSON)3600 c (AI_ANALYTICS_OUTLOOK_TTL_SEC)ai:outlook:v1
POST/ai-analytics/pool/analyticsUniswap 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/globalCoinGecko /global + Fear&Greed60 c (AI_ANALYTICS_MARKET_TTL_SEC)ai:cg:v1:global (без провенанса наружу, см. ниже)
GET/ai-analytics/tokensCoinGecko /coins/markets60 cai:cg:v1:markets:{order}:{page}:{perPage}:{category|-}:{ids|-}
GET/ai-analytics/tokens/searchCoinGecko /search300 cai:cg:v1:search:{normalizedQuery}
GET/ai-analytics/tokens/trendingCoinGecko /search/trending300 cai:cg:v1:trending
GET/ai-analytics/tokens/:idCoinGecko /coins/{id}300 cai:cg:v1:coin:{id}
GET/ai-analytics/tokens/:id/chartCoinGecko /coins/{id}/market_chart300 cai:cg:v1:chart:{id}:{days}
GET/ai-analytics/tokens/:id/newsCryptoCompare (общий фид)600 cai:news:v1:feed
POST/ai-analytics/tokens/:id/analyticsLLM (структурный JSON + Markdown)3600 cai: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 вызывают только его.

services/coingecko.client.ts
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()

  1. Конверт из Redis. Значение хранится как { data, fetchedAt }. Redis-TTL самой записи равен staleTtlSec, а «свежесть» — отдельная проверка в коде: now - fetchedAt < ttlSec * 1000. Резидентность конверта в Redis и есть окно stale — они не расходятся, потому что cacheSet пишет именно с staleTtlSec.
  2. In-flight дедуп по cacheKeyMap<string, Promise<CoinGeckoResult<T>>>. Ретраи живут внутри дедуплицированного промиса: конкурентные вызовы разделяют и успех, и отказ; запись из Map снимается в .finally().
  3. Fetch с жёстким дедлайном. Выход берётся из ProxyPoolService.next(). Каждая попытка идёт с axios timeout и AbortController на том же бюджете — одного timeout недостаточно: за прокси-агентом он не покрывает фазу установления соединения, и промис может не зарезолвиться никогда, навсегда заклинив in-flight дедуп.
  4. Закрытая классификация ошибки — ни одна не выходит из клиента неклассифицированной (см. таблицу ниже). Порядок веток в classify() значим — он и есть контракт.
  5. 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() понимает обе формы тела:

форма 1 — эндпоинт не на плане
{"status":{"error_code":10005}}
форма 2 — вне окна истории
{"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.

services/proxy-pool.service.ts
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 → выход не трогаем):

services/proxy-pool.service.ts
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@hosthttps://***@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-м запросе подрядсерия запросов подряд с одного IPRedis-кеш + in-flight дедуп + пул выходов + stale-on-error; живой тест намеренно разносит запросы паузой (LIVE_PACE_MS)
days=max недоступен даже на платном Demo-ключеmarket_chart?days=max401, тело с error_code: 10012ChartQueryDto.days — закрытый enum 1|7|14|30|90|180|365, max не входит; 10012 на любом статусе классифицируется отдельной корзиной badRange400 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")NaNmapTrending() эти поля вообще не выносит в 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_codeerrorCodeOf() явно проверяет 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()), в порядке приоритета:

  1. categories элемента ленты (pipe-separated) содержит SYMBOL;
  2. иначе tags содержит SYMBOL;
  3. иначе title содержит SYMBOL как отдельное слово, либо содержит полное name токена.
utils внутри token-news.service.ts
/** Тикер как отдельное слово — чтобы 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, роняя общий фид целиком.

AI-ветка

Правило детерминизма

Каждое число, которое увидит пользователь, считается в коде и передаётся модели дословно готовой строкой (buildTokenInput() в prompts/token-analytics.prompt.ts). Модель пишет только прозу — вердикт, наблюдения, риски, сценарии — и никогда не является источником чисел. temperature в вызов LLM не передаётся никогда: локальный AI-прокси (Codex-backend) отклоняет её с 400.

Что уходит в модель

Цена и изменения 1ч/24ч/7д/30д/1г, капитализация и ранг, FDV, объём за 24ч, отношение объёма к капитализации, supply (circulating/total/max), ATH/ATL и расстояние до них, RSI(14) и волатильность за 7д (посчитаны из sparkline7d карточки — AI-ветка не делает отдельного похода за графиком), тренд, категории, описание проекта (обрезано до 1200 символов), метрики разработки (звёзды, коммиты за 4 недели), метрики сообщества (Reddit), sentiment-голоса, Fear&Greed, доминация BTC, до 5 заголовков новостей.

Карточка токена (TokenMarketService.getDetail) — единственный обязательный источник: нет токена → 404 летит наружу до всякого похода к LLM. Fear&Greed, глобальная статистика и новости — необязательны, каждый обёрнут в .catch(() => дефолт), чтобы падение любого из них не лишило пользователя разбора.

Почему Markdown рендерится в коде, а не вторым вызовом LLM

renderTokenMarkdown() (utils/markdown-render.util.ts) — чистая функция, которая строит Markdown из того же JSON-объекта analysis, который вернула модель. Второго вызова LLM для Markdown нет: один вызов модели, ноль шансов, что структурный JSON и человекочитаемый Markdown разойдутся между собой (что случилось бы, если бы Markdown был отдельным независимым ответом модели), и полностью детерминированный юнит-тест рендера.

Почему фолбэк не кешируется

buildFallbackAnalysis() — детерминированный разбор из уже посчитанных метрик (вердикт выводится из trend7d и порогов RSI), используется, когда AI_MODEL пуст или модель не вернула валидный JSON. Он намеренно не проходит через cacheSet: если бы фолбэк лёг в Redis на AI_ANALYTICS_TOKEN_AI_TTL_SEC (час), временный сбой LLM залип бы в ответах на целый час даже после того, как модель снова заработала. Следующий запрос после фолбэка всегда повторяет попытку сходить в модель.

Зачем timeout + maxRetries: 0 на OpenAI-клиенте

providers/openai.provider.ts
export function createOpenAiClient(): OpenAI {
return new OpenAI({
baseURL: process.env.OPENAI_BASE_URL || AI_DEFAULTS.baseUrl,
apiKey: process.env.OPENAI_API_KEY || 'local',
// Без этих двух дефолты SDK — 10 минут таймаута и 2 ретрая, то есть
// зависший прокси держал бы HTTP-запрос до получаса, и гарантия
// «LLM недоступен → 200 с фолбэком» была бы недостижима.
timeout: Number(process.env.AI_REQUEST_TIMEOUT_MS) || AI_DEFAULTS.aiRequestTimeoutMs,
maxRetries: 0,
});
}

Дефолты OpenAI SDK — таймаут 10 минут и 2 автоматических ретрая. Без явной правки один зависший локальный AI-прокси удерживал бы HTTP-запрос до получаса (10 минут × 3 попытки), и обещание «LLM легла → ответ 200 с детерминированным фолбэком за разумное время» было бы физически недостижимо в пределах жизни запроса. OPENAI_CLIENT — общий DI-провайдер, поэтому правка одним махом закрыла ту же дыру и в market/outlook, и в pool/analytics — у них была та же проблема.

Переменные окружения

ПеременнаяНазначениеДефолт
OPENAI_BASE_URLбаза локального AI-прокси (CLIProxyAPIPlus)http://localhost:8317/v1
OPENAI_API_KEYклиентский ключ (нужен, только если у прокси непустой api-keys)
AI_MODELимя модели; пусто → LLM не вызывается вообще, сразу фолбэк
AI_REQUEST_TIMEOUT_MSтаймаут запроса к OpenAI-клиенту, maxRetries всегда 030000
AI_ANALYTICS_DEFAULT_NETWORKсеть по умолчанию для pool/analyticsbsc
AI_ANALYTICS_FNG_TTL_SECTTL кеша Fear&Greed (alternative.me)300
AI_ANALYTICS_OUTLOOK_TTL_SECTTL market/outlook3600
AI_ANALYTICS_TOKEN_TTL_SECTTL TokenDataService (токен по контракту, для pool/analytics)300
AI_ANALYTICS_POOL_TTL_SECTTL pool/analytics900
COINGECKO_URLявное переопределение хоста CoinGecko (перекрывает выбор по плану)по плану
COINGECKO_API_KEYопциональный ключ CoinGecko
COINGECKO_API_PLANdemo | pro — выбирает и base URL, и заголовок ключа; pro-ключ на demo-хосте отвергаетсяdemo
COINGECKO_MAX_RETRIESпопыток при 429/5xx/сетевой ошибке3
COINGECKO_TIMEOUT_MSдедлайн одной попытки (axios timeout + AbortController)15000
MARKET_PROXIESCSV egress-прокси, читается через getEnv() (/run/secretsprocess.env), не из файла— (пусто → пул из одного DIRECT)
MARKET_PROXY_COOLDOWN_MScooldown упавшего выхода; 0 — легитимное значение («не выводить из ротации»)60000
CRYPTOCOMPARE_API_KEYключ ленты новостей; без него лента деградирует в пустую
AI_ANALYTICS_MARKET_TTL_SECTTL /tokens (список) и /market/global60
AI_ANALYTICS_TOKEN_DETAIL_TTL_SECTTL карточки токена и графика300
AI_ANALYTICS_SEARCH_TTL_SECTTL поиска и trending300
AI_ANALYTICS_NEWS_TTL_SECTTL ленты новостей600
AI_ANALYTICS_TOKEN_AI_TTL_SECTTL AI-разбора токена3600
AI_ANALYTICS_STALE_TTL_SECstale-окно для ключей фиксированной кардинальности86400
AI_ANALYTICS_STALE_SHORT_TTL_SECstale-окно для ключей, кардинальность которых задаёт клиент900

Полный список с комментариями — apps/backend/.env.example (секции «AI Analytics» и «Токен-аналитика»); валидация — Joi-схема в apps/backend/src/config/env-validation.schema.ts. Там же зафиксирована асимметрия валидации: почти все числовые переменные модуля обязаны быть строго положительными (.min(1)), и только MARKET_PROXY_COOLDOWN_MS разрешён с .min(0) — ноль здесь законный операционный рычаг («не карантинить упавший выход»), а не ошибка конфигурации.

Живой тест

Юнит- и сервисные тесты мокают axios и коллабораторов. Отдельно есть скрипт, который бьёт по настоящему CoinGecko, настоящему CryptoCompare и настоящему локальному AI-прокси — без Nest, без БД, без JWT:

cd apps/backend && node -r ts-node/register scripts/token-analytics-live-check.ts [list|search|trending|detail|chart|news|ai|all]

Режимы соответствуют веткам сервиса: list, search, trending, detail, chart, news, ai — по одной проверке; all — все по очереди. Скрипт читает apps/backend/.env (OPENAI_BASE_URL, OPENAI_API_KEY, AI_MODEL, CRYPTOCOMPARE_API_KEY) и собирает сервисы вручную через конструкторы (wire() в скрипте), с Map-based фейковым кешем вместо Redis.

Переменные окружения скрипта:

ПеременнаяНазначениеДефолт
LIVE_TOKEN_IDid токена CoinGecko для режимов detail/chart/news/aibitcoin
LIVE_PACE_MSпауза между запросами к CoinGecko3000

Пауза не декоративная: keyless-тариф CoinGecko ловит 429 уже после 5–6 запросов подряд, и режим all без неё не проходил бы до конца.

Старые эндпоинты живым скриптом не переписывались — они прогоняются отдельно, тем же образцом:

cd apps/backend && node -r ts-node/register scripts/ai-analytics-live-check.ts outlook

Известные ограничения

  • Keyless CoinGecko без прокси упирается в ~5–15 запросов в минуту; защита — Redis-кеш, stale-on-error и cooldown на DIRECT. Под реальным трафиком пул прокси (MARKET_PROXIES) обязателен.
  • Новости матчатся по токену эвристически, не нативным фильтром CryptoCompare; для длинного хвоста токенов совпадений может не быть.
  • Поиск (/search) не отдаёт цены и ограничен 25 результатами — это ограничение самого CoinGecko, а не наше.
  • Сортировка по изменению цены ранжирует только внутри топ-250 по капитализации.
  • График ограничен 365 днями — публичный и платный Demo-план CoinGecko это ограничение не снимают, снимает только более старший план.
  • In-flight дедуп внутрипроцессный: при нескольких репликах бэкенда одновременная просрочка ключа даёт по одному апстрим-запросу на реплику, а не один на всё приложение.
  • Rate-limit на стороне собственного API отсутствует — в бэкенде нет @nestjs/throttler вообще. Защита от абьюза — JWT + кеш + дедуп, как у остальных LLM-эндпоинтов проекта.
  • /market/global не несёт провенанса (см. выше) — MarketDataService не прокидывает cached/stale наружу; зафиксировано техдолгом.