Python

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) return None when 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:

MethodTimeframe
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):

MethodTimeframe
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:

ParameterTypeDefaultDescription
symbolstr(required)Stock symbol
from_datestr(required)Start date, YYYY/MM/DD or YYYY/MM/DD HH:MM:SS
to_datestr(required)End date, same format, from_date <= to_date
pageint1Page (>= 1)
sizeint1000Records per page (>= 1)

Returns: list[OHLCData]

Note: get_ohlc_1week_historical and get_ohlc_1month_historical always raise ValidationError because the server does not support 1w / 1M (error 400210). Valid timeframes: 1d, 1m, 3m, 5m, 15m, 1h. download_ohlc_1minute and download_ohlc_1day raise NotImplementedError. For minute/hour bars, a to_date in the future is clamped to today.

Iterate over all bars across pages:

MethodDescription
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.DERIVATIVES is 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.

MethodArgumentsDescription
get_corporate_actionssymbol: str | None = None, language: str = "vn"Corporate actions of today (dividends, bonus shares, rights issues, ...)
get_corporate_actions_historicalfrom_date: str, to_date: str, symbol: str | None = None, language: str = "vn"Corporate actions in a past date range
ArgumentDescription
from_date / to_dateYYYY/MM/DD
symbolOne 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_date and to_date must be in YYYY/MM/DD format.
  • from_date must not be after to_date.
  • to_date must be at most 5 days after from_date.
  • symbol (if given) must be a single symbol made of letters and digits, at most 20 characters.
  • language must be vn or en (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_positions and get_derivative_positions return None (the type hint says list). Check for None before iterating. In 3.3.2, get_open_derivative_positions and get_closed_derivative_positions read straight from that result, so they may raise AttributeError when there are no positions; to be safe, call get_derivative_positions and check for None. get_equity_balance, get_derivative_balance, get_equity_ppmmr and get_derivative_ppmmr can also return None.

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

GroupMethodReturns
Balanceget_equity_balance(account_no)EquityAccountBalance | None
get_derivative_balance(account_no)DerivativeAccountBalance | None
Positionsget_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 Bookget_today_orders(account_no)list[Order]
get_historical_orders(account_no, from_date, to_date)list[Order]
PPMMRget_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.

MethodParametersReturnsDescription
place_batch_orders(orders)orders: Sequence[BatchPlaceOrderItem]BatchOrderResponsePOST /order/batch: place many orders
cancel_batch_orders(orders)orders: Sequence[BatchCancelOrderItem]BatchOrderResponseDELETE /order/batch: cancel many orders

Order count limit

  • A batch holds from 1 to Config.max_batch_orders orders. The default is 20.
  • Over the limit or an empty batch: the SDK raises ValidationError immediately, 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_orders is the caller's decision; follow the limit that applies to your API Key.

Place item (BatchPlaceOrderItem)

A TypedDict with snake_case keys:

KeyRequiredDescription
account_noYesAccount number
symbolYesStock symbol
sideYesOrderSide.BUY / OrderSide.SELL
quantityYesQuantity (positive integer)
priceYesPrice. Can be None for order types that need no price (ATO, ATC, MP)
order_typeYesOrderType (LO, ATO, ATC, MP...)
client_request_idNoIdempotency key of the order, at most 20 characters. The SDK generates one if omitted

Cancel item (BatchCancelOrderItem)

KeyRequiredDescription
account_noYesAccount number
order_idOne of the twoOrder id issued by the server
client_request_idOne of the twoIdempotency 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": [...]}. batchRequestId is a random id and batchRequestTime is epoch milliseconds.
  • The whole body is signed RSA-SHA256 and sent in the X-Signature header. You do not sign anything yourself.
  • The server rejects a batch whose batchRequestTime is outside the anti-replay window (code 400111). 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_id is the idempotency key. Set it yourself so you can look up and cancel the order later.
  • The SDK does not auto-retry POST / DELETE on 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 (ValidationError in the SDK, or APIError from the server). Conversely, once a batch is accepted, some orders can still fail individually: always read success on each BatchOrderResult.

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

ExceptionWhenSent to the server?
ValidationErrorEmpty batch, over max_batch_orders, an order with a missing/invalid field, a cancel item with both or neither of order_id / client_request_idNo
DuplicateRequestError (APIError, HTTP 409)Batch id already used todayReached the server. Check the order book before resending
APIErrorThe server rejected the whole batch (has code, status_code, message), for example 400111 when the time is outside the replay windowReached the server
RateLimitErrorThe server returned HTTP 429 (has retry_after_ms, limit, remaining)Reached the server, rejected
AuthenticationErrorThe server returned HTTP 401/403 (invalid token or not enough permission), or not authenticatedRejected

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

GroupMethodReturns
Placeplace_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
Modifymodify_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
Cancelcancel_order(account_no, client_request_id)CancelOrderResponse
cancel_order_by_order_id(account_no, order_id)CancelOrderResponse
Batchplace_batch_orders(orders)BatchOrderResponse
cancel_batch_orders(orders)BatchOrderResponse
Max Buy/Sellget_max_buy_sell(account_no, symbol, price)MaxBuySellResponse
get_max_buy_sell_at_market_price(account_no, symbol)MaxBuySellResponse
Conditionalplace_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_index and page_size parameters for pagination, and can all be called asynchronously with await. FCOInfo also has price_slip and order_type; FCOOrder also has type.


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
AttributeReceivesNotes
on_dataEvery market data messageIncludes IndexTickMessage, IntervalMessage
on_tradingEvery trading channel message
on_heartbeatHeartbeat
on_market_flagMarketFlagMessage
on_index_summaryIndexSummaryMessage
on_order, on_order_match, on_portfolioTrading messages by typeNeeds OTP
on_connectionConnectionEventConnection 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 needMethodTopicMessage received
Tick of the index itselfsubscribe_index_trade (alias subscribe_index_ticks)trade.index.<code>IndexTickMessage
1m/5m candles of an indexsubscribe_index_trade(..., interval="1m"/"5m")trade.index.<code>@1m / @5mIntervalMessage
Summary figures of an indexsubscribe_index_summaryindexsummary.<code>IndexSummaryMessage
Trade/quote/room of the constituent symbolssubscribe_index (alias subscribe_index_constituents)trade.<idx>@*, quote.<idx>@*, room.<idx>@*TradeMessage, QuoteMessage, ForeignRoomMessage
Trade/quote/room of a whole boardsubscribe_board (alias subscribe_exchange_trades)trade/quote/room.<BOARD>@*same as above
Session flagsubscribe_market_flag (alias subscribe_session_flag)market.flagMarketFlagMessage

Note: subscribe_board can produce thousands of messages per second. Use it only when you really need it, and keep the callback very short.

Unconfirmed: subscribe_index and subscribe_board send topics of the form trade.<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 with list_subscription() and the data you actually receive in your environment.

6.7.2. Subscribe methods

MethodParametersDescription
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" (or Timeframe.MINUTE_1). There is no 1w or 1M.
  • They apply only to the trade and trade.index topics.
  • For trade.index, an interval needs one concrete index code (no * or range).
  • Each topic accepts only one filter: *, a single code, or an A-B range. 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

MethodDescription
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 in on_data. Check isinstance(msg, IndexTickMessage).
  • on_index_summary receives IndexSummaryMessage; on_market_flag receives MarketFlagMessage.
  • For the model structure see Models: Streaming Messages (IndexTickMessage, IntervalMessage, IndexSummaryMessage, MarketFlagMessage).

Unconfirmed: The attribute names of IndexTickMessage are inferred from the server's abbreviations and have not been confirmed on a real frame. When you need to be sure, read msg.raw. The fields inside IndexSummaryMessage.data are 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

AttributeDescription
is_connectedWhether currently connected
reconnect_countNumber of reconnects so far
last_errorThe 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:

stateMeaning
connectedFirst connection succeeded
reconnectedReconnected automatically and re-subscribed
disconnectedConnection lost (the SDK tries to reconnect if auto_reconnect is on)
failedCannot 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

ExceptionWhen
ValidationErrorInvalid topic/interval/filter, empty list. Nothing sent
WebSocketErrorThe first connect() fails after max_retries or exceeds Config.timeout_ms; sending while not connected (Not connected. Call connect() first.); list_subscription timed out
AuthenticationErrorCredentials 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

TopicMessage TypeDescription
trade.*TradeMessageTrade data
trade.*.<timeframe>IntervalMessageOHLCV data by interval
trade.index.*IndexTickMessageTick of an index
quote.*QuoteMessagePrice data (bid/ask)
room.*ForeignRoomMessageForeign room
market.*MarketStatusMessageMarket status
put.*PutMessagePut-through
oddlot.*OddLotMessageOdd lot

Message types via on_trading

TopicMessage TypeDescription
order.*OrderStatusMessageRegular order status
FCOOrderUpdateMessageConditional order status (when event_type == "fcoEvent")
portfolio.*PortfolioMessagePortfolio changes

Streaming Method Overview

GroupMethodDescription
Connectionconnect()Connect WebSocket
disconnect()Disconnect
wait(timeout_ms=None)Wait for data
list_subscription(timeout_ms=5000)See which subscriptions the server holds
Heartbeatping(on_response=None, interval_ms=None)Ping the server
Subscribe symbolssubscribe_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/indexsubscribe_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 tradingsubscribe_order_status(account_no="*")Order status
subscribe_portfolio(account_no="*")Portfolio changes
Unsubscribeunsubscribe_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 from auth.token_manager; there is no dedicated client like Trading. See Client Classes.
  • Config.private_key configured: state-changing commands (place, amend, cancel, batch, FCO) are signed with RSA-SHA256 and are rejected in the SDK (raising WebSocketError) 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):
GroupScope
Orderstradingws:order:post, tradingws:order:put, tradingws:order:delete
FCOtradingws:fco:post, tradingws:fco:delete, tradingws:fco:get
Queriestradingws: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.

FunctionReturnsDescription
trading_ws(auth, **options)TradingWSServiceSync connection, used with Auth
async_trading_ws(auth, **options)AsyncTradingWSServiceAsync 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, so from ssi_sdk.experimental import ... now raises ModuleNotFoundError. Change it to from 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, ...).

OptionDefaultDescription
request_timeout_msConfig.timeout_ms (default 60000; 10000 if the value is empty) when created through trading_ws; 10000 when TradingWSClient is used directlyHow long to wait for a command's response
signature_encoding"auto""auto", "base64" or "hex": signature encoding
handshake_grace_ms500How long to wait for a rejection frame after the socket opens
respect_rate_limitTrueWait automatically when the quota is exhausted instead of sending anyway
max_rate_limit_wait_ms2000Longest wait when respect_rate_limit is on
ping_interval_ms30000Ping period; None turns it off
user_agentConfig.user_agentUser-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

MethodParametersReturnsDescription
place_orderaccount_no, symbol, side, quantity, price, order_type, client_request_id=NonePlaceOrderResponsePlace an order
amend_orderaccount_no, price=None, quantity=None, order_id=None, client_request_id=NoneModifyOrderResponseAmend an order
cancel_orderaccount_no, order_id=None, client_request_id=NoneCancelOrderResponseCancel an order
batch_new_ordersordersTradingWSResponsePlace 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_ordersordersTradingWSResponseCancel several orders (order.batchCancel)

FCO

MethodParametersReturnsDescription
place_fcoparams (GTDParams, StopParams, TrailingStopParams, OCOParams or BullBearParams)FCOPlaceResponsePlace an FCO
cancel_fcofco_id, code=NoneFCOCancelResponseCancel an FCO
list_fcoaccount_no, fco_id, type, process_status, symbol, side, from_date, to_date, page_index, page_sizeTradingWSResponseList FCOs. process_status is a single value, not a list
fco_order_bookfco_idTradingWSResponseOrder book of an FCO (fco.orderbook)
fco_status_historyfco_idTradingWSResponseStatus history of an FCO (fco.statusHistory)

Queries

MethodParametersDescription
query_positionaccount_no, query_summary=TruePositions (query.position)
query_max_buy_sellaccount_no, symbol, price=NoneMax buy/sell (query.maxBuySell)
query_account_balanceaccount_noBalance (query.accountBalance)
query_ppmmr_accountaccount_noPPMMR information (query.ppmmrAccount)
query_order_bookaccount_no, from_date, to_date, symbol, order_status, page_index, page_sizeOrder book (query.orderBook); dates use YYYY/MM/DD

Returns: TradingWSResponse (has status and result). See Models: Trading WebSocket.

Events (subscription)

MethodDescription
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

MethodDescription
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: topic contains 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_connected becomes False; client.rejection holds the TradingWSError from the server's rejection frame (if any).
  • A pending command gets WebSocketError("connection closed before the response arrived"); a command sent afterwards gets WebSocketError("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_id is at most 20 characters. The SDK generates one if you leave it empty.
  • Reusing a client_request_id that 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 socket

Async

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.

ExceptionWhenCommand sent?
ValidationErrorBad parameters (missing field, wildcard on subscribe...)Nothing sent
TradingWSErrorServer rejected: status 401 (token expired), 403 (missing scope), 409 (duplicate client_request_id), 429 (quota exhausted), 5xxReached the server and was rejected
WebSocketErrorMissing 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 WebSocketStream (stream.streaming)
Importfrom ssi_sdk import trading_wsfrom ssi_sdk import Stream
URLConfig.trading_ws_domain (/ws/v3/trading)wss://stream.ssi.com.vn/ws/v3 (default)
PurposeSend orders/queries, receive order eventsReceive market data and order events
Auto reconnectNoYes (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.

On this page

1. Authentication (TokenManager)Authenticate with OTPRequest OTPRefresh tokenAuto-ensure authentication2. Account (AccountService)Get account list3. Market Data (MarketDataService)3.1. OHLC Data (candlestick)3.2. Market Indexes3.3. Index Summary3.4. Securities Info3.5. Securities Summary3.6. Master data (ceiling, floor, reference price)3.7. Corporate actions4. Portfolio (PortfolioService)4.1. Account Balance4.2. Positions4.3. Order Book4.4. PPMMRPortfolio Method Overview5. Trading (TradingService)5.1. Place Orders5.2. Modify Orders5.3. Cancel Orders5.4. Batch OrderOrder count limitPlace item (BatchPlaceOrderItem)Cancel item (BatchCancelOrderItem)ResultSigning and idempotencySync example: place a batch then cancel itAsync exampleBatch error handling5.5. Max Buy/SellTrading Method Overview5.6. Conditional Orders (FCO)6. Realtime Streaming (StreamingService)6.1. Connection6.2. Setup Callbacks6.3. Subscribe Market Data6.4. Unsubscribe6.5. Subscribe Trading Events6.6. Heartbeat6.7. Real-time index data6.7.1. Subscription kinds6.7.2. Subscribe methods6.7.3. Interval rules6.7.4. Unsubscribe and inspect6.7.5. Callbacks and models6.8. Example: follow an indexSyncAsync6.9. Connection state and reconnect6.10. Streaming error handlingMessage types via on_dataMessage types via on_tradingStreaming Method Overview7. Trading WebSocket7.1. Prerequisites7.2. ConnectionOptions7.3. Operations (service)OrdersFCOQueriesEvents (subscription)Other7.4. Receiving events through on_event7.5. Rate limit7.6. Lost connection: NO automatic reconnect7.7. Idempotency7.8. ExamplesSync: subscribe, place, cancel, queryAsync7.9. Error handling7.10. Relation to the Stream client