Repository navigation
feat(engine): stop flatten after 3 attempts that close nothing, so it never loops - #490
Conversation
Flatten kept its own seq counter, because engine could not import strategies. So the seq rule (ADR-0016) lived in two places, and a fix to one would miss the other. SignalEmitter now lives in domain. Flatten seeds one for __operator__ the way StrategyHost seeds a strategy's, and every close goes through place(). The strategies package no longer re-exports it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rice A held symbol the feed never priced got no close and no retry. The run said nothing about why. Now each attempt without a price emits flatten.dry_attempt. Flatten waits 2 seconds between attempts and gives up after 3 in a row with flatten.gave_up. The final read still names the size left, and the run exits 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a fill A close that filled nothing was never sent again, so a thin book left the position open after one try. Each symbol now runs a loop. A close with no fill is a dry attempt, and flatten waits 2 seconds before the next one. A fill resets the count. After 3 dry attempts in a row the symbol gives up with flatten.gave_up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… run never hangs A failed reconcile pass or a failed position read used to end the loop or stop the run. Now each one counts as a dry attempt, waits 2 seconds, and retries the same step. A close that has no verdict yet never gets a second close beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sizes differ A close is split across the partitions, so it is only safe when they sum to the venue size. Flatten now drains the bus when they differ, so a fill still in flight lands first. A gap the drain does not close is a dry attempt with reason size_gap, and no close goes out against it. The reconcile_account step moves to #475, where a live venue has account truth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ue reads flat in flatten A hand trade can close the venue during flatten. No order can close what the venue no longer holds, so the strategy size moves to the unattributed partition at the strategy's entry price. Its realized PnL does not change (ADR-0054). The boot-read case is deferred to #489. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e reconcile pass at a time A thin book on one symbol no longer holds back the others. Each symbol runs its own loop, and a shared lock keeps two reconcile passes from overlapping. A loop that needs a pass waits for the one in progress, then runs its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…net size Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at venue with no price ends the loop Each attempt now returns how it ended, and the loop only counts and waits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… attempt The wait for a close the boot left open is now the same wait that follows an attempt, as ADR-0056 and ADR-0060 describe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dry attempt The resume wait now uses the same read as an attempt, so the read that sizes the next close is never skipped. A loop reports nothing, and only the final read says what is left. The verdict wait folds into one function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pts like the loop Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MarcosACH
left a comment
There was a problem hiding this comment.
Review of ralph/issue-473 @ 20d4ada
Reviewer: code-review skill
Reviewed: 2026-10-12
Optimization axis: correctness
Mechanical
- ruff format: pass
- ruff check: pass
- mypy: pass
- lint-imports: pass (7 kept, 0 broken)
- pytest: pass (2031 passed, 49 skipped, coverage 97.13%)
Findings
[BLOCKING] R001 — src/tickwright/engine/exit_run.py:152 — Concurrent loops can open several closes on one symbol
Category: concurrency
Why: InMemoryBus.publish treats a publish made while another cascade is running as reentrant. It queues the signal and returns at once. The loops now run as concurrent tasks. So when one loop's close yields mid-cascade, another loop's place() returns before its signal is handled. _decided (line 387) then reads the missing saga as decided. _filled reads it as no fill. The loop counts a false dry attempt and sends the next close beside the first one. This breaks ADR-0056: a symbol never has two attempts open.
Reproduced on paper with a venue double whose place does await asyncio.sleep(0) before it forwards, holding BTC 0.02 and ETH 0.5:
places ['BTC', 'ETH', 'ETH', 'ETH']
flatten.dry_attempt ETH count=1 reason=no_fill
flatten.dry_attempt ETH count=2 reason=no_fill
flatten.dry_attempt ETH count=3 reason=no_fill
flatten.gave_up ETH size=0.5
exit.finished outcome=done exit 0
Three ETH closes reached the venue. Only paper's reduce-only rule kept the books right. The run also logs gave_up for a symbol that closed. ManualClock and paper never yield, so the shipped tests stay serial. A live venue's HTTP place yields, and #475 brings one.
Fix: One loop must not publish while another loop's cascade is open. For example, hold the shared _Passes lock across the send as well as the pass. Add a test with a place that yields for two symbols. It should assert one close per symbol per attempt and no no_fill on a close that filled.
Status: open
[WARN] R002 — src/tickwright/engine/exit_run.py:340 — No test for 3 failed reads in a row after a close
Category: testing
Why: Coverage shows the give-up branch in _read_left (line 341) never runs. The tests fail one read, then let the next succeed. So nothing proves a venue that never answers ends the loop. That is the "never hangs" acceptance item.
Fix: Add a test where every fetch_positions after the boot read fails. Assert 3 read_failed dry attempts, one flatten.gave_up, and exit 1 with left from the final read.
Status: open
[WARN] R003 — src/tickwright/engine/exit_run.py:322 — flatten.gave_up can report a size that is already gone
Category: api
Why: After a give-up in _verdict or _read_left, size is the size before the close went out. If the close partly filled, the event names more than is left. The ADR-0060 amendment in this PR calls the field "the size left".
Fix: Either report the books' size for the symbol at give-up, or reword the ADR-0060 amendment to "the last size the venue reported".
Status: open
[WARN] R004 — src/tickwright/engine/checkpoint.py:336 — The unattributed entry price can be zero or negative
Category: testing
Why: With two strategies of opposite sign and different entries, cost / moved is not a real price. Example: A holds +2 at 100 and B holds -1 at 300. Unattributed gets +1 at -100. No test covers more than one strategy, and the store accepts the value. Invariant 9 makes this case rare (old partitions after a config change). But the code keeps machinery for it that nothing proves.
Fix: Add a two-strategy test and pick the rule on purpose. Either keep the cost-basis rule and document that the entry can be any number, or move one piece per strategy at its own entry. Ask Marcos if unsure, since ADR-0054 only shows the one-strategy case.
Status: open
Summary
- BLOCKING: 1 open
- WARN: 3 open
- NIT: 0 open
- Merge: BLOCKED
Ralph-State: changes-requested
…ever sends a second close beside one still in flight While one loop's send waited on the network, another loop's publish joined that cascade and returned before it was handled. The loop then read no fill and sent another close. A close that still has no saga after its publish now stops the run instead of counting as dry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ends the run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reported After failed reads that size can be from before the last close. exit.finished says what is left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sign move The unattributed piece takes the cost the strategies paid together, so no cash moves (ADR-0054). That price can be zero or negative. The docstring and the safe-exit map now say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review findings addressed. Checks at 7cb2c29: ruff, mypy, and lint-imports are clean. pytest: 2035 passed, 49 skipped, coverage 97.17%. R001 — Status: resolved — b261f3b R002 — Status: resolved — f1b3d31 R003 — Status: resolved — 24e3665 R004 — Status: resolved — 7cb2c29 |
MarcosACH
left a comment
There was a problem hiding this comment.
Review of ralph/issue-473 @ 7cb2c29
Reviewer: code-review skill
Reviewed: 2026-10-12
Optimization axis: correctness
Mechanical
- ruff format: pass
- ruff check: pass
- mypy: pass
- lint-imports: pass (7 kept, 0 broken)
- pytest: pass (2035 passed, 49 skipped, coverage 97.17%)
Prior findings
[BLOCKING] R001 — src/tickwright/engine/exit_run.py:191 — Concurrent loops can open several closes on one symbol
Status: resolved — b261f3b (verified)
A close now holds the same lock as a pass. Nothing else publishes outside the lock in a flatten run: the feed has ended, drain() is a no-op on the in-memory bus, and the Kafka publish waits for the whole process to go quiet. A close with no saga now raises InvariantViolation and is never counted as dry. test_a_send_that_waits_on_the_network_gets_no_second_close_beside_it sends one close per symbol and logs no flatten.* event.
[WARN] R002 — src/tickwright/engine/exit_run.py:359 — No test for 3 failed reads in a row after a close
Status: resolved — f1b3d31 (verified)
The give-up line in _read_left is now covered. The test asserts 3 read_failed, one gave_up, no second close, and exit 1.
[WARN] R003 — src/tickwright/engine/exit_run.py:208 — flatten.gave_up can report a size that is already gone
Status: resolved — 24e3665 (verified)
The ADR-0060 amendment and the docstring now say "the last size the venue reported". The R002 test pins it.
[WARN] R004 — src/tickwright/engine/checkpoint.py:337 — The unattributed entry price can be zero or negative
Status: resolved — 7cb2c29 (verified)
The cost-basis rule matches ADR-0054 ("open no cash gap of their own"). Neither the domain type nor the stores reject a non-positive price. The two-strategy test pins 40000. The docstring and the safe-exit map say the price can be zero or negative.
Findings
[NIT] R005 — src/tickwright/engine/exit_run.py:428 — _decided still reads a missing saga as decided
Category: errors
Why: R001 showed that this read is the unsafe one. _attempt now guards it, so the branch is dead, but a new caller without the guard would bring R001 back.
Fix: Move the missing-saga InvariantViolation from _attempt into _decided, so the rule lives in one place.
Status: open
Summary
- BLOCKING: 0 open
- WARN: 0 open
- NIT: 1 open
- Merge: READY
Ralph-State: ready
…one place _decided read a missing saga as decided, which is the unsafe reading. _attempt guarded it, but a new caller without the guard would send a second close beside one still in flight. _decided now raises itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
R005 — Status: resolved — 3d326f0 |
Closes #473
A thin book, a missing price, or a failed read could make flatten loop forever. Now each held symbol gets at most 3 attempts in a row that close nothing. After that, flatten stops with
flatten.gave_upand exits 1.What changes
Flattenkeeps no seq counter of its own.SignalEmittermoved todomain, and every close goes throughplace().flatten.dry_attemptandflatten.gave_up.Changes from the architecture review
These came from
/improve-codebase-architectureon this branch. Two of them change behavior.flatten.gave_up. The leftover never moved, yet the run still exited 0. ADR-0056 says a flat venue ends flatten. The loop now only counts and waits. One_attemptfunction says how each attempt ended.portfolio.account_net(). It no longer sums the split basis a second time.Not in this PR
reconcile_account()for a gap the drain does not close. It moves to Run flatten on Hyperliquid through refusals and rate limits #475, which brings a live venue.Checks
uv run pytest: 2031 passed, 49 skipped.mypy,ruff check,ruff format --check, andlint-importsare clean.🤖 Generated with Claude Code