diff --git a/.env b/.env new file mode 100644 index 0000000..bc4651e --- /dev/null +++ b/.env @@ -0,0 +1,16 @@ +# 한국투자증권 Open API 설정 +KIS_APP_KEY=your_app_key_here +KIS_APP_SECRET=your_app_secret_here +KIS_ACCOUNT_NO=12345678 +KIS_ACCOUNT_CODE=01 +KIS_HTS_ID=your_hts_id + +# 서버 모드 (real: 실전투자, vps: 모의투자) +KIS_SERVER_MODE=vps + +# 앱 설정 +APP_HOST=0.0.0.0 +APP_PORT=8000 +APP_DEBUG=true +DB_PATH=./data/stock.db +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index e99e7a5..f338af9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ ./.venv ./app/__pycache__/ + +*.pyc diff --git a/app/routers/trading.py b/app/routers/trading.py index b7fdf99..96fbbf2 100644 --- a/app/routers/trading.py +++ b/app/routers/trading.py @@ -1,9 +1,12 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException +from datetime import date, timedelta + +from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy.orm import Session +from app.core.auth import token_manager from app.core.database import get_db from app.models.stock import Trade from app.services.trading import trading_service @@ -62,32 +65,57 @@ async def cancel_order(data: CancelRequest) -> dict: @router.get("/history") -def get_trade_history( +async def get_trade_history( stock_code: str | None = None, - limit: int = 50, + start_date: str | None = None, + end_date: str | None = None, + limit: int = 100, db: Session = Depends(get_db), -) -> list[dict]: +) -> dict: + source = "local" + + if token_manager.is_authenticated: + if not start_date: + start_date = (date.today() - timedelta(days=30)).strftime("%Y%m%d") + if not end_date: + end_date = date.today().strftime("%Y%m%d") + + try: + kis_trades = await trading_service.get_daily_ccled( + start_date=start_date, + end_date=end_date, + stock_code=stock_code or "", + ) + if kis_trades: + source = "kis" + return {"source": source, "trades": kis_trades[:limit]} + except Exception: + pass + query = db.query(Trade).order_by(Trade.created_at.desc()) if stock_code: query = query.filter(Trade.stock_code == stock_code) trades = query.limit(limit).all() - return [ - { - "id": t.id, - "order_no": t.order_no, - "stock_code": t.stock_code, - "stock_name": t.stock_name, - "side": t.side, - "qty": t.qty, - "price": t.price, - "order_type": t.order_type, - "status": t.status, - "strategy_id": t.strategy_id, - "created_at": t.created_at.isoformat() if t.created_at else None, - "filled_at": t.filled_at.isoformat() if t.filled_at else None, - } - for t in trades - ] + return { + "source": source, + "trades": [ + { + "id": t.id, + "order_no": t.order_no, + "stock_code": t.stock_code, + "stock_name": t.stock_name, + "side": t.side, + "qty": t.qty, + "price": t.price, + "order_type": t.order_type, + "status": t.status, + "strategy_id": t.strategy_id, + "created_at": t.created_at.isoformat() if t.created_at else None, + "filled_at": t.filled_at.isoformat() if t.filled_at else None, + } + for t in trades + ], + } @router.get("/account") diff --git a/app/services/trading.py b/app/services/trading.py index 7aec2d5..d8291c6 100644 --- a/app/services/trading.py +++ b/app/services/trading.py @@ -11,10 +11,23 @@ logger = setup_logger("trading") _ORDER_URL = "/uapi/domestic-stock/v1/trading/order-cash" _MODIFY_CANCEL_URL = "/uapi/domestic-stock/v1/trading/order-rvsecncl" +_DAILY_CCLED_URL = "/uapi/domestic-stock/v1/trading/inquire-daily-ccld" _TR_IDS = { - "real": {"buy": "TTTC0802U", "sell": "TTTC0801U", "modify": "TTTC0803U", "cancel": "TTTC0804U"}, - "vps": {"buy": "VTTC0802U", "sell": "VTTC0801U", "modify": "VTTC0803U", "cancel": "VTTC0804U"}, + "real": { + "buy": "TTTC0802U", + "sell": "TTTC0801U", + "modify": "TTTC0803U", + "cancel": "TTTC0804U", + "daily_ccled": "TTTC0012R", + }, + "vps": { + "buy": "VTTC0802U", + "sell": "VTTC0801U", + "modify": "VTTC0803U", + "cancel": "VTTC0804U", + "daily_ccled": "VTTC0012R", + }, } @@ -173,6 +186,77 @@ class TradingService: return result + async def get_daily_ccled( + self, + start_date: str, + end_date: str, + stock_code: str = "", + ccled_dvsn: str = "0", + ) -> list[dict]: + """KIS API 일일 체결 내역 조회""" + if not self._check_auth(): + return [] + + await self._rate_limiter.acquire() + + tr_id = _TR_IDS.get(settings.kis.server_mode, _TR_IDS["vps"])["daily_ccled"] + headers = token_manager.get_auth_headers(tr_id) + + params = { + "CANO": settings.kis.account_no, + "ACNT_PRDT_CD": settings.kis.account_code, + "FH_PDNO": stock_code, + "CCLD_DVSN": ccled_dvsn, + "INQR_STRT_DAY": start_date, + "INQR_END_DAY": end_date, + "WCRC_FRCR_DVSN": "0", + "CTAC_TLNO": "", + "MKET_ID": "", + } + + url = f"{get_base_url()}{_DAILY_CCLED_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 [] + + trades = [] + for item in data.get("output", []): + qty = int(item.get("ft_ccld_qty", 0)) + if qty == 0: + continue + + side_code = item.get("sll_buy_dvsn_cd", "") + if side_code in ("02", "06"): + side = "sell" + elif side_code in ("01", "03"): + side = "buy" + else: + side = "buy" if side_code == "01" else "sell" + + trades.append({ + "order_no": item.get("ord_gno_brno", ""), + "stock_code": item.get("pdno", ""), + "stock_name": item.get("prdt_name", ""), + "side": side, + "qty": qty, + "price": int(float(item.get("ft_ccld_unpr3", 0))), + "order_type": item.get("ord_dvsn", "00"), + "status": "filled", + "order_amount": float(item.get("ft_ord_amt", 0)), + "settlement_amount": float(item.get("ft_ccld_amt", 0)), + "tax": float(item.get("sttl_evlu_amt", 0)), + "commission": float(item.get("ft_lof_ruse_amt", 0)), + "created_at": item.get("ord_sttm", ""), + "filled_at": item.get("ft_ccld_no", ""), + }) + + logger.info("일일 체결 내역 조회: %d건", len(trades)) + return trades + async def close(self) -> None: await self._client.aclose() diff --git a/app/templates/index.html b/app/templates/index.html index 2806626..60d9632 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -6,18 +6,29 @@ Stock Automation Dashboard -
-

Stock Automation

-
- - 연결 중... +
+ + + -
-
- API 키가 설정되지 않았거나 유효하지 않습니다. .env 파일에 KIS_APP_KEY와 KIS_APP_SECRET을 입력하세요. - 현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다. -
- -
-
-

총 투자금

-
-
-
-
-

평가금액

-
-
-
-
-

총 수익

-
-
-
-
-

수익률

-
-
+
+
+
실거래
+
+ + 연결 중...
-
-

보유 종목

- - - - - - - - -
종목코드종목명수량평균단가현재가수익수익률
-
+
+ +
+
+ API 키가 설정되지 않았거나 유효하지 않습니다. .env 파일에 KIS_APP_KEY와 KIS_APP_SECRET을 입력하세요. + 현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다. +
-
-

최근 매매 내역

- - - - - - - - -
시간종목코드구분수량가격상태
-
+
+
+

총 투자금

+
-
+
+
+

평가금액

+
-
+
+
+

총 수익

+
-
+
+
+

수익률

+
-
+
+
-
-

매매 주문

-
-
- - +
+

보유 종목

+ + + + + + + + +
종목코드종목명수량평균단가현재가수익수익률
-
- - + +
+

최근 매매 내역

+ + + + + + + + +
시간종목코드구분수량가격상태
-
- - + +
+

매매 주문

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
-
- - +
+ + +
+
+

과거 매매 기록 조회

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + + + + + + +
시간종목코드종목명구분수량체결가주문금액상태
+
-
@@ -136,34 +206,45 @@ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0838031 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,51 @@ +aiosqlite==0.22.1 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.14.2 +APScheduler==3.11.3 +certifi==2026.6.17 +cffi==2.1.0 +charset-normalizer==3.4.9 +click==8.4.2 +colorlog==6.10.1 +cryptography==49.0.0 +fastapi==0.139.2 +greenlet==3.5.3 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.8.0 +httpx==0.28.1 +idna==3.18 +iniconfig==2.3.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +numpy==2.5.1 +packaging==26.2 +pandas==3.0.3 +pluggy==1.6.0 +pycparser==3.0 +pydantic==2.13.4 +pydantic-settings==2.14.2 +pydantic_core==2.46.4 +Pygments==2.20.0 +pytest==9.1.1 +pytest-asyncio==1.4.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +python-kis==2.1.6 +PyYAML==6.0.3 +requests==2.34.2 +ruff==0.15.22 +six==1.17.0 +SQLAlchemy==2.0.51 +starlette==1.3.1 +ta==0.11.0 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +tzlocal==5.4.4 +urllib3==2.7.0 +uvicorn==0.51.0 +uvloop==0.22.1 +watchfiles==1.2.0 +websocket-client==1.9.0 +websockets==16.1