"백엔드_핵심_로직_구현_프론트엔드_연동_및_도커_배포_최적화_완료"
This commit is contained in:
22
Dockerfile
22
Dockerfile
@@ -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.
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 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"])
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
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.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()
|
||||||
|
|||||||
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.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}")
|
||||||
|
|||||||
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.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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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 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()
|
|
||||||
|
# Reference Counting: Code -> Set of Sources
|
||||||
|
# e.g. "005930": {"HOLDING", "FRONTEND_DASHBOARD"}
|
||||||
|
self.subscriptions: Dict[str, Set[str]] = {}
|
||||||
|
|
||||||
self.running = False
|
self.running = False
|
||||||
self.data_map: Dict[str, Dict] = {} # Store IV/Key for encrypted TRs
|
self.data_map: Dict[str, Dict] = {}
|
||||||
|
|
||||||
async def start(self):
|
# 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):
|
||||||
"""
|
"""
|
||||||
Main loop: Connect -> Authenticate -> Listen
|
Request subscription. Increments reference count for the code.
|
||||||
"""
|
"""
|
||||||
self.running = True
|
if code not in self.subscriptions:
|
||||||
while self.running:
|
self.subscriptions[code] = set()
|
||||||
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}")
|
if not self.subscriptions[code]:
|
||||||
async with websockets.connect(self.WS_URL_REAL, ping_interval=None) as websocket:
|
# First subscriber, Send WS Command
|
||||||
self.ws = websocket
|
await self._send_subscribe(code, "1") # 1=Register
|
||||||
logger.info("Connected.")
|
|
||||||
|
|
||||||
# 2. Resubscribe if recovering connection
|
self.subscriptions[code].add(source)
|
||||||
if self.subscribed_codes:
|
logger.info(f"Subscribed {code} by {source}. RefCount: {len(self.subscriptions[code])}")
|
||||||
await self._resubscribe_all()
|
|
||||||
|
|
||||||
# 3. Listen Loop
|
async def remove_subscription(self, code: str, source: str):
|
||||||
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.
|
Remove subscription. Decrements reference count.
|
||||||
type: 'price' (H0STCNT0 - 체결가)
|
|
||||||
"""
|
"""
|
||||||
|
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:
|
if not self.ws or not self.approval_key:
|
||||||
logger.warning("WS not ready. Adding to pending list.")
|
return # Will resubscribe on connect
|
||||||
self.subscribed_codes.add(stock_code)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Domestic Realtime Price TR ID: H0STCNT0
|
|
||||||
tr_id = "H0STCNT0"
|
|
||||||
tr_key = stock_code
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
# Parse Data
|
elif first_char == '{':
|
||||||
if tr_id == "H0STCNT0": # Domestic Price
|
data = json.loads(message)
|
||||||
# Data format: TIME^PRICE^...
|
if data.get('header', {}).get('tr_id') == "PINGPONG":
|
||||||
# We need to look up format spec.
|
await self.ws.send(message)
|
||||||
# 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()
|
||||||
|
|||||||
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.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.
|
||||||
|
"""
|
||||||
|
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
|
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
BIN
backend/kis_stock.db
Normal file
Binary file not shown.
8
services/config.ts
Normal file
8
services/config.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
// Map API response to HoldingItem
|
||||||
|
// API returns: { stockCode, stockName, quantity, avgPrice, currentPrice, profit, profitRate }
|
||||||
|
return data.map((h: any) => ({
|
||||||
|
code: h.stockCode,
|
||||||
|
name: h.stockName,
|
||||||
|
avgPrice: h.avgPrice,
|
||||||
|
quantity: h.quantity,
|
||||||
|
market: h.stockCode.length > 6 ? MarketType.OVERSEAS : MarketType.DOMESTIC,
|
||||||
|
currentPrice: h.currentPrice,
|
||||||
|
profit: h.profit,
|
||||||
|
profitRate: h.profitRate
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch holdings", e);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncOrderToHolding(order: TradeOrder) {
|
|
||||||
const holdings = await this.getHoldings();
|
|
||||||
const existingIdx = holdings.findIndex(h => h.code === order.stockCode);
|
|
||||||
|
|
||||||
if (order.type === OrderType.BUY) {
|
|
||||||
if (existingIdx > -1) {
|
|
||||||
const h = holdings[existingIdx];
|
|
||||||
const newQty = h.quantity + order.quantity;
|
|
||||||
const newAvg = ((h.avgPrice * h.quantity) + (order.price * order.quantity)) / newQty;
|
|
||||||
holdings[existingIdx] = { ...h, quantity: newQty, avgPrice: newAvg };
|
|
||||||
} else {
|
|
||||||
holdings.push({
|
|
||||||
code: order.stockCode,
|
|
||||||
name: order.stockName,
|
|
||||||
avgPrice: order.price,
|
|
||||||
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
|
||||||
|
if (!res.ok) return { totalAssets: 0, buyingPower: 0 };
|
||||||
|
const data = await res.json();
|
||||||
|
// 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 {
|
return {
|
||||||
totalAssets: totalEval + 45800000,
|
totalAssets: parseFloat(data.output2?.tot_evlu_amt || "0"),
|
||||||
buyingPower: 45800000
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user