트레이더봇 만들기

API 키 하나로 도는 간단한 모멘텀 봇을 TypeScript와 Python으로 만들어 봐요. 두 예제 모두 실전 봇에 꼭 필요한 세 가지를 갖췄어요.

  1. 토큰 자동 갱신: 만료 5분 전에 새 토큰을 받아요.
  2. 요청 한도 존중: 429를 받으면 Retry-After만큼 쉬었다가 다시 보내요.
  3. 안전한 재시도: 주문은 clientToken을 재시도 간에 유지해서 중복 체결을 막아요.

전략은 예시일 뿐이에요. 거래량 상위 종목 중 오르는 종목을 조금 사고, 수익 +5% 또는 손실 −3%가 되면 팔아요.

준비

  1. 프로필 → OpenAPI 탭에서 봇 전용 키를 발급해요.
  2. 환경변수로 넣어요.
export FURSTOCK_CLIENT_ID=fsk_...
export FURSTOCK_CLIENT_SECRET=fss_...

TypeScript (Bun / Node 22+)

의존성 없이 내장 fetch만 써요. bun bot.ts 또는 npx tsx bot.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<string> {
  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<T>(path: string, init: RequestInit = {}): Promise<T> {
  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<Order> {
  // 같은 주문을 재시도할 땐 같은 clientToken — api()의 재시도도 이 값을 그대로 쓴다.
  const clientToken = crypto.randomUUID();
  const result = await api<Order>("/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<Holding[]>("/v1/accounts/holdings"),
    api<Ranking[]>("/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+

pip install requests
# 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시간마다) 미체결 지정가는 자동 취소된다는 점을 기억하세요.
  • 실시간으로 반응하기: 폴링 대신 실시간 시세 WebSocket으로 price 이벤트를 받아 판단하세요.
  • 체결 비용 계산: 가격 곡선 모델이라 큰 주문은 평균 체결가가 불리해져요. 호가로 예상 체결가를 미리 가늠하고, 수수료 0.15%와 매도 세금 0.2%를 빼고 수익을 계산하세요.
  • 거래정지 확인: 주문 전에 종목의 tradingHalted를 보거나, rejectReason이 admin_halted·circuit_breaker면 그 종목을 잠시 건너뛰세요.
  • AI 에이전트에게 맡기기: 에이전트에게 SKILL.md를 읽히면 이 규칙들을 지키는 봇을 만들어 줘요.