first commit
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
105
app/core/auth.py
Normal file
105
app/core/auth.py
Normal 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
116
app/core/config.py
Normal 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
43
app/core/database.py
Normal 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
27
app/core/logger.py
Normal 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
25
app/core/rate_limiter.py
Normal 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
0
app/engine/__init__.py
Normal file
93
app/engine/collector.py
Normal file
93
app/engine/collector.py
Normal 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
80
app/engine/scheduler.py
Normal 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("스케줄러 종료")
|
||||
0
app/engine/strategies/__init__.py
Normal file
0
app/engine/strategies/__init__.py
Normal file
43
app/engine/strategies/base.py
Normal file
43
app/engine/strategies/base.py
Normal 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
app/engine/strategies/conditional.py
Normal file
60
app/engine/strategies/conditional.py
Normal 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
app/engine/strategies/periodic.py
Normal file
67
app/engine/strategies/periodic.py
Normal 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
app/engine/strategies/technical.py
Normal file
158
app/engine/strategies/technical.py
Normal 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
app/engine/strategy_engine.py
Normal file
148
app/engine/strategy_engine.py
Normal 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
86
app/main.py
Normal 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
0
app/models/__init__.py
Normal file
76
app/models/stock.py
Normal file
76
app/models/stock.py
Normal 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
0
app/routers/__init__.py
Normal file
68
app/routers/dashboard.py
Normal file
68
app/routers/dashboard.py
Normal 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
79
app/routers/stocks.py
Normal 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
103
app/routers/strategies.py
Normal 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
96
app/routers/trading.py
Normal 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
68
app/routers/websocket.py
Normal 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
0
app/services/__init__.py
Normal file
105
app/services/account.py
Normal file
105
app/services/account.py
Normal 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
125
app/services/market_data.py
Normal 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
198
app/services/realtime.py
Normal 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
165
app/services/trading.py
Normal 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
215
app/templates/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user