트레이더봇 만들기
API 키 하나로 도는 간단한 모멘텀 봇을 TypeScript와 Python으로 만들어 봐요. 두 예제 모두 실전 봇에 꼭 필요한 세 가지를 갖췄어요.
- 토큰 자동 갱신: 만료 5분 전에 새 토큰을 받아요.
- 요청 한도 존중:
429를 받으면Retry-After만큼 쉬었다가 다시 보내요. - 안전한 재시도: 주문은
clientToken을 재시도 간에 유지해서 중복 체결을 막아요.
전략은 예시일 뿐이에요. 거래량 상위 종목 중 오르는 종목을 조금 사고, 수익 +5% 또는 손실 −3%가 되면 팔아요.
준비
- 프로필 → OpenAPI 탭에서 봇 전용 키를 발급해요.
- 환경변수로 넣어요.
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를 읽히면 이 규칙들을 지키는 봇을 만들어 줘요.