Skip to content

fix(t212): espacer les appels par endpoint selon les limites officielles (fin des 429) - #96

Merged
laurentvv merged 7 commits into
mainfrom
fix/t212-rate-limit-budget
Sep 29, 2026
Merged

laurentvv merged 7 commits into
mainfrom
fix/t212-rate-limit-budget

Conversation

@laurentvv

Copy link
Copy Markdown
Owner

Problème

GET /equity/orders est limité à 1 req / 5 s par compte (doc officielle). La lecture des stops était appelée plusieurs fois en quelques secondes par cycle (synchro, cliquet, vente) → 429 à chaque cycle, « Stop fetch error — local stop state preserved » ×16, cliquet de stop qui travaille à l'aveugle. Les 429 étaient réessayés après coup au lieu d'être évités.

Changements

  • src/t212_rate_limit.py (nouveau) : RateGate (créneaux réservés sous verrou, sûr en multi-thread) et GatedSession. Tous les appels get/delete/post de l'exécuteur passent par le régulateur.
  • Le régulateur ne fait que retarder : aucun retry, aucun cache. Aucun changement de sémantique pour les POST d'ordres (règle « jamais de retry aveugle » intacte).
  • safe_request : respecte Retry-After, pénalise le bucket après un 429.
  • _get_active_stop_order : un nouvel essai après 429 ; deux 429 restent ERROR (jamais NOT_FOUND, invariant « Failed fetch ≠ empty »).
  • tests/__init__.py : pas d'attente réelle dans la suite (HTTP mocké).
  • TRADING212_API_GUIDE.md : tableau des limites officielles + section « compte réel : types d'ordres » (limit/stop/stop-limit disponibles par l'API en réel depuis le 29/01/2026, source : annonce staff T212 + centre d'aide ; à confirmer par un premier stop réel de faible montant).

Vérification

  • Sonde réelle lecture seule sur la démo : 4 lectures successives de GET /equity/orders espacées de 5,4 à 5,5 s, toutes 200, stop #55701244265 @1386.81 identique à l'état local.
  • Suite complète : 329 passed. Les 2 échecs restants en local sont les 2 tests corrigés par la PR test: isoler chaque test dans un CWD temporaire (plus d'écriture dans la base du démo) #93 (isolation CWD) ; indépendants de ce changement.
  • ruff check OK. 12 nouveaux tests (tests/test_t212_rate_limit.py).

Impact sur la durée des cycles

Les appels rapprochés attendent jusqu'à ~5,5 s (stops) / ~10,5 s (historique). Les cycles durent aujourd'hui ~1,5 min pour un budget de 40 min : marge très large.

🤖 Generated with Claude Code

…les (fin des 429)

GET /equity/orders est limité à 1 req / 5 s par compte. La lecture des stops était
appelée plusieurs fois en quelques secondes par cycle (synchro, cliquet, vente) :
429 à chaque cycle, « Stop fetch error — local stop state preserved » ×16, cliquet
travaillant à l'aveugle.

- src/t212_rate_limit.py : RateGate (créneaux réservés sous verrou, sûr en multi-thread)
  + GatedSession(requests.Session) : get/delete/post passent tous par le régulateur.
  Limites relevées dans la doc officielle (orders, positions, summary, cash, history...).
  Le régulateur ne fait que retarder : aucun retry, aucun cache, donc aucun changement de
  sémantique pour les POST d'ordres.
- safe_request : respecte Retry-After et pénalise le bucket après un 429.
- _get_active_stop_order : un nouvel essai après 429 ; deux 429 restent ERROR (jamais
  NOT_FOUND : invariant « Failed fetch ≠ empty »).
- tests/__init__.py désactive l'attente réelle pour la suite (HTTP mocké).
- TRADING212_API_GUIDE.md : tableau des limites + types d'ordres disponibles en réel.

Vérifié sur la démo (lecture seule) : 4 lectures successives espacées de 5,4-5,5 s, toutes 200.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comment thread src/t212_rate_limit.py
"GET /equity/positions": 1.1, # 1 req / 1 s
"GET /equity/account/summary": 5.5, # 1 req / 5 s
"GET /equity/account/cash": 2.2, # 1 req / 2 s
"GET /equity/history/orders": 10.5, # 6 req / 1 min

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: 10.5 s on GET /equity/history/orders stretches two existing polling loops that were tuned for a 2 s cadence

src/t212_executor.py:849 (_confirm_fill) polls this endpoint up to FILL_CONFIRM_ATTEMPTS = 6 with FILL_CONFIRM_DELAY = 2.0, and src/t212_executor.py:343 (get_t212_order_history) paginates up to max_pages = 20.

With the gate in front, one SELL confirmation now costs up to ~63 s instead of ~10 s, and it runs in _execute_sell_order after _release_standing_stop_if_reserved has already cancelled the standing GTC stop (src/t212_executor.py:1719 then :1746). The window where the position has no broker-side protection grows by ~50 s per sale. Pagination worst case is ~200 s per sync_state_from_t212 call, which runs per ticker.

Worth either lowering FILL_CONFIRM_ATTEMPTS/max_pages for these call sites, or reusing a single cached history response for the poll instead of re-hitting the endpoint.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/t212_rate_limit.py
"GET /equity/account/summary": 5.5, # 1 req / 5 s
"GET /equity/account/cash": 2.2, # 1 req / 2 s
"GET /equity/history/orders": 10.5, # 6 req / 1 min
"POST /equity/orders/stop": 2.2, # 1 req / 2 s

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Doc/code mismatch: limit and stop_limit order endpoints are documented as gated but have no interval

TRADING212_API_GUIDE.md lists POST /equity/orders/stop (et limit, stop_limit) with a 2,2 s spacing, but T212_INTERVALS only defines the /stop bucket. bucket_for() explicitly routes /equity/orders/limit and /equity/orders/stop_limit to their own bucket names, which fall through self._intervals.get(bucket, 0.0) to 0.0 and are therefore never delayed. Since the doc says these order types are available on live accounts, the first limit/stop-limit order added later would be ungated.

Suggested change
"POST /equity/orders/stop": 2.2, # 1 req / 2 s
"POST /equity/orders/stop": 2.2, # 1 req / 2 s
"POST /equity/orders/limit": 2.2, # 1 req / 2 s
"POST /equity/orders/stop_limit": 2.2, # 1 req / 2 s

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/t212_executor.py Outdated
):
return "FOUND", o
return "NOT_FOUND", None
if resp.status_code == 429 and attempt == 0:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The 429 retry misses the 400 + TooManyRequests form that safe_request treats as rate limiting

safe_request (src/t212_executor.py:745) accepts both 429 and 400 with TooManyRequests in the body as the broker's rate-limit signal — and the code comment on post_order_market (src/t212_executor.py:782) documents that T212 uses that form too. This new retry branch only matches resp.status_code == 429, so a 400 TooManyRequests falls straight through to the logger.warning + break and the fetch returns ERROR — the exact Stop fetch error — local stop state preserved outcome this PR is meant to eliminate.

Consider matching both forms here as well, and calling GATE.penalize for each.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/t212_executor.py
if resp.status_code == 429 and attempt == 0:
GATE.penalize("GET /equity/orders", retry_after_seconds(resp, 5.5))
logger.info("Active stop orders fetch: 429 — nouvel essai après espacement.")
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The retry's delay is implicit — nothing here sleeps unless _t212_session happens to be a GatedSession

This continue relies entirely on GATE.penalize() plus the gated session re-entering GATE.wait() on the next _t212_session.get(...). If _t212_session is ever swapped (tests patch it with a MagicMock, and tests/test_t212_rate_limit.py does exactly that at line 158), the retry fires instantly with zero delay and re-hits the rate limit.

That also means test_429_then_200_returns_found proves the loop iterates, but not that it actually waits — the gate is never exercised in that test. An explicit time.sleep(retry_after_seconds(resp, 5.5)) here would make the delay local and directly assertable.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tests/__init__.py Outdated

import os

os.environ.setdefault("T212_RATE_GATE_DISABLED", "1")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: setdefault leaves the gate active if the variable is already set to anything but 1

RateGate._active() (src/t212_rate_limit.py:92) treats the gate as enabled for every value other than the exact string "1". So an environment that already exports T212_RATE_GATE_DISABLED=0 (or true, or an empty string) keeps the pacing on, and every mocked GET /equity/orders in the suite sleeps 5,5 s for real — a silent multi-minute test run rather than a clear failure.

Suggested change
os.environ.setdefault("T212_RATE_GATE_DISABLED", "1")
os.environ[DISABLE_ENV] = "1"

(or inline the literal "1" and import DISABLE_ENV).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 5 Issues Found | Recommendation: Address before merge

Incremental pass over 08ee5cd..466a1a6 (2 files, +4/-3). The only new commit rewords the unconfirmed-sell warning at src/t212_executor.py:1667 and refreshes the TestUnconfirmedSellReprotects docstring. The reword is accurate (it now names both causes) and the stale-docstring finding is resolved; no new defects were introduced. The two src/t212_rate_limit.py findings from the previous pass are no longer carried forward: that file is outside this incremental diff, and the limit/stop_limit interval suggestion has in fact been applied at HEAD (T212_INTERVALS now carries POST /equity/orders/limit: 2.2 and stop_limit: 2.2).

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 4
Issue Details (click to expand)

WARNING

File Line Issue
src/t212_executor.py 858 SELL_CONFIRM_ATTEMPTS = 3 shortens the window but does not close it: the position still has no broker stop while the polls run.

SUGGESTION

File Line Issue
src/t212_executor.py 969 The 429 retry's delay is implicit — nothing sleeps unless _t212_session is a GatedSession.
src/t212_executor.py 893 The SELL loop's only spacing is the rate gate, an implicit and untested dependency.
src/t212_executor.py 1667 Message fixed, severity not: a request failure can leave an open position with no stop and still logs only WARNING.
tests/test_t212_rate_limit.py 214 The test patches _t212_session out, so it does not verify the gate-spacing invariant the production comment relies on.
Files Reviewed (2 files, incremental diff 08ee5cd..466a1a6)
  • src/t212_executor.py - 4 issues
  • tests/test_t212_rate_limit.py - 1 issue

Fix these issues in Kilo Cloud

Previous Review Summaries (4 snapshots, latest commit 08ee5cd)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 08ee5cd)

Status: 7 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 5
Issue Details (click to expand)

WARNING

File Line Issue
src/t212_rate_limit.py 42 10.5 s on GET /equity/history/orders stretches two existing polling loops tuned for a 2 s cadence.
src/t212_executor.py 858 Cutting the SELL confirmation to 3 attempts widens the no-stop gap, it does not close it.

SUGGESTION

File Line Issue
src/t212_rate_limit.py 43 Doc/code mismatch: limit and stop_limit order endpoints are documented as gated but have no interval.
src/t212_executor.py 969 The 429 retry's delay is implicit — it only sleeps when _t212_session happens to be a GatedSession.
src/t212_executor.py 893 The SELL loop has an implicit, untested dependency on the rate gate for its only spacing.
src/t212_executor.py 1667 The unconfirmed-fill branch collapses a broker refusal and a failed request into one WARNING, and the message assumes the optimistic cause — a request failure leaves an open position with no stop and no CRITICAL.
tests/test_t212_rate_limit.py 237 Class docstring still says the stop is replaced "si la position existe encore", but the _position_exists guard is gone.
tests/test_t212_rate_limit.py 214 This test does not verify the invariant the production comment relies on (_t212_session is patched out).
Files Reviewed (2 files, incremental diff 806012f..08ee5cd)
  • src/t212_executor.py - 4 issues
  • tests/test_t212_rate_limit.py - 2 issues

Fix these issues in Kilo Cloud

Previous review (commit 806012f)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
src/t212_executor.py 1764 On the new unconfirmed-SELL branch, the stop re-placement is gated on _position_exists(...) is not False, but that endpoint is the one documented (line 1682) as serving a ~30 min pre-sale snapshot on T212 demo. A sale that actually filled therefore gets a SELL STOP for shares that no longer exist: the broker refuses it, _place_stop_order returns (None, None), and the code logs a false CRITICAL "position SANS protection" — or accepts a phantom stop and stores its id in state["active_position"] for the ratchet/self-heal to chase.

SUGGESTION

File Line Issue
src/t212_executor.py 1657 _replace_released_stop now serves both the failed-POST path and the unconfirmed-fill path, but its log (and the vente échouée CRITICAL below it) still asserts the sale failed, sending incident triage to the wrong conclusion.
src/t212_executor.py 969 Carried from the previous review: the 429 retry's delay is implicit — it relies on GATE.penalize() plus _t212_session being a GatedSession; with a mocked session the retry fires instantly.
Resolved since the previous review
File Line Resolution
src/t212_executor.py 858 Fixed — the unconfirmed-SELL branch now re-places the released stop (_replace_released_stop, new test TestUnconfirmedSellReprotects).
src/t212_executor.py 893 Fixed — test_sell_history_url_with_query_maps_to_the_gated_bucket now pins bucket_for/T212_INTERVALS for the query-bearing SELL history URL.
tests/test_t212_rate_limit.py 214 Fixed — the same new test covers the invariant the SELL loop's comment relies on.
src/t212_rate_limit.py 43 Fixed — POST /equity/orders/limit and /stop_limit now have 2,2 s intervals.
Files Reviewed (2 files, incremental diff e82e57e..806012f)
  • src/t212_executor.py - 2 issues (+1 carried)
  • tests/test_t212_rate_limit.py - 0 issues

Fix these issues in Kilo Cloud

Previous review (commit e82e57e)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
src/t212_executor.py 858 SELL confirmation cut to 3 attempts (~21 s). On sell_fill is None the caller returns without re-placing the stop released before the POST (unlike _handle_failed_sell), so a 2xx-but-not-yet-filled sale leaves the position with no broker stop until the next cycle's self-heal.
src/t212_rate_limit.py 42 The 10,5 s gate on GET /equity/history/orders still stretches the SELL window; the poll loop was shortened but the endpoint spacing (and the unprotected interval it creates) is unchanged.

SUGGESTION

File Line Issue
src/t212_executor.py 893 The SELL loop no longer sleeps, so its only pacing is the rate gate — an implicit, untested coupling. With the gate disabled or the bucket at interval 0.0, 3 polls collapse into one burst.
tests/test_t212_rate_limit.py 214 test_sell_polls_history_only_a_few_times_and_never_sleeps_on_top_of_the_gate patches _t212_session, so it never asserts the invariant the production comment relies on (the history bucket interval / bucket_for mapping of the query-bearing SELL URL).
Files Reviewed (4 files)
  • src/t212_executor.py - 2 issues
  • src/t212_rate_limit.py - 1 issue
  • tests/test_t212_rate_limit.py - 1 issue
  • tests/__init__.py - 0 issues

Fix these issues in Kilo Cloud

Previous review (commit 2d6fe82)

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
src/t212_rate_limit.py 42 The 10,5 s gate on GET /equity/history/orders stretches _confirm_fill (6 polls x 2 s, ~63 s) and history pagination (up to 20 pages, ~200 s), and the SELL poll runs after the standing stop has been cancelled — a ~50 s longer unprotected window per sale.
src/t212_executor.py 955 The new 429 retry only matches status_code == 429, missing the 400 + TooManyRequests form that safe_request (line 745) and post_order_market (line 782) both treat as rate limiting — so the exact Stop fetch error outcome this PR targets still occurs.

SUGGESTION

File Line Issue
src/t212_rate_limit.py 43 T212_INTERVALS covers only POST /equity/orders/stop; the limit and stop_limit buckets documented as 2,2 s fall through to interval 0.0 and are never delayed.
src/t212_executor.py 958 The 429 retry's delay is implicit — it only exists if _t212_session is a GatedSession, so test_429_then_200_returns_found never actually exercises the gate.
tests/__init__.py 9 setdefault leaves the gate on when T212_RATE_GATE_DISABLED is already set to anything but the literal 1, silently turning the test suite into a multi-minute run.
Files Reviewed (5 files)
  • TRADING212_API_GUIDE.md - 0 issues
  • src/t212_executor.py - 2 issues
  • src/t212_rate_limit.py - 2 issues
  • tests/__init__.py - 1 issue
  • tests/test_t212_rate_limit.py - 0 issues

Fix these issues in Kilo Cloud


Reviewed by free · Input: 0 · Output: 0 · Cached: 0

laurentvv and others added 3 commits September 29, 2026 22:14
…t/stop_limit (revue Kilo)

- SELL_CONFIRM_ATTEMPTS = 3 : l'historique est limité à 6 req/min (espacement 10,5 s) ; 6 essais
  coûtaient ~63 s alors que le stop broker est déjà libéré avant la vente. 3 essais ≈ 21 s ; le
  sleep redondant est supprimé pour SELL (le régulateur espace déjà les lectures). BUY inchangé ;
- POST /equity/orders/limit et /stop_limit sont maintenant régulés comme /stop (la doc les liste
  et ils sont disponibles en réel) ;
- tests : intervalles des 4 endpoints d'ordre, confirmation SELL bornée, BUY inchangé.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…Requests

Review Kilo #96 : _get_active_stop_order ne testait que le 429 alors que
safe_request et post_order_market traitent aussi 400+TooManyRequests comme une
limite de débit. Helper _is_rate_limited partagé par les trois sites ; le délai
est testé explicitement via GATE.penalize ; T212_RATE_GATE_DISABLED forcé dans
tests/__init__.py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comment thread src/t212_executor.py
url_pos = f"{_get_t212_base_url()}/equity/positions"
url_hist = f"{_get_t212_base_url()}/equity/history/orders?limit=10&ticker={t212_ticker}"
for attempt in range(FILL_CONFIRM_ATTEMPTS):
attempts = FILL_CONFIRM_ATTEMPTS if side == "BUY" else SELL_CONFIRM_ATTEMPTS

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Cutting the SELL confirmation to 3 attempts widens the no-stop gap, it does not close it

_execute_sell_order releases the standing broker stop before the POST (_release_standing_stop_if_reserved). If the POST returns 2xx but the fill is not yet in /equity/history/orders, this function now returns None after ~21 s and the caller (src/t212_executor.py:1750) does logger.error(...) + return — it never calls the emergency re-place that _handle_failed_sell performs for a failed POST. The position is then left with no broker-side stop (GO-gate 2) and a stale stop_order_id in the state until the next cycle's self-heal.

Before this change the same path had ~63 s (6 attempts x 2 s) plus the gate spacing to surface the fill, so shortening the window increases the probability of hitting it. Suggest re-placing the stop at prev_stop_price on the sell_fill is None branch too (the values are already in scope in _execute_sell_order), instead of relying on the next cycle.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/t212_executor.py
except (requests.exceptions.RequestException, ValueError, TypeError) as e:
logger.debug(f"Fill confirmation poll error: {e}")
if attempt < FILL_CONFIRM_ATTEMPTS - 1:
if attempt < attempts - 1 and side == "BUY":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The SELL loop now has an implicit, untested dependency on the rate gate for its only spacing

Removing time.sleep for SELL is correct only as long as GATE really spaces GET /equity/history/orders by 10,5 s. If the gate is disabled (T212_RATE_GATE_DISABLED=1) or that bucket ever falls back to interval 0.0, the 3 polls collapse into a single burst in microseconds and _confirm_fill(side="SELL") degenerates to a single read — the confirmation window silently disappears.

A cheap guard that does not re-introduce the long window: sleep a small floor (e.g. min(FILL_CONFIRM_DELAY, ...)) between SELL attempts, or assert T212_INTERVALS[bucket_for("GET", url_hist)] > 0 at the call site so a missing bucket fails loudly instead of silently.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

class TestSellConfirmationStaysShort:
"""Revue Kilo PR #96 : l'espacement de 10,5 s de l'historique ne doit pas rallonger la fenêtre de confirmation."""

def test_sell_polls_history_only_a_few_times_and_never_sleeps_on_top_of_the_gate(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: This test does not verify the invariant the production comment relies on

_t212_session is patched out, so the test only counts mock calls and proves time.sleep is not called. The claim in _confirm_fill ("l'espacement de 10,5 s entre lectures d'historique est déjà assuré par le régulateur") is never asserted: nothing pins the GET /equity/history/orders interval, nor that the SELL URL (which carries ?limit=10&ticker=...) still maps to that bucket. Renaming a bucket or adding a query-bearing URL variant would break the pacing silently while this test stays green.

Consider adding an assertion that bucket_for("GET", url_hist) == "GET /equity/history/orders" and that T212_INTERVALS[...] >= 10, so the gate the SELL path depends on is covered.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

…onfirmée

Review Kilo #96 : après 3 essais sans fill confirmé, la position restait sans stop
jusqu'au self-heal du cycle suivant. Si la position existe encore (ou état inconnu),
le stop est reposé au niveau précédent (helper _replace_released_stop partagé avec
_handle_failed_sell). Test explicite du couplage boucle de vente / porte d'historique.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comment thread src/t212_executor.py Outdated
)
# Le stop a été libéré avant l'ordre : si la position existe encore (ou si on ne sait pas),
# on le repose tout de suite plutôt que d'attendre le self-heal du cycle suivant.
if stop_released and prev_stop_price and _position_exists(t212_ticker, headers) is not False:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: _position_exists is a read the repo already knows can be stale for ~30 min — an unconfirmed SELL is exactly the case where the sale may already have filled

On this branch sell_fill is None means unconfirmed, not not executed: the 2xx POST may have filled and only /equity/history/orders is lagging. The comment at src/t212_executor.py:1682 documents that on 2026-09-08 T212 demo served the pre-sale position (and cash) for ~30 min after a confirmed sale, so _position_exists can return True for a position that no longer exists.

In that case _replace_released_stop POSTs a SELL STOP for shares the account no longer holds: the broker refuses it (selling-equity-not-owned), _place_stop_order returns (None, None), and the code logs logger.critical(... position SANS protection ...) — a false CRITICAL for a position that is in fact closed. Worse, in the variants where the POST is accepted you end up with a phantom stop plus state["active_position"]["stop_order_id"] pointing at it, which the ratchet/self-heal then keeps chasing next cycle.

Cross-check the order endpoint (_get_active_stop_order, the source the AGENTS invariants call authoritative for the order side) or treat the unknown branch as reconcile-next-cycle, or at minimum downgrade the unconfirmed path's logger.critical to a warning since the position may be gone.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/t212_executor.py Outdated
save_portfolio_state(state, t212_ticker)
logger.info(
f"🔐 Stop de secours re-placé #{re_stop_id} @ {pos_state['stop_price']:.2f} "
f"après échec de la vente {t212_ticker}."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Log wording is wrong for the new call site — this is no longer only an "échec de la vente"

_replace_released_stop is now shared between _handle_failed_sell (POST failed — the sale demonstrably did not happen) and the new unconfirmed-fill branch, where the sale may have executed at the broker. During an incident, après échec de la vente and the vente échouée wording in the CRITICAL right below send triage to the wrong conclusion.

Suggest a neutral phrasing that covers both (e.g. après vente non confirmée) — or pass the reason in.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

…s de CRITICAL à tort

Review Kilo #96 : _position_exists peut servir un instantané périmé (~30 min sur le démo) ;
la garde est retirée (le courtier refuse le stop si la vente est passée) et l'échec de
repose après fill non confirmé est un WARNING neutre, plus un CRITICAL « vente échouée ».

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comment thread src/t212_executor.py
else:
# Fill non confirmé : la vente a probablement été exécutée (le courtier refuse alors un stop sur des
# actions qu'on ne possède plus) ; la synchro du cycle suivant tranchera. Pas d'alerte CRITICAL à tort.
logger.warning(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The unconfirmed-fill branch collapses two opposite outcomes into one WARNING, and the message asserts the more optimistic cause

_place_stop_order returns (None, None) both when the broker refuses the stop (selling-equity-not-owned, i.e. the sale did fill) and when the request simply failed (network / 429 / 4xx — see the logger.error at src/t212_executor.py:925). In the second case the position is still open and now has no stop at all, which AGENTS.md calls "a CRITICAL event", yet the only signal is this warning that explicitly assumes refus courtier probable si la vente est exécutée. The new wording is right for the refusal case; the ambiguity it introduces is the unhandled one. Suggest distinguishing them by status code (e.g. keep a CRITICAL when resp is None/5xx, the path where the position is provably still open) and keeping the warning for selling-equity-not-owned.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tests/test_t212_rate_limit.py Outdated


class TestUnconfirmedSellReprotects:
"""Vente acceptée (2xx) mais fill non confirmé : le stop libéré est reposé si la position existe encore."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Class docstring is now stale after the _position_exists guard was dropped

It still says le stop libéré est reposé si la position existe encore, but the production path no longer consults the position snapshot at all (src/t212_executor.py:1773 now calls _replace_released_stop unconditionally and the test asserts _position_exists is never called). A future reader will look for a position check that no longer exists.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

…string de test

Review Kilo #96.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@laurentvv
laurentvv merged commit b4482a0 into main Sep 29, 2026
2 checks passed
@laurentvv
laurentvv deleted the fix/t212-rate-limit-budget branch September 29, 2026 21:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant