Сбор и анализ рыночных данных PoE2

Захотел я получать актуальные рыночные данные по путевым камням 15 уровня в Path of Exile 2, чтобы выставлять цену на собственные лоты конкурентно с текущим спросом — а не продавать хорошие камни за копейки из-за недооценки или неделями ждать продажи из-за завышенной цены.

Источники данных

Данные берутся через официальный Trade Search API Path of Exile 2:

  • POST /api/trade2/search/{league} — поиск по фильтру, возвращает ID подходящих лотов
  • GET /api/trade2/fetch/{ids} — детали лотов (цена, моды) пачками по 10 ID

Для отбора только мгновенного выкупа (без запроса в личку продавцу) используется статус "securable". Первую версию фильтра — sale_type: fixed_price — предложил ассистент по аналогии с PoE1, и она не сработала: в PoE2 такого поля нет. Рабочий статус нашёл самостоятельно — через анализ реального запроса, который отправляет официальный сайт трейда в браузере.

Процесс самостоятельного сбора информации из веб версии

Небольшой нюанс на стороне домена: www.pathofexile.com и ru.pathofexile.com — оба рабочие, но ru-домен возвращает текстовые описания модов на русском, при этом конкретные параметры идут в формате англ_название|русское_значение — это позволяет строить фильтры поиска что на русском, что на английском интерфейсе, ориентируясь на англ-часть. Этот нюанс тоже выяснил самостоятельно: варианты, предложенные ассистентом (Accept-Language, ?language=ru в URL), результата не дали.

Первая попытка — ложный след

Первая попытка получить данные с Trade API закончилась тупиком, и причина оказалась банальнее, чем выглядела в моменте. Ассистент принял Forbidden-ответ API за защиту Cloudflare (cf_clearance, TLS-фингерпринт) и начал разворачивать обход через Playwright. Реальную причину нашёл сам: подводил заголовок User-Agent — явно кастомный waystone-market-fetch/0.1 (contact: ...) API отклонял. Замена на обычную браузерную строку решила проблему без какого-либо обхода защиты — потому что защиты как таковой и не было.

Осознав, что диагностика ассистента зашла не туда, пошёл разбираться самостоятельно: изучение исходного кода kickside (открытого инструмента для трейда PoE) показало, что рабочий запрос к API устроен намного проще, чем выстраивалось до этого. С этим пониманием код собрали заново — на этот раз с помощью DeepSeek, в качестве эксперимента.

Рабочая схема сбора данных

Точный механизм пагинации Trade Search API не выяснялся — не было смысла копать глубже. Факт, который важен на практике: каждый повторный поисковый запрос с одним и тем же фильтром отдаёт новый набор ID, и это как раз то, что нужно — собрать пул пошире можно повторными запросами с дедупликацией. Пул расширяется ещё и перебором порогов по параметрам вручную: один параметр меняется с шагом (например, map_rare_monsters от 18 до 50, map_bonus от 55 до 110), остальные не заданы, кроме фиксированного map_tier=15.

Полученные ID объединяются, дедуплицируются и уходят на fetch пачками по 10 — максимум, который отдаёт API за один запрос.

Rate limit (HTTP 429) на этапе тестов закрывался просто — засыпать на захардкоженные 300 секунд было терпимо (тесты шли параллельно с игрой). Для рабочего решения это не годилось: нужно было не терять данные по всему шагу при каждом попадании в лимит. В ответе API не нашлось отдельного параметра с точным временем ожидания — ассистент предложил обходной путь: parse_wait_seconds вытаскивает это время регуляркой прямо из текста сообщения об ошибке, после чего запрос повторяется автоматически (обёртки post_with_retry/get_with_retry поверх requests.post/requests.get).

Отдельно — набор обычных багов реализации (пересборка списка ID на каждой итерации внешнего цикла, необъявленная переменная в цикле добора, обращение не к тому объекту ответа при обработке ошибок). Все они — следствие исходного прототипа, который собрал самостоятельно на скорую руку и отдал ассистенту на доработку и исправление.

Хранилище: ClickHouse

ClickHouse развернул без участия ассистента — отдельная ВМ в домашней инфраструктуре, попутно обновлённой с Proxmox 8 до 9 по собственной инициативе (никакой функциональной проблемы этот переход не решал).

Единственная реальная проблема — ClickHouse был доступен только изнутри самой ВМ, не с других устройств в сети. Здесь уже подключил ассистента: причина в том, что по умолчанию ClickHouse слушает только localhost, плюс нужно было открыть нужные порты в firewall ВМ. После правки конфигурации и проверки доступа с внешнего интерфейса всё заработало.

Путь данных: от JSON к таблице

Каждый лот из ответа API — вложенная структура (item + listing). parse_waystone_item() разбирает её в плоский словарь: имя, базовый тип, редкость, уровень предмета, статус порчи, свойства карты (доступно возрождений, размер паков, редкость и эффективность монстров, шанс выпадения — через extract_prop() по числовому type в массиве properties), список модов, цена, валюта, комиссия, позиция в стеше, время индексации.

Дальше — приведение типов (cast_waystone_types): числовые характеристики вытаскиваются регуляркой из строк вида "+40%", позиция в стеше собирается в кортеж (имя, x, y), время — в datetime UTC.

Отдельно всплыл побочный эффект: на промежуточном этапе, до того как ассистенту был отдан весь код парсера, потребовалось внести пару правок над уже собранными данными — и для этого использовался проход через CSV. Без полного контекста кода разбор проблемы снова свернул не туда: сохранённые в CSV списки и словари (explicit_mods, stash) превращались в строки-репрезентации Python-объектов, а их разбор через ast.literal_eval() иногда падал с ValueError: malformed node or string. Решилось всё разом, когда ассистенту передали весь код парсера целиком: стало ясно, что таблицу можно собирать напрямую из JSON-ответов API, минуя CSV, и поля просто остаются нативными объектами без нужды в ast.literal_eval.

Финальная схема таблицы в ClickHouse (poe2_trade.waystone_listings) отражает это: Array(String) для модов, Tuple(name String, x UInt8, y UInt8) для позиции в стеше, FixedString(64) для id, ENGINE = ReplacingMergeTree(), ORDER BY id, партиционирование PARTITION BY toYYYYMM(indexed) — выбор партиционирования по дате индексации лота, с последующей принудительной дедубликацией.

Финальная таблица в бд

Аналитика

Первый заход на анализ данных отдали ассистенту целиком — и получили набор красивых, но необоснованных построений, оторванных от реальных данных. После недолгого участия в этом процессе стало ясно, что анализ нужно вести самостоятельно, подключая ассистента точечно, под конкретные вопросы. Дальше — что из предложенного не подошло, и что в итоге сработало.

Что не подошло

Цепи Маркова — отклонены сразу: в данных нет последовательности состояний, а только срез рынка на момент запроса, так что модель просто неприменима к этой структуре данных.

Вторая гипотеза — про готовые инструменты сегментации, RFM и ABC-анализ. Формально оба заточены под другую задачу: RFM — под историю транзакций покупателей, ABC — под вклад позиций в выручку. Ни того, ни другого в данных нет. Но при желании оба подхода можно вывернуть под нужную форму — например, переосмыслить ABC под этот датасет и прогнать результат через RFM-логику, получив сегментацию для быстрой классификации лотов. Проблема не в применимости, а в цене: это заметный объём лишней возни ради самого подхода, а не ради результата — и всё это ради того, что можно получить быстрее другим способом. От RFM/ABC отказались не потому что не сработало бы, а потому что овчинка не стоила выделки.

Отдельно встал вопрос смещения выборки: раз данные собираются сортировкой по цене (по возрастанию) и берутся только верхние результаты, выборка смещена в сторону дешёвых лотов. Это нормально и не требует исправления: покупатель при прочих равных всегда ищет минимальную цену, так что интересен именно нижний край рынка. Бывают товары, где идти строго за рынком не стоит, но путевые камни — не тот случай: это профицитный товар, предложения на рынке заведомо больше, чем спроса на конкретный лот.

Похожая логика применилась и к вопросу "умирающей лиги" — общего снижения цен на всё со временем, характерного для игровых экономик ближе к концу игрового сезона. Чтобы отделить эффект общего проседания цен от ценности конкретных параметров лота, предложено нормализовать цену относительно рыночной медианы за тот же период (price_relative) — с учётом возраста лота (age_days/age_hours).

Что сработало

Отправная точка — таблица ClickHouse, структура которой собрана по привычному шаблону: у каждого поля — осмысленное описание, зачем оно нужно, у самой таблицы — общий комментарий для лога изменений (lineage), чтобы сторонний человек мог разобраться в структуре без дополнительных вопросов. Старая привычка работы в продакшн БД.

Из вариантов оценки влияния параметров на цену рабочим оказался подход через SHAP и градиентный бустинг (LightGBM) — при объёме данных около 14 000 записей многомерный анализ такого рода даёт осмысленный результат.

Дерево решений (DecisionTreeRegressor) как интерпретируемая альтернатива дало неожиданно скромный результат: всего 5 листьев, и из пяти параметров задействовано только два (monster_effectiveness, pack_size). Это не обязательно повод считать остальные параметры бесполезными — скорее сигнал проверить количество наблюдений в каждом листе и ослабить min_samples_leaf, прежде чем делать выводы о значимости оставшихся признаков.

Обсуждалась и взвешенная (по числу наблюдений в группе) Ridge-регрессия на логарифм цены поверх агрегированной таблицы — с диагностикой через сравнение предсказаний модели с фактическими медианами, чтобы понять, нужны ли попарные взаимодействия параметров, а не только аддитивные веса. Дальше обсуждения эта идея не пошла — на практике её не потребовалось.

Финальный инструмент, который реально используется (собрал сам), — проще и надёжнее любой модели: SQL-запрос, группирующий лоты по бакетам всех пяти параметров (floor(param, -1)), с агрегацией median(price_exalted) и порогом having cnt > 5, отсекающим бакеты со слишком малым числом наблюдений для доверия к медиане. Прямой и интерпретируемый способ ответить на вопрос "сколько стоит лот с такими характеристиками" — без обучения моделей.

Агрегат с результатами

Поверх lookup-таблицы собрана функция estimate_price(): каждый параметр — опциональный именованный аргумент, при неполном совпадении используется взвешенное усреднение по нескольким подходящим группам, на выходе — медиана и диапазон (Q25–Q75), с предупреждением при малом числе наблюдений в группе. Обёрнута в интерактивный виджет на ipywidgets — чекбоксы и слайдеры по каждому параметру, результат обновляется на лету.

Финальная на текущий момент реализация проверки цен

Вывод

По ходу этой истории виден чёткий перекос: там, где не хватало контекста или диагноз строился на догадке, ассистент по умолчанию тянулся к сложному решению — хотя рабочим неизменно оказывался вариант на порядок проще. Forbidden-ответ API превратился в разворачивание обхода Cloudflare через Playwright, при том что дело было в одной строке User-Agent. Первый заход на анализ данных вылился в конструкции поверх цепей Маркова, RFM/ABC-сегментации и нескольких моделей регрессии — при том что рабочим инструментом в итоге стал один SQL-запрос с группировкой по бакетам и медианой. Промежуточный CSV с ast.literal_eval() понадобился ровно до того момента, пока ассистент не увидел код целиком, — после этого решение упростилось само, безо всякого воркэраунда.

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

Разница не в том, насколько мощная модель, а в том, есть ли у человека на другом конце возможность распознать, где предлагается лишнее, и настоять на более простом пути. Без этого — лотерея: можно неделями городить обход несуществующей защиты вместо того, чтобы поменять одну строку.

По сути это тот же самый опыт, что и работа с командой: результат проверяется, работа держится на контроле, а не принимается на веру. Разница в одном — каждая задача начинается с чистого листа заново, без накопленного доверия и истории совместной работы, которая с реальным человеком в команде складывается со временем.

Subscribe to zhe700

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe