From eeddc620896797004d3e9c8aeec9c122f2a8e100 Mon Sep 17 00:00:00 2001 From: LGram16 Date: Tue, 3 Feb 2026 00:52:54 +0900 Subject: [PATCH] =?UTF-8?q?"=EB=B0=B1=EC=97=94=EB=93=9C=5F=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=5F=EB=A1=9C=EC=A7=81=5F=EA=B5=AC=ED=98=84=5F=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=5F=EC=97=B0=EB=8F=99=5F?= =?UTF-8?q?=EB=B0=8F=5F=EB=8F=84=EC=BB=A4=5F=EB=B0=B0=ED=8F=AC=5F=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=5F=EC=99=84=EB=A3=8C"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 22 +- backend/app/__pycache__/main.cpython-312.pyc | Bin 2636 -> 3213 bytes .../app/api/__pycache__/api.cpython-312.pyc | Bin 0 -> 530 bytes backend/app/api/api.py | 12 +- .../endpoints/__pycache__/kis.cpython-312.pyc | Bin 0 -> 2948 bytes .../__pycache__/settings.cpython-312.pyc | Bin 0 -> 3890 bytes backend/app/api/endpoints/account.py | 54 ++++ backend/app/api/endpoints/auto_trade.py | 87 +++++ backend/app/api/endpoints/discovery.py | 68 ++++ backend/app/api/endpoints/kis.py | 22 ++ backend/app/api/endpoints/news.py | 29 ++ backend/app/api/endpoints/settings.py | 3 + backend/app/api/endpoints/trade.py | 100 ++++++ backend/app/api/endpoints/watchlist.py | 95 ++++++ .../core/__pycache__/crypto.cpython-312.pyc | Bin 0 -> 3165 bytes .../__pycache__/rate_limiter.cpython-312.pyc | Bin 0 -> 1971 bytes backend/app/core/startup.py | 69 ++++ backend/app/main.py | 23 +- .../__pycache__/kis_auth.cpython-312.pyc | Bin 0 -> 7302 bytes .../__pycache__/kis_client.cpython-312.pyc | Bin 0 -> 8106 bytes .../realtime_manager.cpython-312.pyc | Bin 0 -> 6197 bytes backend/app/services/kis_auth.py | 5 +- backend/app/services/kis_client.py | 54 ++++ backend/app/services/master_service.py | 112 +++++++ backend/app/services/realtime_manager.py | 186 +++++------ backend/app/services/sync_service.py | 104 ++++++ .../__pycache__/scheduler.cpython-312.pyc | Bin 1857 -> 2332 bytes backend/app/workers/scheduler.py | 150 ++++++++- backend/kis_stock.db | Bin 0 -> 110592 bytes services/config.ts | 8 + services/dbService.ts | 304 ++++++++---------- services/kisService.ts | 98 ++++-- 32 files changed, 1287 insertions(+), 318 deletions(-) create mode 100644 backend/app/api/__pycache__/api.cpython-312.pyc create mode 100644 backend/app/api/endpoints/__pycache__/kis.cpython-312.pyc create mode 100644 backend/app/api/endpoints/__pycache__/settings.cpython-312.pyc create mode 100644 backend/app/api/endpoints/account.py create mode 100644 backend/app/api/endpoints/auto_trade.py create mode 100644 backend/app/api/endpoints/discovery.py create mode 100644 backend/app/api/endpoints/news.py create mode 100644 backend/app/api/endpoints/trade.py create mode 100644 backend/app/api/endpoints/watchlist.py create mode 100644 backend/app/core/__pycache__/crypto.cpython-312.pyc create mode 100644 backend/app/core/__pycache__/rate_limiter.cpython-312.pyc create mode 100644 backend/app/core/startup.py create mode 100644 backend/app/services/__pycache__/kis_auth.cpython-312.pyc create mode 100644 backend/app/services/__pycache__/kis_client.cpython-312.pyc create mode 100644 backend/app/services/__pycache__/realtime_manager.cpython-312.pyc create mode 100644 backend/app/services/master_service.py create mode 100644 backend/app/services/sync_service.py create mode 100644 backend/kis_stock.db create mode 100644 services/config.ts 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 48ae1088842ae1f24b75d6d36808b3dea027cd1b..1c0493848845e3a89661517e8cb1832c401de5e6 100644 GIT binary patch delta 1696 zcmaJ>OKclO7@k?L*RR-3{HR^KX*W1-y?r<#sc1_r6gNB?0#1c_2n%E_yAx;K^{!cO zh+={4qDlo(RHb_$qUsSi*hrN)aO;I52ojO1)hdTdJOsDUf&@JvX6&rf$QCQj?#%ap z-~9jo&1k<1e!S23t=H=Y_?fuXka*{cF9NTv9Dchl0?{q|^L`2-f9Vh*?0zts+>MZx z=xGf!sSXZ7TYIFJh4waK2hE3(NAw~%Pl>)ubaR`Uk5F}hAo7+b`iQ-y6TRqlAnv(A zVMYJg;Qz#ia4gdDH|^Uc#Zatt+DO6hnJ0Vmv;sTr;cV$%hW12T-+*D5HdbMxe(+sj zwYx%H8K8kcy$8gg7%DBYz;&^2iUAJIPfv2r{gZ!2edl_-w9 zaAE z@HmJ#E14K<4O@kXvDSg6t)qir*^R=b*Zb^()z`rd%G!Z21q!x3T>>2YJC+#dY**{q z+KEJxuL`)Nh| z)M&$Z{J}#!nFSDh0`zu(yh__0M7s!{h;XM|-ERDAi@(_oTY@e?_>Z)wJFJLx@UTxh zi$lJm&6uKt_1>Cxk?5V0mcdDICHJLm33B#qEi$7>y2Q&;O;U>N)4a~V#aB@(m8!8h zo!4}Np5=uLMNM5)L^dZZAaPMf8et4)Div80cwJJ@DINM^g`HN*6&dL$RSWIJad=@- z7u6*t#nC1stHmPHOj=T~?c{%?;VT6=sQHXf=}bL<7a=^VIsEz!{M`V5GQjS+JbQ6= zZScqjeRR`3^bvP?t}*vB?e3rx8}#HR9oV2lU1)5B9^a&0-Sy$ybaHD5*n{x$R&qYNAIW4bx8fOuJ7C}0PK3)(1# z(#9Liei%2FnX@AYuv@w$u%e_PL07e9R#A0!zHJ(>v)pmxS7tqHI!Nc(Q`3WOmV{|l znU{(tJ(GQQ&U6)#{zAtXXQ4o@YoVZ#N;Tq#HbRPUXU@+QUVXffd-)~o)eny804c=u z;9Hx*m6M?*jD#~cX*!m8O~I)+XfmoM;jC41ugA4^H_mifB?tMGHj6ah=x-SuStvfCiF5^Ms*8clj3wKLMKrPOH+k!ac& z5)Wcx-2^m-o41w(W1{im#iJfd0x@YY!JF}f=s`IcXIly+WwJB-=6&z`=FOY;#QE7~ z`eZbUz{dLfjARqCCI@n4+wPnqdMpxji^KXkKBwSp1T7p8M2yka^8Vn;yzTNhvI~YK zZqGLE+hZnXTkUxMz_r*iiIkbQR+3?o_GoYQq1aa8WBVNJmBEDp*TovAnu1Lw=DBJn zX(GmSw7nY0*YDqhRZ~Y=Dn~>ItD`GNMlV)lhxvjY;=zttJ$oxJNqeIs)wGHv^|Cwr zt8wgPX$fpPiMNs(uFW@8(#DRwE2k>+5Zr5Gk*&CqcwfR2SBY2rVNOJ)g3o}jS^l7s zJ|%rBrt#I)fw%(pD7W{{5?opoOh zr9wADN#bsG2f`78@5TsqkHn>v6pBf+1p8D}X>iJl0!4L#O7WBrQ3Km05y*bc*&#Et zOMB(LzQdmk{MLZp^4RxiEHk#jTi1B|2CplK$Jh9due@$c0JUu=sW2XzdeBqU&1c;` z@{Y!j%<{1C4jJTsg1fhd;YCA)gkq`41WPP@n;u~}hygDQE_X0{Z?!SRua>nA1x*RE27yso=s zRiumhP$JP8N=VA29{HFU=%OYD_)t+ZO{GaG&aMp5O-s3H2d1W#YMrM_cPT(}QGP9Y zU7N_Z#i^0_T-XNo`=tWTUUK|2zwzjYkmnn#hT-JOm6d^lcwwC%{JY%+_2Ve1VHzJ* IHN%4VAA8fs-T(jq 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 0000000000000000000000000000000000000000..992e641c1f67c3d865f7647dcae74a51bce82ca1 GIT binary patch literal 530 zcmX@j%ge<81e13*Wco5PFgylvV1O0M_`C|pn9h*GkiwY5kjogw$OvLHuP3*2qOay-7Hvjv!$@3 z>aOI_Eso;Ul9J54^x|90*_p*vocb_+5erB)(=E1w zqSUm^id!rtiRr~vEc!rIMW7(?)8x3to|af#l30*g1ajpq!Nh_BJs?jnH7}(gKQphS z7;FYu^(`)-M0^q0)?0j;dC57YDXB0%8$^61!)Kr|48Qb(tzv@nON)|IV}d<>17f^0 zi-Sw@le1%z5|gumX2$^S0pd&$)+?yI#bJ}1pHiBWYF8ux)DDa;hGGvO@qw9py&Yraif1p literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e67f9283a28e19687948ce371474ca85fa87ae06 GIT binary patch literal 2948 zcma)8TWlN06`k4HUG9<;sRu>bRO_&{9fww=Bv#V8FK_@T?B2D+K`hLZ9W_nXv-i#0_2AT4UpJ2D2k>)^QT(==h2iDhU0VD_sIz&_)s?bP1Wr#5T&|G|owB zDM!*IC#Pl1?UJqJ)U+yOV*7GhS`)Ht>p6egFJ#3IqyrM7c&#&SItW@|Io45oUZ%PR zU*wbyN$3zVeHW3bQFV=UtYI2l{z6;s<)+?^GyPOE1KYuA2I;_}Z1!E0H@`dhU!p^I zMlVWcxF>Q5A``t|Y|Cc?p!eKOMQ9(}2)&@7PXE8I|!`$9@6M=y#z0KOmRL_Z0=5mCCp*nbKux9(@_Tr0+zo z+zCUw5F0&9T>eEtCUNzyTqa#aUzfXcpT{OXOP(jE@oDrN`8GO@-vBwGoH^)^10AAn ze2yCSxNGHTe1?}Ze&5`|`QwQR(C8v%@o&--^N?~FxL9Cus1`MHR(@$h#7`$Akq%M3 zk8i`40a^hE%3A`sbI7PBbq&i-D2%Vu8(5-lCR<_*4njsG*3&nS5#!B^jRH%^?R@i8 zw}FAF!;`=l&Enj8Z+x-FUbWeXQC4SVd`V z``oLgdU(91jjt0lki!4?C=j`YFbofkUL|imegpsN$(x}AHSNH^)(M#Z?K}vxp2w+v zd~tgMUBP?gA885nIyd|bC(t!L)kl6AO=+a6DIiw+2zbtD51cS;BcG+&O$5L+n;5wa z%H3k5TTeg;`V>NN84GNvcVI*6!3OTZhSZ(wy3~UWz}oZD7Hp7d_C;7D+X<{&wg4|% zXIpF!_kL0;B1+}Srr{dwKJYobmu=Q*FCWd>=eg@Juy&nfUZ>%En!{uD;jybvHijpg z;mL~J3J+BRP@n#X@47red~w@ubQ$lIuPD3Gk8vu9e}Z?T*YuQ5-i)SXQk4}Dt2zM> zoaX}Xgq|W^pIxKkcrFahv>>?ZZSXi-5VG!t6PSHT1Lyf4{jl_ ziW~jYX0Y-$wuQhN+F}9~th_;Op|#Fh{wIF;`4J>~i{uV>byIRjx^j7QkJrin!H+zB zX4@RiQtK2o%n3VNztSXzQg$W|U&i)aslqHSbpMwF)A0ft}&?H0q= zC3?Y~0K6Yu9%0n^4E*sk%sAT(kMmt%0)HRL{uhp(J6apud-aj4dusjrZpxGE631j@ zM-y&|2-ts~yq3H%{M*>uvEM!OUb>pD&3>afo2k!cYQ|#IuOG?eoY1=V`?ili$)=#(R#Zx+_KK*X`?f1TM@8hq6=9=$(mA z{z=-|_wjE%Z_5fEe)%i65xmwzNYY-2or_ftH$am#oP70A?f$9yuE*CADDO|+;;#*Yq^R(& zN4f!DZ{U%2Nx{iimTLDV>pQ-B3nmmJw+VB5>d|wfm2WjL1gkJ|;TD1ypKauC+#ojh E9~1_J=Kufz literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..65e34339548b320bdd00f408a1be62790985d26c GIT binary patch literal 3890 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 Fe**>muTB5} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..537039553844178fa7e42ebb5e77cee4bc5e4f22 GIT binary patch literal 1971 zcmb7EU2GIp6ux(6_NUt|El`%9ws0u~nFQN{707BTEKLZ44bm9HvT-uZ+`_c8Gs~S> z>#QXqwlOuqXj8+MC-g}T@!4nk>I(=7&A@|+KImJaCN#ze&zb3Ni}A&qnRCxQ=iGDe z{m#!%ZEYC>L;3NBl~oA&6&H;G>u9VTg{e+VVp5wF+4 zH(rpU98gvH16L!)nk<^utjWqjSk?(AMQW0wWYVIH{)&nz15KC;PmW1uVn!*Zf~1-X zTQBH7KWCLKpNraO83yANcYLASR*jqN0>4(}p3mN`@+xQkRo!RY8FPifJ*KnD)xfh1 z-DW1Y^?+UG{v_uPJ9+*L1F7xxMgE5Qm|nI0H6zPoM_wN&d+gvK6R~%XRk&atH(bY5 z<2?r5@Hl*%4FAf7=xu&eH{N*l2IB@&Q$%ZOO$saY5Dim;RU-~0AgVkf(DX{IzW^NA6@17`2&OzqMgMo^5dJ^OSsdEhDC(nxebQ|L^WEWe2?9q>bvEh6;Kajes5_ zH*dl}0DsFqa+pLmfXuDv)MF$;JUS^+auKkjB%mM37s(|3L7t?lR68G)1eV!J<66}c zoMC1$-7%SGb6#QA7;`w3m#2$>`7YFmSK)>awZ|Ez3yabvt`c-JqNW7G8Y)pnwv=7R z^1=jZaP%UQB{V0F0-)~k5rT>fwGeYnUcn+tEyd0Jq}uVmorCwCPN z$oCG@LT~ckksja|+gm}txQiC{D~nx8BJW&MpPQbQ2Rg^mdGbtj-cs)R7$TLY!q@SndMA!37HZi!|tt5IEPW?$>ig%(d zBq^q>=s<#kaO)M@y{y|BRzRa+ian^to5!h|elx!Ckk|_{YzZD(EX$PACuGwT()vQ_ UroGPyk|DCacfS09Y>gcJ1AI}t(*OVf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1b06882c6b63352061d787ecb971c32ed958ce5 GIT binary patch literal 7302 zcmeHMU2GdycD|Q0oZ-(%qBJriiBd$Fl1Rojr9|?|QoOds=*Nm=)s7O!X53|H&Pb+2 zk<6V@WT{k;+D4#av)G@u>lB-!F8WX#$zrqEp!Gvh*oyy(_CXX4P?_~&T{H!nH+gL! zUhh8i+#!dQWWkAx?o&I`oO|xMXYM`s%*)F} zHLRdvU8i9N4I60K<`}=A@xw8tcSb!m?>nWcDW#{aEt!%NkyI=&l_YW`mUvrM)M+`P z2BOL7p6<@hjwoJdDFZw`@4pG;JW`8elLcpjxOk+|q;YeLGK40L&90MuRG24?!o@4* z&?P+TH*0)6IWYm=)T|S-Is&{*G;25hYb%SOrLzbU`k^}C^^-5CKs?|S}<9WN?orXYSB%G>n7=p zn~sv!q-E0(bR}lWhmdjo0+Ys*_Q$o-jG=KBN$j711wUrbGw+$rXn{?$lVy)zQMB5e zHj|pPd6If-(?JA~J2nZStJqc1kVRc9Q(eYvll2ByYK{G>jfPC(KeiYv%!6>M59V%2b7>Q)1ur)ldZpHw zqv&H(-p3e?!5{pPwwH7bZnDMTO08ry*2QRp54N}mhUNmlL34|{Qq;77CEn6%2AAg3 z#b{dGf$@}BRew>>D%t3bG3HB~!+Ktz<4@~jb;r6{?dxa#STN?NzK1pJMNK@p%iv4x zW4 zkInR%=t5?Q7HQiVEU^o=cWkqG7M*5}qgm`hU}xPoc)sXdOlhuJWX$kWW)}O)=ErRA z!tsh8xD@x>k$6mUN1_lS6}Os%xEXK{DawqjxD)cMJDHH(v2nM2Iu#?b%6T+~!898q}vFhrvgsO3nt*2wEX6-*6l~XFXO|y_>JWi9G-%e;u z)ojwqu#yKWT3MXJm138LoDY)_S(%BenxGW$OE`IQ5^xXoZy0b4Sf5ITr{p=kQ%*bJ zFW92qw-xjvYDB}bLVt9|V+1Xs;ahV+3J|1rUYT7e3?Vs-X$Gtz_`-^?ohL(>WyWMv@({X&I=J;K=|DF#y8dja|oU=9K zY|T1-IfpOf@U1Z*Uh|-`%I^>V*>Fy%&j|I)4X@r7Ub|D*av`2;?as7zXX|`K_T`D&droD%r*xIORrR?lPo~O~t!l{$Ez3g7s@U*B zA|vj)Cp^LImoRd6{&uYoqe}n7wMJAoiXX0(qq5eAYYq_o_JJKaYJY=F*6LNdKue~o zC0pgq3Eqt0U9A!q2Y=ycVa4U!<2ts=R0(|TbFcM54xFKNId9bZi)Dyle<;TQ{H8!ayN6(gKl}8LzubM zjSud%-0Il?S&5a*G5j(2QNc>c>iKK+yaCt` zaML6WKbsC?HONrhR%p@~kD8#KgFmANI}82wwo%*zxpEB7E;y|o^gPmaHsme^&q8H* z9P%MXbA>FZ=P-VBzPv3-f&2??dRRKFRYl`5NH<}KS4P_LGn#FJL_ny{rShR0f}0+? z^KtEw@KQ3N!d1Io1%*crxO?$fG(y8`+oY0ATrz1aZ8iRs9Fb(AT;jB{VDDIe__bF? z!msrAj%a3DW}SwrP?<|aU(!v`*i=$cH3x~r6gfN&nL|+{>Wo4L!I^$u57y+{z>|GI zG-igxH9i%Yizg!z1p2}p5UW!PeQpXtRu8a*!khVOBc`rT7QHZ{d=H2oPgg6qhY`V`ADV;(353T&}(*iiaxSnx3O-qsPGt)QoF(1Pg7KU zot+y3Glr64Ls7xcj}%J*qo{aYJ~;+2Vr13bn@W)+)EI6+&RZ20TT~N-+A~V(fD(E$ z{Z*R~JE2uJp=C3nZXnH+(2AL~QsM=oRnN*N72O2G0x%$t#COnfu@FEZ?(6nZ?5l%r z(njZKr-T-61%yprpn?ucIw{#r$+v)LG;Z+-DAs^3MGrzqSqcH?!BAUdn$P_qVdw_L+gd&QSg9HMrl$U9YA3 z*BddFZ^x8x*7;VQ`gF;2)&Y^Z-XRW@GdDOqVC8P`Jmt&HAip6p15R|q=^W@}ZtTPZ zZI&Cmc*=J&Fx%G1b>#mDa$Vrlyu{P!^9?cVF$m9l;e1*QYZ;w)7#P+FPviis>t_fB zb0i2vv*wX14NQGh*iXp-C9hL51jKLI1S!cM(V@eX9HHbDAQ}rFIwA^v3sC@+`I4Qy z29kN#CPcYDWX*tr@&S;?faP*?=W_Y(KM=A6z$rfSL3u{>er5e;7_*sTOe@8h%@ku+ z=or%u7}H1jE&;?#3=WoYODsouI|lhu8K!&%7i{8}w&7rnu;k<^-@t>M(AVD({prgJ z%@z(%C#9J<{T3mF!*9(*;`uKV9Jp*L4L@6+DB zx|X~eG;5<+F#A3c*AO&UKpUD{o(HtauS-|+SCC<7^jA^&w@^0at#~9JJta@i=`X~z z3b_3N>12RDF()JNf{MN?)5;1`@PQmx@^8&_%_w|W3h3XxlKI?9>qbJcoW4EHGFABK zLf)jZq6aj~-n>8ek#QJ+_<|IgT-O+k@h_3^YvlVCYWNkUE%5(q)cH$P{uMimvF{$d S4h0IE5XA3x|EWcv^uGbgb85l> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..178daa40abca05bedfc162c08ffbe8e248c6ebcc GIT binary patch literal 8106 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..df918d57200076b2f5a75a23e9cff16fdf1afc4f GIT binary patch literal 6197 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 literal 0 HcmV?d00001 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 8f0e95be0c4713ccf7e5bb60c70273d28bd30eff..cf4314d604c173d45cd559d8fa2daf7ab62d3e4f 100644 GIT binary patch delta 769 zcmaJ;O=uHA6rS14ZZ{jU+E^2R>z3-0fTR~Kwlt(MqG+0^S;X_YnIfXJm%m@q!qE?!I`u|ia!tLM+Ptd;^V*(@Fq+>-dwR0%DD6;KlY-@gK$qW;!bfd!ZtpwHp;cuO~|3NAHm zOeVFwNov^BvK4IAv`dW}TCwRkRi~UxCh1Q&o^Bo3sW3d#Yr2!#xkeqkUe!vqA}6&X zHf^t3$9dB+%b27J38^F5jWWwSsOro%=xJe;&I)sfYfQpt@M8&llYtnzpSzp$_nv8^ ziBB<|b_IRngPiHg>Z8zukRMI@V`)D*?N1lmhV{{?y*Fxp(e{VxZQ1#%fM9sbJjkqU)K&+Yv+Wh{wedb{t=O uA*%3DPa+pa_HY>Dz`Mu@^`)aQLBB{-u|XK#&;u~Ip)lA~B&d^ptmYTzd&9o~ delta 433 zcmbOubdZnlG%qg~0}veD*^rsRwvdl;vMvj!SO-wVn}LyGIztH~NEQULm?mFj6rSwB z$jMR5P{UB9Ucxfjmr;hLSgeF&@+>wPi4>;QP{oW4mCPWxmbsi!lV!3zQ~2b0OlHh} znrxGWnU(o&vE}5ar>7R(V#&-)%b#q91IfblMgbhd0*jh{J_P) z$@_tqLDBL8yZFyyHlU&=M+QeG#$=F#V1Nn8Xa?fXJU{~C>PiNX)hikORx%U`F)&Qt z$fDpS4N}4lB*6B3U}IqA{P~#$$Zuj|A=1DiL7?&?p~)?*8vGJKrUHm73L?ZdUt}$3 z6aop}Vl6I7EGj7iSyjX{If`9{RSC#c-Q3L1$0!9Bl?O@jg9s520n$~ZGhUe2l!G6qp$~KXCzx&s=PbYDL08H2~<&R;mC1 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 0000000000000000000000000000000000000000..02fe0b521923333578ee96e9860c5a45942baf67 GIT binary patch literal 110592 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≀gpQ)JZh(?5)@2CYckDC4*a zJ-(!6_G^yd>+GRJq;5o0k-<(wGljvnNd2cbYKxweetNI1Z+8B$#hYEH-|Ee;(6!9E zL}}qIpHjba&Wc?8hk22U2OeS%?4Fa7$UtSrB(ZPC-s#)YYPnsXJ`qfPXqoZ6gC>`* zO%Giu5oXLl>2a5MZY&+b@34$Vm)Yy5D6kt17W6NXM1SslQvVu9RM2qZuX$lORIz6_ z_UP&A(3KooHzX8OGNcY5E|roC`LnqSe-M`RFr3FgoIFIKQL^WcEh?1pJ1I&I9}@|z z{WPR=1aI45&o3=h&J>ile87(PDnGyXDmPDMJtmPJ<_|vG$NQAtd%ldMG~CtR`ZdGP z>#fTBvjyc19@_K&|35$f&m8^6F9<*Y0uX=z1Rwwb2tWV=5P$##e%1o-=Fjo(PcO{q z_gKE$mj9OU@Bei%umJ%GKmY;|fB*y_009U<00IygSpod}e { + 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(); } }