Coverage for view / web / web_main.py: 83%
155 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 15:08 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 15:08 +0000
1import json
2import os
3import threading
4import time
5import uuid
6from contextlib import asynccontextmanager
7from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
8from fastapi import FastAPI, Request, APIRouter
9from fastapi.staticfiles import StaticFiles
10from fastapi.templating import Jinja2Templates
12# 프로젝트 내부 모듈 임포트
13from view.web.web_app_initializer import WebAppContext
14import view.web.web_api as web_api
15import view.web.api_common as api_common
17# ── 진단 전용 HTTP 서버 (포트 8001, 별도 OS 스레드) ──────────────────────
18# asyncio 이벤트 루프가 완전히 블록되어도 응답 가능.
19# 브라우저/curl 어디서든 http://127.0.0.1:8001/debug/requests 로 확인.
21_DEBUG_SERVER_PORT = 8001
24class _DebugHandler(BaseHTTPRequestHandler):
25 def do_GET(self):
26 if self.path != "/debug/requests":
27 self.send_response(404)
28 self.end_headers()
29 return
30 now = time.monotonic()
31 rows = sorted(
32 [
33 {
34 "path": r["path"],
35 "method": r["method"],
36 "elapsed_sec": round(now - r["start"], 1),
37 "query": r["query"],
38 }
39 for r in api_common._active_requests.values()
40 ],
41 key=lambda x: x["elapsed_sec"],
42 reverse=True,
43 )
44 body = json.dumps(
45 {"count": len(rows), "data": rows, "recent": list(api_common._recent_completed)},
46 ensure_ascii=False,
47 ).encode()
48 self.send_response(200)
49 self.send_header("Content-Type", "application/json; charset=utf-8")
50 self.send_header("Content-Length", str(len(body)))
51 self.send_header("Access-Control-Allow-Origin", "*") # 브라우저 콘솔 fetch 허용
52 self.end_headers()
53 self.wfile.write(body)
55 def log_message(self, *_):
56 pass # 로그 억제
59def _start_debug_server():
60 try:
61 server = ThreadingHTTPServer(("127.0.0.1", _DEBUG_SERVER_PORT), _DebugHandler)
62 t = threading.Thread(target=server.serve_forever, daemon=True, name="dbg-http")
63 t.start()
64 print(f"[DEBUG] 진단 서버 시작: http://127.0.0.1:{_DEBUG_SERVER_PORT}/debug/requests")
65 except OSError as e:
66 print(f"[DEBUG] 진단 서버 시작 실패 (포트 {_DEBUG_SERVER_PORT} 사용 중?): {e}")
69_start_debug_server()
71# [추가] 서버 시작 시 초기화 로직
72@asynccontextmanager
73async def lifespan(app: FastAPI):
74 # 1. 초기화 객체 생성 (app_context 대용으로 빈 객체 전달)
75 class SimpleContext: env = None
76 ctx = WebAppContext(SimpleContext())
78 # 2. 환경 설정 로드 및 서비스 초기화
79 ctx.load_config_and_env()
80 await ctx.initialize_services(is_paper_trading=True) # 기본 모의투자 설정
82 # 3. web_api에 완성된 ctx 연결 (이게 없어서 503 에러가 났던 것임)
83 web_api.set_ctx(ctx)
85 # 4. 전략 스케줄러 초기화 + 이전 상태 복원
86 ctx.initialize_scheduler()
87 await ctx.scheduler.restore_state()
89 # 백그라운드 태스크 시작 (데이터 Flush 등)
90 ctx.start_background_tasks()
92 print("=== 웹 서비스 초기화 완료 ===")
93 yield
95 # 종료 시 정리 (데이터 Flush)
96 await ctx.shutdown()
98 # 종료 시 스케줄러 상태 저장 후 정지
99 if ctx.scheduler and ctx.scheduler._running:
100 await ctx.scheduler.stop(save_state=True)
102# 1. FastAPI 앱 인스턴스 생성 (lifespan 추가)
103app = FastAPI(title="Trading App", lifespan=lifespan)
105# debugpy가 요청 처리 컨텍스트를 인식하도록 첫 요청에서 트리거
106import sys
107if "debugpy" in sys.modules: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 _debugpy_activated = False
110 @app.middleware("http")
111 async def _debugpy_activate_middleware(request: Request, call_next):
112 global _debugpy_activated
113 if not _debugpy_activated:
114 _debugpy_activated = True
115 import debugpy
116 debugpy.debug_this_thread()
117 return await call_next(request)
119# --- 요청 추적 미들웨어 (hang 진단용) ---
120# /api/* 요청의 시작~완료를 api_common._active_requests에 기록한다.
121# /api/debug/requests 엔드포인트가 이 데이터를 읽어 in-flight 요청 목록을 반환한다.
123@app.middleware("http")
124async def request_tracker_middleware(request: Request, call_next):
125 path = request.url.path
126 if not path.startswith("/api/") or path == "/api/debug/requests":
127 return await call_next(request)
128 req_id = uuid.uuid4().hex[:8]
129 api_common._active_requests[req_id] = {
130 "path": path,
131 "method": request.method,
132 "start": time.monotonic(),
133 "query": str(request.url.query) or None,
134 }
135 start = time.monotonic()
136 try:
137 return await call_next(request)
138 finally:
139 elapsed = round(time.monotonic() - start, 2)
140 api_common._active_requests.pop(req_id, None)
141 # 완료 이력 기록 (hang 직전 분석용)
142 rec = api_common._recent_completed
143 rec.append({"path": path, "elapsed_sec": elapsed, "at": round(start, 1)})
144 if len(rec) > api_common._RECENT_MAX:
145 del rec[0]
148# --- Foreground 우선순위 미들웨어 ---
149# Broker API를 호출하는 라우트만 foreground로 래핑하여
150# 백그라운드 태스크(RankingTask, WebSocketWatchdog 등)와의 API rate limit 경합을 방지한다.
152_FOREGROUND_PATHS = frozenset({
153 # stock.py — 현재가, 차트, 기술지표
154 "/api/stock/",
155 "/api/chart/",
156 "/api/indicator/",
157 # balance.py
158 "/api/balance",
159 # order.py
160 "/api/order",
161 # ranking.py
162 "/api/ranking/",
163 "/api/top-market-cap",
164 # program.py — broker API 호출하는 엔드포인트만
165 "/api/program-trading/subscribe",
166 "/api/program-trading/history/",
167 "/api/program-trading/unsubscribe",
168 # scheduler.py — start/stop/strategy 제어
169 "/api/scheduler/start",
170 "/api/scheduler/stop",
171 "/api/scheduler/strategy/",
172 # virtual.py — broker API 호출하는 엔드포인트만
173 "/api/virtual/chart/",
174 "/api/virtual/history",
175})
177_FOREGROUND_EXCLUDE = frozenset({
178 "/api/ranking/progress",
179 "/api/stock/search",
180})
183def _needs_foreground(path: str) -> bool:
184 """경로가 foreground 우선순위 적용 대상인지 판단."""
185 if path in _FOREGROUND_EXCLUDE:
186 return False
187 return any(path.startswith(prefix) for prefix in _FOREGROUND_PATHS)
190@app.middleware("http")
191async def foreground_priority_middleware(request: Request, call_next):
192 """Broker API 호출 라우트에 foreground 우선순위를 적용하는 미들웨어."""
193 path = request.url.path
194 ctx = api_common._ctx
195 fg = getattr(ctx, 'foreground_scheduler', None) if ctx else None
197 if fg and _needs_foreground(path):
198 async with fg.context():
199 return await call_next(request)
200 return await call_next(request)
203# 2. 정적 파일 및 템플릿 설정
204# 현재 파일(web_main.py)의 위치를 기준으로 절대 경로 설정하여 실행 위치에 영향받지 않도록 함
205BASE_DIR = os.path.dirname(os.path.abspath(__file__))
206app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
207templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
209# 3. API 라우터 등록
210app.include_router(web_api.router)
212# 페이지 라우터 생성
213page_router = APIRouter()
215# 공통 페이지 렌더링 함수 (로그인 체크 포함)
216async def render_page(request: Request, template_name: str, active_page: str, extra_context: dict = None):
217 try:
218 ctx = web_api._get_ctx()
219 except:
220 return templates.TemplateResponse(request, "login.html")
222 use_login = ctx.full_config.get("use_login", True)
223 if use_login:
224 auth_config = ctx.full_config.get("auth", {})
225 expected_token = auth_config.get("secret_key")
226 token = request.cookies.get("access_token")
228 if not token or token != expected_token:
229 return templates.TemplateResponse(request, "login.html")
231 context = {"active_page": active_page}
232 if extra_context: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
233 context.update(extra_context)
234 return templates.TemplateResponse(request, template_name, context)
236# 4. 페이지 라우팅
237@page_router.get("/")
238async def index(request: Request):
239 return await render_page(request, "index.html", "home")
241@page_router.get("/stock")
242async def stock(request: Request):
243 # 종목 리스트는 클라이언트에서 /api/stocks/list + localStorage로 관리
244 return await render_page(request, "stock.html", "stock")
246@page_router.get("/balance")
247async def balance(request: Request):
248 return await render_page(request, "balance.html", "balance")
250@page_router.get("/order")
251async def order(request: Request):
252 return await render_page(request, "order.html", "order")
254@page_router.get("/ranking")
255async def ranking(request: Request):
256 return await render_page(request, "ranking.html", "ranking")
258@page_router.get("/marketcap")
259async def marketcap(request: Request):
260 return await render_page(request, "marketcap.html", "marketcap")
262@page_router.get("/virtual")
263async def virtual(request: Request):
264 return await render_page(request, "virtual.html", "virtual")
266@page_router.get("/scheduler")
267async def scheduler(request: Request):
268 return await render_page(request, "scheduler.html", "scheduler")
270@page_router.get("/program")
271async def program(request: Request):
272 return await render_page(request, "program.html", "program")
274@page_router.get("/system")
275async def system(request: Request):
276 return await render_page(request, "system.html", "system")
278# 5. 로그아웃
279@page_router.get("/logout")
280async def logout():
281 from fastapi.responses import RedirectResponse
282 response = RedirectResponse(url="/")
283 response.delete_cookie("access_token")
284 return response
286app.include_router(page_router)