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
View File
+93
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
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
+43
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
+60
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)
+67
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,
)
+158
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)
+148
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()