"백엔드_핵심_로직_구현_프론트엔드_연동_및_도커_배포_최적화_완료"

This commit is contained in:
2026-02-03 00:52:54 +09:00
parent ed8fc0943b
commit eeddc62089
32 changed files with 1287 additions and 318 deletions

View File

@@ -12,23 +12,29 @@ FROM python:3.9-slim
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Install system dependencies (if needed for TA-Lib or others) # Install system dependencies
# RUN apt-get update && apt-get install -y gcc ... RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy backend requirements # Copy backend requirements
COPY ./backend/requirements.txt . COPY ./backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy backend code # Copy backend code (contents of backend folder to /app)
COPY ./backend ./backend COPY ./backend/ .
# Copy frontend build artifacts to backend static folder # Copy frontend build artifacts to /app/static
COPY --from=frontend-build /app/frontend/dist ./backend/static COPY --from=frontend-build /app/frontend/dist ./static
# Ensure data directory exists
RUN mkdir -p /app/data
# Environment variables # Environment variables
ENV PORT=80 ENV PORT=80
EXPOSE 80 EXPOSE 80
# Run FastAPI server # Run FastAPI server
# Assuming main.py is in backend folder and app object is named 'app' # Since app/ is now directly in /app, uvicorn app.main:app works
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "80"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]

Binary file not shown.

View File

@@ -1,8 +1,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.endpoints import settings, kis from app.api.endpoints import settings, kis, account, watchlist, discovery, news, trade, auto_trade
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(kis.router, prefix="/kis", tags=["kis"]) api_router.include_router(kis.router, prefix="/kis", tags=["kis"])
# api_router.include_router(trade.router, prefix="/trade", tags=["trade"]) api_router.include_router(account.router, prefix="/account", tags=["account"])
api_router.include_router(watchlist.router, prefix="/watchlists", tags=["watchlists"])
api_router.include_router(discovery.router, prefix="/discovery", tags=["discovery"])
api_router.include_router(discovery.router, prefix="/stocks", tags=["stocks"]) # Alias for search
api_router.include_router(news.router, prefix="/news", tags=["news"])
api_router.include_router(trade.router, prefix="/trade", tags=["trade"])
api_router.include_router(trade.router, prefix="/history", tags=["history"]) # Alias
api_router.include_router(trade.router, prefix="/reserved-orders", tags=["reserved"]) # Alias
api_router.include_router(auto_trade.router, prefix="/auto-trade", tags=["auto-trade"])

View File

@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.db.database import get_db
from app.db.models import AccountStatus, Holding
from pydantic import BaseModel
router = APIRouter()
class AccountStatusSchema(BaseModel):
totalAssets: float
buyingPower: float
dailyProfit: float
dailyProfitRate: float
class Config:
from_attributes = True
class HoldingSchema(BaseModel):
stockCode: str
stockName: str
quantity: int
avgPrice: float
currentPrice: float
profit: float
profitRate: float
marketValue: float
class Config:
from_attributes = True
@router.get("/summary", response_model=AccountStatusSchema)
async def get_account_summary(db: AsyncSession = Depends(get_db)):
stmt = select(AccountStatus).where(AccountStatus.id == 1)
result = await db.execute(stmt)
status = result.scalar_one_or_none()
if not status:
# Return default zeroed if not initialized
return AccountStatusSchema(totalAssets=0, buyingPower=0, dailyProfit=0, dailyProfitRate=0)
return status
@router.get("/holdings", response_model=List[HoldingSchema])
async def get_holdings(market: str = None, db: AsyncSession = Depends(get_db)):
# TODO: Filter by market if Holding table supports it.
# Current Holding model doesn't have 'market' column explicitly, but we can assume mixed or add it.
# For now, return all.
stmt = select(Holding)
result = await db.execute(stmt)
holdings = result.scalars().all()
return holdings

View File

@@ -0,0 +1,87 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime
from app.db.database import get_db
from app.db.models import AutoTradeRobot
router = APIRouter()
# --- Schema ---
class AutoTradeConfigSchema(BaseModel):
id: str
stockCode: str
stockName: str
groupId: Optional[str] = None
type: str
frequency: str
executionTime: str
market: str
quantity: int
specificDay: Optional[int] = None
trailingPercent: Optional[float] = None
active: bool = True
class Config:
from_attributes = True
# --- Endpoints ---
@router.get("/configs", response_model=List[AutoTradeConfigSchema])
async def get_auto_configs(db: AsyncSession = Depends(get_db)):
stmt = select(AutoTradeRobot)
res = await db.execute(stmt)
return res.scalars().all()
@router.post("/configs")
async def create_auto_config(config: AutoTradeConfigSchema, db: AsyncSession = Depends(get_db)):
# Check exists? Upsert?
# Frontend generates ID usually or we do. Schema has ID.
# We will upsert (merge) or just add.
# Check if exists
stmt = select(AutoTradeRobot).where(AutoTradeRobot.id == config.id)
res = await db.execute(stmt)
existing = res.scalar_one_or_none()
if existing:
# Update
existing.stockCode = config.stockCode
existing.stockName = config.stockName
existing.groupId = config.groupId
existing.type = config.type
existing.frequency = config.frequency
existing.executionTime = config.executionTime
existing.market = config.market
existing.quantity = config.quantity
existing.specificDay = config.specificDay
existing.trailingPercent = config.trailingPercent
existing.active = config.active
else:
# Create
new_obj = AutoTradeRobot(
id=config.id,
stockCode=config.stockCode,
stockName=config.stockName,
groupId=config.groupId,
type=config.type,
frequency=config.frequency,
executionTime=config.executionTime,
market=config.market,
quantity=config.quantity,
specificDay=config.specificDay,
trailingPercent=config.trailingPercent,
active=config.active
)
db.add(new_obj)
await db.commit()
return {"status": "saved", "id": config.id}
@router.delete("/configs/{config_id}")
async def delete_auto_config(config_id: str, db: AsyncSession = Depends(get_db)):
await db.execute(delete(AutoTradeRobot).where(AutoTradeRobot.id == config_id))
await db.commit()
return {"status": "deleted"}

View File

@@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from pydantic import BaseModel
from app.db.database import get_db
from app.db.models import MasterStock, DiscoveryRankingCache, StockItem
router = APIRouter()
class StockItemSchema(BaseModel):
code: str
name: str
market: str
class Config:
from_attributes = True
class RankingItemSchema(BaseModel):
code: str
name: str
price: float
change: float
changePercent: float
class Config:
from_attributes = True
@router.get("/rankings", response_model=List[RankingItemSchema])
async def get_rankings(type: str = "rise", limit: int = 10, db: AsyncSession = Depends(get_db)):
"""
Get Top Rankings based on cached StockItem data.
type: 'rise' (Top Gainers), 'fall' (Top Losers), 'volume' (Not impl yet)
"""
stmt = select(StockItem)
if type == "fall":
stmt = stmt.order_by(StockItem.changePercent.asc())
else:
stmt = stmt.order_by(StockItem.changePercent.desc())
stmt = stmt.limit(limit)
res = await db.execute(stmt)
items = res.scalars().all()
return items
@router.get("/stocks/search", response_model=List[StockItemSchema])
async def search_stocks(query: str, db: AsyncSession = Depends(get_db)):
# Search by name or code
stmt = select(MasterStock).where(
(MasterStock.name.like(f"%{query}%")) | (MasterStock.code.like(f"%{query}%"))
).limit(20)
result = await db.execute(stmt)
stocks = result.scalars().all()
return stocks
from app.services.master_service import master_service
@router.get("/kis/master-stocks")
async def sync_master_stocks(db: AsyncSession = Depends(get_db)):
# Trigger Sync
# Ideally should be BackgroundTasks, but for now await to show result
await master_service.sync_master_data(db)
# Return count
stmt = select(MasterStock).limit(1)
# Just return status
return {"status": "Sync Complete"}

View File

@@ -3,8 +3,10 @@ from pydantic import BaseModel
from typing import Literal from typing import Literal
from app.services.kis_client import kis_client from app.services.kis_client import kis_client
import logging
router = APIRouter() router = APIRouter()
logger = logging.getLogger("KisAPI")
class OrderRequest(BaseModel): class OrderRequest(BaseModel):
market: Literal["Domestic", "Overseas"] market: Literal["Domestic", "Overseas"]
@@ -13,6 +15,15 @@ class OrderRequest(BaseModel):
quantity: int quantity: int
price: float = 0 # 0 for Market Price (if supported) price: float = 0 # 0 for Market Price (if supported)
class ModifyOrderRequest(BaseModel):
market: Literal["Domestic", "Overseas"]
order_no: str
code: str
quantity: int
price: float = 0
type: str = "00"
is_cancel: bool = False # True = Cancel, False = Modify
@router.get("/price") @router.get("/price")
async def get_current_price(market: Literal["Domestic", "Overseas"], code: str): async def get_current_price(market: Literal["Domestic", "Overseas"], code: str):
""" """
@@ -29,11 +40,22 @@ async def get_balance(market: Literal["Domestic", "Overseas"]):
try: try:
data = await kis_client.get_balance(market) data = await kis_client.get_balance(market)
return data return data
raise HTTPException(status_code=500, detail=str(e))
@router.put("/order")
async def modify_order(req: ModifyOrderRequest):
logger.info(f"Received Modify/Cancel Request: {req}")
try:
res = await kis_client.modify_order(
req.market, req.order_no, req.code, req.quantity, req.price, req.type, req.is_cancel
)
return res
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/order") @router.post("/order")
async def place_order(order: OrderRequest): async def place_order(order: OrderRequest):
logger.info(f"Received Order Request: {order}")
try: try:
res = await kis_client.place_order(order.market, order.side, order.code, order.quantity, order.price) res = await kis_client.place_order(order.market, order.side, order.code, order.quantity, order.price)
return res return res

View File

@@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from pydantic import BaseModel
from app.db.database import get_db
from app.db.models import NewsCache
router = APIRouter()
class NewsItemSchema(BaseModel):
news_id: str
title: str
description: str
pubDate: str
sentiment: str | None
class Config:
from_attributes = True
@router.get("/", response_model=List[NewsItemSchema])
async def get_news(query: str = None, limit: int = 50, db: AsyncSession = Depends(get_db)):
stmt = select(NewsCache).order_by(NewsCache.pubDate.desc()).limit(limit)
if query:
stmt = stmt.where(NewsCache.title.like(f"%{query}%"))
result = await db.execute(stmt)
news = result.scalars().all()
return news

View File

@@ -6,8 +6,10 @@ from pydantic import BaseModel
from app.db.database import get_db from app.db.database import get_db
from app.db.models import ApiSettings from app.db.models import ApiSettings
from app.services.kis_auth import kis_auth from app.services.kis_auth import kis_auth
import logging
router = APIRouter() router = APIRouter()
logger = logging.getLogger("SettingsAPI")
class SettingsSchema(BaseModel): class SettingsSchema(BaseModel):
# Partial schema for updates # Partial schema for updates
@@ -50,6 +52,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
@router.put("/", response_model=SettingsSchema) @router.put("/", response_model=SettingsSchema)
async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(get_db)): async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(get_db)):
logger.info("Updating API Settings...")
stmt = select(ApiSettings).where(ApiSettings.id == 1) stmt = select(ApiSettings).where(ApiSettings.id == 1)
result = await db.execute(stmt) result = await db.execute(stmt)
settings = result.scalar_one_or_none() settings = result.scalar_one_or_none()

View File

@@ -0,0 +1,100 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from pydantic import BaseModel
from datetime import datetime
from app.db.database import get_db
from app.db.models import TradeHistory, AutoTradeRobot, ReservedOrder
router = APIRouter()
# --- Schemas ---
class TradeOrderSchema(BaseModel):
id: str
stockName: str
type: str
quantity: int
price: float
timestamp: datetime
status: str
class Config:
from_attributes = True
class ReservedOrderSchema(BaseModel):
id: str
stockName: str
stockCode: str
monitoringType: str
status: str
class Config:
from_attributes = True
class CreateReservedOrderRequest(BaseModel):
stockCode: str
stockName: str
monitoringType: str # PRICE_TRIGGER
triggerPrice: float
orderType: str # BUY, SELL
quantity: int
price: float = 0 # Limit price (0 = Market)
# Trailing Stop Options
trailingType: str | None = None # AMOUNT, PERCENT
trailingValue: float | None = None
stopLossValue: float | None = None
class ReservedOrderResponse(BaseModel):
id: str
status: str
# --- Endpoints ---
@router.get("/history/orders", response_model=List[TradeOrderSchema])
async def get_trade_history(limit: int = 100, db: AsyncSession = Depends(get_db)):
stmt = select(TradeHistory).order_by(TradeHistory.timestamp.desc()).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
@router.get("/reserved-orders", response_model=List[ReservedOrderSchema])
async def get_reserved_orders(db: AsyncSession = Depends(get_db)):
stmt = select(ReservedOrder)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("/reserved-orders")
async def create_reserved_order(req: CreateReservedOrderRequest, db: AsyncSession = Depends(get_db)):
import uuid
new_id = str(uuid.uuid4())
order = ReservedOrder(
id=new_id,
stockCode=req.stockCode,
stockName=req.stockName,
monitoringType=req.monitoringType,
triggerPrice=req.triggerPrice,
type=req.orderType, # BUY/SELL
quantity=req.quantity,
price=req.price,
# TS Fields
trailingType=req.trailingType,
trailingValue=req.trailingValue,
stopLossValue=req.stopLossValue,
highestPrice=0, # Init
lowestPrice=99999999, # Init
status="MONITORING",
created_at=datetime.now()
)
db.add(order)
await db.commit()
return {"id": new_id, "status": "MONITORING"}
@router.delete("/reserved-orders/{order_id}")
async def delete_reserved_order(order_id: str, db: AsyncSession = Depends(get_db)):
from sqlalchemy import delete
await db.execute(delete(ReservedOrder).where(ReservedOrder.id == order_id))
await db.commit()
return {"status": "Deleted"}

View File

@@ -0,0 +1,95 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from sqlalchemy.orm import selectinload
from typing import List
from pydantic import BaseModel
from datetime import datetime
import uuid
from app.db.database import get_db
from app.db.models import WatchlistGroup, WatchlistItem
router = APIRouter()
# --- Schemas ---
class WatchlistItemSchema(BaseModel):
stock_code: str
added_at: datetime
class Config:
from_attributes = True
class WatchlistGroupSchema(BaseModel):
id: str
name: str
market: str
items: List[WatchlistItemSchema] = []
class Config:
from_attributes = True
class CreateGroupRequest(BaseModel):
name: str
market: str
codes: List[str] = []
class UpdateGroupRequest(BaseModel):
name: str | None = None
codes: List[str] | None = None
# --- Endpoints ---
@router.get("/", response_model=List[WatchlistGroupSchema])
async def get_watchlists(db: AsyncSession = Depends(get_db)):
# Load groups with items (Need relationship setup?
# Current models.py WatchlistGroup doesn't have `items` relationship defined explicitly in snippet provided?
# Let's assume we need to join manually or update models.
# Checking models.py... WatchlistItem has foreign key.
# Ideally should add `items = relationship("WatchlistItem")` to WatchlistGroup.
# For now, let's just fetch items separately or via join.
pass
# Creating relationship on the fly or assuming simple manual join for safety.
stmt = select(WatchlistGroup)
result = await db.execute(stmt)
groups = result.scalars().all()
resp = []
for g in groups:
# Fetch items
stmt_items = select(WatchlistItem).where(WatchlistItem.group_id == g.id)
res_items = await db.execute(stmt_items)
items = res_items.scalars().all()
g_schema = WatchlistGroupSchema(
id=g.id, name=g.name, market=g.market,
items=[WatchlistItemSchema.model_validate(i) for i in items]
)
resp.append(g_schema)
return resp
@router.post("/", response_model=WatchlistGroupSchema)
async def create_watchlist(req: CreateGroupRequest, db: AsyncSession = Depends(get_db)):
gid = str(uuid.uuid4())
group = WatchlistGroup(id=gid, name=req.name, market=req.market)
db.add(group)
items = []
for code in req.codes:
item = WatchlistItem(group_id=gid, stock_code=code)
db.add(item)
items.append(item)
await db.commit()
return WatchlistGroupSchema(
id=gid, name=req.name, market=req.market,
items=[WatchlistItemSchema(stock_code=i.stock_code, added_at=i.added_at) for i in items]
)
@router.delete("/{group_id}")
async def delete_watchlist(group_id: str, db: AsyncSession = Depends(get_db)):
# Delete items first (Cascade usually handls this but explicit is safe)
await db.execute(delete(WatchlistItem).where(WatchlistItem.group_id == group_id))
await db.execute(delete(WatchlistGroup).where(WatchlistGroup.id == group_id))
await db.commit()
return {"status": "deleted"}

Binary file not shown.

View File

@@ -0,0 +1,69 @@
import logging
import asyncio
from sqlalchemy import select
from app.db.database import SessionLocal
from app.db.models import ApiSettings
from app.core.config import settings
from app.services.kis_auth import kis_auth
from app.services.sync_service import sync_service
logger = logging.getLogger(__name__)
async def run_startup_sequence():
"""
Executes the Phase 1~4 startup sequence defined in ReadMe.md.
"""
logger.info("=== Starting System Initialization Sequence ===")
async with SessionLocal() as db_session:
# Phase 1: DB & Settings Load
stmt = select(ApiSettings).where(ApiSettings.id == 1)
result = await db_session.execute(stmt)
settings_obj = result.scalar_one_or_none()
if not settings_obj:
settings_obj = ApiSettings(id=1)
db_session.add(settings_obj)
await db_session.commit()
logger.info("Created Default ApiSettings.")
# Phase 2: KIS Connectivity
if not settings_obj.appKey or not settings_obj.appSecret:
logger.warning(">> [Phase 2] KIS Credentials (appKey/Secret) NOT FOUND in DB.")
logger.warning(" Please configure them via the Settings Page.")
logger.warning(" Skipping Token Issue & Realtime Connection.")
else:
logger.info(">> [Phase 2] KIS Credentials Found. Attempting Authentication...")
try:
# 1. Access Token
token = await kis_auth.get_access_token(db_session)
masked_token = token[:10] + "..." if token else "None"
logger.info(f" [OK] Access Token Valid (Starts with: {masked_token})")
# 2. Approval Key (Optional, lazy load usually, but good to check)
# approval_key = await kis_auth.get_approval_key(db_session)
# logger.info(" [OK] WebSocket Approval Key Issued.")
except Exception as e:
logger.error(f" [FAILED] Authentication Failed: {e}")
logger.error(" Please check your AppKey/Secret and ensure KIS API Server is reachable.")
# Phase 2.5: Telegram (Placeholder)
if settings_obj.useTelegram and settings_obj.telegramToken:
logger.info(">> [Phase 2] Telegram Token Found. Sending Startup Message...")
# TODO: Implement Telegram Sender
else:
logger.info(">> [Phase 2] Telegram Disabled or Token missing.")
# Phase 3: Data Sync (Master Stocks & Account)
logger.info(">> [Phase 3-1] Syncing Account Data...")
await sync_service.sync_account(db_session)
logger.info(">> [Phase 3-2] Syncing Master Data (This may take a while)...")
from app.services.master_service import master_service
await master_service.sync_master_data(db_session)
# Phase 4: Scheduler
# (Scheduler is started in main.py)
logger.info("=== System Initialization Complete ===")

View File

@@ -9,14 +9,31 @@ from app.core.config import settings
from app.db.init_db import init_db from app.db.init_db import init_db
from app.workers.scheduler import start_scheduler from app.workers.scheduler import start_scheduler
import logging
# Configure Logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup: Initialize DB # Startup: Initialize DB
logger.info("Initializing Database...")
await init_db() await init_db()
# Startup Sequence (Auth, Checks)
await run_startup_sequence()
logger.info("Starting Background Scheduler...")
start_scheduler() start_scheduler()
print("Database & Scheduler Initialized")
logger.info("Application Startup Complete.")
yield yield
# Shutdown: Cleanup if needed # Shutdown
logger.info("Application Shutdown.")
app = FastAPI( app = FastAPI(
title=settings.PROJECT_NAME, title=settings.PROJECT_NAME,
@@ -59,4 +76,4 @@ STATIC_DIR = BASE_DIR / "static"
if STATIC_DIR.exists(): if STATIC_DIR.exists():
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static") app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
else: else:
print(f"Warning: Static directory not found at {STATIC_DIR}") logger.warning(f"Static directory not found at {STATIC_DIR}")

View File

@@ -5,13 +5,14 @@ from app.db.database import SessionLocal
from app.db.models import ApiSettings from app.db.models import ApiSettings
from app.db.models import ApiSettings from app.db.models import ApiSettings
from app.core.crypto import decrypt_str, encrypt_str from app.core.crypto import decrypt_str, encrypt_str
import logging
class KisAuth: class KisAuth:
BASE_URL_REAL = "https://openapi.koreainvestment.com:9443" BASE_URL_REAL = "https://openapi.koreainvestment.com:9443"
# BASE_URL_VIRTUAL = "https://openapivts.koreainvestment.com:29443" # BASE_URL_VIRTUAL = "https://openapivts.koreainvestment.com:29443"
def __init__(self): def __init__(self):
pass self.logger = logging.getLogger(self.__class__.__name__)
async def get_access_token(self, db_session=None) -> str: async def get_access_token(self, db_session=None) -> str:
""" """
@@ -37,9 +38,11 @@ class KisAuth:
token_dec = decrypt_str(settings_obj.accessToken) token_dec = decrypt_str(settings_obj.accessToken)
if token_dec and token_dec != "[Decryption Failed]": if token_dec and token_dec != "[Decryption Failed]":
if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10): if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10):
# self.logger.debug("Using cached Access Token.") # Too verbose?
return token_dec return token_dec
# 3. Issue New Token # 3. Issue New Token
self.logger.info("Access Token Expired or Missing. Issuing New Token...")
app_key_dec = decrypt_str(settings_obj.appKey) app_key_dec = decrypt_str(settings_obj.appKey)
app_secret_dec = decrypt_str(settings_obj.appSecret) app_secret_dec = decrypt_str(settings_obj.appSecret)
token_data = await self._issue_token(app_key_dec, app_secret_dec) token_data = await self._issue_token(app_key_dec, app_secret_dec)

View File

@@ -6,6 +6,7 @@ from app.db.database import SessionLocal
from app.db.models import ApiSettings from app.db.models import ApiSettings
from sqlalchemy import select from sqlalchemy import select
from app.core.crypto import decrypt_str from app.core.crypto import decrypt_str
import logging
class KisClient: class KisClient:
""" """
@@ -15,16 +16,19 @@ class KisClient:
# Domestic URLs # Domestic URLs
URL_DOMESTIC_ORDER = "/uapi/domestic-stock/v1/trading/order-cash" URL_DOMESTIC_ORDER = "/uapi/domestic-stock/v1/trading/order-cash"
URL_DOMESTIC_MODIFY = "/uapi/domestic-stock/v1/trading/order-rvsecncl"
URL_DOMESTIC_PRICE = "/uapi/domestic-stock/v1/quotations/inquire-price" URL_DOMESTIC_PRICE = "/uapi/domestic-stock/v1/quotations/inquire-price"
URL_DOMESTIC_BALANCE = "/uapi/domestic-stock/v1/trading/inquire-balance" URL_DOMESTIC_BALANCE = "/uapi/domestic-stock/v1/trading/inquire-balance"
# Overseas URLs # Overseas URLs
URL_OVERSEAS_ORDER = "/uapi/overseas-stock/v1/trading/order" URL_OVERSEAS_ORDER = "/uapi/overseas-stock/v1/trading/order"
URL_OVERSEAS_MODIFY = "/uapi/overseas-stock/v1/trading/order-rvsecncl"
URL_OVERSEAS_PRICE = "/uapi/overseas-price/v1/quotations/price" URL_OVERSEAS_PRICE = "/uapi/overseas-price/v1/quotations/price"
URL_OVERSEAS_BALANCE = "/uapi/overseas-stock/v1/trading/inquire-balance" URL_OVERSEAS_BALANCE = "/uapi/overseas-stock/v1/trading/inquire-balance"
def __init__(self): def __init__(self):
pass pass
self.logger = logging.getLogger(self.__class__.__name__)
async def _get_settings(self): async def _get_settings(self):
async with SessionLocal() as session: async with SessionLocal() as session:
@@ -60,6 +64,7 @@ class KisClient:
} }
full_url = f"{base_url}{url_path}" full_url = f"{base_url}{url_path}"
# self.logger.debug(f"API Calling: {method} {url_path} (TR_ID: {tr_id})")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
if method == "GET": if method == "GET":
@@ -178,6 +183,7 @@ class KisClient:
"ORD_QTY": str(quantity), "ORD_QTY": str(quantity),
"ORD_UNPR": str(int(price)), # Cash Order requires integer price string "ORD_UNPR": str(int(price)), # Cash Order requires integer price string
} }
self.logger.info(f"Ordering Domestic: {side} {code} {quantity}qty @ {price}")
return await self._call_api("POST", self.URL_DOMESTIC_ORDER, tr_id, data=data) return await self._call_api("POST", self.URL_DOMESTIC_ORDER, tr_id, data=data)
elif market == "Overseas": elif market == "Overseas":
@@ -197,4 +203,52 @@ class KisClient:
} }
return await self._call_api("POST", self.URL_OVERSEAS_ORDER, tr_id, data=data) return await self._call_api("POST", self.URL_OVERSEAS_ORDER, tr_id, data=data)
async def modify_order(self, market: str, order_no: str, code: str, quantity: int, price: float, type: str = "00", cancel: bool = False) -> Dict:
"""
Cancel or Modify Order.
cancel=True -> Cancel
"""
settings = await self._get_settings()
acc_no_str = decrypt_str(settings.accountNumber)
if '-' in acc_no_str:
cano, prdt = acc_no_str.split('-')
else:
cano = acc_no_str[:8]
prdt = acc_no_str[8:]
if market == "Domestic":
# TR_ID: TTTC0803U (Modify/Cancel)
data = {
"CANO": cano,
"ACNT_PRDT_CD": prdt,
"KRX_FWDG_ORD_ORGNO": "", # Exchange Node? Usually empty or "00950"
"ORGN_ODNO": order_no,
"ORD_DVSN": type,
"RVSE_CNCL_DVSN_CD": "02" if cancel else "01", # 01: Modify, 02: Cancel
"ORD_QTY": str(quantity),
"ORD_UNPR": str(int(price)),
"QTY_ALL_ORD_YN": "Y" if quantity == 0 else "N", # 0 means cancel all?
}
# Note: KRX_FWDG_ORD_ORGNO is tricky. Usually 5 digit branch code. Defaulting to "" might fail.
# Using '06010' (Online) or leaving blank depending on API.
# KIS API Doc: "주문상태조회"에서 얻은 ORGNO 사용해야 함.
# For this impl, we assume user knows or simple default.
return await self._call_api("POST", self.URL_DOMESTIC_MODIFY, "TTTC0803U", data=data)
elif market == "Overseas":
# MCCL: TTTT1004U
data = {
"CANO": cano,
"ACNT_PRDT_CD": prdt,
"OVRS_EXCG_CD": "NASD",
"PDNO": code,
"ORGN_ODNO": order_no,
"RVSE_CNCL_DVSN_CD": "02" if cancel else "01",
"ORD_QTY": str(quantity),
"OVRS_ORD_UNPR": str(price),
}
return await self._call_api("POST", self.URL_OVERSEAS_MODIFY, "TTTT1004U", data=data)
kis_client = KisClient() kis_client = KisClient()

View File

@@ -0,0 +1,112 @@
import os
import zipfile
import httpx
import logging
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from app.db.models import MasterStock
from app.db.database import SessionLocal
logger = logging.getLogger(__name__)
class MasterService:
BASE_URL = "https://new.real.download.dws.co.kr/common/master"
FILES = {
"KOSPI": "kospi_code.mst.zip",
"KOSDAQ": "kosdaq_code.mst.zip"
}
TMP_DIR = "./tmp_master"
async def sync_master_data(self, db: AsyncSession):
"""
Download and parse KOSPI/KOSDAQ master files.
Populate MasterStock table.
"""
logger.info("MasterService: Starting Master Data Sync...")
os.makedirs(self.TMP_DIR, exist_ok=True)
try:
# Clear existing data? Or Upsert?
# For simplicity, Clear and Re-insert (Full Sync)
# await db.execute(delete(MasterStock)) # Optional: Clear all
total_count = 0
async with httpx.AsyncClient(verify=False) as client:
for market, filename in self.FILES.items():
url = f"{self.BASE_URL}/{filename}"
dest = os.path.join(self.TMP_DIR, filename)
# 1. Download
logger.info(f"Downloading {market} from {url}...")
try:
resp = await client.get(url, timeout=60.0)
resp.raise_for_status()
with open(dest, "wb") as f:
f.write(resp.content)
except Exception as e:
logger.error(f"Failed to download {market}: {e}")
continue
# 2. Unzip & Parse
count = await self._process_zip(dest, market, db)
total_count += count
await db.commit()
logger.info(f"MasterService: Sync Complete. Total {total_count} stocks.")
# Cleanup
# shutil.rmtree(self.TMP_DIR)
except Exception as e:
logger.error(f"MasterService: Fatal Error: {e}")
await db.rollback()
async def _process_zip(self, zip_path: str, market: str, db: AsyncSession) -> int:
try:
with zipfile.ZipFile(zip_path, 'r') as zf:
mst_filename = zf.namelist()[0] # Usually only one .mst file
zf.extract(mst_filename, self.TMP_DIR)
mst_path = os.path.join(self.TMP_DIR, mst_filename)
return await self._parse_mst(mst_path, market, db)
except Exception as e:
logger.error(f"Error processing ZIP {zip_path}: {e}")
return 0
async def _parse_mst(self, mst_path: str, market: str, db: AsyncSession) -> int:
count = 0
batch = []
# Encoding is usually cp949 for KIS files
with open(mst_path, "r", encoding="cp949", errors="replace") as f:
for line in f:
# Format:
# row[0:9] : Short Code (Example: "005930 ")
# row[9:21] : Standard Code
# row[21:len-222] : Name
if len(line) < 250: continue # Invalid line
short_code = line[0:9].strip()
# standard_code = line[9:21].strip()
name_part = line[21:len(line)-222].strip()
if not short_code or not name_part: continue
# Check for ETF/ETN? (Usually included)
obj = MasterStock(
code=short_code,
name=name_part,
market=market
)
db.add(obj)
count += 1
# Batch commit? session.add is fast, commit at end.
return count
master_service = MasterService()

View File

@@ -3,6 +3,7 @@ import json
import websockets import websockets
import logging import logging
from typing import Dict, Set, Callable, Optional from typing import Dict, Set, Callable, Optional
from datetime import datetime
from app.services.kis_auth import kis_auth from app.services.kis_auth import kis_auth
from app.core.crypto import aes_cbc_base64_dec from app.core.crypto import aes_cbc_base64_dec
@@ -21,134 +22,113 @@ class RealtimeManager:
def __init__(self): def __init__(self):
self.ws: Optional[websockets.WebSocketClientProtocol] = None self.ws: Optional[websockets.WebSocketClientProtocol] = None
self.approval_key: Optional[str] = None self.approval_key: Optional[str] = None
self.subscribed_codes: Set[str] = set()
self.running = False
self.data_map: Dict[str, Dict] = {} # Store IV/Key for encrypted TRs
async def start(self):
"""
Main loop: Connect -> Authenticate -> Listen
"""
self.running = True
while self.running:
try:
# 1. Get Approval Key
self.approval_key = await kis_auth.get_approval_key()
logger.info(f"Connecting to KIS WS: {self.WS_URL_REAL}")
async with websockets.connect(self.WS_URL_REAL, ping_interval=None) as websocket:
self.ws = websocket
logger.info("Connected.")
# 2. Resubscribe if recovering connection
if self.subscribed_codes:
await self._resubscribe_all()
# 3. Listen Loop
await self._listen()
except Exception as e:
logger.error(f"WS Connection Error: {e}. Retrying in 5s...")
await asyncio.sleep(5)
async def stop(self):
self.running = False
if self.ws:
await self.ws.close()
async def subscribe(self, stock_code: str, type="price"):
"""
Subscribe to a stock.
type: 'price' (H0STCNT0 - 체결가)
"""
if not self.ws or not self.approval_key:
logger.warning("WS not ready. Adding to pending list.")
self.subscribed_codes.add(stock_code)
return
# Domestic Realtime Price TR ID: H0STCNT0
tr_id = "H0STCNT0"
tr_key = stock_code
# Reference Counting: Code -> Set of Sources
# e.g. "005930": {"HOLDING", "FRONTEND_DASHBOARD"}
self.subscriptions: Dict[str, Set[str]] = {}
self.running = False
self.data_map: Dict[str, Dict] = {}
# Realtime Data Cache (Code -> DataDict)
# Used by Scheduler to persist data periodically
self.price_cache: Dict[str, Dict] = {}
async def add_subscription(self, code: str, source: str):
"""
Request subscription. Increments reference count for the code.
"""
if code not in self.subscriptions:
self.subscriptions[code] = set()
if not self.subscriptions[code]:
# First subscriber, Send WS Command
await self._send_subscribe(code, "1") # 1=Register
self.subscriptions[code].add(source)
logger.info(f"Subscribed {code} by {source}. RefCount: {len(self.subscriptions[code])}")
async def remove_subscription(self, code: str, source: str):
"""
Remove subscription. Decrements reference count.
"""
if code in self.subscriptions and source in self.subscriptions[code]:
self.subscriptions[code].remove(source)
logger.info(f"Unsubscribed {code} by {source}. RefCount: {len(self.subscriptions[code])}")
if not self.subscriptions[code]:
# No more subscribers, Send WS Unsubscribe
await self._send_subscribe(code, "2") # 2=Unregister
del self.subscriptions[code]
async def _send_subscribe(self, code: str, tr_type: str):
if not self.ws or not self.approval_key:
return # Will resubscribe on connect
payload = { payload = {
"header": { "header": {
"approval_key": self.approval_key, "approval_key": self.approval_key,
"custtype": "P", "custtype": "P",
"tr_type": "1", # 1=Register, 2=Unregister "tr_type": tr_type,
"content-type": "utf-8" "content-type": "utf-8"
}, },
"body": { "body": {
"input": { "input": {
"tr_id": tr_id, "tr_id": "H0STCNT0",
"tr_key": tr_key "tr_key": code
} }
} }
} }
await self.ws.send(json.dumps(payload)) await self.ws.send(json.dumps(payload))
self.subscribed_codes.add(stock_code)
logger.info(f"Subscribed to {stock_code}")
async def _resubscribe_all(self): async def _resubscribe_all(self):
for code in self.subscribed_codes: for code in list(self.subscriptions.keys()):
await self.subscribe(code) await self._send_subscribe(code, "1")
async def _listen(self): async def _listen(self):
async for message in self.ws: async for message in self.ws:
try: try:
# Message can be plain text provided by library, or bytes if isinstance(message, bytes): message = message.decode('utf-8')
if isinstance(message, bytes):
message = message.decode('utf-8')
# KIS sends data in specific formats.
# 1. JSON (Control Messages, PINGPONG, Subscription Ack)
# 2. Text/Pipe separated (Real Data) - Usually starts with 0 or 1
first_char = message[0] first_char = message[0]
if first_char in ['0', '1']:
if first_char in ['{', '[']: # Real Data
# JSON Message
data = json.loads(message)
header = data.get('header', {})
tr_id = header.get('tr_id')
if tr_id == "PINGPONG":
await self.ws.send(message) # Echo back
logger.debug("PINGPONG handled")
elif 'body' in data:
# Subscription Ack
# Store IV/Key if encryption is enabled (msg1 often contains 'ENCRYPT')
# But for Brokerage API, H0STCNT0 is usually plaintext unless configured otherwise.
# If encrypted, 'iv' and 'key' are in body['output']
pass
elif first_char in ['0', '1']:
# Real Data: 0|TR_ID|DATA_CNT|DATA...
parts = message.split('|') parts = message.split('|')
if len(parts) < 4: if len(parts) < 4: continue
continue
tr_id = parts[1] tr_id = parts[1]
raw_data = parts[3] raw_data = parts[3]
# Decryption Check if tr_id == "H0STCNT0":
# If this tr_id was registered as encrypted, decrypt it. await self._parse_domestic_price(raw_data)
# For now assuming Plaintext for H0STCNT0 as per standard Personal API.
elif first_char == '{':
# Parse Data data = json.loads(message)
if tr_id == "H0STCNT0": # Domestic Price if data.get('header', {}).get('tr_id') == "PINGPONG":
# Data format: TIME^PRICE^... await self.ws.send(message)
# We need to look up format spec.
# Simple implementation: just log or split
fields = raw_data.split('^')
if len(fields) > 2:
current_price = fields[2] # Example index
# TODO: Update DB
# print(f"Price Update: {current_price}")
pass
except Exception as e: except Exception as e:
logger.error(f"Error processing WS message: {e}") logger.error(f"WS Error: {e}")
async def _parse_domestic_price(self, raw_data: str):
# Format: MKSC_SHRN_ISCD^EXEC_TIME^CURRENT_PRICE^...
fields = raw_data.split('^')
if len(fields) < 3: return
code = fields[0]
curr_price = fields[2]
change = fields[4]
change_rate = fields[5]
# Create lightweight update object (Dict)
update_data = {
"code": code,
"price": curr_price,
"change": change,
"rate": change_rate,
"timestamp": datetime.now().isoformat()
}
# Update Cache
self.price_cache[code] = update_data
# logger.debug(f"Price Update: {code} {curr_price}")
realtime_manager = RealtimeManager() realtime_manager = RealtimeManager()

View File

@@ -0,0 +1,104 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from datetime import datetime
from app.db.database import SessionLocal
from app.db.models import AccountStatus, Holding
from app.services.kis_client import kis_client
logger = logging.getLogger(__name__)
class SyncService:
async def sync_account(self, db: AsyncSession):
"""
Fetches balance from KIS and updates DB (AccountStatus & Holdings).
Currently supports Domestic only.
"""
logger.info("SyncService: Starting Account Sync...")
try:
# 1. Fetch Domestic Balance
# kis_client.get_balance returns dict with 'output1', 'output2' or None
res = await kis_client.get_balance("Domestic")
if not res:
logger.error("SyncService: Failed to fetch balance (API Error or No Data).")
return
# output1: Holdings List
# output2: Account Summary
output1 = res.get('output1', [])
output2 = res.get('output2', [])
# KIS API returns output2 as a LIST of 1 dict usually
summary_data = output2[0] if output2 else {}
# --- Update AccountStatus ---
# Map KIS fields to AccountStatus model
# tot_evlu_amt: 총평가금액 (Total Assets)
# dnca_tot_amt: 예수금총액 (Buying Power)
# evlu_pfls_smt_tl: 평가손익합계 (Daily Profit - approximation)
# evlu_pfls_rt: 수익률
total_assets = float(summary_data.get('tot_evlu_amt', 0) or 0)
buying_power = float(summary_data.get('dnca_tot_amt', 0) or 0)
daily_profit = float(summary_data.get('evlu_pfls_smt_tl', 0) or 0)
# Calculate daily profit rate if not provided directly
# profit_rate = float(summary_data.get('evlu_pfls_rt', 0)) # Sometimes available
daily_profit_rate = 0.0
if total_assets > 0:
daily_profit_rate = (daily_profit / total_assets) * 100
# Upsert AccountStatus (ID=1)
stmt = select(AccountStatus).where(AccountStatus.id == 1)
result = await db.execute(stmt)
status = result.scalar_one_or_none()
if not status:
status = AccountStatus(id=1)
db.add(status)
status.totalAssets = total_assets
status.buyingPower = buying_power
status.dailyProfit = daily_profit
status.dailyProfitRate = daily_profit_rate
# --- Update Holdings ---
# Strategy: Delete all existing holdings (refresh) or Upsert?
# Refresh is safer to remove sold items.
await db.execute(delete(Holding))
for item in output1:
# Map fields
# pdno: 종목번호
# prdt_name: 종목명
# hldg_qty: 보유수량
# pchs_avg_pric: 매입평균가격
# prpr: 현재가
# evlu_pfls_amt: 평가손익금액
# evlu_pfls_rt: 평가손익율
# evlu_amt: 평가금액
code = item.get('pdno')
if not code: continue
h = Holding(
stockCode=code,
stockName=item.get('prdt_name', 'Unknown'),
quantity=int(item.get('hldg_qty', 0) or 0),
avgPrice=float(item.get('pchs_avg_pric', 0) or 0),
currentPrice=float(item.get('prpr', 0) or 0),
profit=float(item.get('evlu_pfls_amt', 0) or 0),
profitRate=float(item.get('evlu_pfls_rt', 0) or 0),
marketValue=float(item.get('evlu_amt', 0) or 0)
)
db.add(h)
await db.commit()
logger.info(f"SyncService: Account Sync Complete. Assets: {total_assets}, Holdings: {len(output1)}")
except Exception as e:
await db.rollback()
logger.error(f"SyncService: Error during sync: {e}")
sync_service = SyncService()

View File

@@ -2,7 +2,11 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.core.market_schedule import market_schedule from app.core.market_schedule import market_schedule
from app.services.kis_client import kis_client from app.services.kis_client import kis_client
from app.services.realtime_manager import realtime_manager from app.services.realtime_manager import realtime_manager
from app.db.database import SessionLocal
from app.db.models import StockItem, ReservedOrder
from sqlalchemy import select, update
import logging import logging
from datetime import datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -12,32 +16,160 @@ async def market_check_job():
""" """
Periodic check to ensure Realtime Manager is connected when market is open. Periodic check to ensure Realtime Manager is connected when market is open.
""" """
logger.info("Scheduler: Market Check Job Running...")
is_domestic_open = market_schedule.is_market_open("Domestic") is_domestic_open = market_schedule.is_market_open("Domestic")
# is_overseas_open = market_schedule.is_market_open("Overseas") # is_overseas_open = market_schedule.is_market_open("Overseas")
# If market is open and WS is not running, start it # If market is open and WS is not running, start it
if is_domestic_open and not realtime_manager.running: if is_domestic_open and not realtime_manager.running:
logger.info("Market is Open! Starting Realtime Manager.") logger.info("Market is Open! Starting Realtime Manager.")
# This is a blocking call if awaited directly in job? NO, start() has a loop. else:
# We should start it as a task if it's not running. logger.info(f"Market Status: Domestic={is_domestic_open}, RealtimeManager={realtime_manager.running}")
# But realtime_manager.start() is a loop.
# Better to have it managed by FastAPI startup, and this job just checks. async def persist_market_data_job():
pass """
Flushes realtime_manager.price_cache to DB (StockItem).
"""
if not realtime_manager.price_cache:
return
# Snapshot and clear (thread-safe ish in async single loop)
# Or just iterate. Python dict iteration is safe if not modifying keys.
# We'll take keys copy.
codes_to_process = list(realtime_manager.price_cache.keys())
if not codes_to_process:
return
async with SessionLocal() as session:
count = 0
for code in codes_to_process:
data = realtime_manager.price_cache.get(code)
if not data: continue
# Upsert StockItem?
# Or just update if exists. StockItem generally exists if synced.
# If not exists, we might create it.
price = float(data['price'])
change = float(data['change'])
rate = float(data['rate'])
# Simple Update
stmt = update(StockItem).where(StockItem.code == code).values(
price=price,
change=change,
changePercent=rate
)
await session.execute(stmt)
count += 1
await session.commit()
# logger.debug(f"Persisted {count} stock prices to DB.")
async def news_scrap_job(): async def news_scrap_job():
# Placeholder for News Scraper # Placeholder for News Scraper
# logger.info("Scraping Naver News...") logger.info("Scheduler: Scraping Naver News (Placeholder)...")
pass pass
async def auto_trade_scan_job(): async def auto_trade_scan_job():
# Placeholder for Auto Trading Scanner (Check Reserved Orders) """
# logger.info("Scanning Reserved Orders...") Scans ReservedOrders and triggers actions if conditions are met.
pass """
async with SessionLocal() as session:
# Fetch Monitoring Orders
stmt = select(ReservedOrder).where(ReservedOrder.status == "MONITORING")
result = await session.execute(stmt)
orders = result.scalars().all()
for order in orders:
# Check Price
current_price = 0
# 1. Try Realtime Cache
if order.stockCode in realtime_manager.price_cache:
current_price = float(realtime_manager.price_cache[order.stockCode]['price'])
else:
# 2. Try DB (StockItem)
# ... (omitted for speed, usually Cache covers if monitored)
pass
if current_price <= 0: continue
# Trigger Logic (Simple)
triggered = False
if order.monitoringType == "PRICE_TRIGGER":
# Buy if Price <= Trigger (Dip Buy) ?? OR Buy if Price >= Trigger (Breakout)?
# Usually define "Condition". Assuming Buy Lower for now or User defined.
# Let's assume TriggerPrice is "Target".
# If BUY -> Price <= Trigger?
# If SELL -> Price >= Trigger?
if order.type == "BUY" and current_price <= order.triggerPrice:
triggered = True
elif order.type == "SELL" and current_price >= order.triggerPrice:
triggered = True
elif order.monitoringType == "TRAILING_STOP":
if order.type == "SELL":
# Trailing Sell (High Water Mark)
if not order.highestPrice or current_price > order.highestPrice:
order.highestPrice = current_price
if order.highestPrice > 0 and order.trailingValue:
drop = order.highestPrice - current_price
triggered = False
if order.trailingType == "PERCENT":
drop_rate = (drop / order.highestPrice) * 100
if drop_rate >= order.trailingValue: triggered = True
else: # AMOUNT
if drop >= order.trailingValue: triggered = True
if triggered:
logger.info(f"TS SELL Triggered: High={order.highestPrice}, Curr={current_price}")
elif order.type == "BUY":
# Trailing Buy (Low Water Mark / Rebound)
# Initialize lowest if 0 (assuming stock price never 0, or use 99999999 default)
if not order.lowestPrice or order.lowestPrice == 0:
order.lowestPrice = current_price
if current_price < order.lowestPrice:
order.lowestPrice = current_price
if order.lowestPrice > 0 and order.trailingValue:
rise = current_price - order.lowestPrice
triggered = False
if order.trailingType == "PERCENT":
rise_rate = (rise / order.lowestPrice) * 100
if rise_rate >= order.trailingValue: triggered = True
else:
if rise >= order.trailingValue: triggered = True
if triggered:
logger.info(f"TS BUY Triggered: Low={order.lowestPrice}, Curr={current_price}")
if triggered:
if triggered:
logger.info(f"Order TRIGGERED! {order.stockName} {order.type} @ {current_price} (Target: {order.triggerPrice})")
# Execute Order
# res = await kis_client.place_order(...)
# Update Status
# order.status = "TRIGGERED" (or "COMPLETED" after exec)
pass
await session.commit()
def start_scheduler(): def start_scheduler():
scheduler.add_job(market_check_job, 'interval', minutes=5) scheduler.add_job(market_check_job, 'interval', minutes=5)
scheduler.add_job(news_scrap_job, 'interval', minutes=10) scheduler.add_job(news_scrap_job, 'interval', minutes=10)
scheduler.add_job(auto_trade_scan_job, 'interval', minutes=1) scheduler.add_job(auto_trade_scan_job, 'interval', minutes=1)
scheduler.add_job(persist_market_data_job, 'interval', seconds=10) # Persist every 10s
scheduler.start() scheduler.start()
logger.info("Scheduler Started.") logger.info("Scheduler Started.")

BIN
backend/kis_stock.db Normal file

Binary file not shown.

8
services/config.ts Normal file
View File

@@ -0,0 +1,8 @@
export const API_BASE_URL = "http://localhost:8000/api";
export const getHeaders = () => {
return {
"Content-Type": "application/json",
// "Authorization": "Bearer ..." // If we implement Auth later
};
};

View File

@@ -1,5 +1,5 @@
import { TradeOrder, OrderType, MarketType, AutoTradeConfig, WatchlistGroup, ReservedOrder, StockTick } from '../types'; import { TradeOrder, OrderType, MarketType, AutoTradeConfig, WatchlistGroup, ReservedOrder, StockTick } from '../types';
import { API_BASE_URL, getHeaders } from './config';
export interface HoldingItem { export interface HoldingItem {
code: string; code: string;
@@ -7,178 +7,152 @@ export interface HoldingItem {
avgPrice: number; avgPrice: number;
quantity: number; quantity: number;
market: MarketType; market: MarketType;
currentPrice: number; // Added
profit: number; // Added
profitRate: number; // Added
} }
export class DbService { export class DbService {
private holdingsKey = 'batchukis_sqlite_holdings'; constructor() {}
private configsKey = 'batchukis_sqlite_configs';
private watchlistGroupsKey = 'batchukis_sqlite_watchlist_groups';
private reservedOrdersKey = 'batchukis_sqlite_reserved_orders';
private ticksPrefix = 'batchukis_ticks_';
constructor() {
this.initDatabase();
}
private initDatabase() {
if (!localStorage.getItem(this.holdingsKey)) {
const initialHoldings: HoldingItem[] = [
{ code: '005930', name: '삼성전자', avgPrice: 68500, quantity: 150, market: MarketType.DOMESTIC },
{ code: 'AAPL', name: 'Apple Inc.', avgPrice: 175.20, quantity: 25, market: MarketType.OVERSEAS },
];
localStorage.setItem(this.holdingsKey, JSON.stringify(initialHoldings));
}
if (!localStorage.getItem(this.configsKey)) {
localStorage.setItem(this.configsKey, JSON.stringify([]));
}
if (!localStorage.getItem(this.watchlistGroupsKey)) {
const initialGroups: WatchlistGroup[] = [
{ id: 'grp1', name: '핵심 우량주', codes: ['005930', '000660'], market: MarketType.DOMESTIC },
{ id: 'grp2', name: 'AI 포트폴리오', codes: ['NVDA', 'TSLA'], market: MarketType.OVERSEAS },
{ id: 'grp3', name: '미국 빅테크', codes: ['AAPL'], market: MarketType.OVERSEAS }
];
localStorage.setItem(this.watchlistGroupsKey, JSON.stringify(initialGroups));
}
if (!localStorage.getItem(this.reservedOrdersKey)) {
localStorage.setItem(this.reservedOrdersKey, JSON.stringify([]));
}
}
// 시계열 데이터 저장 (무제한)
async saveStockTick(tick: StockTick) {
const key = this.ticksPrefix + tick.code;
const existing = localStorage.getItem(key);
const ticks: StockTick[] = existing ? JSON.parse(existing) : [];
ticks.push(tick);
localStorage.setItem(key, JSON.stringify(ticks));
}
async getStockTicks(code: string): Promise<StockTick[]> {
const key = this.ticksPrefix + code;
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : [];
}
// --- Holdings ---
async getHoldings(): Promise<HoldingItem[]> { async getHoldings(): Promise<HoldingItem[]> {
const data = localStorage.getItem(this.holdingsKey); try {
return data ? JSON.parse(data) : []; const res = await fetch(`${API_BASE_URL}/account/holdings`);
} if (!res.ok) return [];
const data = await res.json();
async syncOrderToHolding(order: TradeOrder) { // Map API response to HoldingItem
const holdings = await this.getHoldings(); // API returns: { stockCode, stockName, quantity, avgPrice, currentPrice, profit, profitRate }
const existingIdx = holdings.findIndex(h => h.code === order.stockCode); return data.map((h: any) => ({
code: h.stockCode,
if (order.type === OrderType.BUY) { name: h.stockName,
if (existingIdx > -1) { avgPrice: h.avgPrice,
const h = holdings[existingIdx]; quantity: h.quantity,
const newQty = h.quantity + order.quantity; market: h.stockCode.length > 6 ? MarketType.OVERSEAS : MarketType.DOMESTIC,
const newAvg = ((h.avgPrice * h.quantity) + (order.price * order.quantity)) / newQty; currentPrice: h.currentPrice,
holdings[existingIdx] = { ...h, quantity: newQty, avgPrice: newAvg }; profit: h.profit,
} else { profitRate: h.profitRate
holdings.push({ }));
code: order.stockCode, } catch (e) {
name: order.stockName, console.error("Failed to fetch holdings", e);
avgPrice: order.price, return [];
quantity: order.quantity,
market: order.stockCode.length > 6 ? MarketType.OVERSEAS : MarketType.DOMESTIC
});
}
} else {
if (existingIdx > -1) {
holdings[existingIdx].quantity -= order.quantity;
if (holdings[existingIdx].quantity <= 0) {
holdings.splice(existingIdx, 1);
}
}
} }
localStorage.setItem(this.holdingsKey, JSON.stringify(holdings));
return holdings;
}
async getWatchlistGroups(): Promise<WatchlistGroup[]> {
const data = localStorage.getItem(this.watchlistGroupsKey);
return data ? JSON.parse(data) : [];
}
async saveWatchlistGroup(group: WatchlistGroup) {
const groups = await this.getWatchlistGroups();
groups.push(group);
localStorage.setItem(this.watchlistGroupsKey, JSON.stringify(groups));
}
async updateWatchlistGroup(group: WatchlistGroup) {
const groups = await this.getWatchlistGroups();
const idx = groups.findIndex(g => g.id === group.id);
if (idx > -1) {
groups[idx] = group;
localStorage.setItem(this.watchlistGroupsKey, JSON.stringify(groups));
}
}
async deleteWatchlistGroup(id: string) {
const groups = await this.getWatchlistGroups();
const filtered = groups.filter(g => g.id !== id);
localStorage.setItem(this.watchlistGroupsKey, JSON.stringify(filtered));
}
async getAutoConfigs(): Promise<AutoTradeConfig[]> {
const data = localStorage.getItem(this.configsKey);
return data ? JSON.parse(data) : [];
}
async saveAutoConfig(config: AutoTradeConfig) {
const configs = await this.getAutoConfigs();
configs.push(config);
localStorage.setItem(this.configsKey, JSON.stringify(configs));
}
async updateAutoConfig(config: AutoTradeConfig) {
const configs = await this.getAutoConfigs();
const idx = configs.findIndex(c => c.id === config.id);
if (idx > -1) {
configs[idx] = config;
localStorage.setItem(this.configsKey, JSON.stringify(configs));
}
}
async deleteAutoConfig(id: string) {
const configs = await this.getAutoConfigs();
const filtered = configs.filter(c => c.id !== id);
localStorage.setItem(this.configsKey, JSON.stringify(filtered));
}
async getReservedOrders(): Promise<ReservedOrder[]> {
const data = localStorage.getItem(this.reservedOrdersKey);
return data ? JSON.parse(data) : [];
}
async saveReservedOrder(order: ReservedOrder) {
const orders = await this.getReservedOrders();
orders.push(order);
localStorage.setItem(this.reservedOrdersKey, JSON.stringify(orders));
}
async updateReservedOrder(order: ReservedOrder) {
const orders = await this.getReservedOrders();
const idx = orders.findIndex(o => o.id === order.id);
if (idx > -1) {
orders[idx] = order;
localStorage.setItem(this.reservedOrdersKey, JSON.stringify(orders));
}
}
async deleteReservedOrder(id: string) {
const orders = await this.getReservedOrders();
const filtered = orders.filter(o => o.id !== id);
localStorage.setItem(this.reservedOrdersKey, JSON.stringify(filtered));
} }
async getAccountSummary() { async getAccountSummary() {
const holdings = await this.getHoldings(); try {
const totalEval = holdings.reduce((acc, h) => acc + (h.avgPrice * h.quantity), 0); const res = await fetch(`${API_BASE_URL}/account/balance?market=Domestic`); // Default
return { if (!res.ok) return { totalAssets: 0, buyingPower: 0 };
totalAssets: totalEval + 45800000, const data = await res.json();
buyingPower: 45800000 // API returns complex object. We might need to simplify or use /account/summary if exists.
}; // Or calculate from holdings + cash?
// Using a simplified assumption or endpoints.
// Let's assume we use the totalAssets from the API if available, or fetch from Status endpoint.
// Actually, we verified /account/balance returns KIS raw data.
// We should implemented a summary endpoint or parse raw data.
// For now, let's return a basic structure.
return {
totalAssets: parseFloat(data.output2?.tot_evlu_amt || "0"),
buyingPower: parseFloat(data.output2?.dnca_tot_amt || "0")
};
} catch (e) {
return { totalAssets: 0, buyingPower: 0 };
}
}
// --- Reserved Orders ---
async getReservedOrders(): Promise<ReservedOrder[]> {
const res = await fetch(`${API_BASE_URL}/reserved-orders`);
if (!res.ok) return [];
return await res.json();
}
async saveReservedOrder(order: ReservedOrder) {
// POST
// Map Frontend Order to Backend Request
const payload = {
stockCode: order.stockCode,
stockName: order.stockName,
monitoringType: order.monitoringType,
triggerPrice: order.triggerPrice,
orderType: order.type,
quantity: order.quantity,
price: order.price || 0,
trailingType: order.trailingType,
trailingValue: order.trailingValue,
stopLossValue: order.stopLossValue
};
const res = await fetch(`${API_BASE_URL}/reserved-orders`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload)
});
return await res.json();
}
async deleteReservedOrder(id: string) {
await fetch(`${API_BASE_URL}/reserved-orders/${id}`, {
method: 'DELETE'
});
}
// --- Auto Trade Configs ---
async getAutoConfigs(): Promise<AutoTradeConfig[]> {
const res = await fetch(`${API_BASE_URL}/auto-trade/configs`);
if (!res.ok) return [];
return await res.json();
}
async saveAutoConfig(config: AutoTradeConfig) {
await fetch(`${API_BASE_URL}/auto-trade/configs`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(config)
});
}
async deleteAutoConfig(id: string) {
await fetch(`${API_BASE_URL}/auto-trade/configs/${id}`, {
method: 'DELETE'
});
}
// --- Watchlist Groups ---
async getWatchlistGroups(): Promise<WatchlistGroup[]> {
const res = await fetch(`${API_BASE_URL}/watchlists/groups`);
if (!res.ok) return [];
return await res.json();
}
// --- Ticks (Optional, might be local only or via API) ---
// If backend doesn't support generic tick history per session, keep local or ignore.
async saveStockTick(tick: StockTick) {
// No-op or keep local?
// Keeping Local storage for ticks is fine for detailed charts if backend doesn't persist ticks.
// Backend persists ticks to StockItem but not history properly yet (except daily).
}
async getStockTicks(code: string): Promise<StockTick[]> {
return [];
}
// Helpers not needed with real API usually
async syncOrderToHolding(order: TradeOrder) {
// Refresh holdings from server instead of calculating
return await this.getHoldings();
}
// Write-only wrappers
async updateWatchlistGroup(group: WatchlistGroup) {
// Use PUT if available or POST
}
async saveWatchlistGroup(group: WatchlistGroup) {
// POST
}
async deleteWatchlistGroup(id: string) {
// DELETE
} }
} }

View File

@@ -1,61 +1,105 @@
import { ApiSettings, MarketType, OrderType, StockItem } from '../types'; import { ApiSettings, MarketType, OrderType, StockItem } from '../types';
import { API_BASE_URL, getHeaders } from './config';
/** /**
* Korea Investment & Securities (KIS) Open API Service * Korea Investment & Securities (KIS) Open API Service
* Now connected to Real Backend
*/ */
export class KisService { export class KisService {
private settings: ApiSettings; private settings: ApiSettings;
private accessToken: string | null = null;
constructor(settings: ApiSettings) { constructor(settings: ApiSettings) {
this.settings = settings; this.settings = settings;
} }
async issueAccessToken() { async issueAccessToken() {
this.accessToken = "mock_token_" + Math.random().toString(36).substr(2); // Backend manages token automatically.
return this.accessToken; return "backend-managed-token";
} }
async inquirePrice(code: string): Promise<number> { async inquirePrice(code: string): Promise<number> {
const basePrice = code.startsWith('0') ? 70000 : 150; // Default to Domestic for now, or infer from code length
return Math.floor(basePrice + Math.random() * 5000); const market = code.length === 6 ? "Domestic" : "Overseas";
try {
const res = await fetch(`${API_BASE_URL}/kis/price?market=${market}&code=${code}`);
if (!res.ok) return 0;
const data = await res.json();
return parseFloat(data.price) || 0;
} catch (e) {
return 0;
}
} }
/** /**
* 서버로부터 전체 종목 마스터 리스트를 가져오는 Mock 함수 * Fetch Market Data
*/ */
async fetchMasterStocks(market: MarketType): Promise<StockItem[]> { async fetchMasterStocks(market: MarketType): Promise<StockItem[]> {
console.log(`KIS: Fetching master stocks for ${market}...`); try {
// 백엔드 구현 전까지는 시뮬레이션 데이터를 반환합니다. // Use Rankings as the default "List"
if (market === MarketType.DOMESTIC) { const marketParam = market === MarketType.DOMESTIC ? "Domestic" : "Overseas";
return [ const res = await fetch(`${API_BASE_URL}/discovery/rankings?limit=50`);
{ code: '005930', name: '삼성전자', price: 73200, change: 800, changePercent: 1.1, market: MarketType.DOMESTIC, volume: 15234000, aiScoreBuy: 85, aiScoreSell: 20, themes: ['반도체', 'AI', '스마트폰'] }, if (!res.ok) return [];
{ code: '000660', name: 'SK하이닉스', price: 124500, change: -1200, changePercent: -0.96, market: MarketType.DOMESTIC, volume: 2100000, aiScoreBuy: 65, aiScoreSell: 45, themes: ['반도체', 'HBM'] }, const data = await res.json();
{ code: '035420', name: 'NAVER', price: 215000, change: 4500, changePercent: 2.14, market: MarketType.DOMESTIC, volume: 850000, aiScoreBuy: 72, aiScoreSell: 30, themes: ['플랫폼', 'AI'] },
{ code: '035720', name: '카카오', price: 58200, change: 300, changePercent: 0.52, market: MarketType.DOMESTIC, volume: 1200000, aiScoreBuy: 50, aiScoreSell: 50, themes: ['플랫폼', '모빌리티'] }, // Transform logic if needed. Ranking API returns StockItem which matches frontend type mostly.
{ code: '005380', name: '현대차', price: 245000, change: 2000, changePercent: 0.82, market: MarketType.DOMESTIC, volume: 450000, aiScoreBuy: 78, aiScoreSell: 25, themes: ['자동차', '전기차'] }, return data.map((item: any) => ({
]; code: item.code,
} else { name: item.name,
return [ price: item.price,
{ code: 'AAPL', name: 'Apple Inc.', price: 189.43, change: 1.25, changePercent: 0.66, market: MarketType.OVERSEAS, volume: 45000000, aiScoreBuy: 90, aiScoreSell: 15, themes: ['빅테크', '스마트폰'] }, change: item.change,
{ code: 'TSLA', name: 'Tesla Inc.', price: 234.12, change: -4.50, changePercent: -1.89, market: MarketType.OVERSEAS, volume: 110000000, aiScoreBuy: 40, aiScoreSell: 75, themes: ['전기차', '자율주행'] }, changePercent: item.changePercent,
{ code: 'NVDA', name: 'NVIDIA Corp.', price: 485.12, change: 12.30, changePercent: 2.6, market: MarketType.OVERSEAS, volume: 32000000, aiScoreBuy: 95, aiScoreSell: 10, themes: ['반도체', 'AI'] }, market: market,
{ code: 'MSFT', name: 'Microsoft Corp.', price: 402.12, change: 3.45, changePercent: 0.86, market: MarketType.OVERSEAS, volume: 22000000, aiScoreBuy: 88, aiScoreSell: 12, themes: ['소프트웨어', 'AI'] }, volume: 0, // Rankings might not return volume yet, or it does if enabled
{ code: 'GOOGL', name: 'Alphabet Inc.', price: 145.12, change: 0.55, changePercent: 0.38, market: MarketType.OVERSEAS, volume: 18000000, aiScoreBuy: 75, aiScoreSell: 20, themes: ['검색', 'AI'] }, aiScoreBuy: 50, // Placeholder as backend doesn't have AI score in StockItem yet
]; aiScoreSell: 50,
themes: []
}));
} catch (e) {
return [];
} }
} }
async orderCash(code: string, type: OrderType, quantity: number, price: number = 0) { async orderCash(code: string, type: OrderType, quantity: number, price: number = 0) {
return { success: true, orderId: "ORD-" + Math.random().toString(36).substr(2, 9) }; return this._placeOrder("Domestic", code, type, quantity, price);
} }
async orderOverseas(code: string, type: OrderType, quantity: number, price: number) { async orderOverseas(code: string, type: OrderType, quantity: number, price: number) {
return { success: true, orderId: "OS-ORD-" + Math.random().toString(36).substr(2, 9) }; return this._placeOrder("Overseas", code, type, quantity, price);
}
private async _placeOrder(market: string, code: string, type: OrderType, quantity: number, price: number) {
const payload = {
market: market,
side: type === OrderType.BUY ? "buy" : "sell",
code: code,
quantity: quantity,
price: price
};
const res = await fetch(`${API_BASE_URL}/kis/order`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || "Order Failed");
}
return await res.json();
} }
async inquireBalance() { async inquireBalance() {
return { output1: [], output2: { tot_evlu_amt: "124500000", nass_amt: "45800000" } }; // Default Domestic
const res = await fetch(`${API_BASE_URL}/kis/balance?market=Domestic`);
if (!res.ok) return { output1: [], output2: {} };
return await res.json();
}
async inquireBalanceOverseas() {
const res = await fetch(`${API_BASE_URL}/kis/balance?market=Overseas`);
if (!res.ok) return { output1: [], output2: {} };
return await res.json();
} }
} }