← Заметки
Своя стратегия — свой ключ
architecture·security·ownership

Своя стратегия — свой ключ

Мы выкинули модель глобального торгового юзера. Каждая стратегия теперь читает ключи бирж из vault'а своего владельца.

· Mikhail Savchenko · Обновлено

Старая модель: один словарь settings["exchanges"] в БД, в нём ключи Binance + OKX, используется всеми. Любой залогиненный админ мог положить туда свои ключи, и торговый движок ходил по ним из каждой стратегии — длинных, уровней внутри дня, paper, live.

Это ломается, как только в команде два оператора. Ломается сильнее, когда заходит речь о третьем. Хуже всего ломается на инцидентах: ротация ключей значит остановку каждой стратегии разом.

Новая модель

Две колонки и одна новая таблица:

  • PortfolioMeta.owner_user_id — каждая длинная стратегия принадлежит ровно одному пользователю.
  • TradingAccount (в торговой БД) — каждая стратегия торгового класса принадлежит ровно одному пользователю и хранит у себя биржу и метку ключа.
  • UserCredential — зашифрованная запись (Fernet), по одной на (пользователь, провайдер, метка).

Когда движок хочет торговать paper_levels_bob, он зовёт _strategy_creds:

def _strategy_creds(strategy_id, provider):
    # 1. Trading account?
    ta = TradingAccount.find(strategy_id)
    if ta and ta.owner_user_id:
        return load_credential(ta.owner_user_id, ta.venue_provider, ta.credential_label)
    # 2. Portfolio strategy?
    pm = PortfolioMeta.find(strategy_id)
    if pm and pm.owner_user_id:
        return load_credential(pm.owner_user_id, provider, "default")
    # 3. No owner → no creds. Engine logs warning and skips.
    return {}

Никаких запасных вариантов. Стратегия без владельца не торгует. Движок пишет в лог WARNING: no credentials for strategy X и тихо пропускает тик — никаких ордеров, позиций и сюрпризов.

Что это даёт

Подключение пользователя теперь — это админская форма из четырёх шагов: завести пользователя → привязать стратегию → пользователь кладёт ключи в своё хранилище → админ зачисляет кэш. Каждый шаг обратим.

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

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

Изоляция торгового баланса

Хелпер compute_user_balance(user_id) джоинит обе БД и возвращает только дерево этого пользователя. Глобального представления «AUM фонда» по умолчанию нет — на маркетинговом сайте мы его показываем (Total AUM = сумма публичных стратегий), но именно ради этого случая у нас и заведён флаг is_public=True. Внутренние операционные дашборды показывают только ваш кусок.

Почему без запасных ключей

Мы рассматривали временный запасной путь на settings["exchanges"] ради обратной совместимости. Убрали ещё до выкатки. Причины:

  1. Запасной путь — опасное удобство. Операторы кладут «дефолтные» ключи «просто для paper-тестов», забывают про них, и потом реальная стратегия без владельца их подбирает.
  2. Поведение движка должно быть скучным. «Стратегия без владельца ничего не делает» — скучно. «Стратегия без владельца уходит на глобальные ключи» — требует помнить про запасной путь, чтобы предсказать поведение.
  3. Миграция — пара строк SQL. Старым стратегиям выставили is_public=True ради публичной видимости в режиме только-чтения: у них остались только публичные данные, без владельца и без права торговать. Новые админы назначают владельцев явно.

Что видит маркетинговый сайт

Публичный эндпоинт /api/public/strategies/{id} перед отдачей вырезает owner_user_id, внутренности control, сигналы и причину срабатывания kill-switch’а. На inite.fund/strategies вы видите ровно это: equity + просадка + активы + последние 50 сделок + последние 365 точек equity. Без идентификаторов, без внутреннего состояния, без секретов.

Astro-страницы рендерятся на сервере, дёргают этот эндпоинт и кешируются на эдже на 30 секунд. Обновите страницу дважды подряд — увидите те же числа; через 30 секунд они обновятся. Это и есть наш «real-time»: достаточно близко для страницы с историей результатов и достаточно далеко от origin’а, чтобы он не лежал под трафиком.

Сколько это стоило

Рефакторинг затронул 12 файлов в продуктовом репо, 2 новые таблицы, разовую миграцию при старте, ~600 строк кода и ~70 новых тестов. Тесты покрывают резолвинг владельца, зашифрованное хранилище, движения капитала с идемпотентностью и MCP-вызовы с ролевыми проверками.

До: 1004 зелёных теста. После: 1180. Ни одной регрессии.

Маркетинговый сайт (этот самый) построен на той же модели. Полоса метрик наверху? Это compute_user_balance(NULL) — агрегат по публичным стратегиям, забирается на сервере, рендерится в HTML. Ни один кусок приватных данных через границу не уходит.

Похожие заметки
Все заметки →