fix: 예수금 음수 표시 + 배포 후 화면이 영구 정지하던 문제
- 예수금: 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) <noreply@anthropic.com>
This commit is contained in:
@@ -129,15 +129,29 @@ def _extract_balance_data(balance_data: dict) -> dict:
|
|||||||
if balance_data and "output2" in balance_data:
|
if balance_data and "output2" in balance_data:
|
||||||
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
||||||
|
|
||||||
# _ord_psbl_cash: sync_portfolio()가 매수가능조회로 채워 넣는 주문가능현금
|
# 예수금 우선순위 (2026-09-23):
|
||||||
# (당일 매도대금 포함). 없으면 예수금총액(D+2 결제 기준) 폴백.
|
# _ord_psbl_cash — 매수가능조회로 받은 주문가능현금(당일 매도대금 포함)
|
||||||
available_cash = _pick_float(output2, [
|
# prvs_rcdl_excc_amt — 가수도정산금액(주문가능). 데이트레이딩 엔진도 이 값을 쓴다
|
||||||
|
# nxdy_excc_amt — 익일정산금액
|
||||||
|
# dnca_tot_amt — 예수금총액(D+2 기준). 당일 매수가 많으면 음수가 되므로 최후순위
|
||||||
|
# 음수/0은 건너뛰고 첫 양수를 사용한다(화면에 예수금 -913,965원이 뜨던 문제).
|
||||||
|
_cash_keys = [
|
||||||
"_ord_psbl_cash",
|
"_ord_psbl_cash",
|
||||||
"dnca_tot_amt",
|
"prvs_rcdl_excc_amt",
|
||||||
|
"nxdy_excc_amt",
|
||||||
"d2_depo_amt",
|
"d2_depo_amt",
|
||||||
"d2_deposit",
|
"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"])
|
kis_total_assets = _pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"])
|
||||||
|
|
||||||
|
|||||||
@@ -109,3 +109,24 @@ def test_manual_sell_handles_null_current_price():
|
|||||||
from app.api.endpoints import portfolio as pf
|
from app.api.endpoints import portfolio as pf
|
||||||
src = inspect.getsource(pf.sell_stock)
|
src = inspect.getsource(pf.sell_stock)
|
||||||
assert "price or holding.current_price or holding.average_price or 0" in src
|
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
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ export const useTenantMode = () => {
|
|||||||
queryKey: ['auth-me'],
|
queryKey: ['auth-me'],
|
||||||
queryFn: async () => (await api.get('/auth/me')).data,
|
queryFn: async () => (await api.get('/auth/me')).data,
|
||||||
staleTime: 5 * 60 * 1000,
|
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
|
const mode: TenantMode = isLoading || error || !me
|
||||||
|
|||||||
@@ -9,8 +9,16 @@ const queryClient = new QueryClient({
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
refetchOnWindowFocus: false,
|
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' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user