diff --git a/Dockerfile b/Dockerfile index ff8c30b..931d6d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,23 +12,29 @@ FROM python:3.9-slim # Set working directory WORKDIR /app -# Install system dependencies (if needed for TA-Lib or others) -# RUN apt-get update && apt-get install -y gcc ... +# Install system dependencies +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.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy backend code -COPY ./backend ./backend +# Copy backend code (contents of backend folder to /app) +COPY ./backend/ . -# Copy frontend build artifacts to backend static folder -COPY --from=frontend-build /app/frontend/dist ./backend/static +# Copy frontend build artifacts to /app/static +COPY --from=frontend-build /app/frontend/dist ./static + +# Ensure data directory exists +RUN mkdir -p /app/data # Environment variables ENV PORT=80 EXPOSE 80 # Run FastAPI server -# Assuming main.py is in backend folder and app object is named 'app' -CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "80"] \ No newline at end of file +# Since app/ is now directly in /app, uvicorn app.main:app works +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"] \ No newline at end of file diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 48ae108..1c04938 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/api.cpython-312.pyc b/backend/app/api/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000..992e641 Binary files /dev/null and b/backend/app/api/__pycache__/api.cpython-312.pyc differ diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 6b8a69c..c550aad 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -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"]) diff --git a/backend/app/api/endpoints/__pycache__/kis.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/kis.cpython-312.pyc new file mode 100644 index 0000000..e67f928 Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/kis.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/__pycache__/settings.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000..65e3433 Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/settings.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/account.py b/backend/app/api/endpoints/account.py new file mode 100644 index 0000000..7aa6967 --- /dev/null +++ b/backend/app/api/endpoints/account.py @@ -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 diff --git a/backend/app/api/endpoints/auto_trade.py b/backend/app/api/endpoints/auto_trade.py new file mode 100644 index 0000000..25cbbce --- /dev/null +++ b/backend/app/api/endpoints/auto_trade.py @@ -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"} diff --git a/backend/app/api/endpoints/discovery.py b/backend/app/api/endpoints/discovery.py new file mode 100644 index 0000000..1230792 --- /dev/null +++ b/backend/app/api/endpoints/discovery.py @@ -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"} diff --git a/backend/app/api/endpoints/kis.py b/backend/app/api/endpoints/kis.py index 17ece84..4fa0fc3 100644 --- a/backend/app/api/endpoints/kis.py +++ b/backend/app/api/endpoints/kis.py @@ -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 diff --git a/backend/app/api/endpoints/news.py b/backend/app/api/endpoints/news.py new file mode 100644 index 0000000..36a1208 --- /dev/null +++ b/backend/app/api/endpoints/news.py @@ -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 diff --git a/backend/app/api/endpoints/settings.py b/backend/app/api/endpoints/settings.py index 20fdaf9..be90518 100644 --- a/backend/app/api/endpoints/settings.py +++ b/backend/app/api/endpoints/settings.py @@ -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() diff --git a/backend/app/api/endpoints/trade.py b/backend/app/api/endpoints/trade.py new file mode 100644 index 0000000..35bfde2 --- /dev/null +++ b/backend/app/api/endpoints/trade.py @@ -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"} diff --git a/backend/app/api/endpoints/watchlist.py b/backend/app/api/endpoints/watchlist.py new file mode 100644 index 0000000..e9dc16b --- /dev/null +++ b/backend/app/api/endpoints/watchlist.py @@ -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"} diff --git a/backend/app/core/__pycache__/crypto.cpython-312.pyc b/backend/app/core/__pycache__/crypto.cpython-312.pyc new file mode 100644 index 0000000..4b77319 Binary files /dev/null and b/backend/app/core/__pycache__/crypto.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/rate_limiter.cpython-312.pyc b/backend/app/core/__pycache__/rate_limiter.cpython-312.pyc new file mode 100644 index 0000000..5370395 Binary files /dev/null and b/backend/app/core/__pycache__/rate_limiter.cpython-312.pyc differ diff --git a/backend/app/core/startup.py b/backend/app/core/startup.py new file mode 100644 index 0000000..4f8c3f5 --- /dev/null +++ b/backend/app/core/startup.py @@ -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 ===") diff --git a/backend/app/main.py b/backend/app/main.py index 3e666a8..5c69a71 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,14 +9,31 @@ from app.core.config import settings from app.db.init_db import init_db from app.workers.scheduler import start_scheduler +import logging + +# Configure Logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + @asynccontextmanager async def lifespan(app: FastAPI): # Startup: Initialize DB + logger.info("Initializing Database...") await init_db() + + # Startup Sequence (Auth, Checks) + await run_startup_sequence() + + logger.info("Starting Background Scheduler...") start_scheduler() - print("Database & Scheduler Initialized") + + logger.info("Application Startup Complete.") yield - # Shutdown: Cleanup if needed + # Shutdown + logger.info("Application Shutdown.") app = FastAPI( title=settings.PROJECT_NAME, @@ -59,4 +76,4 @@ STATIC_DIR = BASE_DIR / "static" if STATIC_DIR.exists(): app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static") else: - print(f"Warning: Static directory not found at {STATIC_DIR}") + logger.warning(f"Static directory not found at {STATIC_DIR}") diff --git a/backend/app/services/__pycache__/kis_auth.cpython-312.pyc b/backend/app/services/__pycache__/kis_auth.cpython-312.pyc new file mode 100644 index 0000000..e1b0688 Binary files /dev/null and b/backend/app/services/__pycache__/kis_auth.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/kis_client.cpython-312.pyc b/backend/app/services/__pycache__/kis_client.cpython-312.pyc new file mode 100644 index 0000000..178daa4 Binary files /dev/null and b/backend/app/services/__pycache__/kis_client.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/realtime_manager.cpython-312.pyc b/backend/app/services/__pycache__/realtime_manager.cpython-312.pyc new file mode 100644 index 0000000..df918d5 Binary files /dev/null and b/backend/app/services/__pycache__/realtime_manager.cpython-312.pyc differ diff --git a/backend/app/services/kis_auth.py b/backend/app/services/kis_auth.py index fa17e67..2ca2866 100644 --- a/backend/app/services/kis_auth.py +++ b/backend/app/services/kis_auth.py @@ -5,13 +5,14 @@ from app.db.database import SessionLocal from app.db.models import ApiSettings from app.db.models import ApiSettings from app.core.crypto import decrypt_str, encrypt_str +import logging class KisAuth: BASE_URL_REAL = "https://openapi.koreainvestment.com:9443" # BASE_URL_VIRTUAL = "https://openapivts.koreainvestment.com:29443" def __init__(self): - pass + self.logger = logging.getLogger(self.__class__.__name__) async def get_access_token(self, db_session=None) -> str: """ @@ -37,9 +38,11 @@ class KisAuth: token_dec = decrypt_str(settings_obj.accessToken) if token_dec and token_dec != "[Decryption Failed]": if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10): + # self.logger.debug("Using cached Access Token.") # Too verbose? return token_dec # 3. Issue New Token + self.logger.info("Access Token Expired or Missing. Issuing New Token...") app_key_dec = decrypt_str(settings_obj.appKey) app_secret_dec = decrypt_str(settings_obj.appSecret) token_data = await self._issue_token(app_key_dec, app_secret_dec) diff --git a/backend/app/services/kis_client.py b/backend/app/services/kis_client.py index 78e4f3b..6d2204f 100644 --- a/backend/app/services/kis_client.py +++ b/backend/app/services/kis_client.py @@ -6,6 +6,7 @@ from app.db.database import SessionLocal from app.db.models import ApiSettings from sqlalchemy import select from app.core.crypto import decrypt_str +import logging class KisClient: """ @@ -15,16 +16,19 @@ class KisClient: # Domestic URLs URL_DOMESTIC_ORDER = "/uapi/domestic-stock/v1/trading/order-cash" + URL_DOMESTIC_MODIFY = "/uapi/domestic-stock/v1/trading/order-rvsecncl" URL_DOMESTIC_PRICE = "/uapi/domestic-stock/v1/quotations/inquire-price" URL_DOMESTIC_BALANCE = "/uapi/domestic-stock/v1/trading/inquire-balance" # Overseas URLs URL_OVERSEAS_ORDER = "/uapi/overseas-stock/v1/trading/order" + URL_OVERSEAS_MODIFY = "/uapi/overseas-stock/v1/trading/order-rvsecncl" URL_OVERSEAS_PRICE = "/uapi/overseas-price/v1/quotations/price" URL_OVERSEAS_BALANCE = "/uapi/overseas-stock/v1/trading/inquire-balance" def __init__(self): pass + self.logger = logging.getLogger(self.__class__.__name__) async def _get_settings(self): async with SessionLocal() as session: @@ -60,6 +64,7 @@ class KisClient: } full_url = f"{base_url}{url_path}" + # self.logger.debug(f"API Calling: {method} {url_path} (TR_ID: {tr_id})") async with httpx.AsyncClient() as client: if method == "GET": @@ -178,6 +183,7 @@ class KisClient: "ORD_QTY": str(quantity), "ORD_UNPR": str(int(price)), # Cash Order requires integer price string } + self.logger.info(f"Ordering Domestic: {side} {code} {quantity}qty @ {price}") return await self._call_api("POST", self.URL_DOMESTIC_ORDER, tr_id, data=data) elif market == "Overseas": @@ -197,4 +203,52 @@ class KisClient: } return await self._call_api("POST", self.URL_OVERSEAS_ORDER, tr_id, data=data) + async def modify_order(self, market: str, order_no: str, code: str, quantity: int, price: float, type: str = "00", cancel: bool = False) -> Dict: + """ + Cancel or Modify Order. + cancel=True -> Cancel + """ + settings = await self._get_settings() + acc_no_str = decrypt_str(settings.accountNumber) + + if '-' in acc_no_str: + cano, prdt = acc_no_str.split('-') + else: + cano = acc_no_str[:8] + prdt = acc_no_str[8:] + + if market == "Domestic": + # TR_ID: TTTC0803U (Modify/Cancel) + data = { + "CANO": cano, + "ACNT_PRDT_CD": prdt, + "KRX_FWDG_ORD_ORGNO": "", # Exchange Node? Usually empty or "00950" + "ORGN_ODNO": order_no, + "ORD_DVSN": type, + "RVSE_CNCL_DVSN_CD": "02" if cancel else "01", # 01: Modify, 02: Cancel + "ORD_QTY": str(quantity), + "ORD_UNPR": str(int(price)), + "QTY_ALL_ORD_YN": "Y" if quantity == 0 else "N", # 0 means cancel all? + } + # Note: KRX_FWDG_ORD_ORGNO is tricky. Usually 5 digit branch code. Defaulting to "" might fail. + # Using '06010' (Online) or leaving blank depending on API. + # KIS API Doc: "주문상태조회"에서 얻은 ORGNO 사용해야 함. + # For this impl, we assume user knows or simple default. + + return await self._call_api("POST", self.URL_DOMESTIC_MODIFY, "TTTC0803U", data=data) + + elif market == "Overseas": + # MCCL: TTTT1004U + data = { + "CANO": cano, + "ACNT_PRDT_CD": prdt, + "OVRS_EXCG_CD": "NASD", + "PDNO": code, + "ORGN_ODNO": order_no, + "RVSE_CNCL_DVSN_CD": "02" if cancel else "01", + "ORD_QTY": str(quantity), + "OVRS_ORD_UNPR": str(price), + } + return await self._call_api("POST", self.URL_OVERSEAS_MODIFY, "TTTT1004U", data=data) + kis_client = KisClient() diff --git a/backend/app/services/master_service.py b/backend/app/services/master_service.py new file mode 100644 index 0000000..31cc143 --- /dev/null +++ b/backend/app/services/master_service.py @@ -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() diff --git a/backend/app/services/realtime_manager.py b/backend/app/services/realtime_manager.py index 6ee075c..5b6b852 100644 --- a/backend/app/services/realtime_manager.py +++ b/backend/app/services/realtime_manager.py @@ -3,6 +3,7 @@ import json import websockets import logging from typing import Dict, Set, Callable, Optional +from datetime import datetime from app.services.kis_auth import kis_auth from app.core.crypto import aes_cbc_base64_dec @@ -21,134 +22,113 @@ class RealtimeManager: def __init__(self): self.ws: Optional[websockets.WebSocketClientProtocol] = None self.approval_key: Optional[str] = None - self.subscribed_codes: Set[str] = set() - self.running = False - self.data_map: Dict[str, Dict] = {} # Store IV/Key for encrypted TRs - - async def start(self): - """ - Main loop: Connect -> Authenticate -> Listen - """ - self.running = True - while self.running: - try: - # 1. Get Approval Key - self.approval_key = await kis_auth.get_approval_key() - - logger.info(f"Connecting to KIS WS: {self.WS_URL_REAL}") - async with websockets.connect(self.WS_URL_REAL, ping_interval=None) as websocket: - self.ws = websocket - logger.info("Connected.") - - # 2. Resubscribe if recovering connection - if self.subscribed_codes: - await self._resubscribe_all() - - # 3. Listen Loop - await self._listen() - - except Exception as e: - logger.error(f"WS Connection Error: {e}. Retrying in 5s...") - await asyncio.sleep(5) - - async def stop(self): - self.running = False - if self.ws: - await self.ws.close() - - async def subscribe(self, stock_code: str, type="price"): - """ - Subscribe to a stock. - type: 'price' (H0STCNT0 - 체결가) - """ - if not self.ws or not self.approval_key: - logger.warning("WS not ready. Adding to pending list.") - self.subscribed_codes.add(stock_code) - return - - # Domestic Realtime Price TR ID: H0STCNT0 - tr_id = "H0STCNT0" - tr_key = stock_code + # Reference Counting: Code -> Set of Sources + # e.g. "005930": {"HOLDING", "FRONTEND_DASHBOARD"} + self.subscriptions: Dict[str, Set[str]] = {} + + self.running = False + self.data_map: Dict[str, Dict] = {} + + # Realtime Data Cache (Code -> DataDict) + # Used by Scheduler to persist data periodically + self.price_cache: Dict[str, Dict] = {} + + async def add_subscription(self, code: str, source: str): + """ + Request subscription. Increments reference count for the code. + """ + if code not in self.subscriptions: + self.subscriptions[code] = set() + + if not self.subscriptions[code]: + # First subscriber, Send WS Command + await self._send_subscribe(code, "1") # 1=Register + + self.subscriptions[code].add(source) + logger.info(f"Subscribed {code} by {source}. RefCount: {len(self.subscriptions[code])}") + + async def remove_subscription(self, code: str, source: str): + """ + Remove subscription. Decrements reference count. + """ + if code in self.subscriptions and source in self.subscriptions[code]: + self.subscriptions[code].remove(source) + logger.info(f"Unsubscribed {code} by {source}. RefCount: {len(self.subscriptions[code])}") + + if not self.subscriptions[code]: + # No more subscribers, Send WS Unsubscribe + await self._send_subscribe(code, "2") # 2=Unregister + del self.subscriptions[code] + + async def _send_subscribe(self, code: str, tr_type: str): + if not self.ws or not self.approval_key: + return # Will resubscribe on connect + payload = { "header": { "approval_key": self.approval_key, "custtype": "P", - "tr_type": "1", # 1=Register, 2=Unregister + "tr_type": tr_type, "content-type": "utf-8" }, "body": { "input": { - "tr_id": tr_id, - "tr_key": tr_key + "tr_id": "H0STCNT0", + "tr_key": code } } } - await self.ws.send(json.dumps(payload)) - self.subscribed_codes.add(stock_code) - logger.info(f"Subscribed to {stock_code}") async def _resubscribe_all(self): - for code in self.subscribed_codes: - await self.subscribe(code) + for code in list(self.subscriptions.keys()): + await self._send_subscribe(code, "1") async def _listen(self): async for message in self.ws: try: - # Message can be plain text provided by library, or bytes - if isinstance(message, bytes): - message = message.decode('utf-8') - - # KIS sends data in specific formats. - # 1. JSON (Control Messages, PINGPONG, Subscription Ack) - # 2. Text/Pipe separated (Real Data) - Usually starts with 0 or 1 + if isinstance(message, bytes): message = message.decode('utf-8') first_char = message[0] - - if first_char in ['{', '[']: - # JSON Message - data = json.loads(message) - header = data.get('header', {}) - tr_id = header.get('tr_id') - - if tr_id == "PINGPONG": - await self.ws.send(message) # Echo back - logger.debug("PINGPONG handled") - - elif 'body' in data: - # Subscription Ack - # Store IV/Key if encryption is enabled (msg1 often contains 'ENCRYPT') - # But for Brokerage API, H0STCNT0 is usually plaintext unless configured otherwise. - # If encrypted, 'iv' and 'key' are in body['output'] - pass - - elif first_char in ['0', '1']: - # Real Data: 0|TR_ID|DATA_CNT|DATA... + if first_char in ['0', '1']: + # Real Data parts = message.split('|') - if len(parts) < 4: - continue - + if len(parts) < 4: continue tr_id = parts[1] raw_data = parts[3] - # Decryption Check - # If this tr_id was registered as encrypted, decrypt it. - # For now assuming Plaintext for H0STCNT0 as per standard Personal API. - - # Parse Data - if tr_id == "H0STCNT0": # Domestic Price - # Data format: TIME^PRICE^... - # We need to look up format spec. - # Simple implementation: just log or split - fields = raw_data.split('^') - if len(fields) > 2: - current_price = fields[2] # Example index - # TODO: Update DB - # print(f"Price Update: {current_price}") - pass - + if tr_id == "H0STCNT0": + await self._parse_domestic_price(raw_data) + + elif first_char == '{': + data = json.loads(message) + if data.get('header', {}).get('tr_id') == "PINGPONG": + await self.ws.send(message) + except Exception as e: - logger.error(f"Error processing WS message: {e}") + logger.error(f"WS Error: {e}") + async def _parse_domestic_price(self, raw_data: str): + # Format: MKSC_SHRN_ISCD^EXEC_TIME^CURRENT_PRICE^... + fields = raw_data.split('^') + if len(fields) < 3: return + + code = fields[0] + curr_price = fields[2] + change = fields[4] + change_rate = fields[5] + + # Create lightweight update object (Dict) + update_data = { + "code": code, + "price": curr_price, + "change": change, + "rate": change_rate, + "timestamp": datetime.now().isoformat() + } + + # Update Cache + self.price_cache[code] = update_data + # logger.debug(f"Price Update: {code} {curr_price}") realtime_manager = RealtimeManager() diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py new file mode 100644 index 0000000..9cdc78c --- /dev/null +++ b/backend/app/services/sync_service.py @@ -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() diff --git a/backend/app/workers/__pycache__/scheduler.cpython-312.pyc b/backend/app/workers/__pycache__/scheduler.cpython-312.pyc index 8f0e95b..cf4314d 100644 Binary files a/backend/app/workers/__pycache__/scheduler.cpython-312.pyc and b/backend/app/workers/__pycache__/scheduler.cpython-312.pyc differ diff --git a/backend/app/workers/scheduler.py b/backend/app/workers/scheduler.py index 74ea6a8..4898890 100644 --- a/backend/app/workers/scheduler.py +++ b/backend/app/workers/scheduler.py @@ -2,7 +2,11 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.core.market_schedule import market_schedule from app.services.kis_client import kis_client from app.services.realtime_manager import realtime_manager +from app.db.database import SessionLocal +from app.db.models import StockItem, ReservedOrder +from sqlalchemy import select, update import logging +from datetime import datetime logger = logging.getLogger(__name__) @@ -12,32 +16,160 @@ async def market_check_job(): """ Periodic check to ensure Realtime Manager is connected when market is open. """ + logger.info("Scheduler: Market Check Job Running...") is_domestic_open = market_schedule.is_market_open("Domestic") # is_overseas_open = market_schedule.is_market_open("Overseas") # If market is open and WS is not running, start it if is_domestic_open and not realtime_manager.running: logger.info("Market is Open! Starting Realtime Manager.") - # This is a blocking call if awaited directly in job? NO, start() has a loop. - # We should start it as a task if it's not running. - # But realtime_manager.start() is a loop. - # Better to have it managed by FastAPI startup, and this job just checks. - pass + else: + logger.info(f"Market Status: Domestic={is_domestic_open}, RealtimeManager={realtime_manager.running}") + +async def persist_market_data_job(): + """ + Flushes realtime_manager.price_cache to DB (StockItem). + """ + if not realtime_manager.price_cache: + return + + # Snapshot and clear (thread-safe ish in async single loop) + # Or just iterate. Python dict iteration is safe if not modifying keys. + # We'll take keys copy. + codes_to_process = list(realtime_manager.price_cache.keys()) + + if not codes_to_process: + return + + async with SessionLocal() as session: + count = 0 + for code in codes_to_process: + data = realtime_manager.price_cache.get(code) + if not data: continue + + # Upsert StockItem? + # Or just update if exists. StockItem generally exists if synced. + # If not exists, we might create it. + + price = float(data['price']) + change = float(data['change']) + rate = float(data['rate']) + + # Simple Update + stmt = update(StockItem).where(StockItem.code == code).values( + price=price, + change=change, + changePercent=rate + ) + await session.execute(stmt) + count += 1 + + await session.commit() + + # logger.debug(f"Persisted {count} stock prices to DB.") async def news_scrap_job(): # Placeholder for News Scraper - # logger.info("Scraping Naver News...") + logger.info("Scheduler: Scraping Naver News (Placeholder)...") pass async def auto_trade_scan_job(): - # Placeholder for Auto Trading Scanner (Check Reserved Orders) - # logger.info("Scanning Reserved Orders...") - pass + """ + Scans ReservedOrders and triggers actions if conditions are met. + """ + async with SessionLocal() as session: + # Fetch Monitoring Orders + stmt = select(ReservedOrder).where(ReservedOrder.status == "MONITORING") + result = await session.execute(stmt) + orders = result.scalars().all() + + for order in orders: + # Check Price + current_price = 0 + + # 1. Try Realtime Cache + if order.stockCode in realtime_manager.price_cache: + current_price = float(realtime_manager.price_cache[order.stockCode]['price']) + else: + # 2. Try DB (StockItem) + # ... (omitted for speed, usually Cache covers if monitored) + pass + + if current_price <= 0: continue + + # Trigger Logic (Simple) + triggered = False + if order.monitoringType == "PRICE_TRIGGER": + # Buy if Price <= Trigger (Dip Buy) ?? OR Buy if Price >= Trigger (Breakout)? + # Usually define "Condition". Assuming Buy Lower for now or User defined. + # Let's assume TriggerPrice is "Target". + # If BUY -> Price <= Trigger? + # If SELL -> Price >= Trigger? + + if order.type == "BUY" and current_price <= order.triggerPrice: + triggered = True + elif order.type == "SELL" and current_price >= order.triggerPrice: + triggered = True + + elif order.monitoringType == "TRAILING_STOP": + if order.type == "SELL": + # Trailing Sell (High Water Mark) + if not order.highestPrice or current_price > order.highestPrice: + order.highestPrice = current_price + + if order.highestPrice > 0 and order.trailingValue: + drop = order.highestPrice - current_price + triggered = False + + if order.trailingType == "PERCENT": + drop_rate = (drop / order.highestPrice) * 100 + if drop_rate >= order.trailingValue: triggered = True + else: # AMOUNT + if drop >= order.trailingValue: triggered = True + + if triggered: + logger.info(f"TS SELL Triggered: High={order.highestPrice}, Curr={current_price}") + + elif order.type == "BUY": + # Trailing Buy (Low Water Mark / Rebound) + # Initialize lowest if 0 (assuming stock price never 0, or use 99999999 default) + if not order.lowestPrice or order.lowestPrice == 0: + order.lowestPrice = current_price + + if current_price < order.lowestPrice: + order.lowestPrice = current_price + + if order.lowestPrice > 0 and order.trailingValue: + rise = current_price - order.lowestPrice + triggered = False + + if order.trailingType == "PERCENT": + rise_rate = (rise / order.lowestPrice) * 100 + if rise_rate >= order.trailingValue: triggered = True + else: + if rise >= order.trailingValue: triggered = True + + if triggered: + logger.info(f"TS BUY Triggered: Low={order.lowestPrice}, Curr={current_price}") + + if triggered: + + if triggered: + logger.info(f"Order TRIGGERED! {order.stockName} {order.type} @ {current_price} (Target: {order.triggerPrice})") + + # Execute Order + # res = await kis_client.place_order(...) + # Update Status + # order.status = "TRIGGERED" (or "COMPLETED" after exec) + pass + + await session.commit() def start_scheduler(): scheduler.add_job(market_check_job, 'interval', minutes=5) scheduler.add_job(news_scrap_job, 'interval', minutes=10) scheduler.add_job(auto_trade_scan_job, 'interval', minutes=1) + scheduler.add_job(persist_market_data_job, 'interval', seconds=10) # Persist every 10s scheduler.start() logger.info("Scheduler Started.") diff --git a/backend/kis_stock.db b/backend/kis_stock.db new file mode 100644 index 0000000..02fe0b5 Binary files /dev/null and b/backend/kis_stock.db differ diff --git a/services/config.ts b/services/config.ts new file mode 100644 index 0000000..acac598 --- /dev/null +++ b/services/config.ts @@ -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 + }; +}; diff --git a/services/dbService.ts b/services/dbService.ts index 486dace..57cbaef 100644 --- a/services/dbService.ts +++ b/services/dbService.ts @@ -1,5 +1,5 @@ - import { TradeOrder, OrderType, MarketType, AutoTradeConfig, WatchlistGroup, ReservedOrder, StockTick } from '../types'; +import { API_BASE_URL, getHeaders } from './config'; export interface HoldingItem { code: string; @@ -7,178 +7,152 @@ export interface HoldingItem { avgPrice: number; quantity: number; market: MarketType; + currentPrice: number; // Added + profit: number; // Added + profitRate: number; // Added } export class DbService { - private holdingsKey = 'batchukis_sqlite_holdings'; - 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 { - const key = this.ticksPrefix + code; - const data = localStorage.getItem(key); - return data ? JSON.parse(data) : []; - } + constructor() {} + // --- Holdings --- async getHoldings(): Promise { - const data = localStorage.getItem(this.holdingsKey); - return data ? JSON.parse(data) : []; - } - - 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); - } - } + try { + 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 []; } - localStorage.setItem(this.holdingsKey, JSON.stringify(holdings)); - return holdings; - } - - async getWatchlistGroups(): Promise { - 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 { - 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 { - 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() { - const holdings = await this.getHoldings(); - const totalEval = holdings.reduce((acc, h) => acc + (h.avgPrice * h.quantity), 0); - return { - totalAssets: totalEval + 45800000, - buyingPower: 45800000 - }; + try { + 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 { + totalAssets: parseFloat(data.output2?.tot_evlu_amt || "0"), + buyingPower: parseFloat(data.output2?.dnca_tot_amt || "0") + }; + } catch (e) { + return { totalAssets: 0, buyingPower: 0 }; + } + } + + // --- Reserved Orders --- + async getReservedOrders(): Promise { + 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 { + 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 { + 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 { + 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 } } diff --git a/services/kisService.ts b/services/kisService.ts index e86ca5d..bf01bd5 100644 --- a/services/kisService.ts +++ b/services/kisService.ts @@ -1,61 +1,105 @@ import { ApiSettings, MarketType, OrderType, StockItem } from '../types'; +import { API_BASE_URL, getHeaders } from './config'; /** * Korea Investment & Securities (KIS) Open API Service + * Now connected to Real Backend */ export class KisService { private settings: ApiSettings; - private accessToken: string | null = null; constructor(settings: ApiSettings) { this.settings = settings; } async issueAccessToken() { - this.accessToken = "mock_token_" + Math.random().toString(36).substr(2); - return this.accessToken; + // Backend manages token automatically. + return "backend-managed-token"; } async inquirePrice(code: string): Promise { - const basePrice = code.startsWith('0') ? 70000 : 150; - return Math.floor(basePrice + Math.random() * 5000); + // Default to Domestic for now, or infer from code length + 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 { - console.log(`KIS: Fetching master stocks for ${market}...`); - // 백엔드 구현 전까지는 시뮬레이션 데이터를 반환합니다. - if (market === MarketType.DOMESTIC) { - return [ - { code: '005930', name: '삼성전자', price: 73200, change: 800, changePercent: 1.1, market: MarketType.DOMESTIC, volume: 15234000, aiScoreBuy: 85, aiScoreSell: 20, themes: ['반도체', 'AI', '스마트폰'] }, - { code: '000660', name: 'SK하이닉스', price: 124500, change: -1200, changePercent: -0.96, market: MarketType.DOMESTIC, volume: 2100000, aiScoreBuy: 65, aiScoreSell: 45, themes: ['반도체', 'HBM'] }, - { 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: ['플랫폼', '모빌리티'] }, - { code: '005380', name: '현대차', price: 245000, change: 2000, changePercent: 0.82, market: MarketType.DOMESTIC, volume: 450000, aiScoreBuy: 78, aiScoreSell: 25, themes: ['자동차', '전기차'] }, - ]; - } else { - return [ - { code: 'AAPL', name: 'Apple Inc.', price: 189.43, change: 1.25, changePercent: 0.66, market: MarketType.OVERSEAS, volume: 45000000, aiScoreBuy: 90, aiScoreSell: 15, themes: ['빅테크', '스마트폰'] }, - { code: 'TSLA', name: 'Tesla Inc.', price: 234.12, change: -4.50, changePercent: -1.89, market: MarketType.OVERSEAS, volume: 110000000, aiScoreBuy: 40, aiScoreSell: 75, themes: ['전기차', '자율주행'] }, - { 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'] }, - { 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'] }, - { 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'] }, - ]; + try { + // Use Rankings as the default "List" + const marketParam = market === MarketType.DOMESTIC ? "Domestic" : "Overseas"; + const res = await fetch(`${API_BASE_URL}/discovery/rankings?limit=50`); + if (!res.ok) return []; + const data = await res.json(); + + // Transform logic if needed. Ranking API returns StockItem which matches frontend type mostly. + return data.map((item: any) => ({ + code: item.code, + name: item.name, + price: item.price, + change: item.change, + changePercent: item.changePercent, + market: market, + volume: 0, // Rankings might not return volume yet, or it does if enabled + 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) { - 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) { - 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() { - 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(); } }