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

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 

11 

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 

16 

17# ── 진단 전용 HTTP 서버 (포트 8001, 별도 OS 스레드) ────────────────────── 

18# asyncio 이벤트 루프가 완전히 블록되어도 응답 가능. 

19# 브라우저/curl 어디서든 http://127.0.0.1:8001/debug/requests 로 확인. 

20 

21_DEBUG_SERVER_PORT = 8001 

22 

23 

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) 

54 

55 def log_message(self, *_): 

56 pass # 로그 억제 

57 

58 

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}") 

67 

68 

69_start_debug_server() 

70 

71# [추가] 서버 시작 시 초기화 로직 

72@asynccontextmanager 

73async def lifespan(app: FastAPI): 

74 # 1. 초기화 객체 생성 (app_context 대용으로 빈 객체 전달) 

75 class SimpleContext: env = None 

76 ctx = WebAppContext(SimpleContext()) 

77 

78 # 2. 환경 설정 로드 및 서비스 초기화 

79 ctx.load_config_and_env() 

80 await ctx.initialize_services(is_paper_trading=True) # 기본 모의투자 설정 

81 

82 # 3. web_api에 완성된 ctx 연결 (이게 없어서 503 에러가 났던 것임) 

83 web_api.set_ctx(ctx) 

84 

85 # 4. 전략 스케줄러 초기화 + 이전 상태 복원 

86 ctx.initialize_scheduler() 

87 await ctx.scheduler.restore_state() 

88 

89 # 백그라운드 태스크 시작 (데이터 Flush 등) 

90 ctx.start_background_tasks() 

91 

92 print("=== 웹 서비스 초기화 완료 ===") 

93 yield 

94 

95 # 종료 시 정리 (데이터 Flush) 

96 await ctx.shutdown() 

97 

98 # 종료 시 스케줄러 상태 저장 후 정지 

99 if ctx.scheduler and ctx.scheduler._running: 

100 await ctx.scheduler.stop(save_state=True) 

101 

102# 1. FastAPI 앱 인스턴스 생성 (lifespan 추가) 

103app = FastAPI(title="Trading App", lifespan=lifespan) 

104 

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 

109 

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) 

118 

119# --- 요청 추적 미들웨어 (hang 진단용) --- 

120# /api/* 요청의 시작~완료를 api_common._active_requests에 기록한다. 

121# /api/debug/requests 엔드포인트가 이 데이터를 읽어 in-flight 요청 목록을 반환한다. 

122 

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] 

146 

147 

148# --- Foreground 우선순위 미들웨어 --- 

149# Broker API를 호출하는 라우트만 foreground로 래핑하여 

150# 백그라운드 태스크(RankingTask, WebSocketWatchdog 등)와의 API rate limit 경합을 방지한다. 

151 

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}) 

176 

177_FOREGROUND_EXCLUDE = frozenset({ 

178 "/api/ranking/progress", 

179 "/api/stock/search", 

180}) 

181 

182 

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) 

188 

189 

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 

196 

197 if fg and _needs_foreground(path): 

198 async with fg.context(): 

199 return await call_next(request) 

200 return await call_next(request) 

201 

202 

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

208 

209# 3. API 라우터 등록 

210app.include_router(web_api.router) 

211 

212# 페이지 라우터 생성 

213page_router = APIRouter() 

214 

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

221 

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

227 

228 if not token or token != expected_token: 

229 return templates.TemplateResponse(request, "login.html") 

230 

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) 

235 

236# 4. 페이지 라우팅 

237@page_router.get("/") 

238async def index(request: Request): 

239 return await render_page(request, "index.html", "home") 

240 

241@page_router.get("/stock") 

242async def stock(request: Request): 

243 # 종목 리스트는 클라이언트에서 /api/stocks/list + localStorage로 관리 

244 return await render_page(request, "stock.html", "stock") 

245 

246@page_router.get("/balance") 

247async def balance(request: Request): 

248 return await render_page(request, "balance.html", "balance") 

249 

250@page_router.get("/order") 

251async def order(request: Request): 

252 return await render_page(request, "order.html", "order") 

253 

254@page_router.get("/ranking") 

255async def ranking(request: Request): 

256 return await render_page(request, "ranking.html", "ranking") 

257 

258@page_router.get("/marketcap") 

259async def marketcap(request: Request): 

260 return await render_page(request, "marketcap.html", "marketcap") 

261 

262@page_router.get("/virtual") 

263async def virtual(request: Request): 

264 return await render_page(request, "virtual.html", "virtual") 

265 

266@page_router.get("/scheduler") 

267async def scheduler(request: Request): 

268 return await render_page(request, "scheduler.html", "scheduler") 

269 

270@page_router.get("/program") 

271async def program(request: Request): 

272 return await render_page(request, "program.html", "program") 

273 

274@page_router.get("/system") 

275async def system(request: Request): 

276 return await render_page(request, "system.html", "system") 

277 

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 

285 

286app.include_router(page_router)