Эксплуатация на VPS

Цель VPS-контура - держать Китобоя в состоянии, где он может жить без ручного присмотра, но не имеет права тихо ломаться.

Первый боевой pilot запущен 2026-07-20: Binance Spot используется как сигнальная лента, Bybit USDT perpetual - как место исполнения, tenant executor умеет работать с несколькими API-аккаунтами. Капитал на старте малый; задача этапа - реальное исполнение, идемпотентность, журнал ордеров и сверка позиции.

Перед расширением капитала live проходит период наблюдения:

2-4 недели без ручного вмешательства

Состав VPS

Операционный минимум:

репозиторий Decima-8-Sanctum
собранный decima8/build-os/d8_agent_cli
собранный store/market/build/market_to_vsb
Python окружение
Bybit ключи в защищенном env/config
директории store/market/runs и store/market/tenants
логирование stdout/stderr
мониторинг диска
systemd или аналогичный supervisor

Общая market tape

В продуктовой схеме market tape живет отдельно от tenant. Один процесс постоянно ведет ленту по символу, а tenants берут у него warmup и live tail по HTTP:

shared market tape
  -> BTCUSDT s300/s1800
  -> HTTP warmup/current-session API
  -> tenant A director/risk/inventory
  -> tenant B director/risk/inventory

HTTP tape server:

python3 store/market/tools/whaler_tape_http_server.py \
  --host 0.0.0.0 \
  --port 8787 \
  --session-reset utc-day \
  --session-frames 288 \
  --symbol-run BTCUSDT=store/market/runs/live/s300_stage_BTCUSDT_YYYYMMDD_HHMMSS

Если задан env WHALER_TAPE_TOKEN, клиенты передают:

Authorization: Bearer <token>

Основные endpoint:

GET /v1/health
GET /v1/symbols
GET /v1/symbols/BTCUSDT/status
GET /v1/symbols/BTCUSDT/warmup/s300
GET /v1/symbols/BTCUSDT/warmup/s1800?frames=224
GET /v1/symbols/BTCUSDT/tail/frames.mtf.s300.jsonl?lines=100

warmup/s300 отдает текущую utc-day=288 session от ее начала. warmup/s1800 отдает последние phase-фреймы для директора из дневной истории и текущего live run. Это важно: после рестарта tenant не должен ждать 224 новых s1800-фрейма, то есть около 4.7 суток.

Этот пример относится к BTCUSDT и crypto 24/7. Для другой пары или другого класса рынка tenant config должен явно задавать свой market session profile. На рынке с ночным закрытием нельзя автоматически копировать 288: там s300-память должна соответствовать активной торговой сессии и timezone биржи.

Порядок запуска tenant

Штатный запуск tenant делает preflight истории сам: обновляет закрытые дневные файлы, собирает s300/s1800, валидирует размеры и передает директору s1800 warmup.

python3 store/market/tools/whaler_tenant_stage.py \
  --config store/market/tenants/pilot-001/tenant.json \
  --replace

Остановка того же tenant:

python3 store/market/tools/whaler_tenant_stage.py \
  --config store/market/tenants/pilot-001/tenant.json \
  --stop

Низкоуровневая команда обновления истории остается такой:

cd store/market
./tools/whaler_update_history.py --symbol BTCUSDT

Успешный прогон завершается без ошибок и подтверждает дневные размеры:

level-v3 = 86400
s300     = 288
s1800    = 48

Эксплуатационный VSB-контракт:

{
  "level_v3_frame_anchor": "utc-day-first-trade",
  "mtf_bucket_anchor": "utc-day-row",
  "mtf_bootstrap_buckets": 16
}

Tenant launcher передаёт эти значения stage явно. Они совпадают с defaults history updater и replay runner, поэтому обновление процесса или перенос на другой VPS не меняют слух v42.

Для BTCUSDT эти размеры являются дневными UTC-инвариантами. Для рынка с неполной торговой сессией проверка должна быть другой: level-v3 может по-прежнему хранить календарный день, но активная s300-память Decima должна ограничиваться session profile, а не всеми 24 часами.

После preflight tenant stage стартует live примерно с такими параметрами:

python3 store/market/tools/whaler_s300_stage_live.py \
  --env mainnet \
  --category linear \
  --symbol BTCUSDT \
  --pid-key pilot-001_BTCUSDT \
  --out-dir store/market/tenants/pilot-001/runs/live/... \
  --swarm-session-reset utc-day \
  --swarm-session-frames 288 \
  --swarm-session-anchor utc \
  --swarm-warmup-frames-glob 'store/market/runs/BTCUSDT-*/frames.mtf.s300.jsonl' \
  --long-trail-activate-bps 75 \
  --long-trail-giveback-bps 50 \
  --trail-reentry-block same-side-reclaim \
  --trail-reclaim-cooldown-frames 12 \
  --trail-reclaim-buffer-bps 0 \
  --trail-reclaim-max-entries 1 \
  --trail-reclaim-failure-bps 150 \
  --short-trail-activate-bps 300 \
  --short-trail-giveback-bps 350 \
  --signal-short-probe-ratio 0.75 \
  --confirm-bps 20 \
  --scale-in-position-kind normal \
  --phase-warmup-frames-glob 'store/market/runs/BTCUSDT-*/frames.mtf.s1800.jsonl'

Без phase warmup после каждого рестарта директор ждет около 4.7 суток, пока накопится 224 live-кадра s1800. Поэтому для tenant-запуска обновление истории является частью старта, а не ручной рекомендацией.

Важно: warmup не подмешивает историю в текущие сделки.

  • phase_warmup дает директору s1800-контекст;
  • swarm_warmup дает Decima правильное состояние текущей 288-сессии;
  • warmup-сигналы не пишутся как live-сигналы и не попадают в ордера.

Для swarm_session_reset=utc-day одного закрытого дневного архива недостаточно, если tenant стартует внутри текущего UTC-дня. Тогда нужен intraday backfill от границы текущей session до момента запуска. Иначе Decima начнет слушать не с настоящего начала памяти, а с момента рестарта.

Tenant layout

Продуктовый VPS не должен быть "одним ботом в одной папке". Whaler лицензируется как software tenant: у каждого клиента свой config, state, logs, secrets policy и режим запуска.

Базовая структура:

store/market/tenants/
  tenant_id/
    tenant.json
    env.example
    state/
      current_run.json
      history_update.json
      health.json
      kill_switch.json
    runs/
      live/
    logs/
    reports/

tenant.json описывает не секреты, а разрешенную конфигурацию:

{
  "tenant_id": "pilot-001",
  "license": "early-access",
  "mode": "paper",
  "exchange": "bybit",
  "env": "mainnet",
  "category": "linear",
  "symbol": "BTCUSDT",
  "market_tape_base_url": "https://tape.example.internal",
  "market_tape_token_env": "WHALER_TAPE_TOKEN",
  "history_update_enabled": true,
  "history_start": "2026-01-01",
  "history_closed_day_lag": 1,
  "history_batch": 10,
  "history_scales": "300,1800",
  "phase_warmup_frames_glob": "",
  "swarm_warmup_frames_glob": "",
  "max_connected_volume_btc": 1.0,
  "risk_profile": "v42-lifecycle-causal-reclaim",
  "entry_exposure": 5.25,
  "deposit_exposure": 1.0,
  "execution_leverage": 5.25,
  "effective_exposure": 5.25,
  "swarm_session_reset": "utc-day",
  "swarm_session_frames": 288,
  "swarm_session_anchor": "utc",
  "swarm_session_offset_frames": 0,
  "market_session_profile": "crypto-24x7",
  "long_trail_activate_bps": 75,
  "long_trail_giveback_bps": 50,
  "trail_reentry_block": "same-side-reclaim",
  "trail_reclaim_cooldown_frames": 12,
  "trail_reclaim_buffer_bps": 0,
  "trail_reclaim_max_entries": 1,
  "trail_reclaim_failure_bps": 150,
  "short_trail_activate_bps": 300,
  "short_trail_giveback_bps": 350,
  "phase_carry_entry": "long",
  "phase_carry_entry_exposure": 5.25,
  "phase_carry_entry_lookback_frames": 72,
  "phase_carry_entry_confirm_lookback_frames": 48,
  "phase_carry_entry_require_base_phase": true,
  "phase_carry_flip_exit": false,
  "signal_short_exposure": 5.25,
  "signal_short_probe_ratio": 0.75,
  "confirm_exposure": 5.25,
  "confirm_bps": 20,
  "scale_in_position_kind": "normal",
  "short_signal_arm_confirm_bps": 50,
  "short_signal_arm_min_push_bps": 65,
  "short_signal_arm_expire_frames": 33,
  "short_signal_arm_edge_expiry_frames": 6,
  "short_signal_arm_edge_expiry_min_best_bps": 10,
  "short_signal_proof_frames": 0,
  "short_signal_proof_min_best_bps": 300,
  "short_signal_proof_trail_giveback_bps": 100,
  "short_signal_proof_trail_min_age_frames": 0,
  "short_signal_proof_mature_age_frames": 0,
  "short_signal_proof_mature_min_best_bps": 0,
  "short_signal_proof_mature_trail_giveback_bps": 0,
  "short_signal_proof_graded_age_start_frames": 72,
  "short_signal_proof_graded_age_full_frames": 126,
  "short_signal_proof_graded_mfe_start_bps": 75,
  "short_signal_proof_graded_mfe_full_bps": 125,
  "short_signal_proof_graded_giveback_start_bps": 150,
  "short_signal_proof_graded_giveback_full_bps": 50,
  "short_signal_stagnation_frames": 48,
  "short_signal_stagnation_min_best_bps": 25,
  "short_signal_stagnation_progress_step_bps": 25,
  "short_signal_stagnation_giveback_bps": 60,
  "short_signal_stagnation_lock_bps": 4,
  "short_signal_proof_max_rearms": 2,
  "short_signal_proof_rearm_confirm_bps": 50,
  "short_signal_proof_rearm_expire_frames": 144,
  "short_signal_proof_rebound_exposure": 0,
  "short_signal_proof_max_rebound_entries": 0,
  "short_signal_proof_rebound_confirm_bps": 75,
  "short_signal_proof_rebound_trail_activate_bps": 75,
  "short_signal_proof_rebound_trail_giveback_bps": 50,
  "short_signal_proof_rebound_adverse_stop_bps": 100,
  "max_daily_loss_pct": 2.0,
  "max_weekly_loss_pct": 5.0,
  "allow_live_orders": false
}

Параметры директора задают базовый intent. Каждый элемент bybit_accounts может отдельно переопределить целевой effective_exposure. В текущем max-aggression pilot директор и исполнительные аккаунты используют профиль 5.25; более спокойный аккаунт задается явным override, а не скрытым масштабированием ордера.

Профиль v42-lifecycle-graded-no-rebound отдельно сопровождает прямой short: плавно усиливает защиту между 72 и 126 s300-кадрами, закрывает стагнацию 48/25/25/60 и разрешает не более двух подтвержденных re-arm входов. Новый полезный экстремум сбрасывает таймер стагнации, поэтому устойчивый тренд не ограничен фиксированной длительностью. Автоматический rebound-long отключен.

Для ступенчатого входа min_effective_exposure не задается либо равен нулю. Положительное значение является жестким нижним пределом и поднимет probe до этого уровня, фактически отключив confirmation level.

Если market_tape_base_url заполнен, tenant stage перед стартом скачивает:

state/market_tape_warmup.BTCUSDT.s300.jsonl
state/market_tape_warmup.BTCUSDT.s1800.jsonl

Эти файлы передаются в live stage как --swarm-warmup-frames-glob и --phase-warmup-frames-glob. Так tenant стартует от общей ленты, а не от случайного холодного состояния.

Секреты не хранятся в git и не пишутся в tenant.json. Для MVP достаточно env-файла на VPS с правами 0600, дальше - secret manager:

WHALER_TENANT_ID=pilot-001
BYBIT_API_KEY=...
BYBIT_API_SECRET=...
BYBIT_ENABLE_LIVE=0

Шаблон без секретов лежит в репозитории:

store/market/tenants/_template/tenant.json
store/market/tenants/_template/env.example

Правило изоляции:

один tenant -> один run namespace
один tenant -> отдельные logs/state/orders
один tenant -> отдельный kill switch
один tenant не читает secrets другого tenant

На первом этапе tenant может работать в paper или dry-run-orders. live включается отдельным config change после ручной проверки аккаунта, min order qty, leverage, risk limits и exchange reconciliation.

До включения боевых ордеров:

inventory = paper/dry-run
order executor = disabled

После включения боевых ордеров:

director -> order intent -> risk gate -> exchange executor -> exchange reconciliation

Нельзя смешивать paper и real

Боевой слой выносится отдельно:

director decision
  -> order intent
  -> risk gate
  -> exchange order
  -> exchange fill
  -> reconciled inventory

Paper inventory не притворяется биржевым балансом.

Нужны два состояния:

model_inventory
exchange_inventory

И отдельная сверка:

model position == exchange position
model qty      == exchange qty
model side     == exchange side

Health metrics

Минимальные метрики:

process_alive
start_time
uptime_seconds
last_trade_time
last_ws_message_time
last_frame_time
last_s300_time
last_s1800_time
last_director_write_time
ws_messages
trades
frames
reconnects
signals
decisions
equity
position_side
position_qty
realized_pnl
mark_pnl
drawdown
free_disk_gb

Для phase warmup отдельно смотреть:

phase_live_frames
phase_warmup_frames
phase_total_frames
phase_lookback_frames
phase_warmup_frames_glob

Сразу после рестарта нормальная картина:

phase_live_frames=0
phase_warmup_frames больше phase_lookback_frames
signals=0
trades=0

Это означает, что директор уже имеет историческую фазу, но live пока не закрыл свои s300/s1800 buckets.

Файлы мониторинга

Каждый live run пишет:

director.json
director.tsv
director.html
mtf.s300.html
mtf.s1800.html
frames.mtf.s300.jsonl
frames.mtf.s1800.jsonl
tape.mtf.s300.raw8.vsb
tape.mtf.s1800.raw8.vsb

director.json - машинное состояние.

director.tsv - таблица решений.

director.html - человеческий монитор.

Аварийные условия

Kill switch срабатывает при:

нет ws messages дольше N секунд
нет trades дольше N секунд при активном рынке
нет новых frames дольше N секунд
director не обновлялся дольше N секунд
reconnect storm
exchange inventory != model inventory
max daily loss
max weekly loss
max consecutive losses
disk free ниже порога
исключение в order executor
неизвестный position side
битый config

Действия kill switch:

запретить новые входы
записать причину
отправить alert
по настройке закрыть позицию или оставить только manual mode

Диск

Диск уже был реальной проблемой. Поэтому на VPS нельзя бесконечно копить тяжелые артефакты.

Сохранять постоянно:

director.json
director.tsv
director.html
frames.mtf.s300.jsonl
frames.mtf.s1800.jsonl
логи решений
логи ордеров

Сжимать или удалять по retention policy:

сырой trade stream
старые tape.raw8.vsb
старые html heatmaps
временные run.jsonl
debug reports

Минимальная политика:

последние 7 дней: полный live context
последние 30 дней: director + frames + order logs
старше 30 дней: сжатые summaries

Исторические дневные run-директории нужны отдельно от live-retention:

store/market/runs/BTCUSDT-YYYY-MM-DD/

Для phase warmup и walk-forward достаточно постоянно хранить:

frames.level-v3.jsonl
frames.mtf.s300.jsonl
frames.mtf.s1800.jsonl
manifest.json

Тяжелые HTML и старые tape можно сжимать или удалять по отдельной политике, если место снова станет проблемой. Но удалять frames.mtf.s1800.jsonl нельзя, если VPS использует warmup из истории.

Alerts

Нужны alerts:

процесс упал
нет данных
много reconnects
позиция открыта слишком долго
дневной лимит близко
kill switch active
диск заполнен
расхождение inventory
ошибка ордера

Канал может быть любым: Telegram, email, webhook. Важно, чтобы alert содержал:

symbol
run id
time
severity
reason
position
equity
последний decision
ссылка/путь на director.html

Перед расширением live

Checklist:

  • live работает без ручного вмешательства;
  • live vs offline replay сверены хотя бы по последнему UTC-дню;
  • funding учтен;
  • slippage учтен;
  • комиссия соответствует бирже;
  • max daily loss задан;
  • max weekly loss задан;
  • kill switch проверен;
  • order intents пишутся корректно;
  • exchange reconciliation работает;
  • order executor умеет idempotency;
  • все решения директора журналируются;
  • есть способ быстро отключить торговлю.

Боевой order executor

Контракт executor простой: он принимает не "сигналы", а только decision/order intent от директора.

Пример order intent:

{
  "run_id": "s300_stage_BTCUSDT_20260613_022832",
  "symbol": "BTCUSDT",
  "decision_id": "2026-06-13T02:28:32Z-000123",
  "action": "open_short",
  "side": "Sell",
  "reduce_only": false,
  "notional_fraction": 0.5,
  "reason": "v42 signal S42011, s1800 phase allows short",
  "max_slippage_bps": 5.0
}

Executor отвечает:

принял
проверил риск
отправил ордер
получил fill
сверил позицию
записал результат

Идемпотентность

У каждого order intent есть уникальный id.

После перезапуска процесс не открывает позицию повторно по старому решению.

Правило:

один decision_id -> максимум один exchange order

Режимы запуска

Нужно явно различать:

observe: live data + director, без inventory
paper: live data + director + paper inventory
dry-run-orders: order intents пишутся, но не отправляются
testnet: боевые вызовы на testnet
mainnet-small: mainnet минимальным размером
mainnet: полноценный режим

Переход между режимами оформляется как config change, а не как правка кода.