diff --git a/blueprints/us-equities/adaptive-paper/README-safety.md b/blueprints/us-equities/adaptive-paper/README-safety.md index 02c6a7d1f..0c15bd57b 100644 --- a/blueprints/us-equities/adaptive-paper/README-safety.md +++ b/blueprints/us-equities/adaptive-paper/README-safety.md @@ -62,11 +62,26 @@ limit-order mechanism guarantees a flat finish. that no HTTP request was sent; a timeout or other ambiguous send must remain unresolved and be queried by its existing client ID. Observed orders cannot be marked not sent, and a later broker observation of such an ID fails closed. -- `mark_broker_refused(client_id, http_status)` records distinct local terminal +- `mark_broker_refused(client_id, http_status, refusal=None)` records distinct local terminal status `broker_refused` for an attempted zero-fill order without broker ID. Only HTTP 401/403/404 qualify, and transport must first establish that the subsequent client-ID lookup returned 404. It retains the HTTP status and budget. - Timeout, 400, 422, 429 and 5xx remain ambiguous. Neither local terminal status is + Timeout, 400, 422, 429 and 5xx remain ambiguous, with one exception that Alpaca's + documentation proves definitive: a 422 whose `refusal` is `SUB_PENNY_REFUSAL` + (`"sub_penny_minimum_price_variance"`), which the transport assigns only to the + documented body code 42210000 with "sub-penny increment does not fulfill minimum + pricing criteria" (https://docs.alpaca.markets/us/docs/orders-at-alpaca.md: such + orders "will be rejected"). That page documents the body, not the HTTP status; + the 422 was inferred from the code prefix and the POST /v2/orders 422 entry. It + was observed once on the paper endpoint in the 2026-09-24 native-fault run + (`native-faults/receipt.json`, C04: submit 422, then lookup 404). The same body + under any other status still stays ambiguous. The message, not the code, is the discriminator. The ledger also requires the intent's own durable + limit price to violate the minimum price variance (`refusal_contradicts_intent_price` + otherwise). Any other 422, including "client_order_id must be unique", stays + ambiguous. `reserve_intent` still refuses such a price before send + (`invalid_price_increment`, via the overridable `_check_price_increment`); only + the native-fault harness's `FaultLedger` exempts its single C04 client ID. + Neither local terminal status is adopted or looked up as a broker order on restart; any later broker observation of the identity fails closed. - `record_order(client_id, broker_id, status, cumulative_qty, average_price, *, diff --git a/blueprints/us-equities/adaptive-paper/README-transport.md b/blueprints/us-equities/adaptive-paper/README-transport.md index fb1c69240..00b86c74b 100644 --- a/blueprints/us-equities/adaptive-paper/README-transport.md +++ b/blueprints/us-equities/adaptive-paper/README-transport.md @@ -40,10 +40,32 @@ the caller's recovered durable intents. A repeated ID is looked up, never posted again. Broker 401/403/404 refusals with a confirming absent-ID lookup, and a locally prevented request, expose `definitive_rejection=True`. A 400 or 422 may mean a duplicate ID whose earlier order is not visible yet; it remains ambiguous -unless lookup resolves the frozen intent. Ambiguous errors retain unresolved -state, freeze admission, and perform at most one lookup without resubmission. -Cancellation acknowledgements are followed by a lookup, not treated as fills or -terminal cancellations. All observations contain cumulative quantities; stream +unless lookup resolves the frozen intent. The one exception is Alpaca's documented +minimum-price-variance refusal: a 422 whose body code is 42210000 and whose message +contains "sub-penny increment does not fulfill minimum pricing criteria", for a +first POST whose limit price really has more than two decimals at or above $1.00 +(four below), followed by an absent-ID lookup. Alpaca states such orders "will be +rejected" (https://docs.alpaca.markets/us/docs/orders-at-alpaca.md). That page +documents the body but not the HTTP status. 422 was inferred from the code prefix and +the POST /v2/orders 422 entry. It was observed once on the paper endpoint in the +2026-09-24 native-fault run (`native-faults/receipt.json`, C04). The message, not the +code, is the discriminator. The transport +raises `RejectedSubmission(422, "sub_penny_minimum_price_variance")` and compares +the body with those constants only, never retaining or raising it. The engine's +own Ledger refuses such prices before send, so only the native-fault harness, +which exempts its single C04 client ID, can reach this path. Ambiguous errors +retain unresolved state, freeze admission, and perform at most one lookup without +resubmission. `cancel()` sends the DELETE for every owned order: to the broker ID +it already observed, or, with no observation, to the ID a client-ID lookup +returns. It does not skip the DELETE because a cached or freshly read status is +terminal; the caller asked because it believes the order open or ambiguous. The +answer is data: 204 is only an acknowledgement; 404 or 422 ("The order status is +not cancelable", +https://docs.alpaca.markets/us/reference/deleteorderbyorderid-1.md) is accepted +without a freeze when the follow-up lookup shows the order terminal, and freezes +`cancellation_unresolved` otherwise; a timeout, 429 or 5xx freezes as before. Every +DELETE is followed by a lookup, never treated as a fill or terminal cancellation, +and repeating a known terminal observation changes no ledger state. All observations contain cumulative quantities; stream observations additionally retain available execution IDs and individual event quantity/price. Consumers must deduplicate executions and never manufacture individual fills from a cumulative REST snapshot. diff --git a/blueprints/us-equities/adaptive-paper/native-faults/README.md b/blueprints/us-equities/adaptive-paper/native-faults/README.md index 5a7cba425..476300f69 100644 --- a/blueprints/us-equities/adaptive-paper/native-faults/README.md +++ b/blueprints/us-equities/adaptive-paper/native-faults/README.md @@ -2,25 +2,120 @@ Gate: `native-fault-behaviour` in `catalogs/us-equities/gates-20260922.json` (receipt path `blueprints/us-equities/adaptive-paper/native-faults/receipt.json`). -This directory holds the harness, its frozen `plan.json`, this note and the -first live receipt `receipt.json`. The offline suite +This directory holds the harness, its frozen `plan.json`, this note, the current +live receipt `receipt.json` (the 2026-09-24 run) and the retained receipt of the +earlier incomplete run, `receipt-20260923.json`, and under `evidence/` the +independent observation of the 2026-09-24 run's broker orders. The offline suite `tests/test_native_faults_min.py` is a local synthetic fixture with a fake transport; it drives the real `runner.Controller` and `safety.Ledger`. -**With the current engine a live run cannot reach `native_faults_passed`.** -C04 is refused by the Ledger before any POST is sent, and C05 short-circuits -on a client-id lookup without sending a DELETE. A live run therefore yields -partial native evidence only (C01 and C02), with the best status -`native_faults_incomplete`. +## Native run of 2026-09-24: `native_faults_passed` + +One live Alpaca paper run at 2026-09-24 14:39:05Z to 14:39:06Z, started by the +workflow coordinator from this branch's code at commit 8051464. The host class +is the WSL2 workstation (Linux, Python from the pinned adaptive-paper runtime), +against a paper account dedicated to this PC. No account id, credential or +host path is recorded. The raw stdout stays private (231 bytes, sha256 +`1481924e43f2867e5e62338ab60bb5b60cbe9ff9df3f54c36f8fa348f082ecac`). The run +wrote `receipt.json` in place, replacing the 2026-09-23 receipt, which is kept +byte-for-byte as `receipt-20260923.json`. + +The receipt binds this tree. Its `harness_sha256` (3f01fb31...), `plan_sha256` +(f276b26c...) and `engine_sources_sha256` for runner.py (a6101294...), safety.py +(ad520fc4...) and transport.py (b92e8752...) equal the SHA-256 of those files +here and the engine entries in `../source-hashes.json`. The later merge of +origin/main changed none of these five files. + +| Case | Outcome | Evidence class | Broker requests | +|---|---|---|---| +| C01 accept resting buy (SPY 1 @ 382.96, bid 765.92) | passed | native_paper | submit 200, `pending_new`, broker id recorded | +| C02 cancel resting | passed | native_paper | cancel 204 plus five reads 200; ledger and fresh snapshot `canceled`, zero filled | +| C05 cancel again | passed | native_paper | DELETE sent (`delete_sent: true`), answered 204, then one read 200; no new freeze reason; `ledger_before` equals `ledger_after` (`canceled`, zero filled) | +| C04 definitive rejection (306.3601) | passed | native_paper | submit 422, then client-id read 404; ledger `broker_refused` with `{"http_status": 422, "refusal": "sub_penny_minimum_price_variance"}` | + +`posts_reserved` was 2 of 4, with one transport build, no stop error and no +interruption. Cleanup proved flat with `runner.reconcile` over a fresh snapshot: +zero open orders, zero positions, cash delta 0.00. The `IN_FLIGHT` marker was +removed. The receipt does not record the process exit code. By harness design, +`native_faults_passed` is the exit-0 status. + +C05 differs from the expectation written before the run: Alpaca paper answered +the DELETE of an already-canceled order with 204, not the 422 its DELETE +reference suggests ("The order status is not cancelable"). The plan accepts 204, +404 or 422 as data. So `broker_refusal_observed` is false, and the case shows +that the engine sends the repeat DELETE and stays consistent with any of the +three answers. It does not show how the engine handles a 422 cancel refusal; +that path is covered only offline. + +**What C04's definitive refusal rests on.** Alpaca documents that orders +exceeding the minimum price variance "will be rejected", with the body +`{"code": 42210000, "message": "... sub-penny increment does not fulfill +minimum pricing criteria"}` +(https://docs.alpaca.markets/us/docs/orders-at-alpaca.md). This run observed +that body with HTTP 422 on the first and only POST of the C04 client id. The +limit price 306.3601 violates the two-decimal increment. The follow-up +client-id lookup returned 404, and there was no position or cash effect before +or after. The transport raises `RejectedSubmission(422, +"sub_penny_minimum_price_variance")` only when all of these hold, and the ledger +then records `broker_refused`. This is one observation, on the paper endpoint, +from one host and one account. It confirms the previously inferred 422 for this +body there. It is not evidence for the live endpoint, and it does not make 422 +definitive in general. The comments in `safety.py` and `transport.py` still +describe the 422 as inferred. They are left as they are because editing them +would change the engine hashes this receipt binds. + +**Gate status.** `scripts/trading_gates.py` lists `native-fault-behaviour` +as a flip candidate (`/status == "native_faults_passed"` holds). The ladder allows a +status change "only by a dated commit after the checker lists the gate as a flip +candidate". The gate note adds that "native (non-synthetic) faults were actually +exercised ... is qualified manually before the dated commit that flips this +gate". `docs/acceptance-evidence-policy.md` says "Parsing a wrapper's own +`passed` field is not independent confirmation", so the qualification uses the +independent observation below. The gate was flipped to `established` (evidence +class `native_proven`, the ladder's class for paper-run gates) in a separate +dated commit on 2026-09-24, whose note cites the receipt, the independent +observation and the Codex review of 8051464. The engine's handling of a 422 +cancel refusal stays offline-tested only, and the C04 422 is paper-endpoint +evidence only. + +## Independent observation of 2026-09-24 + +`evidence/independent-observation-20260924.json` records a listing made by the +workflow coordinator at 2026-09-24 15:01:47Z by a separate method: an alpaca-py +0.44.0 script (`evidence/observe-native-faults-20260924.py`, sha256 +6f4b1c19...) on Python 3.12.3, not `harness.py` and not the engine transport. +Its stdout is retained byte-identical as +`evidence/observe-native-faults-20260924.stdout.json` (sha256 420d762c...); +stderr was empty and the exit code 0. + +For prefix `nf-20260924t143905-29d7ec-` since the receipt's `started_at` it saw +one broker order, `c01`, `canceled` with filled quantity 0. The client-id lookup +for `c01` returned the same. The lookup for `c04` returned 404, so no broker +order exists for it. There were 0 open orders and 0 positions. These counts +match the receipt: one accepted submit (C01, later canceled), one submit +refused with 422 that created no order (C04), cleanup flat. + +The observation uses the same broker API and account, so it is independent of +the harness and engine code, not of the broker. It confirms only these +broker-side facts. It does not observe the run's own HTTP statuses (C04's 422 +body, C05's 204 DELETE answer), ledger contents, or the engine's handling of a +422 cancel refusal, which remains offline-tested only. + +## Retained history: the incomplete run of 2026-09-23 + +Retained as `receipt-20260923.json`, byte-identical to the `receipt.json` +registered before the 2026-09-24 run (sha256 28b9db92...). -The one live run (2026-09-23 14:20:24Z, from a read-only `git archive` of the -harness commit) went exactly that way. The plan and engine source hashes in -the receipt match this tree; its `harness_sha256` (742fb666...) is the harness -as run. `harness.py` was changed after the run, following review: an +The first live run (2026-09-23 14:20:24Z, from a read-only `git archive` of the +harness commit) predates the 2026-09-24 engine change. It produced the +incomplete result in the table below. Its `plan_sha256`, `harness_sha256` +(742fb666...) and `engine_sources_sha256` (runner.py, safety.py, transport.py) +are the pre-change hashes and do not match this tree. Before the engine change, +`harness.py` was also changed after the run, following review: an exception inside cleanup still writes `CLEANUP_REQUIRED` and a receipt (the run itself cleaned up without error), a leftover marker from an earlier run refuses a new start, the write-ahead marker is fsynced, and a failed transport -stop fails the run. The next run therefore binds a new harness hash. +stop fails the run. | Case | Outcome | Evidence class | Broker requests | |---|---|---|---| @@ -31,7 +126,21 @@ stop fails the run. The next run therefore binds a new harness hash. Cleanup proved flat with zero open orders by `runner.reconcile` (cash delta 0.00). One POST was reserved out of the four allowed. Status: -`native_faults_incomplete`, so the gate stays `not_established`. +`native_faults_incomplete`. + +The 2026-09-24 engine change closed its two gaps. It was verified offline first, +with unit tests using a fake broker and mocked HTTP, then natively by the run +above: + +- C05: `AlpacaPaperTransport.cancel` now sends the DELETE for every owned order + to its known broker id instead of returning early when a client-id lookup + shows it terminal. Alpaca's answer is data. A 404 or 422 is accepted without a + freeze when the follow-up lookup shows the order terminal. The repeated terminal + observation changes no ledger state. +- C04: Alpaca's documented sub-penny rejection body is recognised as definitive + only with HTTP 422. See "C04 choice" below. + The harness's `FaultLedger` lets only the C04 client id past the pre-send + `invalid_price_increment` check, so Alpaca itself answers the fault. ## Run @@ -65,8 +174,8 @@ transport: | --- | --- | --- | | C01 accept_resting_buy | SPY qty-1 DAY limit at 50% of the streamed bid, rounded down to the cent | Broker accepts; ledger shows submitted, broker id recorded, open, zero filled | | C02 cancel_resting | Engine cancel | Ledger `canceled`, zero filled; a fresh broker snapshot shows `canceled` | -| C05 cancel_again | Engine cancel again | No exception, no new transport freeze reason, ledger and effect unchanged. Receipt records `delete_sent` and `broker_refusal_observed` | -| C04 definitive_rejection | SPY qty-1 limit at 40% of the bid plus $0.0001 (at least $1) | Every submit status is one of the engine's definitive refusals (401, 403, 404) and the ledger shows `broker_refused` with no position or cash effect. Any other status (400, 422, 429, 5xx) fails. If the Ledger refuses before send, the case is `unobserved` with reason `engine_refused_before_send: invalid_price_increment` | +| C05 cancel_again | Engine cancel of the already-canceled order | The DELETE is sent and answered 204, 404 or 422. No exception, no new transport freeze reason, ledger and effect unchanged. Receipt records `delete_sent`, `cancel_http_statuses` and `broker_refusal_observed` | +| C04 definitive_rejection | SPY qty-1 limit at 40% of the bid plus $0.0001 (at least $1), sent by the only client id `FaultLedger` exempts | Either the ledger records `broker_refused` for a 422 carrying the documented sub-penny body, with refusal `sub_penny_minimum_price_variance`, or every submit status is 401, 403 or 404. The client-id lookup returns 404, and there is no position or cash effect. Any other 400, 422, 429 or 5xx fails. If the engine still refuses before send, the case is `unobserved` with reason `engine_refused_before_send: ...` | The plan's `c04_sub_penny_increment` must be strictly between 0 and 0.01. @@ -108,8 +217,11 @@ Bounds: - A case is marked `native_paper` only when the transport's request observer recorded a broker HTTP response within that case. For C05 the response must - come from the DELETE itself. A C05 whose only request was the client-id GET is - marked `engine_short_circuit` with `delete_sent: false`. + come from the DELETE itself. A C05 with no DELETE (the engine as run on + 2026-09-23) is marked `engine_short_circuit` with `delete_sent: false`. +- For C04 the receipt records `ledger_refusal`, the ledger's own durable + `broker_refused` event (HTTP status and refusal). It also records + `c04_pre_send_exemption`, the one client id that `FaultLedger` exempts. - Unobserved and not-run cases are marked `none`. - `status` becomes `native_faults_passed` only when all four cases pass with `native_paper` evidence and cleanup proves flat. @@ -119,17 +231,101 @@ Bounds: - The price reference is the engine's streamed quote bid. The engine transport has no last-trade read. -## Expected outcome from source reading (confirmed by the 2026-09-23 run) - -- **C04 will be `unobserved`.** `Ledger.reserve_intent` refuses any price of - at least $1 that is not on a whole cent (`invalid_price_increment`), so no - POST is sent. The offline suite shows this path with the real Ledger. If the - request were sent, the transport maps Alpaca's 422 to `AmbiguousSubmission`, - and `Ledger.mark_broker_refused` accepts only 401, 403 and 404. So this - engine cannot record a 422 as a definitive refusal. -- **C05 will not provoke Alpaca's 422.** `AlpacaPaperTransport.cancel` looks - the order up by client id and returns it without a DELETE once it is - terminal. The receipt records this as evidence class `engine_short_circuit`, - `delete_sent: false` and `broker_refusal_observed: false`. -- For these reasons one run of this harness cannot flip the gate on its own. A - `native_faults_incomplete` receipt is the honest expected result. +## C04 choice: the documented sub-penny rejection + +The alternative was a fault Alpaca answers with 401, 403 or 404, keeping the +pre-send check for every client id. Within this plan's bounds no such fault is +reachable: + +- The create-order reference + (https://docs.alpaca.markets/us/reference/postorder.md) documents only two + refusals: 403 "Buying power or shares is not sufficient." and 422 "Input + parameters are not recognized." +- A 403 needs a buy larger than buying power, or a sell of unheld shares. The + Ledger refuses both before send: a $1,000 order cap on a qty-1 buy, and + `sell_exceeds_owned_unreserved_position`. +- A 401 needs other credentials, so a second client, which is out of bounds. +- No 404 is documented for this endpoint. +- A non-existent or non-tradable symbol has no streamed quote, so the engine + refuses it before send (`no_current_quote`, quote-freshness wire guard). Its + status is also undocumented and would be a 422 at best. + +So C04 takes the reclassification the gate text allows: the engine counts a +broker 422 on an invalid price as a definitive refusal. This applies only where +the documentation proves it definitive. The orders guide +(https://docs.alpaca.markets/us/docs/orders-at-alpaca.md, "Sub-penny +increments") says limit prices at or above $1.00 take at most two decimals, and +four below. It says "Orders received in excess of the minimum price variance +will be rejected", and gives the body `{"code": 42210000, "message": "invalid +limit_price 290.123. sub-penny increment does not fulfill minimum pricing +criteria"}`. + +That page documents the body and the rejection, not the HTTP status. Before +the 2026-09-24 run, the 422 was an inference: the code's 422 prefix and the create-order reference's only +input-refusal status, 422 "Input parameters are not recognized." +(https://docs.alpaca.markets/us/reference/postorder.md). The 2026-09-24 run +observed it once on the paper endpoint (see above). The engine fails closed if it is wrong: `documented_refusal` +requires status 422, and any other status with this body stays ambiguous. +The code 42210000 alone is not treated as sufficient, since it may be shared +by other 422 refusals (the offline fixtures use it for several). The message +match is the discriminator, and the transport requires both. + +422 is not definitive in general. Alpaca also returns it for "client_order_id +must be unique" +(https://alpaca.markets/learn/how-to-fix-common-trading-api-errors-at-alpaca), +which proves that an order exists. The transport therefore requires all of the +following before it raises `RejectedSubmission(422, +"sub_penny_minimum_price_variance")`: + +- the documented code and, as the discriminator, the documented message; +- a limit price that really violates the documented increment; +- the first POST of this client id (SDK retries are disabled); +- a client-id lookup that returns 404. + +`Ledger.mark_broker_refused` then accepts 422 only with that refusal, and only +for an intent whose durable price violates the increment. Timeout, 400, 422, +429 and 5xx otherwise stay ambiguous, as README-safety.md states. + +The pre-send check stays for the engine. The only exemption is the harness's +`FaultLedger`, for its single `c04` client id. No engine path can send a +sub-penny price. + +## What a qualifying native run must show + +This list was written before the 2026-09-24 run and is kept unchanged. That +run's receipt meets every item it can record. It does not record the exit +code, which follows from `native_faults_passed` by harness design. C05's DELETE +answer was 204 rather than the expected 422, and 204 is one of the accepted +answers. Meeting the list makes the receipt +a flip candidate. The dated flip commit still needs the manual qualification +described under "Gate status" above. + +A receipt can flip the gate only when it shows all of the following, bound to +this tree's plan, harness and engine hashes: + +- Status `native_faults_passed`, exit 0, `posts_reserved` 2 of 4, one transport + build and no stop error. +- C01 and C02 as before: submit 200 and an open status, then cancel 204 and + `canceled` in the ledger and in a fresh broker snapshot. +- C05 with evidence `native_paper`, `delete_sent: true` and requests `cancel` + then `read`. The DELETE status is expected to be 422, since Alpaca's DELETE + reference documents + "The order status is not cancelable"; 204 or 404 is also accepted as data. + `new_transport_freeze_reasons` must be empty and `ledger_before` must equal + `ledger_after`, both `canceled` with zero filled. +- C04 with evidence `native_paper` and requests `submit` 422 then `read` 404. + This observation is what confirms the inferred HTTP 422 for the documented + sub-penny body. + The ledger shows `broker_refused` with `ledger_refusal` `{"http_status": 422, + "refusal": "sub_penny_minimum_price_variance"}`, and `effect_before` equals + `effect_after`. +- If Alpaca answers the sub-penny POST with another code or message, C04 fails + as ambiguous and the run ends `cleanup_required`. It never passes. The harness + cannot prove an ambiguous submission flat, so its client id must then be + resolved by hand. An order accepted despite the sub-penny price (submit 200) + also fails C04; cleanup then cancels it. +- Cleanup proves flat with `runner.reconcile` and zero open orders, and the + `IN_FLIGHT` marker is removed. + +A run that shows anything else stays partial or failed evidence, and the gate +stays `not_established`. diff --git a/blueprints/us-equities/adaptive-paper/native-faults/evidence/independent-observation-20260924.json b/blueprints/us-equities/adaptive-paper/native-faults/evidence/independent-observation-20260924.json new file mode 100644 index 000000000..b4d78e678 --- /dev/null +++ b/blueprints/us-equities/adaptive-paper/native-faults/evidence/independent-observation-20260924.json @@ -0,0 +1,66 @@ +{ + "schema_version": 1, + "kind": "independent_observation", + "evidence_class": "independent_observation", + "observed_on": "2026-09-24", + "observed_at": "2026-09-24T15:01:47Z", + "observer": "workflow coordinator session (not the harness process)", + "method": "alpaca-py SDK order and position listing against the paper account, run separately from the harness code path (not native-faults/harness.py and not the adaptive-paper engine transport): TradingClient.get_orders(status=all, after=receipt.started_at) filtered by the receipt's client_id_prefix; get_order_by_client_id for the C01 and C04 client ids; get_all_positions; open orders", + "subject_receipt": "blueprints/us-equities/adaptive-paper/native-faults/receipt.json", + "subject_gate": "native-fault-behaviour", + "client_order_id_prefix": "nf-20260924t143905-29d7ec-", + "listing_since": "2026-09-24T14:39:05.193024+00:00 (the receipt's started_at)", + "observed": { + "orders_listed_since_start": 1, + "orders_with_prefix": 1, + "by_suffix": { + "c01": { + "status": "canceled", + "filled_qty": "0" + } + }, + "lookup_c01": { + "found": true, + "status": "canceled", + "filled_qty": "0" + }, + "lookup_c04": { + "found": false, + "http_status": 404, + "meaning": "no broker order exists for client id nf-20260924t143905-29d7ec-c04" + }, + "open_orders": 0, + "positions": 0 + }, + "compared_with_receipt": { + "receipt_status": "native_faults_passed", + "receipt_submit_responses": 2, + "receipt_submits_accepted": 1, + "receipt_submits_refused_422": 1, + "receipt_c01_final_status": "canceled (C02 and C05 ledger, zero filled)", + "receipt_c04_ledger": "broker_refused {http_status 422, refusal sub_penny_minimum_price_variance}, broker_id_recorded false, client-id read 404", + "receipt_cleanup": "0 open orders, 0 positions, cash delta 0.00", + "expected_broker_orders_with_prefix": 1, + "observed_broker_orders_with_prefix": 1, + "c01_matches": true, + "c04_absent_matches": true, + "flat_matches": true, + "counts_match": true + }, + "claim_boundary": "Confirms only the listed broker-side facts: one order with this prefix since started_at (c01), canceled with filled quantity 0; no broker order for the c04 client id (lookup 404); and no open orders or positions at observation time. It uses the same broker API and account, so it is independent of the harness and engine code, not of the broker. It does not observe HTTP statuses of the run's own requests (C04's 422 body, C05's 204 DELETE answer), request ordering, ledger contents or the engine's handling of a 422 cancel refusal, which C05 did not receive; it is not live-endpoint evidence and not a strategy result.", + "retained": { + "script": { + "path": "observe-native-faults-20260924.py", + "sha256": "6f4b1c192245c4df859d08c4b5b15196c54bd1b0533206a3969c7f6f8a5fe64c" + }, + "stdout": { + "path": "observe-native-faults-20260924.stdout.json", + "sha256": "420d762c27f64204bd5eb65ddecaf31443cfc7da2ecfd90101f13376c691ccd8" + }, + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 (empty)", + "argv_redacted": " observe-native-faults-20260924.py --env-file /alpaca-paper.env --receipt ", + "exit_code": 0, + "runtime": "alpaca-py 0.44.0, Python 3.12.3 (the versions of the pinned adaptive-paper runtime)", + "note": "Script and stdout are byte-identical copies of the coordinator's private files. The script reads the credential file without printing it; the retained files contain no credential, account id or host path. The stdout's observed_at is local time (2026-09-24T11:01:47-04:00 = 15:01:47Z)." + } +} diff --git a/blueprints/us-equities/adaptive-paper/native-faults/evidence/observe-native-faults-20260924.py b/blueprints/us-equities/adaptive-paper/native-faults/evidence/observe-native-faults-20260924.py new file mode 100644 index 000000000..4174d7b10 --- /dev/null +++ b/blueprints/us-equities/adaptive-paper/native-faults/evidence/observe-native-faults-20260924.py @@ -0,0 +1,29 @@ +"""Independent observation of a native-fault run via alpaca-py (separate from harness.py and the engine transport). +Reads credentials from --env-file without printing them; prints only statuses and counts.""" +import argparse, collections, json, sys +from datetime import datetime +import alpaca +from alpaca.common.exceptions import APIError +from alpaca.trading.client import TradingClient +from alpaca.trading.requests import GetOrdersRequest +from alpaca.trading.enums import QueryOrderStatus +ap = argparse.ArgumentParser(); ap.add_argument('--env-file', required=True); ap.add_argument('--receipt', required=True) +a = ap.parse_args() +env = dict(l.strip().split('=', 1) for l in open(a.env_file) if '=' in l) +rc = json.load(open(a.receipt)); prefix = rc['client_id_prefix']; start = datetime.fromisoformat(rc['started_at']) +c = TradingClient(env['APCA_API_KEY_ID'], env['APCA_API_SECRET_KEY'], paper=True) +orders = c.get_orders(GetOrdersRequest(status=QueryOrderStatus.ALL, after=start, limit=500, direction='asc')) +mine = [o for o in orders if str(o.client_order_id).startswith(prefix)] +by_id = {str(o.client_order_id)[len(prefix):]: {"status": str(o.status.value), "filled_qty": str(o.filled_qty)} for o in mine} +def lookup(cid): + try: + o = c.get_order_by_client_id(cid); return {"found": True, "status": str(o.status.value), "filled_qty": str(o.filled_qty)} + except APIError as e: + return {"found": False, "http_status": getattr(e, "status_code", None)} +out = {"method": "alpaca-py TradingClient.get_orders(status=all, after=receipt.started_at) filtered by the receipt's client_id_prefix; get_order_by_client_id for c01 and c04; get_all_positions; open orders", + "alpaca_py_version": alpaca.__version__, "python": sys.version.split()[0], "observed_at": datetime.now().astimezone().isoformat(), + "receipt_started_at": rc['started_at'], "orders_listed_since_start": len(orders), "orders_with_prefix": len(mine), + "by_suffix": by_id, "lookup_c01": lookup(prefix + "c01"), "lookup_c04": lookup(prefix + "c04"), + "status_counts": dict(collections.Counter(v["status"] for v in by_id.values())), + "positions_now": len(c.get_all_positions()), "open_orders_now": len(c.get_orders(GetOrdersRequest(status=QueryOrderStatus.OPEN)))} +print(json.dumps(out, indent=1)) diff --git a/blueprints/us-equities/adaptive-paper/native-faults/evidence/observe-native-faults-20260924.stdout.json b/blueprints/us-equities/adaptive-paper/native-faults/evidence/observe-native-faults-20260924.stdout.json new file mode 100644 index 000000000..195ad694d --- /dev/null +++ b/blueprints/us-equities/adaptive-paper/native-faults/evidence/observe-native-faults-20260924.stdout.json @@ -0,0 +1,29 @@ +{ + "method": "alpaca-py TradingClient.get_orders(status=all, after=receipt.started_at) filtered by the receipt's client_id_prefix; get_order_by_client_id for c01 and c04; get_all_positions; open orders", + "alpaca_py_version": "0.44.0", + "python": "3.12.3", + "observed_at": "2026-09-24T11:01:47.204984-04:00", + "receipt_started_at": "2026-09-24T14:39:05.193024+00:00", + "orders_listed_since_start": 1, + "orders_with_prefix": 1, + "by_suffix": { + "c01": { + "status": "canceled", + "filled_qty": "0" + } + }, + "lookup_c01": { + "found": true, + "status": "canceled", + "filled_qty": "0" + }, + "lookup_c04": { + "found": false, + "http_status": 404 + }, + "status_counts": { + "canceled": 1 + }, + "positions_now": 0, + "open_orders_now": 0 +} diff --git a/blueprints/us-equities/adaptive-paper/native-faults/harness.py b/blueprints/us-equities/adaptive-paper/native-faults/harness.py index 10937294d..ff75f5f2b 100644 --- a/blueprints/us-equities/adaptive-paper/native-faults/harness.py +++ b/blueprints/us-equities/adaptive-paper/native-faults/harness.py @@ -10,6 +10,9 @@ A write-ahead IN_FLIGHT marker (run id and client-id prefix) is written in the run directory before the first POST and removed only after cleanup proves flat; SIGKILL or a crash leaves it for manual recovery. +The ledger is FaultLedger: the engine Ledger except that the single C04 client id +may carry a sub-penny price to Alpaca, whose documented 422 refusal the engine +records as broker_refused (see README.md, "C04 choice"). Usage: harness.py run --env-file PATH --state-root PATH --out PATH """ @@ -35,7 +38,9 @@ sys.path.insert(0, str(ENGINE)) from runner import Controller, credentials, reconcile, save # noqa: E402 -from safety import DEFAULT_STOP, TERMINAL as LEDGER_TERMINAL, Ledger, SafetyError, account_lock_fingerprint # noqa: E402 +from safety import (DEFAULT_STOP, DEFINITIVE_REFUSAL_STATUSES, SUB_PENNY_REFUSAL, # noqa: E402 + TERMINAL as LEDGER_TERMINAL, Ledger, SafetyError, account_lock_fingerprint, + price_increment_valid) from transport import TERMINAL, AlpacaPaperTransport, TransportError, timestamp_ns # noqa: E402 PLAN_PATH = HERE / "plan.json" @@ -45,7 +50,8 @@ CASE_IDS = ["C01", "C02", "C05", "C04"] OPEN = {"pending_new", "accepted", "new", "accepted_for_bidding"} CENT = Decimal("0.01") -REFUSAL_STATUSES = (401, 403, 404) # safety.Ledger.mark_broker_refused's definitive set. +REFUSAL_STATUSES = DEFINITIVE_REFUSAL_STATUSES # plus the documented sub-penny 422, judged from the ledger. +CANCEL_ANSWERS = (204, 404, 422) # DELETE answers the engine treats as data. EXIT = {"native_faults_passed": 0, "not_started": 2, "refused": 2, "cleanup_required": 3} @@ -53,6 +59,22 @@ class Refused(RuntimeError): """Pre-write refusal; no order has been submitted.""" +class FaultLedger(Ledger): + """The engine Ledger with one harness-only change: the single C04 client id + may carry a price outside the minimum price variance to the broker, so that + Alpaca itself answers the fault. Every other client id, including every + engine path, keeps the pre-send ``invalid_price_increment`` refusal.""" + + def __init__(self, db_path, *, sub_penny_client_id): + super().__init__(db_path) + self.sub_penny_client_id = sub_penny_client_id + + def _check_price_increment(self, client_id, price): + if client_id == self.sub_penny_client_id and not price_increment_valid(price): + return + super()._check_price_increment(client_id, price) + + def sha256(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest() @@ -98,6 +120,16 @@ def ledger_effect(ledger): "cash_delta_usd": str(ledger.accounting().cash_delta_usd)} +def refusal_record(ledger, cid): + """The ledger's own durable broker_refused event for ``cid`` (status and refusal).""" + row = ledger.db.execute("SELECT payload FROM events WHERE kind='broker_refused' AND client_id=? " + "ORDER BY rowid DESC LIMIT 1", (cid,)).fetchone() + if row is None: + return None + payload = json.loads(row[0]) + return {"http_status": payload.get("http_status"), "refusal": payload.get("refusal")} + + def unfilled(state): return state is not None and Decimal(state["filled_qty"]) == 0 @@ -117,24 +149,32 @@ def judge_c02(state, broker_status): def judge_c05(before, after, error, new_reasons, effect_before, effect_after, requests): cancels = [r for r in requests if r["kind"] == "cancel"] detail = {"delete_sent": bool(cancels), "cancel_http_statuses": [r["status"] for r in cancels], - "broker_refusal_observed": any(r["status"] == 422 for r in cancels), + "broker_refusal_observed": any(r["status"] in (404, 422) for r in cancels), "new_transport_freeze_reasons": sorted(new_reasons), "error": error} if not cancels: - detail["engine_path"] = "transport.cancel found the order terminal by client-id lookup; no DELETE sent" + detail["engine_path"] = "no DELETE reached the broker" ok = (error is None and not new_reasons and before == after and effect_before == effect_after - and after is not None and after["status"] == "canceled" and unfilled(after)) + and after is not None and after["status"] == "canceled" and unfilled(after) + and all(r["status"] in CANCEL_ANSWERS for r in cancels)) return ("passed" if ok else "failed"), detail -def judge_c04(requests, state, error, effect_before, effect_after): +def judge_c04(requests, state, error, effect_before, effect_after, refusal=None): + """Passed only when the ledger records broker_refused with no effect and the broker's + answer is a definitive refusal: 401/403/404, or the single documented sub-penny 422 + that the ledger recorded with refusal SUB_PENNY_REFUSAL (any other 400/422/429/5xx + stays ambiguous and fails).""" submits = [r for r in requests if r["kind"] == "submit"] - detail = {"submit_http_statuses": [r["status"] for r in submits], "error": error} + statuses = [r["status"] for r in submits] + detail = {"submit_http_statuses": statuses, "error": error, "ledger_refusal": refusal} if not submits: reason = (error or {}).get("reason") or (error or {}).get("type") or "no_submit_request" detail["unobserved_reason"] = "engine_refused_before_send: " + reason return "unobserved", detail + definitive = (all(s in REFUSAL_STATUSES for s in statuses) + or (statuses == [422] and refusal == {"http_status": 422, "refusal": SUB_PENNY_REFUSAL})) ok = (state is not None and state["status"] == "broker_refused" and unfilled(state) - and effect_before == effect_after and all(r["status"] in REFUSAL_STATUSES for r in submits)) + and effect_before == effect_after and definitive) return ("passed" if ok else "failed"), detail @@ -144,7 +184,7 @@ def __init__(self, plan, plan_sha, run_dir, prefix): raise ValueError("client_id_prefix_required") self.plan, self.plan_sha, self.run_dir, self.prefix = plan, plan_sha, run_dir, prefix self.symbol = plan["symbol"] - self.ledger = Ledger(run_dir / "ledger.sqlite3") + self.ledger = FaultLedger(run_dir / "ledger.sqlite3", sub_penny_client_id=prefix + "c04") # Fail-closed until the broker clock is read on the started transport. self.controller = Controller(self.ledger, 0.0, market_open=False) self.responses, self.cases = [], [] @@ -292,7 +332,8 @@ async def c04(self, port): except Exception as exc: error = describe(exc) state = intent_state(self.ledger, cid) - outcome, detail = judge_c04(self.responses[start:], state, error, effect_before, ledger_effect(self.ledger)) + outcome, detail = judge_c04(self.responses[start:], state, error, effect_before, ledger_effect(self.ledger), + refusal_record(self.ledger, cid)) detail.update(client_order_id=cid, limit_price=str(price), reference_bid=str(bid), ledger=state, effect_before=effect_before, effect_after=ledger_effect(self.ledger)) return outcome, detail @@ -435,6 +476,8 @@ def receipt(self, status, *, error=None, cleanup=None, stop_error=None): "submit_responses": sum(r["kind"] == "submit" for r in self.responses), "interrupted": self.interrupted, "error": error, "stop_error": stop_error, "cases": cases, "cleanup": cleanup, + "c04_pre_send_exemption": ("FaultLedger exempts only client id %sc04 from invalid_price_increment" + % self.prefix), "in_flight_marker_present": self.in_flight.exists(), "evidence_note": ("native_paper marks a case only when the engine transport's request observer " "recorded a broker HTTP response inside that case and, for C05, only when " diff --git a/blueprints/us-equities/adaptive-paper/native-faults/plan.json b/blueprints/us-equities/adaptive-paper/native-faults/plan.json index e97c4acae..6e90c8645 100644 --- a/blueprints/us-equities/adaptive-paper/native-faults/plan.json +++ b/blueprints/us-equities/adaptive-paper/native-faults/plan.json @@ -21,9 +21,9 @@ {"id": "C02", "name": "cancel_resting", "pass": "engine ledger records canceled with zero fill and a fresh broker snapshot shows the order canceled"}, {"id": "C05", "name": "cancel_again", - "pass": "second engine cancel raises nothing, adds no transport freeze reason and leaves the ledger intent, fills and positions unchanged; whether a DELETE was sent and refused is recorded, not assumed; without a DELETE the evidence class is engine_short_circuit"}, + "pass": "second engine cancel of the already-canceled order sends the broker DELETE, raises nothing, adds no transport freeze reason, gets an answer the engine treats as data (204, 404 or 422; Alpaca documents 422 'The order status is not cancelable' at https://docs.alpaca.markets/us/reference/deleteorderbyorderid-1.md) and leaves the ledger intent, fills and positions unchanged; the answer is recorded, not assumed; without a DELETE the evidence class is engine_short_circuit"}, {"id": "C04", "name": "definitive_rejection", - "pass": "a sub-penny limit reaches the broker, is refused with a definitive 401/403/404, and the engine ledger records broker_refused with no position or cash effect; unobserved when the engine refuses or rounds before any request reaches the broker"} + "pass": "a sub-penny limit reaches the broker (the harness FaultLedger exempts only this client id from the pre-send invalid_price_increment refusal), Alpaca refuses it with its documented minimum-price-variance body (code 42210000 and, as the discriminator, message 'sub-penny increment does not fulfill minimum pricing criteria', https://docs.alpaca.markets/us/docs/orders-at-alpaca.md, which documents the body but not the HTTP status; HTTP 422 is inferred from the code prefix and the POST /v2/orders 422 entry at https://docs.alpaca.markets/us/reference/postorder.md and must be confirmed by this run) or a definitive 401/403/404, the client-id lookup returns 404, and the engine ledger records broker_refused (for 422 with refusal sub_penny_minimum_price_variance) with no position or cash effect; any other 400/422/429/5xx stays ambiguous and fails; unobserved when the engine refuses or rounds before any request reaches the broker"} ], "stop_rule": "stop after the first failed or errored case; never retry a write", "cleanup": "cancel every non-terminal ledger intent carrying this run's client-id prefix on the same started transport, then prove flat and zero open orders with runner.reconcile over a fresh transport snapshot; otherwise write CLEANUP_REQUIRED", diff --git a/blueprints/us-equities/adaptive-paper/native-faults/receipt-20260923.json b/blueprints/us-equities/adaptive-paper/native-faults/receipt-20260923.json new file mode 100644 index 000000000..29b558f00 --- /dev/null +++ b/blueprints/us-equities/adaptive-paper/native-faults/receipt-20260923.json @@ -0,0 +1,176 @@ +{ + "broker": "alpaca", + "cases": [ + { + "detail": { + "broker_status": "pending_new", + "client_order_id": "nf-20260923t142024-4b0bc9-c01", + "ledger": { + "broker_id_recorded": true, + "filled_qty": "0", + "status": "pending_new", + "submit_attempted": true + }, + "limit_price": "384.58", + "reference_bid": "769.16" + }, + "evidence_class": "native_paper", + "id": "C01", + "name": "accept_resting_buy", + "outcome": "passed", + "requests": [ + { + "kind": "submit", + "status": 200 + } + ] + }, + { + "detail": { + "broker_snapshot_status": "canceled", + "cancel_result_status": "canceled", + "client_order_id": "nf-20260923t142024-4b0bc9-c01", + "ledger": { + "broker_id_recorded": true, + "filled_qty": "0", + "status": "canceled", + "submit_attempted": true + } + }, + "evidence_class": "native_paper", + "id": "C02", + "name": "cancel_resting", + "outcome": "passed", + "requests": [ + { + "kind": "read", + "status": 200 + }, + { + "kind": "cancel", + "status": 204 + }, + { + "kind": "read", + "status": 200 + }, + { + "kind": "read", + "status": 200 + }, + { + "kind": "read", + "status": 200 + }, + { + "kind": "read", + "status": 200 + }, + { + "kind": "read", + "status": 200 + } + ] + }, + { + "detail": { + "broker_refusal_observed": false, + "cancel_http_statuses": [], + "cancel_result_status": "canceled", + "client_order_id": "nf-20260923t142024-4b0bc9-c01", + "delete_sent": false, + "engine_path": "transport.cancel found the order terminal by client-id lookup; no DELETE sent", + "error": null, + "ledger_after": { + "broker_id_recorded": true, + "filled_qty": "0", + "status": "canceled", + "submit_attempted": true + }, + "ledger_before": { + "broker_id_recorded": true, + "filled_qty": "0", + "status": "canceled", + "submit_attempted": true + }, + "new_transport_freeze_reasons": [] + }, + "evidence_class": "engine_short_circuit", + "id": "C05", + "name": "cancel_again", + "outcome": "passed", + "requests": [ + { + "kind": "read", + "status": 200 + } + ] + }, + { + "detail": { + "client_order_id": "nf-20260923t142024-4b0bc9-c04", + "effect_after": { + "cash_delta_usd": "0", + "positions": {} + }, + "effect_before": { + "cash_delta_usd": "0", + "positions": {} + }, + "error": { + "reason": "invalid_price_increment", + "type": "NativeOrderRejected" + }, + "ledger": null, + "limit_price": "307.6601", + "reference_bid": "769.16", + "submit_http_statuses": [], + "unobserved_reason": "engine_refused_before_send: invalid_price_increment" + }, + "evidence_class": "none", + "id": "C04", + "name": "definitive_rejection", + "outcome": "unobserved", + "requests": [] + } + ], + "cleanup": { + "broker_open_orders": 0, + "cancels": [], + "client_id_prefix": "nf-20260923t142024-4b0bc9-", + "flat": true, + "proof": "runner.reconcile over transport.snapshot", + "reconcile": { + "cash_delta_usd": "0.00", + "cash_match": true, + "open_orders": 0, + "positions": 0, + "positions_match": true + } + }, + "client_id_prefix": "nf-20260923t142024-4b0bc9-", + "endpoint": "paper", + "engine_sources_sha256": { + "runner.py": "549a326fec0f7b88fac793b3c8f74b7acdba87fccb585370dd0cfc020d46617f", + "safety.py": "8cc67464ab55794b232cedb9988c264b86c8ec11ebe1734a94fb71480a269fa0", + "transport.py": "c75f7db3025200d55a166b8ec2811939d32518861118490ffc1a57eb7ba389fa" + }, + "error": null, + "evidence_note": "native_paper marks a case only when the engine transport's request observer recorded a broker HTTP response inside that case and, for C05, only when the DELETE itself was sent; C05 without a DELETE is engine_short_circuit. unobserved and not_run cases made no qualifying broker request. Price reference is the engine's streamed quote bid, not a last-trade read. No credential, account id or account fingerprint is recorded.", + "finished_at": "2026-09-23T14:20:26.205394+00:00", + "gate": "native-fault-behaviour", + "harness_sha256": "742fb6664ec9d6789e4ab2e4a038d9b74678e1d23a931111af0e8a223b22013d", + "in_flight_marker_present": false, + "interrupted": null, + "kind": "native_fault_behaviour_receipt", + "max_posts": 4, + "plan_sha256": "2ce6f6be140826a107826e35e6aaef9f31c55fcf82c98e25ce7ffd1b894e5a84", + "posts_reserved": 1, + "schema_version": 1, + "started_at": "2026-09-23T14:20:24.964109+00:00", + "status": "native_faults_incomplete", + "stop_error": null, + "submit_responses": 1, + "symbol": "SPY", + "transport_builds": 1 +} diff --git a/blueprints/us-equities/adaptive-paper/native-faults/receipt.json b/blueprints/us-equities/adaptive-paper/native-faults/receipt.json index 29b558f00..eff04d889 100644 --- a/blueprints/us-equities/adaptive-paper/native-faults/receipt.json +++ b/blueprints/us-equities/adaptive-paper/native-faults/receipt.json @@ -1,18 +1,19 @@ { "broker": "alpaca", + "c04_pre_send_exemption": "FaultLedger exempts only client id nf-20260924t143905-29d7ec-c04 from invalid_price_increment", "cases": [ { "detail": { "broker_status": "pending_new", - "client_order_id": "nf-20260923t142024-4b0bc9-c01", + "client_order_id": "nf-20260924t143905-29d7ec-c01", "ledger": { "broker_id_recorded": true, "filled_qty": "0", "status": "pending_new", "submit_attempted": true }, - "limit_price": "384.58", - "reference_bid": "769.16" + "limit_price": "382.96", + "reference_bid": "765.92" }, "evidence_class": "native_paper", "id": "C01", @@ -29,7 +30,7 @@ "detail": { "broker_snapshot_status": "canceled", "cancel_result_status": "canceled", - "client_order_id": "nf-20260923t142024-4b0bc9-c01", + "client_order_id": "nf-20260924t143905-29d7ec-c01", "ledger": { "broker_id_recorded": true, "filled_qty": "0", @@ -42,10 +43,6 @@ "name": "cancel_resting", "outcome": "passed", "requests": [ - { - "kind": "read", - "status": 200 - }, { "kind": "cancel", "status": 204 @@ -75,11 +72,12 @@ { "detail": { "broker_refusal_observed": false, - "cancel_http_statuses": [], + "cancel_http_statuses": [ + 204 + ], "cancel_result_status": "canceled", - "client_order_id": "nf-20260923t142024-4b0bc9-c01", - "delete_sent": false, - "engine_path": "transport.cancel found the order terminal by client-id lookup; no DELETE sent", + "client_order_id": "nf-20260924t143905-29d7ec-c01", + "delete_sent": true, "error": null, "ledger_after": { "broker_id_recorded": true, @@ -95,11 +93,15 @@ }, "new_transport_freeze_reasons": [] }, - "evidence_class": "engine_short_circuit", + "evidence_class": "native_paper", "id": "C05", "name": "cancel_again", "outcome": "passed", "requests": [ + { + "kind": "cancel", + "status": 204 + }, { "kind": "read", "status": 200 @@ -108,7 +110,7 @@ }, { "detail": { - "client_order_id": "nf-20260923t142024-4b0bc9-c04", + "client_order_id": "nf-20260924t143905-29d7ec-c04", "effect_after": { "cash_delta_usd": "0", "positions": {} @@ -118,26 +120,45 @@ "positions": {} }, "error": { - "reason": "invalid_price_increment", - "type": "NativeOrderRejected" + "reason": "broker definitively rejected submission (HTTP 422: sub_penny_minimum_price_variance)", + "type": "RejectedSubmission" + }, + "ledger": { + "broker_id_recorded": false, + "filled_qty": "0", + "status": "broker_refused", + "submit_attempted": true + }, + "ledger_refusal": { + "http_status": 422, + "refusal": "sub_penny_minimum_price_variance" }, - "ledger": null, - "limit_price": "307.6601", - "reference_bid": "769.16", - "submit_http_statuses": [], - "unobserved_reason": "engine_refused_before_send: invalid_price_increment" + "limit_price": "306.3601", + "reference_bid": "765.92", + "submit_http_statuses": [ + 422 + ] }, - "evidence_class": "none", + "evidence_class": "native_paper", "id": "C04", "name": "definitive_rejection", - "outcome": "unobserved", - "requests": [] + "outcome": "passed", + "requests": [ + { + "kind": "submit", + "status": 422 + }, + { + "kind": "read", + "status": 404 + } + ] } ], "cleanup": { "broker_open_orders": 0, "cancels": [], - "client_id_prefix": "nf-20260923t142024-4b0bc9-", + "client_id_prefix": "nf-20260924t143905-29d7ec-", "flat": true, "proof": "runner.reconcile over transport.snapshot", "reconcile": { @@ -148,29 +169,29 @@ "positions_match": true } }, - "client_id_prefix": "nf-20260923t142024-4b0bc9-", + "client_id_prefix": "nf-20260924t143905-29d7ec-", "endpoint": "paper", "engine_sources_sha256": { - "runner.py": "549a326fec0f7b88fac793b3c8f74b7acdba87fccb585370dd0cfc020d46617f", - "safety.py": "8cc67464ab55794b232cedb9988c264b86c8ec11ebe1734a94fb71480a269fa0", - "transport.py": "c75f7db3025200d55a166b8ec2811939d32518861118490ffc1a57eb7ba389fa" + "runner.py": "a61012948d92eddef959ba91c5fdadc24170944239c02c20190e68c52d717245", + "safety.py": "ad520fc4d84ce10e4d7b5cc956ba769a4f94eef49464ce382746d57f0cd76420", + "transport.py": "b92e87524ccffd168b67b4f80418653e5309250fa23968af0988e2f016e0ed1e" }, "error": null, "evidence_note": "native_paper marks a case only when the engine transport's request observer recorded a broker HTTP response inside that case and, for C05, only when the DELETE itself was sent; C05 without a DELETE is engine_short_circuit. unobserved and not_run cases made no qualifying broker request. Price reference is the engine's streamed quote bid, not a last-trade read. No credential, account id or account fingerprint is recorded.", - "finished_at": "2026-09-23T14:20:26.205394+00:00", + "finished_at": "2026-09-24T14:39:06.592395+00:00", "gate": "native-fault-behaviour", - "harness_sha256": "742fb6664ec9d6789e4ab2e4a038d9b74678e1d23a931111af0e8a223b22013d", + "harness_sha256": "3f01fb317cedeaf75e36b5d4a72738cd3fc8a6cbdc01c9590646b10fa3996d52", "in_flight_marker_present": false, "interrupted": null, "kind": "native_fault_behaviour_receipt", "max_posts": 4, - "plan_sha256": "2ce6f6be140826a107826e35e6aaef9f31c55fcf82c98e25ce7ffd1b894e5a84", - "posts_reserved": 1, + "plan_sha256": "f276b26c16256b91de3a4f6fdf566b28a14a255f1210baa478537990dada6818", + "posts_reserved": 2, "schema_version": 1, - "started_at": "2026-09-23T14:20:24.964109+00:00", - "status": "native_faults_incomplete", + "started_at": "2026-09-24T14:39:05.193024+00:00", + "status": "native_faults_passed", "stop_error": null, - "submit_responses": 1, + "submit_responses": 2, "symbol": "SPY", "transport_builds": 1 } diff --git a/blueprints/us-equities/adaptive-paper/runner.py b/blueprints/us-equities/adaptive-paper/runner.py index 55f13e9f8..911bfbe80 100644 --- a/blueprints/us-equities/adaptive-paper/runner.py +++ b/blueprints/us-equities/adaptive-paper/runner.py @@ -846,7 +846,8 @@ async def observed_submit(order): if getattr(exc, "not_sent", False) is True: self.ledger.mark_not_sent(intent.client_id, "transport_proven_not_sent") elif isinstance(exc, RejectedSubmission): - self.ledger.mark_broker_refused(intent.client_id, exc.status_code) + self.ledger.mark_broker_refused(intent.client_id, exc.status_code, + getattr(exc, "refusal", None)) self.stop = True raise port.submit = observed_submit diff --git a/blueprints/us-equities/adaptive-paper/safety.py b/blueprints/us-equities/adaptive-paper/safety.py index bfe51505b..2b82a0168 100644 --- a/blueprints/us-equities/adaptive-paper/safety.py +++ b/blueprints/us-equities/adaptive-paper/safety.py @@ -37,6 +37,24 @@ "pending_cancel": 4, "done_for_day": 4, "filled": 5, "canceled": 5, "expired": 5, "rejected": 5, "not_sent": 5, "broker_refused": 5} DEFAULT_STOP = Path.home() / ".local/state/native-agent-stack/alpaca-paper/STOP" +DEFINITIVE_REFUSAL_STATUSES = (401, 403, 404) +# The one 422 treated as a definitive refusal: Alpaca states that orders in excess +# of the minimum price variance "will be rejected" and documents the body code +# 42210000 with "sub-penny increment does not fulfill minimum pricing criteria". +# The page does not state the HTTP status; 422 is inferred from the code prefix and +# the POST /v2/orders 422 entry, pending native confirmation. The message, not the +# code, is the discriminator. +# Every other 422 (for example "client_order_id must be unique", which proves an +# order exists) stays ambiguous. +SUB_PENNY_REFUSAL = "sub_penny_minimum_price_variance" + + +def price_increment_valid(price): + """True when ``price`` (a Decimal) meets Alpaca's minimum price variance for limit + prices: two decimals at or above $1.00, four below + (https://docs.alpaca.markets/us/docs/orders-at-alpaca.md, "Sub-penny increments"). + reserve_intent refuses any other price before send.""" + return price == price.quantize(D("0.01") if price >= 1 else D("0.0001")) class SafetyError(RuntimeError): @@ -711,6 +729,13 @@ def _refresh_risk(self, now=None): self._event("risk_halt", reason=reason) return self._state() + def _check_price_increment(self, client_id, price): + """Pre-send refusal of a price outside the documented minimum price variance. + A method so that the native-fault harness alone can exempt its single C04 + client id (native-faults/harness.py FaultLedger); the engine never does.""" + if not price_increment_valid(price): + raise SafetyError("invalid_price_increment") + def reserve_intent(self, client_id, symbol, side, qty, limit_price, *, quote, now, market_open, session_close, stop_file=None): if type(client_id) is not str or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,47}", client_id): @@ -719,8 +744,7 @@ def reserve_intent(self, client_id, symbol, side, qty, limit_price, *, quote, if side not in ("buy", "sell"): raise SafetyError("invalid_order_side") qty, price, now, close = decimal(qty), decimal(limit_price), instant(now), instant(session_close) - if price != price.quantize(D("0.01") if price >= 1 else D("0.0001")): - raise SafetyError("invalid_price_increment") + self._check_price_increment(client_id, price) with self._transaction(keep_observations_on_refusal=True): previous = self.db.execute("SELECT * FROM intents WHERE client_id=?", (client_id,)).fetchone() if previous: @@ -868,14 +892,22 @@ def mark_not_sent(self, client_id, reason): self._event("intent_not_sent", client_id, reason=reason) return True - def mark_broker_refused(self, client_id, http_status): + def mark_broker_refused(self, client_id, http_status, refusal=None): """Retire a proven HTTP refusal after transport's client-ID absence check. Only 401/403/404 plus a subsequent broker client-ID lookup returning 404 qualify at the transport seam. Caller owns that evidence; this API never - infers refusal from absence alone. Timeout/400/422/429/5xx stay ambiguous. + infers refusal from absence alone. Timeout/400/422/429/5xx stay ambiguous, + with one documented exception: a 422 whose transport-classified ``refusal`` + is SUB_PENNY_REFUSAL, for an intent whose own durable limit price really is + outside the minimum price variance. """ - if type(http_status) is not int or http_status not in (401, 403, 404): + if type(http_status) is not int or type(http_status) is bool: + raise SafetyError("unsupported_broker_refusal_status") + if http_status == 422: + if refusal != SUB_PENNY_REFUSAL: + raise SafetyError("unsupported_broker_refusal_status") + elif http_status not in DEFINITIVE_REFUSAL_STATUSES or refusal is not None: raise SafetyError("unsupported_broker_refusal_status") with self._transaction(): row = self.db.execute("SELECT * FROM intents WHERE client_id=?", (client_id,)).fetchone() @@ -887,9 +919,12 @@ def mark_broker_refused(self, client_id, http_status): if (intent.status != "reserved" or not intent.submit_attempted or intent.broker_id is not None or intent.filled_qty): raise SafetyError("cannot_mark_order_broker_refused") + if refusal == SUB_PENNY_REFUSAL and price_increment_valid(intent.limit_price): + raise SafetyError("refusal_contradicts_intent_price") self.db.execute("UPDATE intents SET status='broker_refused' WHERE client_id=?", (client_id,)) + extra = {} if refusal is None else {"refusal": refusal} self._event("broker_refused", client_id, http_status=http_status, - evidence_required="submission_http_refusal_then_client_id_404") + evidence_required="submission_http_refusal_then_client_id_404", **extra) return True def record_order(self, client_id, broker_id, status, cumulative_qty, average_price, *, timestamp=None): diff --git a/blueprints/us-equities/adaptive-paper/source-hashes.json b/blueprints/us-equities/adaptive-paper/source-hashes.json index bccbdbdb1..496c64c23 100644 --- a/blueprints/us-equities/adaptive-paper/source-hashes.json +++ b/blueprints/us-equities/adaptive-paper/source-hashes.json @@ -23,15 +23,15 @@ "blueprints/us-equities/adaptive-paper/recovery.py": "5cf7dcea572efbda6979a2726eebf6d676f6bd62263900c7b6626da8b45c1215", "blueprints/us-equities/adaptive-paper/registry.json": "60da73b985e718bd1dece7a3350f34636bda0fb38e766a44ae71c5a8c532282a", "blueprints/us-equities/adaptive-paper/requirements.txt": "518afe4b77f901e87abd3a5d6faa126aa7e211db77eecec80cfe7ef42d82e07a", - "blueprints/us-equities/adaptive-paper/runner.py": "03427aa9bf65c2d0863ae147fd1daa135dc1f7379bafcd82aa2ba3b1c19de73a", - "blueprints/us-equities/adaptive-paper/safety.py": "8cc67464ab55794b232cedb9988c264b86c8ec11ebe1734a94fb71480a269fa0", + "blueprints/us-equities/adaptive-paper/runner.py": "a61012948d92eddef959ba91c5fdadc24170944239c02c20190e68c52d717245", + "blueprints/us-equities/adaptive-paper/safety.py": "ad520fc4d84ce10e4d7b5cc956ba769a4f94eef49464ce382746d57f0cd76420", "blueprints/us-equities/adaptive-paper/scheduled_trial.sh": "58a5b656875bda629443768cd0c69a4be28cbf90dfc9f5ae8782d7dcf3ac7d76", "blueprints/us-equities/adaptive-paper/selector.py": "28de8ebf66b6e7135979554f73692ede772167682007d8c6ba41da6fb6fb39b3", "blueprints/us-equities/adaptive-paper/sessions.py": "1920952ac8219caf09e622fe517179e2766652fc7f987f7a11258481aa881998", "blueprints/us-equities/adaptive-paper/simulation.py": "085e578e9fabe03ccefdc68b4796b9cf5012aca8d832d65107e4b95e0a80621b", "blueprints/us-equities/adaptive-paper/strategies.py": "2d488a4fdd21ad323ab07cf7fb147b3623fd6e38951abcf2fc59cf8094a1383e", "blueprints/us-equities/adaptive-paper/strategies_v1.py": "1952be269694485e12e4d927f124adca0ee29667588d6ac6651caede454c92f7", - "blueprints/us-equities/adaptive-paper/transport.py": "59ffbb35726b0909c9b9db9043f993242e9beb29329c0d851359617244b06934", + "blueprints/us-equities/adaptive-paper/transport.py": "b92e87524ccffd168b67b4f80418653e5309250fa23968af0988e2f016e0ed1e", "tests/adaptive_paper_hermetic.py": "34f2d5b07f42cb60d415abf24e4feef64b9088d1071163ab8427f0d958ebf7ee", "tests/test_adaptive_market_research.py": "dfd7a09380a7b1b10633c2edf05daf5a35bfcb48498fff130a2d84ff7190e395", "tests/test_adaptive_paper_exits.py": "13a82d65ad1b2e72a8ca0fbb8bbb178a01ded1301399604c9e304ae213456764", @@ -41,10 +41,10 @@ "tests/test_adaptive_paper_mover_native.py": "0257ca4bf9078e348e39d391dccf042b3f7b0e01d5c9e4d9092a9712f04097d5", "tests/test_adaptive_paper_native.py": "7c97707a5c19b704be9a603bbde396d9cb952dad39ca0d5b434ae1f8ebbd1f81", "tests/test_adaptive_paper_recovery.py": "08e351ff903d6d008b802dc93a8dde07da8900df5cc0540f551ffa909cf4ce95", - "tests/test_adaptive_paper_runner.py": "369be21319a33adeed4b55178679ed5a6dd5938fd7ce029e96d6efa5efad7ef2", - "tests/test_adaptive_paper_safety.py": "8b3d962f955b65b89d4ca071b433c33aa7be37dad82fa82b06f3a1a9f6d4a82e", + "tests/test_adaptive_paper_runner.py": "194697ddc50b406a7220b3ae6f223ea012aebe2b607efffbff1405acaaeb40a5", + "tests/test_adaptive_paper_safety.py": "e0b2017ea6bdae87a0778b01ac72747642b31a2495c6d4e8bc98619a5aa1b456", "tests/test_adaptive_paper_selector.py": "27b431ac7911372d050f25d32ac24a3df9d2cf3c5e7dafdebfe51db6cab55936", "tests/test_adaptive_paper_sessions.py": "1569c44eb63ff35afee5503e867fa8927bee2179f23d7f627518d2e82bf38c0d", "tests/test_adaptive_paper_strategies.py": "f0814b36909f2fc6b4c4213b4142ffc13339740f0397c99ad9c7c81982e2033a", - "tests/test_adaptive_paper_transport.py": "95d606e5aa79b4be25603ce77a8690c4008e59905a0e9bedbae4e73f538f3dd9" + "tests/test_adaptive_paper_transport.py": "57b7e1f720eeaebee8b2c9254498ff59215d8131d6a41fbb979157e3ade604d4" } diff --git a/blueprints/us-equities/adaptive-paper/transport.py b/blueprints/us-equities/adaptive-paper/transport.py index 4b73a0310..8f1901cde 100644 --- a/blueprints/us-equities/adaptive-paper/transport.py +++ b/blueprints/us-equities/adaptive-paper/transport.py @@ -48,10 +48,46 @@ class AmbiguousSubmission(TransportError): class RejectedSubmission(TransportError): - def __init__(self, status_code): + def __init__(self, status_code, refusal=None): self.status_code = status_code + self.refusal = refusal self.definitive_rejection = True - super().__init__("broker definitively rejected submission (HTTP %d)" % status_code) + super().__init__("broker definitively rejected submission (HTTP %d%s)" + % (status_code, "" if refusal is None else ": " + refusal)) + + +# Documented definitive refusals for POST /v2/orders. 401/403/404 as before. A 422 is +# ambiguous (it also carries "client_order_id must be unique", which proves an order +# exists) except the one Alpaca documents as a rejection: a limit price in excess of +# the minimum price variance, body code 42210000 with the message below +# (https://docs.alpaca.markets/us/docs/orders-at-alpaca.md, "Sub-penny increments"). +# That page documents the body, not the HTTP status; 422 is inferred from the code +# prefix and POST /v2/orders 422 "Input parameters are not recognized" +# (https://docs.alpaca.markets/us/reference/postorder.md), pending native +# confirmation, so any other status stays ambiguous. The message is the +# discriminator; the code alone is not sufficient. The body is only compared with +# these constants; it is never retained or raised. +SUB_PENNY_REFUSAL = "sub_penny_minimum_price_variance" +SUB_PENNY_CODE = 42210000 +SUB_PENNY_MESSAGE = "sub-penny increment does not fulfill minimum pricing criteria" + + +def violates_minimum_price_variance(limit_price): + price = Decimal(str(limit_price)) + return price != price.quantize(Decimal("0.01") if price >= 1 else Decimal("0.0001")) + + +def documented_refusal(exc, status, intent): + """Classify a failed POST as a documented definitive 422, or return None.""" + if status != 422 or not violates_minimum_price_variance(intent["limit_price"]): + return None + try: + code, message = exc.code, exc.message + except Exception: + return None + if type(code) is not int or code != SUB_PENNY_CODE or not isinstance(message, str): + return None + return SUB_PENNY_REFUSAL if SUB_PENNY_MESSAGE in message else None class SubmissionNotSent(TransportError): @@ -786,7 +822,7 @@ async def submit(self, order): if key in self._not_sent: raise SubmissionNotSent("this frozen intent was already prevented before HTTP") if key in self._rejected: - raise RejectedSubmission(self._rejected[key]) + raise RejectedSubmission(*self._rejected[key]) found = await self._lookup(key) if found is None: self.freeze_health("replay_unresolved") @@ -814,10 +850,14 @@ async def submit(self, order): raise AmbiguousSubmission("submission lookup unavailable; no automatic retry") from None if observed is None: # A 422/400 may report a duplicate client ID whose prior order - # is not visible yet. Neither is proof that the intent failed. - if status in {401, 403, 404}: - self._rejected[key] = status - raise RejectedSubmission(status) from None + # is not visible yet. Neither is proof that the intent failed, + # except the documented sub-penny 422 for a price that really + # violates the minimum price variance (this was the first POST + # of this client ID; SDK retries are disabled). + refusal = documented_refusal(exc, status, intent) + if status in {401, 403, 404} or refusal is not None: + self._rejected[key] = (status, refusal) + raise RejectedSubmission(status, refusal) from None self.freeze_health("submission_ambiguous") raise AmbiguousSubmission("submission unresolved; no automatic retry") from None self.freeze_health("submission_ambiguous") @@ -827,26 +867,43 @@ async def submit(self, order): return await self._observe(observed) async def cancel(self, client_order_id): + """Send the broker DELETE for an owned order and return its queried final state. + + The caller asks to cancel an order it believes open or whose state is + ambiguous; the transport does not overrule that with a cached or freshly + read status. The DELETE goes to the known broker ID (the transport's own + observation, or a client-ID lookup when it has none). The broker's answer is + data: 204 is only an acknowledgement; 422 ("The order status is not + cancelable", https://docs.alpaca.markets/us/reference/deleteorderbyorderid-1.md) + or 404 is accepted without freezing only when the follow-up lookup shows the + order terminal. Any other failure (timeout, 429, 5xx) freezes admissions as + before. The follow-up observation is idempotent, so a second cancel of an + already-canceled order changes no ledger state. + """ async with self._operation_lock: if client_order_id not in self._intents: raise TransportError("cancellation requires an owned durable intent") - order = await self._lookup(client_order_id) + order = self._observed.get(client_order_id) if order is None: - order = self._observed.get(client_order_id) + order = await self._lookup(client_order_id) if order is None: self.freeze_health("cancel_identity_unobserved") return None - if order["status"] in TERMINAL: - return order self._assert_matches(order, self._intents[client_order_id]) + answer = 204 try: await asyncio.to_thread(self._client.cancel_order_by_id, order["id"]) - except Exception: - self.freeze_health("cancellation_unresolved") + except Exception as exc: + answer = getattr(exc, "status_code", None) + if answer not in (404, 422): + self.freeze_health("cancellation_unresolved") # A successful DELETE is only an acknowledgement; query cumulative state. final = await self._lookup(client_order_id) if final is None: self.freeze_health("cancel_finality_unobserved") + elif answer in (404, 422) and final["status"] not in TERMINAL: + # A refusal for an order that is still working is not resolved data. + self.freeze_health("cancellation_unresolved") return final def _pages(self, status, *, after=None): diff --git a/catalogs/us-equities/gates-20260922.json b/catalogs/us-equities/gates-20260922.json index f21d8c30f..d3fe4f7f4 100644 --- a/catalogs/us-equities/gates-20260922.json +++ b/catalogs/us-equities/gates-20260922.json @@ -1,7 +1,7 @@ { "schema_version": 1, "id": "us-equities-gate-ladder-20260922", - "checked_at": "2026-09-23", + "checked_at": "2026-09-24", "scope": "The sim -> paper -> live gate ladder for the selected destination (NautilusTrader 2.0.0rc5 with IBKR primary and a separate Alpaca paper adapter; LEAN 985ef30 frozen oracle). Each gate names the receipt that establishes it and the machine-checkable condition that flips it. scripts/trading_gates.py --check verifies the ladder arithmetically; a status changes only by a dated commit after the checker lists the gate as a flip candidate; an exists condition proves only that a non-empty file is present, so gates using it carry a presence-only limitation in their note. Rung readiness = every required gate of that rung and every earlier rung established. Nothing here authorizes live configuration, paid hosting or orders.", "rungs": [ "sim", @@ -306,9 +306,9 @@ "rung": "live", "layer": "execution-broker", "title": "Broker-state failure handling exercised against native (non-synthetic) faults", - "status": "not_established", + "status": "established", "owner": "peer:sota-workflow-resolution", - "evidence_class": "none", + "evidence_class": "native_proven", "required": true, "receipt_path": "blueprints/us-equities/adaptive-paper/native-faults/receipt.json", "flip_condition": { @@ -316,7 +316,7 @@ "pointer": "/status", "equals": "native_faults_passed" }, - "note": "Partial evidence, 2026-09-23 (status unchanged): blueprints/us-equities/adaptive-paper/native-faults/receipt.json, one live Alpaca paper run of the minimal harness at 14:20:24Z (status native_faults_incomplete). C01 accept resting buy and C02 cancel resting passed with native_paper evidence (submit 200 pending_new; cancel 204 canceled); C05 cancel again short-circuits in the engine without a DELETE (engine_short_circuit); C04 definitive rejection is unobserved because the Ledger refuses the sub-penny price before send (invalid_price_increment). Cleanup proved flat, 1 of 4 POSTs used. Reaching native_faults_passed needs an engine path that sends the C05 DELETE to the broker, and for C04 either an engine or plan change so a broker 422 on an invalid price counts as a definitive refusal (the Ledger accepts only 401, 403 and 404) or a fault Alpaca answers with 401, 403 or 404, besides sending the C04 order at all. The receipt is bound to the harness as run (harness_sha256 742fb666...); harness.py was hardened afterwards after review (cleanup exceptions still mark the run, an earlier IN_FLIGHT or CLEANUP_REQUIRED marker refuses a new start, the write-ahead marker is fsynced, a failed transport stop fails the run), so the next run binds a new hash. runtime-target.json next_acceptance broker-state-failures: bounded_alpaca_synthetic_cases_passed_native_faults_pending. Required receipt schema (preregistered before the first receipt, the 2026-09-23 native_faults_incomplete one, following the next_acceptance status-string convention already used in catalogs/us-equities/runtime-target.json): {\"schema_version\": 1, \"kind\": \"native_fault_behaviour_receipt\", \"status\": \"native_faults_passed\", \"broker\": \"alpaca\"}. The checker only verifies /status == \"native_faults_passed\"; that native (non-synthetic) faults were actually exercised, distinct from the 27 bounded synthetic cases already passed, is qualified manually before the dated commit that flips this gate." + "note": "Established 2026-09-24 by this dated flip commit after manual qualification: the checker listed the gate as a flip candidate and the manual qualification that native (non-synthetic) faults were actually exercised rests on three records. (1) The receipt blueprints/us-equities/adaptive-paper/native-faults/receipt.json (status native_faults_passed, one live Alpaca paper run at 14:39:05Z bound to harness 3f01fb31..., plan f276b26c... and the engine sources in adaptive-paper/source-hashes.json): C01 submit 200, C02 cancel 204, C05 repeat DELETE sent and answered 204, C04 sub-penny submit answered 422 with Alpaca's documented body then client-id lookup 404, cleanup flat. Every case is native_paper in the receipt; the ladder's evidence_class vocabulary (scripts/trading_gates.py) has no native_paper value, so the gate records native_proven like the other paper-run gates (alpaca-paper-smoke, adaptive-paper-broker-trial). (2) The independent observation blueprints/us-equities/adaptive-paper/native-faults/evidence/independent-observation-20260924.json, made by the workflow coordinator at 15:01:47Z with a retained alpaca-py 0.44.0 script (observe-native-faults-20260924.py, sha256 6f4b1c19...; stdout observe-native-faults-20260924.stdout.json, sha256 420d762c...; stderr empty; exit 0), not the harness or the engine transport: for prefix nf-20260924t143905-29d7ec- one broker order (c01, canceled, filled 0), the c04 client-id lookup 404 (no order exists), 0 open orders and 0 positions, matching the receipt's counts. It uses the same broker and account, so it is independent of the harness and engine code, not of the broker. (3) The review: the Codex GitHub review of PR #169 at 8051464, the commit the run used, completed 2026-09-24T07:36:44Z with no review comments (its +1 reaction marks a review without findings). Limits that stay open: the plan exercised four cases (C01, C02, C05, C04) and the only native fault among them is the definitive-rejection class (one sub-penny submit answered 422); ambiguous or in-flight submit faults (timeouts, 429, 5xx, partial fills, restart during a submit) were not exercised natively and remain covered only by the 27 bounded synthetic cases and offline tests, so this status qualifies definitive-rejection and repeat-cancel behaviour only; C05's DELETE was answered 204, so the engine's handling of a 422 cancel refusal is offline-tested only; the C04 422 is one paper-endpoint observation from one host and account, not live-endpoint evidence and not a general rule that 422 is definitive; nothing here covers IBKR or authorizes live orders. Earlier history: Native receipt passed, 2026-09-24 (status unchanged; flip candidate awaiting manual qualification): blueprints/us-equities/adaptive-paper/native-faults/receipt.json is now one live Alpaca paper run at 14:39:05Z, from the WSL2 workstation against a paper account dedicated to that PC, bound to this tree's harness (3f01fb31...), plan (f276b26c...) and engine sources runner.py, safety.py and transport.py (equal to adaptive-paper/source-hashes.json), with status native_faults_passed. C01 submit 200 pending_new; C02 cancel 204 canceled; C05 DELETE sent and answered 204 plus read 200, native_paper, no freeze, ledger unchanged; C04 submit 422 carrying Alpaca's documented sub-penny body for limit 306.3601, then client-id lookup 404, ledger broker_refused {http_status 422, refusal sub_penny_minimum_price_variance}, no position or cash effect; 2 of 4 POSTs, cleanup flat (0 open orders, 0 positions, cash delta 0.00). Raw stdout private, sha256 1481924e43f2867e5e62338ab60bb5b60cbe9ff9df3f54c36f8fa348f082ecac. scripts/trading_gates.py lists this gate as a flip candidate. The status stays not_established because the ladder changes a status 'only by a dated commit after the checker lists the gate as a flip candidate' and this gate requires that native faults were actually exercised to be 'qualified manually before the dated commit that flips this gate'; docs/acceptance-evidence-policy.md does not accept a wrapper's own passed field as that confirmation, so the flip awaits an independent observation by a separate method (not the harness or engine transport) of prefix nf-20260924t143905-29d7ec-: C01 canceled with zero filled, no broker order for the C04 client id, zero open orders and positions. The C04 422 is one paper-endpoint observation, not live-endpoint evidence. The 2026-09-23 incomplete run is retained byte-identical as blueprints/us-equities/adaptive-paper/native-faults/receipt-20260923.json. Earlier history: Partial evidence, 2026-09-23 (status unchanged): blueprints/us-equities/adaptive-paper/native-faults/receipt.json (now retained as receipt-20260923.json), one live Alpaca paper run of the minimal harness at 14:20:24Z (status native_faults_incomplete). C01 accept resting buy and C02 cancel resting passed with native_paper evidence (submit 200 pending_new; cancel 204 canceled); C05 cancel again short-circuits in the engine without a DELETE (engine_short_circuit); C04 definitive rejection is unobserved because the Ledger refuses the sub-penny price before send (invalid_price_increment). Cleanup proved flat, 1 of 4 POSTs used. Reaching native_faults_passed needs an engine path that sends the C05 DELETE to the broker, and for C04 either an engine or plan change so a broker 422 on an invalid price counts as a definitive refusal (the Ledger accepts only 401, 403 and 404) or a fault Alpaca answers with 401, 403 or 404, besides sending the C04 order at all. Offline engine change, 2026-09-24 (status unchanged, no paper run, offline unit tests only): the transport now sends the C05 DELETE and treats Alpaca's 204/404/422 answer as data, and C04 takes the reclassification allowed above, counting as definitive only a 422 carrying Alpaca's documented sub-penny body (code 42210000, message 'sub-penny increment does not fulfill minimum pricing criteria'; the HTTP 422 is inferred and must be confirmed natively) for a price that violates the increment, with the harness FaultLedger exempting only its C04 client id from the pre-send check; blueprints/us-equities/adaptive-paper/native-faults/README.md states what the next native run must show. The receipt is bound to the harness as run (harness_sha256 742fb666...); harness.py was hardened afterwards after review (cleanup exceptions still mark the run, an earlier IN_FLIGHT or CLEANUP_REQUIRED marker refuses a new start, the write-ahead marker is fsynced, a failed transport stop fails the run), so the next run binds a new hash. runtime-target.json next_acceptance broker-state-failures (now bounded_alpaca_synthetic_cases_passed_native_fault_receipt_passed_independent_observation_pending). Required receipt schema (preregistered before the first receipt, the 2026-09-23 native_faults_incomplete one, following the next_acceptance status-string convention already used in catalogs/us-equities/runtime-target.json): {\"schema_version\": 1, \"kind\": \"native_fault_behaviour_receipt\", \"status\": \"native_faults_passed\", \"broker\": \"alpaca\"}. The checker only verifies /status == \"native_faults_passed\"; that native (non-synthetic) faults were actually exercised, distinct from the 27 bounded synthetic cases already passed, is qualified manually before the dated commit that flips this gate." }, { "id": "ibkr-local-acceptance", diff --git a/catalogs/us-equities/runtime-target.json b/catalogs/us-equities/runtime-target.json index f8c7bd005..1f97cd244 100644 --- a/catalogs/us-equities/runtime-target.json +++ b/catalogs/us-equities/runtime-target.json @@ -144,8 +144,8 @@ }, { "id": "broker-state-failures", - "status": "bounded_alpaca_synthetic_cases_passed_native_faults_pending", - "scope": "27 local Alpaca lifecycle tests cover bounded ambiguous submit, duplicate/restart, partial/cancel, stale/clock/risk, request budget, ownership and kill failures. Actual paper execution observed full fills and completed-trial recovery only; native in-flight fault behavior remains unqualified." + "status": "bounded_alpaca_synthetic_cases_passed_native_fault_behaviour_established_alpaca_paper", + "scope": "27 local Alpaca lifecycle tests cover bounded ambiguous submit, duplicate/restart, partial/cancel, stale/clock/risk, request budget, ownership and kill failures. Actual paper execution observed full fills and completed-trial recovery; native in-flight (ambiguous-submit) fault behaviour remains unqualified, while native definitive-rejection and repeat-cancel behaviour are qualified by the 2026-09-24 receipt. Native fault receipt 2026-09-24 (blueprints/us-equities/adaptive-paper/native-faults/receipt.json, native_faults_passed on Alpaca paper from the WSL2 workstation): resting submit and cancel, a repeat cancel whose DELETE was answered 204, and a documented sub-penny submit refused with 422 then a 404 lookup, cleanup flat. Gate native-fault-behaviour established 2026-09-24 by a dated flip commit after an independent alpaca-py observation (blueprints/us-equities/adaptive-paper/native-faults/evidence/independent-observation-20260924.json: c01 canceled with 0 filled, no order for c04, 0 open orders and positions, matching the receipt). Only the definitive-rejection class (one sub-penny 422) was exercised as a native fault; ambiguous submits, timeouts, 429/5xx and partial fills remain synthetic or offline-only. C05's DELETE was answered 204, so the engine's handling of a 422 cancel refusal remains offline-tested only; the C04 422 is one paper-endpoint observation, not live evidence; IBKR fault behaviour is not covered." }, { "id": "separate-paper-adapters", diff --git a/manifests/evidence.json b/manifests/evidence.json index a9ac7514c..fe484fe4a 100644 --- a/manifests/evidence.json +++ b/manifests/evidence.json @@ -6408,13 +6408,13 @@ }, { "path": "blueprints/us-equities/adaptive-paper/README-safety.md", - "sha256": "4b2e1ce6c70b92a85ef25d24bff36ac7cd1ae277e96c8ab97414252645314f31", - "bytes": 16548 + "sha256": "4967e194064317fbf1b45c4f75092e82a27e91aba8be9d9abf634ee07abec134", + "bytes": 17789 }, { "path": "blueprints/us-equities/adaptive-paper/README-transport.md", - "sha256": "a21d8bc518384017e038182658ddbc58e85cc24a406d69e9ca04766b9c6cd111", - "bytes": 9858 + "sha256": "507a1b8cb2763b26d001044ffd46fddbaef08145926ae9f81107e216a32461d8", + "bytes": 11593 }, { "path": "blueprints/us-equities/adaptive-paper/README.md", @@ -6523,24 +6523,29 @@ }, { "path": "blueprints/us-equities/adaptive-paper/native-faults/README.md", - "sha256": "0686e62d04ca2947d7bfa27462aa62528dbcc45b0899c4302260ff0478c9d71a", - "bytes": 8191 + "sha256": "abffbbd075d6e7ab439a20f2210df24dc75fe7703f354415c1f395794063c60e", + "bytes": 20054 }, { "path": "blueprints/us-equities/adaptive-paper/native-faults/harness.py", - "sha256": "af7ce5cd2bd87f8e6d7cf2d07c41be169e1eab65b961deb95ff8427be250283a", - "bytes": 24944 + "sha256": "3f01fb317cedeaf75e36b5d4a72738cd3fc8a6cbdc01c9590646b10fa3996d52", + "bytes": 27350 }, { "path": "blueprints/us-equities/adaptive-paper/native-faults/plan.json", - "sha256": "2ce6f6be140826a107826e35e6aaef9f31c55fcf82c98e25ce7ffd1b894e5a84", - "bytes": 1878 + "sha256": "f276b26c16256b91de3a4f6fdf566b28a14a255f1210baa478537990dada6818", + "bytes": 2785 }, { - "path": "blueprints/us-equities/adaptive-paper/native-faults/receipt.json", + "path": "blueprints/us-equities/adaptive-paper/native-faults/receipt-20260923.json", "sha256": "28b9db92ecef7ef16e27774f9266d0bf9ddb7f55259d645ee2356c316464099a", "bytes": 5186 }, + { + "path": "blueprints/us-equities/adaptive-paper/native-faults/receipt.json", + "sha256": "e3cec5a67b2030589b114cbf6822965c140049b15836c669ef01dca782b3a105", + "bytes": 5645 + }, { "path": "blueprints/us-equities/adaptive-paper/native_adapter.py", "sha256": "3e9cf2d93980b0f59b781dc3771f176c4e90d6af921b61c1022017cf85e64e0f", @@ -6578,13 +6583,13 @@ }, { "path": "blueprints/us-equities/adaptive-paper/runner.py", - "sha256": "03427aa9bf65c2d0863ae147fd1daa135dc1f7379bafcd82aa2ba3b1c19de73a", - "bytes": 99013 + "sha256": "a61012948d92eddef959ba91c5fdadc24170944239c02c20190e68c52d717245", + "bytes": 99100 }, { "path": "blueprints/us-equities/adaptive-paper/safety.py", - "sha256": "8cc67464ab55794b232cedb9988c264b86c8ec11ebe1734a94fb71480a269fa0", - "bytes": 63126 + "sha256": "ad520fc4d84ce10e4d7b5cc956ba769a4f94eef49464ce382746d57f0cd76420", + "bytes": 65265 }, { "path": "blueprints/us-equities/adaptive-paper/selector.py", @@ -6603,7 +6608,7 @@ }, { "path": "blueprints/us-equities/adaptive-paper/source-hashes.json", - "sha256": "ab13b1b8cc23c019353c205afb026fda6f49934c4a6f22a0fcf8a438287c4aa3", + "sha256": "dba8a3eac59f65e0ef8a2b38367eb6303f8fecb4b6ab92997dce265862adb4dd", "bytes": 5870 }, { @@ -6618,8 +6623,8 @@ }, { "path": "blueprints/us-equities/adaptive-paper/transport.py", - "sha256": "59ffbb35726b0909c9b9db9043f993242e9beb29329c0d851359617244b06934", - "bytes": 46491 + "sha256": "b92e87524ccffd168b67b4f80418653e5309250fa23968af0988e2f016e0ed1e", + "bytes": 49843 }, { "path": "blueprints/us-equities/adaptive-paper/trials/20260923-interrupted/README.md", @@ -9378,8 +9383,8 @@ }, { "path": "catalogs/us-equities/gates-20260922.json", - "sha256": "5cfa39ba0f927505497b06a2777be7a7c001ac655b96a58095905dc7fd382cfe", - "bytes": 36072 + "sha256": "8c27800fd27821dec706a8cced1eff1cc688396fb9b87fe13e50f4572d3774a2", + "bytes": 41396 }, { "path": "catalogs/us-equities/hosting-source-review.json", @@ -9453,8 +9458,8 @@ }, { "path": "catalogs/us-equities/runtime-target.json", - "sha256": "07d524b9c9c63949df656f16832daa5a7a7bebcff2f2ad5e39c27b590e33410d", - "bytes": 11632 + "sha256": "f21f52bc1fc539e89a2569e329f840291ffc90f0692731cafc057d17da34f137", + "bytes": 12804 }, { "path": "catalogs/us-equities/security-identity-review.json", @@ -28603,13 +28608,13 @@ }, { "path": "tests/test_adaptive_paper_runner.py", - "sha256": "369be21319a33adeed4b55178679ed5a6dd5938fd7ce029e96d6efa5efad7ef2", - "bytes": 132475 + "sha256": "194697ddc50b406a7220b3ae6f223ea012aebe2b607efffbff1405acaaeb40a5", + "bytes": 134422 }, { "path": "tests/test_adaptive_paper_safety.py", - "sha256": "8b3d962f955b65b89d4ca071b433c33aa7be37dad82fa82b06f3a1a9f6d4a82e", - "bytes": 45650 + "sha256": "e0b2017ea6bdae87a0778b01ac72747642b31a2495c6d4e8bc98619a5aa1b456", + "bytes": 48028 }, { "path": "tests/test_adaptive_paper_sessions.py", @@ -28623,8 +28628,8 @@ }, { "path": "tests/test_adaptive_paper_transport.py", - "sha256": "95d606e5aa79b4be25603ce77a8690c4008e59905a0e9bedbae4e73f538f3dd9", - "bytes": 41484 + "sha256": "57b7e1f720eeaebee8b2c9254498ff59215d8131d6a41fbb979157e3ade604d4", + "bytes": 48462 }, { "path": "tests/test_adoption_bootstrap.py", @@ -28913,8 +28918,8 @@ }, { "path": "tests/test_native_faults_min.py", - "sha256": "e84e8686918b94d741e70bdb669c3c29708c4f0adaffa3aa51a55c2715fc93a2", - "bytes": 21264 + "sha256": "b8bd6d3015a6cc003dc9c7b2d09cac7f42475f36205ee6bef068971a6bbd5e8e", + "bytes": 28436 }, { "path": "tests/test_native_maintenance.py", diff --git a/tests/test_adaptive_paper_runner.py b/tests/test_adaptive_paper_runner.py index d4b3d3d2d..54de03cd5 100644 --- a/tests/test_adaptive_paper_runner.py +++ b/tests/test_adaptive_paper_runner.py @@ -304,6 +304,40 @@ async def submit(self, payload): self.assertEqual(proof["open_orders"], 0) ledger.close() + def test_documented_sub_penny_refusal_is_recorded_through_the_controller(self): + from transport import RejectedSubmission, SUB_PENNY_REFUSAL + import safety + + class Exempt(Ledger): # native-faults/harness.py FaultLedger shape + def _check_price_increment(self, client_id, price): + if client_id != "c04-test": + super()._check_price_increment(client_id, price) + now = time.time() + with tempfile.TemporaryDirectory() as root: + ledger = Exempt(Path(root) / "journal.db") + ledger.start_trial(now) + controller = Controller(ledger, now + 3600, market_open=True) + + class RefusingPort: + ready = True + async def submit(self, payload): + controller.before_submit(payload) + await controller.before_request("submit", payload["client_order_id"]) + raise RejectedSubmission(422, SUB_PENNY_REFUSAL) + controller.port = controller.bind(RefusingPort()) + controller.quote({"symbol": "SPY", "bid": "100", "ask": "100.01", "ts_ns": time.time_ns()}) + payload = {"client_order_id": "c04-test", "symbol": "SPY", "side": "buy", "qty": "1", + "limit_price": "40.0001"} + with self.assertRaises(RejectedSubmission) as caught: + asyncio.run(controller.port.submit(payload)) + self.assertEqual(caught.exception.refusal, safety.SUB_PENNY_REFUSAL) + intent = ledger.intents()[0] + self.assertEqual((intent.status, intent.limit_price), ("broker_refused", Decimal("40.0001"))) + self.assertTrue(controller.stop) + proof = reconcile(ledger, {"complete": True, "orders": [], "positions": [], "account": {"cash": "100000"}}, "100000") + self.assertEqual((proof["open_orders"], proof["positions"]), (0, 0)) + ledger.close() + def test_recovery_preflight_does_not_require_entry_cash_or_unrelated_quotes(self): config, _, _ = load_config(SOURCE / "config.json") now = time.time_ns() diff --git a/tests/test_adaptive_paper_safety.py b/tests/test_adaptive_paper_safety.py index 5945cfccc..5106d828b 100644 --- a/tests/test_adaptive_paper_safety.py +++ b/tests/test_adaptive_paper_safety.py @@ -3,6 +3,7 @@ from decimal import Decimal as D import hashlib import importlib.util +import json from pathlib import Path import sqlite3 import sys @@ -552,6 +553,42 @@ def test_proven_broker_refusal_has_distinct_status_and_retains_request(self): with self.assertRaisesRegex(s.SafetyError, "after_definitive_refusal"): self.fill() + def test_price_outside_minimum_price_variance_is_refused_before_send(self): + for cid, price in (("sub-1", "100.0201"), ("sub-2", "0.99991")): + with self.assertRaisesRegex(s.SafetyError, "invalid_price_increment"): + self.reserve(cid, price=price) + self.assertEqual(self.ledger.intents(), []) + self.reserve("ok-1", price="0.9999") # four decimals below $1.00 are valid + + def test_documented_sub_penny_422_is_the_only_definitive_422(self): + class Exempt(s.Ledger): # the native-fault harness's FaultLedger shape + def _check_price_increment(self, client_id, price): + if client_id != "c04": + super()._check_price_increment(client_id, price) + self.ledger.close() + self.ledger = Exempt(self.db) + with self.assertRaisesRegex(s.SafetyError, "invalid_price_increment"): + self.reserve("other", price="100.0201") + self.reserve("c04", price="100.0201") + self.reserve("valid", price="100.02") + for cid in ("c04", "valid"): + self.ledger.request_budget(self.now, "submit", cid) + for status, refusal in [(422, None), (422, "other"), (403, s.SUB_PENNY_REFUSAL), (400, s.SUB_PENNY_REFUSAL)]: + with self.assertRaisesRegex(s.SafetyError, "unsupported_broker_refusal"): + self.ledger.mark_broker_refused("c04", status, refusal) + # The refusal must agree with the intent's own durable price. + with self.assertRaisesRegex(s.SafetyError, "refusal_contradicts_intent_price"): + self.ledger.mark_broker_refused("valid", 422, s.SUB_PENNY_REFUSAL) + self.assertTrue(self.ledger.mark_broker_refused("c04", 422, s.SUB_PENNY_REFUSAL)) + self.assertFalse(self.ledger.mark_broker_refused("c04", 422, s.SUB_PENNY_REFUSAL)) + statuses = {i.client_id: i.status for i in self.ledger.intents()} + self.assertEqual(statuses, {"c04": "broker_refused", "valid": "reserved"}) + payload = json.loads(self.ledger.db.execute( + "SELECT payload FROM events WHERE kind='broker_refused' AND client_id='c04'").fetchone()[0]) + self.assertEqual((payload["http_status"], payload["refusal"]), (422, s.SUB_PENNY_REFUSAL)) + self.assertEqual(self.ledger.accounting().cash_delta_usd, 0) + self.assertEqual(self.ledger.positions(), {}) + def test_wide_quote_values_held_loss_but_does_not_block_owned_exit(self): self.reserve() self.fill() diff --git a/tests/test_adaptive_paper_transport.py b/tests/test_adaptive_paper_transport.py index f579a5868..a27f0e99d 100644 --- a/tests/test_adaptive_paper_transport.py +++ b/tests/test_adaptive_paper_transport.py @@ -397,12 +397,116 @@ async def test_known_partial_then_invisible_order_still_cancels_known_id(self): await self.port._observe(t.normalize_order(order(filled_qty="0.25", status="partially_filled"))) missing = response({"code": 404, "message": "not found"}, 404) with patch.object(self.port._client._session._session, "request", - side_effect=[missing, response(None, 204), missing]) as request: + side_effect=[response(None, 204), missing]) as request: result = await self.port.cancel("trial-1") self.assertIsNone(result) - self.assertEqual([c.args[0] for c in request.call_args_list], ["GET", "DELETE", "GET"]) + # The known broker id is cancelled directly; no pre-DELETE lookup decides for the caller. + self.assertEqual([c.args[0] for c in request.call_args_list], ["DELETE", "GET"]) + self.assertTrue(request.call_args_list[0].args[1].endswith("/v2/orders/" + ID)) self.assertIn("cancel_finality_unobserved", self.port.health["reasons"]) + async def test_cancel_again_sends_delete_and_takes_422_as_data(self): + # C05: the order is already canceled; the second cancel still reaches the + # broker, whose documented 422 "not cancelable" is data, not a freeze. + self.port.adopt_intents([intent()]) + canceled = order(status="canceled", updated_at="2026-09-21T15:00:01Z") + await self.port._observe(t.normalize_order(canceled)) + observed = len(self.observations) + with patch.object(self.port._client._session._session, "request", side_effect=[ + response({"code": 42210000, "message": "order is already in \"canceled\" state"}, 422), + response(canceled)]) as request: + result = await self.port.cancel("trial-1") + self.assertEqual(result["status"], "canceled") + self.assertEqual([c.args[0] for c in request.call_args_list], ["DELETE", "GET"]) + self.assertEqual([b[0] for b in self.budgets], ["cancel", "read"]) + self.assertEqual(self.port.health["reasons"], []) + # The follow-up observation repeats the known terminal state exactly. + self.assertTrue(all(o["status"] == "canceled" and o["filled_qty"] == "0" + for o in self.observations[observed:])) + + async def test_cancel_404_with_terminal_final_is_data(self): + self.port.adopt_intents([intent()]) + await self.port._observe(t.normalize_order(order())) + with patch.object(self.port._client._session._session, "request", side_effect=[ + response({"code": 40410000, "message": "order not found"}, 404), + response(order(status="canceled", updated_at="2026-09-21T15:00:01Z"))]): + result = await self.port.cancel("trial-1") + self.assertEqual(result["status"], "canceled") + self.assertEqual(self.port.health["reasons"], []) + + async def test_cancel_refusal_for_still_working_order_freezes(self): + self.port.adopt_intents([intent()]) + await self.port._observe(t.normalize_order(order())) + with patch.object(self.port._client._session._session, "request", side_effect=[ + response({"code": 42210000, "message": "not cancelable"}, 422), + response(order(status="pending_cancel", updated_at="2026-09-21T15:00:01Z"))]): + result = await self.port.cancel("trial-1") + self.assertEqual(result["status"], "pending_cancel") + self.assertIn("cancellation_unresolved", self.port.health["reasons"]) + + async def test_cancel_server_error_stays_unresolved(self): + self.port.adopt_intents([intent()]) + await self.port._observe(t.normalize_order(order())) + with patch.object(self.port._client._session._session, "request", side_effect=[ + response({"code": 50010000, "message": "internal"}, 500), + response(order(status="canceled", updated_at="2026-09-21T15:00:01Z"))]): + await self.port.cancel("trial-1") + self.assertIn("cancellation_unresolved", self.port.health["reasons"]) + + async def test_documented_sub_penny_422_is_definitive_once_absent(self): + # C04: Alpaca documents this body for a price beyond the minimum price variance. + body = {"code": 42210000, + "message": "invalid limit_price 100.0101. sub-penny increment does not fulfill minimum pricing criteria"} + missing = response({"code": 40410000, "message": "not found"}, 404) + with patch.object(self.port._client._session._session, "request", + side_effect=[response(body, 422), missing]) as request: + with self.assertRaises(t.RejectedSubmission) as caught: + await self.port.submit(intent(limit_price="100.0101")) + with self.assertRaises(t.RejectedSubmission) as replay: + await self.port.submit(intent(limit_price="100.0101")) + self.assertEqual((caught.exception.status_code, caught.exception.refusal), (422, t.SUB_PENNY_REFUSAL)) + self.assertEqual((replay.exception.status_code, replay.exception.refusal), (422, t.SUB_PENNY_REFUSAL)) + self.assertTrue(caught.exception.definitive_rejection) + self.assertEqual([c.args[0] for c in request.call_args_list], ["POST", "GET"]) # no retry, no replay POST + self.assertEqual(self.port.health["reasons"], []) + self.assertNotIn("invalid limit_price", str(caught.exception)) # provider text never raised + + async def test_other_422_stays_ambiguous(self): + missing = response({"code": 40410000, "message": "not found"}, 404) + cases = [ + # The documented sub-penny body for a price that is on the increment contradicts itself. + ("valid-price", "100.01", {"code": 42210000, "message": + "sub-penny increment does not fulfill minimum pricing criteria"}), + # A duplicate client id proves that an order exists. + ("duplicate", "100.0101", {"code": 40010001, "message": "client_order_id must be unique"}), + ("other-code", "100.0101", {"code": 40010001, "message": + "sub-penny increment does not fulfill minimum pricing criteria"}), + ("no-json", "100.0101", None), + ] + for index, (label, price, body) in enumerate(cases): + with self.subTest(label=label): + self.port._reasons.clear() + with patch.object(self.port._client._session._session, "request", + side_effect=[response(body, 422), missing]): + with self.assertRaises(t.AmbiguousSubmission): + await self.port.submit(intent(client_order_id="amb-%d" % index, limit_price=price)) + self.assertIn("submission_ambiguous", self.port.health["reasons"]) + + async def test_sub_penny_422_with_visible_order_is_not_a_refusal(self): + body = {"code": 42210000, "message": "sub-penny increment does not fulfill minimum pricing criteria"} + with patch.object(self.port._client._session._session, "request", + side_effect=[response(body, 422), response(order(limit_price="100.0101"))]): + found = await self.port.submit(intent(limit_price="100.0101")) + self.assertEqual(found["id"], ID) + self.assertIn("submission_ambiguous", self.port.health["reasons"]) + + def test_minimum_price_variance_matches_the_ledger(self): + import safety + self.assertEqual(t.SUB_PENNY_REFUSAL, safety.SUB_PENNY_REFUSAL) + for price in ("1", "1.01", "0.9999", "0.5", "100.0101", "1.001", "0.99991", "307.6601"): + self.assertEqual(t.violates_minimum_price_variance(price), + not safety.price_increment_valid(__import__("decimal").Decimal(price)), price) + async def test_proven_not_sent_intent_does_not_break_flat_snapshot(self): self.port.adopt_intents([intent()]) self.port._not_sent.add("trial-1") diff --git a/tests/test_native_faults_min.py b/tests/test_native_faults_min.py index 109bce81f..b0e8f9c0a 100644 --- a/tests/test_native_faults_min.py +++ b/tests/test_native_faults_min.py @@ -5,7 +5,6 @@ real runner.Controller and safety.Ledger; it establishes no broker behaviour. """ import asyncio -from decimal import Decimal from datetime import datetime, timedelta, timezone import importlib.util import json @@ -34,7 +33,8 @@ h = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(h) import safety # noqa: E402 (same module object the harness imported) -from transport import TERMINAL, TransportError # noqa: E402 +from transport import (TERMINAL, AmbiguousSubmission, RejectedSubmission, SUB_PENNY_REFUSAL, # noqa: E402 + TransportError, documented_refusal, violates_minimum_price_variance) try: # package mode (python -m unittest tests.x) or discover -s tests (top-level modules) from .adaptive_paper_hermetic import patch_default_stop, restore_default_stop @@ -61,6 +61,18 @@ class NotSent(TransportError): not_sent = True +class FakeAPIError(Exception): + """Shape of alpaca-py's APIError: code and message parsed from the response body.""" + + def __init__(self, body): + super().__init__("provider text that must not be recorded") + self.code, self.message = body.get("code"), body.get("message") + + +DOCUMENTED_SUB_PENNY = {"code": 42210000, "message": "invalid limit_price 240.0001. sub-penny increment " + "does not fulfill minimum pricing criteria"} + + class FakeClient: def get_clock(self): close = datetime.now(timezone.utc) + timedelta(hours=2) @@ -71,7 +83,13 @@ def get_account(self): class FakePort: - """Engine-seam fake: every broker call reports a response to the observer.""" + """Engine-seam fake: every broker call reports a response to the observer. + + Mirrors AlpacaPaperTransport's current semantics: a sub-penny POST is answered + with a 422 (by default Alpaca's documented minimum-price-variance body), then a + client-id lookup 404, and classified by the real transport.documented_refusal; + a cancel sends the DELETE to a known order whatever its cached status and + treats 404/422 as data once the follow-up read shows it terminal.""" scenario = {} instances = [] @@ -115,9 +133,16 @@ async def submit(self, order): self.in_flight_at_first_post = [json.loads(m.read_text()) for m in self.scenario["run_root"].glob("*/IN_FLIGHT")] self.posts += 1 - if Decimal(order["limit_price"]) != Decimal(order["limit_price"]).quantize(Decimal("0.01")): - self.observer({"kind": "submit", "status": 422}) - raise TransportError("submission unresolved; no automatic retry") + if violates_minimum_price_variance(order["limit_price"]): + status = self.scenario.get("c04_status", 422) + self.observer({"kind": "submit", "status": status}) + await self._http("read", 404) # client-id absence lookup + refusal = documented_refusal(FakeAPIError(self.scenario.get("c04_body", DOCUMENTED_SUB_PENNY)), + status, order) + if status in (401, 403, 404) or refusal is not None: + raise RejectedSubmission(status, refusal) + self.reasons.add("submission_ambiguous") + raise AmbiguousSubmission("submission unresolved; no automatic retry") self.observer({"kind": "submit", "status": 200}) record = {"client_order_id": order["client_order_id"], "id": "b-%d" % self.posts, "symbol": "SPY", "side": "buy", "qty": "1", "filled_qty": "0", "filled_avg_price": None, @@ -134,18 +159,20 @@ async def cancel(self, cid): raise TransportError("cancellation requires an owned durable intent") if self.scenario.get("cancel_raises_first") and self.cancel_calls == 1: raise RuntimeError("provider text that must not be recorded") - await self._http("read", 200) order = self.orders[cid] - if order["status"] in TERMINAL: - if self.scenario.get("delete_when_terminal"): - await self._http("cancel", 422) - self.deletes.append(cid) + if self.scenario.get("legacy_short_circuit") and order["status"] in TERMINAL: + await self._http("read", 200) # the pre-2026-09-24 engine: lookup, then no DELETE return dict(order) - await self._http("cancel", 204) + answer = self.scenario.get("terminal_delete_answer", 422) if order["status"] in TERMINAL else 204 + await self._http("cancel", answer) self.deletes.append(cid) - order.update(status="canceled", updated_at_ns=time.time_ns()) - if self.scenario.get("leak_position_on_cancel"): - self.positions = [{"symbol": "SPY", "qty": "1", "avg_entry_price": "300"}] + if answer == 204: + order.update(status="canceled", updated_at_ns=time.time_ns()) + if self.scenario.get("leak_position_on_cancel"): + self.positions = [{"symbol": "SPY", "qty": "1", "avg_entry_price": "300"}] + elif answer not in (404, 422): + self.reasons.add("cancellation_unresolved") + await self._http("read", 200) self.sink(dict(order)) return dict(order) @@ -212,13 +239,14 @@ async def wrapper(harness, port): def markers(self, name="CLEANUP_REQUIRED"): return list(self.root.glob("*/" + name)) - def test_full_sequence_records_engine_state_and_honest_c04(self): + def test_full_sequence_reaches_native_faults_passed_offline(self): code, receipt, port = self.run_harness(run_root=self.root) cases = {c["id"]: c for c in receipt["cases"]} + prefix = receipt["client_id_prefix"] self.assertEqual([c["id"] for c in receipt["cases"]], ["C01", "C02", "C05", "C04"]) # Write-ahead marker existed before the first POST and was removed after flat proof. self.assertEqual(port.in_flight_at_first_post, [ - {"run_id": receipt["client_id_prefix"].rstrip("-"), "client_id_prefix": receipt["client_id_prefix"], + {"run_id": prefix.rstrip("-"), "client_id_prefix": prefix, "at": port.in_flight_at_first_post[0]["at"]}]) self.assertEqual(self.markers("IN_FLIGHT"), []) self.assertFalse(receipt["in_flight_marker_present"]) @@ -227,38 +255,85 @@ def test_full_sequence_records_engine_state_and_honest_c04(self): self.assertEqual(cases["C01"]["detail"]["limit_price"], "300.00") self.assertEqual(cases["C02"]["outcome"], "passed") self.assertEqual(cases["C02"]["detail"]["ledger"]["status"], "canceled") - self.assertEqual(cases["C05"]["outcome"], "passed") - self.assertFalse(cases["C05"]["detail"]["delete_sent"]) - self.assertFalse(cases["C05"]["detail"]["broker_refusal_observed"]) - # The engine Ledger refuses a sub-penny >= $1 price before any POST. - self.assertEqual(cases["C04"]["outcome"], "unobserved") - self.assertEqual(cases["C04"]["evidence_class"], "none") - self.assertEqual(cases["C04"]["requests"], []) - self.assertEqual(cases["C04"]["detail"]["unobserved_reason"], - "engine_refused_before_send: invalid_price_increment") - self.assertEqual(port.posts, 1) - self.assertEqual(port.deletes, [receipt["client_id_prefix"] + "c01"]) - self.assertEqual(receipt["status"], "native_faults_incomplete") + # C05: the second cancel sends the DELETE; the broker's 422 is data. + c05 = cases["C05"] + self.assertEqual((c05["outcome"], c05["evidence_class"]), ("passed", "native_paper")) + self.assertEqual([r["kind"] for r in c05["requests"]], ["cancel", "read"]) + self.assertTrue(c05["detail"]["delete_sent"]) + self.assertEqual(c05["detail"]["cancel_http_statuses"], [422]) + self.assertTrue(c05["detail"]["broker_refusal_observed"]) + self.assertEqual(c05["detail"]["ledger_before"], c05["detail"]["ledger_after"]) + self.assertEqual(c05["detail"]["new_transport_freeze_reasons"], []) + # C04: the sub-penny order reaches the broker and its documented 422 is definitive. + c04 = cases["C04"] + self.assertEqual((c04["outcome"], c04["evidence_class"]), ("passed", "native_paper")) + self.assertEqual(c04["detail"]["limit_price"], "240.0001") + self.assertEqual([(r["kind"], r["status"]) for r in c04["requests"]], [("submit", 422), ("read", 404)]) + self.assertEqual(c04["detail"]["ledger"]["status"], "broker_refused") + self.assertEqual(c04["detail"]["ledger_refusal"], {"http_status": 422, "refusal": SUB_PENNY_REFUSAL}) + self.assertEqual(c04["detail"]["effect_before"], c04["detail"]["effect_after"]) + self.assertEqual(receipt["c04_pre_send_exemption"], + "FaultLedger exempts only client id %sc04 from invalid_price_increment" % prefix) + self.assertEqual(port.posts, 2) + self.assertEqual(port.deletes, [prefix + "c01", prefix + "c01"]) + self.assertEqual(receipt["cleanup"]["cancels"], []) self.assertTrue(receipt["cleanup"]["flat"]) - self.assertEqual(code, 1) + self.assertEqual((receipt["status"], code), ("native_faults_passed", 0)) self.assertEqual(self.markers(), []) for case in ("C01", "C02"): self.assertEqual(cases[case]["evidence_class"], "native_paper") - # C05's GET-by-client-id reached the broker but no DELETE was sent. - self.assertEqual(cases["C05"]["evidence_class"], "engine_short_circuit") - self.assertEqual([r["kind"] for r in cases["C05"]["requests"]], ["read"]) plan_sha = __import__("hashlib").sha256((HARNESS.parent / "plan.json").read_bytes()).hexdigest() self.assertEqual(receipt["plan_sha256"], plan_sha) - def test_c05_delete_sent_is_native_paper(self): - code, receipt, port = self.run_harness(delete_when_terminal=True) + def test_legacy_short_circuit_is_never_native_evidence(self): + # The engine as run on 2026-09-23: a client-id GET found the order terminal, no DELETE. + code, receipt, port = self.run_harness(legacy_short_circuit=True) c05 = {c["id"]: c for c in receipt["cases"]}["C05"] - self.assertEqual((c05["outcome"], c05["evidence_class"]), ("passed", "native_paper")) - self.assertTrue(c05["detail"]["delete_sent"]) - self.assertTrue(c05["detail"]["broker_refusal_observed"]) - # C04 is still refused before send, so the run cannot pass. + self.assertEqual((c05["outcome"], c05["evidence_class"]), ("passed", "engine_short_circuit")) + self.assertFalse(c05["detail"]["delete_sent"]) + self.assertFalse(c05["detail"]["broker_refusal_observed"]) self.assertEqual((receipt["status"], code), ("native_faults_incomplete", 1)) + def test_c05_unresolved_delete_answer_fails(self): + code, receipt, port = self.run_harness(terminal_delete_answer=500) + cases = {c["id"]: c for c in receipt["cases"]} + self.assertEqual(cases["C05"]["outcome"], "failed") + self.assertEqual(cases["C05"]["detail"]["new_transport_freeze_reasons"], ["cancellation_unresolved"]) + self.assertEqual(cases["C04"]["outcome"], "not_run") + self.assertEqual(port.posts, 1) + self.assertEqual((receipt["status"], code), ("native_faults_failed", 1)) + + def test_c05_404_answer_is_data(self): + code, receipt, port = self.run_harness(terminal_delete_answer=404) + c05 = {c["id"]: c for c in receipt["cases"]}["C05"] + self.assertEqual((c05["outcome"], c05["evidence_class"]), ("passed", "native_paper")) + self.assertEqual(c05["detail"]["cancel_http_statuses"], [404]) + self.assertEqual((receipt["status"], code), ("native_faults_passed", 0)) + + def test_c04_definitive_403_passes(self): + code, receipt, port = self.run_harness(c04_status=403) + c04 = {c["id"]: c for c in receipt["cases"]}["C04"] + self.assertEqual(c04["outcome"], "passed") + self.assertEqual(c04["detail"]["ledger_refusal"], {"http_status": 403, "refusal": None}) + self.assertEqual((receipt["status"], code), ("native_faults_passed", 0)) + + def test_c04_undocumented_422_stays_ambiguous_and_fails(self): + # "client_order_id must be unique" proves an order exists: never a definitive refusal. + for label, body in (("duplicate", {"code": 40010001, "message": "client_order_id must be unique"}), + ("other_code", dict(DOCUMENTED_SUB_PENNY, code=40010001))): + with self.subTest(label=label): + FakePort.instances = [] + self.root = self.state_base / ("native-faults-" + label) + code, receipt, port = self.run_harness(c04_body=body) + c04 = {c["id"]: c for c in receipt["cases"]}["C04"] + self.assertEqual(c04["outcome"], "failed") + self.assertEqual(c04["detail"]["ledger"]["status"], "reserved") + self.assertIsNone(c04["detail"]["ledger_refusal"]) + self.assertEqual(c04["detail"]["error"]["type"], "AmbiguousSubmission") + # An ambiguous submission is never proven flat: the run is marked for manual recovery. + self.assertFalse(receipt["cleanup"]["flat"]) + self.assertEqual((receipt["status"], code), ("cleanup_required", 3)) + def test_flat_start_refusal_makes_no_write(self): code, receipt, port = self.run_harness(positions=[{"symbol": "AAPL", "qty": "3", "avg_entry_price": "1"}]) self.assertEqual((code, receipt["status"], port.posts), (2, "refused", 0)) @@ -390,6 +465,15 @@ def test_c04_judge_branches(self): for status in (400, 422, 429, 500): # Ambiguous to the engine; never a definitive refusal. self.assertEqual(h.judge_c04([{"kind": "submit", "status": status}], refused, None, effect, effect)[0], "failed") + documented = {"http_status": 422, "refusal": SUB_PENNY_REFUSAL} + sub422 = [{"kind": "submit", "status": 422}, {"kind": "read", "status": 404}] + self.assertEqual(h.judge_c04(sub422, refused, None, effect, effect, documented)[0], "passed") + # The ledger's own refusal record decides; a 422 without it, or on a live intent, fails. + self.assertEqual(h.judge_c04(sub422, refused, None, effect, effect, + {"http_status": 422, "refusal": None})[0], "failed") + self.assertEqual(h.judge_c04(sub422, ambiguous, None, effect, effect, documented)[0], "failed") + self.assertEqual(h.judge_c04([{"kind": "submit", "status": 500}], refused, None, effect, effect, + documented)[0], "failed") self.assertEqual(h.judge_c04(sub, ambiguous, {"type": "AmbiguousSubmission"}, effect, effect)[0], "failed") moved = {"positions": {"SPY": "1"}, "cash_delta_usd": "-1"} self.assertEqual(h.judge_c04(sub, refused, None, effect, moved)[0], "failed") @@ -408,6 +492,25 @@ def test_c05_judge_records_refusal_and_freeze(self): outcome, detail = h.judge_c05(state, state, None, {"cancellation_unresolved"}, effect, effect, refusal) self.assertEqual(outcome, "failed") self.assertEqual(h.judge_c05(state, state, {"type": "TransportError"}, set(), effect, effect, [])[0], "failed") + unresolved = [{"kind": "cancel", "status": 503}, {"kind": "read", "status": 200}] + self.assertEqual(h.judge_c05(state, state, None, set(), effect, effect, unresolved)[0], "failed") + outcome, detail = h.judge_c05(state, state, None, set(), effect, effect, [{"kind": "read", "status": 200}]) + self.assertEqual((outcome, detail["delete_sent"]), ("passed", False)) # run_cases then marks it engine_short_circuit + + def test_fault_ledger_exempts_only_the_c04_client_id(self): + with tempfile.TemporaryDirectory() as tmp: + ledger = h.FaultLedger(Path(tmp) / "ledger.sqlite3", sub_penny_client_id="nf-x-c04") + now = time.time() + ledger.start_trial(now) + quote = safety.Quote("SPY", "600", "600.02", now) + args = {"quote": quote, "now": now, "market_open": True, "session_close": now + 7200, + "stop_file": Path(tmp) / "STOP"} + for cid in ("nf-x-c01", "nf-x-c040", "other"): + with self.assertRaisesRegex(safety.SafetyError, "invalid_price_increment"): + ledger.reserve_intent(cid, "SPY", "buy", "1", "240.0001", **args) + self.assertTrue(ledger.reserve_intent("nf-x-c04", "SPY", "buy", "1", "240.0001", **args).newly_reserved) + self.assertTrue(ledger.reserve_intent("nf-x-c01", "SPY", "buy", "1", "240.00", **args).newly_reserved) + ledger.close() def test_c01_c02_judges_use_ledger_state(self): state = {"status": "new", "filled_qty": "0", "submit_attempted": True, "broker_id_recorded": True}