Skip to content

test(news-ws-client): migrate reconnect_attempt to fake transport, cover the backoff branch - #2

Closed
nahimrgz wants to merge 28 commits into
KoNananachan:mainfrom
nahimrgz:claude/elegant-hopper-02cf8c
Closed

nahimrgz wants to merge 28 commits into
KoNananachan:mainfrom
nahimrgz:claude/elegant-hopper-02cf8c

Conversation

@nahimrgz

Copy link
Copy Markdown

Summary

Two confirmed follow-ups from the full 10-angle review of 17b9187 (which fixed test_ws_client_reconnects_after_drop's CPU-load flakiness):

  • test_on_event_reconnect_attempt_after_drop had the identical real-socket-vs-wall-clock race shape — never caught flaky, but latent. Migrated it onto a shared _fake_reconnect_twice helper (extracted from the two reconnect tests) instead of a real websockets.serve server.
  • run_forever's exponential-backoff/exception branch (except ConnectionClosed/WebSocketException/OSError) had zero test coverage anywhere in the file. Added test_ws_client_backs_off_after_connection_drop: raises directly from a synchronous connect stub (no async-context-manager wrapper needed — run_forever's try already wraps the connect() call itself), asserts the retry actually waits out the real backoff with no fudge factor (asyncio.wait_for timeouts never fire early), and cross-checks the on_event "disconnected" detail against the clean-close path's.

Ran /simplify then /code-review max per this repo's usual pre-commit habit and applied the confirmed findings:

  • Renamed the sibling test to ..._after_clean_close — it tests a clean close, not a drop, and its old name read as symmetric coverage with the new backoff test.
  • Narrowed _fake_reconnect_twice's **client_kwargs to an explicit on_event param — the blind forward had no catch-all on the client side and could raise a confusing TypeError on a future typo.
  • Gave a bare next() a default so a future regression fails with a clear assertion instead of RuntimeError: coroutine raised StopIteration.
  • Unified the file onto one websockets.connect patch spelling (dropped a leftover wsc_mod indirection).
  • Shrunk the new test's backoff values to this file's usual 0.05/0.1 (the old max_backoff was provably inert).

Test plan

  • pytest — full suite green
  • Both timing-sensitive tests stable across 8 repeated runs (before and after shrinking the backoff values)
  • ruff check / ruff format --check — clean
  • mypy (scoped to openpoly/, per this repo's own pyproject.toml) — clean

🤖 Generated with Claude Code

nahimrgz and others added 28 commits August 30, 2026 17:20
…, async sells

Trailing distance now floors at max(min_trail_ticks, spread, pct of gain)
so the stop can never sit inside one tick; the lock arms at +30 percent
and take_profit ships disabled so the trail is the primary exit. Marks
come only from depth-qualified bids (no mid fallback), peaks update from
the book sampler, and live sells run off the event loop with an
in-flight registry so settlement/reconciliation cannot double-close.
…dwork

quantize_size follows the venue size-decimals rule and never zeroes a
whole-share position at 3-decimal prices; sub-share remainders skip and
stay open for settlement instead of being written off at zero. Paper and
live share one sizing helper, buys abort when the CTF baseline read
fails, the write-behind writer counts drops and drains on stop,
clob_patch gets tests, manual closes respect the closing registry, and
entries persist p_model/confidence/edge behind a calibration report with
an edge-sizing knob that ships disabled.
Mutating routes require X-OpenPoly-Token when configured; empty or
unresolvable tokens fail closed and live mode is refused (and demoted at
startup) without a usable token, even when runtime.json is unwritable.
A schema_version table replaces the hand-rolled PRAGMA checks, order
book snapshots get a composite index plus an hourly retention prune
wired from the canvas config, credential fragments leave INFO logs, and
carried review follow-ups land (partial-fill bookkeeping, heat-cap
fallback, reconciliation dust filter, calibration sample hygiene).
vitest covers the canvas store and template IO (blocking in CI); the
static catalog mirrors the real section schemas and close-all reports
partial fills honestly; mutating requests attach the API token from a
new settings panel. The backend refuses cross-origin writes via
Sec-Fetch-Site/Origin authority checks (vite proxy pins
changeOrigin:false to stay same-authority), runtime monitors get
architecture docs and a shared TickLoopMonitor base, and mypy plus
pre-commit join the toolchain non-blocking.
The review caught six wiring bugs whose units were already well tested:
switching to live built a live executor and discarded it (every trade
skipped live_not_ready until restart), the lifespan never called
bootstrap_peaks (each restart forgot every run-up), the manual close
routes ran the blocking sell inline on the event loop, canvas edits to
the retention window were persisted and ignored, spread was measured
from the raw level-1 bid instead of the depth-guarded mark, and a
reconciled close reset the consecutive-loss brake. The rest are smaller:
per-position isolation in close-all, a bare recorded_at index for the
retention DELETE, a stop check between prune batches, auth failures no
longer render as "backend unreachable", and the secret-ref scheme list
has one owner again.

Each of the six ships with a regression test proven to fail when its
fix is reverted — these survived precisely because unit coverage does
not exercise wiring.
CI text predated the frontend test job and the informational mypy /
ruff-format steps. Adds the two conventions this work introduced that an
agent would otherwise miss: a schema change needs a MIGRATIONS entry
(create_all only covers fresh databases) and every mutating route
declares the API-token dependency.
The live executor posts crossing GTC limit orders at the level-1 price. When
nothing crossed it returned live_no_match and left the order resting on the
book at a price that was already stale, where it could fill later with no
ledger row — the same orphan incident the partial-fill cancel was written
for, one branch over. Per the current CLOB docs there is no status that
means "not placed": `unmatched` rests on the book after the delay window,
`delayed` cannot be cancelled while pending, and every cancel answers 200
with `canceled` / `not_canceled`, which the SDK hands back unread.

Every answered order short of a full fill is now settled. The cancel is
retried inside a bounded budget (8 s of wall clock, at most 16 attempts)
across the matching-delay window; the cancel body is read; a refused cancel
is checked against the order itself, and only a full size_matched or a
cancelled status ends the loop — MATCHED alone does not, because a partial
fill reports it too. The largest matched size seen is never discarded, one
fresh read follows the loop unless a read already proved the order terminal,
and the CTF balance delta stands in when that read fails. A fill that raced
the cancel is recorded at the limit price, the conservative bound. ExecResult
carries resting_order_id so callers can back off instead of posting a second
order on top of one that may still rest (the exit-monitor consumer and the
startup sweep follow separately). BUY persistence gets the same bounded retry
as SELL, and non-dict responses, unparseable amounts and a missing USDC
amount no longer escape the never-raise contract or book a zero price. New
skip reasons: live_cancel_failed, live_fill_unknown, open_persist_failed.

Judgment Day approved the change after one correction round (wall-clock
bound, max-seen fill, MATCHED-is-not-terminal). Tracked follow-ups: per-asset
cancel after a lost response, resting-order sweep at live start, explicit SDK
timeout, and the live_fill_unknown guard when the only in-loop read was a
stale zero.
The runtime-monitors doc and the Phase 4 changelog entry still said that
`bootstrap_peaks` was not wired and that trailing-stop peaks reset on every
restart. The same-day code-review follow-up (b52f115) wired it into the
lifespan between configure() and start(), with a regression test pinning the
call, so a lock that armed on an earlier run-up now survives a restart for as
long as the snapshot retention window covers the position. Both texts now say
so, with the retention caveat spelled out.

The strategy changelog also gains the entry the fill-or-cancel change
deserved: an order that did not cross is cancelled rather than left resting,
the cancel response is read, MATCHED alone is not "nothing rests", a fill that
raced the cancel is booked at the limit price, and the executor reports
live_cancel_failed / live_fill_unknown instead of claiming a clean miss.
Without a .python-version, setup-uv resolves the newest CPython that satisfies
requires-python at each CI run, so a new interpreter release can break the
build through missing wheels (torch via sentence-transformers) with no change
in the repo. Pin 3.13, which is what the local venv already runs, so nothing
rebuilds locally and CI matches it; whether to move the project to 3.12, its
declared target, or stay on 3.13 is still an open decision. .atl/ (the
personal skills registry cache) and .coverage (pytest-cov data) join the
ignore list.
The format check ran with continue-on-error because a newer ruff could
reformat code under us and block merges for reasons unrelated to the change.
That rationale no longer holds: ruff is pinned to an exact version in
pyproject, so the rules cannot move without a deliberate bump. The tree is
already clean, so promoting the step costs nothing today and stops drift from
creeping back in. AGENTS.md lists the gates as CI actually enforces them.
The workflows pin every action to an exact version and both lockfiles pin
every dependency, which is right, but nothing was moving them. Weekly grouped
minor/patch updates for the three ecosystems keep the pins current without a
flood of PRs; majors still arrive one at a time.
mypy shipped as a reporting step because it was introduced onto a codebase
that never had it. The 14 errors it reported were all type hygiene, not
behavior, so clearing them costs nothing and closes the loop: the gate can
block now, and the config keeps its deliberate leniency about the third-party
SDKs that ship no stubs.

The one that was worth the walk is the kill-switch check. It reads a slice of
closed positions where closed_at and realized_pnl are Optional on the shared
record, and the filter that guarantees they are set could not survive the
comprehension, so the arithmetic in all three brakes was unprovable. A small
NamedTuple built by the same filter carries the narrowing forward and leaves
the brakes byte-identical. Elsewhere: the pipeline hooks are typed by what the
managers do with the return value, which is nothing; the LLM tool definitions
are typed with the SDK's own ToolParam instead of a dict plus a cast, so a
malformed tool is a type error rather than a 400; the query helper takes a
Mapping so its callers stop annotating dict literals one by one; and the wallet
balance cache holds the response model rather than dumping and re-validating it
on every hit.

CONTRIBUTING.md and the pre-commit config still told contributors that format
and mypy could not fail a PR, which stopped being true one commit ago; both now
list the real gates.
The note added two commits ago said a position older than the snapshot
retention window re-seeds its peak at the first mark observed after a restart.
That is not what happens: the prune deletes snapshot rows by age alone, and
bootstrap_peaks takes the max depth-guarded mark over every row that survives
for the position, so an old position still rebuilds from the recent window and
only forgets a run-up older than it. Entry price is the fallback when no
snapshot survives at all. The distinction matters to anyone sizing the
retention window from that paragraph.

The exit monitor's own comment about partial sells still described the fill
model as IOC, which the fill-or-cancel rewrite replaced.
Weekly grouped minor and patch updates are right for the ordinary
dependencies and wrong for three of them. py-clob-client-v2 is pinned to a
pre-release, places real orders, and has its HTTP layer monkey-patched by
clob_patch, so a bump that rides in a group merges on a green suite that
proves nothing about the venue — every executor test runs against a fake. Ruff
and mypy are the pinned tools behind two blocking gates; a grouped bump can
reformat the tree or tighten a check, which is exactly the review that should
not be buried among a dozen unrelated version lines. The SDK is now ignored
here and moves only through a deliberate upgrade with a paper run; ruff and
mypy still get PRs, just their own.
The settle path trusted the venue's own account of an order more than it
should. Both amounts of a POST response were parsed in one try, so a garbage
price field zeroed a share count reported alongside it: a response saying all
ten shares filled became a cancel against an already-filled order and no
ledger row for tokens the wallet was holding. One field of the same body could
still crash the executor outright — a transaction-hash list arriving as a bare
number is not subscriptable — and a bare string was indexed into, storing its
first character as the hash.

That body is now parsed once into a view whose every field is validated by
construction, so no shape of it reaches the ledger or an exception. Amounts
that will not parse are unknown rather than zero, and that now includes NaN and
the infinities: they convert without raising, and then make every comparison
downstream false, so a share count nothing could read was reported as a clean
miss and an infinite price was persisted as a cost basis. A share count larger
than the order is discarded rather than believed — clamping it looked tidier
but booked a full-size position against an empty wallet, which blocks re-entry
and can never be closed. The order read is clamped instead, since it is what
the settle trusts once the response is discarded, and an unusable size there
makes the whole read a failed read.

The cancel body gets the same treatment: an acknowledgement must be a list
holding our id, not a string that merely contains it, and an unexpected shape
is a refusal rather than an exception, because raising abandons the order on
the book. An id that is a whole number is sent rather than dropped, since not
cancelling is the worse failure.

Finally, the resting-order alert is cleared only by evidence about this order.
The wallet balance cannot say which order moved it, so an earlier order's
remainder filling during the same poll could have silenced the alert for one
still on the book. A false alert costs a look at reconciliation; a suppressed
one costs the position.

Judgment Day approved this after one correction round; the follow-ups it
listed (an explicit transport timeout, rejections told apart from lost
responses, the never-raise contract made structural) are tracked separately.
The settle had been patched a round at a time until it carried seven
interacting variables and no stated rule for which account of an order wins.
It contradicted itself in both directions. A garbage order read of 999 shares
on a ten-share order was booked down to ten and recorded as a full fill — a
position against an empty wallet that blocks re-entry and can never be sold,
which is precisely what discarding the same nonsense from the response body
was written to prevent. One line over, a read of zero taken while the order
was still live was trusted over a wallet that could see the shares.

Every quantity the executor learns is now evidence, and the rules are written
down where the code applies them. A quantity above the order size is not a
quantity: it is a wrong-scale field, discarded from any source rather than
clamped into a plausible-looking lie. A reading of the order taken once the
order can no longer fill is the venue's own account and decides alone — but it
never resolves below an earlier reading of that same order, because a count of
what one order matched only grows, and a smaller later answer is the data API
lagging the matching engine. The response's immediate cross, a reading taken
while the order could still fill, and the wallet delta are lower bounds.
Whether anything may still rest is a separate question, and the wallet never
votes on it: it is a wallet-wide delta attributed to no order, so another
order's remainder settling in the same window is indistinguishable from this
one filling.

The wallet is the single source clamped rather than discarded, and both places
that ask it now ask the same function. An order for exactly this size was
sent, so a delta above it still means at least that much was ours; discarding
it is the worse error, because the wallet is the last source to speak and a
larger delta would otherwise book less than a smaller one.

Prices get the same treatment. A quotient outside the range a token can trade
in is not a price, and an order is refused outright rather than posted at a
level-1 price the venue could not have produced — the band lives beside the
notional minimum, with the other venue rules.

Judgment Day and two review passes drove this; the trade-off they weighed is
that a clean no-fill cancel now consults the wallet before accepting the zero,
which costs a poll on a common path and is the only way to see a fill that
raced the cancel. What they found and this does not answer is tracked: paper
still applies fewer gates than live, the band is wider than the SDK's own, and
the resting-order signal still has no reader.
MAX_TOKEN_PRICE was 1.0, which is not the venue's actual rule.
py-clob-client-v2 requires tick_size <= price <= 1 - tick_size, so the
finest tick (0.0001) puts the true ceiling at 0.9999, not 1.0. A price
like 0.99995 sat inside the old band and would have been signed into a
live order the SDK's own builder refuses.

The predicate that checks this band also lived in the wrong place: it
was a private function inside live_executor.py even though its
constants already lived in sizing.py, the module both executors are
supposed to size through so paper never simulates a fill live would
reject. The result was that LiveExecutor enforced the band and
PaperExecutor did not enforce it at all — it booked book.asks[0][0] or
book.bids[0][0] straight as a cost basis with no bound. A companion fix
on this branch already stops NaN and out-of-(0,1) levels from reaching
that code, but a legal-looking level like 0.00005 or 0.99998 still got
through untouched.

in_price_band now lives in sizing.py, public, and both executors call
it: LiveExecutor imports it instead of defining its own copy, and
PaperExecutor checks it right after reading the level-1 price, before
sizing, mirroring the order live already used. Three log format
specifiers that printed MAX_TOKEN_PRICE with %.1f (correct when the
value was 1.0, misleading now) were switched to %.4f.

Not addressed here: sourcing tick size per-market instead of using the
one finest-tick band for every market is out of scope for this change.
.python-version pins 3.13, the interpreter uv run actually executes on, but
requires-python and the ruff/mypy targets still said 3.12 — four blocking
gates were analysing a different language version than the one running them.
mypy's own version wasn't pinned either, just a floor (>=1.14), unlike the
exact ruff==0.15.13 beside it, even though CONTRIBUTING.md and dependabot.yml
already claimed both tools carry exact pins.

requires-python is now >=3.13, ruff's target-version and mypy's python_version
are both py313/3.13, and mypy is pinned to 2.3.1 — the version uv.lock already
resolved under the >=1.14 floor — so the docs' claim is now true instead of
aspirational. uv lock picked up the tightened requires-python, which drops the
now-irrelevant cp312 wheel entries and markers from the lockfile. The README
badge advertised 3.12 too; that's fixed. ruff check, ruff format --check,
mypy, and the full pytest suite all stay green under python 3.13.7.
A Polymarket price is a probability: nothing on the CLOB rests outside
(0, 1), and a size is never zero or negative. parse_clob_book trusted the
raw payload past that — it dropped a level only when price or size failed
to parse at all, not when the parsed value was garbage. A 1.5 bid sorted
to the front of the book as the "best" price, silently making a position
unsellable; a 0.0 ask survived into any caller that divides notional by
it, a ZeroDivisionError two frames from the network response. NaN and the
infinities parsed as valid floats too and propagated the same way.

_to_float now rejects non-finite values, and _book_levels drops any level
whose price is not strictly between 0 and 1 or whose size is not
positive. This is the upstream fix: it makes a wrong-scale book fail at
the one place it enters the system, instead of at whichever downstream
caller happens to divide or sort by it first — including the paper
executor, which books its cost basis straight off the book with no check
of its own.
Two gaps in the same code path. First, the SDK's module-level httpx
client carries no timeout of its own — ClobClient.__init__ takes no
timeout parameter, so a single request was bounded only by httpx's
current default, which the settle-loop budget comment in
live_executor.py already flagged as the one thing it could not account
for. clob_patch now replaces that client with one pinned to
httpx.Timeout(5.0) — httpx's own current default, so this changes
nothing about actual behavior today, only makes the number explicit and
immune to a future httpx version silently changing it.

Second, execute_buy and execute_sell wrapped self._post_order in a bare
except that always ran the lost-response recovery path — polling the
wallet's CTF balance to guess whether an order landed despite a lost
reply. But py_clob_client_v2 already distinguishes the two cases:
PolyApiException carries a real status_code when the venue answered
with a non-200 (bad precision, min-size, closed-only mode, ...), and
status_code is None only for a genuine transport failure. A definitive
rejection was being treated as if the response had gone missing —
polling a balance that could never have moved, and discarding the
venue's own rejection reason in the process. Both call sites now check
status_code first: a real rejection logs a warning with the venue's
status and message and returns live_rejected: immediately, with no
balance poll; everything else (status_code is None, or not a
PolyApiException at all) falls through to the existing lost-response
path unchanged.

PolyApiException is re-exported from clob_patch alongside the other SDK
types so callers never import the SDK's exceptions module directly.
…as ok

_persist_irreversible retried the ledger write on any Exception, but only
sqlalchemy.exc.OperationalError (SQLite lock contention) is actually
transient. IntegrityError (a genuine duplicate against the partial unique
index) and ValueError (position not found / already closed / not open) fail
identically on every attempt, so retrying them just burns the full
_PERSIST_ATTEMPTS x _PERSIST_SLEEP budget and logs a misleading "retrying"
message for something no retry can fix. The retry is now narrowed to
OperationalError; everything else propagates on the first attempt with no
sleep, straight to the caller's existing CRITICAL log. The wait-budget
comment above _PERSIST_ATTEMPTS undercounted this term too: PRAGMA
busy_timeout=5000 (db/engine.py) means a single locked-DB call can already
block up to 5s inside SQLite before OperationalError is even raised, so the
worst case for that error class is closer to 5 x (5s + 0.5s) ~= 27.5s, not
the "~2s" the comment implied; the comment now says so and notes that a
permanent failure now costs one call and no sleep.

Separately, orchestrator._run_entry lost a BUY's ledger-write failure on the
way to the entry log. execute_buy never raises for this case (a deliberate
never-raise contract) - it returns skip("open_persist_failed:<...>") after
an on-chain fill already succeeded, so the section's own verdict stayed
"ok" and the except Exception branch below it never fired. A wallet now
holding tokens no ledger row manages was indistinguishable in verdict from
a routine, benign skip like dust or price_out_of_band. _run_entry now
checks for the open_persist_failed: prefix specifically and flips that one
case to verdict="error" with an explanatory error message; every other
skip reason keeps verdict="ok" unchanged, since declining to open a
position is normal and most skips are not failures.
Four bounded defects in the same call sites. The success check treated
resp.get("success") as the whole answer: an absent key (some response
variants omit it) made not None true and returned live_rejected
immediately, abandoning a possibly-resting order with no cancel attempted;
a string "false" made not "false" false and sailed straight into
_resolve_fill as though the order had succeeded. Both call sites now go
through a new _order_was_rejected helper that returns True only when
"success" is present and is either the boolean False or a string equal,
case-insensitively and stripped, to "false" - everything else, including
an absent key, proceeds to settle.

_ORDER_DONE_STATUSES was missing EXPIRED and REJECTED, two terminal
statuses the venue's order lifecycle already documents alongside
CANCELED/CANCELLED; without them the settle loop kept burning its
retry/cancel budget on an order the venue had already reported dead.
_read_order also treated every get_order failure alike, including a 404,
which for this endpoint means the order aged out of the venue's records
and is definitely gone rather than a transient read worth retrying. It now
catches PolyApiException separately: a 404 returns the synthetic status
PURGED (added to _ORDER_DONE_STATUSES so add_read treats it as terminal
with no other change needed), any other status code falls through to the
existing warn-and-return-None behavior.

Every function that branches on side treated anything other than exactly
"buy" as "sell" via an if/else with no final raise, so a caller bug (a
typo, or passing intent.side, which is a real "yes"/"no" domain that
already exists in this codebase) would silently book the wrong amount as
shares and the wrong price edge as conservative instead of failing loudly.
_bookable_price, _resolve_fill, and _fill_from_balance now raise
ValueError up front for anything other than "buy" or "sell"; both existing
call sites already pass correct literals, so this only guards against a
future internal mistake.
test_live_executor.py's no_sleep fixture patched attributes on le_mod.time,
but le_mod.time is the real, process-wide time module object (Python caches
modules in sys.modules, so there is only ever one). Any other code running
while a test held that patch — a background thread, an in-process xdist
worker, coverage.py's own instrumentation — saw the fake sleep or, worse for
test_settle_stops_at_the_wall_clock_deadline, the fake stepping monotonic
clock, whose shared mutable counter could be consumed by an incidental call
from outside the test and throw off the wall-clock-deadline assertion. The
fixture now rebinds live_executor.py's own module-level time name to a fresh
types.SimpleNamespace instead, leaving sys.modules["time"] untouched; the
namespace's monotonic defaults to the real time.monotonic so a test that
never asked for a fake clock still gets real elapsed time. The wall-clock
test's own monkeypatch.setattr(le_mod.time, "monotonic", ...) line needed no
change — it now sets an attribute on the fake namespace instead of the real
module, composing automatically once no_sleep hands out the namespace first.

test_execution_sizing.py's _NoopClob.cancel_order returned None
unconditionally. LiveExecutor's settle path treats any non-dict cancel
response as a refusal, so the first partial-fill test against this fake
would burn the entire cancel-retry budget treating every attempt as refused
— and, since this file had no sleep-scoping fixture of its own, would do it
through real time.sleep calls. cancel_order now returns a realistic
acknowledged-cancel body ({"canceled": [payload.orderID], "not_canceled":
{}}), and the file gains the same scoped no_sleep fixture as
test_live_executor.py so any retry loop a test drives through LiveExecutor
never sleeps for real. Kept as a duplicate rather than moved into
conftest.py: consolidating would make every test in the suite pay for an
import and monkeypatch of live_executor's time name, including tests with
no relationship to it, for no benefit over the two small, self-contained
copies.
test_execute_sell_runs_off_the_event_loop proved the slow sell ran in a
worker thread by counting asyncio heartbeats (10ms apart) during a 300ms
blocking call and asserting at least 10 landed. Under CPU load or scheduler
jitter, a starved heartbeat task can log fewer ticks even though the event
loop never actually stalled, which is the flakiness prior review runs
reported (about 3 in 10 full-suite runs). The count carried no information
the test actually needed: what matters is not how many heartbeats fit in
the window, only whether the loop kept running at all while the sell slept.

The test now races a single heartbeat tick against the slow call itself
with asyncio.wait(..., return_when=FIRST_COMPLETED): the tick only wins if
the event loop was genuinely blocked for the whole 300ms, a margin no
ordinary scheduling jitter can close, and it still fails the same way a
regression would — offloading removed, the blocking call finishes long
before the loop ever gets to run the heartbeat's timer.

test_ws_client_reconnects_after_drop and the buffer-length assertion beside
it (both in test_news_ws_client.py) were also named in the review as
occasionally flaky, but they already poll toward a generous 3s ceiling
rather than counting within a fixed window; their real sockets and real
reconnect backoff are the more likely source of load sensitivity, and
replacing that with a fake transport is a larger change than this pass
should force. Left alone.
…erID is not settleable

A Judgment Day correction round on the last three execution commits. Both
judges independently found the same three places where a signal was read as
more certain than it is.

The rejection check around the order post asked only whether
PolyApiException.status_code was not None, but that is true of every non-200
the venue ever answers, 502/503/504 included. The pinned SDK's own
_is_transient_error classes 500 <= code < 600 as transient precisely because
a 5xx is not proof the order was never placed: the matching engine can have
accepted and matched it and only the response leg failed. Short-circuiting
past the CTF balance-confirm there leaves a real on-chain fill with no ledger
row, the untracked position the pre-order baseline read exists to prevent.
Both call sites now require a 4xx to call it a definitive rejection;
everything else, 5xx included, falls through to the balance-confirm unchanged.

_read_order treated a 404 from GET /data/order as the synthetic terminal
status PURGED. But this file documents at length that the venue's data API
lags the matching engine, so a just-placed order — especially one still inside
the non-cancellable matching-delay window — can 404 simply because it has not
been indexed yet, which is indistinguishable here from an order that really
aged out. Believing it ended the cancel-retry loop after a single attempt and
dropped the RESTING ORDER ALERT for an order that may still have been live on
the book. The 404 branch and the PURGED entry in _ORDER_DONE_STATUSES are
gone; a 404 is a failed read like any other and the existing attempt and
wall-clock budget decides when to stop. EXPIRED and REJECTED stay: those are
real statuses returned in a successful read, not inferred from an absent one.

_order_was_rejected returned "proceed to settle" for any body without a
"success" key. That is right for the case it was written for — some variants
omit the key on an order that was genuinely placed, with an orderID and
amounts to settle on — and wrong for an error-shaped body such as
{"error": "..."}, which has neither. That one flowed into the settle with a
None order id, logged a false RESTING ORDER ALERT, spent the balance-poll
budget, and could book a fill out of any unrelated balance move in that
window; the module's own notes name an earlier order's remainder settling as
exactly such a move. An absent "success" key now only proceeds when the body
also carries a non-empty orderID. The skip message reads the venue's words
through a small _rejection_reason helper, since an error-shaped body names the
text "error" rather than "errorMsg" and dropping it left the reason saying
nothing.

AGENTS.md still advertised the ruff target as py312; the project was
reconciled to 3.13.
Twelve commits from the 2026-09-05/06 audit: settle-loop fill accounting
(fill-or-cancel integrity, malformed-response handling, one rule for what
a fill was), order-book ingest validation, a shared price band for both
executors, honest transport/rejection handling, scoped persistence
retries with a restored error channel, corrected settle signals
(success-flag parsing, terminal statuses, side guards), test-hygiene
fixes, and version-pin reconciliation to Python 3.13. Judgment Day ran
across the full batch at the end; three confirmed defects were fixed and
a scoped re-judgment came back clean.
…port, not a real socket

test_ws_client_reconnects_after_drop was flagged flaky under CPU load
(~3 of 10 full-suite runs) when e86f6a9 fixed its exit-monitor sibling;
that commit diagnosed the residual cause as real sockets and real
reconnect backoff racing a wall-clock ceiling, and deferred the larger
fix of swapping in a fake transport.

Rewrites the test to monkeypatch websockets.connect (same mechanism
test_on_event_auth_fail_stops_loop already uses) instead of running a
local websockets.serve server, and waits on an asyncio.Queue the fake
connector fills per attempt instead of polling a counter toward a
timeout. No real I/O is left on this path, so the test is bounded only
by event-loop scheduling, not real socket/backoff timing.

Verified: 30/30 passes under artificial CPU load (6 busy-loop
processes), and the test goes red when run_forever's reconnect is
sabotaged, confirming it still catches the regression it's meant to
catch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ver the backoff branch

Two confirmed follow-ups from the full review of 17b9187, which fixed
test_ws_client_reconnects_after_drop's CPU-load flakiness but left two
gaps: test_on_event_reconnect_attempt_after_drop had the identical
real-socket-vs-wall-clock race shape, never caught flaky but latent;
and run_forever's exponential-backoff/exception branch (the except
ConnectionClosed/WebSocketException/OSError path) had zero coverage
anywhere in the file.

Extracts the fake-connect scaffolding both reconnect tests shared into
_fake_reconnect_twice, migrates test_on_event_reconnect_attempt_after_drop
onto it, and adds test_ws_client_backs_off_after_connection_drop: raises
directly from a synchronous connect stub (run_forever's try already
wraps the connect() call itself, no async-context-manager wrapper
needed), asserts the retry waits out the real backoff with no fudge
factor (asyncio.wait_for timeouts never fire early), and cross-checks
the on_event "disconnected" detail against the clean-close path's.

Ran /simplify then /code-review max per house habit before committing;
applied the confirmed findings: renamed the sibling test to
..._after_clean_close (it tests a clean close, not a drop — its old
name read as symmetric coverage with the new backoff test), narrowed
_fake_reconnect_twice's **client_kwargs to an explicit on_event param
(the blind forward had no catch-all on the client side and could raise
a confusing TypeError on a future typo), gave a bare next() a default
so a future regression fails with a clear assertion instead of
RuntimeError: coroutine raised StopIteration, unified the file onto one
websockets.connect patch spelling, and shrunk the new test's backoff to
this file's usual 0.05/0.1.

Verified: full suite green, both timing-sensitive tests stable across
8 repeated runs, ruff and mypy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nahimrgz

Copy link
Copy Markdown
Author

Opened against the wrong repo by mistake (gh defaulted to the fork parent) — recreating against nahimrgz/OpenPoly instead.

@nahimrgz nahimrgz closed this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant