first commit

This commit is contained in:
2026-07-16 23:55:16 +09:00
commit 57c07a4e12
40 changed files with 2513 additions and 0 deletions

16
.env.example Normal file
View File

@@ -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

0
app/__init__.py Normal file
View File

0
app/core/__init__.py Normal file
View File

105
app/core/auth.py Normal file
View File

@@ -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()

116
app/core/config.py Normal file
View File

@@ -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"

43
app/core/database.py Normal file
View File

@@ -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()

27
app/core/logger.py Normal file
View File

@@ -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

25
app/core/rate_limiter.py Normal file
View File

@@ -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())

0
app/engine/__init__.py Normal file
View File

93
app/engine/collector.py Normal file
View File

@@ -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()

80
app/engine/scheduler.py Normal file
View File

@@ -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("스케줄러 종료")

View File

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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()

86
app/main.py Normal file
View File

@@ -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,
)

0
app/models/__init__.py Normal file
View File

76
app/models/stock.py Normal file
View File

@@ -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())

0
app/routers/__init__.py Normal file
View File

68
app/routers/dashboard.py Normal file
View File

@@ -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,
}

79
app/routers/stocks.py Normal file
View File

@@ -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

103
app/routers/strategies.py Normal file
View File

@@ -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}

96
app/routers/trading.py Normal file
View File

@@ -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()

68
app/routers/websocket.py Normal file
View File

@@ -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))

0
app/services/__init__.py Normal file
View File

105
app/services/account.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import httpx
from app.core.auth import token_manager
from app.core.config import get_base_url, settings
from app.core.logger import setup_logger
from app.core.rate_limiter import RateLimiter
logger = setup_logger("account")
_BALANCE_URL = "/uapi/domestic-stock/v1/trading/inquire-balance"
_ORDERABLE_URL = "/uapi/domestic-stock/v1/trading/inquire-psamount"
_BALANCE_TR_IDS = {"real": "TTTC0311R", "vps": "VTTC0311R"}
class AccountService:
def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=10.0)
self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second)
async def get_balance(self) -> dict:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
tr_id = _BALANCE_TR_IDS.get(settings.kis.server_mode, "VTTC0311R")
headers = token_manager.get_auth_headers(tr_id)
params = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"AFHR_FLPR_YN": "N",
"OFLN_YN": "N",
"INQR_DVSN": "02",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"CNCD_UNCLD_YN": "N",
}
url = f"{get_base_url()}{_BALANCE_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
logger.warning("잔고 조회 실패: %s", data.get("msg1"))
return {"deposits": [], "stocks": []}
stocks = []
for item in data.get("output1", []):
stocks.append({
"stock_code": item.get("pdno", ""),
"stock_name": item.get("hsts_km_name", ""),
"qty": int(item.get("hldg_qty", 0)),
"avg_price": float(item.get("pchs_avg_pric", 0)),
"current_price": int(item.get("prpr", 0)),
"profit": float(item.get("evlu_pfls_amt", 0)),
"profit_rate": float(item.get("pfls_rt", 0)),
"buy_amount": float(item.get("pchs_amt", 0)),
"eval_amount": float(item.get("evlu_amt", 0)),
})
deposits = []
for item in data.get("output2", []):
deposits.append({
"currency": item.get("crcy_cd", "KRW"),
"amount": float(item.get("nmbdy_now_amt", 0)),
"orderable": float(item.get("ord_psbl_amt", 0)),
})
return {"deposits": deposits, "stocks": stocks}
async def get_orderable_amount(self, stock_code: str, price: int) -> dict:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
headers = token_manager.get_auth_headers("VTTC0830R" if settings.kis.server_mode == "vps" else "TTTC0830R")
params = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"PDNO": stock_code,
"ORD_UNPR": str(price),
"ORD_DVSN": "00",
}
url = f"{get_base_url()}{_ORDERABLE_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
return {"orderable_amount": 0, "orderable_qty": 0}
output = data.get("output", {})
return {
"orderable_amount": float(output.get("psmps_numb", 0)),
"orderable_qty": int(output.get("ord_psbl_qty", 0)),
"max_buy_amount": float(output.get("max_buy_psbl_amt", 0)),
}
async def close(self) -> None:
await self._client.aclose()
account_service = AccountService()

125
app/services/market_data.py Normal file
View File

@@ -0,0 +1,125 @@
from __future__ import annotations
import httpx
from app.core.auth import token_manager
from app.core.config import get_base_url, settings
from app.core.logger import setup_logger
from app.core.rate_limiter import RateLimiter
logger = setup_logger("market_data")
_PRICE_URL = "/uapi/domestic-stock/v1/quotations/inquire-price"
_CHART_URL = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
_ORDERBOOK_URL = "/uapi/domestic-stock/v1/quotations/inquire-asking-price"
_FID_INPUT_ISCD = "FID_INPUT_ISCD"
_FID_COND_MRKT_DIV_CODE = "FID_COND_MRKT_DIV_CODE"
class MarketDataService:
def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=10.0)
self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second)
async def get_current_price(self, stock_code: str) -> dict:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
headers = token_manager.get_auth_headers("FHKST01010100")
params = {_FID_COND_MRKT_DIV_CODE: "J", _FID_INPUT_ISCD: stock_code}
url = f"{get_base_url()}{_PRICE_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
logger.warning("현재가 조회 실패: %s - %s", stock_code, data.get("msg1"))
return {}
output = data.get("output", {})
return {
"stock_code": stock_code,
"current_price": int(output.get("stck_prpr", 0)),
"change_price": int(output.get("prdy_vrss", 0)),
"change_rate": float(output.get("prdy_ctrt", 0)),
"open_price": int(output.get("stck_oprc", 0)),
"high_price": int(output.get("stck_hgpr", 0)),
"low_price": int(output.get("stck_lwpr", 0)),
"volume": int(output.get("acml_vol", 0)),
"trade_amount": int(output.get("acml_tr_pbmn", 0)),
"stock_name": output.get("hts_kor_isnm", ""),
}
async def get_daily_chart(
self, stock_code: str, start_date: str, end_date: str, count: int = 30
) -> list[dict]:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
headers = token_manager.get_auth_headers("FHKST03010200")
params = {
_FID_COND_MRKT_DIV_CODE: "J",
_FID_INPUT_ISCD: stock_code,
"FID_INPUT_DATE_1": start_date,
"FID_INPUT_DATE_2": end_date,
"FID_PERIOD_DIV_CODE": "D",
"FID_ADJ_PRC": "1",
}
url = f"{get_base_url()}{_CHART_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
logger.warning("차트 조회 실패: %s - %s", stock_code, data.get("msg1"))
return []
result = []
for item in data.get("output2", [])[:count]:
result.append({
"date": item.get("stck_bsop_date", ""),
"open": int(item.get("stck_oprc", 0)),
"high": int(item.get("stck_hgpr", 0)),
"low": int(item.get("stck_lwpr", 0)),
"close": int(item.get("stck_clpr", 0)),
"volume": int(item.get("acml_vol", 0)),
})
return result
async def get_orderbook(self, stock_code: str) -> dict:
await self._rate_limiter.acquire()
token = await token_manager.get_access_token()
headers = token_manager.get_auth_headers("FHKST01010200")
params = {_FID_COND_MRKT_DIV_CODE: "J", _FID_INPUT_ISCD: stock_code}
url = f"{get_base_url()}{_ORDERBOOK_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
return {}
output = data.get("output", [{}])[0] if data.get("output") else {}
return {
"stock_code": stock_code,
"bid_prices": [
int(output.get(f"phsc_kprc_{i}", 0)) for i in range(1, 6)
],
"ask_prices": [
int(output.get(f"sats_kprc_{i}", 0)) for i in range(1, 6)
],
"bid_volumes": [
int(output.get(f"phsc_vola_{i}", 0)) for i in range(1, 6)
],
"ask_volumes": [
int(output.get(f"sats_ac_vola_{i}", 0)) for i in range(1, 6)
],
}
async def close(self) -> None:
await self._client.aclose()
market_data_service = MarketDataService()

198
app/services/realtime.py Normal file
View File

@@ -0,0 +1,198 @@
from __future__ import annotations
import asyncio
import json
from typing import Callable
import websockets
from app.core.auth import token_manager
from app.core.config import get_ws_url, settings
from app.core.logger import setup_logger
from app.core.rate_limiter import RateLimiter
logger = setup_logger("realtime")
_SUBSCRIBE_TR_IDS = {
"price": "H0STCNT0",
"orderbook": "H0STASP0",
"trade": "H0STCNI0",
}
class RealtimeService:
def __init__(self) -> None:
self._ws: websockets.WebSocketClientProtocol | None = None
self._running = False
self._callbacks: dict[str, list[Callable]] = {}
self._subscriptions: dict[str, list[str]] = {}
self._rate_limiter = RateLimiter(requests_per_second=0.5)
self._heartbeat_task: asyncio.Task | None = None
self._receive_task: asyncio.Task | None = None
def on(self, event: str, callback: Callable) -> None:
self._callbacks.setdefault(event, []).append(callback)
async def connect(self) -> None:
approval_key = await token_manager.get_approval_key()
url = get_ws_url()
try:
self._ws = await websockets.connect(
url,
extra_headers={"approval_key": approval_key, "type": "Y"},
)
self._running = True
self._receive_task = asyncio.create_task(self._receive_loop())
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
logger.info("WebSocket 연결 성공: %s", url)
except Exception as e:
logger.error("WebSocket 연결 실패: %s", e)
raise
async def subscribe(self, stock_code: str, data_type: str = "price") -> None:
if not self._ws:
await self.connect()
tr_id = _SUBSCRIBE_TR_IDS.get(data_type, "H0STCNT0")
msg = {
"header": {
"approval_key": await token_manager.get_approval_key(),
"custtype": "P",
"tr_type": "1",
"content-type": "utf-8",
},
"body": {
"input": {
"tr_id": tr_id,
"tr_key": stock_code,
}
},
}
await self._ws.send(json.dumps(msg))
self._subscriptions.setdefault(data_type, []).append(stock_code)
logger.info("구독 요청: %s - %s", data_type, stock_code)
async def unsubscribe(self, stock_code: str, data_type: str = "price") -> None:
if not self._ws:
return
tr_id = _SUBSCRIBE_TR_IDS.get(data_type, "H0STCNT0")
msg = {
"header": {
"approval_key": await token_manager.get_approval_key(),
"custtype": "P",
"tr_type": "2",
"content-type": "utf-8",
},
"body": {
"input": {
"tr_id": tr_id,
"tr_key": stock_code,
}
},
}
await self._ws.send(json.dumps(msg))
subs = self._subscriptions.get(data_type, [])
if stock_code in subs:
subs.remove(stock_code)
logger.info("구독 해제: %s - %s", data_type, stock_code)
async def _receive_loop(self) -> None:
try:
async for raw_msg in self._ws:
await self._handle_message(raw_msg)
except websockets.ConnectionClosed:
logger.warning("WebSocket 연결 끊김. 5초 후 재연결...")
if self._running:
await asyncio.sleep(5)
await self.connect()
except Exception as e:
logger.error("WebSocket 수신 오류: %s", e)
async def _handle_message(self, raw_msg: str) -> None:
try:
msg = json.loads(raw_msg)
header = msg.get("header", {})
body = msg.get("body", {})
tr_id = header.get("tr_id", "")
if tr_id == "PINGPONG":
return
parsed = self._parse_data(tr_id, body.get("output", {}))
if not parsed:
return
event_type = "price"
if "bid_prices" in parsed:
event_type = "orderbook"
elif "trade_price" in parsed:
event_type = "trade"
for callback in self._callbacks.get(event_type, []):
try:
result = callback(parsed)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error("콜백 실행 오류: %s", e)
except json.JSONDecodeError:
logger.warning("JSON 파싱 실패")
def _parse_data(self, tr_id: str, output: dict) -> dict | None:
if not output:
return None
if tr_id == "H0STCNT0":
return {
"type": "price",
"stock_code": output.get("mksc_shrn_iscd", ""),
"trade_price": int(output.get("stck_prpr", 0)),
"change_price": int(output.get("prdy_vrss", 0)),
"change_rate": float(output.get("prdy_ctrt", 0)),
"open_price": int(output.get("stck_oprc", 0)),
"high_price": int(output.get("stck_hgpr", 0)),
"low_price": int(output.get("stck_lwpr", 0)),
"volume": int(output.get("acml_vol", 0)),
"trade_time": output.get("stck_cntg_hour", ""),
}
if tr_id == "H0STASP0":
return {
"type": "orderbook",
"stock_code": output.get("mksc_shrn_iscd", ""),
"bid_prices": [int(output.get(f"phsc_kprc_{i}", 0)) for i in range(1, 6)],
"ask_prices": [int(output.get(f"sats_kprc_{i}", 0)) for i in range(1, 6)],
"bid_volumes": [int(output.get(f"phsc_vola_{i}", 0)) for i in range(1, 6)],
"ask_volumes": [int(output.get(f"sats_ac_vola_{i}", 0)) for i in range(1, 6)],
}
return None
async def _heartbeat_loop(self) -> None:
while self._running:
try:
await asyncio.sleep(30)
if self._ws and self._ws.open:
await self._ws.send(json.dumps({"header": {"tr_id": "PINGPONG"}}))
except Exception:
break
async def disconnect(self) -> None:
self._running = False
if self._heartbeat_task:
self._heartbeat_task.cancel()
if self._receive_task:
self._receive_task.cancel()
if self._ws:
await self._ws.close()
self._ws = None
logger.info("WebSocket 연결 종료")
realtime_service = RealtimeService()

165
app/services/trading.py Normal file
View File

@@ -0,0 +1,165 @@
from __future__ import annotations
import httpx
from app.core.auth import token_manager
from app.core.config import get_base_url, settings
from app.core.logger import setup_logger
from app.core.rate_limiter import RateLimiter
logger = setup_logger("trading")
_ORDER_URL = "/uapi/domestic-stock/v1/trading/order-cash"
_MODIFY_CANCEL_URL = "/uapi/domestic-stock/v1/trading/order-rvsecncl"
_TR_IDS = {
"real": {"buy": "TTTC0802U", "sell": "TTTC0801U", "modify": "TTTC0803U", "cancel": "TTTC0804U"},
"vps": {"buy": "VTTC0802U", "sell": "VTTC0801U", "modify": "VTTC0803U", "cancel": "VTTC0804U"},
}
class TradingService:
def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=10.0)
self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second)
def _get_tr_id(self, side: str) -> str:
mode = settings.kis.server_mode
return _TR_IDS.get(mode, _TR_IDS["vps"]).get(side, "VTTC0802U")
async def place_order(
self,
stock_code: str,
side: str,
qty: int,
price: int = 0,
order_type: str = "00",
) -> dict:
await self._rate_limiter.acquire()
tr_id = self._get_tr_id(side)
headers = token_manager.get_auth_headers(tr_id)
body = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"PDNO": stock_code,
"ORD_DVSN": order_type,
"ORD_QTY": str(qty),
"ORD_UNPR": str(price) if order_type == "00" else "0",
}
hashkey = await token_manager.generate_hashkey(body)
headers["hashkey"] = hashkey
url = f"{get_base_url()}{_ORDER_URL}"
resp = await self._client.post(url, json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
result = {
"order_no": data.get("output", {}).get("odno", ""),
"rt_cd": data.get("rt_cd"),
"msg_cd": data.get("msg_cd"),
"msg": data.get("msg1"),
"stock_code": stock_code,
"side": side,
"qty": qty,
"price": price,
"order_type": order_type,
}
if data.get("rt_cd") == "0":
logger.info(
"주문 성공: %s %s %s%s @ %s",
stock_code,
"매수" if side == "buy" else "매도",
qty,
order_type,
price,
)
else:
logger.warning("주문 실패: %s - %s", stock_code, data.get("msg1"))
return result
async def modify_order(
self, order_no: str, stock_code: str, qty: int, price: int, order_type: str = "00"
) -> dict:
await self._rate_limiter.acquire()
tr_id = self._get_tr_id("modify")
headers = token_manager.get_auth_headers(tr_id)
body = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"ODNO": order_no,
"PDNO": stock_code,
"ORD_DVSN": order_type,
"ORD_QTY": str(qty),
"ORD_UNPR": str(price),
}
hashkey = await token_manager.generate_hashkey(body)
headers["hashkey"] = hashkey
url = f"{get_base_url()}{_MODIFY_CANCEL_URL}"
resp = await self._client.post(url, json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
result = {
"order_no": data.get("output", {}).get("odno", ""),
"rt_cd": data.get("rt_cd"),
"msg": data.get("msg1"),
}
if data.get("rt_cd") == "0":
logger.info("정정 성공: 주문번호 %s", order_no)
else:
logger.warning("정정 실패: %s - %s", order_no, data.get("msg1"))
return result
async def cancel_order(self, order_no: str, stock_code: str, qty: int) -> dict:
await self._rate_limiter.acquire()
tr_id = self._get_tr_id("cancel")
headers = token_manager.get_auth_headers(tr_id)
body = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"ODNO": order_no,
"PDNO": stock_code,
"ORD_DVSN": "00",
"ORD_QTY": str(qty),
"ORD_UNPR": "0",
}
hashkey = await token_manager.generate_hashkey(body)
headers["hashkey"] = hashkey
url = f"{get_base_url()}{_MODIFY_CANCEL_URL}"
resp = await self._client.post(url, json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
result = {
"rt_cd": data.get("rt_cd"),
"msg": data.get("msg1"),
}
if data.get("rt_cd") == "0":
logger.info("취소 성공: 주문번호 %s", order_no)
else:
logger.warning("취소 실패: %s - %s", order_no, data.get("msg1"))
return result
async def close(self) -> None:
await self._client.aclose()
trading_service = TradingService()

215
app/templates/index.html Normal file
View File

@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stock Automation Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e1e4e8; }
.header { background: #161b22; padding: 16px 24px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 20px; color: #58a6ff; }
.header .status { font-size: 13px; color: #8b949e; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
.card h3 { font-size: 13px; color: #8b949e; margin-bottom: 8px; text-transform: uppercase; }
.card .value { font-size: 28px; font-weight: 700; }
.card .value.profit { color: #f85149; }
.card .value.positive { color: #3fb950; }
.card .value.negative { color: #f85149; }
.section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 20px; }
.section h2 { font-size: 16px; margin-bottom: 12px; color: #c9d1d9; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 8px 12px; color: #8b949e; border-bottom: 1px solid #30363d; }
td { padding: 8px 12px; border-bottom: 1px solid #21262d; }
tr:hover { background: #1c2128; }
.badge { padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.badge.buy { background: #1a3a2a; color: #3fb950; }
.badge.sell { background: #3a1a1a; color: #f85149; }
.badge.pending { background: #3a3a1a; color: #d29922; }
.badge.filled { background: #1a2a3a; color: #58a6ff; }
.actions { display: flex; gap: 8px; margin-bottom: 20px; }
.btn { padding: 8px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #c9d1d9; cursor: pointer; font-size: 13px; }
.btn:hover { background: #30363d; }
.btn.primary { background: #238636; border-color: #2ea043; color: #fff; }
.btn.primary:hover { background: #2ea043; }
.form-row { display: flex; gap: 12px; align-items: end; margin-bottom: 12px; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 12px; color: #8b949e; }
.form-group input, .form-group select { padding: 6px 10px; border-radius: 4px; border: 1px solid #30363d; background: #0d1117; color: #c9d1d9; font-size: 13px; }
#ws-status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
#ws-status.connected { background: #3fb950; }
#ws-status.disconnected { background: #f85149; }
</style>
</head>
<body>
<div class="header">
<h1>Stock Automation</h1>
<div class="status">
<span id="ws-status" class="disconnected"></span>
<span id="status-text">연결 중...</span>
</div>
</div>
<div class="container">
<div class="grid">
<div class="card">
<h3>총 투자금</h3>
<div class="value" id="total-invested">-</div>
</div>
<div class="card">
<h3>평가금액</h3>
<div class="value" id="total-evaluated">-</div>
</div>
<div class="card">
<h3>총 수익</h3>
<div class="value" id="total-profit">-</div>
</div>
<div class="card">
<h3>수익률</h3>
<div class="value" id="profit-rate">-</div>
</div>
</div>
<div class="section">
<h2>보유 종목</h2>
<table>
<thead>
<tr>
<th>종목코드</th><th>종목명</th><th>수량</th>
<th>평균단가</th><th>현재가</th><th>수익</th><th>수익률</th>
</tr>
</thead>
<tbody id="holdings-table"></tbody>
</table>
</div>
<div class="section">
<h2>최근 매매 내역</h2>
<table>
<thead>
<tr>
<th>시간</th><th>종목코드</th><th>구분</th>
<th>수량</th><th>가격</th><th>상태</th>
</tr>
</thead>
<tbody id="trades-table"></tbody>
</table>
</div>
<div class="section">
<h2>매매 주문</h2>
<div class="form-row">
<div class="form-group">
<label>종목코드</label>
<input id="order-code" type="text" placeholder="005930" />
</div>
<div class="form-group">
<label>구분</label>
<select id="order-side">
<option value="buy">매수</option>
<option value="sell">매도</option>
</select>
</div>
<div class="form-group">
<label>수량</label>
<input id="order-qty" type="number" value="1" min="1" />
</div>
<div class="form-group">
<label>가격 (0=시장가)</label>
<input id="order-price" type="number" value="0" />
</div>
<button class="btn primary" onclick="placeOrder()">주문</button>
</div>
</div>
</div>
<script>
const API = '';
let ws;
function formatPrice(n) { return n ? n.toLocaleString('ko-KR') + '원' : '-'; }
function formatRate(n) { return n >= 0 ? '+' + n.toFixed(2) + '%' : n.toFixed(2) + '%'; }
async function loadDashboard() {
try {
const res = await fetch(API + '/api/dashboard/');
const data = await res.json();
const s = data.summary;
document.getElementById('total-invested').textContent = formatPrice(s.total_invested);
document.getElementById('total-evaluated').textContent = formatPrice(s.total_evaluated);
const profitEl = document.getElementById('total-profit');
profitEl.textContent = formatPrice(s.total_profit);
profitEl.className = 'value ' + (s.total_profit >= 0 ? 'positive' : 'negative');
const rateEl = document.getElementById('profit-rate');
rateEl.textContent = formatRate(s.profit_rate);
rateEl.className = 'value ' + (s.profit_rate >= 0 ? 'positive' : 'negative');
const htb = document.getElementById('holdings-table');
htb.innerHTML = data.holdings.map(h => `
<tr>
<td>${h.stock_code}</td><td>${h.stock_name}</td><td>${h.qty}</td>
<td>${formatPrice(h.avg_price)}</td><td>${formatPrice(h.current_price)}</td>
<td class="${h.profit >= 0 ? 'positive' : 'negative'}">${formatPrice(h.profit)}</td>
<td class="${h.profit_rate >= 0 ? 'positive' : 'negative'}">${formatRate(h.profit_rate)}</td>
</tr>
`).join('');
const ttb = document.getElementById('trades-table');
ttb.innerHTML = data.recent_trades.map(t => `
<tr>
<td>${t.created_at ? new Date(t.created_at).toLocaleString('ko-KR') : '-'}</td>
<td>${t.stock_code}</td>
<td><span class="badge ${t.side}">${t.side === 'buy' ? '매수' : '매도'}</span></td>
<td>${t.qty}</td><td>${formatPrice(t.price)}</td>
<td><span class="badge ${t.status}">${t.status}</span></td>
</tr>
`).join('');
} catch (e) { console.error('대시보드 로드 실패:', e); }
}
async function placeOrder() {
const body = {
stock_code: document.getElementById('order-code').value,
side: document.getElementById('order-side').value,
qty: parseInt(document.getElementById('order-qty').value),
price: parseInt(document.getElementById('order-price').value),
};
try {
const res = await fetch(API + '/api/trading/order', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body),
});
const data = await res.json();
alert(data.rt_cd === '0' ? '주문 성공: ' + data.order_no : '주문 실패: ' + data.msg);
loadDashboard();
} catch (e) { alert('주문 오류: ' + e.message); }
}
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/realtime`);
ws.onopen = () => {
document.getElementById('ws-status').className = 'connected';
document.getElementById('status-text').textContent = '실시간 연결됨';
};
ws.onclose = () => {
document.getElementById('ws-status').className = 'disconnected';
document.getElementById('status-text').textContent = '연결 끊김 - 재연결 중...';
setTimeout(connectWS, 3000);
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'price') loadDashboard();
};
}
loadDashboard();
setInterval(loadDashboard, 10000);
connectWS();
</script>
</body>
</html>

30
config.yaml Normal file
View File

@@ -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"

38
pyproject.toml Normal file
View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1 @@

View File

@@ -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

View File

@@ -0,0 +1 @@
app

0
tests/__init__.py Normal file
View File