일단 커밋
This commit is contained in:
+10
-3
@@ -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.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
@@ -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.
@@ -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":
|
||||
|
||||
+48
-16
@@ -57,6 +57,36 @@ class TradingService:
|
||||
if not self._check_auth():
|
||||
return {"rt_cd": "-1", "msg": "API 인증되지 않음", "order_no": ""}
|
||||
|
||||
if qty <= 0:
|
||||
return {"rt_cd": "-1", "msg": "주문 수량은 0보다 커야 합니다", "order_no": ""}
|
||||
|
||||
if side not in ("buy", "sell"):
|
||||
return {"rt_cd": "-1", "msg": "유효하지 않은 주문 방향입니다", "order_no": ""}
|
||||
|
||||
if order_type == "00" and price <= 0:
|
||||
return {"rt_cd": "-1", "msg": "지정가 주문의 가격은 0보다 커야 합니다", "order_no": ""}
|
||||
|
||||
order_amount = qty * price
|
||||
if price > 0:
|
||||
if order_amount < settings.trading.min_order_amount:
|
||||
return {
|
||||
"rt_cd": "-1",
|
||||
"msg": (
|
||||
f"최소 주문 금액 미달: {order_amount:,}원 "
|
||||
f"< {settings.trading.min_order_amount:,}원"
|
||||
),
|
||||
"order_no": "",
|
||||
}
|
||||
if order_amount > settings.trading.max_order_amount:
|
||||
return {
|
||||
"rt_cd": "-1",
|
||||
"msg": (
|
||||
f"최대 주문 금액 초과: {order_amount:,}원 "
|
||||
f"> {settings.trading.max_order_amount:,}원"
|
||||
),
|
||||
"order_no": "",
|
||||
}
|
||||
|
||||
await self._rate_limiter.acquire()
|
||||
|
||||
tr_id = self._get_tr_id(side)
|
||||
@@ -237,22 +267,24 @@ class TradingService:
|
||||
else:
|
||||
side = "buy" if side_code == "01" else "sell"
|
||||
|
||||
trades.append({
|
||||
"order_no": item.get("ord_gno_brno", ""),
|
||||
"stock_code": item.get("pdno", ""),
|
||||
"stock_name": item.get("prdt_name", ""),
|
||||
"side": side,
|
||||
"qty": qty,
|
||||
"price": int(float(item.get("ft_ccld_unpr3", 0))),
|
||||
"order_type": item.get("ord_dvsn", "00"),
|
||||
"status": "filled",
|
||||
"order_amount": float(item.get("ft_ord_amt", 0)),
|
||||
"settlement_amount": float(item.get("ft_ccld_amt", 0)),
|
||||
"tax": float(item.get("sttl_evlu_amt", 0)),
|
||||
"commission": float(item.get("ft_lof_ruse_amt", 0)),
|
||||
"created_at": item.get("ord_sttm", ""),
|
||||
"filled_at": item.get("ft_ccld_no", ""),
|
||||
})
|
||||
trades.append(
|
||||
{
|
||||
"order_no": item.get("ord_gno_brno", ""),
|
||||
"stock_code": item.get("pdno", ""),
|
||||
"stock_name": item.get("prdt_name", ""),
|
||||
"side": side,
|
||||
"qty": qty,
|
||||
"price": int(float(item.get("ft_ccld_unpr3", 0))),
|
||||
"order_type": item.get("ord_dvsn", "00"),
|
||||
"status": "filled",
|
||||
"order_amount": float(item.get("ft_ord_amt", 0)),
|
||||
"settlement_amount": float(item.get("ft_ccld_amt", 0)),
|
||||
"tax": float(item.get("sttl_evlu_amt", 0)),
|
||||
"commission": float(item.get("ft_lof_ruse_amt", 0)),
|
||||
"created_at": item.get("ord_sttm", ""),
|
||||
"filled_at": item.get("ft_ccld_no", ""),
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("일일 체결 내역 조회: %d건", len(trades))
|
||||
return trades
|
||||
|
||||
@@ -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"
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user