Skip to main content

Unified Python client for Korean securities brokers — common abstraction with raw interface escape hatch

Project description

tooja

Python License: MIT

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 on asyncio
  • Strict MoneyDecimal-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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tooja-0.1.0.tar.gz (234.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tooja-0.1.0-py3-none-any.whl (616.0 kB view details)

Uploaded Python 3

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

Hashes for tooja-0.1.0.tar.gz
Algorithm Hash digest
SHA256 475e3eb943b5bb69772515a3d55232bc0d3ad968e88e8f78d98a1f73ed6ae27d
MD5 573942d1cd155eef40d73bb66fa2f1c1
BLAKE2b-256 f77f37ce219c2fc5c5d9b224685231dffc719945e2054d320d3bad7152d61729

See more details on using hashes here.

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

Hashes for tooja-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c5821e6eb3b3d4dbb9234f23c95f0466798693ea27360f671133af17081ee596
MD5 168fadf826c05f38f64be42bbc39e87f
BLAKE2b-256 0feea8fe1f6731030728caa88483ba6b22181c0df85a00b29f7077c1880b1d84

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page