Unified Python client for Korean securities brokers — common abstraction with raw interface escape hatch
Project description
tooja
A unified Python client for Korean securities brokers. One broker-agnostic API
(Quote / Order / Balance / streams) across multiple brokers, plus a raw escape
hatch to each broker's native API when you need it. The goal: do for Korean brokers
what ccxt did for crypto exchanges.
Adapters: Korea Investment & Securities (KIS) · Toss Securities (Toss).
Features
- One API, many brokers — normalized
Quote/Order/Balance; switch adapters without touching strategy code - Raw escape hatch — reach any native endpoint directly via
broker.raw.* - async-first — every call is
async/await, built onasyncio - Strict
Money—Decimal-only, rejects math across mismatched currencies - Built-in rate limiting — token bucket + exponential backoff on the server-side
EGW00201 - Persistent token cache — on disk, multi-account safe (scoped by app_key hash)
- WebSocket streams — quotes/trades/orderbook/my-orders, with auto-reconnect + PINGPONG
Installation
uv add tooja # or: pip install tooja
Requires Python 3.13+. Before it lands on PyPI, install from source:
git clone https://github.com/cookieshake/tooja && cd tooja && uv sync
Quick start
The constructor differs per broker; everything after it is identical.
import asyncio
from tooja.brokers.kis import KisBroker
from tooja.brokers.toss import TossBroker
async def main():
# Korea Investment & Securities
broker = KisBroker(
app_key="...", app_secret="...",
cano="50000000", # first 8 digits of the account number
hts_id="your_hts_id",
env="demo", # "real" or "demo"
)
# ...or Toss Securities — same API from here on
# broker = TossBroker(client_id="...", client_secret="...", account_seq=12345678)
async with broker:
quote = await broker.market.get_quote("005930") # Samsung Electronics
print(quote.price) # Money(amount=Decimal('70000'), currency=KRW)
asyncio.run(main())
A broker is an async context manager; leaving the async with block closes the HTTP
session (use await broker.open() / await broker.close() for manual control).
Tokens are issued lazily — on the first authenticated call — and cached to disk.
Brokers
Both adapters speak the same API; they differ in how much of it they implement.
| Domain | KIS | Toss |
|---|---|---|
| market | full — quote, orderbook, OHLCV, price limits | quote, orderbook, price limits; OHLCV 1m/1d only |
| account | full | full |
| orders | full, incl. fills (list_fills/iter_fills) |
no stop orders, no fills |
| info | full — incl. dividends, financials, halts | get_stock, get_warnings, is_holiday only |
| analytics | ✅ investor flows, program trading, short selling, … | — |
| rankings | ✅ | — |
| stream | ✅ quotes, trades, orderbook, my-orders | — |
Anything not covered above raises UnsupportedOperation — and is still reachable via
the raw escape hatch.
KIS
KisBroker(app_key="...", app_secret="...", cano="50000000", hts_id="...", env="demo")
| Argument | Description |
|---|---|
app_key,app_secret |
KIS app key/secret |
cano |
first 8 digits of the account number |
hts_id |
HTS user ID (required for the WS my-order stream) |
acnt_prdt_cd |
account product code, defaults to "01" |
env |
"real" (live, 20 RPS) / "demo" (paper, 2 RPS) |
rate_limit |
a RateLimitConfig, optional |
env is the entire safety boundary — there is no dry-run mode, so with env="real"
orders are actually sent. Note that KIS demo does not provide some TRs (e.g.
inquire-daily-ccld, search-stock-info).
Toss
TossBroker(client_id="...", client_secret="...", account_seq=12345678)
| Argument | Description |
|---|---|
client_id,client_secret |
Toss Open API OAuth2 client credentials |
account_seq |
account sequence number (int); required only for account/order calls |
token_cache |
"disk" (default) / "memory" |
rate_limit |
a RateLimitConfig, optional |
Authentication is OAuth2 client_credentials; the access token is issued on the first call and cached.
Usage
These calls are the same on any adapter — the examples use broker from the quick start.
Market
await broker.market.get_quote("005930") # -> Quote
await broker.market.get_quotes(["005930", "000660"]) # -> list[Quote] (concurrent)
await broker.market.get_orderbook("005930", depth=10) # -> Orderbook
await broker.market.get_ohlcv("005930", interval="1d", limit=30) # -> list[OHLCV]
# KIS intervals: "1m" "5m" "15m" "30m" "1h" "1d" "1w" "1M" · Toss: "1m" "1d"
Account
balance = await broker.account.get_balance() # -> Balance (total_asset, cash, positions)
positions = await broker.account.get_positions() # -> list[Position]
pos = await broker.account.get_position("005930") # -> Position | None
Orders
from decimal import Decimal
from tooja.core import Money, Symbol, LimitOrder, MarketOrder, OrderSide, Currency
# limit buy
order = await broker.orders.create(LimitOrder(
symbol=Symbol.parse("005930"),
side=OrderSide.BUY,
qty=Decimal(10),
price=Money(amount=Decimal(70000), currency=Currency.KRW),
))
await broker.orders.get(order.order_id) # -> Order (current state)
await broker.orders.replace(order.order_id, price=Decimal(69000)) # amend
await broker.orders.cancel(order.order_id) # cancel
# market sell
await broker.orders.create(MarketOrder(
symbol=Symbol.parse("000660"), side=OrderSide.SELL, qty=Decimal(5),
))
# query
await broker.orders.list_orders(status="open") # "all" | "open" | "closed"
await broker.orders.list_fills() # KIS only -> list[Fill]
Info / analytics / rankings
from datetime import date
from tooja.core import RankingType
await broker.info.get_stock("005930") # -> StockInfo
await broker.info.is_holiday(date(2026, 1, 1)) # -> bool
# KIS only:
await broker.info.get_dividends("005930")
await broker.info.list_halts() # halted symbols
await broker.analytics.investor_flows("005930") # trading flows by investor type
await broker.rankings.get(RankingType.VOLUME, limit=30) # -> list[RankingEntry]
Streams (WebSocket, KIS only)
async with broker.stream.quotes(["005930", "000660"]) as stream:
async for quote in stream:
print(quote.symbol, quote.price)
# trades / orderbook follow the same pattern; orders() streams my-order fills
Streams are entered with async with and consumed with async for. They auto-reconnect
by default; adjust subscriptions at runtime with await stream.subscribe(sym) /
await stream.unsubscribe(sym).
Rebalancing
from decimal import Decimal
from tooja.core import Symbol
from tooja.portfolio.rebalance import Rebalancer, TargetWeight
rb = Rebalancer(
broker,
targets=[
TargetWeight(symbol=Symbol.parse("005930"), weight=Decimal("0.6")),
TargetWeight(symbol=Symbol.parse("000660"), weight=Decimal("0.4")),
],
cash_buffer_rate=Decimal("0.02"), # hold 2% as cash
min_order_value=Decimal("10000"), # skip orders below 10,000 KRW
)
plan = await rb.compute_plan() # -> RebalancePlan (orders, expected_drift)
await rb.execute(plan) # execute the orders as planned
Rebalancer depends only on the Broker ABC, so it works with any adapter.
Raw escape hatch
For endpoints the common API doesn't cover, call each broker's native API directly via
broker.raw. Categories are lazily imported on first access.
# KIS — auto-generated executor classes (338 endpoints)
ExecCls = broker.raw.domestic_stock_quotations.InquirePriceExecutor
# Toss — categories: account, asset, auth, market_data, market_info,
# order, order_history, order_info, stock_info
client = broker.raw.market_data
The raw layer currently exposes executor/category access; normalized call helpers are future work. For most tasks the common API above is enough.
Rate limits & errors
from tooja.core import RateLimitConfig
broker = KisBroker(..., rate_limit=RateLimitConfig(per_sec=10, max_retries=5, base_backoff=0.1))
Defaults: 20 RPS on KIS real, 2 RPS on KIS demo. The server-side EGW00201
(transactions-per-second exceeded) is retried automatically with exponential backoff.
All exceptions inherit from BrokerError:
AuthError · PermissionDenied · RateLimitError · UnsupportedOperation ·
MarketClosed · SymbolNotFound · OrderRejected · InsufficientFunds ·
OrderNotFound · NetworkError · TimeoutError · SubscriptionLimitExceeded ·
ConfigError · BrokerAPIError
from tooja.core import OrderRejected
try:
await broker.orders.create(...)
except OrderRejected as e:
print(e.raw_code, e.raw_message) # preserves the original broker code/message
License
MIT © Youngchan Kim
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tooja-0.1.0.tar.gz.
File metadata
- Download URL: tooja-0.1.0.tar.gz
- Upload date:
- Size: 234.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
475e3eb943b5bb69772515a3d55232bc0d3ad968e88e8f78d98a1f73ed6ae27d
|
|
| MD5 |
573942d1cd155eef40d73bb66fa2f1c1
|
|
| BLAKE2b-256 |
f77f37ce219c2fc5c5d9b224685231dffc719945e2054d320d3bad7152d61729
|
File details
Details for the file tooja-0.1.0-py3-none-any.whl.
File metadata
- Download URL: tooja-0.1.0-py3-none-any.whl
- Upload date:
- Size: 616.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c5821e6eb3b3d4dbb9234f23c95f0466798693ea27360f671133af17081ee596
|
|
| MD5 |
168fadf826c05f38f64be42bbc39e87f
|
|
| BLAKE2b-256 |
0feea8fe1f6731030728caa88483ba6b22181c0df85a00b29f7077c1880b1d84
|