Metadata-Version: 2.4
Name: tooja
Version: 0.1.0
Summary: Unified Python client for Korean securities brokers — common abstraction with raw interface escape hatch
Keywords: kis,korea-investment,securities,broker,trading,stock,ccxt
Author: Youngchan Kim
Author-email: Youngchan Kim <cookieshake.dev@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Typing :: Typed
Requires-Dist: httpx>=0.28
Requires-Dist: pydantic>=2.9
Requires-Dist: platformdirs>=4.3
Requires-Dist: websockets>=13
Requires-Python: >=3.13
Project-URL: Homepage, https://github.com/cookieshake/tooja
Project-URL: Repository, https://github.com/cookieshake/tooja
Project-URL: Issues, https://github.com/cookieshake/tooja/issues
Description-Content-Type: text/markdown

# tooja

[![Python](https://img.shields.io/badge/python-3.13%2B-blue)](https://www.python.org/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)

**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 `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

```bash
uv add tooja      # or: pip install tooja
```

Requires Python 3.13+. Before it lands on PyPI, install from source:

```bash
git clone https://github.com/cookieshake/tooja && cd tooja && uv sync
```

---

## Quick start

The constructor differs per broker; **everything after it is identical.**

```python
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](#raw-escape-hatch).

### KIS

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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)

```python
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

```python
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.

```python
# 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

```python
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`

```python
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](LICENSE) © Youngchan Kim
