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.

ActionVerdictNotes
Place LIMIT / LO (DAY)✅ Do thisThe only reliably accepted order — use it by default.
Place MARKET⚠️ CarefulOnly survives the continuous session when the server has a price band. Biggest single source of rejects.
Place ATO❌ Can'tClient has no ATO order type; orders before 09:00 are rejected.
Place ATC❌ Can'tServer supports ATC, but the client can only emit LIMIT/MARKET. During 14:30–14:45 only LO survives.
TIF GTC❌ Rejectedoutside allowed same-day trading session
TIF IOC❌ RejectedRejected the same way as GTC, even mid-session.
TIF DAY✅ Only oneThe only accepted TIF — never pass tif=, use the default.
MARKET with price=None❌ CrashesRaises a client-side TypeErrorprice is a required positional argument even for MARKET.
Any order after 14:45❌ Rejectedthe market is closed for the day
MARKET during 14:30–14:45❌ Rejectedonly LO or ATC orders are accepted during the ATC session
Cancel a filled/cancelled order⚠️ BenignOrder not found or already cancelled — handle idempotently.
Cancel an order in the auction queue❌ Too lateToo late to cancel: order is already in auction queue
EOD flatten✅ Before 14:25–14:29Use 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:

OrdType = LIMIT if ord_type == "LIMIT" else MARKET
Trap: "ATO", "ATC", "LO", "MTL", and even lowercase "limit" all silently turn into a MARKET (40=1) order. There is no validation and no warning. To place an LO you must pass the exact string "LIMIT" (or leave the default).

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.
Rule: never pass tif=. The default of client.place_order is "DAY", which is correct and sufficient.

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

cl_ord_id = client.place_order( full_symbol="HNXDS:VN30F2607", # required, EXCHANGE:SYMBOL form side="BUY", # "BUY" | "SELL" qty=1, # int > 0 price=1922.5, # already rounded to tick 0.1 # ord_type defaults to "LIMIT", tif defaults to "DAY" — pass nothing else )

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

  1. 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.
  2. 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.
  3. 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.
  4. 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].
  5. 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 unavailableBand not published: lunch break, session edges, data gaps — can even happen mid-session.
outside allowed same-day trading sessionBefore 09:00, after 14:30 (outside ATC), or any non-DAY TIF.
only LO or ATC orders are accepted during the ATC session14:30:00–14:44:59.
the market is closed for the dayAt or after 14:45.

Rules for using MARKET

  1. 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.
  2. Only use it during 09:00–14:25, and only to exit (flatten / stop-loss). Opening a position with MARKET is not recommended.
  3. 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.
  4. 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.
  5. 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.
Watch out — the framework's default MARKET path. The signal-driven alpha framework builds close / take-profit / stop-loss orders with price=None, ord_type="MARKET" and then drops the price kwarg — which makes place_order() raise a TypeError that the framework swallows as “skipping”. The exit order then never reaches the wire. See §7 for the workaround.

4. Trading hours (ICT)

WindowVenue stateLO (LIMIT DAY)MARKET
< 09:00 (incl. 08:45–09:00 “ATO”)Pre-session treat as unavailable outside session
09:00–11:30Morning continuous⚠️ only if band + marketable
11:30–13:00Lunch breakmarket quiet band unavailable
13:00–14:30Afternoon continuous⚠️ as morning
14:25–14:30Edge into ATC🎲 fills or band-unavailable
14:30–14:45 (ATC)Closing auction only surviving type only LO or ATC
≥ 14:45Closed market closed
EOD recommendation: start flattening at 14:25 and finish before 14:29, using a priced MARKET or a crossing LO; latch so you don't re-enter. If you still hold a position at 14:30, switch to a crossing LO — don't fire MARKET into the ATC auction.
Note on the wheel's TradingSession: its hardcoded open time is 09:15, which is the HOSE equities schedule and does not match HNX derivatives (order entry from 09:00, ATC 14:30–14:45). Don't use TradingSession.is_open() as your order gate for derivatives — write your own gate from the table above.

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 typeWhat to do
ceiling/floor price band is unavailableMARKETBack off ≥30s; don't retry immediately. Prefer a crossing LO.
outside allowed same-day trading sessionMARKET, or any TIF=GTC/IOCTwo causes, one text: wrong window or wrong TIF. Gate by time; never pass tif=.
Insufficient balance to place orderLIMITMargin check failed. Stop the order tier; call get_max_placeable first.
only LO or ATC orders are accepted during the ATC sessionMARKET in 14:30–14:45Switch to a crossing LO.
the market is closed for the dayMARKET after 14:45Stop sending. Nothing survives.
Order would match against your own resting order (self-trade prevention)LIMITCheck 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 cancelledBenign 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 queueThe 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

is_terminal, status = client.cancel_order(cl_ord_id, timeout=2.0) # status is the order's real live state — "New", "PartiallyFilled", # "Canceled", … — never a stranded "PendingCancel". rej = client.get_last_cancel_reject(cl_ord_id) if rej: # {"code": "1", "reason": "Order not found or already cancelled.", # "at": datetime(...), "cancel_cl_ord_id": "a1b2c3d4-CXL1"} log.info("cancel refused (%s): %s", rej["code"], rej["reason"]) if not is_terminal: client.request_order_status(cl_ord_id) # 35=H — already sent for you
Recommended pattern: retry the cancel under an explicit attempt bound — each attempt goes out with a fresh -CXLn id, so retrying is safe. Use get_last_cancel_reject() to tell a refused cancel from one still in flight rather than string-matching Text(58), and call request_order_status() when you want to force reconciliation. Only cancel orders that have reached [NEW] — cancelling an un-acked order raises TimeoutError, so wrap it in try/except.
Upgrading from 0.2.7? If you previously gated a retry on status == "PendingCancel", that guard was doubling as your retry limiter — the status no longer sticks, so the loop will genuinely re-send. Give it an explicit bound. Anything matching the literal -CXL suffix (log greps, dashboards, reconciliation keyed on the string) needs to allow the trailing attempt number.

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.

Fixed in 0.2.8: a rejected cancel used to leave the order at PendingCancel with nothing able to clear it, so the order never settled and the retry loop hung. The status now rolls back and the client reconciles with the venue on its own. If you carry a _abandoned flag or a stuck-order timeout as a workaround for that, you can drop it.
LimitationEffectWorkaround
Framework builds exits with price=NoneThe 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=NoneThe 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 tagsThe 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 venueAfter 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 brokerNot usable.Use get_orders(today, today).
Tag 60 is UTC on the ack but ICT on the fillParsing 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 monthAfter 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

  1. Place LO + DAY only (don't pass ord_type/tif); round price to tick 0.1 with Decimal HALF_UP.
  2. 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.
  3. 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.
  4. Call get_max_placeable before any batch; the total qty of a grid/ladder must fit your margin.
  5. 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.
  6. 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.
  7. Don't trust ctx.positions — reconcile from REST on startup, periodically, and around session boundaries.
  8. Use a per-day, per-bot order_store_path=orders-{YYYYMMDD}.db; call recover_pending_orders() on startup.
  9. Use a real-time market-data feed (not sparse polling) for any serious bot; handle feed stalls.
  10. Roll the contract month: update INSTRUMENT in your .env before expiry (3rd Thursday).
Back to Docs FIX Guide API Reference