일단 커밋

This commit is contained in:
2026-09-09 00:47:34 +09:00
parent 3008f8e0bf
commit a190a40bde
40 changed files with 181 additions and 36 deletions
+10 -3
View File
@@ -1,7 +1,14 @@
.venv
app/__pycache__/
app/core/__pycache__/
app/engine/__pycache__/
app/engine/strategies/__pycache__/
app/models/__pycache__/
app/routers/__pycache__/
app/services/__pycache__/
__pycache__/
*.pyc
.venv/
.auth_cache.json
config.yaml
.env
*.pyc
output.log
config.yaml
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45
View File
@@ -1,11 +1,34 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
def _load_dotenv(path: Path) -> None:
"""간단한 .env 파서 (python-dotenv 의존성 대신 내장 구현)."""
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
_load_dotenv(Path(".env"))
def _env(name: str, default: Any = None) -> Any:
return os.environ.get(name, default)
class KISConfig:
app_key: str = ""
app_secret: str = ""
@@ -14,6 +37,27 @@ class KISConfig:
hts_id: str = ""
server_mode: str = "vps"
def apply_env(self) -> None:
app_key = _env("KIS_APP_KEY")
app_secret = _env("KIS_APP_SECRET")
account_no = _env("KIS_ACCOUNT_NO")
account_code = _env("KIS_ACCOUNT_CODE")
hts_id = _env("KIS_HTS_ID")
server_mode = _env("KIS_SERVER_MODE")
if app_key:
self.app_key = app_key
if app_secret:
self.app_secret = app_secret
if account_no:
self.account_no = account_no
if account_code:
self.account_code = account_code
if hts_id:
self.hts_id = hts_id
if server_mode:
self.server_mode = server_mode
class AppConfig:
name: str = "StockAutomation"
@@ -58,6 +102,7 @@ class Settings:
self.trading = TradingConfig()
self.rate_limit = RateLimitConfig()
self._load_yaml()
self.kis.apply_env()
def _load_yaml(self) -> None:
yaml_path = Path("config.yaml")
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25 -12
View File
@@ -4,15 +4,17 @@ import json
from sqlalchemy.orm import Session
from app.core.auth import token_manager
from app.core.config import settings
from app.core.logger import setup_logger
from app.models.stock import PriceHistory, Strategy, Trade
from app.services.market_data import market_data_service
from app.services.trading import trading_service
from app.engine.strategies.base import BaseStrategy, Signal
from app.engine.strategies.conditional import ConditionalStrategy
from app.engine.strategies.technical import TechnicalStrategy
from app.engine.strategies.periodic import PeriodicStrategy
from app.engine.strategies.technical import TechnicalStrategy
from app.models.stock import PriceHistory, Strategy, Trade
from app.services.account import account_service
from app.services.market_data import market_data_service
from app.services.trading import trading_service
logger = setup_logger("strategy_engine")
@@ -40,9 +42,25 @@ class StrategyEngine:
strategy_id=strategy_record.id,
)
async def _get_real_holdings(self, db: Session) -> dict[str, int]:
"""실제 계좌 잔고 기반 보유수량 맵. 실패 시 로컬 DB로 폴백."""
if token_manager.is_authenticated:
try:
balance = await account_service.get_balance()
holdings = {s["stock_code"]: s["qty"] for s in balance.get("stocks", [])}
if holdings:
return holdings
except Exception as e:
logger.warning("잔고 조회 실패 - 로컬 보유정보 사용: %s", e)
from app.models.stock import Holding
return {h.stock_code: h.qty for h in db.query(Holding).filter(Holding.qty > 0).all()}
async def evaluate_all(self, db: Session) -> list[Signal]:
strategies = db.query(Strategy).filter(Strategy.is_active == True).all()
strategies = db.query(Strategy).filter(Strategy.is_active).all()
signals: list[Signal] = []
real_holdings = await self._get_real_holdings(db)
for strat_record in strategies:
try:
@@ -55,7 +73,7 @@ class StrategyEngine:
continue
price_history = self._get_price_history(db, strat_record.stock_code)
holding = self._get_holding_qty(db, strat_record.stock_code)
holding = real_holdings.get(strat_record.stock_code, 0)
signal = strategy.evaluate(current_price, price_history, holding)
if signal.action != "hold":
@@ -103,7 +121,7 @@ class StrategyEngine:
qty=signal.qty,
price=signal.price,
order_type=settings.strategy.default_order_type,
status="filled" if order_result.get("rt_cd") == "0" else "rejected",
status="pending" if order_result.get("rt_cd") == "0" else "rejected",
strategy_id=signal.strategy_id,
)
db.add(trade)
@@ -136,11 +154,6 @@ class StrategyEngine:
for r in reversed(records)
]
def _get_holding_qty(self, db: Session, stock_code: str) -> int:
from app.models.stock import Holding
holding = db.query(Holding).filter(Holding.stock_code == stock_code).first()
return holding.qty if holding else 0
def reset_daily_count(self) -> None:
self._daily_trade_count = 0
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -46,7 +46,7 @@ async def place_order(data: OrderRequest, db: Session = Depends(get_db)) -> dict
qty=data.qty,
price=data.price,
order_type=data.order_type,
status="filled" if result.get("rt_cd") == "0" else "rejected",
status="pending" if result.get("rt_cd") == "0" else "rejected",
)
db.add(trade)
db.commit()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -3
View File
@@ -38,7 +38,9 @@ class MarketDataService:
url = f"{get_base_url()}{_PRICE_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
if resp.status_code != 200:
logger.warning("현재가 조회 HTTP 오류: %s - %s", stock_code, resp.status_code)
return {}
data = resp.json()
if data.get("rt_cd") != "0":
@@ -78,7 +80,9 @@ class MarketDataService:
url = f"{get_base_url()}{_CHART_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
if resp.status_code != 200:
logger.warning("차트 조회 HTTP 오류: %s - %s", stock_code, resp.status_code)
return []
data = resp.json()
if data.get("rt_cd") != "0":
@@ -107,7 +111,9 @@ class MarketDataService:
url = f"{get_base_url()}{_ORDERBOOK_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
if resp.status_code != 200:
logger.warning("호가 조회 HTTP 오류: %s - %s", stock_code, resp.status_code)
return {}
data = resp.json()
if data.get("rt_cd") != "0":
+34 -2
View File
@@ -57,6 +57,36 @@ class TradingService:
if not self._check_auth():
return {"rt_cd": "-1", "msg": "API 인증되지 않음", "order_no": ""}
if qty <= 0:
return {"rt_cd": "-1", "msg": "주문 수량은 0보다 커야 합니다", "order_no": ""}
if side not in ("buy", "sell"):
return {"rt_cd": "-1", "msg": "유효하지 않은 주문 방향입니다", "order_no": ""}
if order_type == "00" and price <= 0:
return {"rt_cd": "-1", "msg": "지정가 주문의 가격은 0보다 커야 합니다", "order_no": ""}
order_amount = qty * price
if price > 0:
if order_amount < settings.trading.min_order_amount:
return {
"rt_cd": "-1",
"msg": (
f"최소 주문 금액 미달: {order_amount:,}원 "
f"< {settings.trading.min_order_amount:,}원"
),
"order_no": "",
}
if order_amount > settings.trading.max_order_amount:
return {
"rt_cd": "-1",
"msg": (
f"최대 주문 금액 초과: {order_amount:,}원 "
f"> {settings.trading.max_order_amount:,}원"
),
"order_no": "",
}
await self._rate_limiter.acquire()
tr_id = self._get_tr_id(side)
@@ -237,7 +267,8 @@ class TradingService:
else:
side = "buy" if side_code == "01" else "sell"
trades.append({
trades.append(
{
"order_no": item.get("ord_gno_brno", ""),
"stock_code": item.get("pdno", ""),
"stock_name": item.get("prdt_name", ""),
@@ -252,7 +283,8 @@ class TradingService:
"commission": float(item.get("ft_lof_ruse_amt", 0)),
"created_at": item.get("ord_sttm", ""),
"filled_at": item.get("ft_ccld_no", ""),
})
}
)
logger.info("일일 체결 내역 조회: %d건", len(trades))
return trades
+43
View File
@@ -0,0 +1,43 @@
# StockAutomation 설정 템플릿
# 사용법: 이 파일을 복사하여 config.yaml 로 저장한 뒤 수정하세요.
# cp config.example.yaml config.yaml
#
# 주의: 시크릿 값(app_key, app_secret, account_no, hts_id)은 이 파일에 두지 마세요.
# 반드시 환경변수(.env 또는 서버 환경변수)로 주입하세요.
# KIS_APP_KEY / KIS_APP_SECRET / KIS_ACCOUNT_NO / KIS_ACCOUNT_CODE / KIS_HTS_ID
app:
name: "StockAutomation"
version: "0.1.0"
host: "0.0.0.0"
port: 8000
debug: true
db_path: "./data/stock.db"
log_level: "INFO"
kis:
server_mode: "vps" # vps: 모의투자, real: 실전투자
rate_limit:
requests_per_second: 2.0 # 모의투자/실전투자 공통: 초당 2회
retry_delay: 1.5
collector:
interval_seconds: 5 # 주가 수집 간격 (초)
market_open_hour: 9
market_close_hour: 15
market_close_minute: 30
strategies:
check_interval_seconds: 3 # 전략 체크 간격 (초)
max_daily_trades: 50 # 일일 최대 매매 횟수
default_order_type: "00" # 00: 지정가, 01: 시장가
trading:
max_order_amount: 10000000 # 최대 주문 금액 (원)
min_order_amount: 100000 # 최소 주문 금액 (원)
slippage_percent: 0.1 # 슬리피지 허용 비율 (%)
logging:
level: "INFO"
format: "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
file: "./data/logs/stock.log"
BIN
View File
Binary file not shown.
View File
-1
View File
@@ -45,7 +45,6 @@ typing_extensions==4.16.0
tzlocal==5.4.4
urllib3==2.7.0
uvicorn==0.51.0
uvloop==0.22.1
watchfiles==1.2.0
websocket-client==1.9.0
websockets==16.1