From e2d4b607cbb153e5724f71d67bd48218628f9cf1 Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 11:15:48 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=98=88=EC=88=98=EA=B8=88=20=EC=9D=8C?= =?UTF-8?q?=EC=88=98=20=ED=91=9C=EC=8B=9C=20+=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=ED=9B=84=20=ED=99=94=EB=A9=B4=EC=9D=B4=20=EC=98=81=EA=B5=AC=20?= =?UTF-8?q?=EC=A0=95=EC=A7=80=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 예수금: dnca_tot_amt(D+2 기준)를 먼저 읽어 당일 매수가 많으면 -913,965원처럼 음수가 표시됐다. 주문가능현금(_ord_psbl_cash) → prvs_rcdl_excc_amt → nxdy_excc_amt 순으로 첫 양수를 사용한다(데이트레이딩 엔진과 동일 기준). - 프론트: /auth/me 가 한 번 실패하면(배포 중 502) useTenantMode.mode 가 undefined 로 남아 포트폴리오·매매 쿼리가 전부 enabled:false 로 멈췄다(오늘 26분간 API 호출 0건 확인). auth-me 를 실패 시 10초 간격 재시도로 바꾸고, queryClient 기본값에 retry 3회/백오프, refetchOnReconnect, networkMode:'always' 를 적용해 백엔드 복구 시 자동으로 되살아나게 함. Co-Authored-By: Claude Opus 5 (1M context) --- backend/app/api/endpoints/portfolio.py | 26 ++++++++++++++++++++------ backend/tests/test_p3_audit_fixes.py | 21 +++++++++++++++++++++ frontend/src/lib/tenant.ts | 7 ++++++- frontend/src/main.tsx | 10 +++++++++- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 7a1a349..7da8f13 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -129,15 +129,29 @@ def _extract_balance_data(balance_data: dict) -> dict: if balance_data and "output2" in balance_data: output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] - # _ord_psbl_cash: sync_portfolio()가 매수가능조회로 채워 넣는 주문가능현금 - # (당일 매도대금 포함). 없으면 예수금총액(D+2 결제 기준) 폴백. - available_cash = _pick_float(output2, [ + # 예수금 우선순위 (2026-09-23): + # _ord_psbl_cash — 매수가능조회로 받은 주문가능현금(당일 매도대금 포함) + # prvs_rcdl_excc_amt — 가수도정산금액(주문가능). 데이트레이딩 엔진도 이 값을 쓴다 + # nxdy_excc_amt — 익일정산금액 + # dnca_tot_amt — 예수금총액(D+2 기준). 당일 매수가 많으면 음수가 되므로 최후순위 + # 음수/0은 건너뛰고 첫 양수를 사용한다(화면에 예수금 -913,965원이 뜨던 문제). + _cash_keys = [ "_ord_psbl_cash", - "dnca_tot_amt", + "prvs_rcdl_excc_amt", + "nxdy_excc_amt", "d2_depo_amt", "d2_deposit", - "nass_amt" - ]) + "dnca_tot_amt", + "nass_amt", + ] + available_cash = 0.0 + for _k in _cash_keys: + _v = _pick_float(output2, [_k]) + if _v > 0: + available_cash = _v + if _k != "_ord_psbl_cash": + logger.debug(f"예수금 필드 선택: {_k}={_v:,.0f}") + break kis_total_assets = _pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"]) diff --git a/backend/tests/test_p3_audit_fixes.py b/backend/tests/test_p3_audit_fixes.py index a6018b0..6d859ad 100644 --- a/backend/tests/test_p3_audit_fixes.py +++ b/backend/tests/test_p3_audit_fixes.py @@ -109,3 +109,24 @@ def test_manual_sell_handles_null_current_price(): from app.api.endpoints import portfolio as pf src = inspect.getsource(pf.sell_stock) assert "price or holding.current_price or holding.average_price or 0" in src + + +def test_available_cash_skips_negative_deposit(): + """예수금이 D+2 기준으로 음수(-913,965)여도 주문가능/정산 금액을 우선 사용해야 한다.""" + from app.api.endpoints.portfolio import _extract_balance_data + + out = _extract_balance_data({"output2": [{ + "dnca_tot_amt": "-913965", # D+2 예수금 (당일 매수 반영으로 음수) + "nxdy_excc_amt": "4524276", + "prvs_rcdl_excc_amt": "5224855", + "tot_evlu_amt": "9825565", + }]}) + assert out["available_cash"] == 5224855.0 + + # 주문가능현금이 주입되면 그 값이 최우선 + out2 = _extract_balance_data({"output2": [{ + "_ord_psbl_cash": 6000000.0, + "dnca_tot_amt": "-913965", + "prvs_rcdl_excc_amt": "5224855", + }]}) + assert out2["available_cash"] == 6000000.0 diff --git a/frontend/src/lib/tenant.ts b/frontend/src/lib/tenant.ts index cf3ee32..3fe9a30 100644 --- a/frontend/src/lib/tenant.ts +++ b/frontend/src/lib/tenant.ts @@ -43,7 +43,12 @@ export const useTenantMode = () => { queryKey: ['auth-me'], queryFn: async () => (await api.get('/auth/me')).data, staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, + refetchOnWindowFocus: true, + // 이 쿼리가 실패하면 mode 가 undefined 로 남아 포트폴리오·매매 쿼리가 전부 + // enabled:false 로 멈춘다(배포 중 502 한 번에 화면이 영구 정지). 주기적으로 재시도. + refetchInterval: (query) => (query.state.error ? 10_000 : 5 * 60 * 1000), + refetchIntervalInBackground: true, + retry: 5, }) const mode: TenantMode = isLoading || error || !me diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 545139f..d220e80 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,8 +9,16 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, - retry: 1, + // 배포(1~2분 다운) 후에도 화면이 스스로 살아나도록: + // - 재시도 3회(지수 백오프, 최대 10초) + // - 네트워크 복구 시 자동 재조회 + // - 오프라인 판단으로 쿼리가 멈추지 않도록 항상 실행 + retry: 3, + retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000), + refetchOnReconnect: true, + networkMode: 'always', }, + mutations: { networkMode: 'always' }, }, })