← Back

Weather Bot — Полный аудит кода

Дата: 2026-05-30 Метод: 4 параллельных агента по модулям + 1 adversarial-верификатор (8 находок перепроверены по коду). Покрытие: executor.py, config.py, resolver.py, analyzer.py, risk.py, scanner.py, bot.py, db.py, forecaster.py (4199 строк).

Статусы: ✅ CONFIRMED (верифицировано вторым агентом) · ◻️ не проверялось вторым агентом (single-source, обоснование в коде) · ❌ FALSE POSITIVE (проверено и отклонено).


🔴 Критичные (теряют деньги / искажают результат)

1. ✅ resolver.py:765-770 — °C between/eq маркеты резолвятся с °F-округлением → неверный win/loss

Для between/eq границы конвертируются в °F (13–14°C → 55.4–57.2°F) и сравниваются с round(actual_F). Polymarket резолвит C-маркеты округлением фактической температуры в °C. День 55°F (12.8°C → round = 13°C = YES) скорится как NO. Бьёт по каждому Celsius-маркету = почти все не-US города. Искажает P&L и калибровку. Fix: при unit=="C" округлять факт в °C и сравнивать с целыми °C-границами: actual_c=(actual-32)*5/9; yes = low_c <= round(actual_c) <= high_c (использовать оригинальные threshold_low/high до конвертации).

2. ◻️ executor.py:184-218 — retry-цикл post_order может разместить ордер дважды (double-spend)

Цикл ретраит client.post_order() до 3 раз на любом исключении. Если первый POST дошёл и ордер принят, но HTTP-ответ потерян (timeout/proxy drop — ровно то, на что рассчитан ретрай), вторая попытка размещает вторую live-позицию. Idempotency-гарды (get_active_trades, has_cancelled_for_market) работают до цикла и не видят дубль внутри него. Fix: не ретраить вслепую. Ретраить только ошибки, доказывающие, что запрос не дошёл (connect-timeout/DNS). При read-timeout/unknown — пометить unverified и отдать reconciliation, как уже сделано на loss-пути.

3. ◻️ executor.py:107-120 — idempotency-гард не атомарен, параллельные сканы оба проходят

place_bet идёт через asyncio.to_thread в цикле по брекетам. Последовательность check (get_active_trades) → insert (save_trade) без лока и без UNIQUE-констрейнта. Два сигнала на один market+side, обработанные близко (или скан, пересёкший предыдущий — scan-лока нет, только _resolve_lock), оба читают «нет активного трейда» и размещают live-ордера. Fix: partial UNIQUE index на (market_id, side) WHERE status IN ('pending','filled') AND dry_run=0 + scan-level asyncio lock по аналогии с _resolve_lock.

4. ◻️ executor.py:355-375 — redeem перебирает [balance,balance], шлёт лишние on-chain tx, жжёт газ

Для neg-risk redeem цикл [[balance,0],[0,balance],[balance,balance]] шлёт реальную tx на каждой итерации. [balance,balance] просит редимить оба исхода, имея только один → revert (потраченный газ). На позициях $3–6 три неудачные tx стоят дороже позиции. Нет cap газа против стоимости позиции. Fix: определять индекс исхода из side/token_id (известно, что держим) и слать ровно один redeem. Убрать [balance,balance]. Скип redeem, если стоимость позиции < ожидаемого газа.

5. ✅ forecaster.py:299, 448 — control-run считается членом ансамбля → смещение вероятности

member_keys = [k for k in hourly if k.startswith("temperature_2m")] ловит и детерминированный temperature_2m (control), и ..._memberNN. Control получает равный вес с возмущённым членом (~1/31 на модель, 2 control-рана из ~82). Смещает вероятность каждой ставки к среднему. (config.py:150 подтверждает ENSEMBLE_MEMBERS=31 = control + member01..30.) Fix: startswith("temperature_2m_member") либо явно выкинуть "temperature_2m". Проверить, что гейт ≥10 членов всё ещё проходит.


🟡 Средние

6. ✅ db.py:306-319 (get_today_stats) — UTC-день вместо Vancouver ослабляет kill switch

today = datetime.now(timezone.utc) бакетит дневной P&L по UTC-дню. С ~17:00 PDT (00:00 UTC) UTC уходит на следующий день, пока в Ванкувере ещё «сегодня». Убыточная вечерняя серия рвётся на два бакета → MAX_DAILY_LOSS (читается из risk.py:32) может не сработать. Доп: pnl = sum(t["pnl"] for t in trades if t["pnl"]) отбрасывает легитимный $0. Fix: считать день в ZoneInfo("America/Vancouver") консистентно с записью created_at; sum((t["pnl"] or 0) for ...).

7. ✅ risk.py:23-35 — kill switch проверяется только внутри can_trade()

Проверка дневного убытка живёт только в can_trade(), вызываемой при появлении нового сигнала. Если новых сигналов нет, уже превышенный убыток по открытым позициям не триггерит стоп. Fix: отдельный check_daily_loss() в начале каждого скан-цикла, до цикла по брекетам.

8. ✅ bot.py:372-386 — Basic Auth fail-open при пустом DASH_USER

if not DASH_USER: return await call_next(request) — при незаданном/пустом DASH_USER (PM2 env не пробросился — известные грабли) все эндпоинты, включая мутирующие POST /api/scan, /api/resolve, /api/kill-switch/*, открыты. Fix: fail closed — отказывать в старте или денаить мутирующие POST, если креды не заданы; bind на localhost + nginx auth.

9. ◻️ analyzer.py:112 — финальная price может упасть ниже только что проверенного MIN_BUY_PRICE

price = min(buy_price + 0.01, win_prob - 0.02). Band-проверка (MIN/MAX_BUY_PRICE) сделана на buy_price, но торгуется price. NO-бет при buy_price 0.73, model NO-prob 0.74 → price = min(0.74, 0.72) = 0.72 — ниже пола 0.72 и ниже аска (не зафилится). Fix: перепроверять финальную price против MIN/MAX_BUY_PRICE (или валидировать price, а не buy_price).

10. ◻️ config.py:18, 26 — парсинг bool: BLOCK_YES_SIDE fail-dangerous

BLOCK_YES_SIDE = os.getenv(...).lower() == "true". Любое искажённое значение ("True " с пробелом, "1", "yes", дроп при env-reload) → False, разблокируя YES-сторону (42.9% WR, −$13.49). DRY_RUN парсится так же, но там дефолт fail-safe. Fix: строгий хелпер val.strip().lower() in ("true","1","yes") + лог фактических DRY_RUN/BLOCK_YES_SIDE на старте.

11. ◻️ executor.py:189 — ответ без orderID принимается как успех

order_id = response.get("orderID") or response.get("id","unknown"). 2xx-тело с реальным реджектом ({"success":false} без orderID) → order_id="unknown", success=True, пишется pending-трейд. Фантомная позиция занимает слот MAX_OPEN_POSITIONS и списывает actual_cost из running balance. Fix: отсутствие orderID/id или falsy success = провал; успех только при реальном id.

12. ◻️ executor.py:157-172 + bot.py:287,305 — три разные формулы стоимости ордера расходятся

executor.actual_cost = round(shares*price,2) (shares = round(size/price,2)), а bot.py дважды считает max(signal["size"], 5*price), executor применяет MIN_SHARES по своему правилу. Running-balance гейт в bot.py мис-предсказывает фактическую стоимость → преждевременный balance_exhausted или over-deploy. Fix: единый источник правды — переиспользовать возвращаемый executor actual_cost; округлять shares к точности венчика.

13. ◻️ executor.py:227 — классификация ошибок по подстроке хрупкая

is_definite_reject = "not enough balance" in err or "allowance" in err. Смена формулировки SDK ("insufficient balance", локализация) → definite reject уходит в unverified; посторонняя ошибка с "allowance" → ошибочно cancelled (реоткрывает ghost-position риск). Fix: структурные коды/типы исключений py_clob_client_v2 вместо подстрок; неоднозначное → unverified.

14. ◻️ executor.py:440-481 — Safe nonce читается один раз, в retry не обновляется

safe_nonce = wc.safe.functions.nonce().call() читается до подписи; retry-цикл обновляет только EOA nonce, не Safe nonce и не подпись. Если redeem-tx через Safe майнится между чтением и execTransaction → Safe nonce устарел → revert, ретрай той же устаревшей подписью жжёт до 3× газа при gas=500000. Fix: перечитывать safe.nonce() и переподписывать внутри retry; либо сериализовать redeem и wrap.

15. ◻️ executor.py:271+ — захардкоженные адреса без checksum; get_ctf_balance()==-1 не обрабатывается

CTF/USDC_E/ONRAMP/USDC_NATIVE — строковые литералы, используются raw в eth_call. get_ctf_balance возвращает -1 при ошибке RPC, но redeem_position различает только ==0; -1 идёт в elif balance_before > 0 → False → redeem молча ничего не делает или действует на устаревших данных. Бесплатные RPC отдают stale. Fix: checksummed-константы; при -1 (unknown) — abort redeem-пути; retry по 2 RPC до действия.

16. ◻️ bot.py:346-348 — APScheduler без max_instances/coalesce/misfire_grace_time

Скан дольше SCAN_INTERVAL_SEC (1800s) → следующий ран дропается с дефолтным misfire_grace_time=1, дашборд показывает stale (дропнутый путь не обновляет last_scan_result). Внутренние локи частично прикрывают, но не таймстемпы. Fix: add_job(..., max_instances=1, coalesce=True, misfire_grace_time=300).

17. ◻️ forecaster.py:155 — Gaussian-fallback торгует overconfident

Когда ансамбль недоступен (rate-limit/sparse), fallback на calculate_probability с SIGMA_BY_DAYS. Узкий between-брекет (±0.5°F) при σ=3.0 даёт ненулевую вероятность, проходящую MIN_EDGE после Kelly — ровно там, где модель наименее надёжна. Fix: блокировать live при prob_method=="gaussian" для between/eq, либо раздувать sigma / поднимать edge при n_members==0.

18. ◻️ resolver.py:331-333 — поиск YES-токена без break, побеждает последний

Цикл по токенам не делает break после матча, перезаписывает yes_winner на каждой итерации. При >1 токене с outcome=="yes" или неожиданном порядке результат определяет последняя итерация. Fix: break после первого outcome=="yes".

19. ◻️ resolver.py:486, 575 — дублирующий redeem за один цикл

_auto_redeem_resolved(trades) и затем безусловный _retry_unredeemed_wins() оба берут только что зарезолвленные wins и шлют redeem (time.sleep(2) каждый). Не теряет средства (redeem идемпотентен, _mark_redeemed гард), но шлёт избыточные on-chain tx до коммита redeemed=1. Fix: убрать _auto_redeem_resolved, полагаться на _retry_unredeemed_wins (покрывает все unredeemed wins).

20. ◻️ analyzer.py:55 — tie-break берёт YES, потом блокирует → пропуск торгуемого NO

if edge_yes >= edge_no на равенстве выбирает YES, который при BLOCK_YES_SIDE=true дропается (строка 73) — хотя идентичный по edge NO был бы торгуем. Редко (точное равенство), но молча теряет валидные NO. Fix: на ничье предпочитать NO при BLOCK_YES_SIDE: if edge_yes > edge_no and ....

21. ◻️ resolver.py:766-767between fallback ±0.5 при отсутствии low/high

Когда low_f/high_f отсутствуют, fallback threshold_f ± 0.5 + сравнение с round(actual) (int). Для CLOB-sourced трейдов без low/high окно молча сужается; в связке с °F-округлением (#1) резолвит неверно. Fix: требовать явные threshold_low/high; при отсутствии для between/eq — скипать резолюцию (return None, 0), не угадывать.


🟢 Минорные

# Файл Проблема Fix
22 bot.py:454-457 total_capital = exchange + total_deployed двойной счёт pending (display-only, дашборд врёт о капитале) выбрать одну модель: при exchange-excludes-pending деплоить только filled
23 bot.py:224-268 between/eq cache-hit не вызывает db.save_forecast → только первый брекет на город+дату пишется в forecasts (теряется калибровка) вызвать db.save_forecast(forecast) в ветке cache-reuse
24 scanner.py:95-208 ❌→🟢 нет фильтра closed/active/acceptingOrders. Импакт FALSE POSITIVE (скан только future-date + _is_past_peak_time скип) — но latent robustness gap скипать closed==true/acceptingOrders==false
25 scanner.py:142 ◻️ Gamma 429 глотается как «нет рынка», без backoff/retry детектить status==429, ретраить, логировать отдельно от 404
26 scanner.py:188 ◻️ volume or-цепочка путает 0 и missing детерминированно брать volumeNum
27 risk.py:60-64 ◻️ per-market deployed/cooldown не видят cancelled-but-filled ghost shares (status cancelled исключён) → MAX_PER_MARKET обходится учитывать has_cancelled_for_market и в risk
28 risk.py:43 ◻️ cooldown по market_id без side ключ (market_id, side) для консистентности с executor
29 risk.py:91-93 ◻️ get_status() считает dry-run трейды в live (bot.py частично оверрайдит) live_only=not DRY_RUN
30 resolver.py:438 ◻️ Open-Meteo cutoff = date-string compare с truncated datetime → off-by-one, резолв до финализации архива сравнивать полные datetime по city-TZ end-of-day
31 analyzer.py:288 ◻️ KELLY_BANKROLL фикс $25, при просадке банка mild over-betting подавать live-баланс (известный TODO «dynamic sizing»)
32 config.py:20-23 ◻️ дефолты MAX_OPEN_POSITIONS=20, MAX_PER_MARKET=6 против $25 кошелька — при потере .env снимают потолок риска дефолты = документированные live-safe (7/3)
33 executor.py:48-49 ◻️ proxy-config fail только логируется («IP may be exposed»), ордера идут un-proxied из geoblocked-региона в live — hard-stop ордеров, если _apply_proxy упал при заданном TRADE_PROXY
34 executor.py:298,403 ◻️ redeem_position/wrap читают module-level DRY_RUN, игнорируя override place_bet(dry_run=...) добавить опц. dry_run-параметр

❌ Отклонено верификатором (не баги)


✅ Ранее исправленное — подтверждено корректным

Brier NO-side inversion (убран), резолюция строго m.date < date('now'), auto-redeem фильтр outcome='win' AND dry_run=0, CLOB proxy→direct fallback, thread-local без conn.close(), cleanup_old_forecasts(7), forecaster retry 3× для 429/5xx, async balance + 60s cache, FK-off при ALTER. P&L/fee-математика (size*(1-price)/price - fee / -(size+fee), fee shares*0.02*price*(1-price)) — без sign-ошибок для YES и NO. gte/lte сравнения и actual-temp fetch с city-TZ — корректны.


Приоритет фиксов

  1. #1 °C-резолюция — искажает P&L/калибровку на всех Celsius-маркетах (большинство). Чинить первым.
  2. #2, #3, #4 executor — double-spend / неатомарность / лишний redeem-газ на live-деньгах.
  3. #5 control-run в ансамбле — смещает каждую ставку.
  4. #7, #8, #10 — kill switch не на всех путях, fail-open auth, fail-dangerous BLOCK_YES_SIDE.

Находки 🔴#2–4, 🟡#9–21, 🟢#25–34 — single-source (один аудитор), обоснованы по коду, но не прогонялись через второй агент. Верифицированы (✅): #1, #5, #6, #7, #8, #22, #23, #24-отклонение.

📜 Git History

8fca132chore: initial commit — version control setup5 weeks ago
Show last diff
Loading...