Unofficial Go client for the TCBS iFlash Open API; not affiliated with or endorsed by Techcom Securities.
The iFlash Open API lets a TCBS customer trade stocks and derivatives, query accounts, move money and stream market data over REST and WebSocket. This module has two clients for it:
tcbs: a hand-written client with typed REST models and WebSocket dialers. Its REST calls follow an earlier edition of the TCBS spec (see docs/spec-and-codegen.md); the WebSocket protocol (auth, subscribe, heartbeat) is left to the caller.tcbsgen: a client generated by oapi-codegen from the current spec on the TCBS developer portal, aftertools/specfixrepairs it.
The API moves real money.
PlaceOrder,TransferMoney, the derivative order calls and the margin deposit/withdraw calls act on a live account. Try them against the SIT environment (tcbs.SITBaseURL) first.
TCBS, TCInvest and iFlash are names of Techcom Securities. The API specs
belong to TCBS and are not kept in the current tree (older commits still
contain copies); go generate downloads
the one it needs from the TCBS developer portal.
go get github.com/tiennm99dev/tcbs-api/tcbs # hand-written client
go get github.com/tiennm99dev/tcbs-api/tcbsgen # generated clientThe module is pure Go (CGO_ENABLED=0 builds) and needs the Go version in
go.mod.
- Register an API key in TCInvest, under Utilities (Tiện ích), iFlash Open API: https://tcinvest.tcbs.com.vn/iflash-open-api.
- Exchange the key plus the current Smart OTP (iOTP) from the TCInvest app for
a JWT at
POST /gaia/v1/oauth2/openapi/token. The token is valid for at most 8 hours from the iOTP confirmation; request a new one when it expires. - Send the JWT as
Authorization: Bearer <token>on every other call.
Two key-management calls take the API key in the body instead of a JWT:
POST /gaia/v1/oauth2/openapi/apikey/info (key only) returns the key's expiry
date, and POST /gaia/v1/oauth2/openapi/apikey/extend (key and iOTP) extends
it. Read the key from the environment or a secret store; never commit it.
Details: docs/authentication.md.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/tiennm99dev/tcbs-api/tcbs"
)
func main() {
client := tcbs.NewClient() // production; tcbs.WithBaseURL(tcbs.SITBaseURL) for SIT
ctx := context.Background()
// Exchange the API key and iOTP for a JWT; the client keeps the token.
if _, err := client.GetToken(ctx, os.Getenv("TCBS_API_KEY"), os.Getenv("TCBS_OTP")); err != nil {
log.Fatal(err)
}
prices, err := client.GetStockPrices(ctx, []string{"FPT", "VNM"})
if err != nil {
log.Fatal(err)
}
for _, p := range prices {
fmt.Printf("%s: %.0f\n", p.Symbol, p.MatchPrice.Float())
}
}A longer walk-through is in tcbs/example. It reads
its credentials and account numbers from environment variables and points at
SIT, because one step places an order.
TCBS sometimes sends numbers as strings ("1000", "0E-10"). tcbs.Number
accepts both, but only some tcbs models use it: holdings
(StockHoldingInfo), cash (CashInvestment, IAInfo), REST stock prices
(MarketStockInfo) and the margin risk models. Every other numeric field,
including all derivative, money-transfer and WebSocket models, is a plain
float64/int, and a string there makes the call fail to decode, as in
tcbsgen. Error responses come back as
*tcbs.APIError, with Code and Message filled from a {code,message} body.
// Production (default)
client := tcbs.NewClient()
// SIT environment
client := tcbs.NewClient(tcbs.WithBaseURL(tcbs.SITBaseURL))
// Custom HTTP client
client := tcbs.NewClient(tcbs.WithHTTPClient(&http.Client{Timeout: 60 * time.Second}))
// Pre-set token
client := tcbs.NewClient(tcbs.WithToken("your-jwt-token"))| Method | Description |
|---|---|
GetToken |
Exchange API Key + OTP for JWT token |
| Method | Description |
|---|---|
GetSubAccountInfo |
Get sub-account profile information |
ListAccounts |
List the sub-accounts under a custody code |
| Method | Description |
|---|---|
PlaceOrder |
Place a stock order |
UpdateOrder |
Modify an existing order |
CancelOrder |
Cancel existing orders |
| Method | Description |
|---|---|
GetOrders |
Get order book |
GetOrderByID |
Get specific order by ID |
GetMatchingDetails |
Get order matching details |
GetPurchasingPower |
Get purchasing power |
GetPurchasingPowerBySymbol |
Get purchasing power for symbol |
GetPurchasingPowerBySymbolPrice |
Get purchasing power for symbol at price |
GetMarginQuota |
Get margin quota |
GetMarginAccountInfo |
Get margin account risk info |
GetSupplementaryLoanPackages |
Get loan package details |
GetLoans |
Get loan list |
GetStockAssets |
Get stock holdings |
GetCashBalance |
Get cash balance |
GetCashStatements |
Get cash statement history |
GetMarginInfo |
Get debt inquiry info |
| Method | Description |
|---|---|
GetStockPrices |
Get stock ticker pricing |
GetForeignRoom |
Get foreign investor room info |
GetPutThroughInfo |
Get put-through match info |
GetIntradayHistory |
Get intraday price history |
GetSupplyDemand |
Get 15-min supply/demand data |
GetSupplyDemandExt |
Get extended supply/demand data |
GetSupplyDemandMonth |
Get monthly supply/demand data |
| Method | Description |
|---|---|
TransferMoney |
Transfer between sub-accounts |
DepositMargin |
Deposit margin for derivatives |
WithdrawMargin |
Withdraw margin for derivatives |
| Method | Description |
|---|---|
GetDerivativeCashStatus |
Get derivative cash/margin overview |
GetDerivativeClosedPositions |
Get closed positions |
GetDerivativeOpenPositions |
Get open positions |
GetDerivativeNormalOrders |
List normal orders |
GetDerivativeConditionOrders |
List conditional orders |
PlaceDerivativeNormalOrder |
Place normal order |
PlaceDerivativeConditionOrder |
Place conditional order |
ChangeDerivativeNormalOrder |
Modify normal order |
ChangeDerivativeConditionOrder |
Modify conditional order |
CancelDerivativeNormalOrder |
Cancel normal order |
CancelDerivativeConditionOrder |
Cancel conditional order |
GetDerivativeMarketInfo |
Get derivative contract pricing |
| Method | Endpoint | Portal page |
|---|---|---|
ConnectStockPrice |
/ws/thesis/v1/stream/normal |
stock/ws-price-board |
ConnectDerivativePrice |
/ws/thesis/v1/stream/derivative |
derivative/ws-price-board |
ConnectCenter |
/ws/ouranos/v1/stream |
stock/ws-history |
ConnectStockMatch |
/ws/aither |
stock/ws-order-matching |
ConnectDerivativeMatch |
/ws/nesoi |
derivative/ws-order-matching |
Connect* only opens the socket (sending the stored token as an
Authorization: Bearer dial header) and passes every received frame to your
handler. It does not authenticate, subscribe or keep the connection alive;
the caller sends those frames with ws.Send. The streams use pipe-delimited
text frames, not JSON. The portal pages linked above are the protocol
reference.
The price board on /ws/thesis/v1/stream/normal:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
authOK := make(chan bool, 1)
ws, err := client.ConnectStockPrice(ctx, func(_ websocket.MessageType, data []byte) {
msg := string(data)
if strings.HasPrefix(msg, "d|0|") { // auth result
select {
case authOK <- strings.Contains(msg, `"success":true`):
default:
}
return
}
fmt.Println(msg)
})
if err != nil {
log.Fatal(err)
}
defer ws.Close()
// 1. Authenticate in-band with the base64-encoded JWT.
auth := "d|a|||" + base64.StdEncoding.EncodeToString([]byte(tok.Token))
if err := ws.Send(ctx, []byte(auth)); err != nil {
log.Fatal(err)
}
// 2. Wait for d|0|{"success":true,...} before subscribing.
select {
case ok := <-authOK:
if !ok {
log.Fatal("websocket auth rejected")
}
case <-time.After(10 * time.Second):
log.Fatal("no websocket auth response")
}
// 3. Keep the connection alive: a text-frame heartbeat every 2s
// (the server announces a 15s timeout with d|33|15).
go func() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := ws.Send(ctx, []byte("d|p|||")); err != nil {
return
}
}
}
}()
// 4. Subscribe to ticker updates.
if err := ws.Send(ctx, []byte("d|s|tk|bp+bi+tm+mp+op+fe|FPT")); err != nil {
log.Fatal(err)
}Here tok is the *tcbs.TokenResponse returned by GetToken. The handler
runs on the read goroutine, so keep it short.
The frames differ per stream:
| Stream | Auth frame | Server success reply | Subscribe | Heartbeat (text frame, every 2s) | Server timeout |
|---|---|---|---|---|---|
/ws/thesis/v1/stream/normal, /ws/thesis/v1/stream/derivative |
d|a|||<base64(JWT)> |
d|0|{"success":true,...} |
d|s|tk|bp+bi+tm+mp+op+fe|<SYMBOLS> |
d|p||| |
15s (d|33|15) |
/ws/ouranos/v1/stream |
d|a|||<base64(JWT)> |
d|0|{"success":true,...} |
d|st|C001+C002S60+C002S900|<SYMBOLS> |
d|po |
15s (d|33|15) |
/ws/aither |
authenticate|<base64({"jwt":"<JWT>"})> |
authenticate|{"success":true,...} |
subscribe|<base64({"topic":"STOCK_ORDER"})> |
ping|1 |
7s (pingTimeout|7) |
/ws/nesoi |
authenticate|<base64({"jwt":"<JWT>"})> |
authenticate|{"success":true,...} |
subscribe|<base64({"topic":"DE_ORDER"})>, also DE_CONDITIONAL_ORDER, DE_OPEN_POSITION |
ping|1 |
45s |
Use only text-frame heartbeats; the portal says the server rejects RFC 6455
ping frames. The client used here (nhooyr.io/websocket) sends none on its
own.
tcbsgen covers every operation in the portal spec, including the API-key
calls the hand-written client lacks. Each operation is named after its portal
docs page (stock/asset becomes StockAsset).
import "github.com/tiennm99dev/tcbs-api/tcbsgen"
c, err := tcbsgen.NewClientWithResponses("https://openapi.tcbs.com.vn",
tcbsgen.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+token)
return nil
}))
if err != nil {
log.Fatal(err)
}
resp, err := c.StockAssetWithResponse(ctx, accountNo)
if err != nil {
log.Fatal(err)
}
if resp.JSON200 != nil && resp.JSON200.Assets != nil {
for _, a := range *resp.JSON200.Assets {
fmt.Println(*a.Symbol, *a.Quantity)
}
}Generated models use plain int64/float64, so a number TCBS sends as a
string makes the whole ...WithResponse call return an error. The plain
Client methods (c.ClientInterface.StockAsset, for example) return the raw
*http.Response if you need to decode such a body yourself. See
docs/spec-and-codegen.md for this and the other
known spec gaps.
To regenerate:
go generate ./tcbsgengo generate downloads the portal spec, checks it against the SHA-256 pinned
in tcbsgen/generate.go, repairs it with
tools/specfix into tcbsgen/openapi.json (gitignored), and runs
oapi-codegen. If TCBS publishes a changed spec, the checksum check fails:
review the change, update the pinned checksum, and regenerate. Do not edit
tcbsgen/client.gen.go by hand. specfix also fails if an endpoint is added
or removed without a matching entry in
tcbsgen/operation-ids.json.
go vet ./...
go test ./...
CGO_ENABLED=0 go build ./...Tests make no network calls; the clients run against httptest servers.
- docs/authentication.md: token flow, API-key expiry, and how to supply credentials safely.
- docs/spec-and-codegen.md: the two specs, what
specfixrepairs, open questions about live responses, and the generator comparison.
The code is licensed under the Apache License 2.0; see LICENSE. The TCBS API specs are not part of the current tree; copies in older commits remain TCBS's and are not covered by this license.