first commit

This commit is contained in:
2026-07-16 23:55:16 +09:00
commit 57c07a4e12
40 changed files with 2513 additions and 0 deletions
View File
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import httpx
from app.core.auth import token_manager
from app.core.config import get_base_url, settings
from app.core.logger import setup_logger
from app.core.rate_limiter import RateLimiter
logger = setup_logger("account")
_BALANCE_URL = "/uapi/domestic-stock/v1/trading/inquire-balance"
_ORDERABLE_URL = "/uapi/domestic-stock/v1/trading/inquire-psamount"
_BALANCE_TR_IDS = {"real": "TTTC0311R", "vps": "VTTC0311R"}
class AccountService:
def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=10.0)
self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second)
async def get_balance(self) -> dict:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
tr_id = _BALANCE_TR_IDS.get(settings.kis.server_mode, "VTTC0311R")
headers = token_manager.get_auth_headers(tr_id)
params = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"AFHR_FLPR_YN": "N",
"OFLN_YN": "N",
"INQR_DVSN": "02",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"CNCD_UNCLD_YN": "N",
}
url = f"{get_base_url()}{_BALANCE_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
logger.warning("잔고 조회 실패: %s", data.get("msg1"))
return {"deposits": [], "stocks": []}
stocks = []
for item in data.get("output1", []):
stocks.append({
"stock_code": item.get("pdno", ""),
"stock_name": item.get("hsts_km_name", ""),
"qty": int(item.get("hldg_qty", 0)),
"avg_price": float(item.get("pchs_avg_pric", 0)),
"current_price": int(item.get("prpr", 0)),
"profit": float(item.get("evlu_pfls_amt", 0)),
"profit_rate": float(item.get("pfls_rt", 0)),
"buy_amount": float(item.get("pchs_amt", 0)),
"eval_amount": float(item.get("evlu_amt", 0)),
})
deposits = []
for item in data.get("output2", []):
deposits.append({
"currency": item.get("crcy_cd", "KRW"),
"amount": float(item.get("nmbdy_now_amt", 0)),
"orderable": float(item.get("ord_psbl_amt", 0)),
})
return {"deposits": deposits, "stocks": stocks}
async def get_orderable_amount(self, stock_code: str, price: int) -> dict:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
headers = token_manager.get_auth_headers("VTTC0830R" if settings.kis.server_mode == "vps" else "TTTC0830R")
params = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"PDNO": stock_code,
"ORD_UNPR": str(price),
"ORD_DVSN": "00",
}
url = f"{get_base_url()}{_ORDERABLE_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
return {"orderable_amount": 0, "orderable_qty": 0}
output = data.get("output", {})
return {
"orderable_amount": float(output.get("psmps_numb", 0)),
"orderable_qty": int(output.get("ord_psbl_qty", 0)),
"max_buy_amount": float(output.get("max_buy_psbl_amt", 0)),
}
async def close(self) -> None:
await self._client.aclose()
account_service = AccountService()
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import httpx
from app.core.auth import token_manager
from app.core.config import get_base_url, settings
from app.core.logger import setup_logger
from app.core.rate_limiter import RateLimiter
logger = setup_logger("market_data")
_PRICE_URL = "/uapi/domestic-stock/v1/quotations/inquire-price"
_CHART_URL = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
_ORDERBOOK_URL = "/uapi/domestic-stock/v1/quotations/inquire-asking-price"
_FID_INPUT_ISCD = "FID_INPUT_ISCD"
_FID_COND_MRKT_DIV_CODE = "FID_COND_MRKT_DIV_CODE"
class MarketDataService:
def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=10.0)
self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second)
async def get_current_price(self, stock_code: str) -> dict:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
headers = token_manager.get_auth_headers("FHKST01010100")
params = {_FID_COND_MRKT_DIV_CODE: "J", _FID_INPUT_ISCD: stock_code}
url = f"{get_base_url()}{_PRICE_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
logger.warning("현재가 조회 실패: %s - %s", stock_code, data.get("msg1"))
return {}
output = data.get("output", {})
return {
"stock_code": stock_code,
"current_price": int(output.get("stck_prpr", 0)),
"change_price": int(output.get("prdy_vrss", 0)),
"change_rate": float(output.get("prdy_ctrt", 0)),
"open_price": int(output.get("stck_oprc", 0)),
"high_price": int(output.get("stck_hgpr", 0)),
"low_price": int(output.get("stck_lwpr", 0)),
"volume": int(output.get("acml_vol", 0)),
"trade_amount": int(output.get("acml_tr_pbmn", 0)),
"stock_name": output.get("hts_kor_isnm", ""),
}
async def get_daily_chart(
self, stock_code: str, start_date: str, end_date: str, count: int = 30
) -> list[dict]:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
headers = token_manager.get_auth_headers("FHKST03010200")
params = {
_FID_COND_MRKT_DIV_CODE: "J",
_FID_INPUT_ISCD: stock_code,
"FID_INPUT_DATE_1": start_date,
"FID_INPUT_DATE_2": end_date,
"FID_PERIOD_DIV_CODE": "D",
"FID_ADJ_PRC": "1",
}
url = f"{get_base_url()}{_CHART_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
logger.warning("차트 조회 실패: %s - %s", stock_code, data.get("msg1"))
return []
result = []
for item in data.get("output2", [])[:count]:
result.append({
"date": item.get("stck_bsop_date", ""),
"open": int(item.get("stck_oprc", 0)),
"high": int(item.get("stck_hgpr", 0)),
"low": int(item.get("stck_lwpr", 0)),
"close": int(item.get("stck_clpr", 0)),
"volume": int(item.get("acml_vol", 0)),
})
return result
async def get_orderbook(self, stock_code: str) -> dict:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
headers = token_manager.get_auth_headers("FHKST01010200")
params = {_FID_COND_MRKT_DIV_CODE: "J", _FID_INPUT_ISCD: stock_code}
url = f"{get_base_url()}{_ORDERBOOK_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
return {}
output = data.get("output", [{}])[0] if data.get("output") else {}
return {
"stock_code": stock_code,
"bid_prices": [
int(output.get(f"phsc_kprc_{i}", 0)) for i in range(1, 6)
],
"ask_prices": [
int(output.get(f"sats_kprc_{i}", 0)) for i in range(1, 6)
],
"bid_volumes": [
int(output.get(f"phsc_vola_{i}", 0)) for i in range(1, 6)
],
"ask_volumes": [
int(output.get(f"sats_ac_vola_{i}", 0)) for i in range(1, 6)
],
}
async def close(self) -> None:
await self._client.aclose()
market_data_service = MarketDataService()
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
import asyncio
import json
from typing import Callable
import websockets
from app.core.auth import token_manager
from app.core.config import get_ws_url, settings
from app.core.logger import setup_logger
from app.core.rate_limiter import RateLimiter
logger = setup_logger("realtime")
_SUBSCRIBE_TR_IDS = {
"price": "H0STCNT0",
"orderbook": "H0STASP0",
"trade": "H0STCNI0",
}
class RealtimeService:
def __init__(self) -> None:
self._ws: websockets.WebSocketClientProtocol | None = None
self._running = False
self._callbacks: dict[str, list[Callable]] = {}
self._subscriptions: dict[str, list[str]] = {}
self._rate_limiter = RateLimiter(requests_per_second=0.5)
self._heartbeat_task: asyncio.Task | None = None
self._receive_task: asyncio.Task | None = None
def on(self, event: str, callback: Callable) -> None:
self._callbacks.setdefault(event, []).append(callback)
async def connect(self) -> None:
approval_key = await token_manager.get_approval_key()
url = get_ws_url()
try:
self._ws = await websockets.connect(
url,
extra_headers={"approval_key": approval_key, "type": "Y"},
)
self._running = True
self._receive_task = asyncio.create_task(self._receive_loop())
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
logger.info("WebSocket 연결 성공: %s", url)
except Exception as e:
logger.error("WebSocket 연결 실패: %s", e)
raise
async def subscribe(self, stock_code: str, data_type: str = "price") -> None:
if not self._ws:
await self.connect()
tr_id = _SUBSCRIBE_TR_IDS.get(data_type, "H0STCNT0")
msg = {
"header": {
"approval_key": await token_manager.get_approval_key(),
"custtype": "P",
"tr_type": "1",
"content-type": "utf-8",
},
"body": {
"input": {
"tr_id": tr_id,
"tr_key": stock_code,
}
},
}
await self._ws.send(json.dumps(msg))
self._subscriptions.setdefault(data_type, []).append(stock_code)
logger.info("구독 요청: %s - %s", data_type, stock_code)
async def unsubscribe(self, stock_code: str, data_type: str = "price") -> None:
if not self._ws:
return
tr_id = _SUBSCRIBE_TR_IDS.get(data_type, "H0STCNT0")
msg = {
"header": {
"approval_key": await token_manager.get_approval_key(),
"custtype": "P",
"tr_type": "2",
"content-type": "utf-8",
},
"body": {
"input": {
"tr_id": tr_id,
"tr_key": stock_code,
}
},
}
await self._ws.send(json.dumps(msg))
subs = self._subscriptions.get(data_type, [])
if stock_code in subs:
subs.remove(stock_code)
logger.info("구독 해제: %s - %s", data_type, stock_code)
async def _receive_loop(self) -> None:
try:
async for raw_msg in self._ws:
await self._handle_message(raw_msg)
except websockets.ConnectionClosed:
logger.warning("WebSocket 연결 끊김. 5초 후 재연결...")
if self._running:
await asyncio.sleep(5)
await self.connect()
except Exception as e:
logger.error("WebSocket 수신 오류: %s", e)
async def _handle_message(self, raw_msg: str) -> None:
try:
msg = json.loads(raw_msg)
header = msg.get("header", {})
body = msg.get("body", {})
tr_id = header.get("tr_id", "")
if tr_id == "PINGPONG":
return
parsed = self._parse_data(tr_id, body.get("output", {}))
if not parsed:
return
event_type = "price"
if "bid_prices" in parsed:
event_type = "orderbook"
elif "trade_price" in parsed:
event_type = "trade"
for callback in self._callbacks.get(event_type, []):
try:
result = callback(parsed)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error("콜백 실행 오류: %s", e)
except json.JSONDecodeError:
logger.warning("JSON 파싱 실패")
def _parse_data(self, tr_id: str, output: dict) -> dict | None:
if not output:
return None
if tr_id == "H0STCNT0":
return {
"type": "price",
"stock_code": output.get("mksc_shrn_iscd", ""),
"trade_price": int(output.get("stck_prpr", 0)),
"change_price": int(output.get("prdy_vrss", 0)),
"change_rate": float(output.get("prdy_ctrt", 0)),
"open_price": int(output.get("stck_oprc", 0)),
"high_price": int(output.get("stck_hgpr", 0)),
"low_price": int(output.get("stck_lwpr", 0)),
"volume": int(output.get("acml_vol", 0)),
"trade_time": output.get("stck_cntg_hour", ""),
}
if tr_id == "H0STASP0":
return {
"type": "orderbook",
"stock_code": output.get("mksc_shrn_iscd", ""),
"bid_prices": [int(output.get(f"phsc_kprc_{i}", 0)) for i in range(1, 6)],
"ask_prices": [int(output.get(f"sats_kprc_{i}", 0)) for i in range(1, 6)],
"bid_volumes": [int(output.get(f"phsc_vola_{i}", 0)) for i in range(1, 6)],
"ask_volumes": [int(output.get(f"sats_ac_vola_{i}", 0)) for i in range(1, 6)],
}
return None
async def _heartbeat_loop(self) -> None:
while self._running:
try:
await asyncio.sleep(30)
if self._ws and self._ws.open:
await self._ws.send(json.dumps({"header": {"tr_id": "PINGPONG"}}))
except Exception:
break
async def disconnect(self) -> None:
self._running = False
if self._heartbeat_task:
self._heartbeat_task.cancel()
if self._receive_task:
self._receive_task.cancel()
if self._ws:
await self._ws.close()
self._ws = None
logger.info("WebSocket 연결 종료")
realtime_service = RealtimeService()
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import httpx
from app.core.auth import token_manager
from app.core.config import get_base_url, settings
from app.core.logger import setup_logger
from app.core.rate_limiter import RateLimiter
logger = setup_logger("trading")
_ORDER_URL = "/uapi/domestic-stock/v1/trading/order-cash"
_MODIFY_CANCEL_URL = "/uapi/domestic-stock/v1/trading/order-rvsecncl"
_TR_IDS = {
"real": {"buy": "TTTC0802U", "sell": "TTTC0801U", "modify": "TTTC0803U", "cancel": "TTTC0804U"},
"vps": {"buy": "VTTC0802U", "sell": "VTTC0801U", "modify": "VTTC0803U", "cancel": "VTTC0804U"},
}
class TradingService:
def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=10.0)
self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second)
def _get_tr_id(self, side: str) -> str:
mode = settings.kis.server_mode
return _TR_IDS.get(mode, _TR_IDS["vps"]).get(side, "VTTC0802U")
async def place_order(
self,
stock_code: str,
side: str,
qty: int,
price: int = 0,
order_type: str = "00",
) -> dict:
await self._rate_limiter.acquire()
tr_id = self._get_tr_id(side)
headers = token_manager.get_auth_headers(tr_id)
body = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"PDNO": stock_code,
"ORD_DVSN": order_type,
"ORD_QTY": str(qty),
"ORD_UNPR": str(price) if order_type == "00" else "0",
}
hashkey = await token_manager.generate_hashkey(body)
headers["hashkey"] = hashkey
url = f"{get_base_url()}{_ORDER_URL}"
resp = await self._client.post(url, json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
result = {
"order_no": data.get("output", {}).get("odno", ""),
"rt_cd": data.get("rt_cd"),
"msg_cd": data.get("msg_cd"),
"msg": data.get("msg1"),
"stock_code": stock_code,
"side": side,
"qty": qty,
"price": price,
"order_type": order_type,
}
if data.get("rt_cd") == "0":
logger.info(
"주문 성공: %s %s %s주 %s @ %s원",
stock_code,
"매수" if side == "buy" else "매도",
qty,
order_type,
price,
)
else:
logger.warning("주문 실패: %s - %s", stock_code, data.get("msg1"))
return result
async def modify_order(
self, order_no: str, stock_code: str, qty: int, price: int, order_type: str = "00"
) -> dict:
await self._rate_limiter.acquire()
tr_id = self._get_tr_id("modify")
headers = token_manager.get_auth_headers(tr_id)
body = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"ODNO": order_no,
"PDNO": stock_code,
"ORD_DVSN": order_type,
"ORD_QTY": str(qty),
"ORD_UNPR": str(price),
}
hashkey = await token_manager.generate_hashkey(body)
headers["hashkey"] = hashkey
url = f"{get_base_url()}{_MODIFY_CANCEL_URL}"
resp = await self._client.post(url, json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
result = {
"order_no": data.get("output", {}).get("odno", ""),
"rt_cd": data.get("rt_cd"),
"msg": data.get("msg1"),
}
if data.get("rt_cd") == "0":
logger.info("정정 성공: 주문번호 %s", order_no)
else:
logger.warning("정정 실패: %s - %s", order_no, data.get("msg1"))
return result
async def cancel_order(self, order_no: str, stock_code: str, qty: int) -> dict:
await self._rate_limiter.acquire()
tr_id = self._get_tr_id("cancel")
headers = token_manager.get_auth_headers(tr_id)
body = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"ODNO": order_no,
"PDNO": stock_code,
"ORD_DVSN": "00",
"ORD_QTY": str(qty),
"ORD_UNPR": "0",
}
hashkey = await token_manager.generate_hashkey(body)
headers["hashkey"] = hashkey
url = f"{get_base_url()}{_MODIFY_CANCEL_URL}"
resp = await self._client.post(url, json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
result = {
"rt_cd": data.get("rt_cd"),
"msg": data.get("msg1"),
}
if data.get("rt_cd") == "0":
logger.info("취소 성공: 주문번호 %s", order_no)
else:
logger.warning("취소 실패: %s - %s", order_no, data.get("msg1"))
return result
async def close(self) -> None:
await self._client.aclose()
trading_service = TradingService()