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

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

Binary file not shown.

View File

@@ -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"])

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,10 @@ from pydantic import BaseModel
from typing import Literal
from 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

View File

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

View File

@@ -6,8 +6,10 @@ from pydantic import BaseModel
from app.db.database import get_db
from app.db.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()

View File

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

View File

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