Compare commits

...

1 commit

Author SHA1 Message Date
Jeffrey (Dongkyu) Kim
35b8207561 feat(toss-securities): add official read-only OpenAPI client
Add an official Toss Securities Open API client alongside the existing
unofficial tossctl wrapper. The package ships read-only helpers backed by
the official REST API (https://openapi.tossinvest.com): OAuth2
client_credentials token issuance with an in-memory token cache, bearer +
X-Tossinvest-Account header handling, TossApiError/TossCredentialsError
with secret/token redaction, and 429 Retry-After/backoff retry.

Credentials are read from TOSSINVEST_CLIENT_ID/TOSSINVEST_CLIENT_SECRET
(optional TOSSINVEST_ACCOUNT/TOSSINVEST_API_BASE_URL) and sent directly to
Toss, never through a shared proxy. Order mutation remains out of scope;
the tossctl path is retained as a documented fallback.

Closes #306
2026-06-10 16:11:37 +09:00
12 changed files with 1216 additions and 153 deletions

View file

@ -0,0 +1,5 @@
---
"toss-securities": minor
---
Add an official Toss Securities Open API client alongside the existing unofficial `tossctl` wrapper. The package now ships read-only helpers backed by the official REST API (`https://openapi.tossinvest.com`): OAuth 2.0 Client Credentials token issuance with an in-memory token cache, bearer + `X-Tossinvest-Account` header handling, `TossApiError`/`TossCredentialsError` envelopes with secret/token redaction, and 429 `Retry-After`/backoff retry. New read-only helpers cover prices, orderbook, trades, price limits, candles, stocks, stock warnings, exchange rate, market calendars, accounts, holdings, open orders, order detail, buying power, sellable quantity, and commissions. Credentials are read from `TOSSINVEST_CLIENT_ID`/`TOSSINVEST_CLIENT_SECRET` (optional `TOSSINVEST_ACCOUNT`/`TOSSINVEST_API_BASE_URL`) and sent directly to Toss, never through a shared proxy. Order mutation (create/modify/cancel) remains out of scope. The `tossctl` path is retained as a documented fallback.

View file

@ -73,7 +73,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| KBL 경기 결과 조회 | `kbl-results` | 날짜별 KBL 경기 일정, 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [KBL 경기 결과 가이드](docs/features/kbl-results.md) |
| K리그 경기 결과 조회 | `kleague-results` | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
| LCK 경기 분석 | `lck-analytics` | LCK 경기 결과, 현재 순위, live turning point, 밴픽, 패치 메타, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
| 토스증권 조회 | `toss-securities` | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 토스증권 조회 | `toss-securities` | 토스증권 공식 Open API(OAuth2) 우선, tossctl fallback으로 계좌·보유주식·시세·주문조회 등 조회 전용 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 하이패스 영수증 발급 | `hipass-receipt` | 하이패스 사용내역 조회 및 영수증 출력 payload 준비 | 필요 | [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md) |
| 캐치테이블 예약 스나이핑 | `catchtable-sniper` | 로그인된 캐치테이블 Chrome 세션으로 빈자리 감시, 오픈런, 자동 예약 시도 | 필요 | [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md) |
| 공연 일정·잔여석 조회 | `ticket-availability` | YES24·인터파크 공연의 회차별 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회 (조회 전용, 예매·결제 없음) | 불필요 | [공연 일정·잔여석 조회 가이드](docs/features/ticket-availability.md) |

View file

@ -1,23 +1,68 @@
# 토스증권 조회 가이드
토스증권 조회는 두 경로를 제공한다. **공식 Open API(OAuth2)를 우선** 사용하고, 공식 credentials가 없으면 비공식 `tossctl` 을 fallback으로 쓴다. 두 경로 모두 read-only(조회 전용)이며 실거래 mutation은 포함하지 않는다.
## 이 기능으로 할 수 있는 일
- `tossctl` 기반 토스증권 계좌 목록 / 계좌 요약 조회
- 포트폴리오 보유 종목 / 자산 비중 조회
- 단일 종목 / 다중 종목 시세 조회
- 미체결 주문 / 월간 체결 내역 조회
- 관심종목 목록 조회
- 공식 API: 계좌 목록 / 보유 주식 조회
- 공식 API: 시세(현재가·호가·체결·상하한가·캔들) / 종목 정보 / 매수 유의사항
- 공식 API: 환율(KRW↔USD) / 장 운영 캘린더(KR·US)
- 공식 API: 대기중 주문 조회 / 주문 상세 / 매수가능금액 / 판매가능수량 / 수수료
- tossctl fallback: 계좌 요약, 포트폴리오 보유 종목 / 자산 비중, 관심종목, 월간 체결 내역
## 먼저 필요한 것
## 1. 공식 Open API (권장)
- macOS + Homebrew
- `tossctl` 설치
- `tossctl auth login` 으로 브라우저 세션 확보
- `node` 18+
### 먼저 필요한 것
## upstream 설치와 로그인
- 토스증권 OpenAPI 콘솔에서 발급한 `client_id` / `client_secret`
- `node` 18+ (global `fetch`)
이 기능은 `JungHoonGhae/tossinvest-cli``tossctl` 을 그대로 사용한다.
자격 증명은 사용자 환경변수로 두고 helper가 `https://openapi.tossinvest.com` 으로 직접 호출한다. 공유 프록시(k-skill-proxy)로 보내지 않는다.
| 환경변수 | 설명 |
|---|---|
| `TOSSINVEST_CLIENT_ID` | client id (필수) |
| `TOSSINVEST_CLIENT_SECRET` | client secret (필수) |
| `TOSSINVEST_ACCOUNT` | accountSeq. 계좌·자산·주문조회에 필요 (선택) |
| `TOSSINVEST_API_BASE_URL` | 기본 `https://openapi.tossinvest.com` (선택) |
### 동작 방식
helper는 `POST /oauth2/token` 으로 Client Credentials access token을 발급받아 `Authorization: Bearer` 로 호출한다. 계좌·자산·주문조회 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다. `429``Retry-After` 만큼 대기 후 백오프 재시도하고, `401` 은 토큰을 1회 재발급한다. `client_secret`/토큰은 에러에서 마스킹된다.
### Node.js 예시
```js
const {
getPrices,
listOfficialAccounts,
getHoldings,
getBuyingPower
} = require("toss-securities");
async function main() {
const prices = await getPrices(["005930", "AAPL"]);
const accounts = await listOfficialAccounts();
const accountSeq = accounts.data.result[0].accountSeq;
const holdings = await getHoldings({ account: accountSeq });
const buyingPower = await getBuyingPower({ account: accountSeq, currency: "KRW" });
console.log(prices.data);
console.log(holdings.data);
console.log(buyingPower.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 2. tossctl fallback
이 경로는 `JungHoonGhae/tossinvest-cli``tossctl` 을 그대로 사용한다. 공식 API credentials가 없을 때 쓴다.
```bash
brew tap JungHoonGhae/tossinvest-cli
@ -29,7 +74,7 @@ tossctl auth login
로그인이 끝나기 전에는 계좌/포트폴리오 조회를 시도하지 않는다.
## 지원하는 read-only 명령
지원하는 read-only 명령:
- `tossctl account list --output json`
- `tossctl account summary --output json`
@ -41,43 +86,17 @@ tossctl auth login
- `tossctl orders completed --market all --output json`
- `tossctl watchlist list --output json`
## Node.js 예시
```js
const {
getAccountSummary,
getPortfolioPositions,
getQuote,
listCompletedOrders
} = require("toss-securities");
async function main() {
const summary = await getAccountSummary();
const positions = await getPortfolioPositions();
const quote = await getQuote("TSLA");
const completed = await listCompletedOrders({ market: "all" });
console.log(summary.data);
console.log(positions.data);
console.log(quote.data);
console.log(completed.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
패키지 wrapper(`getAccountSummary`, `getPortfolioPositions`, `getQuote`, `listCompletedOrders`, `listWatchlist` 등)도 동일하게 동작한다.
## 운영 팁
- 계좌 요약과 포트폴리오는 로그인 세션이 있어야만 동작한다.
- `TSLA`, `VOO`, `005930` 같이 심볼을 그대로 넘기면 된다.
- 공식 API는 `TOSSINVEST_CLIENT_ID`/`TOSSINVEST_CLIENT_SECRET` 가 있어야 동작하고, 계좌·자산·주문조회는 `X-Tossinvest-Account`(=`TOSSINVEST_ACCOUNT` 또는 `account` 옵션)가 필요하다.
- `005930`, `AAPL`, `TSLA` 같이 심볼을 그대로 넘기면 된다. 공식 `getPrices`/`getStocks` 는 다건 심볼을 콤마로 연결한다.
- 주문 관련 답변은 **조회 결과만** 정리하고, 실거래로 이어지는 행동은 권하지 않는다.
- 민감한 계좌 정보는 꼭 필요한 값만 답한다.
## 주의할 점
- `tossctl` 은 비공식 CLI 이므로 웹 내부 API 변경에 영향을 받을 수 있다.
- 브라우저 세션이 만료되면 `tossctl auth login` 을 다시 해야 할 수 있다.
- 이 레포의 `toss-securities` 패키지는 read-only wrapper 이며, 거래 mutation 명령은 공개 API에 포함하지 않는다.
- 공식 credentials가 없으면 helper가 `TossCredentialsError` 로 명확히 실패한다.
- `tossctl` 은 비공식 CLI 이므로 웹 내부 API 변경에 영향을 받을 수 있다. 브라우저 세션이 만료되면 `tossctl auth login` 을 다시 해야 할 수 있다.
- 이 레포의 `toss-securities` 패키지는 공식/비공식 모두 read-only 이며, 거래 mutation 명령(주문 생성/정정/취소)은 공개 API에 포함하지 않는다.

View file

@ -339,6 +339,14 @@ brew tap JungHoonGhae/tossinvest-cli
brew install tossctl
```
`toss-securities` 스킬은 공식 토스증권 Open API를 우선 사용한다. 공식 경로를 쓰려면 발급받은 자격증명을 사용자 환경변수로 둔다(공유 프록시로 보내지 않고 토스 서버로 직접 호출한다). `tossctl` 설치는 공식 credentials가 없을 때의 fallback 경로용이다.
```bash
export TOSSINVEST_CLIENT_ID=... # 필수
export TOSSINVEST_CLIENT_SECRET=... # 필수
export TOSSINVEST_ACCOUNT=... # 선택, 계좌·자산·주문조회 시 X-Tossinvest-Account
```
### Python 패키지
```bash

View file

@ -18,6 +18,9 @@
- KBL 일정/결과 API: https://api.kbl.or.kr/match/list
- KBL 팀 순위 API: https://api.kbl.or.kr/league/rank/team
- tossinvest-cli: https://github.com/JungHoonGhae/tossinvest-cli
- 토스증권 공식 Open API 문서: https://developers.tossinvest.com/docs
- 토스증권 공식 Open API OpenAPI JSON (source of truth): https://openapi.tossinvest.com/openapi-docs/latest/openapi.json
- 토스증권 공식 Open API 개요: https://openapi.tossinvest.com/openapi-docs/overview.md — 서버 host `https://openapi.tossinvest.com`. OAuth2 Client Credentials(`POST /oauth2/token`) 토큰으로 호출하며, 계좌·자산·주문 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다. 사용자별 민감 자격증명이므로 `k-skill-proxy` 가 아니라 사용자 환경에서 직접 호출한다.
- 하이패스 메인: https://www.hipass.co.kr/main.do
- 하이패스 로그인: https://www.hipass.co.kr/comm/lginpg.do
- 하이패스 사용내역 조회 진입: https://www.hipass.co.kr/usepculr/InitUsePculrTabSearch.do

View file

@ -1,10 +1,95 @@
# toss-securities
`JungHoonGhae/tossinvest-cli``tossctl` 바이너리를 감싸는 **read-only tossctl wrapper** 입니다. 이 패키지는 설치/로그인/조회 흐름만 정리하고, 거래 mutation 은 공개 API에서 지원하지 않습니다.
토스증권 **조회 전용(read-only)** 클라이언트입니다. 두 경로를 제공합니다.
## Install
1. **공식 Open API (권장 / primary)** — 토스증권 공식 Open API(`https://openapi.tossinvest.com`)를 OAuth 2.0 Client Credentials 토큰으로 직접 호출합니다.
2. **tossctl fallback** — 공식 API credentials가 없을 때를 위한 비공식 `JungHoonGhae/tossinvest-cli``tossctl` **read-only tossctl wrapper** 입니다.
먼저 upstream CLI 를 설치합니다.
두 경로 모두 조회 전용입니다. 거래 mutation(주문 생성/정정/취소)은 의도적으로 래핑하지 않습니다.
## 1. 공식 Open API (권장)
### Credentials
토스증권 OpenAPI 콘솔에서 클라이언트를 등록해 `client_id` / `client_secret` 을 발급받습니다. 자격 증명은 **사용자 본인의 환경변수**로 두고, helper가 `https://openapi.tossinvest.com` 으로 **직접** 호출합니다. 공유 프록시(k-skill-proxy)로는 절대 라우팅하지 않습니다.
| 환경변수 | 필수 | 설명 |
|---|---|---|
| `TOSSINVEST_CLIENT_ID` | 필수 | 발급받은 client id |
| `TOSSINVEST_CLIENT_SECRET` | 필수 | 발급받은 client secret |
| `TOSSINVEST_ACCOUNT` | 선택 | `X-Tossinvest-Account` 에 쓸 accountSeq. 계좌·자산·주문조회 helper에 필요 |
| `TOSSINVEST_API_BASE_URL` | 선택 | 기본 `https://openapi.tossinvest.com` |
per-call 옵션(`{ clientId, clientSecret, account, baseUrl }`)이 환경변수보다 우선합니다.
### 토큰 흐름
helper들은 내부적으로 `POST /oauth2/token` (Client Credentials, `application/x-www-form-urlencoded`)으로 access token을 발급받아 `Authorization: Bearer {token}` 헤더로 호출합니다. 토큰은 프로세스 전역(in-memory) 캐시에 `client_id::base_url` 키로 보관되며 만료 60초 전에 자동 재발급됩니다. 테스트 등에서 캐시를 비우려면 `clearTokenCache()` 를 호출합니다.
> 보안: `client_secret` 와 access token은 throw되는 에러 메시지/`data`에서 항상 `[REDACTED]` 로 마스킹됩니다. 토큰 캐시는 같은 Node 프로세스 안에서 공유됩니다.
### 시세·종목 helper (토큰만 필요)
- `getOrderbook(symbol)``GET /api/v1/orderbook`
- `getPrices(symbols)``GET /api/v1/prices` (다건은 콤마로 연결, 최대 200)
- `getTrades(symbol, { count })``GET /api/v1/trades`
- `getPriceLimits(symbol)``GET /api/v1/price-limits`
- `getCandles(symbol, { interval })``GET /api/v1/candles` (`interval``1m`·`1d`, 필수)
- `getStocks(symbols)``GET /api/v1/stocks`
- `getStockWarnings(symbol)``GET /api/v1/stocks/{symbol}/warnings`
- `getExchangeRate({ from, to })``GET /api/v1/exchange-rate`
- `getMarketCalendarKR({ date })``GET /api/v1/market-calendar/KR`
- `getMarketCalendarUS({ date })``GET /api/v1/market-calendar/US`
### 계좌·자산·주문조회 helper (토큰 + `X-Tossinvest-Account`)
- `listOfficialAccounts()``GET /api/v1/accounts` (accountSeq를 얻는 진입점, 토큰만 필요)
- `getHoldings({ symbol })``GET /api/v1/holdings`
- `listOpenOrders()``GET /api/v1/orders` (대기중 주문)
- `getOrderDetail(orderId)``GET /api/v1/orders/{orderId}`
- `getBuyingPower({ currency })``GET /api/v1/buying-power`
- `getSellableQuantity(symbol)``GET /api/v1/sellable-quantity`
- `getCommissions()``GET /api/v1/commissions`
각 helper는 `{ data, rateLimit: { limit, remaining, reset }, requestId, status }` 를 반환합니다. account 헤더가 필요한 helper에서 account가 없으면 네트워크 호출 전에 `TossCredentialsError` 를 던집니다.
### Rate limit / 에러
- `429` 응답은 `Retry-After` (없으면 `X-RateLimit-Reset`) 만큼 대기 후 지수 백오프(1→2→4초)+jitter로 재시도하며 `maxRetries`(기본 3)에서 멈춥니다.
- `401`(`invalid-token`/`expired-token`)은 토큰을 1회 재발급해 재시도하고, 그래도 실패하면 throw합니다.
- 에러 envelope `{ error: { requestId, code, message, data } }``TossApiError{ code, message, requestId, httpStatus, data }` 로 변환됩니다. `requestId` 는 본문에 없으면 `X-Request-Id` 헤더에서 가져옵니다.
### 사용 예시
```js
const {
getPrices,
getHoldings,
listOfficialAccounts
} = require("toss-securities");
async function main() {
// 환경변수 TOSSINVEST_CLIENT_ID / TOSSINVEST_CLIENT_SECRET 필요
const prices = await getPrices(["005930", "AAPL"]);
const accounts = await listOfficialAccounts();
const accountSeq = accounts.data.result[0].accountSeq;
const holdings = await getHoldings({ account: accountSeq });
console.log(prices.data);
console.log(holdings.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 2. tossctl fallback (read-only tossctl wrapper)
공식 API credentials가 없으면 비공식 `tossctl` 경로를 fallback으로 쓸 수 있습니다. 먼저 upstream CLI를 설치합니다.
중요: `tossctl >= 0.3.6` 사용을 권장합니다. (`quote` 403 / 세션 관련 upstream 이슈 #15 반영 버전)
@ -22,60 +107,26 @@ tossctl auth login
npm install toss-securities
```
## Supported read-only helpers
### tossctl read-only helpers
- `listAccounts()`
- `getAccountSummary()`
- `getAccountSummary()``tossctl account summary --output json`
- `getPortfolioPositions()`
- `getPortfolioAllocation()`
- `getQuote(symbol)`
- `getQuote(symbol)``tossctl quote get TSLA --output json`
- `getQuoteBatch(symbols)`
- `listOrders()`
- `listCompletedOrders({ market })`
- `listWatchlist()`
- `listWatchlist()``tossctl watchlist list --output json`
- `checkSession()`
모든 helper 는 내부적으로 `tossctl ... --output json` 을 실행하고, `commandName`, `bin`, `args`, `data` 를 반환합니다.
모든 tossctl helper는 내부적으로 `tossctl ... --output json` 을 실행하고 `commandName`, `bin`, `args`, `data` 를 반환합니다. 세션이 만료되면 `TossSessionExpiredError` 로 승격됩니다.
세션 만료 관련:
- `account summary` 등은 만료 시 에러를 던집니다.
- 일부 커맨드(`portfolio positions`, `watchlist list`)는 upstream에서 빈 배열(`[]`)을 반환할 수 있어, 이 패키지는 기본적으로 `auth doctor`를 추가 확인해 만료를 `TossSessionExpiredError`로 승격합니다.
- 필요하면 `verifySessionOnEmpty: false`로 기존 빈 배열 동작을 유지할 수 있습니다.
## 지원하지 않는 것 (not supported)
대응되는 대표 CLI 는 `tossctl account summary --output json`, `tossctl quote get TSLA --output json`, `tossctl watchlist list --output json` 입니다.
## Usage
```js
const {
getAccountSummary,
getQuote,
listWatchlist
} = require("toss-securities");
async function main() {
const summary = await getAccountSummary({
configDir: "/Users/me/.config/tossctl"
});
const quote = await getQuote("TSLA");
const watchlist = await listWatchlist();
console.log(summary.data);
console.log(quote.data);
console.log(watchlist.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## What is intentionally not supported
- `tossctl order place`
- `tossctl order cancel`
- `tossctl order amend`
- `tossctl order place` / 공식 API `POST /api/v1/orders` (주문 생성)
- `tossctl order cancel` / 공식 API `POST /api/v1/orders/{orderId}/cancel`
- `tossctl order amend` / 공식 API `POST /api/v1/orders/{orderId}/modify`
- permission grant/revoke
이 패키지는 조회 전용이다. 실거래에 영향을 주는 명령은 upstream safety gate 를 우회하지 않도록 래핑하지 않는다.
이 패키지는 조회 전용이다. 실거래에 영향을 주는 명령은 공식/비공식 어느 경로에서도 래핑하지 않는다.

View file

@ -1,7 +1,7 @@
{
"name": "toss-securities",
"version": "0.4.0",
"description": "Safe read-only tossctl wrapper for Toss Securities skill workflows",
"description": "Read-only Toss Securities client: official Open API (OAuth2) first, unofficial tossctl wrapper as fallback",
"license": "MIT",
"main": "src/index.js",
"files": [
@ -23,10 +23,12 @@
"korea",
"toss",
"securities",
"tossinvest",
"openapi",
"tossctl"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"lint": "node --check src/index.js && node --check src/parse.js && node --check src/official-client.js && node --check test/index.test.js && node --check test/official-client.test.js",
"test": "node --test"
}
}

View file

@ -6,6 +6,8 @@ const {
parseJsonOutput
} = require("./parse");
const officialClient = require("./official-client");
const execFile = util.promisify(childProcess.execFile);
const SESSION_EXPIRED_PATTERN = /stored session is no longer valid/iu;
@ -183,6 +185,7 @@ function getQuoteBatch(symbols, options = {}) {
}
module.exports = {
// Unofficial tossctl wrapper (fallback path)
buildReadOnlyCommand,
checkSession,
getAccountSummary,
@ -195,5 +198,7 @@ module.exports = {
listOrders,
listWatchlist,
runReadOnlyCommand,
TossSessionExpiredError
TossSessionExpiredError,
// Official Toss Securities Open API client (primary path)
...officialClient
};

View file

@ -0,0 +1,530 @@
"use strict";
// Official Toss Securities Open API client (read-only).
//
// Source of truth: https://openapi.tossinvest.com/openapi-docs/latest/openapi.json
// (title "토스증권 Open API", version 1.0.3, server https://openapi.tossinvest.com).
//
// Security posture:
// - Credentials (client id/secret, account) are read from the user's environment
// and sent directly to the official API. They are NEVER routed through any
// shared proxy (k-skill-proxy is free-API-only).
// - client_secret and access tokens are redacted from thrown error messages.
// - This module is read-only: it implements GET endpoints plus the OAuth token
// issuance required to call them. It deliberately exposes NO order mutation
// (no place/modify/cancel order) functions.
const OFFICIAL_BASE_URL = "https://openapi.tossinvest.com";
const TOKEN_EXPIRY_SKEW_MS = 60_000;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_BACKOFF_BASE_MS = 1000;
// Endpoint registry. `requiresAccount` marks the account/asset/order surfaces
// that additionally need the `X-Tossinvest-Account` header. Note `/api/v1/accounts`
// is bearer-only: it is the entry point used to discover the accountSeq.
const ENDPOINTS = Object.freeze({
// Market data (bearer only)
getOrderbook: { path: "/api/v1/orderbook", requiresAccount: false, rateLimitGroup: "MARKET_DATA" },
getPrices: { path: "/api/v1/prices", requiresAccount: false, rateLimitGroup: "MARKET_DATA" },
getTrades: { path: "/api/v1/trades", requiresAccount: false, rateLimitGroup: "MARKET_DATA" },
getPriceLimits: { path: "/api/v1/price-limits", requiresAccount: false, rateLimitGroup: "MARKET_DATA" },
getCandles: { path: "/api/v1/candles", requiresAccount: false, rateLimitGroup: "MARKET_DATA_CHART" },
// Stock info (bearer only)
getStocks: { path: "/api/v1/stocks", requiresAccount: false, rateLimitGroup: "STOCK" },
getStockWarnings: { path: "/api/v1/stocks/{symbol}/warnings", requiresAccount: false, rateLimitGroup: "STOCK" },
// Market info (bearer only)
getExchangeRate: { path: "/api/v1/exchange-rate", requiresAccount: false, rateLimitGroup: "MARKET_INFO" },
getMarketCalendarKR: { path: "/api/v1/market-calendar/KR", requiresAccount: false, rateLimitGroup: "MARKET_INFO" },
getMarketCalendarUS: { path: "/api/v1/market-calendar/US", requiresAccount: false, rateLimitGroup: "MARKET_INFO" },
// Account / asset
listOfficialAccounts: { path: "/api/v1/accounts", requiresAccount: false, rateLimitGroup: "ACCOUNT" },
getHoldings: { path: "/api/v1/holdings", requiresAccount: true, rateLimitGroup: "ASSET" },
// Order history (read-only)
listOpenOrders: { path: "/api/v1/orders", requiresAccount: true, rateLimitGroup: "ORDER_HISTORY" },
getOrderDetail: { path: "/api/v1/orders/{orderId}", requiresAccount: true, rateLimitGroup: "ORDER_HISTORY" },
// Order info (read-only)
getBuyingPower: { path: "/api/v1/buying-power", requiresAccount: true, rateLimitGroup: "ORDER_INFO" },
getSellableQuantity: { path: "/api/v1/sellable-quantity", requiresAccount: true, rateLimitGroup: "ORDER_INFO" },
getCommissions: { path: "/api/v1/commissions", requiresAccount: true, rateLimitGroup: "ORDER_INFO" }
});
// Process-global token cache, keyed by `${clientId}::${baseUrl}`. By design the
// cache is shared across all callers in a single Node process; call
// `clearTokenCache()` to reset it (tests do this between cases).
const tokenCache = new Map();
class TossApiError extends Error {
constructor({ code, message, requestId, httpStatus, data } = {}, secrets = []) {
super(redact(`[${code}] ${message}`, secrets));
this.name = "TossApiError";
this.code = code;
this.requestId = requestId || null;
this.httpStatus = httpStatus;
this.data = redactDeep(data ?? null, secrets);
}
}
class TossCredentialsError extends Error {
constructor(message) {
super(message);
this.name = "TossCredentialsError";
}
}
function isBlank(value) {
return value === undefined || value === null || String(value).trim() === "";
}
function redact(text, secrets = []) {
let out = String(text);
for (const secret of secrets) {
if (secret) {
out = out.split(String(secret)).join("[REDACTED]");
}
}
return out;
}
function redactDeep(value, secrets = []) {
if (value === undefined || value === null) {
return value;
}
try {
return JSON.parse(redact(JSON.stringify(value), secrets));
} catch {
return value;
}
}
function resolveConfig(options = {}) {
const env = options.env || process.env;
const baseUrl = String(
options.baseUrl ?? env.TOSSINVEST_API_BASE_URL ?? OFFICIAL_BASE_URL
).replace(/\/+$/u, "");
return {
clientId: options.clientId ?? env.TOSSINVEST_CLIENT_ID,
clientSecret: options.clientSecret ?? env.TOSSINVEST_CLIENT_SECRET,
account: options.account ?? env.TOSSINVEST_ACCOUNT,
baseUrl,
fetchImpl: options.fetch || globalThis.fetch,
now: options.now || Date.now,
sleep: options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
maxRetries: Number.isInteger(options.maxRetries) ? options.maxRetries : DEFAULT_MAX_RETRIES,
backoffBaseMs: Number.isFinite(options.backoffBaseMs) ? options.backoffBaseMs : DEFAULT_BACKOFF_BASE_MS,
jitter: typeof options.jitter === "function" ? options.jitter : Math.random
};
}
function collectSecrets(cfg, token) {
return [cfg && cfg.clientSecret, token].filter(Boolean);
}
function assertClientCredentials(cfg) {
if (isBlank(cfg.clientId) || isBlank(cfg.clientSecret)) {
throw new TossCredentialsError(
"Toss official API credentials are missing. Set TOSSINVEST_CLIENT_ID and TOSSINVEST_CLIENT_SECRET (or pass clientId/clientSecret)."
);
}
}
function assertFetch(cfg) {
if (typeof cfg.fetchImpl !== "function") {
throw new Error("A fetch implementation is required (Node 18+ global fetch or options.fetch).");
}
}
function tokenCacheKey(clientId, baseUrl) {
return `${clientId}::${baseUrl}`;
}
function clearTokenCache() {
tokenCache.clear();
}
async function readJson(response) {
if (response && typeof response.json === "function") {
try {
return await response.json();
} catch {
// fall through to text parsing
}
}
if (response && typeof response.text === "function") {
try {
const text = await response.text();
return text ? JSON.parse(text) : null;
} catch {
return null;
}
}
return null;
}
function headerValue(response, name) {
const headers = response && response.headers;
if (headers && typeof headers.get === "function") {
return headers.get(name);
}
return null;
}
function readRateLimit(response) {
const toNumber = (value) => (value === null || value === undefined || value === "" ? null : Number(value));
return {
limit: toNumber(headerValue(response, "x-ratelimit-limit")),
remaining: toNumber(headerValue(response, "x-ratelimit-remaining")),
reset: toNumber(headerValue(response, "x-ratelimit-reset"))
};
}
function buildUrl(baseUrl, path, query) {
const url = new URL(`${baseUrl}${path}`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) {
continue;
}
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
function applyPathParams(path, params) {
if (!params) {
return path;
}
return path.replace(/\{(\w+)\}/gu, (_match, key) => {
const value = params[key];
if (isBlank(value)) {
throw new Error(`Missing path parameter: ${key}`);
}
return encodeURIComponent(String(value));
});
}
function normalizeSymbols(symbols) {
const list = Array.isArray(symbols) ? symbols : String(symbols ?? "").split(",");
const cleaned = list.map((symbol) => String(symbol).trim()).filter(Boolean);
if (cleaned.length === 0) {
throw new Error("symbols is required (one or more).");
}
return cleaned.join(",");
}
function requireSymbol(symbol) {
const value = String(symbol ?? "").trim();
if (!value) {
throw new Error("symbol is required.");
}
return value;
}
function buildAuthHeaders(token) {
if (isBlank(token)) {
throw new TossCredentialsError("An access token is required to build authorization headers.");
}
return { Authorization: `Bearer ${token}` };
}
function buildAccountHeaders(token, account) {
const headers = buildAuthHeaders(token);
if (isBlank(account)) {
throw new TossCredentialsError(
"X-Tossinvest-Account is required for account, asset, and order APIs. Set TOSSINVEST_ACCOUNT or pass options.account."
);
}
headers["X-Tossinvest-Account"] = String(account);
return headers;
}
async function issueAccessToken(options = {}) {
const cfg = resolveConfig(options);
assertClientCredentials(cfg);
assertFetch(cfg);
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: cfg.clientId,
client_secret: cfg.clientSecret
});
const response = await cfg.fetchImpl(`${cfg.baseUrl}/oauth2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json"
},
body: body.toString()
});
const payload = await readJson(response);
if (!response.ok) {
// The token endpoint uses the OAuth2 standard error shape, not the BFF envelope.
throw new TossApiError(
{
code: (payload && payload.error) || `http-${response.status}`,
message: (payload && payload.error_description) || "OAuth2 token request failed.",
requestId: headerValue(response, "x-request-id"),
httpStatus: response.status,
data: null
},
collectSecrets(cfg)
);
}
return {
accessToken: payload && payload.access_token,
tokenType: payload && payload.token_type,
expiresIn: payload && payload.expires_in,
raw: payload
};
}
async function getAccessToken(options = {}) {
const cfg = resolveConfig(options);
assertClientCredentials(cfg);
const key = tokenCacheKey(cfg.clientId, cfg.baseUrl);
if (options.forceRefresh !== true) {
const cached = tokenCache.get(key);
if (cached && cached.expiresAt > cfg.now()) {
return cached.accessToken;
}
}
const token = await issueAccessToken(options);
if (isBlank(token.accessToken)) {
throw new TossApiError(
{ code: "invalid-token-response", message: "Token endpoint did not return an access_token.", httpStatus: 200 },
collectSecrets(cfg)
);
}
const expiresInSeconds = Number(token.expiresIn);
const ttlMs = Number.isFinite(expiresInSeconds) ? expiresInSeconds * 1000 : 0;
tokenCache.set(key, {
accessToken: token.accessToken,
expiresAt: cfg.now() + ttlMs - TOKEN_EXPIRY_SKEW_MS
});
return token.accessToken;
}
function buildApiError(response, payload, secrets) {
const envelope = payload && payload.error;
const code = (envelope && envelope.code) || `http-${response.status}`;
const message =
(envelope && envelope.message) || `Toss official API request failed with status ${response.status}.`;
const requestId = (envelope && envelope.requestId) || headerValue(response, "x-request-id");
const data = (envelope && envelope.data) || null;
return new TossApiError({ code, message, requestId, httpStatus: response.status, data }, secrets);
}
function computeRetryDelayMs(response, cfg, attempt) {
const retryAfter = Number(headerValue(response, "retry-after"));
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
return retryAfter * 1000;
}
const reset = Number(headerValue(response, "x-ratelimit-reset"));
if (Number.isFinite(reset) && reset >= 0) {
return reset * 1000;
}
const base = cfg.backoffBaseMs * 2 ** attempt;
return base + Math.floor(cfg.jitter() * cfg.backoffBaseMs);
}
async function tossApiRequest(endpointKey, requestOptions = {}, options = {}) {
const endpoint = ENDPOINTS[endpointKey];
if (!endpoint) {
throw new Error(`Unknown Toss official endpoint: ${endpointKey}`);
}
const cfg = resolveConfig(options);
assertClientCredentials(cfg);
assertFetch(cfg);
// Enforce the account-header requirement locally before any network call.
if (endpoint.requiresAccount && isBlank(cfg.account)) {
throw new TossCredentialsError(
`${endpointKey} requires the X-Tossinvest-Account header. Set TOSSINVEST_ACCOUNT or pass options.account.`
);
}
const path = applyPathParams(endpoint.path, requestOptions.pathParams);
const url = buildUrl(cfg.baseUrl, path, requestOptions.query);
let attempt = 0;
let tokenRetried = false;
for (;;) {
const token = await getAccessToken(options);
const headers = endpoint.requiresAccount
? buildAccountHeaders(token, cfg.account)
: buildAuthHeaders(token);
headers.Accept = "application/json";
const response = await cfg.fetchImpl(url, { method: "GET", headers });
const payload = await readJson(response);
if (response.ok) {
return {
data: payload,
rateLimit: readRateLimit(response),
requestId: headerValue(response, "x-request-id") || (payload && payload.error && payload.error.requestId) || null,
status: response.status
};
}
// Expired/invalid token: clear the cache and re-issue exactly once.
if (response.status === 401 && !tokenRetried) {
tokenRetried = true;
tokenCache.delete(tokenCacheKey(cfg.clientId, cfg.baseUrl));
continue;
}
// Rate limited: honor Retry-After / reset, then exponential backoff + jitter.
if (response.status === 429 && attempt < cfg.maxRetries) {
const delayMs = computeRetryDelayMs(response, cfg, attempt);
attempt += 1;
await cfg.sleep(delayMs);
continue;
}
throw buildApiError(response, payload, collectSecrets(cfg, token));
}
}
// --- Read-only helpers (1:1 with GET endpoints) ---
// Market data (bearer only)
function getOrderbook(symbol, options = {}) {
return tossApiRequest("getOrderbook", { query: { symbol: requireSymbol(symbol) } }, options);
}
function getPrices(symbols, options = {}) {
return tossApiRequest("getPrices", { query: { symbols: normalizeSymbols(symbols) } }, options);
}
function getTrades(symbol, options = {}) {
return tossApiRequest("getTrades", { query: { symbol: requireSymbol(symbol), count: options.count } }, options);
}
function getPriceLimits(symbol, options = {}) {
return tossApiRequest("getPriceLimits", { query: { symbol: requireSymbol(symbol) } }, options);
}
function getCandles(symbol, options = {}) {
if (isBlank(options.interval)) {
throw new Error("interval is required for getCandles ('1m' or '1d').");
}
return tossApiRequest(
"getCandles",
{
query: {
symbol: requireSymbol(symbol),
interval: options.interval,
count: options.count,
before: options.before,
adjusted: options.adjusted
}
},
options
);
}
// Stock info (bearer only)
function getStocks(symbols, options = {}) {
return tossApiRequest("getStocks", { query: { symbols: normalizeSymbols(symbols) } }, options);
}
function getStockWarnings(symbol, options = {}) {
return tossApiRequest("getStockWarnings", { pathParams: { symbol: requireSymbol(symbol) } }, options);
}
// Market info (bearer only)
function getExchangeRate(options = {}) {
return tossApiRequest(
"getExchangeRate",
{ query: { from: options.from, to: options.to, dateTime: options.dateTime } },
options
);
}
function getMarketCalendarKR(options = {}) {
return tossApiRequest("getMarketCalendarKR", { query: { date: options.date } }, options);
}
function getMarketCalendarUS(options = {}) {
return tossApiRequest("getMarketCalendarUS", { query: { date: options.date } }, options);
}
// Account / asset
function listOfficialAccounts(options = {}) {
return tossApiRequest("listOfficialAccounts", {}, options);
}
function getHoldings(options = {}) {
return tossApiRequest("getHoldings", { query: { symbol: options.symbol } }, options);
}
// Order history (read-only)
function listOpenOrders(options = {}) {
return tossApiRequest("listOpenOrders", { query: { status: options.status || "OPEN" } }, options);
}
function getOrderDetail(orderId, options = {}) {
const id = String(orderId ?? "").trim();
if (!id) {
throw new Error("orderId is required.");
}
return tossApiRequest("getOrderDetail", { pathParams: { orderId: id } }, options);
}
// Order info (read-only)
function getBuyingPower(options = {}) {
return tossApiRequest("getBuyingPower", { query: { currency: options.currency } }, options);
}
function getSellableQuantity(symbol, options = {}) {
return tossApiRequest("getSellableQuantity", { query: { symbol: requireSymbol(symbol) } }, options);
}
function getCommissions(options = {}) {
return tossApiRequest("getCommissions", {}, options);
}
module.exports = {
OFFICIAL_BASE_URL,
ENDPOINTS,
TossApiError,
TossCredentialsError,
resolveConfig,
clearTokenCache,
issueAccessToken,
getAccessToken,
buildAuthHeaders,
buildAccountHeaders,
tossApiRequest,
// read-only helpers
getOrderbook,
getPrices,
getTrades,
getPriceLimits,
getCandles,
getStocks,
getStockWarnings,
getExchangeRate,
getMarketCalendarKR,
getMarketCalendarUS,
listOfficialAccounts,
getHoldings,
listOpenOrders,
getOrderDetail,
getBuyingPower,
getSellableQuantity,
getCommissions
};

View file

@ -0,0 +1,394 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
ENDPOINTS,
TossApiError,
TossCredentialsError,
clearTokenCache,
issueAccessToken,
getAccessToken,
buildAuthHeaders,
buildAccountHeaders,
getOrderbook,
getPrices,
getTrades,
getPriceLimits,
getCandles,
getStocks,
getStockWarnings,
getExchangeRate,
getMarketCalendarKR,
getMarketCalendarUS,
listOfficialAccounts,
getHoldings,
listOpenOrders,
getOrderDetail,
getBuyingPower,
getSellableQuantity,
getCommissions
} = require("../src/official-client");
const CLIENT_ID = "c_test_id";
const CLIENT_SECRET = "s_super_secret_value";
const ACCESS_TOKEN = "eyJhbGciOiJ.access.token";
const BASE_URL = "https://openapi.tossinvest.com";
function jsonResponse({ status = 200, body = {}, headers = {} } = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(headers),
async json() {
return body;
},
async text() {
return JSON.stringify(body);
}
};
}
// Builds a fetch mock from an ordered queue of responses (or response factories).
// Records every call's url, method, headers, and body.
function makeFetch(queue) {
const calls = [];
const responses = Array.isArray(queue) ? [...queue] : [queue];
const fetchImpl = async (url, init = {}) => {
calls.push({ url, method: init.method || "GET", headers: init.headers || {}, body: init.body });
if (responses.length === 0) {
throw new Error(`Unexpected fetch call (no queued response) for ${url}`);
}
const next = responses.shift();
return typeof next === "function" ? next() : next;
};
fetchImpl.calls = calls;
return fetchImpl;
}
const tokenOk = () =>
jsonResponse({ body: { access_token: ACCESS_TOKEN, token_type: "Bearer", expires_in: 86400 } });
function baseOptions(extra = {}) {
return {
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
baseUrl: BASE_URL,
env: {},
now: () => 1_000_000,
sleep: async () => {},
...extra
};
}
test.beforeEach(() => {
clearTokenCache();
});
test("issueAccessToken posts client_credentials form body to /oauth2/token", async () => {
const fetchImpl = makeFetch([tokenOk()]);
const result = await issueAccessToken(baseOptions({ fetch: fetchImpl }));
assert.equal(result.accessToken, ACCESS_TOKEN);
assert.equal(result.tokenType, "Bearer");
assert.equal(result.expiresIn, 86400);
const call = fetchImpl.calls[0];
assert.equal(call.url, `${BASE_URL}/oauth2/token`);
assert.equal(call.method, "POST");
assert.equal(call.headers["Content-Type"], "application/x-www-form-urlencoded");
const params = new URLSearchParams(call.body);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), CLIENT_ID);
assert.equal(params.get("client_secret"), CLIENT_SECRET);
});
test("getAccessToken caches the token and reuses it across calls", async () => {
const fetchImpl = makeFetch([tokenOk(), tokenOk()]);
const opts = baseOptions({ fetch: fetchImpl });
const first = await getAccessToken(opts);
const second = await getAccessToken(opts);
assert.equal(first, ACCESS_TOKEN);
assert.equal(second, ACCESS_TOKEN);
const tokenCalls = fetchImpl.calls.filter((c) => c.url.endsWith("/oauth2/token"));
assert.equal(tokenCalls.length, 1, "token endpoint should be hit exactly once");
});
test("getAccessToken refreshes after expiry", async () => {
const fetchImpl = makeFetch([
jsonResponse({ body: { access_token: "tok-1", token_type: "Bearer", expires_in: 100 } }),
jsonResponse({ body: { access_token: "tok-2", token_type: "Bearer", expires_in: 100 } })
]);
let clock = 1_000_000;
const opts = baseOptions({ fetch: fetchImpl, now: () => clock });
const first = await getAccessToken(opts);
assert.equal(first, "tok-1");
// Advance beyond expiry (100s ttl minus 60s skew => ~40s of validity).
clock += 200_000;
const second = await getAccessToken(opts);
assert.equal(second, "tok-2");
const tokenCalls = fetchImpl.calls.filter((c) => c.url.endsWith("/oauth2/token"));
assert.equal(tokenCalls.length, 2);
});
test("market helpers send only the bearer header (no account header)", async () => {
const fetchImpl = makeFetch([tokenOk(), jsonResponse({ body: { result: { price: "72000" } } })]);
const res = await getPrices(["005930", "AAPL"], baseOptions({ fetch: fetchImpl, account: "1" }));
assert.deepEqual(res.data, { result: { price: "72000" } });
const apiCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/prices"));
assert.equal(apiCall.headers.Authorization, `Bearer ${ACCESS_TOKEN}`);
assert.equal(apiCall.headers["X-Tossinvest-Account"], undefined);
});
test("getPrices and getStocks comma-join multiple symbols per the OpenAPI symbols param", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({ body: { result: [] } }),
jsonResponse({ body: { result: [] } })
]);
const opts = baseOptions({ fetch: fetchImpl });
await getPrices(["005930", "000660", "AAPL"], opts);
await getStocks(["005930", "AAPL"], opts);
const pricesCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/prices"));
const stocksCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/stocks"));
assert.equal(new URL(pricesCall.url).searchParams.get("symbols"), "005930,000660,AAPL");
assert.equal(new URL(stocksCall.url).searchParams.get("symbols"), "005930,AAPL");
});
test("account helpers send X-Tossinvest-Account header when account is configured", async () => {
const fetchImpl = makeFetch([tokenOk(), jsonResponse({ body: { result: { holdings: [] } } })]);
const res = await getHoldings(baseOptions({ fetch: fetchImpl, account: "42" }));
assert.deepEqual(res.data, { result: { holdings: [] } });
const apiCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/holdings"));
assert.equal(apiCall.headers["X-Tossinvest-Account"], "42");
assert.equal(apiCall.headers.Authorization, `Bearer ${ACCESS_TOKEN}`);
});
test("account-required helper throws TossCredentialsError before any network call when account is missing", async () => {
const fetchImpl = makeFetch([]);
await assert.rejects(
() => getHoldings(baseOptions({ fetch: fetchImpl })),
(error) => error instanceof TossCredentialsError && /X-Tossinvest-Account/.test(error.message)
);
assert.equal(fetchImpl.calls.length, 0, "no fetch should occur for a missing account header");
});
test("listOfficialAccounts is bearer-only and does not require an account header", async () => {
const fetchImpl = makeFetch([tokenOk(), jsonResponse({ body: { result: [{ accountSeq: 1 }] } })]);
const res = await listOfficialAccounts(baseOptions({ fetch: fetchImpl }));
assert.deepEqual(res.data, { result: [{ accountSeq: 1 }] });
const apiCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/accounts"));
assert.equal(apiCall.headers["X-Tossinvest-Account"], undefined);
assert.equal(ENDPOINTS.listOfficialAccounts.requiresAccount, false);
});
test("error envelope is parsed into TossApiError with code, message, requestId, httpStatus", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({
status: 404,
body: { error: { requestId: "REQ-1", code: "stock-not-found", message: "no such stock" } }
})
]);
await assert.rejects(
() => getStockWarnings("ZZZZ", baseOptions({ fetch: fetchImpl })),
(error) => {
assert.ok(error instanceof TossApiError);
assert.equal(error.code, "stock-not-found");
assert.equal(error.requestId, "REQ-1");
assert.equal(error.httpStatus, 404);
assert.match(error.message, /stock-not-found/);
return true;
}
);
});
test("requestId falls back to the X-Request-Id header when absent from the body", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({
status: 500,
headers: { "X-Request-Id": "HDR-REQ-9" },
body: { error: { code: "internal-error", message: "boom" } }
})
]);
await assert.rejects(
() => getCommissions(baseOptions({ fetch: fetchImpl, account: "1" })),
(error) => error instanceof TossApiError && error.requestId === "HDR-REQ-9"
);
});
test("thrown errors never expose the client_secret or the access token", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({
status: 400,
body: {
error: {
requestId: "REQ-2",
code: "invalid-request",
message: `leaked ${CLIENT_SECRET} and ${ACCESS_TOKEN}`,
data: { secret: CLIENT_SECRET, token: ACCESS_TOKEN }
}
}
})
]);
await assert.rejects(
() => getBuyingPower(baseOptions({ fetch: fetchImpl, account: "1" })),
(error) => {
const serialized = `${error.message} ${JSON.stringify(error.data)}`;
assert.ok(!serialized.includes(CLIENT_SECRET), "client_secret must be redacted");
assert.ok(!serialized.includes(ACCESS_TOKEN), "access token must be redacted");
assert.match(serialized, /\[REDACTED\]/);
return true;
}
);
});
test("a 401 re-issues the token exactly once, then throws on a second 401", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({ status: 401, body: { error: { code: "expired-token", message: "expired", requestId: "R1" } } }),
tokenOk(),
jsonResponse({ status: 401, body: { error: { code: "expired-token", message: "expired", requestId: "R2" } } })
]);
await assert.rejects(
() => getHoldings(baseOptions({ fetch: fetchImpl, account: "1" })),
(error) => error instanceof TossApiError && error.code === "expired-token"
);
const tokenCalls = fetchImpl.calls.filter((c) => c.url.endsWith("/oauth2/token"));
assert.equal(tokenCalls.length, 2, "token should be re-issued exactly once after the first 401");
});
test("a 401 followed by success retries with a fresh token", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({ status: 401, body: { error: { code: "expired-token", message: "expired", requestId: "R1" } } }),
tokenOk(),
jsonResponse({ body: { result: { ok: true } } })
]);
const res = await getHoldings(baseOptions({ fetch: fetchImpl, account: "1" }));
assert.deepEqual(res.data, { result: { ok: true } });
});
test("429 waits Retry-After then retries and succeeds", async () => {
const slept = [];
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({
status: 429,
headers: { "Retry-After": "2", "X-RateLimit-Remaining": "0" },
body: { error: { code: "rate-limit-exceeded", message: "slow down" } }
}),
jsonResponse({ body: { result: { price: "1" } }, headers: { "X-RateLimit-Limit": "10", "X-RateLimit-Remaining": "9" } })
]);
const res = await getPrices(
"005930",
baseOptions({ fetch: fetchImpl, account: "1", sleep: async (ms) => slept.push(ms) })
);
assert.deepEqual(res.data, { result: { price: "1" } });
assert.equal(res.rateLimit.limit, 10);
assert.equal(res.rateLimit.remaining, 9);
assert.deepEqual(slept, [2000], "should wait Retry-After seconds (in ms)");
});
test("429 beyond maxRetries throws a TossApiError", async () => {
const slept = [];
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({ status: 429, headers: { "Retry-After": "1" }, body: { error: { code: "rate-limit-exceeded", message: "slow" } } }),
jsonResponse({ status: 429, headers: { "Retry-After": "1" }, body: { error: { code: "rate-limit-exceeded", message: "slow" } } })
]);
await assert.rejects(
() =>
getPrices(
"005930",
baseOptions({ fetch: fetchImpl, account: "1", maxRetries: 1, sleep: async (ms) => slept.push(ms) })
),
(error) => error instanceof TossApiError && error.code === "rate-limit-exceeded" && error.httpStatus === 429
);
assert.equal(slept.length, 1, "should retry exactly maxRetries times before throwing");
});
test("missing client credentials throws TossCredentialsError without echoing secrets", async () => {
const fetchImpl = makeFetch([]);
await assert.rejects(
() => getPrices("005930", { fetch: fetchImpl, env: {} }),
(error) =>
error instanceof TossCredentialsError &&
/TOSSINVEST_CLIENT_ID/.test(error.message) &&
!error.message.includes(CLIENT_SECRET)
);
assert.equal(fetchImpl.calls.length, 0);
});
test("buildAuthHeaders and buildAccountHeaders construct the expected header sets", () => {
assert.deepEqual(buildAuthHeaders("abc"), { Authorization: "Bearer abc" });
assert.deepEqual(buildAccountHeaders("abc", 7), {
Authorization: "Bearer abc",
"X-Tossinvest-Account": "7"
});
assert.throws(() => buildAccountHeaders("abc", ""), TossCredentialsError);
assert.throws(() => buildAuthHeaders(""), TossCredentialsError);
});
test("each read-only helper builds the correct path, query, headers, and path params", async () => {
const opts = (fetchImpl) => baseOptions({ fetch: fetchImpl, account: "5" });
const cases = [
{ run: (o) => getOrderbook("005930", o), path: "/api/v1/orderbook", query: { symbol: "005930" }, account: false },
{ run: (o) => getTrades("005930", { ...o, count: 30 }), path: "/api/v1/trades", query: { symbol: "005930", count: "30" }, account: false },
{ run: (o) => getPriceLimits("005930", o), path: "/api/v1/price-limits", query: { symbol: "005930" }, account: false },
{ run: (o) => getCandles("005930", { ...o, interval: "1d", count: 50 }), path: "/api/v1/candles", query: { symbol: "005930", interval: "1d", count: "50" }, account: false },
{ run: (o) => getStockWarnings("005930", o), path: "/api/v1/stocks/005930/warnings", query: {}, account: false },
{ run: (o) => getExchangeRate({ ...o, from: "USD", to: "KRW" }), path: "/api/v1/exchange-rate", query: { from: "USD", to: "KRW" }, account: false },
{ run: (o) => getMarketCalendarKR({ ...o, date: "2026-06-09" }), path: "/api/v1/market-calendar/KR", query: { date: "2026-06-09" }, account: false },
{ run: (o) => getMarketCalendarUS(o), path: "/api/v1/market-calendar/US", query: {}, account: false },
{ run: (o) => listOpenOrders(o), path: "/api/v1/orders", query: { status: "OPEN" }, account: true },
{ run: (o) => getOrderDetail("ORD-1", o), path: "/api/v1/orders/ORD-1", query: {}, account: true },
{ run: (o) => getBuyingPower({ ...o, currency: "KRW" }), path: "/api/v1/buying-power", query: { currency: "KRW" }, account: true },
{ run: (o) => getSellableQuantity("005930", o), path: "/api/v1/sellable-quantity", query: { symbol: "005930" }, account: true }
];
for (const tc of cases) {
clearTokenCache();
const fetchImpl = makeFetch([tokenOk(), jsonResponse({ body: { result: {} } })]);
await tc.run(opts(fetchImpl));
const apiCall = fetchImpl.calls.find((c) => !c.url.endsWith("/oauth2/token"));
const parsed = new URL(apiCall.url);
assert.equal(parsed.pathname, tc.path, `path for ${tc.path}`);
for (const [k, v] of Object.entries(tc.query)) {
assert.equal(parsed.searchParams.get(k), v, `query ${k} for ${tc.path}`);
}
if (tc.account) {
assert.equal(apiCall.headers["X-Tossinvest-Account"], "5", `account header for ${tc.path}`);
} else {
assert.equal(apiCall.headers["X-Tossinvest-Account"], undefined, `no account header for ${tc.path}`);
}
}
});
test("module exposes no order mutation helpers (read-only safety contract)", () => {
const mod = require("../src/official-client");
assert.equal(mod.placeOrder, undefined);
assert.equal(mod.createOrder, undefined);
assert.equal(mod.modifyOrder, undefined);
assert.equal(mod.cancelOrder, undefined);
});

View file

@ -1851,7 +1851,7 @@ test("repository docs advertise the hipass-receipt skill across the documented s
assert.match(sources, /https:\/\/www\.hipass\.co\.kr\/html\/guide\/siteguide_6\.jsp/);
});
test("toss-securities skill documents the tossctl install, auth, and read-only workflow", () => {
test("toss-securities skill documents the official Open API and tossctl fallback workflow", () => {
const skillPath = path.join(repoRoot, "toss-securities", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected toss-securities/SKILL.md to exist");
@ -1862,6 +1862,12 @@ test("toss-securities skill documents the tossctl install, auth, and read-only w
assert.match(skill, /^name: toss-securities$/m);
for (const doc of [skill, featureDoc]) {
// Official Open API path (primary).
assert.match(doc, /openapi\.tossinvest\.com|developers\.tossinvest\.com/);
assert.match(doc, /TOSSINVEST_CLIENT_ID/);
assert.match(doc, /X-Tossinvest-Account/);
assert.match(doc, /\/oauth2\/token/);
// tossctl fallback path (retained).
assert.match(doc, /tossctl/);
assert.match(doc, /JungHoonGhae\/tossinvest-cli/);
assert.match(doc, /auth login/);
@ -1900,9 +1906,10 @@ test("hipass-receipt skill documents the logged-in browser session contract", ()
assert.match(packageReadme, /playwright-core/);
});
test("toss-securities package exposes safe read-only tossctl helpers", () => {
test("toss-securities package exposes safe read-only official + tossctl helpers", () => {
const pkg = require(path.join(repoRoot, "packages", "toss-securities", "src", "index.js"));
// tossctl fallback wrapper (retained).
assert.equal(typeof pkg.buildReadOnlyCommand, "function");
assert.equal(typeof pkg.runReadOnlyCommand, "function");
assert.equal(typeof pkg.getAccountSummary, "function");
@ -1910,6 +1917,20 @@ test("toss-securities package exposes safe read-only tossctl helpers", () => {
assert.equal(typeof pkg.getQuote, "function");
assert.equal(typeof pkg.getQuoteBatch, "function");
assert.equal(typeof pkg.listWatchlist, "function");
// Official Open API client (primary).
assert.equal(typeof pkg.issueAccessToken, "function");
assert.equal(typeof pkg.getPrices, "function");
assert.equal(typeof pkg.getHoldings, "function");
assert.equal(typeof pkg.getBuyingPower, "function");
assert.equal(typeof pkg.listOfficialAccounts, "function");
assert.equal(typeof pkg.TossApiError, "function");
assert.equal(typeof pkg.TossCredentialsError, "function");
// Read-only safety contract: no order mutation helpers.
assert.equal(pkg.placeOrder, undefined);
assert.equal(pkg.modifyOrder, undefined);
assert.equal(pkg.cancelOrder, undefined);
});
test("hipass-receipt package exposes fixture-friendly query, parse, and session helpers", () => {
@ -1922,9 +1943,14 @@ test("hipass-receipt package exposes fixture-friendly query, parse, and session
assert.equal(typeof pkg.buildReceiptRequest, "function");
});
test("toss-securities package README stays aligned with the read-only tossctl wrapper contract", () => {
test("toss-securities package README stays aligned with the official-first read-only contract", () => {
const packageReadme = read(path.join("packages", "toss-securities", "README.md"));
// Official Open API path (primary).
assert.match(packageReadme, /official.*Open API|공식 Open API/i);
assert.match(packageReadme, /TOSSINVEST_CLIENT_ID/);
assert.match(packageReadme, /X-Tossinvest-Account/);
// tossctl fallback path (retained).
assert.match(packageReadme, /read-only tossctl wrapper/i);
assert.match(packageReadme, /brew tap JungHoonGhae\/tossinvest-cli/);
assert.match(packageReadme, /account summary/);

View file

@ -1,6 +1,6 @@
---
name: toss-securities
description: 토스증권 조회형 질문에 대해 tossinvest-cli의 tossctl을 설치/로그인한 뒤 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목을 안전한 read-only 흐름으로 조회한다.
description: 토스증권 조회형 질문을 공식 Open API(OAuth2)로 우선 처리하고, 공식 credentials가 없으면 tossinvest-cli의 tossctl을 fallback으로 써서 계좌, 보유주식, 시세/종목/시장정보, 주문조회를 안전한 read-only 흐름으로 조회한다.
license: MIT
metadata:
category: finance
@ -12,31 +12,79 @@ metadata:
## What this skill does
`JungHoonGhae/tossinvest-cli``tossctl` 을 이용해 토스증권 **조회 전용(read-only)** 흐름을 실행한다.
토스증권 **조회 전용(read-only)** 흐름을 실행한다. 두 경로가 있다.
- 계좌 목록 / 요약
- 포트폴리오 보유 종목 / 비중
- 단일 종목 / 다중 종목 시세
- 미체결 주문 / 월간 체결 내역
- 관심 종목
1. **공식 Open API (권장)** — 토스증권 공식 Open API(`https://openapi.tossinvest.com`)를 OAuth 2.0 Client Credentials 토큰으로 호출.
2. **tossctl fallback** — 공식 credentials가 없을 때 `JungHoonGhae/tossinvest-cli``tossctl` 을 사용.
조회 항목:
- 계좌 목록 / 보유 주식
- 시세(현재가/호가/체결/상하한가/캔들) / 종목 정보 / 매수 유의사항
- 환율 / 장 운영 캘린더(KR·US)
- 대기중 주문 조회 / 주문 상세 / 매수가능금액 / 판매가능수량 / 수수료
- (tossctl fallback) 계좌 요약, 포트폴리오 비중, 관심종목
## When to use
- "토스증권 계좌 요약 보여줘"
- "토스증권 TSLA 시세 확인해줘"
- "관심종목 목록 보여줘"
- "이번 달 체결 내역 조회해줘"
- "토스증권 삼성전자 현재가 확인해줘"
- "내 보유 주식 보여줘"
- "대기중 주문 조회해줘"
- "원달러 환율 알려줘"
## Prerequisites
## 1. Prefer the official Open API
- macOS + Homebrew
- `tossctl` 설치
- `tossctl auth login` 으로 브라우저 세션 확보
- Node.js 18+
### Prerequisites
## Workflow
- 토스증권 OpenAPI 콘솔에서 발급한 `client_id` / `client_secret`
- Node.js 18+ (global `fetch`)
### 0. Install `tossctl` first when missing
자격 증명은 사용자 환경변수로 두고 helper가 토스 서버로 **직접** 호출한다. 공유 프록시로 보내지 않는다.
| 환경변수 | 설명 |
|---|---|
| `TOSSINVEST_CLIENT_ID` | client id (필수) |
| `TOSSINVEST_CLIENT_SECRET` | client secret (필수) |
| `TOSSINVEST_ACCOUNT` | accountSeq. 계좌·자산·주문조회에 필요 (선택) |
| `TOSSINVEST_API_BASE_URL` | 기본 `https://openapi.tossinvest.com` (선택) |
### Workflow
helper는 내부적으로 `POST /oauth2/token` 으로 토큰을 발급(Client Credentials)받아 `Authorization: Bearer` 로 호출한다. 계좌·자산·주문조회 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다.
```js
const {
getPrices,
listOfficialAccounts,
getHoldings
} = require("toss-securities");
async function main() {
const prices = await getPrices(["005930", "AAPL"]);
const accounts = await listOfficialAccounts();
const accountSeq = accounts.data.result[0].accountSeq;
const holdings = await getHoldings({ account: accountSeq });
console.log(prices.data);
console.log(holdings.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
- `429``Retry-After`/`X-RateLimit-Reset` 만큼 대기 후 백오프 재시도한다.
- `401` 은 토큰을 1회 재발급해 재시도한다.
- `client_secret`/토큰은 에러 메시지에서 마스킹된다.
## 2. tossctl fallback
공식 credentials가 없으면 비공식 `tossctl` 을 fallback으로 쓴다.
### Install `tossctl` first when missing
```bash
brew tap JungHoonGhae/tossinvest-cli
@ -48,46 +96,17 @@ tossctl auth login
로그인 세션이 없으면 먼저 위 흐름을 끝낸다. 다른 비공식 크롤링이나 임의 HTTP 재구현으로 우회하지 않는다.
### 1. Prefer the read-only `tossctl` surface
지원하는 read-only 명령:
지원하는 기본 명령:
- `tossctl account list --output json`
- `tossctl account summary --output json`
- `tossctl portfolio positions --output json`
- `tossctl portfolio allocation --output json`
- `tossctl quote get TSLA --output json`
- `tossctl quote batch TSLA 005930 VOO --output json`
- `tossctl orders list --output json`
- `tossctl orders completed --market all --output json`
- `tossctl watchlist list --output json`
- `tossctl orders completed --market all --output json`
### 2. Use the local package wrapper when scripting helps
패키지 wrapper(`getAccountSummary`, `getPortfolioPositions`, `getQuote`, `listWatchlist` 등)도 그대로 쓸 수 있다.
```js
const {
getAccountSummary,
getQuote,
listWatchlist
} = require("toss-securities");
async function main() {
const summary = await getAccountSummary();
const quote = await getQuote("TSLA");
const watchlist = await listWatchlist();
console.log(summary.data);
console.log(quote.data);
console.log(watchlist.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
### 3. Answer conservatively
## Answer conservatively
- 계좌번호/민감정보는 꼭 필요한 범위만 노출한다.
- 사용자가 "오늘" 같은 상대 날짜를 말하면 절대 날짜로 풀어 답한다.
@ -95,12 +114,13 @@ main().catch((error) => {
## Done when
- `tossctl` 설치/로그인 상태가 확인되었다.
- 요청에 맞는 read-only 명령을 실행했다.
- 공식 API credentials(또는 tossctl 로그인) 상태가 확인되었다.
- 요청에 맞는 read-only 호출을 실행했다.
- 결과를 한국어로 짧게 정리했다.
## Failure modes
- `tossctl auth login` 전이면 계좌/포트폴리오 조회가 실패할 수 있다.
- upstream 웹 API 구조가 바뀌면 `tossctl` 자체 업데이트가 필요할 수 있다.
- 공식 API credentials(`TOSSINVEST_CLIENT_ID`/`SECRET`)가 없으면 `TossCredentialsError` 로 명확히 실패한다.
- 계좌·자산·주문조회 helper에 `X-Tossinvest-Account` 가 없으면 네트워크 호출 전에 실패한다.
- tossctl fallback은 `auth login` 전이면 계좌/포트폴리오 조회가 실패할 수 있다.
- 계좌/주문 정보는 민감하므로 출력 범위를 과도하게 넓히지 않는다.