Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ba44d24
refactor(engine): send every flatten close through SignalEmitter
MarcosACH Oct 12, 2026
f3eddd3
feat(engine): stop flatten after 3 dry attempts on a symbol with no p…
MarcosACH Oct 12, 2026
bc64653
feat(engine): retry a flatten close that fills nothing, and reset on …
MarcosACH Oct 12, 2026
61763c0
feat(engine): count a failed read in flatten as a dry attempt, so the…
MarcosACH Oct 12, 2026
234412d
feat(engine): drain before a flatten close when the ledger and venue …
MarcosACH Oct 12, 2026
8dfe5e2
feat(engine): move a strategy's leftover to unattributed when the ven…
MarcosACH Oct 12, 2026
709c21b
feat(engine): retry every held symbol at the same time in flatten, on…
MarcosACH Oct 12, 2026
051d7a0
refactor(engine): check the flatten size gap against the one account …
MarcosACH Oct 12, 2026
256510e
feat(engine): read the venue before a flatten attempt counts, so a fl…
MarcosACH Oct 12, 2026
e3b3d95
feat(engine): count a failed pass in the flatten resume wait as a dry…
MarcosACH Oct 12, 2026
1253e67
feat(engine): count a failed read after the flatten resume wait as a …
MarcosACH Oct 12, 2026
20d4ada
docs(safe-exit): record that the flatten resume wait counts dry attem…
MarcosACH Oct 12, 2026
b261f3b
fix(engine): send each flatten close under the pass lock, so a loop n…
MarcosACH Oct 12, 2026
f1b3d31
test(engine): prove a venue that never answers after a flatten close …
MarcosACH Oct 12, 2026
24e3665
docs(engine): say that flatten.gave_up names the last size the venue …
MarcosACH Oct 12, 2026
7cb2c29
test(engine): pin the leftover price when two strategies of opposite …
MarcosACH Oct 12, 2026
3d326f0
refactor(engine): keep the missing-saga check for a flatten close in …
MarcosACH Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/verify-tickwright/scripts/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@
OrderType,
Portfolio,
Side,
SignalEmitter,
TimeInForce,
)
from tickwright.strategies.emitter import SignalEmitter

log = structlog.get_logger()

Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ also reads it. Not built yet (#408).
_Avoid_: signal uuid, request id.

**SignalEmitter**:
The strategy-author's helper (`strategies/emitter.py`) that owns a [[Strategy]]'s monotonic
The strategy-author's helper (`domain/emitter.py`) that owns a [[Strategy]]'s monotonic
`seq` counter and builds/publishes its [[Signal]]s (`place`/`cancel`, clock-stamped, returning
the [[signal_id]]). Concentrates the one piece of strategy mechanics that is a correctness spine
— never reusing a [[signal_id]] across restart — so it is not each author's problem. A strategy
Expand Down
10 changes: 10 additions & 0 deletions docs/adr/0060-what-an-exit-run-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ when a venue read fails, and which named events an exit run emits. Decided in
its own `left`, because only the job knows what its last read covered. The engine writes only
`unread`. Flatten's `left` lists the resting orders first, then each position as `SYMBOL=size`
with the signed size, in one comma list. For example `8812,BTC=0.5`.**)**

**(Field names as shipped, since [#473](https://github.com/MarcosACH/tickwright/issues/473):**
`size` on `flatten.gave_up` is the last size the venue reported for the symbol, signed, as a
string. After failed reads it can be from before the last close, so it can be more than is left.
`exit.finished` says what is left. `count` on
`flatten.dry_attempt` is the dry attempts in a row so far, from 1 to 3.**)**

**(Extended by [#473](https://github.com/MarcosACH/tickwright/issues/473):** `reason` on
`flatten.dry_attempt` can also be `size_gap`. That is a gap between the partitions and the venue
size that a drain did not close (ADR-0054). No close goes out against it.**)**
- **No separate summary.** `exit.finished` is the summary. It goes through the same logger as every
other event, so it is the last line the operator sees.

Expand Down
2 changes: 1 addition & 1 deletion docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ A strategy consumes ticks and lifecycle events and emits `PlaceSignal`/`CancelSi
- [ ] Add a class in `src/tickwright/strategies/` implementing the `Strategy` Protocol:
`strategy_id`, `on_tick`, `on_order_event`, `set_next_seq`, `snapshot`, `restore`.
- [ ] Emit signals through a composed `SignalEmitter`
([`strategies/emitter.py`](../src/tickwright/strategies/emitter.py)) — it owns the `seq`,
([`domain/emitter.py`](../src/tickwright/domain/emitter.py)) — it owns the `seq`,
clock-stamps each signal, and publishes it. Never build a `signal_id` by hand.
- [ ] Close a position with a reduce-only order. It can only shrink the account net, never grow it
or flip it. An order larger than the net is shrunk to it, and the cut part ends `CANCELLED`
Expand Down
35 changes: 33 additions & 2 deletions docs/module-maps/safe-exit.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ src/tickwright/
store/ # the lock, the seq record, split_basis
feed/replay.py # add_symbols is a no-op
venues/hyperliquid/ # r flag, batches of 200, refusals, mark cap, reads, feed symbols
strategies/emitter.py # cancel_all(), reduce_only on place()
domain/emitter.py # cancel_all(), reduce_only on place(), moved from strategies
observability/catalog.py # the 8 new events
app/
__main__.py # cancel-all and flatten commands, --new-store
Expand Down Expand Up @@ -156,6 +156,13 @@ context. Starts the feed through the `FeedHandle`.
- **(Deferred by #472:** the closes run one after another, and there is no pass lock. Each symbol
gets one attempt, so only one pass runs at a time anyway. #473 builds the loops and the lock,
because retries need them.**)**
- **(Built by #473:** each symbol runs in its own task in one `asyncio.TaskGroup`, so a fault in
one loop cancels the others. The pass lock is an `asyncio.Lock` that every loop shares. A loop
returns nothing. Only the final read says what is left.**)**
- **(Built by #473:** a close takes the same lock as a pass. A publish made while another task's
cascade runs joins that cascade and returns before it is handled. The loop would then read no
saga as a close that filled nothing, and send a second one beside it. A close that still has no
saga after its publish stops the run, so the next boot resumes it.**)**
- Do the final venue read. Done means no resting order and no position.
- Keep the `__operator__` seq counter. Start it from the same saga high-water fold the
`StrategyHost` uses. `engine` cannot import `SignalEmitter`.
Expand All @@ -164,6 +171,9 @@ context. Starts the feed through the `FeedHandle`.
flatten closed. It exits 1 and names the symbol in `left`. #474 builds the counter.**)**
- **(Built by #474:** the fold moved to `Cache.seq_high_water()`. `StrategyHost` and `Flatten`
both read it. Each close takes the next seq.**)**
- **(Built by #473:** `SignalEmitter` moved to `domain`, so `engine` can import it. `Flatten` keeps
no counter of its own. It seeds an emitter for `__operator__` the way `StrategyHost` seeds a
strategy's, and every close goes through `place()`. The seq rule (ADR-0016) lives in one place.**)**

**The per-symbol loop (private):**

Expand All @@ -174,6 +184,9 @@ context. Starts the feed through the `FeedHandle`.
played. Its passes move the replay clock, so an earlier wait would fault the replay. The size is
read again after the wait, because the old close may have filled. A wait that ends with the
saga still open sends no close for that symbol. The final read reports its size.**)**
- **(Built by #473:** the resume wait and the read after it are the ones an attempt uses. So a
failed pass or a failed read there is a dry attempt, in the same count as the loop's. A flat
read there moves the leftover too.**)**
- Place only when the partition sizes sum to the venue size from `fetch_positions()` (ADR-0054).
If they differ, call `bus.drain()` first, so every fill already published is applied. If they
still differ and the venue has account truth, run `LedgerReconciliation.reconcile_account()`.
Expand All @@ -185,9 +198,24 @@ context. Starts the feed through the `FeedHandle`.
- **(Deferred by #472:** this size check and the drain moved to #475. On paper the sizes always
match when flatten places. #472 also sends no order for a symbol the feed never priced, and the
final read reports it. Under replay no later price can come.**)**
- **(Built by #473:** the size check and the drain. A gap the drain does not close is a dry
attempt with reason `size_gap`. The gap is checked again after a new venue read. The check
compares the venue size with the account net size from `portfolio.account_net()`.**)**
- **(Built by #473:** every attempt reads the venue before it counts, even one that sent no
close for a missing price or a gap. So a venue that went flat ends the loop with no dry
attempt.**)**
- **(Deferred by #473:** the `reconcile_account()` step moved to #475, code and test together.
Paper has no account truth, so it cannot run there, and a live venue arrives in #475.**)**
- A gap that is still there counts as a dry attempt. So a gap that never heals ends in
`flatten.gave_up`, not in a loop.
- A flat venue moves each leftover into the unattributed partition instead.
- **(Built by #473:** `Checkpointer.checkpoint_leftover()` does the move in one transaction, with
no order. Each strategy leaves at its own entry price. The unattributed side is one piece at the
cost the strategies paid together, so no cash moves. With strategies of opposite sign, that price
is a cost figure and can be zero or negative. Only a read inside a loop triggers it.**)**
- **(Deferred by #473:** a symbol the venue already reads flat at the boot read gets no loop, so
its leftover does not move. [#489](https://github.com/MarcosACH/tickwright/issues/489) builds
it.**)**
- Place one reduce-only market IOC order, as a `PlaceSignal` from `__operator__` on the bus. Size it
from `fetch_positions()`.
- After each attempt, run passes until the saga has a verdict. Wait the in-flight interval between
Expand Down Expand Up @@ -299,7 +327,7 @@ check. `__operator__` owns no partition, so that check would deny every flatten

---

### StrategyHost and SignalEmitter (`engine`, `strategies`, existing)
### StrategyHost and SignalEmitter (`engine`, `domain`, existing)

**Interface:** The emitter gains `cancel_all(symbol=...)`, which returns its `signal_id`. `place()`
gains `reduce_only`.
Expand All @@ -311,6 +339,9 @@ a handled cancel all's seq (ADR-0055).

**Depth note:** The seq stays owned by the emitter. The host only resumes it.

**(Built by #473:** the emitter moved from `strategies` to `domain`. `Flatten` uses one for
`__operator__`.**)**

---

### Exchange (`domain`, existing seam)
Expand Down
2 changes: 1 addition & 1 deletion docs/module-maps/v1-core-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ src/tickwright/

### strategies (`strategies/`)

**Interface:** Minimal reference `Strategy` adapters (engine capability, not a library). Authors must know: `on_tick`/`on_order_event`; emit `PlaceSignal`/`CancelSignal` via a composed `SignalEmitter` (`emitter.py`) that owns the strategy-owned monotonic `seq` (resumed at startup via the engine-set `set_next_seq()`, ADR-0016) and clock-stamps + publishes each signal; own state *content* via `snapshot()`/`restore()` (engine persists the bytes); cancel by your own `signal_id`; handler exceptions are contained, not fatal.
**Interface:** Minimal reference `Strategy` adapters (engine capability, not a library). Authors must know: `on_tick`/`on_order_event`; emit `PlaceSignal`/`CancelSignal` via a composed `SignalEmitter` (`domain/emitter.py`) that owns the strategy-owned monotonic `seq` (resumed at startup via the engine-set `set_next_seq()`, ADR-0016) and clock-stamps + publishes each signal; own state *content* via `snapshot()`/`restore()` (engine persists the bytes); cancel by your own `signal_id`; handler exceptions are contained, not fatal.

**Responsibilities:** Signal logic only. No persistence, no venue knowledge, no saga awareness beyond `OrderEvent`s.

Expand Down
3 changes: 3 additions & 0 deletions src/tickwright/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .account import Account, AccountSpec, AccountView, venue_cash
from .duration import duration_ns
from .economics import fill_fee, funding_amount, funding_boundaries
from .emitter import SignalEmitter
from .enums import (
AggressorSide,
ComponentState,
Expand Down Expand Up @@ -231,6 +232,8 @@
"account_valuation",
"account_view",
"position_view",
# the one seq counter for every signal source (ADR-0016)
"SignalEmitter",
# retry pacing
"Backoff",
"Deadline",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,15 @@
it, plus the clock-stamped envelope and the publish, behind two typed methods.
A strategy *composes* an emitter (holds one as a field) rather than inheriting a
base: the ``Strategy`` seam is satisfied by shape, never by inheritance (ADR-0032).
Depends on ``domain`` only, so it emits no named events.
It lives in ``domain`` so the engine's own operator jobs keep their seq the same
way. One counter rule then serves every signal source. It emits no named events.
"""

from decimal import Decimal

from tickwright.domain import (
CancelSignal,
Clock,
EventBus,
OrderType,
PlaceSignal,
Side,
TimeInForce,
)
from .enums import OrderType, Side, TimeInForce
from .events import CancelSignal, PlaceSignal
from .protocols import Clock, EventBus


class SignalEmitter:
Expand Down
57 changes: 57 additions & 0 deletions src/tickwright/engine/checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,46 @@ def _split(
for owner, move in moves.items()
]

def checkpoint_leftover(self, symbol: str) -> None:
"""Move every strategy's size on ``symbol`` into the unattributed partition.

Only for a venue that reads flat, where no order can close the size
(ADR-0054). Each strategy leaves at its own entry price, so its realized
PnL does not change. The unattributed side is one piece at the cost the
strategies paid together, so no cash moves (ADR-0054). With strategies of
opposite sign, that price is a cost figure, not a traded price. It can be
zero or negative. No order and no fee, and every partition moves in one
transaction.
"""
ts_ns = self._clock.timestamp_ns()
pieces: list[tuple[PartitionFill, Side]] = []
moved = cost = Decimal(0)
for owner, size in self._portfolio.partition_sizes(symbol):
view = self._portfolio.position(symbol, strategy_id=owner)
if owner is None or not size or view is None:
continue
pieces.append(
_leftover_piece(owner, symbol, -size, price=view.entry_price, ts_ns=ts_ns)
)
moved += size
cost += size * view.entry_price
if moved:
pieces.append(_leftover_piece(None, symbol, moved, price=cost / moved, ts_ns=ts_ns))
if not pieces:
return
changes = [self._portfolio.apply_fill(piece, side=side) for piece, side in pieces]
try:
self._store.checkpoint_ledger(
account=changes[0].account,
positions=tuple(change.position for change in changes),
ts_ns=ts_ns,
)
except InvariantViolation as exc:
# Only the seam's own failure type is relabelled, as its siblings do.
raise InvariantViolation(f"leftover move write failed for {symbol}") from exc
for change in changes:
self._portfolio.project(change)

def checkpoint_heal(
self,
fills: Sequence[ReconciliationFill],
Expand Down Expand Up @@ -459,3 +499,20 @@ def checkpoint_funding(self, accrual: FundingAccrual) -> None:
f"at boundary {accrual.boundary_ts_ns}"
) from exc
self._portfolio.project_funding(change)


def _leftover_piece(
owner: str | None, symbol: str, size: Decimal, *, price: Decimal, ts_ns: int
) -> tuple[PartitionFill, Side]:
"""One partition's signed ``size`` move of a leftover, at ``price`` and with no fee."""
return (
PartitionFill(
event_id=f"leftover:{symbol}:{ts_ns}:{UNATTRIBUTED if owner is None else owner}",
strategy_id=owner,
symbol=symbol,
quantity=abs(size),
price=price,
fee=Decimal(0),
),
Side.BUY if size > 0 else Side.SELL,
)
Loading
Loading