"백엔드_핵심_로직_구현_프론트엔드_연동_및_도커_배포_최적화_완료"
This commit is contained in:
Binary file not shown.
BIN
backend/app/api/__pycache__/api.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
@@ -1,8 +1,16 @@
|
||||
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.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
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"])
|
||||
|
||||
BIN
backend/app/api/endpoints/__pycache__/kis.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/kis.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/endpoints/__pycache__/settings.cpython-312.pyc
Normal file
BIN
backend/app/api/endpoints/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
54
backend/app/api/endpoints/account.py
Normal file
54
backend/app/api/endpoints/account.py
Normal 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
|
||||
87
backend/app/api/endpoints/auto_trade.py
Normal file
87
backend/app/api/endpoints/auto_trade.py
Normal 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"}
|
||||
68
backend/app/api/endpoints/discovery.py
Normal file
68
backend/app/api/endpoints/discovery.py
Normal 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"}
|
||||
@@ -3,8 +3,10 @@ from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
from app.services.kis_client import kis_client
|
||||
import logging
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("KisAPI")
|
||||
|
||||
class OrderRequest(BaseModel):
|
||||
market: Literal["Domestic", "Overseas"]
|
||||
@@ -13,6 +15,15 @@ class OrderRequest(BaseModel):
|
||||
quantity: int
|
||||
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")
|
||||
async def get_current_price(market: Literal["Domestic", "Overseas"], code: str):
|
||||
"""
|
||||
@@ -29,11 +40,22 @@ async def get_balance(market: Literal["Domestic", "Overseas"]):
|
||||
try:
|
||||
data = await kis_client.get_balance(market)
|
||||
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:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/order")
|
||||
async def place_order(order: OrderRequest):
|
||||
logger.info(f"Received Order Request: {order}")
|
||||
try:
|
||||
res = await kis_client.place_order(order.market, order.side, order.code, order.quantity, order.price)
|
||||
return res
|
||||
|
||||
29
backend/app/api/endpoints/news.py
Normal file
29
backend/app/api/endpoints/news.py
Normal 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
|
||||
@@ -6,8 +6,10 @@ from pydantic import BaseModel
|
||||
from app.db.database import get_db
|
||||
from app.db.models import ApiSettings
|
||||
from app.services.kis_auth import kis_auth
|
||||
import logging
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("SettingsAPI")
|
||||
|
||||
class SettingsSchema(BaseModel):
|
||||
# Partial schema for updates
|
||||
@@ -50,6 +52,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.put("/", response_model=SettingsSchema)
|
||||
async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Updating API Settings...")
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await db.execute(stmt)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
100
backend/app/api/endpoints/trade.py
Normal file
100
backend/app/api/endpoints/trade.py
Normal 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"}
|
||||
95
backend/app/api/endpoints/watchlist.py
Normal file
95
backend/app/api/endpoints/watchlist.py
Normal 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"}
|
||||
BIN
backend/app/core/__pycache__/crypto.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/crypto.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/rate_limiter.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/rate_limiter.cpython-312.pyc
Normal file
Binary file not shown.
69
backend/app/core/startup.py
Normal file
69
backend/app/core/startup.py
Normal 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 ===")
|
||||
@@ -9,14 +9,31 @@ from app.core.config import settings
|
||||
from app.db.init_db import init_db
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Initialize DB
|
||||
logger.info("Initializing Database...")
|
||||
await init_db()
|
||||
|
||||
# Startup Sequence (Auth, Checks)
|
||||
await run_startup_sequence()
|
||||
|
||||
logger.info("Starting Background Scheduler...")
|
||||
start_scheduler()
|
||||
print("Database & Scheduler Initialized")
|
||||
|
||||
logger.info("Application Startup Complete.")
|
||||
yield
|
||||
# Shutdown: Cleanup if needed
|
||||
# Shutdown
|
||||
logger.info("Application Shutdown.")
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
@@ -59,4 +76,4 @@ STATIC_DIR = BASE_DIR / "static"
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
|
||||
else:
|
||||
print(f"Warning: Static directory not found at {STATIC_DIR}")
|
||||
logger.warning(f"Static directory not found at {STATIC_DIR}")
|
||||
|
||||
BIN
backend/app/services/__pycache__/kis_auth.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/kis_auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/kis_client.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/kis_client.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -5,13 +5,14 @@ from app.db.database import SessionLocal
|
||||
from app.db.models import ApiSettings
|
||||
from app.db.models import ApiSettings
|
||||
from app.core.crypto import decrypt_str, encrypt_str
|
||||
import logging
|
||||
|
||||
class KisAuth:
|
||||
BASE_URL_REAL = "https://openapi.koreainvestment.com:9443"
|
||||
# BASE_URL_VIRTUAL = "https://openapivts.koreainvestment.com:29443"
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
async def get_access_token(self, db_session=None) -> str:
|
||||
"""
|
||||
@@ -37,9 +38,11 @@ class KisAuth:
|
||||
token_dec = decrypt_str(settings_obj.accessToken)
|
||||
if token_dec and token_dec != "[Decryption Failed]":
|
||||
if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10):
|
||||
# self.logger.debug("Using cached Access Token.") # Too verbose?
|
||||
return token_dec
|
||||
|
||||
# 3. Issue New Token
|
||||
self.logger.info("Access Token Expired or Missing. Issuing New Token...")
|
||||
app_key_dec = decrypt_str(settings_obj.appKey)
|
||||
app_secret_dec = decrypt_str(settings_obj.appSecret)
|
||||
token_data = await self._issue_token(app_key_dec, app_secret_dec)
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.db.database import SessionLocal
|
||||
from app.db.models import ApiSettings
|
||||
from sqlalchemy import select
|
||||
from app.core.crypto import decrypt_str
|
||||
import logging
|
||||
|
||||
class KisClient:
|
||||
"""
|
||||
@@ -15,16 +16,19 @@ class KisClient:
|
||||
|
||||
# Domestic URLs
|
||||
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_BALANCE = "/uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
|
||||
# Overseas URLs
|
||||
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_BALANCE = "/uapi/overseas-stock/v1/trading/inquire-balance"
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
async def _get_settings(self):
|
||||
async with SessionLocal() as session:
|
||||
@@ -60,6 +64,7 @@ class KisClient:
|
||||
}
|
||||
|
||||
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:
|
||||
if method == "GET":
|
||||
@@ -178,6 +183,7 @@ class KisClient:
|
||||
"ORD_QTY": str(quantity),
|
||||
"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)
|
||||
|
||||
elif market == "Overseas":
|
||||
@@ -197,4 +203,52 @@ class KisClient:
|
||||
}
|
||||
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()
|
||||
|
||||
112
backend/app/services/master_service.py
Normal file
112
backend/app/services/master_service.py
Normal 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()
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import websockets
|
||||
import logging
|
||||
from typing import Dict, Set, Callable, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.kis_auth import kis_auth
|
||||
from app.core.crypto import aes_cbc_base64_dec
|
||||
@@ -21,134 +22,113 @@ class RealtimeManager:
|
||||
def __init__(self):
|
||||
self.ws: Optional[websockets.WebSocketClientProtocol] = 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 = {
|
||||
"header": {
|
||||
"approval_key": self.approval_key,
|
||||
"custtype": "P",
|
||||
"tr_type": "1", # 1=Register, 2=Unregister
|
||||
"tr_type": tr_type,
|
||||
"content-type": "utf-8"
|
||||
},
|
||||
"body": {
|
||||
"input": {
|
||||
"tr_id": tr_id,
|
||||
"tr_key": tr_key
|
||||
"tr_id": "H0STCNT0",
|
||||
"tr_key": code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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):
|
||||
for code in self.subscribed_codes:
|
||||
await self.subscribe(code)
|
||||
for code in list(self.subscriptions.keys()):
|
||||
await self._send_subscribe(code, "1")
|
||||
|
||||
async def _listen(self):
|
||||
async for message in self.ws:
|
||||
try:
|
||||
# Message can be plain text provided by library, or bytes
|
||||
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
|
||||
if isinstance(message, bytes): message = message.decode('utf-8')
|
||||
|
||||
first_char = message[0]
|
||||
|
||||
if first_char in ['{', '[']:
|
||||
# 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...
|
||||
if first_char in ['0', '1']:
|
||||
# Real Data
|
||||
parts = message.split('|')
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
|
||||
if len(parts) < 4: continue
|
||||
tr_id = parts[1]
|
||||
raw_data = parts[3]
|
||||
|
||||
# Decryption Check
|
||||
# If this tr_id was registered as encrypted, decrypt it.
|
||||
# For now assuming Plaintext for H0STCNT0 as per standard Personal API.
|
||||
|
||||
# Parse Data
|
||||
if tr_id == "H0STCNT0": # Domestic Price
|
||||
# Data format: TIME^PRICE^...
|
||||
# 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
|
||||
|
||||
if tr_id == "H0STCNT0":
|
||||
await self._parse_domestic_price(raw_data)
|
||||
|
||||
elif first_char == '{':
|
||||
data = json.loads(message)
|
||||
if data.get('header', {}).get('tr_id') == "PINGPONG":
|
||||
await self.ws.send(message)
|
||||
|
||||
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()
|
||||
|
||||
104
backend/app/services/sync_service.py
Normal file
104
backend/app/services/sync_service.py
Normal 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()
|
||||
Binary file not shown.
@@ -2,7 +2,11 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.core.market_schedule import market_schedule
|
||||
from app.services.kis_client import kis_client
|
||||
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
|
||||
from datetime import datetime
|
||||
|
||||
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.
|
||||
"""
|
||||
logger.info("Scheduler: Market Check Job Running...")
|
||||
is_domestic_open = market_schedule.is_market_open("Domestic")
|
||||
# is_overseas_open = market_schedule.is_market_open("Overseas")
|
||||
|
||||
# If market is open and WS is not running, start it
|
||||
if is_domestic_open and not realtime_manager.running:
|
||||
logger.info("Market is Open! Starting Realtime Manager.")
|
||||
# This is a blocking call if awaited directly in job? NO, start() has a loop.
|
||||
# We should start it as a task if it's not running.
|
||||
# But realtime_manager.start() is a loop.
|
||||
# Better to have it managed by FastAPI startup, and this job just checks.
|
||||
pass
|
||||
else:
|
||||
logger.info(f"Market Status: Domestic={is_domestic_open}, RealtimeManager={realtime_manager.running}")
|
||||
|
||||
async def persist_market_data_job():
|
||||
"""
|
||||
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():
|
||||
# Placeholder for News Scraper
|
||||
# logger.info("Scraping Naver News...")
|
||||
logger.info("Scheduler: Scraping Naver News (Placeholder)...")
|
||||
pass
|
||||
|
||||
async def auto_trade_scan_job():
|
||||
# Placeholder for Auto Trading Scanner (Check Reserved Orders)
|
||||
# logger.info("Scanning Reserved Orders...")
|
||||
pass
|
||||
"""
|
||||
Scans ReservedOrders and triggers actions if conditions are met.
|
||||
"""
|
||||
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():
|
||||
scheduler.add_job(market_check_job, 'interval', minutes=5)
|
||||
scheduler.add_job(news_scrap_job, 'interval', minutes=10)
|
||||
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()
|
||||
logger.info("Scheduler Started.")
|
||||
|
||||
BIN
backend/kis_stock.db
Normal file
BIN
backend/kis_stock.db
Normal file
Binary file not shown.
Reference in New Issue
Block a user