# Furstock OpenAPI — full documentation Spec: https://api.furstock.trade/openapi.json --- # 퍼스톡 OpenAPI 시작하기 퍼스톡 OpenAPI로 퍼스톡 웹에서 하는 일을 프로그램에서 그대로 할 수 있어요. 시세 조회, 매수·매도, 잔고·보유종목 확인, 커뮤니티 글쓰기 모두 됩니다. 나만의 트레이더봇이나 커스텀 퍼스톡 클라이언트를 만들어 보세요. > 퍼스톡은 **모의투자** 서비스예요. 모든 금액은 실제 가치가 없는 가상 화폐("원")이고, API로 하는 거래도 똑같이 가상 거래입니다. ## 한눈에 보기 | 항목 | 값 | | ------------ | ----------------------------------------------------------------------------------------------------------------- | | REST API | `https://api.furstock.trade` | | 인증 | OAuth 2.0 Client Credentials → `Authorization: Bearer ` | | 토큰 발급 | `POST https://api.furstock.trade/oauth2/token` | | 실시간 시세 | `wss://ws.furstock.trade/socket` (Phoenix Channels, 인증 불필요) | | OpenAPI 스펙 | [openapi.json](https://api.furstock.trade/openapi.json) · [openapi.yaml](https://api.furstock.trade/openapi.yaml) | | Swagger UI | [api.furstock.trade/docs](https://api.furstock.trade/docs) | | 데이터 형식 | JSON(UTF-8), 필드명 camelCase | | 요청 한도 | API 키당 초당 10회(순간 20회) | ## 5분 퀵스타트 ### 1. API 키 발급 [프로필 → OpenAPI 탭](https://furstock.trade/profile?tab=api)에서 **새 키 발급**을 누르면 `client_id`와 `client_secret`이 나와요. `client_secret`은 **그때 한 번만** 보이니 바로 안전한 곳에 복사해 두세요. ### 2. access token 받기 ```bash curl -s https://api.furstock.trade/oauth2/token \ -d grant_type=client_credentials \ -d client_id=$FURSTOCK_CLIENT_ID \ -d client_secret=$FURSTOCK_CLIENT_SECRET ``` ```json { "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600 } ``` 토큰은 1시간 동안 쓸 수 있어요. 만료되면 같은 요청으로 새로 받으면 됩니다. ### 3. 내 잔고 조회 ```bash curl -s https://api.furstock.trade/v1/accounts/buying-power \ -H "Authorization: Bearer $TOKEN" ``` ```json { "cashBalance": 300000 } ``` ### 4. 첫 주문 ```bash curl -s https://api.furstock.trade/v1/orders \ -H "Authorization: Bearer $TOKEN" \ -H "content-type: application/json" \ -d '{"symbol":"FOXX","side":"buy","type":"market","quantity":1,"clientToken":"'$(uuidgen)'"}' ``` 응답의 `status`가 `filled`면 체결된 거예요. 자세한 내용은 [주문](/developers/orders)을 보세요. ## 할 수 있는 것 | 분류 | 주요 기능 | 문서 | | ------------- | ------------------------------------------------------------------------------------------ | -------------------------------------- | | 종목·시세 | 종목 목록(최대 1000개)·검색·상세, 현재가, 호가, 캔들, 체결, 인기·거래량·급상승·급하락 순위 | [종목·시세](/developers/market) | | 계좌 | 잔고(주문가능금액), 보유종목, 프로필 | [계좌](/developers/account) | | 주문 | 시장가·지정가 매수/매도, 주문 이력, 매매 대기열, 지정가 취소 | [주문](/developers/orders) | | 커뮤니티·소셜 | 종목토론 조회·작성·수정, 관심종목, 최근 본 종목, 종목 그룹, 뉴스·갤러리, 상장 신청 | [커뮤니티·소셜](/developers/community) | | 실시간 | 종목별 시세 push | [실시간 시세](/developers/realtime) | API 키로 **할 수 없는** 것도 있어요. API 키 발급·폐기와 회원 탈퇴는 보안상 퍼스톡 웹에 로그인해서만 할 수 있어요. 자세한 건 [인증](/developers/authentication)을 보세요. ## AI 에이전트로 봇 만들기 Claude Code, Cursor 같은 AI 코딩 에이전트에게 이 페이지 주소(`https://furstock.trade/developers`)만 알려주세요. 에이전트가 읽을 수 있는 자료를 따로 준비해 뒀어요. - [`/llms.txt`](https://furstock.trade/llms.txt): 에이전트용 문서 색인 - [`/llms-full.txt`](https://furstock.trade/llms-full.txt): 모든 문서를 합친 한 파일 - [`/skills/furstock-openapi/SKILL.md`](https://furstock.trade/skills/furstock-openapi/SKILL.md): 에이전트 스킬. 봇 개발 절차, 주의사항, 참고 문서 링크가 들어 있어요. - [`openapi.json`](https://api.furstock.trade/openapi.json): 기계가 읽는 정식 스펙 에이전트에게 이렇게 말하면 됩니다: _"https://furstock.trade/developers 를 참고해서 거래량 상위 종목을 매매하는 퍼스톡 봇을 만들어줘. 키는 환경변수 FURSTOCK_CLIENT_ID / FURSTOCK_CLIENT_SECRET에 있어."_ ## 이용 수칙 - 키는 비밀번호와 같아요. 코드 저장소에 올리지 말고 환경변수로 관리하세요. 유출됐다면 바로 폐기하세요. - 요청 한도를 지키고, `429`를 받으면 `Retry-After`만큼 기다렸다 다시 시도하세요. - 봇도 [운영정책](/policy)을 따라요. 커뮤니티 도배, 시세 조작을 노린 반복 매매 같은 행위는 이용 제한 대상이에요. --- # 인증 퍼스톡 OpenAPI는 **OAuth 2.0 Client Credentials Grant**를 씁니다. API 키(`client_id` + `client_secret`)로 1시간짜리 access token을 받고, 모든 요청의 `Authorization` 헤더에 실어 보내요. ```text [API 키 발급 (웹, 1회)] → [POST /oauth2/token] → access_token (1시간) ↓ Authorization: Bearer ``` ## API 키 발급 1. 퍼스톡에 Google 계정으로 로그인해요. 2. [프로필 → OpenAPI 탭](https://furstock.trade/profile?tab=api)에서 키 이름을 적고 **새 키 발급**을 눌러요. 3. 화면에 나온 `client_id`(`fsk_`로 시작)와 `client_secret`(`fss_`로 시작)을 복사해요. | 규칙 | 내용 | | ----------- | -------------------------------------------------------------------------------------------------------------- | | secret 노출 | 발급 직후 **한 번만** 보여요. 서버엔 해시만 저장돼서 다시 볼 수 없어요. 잃어버렸으면 폐기하고 새로 발급하세요. | | 개수 | 계정당 활성 키 최대 **5개** | | 범위 | 키는 **발급한 본인 계정**만 조작해요. 다른 사람 계정을 대신 조작하는 방법은 없어요. | ## access token 받기 `POST https://api.furstock.trade/oauth2/token` OAuth 2.0 표준대로 `application/x-www-form-urlencoded`로 보내요. `application/json`도 받습니다. | 필드 | 필수 | 설명 | | --------------- | ---- | ------------------------- | | `grant_type` | ✅ | 항상 `client_credentials` | | `client_id` | ✅ | 발급받은 client_id | | `client_secret` | ✅ | 발급받은 client_secret | ```bash curl -s https://api.furstock.trade/oauth2/token \ -d grant_type=client_credentials \ -d client_id=$FURSTOCK_CLIENT_ID \ -d client_secret=$FURSTOCK_CLIENT_SECRET ``` **응답 200** ```json { "access_token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "Bearer", "expires_in": 3600 } ``` | 상태 | `error` | 의미 | | ---- | ----------------- | ------------------------------------- | | 400 | `invalid_request` | 필드 누락·형식 오류 | | 401 | `invalid_client` | client_id/secret이 틀렸거나 폐기된 키 | | 429 | `rate_limited` | 같은 client_id로 분당 30회 초과 | 토큰에는 refresh token이 없어요. 만료(`expires_in`초) 몇 분 전에 같은 요청으로 새 토큰을 받으세요. 요청마다 토큰을 새로 받지는 마세요. 분당 30회 제한에 걸립니다. ## API 호출 모든 요청에 헤더를 붙여요. ```http GET /v1/accounts/holdings HTTP/1.1 Host: api.furstock.trade Authorization: Bearer eyJhbGciOiJIUzI1NiIs... ``` 시세·종목·커뮤니티 조회처럼 공개된 API는 토큰 없이도 호출돼요. 각 API 표의 **인증** 열을 보세요. 토큰이 유효한지는 `GET /v1/me`로 확인할 수 있어요. ```json { "userId": "8b6b74e7-2bf7-42ad-8cfc-534ed05708b8", "isAdmin": false, "authMethod": "api_key" } ``` ## 키 폐기 [프로필 → OpenAPI 탭](https://furstock.trade/profile?tab=api)에서 **폐기**를 누르면: - 그 키로 새 토큰을 받을 수 없어요(`401 invalid_client`). - **이미 받은 토큰도 즉시 막혀요.** 폐기 후 요청은 `401`이에요. 드물게 최대 30초까지 늦어질 수 있어요. - 되살릴 수 없어요. 필요하면 새로 발급하세요. ## API 키로 할 수 없는 것 키가 유출돼도 피해가 커지지 않도록, 다음은 **퍼스톡 웹에 로그인한 상태에서만** 할 수 있어요. API 키 토큰으로 호출하면 `403 session_required`가 와요. | 작업 | 이유 | | ---------------------------------------- | --------------------------------------------------- | | API 키 발급·목록·폐기 (`/v1/api-keys`) | 유출된 키로 새 키를 만들어 계속 쓰는 걸 막기 위해 | | 회원 탈퇴 (`POST /v1/accounts/withdraw`) | 되돌릴 수 없는 작업이라서 | | 관리자 권한 | 관리자 계정의 키라도 API에선 일반 사용자로 취급돼요 | ## 보안 수칙 - `client_secret`은 코드에 직접 쓰지 말고 환경변수나 비밀 저장소로 관리하세요. `.env`는 `.gitignore`에 넣으세요. - 브라우저에서 동작하는 커스텀 클라이언트라면 secret이 사용자에게 그대로 노출돼요. 본인만 쓰는 도구에만 쓰고, 공개 웹앱에 secret을 넣지 마세요. - 봇마다 키를 따로 발급하면 문제가 생긴 봇만 골라서 폐기할 수 있어요. --- # 공통 규칙 ## 요청과 응답 - Base URL: `https://api.furstock.trade` - 요청 바디는 `application/json`이에요. 이미지 업로드(상장 신청, 갤러리)만 `multipart/form-data`예요. - 응답은 항상 JSON이고, 필드명은 camelCase예요. 삭제 API 일부는 바디 없이 `204`를 돌려줘요. - **금액·가격**은 전부 정수 "원"이에요. 퍼스톡 가상 화폐이고, 소수점은 없어요. - **시각**은 ISO 8601 UTC 문자열이에요(예. `2026-09-24T03:00:00.000Z`). 예외로 시세 스냅샷·체결의 `ts`는 epoch 밀리초, 캔들의 `time`은 epoch 초예요. - **종목 식별자(`symbol`)는 캐릭터 이름 그 자체**예요. 영문 티커가 아니라 한글일 수도 있어요(예. `보팔래빗`). 경로에 넣을 땐 URL 인코딩하세요(`/v1/stocks/%EB%B3%B4%ED%8C%94%EB%9E%98%EB%B9%97`). 1~20자이고 유니코드 문자·숫자와 기호 `-` `.` `_` `~` `(` `)`로만 이루어져요(공백·`/`·`?` 등은 없어요). `(` `)`는 인코딩하지 않아도 되지만, `encodeURIComponent`로 통째로 인코딩해도 괜찮아요. - 모든 응답에 `X-Request-Id` 헤더가 있어요. 문의할 때 이 값을 알려주시면 빨리 찾을 수 있어요. ## 오류 오류 응답은 모두 같은 모양이에요. ```json { "error": "not_found", "message": "사람이 읽는 설명(없을 수도 있음)" } ``` | 상태 | 대표 `error` | 의미 | | ---- | ----------------------------------------------------- | ------------------------------------------------------------------- | | 400 | `invalid_request` | 파라미터·바디 형식 오류. `issues`에 상세가 붙기도 해요 | | 401 | `unauthorized`, `invalid_client` | 토큰 없음·만료·무효, 폐기된 키 | | 403 | `forbidden` | 권한 없음(예. 남의 종목에 뉴스 작성) | | 403 | `session_required` | API 키로는 할 수 없는 작업([인증](/developers/authentication) 참고) | | 404 | `not_found` | 대상 없음(없는 종목, 남의 주문 등) | | 409 | `not_pending`, `duplicate_symbol`, `too_many_keys` 등 | 현재 상태와 충돌 | | 429 | `rate_limited` | 요청 한도 초과 | | 500 | `internal_error` | 서버 오류. `reqId`와 함께 문의해 주세요 | **주문 거부는 오류가 아니에요.** 잔고 부족처럼 주문이 거부돼도 HTTP는 `200`이고, 응답 바디의 `status: "rejected"`와 `rejectReason`으로 알려줘요. [주문](/developers/orders)을 보세요. ## 요청 한도 | 대상 | 한도 | | ------------------------------ | --------------------------------------------- | | API 키로 받은 토큰의 모든 요청 | 키당 **초당 10회**, 순간 최대 20회(토큰 버킷) | | `POST /oauth2/token` | client_id당 **분당 30회** | 한도를 넘으면 `429`와 `Retry-After`(초) 헤더가 와요. 그만큼 기다렸다 다시 보내세요. 정상 응답엔 `X-RateLimit-Limit`(초당 허용 횟수), `X-RateLimit-Remaining`(지금 남은 여유) 헤더가 붙어요. > 여러 종목 시세를 자주 봐야 한다면 종목마다 폴링하지 말고 [실시간 시세](/developers/realtime) WebSocket을 쓰세요. WebSocket 수신은 요청 한도에 포함되지 않아요. ## 멱등성 (주문 중복 방지) `POST /v1/orders`는 `clientToken`(UUID)을 필수로 받아요. - 새 주문마다 **새 UUID**를 만드세요. - 타임아웃이나 네트워크 오류로 결과를 못 받았다면 **같은 `clientToken`으로 다시 보내세요.** 서버가 이미 처리한 주문이면 새로 체결하지 않고 처음 결과를 그대로 돌려줘요. ## 페이지네이션 목록 API는 대부분 고정 상한만 있어요(예. 커뮤니티 최신 200개). 예외: | API | 방식 | | --------------------------------- | ------------------------------------------------------------------------- | | `GET /v1/orders` | 커서. `limit`(최대 200) + `before`(이전 페이지 마지막 항목의 `createdAt`) | | `GET /v1/stocks/{symbol}/gallery` | 페이지 번호. `page`(1부터), 응답의 `totalCount`, `pageSize` | ## CORS 모든 origin에서 호출할 수 있어요. 쿠키 없이 `Authorization` 헤더만 쓰는 구조라서 그래요. 단, 브라우저 코드에 `client_secret`을 넣으면 그대로 노출된다는 점에 주의하세요. ## 시장 규칙 API로 하는 거래도 웹과 똑같은 규칙을 따라요. | 규칙 | 값 | | ------------- | ---------------------------------------------------------------------------------------- | | 가입 시 지급 | 300,000원 (입출금 없음) | | 운영 시간 | **24시간 연속**. 휴장 없음 | | 세션 | KST 00·03·06·09·12·15·18·21시마다 새 세션. 직전 종가가 새 시작가가 돼요 | | 가격 제한폭 | 세션 시작가 **±30%** (최저가 500원) | | 서킷브레이커 | 세션 시작가 대비 **±15%** 움직이면 그 종목 **5분** 거래정지(방향별 세션당 1회) | | 수수료 | 체결 금액의 **0.15%** (매수·매도 모두) | | 세금 | 매도 체결 금액의 **0.2%** | | 체결 방식 | 실제 주문장이 아닌 **가격 곡선(본딩 커브)** 모델. 사는 만큼 오르고 파는 만큼 내려요 | | 지정가 | 대기했다가 가격이 닿으면 **전량 체결**(부분체결 없음). 세션 정산 때 미체결분은 자동 취소 | | 거래정지 종목 | `tradingHalted: true`인 종목은 주문이 거부돼요(`admin_halted`) | | 관리종목·숨김 | `hidden: true`면 검색·순위에서 빠지지만 거래는 돼요(관리자 지정·상장주 숨김 둘 다) | --- # 종목·시세 시세·종목 API는 전부 **공개**라서 토큰 없이도 호출돼요. 토큰을 실어 보내도 괜찮아요. | 메서드 | 경로 | 설명 | | ------ | --------------------------------- | --------------------------------------------------- | | GET | `/v1/stocks` | 종목 목록(최대 1000개) / `q`로 검색(최대 50개) | | GET | `/v1/stocks?symbol=…` | 여러 종목 한 번에(최대 100개) | | GET | `/v1/stocks/{symbol}` | 종목 상세 | | GET | `/v1/stocks/{symbol}/disclosures` | 거래소 공시(거래정지·재개, 관리종목, 종목명 변경) | | GET | `/v1/market/price/{symbol}` | 현재가 스냅샷 + 호가 | | GET | `/v1/market/prices?symbol=…` | 여러 종목 현재가 스냅샷 한 번에(최대 100개) | | GET | `/v1/market/orderbook/{symbol}` | 호가 | | GET | `/v1/market/candles/{symbol}` | 캔들(분·일·주·월·년봉) | | GET | `/v1/market/trades/{symbol}` | 최근 체결 | | GET | `/v1/market/popular?limit=` | 인기 종목(직전 세션 거래대금 순, 세션 마감 때 갱신) | | GET | `/v1/market/rankings` | 실시간 순위(홈 화면의 인기·거래량·급상승·급하락) | ## 종목 목록 `GET /v1/stocks` | 쿼리 | 설명 | | ------- | ----------------- | | `limit` | 1~1000. 기본 1000 | 전체 상장 종목을 이름순으로 돌려줘요. **한 번에 최대 1000개**이고, 그 이상은 검색으로 찾아야 해요. 관리종목(`hidden: true`)도 포함되니 "발견"용 목록을 만들 땐 걸러내세요. **응답 200** `Stock[]` ```json [ { "symbol": "FOXX", "name": "FOXX", "species": "여우", "status": "listed", "tags": ["여우", "수인"], "lastPrice": 1234, "changeRate": 0.0523, "volume": 812, "characterId": "5b0c7a3e-...", "coverImageUrl": "https://images.furstock.trade/p/...", "listedAt": "2026-09-12T08:00:00.000Z", "tradingHalted": false, "haltReason": null, "hidden": false, "hideReason": null, "hideLocked": false, "bio": "여우 수인 캐릭터..." } ] ``` | 필드 | 설명 | | --------------- | ----------------------------------------------------------------------- | | `lastPrice` | 현재가(원) | | `changeRate` | 세션 시작가 대비 등락률. `0.0523` = +5.23% | | `volume` | 현재 세션 누적 거래량(주) | | `coverImageUrl` | 캐릭터 대표 이미지. 종목을 화면에 표시할 땐 이 이미지를 함께 보여주세요 | | `tradingHalted` | `true`면 관리자 거래정지 — 주문이 거부돼요 | | `hidden` | `true`면 숨겨진 종목 — 검색·순위에서 빠지지만 거래는 돼요 | | `hideLocked` | `hidden`일 때 `true`면 관리자 지정 관리종목, `false`면 상장주가 숨긴 것 | ## 종목 검색 `GET /v1/stocks?q=여우` | 쿼리 | 설명 | | ------- | ------------------------------------------- | | `q` | 검색어. 종목명·태그 부분일치, 대소문자 무시 | | `limit` | 1~50. 기본 50 | 홈 화면 검색창과 같은 규칙이에요. 관리종목은 결과에서 빠져요. 응답 모양은 종목 목록과 같아요. ## 여러 종목 조회 `GET /v1/stocks?symbol=FOXX&symbol=WOLF` 화면에 보이는 몇 종목의 정보만 필요할 땐 전체 목록을 받지 말고 이걸 쓰세요. `symbol`을 1~100번 반복해서 넘기고, 응답 모양은 종목 목록과 같아요. 관리종목도 포함되고, 없는 종목은 빠지며, 순서는 요청 순서를 따라요. `symbol`이 있으면 `q`·`limit`은 무시돼요. ## 종목 상세 `GET /v1/stocks/{symbol}` → `Stock` 하나. 없으면 `404`. ## 공시 `GET /v1/stocks/{symbol}/disclosures` → 최신순 최대 50개 ```json [{ "kind": "trading_halted", "message": "관리자에 의해 거래가 정지되었습니다: ...", "createdAt": "..." }] ``` `kind`: `trading_halted` · `trading_resumed` · `hidden` · `unhidden` · `symbol_changed` 상장주 소유자가 쓰는 뉴스는 [커뮤니티·소셜](/developers/community)의 저널 API에 있어요. ## 현재가 `GET /v1/market/price/{symbol}` ```json { "symbol": "FOXX", "sessionId": "0f6c...", "seq": 1790226600668, "lastPrice": 1234, "openPrice": 1173, "high": 1260, "low": 1150, "bandLow": 821, "bandHigh": 1524, "volume": 812, "orderbook": { "bids": [ [1233, 10], [1232, 20] ], "asks": [ [1235, 10], [1236, 20] ] }, "ts": 1790226600668 } ``` | 필드 | 설명 | | ---------------------- | --------------------------------------------------------- | | `openPrice` | 이번 세션 시작가 | | `bandLow` / `bandHigh` | 하한가 / 상한가(시작가 ±30%). 이 밖으로는 체결되지 않아요 | | `orderbook` | 호가(아래 참고) | | `ts` | 스냅샷 시각(epoch ms) | 여러 종목을 볼 때는 종목마다 따로 부르지 말고 `GET /v1/market/prices`로 한 번에 받으세요. `symbol`을 1~100번 반복해서 넘기면(`?symbol=FOXX&symbol=WOLF`) 위와 같은 스냅샷의 배열이 요청 순서대로 와요. 없는 종목은 빠져요. 시세 REST는 최대 1초 캐시되니, 1초보다 자주 부를 필요는 없어요. 실시간 변동은 [웹소켓](/developers/realtime)으로 받는 게 가장 좋아요. ## 호가 `GET /v1/market/orderbook/{symbol}` → `{ bids, asks }` 각 단계는 `[가격, 그 가격까지의 누적 수량]`이에요. 퍼스톡은 실제 주문장 대신 **가격 곡선**으로 체결하기 때문에, 호가는 "지금 이만큼 사면(팔면) 가격이 어디까지 움직이는지"를 곡선에서 계산해 보여주는 **합성 호가**예요. 다른 참가자가 주문을 내면 곡선이 움직이므로, 실제 체결가는 주문 시점의 곡선으로 정해져요. ## 캔들 `GET /v1/market/candles/{symbol}?unit=10m&before=1790226000` | 쿼리 | 설명 | | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | `unit` | `1m`(기본), `3m`, `5m`, `10m` … N분봉(N=1~1440, 차트 화면은 1·3·5·10·15·30·60·120·240분), `1d`, `1w`, `1M`, `1y` 일·주·월·년봉 | | `before` | (선택) 봉 시작 시각(epoch **초**). 이 시각보다 앞선 봉만 줘요 — 과거를 이어 받을 때 | ```json [{ "time": 1790226000, "open": 1200, "high": 1260, "low": 1190, "close": 1234, "volume": 55 }] ``` **빈틈없는 봉 최대 500개**를 오래된 순서로 줘요. `time`은 봉 시작 시각(epoch **초**)이에요. 진행 중인 봉(지금 분봉, 오늘 일봉 등)도 지금까지의 체결로 들어 있어요. **거래가 없던 구간은 거래정지처럼 표시해요.** 체결이 없던 봉은 직전 종가 하나로 시가·고가·저가·종가가 모두 같은 1자 봉이 되고, `volume`은 0이에요. 그래서 봉 사이에 빈 시간이 없어요. 상장(첫 체결) 이전은 채우지 않아요. **과거를 끝까지 받으려면** 받은 봉 중 가장 오래된 `time`을 `before`로 넘겨 다시 부르세요. 500개보다 적게 오면 그 종목의 첫 봉까지 다 받은 거예요. 봉은 **한국 시간(KST)** 기준으로 끊어요. 120·240분봉과 일봉은 KST 00시, 주봉은 월요일, 월봉은 1일, 년봉은 1월 1일에 시작해요. 응답 헤더 `x-last-trade-seq`는 이 응답에 이미 반영된 가장 최근 체결의 `seq`예요(`before` 없이 부른 최신 페이지에서만 의미가 있어요). 아래 최근 체결(`/v1/market/trades`)을 폴링해 봉을 실시간으로 갱신한다면, `seq`가 이 값 이하인 체결은 건너뛰세요(두 번 세지 않도록). ## 최근 체결 `GET /v1/market/trades/{symbol}?limit=50` (최대 200, 최신순) ```json [{ "symbol": "FOXX", "seq": 918273, "price": 1234, "quantity": 3, "side": "buy", "ts": 1790226600668 }] ``` `seq`는 체결마다 고유하고 계속 커져요. 폴링하면서 이미 본 체결을 거를 때 쓰세요. `side`는 그 체결을 일으킨 주문의 방향이에요. ## 실시간 순위 `GET /v1/market/rankings?type=value&limit=20` | `type` | 정렬 | 홈 화면 | | ------------- | -------------------------------------- | -------- | | `value`(기본) | 거래대금(`lastPrice × volume`) 높은 순 | 인기 | | `volume` | 거래량 많은 순 | 거래량순 | | `gainers` | 등락률 높은 순 | 급상승 | | `losers` | 등락률 낮은 순 | 급하락 | `limit` 1~50(기본 20). 관리종목은 빠져요. ```json [ { "symbol": "FOXX", "name": "FOXX", "species": "여우", "lastPrice": 1234, "changeRate": 0.0523, "volume": 812, "tradingValue": 1002008 } ] ``` --- # 계좌 모든 계좌 API는 **인증 필요**예요. 토큰 주인의 계좌만 보여요. | 메서드 | 경로 | 설명 | | ------ | --------------------------- | ------------------------------- | | GET | `/v1/accounts/buying-power` | 잔고(주문가능금액) | | GET | `/v1/accounts/holdings` | 보유종목 | | GET | `/v1/accounts/profile` | 프로필 | | PATCH | `/v1/accounts/profile` | 표시 이름 변경 | | GET | `/v1/me` | 토큰 확인(사용자 id, 인증 방식) | ## 잔고 `GET /v1/accounts/buying-power` ```json { "cashBalance": 287450 } ``` `cashBalance`는 현금 잔고예요. 지정가 대기 주문은 돈을 미리 묶어두지 않아서, 대기 주문이 있어도 이 값은 줄지 않아요. 대신 지정가가 체결되는 순간 잔고가 모자라면 체결되지 않고 계속 대기해요. ## 보유종목 `GET /v1/accounts/holdings` ```json [ { "symbol": "FOXX", "species": "여우", "quantity": 10, "avgCost": 1180.5, "lastPrice": 1234, "marketValue": 12340, "unrealizedPnl": 535 } ] ``` | 필드 | 설명 | | --------------- | --------------------------------------------- | | `quantity` | 보유 수량 | | `avgCost` | 평균 매입단가(소수 가능) | | `marketValue` | 평가금액 = `quantity × lastPrice` | | `unrealizedPnl` | 평가손익 = `marketValue − quantity × avgCost` | 총 평가자산은 `cashBalance + Σ marketValue`, 총수익은 거기서 시작 자금 300,000원을 뺀 값이에요(웹 프로필 화면과 같은 계산). ## 프로필 `GET /v1/accounts/profile` → `{ "id": "...", "displayName": "호야" }` `PATCH /v1/accounts/profile` 바디 `{ "displayName": "새 이름" }` (1~40자). 커뮤니티 글 작성자 이름으로 쓰여요. ## 토큰 확인 `GET /v1/me` ```json { "userId": "8b6b74e7-...", "isAdmin": false, "authMethod": "api_key" } ``` `authMethod`는 `api_key`(API 키 토큰) 또는 `session`(웹 로그인 토큰)이에요. --- # 주문 모든 주문 API는 **인증 필요**예요. | 메서드 | 경로 | 설명 | | ------ | ----------------------------- | ----------------------------------------- | | POST | `/v1/orders` | 주문(시장가·지정가 매수/매도) | | GET | `/v1/orders` | 주문 이력 / 매매 대기열(`status=pending`) | | GET | `/v1/orders/{orderId}` | 주문 단건 조회 | | POST | `/v1/orders/{orderId}/cancel` | 대기 중인 지정가 주문 취소 | ## 주문하기 `POST /v1/orders` | 필드 | 필수 | 설명 | | ------------- | -------------- | ---------------------------------------------------------------------------------- | | `symbol` | ✅ | 종목 | | `side` | ✅ | `buy` 매수 / `sell` 매도 | | `type` | | `market` 시장가(기본) / `limit` 지정가 | | `quantity` | ✅ | 수량(1 이상 정수) | | `limitPrice` | 지정가일 때 ✅ | 지정 가격(원, 정수) | | `clientToken` | ✅ | UUID. 주문마다 새로 만들고, 재시도할 땐 같은 값([멱등성](/developers/conventions)) | ### 시장가 매수 ```json { "symbol": "FOXX", "side": "buy", "type": "market", "quantity": 10, "clientToken": "7f1c0a52-3c1e-4b0e-9a57-6f1d2b8e4c11" } ``` ### 시장가 매도 ```json { "symbol": "FOXX", "side": "sell", "type": "market", "quantity": 10, "clientToken": "0c2e7a61-..." } ``` ### 지정가 매수 (1,100원 이하가 되면 사기) ```json { "symbol": "FOXX", "side": "buy", "type": "limit", "limitPrice": 1100, "quantity": 5, "clientToken": "a8d1..." } ``` ### 지정가 매도 (1,500원 이상이 되면 팔기) ```json { "symbol": "FOXX", "side": "sell", "type": "limit", "limitPrice": 1500, "quantity": 5, "clientToken": "b93f..." } ``` **응답 200** `Order` ```json { "orderId": "3f0e8f7a-...", "symbol": "FOXX", "sessionId": "0f6c...", "side": "buy", "type": "market", "limitPrice": null, "status": "filled", "rejectReason": null, "quantityRequested": 10, "quantityFilled": 10, "avgPrice": 1241, "notional": 12410, "fee": 18, "tax": 0, "cashBalanceAfter": 275022, "lastPriceAfter": 1249, "createdAt": "2026-09-24T05:12:03.120Z" } ``` | 필드 | 설명 | | ------------------ | ------------------------------------------- | | `status` | 아래 상태표 참고 | | `quantityFilled` | 체결된 수량 | | `avgPrice` | 평균 체결가. 미체결이면 0 | | `notional` | 체결 금액(`avgPrice × quantityFilled` 근사) | | `fee` / `tax` | 수수료(0.15%) / 매도 세금(0.2%) | | `cashBalanceAfter` | 주문 직후 현금 잔고 | | `lastPriceAfter` | 주문 직후 현재가 | ### 체결 방식 - **시장가**는 즉시 체결돼요. 퍼스톡은 가격 곡선 모델이라 많이 살수록 평균 체결가가 올라가요(파는 쪽은 내려가요). 상한가·하한가(세션 시작가 ±30%)에 닿으면 거기까지만 체결되고(`partial`) 나머지는 거부돼요. - **지정가**는 `pending`으로 대기열에 들어가요. 매수는 현재가가 `limitPrice` 이하이고 **전량을 샀을 때의 평균 체결가도** `limitPrice` 이하일 때(매도는 둘 다 이상일 때) **전량 한 번에** 체결돼요(부분체결 없음). 조건은 그 종목에 거래가 일어날 때마다 다시 확인하고, 이미 맞으면 주문하자마자 체결될 수도 있어요. - 지정가 대기 중엔 돈·주식을 묶어두지 않아요. 체결 순간 잔고나 보유 수량이 모자라면 체결되지 않고 계속 대기해요. - 세션 정산(KST 3시간마다) 때까지 체결되지 않은 지정가 주문은 자동 취소돼요(`cancelled`, `session_settlement`). ### 주문 상태 | `status` | 의미 | | ----------- | ---------------------------------------- | | `filled` | 전량 체결 | | `partial` | 일부 체결, 나머지 거부(가격 제한폭 도달) | | `pending` | 지정가 대기 중 | | `rejected` | 거부(`rejectReason` 참고) | | `cancelled` | 취소(세션 정산, 거래정지, 직접 취소) | ### 거부·취소 사유 (`rejectReason`) | 값 | 의미 | | ------------------------ | ---------------------------------------------------------------------------- | | `insufficient_cash` | 매수 잔고 부족 | | `insufficient_shares` | 매도할 보유 수량 부족 | | `price_band_reached` | 상한가·하한가 도달 | | `circuit_breaker` | 서킷브레이커 발동 중(종목 5분 정지) | | `admin_halted` | 관리자가 거래정지한 종목 | | `account_frozen` | 관리자가 내 계좌를 동결함(매수·매도 모두 거부, 미체결 주문도 이 사유로 취소) | | `market_closed` | 일시적으로 거래 불가(세션 전환 순간 등). 잠시 후 재시도 | | `stock_delisted` | 상장폐지된 종목 | | `qty_not_positive` | 수량이 1 미만 | | `duplicate_client_token` | 이미 쓴 clientToken | | `session_settlement` | 세션 정산으로 지정가 자동 취소 | | `user_cancelled` | 직접 취소한 지정가 | > 주문 거부는 HTTP 오류가 아니라 `200`에 `status: "rejected"`로 와요. 봇은 HTTP 상태뿐 아니라 **`status`를 꼭 확인**하세요. ## 주문 이력 `GET /v1/orders` | 쿼리 | 설명 | | -------- | ---------------------------------------------------------------------------------------------- | | `status` | `filled` · `partial` · `rejected` · `pending` · `cancelled` 중 하나로 거르기 | | `symbol` | 종목으로 거르기 | | `limit` | 1~200. 기본 50 | | `before` | 이 시각보다 오래된 것만(ISO 8601). 다음 페이지를 볼 때 이전 페이지 마지막 `createdAt`을 넣어요 | 최신순 `Order[]`. 목록에서는 `cashBalanceAfter`/`lastPriceAfter`가 항상 0이에요(주문 직후 값이라 이력에선 의미가 없어요). ```bash # 다음 페이지 curl -s "https://api.furstock.trade/v1/orders?limit=200&before=2026-09-24T05:12:03.120Z" \ -H "Authorization: Bearer $TOKEN" ``` 오래된 주문은 목록에서 빠질 수 있어요. 아카이브된 주문도 `GET /v1/orders/{orderId}`로는 조회돼요. ## 매매 대기열 `GET /v1/orders?status=pending` 아직 체결되지 않은 내 지정가 주문 목록이에요. 웹의 "내 투자 → 주문내역 → 대기" 탭과 같아요. ## 주문 단건 `GET /v1/orders/{orderId}` → `Order`. 내 주문이 아니거나 없으면 `404`. ## 지정가 취소 `POST /v1/orders/{orderId}/cancel` `pending` 상태인 내 지정가 주문을 취소해요. 성공하면 `status: "cancelled"`, `rejectReason: "user_cancelled"`인 `Order`를 돌려줘요. | 상태 | `error` | 의미 | | ---- | ------------- | ----------------------------------------------------- | | 404 | `not_found` | 없거나 내 주문이 아님 | | 409 | `not_pending` | 이미 체결·취소·거부됨. 취소 직전에 체결됐을 수 있어요 | 지정가를 **정정**하는 API는 따로 없어요. 취소하고 새로 주문하세요. --- # 커뮤니티·소셜 종목토론, 관심종목, 종목 그룹, 종목 뉴스·갤러리, 캐릭터 친구, 캐릭터 상장까지 웹의 나머지 기능이에요. 조회는 대부분 공개이고, 쓰기는 **인증 필요**예요. > 봇이 쓰는 글도 사람이 쓰는 글과 똑같이 [운영정책](/policy)을 따라요. 짧은 시간에 같은 글을 반복해서 올리는 도배는 이용 제한 대상이에요. ## 종목토론 (커뮤니티) | 메서드 | 경로 | 인증 | 설명 | | ------ | ------------------------------ | ---- | -------------------------- | | GET | `/v1/community/{symbol}` | 공개 | 글 목록(최신순 최대 200개) | | POST | `/v1/community/{symbol}` | 필요 | 글쓰기 | | PATCH | `/v1/community/posts/{postId}` | 필요 | 내 글 수정 | **글쓰기** 바디 `{ "body": "마크다운 원문 (1~6000자)" }` ```json { "id": "1d5e...", "symbol": "FOXX", "userId": "8b6b...", "authorName": "호야", "body": "오늘 거래량 **폭발**", "attributes": {}, "createdAt": "2026-09-24T05:20:00.000Z", "updatedAt": "2026-09-24T05:20:00.000Z" } ``` - 본문은 마크다운 원문으로 저장되고, 렌더링은 보여주는 쪽(클라이언트)이 해요. - 수정하면 `attributes.edited`가 `true`가 돼요. 남의 글 `postId`로 수정하면 `404`예요. - 작성자 이름은 [프로필](/developers/account)의 `displayName`이에요. ## 관심종목 | 메서드 | 경로 | 설명 | | ------ | ------------------------ | --------------------------------- | | GET | `/v1/watchlist` | 관심종목(담은 순, `addedAt` 포함) | | POST | `/v1/watchlist/{symbol}` | 담기(이미 있으면 그대로) | | DELETE | `/v1/watchlist/{symbol}` | 빼기 | 항목 모양은 `{ symbol, name, species, lastPrice, changeRate, volume, addedAt }`이에요. 모두 **인증 필요**. ## 최근 본 종목 | 메서드 | 경로 | 설명 | | ------ | ------------------------------ | ------------------------------------- | | GET | `/v1/recently-viewed` | 최근 본 순 최대 30개(`viewedAt` 포함) | | POST | `/v1/recently-viewed/{symbol}` | 본 기록 남기기 | | DELETE | `/v1/recently-viewed/{symbol}` | 목록에서 지우기 | 커스텀 클라이언트에서 종목 상세 화면을 열 때 `POST`하면 웹과 같은 "최근 본" 목록이 유지돼요. ## 종목 그룹 웹의 "내 투자"·"관심" 탭에서 종목을 묶는 사용자 정의 그룹이에요. | 메서드 | 경로 | 설명 | | ------ | ----------------------------------- | ------------------------------------------------------------ | | GET | `/v1/item-groups?kind=holding` | 그룹 목록(`kind`: `holding` 또는 `watchlist`, 필수) | | POST | `/v1/item-groups` | 그룹 만들기 `{ "kind": "watchlist", "name": "최애" }` | | PATCH | `/v1/item-groups/{groupId}` | 이름·순서 변경 `{ "name"?, "sortOrder"? }` | | DELETE | `/v1/item-groups/{groupId}` | 그룹 삭제(멤버는 미분류로) | | PUT | `/v1/item-groups/{groupId}/members` | 멤버 전체를 이 순서로 교체 `{ "symbols": ["FOXX", "WOLF"] }` | 다른 그룹에 있던 종목을 `members`에 넣으면 이 그룹으로 옮겨져요. ## 종목 뉴스 (저널) 상장주 **소유자**가 자기 종목 소식을 올리는 곳이에요. "뉴스·공시" 탭에 보여요. | 메서드 | 경로 | 인증 | 설명 | | ------ | ------------------------------------------ | ------ | ---------------------------------------- | | GET | `/v1/stocks/{symbol}/journals` | 공개 | 목록(최신순 최대 200개) | | GET | `/v1/stocks/{symbol}/journals/{journalId}` | 공개 | 한 건 | | POST | `/v1/stocks/{symbol}/journals` | 소유자 | 작성 | | PATCH | `/v1/stocks/{symbol}/journals/{journalId}` | 소유자 | 수정 | | DELETE | `/v1/stocks/{symbol}/journals/{journalId}` | 소유자 | 삭제(`204`) | | POST | `/v1/link-preview` | 필요 | 링크 미리보기 `{ "url": "https://..." }` | 작성 바디: `{ "title": "1~200자", "body": "마크다운 1~20000자", "linkUrl": "https://... 또는 null" }`. `linkUrl`을 주면 서버가 링크 제목·이미지를 채워요. `linkUrl`은 `http://`나 `https://`로 시작해야 하고 아니면 `400 invalid_request`예요. 소유자가 아니면 `403 forbidden`이에요. ## 캐릭터 갤러리 종목 "갤러리" 탭(`/stocks/{symbol}/gallery?page=N`)의 사진 갤러리예요. 사진 올리기·수정·삭제는 상장주 소유자만, 좋아요·댓글은 누구나 할 수 있어요. | 메서드 | 경로 | 인증 | 설명 | | -------------- | -------------------------------------------------- | ------------- | ---------------------------------------------------------------- | | GET | `/v1/stocks/{symbol}/gallery?page=1` | 선택 | 사진 목록(페이지당 24장). 토큰을 보내면 `heartedByMe`가 채워져요 | | POST | `/v1/stocks/{symbol}/gallery` | 소유자 | 사진 올리기(multipart) | | PATCH | `/v1/stocks/{symbol}/gallery/{photoId}` | 소유자 | 제목·설명·태그 수정 | | DELETE | `/v1/stocks/{symbol}/gallery/{photoId}` | 소유자 | 삭제 | | POST | `/v1/stocks/{symbol}/gallery/{photoId}/view` | 필요 | 조회 기록(사람당 1회만 집계) | | POST / DELETE | `/v1/stocks/{symbol}/gallery/{photoId}/heart` | 필요 | 좋아요 / 취소 | | GET | `/v1/stocks/{symbol}/gallery/{photoId}/comments` | 공개 | 댓글 목록(최근 `limit`개, 기본 50·최대 200). 아래 참고 | | POST | `/v1/stocks/{symbol}/gallery/{photoId}/comments` | 필요 | 댓글 쓰기 `{ "body": "1~1000자" }` | | PATCH / DELETE | `/v1/stocks/{symbol}/gallery/comments/{commentId}` | 작성자·소유자 | 댓글 수정 / 삭제 | 댓글 목록은 페이지 단위예요. 처음엔 가장 최근 댓글들이 시간순(최신이 아래)으로 오고, 응답 헤더 `x-has-more: true`면 더 오래된 댓글이 남아 있어요. 그때 받은 페이지의 첫 댓글 id를 `before`로 넘겨 다시 부르면 그 앞 페이지가 와요. 사진 올리기(`multipart/form-data`): `title`(필수), `description`, `tags`(JSON 문자열 배열), `image`(png·jpeg·webp·gif, 최대 25MB). ```bash curl -s https://api.furstock.trade/v1/stocks/FOXX/gallery \ -H "Authorization: Bearer $TOKEN" \ -F title="새 일러스트" \ -F 'tags=["팬아트"]' \ -F image=@./art.png ``` ## 다른 캐릭터·친구 종목정보 탭(`/stocks/{symbol}/info`)의 BIO 아래 두 목록이에요. "이 캐릭터를 올린 사람의 다른 캐릭터"는 같은 소유자의 다른 상장 캐릭터(상장순)이고, "이 캐릭터의 친구"는 캐릭터 소유자가 직접 고른 캐릭터(소유자가 정한 순서, 최대 100명)예요. 둘 다 관리종목은 빠지고 거래정지 종목은 그대로 나와요. 친구는 사람이 아니라 캐릭터에 묶여요. 종목명이 바뀌어도 그대로이고, 한쪽에서 친구로 올려도 상대 목록에는 생기지 않아요. 같은 소유자의 다른 캐릭터도 친구로 올릴 수 있고, 자기 자신만 안 돼요. | 메서드 | 경로 | 인증 | 설명 | | ------ | -------------------------------------------- | ------ | --------------------------------------------------------------------------------------- | | GET | `/v1/stocks/{symbol}/related` | 공개 | `{ "sameOwner": [...], "friends": [...] }` (각 항목은 종목명·대표 이미지·현재가·등락률) | | POST | `/v1/stocks/{symbol}/friends` | 소유자 | 친구 추가(맨 끝) `{ "symbol": "친구종목명" }` | | PUT | `/v1/stocks/{symbol}/friends/order` | 소유자 | 순서 변경 `{ "symbols": ["A", "B", ...] }` | | DELETE | `/v1/stocks/{symbol}/friends/{friendSymbol}` | 소유자 | 친구 삭제 | 쓰기 3종은 모두 갱신된 친구 목록을 돌려줘요. 자기 자신은 `409 cannot_friend_self`, 관리종목은 `409 friend_unavailable`, 100명을 넘으면 `409 too_many_friends`예요. ## 캐릭터 상장 | 메서드 | 경로 | 설명 | | ------ | ----------------------------------------------------- | ------------------------------------------------------------- | | POST | `/v1/characters` | 상장 신청(multipart). 관리자 심사 후 상장 | | GET | `/v1/characters/mine` | 내 신청·상장종목(심사 상태, 반려 사유, 종목명 변경 심사 여부) | | PATCH | `/v1/characters/{characterId}` | 내 종목 정보 수정(태그·BIO·SNS·대표 이미지) | | POST | `/v1/characters/{characterId}/symbol-change-requests` | 종목명 변경 신청 `{ "newSymbol": "새이름" }` | 상장 신청 필드(`multipart/form-data`): | 필드 | 필수 | 설명 | | ---------- | ---- | ------------------------------------------------------------------------------------------------------------------ | | `name` | ✅ | 캐릭터 이름 = 종목명. 1~20자, 유니코드 문자·숫자와 기호 `-` `.` `_` `~` `(` `)`만(문자·숫자 1개 이상, 공백 불가) | | `image` | ✅ | 대표 이미지 png·jpeg·webp, 최대 5MB | | `tags` | | JSON 문자열 배열(최대 20개, 각 20자) | | `bio` | | 소개(마크다운, 최대 2000자) | | `snsLinks` | | JSON 배열 `[{"platform":"X","url":"https://x.com/..."}]`. `url`은 `http://`·`https://`로 시작해야 해요(아니면 400) | 이미 쓰이는 이름이면 `409 duplicate_symbol`이에요. ## 의견 보내기 `POST /v1/feedback` `{ "rating": 1~5, "comment": "선택, 최대 2000자" }`. 웹 사이드바 만족도 위젯과 같아요. --- # 실시간 시세 (WebSocket) 종목 시세가 바뀔 때마다 서버가 밀어주는 WebSocket이에요. 퍼스톡 웹 화면도 이걸로 시세를 받아요. 여러 종목을 짧은 주기로 폴링하는 대신 이걸 쓰면 빠르고, REST 요청 한도도 아낄 수 있어요. | 항목 | 값 | | -------- | ----------------------------------------------------------------------------- | | 주소 | `wss://ws.furstock.trade/socket` | | 프로토콜 | [Phoenix Channels](https://hexdocs.pm/phoenix/channels.html) (JSON 직렬화 v2) | | 인증 | 필요 없음(시세는 공개 데이터) | | 방향 | **수신 전용**. 주문은 항상 REST(`POST /v1/orders`)로 | | 토픽 | `stock:{symbol}` 종목마다 하나 | > 서버·봇 프로그램(Node, Bun, Python 등)은 바로 접속돼요. 브라우저에서 접속할 땐 현재 퍼스톡 도메인만 허용돼 있어서, 다른 도메인에서 서비스하는 웹 클라이언트는 시세를 REST로 폴링하세요. ## 이벤트 | 이벤트 | 언제 | payload | | ---------- | --------------------------------------------------------------- | --------------------- | | `snapshot` | 처음 구독했을 때, 끊겼다 다시 붙어 놓친 게 많을 때 | 현재가 스냅샷 전체 | | `price` | 체결로 시세가 바뀔 때마다(종목당 최대 초당 약 10회로 묶여서 옴) | 현재가 스냅샷 전체 | | `cold` | 서버가 아직 이 종목 시세를 모를 때(최근 거래 없음) | `{ "symbol": "..." }` | `snapshot`과 `price`의 payload는 [현재가 API](/developers/market)(`GET /v1/market/price/{symbol}`) 응답과 같은 모양이에요: `symbol`, `sessionId`, `seq`, `lastPrice`, `openPrice`, `high`, `low`, `bandLow`, `bandHigh`, `volume`, `orderbook`, `ts`. `cold`를 받았다면 REST로 현재가를 한 번 조회해 두세요. 그 종목에 체결이 생기면 그때부터 `price`가 와요. ## 재접속 구독할 때 마지막으로 받은 `seq`를 `last_seq`로 넘기면, 서버가 그 뒤의 변경분만 보내거나(짧은 끊김) `snapshot`으로 전체를 다시 보내줘요(긴 끊김). ## JavaScript / TypeScript (phoenix 패키지) ```bash npm install phoenix ``` ```ts import { Socket } from "phoenix"; const socket = new Socket("wss://ws.furstock.trade/socket"); socket.connect(); let lastSeq = 0; const channel = socket.channel("stock:FOXX", () => ({ last_seq: lastSeq })); const onPrice = (snap: { seq: number; lastPrice: number; volume: number }) => { lastSeq = snap.seq; console.log("FOXX", snap.lastPrice, "vol", snap.volume); }; channel.on("snapshot", onPrice); channel.on("price", onPrice); channel.on("cold", () => console.log("아직 시세 없음 — REST로 조회")); channel .join() .receive("ok", () => console.log("구독 시작")) .receive("error", (e) => console.error("구독 실패", e)); ``` Node 20 이하에서는 전역 `WebSocket`이 없어서 `new Socket(url, { transport: WebSocket })`에 `ws` 패키지의 `WebSocket`을 넘겨야 해요. Node 22+, Bun, Deno, 브라우저는 그대로 됩니다. ## 라이브러리 없이 (원시 프로토콜) Phoenix v2 직렬화는 메시지 하나가 JSON 배열 `[join_ref, ref, topic, event, payload]`이에요. 1. `wss://ws.furstock.trade/socket/websocket?vsn=2.0.0`에 접속 2. 구독: `["1", "1", "stock:FOXX", "phx_join", {"last_seq": 0}]` 전송 → `phx_reply`(status `ok`) 수신 3. 수신: `[join_ref, null, "stock:FOXX", "price", { ...스냅샷 }]` 4. **30초마다 하트비트**: `[null, "", "phoenix", "heartbeat", {}]`. 안 보내면 60초 뒤 서버가 연결을 끊어요. 5. 구독 해제: `["1", "", "stock:FOXX", "phx_leave", {}]` `ref`는 보낼 때마다 1씩 늘리는 문자열이면 돼요. 한 연결로 여러 종목을 구독할 수 있어요(종목마다 다른 `join_ref`). ## Python (websockets) ```bash pip install websockets ``` ```python import asyncio, json, itertools import websockets URL = "wss://ws.furstock.trade/socket/websocket?vsn=2.0.0" SYMBOLS = ["FOXX", "WOLF"] async def main(): refs = itertools.count(1) async with websockets.connect(URL) as ws: for i, sym in enumerate(SYMBOLS, start=1): await ws.send(json.dumps([str(i), str(next(refs)), f"stock:{sym}", "phx_join", {"last_seq": 0}])) async def heartbeat(): while True: await asyncio.sleep(30) await ws.send(json.dumps([None, str(next(refs)), "phoenix", "heartbeat", {}])) asyncio.create_task(heartbeat()) async for raw in ws: join_ref, ref, topic, event, payload = json.loads(raw) if event in ("snapshot", "price"): print(topic, payload["lastPrice"], payload["volume"]) asyncio.run(main()) ``` ## 체결 테이프 WebSocket은 현재가 스냅샷만 보내요. 개별 체결 목록이 필요하면 `GET /v1/market/trades/{symbol}`을 폴링하고 `seq`로 새 체결만 골라내세요. 내 주문 체결 여부도 REST(`GET /v1/orders`)로 확인해요. --- # 트레이더봇 만들기 API 키 하나로 도는 간단한 모멘텀 봇을 TypeScript와 Python으로 만들어 봐요. 두 예제 모두 실전 봇에 꼭 필요한 세 가지를 갖췄어요. 1. **토큰 자동 갱신**: 만료 5분 전에 새 토큰을 받아요. 2. **요청 한도 존중**: `429`를 받으면 `Retry-After`만큼 쉬었다가 다시 보내요. 3. **안전한 재시도**: 주문은 `clientToken`을 재시도 간에 유지해서 중복 체결을 막아요. 전략은 예시일 뿐이에요. 거래량 상위 종목 중 오르는 종목을 조금 사고, 수익 +5% 또는 손실 −3%가 되면 팔아요. ## 준비 1. [프로필 → OpenAPI 탭](https://furstock.trade/profile?tab=api)에서 봇 전용 키를 발급해요. 2. 환경변수로 넣어요. ```bash export FURSTOCK_CLIENT_ID=fsk_... export FURSTOCK_CLIENT_SECRET=fss_... ``` ## TypeScript (Bun / Node 22+) 의존성 없이 내장 `fetch`만 써요. `bun bot.ts` 또는 `npx tsx bot.ts`로 실행하세요. ```ts // bot.ts const API = "https://api.furstock.trade"; const CLIENT_ID = process.env.FURSTOCK_CLIENT_ID!; const CLIENT_SECRET = process.env.FURSTOCK_CLIENT_SECRET!; let token = ""; let tokenExpiresAt = 0; async function accessToken(): Promise { if (Date.now() < tokenExpiresAt - 5 * 60_000) return token; const res = await fetch(`${API}/oauth2/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }), }); if (!res.ok) throw new Error(`token ${res.status}: ${await res.text()}`); const body = (await res.json()) as { access_token: string; expires_in: number }; token = body.access_token; tokenExpiresAt = Date.now() + body.expires_in * 1000; return token; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function api(path: string, init: RequestInit = {}): Promise { for (let attempt = 0; attempt < 5; attempt++) { const res = await fetch(`${API}${path}`, { ...init, headers: { "content-type": "application/json", authorization: `Bearer ${await accessToken()}`, ...init.headers }, }); if (res.status === 429) { await sleep(Number(res.headers.get("retry-after") ?? 1) * 1000); continue; } if (res.status === 401) { tokenExpiresAt = 0; // 토큰 만료·폐기 — 다시 받아서 재시도 continue; } if (!res.ok) throw new Error(`${init.method ?? "GET"} ${path} → ${res.status}: ${await res.text()}`); return (await res.json()) as T; } throw new Error(`${path}: retries exhausted`); } type Ranking = { symbol: string; lastPrice: number; changeRate: number }; type Holding = { symbol: string; quantity: number; avgCost: number; lastPrice: number }; type Order = { status: string; rejectReason: string | null; quantityFilled: number; avgPrice: number }; async function order(symbol: string, side: "buy" | "sell", quantity: number): Promise { // 같은 주문을 재시도할 땐 같은 clientToken — api()의 재시도도 이 값을 그대로 쓴다. const clientToken = crypto.randomUUID(); const result = await api("/v1/orders", { method: "POST", body: JSON.stringify({ symbol, side, type: "market", quantity, clientToken }), }); console.log(`${side} ${symbol} x${quantity}: ${result.status}`, result.rejectReason ?? `@${result.avgPrice}`); return result; } async function tick() { const [{ cashBalance }, holdings, movers] = await Promise.all([ api<{ cashBalance: number }>("/v1/accounts/buying-power"), api("/v1/accounts/holdings"), api("/v1/market/rankings?type=volume&limit=10"), ]); // 익절 +5% / 손절 −3% for (const h of holdings) { const pnl = h.lastPrice / h.avgCost - 1; if (pnl >= 0.05 || pnl <= -0.03) await order(h.symbol, "sell", h.quantity); } // 거래량 상위 중 오르는 종목을 현금의 5%만큼 const held = new Set(holdings.map((h) => h.symbol)); const pick = movers.find((m) => m.changeRate > 0.02 && !held.has(m.symbol)); if (pick) { const qty = Math.floor((cashBalance * 0.05) / pick.lastPrice); if (qty > 0) await order(pick.symbol, "buy", qty); } } while (true) { try { await tick(); } catch (err) { console.error(err); } await sleep(15_000); } ``` ## Python 3.10+ ```bash pip install requests ``` ```python # bot.py import os, time, uuid import requests API = "https://api.furstock.trade" CLIENT_ID = os.environ["FURSTOCK_CLIENT_ID"] CLIENT_SECRET = os.environ["FURSTOCK_CLIENT_SECRET"] _token, _expires_at = "", 0.0 def access_token() -> str: global _token, _expires_at if time.time() < _expires_at - 300: return _token r = requests.post(f"{API}/oauth2/token", data={ "grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, }, timeout=10) r.raise_for_status() body = r.json() _token, _expires_at = body["access_token"], time.time() + body["expires_in"] return _token def api(method: str, path: str, json=None): global _expires_at for _ in range(5): r = requests.request(method, f"{API}{path}", json=json, timeout=10, headers={"Authorization": f"Bearer {access_token()}"}) if r.status_code == 429: time.sleep(float(r.headers.get("Retry-After", 1))) continue if r.status_code == 401: _expires_at = 0 # 토큰 만료·폐기 — 다시 받아서 재시도 continue r.raise_for_status() return r.json() raise RuntimeError(f"{path}: retries exhausted") def order(symbol: str, side: str, quantity: int): # 재시도해도 같은 clientToken — 중복 체결 방지 body = {"symbol": symbol, "side": side, "type": "market", "quantity": quantity, "clientToken": str(uuid.uuid4())} result = api("POST", "/v1/orders", body) print(side, symbol, quantity, result["status"], result["rejectReason"] or result["avgPrice"]) return result def tick(): cash = api("GET", "/v1/accounts/buying-power")["cashBalance"] holdings = api("GET", "/v1/accounts/holdings") movers = api("GET", "/v1/market/rankings?type=volume&limit=10") for h in holdings: pnl = h["lastPrice"] / h["avgCost"] - 1 if pnl >= 0.05 or pnl <= -0.03: order(h["symbol"], "sell", h["quantity"]) held = {h["symbol"] for h in holdings} pick = next((m for m in movers if m["changeRate"] > 0.02 and m["symbol"] not in held), None) if pick: qty = int(cash * 0.05 // pick["lastPrice"]) if qty > 0: order(pick["symbol"], "buy", qty) while True: try: tick() except Exception as e: print("error:", e) time.sleep(15) ``` ## 다음 단계 - **지정가로 싸게 사기**: `type: "limit"`으로 주문하고 `GET /v1/orders?status=pending`으로 대기열을 확인하세요. 오래 안 걸리면 `POST /v1/orders/{orderId}/cancel`로 취소하고요. 세션이 바뀌면(KST 3시간마다) 미체결 지정가는 자동 취소된다는 점을 기억하세요. - **실시간으로 반응하기**: 폴링 대신 [실시간 시세](/developers/realtime) WebSocket으로 `price` 이벤트를 받아 판단하세요. - **체결 비용 계산**: 가격 곡선 모델이라 큰 주문은 평균 체결가가 불리해져요. [호가](/developers/market)로 예상 체결가를 미리 가늠하고, 수수료 0.15%와 매도 세금 0.2%를 빼고 수익을 계산하세요. - **거래정지 확인**: 주문 전에 종목의 `tradingHalted`를 보거나, `rejectReason`이 `admin_halted`·`circuit_breaker`면 그 종목을 잠시 건너뛰세요. - **AI 에이전트에게 맡기기**: 에이전트에게 [`SKILL.md`](https://furstock.trade/skills/furstock-openapi/SKILL.md)를 읽히면 이 규칙들을 지키는 봇을 만들어 줘요.