From 57c07a4e124a8d06a369504802af83fddafb48aa Mon Sep 17 00:00:00 2001 From: gerd Date: Thu, 16 Jul 2026 23:55:16 +0900 Subject: [PATCH] first commit --- .env.example | 16 ++ app/__init__.py | 0 app/core/__init__.py | 0 app/core/auth.py | 105 +++++++++ app/core/config.py | 116 ++++++++++ app/core/database.py | 43 ++++ app/core/logger.py | 27 +++ app/core/rate_limiter.py | 25 ++ app/engine/__init__.py | 0 app/engine/collector.py | 93 ++++++++ app/engine/scheduler.py | 80 +++++++ app/engine/strategies/__init__.py | 0 app/engine/strategies/base.py | 43 ++++ app/engine/strategies/conditional.py | 60 +++++ app/engine/strategies/periodic.py | 67 ++++++ app/engine/strategies/technical.py | 158 +++++++++++++ app/engine/strategy_engine.py | 148 ++++++++++++ app/main.py | 86 +++++++ app/models/__init__.py | 0 app/models/stock.py | 76 +++++++ app/routers/__init__.py | 0 app/routers/dashboard.py | 68 ++++++ app/routers/stocks.py | 79 +++++++ app/routers/strategies.py | 103 +++++++++ app/routers/trading.py | 96 ++++++++ app/routers/websocket.py | 68 ++++++ app/services/__init__.py | 0 app/services/account.py | 105 +++++++++ app/services/market_data.py | 125 ++++++++++ app/services/realtime.py | 198 ++++++++++++++++ app/services/trading.py | 165 ++++++++++++++ app/templates/index.html | 215 ++++++++++++++++++ config.yaml | 30 +++ pyproject.toml | 38 ++++ stock_automation.egg-info/PKG-INFO | 23 ++ stock_automation.egg-info/SOURCES.txt | 36 +++ .../dependency_links.txt | 1 + stock_automation.egg-info/requires.txt | 19 ++ stock_automation.egg-info/top_level.txt | 1 + tests/__init__.py | 0 40 files changed, 2513 insertions(+) create mode 100644 .env.example create mode 100644 app/__init__.py create mode 100644 app/core/__init__.py create mode 100644 app/core/auth.py create mode 100644 app/core/config.py create mode 100644 app/core/database.py create mode 100644 app/core/logger.py create mode 100644 app/core/rate_limiter.py create mode 100644 app/engine/__init__.py create mode 100644 app/engine/collector.py create mode 100644 app/engine/scheduler.py create mode 100644 app/engine/strategies/__init__.py create mode 100644 app/engine/strategies/base.py create mode 100644 app/engine/strategies/conditional.py create mode 100644 app/engine/strategies/periodic.py create mode 100644 app/engine/strategies/technical.py create mode 100644 app/engine/strategy_engine.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/stock.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/dashboard.py create mode 100644 app/routers/stocks.py create mode 100644 app/routers/strategies.py create mode 100644 app/routers/trading.py create mode 100644 app/routers/websocket.py create mode 100644 app/services/__init__.py create mode 100644 app/services/account.py create mode 100644 app/services/market_data.py create mode 100644 app/services/realtime.py create mode 100644 app/services/trading.py create mode 100644 app/templates/index.html create mode 100644 config.yaml create mode 100644 pyproject.toml create mode 100644 stock_automation.egg-info/PKG-INFO create mode 100644 stock_automation.egg-info/SOURCES.txt create mode 100644 stock_automation.egg-info/dependency_links.txt create mode 100644 stock_automation.egg-info/requires.txt create mode 100644 stock_automation.egg-info/top_level.txt create mode 100644 tests/__init__.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc4651e --- /dev/null +++ b/.env.example @@ -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/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..10bcd41 --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import time +from datetime import datetime, timedelta + +import httpx + +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("auth") + +_TOKEN_URL = "/oauth2/tokenP" +_APPROVAL_URL = "/oauth2/Approval" +_HASHKEY_URL = "/uapi/hashkey" + + +class TokenManager: + def __init__(self) -> None: + self._access_token: str = "" + self._token_expires_at: datetime = datetime.min + self._approval_key: str = "" + self._approval_expires_at: datetime = datetime.min + self._rate_limiter = RateLimiter(requests_per_second=1.0) + self._lock = asyncio.Lock() + self._client = httpx.AsyncClient(timeout=10.0) + + @property + def is_vps(self) -> bool: + return settings.kis.server_mode == "vps" + + async def _request_token(self) -> None: + url = f"{get_base_url()}{_TOKEN_URL}" + payload = { + "grant_type": "client_credentials", + "appkey": settings.kis.app_key, + "appsecret": settings.kis.app_secret, + } + resp = await self._client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + + self._access_token = data["access_token"] + expires_in = int(data.get("expires_in", 7776000)) + self._token_expires_at = datetime.now() + timedelta(seconds=expires_in) + logger.info("REST access_token 발급 완료 (만료: %s)", self._token_expires_at) + + async def _request_approval_key(self) -> None: + url = f"{get_base_url()}{_APPROVAL_URL}" + payload = { + "grant_type": "client_credentials", + "appkey": settings.kis.app_key, + "secretkey": settings.kis.app_secret, + } + resp = await self._client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + + self._approval_key = data["approval_key"] + self._approval_expires_at = datetime.now() + timedelta(hours=23, minutes=50) + logger.info("WebSocket approval_key 발급 완료 (만료: %s)", self._approval_expires_at) + + async def get_access_token(self) -> str: + async with self._lock: + if datetime.now() >= self._token_expires_at: + await self._request_token() + return self._access_token + + async def get_approval_key(self) -> str: + async with self._lock: + if datetime.now() >= self._approval_expires_at: + await self._request_approval_key() + return self._approval_key + + async def generate_hashkey(self, data: dict) -> str: + await self._rate_limiter.acquire() + url = f"{get_base_url()}{_HASHKEY_URL}" + headers = { + "content-type": "application/json", + "appkey": settings.kis.app_key, + "appsecret": settings.kis.app_secret, + } + resp = await self._client.post(url, json=data, headers=headers) + resp.raise_for_status() + return resp.json()["HASH"] + + def get_auth_headers(self, tr_id: str) -> dict[str, str]: + return { + "Content-Type": "application/json; charset=utf-8", + "authorization": f"Bearer {self._access_token}", + "appKey": settings.kis.app_key, + "appSecret": settings.kis.app_secret, + "tr_id": tr_id, + "custtype": "P", + } + + async def close(self) -> None: + await self._client.aclose() + + +token_manager = TokenManager() diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..8a40dba --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import yaml +from pydantic import Field +from pydantic_settings import BaseSettings + + +class KISConfig(BaseSettings): + app_key: str = Field(default="", alias="KIS_APP_KEY") + app_secret: str = Field(default="", alias="KIS_APP_SECRET") + account_no: str = Field(default="", alias="KIS_ACCOUNT_NO") + account_code: str = Field(default="01", alias="KIS_ACCOUNT_CODE") + hts_id: str = Field(default="", alias="KIS_HTS_ID") + server_mode: str = Field(default="vps", alias="KIS_SERVER_MODE") + + model_config = {"env_file": ".env", "extra": "ignore"} + + +class AppConfig(BaseSettings): + host: str = Field(default="0.0.0.0", alias="APP_HOST") + port: int = Field(default=8000, alias="APP_PORT") + debug: bool = Field(default=False, alias="APP_DEBUG") + db_path: str = Field(default="./data/stock.db", alias="DB_PATH") + log_level: str = Field(default="INFO", alias="LOG_LEVEL") + + model_config = {"env_file": ".env", "extra": "ignore"} + + +class CollectorConfig: + interval_seconds: int = 5 + market_open_hour: int = 9 + market_close_hour: int = 15 + market_close_minute: int = 30 + + +class StrategyConfig: + check_interval_seconds: int = 3 + max_daily_trades: int = 50 + default_order_type: str = "00" + + +class TradingConfig: + max_order_amount: int = 10_000_000 + min_order_amount: int = 100_000 + slippage_percent: float = 0.1 + + +class RateLimitConfig: + requests_per_second: float = 1.0 + retry_delay: float = 1.5 + + +class Settings: + def __init__(self) -> None: + self.kis = KISConfig() + self.app = AppConfig() + self.collector = CollectorConfig() + self.strategy = StrategyConfig() + self.trading = TradingConfig() + self.rate_limit = RateLimitConfig() + self._load_yaml() + + def _load_yaml(self) -> None: + yaml_path = Path("config.yaml") + if not yaml_path.exists(): + return + + with open(yaml_path, encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) or {} + + kis = data.get("kis", {}) + if "server_mode" in kis: + self.kis.server_mode = kis["server_mode"] + rl = kis.get("rate_limit", {}) + if "requests_per_second" in rl: + self.rate_limit.requests_per_second = rl["requests_per_second"] + if "retry_delay" in rl: + self.rate_limit.retry_delay = rl["retry_delay"] + + collector = data.get("collector", {}) + for key, val in collector.items(): + if hasattr(self.collector, key): + setattr(self.collector, key, val) + + strategy = data.get("strategies", {}) + for key, val in strategy.items(): + if hasattr(self.strategy, key): + setattr(self.strategy, key, val) + + trading = data.get("trading", {}) + for key, val in trading.items(): + if hasattr(self.trading, key): + setattr(self.trading, key, val) + + logging_cfg = data.get("logging", {}) + if "level" in logging_cfg: + self.app.log_level = logging_cfg["level"] + + +settings = Settings() + + +def get_base_url() -> str: + if settings.kis.server_mode == "real": + return "https://openapi.koreainvestment.com:9443" + return "https://openapivts.koreainvestment.com:29443" + + +def get_ws_url() -> str: + if settings.kis.server_mode == "real": + return "ws://ops.koreainvestment.com:21000" + return "ws://ops.koreainvestment.com:31000" diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..55103b8 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path + +from sqlalchemy import create_engine, event +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from app.core.config import settings + + +class Base(DeclarativeBase): + pass + + +engine = create_engine( + f"sqlite:///{settings.app.db_path}", + echo=False, + connect_args={"check_same_thread": False}, +) + + +@event.listens_for(engine, "connect") +def _set_sqlite_pragma(dbapi_connection, connection_record) -> None: # noqa: ANN001 + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def init_db() -> None: + Path(settings.app.db_path).parent.mkdir(parents=True, exist_ok=True) + Base.metadata.create_all(bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/core/logger.py b/app/core/logger.py new file mode 100644 index 0000000..4c6cdde --- /dev/null +++ b/app/core/logger.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" + + +def setup_logger(name: str, level: str = "INFO", log_file: str | None = None) -> logging.Logger: + logger = logging.getLogger(name) + logger.setLevel(getattr(logging, level.upper(), logging.INFO)) + + if not logger.handlers: + formatter = logging.Formatter(LOG_FORMAT, datefmt="%Y-%m-%d %H:%M:%S") + + console = logging.StreamHandler() + console.setFormatter(formatter) + logger.addHandler(console) + + if log_file: + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger diff --git a/app/core/rate_limiter.py b/app/core/rate_limiter.py new file mode 100644 index 0000000..719a74f --- /dev/null +++ b/app/core/rate_limiter.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import asyncio +import time +from collections import deque + + +class RateLimiter: + def __init__(self, requests_per_second: float = 1.0) -> None: + self._min_interval = 1.0 / requests_per_second + self._timestamps: deque[float] = deque() + self._lock = asyncio.Lock() + + async def acquire(self) -> None: + async with self._lock: + now = time.monotonic() + while self._timestamps and self._timestamps[0] <= now - self._min_interval: + self._timestamps.popleft() + + if self._timestamps: + wait_time = self._timestamps[0] + self._min_interval - now + if wait_time > 0: + await asyncio.sleep(wait_time) + + self._timestamps.append(time.monotonic()) diff --git a/app/engine/__init__.py b/app/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/engine/collector.py b/app/engine/collector.py new file mode 100644 index 0000000..20dc84a --- /dev/null +++ b/app/engine/collector.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from datetime import datetime, time + +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.logger import setup_logger +from app.models.stock import PriceHistory +from app.services.market_data import market_data_service + +logger = setup_logger("collector") + + +class PriceCollector: + def __init__(self) -> None: + self._running = False + + def is_market_open(self) -> bool: + now = datetime.now() + if now.weekday() >= 5: + return False + + market_open = time(settings.collector.market_open_hour, 0) + market_close = time(settings.collector.market_close_hour, settings.collector.market_close_minute) + return market_open <= now.time() <= market_close + + async def collect_price(self, stock_code: str, db: Session) -> bool: + if not self.is_market_open(): + return False + + try: + price_data = await market_data_service.get_current_price(stock_code) + if not price_data: + return False + + now = datetime.now() + record = PriceHistory( + stock_code=stock_code, + datetime=now, + open=price_data.get("open_price", 0), + high=price_data.get("high_price", 0), + low=price_data.get("low_price", 0), + close=price_data.get("current_price", 0), + volume=price_data.get("volume", 0), + ) + db.add(record) + db.commit() + + logger.debug( + "가격 수집: %s = %d원 (%+.2f%%)", + stock_code, + price_data.get("current_price", 0), + price_data.get("change_rate", 0), + ) + return True + + except Exception as e: + logger.error("가격 수집 오류 (%s): %s", stock_code, e) + db.rollback() + return False + + async def collect_all(self, db: Session) -> int: + from app.models.stock import Stock + + stocks = db.query(Stock).filter(Stock.is_active == True).all() + count = 0 + for stock in stocks: + if await self.collect_price(stock.code, db): + count += 1 + return count + + def get_latest_price(self, stock_code: str, db: Session) -> dict | None: + record = ( + db.query(PriceHistory) + .filter(PriceHistory.stock_code == stock_code) + .order_by(PriceHistory.datetime.desc()) + .first() + ) + if not record: + return None + return { + "stock_code": record.stock_code, + "current_price": record.close, + "open": record.open, + "high": record.high, + "low": record.low, + "volume": record.volume, + "datetime": record.datetime.isoformat(), + } + + +price_collector = PriceCollector() diff --git a/app/engine/scheduler.py b/app/engine/scheduler.py new file mode 100644 index 0000000..1bd3363 --- /dev/null +++ b/app/engine/scheduler.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger + +from app.core.config import settings +from app.core.database import SessionLocal +from app.core.logger import setup_logger +from app.engine.collector import price_collector +from app.engine.strategy_engine import strategy_engine + +logger = setup_logger("scheduler") + +scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + + +async def collect_job() -> None: + db = SessionLocal() + try: + count = await price_collector.collect_all(db) + if count > 0: + logger.debug("가격 수집 완료: %d개 종목", count) + finally: + db.close() + + +async def strategy_job() -> None: + db = SessionLocal() + try: + signals = await strategy_engine.evaluate_all(db) + if signals: + await strategy_engine.execute_signals(signals, db) + finally: + db.close() + + +async def reset_daily_count_job() -> None: + strategy_engine.reset_daily_count() + logger.info("일일 매매 카운트 초기화") + + +def start_scheduler() -> None: + scheduler.add_job( + collect_job, + trigger=IntervalTrigger(seconds=settings.collector.interval_seconds), + id="price_collector", + name="주가 수집", + replace_existing=True, + ) + + scheduler.add_job( + strategy_job, + trigger=IntervalTrigger(seconds=settings.strategy.check_interval_seconds), + id="strategy_engine", + name="전략 실행", + replace_existing=True, + ) + + scheduler.add_job( + reset_daily_count_job, + trigger="cron", + hour=0, + minute=0, + id="daily_reset", + name="일일 카운트 초기화", + replace_existing=True, + ) + + scheduler.start() + logger.info( + "스케줄러 시작 - 수집: %d초 간격, 전략: %d초 간격", + settings.collector.interval_seconds, + settings.strategy.check_interval_seconds, + ) + + +def stop_scheduler() -> None: + if scheduler.running: + scheduler.shutdown(wait=False) + logger.info("스케줄러 종료") diff --git a/app/engine/strategies/__init__.py b/app/engine/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/engine/strategies/base.py b/app/engine/strategies/base.py new file mode 100644 index 0000000..906886e --- /dev/null +++ b/app/engine/strategies/base.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import pandas as pd + +from app.core.logger import setup_logger + +logger = setup_logger("strategy") + + +@dataclass +class Signal: + action: str # buy / sell / hold + stock_code: str + qty: int = 0 + price: int = 0 + reason: str = "" + confidence: float = 0.0 + strategy_id: int | None = None + + +class BaseStrategy(ABC): + def __init__(self, stock_code: str, params: dict, strategy_id: int = 0) -> None: + self.stock_code = stock_code + self.params = params + self.strategy_id = strategy_id + self.name = self.__class__.__name__ + + @abstractmethod + def evaluate(self, current_price: dict, price_history: list[dict], holding_qty: int) -> Signal: + ... + + @staticmethod + def _to_dataframe(prices: list[dict]) -> pd.DataFrame: + if not prices: + return pd.DataFrame() + df = pd.DataFrame(prices) + for col in ["open", "high", "low", "close", "volume"]: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors="coerce") + return df diff --git a/app/engine/strategies/conditional.py b/app/engine/strategies/conditional.py new file mode 100644 index 0000000..bf1440b --- /dev/null +++ b/app/engine/strategies/conditional.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from app.engine.strategies.base import BaseStrategy, Signal + + +class ConditionalStrategy(BaseStrategy): + """조건부 지정가/시장가 전략""" + + def evaluate(self, current_price: dict, price_history: list[dict], holding_qty: int) -> Signal: + price = current_price.get("current_price", 0) + if price <= 0: + return Signal(action="hold", stock_code=self.stock_code) + + buy_price = self.params.get("buy_price", 0) + sell_price = self.params.get("sell_price", 0) + change_rate_limit = self.params.get("change_rate_limit", 0) + qty = self.params.get("qty", 1) + + if change_rate_limit: + change_rate = current_price.get("change_rate", 0) + if change_rate <= -change_rate_limit and price > 0: + return Signal( + action="buy", + stock_code=self.stock_code, + qty=qty, + price=price, + reason=f"하락률 조건 충족: {change_rate:.2f}% <= -{change_rate_limit}%", + confidence=min(abs(change_rate) / change_rate_limit, 1.0), + ) + if change_rate >= change_rate_limit and holding_qty > 0: + return Signal( + action="sell", + stock_code=self.stock_code, + qty=min(qty, holding_qty), + price=price, + reason=f"상승률 조건 충족: {change_rate:.2f}% >= {change_rate_limit}%", + confidence=min(abs(change_rate) / change_rate_limit, 1.0), + ) + + if buy_price and price <= buy_price and holding_qty == 0: + return Signal( + action="buy", + stock_code=self.stock_code, + qty=qty, + price=price, + reason=f"매수 조건 충족: 현재가 {price} <= 목표가 {buy_price}", + confidence=1.0, + ) + + if sell_price and price >= sell_price and holding_qty > 0: + return Signal( + action="sell", + stock_code=self.stock_code, + qty=min(qty, holding_qty), + price=price, + reason=f"매도 조건 충족: 현재가 {price} >= 목표가 {sell_price}", + confidence=1.0, + ) + + return Signal(action="hold", stock_code=self.stock_code) diff --git a/app/engine/strategies/periodic.py b/app/engine/strategies/periodic.py new file mode 100644 index 0000000..6f2344c --- /dev/null +++ b/app/engine/strategies/periodic.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from datetime import datetime + +from app.engine.strategies.base import BaseStrategy, Signal + + +class PeriodicStrategy(BaseStrategy): + """정액(DCA) / 정률 투자 전략""" + + def evaluate(self, current_price: dict, price_history: list[dict], holding_qty: int) -> Signal: + price = current_price.get("current_price", 0) + if price <= 0: + return Signal(action="hold", stock_code=self.stock_code) + + invest_type = self.params.get("invest_type", "fixed_amount") + amount = self.params.get("amount", 100000) + ratio = self.params.get("ratio", 0.0) + invest_days = self.params.get("invest_days", [0, 1, 2, 3, 4]) + invest_hour = self.params.get("invest_hour", 10) + invest_minute = self.params.get("invest_minute", 0) + min_price_drop = self.params.get("min_price_drop_percent", 0) + max_price = self.params.get("max_price", 0) + + now = datetime.now() + + if now.weekday() not in invest_days: + return Signal(action="hold", stock_code=self.stock_code) + + if now.hour != invest_hour or now.minute != invest_minute: + return Signal(action="hold", stock_code=self.stock_code) + + if max_price and price > max_price: + return Signal( + action="hold", + stock_code=self.stock_code, + reason=f"가격 상한 초과: {price} > {max_price}", + ) + + if min_price_drop and len(price_history) >= 2: + prev_close = price_history[-2].get("close", price) + if prev_close > 0: + drop_pct = (prev_close - price) / prev_close * 100 + if drop_pct < min_price_drop: + return Signal( + action="hold", + stock_code=self.stock_code, + reason=f"가격 하락 미충족: {drop_pct:.2f}% < {min_price_drop}%", + ) + + if invest_type == "fixed_amount": + qty = max(1, amount // price) + elif invest_type == "fixed_ratio": + total_invest = self.params.get("total_capital", 100_000_000) + invest_amount = int(total_invest * ratio) + qty = max(1, invest_amount // price) + else: + qty = 1 + + return Signal( + action="buy", + stock_code=self.stock_code, + qty=qty, + price=price, + reason=f"정기투자: {invest_type}, {qty}주", + confidence=1.0, + ) diff --git a/app/engine/strategies/technical.py b/app/engine/strategies/technical.py new file mode 100644 index 0000000..b480e5b --- /dev/null +++ b/app/engine/strategies/technical.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import ta +import pandas as pd + +from app.engine.strategies.base import BaseStrategy, Signal + + +class TechnicalStrategy(BaseStrategy): + """기술적 분석 기반 전략 (MACD, RSI, 볼린저밴드)""" + + def evaluate(self, current_price: dict, price_history: list[dict], holding_qty: int) -> Signal: + df = self._to_dataframe(price_history) + if len(df) < 30: + return Signal(action="hold", stock_code=self.stock_code) + + price = current_price.get("current_price", 0) + indicators = self.params.get("indicators", ["rsi"]) + qty = self.params.get("qty", 1) + + buy_signals = [] + sell_signals = [] + + if "rsi" in indicators: + rsi_signal = self._evaluate_rsi(df, price, holding_qty, qty) + if rsi_signal.action == "buy": + buy_signals.append(rsi_signal) + elif rsi_signal.action == "sell": + sell_signals.append(rsi_signal) + + if "macd" in indicators: + macd_signal = self._evaluate_macd(df, price, holding_qty, qty) + if macd_signal.action == "buy": + buy_signals.append(macd_signal) + elif macd_signal.action == "sell": + sell_signals.append(macd_signal) + + if "bollinger" in indicators: + bb_signal = self._evaluate_bollinger(df, price, holding_qty, qty) + if bb_signal.action == "buy": + buy_signals.append(bb_signal) + elif bb_signal.action == "sell": + sell_signals.append(bb_signal) + + if buy_signals: + best = max(buy_signals, key=lambda s: s.confidence) + return best + if sell_signals: + best = max(sell_signals, key=lambda s: s.confidence) + return best + + return Signal(action="hold", stock_code=self.stock_code) + + def _evaluate_rsi( + self, df: pd.DataFrame, price: int, holding_qty: int, qty: int + ) -> Signal: + period = self.params.get("rsi_period", 14) + oversold = self.params.get("rsi_oversold", 30) + overbought = self.params.get("rsi_overbought", 70) + + rsi = ta.momentum.RSIIndicator(df["close"], window=period).rsi() + current_rsi = rsi.iloc[-1] if not rsi.empty else 50 + + if current_rsi <= oversold and holding_qty == 0: + return Signal( + action="buy", + stock_code=self.stock_code, + qty=qty, + price=price, + reason=f"RSI 과매도: {current_rsi:.1f} <= {oversold}", + confidence=(oversold - current_rsi) / oversold if oversold > 0 else 0, + ) + + if current_rsi >= overbought and holding_qty > 0: + return Signal( + action="sell", + stock_code=self.stock_code, + qty=min(qty, holding_qty), + price=price, + reason=f"RSI 과매수: {current_rsi:.1f} >= {overbought}", + confidence=(current_rsi - overbought) / (100 - overbought) if overbought < 100 else 0, + ) + + return Signal(action="hold", stock_code=self.stock_code) + + def _evaluate_macd( + self, df: pd.DataFrame, price: int, holding_qty: int, qty: int + ) -> Signal: + fast = self.params.get("macd_fast", 12) + slow = self.params.get("macd_slow", 26) + signal_period = self.params.get("macd_signal", 9) + + macd_ind = ta.trend.MACD(df["close"], window_fast=fast, window_slow=slow, window_sign=signal_period) + macd_line = macd_ind.macd() + signal_line = macd_ind.macd_signal() + + if len(macd_line) < 2 or len(signal_line) < 2: + return Signal(action="hold", stock_code=self.stock_code) + + prev_macd = macd_line.iloc[-2] + prev_signal = signal_line.iloc[-2] + curr_macd = macd_line.iloc[-1] + curr_signal = signal_line.iloc[-1] + + if prev_macd <= prev_signal and curr_macd > curr_signal and holding_qty == 0: + return Signal( + action="buy", + stock_code=self.stock_code, + qty=qty, + price=price, + reason=f"MACD 골든크로스: MACD({curr_macd:.2f}) > Signal({curr_signal:.2f})", + confidence=0.8, + ) + + if prev_macd >= prev_signal and curr_macd < curr_signal and holding_qty > 0: + return Signal( + action="sell", + stock_code=self.stock_code, + qty=min(qty, holding_qty), + price=price, + reason=f"MACD 데드크로스: MACD({curr_macd:.2f}) < Signal({curr_signal:.2f})", + confidence=0.8, + ) + + return Signal(action="hold", stock_code=self.stock_code) + + def _evaluate_bollinger( + self, df: pd.DataFrame, price: int, holding_qty: int, qty: int + ) -> Signal: + period = self.params.get("bb_period", 20) + std_dev = self.params.get("bb_std", 2.0) + + bb = ta.volatility.BollingerBands(df["close"], window=period, window_dev=std_dev) + upper = bb.bollinger_hband().iloc[-1] + lower = bb.bollinger_lband().iloc[-1] + mid = bb.bollinger_mavg().iloc[-1] + + if price <= lower and holding_qty == 0: + return Signal( + action="buy", + stock_code=self.stock_code, + qty=qty, + price=price, + reason=f"볼린저밴드 하단 돌파: 가격({price}) <= 하단({lower:.0f})", + confidence=0.7, + ) + + if price >= upper and holding_qty > 0: + return Signal( + action="sell", + stock_code=self.stock_code, + qty=min(qty, holding_qty), + price=price, + reason=f"볼린저밴드 상단 돌파: 가격({price}) >= 상단({upper:.0f})", + confidence=0.7, + ) + + return Signal(action="hold", stock_code=self.stock_code) diff --git a/app/engine/strategy_engine.py b/app/engine/strategy_engine.py new file mode 100644 index 0000000..8c3d731 --- /dev/null +++ b/app/engine/strategy_engine.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import json + +from sqlalchemy.orm import Session + +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 + +logger = setup_logger("strategy_engine") + +STRATEGY_MAP = { + "conditional": ConditionalStrategy, + "technical": TechnicalStrategy, + "periodic": PeriodicStrategy, +} + + +class StrategyEngine: + def __init__(self) -> None: + self._daily_trade_count = 0 + + def _create_strategy(self, strategy_record: Strategy) -> BaseStrategy | None: + cls = STRATEGY_MAP.get(strategy_record.strategy_type) + if not cls: + logger.warning("알 수 없는 전략 타입: %s", strategy_record.strategy_type) + return None + + params = json.loads(strategy_record.params_json) if strategy_record.params_json else {} + return cls( + stock_code=strategy_record.stock_code, + params=params, + strategy_id=strategy_record.id, + ) + + async def evaluate_all(self, db: Session) -> list[Signal]: + strategies = db.query(Strategy).filter(Strategy.is_active == True).all() + signals: list[Signal] = [] + + for strat_record in strategies: + try: + strategy = self._create_strategy(strat_record) + if not strategy: + continue + + current_price = await market_data_service.get_current_price(strat_record.stock_code) + if not current_price: + continue + + price_history = self._get_price_history(db, strat_record.stock_code) + holding = self._get_holding_qty(db, strat_record.stock_code) + + signal = strategy.evaluate(current_price, price_history, holding) + if signal.action != "hold": + signal.qty = max(signal.qty, strat_record.qty) + signal.strategy_id = strat_record.id + signals.append(signal) + logger.info( + "신호 발생: %s %s %s주 - %s", + signal.stock_code, + signal.action, + signal.qty, + signal.reason, + ) + except Exception as e: + logger.error("전략 평가 오류 (ID=%s): %s", strat_record.id, e) + + return signals + + async def execute_signals(self, signals: list[Signal], db: Session) -> list[Trade]: + if self._daily_trade_count >= settings.strategy.max_daily_trades: + logger.warning("일일 최대 매매 횟수 초과 (%d)", settings.strategy.max_daily_trades) + return [] + + trades: list[Trade] = [] + for signal in signals: + try: + order_result = await trading_service.place_order( + stock_code=signal.stock_code, + side=signal.action, + qty=signal.qty, + price=signal.price, + order_type=settings.strategy.default_order_type, + ) + + stock_name = "" + price_data = await market_data_service.get_current_price(signal.stock_code) + if price_data: + stock_name = price_data.get("stock_name", "") + + trade = Trade( + order_no=order_result.get("order_no", ""), + stock_code=signal.stock_code, + stock_name=stock_name, + side=signal.action, + qty=signal.qty, + price=signal.price, + order_type=settings.strategy.default_order_type, + status="filled" if order_result.get("rt_cd") == "0" else "rejected", + strategy_id=signal.strategy_id, + ) + db.add(trade) + db.commit() + self._daily_trade_count += 1 + trades.append(trade) + + except Exception as e: + logger.error("주문 실행 오류: %s", e) + + return trades + + def _get_price_history(self, db: Session, stock_code: str, limit: int = 60) -> list[dict]: + records = ( + db.query(PriceHistory) + .filter(PriceHistory.stock_code == stock_code) + .order_by(PriceHistory.datetime.desc()) + .limit(limit) + .all() + ) + return [ + { + "date": r.datetime.strftime("%Y%m%d"), + "open": r.open, + "high": r.high, + "low": r.low, + "close": r.close, + "volume": r.volume, + } + 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 + + +strategy_engine = StrategyEngine() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..6ea1199 --- /dev/null +++ b/app/main.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from app.core.config import settings +from app.core.database import init_db +from app.core.logger import setup_logger +from app.core.auth import token_manager +from app.engine.scheduler import start_scheduler, stop_scheduler +from app.services.market_data import market_data_service +from app.services.trading import trading_service +from app.services.account import account_service +from app.services.realtime import realtime_service + +from app.routers import stocks, trading, strategies, websocket, dashboard + +logger = setup_logger("main", level=settings.app.log_level) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("=== Stock Automation 시작 ===") + logger.info("서버 모드: %s", settings.kis.server_mode) + + init_db() + logger.info("데이터베이스 초기화 완료") + + try: + await token_manager.get_access_token() + logger.info("KIS 인증 완료") + except Exception as e: + logger.warning("KIS 인증 실패 (API 키를 확인하세요): %s", e) + + start_scheduler() + + yield + + logger.info("=== Stock Automation 종료 ===") + stop_scheduler() + await realtime_service.disconnect() + await market_data_service.close() + await trading_service.close() + await account_service.close() + await token_manager.close() + + +app = FastAPI( + title="Stock Automation", + description="한국투자증권 API 기반 자동매매 시스템", + version="0.1.0", + lifespan=lifespan, +) + +app.include_router(stocks.router) +app.include_router(trading.router) +app.include_router(strategies.router) +app.include_router(websocket.router) +app.include_router(dashboard.router) + +templates = Jinja2Templates(directory="app/templates") + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + + +@app.get("/health") +async def health(): + return {"status": "ok", "mode": settings.kis.server_mode} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host=settings.app.host, + port=settings.app.port, + reload=settings.app.debug, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/stock.py b/app/models/stock.py new file mode 100644 index 0000000..c1a3b33 --- /dev/null +++ b/app/models/stock.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Float, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class Stock(Base): + __tablename__ = "stocks" + + code: Mapped[str] = mapped_column(String(10), primary_key=True) + name: Mapped[str] = mapped_column(String(50), nullable=False) + market: Mapped[str] = mapped_column(String(10), nullable=False, default="KRX") + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + +class PriceHistory(Base): + __tablename__ = "price_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + stock_code: Mapped[str] = mapped_column(String(10), nullable=False, index=True) + datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) + open: Mapped[float] = mapped_column(Float, nullable=False) + high: Mapped[float] = mapped_column(Float, nullable=False) + low: Mapped[float] = mapped_column(Float, nullable=False) + close: Mapped[float] = mapped_column(Float, nullable=False) + volume: Mapped[int] = mapped_column(Integer, default=0) + + +class Trade(Base): + __tablename__ = "trades" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + order_no: Mapped[str] = mapped_column(String(20), default="") + stock_code: Mapped[str] = mapped_column(String(10), nullable=False, index=True) + stock_name: Mapped[str] = mapped_column(String(50), default="") + side: Mapped[str] = mapped_column(String(4), nullable=False) # buy / sell + qty: Mapped[int] = mapped_column(Integer, nullable=False) + price: Mapped[float] = mapped_column(Float, nullable=False) + order_type: Mapped[str] = mapped_column(String(4), default="00") # 지정가 + status: Mapped[str] = mapped_column(String(10), default="pending") # pending, filled, cancelled, rejected + strategy_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + filled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + +class Strategy(Base): + __tablename__ = "strategies" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + strategy_type: Mapped[str] = mapped_column(String(20), nullable=False) + stock_code: Mapped[str] = mapped_column(String(10), nullable=False, index=True) + params_json: Mapped[str] = mapped_column(String(500), default="{}") + order_type: Mapped[str] = mapped_column(String(4), default="00") + qty: Mapped[int] = mapped_column(Integer, default=1) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class Holding(Base): + __tablename__ = "holdings" + + stock_code: Mapped[str] = mapped_column(String(10), primary_key=True) + stock_name: Mapped[str] = mapped_column(String(50), default="") + qty: Mapped[int] = mapped_column(Integer, default=0) + avg_price: Mapped[float] = mapped_column(Float, default=0.0) + current_price: Mapped[float] = mapped_column(Float, default=0.0) + profit: Mapped[float] = mapped_column(Float, default=0.0) + profit_rate: Mapped[float] = mapped_column(Float, default=0.0) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py new file mode 100644 index 0000000..e1e0258 --- /dev/null +++ b/app/routers/dashboard.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.models.stock import Holding, PriceHistory, Strategy, Trade, Stock +from app.engine.scheduler import scheduler + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + + +@router.get("/") +def dashboard(db: Session = Depends(get_db)) -> dict: + holdings = db.query(Holding).all() + recent_trades = db.query(Trade).order_by(Trade.created_at.desc()).limit(20).all() + active_strategies = db.query(Strategy).filter(Strategy.is_active == True).all() + active_stocks = db.query(Stock).filter(Stock.is_active == True).all() + + total_profit = sum(h.profit for h in holdings) + total_invested = sum(h.avg_price * h.qty for h in holdings if h.avg_price > 0) + total_evaluated = sum(h.current_price * h.qty for h in holdings if h.current_price > 0) + + jobs = [] + if scheduler.running: + for job in scheduler.get_jobs(): + jobs.append({ + "id": job.id, + "name": job.name, + "next_run": str(job.next_run_time) if job.next_run_time else None, + }) + + return { + "summary": { + "total_holdings": len(holdings), + "total_profit": total_profit, + "total_invested": total_invested, + "total_evaluated": total_evaluated, + "profit_rate": (total_profit / total_invested * 100) if total_invested > 0 else 0, + "active_strategies": len(active_strategies), + "active_stocks": len(active_stocks), + }, + "holdings": [ + { + "stock_code": h.stock_code, + "stock_name": h.stock_name, + "qty": h.qty, + "avg_price": h.avg_price, + "current_price": h.current_price, + "profit": h.profit, + "profit_rate": h.profit_rate, + } + for h in holdings + ], + "recent_trades": [ + { + "id": t.id, + "stock_code": t.stock_code, + "side": t.side, + "qty": t.qty, + "price": t.price, + "status": t.status, + "created_at": t.created_at.isoformat() if t.created_at else None, + } + for t in recent_trades + ], + "scheduler_jobs": jobs, + } diff --git a/app/routers/stocks.py b/app/routers/stocks.py new file mode 100644 index 0000000..74193a8 --- /dev/null +++ b/app/routers/stocks.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.models.stock import Stock +from app.services.market_data import market_data_service + +router = APIRouter(prefix="/api/stocks", tags=["stocks"]) + + +class StockCreate(BaseModel): + code: str + name: str + market: str = "KRX" + + +class StockUpdate(BaseModel): + name: str | None = None + is_active: bool | None = None + + +@router.get("/") +def list_stocks(db: Session = Depends(get_db)) -> list[dict]: + stocks = db.query(Stock).all() + return [ + { + "code": s.code, + "name": s.name, + "market": s.market, + "is_active": s.is_active, + "created_at": s.created_at.isoformat() if s.created_at else None, + } + for s in stocks + ] + + +@router.post("/") +def add_stock(data: StockCreate, db: Session = Depends(get_db)) -> dict: + existing = db.query(Stock).filter(Stock.code == data.code).first() + if existing: + raise HTTPException(status_code=409, detail="이미 존재하는 종목입니다") + + stock = Stock(code=data.code, name=data.name, market=data.market) + db.add(stock) + db.commit() + db.refresh(stock) + return {"code": stock.code, "name": stock.name, "market": stock.market, "is_active": stock.is_active} + + +@router.delete("/{stock_code}") +def remove_stock(stock_code: str, db: Session = Depends(get_db)) -> dict: + stock = db.query(Stock).filter(Stock.code == stock_code).first() + if not stock: + raise HTTPException(status_code=404, detail="종목을 찾을 수 없습니다") + + stock.is_active = False + db.commit() + return {"message": "종목이 비활성화되었습니다", "code": stock_code} + + +@router.get("/{stock_code}/price") +async def get_price(stock_code: str) -> dict: + price = await market_data_service.get_current_price(stock_code) + if not price: + raise HTTPException(status_code=404, detail="시세 조회 실패") + return price + + +@router.get("/{stock_code}/chart") +async def get_chart(stock_code: str, days: int = 30) -> list[dict]: + from datetime import datetime, timedelta + + end = datetime.now().strftime("%Y%m%d") + start = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d") + chart = await market_data_service.get_daily_chart(stock_code, start, end, days) + return chart diff --git a/app/routers/strategies.py b/app/routers/strategies.py new file mode 100644 index 0000000..071bfac --- /dev/null +++ b/app/routers/strategies.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.models.stock import Strategy + +router = APIRouter(prefix="/api/strategies", tags=["strategies"]) + + +class StrategyCreate(BaseModel): + name: str + strategy_type: str # conditional / technical / periodic + stock_code: str + params: dict = {} + order_type: str = "00" + qty: int = 1 + + +class StrategyUpdate(BaseModel): + name: str | None = None + params: dict | None = None + order_type: str | None = None + qty: int | None = None + is_active: bool | None = None + + +@router.get("/") +def list_strategies(db: Session = Depends(get_db)) -> list[dict]: + strategies = db.query(Strategy).all() + return [ + { + "id": s.id, + "name": s.name, + "strategy_type": s.strategy_type, + "stock_code": s.stock_code, + "params": json.loads(s.params_json) if s.params_json else {}, + "order_type": s.order_type, + "qty": s.qty, + "is_active": s.is_active, + "created_at": s.created_at.isoformat() if s.created_at else None, + } + for s in strategies + ] + + +@router.post("/") +def create_strategy(data: StrategyCreate, db: Session = Depends(get_db)) -> dict: + strategy = Strategy( + name=data.name, + strategy_type=data.strategy_type, + stock_code=data.stock_code, + params_json=json.dumps(data.params, ensure_ascii=False), + order_type=data.order_type, + qty=data.qty, + ) + db.add(strategy) + db.commit() + db.refresh(strategy) + return { + "id": strategy.id, + "name": strategy.name, + "strategy_type": strategy.strategy_type, + "stock_code": strategy.stock_code, + "params": data.params, + "is_active": strategy.is_active, + } + + +@router.put("/{strategy_id}") +def update_strategy(strategy_id: int, data: StrategyUpdate, db: Session = Depends(get_db)) -> dict: + strategy = db.query(Strategy).filter(Strategy.id == strategy_id).first() + if not strategy: + raise HTTPException(status_code=404, detail="전략을 찾을 수 없습니다") + + if data.name is not None: + strategy.name = data.name + if data.params is not None: + strategy.params_json = json.dumps(data.params, ensure_ascii=False) + if data.order_type is not None: + strategy.order_type = data.order_type + if data.qty is not None: + strategy.qty = data.qty + if data.is_active is not None: + strategy.is_active = data.is_active + + db.commit() + return {"message": "전략이 업데이트되었습니다", "id": strategy_id} + + +@router.delete("/{strategy_id}") +def delete_strategy(strategy_id: int, db: Session = Depends(get_db)) -> dict: + strategy = db.query(Strategy).filter(Strategy.id == strategy_id).first() + if not strategy: + raise HTTPException(status_code=404, detail="전략을 찾을 수 없습니다") + + db.delete(strategy) + db.commit() + return {"message": "전략이 삭제되었습니다", "id": strategy_id} diff --git a/app/routers/trading.py b/app/routers/trading.py new file mode 100644 index 0000000..b7fdf99 --- /dev/null +++ b/app/routers/trading.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.models.stock import Trade +from app.services.trading import trading_service + +router = APIRouter(prefix="/api/trading", tags=["trading"]) + + +class OrderRequest(BaseModel): + stock_code: str + side: str # buy / sell + qty: int + price: int = 0 + order_type: str = "00" + + +class CancelRequest(BaseModel): + order_no: str + stock_code: str + qty: int + + +@router.post("/order") +async def place_order(data: OrderRequest, db: Session = Depends(get_db)) -> dict: + result = await trading_service.place_order( + stock_code=data.stock_code, + side=data.side, + qty=data.qty, + price=data.price, + order_type=data.order_type, + ) + + trade = Trade( + order_no=result.get("order_no", ""), + stock_code=data.stock_code, + stock_name="", + side=data.side, + qty=data.qty, + price=data.price, + order_type=data.order_type, + status="filled" if result.get("rt_cd") == "0" else "rejected", + ) + db.add(trade) + db.commit() + + return result + + +@router.post("/cancel") +async def cancel_order(data: CancelRequest) -> dict: + result = await trading_service.cancel_order( + order_no=data.order_no, + stock_code=data.stock_code, + qty=data.qty, + ) + return result + + +@router.get("/history") +def get_trade_history( + stock_code: str | None = None, + limit: int = 50, + db: Session = Depends(get_db), +) -> list[dict]: + 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 + ] + + +@router.get("/account") +async def get_account() -> dict: + from app.services.account import account_service + return await account_service.get_balance() diff --git a/app/routers/websocket.py b/app/routers/websocket.py new file mode 100644 index 0000000..0293302 --- /dev/null +++ b/app/routers/websocket.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import asyncio +import json + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.core.logger import setup_logger +from app.services.realtime import realtime_service + +router = APIRouter(tags=["websocket"]) +logger = setup_logger("ws_router") + +_connected_clients: set[WebSocket] = set() + + +async def _broadcast(data: dict) -> None: + message = json.dumps(data, ensure_ascii=False) + disconnected: list[WebSocket] = [] + for client in _connected_clients: + try: + await client.send_text(message) + except Exception: + disconnected.append(client) + for client in disconnected: + _connected_clients.discard(client) + + +@router.websocket("/ws/realtime") +async def websocket_endpoint(websocket: WebSocket) -> None: + await websocket.accept() + _connected_clients.add(websocket) + logger.info("WebSocket 클라이언트 연결 (총 %d)", len(_connected_clients)) + + realtime_service.on("price", _broadcast) + realtime_service.on("orderbook", _broadcast) + + try: + while True: + raw = await websocket.receive_text() + try: + msg = json.loads(raw) + action = msg.get("action", "") + + if action == "subscribe": + stock_code = msg.get("stock_code", "") + data_type = msg.get("data_type", "price") + await realtime_service.subscribe(stock_code, data_type) + await websocket.send_text( + json.dumps({"status": "subscribed", "stock_code": stock_code, "data_type": data_type}) + ) + + elif action == "unsubscribe": + stock_code = msg.get("stock_code", "") + data_type = msg.get("data_type", "price") + await realtime_service.unsubscribe(stock_code, data_type) + await websocket.send_text( + json.dumps({"status": "unsubscribed", "stock_code": stock_code}) + ) + + except json.JSONDecodeError: + pass + + except WebSocketDisconnect: + pass + finally: + _connected_clients.discard(websocket) + logger.info("WebSocket 클라이언트 해제 (총 %d)", len(_connected_clients)) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/account.py b/app/services/account.py new file mode 100644 index 0000000..24a0135 --- /dev/null +++ b/app/services/account.py @@ -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() diff --git a/app/services/market_data.py b/app/services/market_data.py new file mode 100644 index 0000000..98ce19d --- /dev/null +++ b/app/services/market_data.py @@ -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() diff --git a/app/services/realtime.py b/app/services/realtime.py new file mode 100644 index 0000000..942fdce --- /dev/null +++ b/app/services/realtime.py @@ -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() diff --git a/app/services/trading.py b/app/services/trading.py new file mode 100644 index 0000000..09831f2 --- /dev/null +++ b/app/services/trading.py @@ -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() diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..e293c0b --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,215 @@ + + + + + + Stock Automation Dashboard + + + +
+

Stock Automation

+
+ + 연결 중... +
+
+ +
+
+
+

총 투자금

+
-
+
+
+

평가금액

+
-
+
+
+

총 수익

+
-
+
+
+

수익률

+
-
+
+
+ +
+

보유 종목

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

최근 매매 내역

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

매매 주문

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..676b141 --- /dev/null +++ b/config.yaml @@ -0,0 +1,30 @@ +app: + name: "StockAutomation" + version: "0.1.0" + +kis: + server_mode: "vps" # vps: 모의투자, real: 실전투자 + rate_limit: + requests_per_second: 1.0 # 모의투자: 1.0, 실전투자: 2.0 + 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4b691e7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "stock-automation" +version = "0.1.0" +description = "한국투자증권 API 기반 자동매매 웹앱" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.0", + "aiosqlite>=0.20.0", + "python-kis>=2.1.0", + "apscheduler>=3.10.0", + "pandas>=2.2.0", + "ta>=0.11.0", + "pydantic-settings>=2.0.0", + "pyyaml>=6.0.0", + "python-dotenv>=1.0.0", + "jinja2>=3.1.0", + "httpx>=0.27.0", + "websockets>=13.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.5.0", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/stock_automation.egg-info/PKG-INFO b/stock_automation.egg-info/PKG-INFO new file mode 100644 index 0000000..754cd98 --- /dev/null +++ b/stock_automation.egg-info/PKG-INFO @@ -0,0 +1,23 @@ +Metadata-Version: 2.4 +Name: stock-automation +Version: 0.1.0 +Summary: 한국투자증권 API 기반 자동매매 웹앱 +Requires-Python: >=3.11 +Requires-Dist: fastapi>=0.115.0 +Requires-Dist: uvicorn[standard]>=0.30.0 +Requires-Dist: sqlalchemy>=2.0.0 +Requires-Dist: aiosqlite>=0.20.0 +Requires-Dist: python-kis>=2.1.0 +Requires-Dist: apscheduler>=3.10.0 +Requires-Dist: pandas>=2.2.0 +Requires-Dist: ta>=0.11.0 +Requires-Dist: pydantic-settings>=2.0.0 +Requires-Dist: pyyaml>=6.0.0 +Requires-Dist: python-dotenv>=1.0.0 +Requires-Dist: jinja2>=3.1.0 +Requires-Dist: httpx>=0.27.0 +Requires-Dist: websockets>=13.0.0 +Provides-Extra: dev +Requires-Dist: pytest>=8.0.0; extra == "dev" +Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev" +Requires-Dist: ruff>=0.5.0; extra == "dev" diff --git a/stock_automation.egg-info/SOURCES.txt b/stock_automation.egg-info/SOURCES.txt new file mode 100644 index 0000000..0cba127 --- /dev/null +++ b/stock_automation.egg-info/SOURCES.txt @@ -0,0 +1,36 @@ +pyproject.toml +app/__init__.py +app/main.py +app/core/__init__.py +app/core/auth.py +app/core/config.py +app/core/database.py +app/core/logger.py +app/core/rate_limiter.py +app/engine/__init__.py +app/engine/collector.py +app/engine/scheduler.py +app/engine/strategy_engine.py +app/engine/strategies/__init__.py +app/engine/strategies/base.py +app/engine/strategies/conditional.py +app/engine/strategies/periodic.py +app/engine/strategies/technical.py +app/models/__init__.py +app/models/stock.py +app/routers/__init__.py +app/routers/dashboard.py +app/routers/stocks.py +app/routers/strategies.py +app/routers/trading.py +app/routers/websocket.py +app/services/__init__.py +app/services/account.py +app/services/market_data.py +app/services/realtime.py +app/services/trading.py +stock_automation.egg-info/PKG-INFO +stock_automation.egg-info/SOURCES.txt +stock_automation.egg-info/dependency_links.txt +stock_automation.egg-info/requires.txt +stock_automation.egg-info/top_level.txt \ No newline at end of file diff --git a/stock_automation.egg-info/dependency_links.txt b/stock_automation.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/stock_automation.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/stock_automation.egg-info/requires.txt b/stock_automation.egg-info/requires.txt new file mode 100644 index 0000000..99ff045 --- /dev/null +++ b/stock_automation.egg-info/requires.txt @@ -0,0 +1,19 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +sqlalchemy>=2.0.0 +aiosqlite>=0.20.0 +python-kis>=2.1.0 +apscheduler>=3.10.0 +pandas>=2.2.0 +ta>=0.11.0 +pydantic-settings>=2.0.0 +pyyaml>=6.0.0 +python-dotenv>=1.0.0 +jinja2>=3.1.0 +httpx>=0.27.0 +websockets>=13.0.0 + +[dev] +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +ruff>=0.5.0 diff --git a/stock_automation.egg-info/top_level.txt b/stock_automation.egg-info/top_level.txt new file mode 100644 index 0000000..b80f0bd --- /dev/null +++ b/stock_automation.egg-info/top_level.txt @@ -0,0 +1 @@ +app diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29