Тема
Целевой Strategy SDK pm-engine
Уровень: target. Источник правды для контракта между Engine и стратегиями (Strategy trait + StrategyContext + Signal lifecycle). Противоречащие документы (ScannerHooks из legacy pm-bot, прямые вызовы
OrderClientиз стратегий, ADR-025 как самостоятельный источник без интеграции в unify) подлежат удалению/redirect во второй итерации.
Контекст и scope
Что покрывает. Strategy trait, StrategyContext (хуки + helpers), Signal lifecycle, инвариант mode-agnostic стратегии, дизайн restoration алгоритмов как layers поверх SDK.
Что не покрывает.
- Где enforce'ятся RISK-LIMITS / kill switch — см. risk-and-emergency.md.
- Внутреннюю архитектуру Engine — см. engine.md.
- DDL
signals/orders/positions/events— см. storage.md. - Бизнес-логику directional/Harvester —
docs/strategies/directional-prd.md+docs/strategies/directional.md.
Якорь
project_canonical_vision A1 (directional/Harvester — единственная активная), A2 (восстановление через layers поверх extension API), A4 (stop-loss переработан через config-driven форму). Источник: ADR-025 (PROPOSED) + openspec spec order-router-and-sdk + design «Стратегический SDK».
Принципы SDK
- Единый путь live/probe. Код стратегии не содержит ветвления по режиму. Live и реальный денежный probe используют один
OrderClient; ограничения probe принадлежат внешним предохранителям. - Декларативный intent. Стратегия не вызывает
OrderClientнапрямую. Она добавляетSignalи связанныйOrderIntentв выходнойStrategyEventContext. Engine фиксирует сигнал и проводит intent через EmergencyGuard → OrderRouter → OrderClient. - Изолированная ветвь события. Стратегия получает свой
StrategyEventContext, разделяет исходнуюArc<EventRevision>с другими стратегиями только для чтения и возвращает новую типизированную стадию. Полный контракт определён в event-context.md.
В целевой архитектуре имя StrategyContext остаётся за исторической сервисной поверхностью только до миграции. Чтобы не смешивать зависимости стратегии с данными конкретного решения, целевые роли называются StrategyServices и StrategyEventContext соответственно. 4. Изоляция per-strategy. Один файл-стратегия = одна реализация trait'а. Никаких cross-strategy if-веток. Расширение = новый файл (strategies/binary_arb/, strategies/bucket_arb/, …) без правок в Engine. Принцип feedback_strategy_isolation. 5. Hooks-driven. Engine вызывает стратегию через хуки на конкретные события (market data, tick, order event, position change, closure). Стратегия не делает busy-loop, не держит фоновый таск, не вызывает Engine inversely вне хелперов context'а. 6. Typed config per strategy. Каждая стратегия декларирует свой Config struct с named typed knobs. Никакого universal condition engine, никакого composable enum Condition, никакого evaluate() over generic tree. Form schema во фронтенде — strategy-specific keys с showWhen. Альтернатива (composable conditions из аудита pm-orchestrator) рассмотрена и отвергнута по KISS. См. memory rule feedback_strategy_specific_typed_config + design D14. 7. Static registry для регистрации. Strategy trait + static registry; добавление стратегии = новый файл + регистрация. Альтернатива enum StrategyMode с разветвлением в core отвергнута. См. design D12. 8. FSM per strategy — typed enum. States + transitions — typed enum внутри стратегии. SDK даёт transition!(from, to, reason) макро, которое в одной SQLite-транзакции с domain mutation пишет events.StrategyFsmTransition. Generic typestate / trait-based FSM библиотеки отвергнуты. См. design D13.
Strategy trait
rust
pub trait Strategy: Send + Sync {
fn name(&self) -> &str;
// Event hooks — все опциональны, default = no-op.
fn on_market_data(&mut self, snapshot: MarketSnapshot, ctx: &mut StrategyContext) {}
fn on_tick(&mut self, now: LogicalTime, ctx: &mut StrategyContext) {}
fn on_order_event(&mut self, event: OrderEvent, ctx: &mut StrategyContext) {}
fn on_position_change(&mut self, position: PositionSnapshot, reason: ChangeReason, ctx: &mut StrategyContext) {}
fn on_closure(&mut self, market_id: ConditionId, reason: ClosureReason, ctx: &mut StrategyContext) {}
}on_market_data: реактивный вход при изменении книги (book/price_change через unified OrderbookStore).on_tick: периодический скан (heartbeat от LifecycleFSM, фиксированный интервал из config).on_order_event: реакция на ack/fill/cancel/reject/expired от OrderClient.on_position_change: позиция изменилась (открыта/обновлена/закрыта/redeemed).on_closure: рынок закрылся (per-outcome closure, см.outcome-tradability-detection).
StrategyContext — helpers + emit
Emission helpers
| Метод | Назначение |
|---|---|
ctx.emit_signal(Signal) -> SignalUid | Регистрирует Signal через SignalRegistry (txn: signals + events.SignalEmitted) |
ctx.emit_order(OrderIntent) | Декларативный intent → EmergencyGuard → OrderRouter |
ctx.cancel_order(OrderUid, reason) | Отмена ордера через OrderRouter (тот же writer) |
ctx.close_signal(SignalUid, reason) | Явное закрытие сигнала (Signal → COMPLETED) |
ctx.supersede_signal(old: SignalUid, new: Signal) -> SignalUid | Замена сигнала: old → SUPERSEDED, орфан-ордера cancel'ятся, new → EMITTED |
transition!(from, to, reason) (макро) | Persists events.StrategyFsmTransition атомарно с domain mutation; используется внутри hooks стратегии при смене своей typed-enum фазы |
ctx.load_fsm_phase(SignalUid) -> Option<Phase> | Recovery FSM-фазы на bootstrap; стратегия знает свой Phase-тип, SDK хранит serde_json::Value |
Read helpers (read-only views)
| Метод | Возвращает |
|---|---|
ctx.positions_for_signal(SignalUid) | Все позиции, привязанные к signal_id (из PositionRegistry write-through cache) |
ctx.orders_for_signal(SignalUid) | Все ордера signal'а (live + терминальные за окно retention) |
ctx.open_positions(strategy) | Все открытые позиции стратегии |
ctx.orderbook(token_id) -> &OrderbookSnapshot | Read-only snapshot unified OrderbookStore (ADR-022) |
Policy helpers (ENTRY-POLICY / EXECUTION)
Помогают стратегии решать «когда / по какой цене эмитить». Возвращают decision, не блокируют поток.
| Helper | Decision | Источник правил |
|---|---|---|
ctx.entry_window_check(market) -> EntryWindowDecision::{Open, Closed} | per-strategy finish_type ∈ {EndDate, GstOffset { offset_seconds }, GstLocalMidnight} + Option<u64> bounds | Якорь A9, capability outcome-tradability-detection |
ctx.debounce_gate(signal_key) -> DebounceDecision::{Armed, Suppressed} | armed-state per-key в config.db knobs | ADR-030 |
ctx.stop_loss_eval(position) -> StopLossDecision::{Hold, Exit} | threshold/cooldown/debounce из config.db | ADR-027 directional-stop-loss + A4 |
Эти решения не дают reject в OrderRouter — они формируют поток intents до эмиссии. Если decision = «не эмитить» — стратегия просто не вызывает emit_order. Принцип см. risk-and-emergency.md.
Sizing helper (RISK-LIMITS-aware)
ctx.compute_size(intent_draft) -> SizingDecision::{Size(shares), Skip(reason)} — рассчитывает размер ордера с учётом всех 3 canonical RISK-LIMITS (shares-per-market, USD-per-market, total budget + reinvest). Размер НЕ зависит от баланса кошелька: относительного лимита в системе нет. Если интент-черновик не помещается ни в один из лимитов — возвращает Skip(reason). Помогает стратегии не эмитить заведомо-rejected intent (оптимизация; реальный enforcement всё равно в OrderRouter).
Signal lifecycle
EMITTED ─────► EXECUTING ─────► COMPLETED
│ ▲
│ │
├──► REJECTED │
│ (нет дочерних entities) │
│ │
└──► SUPERSEDED ─────────────────┘
(новый сигнал заменяет)EMITTED— послеctx.emit_signal(...).EXECUTING— на первом ассоциированномOrderPlaced.COMPLETED— auto, когда все связанные orders в терминальном статусе И все связанные positions вCLOSED|REDEEMED. Также через явныйctx.close_signal(...).REJECTED— если ни один OrderIntent не прошёл (например, kill switch активен).SUPERSEDED— черезctx.supersede_signal(...). Орфан-ордера cancel'ятся OrderRouter'ом.
Полная трассировка signal → orders → positions → events — один JOIN по signal_id (см. storage.md). Используется в pm-cli signal show и в дашборд-странице /trace/<signal_uid>.
Пример — directional/Harvester (single active per ADR-024)
Алгоритм переписывается с нуля по PRD/FSD на чистый SDK:
rust
impl Strategy for HarvesterStrategy {
fn on_market_data(&mut self, snap: MarketSnapshot, ctx: &mut StrategyContext) {
// 1. ENTRY-POLICY: entry window
if ctx.entry_window_check(&snap.market).is_closed() { return; }
// 2. ENTRY-POLICY: debounce
if ctx.debounce_gate(snap.signal_key()).is_suppressed() { return; }
// 3. Compute intent draft, RISK-LIMITS-aware
let intent_draft = OrderIntent::buy(snap.token_id, best_ask, target_size);
let SizingDecision::Size(shares) = ctx.compute_size(&intent_draft) else { return };
// 4. Emit signal + order
let signal_uid = ctx.emit_signal(Signal::entry(snap.market.condition_id, …));
ctx.emit_order(intent_draft.with_signal(signal_uid).with_size(shares));
}
fn on_order_event(&mut self, ev: OrderEvent, ctx: &mut StrategyContext) {
if let OrderEvent::Fill(fill) = ev {
// GTC@0.99 exit per directional spec
let exit_intent = OrderIntent::sell_gtc(fill.token_id, price_milli(990), fill.size_shares);
ctx.emit_order(exit_intent.with_signal(fill.signal_uid));
}
}
fn on_position_change(&mut self, pos: PositionSnapshot, _r: ChangeReason, ctx: &mut StrategyContext) {
// EXECUTION: stop-loss exit mechanic
if let StopLossDecision::Exit = ctx.stop_loss_eval(&pos) {
ctx.emit_order(OrderIntent::fok_sell(pos.token_id, pos.best_bid, pos.size_shares)
.with_signal(pos.signal_uid));
}
}
}Бизнес-семантика (entry [0.95, 0.98] best-ask, DCA $5/маркет, GTC@$0.99 exit, stop-loss с config-driven knobs, redeem cycle через Phase J state machine) — из project_harvester_semantics, docs/strategies/directional-harvester.md (раздел «Redeem cycle (pm-engine Phase J)»). SDK не диктует семантику; он диктует контракт.
RedemptionPort (Phase J1)
Hexagonal port для on-chain CTF redeem operations. Граница между domain (strategy / RedemptionService) и pm-sdk on-chain adapters.
Trait
rust
#[async_trait]
pub trait RedemptionPort: Send + Sync + 'static {
async fn redeem(&self, request: RedemptionRequest) -> Result<RedemptionAck, RedemptionError>;
}RedemptionRequest несёт owner_address, condition_id, neg_risk flag + (для neg-risk) yes_token_id / no_token_id. RedemptionAck содержит tx_hash + submitted_at.
Implementations
LiveRedemptionAdapter—pm-sdk redeem_standard/redeem_neg_riskчерез alloy provider.TestRedemptionAdapter— test-only двойник с явно заданным результатом; в production target не входит.
RedemptionError
Transient— retryable (network, RPC, gas issues).Terminal— non-retryable (zero balance, invalid token id, already redeemed).InvalidRequest— engine/strategy bug (fail-fast).
RedemptionService маппит Transient на retry-цикл с backoff из knobs, Terminal / InvalidRequest — на immediate mark_redeem_failed. См. docs/strategies/directional-harvester.md раздел «Redeem cycle».
Restoration алгоритмов как layers
Согласно якорю A2: будущие стратегии (binary-arb, bucket-arb, single-token-mm, maker_rebate, merge_arb) восстанавливаются как отдельные файлы в strategies/<name>/, реализующие Strategy trait. Core (engine/, ports/, sdk/) не изменяется при добавлении новой стратегии — это и есть смысл «extension points».
Acceptance gate ADR-025: первая успешно восстановленная стратегия валидирует достаточность SDK. До этого SDK считается provisional; если первая restoration требует расширения trait'а или нового helper'а — это нормальный delta, не нарушение принципа.
Restoration roadmap: docs/restoration-backlog/ + docs/tests/backlog/restore-*-as-layer.md (seeded в pivot plan F6).
Что SDK НЕ даёт стратегии
- Доступа к
OrderClientнапрямую (всё черезctx.emit_order→ OrderRouter). - Доступа к factual runtime mode (
live/probe) — by design, mode-agnostic invariant; simulated execution mode не существует. - Прямого write в
signals/orders/positions/events(single-writer гейт ast-grep в CI). - Доступа к kill switch reset / EmergencyGuard mutate (operator-only через
pm-cli). - Long-running async task'ов вне хуков (нет background spawn).
Decision rationale (растворённые ADR)
- Per ADR-024 (ACCEPTED) — directional-first: единственная активная стратегия в active scope. SDK проектируется так, чтобы восстановление было аддитивным (новый файл стратегии, реализующий
Strategytrait, core не меняется). - Per ADR-025 (PROPOSED) — Strategy extension points: формализация Strategy trait + StrategyContext + Signal lifecycle. Acceptance gate = первая restoration validates SDK достаточность. Этот SSOT-документ — целевая форма ADR-025.
- Per design «Стратегический SDK» (
unify-orders-positions-signals-events/design.md): mode-agnostic invariant (стратегия не знает про режим), декларативный intent (никаких прямых OrderClient вызовов), полная переписанная directional/Harvester на чистый SDK по PRD/FSD. - Per spec
order-router-and-sdk(формальные requirement'ы «Strategy Emits Declarative Intent via Context» + «Strategy code is mode-agnostic» + «ENTRY-POLICY and EXECUTION Logic Lives in Strategy»). - Per strategy-isolation-design (ACCEPTED, исторический ADR) — принцип «один файл = одна стратегия, никаких cross-strategy if-веток» сохраняется и усиливается.
- Per ADR-027 directional-stop-loss + якорь A4: stop-loss как EXECUTION helper в StrategyContext, не RISK-LIMIT. Knobs в
config.db. - Per ADR-030 (DEFERRED) — entry debounce: в текущем pm-engine не реализован. Будущий возврат оформляется отдельным ENTRY-POLICY extension, а не считается существующим helper'ом.
Cross-references
- Engine — кто вызывает хуки и принимает emit'ы: engine.md.
- Кто проверяет 3 canonical RISK-LIMITS после emit_order: risk-and-emergency.md.
- Где живут strategy knobs (config.db): storage.md + memory
feedback_strategy_config_via_ui. - SSE-каналы, через которые наблюдается signal lifecycle: wire-contract.md.
- Бизнес-семантика directional/Harvester:
docs/strategies/directional-prd.md,docs/strategies/directional.md. - Restoration backlog:
docs/restoration-backlog/,docs/tests/backlog/restore-*-as-layer.md. - Memory:
project_canonical_visionA1+A2,feedback_strategy_isolation,project_harvester_semantics. - ADR:
docs/adr/adr-024-directional-first-architecture.md,docs/adr/adr-025-strategy-extension-points.md,docs/adr/strategy-isolation-design.md.