Order Placement Guide
What the venue and the paperbroker_client 0.2.8 wheel actually accept — order types, time-in-force, trading hours, reject reasons, and cancel behavior for VN30F derivatives.
0. TL;DR quick reference
Instrument used throughout: HNXDS:VN30F26xx (VN30F, tick 0.1). All times are ICT unless noted.
| Action | Verdict | Notes |
|---|---|---|
| Place LIMIT / LO (DAY) | ✅ Do this | The only reliably accepted order — use it by default. |
| Place MARKET | ⚠️ Careful | Only survives the continuous session when the server has a price band. Biggest single source of rejects. |
| Place ATO | ❌ Can't | Client has no ATO order type; orders before 09:00 are rejected. |
| Place ATC | ❌ Can't | Server supports ATC, but the client can only emit LIMIT/MARKET. During 14:30–14:45 only LO survives. |
| TIF GTC | ❌ Rejected | outside allowed same-day trading session |
| TIF IOC | ❌ Rejected | Rejected the same way as GTC, even mid-session. |
| TIF DAY | ✅ Only one | The only accepted TIF — never pass tif=, use the default. |
| MARKET with price=None | ❌ Crashes | Raises a client-side TypeError — price is a required positional argument even for MARKET. |
| Any order after 14:45 | ❌ Rejected | the market is closed for the day |
| MARKET during 14:30–14:45 | ❌ Rejected | only LO or ATC orders are accepted during the ATC session |
| Cancel a filled/cancelled order | ⚠️ Benign | Order not found or already cancelled — handle idempotently. |
| Cancel an order in the auction queue | ❌ Too late | Too late to cancel: order is already in auction queue |
| EOD flatten | ✅ Before 14:25–14:29 | Use crossing LO or a priced MARKET; don't let it spill into the ATC session. |
1. Order types & time-in-force
The client emits only LIMIT and MARKET
Under the hood the client maps exactly two values — anything else silently becomes MARKET:
Only DAY is accepted — GTC and IOC are rejected
- GTC (59=1) → rejected with Order rejected: outside allowed same-day trading session.
- IOC (59=3) → rejected the same way, even for a valid LIMIT order in the middle of the continuous session.
- DAY (59=0) → the only accepted TIF.
ATO / ATC: the venue knows them, the client can't send them
- The server's ATC-session reject text (only LO or ATC orders are accepted) confirms the venue has an ATC order type — but the 0.2.7 client cannot emit it. So during the ATC auction, LO is the only order type you can actually place.
- There is no evidence of an order-accepting ATO window on the paper venue: MARKET orders sent between 08:45 and 09:00 are rejected as outside allowed same-day trading session. Treat 09:00 as the start of the effective order-entry window.
2. Placing a LIMIT (LO) order
The API call
The FIX envelope the client emits carries only standard tags (11 ClOrdID, 55/207 Symbol/Exchange, 54/38 Side/Qty, 40/44 OrdType/Price, 59 TIF=0, 60 TransactTime). The account (tag 1) and username (tag 553) are injected automatically — you don't set them.
Accompanying rules
- Round to tick 0.1 before sending. A clean pattern: Decimal(str(px)).quantize(Decimal("0.1"), ROUND_HALF_UP). Prices taken from bar close / book median are usually already on-tick.
- Don't trade against yourself. A SELL quote that crosses your own resting bid is rejected with Order would match against your own resting order (self-trade prevention). Two-sided quoting must check your own book first.
- Size to your margin first. The server auto-rejects with Insufficient balance to place order. Call client.get_max_placeable(symbol, price, side) before placing a batch of orders.
- PendingNew (39=A) is not acceptance. The server may ack PendingNew and then reject shortly after. Only treat an order as live once you receive [NEW] (150=0), and only treat a position as open on [TRADE].
- Use per-day, per-bot order stores. DAY-TIF orders die with the exchange session, so a store named orders-{YYYYMMDD}.db avoids resurrecting stale orders across days (which makes the broker reject every cancel).
3. MARKET orders
How it actually behaves
- The client sends 40=1; the server always re-classifies it to 40=K (market, leftover as limit) in every ExecutionReport.
- The price in tag 44 is still required, but the venue ignores it when matching — a MARKET BUY sent with 44=1936.8 can fill at 1937.6. You must supply a price anyway; None is rejected client-side.
- MARKET can fill during the continuous session, but only when the server has published a price band.
The four ways MARKET dies
| Reject text (tag 58) | When |
|---|---|
| ceiling/floor price band is unavailable | Band not published: lunch break, session edges, data gaps — can even happen mid-session. |
| outside allowed same-day trading session | Before 09:00, after 14:30 (outside ATC), or any non-DAY TIF. |
| only LO or ATC orders are accepted during the ATC session | 14:30:00–14:44:59. |
| the market is closed for the day | At or after 14:45. |
Rules for using MARKET
- Always pass a price (a crossing top-of-book reference): the API requires it positionally, FIX tag 44 always ships, and the server matches at the true market price anyway.
- Only use it during 09:00–14:25, and only to exit (flatten / stop-loss). Opening a position with MARKET is not recommended.
- Back off on ceiling/floor band unavailable. This reject repeats indefinitely if you retry immediately — an SL re-armed every 2–3s produces a reject storm that floods your logs and stalls your loop. There is no server rate-limit, so it's on you to throttle.
- Never MARKET after 14:30. Switch to a crossing LO if you must still exit during the ATC session; stop sending entirely after 14:45.
- Safer alternative: crossing LO. An aggressive LIMIT that crosses the book (e.g. by a few ticks) fills immediately like a MARKET but never hits band-unavailable or ATC rejects — as long as you're inside the order-entry window.
4. Trading hours (ICT)
| Window | Venue state | LO (LIMIT DAY) | MARKET |
|---|---|---|---|
| < 09:00 (incl. 08:45–09:00 “ATO”) | Pre-session | ❌ treat as unavailable | ❌ outside session |
| 09:00–11:30 | Morning continuous | ✅ | ⚠️ only if band + marketable |
| 11:30–13:00 | Lunch break | market quiet | ❌ band unavailable |
| 13:00–14:30 | Afternoon continuous | ✅ | ⚠️ as morning |
| 14:25–14:30 | Edge into ATC | ✅ | 🎲 fills or band-unavailable |
| 14:30–14:45 (ATC) | Closing auction | ✅ only surviving type | ❌ only LO or ATC |
| ≥ 14:45 | Closed | ❌ | ❌ market closed |
5. Reject reason catalog
The server never sets OrdRejReason (103) — the only way to know why an order was rejected is to read the Text (58) field, exposed via client.get_order_text(cl_ord_id) and the fix:order:rejected event payload. There are no session-level rejects (35=3) or business rejects (35=j); every failure is a well-formed business reject carrying a Text.
Order rejects (35=8, 150=8)
| Text (58) | Order type | What to do |
|---|---|---|
| ceiling/floor price band is unavailable | MARKET | Back off ≥30s; don't retry immediately. Prefer a crossing LO. |
| outside allowed same-day trading session | MARKET, or any TIF=GTC/IOC | Two causes, one text: wrong window or wrong TIF. Gate by time; never pass tif=. |
| Insufficient balance to place order | LIMIT | Margin check failed. Stop the order tier; call get_max_placeable first. |
| only LO or ATC orders are accepted during the ATC session | MARKET in 14:30–14:45 | Switch to a crossing LO. |
| the market is closed for the day | MARKET after 14:45 | Stop sending. Nothing survives. |
| Order would match against your own resting order (self-trade prevention) | LIMIT | Check your own resting book before quoting the other side. |
Cancel rejects (35=9)
Only two reasons exist — see §6.
Observed order states
PendingNew(A) → New(0) → PartiallyFilled(1)/Filled(2) | Canceled(4) | Rejected(8) | Expired(C) (rare, MARKET only). DoneForDay / Replaced / Suspended are never observed.
6. Cancel behavior
API: cancel_order(cl_ord_id, timeout=2.0) → (is_terminal, status). Each cancel attempt gets its own ClOrdID — {original}-CXL1, -CXL2, … (FIX requires every ClOrdID to be unique within a session, so retrying with a fixed suffix was a duplicate). Since 0.2.8 the cancel result is trustworthy enough to branch on: a rejected cancel rolls the local status back to the order's real live state instead of leaving it at PendingCancel.
| CxlRejReason (102) | Text (58) | Nature |
|---|---|---|
| 1 (Unknown order) | Order not found or already cancelled | Benign race: the order just filled or was already cancelled when your cancel arrived. Handle idempotently — not a bug. |
| 0 (Too late) | Too late to cancel: order is already in auction queue | The order has entered the periodic auction queue and cannot be pulled. To avoid this, don't leave an LO resting right up to 14:30 unless you accept an ATC fill. |
Both reasons are recoverable. On either one the client rolls the order's status back to its real live state (a partially filled order comes back as PartiallyFilled with its fill count intact) and automatically sends an OrderStatusRequest (35=H) to reconcile with the venue — so your retry loop keeps making progress instead of stalling on a status nothing can clear.
Reading the result
7. Client limitations & workarounds (wheel 0.2.8)
Known limitations of the 0.2.8 wheel and the recommended way to work around each. None of these require changes on your side beyond the workaround shown.
| Limitation | Effect | Workaround |
|---|---|---|
| Framework builds exits with price=None | The exit order raises a swallowed TypeError and never reaches the wire. | Override your order-planning step to stamp a reference price onto every order whose price is None before it is sent. |
| Default kill-switch flatten also uses price=None | The built-in kill switch cannot flatten. | Subclass / patch it to supply a crossing price before relying on halt_and_flatten. |
| OrderRequest(sub_account=...) | Passes an unsupported kwarg → TypeError, order dropped. | Don't set sub_account on OrderRequest; use client.use_sub_account() instead. |
| Legacy façade client.session.place_order defaults tif="GTC" | Every order gets rejected by the venue. | Only call client.place_order (defaults to DAY). |
| ExecutionReport carries no symbol/side tags | The built-in position tracker bails; ctx.positions stays at 0 — your bot is blind to its position. | Enrich fills from your local order map and reconcile periodically via REST get_portfolio_by_sub(). |
| recover_pending_orders() doesn't reconcile with the venue | After a restart it rehydrates from the local SQLite store only, so an order the venue already settled can come back looking live. | Follow recovery with request_order_status() per rehydrated order, or reconcile via REST get_orders(today, today). |
| get_derivative_orders() returns 404 on this broker | Not usable. | Use get_orders(today, today). |
| Tag 60 is UTC on the ack but ICT on the fill | Parsing fill time from tag 60 is off by 7h. | Prefer tag 52 / your own log timestamp; distrust tag 60 on fills. |
| REST symbol is fixed per contract month | After expiry the bot fails silently. | Roll INSTRUMENT in your .env before the expiry date (3rd Thursday). |
| Unknown ord_type string → silent MARKET | "ATO"/"LO" become MARKET orders. | Only ever pass "LIMIT" or "MARKET". |
8. New-bot checklist
- Place LO + DAY only (don't pass ord_type/tif); round price to tick 0.1 with Decimal HALF_UP.
- Every exit path (EOD / SL / kill) carries a reference price — stamp a price onto any price=None order; test with a simulated close signal before going live.
- Write your own time gate: place only 09:00–14:30 (LO also 14:30–14:45); latch EOD flatten from 14:25; stop entirely after 14:45.
- Call get_max_placeable before any batch; the total qty of a grid/ladder must fit your margin.
- Handle fix:order:rejected and read Text(58): band unavailable → back off ≥30s; Insufficient balance → stop the order tier; only LO or ATC → switch to a crossing LO.
- Bound your cancel retries, tolerate Order not found or already cancelled, branch on get_last_cancel_reject() rather than on Text(58), accept that auction-queue orders can't be pulled.
- Don't trust ctx.positions — reconcile from REST on startup, periodically, and around session boundaries.
- Use a per-day, per-bot order_store_path=orders-{YYYYMMDD}.db; call recover_pending_orders() on startup.
- Use a real-time market-data feed (not sparse polling) for any serious bot; handle feed stalls.
- Roll the contract month: update INSTRUMENT in your .env before expiry (3rd Thursday).