API Services
Detailed API methods for each service in Python SDK 3.3.2
1. Authentication (TokenManager)
Access via auth.token_manager or directly on auth (delegate). See also Client Classes.
Authenticate with OTP
token = auth.authenticate(otp="<OTP>")
print(f"Expires at: {token.expires_at}")authenticate(otp=None, transaction_id=None): otp and transaction_id are mutually exclusive.
Request OTP
# Returns OTPResponse(message, transaction_id)
# transaction_id is None when the OTP is sent by SMS/email
otp = auth.request_otp_typed()
# Older form: returns the raw server dict
raw = auth.request_otp()For Smart OTP, approve on the app and pass the transaction_id:
auth.ensure_authenticated(transaction_id=otp.transaction_id)The SDK polls every Config.otp_poll_interval_ms (default 5000) for up to otp_poll_max_wait_ms (default 60000, i.e. 12 polls). If it is still not approved in time: SmartOTPPendingError, call again with the same transaction_id. If rejected or expired: SmartOTPRejectedError. See Exceptions.
Refresh token
token = auth.refresh()Auto-ensure authentication
access_token = auth.ensure_authenticated(
otp=None, transaction_id=None, poll_interval_ms=None, poll_max_retries=None,
)Each apiKey has only one active session. A token is treated as expired 30 seconds early.
2. Account (AccountService)
Access via trading.account.
Get account list
accounts = trading.account.get_account_info()
for acc in accounts:
print(f"{acc.account_no} - {acc.account_type}")Returns: list[Account] — each Account has account_no: str, account_type: AccountType.
3. Market Data (MarketDataService)
Access via data.market_data. Only auth.authenticate() is needed, no OTP.
Note: Methods that return a single object (
get_index_summary,get_index_summary_historical,get_board_summary,get_board_summary_historical,get_securities_info) returnNonewhen the server has no data (not an exception). A{"code": 204}response gives an empty list for the methods that return a list.
3.1. OHLC Data (candlestick)
Intraday:
| Method | Timeframe |
|---|---|
get_ohlc_1minute(symbol) | 1 minute |
get_ohlc_3minute(symbol) | 3 minutes |
get_ohlc_5minute(symbol) | 5 minutes |
get_ohlc_15minute(symbol) | 15 minutes |
get_ohlc_1hour(symbol) | 1 hour |
ohlc = data.market_data.get_ohlc_1minute("SSI")
for candle in ohlc:
print(f"{candle.trading_date}: O={candle.open_price} H={candle.high_price} L={candle.low_price} C={candle.close_price} V={candle.volume}")Historical (pass a date range + pagination):
| Method | Timeframe |
|---|---|
get_ohlc_1minute_historical(symbol, from_date, to_date, page, size) | 1 minute |
get_ohlc_3minute_historical(...) | 3 minutes |
get_ohlc_5minute_historical(...) | 5 minutes |
get_ohlc_15minute_historical(...) | 15 minutes |
get_ohlc_1hour_historical(...) | 1 hour |
get_ohlc_1day_historical(...) | 1 day |
ohlc = data.market_data.get_ohlc_1day_historical(
symbol="SSI",
from_date="2026/03/27 00:00:00",
to_date="2026/03/27 00:00:00",
page=1,
size=100,
)Historical parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
symbol | str | (required) | Stock symbol |
from_date | str | (required) | Start date, YYYY/MM/DD or YYYY/MM/DD HH:MM:SS |
to_date | str | (required) | End date, same format, from_date <= to_date |
page | int | 1 | Page (>= 1) |
size | int | 1000 | Records per page (>= 1) |
Returns: list[OHLCData]
Note:
get_ohlc_1week_historicalandget_ohlc_1month_historicalalways raiseValidationErrorbecause the server does not support1w/1M(error 400210). Valid timeframes: 1d, 1m, 3m, 5m, 15m, 1h.download_ohlc_1minuteanddownload_ohlc_1dayraiseNotImplementedError. For minute/hour bars, ato_datein the future is clamped to today.
Iterate over all bars across pages:
| Method | Description |
|---|---|
get_ohlc_all(symbol, timeframe, from_date=None, to_date=None, size=1000, ascending=False) | Returns the list[OHLCData] for the whole range. ascending=True for oldest first |
iter_ohlc(symbol, timeframe, from_date=None, to_date=None, size=1000) | Iterator over each bar (the async version uses async for) |
from ssi_sdk.enums import Timeframe
# from_date/to_date default to today
bars = data.market_data.get_ohlc_all("SSI", Timeframe.MINUTE_5, ascending=True)
for bar in data.market_data.iter_ohlc("SSI", Timeframe.DAY_1, "2026/01/01", "2026/03/31"):
print(bar.trading_date, bar.close_price)The OHLC endpoint returns the newest bars first and gives no page count, so the SDK stops at the first empty or short page.
3.2. Market Indexes
# All indexes
indices = data.market_data.get_indexes()
# By board
from ssi_sdk.enums import Board
indices = data.market_data.get_indexes_by_board(Board.HOSE)
for idx in indices:
print(f"{idx.index} - {idx.index_name}")Returns: list[MarketIndexes]
3.3. Index Summary
# Current summary
summary = data.market_data.get_index_summary("VNINDEX")
# Historical summary
summary = data.market_data.get_index_summary_historical("VNINDEX", "2025/01/15")
# Board summary
summary = data.market_data.get_board_summary(Board.HOSE)
# Historical board summary
summary = data.market_data.get_board_summary_historical(Board.HOSE, "2025/01/15")Returns: MarketIndexSummary | None
Note:
Board.DERIVATIVESis only valid on the stream and master data, not on the REST data endpoints.
3.4. Securities Info
# Single symbol
info = data.market_data.get_securities_info("SSI")
# By index
securities = data.market_data.get_securities_info_by_index("VN30")
# By board
securities = data.market_data.get_securities_info_by_board(Board.HOSE)Returns: SecuritiesInfo | None for a single symbol; list[SecuritiesInfo] by index / board.
3.5. Securities Summary
# Current summary
summary = data.market_data.get_securities_summary("SSI")
# Historical summary
summary = data.market_data.get_securities_summary_historical(
symbol="SSI",
from_date="2025/01/01",
to_date="2025/01/31",
)
# By index
summary = data.market_data.get_securities_summary_by_index("VN30")
# By index historical
summary = data.market_data.get_securities_summary_by_index_historical(
index="VN30",
from_date="2025/01/01",
to_date="2025/01/31",
)Returns: list[SecuritiesSummary]
securitiesSummary and masterdata are paginated automatically by the SDK using pagesCount. Today's summary/master data may be cached on the server for up to 1 hour.
3.6. Master data (ceiling, floor, reference price)
today = data.market_data.get_master_data()
history = data.market_data.get_master_data_historical("2026/03/01", "2026/03/05")
for m in today[:3]:
print(m.board, m.symbol, m.ceiling, m.floor, m.ref_price)Returns: list[MasterData] (board, symbol, trading_date, ceiling, floor, ref_price).
3.7. Corporate actions
GET /api/v3/data/corporateActions. Two methods, split by time: one for today, one for a past date range. The SDK fetches every page for you (page size 100) and returns one combined list.
# Today (all symbols, or one symbol)
today = data.market_data.get_corporate_actions()
ssi_today = data.market_data.get_corporate_actions(symbol="SSI", language="en")
# Past range: at most 5 days
past = data.market_data.get_corporate_actions_historical(
from_date="2026/10/08",
to_date="2026/10/12",
symbol=None, # None = all symbols
language="vn", # "vn" (default) or "en"
)
for e in past:
print(e.symbol, e.event_title, e.exright_date, e.record_date, e.issue_date, e.exchange, e.ratio)With AsyncData, the same methods exist with the same arguments; call them with await.
| Method | Arguments | Description |
|---|---|---|
get_corporate_actions | symbol: str | None = None, language: str = "vn" | Corporate actions of today (dividends, bonus shares, rights issues, ...) |
get_corporate_actions_historical | from_date: str, to_date: str, symbol: str | None = None, language: str = "vn" | Corporate actions in a past date range |
| Argument | Description |
|---|---|
from_date / to_date | YYYY/MM/DD |
symbol | One symbol, or None for all symbols |
language | "vn" (default) or "en" |
Returns: list[CorporateAction] (symbol, event_title, exright_date, record_date, issue_date, exchange, ratio). Every field is a str exactly as the server sends it: dates stay YYYY/MM/DD text and ratio is not parsed. A field the server leaves blank (for example a newly announced event with no ex-right date or ratio yet) is "". When the server has no data, the list is empty. See Models: CorporateAction.
The SDK checks these rules before it sends anything; a violation raises ValidationError and no request is made:
from_dateandto_datemust be inYYYY/MM/DDformat.from_datemust not be afterto_date.to_datemust be at most 5 days afterfrom_date.symbol(if given) must be a single symbol made of letters and digits, at most 20 characters.languagemust bevnoren(case-insensitive).
get_corporate_actions takes no dates: it uses today's date. An error from the server raises APIError, and a response with an unexpected shape also raises APIError.
Note: These two methods were written from the official API documentation and have not yet been run against the live server (see the SDK changelog). Test in your own environment first.
4. Portfolio (PortfolioService)
Access via trading.portfolio.
4.1. Account Balance
# Equity balance
equity_balance = trading.portfolio.get_equity_balance("<EQUITY_ACCOUNT_NO>")
if equity_balance:
print(f"Account balance: {equity_balance.account_balance}")
# Derivative balance
derivative_balance = trading.portfolio.get_derivative_balance("<DERIVATIVE_ACCOUNT_NO>")
if derivative_balance:
print(f"Withdrawable: {derivative_balance.withdrawable}")4.2. Positions
# Equity positions: None when there are no positions
positions = trading.portfolio.get_equity_positions("<EQUITY_ACCOUNT_NO>") or []
for pos in positions:
print(f"{pos.symbol}: {pos.quantity} shares | Cost: {pos.cost_price}")
# All derivative positions
derivative_positions = trading.portfolio.get_derivative_positions("<DERIVATIVE_ACCOUNT_NO>")
# Open derivative positions only
open_pos = trading.portfolio.get_open_derivative_positions("<DERIVATIVE_ACCOUNT_NO>")
# Closed derivative positions only
closed_pos = trading.portfolio.get_closed_derivative_positions("<DERIVATIVE_ACCOUNT_NO>")Note: When the account has no positions (or the server returns
{"code": 204}),get_equity_positionsandget_derivative_positionsreturnNone(the type hint sayslist). Check forNonebefore iterating. In 3.3.2,get_open_derivative_positionsandget_closed_derivative_positionsread straight from that result, so they may raiseAttributeErrorwhen there are no positions; to be safe, callget_derivative_positionsand check forNone.get_equity_balance,get_derivative_balance,get_equity_ppmmrandget_derivative_ppmmrcan also returnNone.
4.3. Order Book
# Today's orders
today_orders = trading.portfolio.get_today_orders("<EQUITY_ACCOUNT_NO>")
for order in today_orders:
print(f"{order.order_id}: {order.symbol} {order.side} {order.quantity}@{order.price} - {order.status}")
# Historical orders
historical_orders = trading.portfolio.get_historical_orders(
"<EQUITY_ACCOUNT_NO>",
from_date="2025/01/01",
to_date="2025/01/31",
)A {"code": 204} response gives an empty list. Order.os_quantity is int | None (it is None on REST).
4.4. PPMMR
# Equity PPMMR
equity_ppmmr = trading.portfolio.get_equity_ppmmr("<EQUITY_ACCOUNT_NO>")
if equity_ppmmr:
print(f"Purchasing power: {equity_ppmmr.purchasing_power}")
# Derivative PPMMR
derivative_ppmmr = trading.portfolio.get_derivative_ppmmr("<DERIVATIVE_ACCOUNT_NO>")Portfolio Method Overview
| Group | Method | Returns |
|---|---|---|
| Balance | get_equity_balance(account_no) | EquityAccountBalance | None |
get_derivative_balance(account_no) | DerivativeAccountBalance | None | |
| Positions | get_equity_positions(account_no) | list[EquityPosition] (None when empty) |
get_derivative_positions(account_no) | list[AllDerivativePosition] (None when empty) | |
get_open_derivative_positions(account_no) | list[DerivativePosition] | |
get_closed_derivative_positions(account_no) | list[DerivativePosition] | |
| Order Book | get_today_orders(account_no) | list[Order] |
get_historical_orders(account_no, from_date, to_date) | list[Order] | |
| PPMMR | get_equity_ppmmr(account_no) | EquityPPMMR | None |
get_derivative_ppmmr(account_no) | DerivativePPMMR | None |
5. Trading (TradingService)
Access via trading.trading. State-changing commands (place, modify, cancel, batch, FCO) are signed RSA-SHA256 and need Config.private_key. The SDK never auto-retries POST/PUT/DELETE.
5.1. Place Orders
from ssi_sdk.enums import OrderSide, OrderType
# Limit order (LO)
result = trading.trading.place_limit_order(
account_no="<ACCOUNT_NO>",
symbol="SSI",
side=OrderSide.BUY,
quantity=100,
price=66000,
)
print(f"Order ID: {result.order_id}, Status: {result.status}")
# Market order (MTL)
result = trading.trading.place_market_order(
account_no="<ACCOUNT_NO>", symbol="SSI", side=OrderSide.BUY, quantity=100,
)
# ATO order (at the open)
result = trading.trading.place_ato_order(
account_no="<ACCOUNT_NO>", symbol="SSI", side=OrderSide.BUY, quantity=100,
)
# ATC order (at the close)
result = trading.trading.place_atc_order(
account_no="<ACCOUNT_NO>", symbol="SSI", side=OrderSide.SELL, quantity=100,
)
# Custom order type
result = trading.trading.place_order(
account_no="<ACCOUNT_NO>", symbol="SSI", side=OrderSide.BUY,
quantity=100, price=66000, order_type=OrderType.LO,
)client_request_id is an optional parameter on every place_* method (at most 20 characters). If omitted, the SDK generates one. Reusing an id that was already used today: the server returns 409, the SDK raises DuplicateRequestError and does not resend. Prices can be passed as int, float, Decimal or str; prefer Decimal/str to avoid floating-point error.
5.2. Modify Orders
Modify only the price or the quantity (not both at once).
# Modify price by client_request_id
result = trading.trading.modify_order_price(
account_no="<ACCOUNT_NO>", client_request_id="REQ123", price=68000,
)
# Modify price by order_id
result = trading.trading.modify_order_price_by_order_id(
account_no="<ACCOUNT_NO>", order_id="ORD123", price=68000,
)
# Modify quantity by client_request_id
result = trading.trading.modify_order_quantity(
account_no="<ACCOUNT_NO>", client_request_id="REQ123", quantity=200,
)
# Modify quantity by order_id
result = trading.trading.modify_order_quantity_by_order_id(
account_no="<ACCOUNT_NO>", order_id="ORD123", quantity=200,
)5.3. Cancel Orders
# Cancel by client_request_id
result = trading.trading.cancel_order(
account_no="<ACCOUNT_NO>", client_request_id="REQ123",
)
# Cancel by order_id
result = trading.trading.cancel_order_by_order_id(
account_no="<ACCOUNT_NO>", order_id="ORD123",
)5.4. Batch Order
Send many orders in one request: POST /order/batch (place) or DELETE /order/batch (cancel). The whole body is signed once with RSA-SHA256 and the result comes back per order. Use it when you need to place or cancel many orders at once (for example a basket). For a single order, use place_order / cancel_order above.
Requirements: Trading / AsyncTrading (trading.trading), authenticated with OTP or Smart OTP, and Config.private_key configured.
| Method | Parameters | Returns | Description |
|---|---|---|---|
place_batch_orders(orders) | orders: Sequence[BatchPlaceOrderItem] | BatchOrderResponse | POST /order/batch: place many orders |
cancel_batch_orders(orders) | orders: Sequence[BatchCancelOrderItem] | BatchOrderResponse | DELETE /order/batch: cancel many orders |
Order count limit
- A batch holds from 1 to
Config.max_batch_ordersorders. The default is 20. - Over the limit or an empty batch: the SDK raises
ValidationErrorimmediately, nothing is sent. - An invalid
max_batch_orders, or one below 1, is reset to 20. See Configuration.
Note: The API reference (Place batch orders) states a server-side limit of 50 orders per request, while the SDK default is 20. Raising
Config.max_batch_ordersis the caller's decision; follow the limit that applies to your API Key.
Place item (BatchPlaceOrderItem)
A TypedDict with snake_case keys:
| Key | Required | Description |
|---|---|---|
account_no | Yes | Account number |
symbol | Yes | Stock symbol |
side | Yes | OrderSide.BUY / OrderSide.SELL |
quantity | Yes | Quantity (positive integer) |
price | Yes | Price. Can be None for order types that need no price (ATO, ATC, MP) |
order_type | Yes | OrderType (LO, ATO, ATC, MP...) |
client_request_id | No | Idempotency key of the order, at most 20 characters. The SDK generates one if omitted |
Cancel item (BatchCancelOrderItem)
| Key | Required | Description |
|---|---|---|
account_no | Yes | Account number |
order_id | One of the two | Order id issued by the server |
client_request_id | One of the two | Idempotency key used when placing the order |
Each item must have exactly one of order_id / client_request_id.
Result
Both methods return BatchOrderResponse(batch_request_id, results: list[BatchOrderResult]). Each BatchOrderResult has client_request_id, order_id, status, success, error_code, error_message, client_cancel_id (only when cancelling a batch). For the attributes see Models: Batch Order.
Signing and idempotency
- The SDK builds a camelCase body
{"batchRequestId", "batchRequestTime", "orders": [...]}.batchRequestIdis a random id andbatchRequestTimeis epoch milliseconds. - The whole body is signed RSA-SHA256 and sent in the
X-Signatureheader. You do not sign anything yourself. - The server rejects a batch whose
batchRequestTimeis outside the anti-replay window (code400111). Keep the server clock accurate. - Resending a batch with an id already used today: HTTP 409, the SDK raises
DuplicateRequestError. - The per-order
client_request_idis the idempotency key. Set it yourself so you can look up and cancel the order later. - The SDK does not auto-retry
POST/DELETEon a timeout. The order may already be on the exchange: check the order book before resending.
Note: Every order in the batch is validated before sending. One bad order makes the whole batch fail (
ValidationErrorin the SDK, orAPIErrorfrom the server). Conversely, once a batch is accepted, some orders can still fail individually: always readsuccesson eachBatchOrderResult.
Sync example: place a batch then cancel it
This example sends real orders. They are buy LO orders at the floor price (far from the market) with a small quantity.
import uuid
from decimal import Decimal
from ssi_sdk import Auth, Trading, Config
from ssi_sdk.enums import OrderSide, OrderType
from ssi_sdk.exceptions import APIError, DuplicateRequestError, ValidationError
from ssi_sdk.models import BatchCancelOrderItem, BatchPlaceOrderItem
config = Config(
client_id="<CLIENT_ID>",
api_key="<API_KEY>",
api_secret="<API_SECRET>",
private_key="<PRIVATE_KEY_CONTENT>",
)
ACCOUNT_NO = "<ACCOUNT_NO>"
FLOOR_PRICE = Decimal("<FLOOR_PRICE>") # from master data (section 3.6)
with Auth(config) as auth:
auth.ensure_authenticated(otp="<OTP>")
with Trading(auth) as trading:
tag = uuid.uuid4().hex[:8]
place_items: list[BatchPlaceOrderItem] = [
{
"account_no": ACCOUNT_NO,
"symbol": "SSI",
"side": OrderSide.BUY,
"quantity": 100,
"price": FLOOR_PRICE,
"order_type": OrderType.LO,
"client_request_id": f"bt{tag}{i:02d}", # <= 20 characters
}
for i in range(1, 4)
]
if len(place_items) > config.max_batch_orders:
raise SystemExit(f"At most {config.max_batch_orders} orders per batch")
try:
response = trading.trading.place_batch_orders(place_items)
except ValidationError as e:
raise SystemExit(f"Invalid batch (nothing sent): {e}")
except DuplicateRequestError:
raise SystemExit("Batch id already used today (409): check the order book before resending")
except APIError as e:
raise SystemExit(f"Server rejected the batch: code={e.code} status={e.status_code} msg={e.message}")
placed_ok: list[str] = []
for r in response.results:
if r.success:
placed_ok.append(r.order_id)
print(f"OK {r.client_request_id} -> orderId={r.order_id} status={r.status}")
else:
print(f"FAIL {r.client_request_id} -> code={r.error_code} msg={r.error_message}")
if placed_ok:
# Each item has EXACTLY ONE of order_id / client_request_id
cancel_items: list[BatchCancelOrderItem] = [
{"account_no": ACCOUNT_NO, "order_id": oid} for oid in placed_ok
]
try:
cancel_resp = trading.trading.cancel_batch_orders(cancel_items)
except (ValidationError, DuplicateRequestError, APIError) as e:
raise SystemExit(f"Batch cancel failed: {type(e).__name__}: {e}")
for r in cancel_resp.results:
state = "OK " if r.success else "FAIL"
print(f"{state} orderId={r.order_id} cancelId={r.client_cancel_id} status={r.status}")Async example
import asyncio
import uuid
from decimal import Decimal
from ssi_sdk import AsyncAuth, AsyncTrading, Config
from ssi_sdk.enums import OrderSide, OrderType
from ssi_sdk.exceptions import APIError, DuplicateRequestError, ValidationError
config = Config(
client_id="<CLIENT_ID>",
api_key="<API_KEY>",
api_secret="<API_SECRET>",
private_key="<PRIVATE_KEY_CONTENT>",
)
ACCOUNT_NO = "<ACCOUNT_NO>"
async def main():
async with AsyncAuth(config) as auth:
await auth.ensure_authenticated(otp="<OTP>")
async with AsyncTrading(auth) as trading:
tag = uuid.uuid4().hex[:8]
items = [
{
"account_no": ACCOUNT_NO,
"symbol": "SSI",
"side": OrderSide.BUY,
"quantity": 100,
"price": Decimal("<FLOOR_PRICE>"),
"order_type": OrderType.LO,
"client_request_id": f"bt{tag}{i:02d}",
}
for i in range(1, 4)
]
try:
response = await trading.trading.place_batch_orders(items)
except ValidationError as e:
raise SystemExit(f"Invalid batch (nothing sent): {e}")
except DuplicateRequestError:
raise SystemExit("Batch id already used today (409)")
except APIError as e:
raise SystemExit(f"Server rejected the batch: code={e.code} msg={e.message}")
ok_ids = [r.order_id for r in response.results if r.success]
for r in response.results:
if not r.success:
print(f"FAIL {r.client_request_id}: {r.error_code} {r.error_message}")
if ok_ids:
cancel_items = [{"account_no": ACCOUNT_NO, "order_id": oid} for oid in ok_ids]
cancel_resp = await trading.trading.cancel_batch_orders(cancel_items)
print(f"Cancelled: {sum(1 for r in cancel_resp.results if r.success)}/{len(cancel_items)}")
asyncio.run(main())Batch error handling
| Exception | When | Sent to the server? |
|---|---|---|
ValidationError | Empty batch, over max_batch_orders, an order with a missing/invalid field, a cancel item with both or neither of order_id / client_request_id | No |
DuplicateRequestError (APIError, HTTP 409) | Batch id already used today | Reached the server. Check the order book before resending |
APIError | The server rejected the whole batch (has code, status_code, message), for example 400111 when the time is outside the replay window | Reached the server |
RateLimitError | The server returned HTTP 429 (has retry_after_ms, limit, remaining) | Reached the server, rejected |
AuthenticationError | The server returned HTTP 401/403 (invalid token or not enough permission), or not authenticated | Rejected |
Errors per order are not exceptions: they are in BatchOrderResult.success, error_code, error_message. See Exceptions. See also: Tutorial: Batch Orders, API: Place batch orders, API: Cancel batch orders. To send orders over WebSocket, see section 7 (batch_new_orders, batch_cancel_orders).
5.5. Max Buy/Sell
# With specific price
max_bs = trading.trading.get_max_buy_sell(
account_no="<ACCOUNT_NO>", symbol="SSI", price=66000,
)
print(f"Max buy: {max_bs.max_buy_quantity}")
print(f"Max sell: {max_bs.max_sell_quantity}")
# At market price
max_bs = trading.trading.get_max_buy_sell_at_market_price(
account_no="<ACCOUNT_NO>", symbol="SSI",
)Trading Method Overview
| Group | Method | Returns |
|---|---|---|
| Place | place_order(account_no, symbol, side, quantity, price, order_type, client_request_id=None) | PlaceOrderResponse |
place_limit_order(account_no, symbol, side, quantity, price, client_request_id=None) | PlaceOrderResponse | |
place_market_order(account_no, symbol, side, quantity, client_request_id=None) | PlaceOrderResponse | |
place_ato_order(account_no, symbol, side, quantity, client_request_id=None) | PlaceOrderResponse | |
place_atc_order(account_no, symbol, side, quantity, client_request_id=None) | PlaceOrderResponse | |
| Modify | modify_order_price(account_no, client_request_id, price) | ModifyOrderResponse |
modify_order_price_by_order_id(account_no, order_id, price) | ModifyOrderResponse | |
modify_order_quantity(account_no, client_request_id, quantity) | ModifyOrderResponse | |
modify_order_quantity_by_order_id(account_no, order_id, quantity) | ModifyOrderResponse | |
| Cancel | cancel_order(account_no, client_request_id) | CancelOrderResponse |
cancel_order_by_order_id(account_no, order_id) | CancelOrderResponse | |
| Batch | place_batch_orders(orders) | BatchOrderResponse |
cancel_batch_orders(orders) | BatchOrderResponse | |
| Max Buy/Sell | get_max_buy_sell(account_no, symbol, price) | MaxBuySellResponse |
get_max_buy_sell_at_market_price(account_no, symbol) | MaxBuySellResponse | |
| Conditional | place_fco_gtd(account_no, symbol, side, quantity, price, price_slip, from_date, to_date, code=None) | FCOPlaceResponse |
place_fco_stop(account_no, symbol, side, quantity, stop_price, operator, from_date, to_date, code=None) | FCOPlaceResponse | |
place_fco_stop_limit(account_no, symbol, side, quantity, price, price_slip, stop_price, operator, from_date, to_date, code=None) | FCOPlaceResponse | |
place_fco_trailing_stop(account_no, symbol, side, quantity, active_price, trailing_amount, from_date, to_date, code=None) | FCOPlaceResponse | |
place_fco_trailing_stop_limit(account_no, symbol, side, quantity, active_price, trailing_amount, price_slip, from_date, to_date, code=None) | FCOPlaceResponse | |
place_fco_oco(account_no, symbol, side, quantity, tp_active_price, sl_active_price, tp_price, sl_price, tp_slip, sl_slip, from_date, to_date, code=None) | FCOPlaceResponse | |
place_fco_bull_bear(account_no, symbol, side, quantity, price, price_slip, tp_active_price, sl_active_price, tp_price, sl_price, tp_slip, sl_slip, from_date, to_date, code=None) | FCOPlaceResponse | |
cancel_fco(fco_id, code=None) | FCOCancelResponse | |
get_fco_by_account_no(account_no, page_index, page_size) | FCOListResponse | |
get_fco_by_symbol(account_no, symbol, page_index, page_size) | FCOListResponse | |
get_fco_by_status(account_no, process_status, page_index, page_size) | FCOListResponse | |
get_fco_by_date(account_no, from_date, to_date, page_index, page_size) | FCOListResponse | |
get_fco_by_id(account_no, fco_id) | FCOInfo | None | |
get_fco_order_book(fco_id, page_index, page_size) | FCOOrderBookResponse | |
get_fco_status_history(fco_id) | list[FCOStatusHistoryItem] |
5.6. Conditional Orders (FCO)
Supports flexible conditional order types (FCO): GTD, Stop, Stop Limit, Trailing Stop, Trailing Stop Limit, OCO, and Bull Bear. Both Trading (sync) and AsyncTrading (async) support all of these methods.
Rules: from_date / to_date use the format YYYY/MM/DD HH:MM:SS, from_date <= to_date, and the window is at most 31 days. Every place_fco_* and cancel_fco has an optional code parameter (the OTP code) for when the server asks for confirmation.
from ssi_sdk.enums import OrderSide, OrderType, FCOOperator
# GTD order (Good Till Date)
gtd_res = trading.trading.place_fco_gtd(
account_no="<ACCOUNT_NO>",
symbol="SSI",
side=OrderSide.BUY,
quantity=100,
price=OrderType.MTL, # Or a specific numeric price
price_slip=0.5,
from_date="2026/07/21 00:00:00",
to_date="2026/07/28 23:00:00",
)
# Stop order (stop market)
stop_res = trading.trading.place_fco_stop(
account_no="<ACCOUNT_NO>",
symbol="SSI",
side=OrderSide.BUY,
quantity=100,
stop_price=20000,
operator=FCOOperator.GREATER_OR_EQUAL,
from_date="2026/07/21 00:00:00",
to_date="2026/07/28 23:00:00",
)
# Stop Limit order
stop_limit_res = trading.trading.place_fco_stop_limit(
account_no="<ACCOUNT_NO>",
symbol="SSI",
side=OrderSide.BUY,
quantity=100,
stop_price=20000,
price=20500,
price_slip=0.5,
operator=FCOOperator.GREATER_OR_EQUAL,
from_date="2026/07/21 00:00:00",
to_date="2026/07/28 23:00:00",
)
# Trailing Stop order
ts_res = trading.trading.place_fco_trailing_stop(
account_no="<ACCOUNT_NO>",
symbol="SSI",
side=OrderSide.BUY,
quantity=100,
active_price=20000,
trailing_amount=1000,
from_date="2026/07/21 00:00:00",
to_date="2026/07/28 23:00:00",
)
# OCO order (one cancels the other)
oco_res = trading.trading.place_fco_oco(
account_no="<ACCOUNT_NO>",
symbol="SSI",
side=OrderSide.BUY,
quantity=100,
tp_active_price=30000, # Take-profit trigger price
sl_active_price=20000, # Stop-loss trigger price
tp_price=OrderType.MP,
sl_price=OrderType.MP,
tp_slip=0.1,
sl_slip=0,
from_date="2026/07/21 00:00:00",
to_date="2026/07/28 23:00:00",
)
# Cancel a conditional order
cancel_res = trading.trading.cancel_fco(fco_id="<FCO_ID>")
# Async: same await syntax on trading from AsyncTrading
gtd_res = await trading.trading.place_fco_gtd(
account_no="<ACCOUNT_NO>", symbol="SSI", side=OrderSide.BUY, quantity=100,
price=OrderType.MTL, price_slip=0.5,
from_date="2026/07/21 00:00:00", to_date="2026/07/28 23:00:00",
)Returns: FCOPlaceResponse (fco_id) when placing, FCOCancelResponse (fco_id) when cancelling.
# Get all FCO orders for an account
fcos = trading.trading.get_fco_by_account_no(account_no="<ACCOUNT_NO>")
# Get FCO orders by symbol
fcos_symbol = trading.trading.get_fco_by_symbol(account_no="<ACCOUNT_NO>", symbol="SSI")
# Get FCO orders by processing status
fcos_status = trading.trading.get_fco_by_status(account_no="<ACCOUNT_NO>", process_status="TRIT")
# Get FCO orders by date range (a bare date is accepted)
fcos_date = trading.trading.get_fco_by_date(account_no="<ACCOUNT_NO>", from_date="2026/07/01", to_date="2026/07/21")
# Get a single FCO order by ID
fco_info = trading.trading.get_fco_by_id(account_no="<ACCOUNT_NO>", fco_id="<FCO_ID>")
# Get the order book / execution log for an FCO order
order_book = trading.trading.get_fco_order_book(fco_id="<FCO_ID>")
# Status history of an FCO order (empty gives [])
history = trading.trading.get_fco_status_history(fco_id="<FCO_ID>")
for h in history:
print(h.state, h.time, h.code, h.detail)Note: The list query methods support optional
page_indexandpage_sizeparameters for pagination, and can all be called asynchronously withawait.FCOInfoalso hasprice_slipandorder_type;FCOOrderalso hastype.
6. Realtime Streaming (StreamingService)
Access via stream.streaming. You must call connect() first. Client: Stream (sync) or AsyncStream (async, use await; callbacks may be coroutines). Stream / AsyncStream disconnect automatically when you leave with / async with.
The market data channel needs no separate OTP; the trading channel (on_order, on_order_match, on_portfolio) needs OTP.
6.1. Connection
stream.streaming.connect() # Connect WebSocket
stream.streaming.disconnect() # Disconnect
stream.streaming.wait(timeout_ms=None) # Wait for data (blocking)6.2. Setup Callbacks
Register callbacks before connect() so you do not miss the connected event.
def on_market_data(msg):
print(f"Market data: {msg}")
def on_trading_data(msg):
print(f"Trading: {msg}")
def on_heartbeat(msg):
print(f"Heartbeat: {msg.status}")
stream.streaming.on_data = on_market_data
stream.streaming.on_trading = on_trading_data
stream.streaming.on_heartbeat = on_heartbeat| Attribute | Receives | Notes |
|---|---|---|
on_data | Every market data message | Includes IndexTickMessage, IntervalMessage |
on_trading | Every trading channel message | |
on_heartbeat | Heartbeat | |
on_market_flag | MarketFlagMessage | |
on_index_summary | IndexSummaryMessage | |
on_order, on_order_match, on_portfolio | Trading messages by type | Needs OTP |
on_connection | ConnectionEvent | Connection state, see 6.9 |
Typed callbacks do not replace on_data / on_trading (they still receive every message), and setting a callback does not subscribe. Callbacks run on an SDK thread/task: keep them SHORT (count, record, push to a queue).
6.3. Subscribe Market Data
# All channels (trade + quote + room) for symbols
stream.streaming.subscribe_symbol(["SSI", "HPG", "VIC"])
# Individual channels
stream.streaming.subscribe_symbol_trade(["SSI", "HPG"])
stream.streaming.subscribe_symbol_quote(["SSI", "HPG"])
stream.streaming.subscribe_symbol_room(["SSI", "HPG"])
stream.streaming.subscribe_symbol_put_through(["SSI"])
stream.streaming.subscribe_symbol_odd_lot(["SSI"])
# OHLCV by interval
from ssi_sdk.enums import Timeframe
stream.streaming.subscribe_symbol_ohlcv(["SSI"], interval=Timeframe.MINUTE_1)
# By board
from ssi_sdk.enums import Board
stream.streaming.subscribe_board([Board.HOSE, Board.HNX])
# By index (constituents), see 6.7
stream.streaming.subscribe_index(["VNINDEX", "VN30"])
# Market
stream.streaming.subscribe_market(["HOSE"])Topic rules that apply to every subscribe: an empty list raises ValidationError("at least one symbol is required"); codes are upper-cased; each topic accepts only one filter (*, a single code, or an A-B range), and a comma list is rejected. The asset.* and margin.* topics are not supported (ValidationError). A bad argument raises ValidationError and nothing is sent to the server. For interval details see 6.7.3.
6.4. Unsubscribe
stream.streaming.unsubscribe_symbol(["SSI", "HPG"])
stream.streaming.unsubscribe_symbol_trade(["SSI"])
stream.streaming.unsubscribe_symbol_quote(["SSI"])
stream.streaming.unsubscribe_symbol_room(["SSI"])
stream.streaming.unsubscribe_symbol_put_through(["SSI"])
stream.streaming.unsubscribe_symbol_odd_lot(["SSI"])
# Unsubscribe OHLCV
stream.streaming.unsubscribe_symbol_ohlcv(["SSI"], interval=Timeframe.MINUTE_1)
stream.streaming.unsubscribe_board([Board.HOSE])
stream.streaming.unsubscribe_index(["VNINDEX"])6.5. Subscribe Trading Events
# Order status (all accounts)
stream.streaming.subscribe_order_status()
# For specific account
stream.streaming.subscribe_order_status(account_no="<ACCOUNT_NO>")
# Portfolio
stream.streaming.subscribe_portfolio()
stream.streaming.subscribe_portfolio(account_no="<ACCOUNT_NO>")6.6. Heartbeat
# Ping once
stream.streaming.ping()
# Auto-ping at an interval (milliseconds)
stream.streaming.ping(interval_ms=30000)The on_response of ping and of the subscribe methods is a callback that receives the server's ack (not data).
6.7. Real-time index data
Streaming methods for index data (VN30, VNINDEX, HNXINDEX...): the index's own tick, 1-minute/5-minute candles, summary figures, the session flag, and data of the constituent symbols. Use them for dashboards/bots that follow an index in real time. For history or snapshots, use REST (section 3).
6.7.1. Subscription kinds
| You need | Method | Topic | Message received |
|---|---|---|---|
| Tick of the index itself | subscribe_index_trade (alias subscribe_index_ticks) | trade.index.<code> | IndexTickMessage |
| 1m/5m candles of an index | subscribe_index_trade(..., interval="1m"/"5m") | trade.index.<code>@1m / @5m | IntervalMessage |
| Summary figures of an index | subscribe_index_summary | indexsummary.<code> | IndexSummaryMessage |
| Trade/quote/room of the constituent symbols | subscribe_index (alias subscribe_index_constituents) | trade.<idx>@*, quote.<idx>@*, room.<idx>@* | TradeMessage, QuoteMessage, ForeignRoomMessage |
| Trade/quote/room of a whole board | subscribe_board (alias subscribe_exchange_trades) | trade/quote/room.<BOARD>@* | same as above |
| Session flag | subscribe_market_flag (alias subscribe_session_flag) | market.flag | MarketFlagMessage |
Note:
subscribe_boardcan produce thousands of messages per second. Use it only when you really need it, and keep the callback very short.
Unconfirmed:
subscribe_indexandsubscribe_boardsend topics of the formtrade.<code>@*plus quote and room. What@*means (receiving the constituent symbols / the whole board) is the SDK's reading of the server's routing and has not been confirmed on a live server. Check withlist_subscription()and the data you actually receive in your environment.
6.7.2. Subscribe methods
| Method | Parameters | Description |
|---|---|---|
subscribe_index(indices, on_response=None) | indices: list[str] | Trade, quote, room of the constituent symbols of an index |
subscribe_board(boards, on_response=None) | boards: list[Board] | Trade, quote, room of a whole board |
subscribe_index_trade(indices, interval=None, on_response=None) | interval: None (tick), "1m" or "5m" | Tick or candles of the index itself |
subscribe_index_summary(indices, on_response=None) | indices: list[str] | Summary figures of an index |
subscribe_market_flag(on_response=None) | Trading session flag |
Index codes are upper-cased automatically (vn30 becomes VN30) and UPCOMINDEX is mapped to HNXUPCOMINDEX. Board has HOSE, HNX, UPCOM, DERIVATIVES.
6.7.3. Interval rules
- Valid intervals on the stream:
"tick","1m","5m"(orTimeframe.MINUTE_1). There is no1wor1M. - They apply only to the
tradeandtrade.indextopics. - For
trade.index, an interval needs one concrete index code (no*or range). - Each topic accepts only one filter:
*, a single code, or anA-Brange. A comma is rejected; pass a list of codes instead. - The
@*marker (STREAM_INTERVAL_ALL) is used for trade, quote, room, put, oddlot with one concrete index or board code.
6.7.4. Unsubscribe and inspect
| Method | Description |
|---|---|
unsubscribe_index(indices) | Unsubscribe the constituent symbols of an index |
unsubscribe_board(boards) | Unsubscribe a whole board (alias unsubscribe_exchange_trades) |
unsubscribe_index_trade(indices, interval=None) | Unsubscribe index ticks/candles. Must use the same interval you subscribed with (alias unsubscribe_index_ticks) |
unsubscribe_index_summary(indices) | Unsubscribe summary figures |
unsubscribe_market_flag() | Unsubscribe the session flag (alias unsubscribe_session_flag) |
unsubscribe_market(symbols) | Unsubscribe the market topic |
unsubscribe_all() | Ask the server, then unsubscribe exactly what is left. Returns {"data": [...], "trading": [...]} |
list_subscription(timeout_ms=5000) | See which subscriptions the server holds. Returns {"trading": [...], "data": [...]} |
list_subscription raises WebSocketError if not connected or after timeout_ms.
6.7.5. Callbacks and models
- There is no dedicated typed callback for
IndexTickMessage: the index's own tick arrives only inon_data. Checkisinstance(msg, IndexTickMessage). on_index_summaryreceivesIndexSummaryMessage;on_market_flagreceivesMarketFlagMessage.- For the model structure see Models: Streaming Messages (
IndexTickMessage,IntervalMessage,IndexSummaryMessage,MarketFlagMessage).
Unconfirmed: The attribute names of
IndexTickMessageare inferred from the server's abbreviations and have not been confirmed on a real frame. When you need to be sure, readmsg.raw. The fields insideIndexSummaryMessage.dataare not publicly documented.
6.8. Example: follow an index
Sync
from collections import Counter
from ssi_sdk import Auth, Stream, Config
from ssi_sdk.models import IndexSummaryMessage, IndexTickMessage, MarketFlagMessage
config = Config(
client_id="<CLIENT_ID>",
api_key="<API_KEY>",
api_secret="<API_SECRET>",
private_key="<PRIVATE_KEY_CONTENT>",
)
counts: Counter = Counter()
def on_connection(event):
# state: connected | reconnected | disconnected | failed
err = f" | error: {type(event.error).__name__}" if event.error else ""
print(f"[CONN] {event.state} (reconnect_count={event.reconnect_count}){err}")
def on_data(msg):
counts[type(msg).__name__] += 1
if isinstance(msg, IndexTickMessage): # no dedicated typed callback for index ticks
print(f"[TICK] {msg.symbol} value={msg.value} change={msg.change} change_percent={msg.change_percent}")
def on_index_summary(msg: IndexSummaryMessage):
if counts["IndexSummary"] == 0: # print only the first one to see the structure
print(f"[SUMMARY] {msg.index} | fields: {len(msg.data)}")
counts["IndexSummary"] += 1
def on_market_flag(msg: MarketFlagMessage):
print(f"[FLAG] {msg.board} | {msg.flag} | {msg.trading_time}")
with Auth(config) as auth:
auth.ensure_authenticated(otp="<OTP>")
with Stream(auth) as stream:
ws = stream.streaming
# Register callbacks BEFORE connect
ws.on_connection = on_connection
ws.on_data = on_data
ws.on_index_summary = on_index_summary
ws.on_market_flag = on_market_flag
ws.connect()
ws.subscribe_index_ticks(["VNINDEX"]) # tick of the index itself
ws.subscribe_index_trade(["VN30"], interval="1m") # 1-minute candles of VN30
ws.subscribe_index_summary(["VN30"]) # summary figures
ws.subscribe_market_flag() # session flag
ws.subscribe_index(["VN30"]) # trade/quote/room of the constituents
ws.wait(timeout_ms=30_000) # listen for 30 seconds
subs = ws.list_subscription()
print(f"data={len(subs['data'])} topics, trading={len(subs['trading'])} topics")
ws.unsubscribe_index_ticks(["VNINDEX"])
ws.unsubscribe_index_trade(["VN30"], interval="1m") # same interval as subscribed
removed = ws.unsubscribe_all() # unsubscribe everything left
print(f"unsubscribe_all: data={len(removed['data'])}, trading={len(removed['trading'])}")
print(dict(counts))Async
import asyncio
from ssi_sdk import AsyncAuth, AsyncStream, Config
from ssi_sdk.models import IndexTickMessage
config = Config(
client_id="<CLIENT_ID>",
api_key="<API_KEY>",
api_secret="<API_SECRET>",
private_key="<PRIVATE_KEY_CONTENT>",
)
async def on_data(msg): # the callback may be a coroutine
if isinstance(msg, IndexTickMessage):
print(f"[TICK] {msg.symbol} value={msg.value}")
async def main():
async with AsyncAuth(config) as auth:
await auth.ensure_authenticated(otp="<OTP>")
async with AsyncStream(auth) as stream:
ws = stream.streaming
ws.on_data = on_data
await ws.connect()
await ws.subscribe_index_ticks(["VNINDEX"])
await ws.subscribe_index_summary(["VN30"])
await ws.wait(timeout_ms=30_000)
subs = await ws.list_subscription()
print(f"data={len(subs['data'])} topics")
await ws.unsubscribe_all()
asyncio.run(main())6.9. Connection state and reconnect
| Attribute | Description |
|---|---|
is_connected | Whether currently connected |
reconnect_count | Number of reconnects so far |
last_error | The most recent error |
on_connection receives ConnectionEvent(state, reconnect_count=0, error=None) (a frozen dataclass defined in ssi_sdk.transport.reconnect, not exported at top level). state is one of:
state | Meaning |
|---|---|
connected | First connection succeeded |
reconnected | Reconnected automatically and re-subscribed |
disconnected | Connection lost (the SDK tries to reconnect if auto_reconnect is on) |
failed | Cannot recover (for example a new OTP is needed). The SDK does not reconnect again |
Automatic reconnect when Config.auto_reconnect is on (default True):
- Exponential backoff with jitter, capped at 60000 ms, based on
Config.retry_delay_ms(default 2000 ms). - Each reconnect uses a fresh token; the SDK proactively swaps the socket 60000 ms before the token expires.
- Subscriptions are re-subscribed automatically.
- On a 429, the backoff is at least 30000 ms.
- At most 10 sockets per client. Frames over 4000 bytes are split automatically (the server limit is 4096).
6.10. Streaming error handling
| Exception | When |
|---|---|
ValidationError | Invalid topic/interval/filter, empty list. Nothing sent |
WebSocketError | The first connect() fails after max_retries or exceeds Config.timeout_ms; sending while not connected (Not connected. Call connect() first.); list_subscription timed out |
AuthenticationError | Credentials rejected on connect (including ReauthenticationRequired) |
Errors that happen after the connection is up (network loss, token expiry) are reported through on_connection (disconnected, reconnected, failed), not raised on the main flow. On failed, authenticate again (for example with a new OTP) and create a new connection. See Exceptions. See also: Tutorial: Advanced streaming, Tutorial: WebSocket data.
Message types via on_data
| Topic | Message Type | Description |
|---|---|---|
trade.* | TradeMessage | Trade data |
trade.*.<timeframe> | IntervalMessage | OHLCV data by interval |
trade.index.* | IndexTickMessage | Tick of an index |
quote.* | QuoteMessage | Price data (bid/ask) |
room.* | ForeignRoomMessage | Foreign room |
market.* | MarketStatusMessage | Market status |
put.* | PutMessage | Put-through |
oddlot.* | OddLotMessage | Odd lot |
Message types via on_trading
| Topic | Message Type | Description |
|---|---|---|
order.* | OrderStatusMessage | Regular order status |
FCOOrderUpdateMessage | Conditional order status (when event_type == "fcoEvent") | |
portfolio.* | PortfolioMessage | Portfolio changes |
Streaming Method Overview
| Group | Method | Description |
|---|---|---|
| Connection | connect() | Connect WebSocket |
disconnect() | Disconnect | |
wait(timeout_ms=None) | Wait for data | |
list_subscription(timeout_ms=5000) | See which subscriptions the server holds | |
| Heartbeat | ping(on_response=None, interval_ms=None) | Ping the server |
| Subscribe symbols | subscribe_symbol(symbols) | Trade + Quote + Room |
subscribe_symbol_trade(symbols) | Trade only | |
subscribe_symbol_quote(symbols) | Quote only | |
subscribe_symbol_room(symbols) | Foreign room only | |
subscribe_symbol_put_through(symbols) | Put-through only | |
subscribe_symbol_odd_lot(symbols) | Odd lot only | |
subscribe_symbol_ohlcv(symbols, interval) | OHLCV by timeframe | |
subscribe_market(symbols) | Market topic | |
| Subscribe board/index | subscribe_board(boards) | By board |
subscribe_index(indices) | Constituents of an index | |
subscribe_index_trade(indices, interval=None) | Tick/candles of the index itself | |
subscribe_index_summary(indices) | Summary figures of an index | |
subscribe_market_flag() | Session flag | |
| Subscribe trading | subscribe_order_status(account_no="*") | Order status |
subscribe_portfolio(account_no="*") | Portfolio changes | |
| Unsubscribe | unsubscribe_symbol(symbols) | Unsubscribe all channels for symbols |
unsubscribe_symbol_trade(symbols) | Unsubscribe trade | |
unsubscribe_symbol_quote(symbols) | Unsubscribe quote | |
unsubscribe_symbol_room(symbols) | Unsubscribe foreign room | |
unsubscribe_symbol_put_through(symbols) | Unsubscribe put-through | |
unsubscribe_symbol_odd_lot(symbols) | Unsubscribe odd lot | |
unsubscribe_symbol_ohlcv(symbols, interval) | Unsubscribe OHLCV | |
unsubscribe_market(symbols) | Unsubscribe the market topic | |
unsubscribe_board(boards) | Unsubscribe board | |
unsubscribe_index(indices) | Unsubscribe index | |
unsubscribe_index_trade(indices, interval=None) | Unsubscribe index ticks/candles | |
unsubscribe_index_summary(indices) | Unsubscribe summary figures | |
unsubscribe_market_flag() | Unsubscribe the session flag | |
unsubscribe_all() | Unsubscribe every remaining subscription |
Besides the main names, the SDK has these aliases: subscribe_trades, subscribe_candles, subscribe_quotes, subscribe_foreign_room, subscribe_put_through, subscribe_odd_lot, subscribe_exchange_trades, subscribe_index_constituents, subscribe_index_ticks, subscribe_master_data, subscribe_session_flag, subscribe_orders, subscribe_positions (and the matching unsubscribe_* aliases).
7. Trading WebSocket
The Trading WebSocket lets you send orders (place, amend, cancel, batch, FCO) and queries (positions, max buy/sell, balance, order book) over one WebSocket connection instead of one REST request each. It uses the same token and private key as REST.
When to use it: you need to send many orders/queries in a row with low latency on one connection, and you want to keep one connection open. If you prefer one plain request per call, use trading.trading (REST, section 5).
7.1. Prerequisites
- Authenticated with
Auth/AsyncAuth(OTP or Smart OTP, same as REST order placement). The Trading WS takes its token fromauth.token_manager; there is no dedicated client likeTrading. See Client Classes. Config.private_keyconfigured: state-changing commands (place, amend, cancel, batch, FCO) are signed with RSA-SHA256 and are rejected in the SDK (raisingWebSocketError) when there is no private key (fail closed). Queries need no signature.- The token must carry enough scopes (a missing scope gets a 403 from the server):
| Group | Scope |
|---|---|
| Orders | tradingws:order:post, tradingws:order:put, tradingws:order:delete |
| FCO | tradingws:fco:post, tradingws:fco:delete, tradingws:fco:get |
| Queries | tradingws:position:get, tradingws:maxBuySell:get, tradingws:accountBalance:get, tradingws:ppmmrAccount:get, tradingws:orderBook:get |
7.2. Connection
The URL comes from Config.trading_ws_domain, default wss://api.ssi.com.vn/ws/v3/trading (see Configuration). Only wss:// is accepted (any other scheme raises WebSocketError). The bearer token is sent in a header during the handshake.
| Function | Returns | Description |
|---|---|---|
trading_ws(auth, **options) | TradingWSService | Sync connection, used with Auth |
async_trading_ws(auth, **options) | AsyncTradingWSService | Async connection (await), used with AsyncAuth |
Import from ssi_sdk:
from ssi_sdk import (
trading_ws, async_trading_ws,
TradingWSClient, AsyncTradingWSClient,
TradingWSResponse, TradingWSError,
)Upgrading from 3.3.1 or earlier: these names used to live in
ssi_sdk.experimental. That module was removed in 3.3.2 with no alias, sofrom ssi_sdk.experimental import ...now raisesModuleNotFoundError. Change it tofrom ssi_sdk import .... Names, signatures and defaults are the same.
After connect, the server may accept the socket (101) and only then reject it with a frame {"code": 401|403|429|500, "msg": ...}. The SDK waits handshake_grace_ms to catch this and raises TradingWSError right inside trading_ws(...).
Options
All durations are in milliseconds. Pass them as keyword arguments to trading_ws(auth, ...).
| Option | Default | Description |
|---|---|---|
request_timeout_ms | Config.timeout_ms (default 60000; 10000 if the value is empty) when created through trading_ws; 10000 when TradingWSClient is used directly | How long to wait for a command's response |
signature_encoding | "auto" | "auto", "base64" or "hex": signature encoding |
handshake_grace_ms | 500 | How long to wait for a rejection frame after the socket opens |
respect_rate_limit | True | Wait automatically when the quota is exhausted instead of sending anyway |
max_rate_limit_wait_ms | 2000 | Longest wait when respect_rate_limit is on |
ping_interval_ms | 30000 | Ping period; None turns it off |
user_agent | Config.user_agent | User-Agent sent during the handshake |
Note:
signature_encoding="auto"signs with base64 first. If the server returns 401006, 401008 or 401013 (the command was not executed), the SDK resends once with a hex signature. If both are rejected, the error message carries extra information.
7.3. Operations (service)
TradingWSService (sync) and AsyncTradingWSService (async) have the same method names; the async version uses await. Methods return typed models shared with REST, or TradingWSResponse for commands that have no model of their own.
Orders
| Method | Parameters | Returns | Description |
|---|---|---|---|
place_order | account_no, symbol, side, quantity, price, order_type, client_request_id=None | PlaceOrderResponse | Place an order |
amend_order | account_no, price=None, quantity=None, order_id=None, client_request_id=None | ModifyOrderResponse | Amend an order |
cancel_order | account_no, order_id=None, client_request_id=None | CancelOrderResponse | Cancel an order |
batch_new_orders | orders | TradingWSResponse | Place several orders (order.batchNew); result.results has clientRequestId, orderStatus, success for each order. Items match Batch Order REST; the limit follows Config.max_batch_orders |
batch_cancel_orders | orders | TradingWSResponse | Cancel several orders (order.batchCancel) |
FCO
| Method | Parameters | Returns | Description |
|---|---|---|---|
place_fco | params (GTDParams, StopParams, TrailingStopParams, OCOParams or BullBearParams) | FCOPlaceResponse | Place an FCO |
cancel_fco | fco_id, code=None | FCOCancelResponse | Cancel an FCO |
list_fco | account_no, fco_id, type, process_status, symbol, side, from_date, to_date, page_index, page_size | TradingWSResponse | List FCOs. process_status is a single value, not a list |
fco_order_book | fco_id | TradingWSResponse | Order book of an FCO (fco.orderbook) |
fco_status_history | fco_id | TradingWSResponse | Status history of an FCO (fco.statusHistory) |
Queries
| Method | Parameters | Description |
|---|---|---|
query_position | account_no, query_summary=True | Positions (query.position) |
query_max_buy_sell | account_no, symbol, price=None | Max buy/sell (query.maxBuySell) |
query_account_balance | account_no | Balance (query.accountBalance) |
query_ppmmr_account | account_no | PPMMR information (query.ppmmrAccount) |
query_order_book | account_no, from_date, to_date, symbol, order_status, page_index, page_size | Order book (query.orderBook); dates use YYYY/MM/DD |
Returns: TradingWSResponse (has status and result). See Models: Trading WebSocket.
Events (subscription)
| Method | Description |
|---|---|
subscribe_order_events(account_no) / unsubscribe_order_events(account_no) | Order events of one account |
subscribe_portfolio_events(account_no) / unsubscribe_portfolio_events(account_no) | Portfolio events of one account |
unsubscribe_all_events() | Cancel every trading subscription |
A wildcard or an empty account is rejected with ValidationError (nothing is sent to the server).
Other
| Method | Description |
|---|---|
command(method, params) | Send any command by the server's method name |
close() | Close the socket, stop the ping loop and the reader. The async version is a coroutine |
The price parameter accepts Decimal, int, float or str. Prefer Decimal or str to avoid floating-point error.
7.4. Receiving events through on_event
Attach a hook on service.client. The hook receives every frame without a command id: subscription acks and events shaped {"channel", "topic", "data"} where data.eventType is orderEvent, orderMatchEvent or clientPortfolioEvent.
- Sync version: the hook runs on the SDK's reader thread.
- Async version: the hook runs in an SDK task and may be a coroutine.
- Exceptions inside the hook are logged and do not drop the connection.
- Keep the hook short. Do not print the whole frame:
topiccontains the account number.
7.5. Rate limit
service.client.rate_limit is a dict with limit, remaining, resetMs, updated from the server's responses. According to the official sample, the quota is 50 commands per window for orders and 100 for queries.
When remaining == 0 and respect_rate_limit=True, the next command waits min(resetMs, max_rate_limit_wait_ms) before it is sent. If the quota is still exhausted, the server returns 429 and the SDK raises TradingWSError.
7.6. Lost connection: NO automatic reconnect
Unlike the Stream client, the Trading WS does not reconnect on its own and does not re-subscribe. When the socket drops:
client.is_connectedbecomesFalse;client.rejectionholds theTradingWSErrorfrom the server's rejection frame (if any).- A pending command gets
WebSocketError("connection closed before the response arrived"); a command sent afterwards getsWebSocketError("not connected"). - You must call
trading_ws(auth)again and re-subscribe to events yourself.
A command that times out is never resent by the SDK, because the order may already have reached the exchange. Reconcile through the order.<account> event or query_order_book before resending, and keep the same client_request_id to avoid a duplicate order.
7.7. Idempotency
client_request_idis at most 20 characters. The SDK generates one if you leave it empty.- Reusing a
client_request_idthat was already used: the server returns 409. - The place-order ack usually has
status=PD(pending). The real result arrives through order events, not through the ack.
7.8. Examples
Sync: subscribe, place, cancel, query
Use placeholders only. To send a real order, set the price far from the market and double-check before you run it.
import uuid
from decimal import Decimal
from ssi_sdk import Auth, Config, TradingWSError, trading_ws
from ssi_sdk.enums import OrderSide, OrderType
from ssi_sdk.exceptions import ValidationError, WebSocketError
config = Config(
client_id="<CLIENT_ID>",
api_key="<API_KEY>",
api_secret="<API_SECRET>",
private_key="<PRIVATE_KEY_CONTENT>",
)
ACCOUNT_NO = "<ACCOUNT_NO>"
def on_event(frame: dict):
# Keep only the event type and status, not the topic (it has the account number)
data = frame.get("data") if isinstance(frame.get("data"), dict) else {}
print(f"[EVENT] {data.get('eventType')} | status={data.get('orderStatus')}")
with Auth(config) as auth:
auth.ensure_authenticated(otp="<OTP>")
try:
ws = trading_ws(auth, respect_rate_limit=True, max_rate_limit_wait_ms=2000)
except (TradingWSError, WebSocketError) as e:
raise SystemExit(f"Cannot connect to trading WS: {type(e).__name__}: {e}")
try:
ws.client.on_event = on_event
ws.subscribe_order_events(ACCOUNT_NO)
try:
ack = ws.place_order(
account_no=ACCOUNT_NO,
symbol="SSI",
side=OrderSide.BUY,
quantity=100,
price=Decimal("<FLOOR_PRICE>"), # LO order far from the market
order_type=OrderType.LO,
client_request_id=f"ws{uuid.uuid4().hex[:12]}", # <= 20 characters
)
print(f"Ack: orderId={ack.order_id} status={ack.status}") # usually PD
cancel = ws.cancel_order(account_no=ACCOUNT_NO, order_id=ack.order_id)
print(f"Cancel ack: status={cancel.status}")
except ValidationError as e:
print(f"Invalid input (nothing sent): {e}")
except TradingWSError as e:
print(f"Server rejected: status={e.status} code={e.error_code} msg={e.server_message}")
book = ws.query_order_book(ACCOUNT_NO, symbol="SSI", page_size=10)
print(f"query_order_book: status={book.status}")
print("rate_limit:", ws.client.rate_limit)
ws.unsubscribe_all_events()
finally:
ws.close() # always close the socketAsync
import asyncio
import uuid
from decimal import Decimal
from ssi_sdk import AsyncAuth, Config, TradingWSError, async_trading_ws
from ssi_sdk.enums import OrderSide, OrderType
config = Config(
client_id="<CLIENT_ID>",
api_key="<API_KEY>",
api_secret="<API_SECRET>",
private_key="<PRIVATE_KEY_CONTENT>",
)
ACCOUNT_NO = "<ACCOUNT_NO>"
async def on_event(frame: dict): # a plain function or a coroutine
data = frame.get("data") if isinstance(frame.get("data"), dict) else {}
print(f"[EVENT] {data.get('eventType')} | status={data.get('orderStatus')}")
async def main():
async with AsyncAuth(config) as auth:
await auth.ensure_authenticated(otp="<OTP>")
ws = await async_trading_ws(auth)
try:
ws.client.on_event = on_event
await ws.subscribe_order_events(ACCOUNT_NO)
try:
ack = await ws.place_order(
account_no=ACCOUNT_NO,
symbol="SSI",
side=OrderSide.BUY,
quantity=100,
price=Decimal("<FLOOR_PRICE>"),
order_type=OrderType.LO,
client_request_id=f"ws{uuid.uuid4().hex[:12]}",
)
await ws.cancel_order(account_no=ACCOUNT_NO, order_id=ack.order_id)
except TradingWSError as e:
print(f"Server rejected: status={e.status} code={e.error_code}")
await ws.unsubscribe_all_events()
finally:
await ws.close()
asyncio.run(main())7.9. Error handling
TradingWSError(status, code, message) extends WebSocketError (and SSIError). Attributes: .status, .error_code, .server_message. The exception does not carry the command's params, so it is safe to log, but still do not log it together with an account number. See Exceptions.
| Exception | When | Command sent? |
|---|---|---|
ValidationError | Bad parameters (missing field, wildcard on subscribe...) | Nothing sent |
TradingWSError | Server rejected: status 401 (token expired), 403 (missing scope), 409 (duplicate client_request_id), 429 (quota exhausted), 5xx | Reached the server and was rejected |
WebSocketError | Missing private key for a command that needs signing (nothing sent), not connected, URL is not wss://, lost connection, or timed out (timed out waiting for a response (not retried)) | Depends. For a lost connection and a timeout it is unknown: reconcile before resending |
A server error frame looks like {"id", "status": 4xx | "error", "error": {"code", "message"}}. fco.* commands return status: "error" with code FCO_ERROR.
7.10. Relation to the Stream client
| Trading WebSocket | Stream (stream.streaming) | |
|---|---|---|
| Import | from ssi_sdk import trading_ws | from ssi_sdk import Stream |
| URL | Config.trading_ws_domain (/ws/v3/trading) | wss://stream.ssi.com.vn/ws/v3 (default) |
| Purpose | Send orders/queries, receive order events | Receive market data and order events |
| Auto reconnect | No | Yes (Config.auto_reconnect), re-subscribes |
The two connections are independent. You can use Stream for market data and the Trading WS to send orders with the same auth. For a step-by-step walkthrough, read Tutorial: Trading WebSocket.