diff --git a/.gitignore b/.gitignore index 3bb885c..411d601 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,14 @@ -.venv +app/__pycache__/ +app/core/__pycache__/ +app/engine/__pycache__/ +app/engine/strategies/__pycache__/ +app/models/__pycache__/ +app/routers/__pycache__/ +app/services/__pycache__/ __pycache__/ -*.pyc +.venv/ .auth_cache.json -config.yaml .env +*.pyc output.log +config.yaml diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc index d9cf1eb..3243ec8 100644 Binary files a/app/__pycache__/__init__.cpython-313.pyc and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 04e3c00..2839c7e 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/core/__pycache__/__init__.cpython-313.pyc b/app/core/__pycache__/__init__.cpython-313.pyc index a2afb90..3779a20 100644 Binary files a/app/core/__pycache__/__init__.cpython-313.pyc and b/app/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/core/__pycache__/auth.cpython-313.pyc b/app/core/__pycache__/auth.cpython-313.pyc index 642a478..4e05382 100644 Binary files a/app/core/__pycache__/auth.cpython-313.pyc and b/app/core/__pycache__/auth.cpython-313.pyc differ diff --git a/app/core/__pycache__/config.cpython-313.pyc b/app/core/__pycache__/config.cpython-313.pyc index 98ed851..a7039ac 100644 Binary files a/app/core/__pycache__/config.cpython-313.pyc and b/app/core/__pycache__/config.cpython-313.pyc differ diff --git a/app/core/__pycache__/database.cpython-313.pyc b/app/core/__pycache__/database.cpython-313.pyc index 08551a6..5aaa5f1 100644 Binary files a/app/core/__pycache__/database.cpython-313.pyc and b/app/core/__pycache__/database.cpython-313.pyc differ diff --git a/app/core/__pycache__/logger.cpython-313.pyc b/app/core/__pycache__/logger.cpython-313.pyc index d071661..f237dad 100644 Binary files a/app/core/__pycache__/logger.cpython-313.pyc and b/app/core/__pycache__/logger.cpython-313.pyc differ diff --git a/app/core/__pycache__/rate_limiter.cpython-313.pyc b/app/core/__pycache__/rate_limiter.cpython-313.pyc index b036b57..e802330 100644 Binary files a/app/core/__pycache__/rate_limiter.cpython-313.pyc and b/app/core/__pycache__/rate_limiter.cpython-313.pyc differ diff --git a/app/core/config.py b/app/core/config.py index 5e0ef38..88bb62a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,11 +1,34 @@ from __future__ import annotations +import os from pathlib import Path from typing import Any import yaml +def _load_dotenv(path: Path) -> None: + """간단한 .env 파서 (python-dotenv 의존성 대신 내장 구현).""" + if not path.exists(): + return + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +_load_dotenv(Path(".env")) + + +def _env(name: str, default: Any = None) -> Any: + return os.environ.get(name, default) + + class KISConfig: app_key: str = "" app_secret: str = "" @@ -14,6 +37,27 @@ class KISConfig: hts_id: str = "" server_mode: str = "vps" + def apply_env(self) -> None: + app_key = _env("KIS_APP_KEY") + app_secret = _env("KIS_APP_SECRET") + account_no = _env("KIS_ACCOUNT_NO") + account_code = _env("KIS_ACCOUNT_CODE") + hts_id = _env("KIS_HTS_ID") + server_mode = _env("KIS_SERVER_MODE") + + if app_key: + self.app_key = app_key + if app_secret: + self.app_secret = app_secret + if account_no: + self.account_no = account_no + if account_code: + self.account_code = account_code + if hts_id: + self.hts_id = hts_id + if server_mode: + self.server_mode = server_mode + class AppConfig: name: str = "StockAutomation" @@ -58,6 +102,7 @@ class Settings: self.trading = TradingConfig() self.rate_limit = RateLimitConfig() self._load_yaml() + self.kis.apply_env() def _load_yaml(self) -> None: yaml_path = Path("config.yaml") diff --git a/app/engine/__pycache__/__init__.cpython-313.pyc b/app/engine/__pycache__/__init__.cpython-313.pyc index 809df23..29b131a 100644 Binary files a/app/engine/__pycache__/__init__.cpython-313.pyc and b/app/engine/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/engine/__pycache__/collector.cpython-313.pyc b/app/engine/__pycache__/collector.cpython-313.pyc index 7e5f307..13a02d7 100644 Binary files a/app/engine/__pycache__/collector.cpython-313.pyc and b/app/engine/__pycache__/collector.cpython-313.pyc differ diff --git a/app/engine/__pycache__/scheduler.cpython-313.pyc b/app/engine/__pycache__/scheduler.cpython-313.pyc index 1130af1..45914de 100644 Binary files a/app/engine/__pycache__/scheduler.cpython-313.pyc and b/app/engine/__pycache__/scheduler.cpython-313.pyc differ diff --git a/app/engine/__pycache__/strategy_engine.cpython-313.pyc b/app/engine/__pycache__/strategy_engine.cpython-313.pyc index fb28290..57ba9d2 100644 Binary files a/app/engine/__pycache__/strategy_engine.cpython-313.pyc and b/app/engine/__pycache__/strategy_engine.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/__init__.cpython-313.pyc b/app/engine/strategies/__pycache__/__init__.cpython-313.pyc index 5771c35..13deebb 100644 Binary files a/app/engine/strategies/__pycache__/__init__.cpython-313.pyc and b/app/engine/strategies/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/base.cpython-313.pyc b/app/engine/strategies/__pycache__/base.cpython-313.pyc index 7aa2d8c..8131845 100644 Binary files a/app/engine/strategies/__pycache__/base.cpython-313.pyc and b/app/engine/strategies/__pycache__/base.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/conditional.cpython-313.pyc b/app/engine/strategies/__pycache__/conditional.cpython-313.pyc index 0e931a4..b01032d 100644 Binary files a/app/engine/strategies/__pycache__/conditional.cpython-313.pyc and b/app/engine/strategies/__pycache__/conditional.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/periodic.cpython-313.pyc b/app/engine/strategies/__pycache__/periodic.cpython-313.pyc index 4b87641..31df8d3 100644 Binary files a/app/engine/strategies/__pycache__/periodic.cpython-313.pyc and b/app/engine/strategies/__pycache__/periodic.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/technical.cpython-313.pyc b/app/engine/strategies/__pycache__/technical.cpython-313.pyc index bdb5094..287934c 100644 Binary files a/app/engine/strategies/__pycache__/technical.cpython-313.pyc and b/app/engine/strategies/__pycache__/technical.cpython-313.pyc differ diff --git a/app/engine/strategy_engine.py b/app/engine/strategy_engine.py index 8c3d731..11a9190 100644 --- a/app/engine/strategy_engine.py +++ b/app/engine/strategy_engine.py @@ -4,15 +4,17 @@ import json from sqlalchemy.orm import Session +from app.core.auth import token_manager from app.core.config import settings from app.core.logger import setup_logger -from app.models.stock import PriceHistory, Strategy, Trade -from app.services.market_data import market_data_service -from app.services.trading import trading_service from app.engine.strategies.base import BaseStrategy, Signal from app.engine.strategies.conditional import ConditionalStrategy -from app.engine.strategies.technical import TechnicalStrategy from app.engine.strategies.periodic import PeriodicStrategy +from app.engine.strategies.technical import TechnicalStrategy +from app.models.stock import PriceHistory, Strategy, Trade +from app.services.account import account_service +from app.services.market_data import market_data_service +from app.services.trading import trading_service logger = setup_logger("strategy_engine") @@ -40,9 +42,25 @@ class StrategyEngine: strategy_id=strategy_record.id, ) + async def _get_real_holdings(self, db: Session) -> dict[str, int]: + """실제 계좌 잔고 기반 보유수량 맵. 실패 시 로컬 DB로 폴백.""" + if token_manager.is_authenticated: + try: + balance = await account_service.get_balance() + holdings = {s["stock_code"]: s["qty"] for s in balance.get("stocks", [])} + if holdings: + return holdings + except Exception as e: + logger.warning("잔고 조회 실패 - 로컬 보유정보 사용: %s", e) + + from app.models.stock import Holding + + return {h.stock_code: h.qty for h in db.query(Holding).filter(Holding.qty > 0).all()} + async def evaluate_all(self, db: Session) -> list[Signal]: - strategies = db.query(Strategy).filter(Strategy.is_active == True).all() + strategies = db.query(Strategy).filter(Strategy.is_active).all() signals: list[Signal] = [] + real_holdings = await self._get_real_holdings(db) for strat_record in strategies: try: @@ -55,7 +73,7 @@ class StrategyEngine: continue price_history = self._get_price_history(db, strat_record.stock_code) - holding = self._get_holding_qty(db, strat_record.stock_code) + holding = real_holdings.get(strat_record.stock_code, 0) signal = strategy.evaluate(current_price, price_history, holding) if signal.action != "hold": @@ -103,7 +121,7 @@ class StrategyEngine: qty=signal.qty, price=signal.price, order_type=settings.strategy.default_order_type, - status="filled" if order_result.get("rt_cd") == "0" else "rejected", + status="pending" if order_result.get("rt_cd") == "0" else "rejected", strategy_id=signal.strategy_id, ) db.add(trade) @@ -136,11 +154,6 @@ class StrategyEngine: for r in reversed(records) ] - def _get_holding_qty(self, db: Session, stock_code: str) -> int: - from app.models.stock import Holding - holding = db.query(Holding).filter(Holding.stock_code == stock_code).first() - return holding.qty if holding else 0 - def reset_daily_count(self) -> None: self._daily_trade_count = 0 diff --git a/app/models/__pycache__/__init__.cpython-313.pyc b/app/models/__pycache__/__init__.cpython-313.pyc index 62e09d4..1e523c1 100644 Binary files a/app/models/__pycache__/__init__.cpython-313.pyc and b/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/models/__pycache__/stock.cpython-313.pyc b/app/models/__pycache__/stock.cpython-313.pyc index 0726163..6e4b801 100644 Binary files a/app/models/__pycache__/stock.cpython-313.pyc and b/app/models/__pycache__/stock.cpython-313.pyc differ diff --git a/app/routers/__pycache__/__init__.cpython-313.pyc b/app/routers/__pycache__/__init__.cpython-313.pyc index 65ea507..40a74fe 100644 Binary files a/app/routers/__pycache__/__init__.cpython-313.pyc and b/app/routers/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/routers/__pycache__/dashboard.cpython-313.pyc b/app/routers/__pycache__/dashboard.cpython-313.pyc index 727a67a..27e316a 100644 Binary files a/app/routers/__pycache__/dashboard.cpython-313.pyc and b/app/routers/__pycache__/dashboard.cpython-313.pyc differ diff --git a/app/routers/__pycache__/stocks.cpython-313.pyc b/app/routers/__pycache__/stocks.cpython-313.pyc index c2fdf61..236871c 100644 Binary files a/app/routers/__pycache__/stocks.cpython-313.pyc and b/app/routers/__pycache__/stocks.cpython-313.pyc differ diff --git a/app/routers/__pycache__/strategies.cpython-313.pyc b/app/routers/__pycache__/strategies.cpython-313.pyc index 368de5a..a87c18f 100644 Binary files a/app/routers/__pycache__/strategies.cpython-313.pyc and b/app/routers/__pycache__/strategies.cpython-313.pyc differ diff --git a/app/routers/__pycache__/trading.cpython-313.pyc b/app/routers/__pycache__/trading.cpython-313.pyc index 882fd04..276ea64 100644 Binary files a/app/routers/__pycache__/trading.cpython-313.pyc and b/app/routers/__pycache__/trading.cpython-313.pyc differ diff --git a/app/routers/__pycache__/websocket.cpython-313.pyc b/app/routers/__pycache__/websocket.cpython-313.pyc index 523aa4c..5b0ea6c 100644 Binary files a/app/routers/__pycache__/websocket.cpython-313.pyc and b/app/routers/__pycache__/websocket.cpython-313.pyc differ diff --git a/app/routers/trading.py b/app/routers/trading.py index 96fbbf2..e9daf86 100644 --- a/app/routers/trading.py +++ b/app/routers/trading.py @@ -46,7 +46,7 @@ async def place_order(data: OrderRequest, db: Session = Depends(get_db)) -> dict qty=data.qty, price=data.price, order_type=data.order_type, - status="filled" if result.get("rt_cd") == "0" else "rejected", + status="pending" if result.get("rt_cd") == "0" else "rejected", ) db.add(trade) db.commit() diff --git a/app/services/__pycache__/__init__.cpython-313.pyc b/app/services/__pycache__/__init__.cpython-313.pyc index 6cf3df8..1f10dec 100644 Binary files a/app/services/__pycache__/__init__.cpython-313.pyc and b/app/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/services/__pycache__/account.cpython-313.pyc b/app/services/__pycache__/account.cpython-313.pyc index 84d7e58..7e1b2ea 100644 Binary files a/app/services/__pycache__/account.cpython-313.pyc and b/app/services/__pycache__/account.cpython-313.pyc differ diff --git a/app/services/__pycache__/market_data.cpython-313.pyc b/app/services/__pycache__/market_data.cpython-313.pyc index 9347c6e..33951f0 100644 Binary files a/app/services/__pycache__/market_data.cpython-313.pyc and b/app/services/__pycache__/market_data.cpython-313.pyc differ diff --git a/app/services/__pycache__/realtime.cpython-313.pyc b/app/services/__pycache__/realtime.cpython-313.pyc index 4bc7932..caffecb 100644 Binary files a/app/services/__pycache__/realtime.cpython-313.pyc and b/app/services/__pycache__/realtime.cpython-313.pyc differ diff --git a/app/services/__pycache__/trading.cpython-313.pyc b/app/services/__pycache__/trading.cpython-313.pyc index 276a60e..4ae69db 100644 Binary files a/app/services/__pycache__/trading.cpython-313.pyc and b/app/services/__pycache__/trading.cpython-313.pyc differ diff --git a/app/services/market_data.py b/app/services/market_data.py index 5b4dc1b..eb27f4a 100644 --- a/app/services/market_data.py +++ b/app/services/market_data.py @@ -38,7 +38,9 @@ class MarketDataService: url = f"{get_base_url()}{_PRICE_URL}" resp = await self._client.get(url, headers=headers, params=params) - resp.raise_for_status() + if resp.status_code != 200: + logger.warning("현재가 조회 HTTP 오류: %s - %s", stock_code, resp.status_code) + return {} data = resp.json() if data.get("rt_cd") != "0": @@ -78,7 +80,9 @@ class MarketDataService: url = f"{get_base_url()}{_CHART_URL}" resp = await self._client.get(url, headers=headers, params=params) - resp.raise_for_status() + if resp.status_code != 200: + logger.warning("차트 조회 HTTP 오류: %s - %s", stock_code, resp.status_code) + return [] data = resp.json() if data.get("rt_cd") != "0": @@ -107,7 +111,9 @@ class MarketDataService: url = f"{get_base_url()}{_ORDERBOOK_URL}" resp = await self._client.get(url, headers=headers, params=params) - resp.raise_for_status() + if resp.status_code != 200: + logger.warning("호가 조회 HTTP 오류: %s - %s", stock_code, resp.status_code) + return {} data = resp.json() if data.get("rt_cd") != "0": diff --git a/app/services/trading.py b/app/services/trading.py index d8291c6..da19694 100644 --- a/app/services/trading.py +++ b/app/services/trading.py @@ -57,6 +57,36 @@ class TradingService: if not self._check_auth(): return {"rt_cd": "-1", "msg": "API 인증되지 않음", "order_no": ""} + if qty <= 0: + return {"rt_cd": "-1", "msg": "주문 수량은 0보다 커야 합니다", "order_no": ""} + + if side not in ("buy", "sell"): + return {"rt_cd": "-1", "msg": "유효하지 않은 주문 방향입니다", "order_no": ""} + + if order_type == "00" and price <= 0: + return {"rt_cd": "-1", "msg": "지정가 주문의 가격은 0보다 커야 합니다", "order_no": ""} + + order_amount = qty * price + if price > 0: + if order_amount < settings.trading.min_order_amount: + return { + "rt_cd": "-1", + "msg": ( + f"최소 주문 금액 미달: {order_amount:,}원 " + f"< {settings.trading.min_order_amount:,}원" + ), + "order_no": "", + } + if order_amount > settings.trading.max_order_amount: + return { + "rt_cd": "-1", + "msg": ( + f"최대 주문 금액 초과: {order_amount:,}원 " + f"> {settings.trading.max_order_amount:,}원" + ), + "order_no": "", + } + await self._rate_limiter.acquire() tr_id = self._get_tr_id(side) @@ -237,22 +267,24 @@ class TradingService: 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", ""), - }) + 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 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..68e96c1 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,43 @@ +# StockAutomation 설정 템플릿 +# 사용법: 이 파일을 복사하여 config.yaml 로 저장한 뒤 수정하세요. +# cp config.example.yaml config.yaml +# +# 주의: 시크릿 값(app_key, app_secret, account_no, hts_id)은 이 파일에 두지 마세요. +# 반드시 환경변수(.env 또는 서버 환경변수)로 주입하세요. +# KIS_APP_KEY / KIS_APP_SECRET / KIS_ACCOUNT_NO / KIS_ACCOUNT_CODE / KIS_HTS_ID + +app: + name: "StockAutomation" + version: "0.1.0" + host: "0.0.0.0" + port: 8000 + debug: true + db_path: "./data/stock.db" + log_level: "INFO" + +kis: + server_mode: "vps" # vps: 모의투자, real: 실전투자 + rate_limit: + requests_per_second: 2.0 # 모의투자/실전투자 공통: 초당 2회 + retry_delay: 1.5 + +collector: + interval_seconds: 5 # 주가 수집 간격 (초) + market_open_hour: 9 + market_close_hour: 15 + market_close_minute: 30 + +strategies: + check_interval_seconds: 3 # 전략 체크 간격 (초) + max_daily_trades: 50 # 일일 최대 매매 횟수 + default_order_type: "00" # 00: 지정가, 01: 시장가 + +trading: + max_order_amount: 10000000 # 최대 주문 금액 (원) + min_order_amount: 100000 # 최소 주문 금액 (원) + slippage_percent: 0.1 # 슬리피지 허용 비율 (%) + +logging: + level: "INFO" + format: "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" + file: "./data/logs/stock.log" diff --git a/data/stock.db-shm b/data/stock.db-shm deleted file mode 100644 index fe9ac28..0000000 Binary files a/data/stock.db-shm and /dev/null differ diff --git a/data/stock.db-wal b/data/stock.db-wal deleted file mode 100644 index e69de29..0000000 diff --git a/requirements.txt b/requirements.txt index 0838031..41a495a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,7 +45,6 @@ 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