From 1ac907cd27c4fd3170a161137c584ec905051623 Mon Sep 17 00:00:00 2001 From: "jihoon87.lee" Date: Fri, 13 Feb 2026 12:17:35 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 18 +- app/globals.css | 8 +- .../dashboard/components/ActivitySection.tsx | 36 +- .../components/DashboardContainer.tsx | 273 +++++++++- .../dashboard/components/HoldingsList.tsx | 226 +++++--- .../dashboard/components/MarketSummary.tsx | 195 +++++-- .../dashboard/components/StatusHeader.tsx | 78 ++- .../components/StockDetailPreview.tsx | 129 ++++- .../dashboard/hooks/use-dashboard-data.ts | 10 +- .../dashboard/hooks/use-holdings-realtime.ts | 76 +++ .../dashboard/hooks/use-market-realtime.ts | 77 +++ features/dashboard/hooks/use-price-flash.ts | 73 +++ features/dashboard/types/dashboard.types.ts | 3 + .../utils/kis-index-realtime.utils.ts | 62 +++ .../utils/kis-stock-realtime.utils.ts | 69 +++ .../kis-realtime/hooks/useKisWebSocket.ts | 51 ++ .../kis-realtime/stores/kisWebSocketStore.ts | 386 ++++++++++++++ features/kis-realtime/utils/websocketUtils.ts | 29 + features/settings/components/KisAuthForm.tsx | 301 ++++++----- .../settings/components/KisProfileForm.tsx | 211 ++++---- features/settings/components/SettingsCard.tsx | 98 ++++ .../settings/components/SettingsContainer.tsx | 133 +++-- .../settings/store/use-kis-runtime-store.ts | 8 + features/trade/components/TradeContainer.tsx | 37 +- .../trade/components/chart/StockLineChart.tsx | 39 +- .../trade/components/chart/chart-utils.ts | 6 +- features/trade/hooks/useDomesticSession.ts | 29 + features/trade/hooks/useKisTradeWebSocket.ts | 501 ++---------------- features/trade/hooks/useOrderBook.ts | 90 +++- .../trade/hooks/useOrderbookSubscription.ts | 63 +++ .../trade/hooks/useTradeTickSubscription.ts | 110 ++++ ...-realtime.utils.ts => kisRealtimeUtils.ts} | 159 ++++-- lib/kis/dashboard.ts | 238 +++++++-- settings-mobile-debug.png | Bin 0 -> 49438 bytes settings-stacked-layout.png | Bin 0 -> 67752 bytes 35 files changed, 2790 insertions(+), 1032 deletions(-) create mode 100644 features/dashboard/hooks/use-holdings-realtime.ts create mode 100644 features/dashboard/hooks/use-market-realtime.ts create mode 100644 features/dashboard/hooks/use-price-flash.ts create mode 100644 features/dashboard/utils/kis-index-realtime.utils.ts create mode 100644 features/dashboard/utils/kis-stock-realtime.utils.ts create mode 100644 features/kis-realtime/hooks/useKisWebSocket.ts create mode 100644 features/kis-realtime/stores/kisWebSocketStore.ts create mode 100644 features/kis-realtime/utils/websocketUtils.ts create mode 100644 features/settings/components/SettingsCard.tsx create mode 100644 features/trade/hooks/useDomesticSession.ts create mode 100644 features/trade/hooks/useOrderbookSubscription.ts create mode 100644 features/trade/hooks/useTradeTickSubscription.ts rename features/trade/utils/{kis-realtime.utils.ts => kisRealtimeUtils.ts} (67%) create mode 100644 settings-mobile-debug.png create mode 100644 settings-stacked-layout.png diff --git a/AGENTS.md b/AGENTS.md index f67dbd6..a5f0639 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,13 @@ # AGENTS.md (auto-trade) ## 기본 원칙 + - 모든 응답과 설명은 한국어로 작성. - 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임. - 요청이 모호하면 먼저 질문 1~3개로 범위를 확인. ## 프로젝트 요약 + - Next.js 16 App Router, React 19, TypeScript - 상태 관리: zustand - 데이터: Supabase @@ -13,26 +15,22 @@ - UI: Tailwind CSS v4, Radix UI (`components.json` 사용) ## 명령어 + - 개발 서버(포트 3001): `npm run dev` - 린트: `npm run lint` - 빌드: `npm run build` - 실행: `npm run start` ## 코드 및 문서 규칙 + - JSX 섹션 주석 형식: `{/* ========== SECTION NAME ========== */}` - 함수 및 컴포넌트 JSDoc에 `@see` 필수 - `@see`에는 호출 파일, 함수/이벤트 이름, 목적을 함께 작성 - 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성 - UI 흐름 설명 필수: `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영` 형태로 작성 -## 인코딩/편집 규칙 -- 텍스트 파일 수정은 원칙적으로 `apply_patch`만 사용 -- `shell_command`로 `Set-Content`, `Out-File`, 리다이렉션(`>`)으로 코드 파일 저장 금지 -- 파일 읽기는 반드시 인코딩 명시: `Get-Content -Encoding UTF8` -- 부득이하게 셸로 저장해야 하면 BOM 없는 UTF-8만 사용: - `[System.IO.File]::WriteAllText($path, $text, [System.Text.UTF8Encoding]::new($false))` - ## 브랜드 색상 규칙 + - 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트 사용 - 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용 - 예시: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700` @@ -50,3 +48,9 @@ - `next-devtools`: Next.js 프로젝트 개발 및 디버깅 - `context7`: 라이브러리/프레임워크 공식 문서 참조 - `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리 + +## 한국 투자 증권 API 이용시 + +- `mcp:kis-code-assistant-mcp` 활용 +- `C:\dev\auto-trade\.tmp\open-trading-api` 활용 +- API 이용시 공식 문서에 최신 업데이트가 안되어 있을수 있으므로 필요시 최신 API 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다. diff --git a/app/globals.css b/app/globals.css index b59ea82..f50737b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@plugin "tailwindcss-animate"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); @@ -55,11 +56,12 @@ --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); --radius-4xl: calc(var(--radius) + 16px); - + --animate-gradient-x: gradient-x 15s ease infinite; - + @keyframes gradient-x { - 0%, 100% { + 0%, + 100% { background-size: 200% 200%; background-position: left center; } diff --git a/features/dashboard/components/ActivitySection.tsx b/features/dashboard/components/ActivitySection.tsx index 4aec0e8..9b286ad 100644 --- a/features/dashboard/components/ActivitySection.tsx +++ b/features/dashboard/components/ActivitySection.tsx @@ -1,5 +1,6 @@ -import { AlertCircle, ClipboardList, FileText } from "lucide-react"; +import { AlertCircle, ClipboardList, FileText, RefreshCcw } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -24,6 +25,7 @@ interface ActivitySectionProps { activity: DashboardActivityResponse | null; isLoading: boolean; error: string | null; + onRetry?: () => void; } /** @@ -32,7 +34,12 @@ interface ActivitySectionProps { * @see features/dashboard/components/DashboardContainer.tsx 하단 영역에서 호출합니다. * @see app/api/kis/domestic/activity/route.ts 주문내역/매매일지 데이터 소스 */ -export function ActivitySection({ activity, isLoading, error }: ActivitySectionProps) { +export function ActivitySection({ + activity, + isLoading, + error, + onRetry, +}: ActivitySectionProps) { const orders = activity?.orders ?? []; const journalRows = activity?.tradeJournal ?? []; const summary = activity?.journalSummary; @@ -59,10 +66,27 @@ export function ActivitySection({ activity, isLoading, error }: ActivitySectionP )} {error && ( -

- - {error} -

+
+

+ + {error} +

+

+ 주문/매매일지 API는 장중 혼잡 시간에 간헐적 실패가 발생할 수 있습니다. +

+ {onRetry ? ( + + ) : null} +
)} {warnings.length > 0 && ( diff --git a/features/dashboard/components/DashboardContainer.tsx b/features/dashboard/components/DashboardContainer.tsx index 1d617da..09c7976 100644 --- a/features/dashboard/components/DashboardContainer.tsx +++ b/features/dashboard/components/DashboardContainer.tsx @@ -11,15 +11,29 @@ import { MarketSummary } from "@/features/dashboard/components/MarketSummary"; import { StatusHeader } from "@/features/dashboard/components/StatusHeader"; import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview"; import { useDashboardData } from "@/features/dashboard/hooks/use-dashboard-data"; +import { useMarketRealtime } from "@/features/dashboard/hooks/use-market-realtime"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; +import { useHoldingsRealtime } from "@/features/dashboard/hooks/use-holdings-realtime"; +import type { + DashboardBalanceSummary, + DashboardHoldingItem, + DashboardMarketIndexItem, +} from "@/features/dashboard/types/dashboard.types"; +import type { KisRealtimeStockTick } from "@/features/dashboard/utils/kis-stock-realtime.utils"; /** - * @description 대시보드 메인 컨테이너입니다. - * @remarks UI 흐름: 대시보드 진입 -> useDashboardData API 호출 -> StatusHeader/MarketSummary/HoldingsList/StockDetailPreview 순으로 렌더링 - * @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다. - * @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 데이터 조회/갱신 상태를 관리합니다. + * @file DashboardContainer.tsx + * @description 대시보드 메인 레이아웃 및 데이터 통합 관리 컴포넌트 + * @remarks + * - [레이어] Components / Container + * - [사용자 행동] 대시보드 진입 -> 전체 자산/시장 지수/보유 종목 확인 -> 특정 종목 선택 상세 확인 + * - [데이터 흐름] API(REST/WS) -> Hooks(useDashboardData, useMarketRealtime, useHoldingsRealtime) -> UI 병합 -> 하위 컴포넌트 전파 + * - [연관 파일] use-dashboard-data.ts, use-holdings-realtime.ts, StatusHeader.tsx, HoldingsList.tsx + * @author jihoon87.lee */ export function DashboardContainer() { + // [Store] KIS 런타임 설정 상태 (인증 여부, 접속 계좌, 웹소켓 정보 등) const { verifiedCredentials, isKisVerified, @@ -40,13 +54,15 @@ export function DashboardContainer() { })), ); + // KIS 접근 가능 여부 판단 const canAccess = isKisVerified && Boolean(verifiedCredentials); + // [Hooks] 기본적인 대시보드 데이터(잔고, 지수, 활동내역) 조회 및 선택 상태 관리 + // @see use-dashboard-data.ts - 초기 데이터 로딩 및 폴링 처리 const { activity, balance, - indices, - selectedHolding, + indices: initialIndices, selectedSymbol, setSelectedSymbol, isLoading, @@ -58,12 +74,94 @@ export function DashboardContainer() { refresh, } = useDashboardData(canAccess ? verifiedCredentials : null); - const isKisRestConnected = useMemo(() => { - if (indices.length > 0) return true; - if (balance && !balanceError) return true; - return false; - }, [balance, balanceError, indices.length]); + // [Hooks] 시장 지수(코스피/코스닥) 실시간 웹소켓 데이터 구독 + // @see use-market-realtime.ts - 웹소켓 연결 및 지수 파싱 + const { realtimeIndices, isConnected: isWsConnected } = useMarketRealtime( + verifiedCredentials, + isKisVerified, + ); + // [Hooks] 보유 종목 실시간 시세 웹소켓 데이터 구독 + // @see use-holdings-realtime.ts - 보유 종목 리스트 기반 시세 업데이트 + const { realtimeData: realtimeHoldings } = useHoldingsRealtime( + balance?.holdings ?? [], + ); + const reconnectWebSocket = useKisWebSocketStore((state) => state.reconnect); + + // [Step 1] REST API로 가져온 기본 지수 정보와 실시간 웹소켓 시세 병합 + const indices = useMemo(() => { + if (initialIndices.length === 0) { + return buildRealtimeOnlyIndices(realtimeIndices); + } + + return initialIndices.map((item) => { + const realtime = realtimeIndices[item.code]; + if (!realtime) return item; + + return { + ...item, + price: realtime.price, + change: realtime.change, + changeRate: realtime.changeRate, + }; + }); + }, [initialIndices, realtimeIndices]); + + // [Step 2] 초기 잔고 데이터와 실시간 보유 종목 시세를 병합하여 손익 재계산 + const mergedHoldings = useMemo( + () => mergeHoldingsWithRealtime(balance?.holdings ?? [], realtimeHoldings), + [balance?.holdings, realtimeHoldings], + ); + + const isKisRestConnected = Boolean( + (balance && !balanceError) || + (initialIndices.length > 0 && !indicesError) || + (activity && !activityError), + ); + const hasRealtimeStreaming = + Object.keys(realtimeIndices).length > 0 || + Object.keys(realtimeHoldings).length > 0; + const isRealtimePending = Boolean( + wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming, + ); + const effectiveIndicesError = indices.length === 0 ? indicesError : null; + const indicesWarning = + indices.length > 0 && indicesError + ? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다." + : null; + + /** + * 대시보드 수동 새로고침 시 REST 조회 + 웹소켓 재연결을 함께 수행합니다. + * @remarks UI 흐름: StatusHeader/각 카드 다시 불러오기 버튼 -> handleRefreshAll -> REST 재조회 + WS 완전 종료 후 재연결 + * @see features/dashboard/components/StatusHeader.tsx 상단 다시 불러오기 버튼 + * @see features/kis-realtime/stores/kisWebSocketStore.ts reconnect + */ + const handleRefreshAll = async () => { + await Promise.allSettled([ + refresh(), + reconnectWebSocket({ refreshApproval: false }), + ]); + }; + + /** + * 실시간 보유종목 데이터를 기반으로 전체 자산 요약을 계산합니다. + * @returns 실시간 요약 데이터 (총자산, 손익, 평가금액 등) + */ + const mergedSummary = useMemo( + () => buildRealtimeSummary(balance?.summary ?? null, mergedHoldings), + [balance?.summary, mergedHoldings], + ); + + // [Step 3] 실시간 병합 데이터에서 현재 선택된 종목 정보를 추출 + // @see StockDetailPreview.tsx - 선택된 종목의 상세 정보 표시 + const realtimeSelectedHolding = useMemo(() => { + if (!selectedSymbol || mergedHoldings.length === 0) return null; + return ( + mergedHoldings.find((item) => item.symbol === selectedSymbol) ?? null + ); + }, [mergedHoldings, selectedSymbol]); + + // 하이드레이션 이전에는 로딩 스피너 표시 if (!_hasHydrated) { return (
@@ -72,60 +170,193 @@ export function DashboardContainer() { ); } + // KIS 인증이 되지 않은 경우 접근 제한 게이트 표시 if (!canAccess) { return ; } + // 데이터 로딩 중이며 아직 데이터가 없는 경우 스켈레톤 표시 if (isLoading && !balance && indices.length === 0) { return ; } return (
- {/* ========== STATUS HEADER ========== */} + {/* ========== 상단 상태 영역: 계좌 연결 정보 및 새로고침 ========== */} { - void refresh(); + void handleRefreshAll(); }} /> - {/* ========== MAIN CONTENT GRID ========== */} + {/* ========== 메인 그리드 구성 ========== */}
+ {/* 왼쪽 섹션: 보유 종목 목록 리스트 */} { + void handleRefreshAll(); + }} onSelect={setSelectedSymbol} /> + {/* 오른쪽 섹션: 시장 지수 요약 및 선택 종목 상세 정보 */}
+ {/* 시장 지수 현황 (코스피/코스닥) */} { + void handleRefreshAll(); + }} /> + {/* 선택된 종목의 실시간 상세 요약 정보 */}
- {/* ========== ACTIVITY SECTION ========== */} + {/* ========== 하단 섹션: 최근 매매/충전 활동 내역 ========== */} { + void handleRefreshAll(); + }} />
); } + +/** + * @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다. + * @param realtimeIndices 실시간 지수 맵 + * @returns 화면 렌더링용 지수 배열 + * @remarks UI 흐름: DashboardContainer -> buildRealtimeOnlyIndices -> MarketSummary 렌더링 + * @see features/dashboard/hooks/use-market-realtime.ts 실시간 지수 수신 훅 + */ +function buildRealtimeOnlyIndices( + realtimeIndices: Record, +) { + const baseItems: DashboardMarketIndexItem[] = [ + { market: "KOSPI", code: "0001", name: "코스피", price: 0, change: 0, changeRate: 0 }, + { market: "KOSDAQ", code: "1001", name: "코스닥", price: 0, change: 0, changeRate: 0 }, + ]; + + return baseItems + .map((item) => { + const realtime = realtimeIndices[item.code]; + if (!realtime) return null; + return { + ...item, + price: realtime.price, + change: realtime.change, + changeRate: realtime.changeRate, + } satisfies DashboardMarketIndexItem; + }) + .filter((item): item is DashboardMarketIndexItem => Boolean(item)); +} + +/** + * @description 보유종목 리스트에 실시간 체결가를 병합해 현재가/평가금액/손익을 재계산합니다. + * @param holdings REST 기준 보유종목 + * @param realtimeHoldings 종목별 실시간 체결 데이터 + * @returns 병합된 보유종목 리스트 + * @remarks UI 흐름: DashboardContainer -> mergeHoldingsWithRealtime -> HoldingsList/StockDetailPreview 반영 + * @see features/dashboard/hooks/use-holdings-realtime.ts 보유종목 실시간 체결 구독 + */ +function mergeHoldingsWithRealtime( + holdings: DashboardHoldingItem[], + realtimeHoldings: Record, +) { + if (holdings.length === 0 || Object.keys(realtimeHoldings).length === 0) { + return holdings; + } + + return holdings.map((item) => { + const tick = realtimeHoldings[item.symbol]; + if (!tick) return item; + + const currentPrice = tick.currentPrice; + const purchaseAmount = item.averagePrice * item.quantity; + const evaluationAmount = currentPrice * item.quantity; + const profitLoss = evaluationAmount - purchaseAmount; + const profitRate = purchaseAmount > 0 ? (profitLoss / purchaseAmount) * 100 : 0; + + return { + ...item, + currentPrice, + evaluationAmount, + profitLoss, + profitRate, + }; + }); +} + +/** + * @description 실시간 보유종목 기준으로 대시보드 요약(총자산/손익)을 일관되게 재계산합니다. + * @param summary REST API 요약 값 + * @param holdings 실시간 병합된 보유종목 + * @returns 재계산된 요약 값 + * @remarks UI 흐름: DashboardContainer -> buildRealtimeSummary -> StatusHeader 카드 반영 + * @see features/dashboard/components/StatusHeader.tsx 상단 요약 렌더링 + */ +function buildRealtimeSummary( + summary: DashboardBalanceSummary | null, + holdings: DashboardHoldingItem[], +) { + if (!summary) return null; + if (holdings.length === 0) return summary; + + const evaluationAmount = holdings.reduce( + (total, item) => total + item.evaluationAmount, + 0, + ); + const purchaseAmount = holdings.reduce( + (total, item) => total + item.averagePrice * item.quantity, + 0, + ); + const totalProfitLoss = evaluationAmount - purchaseAmount; + const totalProfitRate = + purchaseAmount > 0 ? (totalProfitLoss / purchaseAmount) * 100 : 0; + + const evaluationDelta = evaluationAmount - summary.evaluationAmount; + const baseTotalAmount = + summary.apiReportedNetAssetAmount > 0 + ? summary.apiReportedNetAssetAmount + : summary.totalAmount; + + // 실시간은 "기준 순자산 + 평가금 증감분"으로만 반영합니다. + const totalAmount = Math.max(baseTotalAmount + evaluationDelta, 0); + const netAssetAmount = totalAmount; + const cashBalance = Math.max(totalAmount - evaluationAmount, 0); + + return { + ...summary, + totalAmount, + netAssetAmount, + cashBalance, + evaluationAmount, + purchaseAmount, + totalProfitLoss, + totalProfitRate, + } satisfies DashboardBalanceSummary; +} diff --git a/features/dashboard/components/HoldingsList.tsx b/features/dashboard/components/HoldingsList.tsx index 675e0cb..4adfee2 100644 --- a/features/dashboard/components/HoldingsList.tsx +++ b/features/dashboard/components/HoldingsList.tsx @@ -1,5 +1,17 @@ +/** + * @file HoldingsList.tsx + * @description 대시보드 좌측 영역의 보유 종목 리스트 컴포넌트 + * @remarks + * - [레이어] Components / UI + * - [사용자 행동] 종목 리스트 스크롤 -> 특정 종목 클릭(선택) -> 우측 상세 프레뷰 갱신 + * - [데이터 흐름] DashboardContainer(mergedHoldings) -> HoldingsList -> HoldingItemRow -> onSelect(Callback) + * - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts + * @author jihoon87.lee + */ import { AlertCircle, Wallet2 } from "lucide-react"; +import { RefreshCcw } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -14,30 +26,43 @@ import { getChangeToneClass, } from "@/features/dashboard/utils/dashboard-format"; import { cn } from "@/lib/utils"; +import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash"; interface HoldingsListProps { + /** 보유 종목 데이터 리스트 (실시간 시세 병합됨) */ holdings: DashboardHoldingItem[]; + /** 현재 선택된 종목의 심볼 (없으면 null) */ selectedSymbol: string | null; + /** 데이터 로딩 상태 */ isLoading: boolean; + /** 에러 메시지 (없으면 null) */ error: string | null; + /** 섹션 재조회 핸들러 */ + onRetry?: () => void; + /** 종목 선택 시 호출되는 핸들러 */ onSelect: (symbol: string) => void; } /** - * @description 보유 종목 리스트 카드입니다. - * @see features/dashboard/components/DashboardContainer.tsx 좌측 메인 영역에서 호출합니다. + * [컴포넌트] 보유 종목 리스트 + * 사용자의 잔고 정보를 바탕으로 실시간 시세가 반영된 종목 카드 목록을 렌더링합니다. + * + * @param props HoldingsListProps + * @see DashboardContainer.tsx - 좌측 메인 영역에서 실시간 병합 데이터를 전달받아 호출 + * @see DashboardContainer.tsx - setSelectedSymbol 핸들러를 onSelect로 전달 */ export function HoldingsList({ holdings, selectedSymbol, isLoading, error, + onRetry, onSelect, }: HoldingsListProps) { return ( - + + {/* ========== 카드 헤더: 타이틀 및 설명 ========== */} - {/* ========== TITLE ========== */} 보유 종목 @@ -47,76 +72,57 @@ export function HoldingsList({ + {/* ========== 카드 본문: 상태별 메시지 및 리스트 ========== */} + {/* 로딩 중 상태 (데이터가 아직 없는 경우) */} {isLoading && holdings.length === 0 && ( -

보유 종목을 불러오는 중입니다.

- )} - - {error && ( -

- - {error} +

+ 보유 종목을 불러오는 중입니다.

)} + {/* 에러 발생 상태 */} + {error && ( +
+

+ + {error} +

+

+ 한국투자증권 API가 일시적으로 불안정할 수 있습니다. 잠시 후 다시 시도해 주세요. +

+ {onRetry ? ( + + ) : null} +
+ )} + + {/* 데이터 없음 상태 */} {!isLoading && holdings.length === 0 && !error && (

보유 종목이 없습니다.

)} + {/* 종목 리스트 렌더링 영역 */} {holdings.length > 0 && (
- {holdings.map((holding) => { - const isSelected = selectedSymbol === holding.symbol; - const toneClass = getChangeToneClass(holding.profitLoss); - - return ( - - ); - })} + {holdings.map((holding) => ( + + ))}
)} @@ -124,3 +130,99 @@ export function HoldingsList({
); } + +interface HoldingItemRowProps { + /** 개별 종목 정보 */ + holding: DashboardHoldingItem; + /** 선택 여부 */ + isSelected: boolean; + /** 클릭 핸들러 */ + onSelect: (symbol: string) => void; +} + +/** + * [컴포넌트] 보유 종목 개별 행 (아이템) + * 종목의 기본 정보와 실시간 시세, 현재 손익 상태를 표시합니다. + * + * @param props HoldingItemRowProps + * @see HoldingsList.tsx - holdings.map 내에서 호출 + * @see use-price-flash.ts - 현재가 변경 감지 및 애니메이션 효과 트리거 + */ +function HoldingItemRow({ + holding, + isSelected, + onSelect, +}: HoldingItemRowProps) { + // [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리 + // @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정 + const flash = usePriceFlash(holding.currentPrice, holding.symbol); + + // [UI] 손익 상태에 따른 텍스트 색상 클래스 결정 (상승: red, 하락: blue) + const toneClass = getChangeToneClass(holding.profitLoss); + + return ( + + ); +} diff --git a/features/dashboard/components/MarketSummary.tsx b/features/dashboard/components/MarketSummary.tsx index 8b0c996..3847e22 100644 --- a/features/dashboard/components/MarketSummary.tsx +++ b/features/dashboard/components/MarketSummary.tsx @@ -1,4 +1,5 @@ -import { AlertCircle, BarChart3 } from "lucide-react"; +import { BarChart3, TrendingDown, TrendingUp } from "lucide-react"; +import { RefreshCcw } from "lucide-react"; import { Card, CardContent, @@ -7,79 +8,185 @@ import { CardTitle, } from "@/components/ui/card"; import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types"; +import { Button } from "@/components/ui/button"; import { formatCurrency, formatSignedCurrency, formatSignedPercent, - getChangeToneClass, } from "@/features/dashboard/utils/dashboard-format"; import { cn } from "@/lib/utils"; +import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash"; interface MarketSummaryProps { items: DashboardMarketIndexItem[]; isLoading: boolean; error: string | null; + warning?: string | null; + isRealtimePending?: boolean; + onRetry?: () => void; } /** * @description 코스피/코스닥 지수 요약 카드입니다. * @see features/dashboard/components/DashboardContainer.tsx 우측 상단 영역에서 호출합니다. */ -export function MarketSummary({ items, isLoading, error }: MarketSummaryProps) { +export function MarketSummary({ + items, + isLoading, + error, + warning = null, + isRealtimePending = false, + onRetry, +}: MarketSummaryProps) { return ( - - - {/* ========== TITLE ========== */} - - - 시장 지수 - - - 코스피/코스닥 지수 움직임을 보여줍니다. - + + +
+ + + 시장 지수 + +
+ 실시간 코스피/코스닥 지수 현황입니다.
- + + {/* ========== LOADING STATE ========== */} {isLoading && items.length === 0 && ( -

지수 데이터를 불러오는 중입니다.

+
+ 지수 데이터를 불러오는 중입니다... +
)} + {/* ========== REALTIME PENDING STATE ========== */} + {isRealtimePending && items.length === 0 && !isLoading && !error && ( +
+ 실시간 시세 연결은 완료되었고 첫 지수 데이터를 기다리는 중입니다. +
+ )} + + {/* ========== ERROR/WARNING STATE ========== */} {error && ( -

- - {error} -

+
+

지수 정보를 가져오는데 실패했습니다.

+

+ {toCompactErrorMessage(error)} +

+

+ 토큰이 정상이어도 한국투자증권 API 점검/지연 시 일시적으로 실패할 수 있습니다. +

+ {onRetry ? ( + + ) : null} +
+ )} + {!error && warning && ( +
+ {warning} +
)} - {items.map((item) => { - const toneClass = getChangeToneClass(item.change); - - return ( -
-
-
-

{item.market}

-

{item.name}

-
-

- {formatCurrency(item.price)} -

-
-
- {formatSignedCurrency(item.change)} - {formatSignedPercent(item.changeRate)} -
-
- ); - })} + {/* ========== INDEX CARDS ========== */} + {items.map((item) => ( + + ))} {!isLoading && items.length === 0 && !error && ( -

표시할 지수 데이터가 없습니다.

+
+ 표시할 데이터가 없습니다. +
)}
); } + +/** + * @description 길고 복잡한 서버 오류를 대시보드 카드에 맞는 짧은 문구로 축약합니다. + * @param error 원본 오류 문자열 + * @returns 화면 노출용 오류 메시지 + * @see features/dashboard/components/MarketSummary.tsx 지수 오류 배너 상세 문구 + */ +function toCompactErrorMessage(error: string) { + const normalized = error.replaceAll(/\s+/g, " ").trim(); + if (!normalized) return "잠시 후 다시 시도해 주세요."; + if (normalized.length <= 120) return normalized; + return `${normalized.slice(0, 120)}...`; +} + +function IndexItem({ item }: { item: DashboardMarketIndexItem }) { + const isUp = item.change > 0; + const isDown = item.change < 0; + const toneClass = isUp + ? "text-red-600 dark:text-red-400" + : isDown + ? "text-blue-600 dark:text-blue-400" + : "text-muted-foreground"; + + const bgClass = isUp + ? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30" + : isDown + ? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30" + : "bg-muted/50 border-border/50"; + + const flash = usePriceFlash(item.price, item.code); + + return ( +
+
+ + {item.market} + + {isUp ? ( + + ) : isDown ? ( + + ) : null} +
+ +
+ {formatCurrency(item.price)} + + {/* Flash Indicator */} + {flash && ( +
+ {flash.type === "up" ? "+" : ""} + {flash.val.toFixed(2)} +
+ )} +
+ +
+ {formatSignedCurrency(item.change)} + + {formatSignedPercent(item.changeRate)} + +
+
+ ); +} diff --git a/features/dashboard/components/StatusHeader.tsx b/features/dashboard/components/StatusHeader.tsx index ee4e844..a585441 100644 --- a/features/dashboard/components/StatusHeader.tsx +++ b/features/dashboard/components/StatusHeader.tsx @@ -5,7 +5,8 @@ import { Card, CardContent } from "@/components/ui/card"; import type { DashboardBalanceSummary } from "@/features/dashboard/types/dashboard.types"; import { formatCurrency, - formatPercent, + formatSignedCurrency, + formatSignedPercent, getChangeToneClass, } from "@/features/dashboard/utils/dashboard-format"; import { cn } from "@/lib/utils"; @@ -14,6 +15,7 @@ interface StatusHeaderProps { summary: DashboardBalanceSummary | null; isKisRestConnected: boolean; isWebSocketReady: boolean; + isRealtimePending: boolean; isProfileVerified: boolean; verifiedAccountNo: string | null; isRefreshing: boolean; @@ -29,6 +31,7 @@ export function StatusHeader({ summary, isKisRestConnected, isWebSocketReady, + isRealtimePending, isProfileVerified, verifiedAccountNo, isRefreshing, @@ -41,6 +44,20 @@ export function StatusHeader({ hour12: false, }) : "--:--:--"; + const hasApiTotalAmount = + Boolean(summary) && (summary?.apiReportedTotalAmount ?? 0) > 0; + const hasApiNetAssetAmount = + Boolean(summary) && (summary?.apiReportedNetAssetAmount ?? 0) > 0; + const isApiTotalAmountDifferent = + Boolean(summary) && + Math.abs( + (summary?.apiReportedTotalAmount ?? 0) - (summary?.totalAmount ?? 0), + ) >= 1; + const realtimeStatusLabel = isWebSocketReady + ? isRealtimePending + ? "수신 대기중" + : "연결됨" + : "미연결"; return ( @@ -50,32 +67,61 @@ export function StatusHeader({ {/* ========== TOTAL ASSET ========== */}
-

총 자산

+

내 자산 (순자산 실시간)

{summary ? `${formatCurrency(summary.totalAmount)}원` : "-"}

- 예수금 {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"} + 현금(예수금) {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"}

- 실제 자산 {summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"} + 주식 평가금{" "} + {summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}

+

+ 총예수금(KIS){" "} + {summary ? `${formatCurrency(summary.totalDepositAmount)}원` : "-"} +

+

+ 총예수금은 결제 대기 금액이 포함될 수 있어 체감 현금과 다를 수 있습니다. +

+

+ 순자산(대출 반영){" "} + {summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"} +

+ {hasApiTotalAmount && isApiTotalAmountDifferent ? ( +

+ KIS 집계 총자산 {formatCurrency(summary?.apiReportedTotalAmount ?? 0)}원 +

+ ) : null} + {hasApiNetAssetAmount ? ( +

+ KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원 +

+ ) : null}
{/* ========== PROFIT/LOSS ========== */}

현재 손익

-

- {summary ? `${formatCurrency(summary.totalProfitLoss)}원` : "-"} +

+ {summary ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}

- {summary ? formatPercent(summary.totalProfitRate) : "-"} + {summary ? formatSignedPercent(summary.totalProfitRate) : "-"}

- 현재 평가금액 {summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"} + 현재 평가금액{" "} + {summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}

- 총 매수금액 {summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"} + 총 매수금액{" "} + {summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"}

@@ -98,12 +144,14 @@ export function StatusHeader({ className={cn( "inline-flex items-center gap-1 rounded-full px-2 py-1", isWebSocketReady - ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" + ? isRealtimePending + ? "bg-amber-500/10 text-amber-700 dark:text-amber-400" + : "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground", )} > - 실시간 시세 {isWebSocketReady ? "연결됨" : "미연결"} + 실시간 시세 {realtimeStatusLabel} {/* ========== QUICK ACTIONS ========== */} -
+
diff --git a/features/dashboard/components/StockDetailPreview.tsx b/features/dashboard/components/StockDetailPreview.tsx index 2191bed..743eabc 100644 --- a/features/dashboard/components/StockDetailPreview.tsx +++ b/features/dashboard/components/StockDetailPreview.tsx @@ -1,4 +1,14 @@ -import { BarChartBig, MousePointerClick } from "lucide-react"; +/** + * @file StockDetailPreview.tsx + * @description 대시보드 우측 영역의 선택 종목 상세 정보 및 실시간 시세 반영 컴포넌트 + * @remarks + * - [레이어] Components / UI + * - [사용자 행동] 종목 리스트에서 항목 선택 -> 상세 정보 조회 -> 실시간 시세 변동 확인 + * - [데이터 흐름] DashboardContainer(realtimeSelectedHolding) -> StockDetailPreview -> Metric(UI) + * - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts + * @author jihoon87.lee + */ +import { BarChartBig, ExternalLink, MousePointerClick } from "lucide-react"; import { Card, CardContent, @@ -6,6 +16,8 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useRouter } from "next/navigation"; +import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash"; import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; import { formatCurrency, @@ -15,18 +27,30 @@ import { import { cn } from "@/lib/utils"; interface StockDetailPreviewProps { + /** 선택된 종목 정보 (없으면 null) */ holding: DashboardHoldingItem | null; + /** 현재 총 자산 (비중 계산용) */ totalAmount: number; } /** - * @description 선택 종목 상세 요약 카드입니다. - * @see features/dashboard/components/DashboardContainer.tsx HoldingsList 선택 결과를 전달받아 렌더링합니다. + * [컴포넌트] 선택 종목 상세 요약 카드 + * 대시보드에서 선택된 특정 종목의 매입가, 현재가, 수익률 등 상세 지표를 실시간으로 보여줍니다. + * + * @param props StockDetailPreviewProps + * @see DashboardContainer.tsx - HoldingsList 선택 결과를 실시간 데이터로 전달받아 렌더링 */ export function StockDetailPreview({ holding, totalAmount, }: StockDetailPreviewProps) { + const router = useRouter(); + // [State/Hook] 실시간 가격 변동 애니메이션 상태 관리 + // @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달 + const currentPrice = holding?.currentPrice ?? 0; + const priceFlash = usePriceFlash(currentPrice, holding?.symbol); + + // [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링 if (!holding) { return ( @@ -48,29 +72,67 @@ export function StockDetailPreview({ ); } + // [Step 2] 수익/손실 여부에 따른 UI 톤(색상) 결정 const profitToneClass = getChangeToneClass(holding.profitLoss); + + // [Step 3] 총 자산 대비 비중 계산 const allocationRate = - totalAmount > 0 ? Math.min((holding.evaluationAmount / totalAmount) * 100, 100) : 0; + totalAmount > 0 + ? Math.min((holding.evaluationAmount / totalAmount) * 100, 100) + : 0; return ( + {/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */} - {/* ========== TITLE ========== */} 선택 종목 정보 - - {holding.name} ({holding.symbol}) · {holding.market} + + + + · {holding.market} + - {/* ========== PRIMARY METRICS ========== */} + {/* ========== 실시간 주요 지표 영역 (Grid) ========== */}
- - - + + +
- {/* ========== ALLOCATION BAR ========== */} + {/* ========== 자산 비중 그래프 영역 ========== */}
총 자산 대비 비중 @@ -101,7 +163,7 @@ export function StockDetailPreview({
- {/* ========== QUICK ORDER PLACEHOLDER ========== */} + {/* ========== 추가 기능 예고 영역 (Placeholder) ========== */}

@@ -117,23 +179,48 @@ export function StockDetailPreview({ } interface MetricProps { + /** 지표 레이블 */ label: string; + /** 표시될 값 */ value: string; + /** 값 텍스트 추가 스타일 */ valueClassName?: string; + /** 가격 변동 애니메이션 상태 */ + flash?: { type: "up" | "down"; val: number; id: number } | null; } /** - * @description 상세 카드에서 공통으로 사용하는 지표 행입니다. - * @param label 지표명 - * @param value 지표값 - * @param valueClassName 값 텍스트 색상 클래스 - * @see features/dashboard/components/StockDetailPreview.tsx 종목 상세 지표 표시 + * [컴포넌트] 상세 카드용 개별 지표 아이템 + * 레이블과 값을 박스 형태로 렌더링하며, 필요한 경우 시세 변동 Flash 애니메이션을 처리합니다. + * + * @param props MetricProps + * @see StockDetailPreview.tsx - 내부 그리드 영역에서 여러 개 호출 */ -function Metric({ label, value, valueClassName }: MetricProps) { +function Metric({ label, value, valueClassName, flash }: MetricProps) { return ( -

+
+ {/* 시세 변동 시 나타나는 일시적인 수치 표시 (Flash) */} + {flash && ( + + {flash.type === "up" ? "+" : ""} + {flash.val.toLocaleString()} + + )} + + {/* 지표 레이블 및 본체 값 */}

{label}

-

+

{value}

diff --git a/features/dashboard/hooks/use-dashboard-data.ts b/features/dashboard/hooks/use-dashboard-data.ts index d11c895..db4b115 100644 --- a/features/dashboard/hooks/use-dashboard-data.ts +++ b/features/dashboard/hooks/use-dashboard-data.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import { fetchDashboardActivity, @@ -10,7 +10,6 @@ import { import type { DashboardActivityResponse, DashboardBalanceResponse, - DashboardHoldingItem, DashboardIndicesResponse, } from "@/features/dashboard/types/dashboard.types"; @@ -18,7 +17,6 @@ interface UseDashboardDataResult { activity: DashboardActivityResponse | null; balance: DashboardBalanceResponse | null; indices: DashboardIndicesResponse["items"]; - selectedHolding: DashboardHoldingItem | null; selectedSymbol: string | null; setSelectedSymbol: (symbol: string) => void; isLoading: boolean; @@ -179,11 +177,6 @@ export function useDashboardData( return () => window.clearInterval(interval); }, [credentials, refreshInternal]); - const selectedHolding = useMemo(() => { - if (!selectedSymbol || !balance) return null; - return balance.holdings.find((item) => item.symbol === selectedSymbol) ?? null; - }, [balance, selectedSymbol]); - const setSelectedSymbol = useCallback((symbol: string) => { setSelectedSymbolState(symbol); }, []); @@ -192,7 +185,6 @@ export function useDashboardData( activity, balance, indices, - selectedHolding, selectedSymbol, setSelectedSymbol, isLoading, diff --git a/features/dashboard/hooks/use-holdings-realtime.ts b/features/dashboard/hooks/use-holdings-realtime.ts new file mode 100644 index 0000000..0d12fe3 --- /dev/null +++ b/features/dashboard/hooks/use-holdings-realtime.ts @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; +import { + type KisRealtimeStockTick, + parseKisRealtimeStockTick, +} from "@/features/dashboard/utils/kis-stock-realtime.utils"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; + +const STOCK_REALTIME_TR_ID = "H0STCNT0"; + +/** + * @description 보유 종목 목록에 대한 실시간 체결 데이터를 구독합니다. + * @param holdings 보유 종목 목록 + * @returns 종목별 실시간 체결 데이터/연결 상태 + * @remarks UI 흐름: DashboardContainer -> useHoldingsRealtime -> HoldingsList/summary 실시간 반영 + * @see features/dashboard/components/DashboardContainer.tsx 보유종목 실시간 병합 + */ +export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) { + const [realtimeData, setRealtimeData] = useState< + Record + >({}); + const { subscribe, connect, isConnected } = useKisWebSocketStore(); + + const uniqueSymbols = useMemo( + () => Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(), + [holdings], + ); + const symbolKey = useMemo(() => uniqueSymbols.join(","), [uniqueSymbols]); + + useEffect(() => { + if (uniqueSymbols.length === 0) { + const resetTimerId = window.setTimeout(() => { + setRealtimeData({}); + }, 0); + return () => window.clearTimeout(resetTimerId); + } + + if (!isConnected) { + connect(); + } + + const unsubs: (() => void)[] = []; + + uniqueSymbols.forEach((symbol) => { + const unsub = subscribe(STOCK_REALTIME_TR_ID, symbol, (data) => { + const tick = parseKisRealtimeStockTick(data); + if (tick) { + setRealtimeData((prev) => { + const prevTick = prev[tick.symbol]; + if ( + prevTick?.currentPrice === tick.currentPrice && + prevTick?.change === tick.change && + prevTick?.changeRate === tick.changeRate + ) { + return prev; + } + + return { + ...prev, + [tick.symbol]: tick, + }; + }); + } + }); + unsubs.push(unsub); + }); + + return () => { + unsubs.forEach((unsub) => unsub()); + }; + }, [symbolKey, uniqueSymbols, connect, subscribe, isConnected]); + + return { realtimeData, isConnected }; +} diff --git a/features/dashboard/hooks/use-market-realtime.ts b/features/dashboard/hooks/use-market-realtime.ts new file mode 100644 index 0000000..03c6c03 --- /dev/null +++ b/features/dashboard/hooks/use-market-realtime.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + parseKisRealtimeIndexTick, + type KisRealtimeIndexTick, +} from "@/features/dashboard/utils/kis-index-realtime.utils"; +import { useKisWebSocket } from "@/features/kis-realtime/hooks/useKisWebSocket"; +import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; + +const INDEX_TR_ID = "H0UPCNT0"; +const KOSPI_SYMBOL = "0001"; +const KOSDAQ_SYMBOL = "1001"; + +interface UseMarketRealtimeResult { + realtimeIndices: Record; + isConnected: boolean; + hasReceivedTick: boolean; + isPending: boolean; + lastTickAt: string | null; +} + +/** + * @description 코스피/코스닥 실시간 지수 웹소켓 구독 상태를 관리합니다. + * @param credentials KIS 인증 정보(하위 호환 파라미터) + * @param isVerified KIS 연결 인증 여부 + * @returns 실시간 지수 맵/연결 상태/수신 대기 상태 + * @remarks UI 흐름: DashboardContainer -> useMarketRealtime -> MarketSummary/StatusHeader 렌더링 반영 + * @see features/dashboard/components/DashboardContainer.tsx 지수 데이터 통합 및 상태 전달 + */ +export function useMarketRealtime( + _credentials: KisRuntimeCredentials | null, // 하위 호환성을 위해 남겨둠 (실제로는 스토어 사용) + isVerified: boolean, // 하위 호환성을 위해 남겨둠 +): UseMarketRealtimeResult { + const [realtimeIndices, setRealtimeIndices] = useState< + Record + >({}); + const [lastTickAt, setLastTickAt] = useState(null); + + const handleMessage = useCallback((data: string) => { + const tick = parseKisRealtimeIndexTick(data); + if (tick) { + setLastTickAt(new Date().toISOString()); + setRealtimeIndices((prev) => ({ + ...prev, + [tick.symbol]: tick, + })); + } + }, []); + + // KOSPI 구독 + const { isConnected: isKospiConnected } = useKisWebSocket({ + symbol: KOSPI_SYMBOL, + trId: INDEX_TR_ID, + onMessage: handleMessage, + enabled: isVerified, + }); + + // KOSDAQ 구독 + const { isConnected: isKosdaqConnected } = useKisWebSocket({ + symbol: KOSDAQ_SYMBOL, + trId: INDEX_TR_ID, + onMessage: handleMessage, + enabled: isVerified, + }); + + const hasReceivedTick = Object.keys(realtimeIndices).length > 0; + const isPending = isVerified && (isKospiConnected || isKosdaqConnected) && !hasReceivedTick; + + return { + realtimeIndices, + isConnected: isKospiConnected || isKosdaqConnected, + hasReceivedTick, + isPending, + lastTickAt, + }; +} diff --git a/features/dashboard/hooks/use-price-flash.ts b/features/dashboard/hooks/use-price-flash.ts new file mode 100644 index 0000000..775528c --- /dev/null +++ b/features/dashboard/hooks/use-price-flash.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +const FLASH_DURATION_MS = 2_000; + +/** + * @description 가격 변동 시 일시 플래시(+/-) 값을 생성합니다. + * @param currentPrice 현재가 + * @param key 종목 식별 키(종목 변경 시 상태 초기화) + * @returns 플래시 값(up/down, 변화량) 또는 null + * @remarks UI 흐름: 시세 변경 -> usePriceFlash -> 플래시 값 노출 -> 2초 후 자동 제거 + * @see features/dashboard/components/HoldingsList.tsx 보유종목 현재가 플래시 + * @see features/dashboard/components/StockDetailPreview.tsx 상세 카드 현재가 플래시 + */ +export function usePriceFlash(currentPrice: number, key?: string) { + const [flash, setFlash] = useState<{ + val: number; + type: "up" | "down"; + id: number; + } | null>(null); + + const prevKeyRef = useRef(key); + const prevPriceRef = useRef(currentPrice); + const timerRef = useRef(null); + + useEffect(() => { + const keyChanged = prevKeyRef.current !== key; + + if (keyChanged) { + prevKeyRef.current = key; + prevPriceRef.current = currentPrice; + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + const resetTimerId = window.setTimeout(() => { + setFlash(null); + }, 0); + return () => window.clearTimeout(resetTimerId); + } + + const prevPrice = prevPriceRef.current; + const diff = currentPrice - prevPrice; + prevPriceRef.current = currentPrice; + + if (prevPrice === 0 || Math.abs(diff) === 0) return; + + // 플래시가 보이는 동안에는 새 플래시를 덮어쓰지 않아 화면 잔상이 지속되지 않게 합니다. + if (timerRef.current !== null) return; + + setFlash({ + val: diff, + type: diff > 0 ? "up" : "down", + id: Date.now(), + }); + + timerRef.current = window.setTimeout(() => { + setFlash(null); + timerRef.current = null; + }, FLASH_DURATION_MS); + }, [currentPrice, key]); + + useEffect(() => { + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + } + }; + }, []); + + return flash; +} diff --git a/features/dashboard/types/dashboard.types.ts b/features/dashboard/types/dashboard.types.ts index 6addf92..679b491 100644 --- a/features/dashboard/types/dashboard.types.ts +++ b/features/dashboard/types/dashboard.types.ts @@ -13,12 +13,15 @@ export type DashboardMarket = "KOSPI" | "KOSDAQ"; export interface DashboardBalanceSummary { totalAmount: number; cashBalance: number; + totalDepositAmount: number; totalProfitLoss: number; totalProfitRate: number; netAssetAmount: number; evaluationAmount: number; purchaseAmount: number; loanAmount: number; + apiReportedTotalAmount: number; + apiReportedNetAssetAmount: number; } /** diff --git a/features/dashboard/utils/kis-index-realtime.utils.ts b/features/dashboard/utils/kis-index-realtime.utils.ts new file mode 100644 index 0000000..137b1d9 --- /dev/null +++ b/features/dashboard/utils/kis-index-realtime.utils.ts @@ -0,0 +1,62 @@ +export interface KisRealtimeIndexTick { + symbol: string; // 업종코드 (0001: KOSPI, 1001: KOSDAQ) + price: number; // 현재가 + change: number; // 전일대비 + changeRate: number; // 전일대비율 + sign: string; // 대비부호 + time: string; // 체결시간 +} + +const INDEX_REALTIME_TR_ID = "H0UPCNT0"; + +const INDEX_FIELD_INDEX = { + symbol: 0, // bstp_cls_code + time: 1, // bsop_hour + price: 2, // prpr_nmix + sign: 3, // prdy_vrss_sign + change: 4, // bstp_nmix_prdy_vrss + accumulatedVolume: 5, // acml_vol + accumulatedAmount: 6, // acml_tr_pbmn + changeRate: 9, // prdy_ctrt +} as const; + +export function parseKisRealtimeIndexTick( + raw: string, +): KisRealtimeIndexTick | null { + // Format: 0|H0UPCNT0|001|0001^123456^... + if (!/^([01])\|/.test(raw)) return null; + + const parts = raw.split("|"); + if (parts.length < 4) return null; + + // Check TR ID + if (parts[1] !== INDEX_REALTIME_TR_ID) { + return null; + } + + const values = parts[3].split("^"); + if (values.length < 10) return null; // Ensure minimum fields exist + + const symbol = values[INDEX_FIELD_INDEX.symbol]; + const price = parseFloat(values[INDEX_FIELD_INDEX.price]); + const sign = values[INDEX_FIELD_INDEX.sign]; + const changeRaw = parseFloat(values[INDEX_FIELD_INDEX.change]); + const changeRateRaw = parseFloat(values[INDEX_FIELD_INDEX.changeRate]); + + // Adjust sign for negative values if necessary (usually API sends absolute values for change) + const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한 + + const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw); + const changeRate = isNegative + ? -Math.abs(changeRateRaw) + : Math.abs(changeRateRaw); + + return { + symbol, + time: values[INDEX_FIELD_INDEX.time], + price, + change, + changeRate, + sign, + }; +} diff --git a/features/dashboard/utils/kis-stock-realtime.utils.ts b/features/dashboard/utils/kis-stock-realtime.utils.ts new file mode 100644 index 0000000..64f0da9 --- /dev/null +++ b/features/dashboard/utils/kis-stock-realtime.utils.ts @@ -0,0 +1,69 @@ +export interface KisRealtimeStockTick { + symbol: string; // 종목코드 + time: string; // 체결시간 + currentPrice: number; // 현재가 + sign: string; // 전일대비부호 (1:상한, 2:상승, 3:보합, 4:하한, 5:하락) + change: number; // 전일대비 + changeRate: number; // 전일대비율 + accumulatedVolume: number; // 누적거래량 +} + +const STOCK_realtime_TR_ID = "H0STCNT0"; + +// H0STCNT0 Output format indices based on typical KIS Realtime API +// Format: MKSC_SHRN_ISCD^STCK_CNTG_HOUR^STCK_PRPR^PRDY_VRSS_SIGN^PRDY_VRSS^PRDY_CTRT^... +const STOCK_FIELD_INDEX = { + symbol: 0, // MKSC_SHRN_ISCD + time: 1, // STCK_CNTG_HOUR + currentPrice: 2, // STCK_PRPR + sign: 3, // PRDY_VRSS_SIGN + change: 4, // PRDY_VRSS + changeRate: 5, // PRDY_CTRT + accumulatedVolume: 12, // ACML_VOL (Usually at index 12 or similar, need to be careful here) +} as const; + +export function parseKisRealtimeStockTick( + raw: string, +): KisRealtimeStockTick | null { + // Format: 0|H0STCNT0|001|SYMBOL^TIME^PRICE^SIGN^CHANGE^... + if (!/^([01])\|/.test(raw)) return null; + + const parts = raw.split("|"); + if (parts.length < 4) return null; + + // Check TR ID + if (parts[1] !== STOCK_realtime_TR_ID) { + return null; + } + + const values = parts[3].split("^"); + if (values.length < 6) return null; // Ensure minimum fields exist + + const symbol = values[STOCK_FIELD_INDEX.symbol]; + const currentPrice = parseFloat(values[STOCK_FIELD_INDEX.currentPrice]); + const sign = values[STOCK_FIELD_INDEX.sign]; + const changeRaw = parseFloat(values[STOCK_FIELD_INDEX.change]); + const changeRateRaw = parseFloat(values[STOCK_FIELD_INDEX.changeRate]); + + // Adjust sign for negative values if necessary + const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한 + + const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw); + const changeRate = isNegative + ? -Math.abs(changeRateRaw) + : Math.abs(changeRateRaw); + + // Validate numeric values + if (isNaN(currentPrice)) return null; + + return { + symbol, + time: values[STOCK_FIELD_INDEX.time], + currentPrice, + sign, + change, + changeRate, + accumulatedVolume: + parseFloat(values[STOCK_FIELD_INDEX.accumulatedVolume]) || 0, + }; +} diff --git a/features/kis-realtime/hooks/useKisWebSocket.ts b/features/kis-realtime/hooks/useKisWebSocket.ts new file mode 100644 index 0000000..020090a --- /dev/null +++ b/features/kis-realtime/hooks/useKisWebSocket.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; + +/** + * @file features/kis-realtime/hooks/useKisWebSocket.ts + * @description KIS 실시간 데이터를 구독하기 위한 통합 훅입니다. + * 컴포넌트 마운트/언마운트 시 자동으로 구독 및 해제를 처리합니다. + */ + +type RealtimeCallback = (data: string) => void; + +interface UseKisWebSocketParams { + symbol?: string; // 종목코드 (없으면 구독 안 함) + trId?: string; // 거래 ID (예: H0STCNT0) + onMessage?: RealtimeCallback; // 데이터 수신 콜백 + enabled?: boolean; // 구독 활성화 여부 +} + +export function useKisWebSocket({ + symbol, + trId, + onMessage, + enabled = true, +}: UseKisWebSocketParams) { + const { subscribe, connect, isConnected } = useKisWebSocketStore(); + const callbackRef = useRef(onMessage); + + // 콜백 함수가 바뀌어도 재구독하지 않도록 ref 사용 + useEffect(() => { + callbackRef.current = onMessage; + }, [onMessage]); + + useEffect(() => { + if (!enabled || !symbol || !trId) return; + + // 연결 시도 (이미 연결 중이면 스토어에서 무시됨) + connect(); + + // 구독 요청 + const unsubscribe = subscribe(trId, symbol, (data) => { + callbackRef.current?.(data); + }); + + // 언마운트 시 구독 해제 + return () => { + unsubscribe(); + }; + }, [symbol, trId, enabled, connect, subscribe]); + + return { isConnected }; +} diff --git a/features/kis-realtime/stores/kisWebSocketStore.ts b/features/kis-realtime/stores/kisWebSocketStore.ts new file mode 100644 index 0000000..309e495 --- /dev/null +++ b/features/kis-realtime/stores/kisWebSocketStore.ts @@ -0,0 +1,386 @@ +import { create } from "zustand"; +import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; +import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils"; + +/** + * @file features/kis-realtime/stores/kisWebSocketStore.ts + * @description KIS 실시간 웹소켓 연결을 전역에서 하나로 관리하는 스토어입니다. + * 중복 연결을 방지하고, 여러 컴포넌트에서 동일한 데이터를 구독할 때 효율적으로 처리합니다. + */ + +type RealtimeCallback = (data: string) => void; + +interface KisWebSocketState { + isConnected: boolean; + error: string | null; + + /** + * 웹소켓 연결을 수립합니다. + * 이미 연결되어 있거나 연결 중이면 무시합니다. + */ + connect: (options?: { forceApprovalRefresh?: boolean }) => Promise; + + /** + * 웹소켓 연결을 강제로 재시작합니다. + * 필요 시 승인키를 새로 발급받아 재연결합니다. + */ + reconnect: (options?: { refreshApproval?: boolean }) => Promise; + + /** + * 웹소켓 연결을 종료합니다. + * 모든 구독이 해제됩니다. + */ + disconnect: () => void; + + /** + * 특정 TR ID와 종목 코드로 실시간 데이터를 구독합니다. + * @param trId 거래 ID (예: H0STCNT0) + * @param symbol 종목 코드 (예: 005930) + * @param callback 데이터 수신 시 실행할 콜백 함수 + * @returns 구독 해제 함수 (useEffect cleanup에서 호출하세요) + */ + subscribe: ( + trId: string, + symbol: string, + callback: RealtimeCallback, + ) => () => void; +} + +// 구독자 목록 관리 (Key: "TR_ID|SYMBOL", Value: Set) +// 스토어 외부 변수로 관리하여 불필요한 리렌더링을 방지합니다. +const subscribers = new Map>(); +const subscriberCounts = new Map(); // 실제 소켓 구독 요청 여부 추적용 + +let socket: WebSocket | null = null; +let pingInterval: number | undefined; +let isConnecting = false; // 연결 진행 중 상태 잠금 +let reconnectRetryTimer: number | undefined; +let lastAppKeyConflictAt = 0; + +export const useKisWebSocketStore = create((set, get) => ({ + isConnected: false, + error: null, + + connect: async (options) => { + const forceApprovalRefresh = options?.forceApprovalRefresh ?? false; + const currentSocket = socket; + + if (currentSocket?.readyState === WebSocket.CLOSING) { + await waitForSocketClose(currentSocket); + } + + // 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지 + if ( + socket?.readyState === WebSocket.OPEN || + socket?.readyState === WebSocket.CONNECTING || + socket?.readyState === WebSocket.CLOSING || + isConnecting + ) { + return; + } + + try { + isConnecting = true; + const { getOrFetchWsConnection, clearWsConnectionCache } = + useKisRuntimeStore.getState(); + if (forceApprovalRefresh) { + clearWsConnectionCache(); + } + const wsConnection = await getOrFetchWsConnection(); + + // 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인 + if ( + socket?.readyState === WebSocket.OPEN || + socket?.readyState === WebSocket.CONNECTING + ) { + isConnecting = false; + return; + } + + if (!wsConnection) { + throw new Error("웹소켓 접속 키 발급에 실패했습니다."); + } + + // 소켓 생성 + // socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지 + const ws = new WebSocket(`${wsConnection.wsUrl}/tryitout`); + socket = ws; + + ws.onopen = () => { + isConnecting = false; + // socket 변수가 다른 인스턴스로 바뀌었을 가능성은 낮지만(락 때문), + // 안전을 위해 이벤트 발생 주체인 ws를 사용 또는 현재 socket 확인 + if (socket !== ws) return; + + set({ isConnected: true, error: null }); + console.log("[KisWebSocket] Connected"); + + // 재연결 시 기존 구독 복구 + const approvalKey = wsConnection.approvalKey; + if (approvalKey) { + subscriberCounts.forEach((_, key) => { + const [trId, symbol] = key.split("|"); + + // OPEN 상태일 때만 전송 + if (ws.readyState === WebSocket.OPEN) { + sendSubscription(ws, approvalKey, trId, symbol, "1"); // 구독 + } + }); + } + + // PINGPONG (Keep-alive) + window.clearInterval(pingInterval); + pingInterval = window.setInterval(() => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send("PINGPONG"); // 일부 환경에서는 PINGPONG 텍스트 전송 + } + }, 100_000); // 100초 주기 + }; + + ws.onclose = () => { + if (socket === ws) { + isConnecting = false; + set({ isConnected: false }); + console.log("[KisWebSocket] Disconnected"); + window.clearInterval(pingInterval); + socket = null; + } + }; + + ws.onerror = (event) => { + if (socket === ws) { + isConnecting = false; + console.error("[KisWebSocket] Error", event); + set({ + isConnected: false, + error: "웹소켓 연결 중 오류가 발생했습니다.", + }); + } + }; + + ws.onmessage = (event) => { + const data = event.data; + if (typeof data !== "string") return; + + // PINGPONG 응답 또는 제어 메시지 처리 + if (data.startsWith("{")) { + const control = parseControlMessage(data); + if (control?.rt_cd && control.rt_cd !== "0") { + const errorMessage = buildControlErrorMessage(control); + set({ + error: errorMessage, + }); + + // KIS 제어 메시지: ALREADY IN USE appkey + // 이전 세션이 닫히기 전에 재연결될 때 간헐적으로 발생합니다. + if (control.msg_cd === "OPSP8996") { + const now = Date.now(); + if (now - lastAppKeyConflictAt > 5_000) { + lastAppKeyConflictAt = now; + window.clearTimeout(reconnectRetryTimer); + reconnectRetryTimer = window.setTimeout(() => { + void get().reconnect({ refreshApproval: false }); + }, 1_200); + } + } + } + return; + } + + if (data[0] === "0" || data[0] === "1") { + // 데이터 포맷: 0|TR_ID|KEY|... + const parts = data.split("|"); + if (parts.length >= 4) { + const trId = parts[1]; + // 데이터 부분 (마지막 부분)에서 종목코드를 찾아야 함. + // 하지만 응답에는 종목코드가 명시적으로 없는 경우가 많음 (순서로 추론). + // 다행히 KIS API는 요청했던 TR_ID와 수신된 데이터의 호가/체결 데이터를 매핑해야 함. + // 여기서는 모든 구독자에게 브로드캐스트하는 방식을 사용 (TR_ID 기준). + + // 더 정확한 라우팅을 위해: + // 실시간 체결/호가 데이터에는 종목코드가 포함되어 있음. + // 체결(H0STCNT0): data.split("^")[0] (유가증권 단축종목코드) + const body = parts[3]; + const values = body.split("^"); + const symbol = values[0]; // 대부분 첫 번째 필드가 종목코드 + + const key = `${trId}|${symbol}`; + const callbacks = subscribers.get(key); + callbacks?.forEach((cb) => cb(data)); + } + } + }; + } catch (err) { + isConnecting = false; + set({ + isConnected: false, + error: err instanceof Error ? err.message : "연결 실패", + }); + } + }, + + reconnect: async (options) => { + const refreshApproval = options?.refreshApproval ?? false; + const currentSocket = socket; + get().disconnect(); + if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) { + await waitForSocketClose(currentSocket); + } + await get().connect({ + forceApprovalRefresh: refreshApproval, + }); + }, + + disconnect: () => { + const currentSocket = socket; + if ( + currentSocket && + (currentSocket.readyState === WebSocket.OPEN || + currentSocket.readyState === WebSocket.CONNECTING || + currentSocket.readyState === WebSocket.CLOSING) + ) { + currentSocket.close(); + } + if (currentSocket?.readyState === WebSocket.CLOSED && socket === currentSocket) { + socket = null; + } + set({ isConnected: false }); + window.clearInterval(pingInterval); + window.clearTimeout(reconnectRetryTimer); + isConnecting = false; + }, + + subscribe: (trId, symbol, callback) => { + const key = `${trId}|${symbol}`; + + // 1. 구독자 목록에 추가 + if (!subscribers.has(key)) { + subscribers.set(key, new Set()); + } + subscribers.get(key)!.add(callback); + + // 2. 소켓 서버에 구독 요청 (첫 번째 구독자인 경우) + const currentCount = subscriberCounts.get(key) || 0; + if (currentCount === 0) { + const { wsApprovalKey } = useKisRuntimeStore.getState(); + if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) { + sendSubscription(socket, wsApprovalKey, trId, symbol, "1"); // "1": 등록 + } + } + subscriberCounts.set(key, currentCount + 1); + + // **연결이 안 되어 있으면 연결 시도** + if (!socket || socket.readyState !== WebSocket.OPEN) { + get().connect(); + } + + // 3. 구독 해제 함수 반환 + return () => { + const callbacks = subscribers.get(key); + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + subscribers.delete(key); + } + } + + const count = subscriberCounts.get(key) || 0; + if (count > 0) { + subscriberCounts.set(key, count - 1); + if (count - 1 === 0) { + // 마지막 구독자가 사라지면 소켓 구독 해제 + const { wsApprovalKey } = useKisRuntimeStore.getState(); + if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) { + sendSubscription(socket, wsApprovalKey, trId, symbol, "2"); // "2": 해제 + } + } + } + }; + }, +})); + +// 헬퍼: 구독/해제 메시지 전송 +function sendSubscription( + ws: WebSocket, + appKey: string, + trId: string, + symbol: string, + trType: "1" | "2", +) { + try { + const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType); + ws.send(JSON.stringify(msg)); + console.debug( + `[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`, + ); + } catch (e) { + console.warn("[KisWebSocket] Send error", e); + } +} + +interface KisWsControlMessage { + rt_cd?: string; + msg_cd?: string; + msg1?: string; +} + +/** + * @description 웹소켓 제어 메시지(JSON)를 파싱합니다. + * @param rawData 원본 메시지 문자열 + * @returns 파싱된 제어 메시지 또는 null + * @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage + */ +function parseControlMessage(rawData: string): KisWsControlMessage | null { + try { + const parsed = JSON.parse(rawData) as KisWsControlMessage; + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** + * @description KIS 웹소켓 제어 오류를 사용자용 짧은 문구로 변환합니다. + * @param message KIS 제어 메시지 + * @returns 표시용 오류 문자열 + * @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage + */ +function buildControlErrorMessage(message: KisWsControlMessage) { + if (message.msg_cd === "OPSP8996") { + return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다."; + } + const detail = [message.msg1, message.msg_cd].filter(Boolean).join(" / "); + return detail ? `실시간 제어 메시지 오류: ${detail}` : "실시간 제어 메시지 오류"; +} + +/** + * @description 특정 웹소켓 인스턴스가 완전히 닫힐 때까지 대기합니다. + * @param target 대기할 웹소켓 인스턴스 + * @param timeoutMs 최대 대기 시간(ms) + * @returns close/error/timeout 중 먼저 완료되면 resolve + * @see features/kis-realtime/stores/kisWebSocketStore.ts connect/reconnect + */ +function waitForSocketClose(target: WebSocket, timeoutMs = 2_000) { + if (target.readyState === WebSocket.CLOSED) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let settled = false; + const onClose = () => finish(); + const onError = () => finish(); + const timeoutId = window.setTimeout(() => finish(), timeoutMs); + + const finish = () => { + if (settled) return; + settled = true; + window.clearTimeout(timeoutId); + target.removeEventListener("close", onClose); + target.removeEventListener("error", onError); + resolve(); + }; + + target.addEventListener("close", onClose); + target.addEventListener("error", onError); + }); +} diff --git a/features/kis-realtime/utils/websocketUtils.ts b/features/kis-realtime/utils/websocketUtils.ts new file mode 100644 index 0000000..c2c516e --- /dev/null +++ b/features/kis-realtime/utils/websocketUtils.ts @@ -0,0 +1,29 @@ +/** + * @file features/kis-realtime/utils/websocketUtils.ts + * @description KIS 웹소켓 메시지 생성 및 파싱 관련 유틸리티 함수 모음 + */ + +/** + * @description KIS 실시간 구독/해제 소켓 메시지를 생성합니다. + */ +export function buildKisRealtimeMessage( + approvalKey: string, + symbol: string, + trId: string, + trType: "1" | "2", +) { + return { + header: { + approval_key: approvalKey, + custtype: "P", + tr_type: trType, + "content-type": "utf-8", + }, + body: { + input: { + tr_id: trId, + tr_key: symbol, + }, + }, + }; +} diff --git a/features/settings/components/KisAuthForm.tsx b/features/settings/components/KisAuthForm.tsx index 4869c61..37625eb 100644 --- a/features/settings/components/KisAuthForm.tsx +++ b/features/settings/components/KisAuthForm.tsx @@ -3,6 +3,7 @@ import { useShallow } from "zustand/react/shallow"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { revokeKisCredentials, @@ -10,15 +11,19 @@ import { } from "@/features/settings/apis/kis-auth.api"; import { KeyRound, - Shield, + ShieldCheck, CheckCircle2, XCircle, Lock, - Sparkles, - Zap, + Link2, + Unlink2, Activity, + Zap, + KeySquare, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { InlineSpinner } from "@/components/ui/loading-spinner"; +import { SettingsCard } from "./SettingsCard"; /** * @description 한국투자증권 앱키/앱시크릿키 인증 폼입니다. @@ -62,9 +67,6 @@ export function KisAuthForm() { const [isValidating, startValidateTransition] = useTransition(); const [isRevoking, startRevokeTransition] = useTransition(); - // 입력 필드 Focus 상태 관리를 위한 State - const [focusedField, setFocusedField] = useState<"appKey" | "appSecret" | null>(null); - function handleValidate() { startValidateTransition(async () => { try { @@ -123,134 +125,21 @@ export function KisAuthForm() { } return ( -
- {/* Inner Content Container - Compact spacing */} -
- {/* Header Section */} -
-
-
- -
-
-

- 한국투자증권 앱키 연결 - {isKisVerified && ( - - - 연결됨 - - )} -

-

- 한국투자증권 Open API에서 발급받은 앱키/앱시크릿키를 입력해 주세요. -

-
-
- - {/* Trading Mode Switch (Segmented Control - Compact) */} -
- - -
-
- - {/* Input Fields Section (Compact Stacked Layout) */} -
- {/* ========== APP KEY INPUT ========== */} -
-
- -
-
- 앱키 -
- setKisAppKeyInput(e.target.value)} - onFocus={() => setFocusedField("appKey")} - onBlur={() => setFocusedField(null)} - placeholder="한국투자증권 앱키 입력" - className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600" - autoComplete="off" - /> -
- - {/* ========== APP SECRET INPUT ========== */} -
-
- -
-
- 시크릿키 -
- setKisAppSecretInput(e.target.value)} - onFocus={() => setFocusedField("appSecret")} - onBlur={() => setFocusedField(null)} - placeholder="한국투자증권 앱시크릿키 입력" - className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600" - autoComplete="off" - /> -
-
- - {/* Action & Status Section */} -
-
+ + + 연결됨 + + ) : undefined + } + footer={{ + actions: ( +
+ ) : ( + + + 앱키 연결 확인 + + )} + {isKisVerified && ( )}
- - {/* Status Messages - Compact */} -
+ ), + status: ( +
{errorMessage && ( -

+

{errorMessage}

)} {statusMessage && ( -

+

{statusMessage}

)} {!errorMessage && !statusMessage && !isKisVerified && ( -

- - 미연결 +

+ + 미연결 상태입니다

)}
+ ), + }} + className="h-full" + > +
+ {/* ========== TRADING MODE ========== */} +
+
+ + 투자 모드 선택 +
+
+ + +
+
+ + {/* ========== APP KEY INPUTS ========== */} +
+ +
+ + ); +} + +/** + * @description 앱키/시크릿키 입력 전용 필드 블록입니다. + * @see features/settings/components/KisAuthForm.tsx 입력 UI 렌더링 + */ +function CredentialInput({ + id, + label, + value, + placeholder, + onChange, + icon: Icon, +}: { + id: string; + label: string; + value: string; + placeholder: string; + onChange: (value: string) => void; + icon: LucideIcon; +}) { + return ( +
+ +
+
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0" + autoComplete="off" + /> +
); } diff --git a/features/settings/components/KisProfileForm.tsx b/features/settings/components/KisProfileForm.tsx index 5bc7e71..9855042 100644 --- a/features/settings/components/KisProfileForm.tsx +++ b/features/settings/components/KisProfileForm.tsx @@ -2,12 +2,21 @@ import { useState, useTransition } from "react"; import { useShallow } from "zustand/react/shallow"; -import { CreditCard, CheckCircle2, SearchCheck, ShieldOff, XCircle } from "lucide-react"; +import { + CreditCard, + CheckCircle2, + SearchCheck, + ShieldOff, + XCircle, + FileLock2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { InlineSpinner } from "@/components/ui/loading-spinner"; import { validateKisProfile } from "@/features/settings/apis/kis-auth.api"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; +import { SettingsCard } from "./SettingsCard"; /** * @description 한국투자증권 계좌번호 검증 폼입니다. @@ -98,98 +107,122 @@ export function KisProfileForm() { } return ( -
- {/* ========== HEADER ========== */} -
-
-

- 한국투자증권 계좌 인증 -

-

- 앱키 연결 완료 후 계좌번호를 확인합니다. + + + 인증 완료 + + ) : undefined + } + className={ + !isKisVerified ? "opacity-60 grayscale pointer-events-none" : undefined + } + footer={{ + actions: ( +

+ + +
+ ), + status: ( +
+ {errorMessage && ( +

+ + {errorMessage} +

+ )} + {statusMessage && ( +

+ + {statusMessage} +

+ )} + {!statusMessage && !errorMessage && !isKisVerified && ( +

+ + 먼저 앱키 연결을 완료해 주세요 +

+ )} + {!statusMessage && !errorMessage && isKisProfileVerified && ( +

+ 확인된 계좌: {maskAccountNo(verifiedAccountNo)} +

+ )} +
+ ), + }} + > +
+ {/* ========== ACCOUNT GUIDE ========== */} +
+

+ + 계좌번호 형식 안내

-
- - {isKisProfileVerified ? "인증 완료" : "미인증"} - -
+

+ 8-2 형식으로 입력하세요. 예: 12345678-01 +

+ - {/* ========== INPUTS ========== */} -
-
- - setKisAccountNoInput(event.target.value)} - placeholder="계좌번호 (예: 12345678-01)" - className="h-9 pl-8 text-xs" - autoComplete="off" - /> + {/* ========== ACCOUNT NO INPUT ========== */} +
+ +
+
+ +
+ setKisAccountNoInput(e.target.value)} + placeholder="계좌번호 (예: 12345678-01)" + className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0" + autoComplete="off" + /> +
- - {/* ========== ACTION ========== */} -
- - - -
- {errorMessage && ( -

- - {errorMessage} -

- )} - {statusMessage && ( -

- - {statusMessage} -

- )} - {!statusMessage && !errorMessage && !isKisVerified && ( -

- 먼저 앱키 연결을 완료해 주세요. -

- )} - {!statusMessage && !errorMessage && isKisProfileVerified && ( -

- 확인된 계좌: {maskAccountNo(verifiedAccountNo)} -

- )} -
-
-
+ ); } diff --git a/features/settings/components/SettingsCard.tsx b/features/settings/components/SettingsCard.tsx new file mode 100644 index 0000000..c37ba34 --- /dev/null +++ b/features/settings/components/SettingsCard.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { ReactNode } from "react"; +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SettingsCardProps { + /** 카드 상단에 표시될 아이콘 컴포넌트 */ + icon: LucideIcon; + /** 카드 제목 */ + title: ReactNode; + /** 제목 옆에 표시될 배지 (선택 사항) */ + badge?: ReactNode; + /** 헤더 우측에 표시될 액션 요소 (스위치, 버튼 등) */ + headerAction?: ReactNode; + /** 카드 설명 텍스트 */ + description?: string; + /** 카드 본문 컨텐츠 */ + children: ReactNode; + /** 카드 하단 영역 (액션 버튼 및 상태 메시지 포함) */ + footer?: { + /** 좌측 액션 버튼들 */ + actions?: ReactNode; + /** 우측 상태 메시지 */ + status?: ReactNode; + }; + /** 추가 클래스 */ + className?: string; +} + +/** + * @description 설정 페이지에서 사용되는 통일된 카드 UI 컴포넌트입니다. + * @remarks 모든 설정 폼(인증, 프로필 등)은 이 컴포넌트를 사용하여 일관된 디자인을 유지해야 합니다. + */ +export function SettingsCard({ + icon: Icon, + title, + badge, + headerAction, + description, + children, + footer, + className, +}: SettingsCardProps) { + return ( +
+
+ +
+ {/* ========== CARD HEADER ========== */} +
+
+
+
+ +
+
+
+

+ {title} +

+ {badge &&
{badge}
} +
+ {description && ( +

+ {description} +

+ )} +
+
+ + {headerAction && ( +
{headerAction}
+ )} +
+
+ + {/* ========== CARD BODY ========== */} +
{children}
+
+ + {/* ========== CARD FOOTER ========== */} + {footer && ( +
+
+
{footer.actions}
+
{footer.status}
+
+
+ )} +
+ ); +} diff --git a/features/settings/components/SettingsContainer.tsx b/features/settings/components/SettingsContainer.tsx index f6b31a8..7f68056 100644 --- a/features/settings/components/SettingsContainer.tsx +++ b/features/settings/components/SettingsContainer.tsx @@ -1,10 +1,11 @@ "use client"; -import { Info } from "lucide-react"; +import { type LucideIcon, Info, Link2, Wallet } from "lucide-react"; import { useShallow } from "zustand/react/shallow"; import { KisAuthForm } from "@/features/settings/components/KisAuthForm"; import { KisProfileForm } from "@/features/settings/components/KisProfileForm"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; +import { cn } from "@/lib/utils"; /** * @description 설정 페이지 컨테이너입니다. KIS 연결 상태와 인증 폼을 카드 UI로 제공합니다. @@ -28,54 +29,59 @@ export function SettingsContainer() { ); return ( -
- {/* ========== STATUS CARD ========== */} -
-

- 한국투자증권 연결 설정 -

-
- 연결 상태: - {isKisVerified ? ( - - - 연결됨 ({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"}) - - ) : ( - - - 미연결 - - )} -
-
- 계좌 인증 상태: - {isKisProfileVerified ? ( - - 확인됨 ({maskAccountNo(verifiedAccountNo)}) - - ) : ( - - 미확인 - - )} -
-
+
+ {/* ========== SETTINGS OVERVIEW ========== */} +
+
+
+

+ 한국투자증권 연결 센터 +

+

+ 앱키 연결과 계좌 확인을 한 화면에서 처리합니다. 아래 순서대로 + 진행하면 바로 대시보드/트레이드 화면에 반영됩니다. +

+
+

+ 진행 순서: 1) 앱키 연결 확인 {"->"} 2) 계좌 인증 {"->"} 3) + 거래 화면 사용 +

+
+
- {/* ========== PRIVACY NOTICE ========== */} -
-

- - 입력 정보 보관 안내 -

-

- 이 화면에서 입력한 한국투자증권 앱키, 앱시크릿키, 계좌번호는 서버 DB에 저장하지 않습니다. - 현재 사용 중인 브라우저(클라이언트)에서만 관리되며, 연결 해제 시 즉시 지울 수 있습니다. -

+
+ + + +
+
{/* ========== FORM GRID ========== */} -
+
@@ -96,3 +102,40 @@ function maskAccountNo(value: string | null) { return "********-**"; } +type StatusTileTone = "success" | "idle" | "notice"; + +/** + * @description 설정 페이지 상단 요약 상태 타일입니다. + * @see features/settings/components/SettingsContainer.tsx 상태 요약 렌더링 + */ +function StatusTile({ + icon: Icon, + title, + value, + tone, +}: { + icon: LucideIcon; + title: string; + value: string; + tone: StatusTileTone; +}) { + return ( +
+

+ + {title} +

+

{value}

+
+ ); +} diff --git a/features/settings/store/use-kis-runtime-store.ts b/features/settings/store/use-kis-runtime-store.ts index b0aa8e3..cf91602 100644 --- a/features/settings/store/use-kis-runtime-store.ts +++ b/features/settings/store/use-kis-runtime-store.ts @@ -56,6 +56,7 @@ interface KisRuntimeStoreActions { invalidateKisVerification: () => void; clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void; getOrFetchWsConnection: () => Promise; + clearWsConnectionCache: () => void; setHasHydrated: (state: boolean) => void; } @@ -224,6 +225,13 @@ export const useKisRuntimeStore = create< return wsConnectionPromise; }, + clearWsConnectionCache: () => { + wsConnectionPromise = null; + set({ + wsApprovalKey: null, + wsUrl: null, + }); + }, setHasHydrated: (state) => { set({ _hasHydrated: state, diff --git a/features/trade/components/TradeContainer.tsx b/features/trade/components/TradeContainer.tsx index 9222639..499f300 100644 --- a/features/trade/components/TradeContainer.tsx +++ b/features/trade/components/TradeContainer.tsx @@ -1,6 +1,7 @@ "use client"; -import { type FormEvent, useCallback, useState } from "react"; +import { type FormEvent, useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; @@ -26,6 +27,10 @@ import type { * @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다. */ export function TradeContainer() { + const searchParams = useSearchParams(); + const symbolParam = searchParams.get("symbol"); + const nameParam = searchParams.get("name"); + // [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신) const [realtimeOrderBook, setRealtimeOrderBook] = useState(null); @@ -53,6 +58,36 @@ export function TradeContainer() { } = useStockSearch(); const { selectedStock, loadOverview, updateRealtimeTradeTick } = useStockOverview(); + + /** + * [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드 + * 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다. + */ + useEffect(() => { + if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) { + // 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행 + if (selectedStock?.symbol !== symbolParam) { + setKeyword(nameParam || symbolParam); + appendSearchHistory({ + symbol: symbolParam, + name: nameParam || symbolParam, + market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨 + }); + loadOverview(symbolParam, verifiedCredentials); + } + } + }, [ + symbolParam, + nameParam, + isKisVerified, + verifiedCredentials, + _hasHydrated, + selectedStock?.symbol, + loadOverview, + setKeyword, + appendSearchHistory, + ]); + const canTrade = isKisVerified && !!verifiedCredentials; const canSearch = canTrade; diff --git a/features/trade/components/chart/StockLineChart.tsx b/features/trade/components/chart/StockLineChart.tsx index 4b13caa..28065a5 100644 --- a/features/trade/components/chart/StockLineChart.tsx +++ b/features/trade/components/chart/StockLineChart.tsx @@ -35,7 +35,7 @@ import { } from "./chart-utils"; const UP_COLOR = "#ef4444"; -const MINUTE_SYNC_INTERVAL_MS = 5000; +const MINUTE_SYNC_INTERVAL_MS = 30000; const REALTIME_STALE_THRESHOLD_MS = 12000; interface ChartPalette { @@ -522,11 +522,11 @@ export function StockLineChart({ } }, [isChartReady, renderableBars, setSeriesData]); - /** - * @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts latestTick - * @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar - */ +/** + * @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다. + * @see features/trade/hooks/useKisTradeWebSocket.ts latestTick + * @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar + */ useEffect(() => { if (!latestTick) return; if (bars.length === 0) return; @@ -567,7 +567,10 @@ export function StockLineChart({ const recentBars = latestPageBars.slice(-10); if (recentBars.length === 0) return; - setBars((prev) => mergeBars(prev, recentBars)); + setBars((prev) => { + const merged = mergeBars(prev, recentBars); + return areBarsEqual(prev, merged) ? prev : merged; + }); } catch { // 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다. } @@ -690,3 +693,25 @@ export function StockLineChart({
); } + +function areBarsEqual(left: ChartBar[], right: ChartBar[]) { + if (left.length !== right.length) return false; + + for (let index = 0; index < left.length; index += 1) { + const lhs = left[index]; + const rhs = right[index]; + if (!lhs || !rhs) return false; + if ( + lhs.time !== rhs.time || + lhs.open !== rhs.open || + lhs.high !== rhs.high || + lhs.low !== rhs.low || + lhs.close !== rhs.close || + lhs.volume !== rhs.volume + ) { + return false; + } + } + + return true; +} diff --git a/features/trade/components/chart/chart-utils.ts b/features/trade/components/chart/chart-utils.ts index 122af67..4efbd55 100644 --- a/features/trade/components/chart/chart-utils.ts +++ b/features/trade/components/chart/chart-utils.ts @@ -150,7 +150,7 @@ function resolveBarTimestamp( /** * 타임스탬프를 타임프레임 버킷 경계에 정렬 - * - 1m: 그대로 + * - 1m: 초/밀리초를 제거해 분 경계에 정렬 * - 30m/1h: 분 단위를 버킷에 정렬 * - 1d: 00:00:00 * - 1w: 월요일 00:00:00 @@ -161,7 +161,9 @@ function alignTimestamp( ): UTCTimestamp { const d = new Date(timestamp * 1000); - if (timeframe === "30m" || timeframe === "1h") { + if (timeframe === "1m") { + d.setUTCSeconds(0, 0); + } else if (timeframe === "30m" || timeframe === "1h") { const bucket = timeframe === "30m" ? 30 : 60; d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0); } else if (timeframe === "1d") { diff --git a/features/trade/hooks/useDomesticSession.ts b/features/trade/hooks/useDomesticSession.ts new file mode 100644 index 0000000..ab14c6d --- /dev/null +++ b/features/trade/hooks/useDomesticSession.ts @@ -0,0 +1,29 @@ +import { useState, useEffect } from "react"; +import { + resolveDomesticKisSession, + type DomesticKisSession, +} from "@/lib/kis/domestic-market-session"; + +// 클라이언트 환경에서 현재 시간을 기준으로 세션을 계산합니다. +// 매 30초마다 갱신됩니다. + +/** + * @description 국내 주식 시장의 세션(장 운영 상태)을 관리하는 훅입니다. + * client-side에서 로컬 스토리지를 확인하거나 기본 세션을 반환하며, + * 30초마다 세션 정보를 갱신하여 장 시작/마감/시간외 단일가 등 상태 변화를 감지합니다. + */ +export function useDomesticSession() { + const [marketSession, setMarketSession] = useState(() => + resolveDomesticKisSession(), + ); + + useEffect(() => { + const timerId = window.setInterval(() => { + const nextSession = resolveDomesticKisSession(); + setMarketSession((prev) => (prev === nextSession ? prev : nextSession)); + }, 30_000); + return () => window.clearInterval(timerId); + }, []); + + return marketSession; +} diff --git a/features/trade/hooks/useKisTradeWebSocket.ts b/features/trade/hooks/useKisTradeWebSocket.ts index 1e1201c..90753a8 100644 --- a/features/trade/hooks/useKisTradeWebSocket.ts +++ b/features/trade/hooks/useKisTradeWebSocket.ts @@ -1,156 +1,24 @@ -import { useEffect, useRef, useState } from "react"; -import { - type KisRuntimeCredentials, - useKisRuntimeStore, -} from "@/features/settings/store/use-kis-runtime-store"; +import { useEffect, useMemo } from "react"; +import { type KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/trade/types/trade.types"; -import { - buildKisRealtimeMessage, - parseKisRealtimeOrderbook, - parseKisRealtimeTickBatch, -} from "@/features/trade/utils/kis-realtime.utils"; -import { - DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, - resolveDomesticKisSession, - shouldUseAfterHoursSinglePriceTr, - shouldUseExpectedExecutionTr, - type DomesticKisSession, -} from "@/lib/kis/domestic-market-session"; - -const TRADE_TR_ID = "H0STCNT0"; -const TRADE_TR_ID_EXPECTED = "H0STANC0"; -const TRADE_TR_ID_OVERTIME = "H0STOUP0"; -const TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0"; -const TRADE_TR_ID_TOTAL = "H0UNCNT0"; -const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0"; -const ORDERBOOK_TR_ID = "H0STASP0"; -const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; -const MAX_TRADE_TICKS = 10; -const WS_DEBUG_STORAGE_KEY = "KIS_WS_DEBUG"; +import { resolveTradeTrIds } from "@/features/trade/utils/kisRealtimeUtils"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; +import { useDomesticSession } from "@/features/trade/hooks/useDomesticSession"; +import { useTradeTickSubscription } from "@/features/trade/hooks/useTradeTickSubscription"; +import { useOrderbookSubscription } from "@/features/trade/hooks/useOrderbookSubscription"; /** - * @description 장 구간/시장별 누락을 줄이기 위해 TR ID를 우선순위 배열로 반환합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록 - * @see temp-kis-domestic-functions-ws.py ccnl_krx/ccnl_total/exp_ccnl_krx/exp_ccnl_total/overtime_ccnl_krx - */ -function resolveTradeTrIds( - env: KisRuntimeCredentials["tradingEnv"], - session: DomesticKisSession, -) { - if (env === "mock") return [TRADE_TR_ID]; - - if (shouldUseAfterHoursSinglePriceTr(session)) { - // 시간외 단일가(16:00~18:00): 전용 TR + 통합 TR 백업 - return uniqueTrIds([ - TRADE_TR_ID_OVERTIME, - TRADE_TR_ID_OVERTIME_EXPECTED, - TRADE_TR_ID_TOTAL, - TRADE_TR_ID_TOTAL_EXPECTED, - ]); - } - - if (shouldUseExpectedExecutionTr(session)) { - // 동시호가 구간(장전/장마감): 예상체결 TR을 우선, 일반체결/통합체결을 백업 - return uniqueTrIds([ - TRADE_TR_ID_EXPECTED, - TRADE_TR_ID_TOTAL_EXPECTED, - TRADE_TR_ID, - TRADE_TR_ID_TOTAL, - ]); - } - - if (session === "afterCloseFixedPrice") { - // 시간외 종가(15:40~16:00): 브로커별 라우팅 차이를 대비해 일반/시간외/통합 TR을 함께 구독 - return uniqueTrIds([ - TRADE_TR_ID, - TRADE_TR_ID_TOTAL, - TRADE_TR_ID_OVERTIME, - TRADE_TR_ID_OVERTIME_EXPECTED, - TRADE_TR_ID_TOTAL_EXPECTED, - ]); - } - - return uniqueTrIds([TRADE_TR_ID, TRADE_TR_ID_TOTAL]); -} - -/** - * @description 장 구간별 호가 TR ID 후보를 우선순위 배열로 반환합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록 - * @see temp-kis-domestic-functions-ws.py asking_price_krx/asking_price_total/overtime_asking_price_krx - */ -function resolveOrderBookTrIds( - env: KisRuntimeCredentials["tradingEnv"], - session: DomesticKisSession, -) { - if (env === "mock") return [ORDERBOOK_TR_ID]; - - if (shouldUseAfterHoursSinglePriceTr(session)) { - // 시간외 단일가(16:00~18:00)는 KRX 전용 호가 TR만 구독합니다. - // 통합 TR(H0UNASP0)을 같이 구독하면 종목별로 포맷/잔량이 섞여 보일 수 있습니다. - return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]); - } - - if (session === "afterCloseFixedPrice") { - return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]); - } - - // UI 흐름: 호가창 UI -> useKisTradeWebSocket onmessage -> onOrderBookMessage - // -> TradeContainer setRealtimeOrderBook -> useOrderBook 병합 -> OrderBook 렌더 - // 장중에는 KRX 전용(H0STASP0)만 구독해 값이 번갈아 덮이는 현상을 방지합니다. - return uniqueTrIds([ORDERBOOK_TR_ID]); -} - -/** - * @description 콘솔 디버그 플래그를 확인합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage - */ -function isWsDebugEnabled() { - if (typeof window === "undefined") return false; - - try { - return window.localStorage.getItem(WS_DEBUG_STORAGE_KEY) === "1"; - } catch { - return false; - } -} - -/** - * @description 실시간 웹소켓 제어(JSON) 메시지를 파싱합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage - */ -function parseWsControlMessage(raw: string) { - if (!raw.startsWith("{")) return null; - - try { - return JSON.parse(raw) as { - header?: { tr_id?: string }; - body?: { rt_cd?: string; msg1?: string }; - }; - } catch { - return null; - } -} - -/** - * @description 실시간 원문에서 파이프 구분 TR ID를 빠르게 추출합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage - */ -function peekPipeTrId(raw: string) { - const parts = raw.split("|"); - return parts.length > 1 ? parts[1] : ""; -} - -function uniqueTrIds(ids: string[]) { - return [...new Set(ids)]; -} - -/** - * @description Subscribes trade ticks and orderbook over one websocket. - * @see features/trade/components/TradeContainer.tsx - * @see lib/kis/domestic-market-session.ts + * @description KIS 실시간 매매/호가 데이터를 통합 구독하는 커스텀 훅입니다. + * @summary + * - `useDomesticSession`: 장 운영 시간(정규장, 시간외 등) 세션 관리 + * - `useTradeTickSubscription`: 실시간 체결가(Tick) 데이터 구독 및 상태 관리 + * - `useOrderbookSubscription`: 실시간 호가(Orderbook) 데이터 구독 + * - `useKisWebSocketStore`: 전역 웹소켓 연결 상태 및 에러 관리 + * + * 위 훅들을 조합(Composition)하여 트레이딩 화면에 필요한 모든 실시간 데이터를 제공합니다. */ export function useKisTradeWebSocket( symbol: string | undefined, @@ -162,305 +30,41 @@ export function useKisTradeWebSocket( onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void; }, ) { - const [latestTick, setLatestTick] = - useState(null); - const [recentTradeTicks, setRecentTradeTicks] = useState< - DashboardRealtimeTradeTick[] - >([]); - const [isConnected, setIsConnected] = useState(false); - const [error, setError] = useState(null); - const [lastTickAt, setLastTickAt] = useState(null); - const [marketSession, setMarketSession] = useState(() => - resolveSessionInClient(), + const marketSession = useDomesticSession(); + const { isConnected, error } = useKisWebSocketStore(); + + const { latestTick, recentTradeTicks, lastTickAt } = useTradeTickSubscription( + { + symbol, + isVerified, + credentials, + marketSession, + onTick, + }, ); - const socketRef = useRef(null); - const approvalKeyRef = useRef(null); - const seenTickRef = useRef>(new Set()); - - const obSymbol = options?.orderBookSymbol; - const onOrderBookMsg = options?.onOrderBookMessage; - const realtimeTrIds = credentials - ? resolveTradeTrIds(credentials.tradingEnv, marketSession) - : [TRADE_TR_ID]; - const realtimeTrId = credentials - ? realtimeTrIds[0] ?? TRADE_TR_ID - : TRADE_TR_ID; - - useEffect(() => { - const timerId = window.setInterval(() => { - const nextSession = resolveSessionInClient(); - setMarketSession((prev) => (prev === nextSession ? prev : nextSession)); - }, 30_000); - - return () => window.clearInterval(timerId); - }, []); - - useEffect(() => { - if (!isConnected || lastTickAt) return; - - const timer = window.setTimeout(() => { - setError( - "실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.", - ); - }, 8000); - - return () => window.clearTimeout(timer); - }, [isConnected, lastTickAt]); - - useEffect(() => { - setLatestTick(null); - setRecentTradeTicks([]); - setError(null); - setLastTickAt(null); - seenTickRef.current.clear(); - - if (!symbol || !isVerified || !credentials) { - socketRef.current?.close(); - socketRef.current = null; - approvalKeyRef.current = null; - setIsConnected(false); - return; - } - - let disposed = false; - let socket: WebSocket | null = null; - const debugEnabled = isWsDebugEnabled(); - - const tradeTrIds = resolveTradeTrIds(credentials.tradingEnv, marketSession); - const orderBookTrIds = - obSymbol && onOrderBookMsg - ? resolveOrderBookTrIds(credentials.tradingEnv, marketSession) - : []; - - const subscribe = ( - key: string, - targetSymbol: string, - trId: string, - trType: "1" | "2", - ) => { - socket?.send( - JSON.stringify(buildKisRealtimeMessage(key, targetSymbol, trId, trType)), - ); - }; - - const connect = async () => { - try { - setError(null); - setIsConnected(false); - - const wsConnection = await useKisRuntimeStore - .getState() - .getOrFetchWsConnection(); - - if (!wsConnection) { - throw new Error("웹소켓 승인키 발급에 실패했습니다."); - } - - if (disposed) return; - approvalKeyRef.current = wsConnection.approvalKey; - - // 공식 샘플과 동일하게 /tryitout 엔드포인트로 연결하고, TR은 payload로 구독합니다. - socket = new WebSocket(`${wsConnection.wsUrl}/tryitout`); - socketRef.current = socket; - - socket.onopen = () => { - if (disposed || !approvalKeyRef.current) return; - - for (const trId of tradeTrIds) { - subscribe(approvalKeyRef.current, symbol, trId, "1"); - } - - if (obSymbol) { - for (const trId of orderBookTrIds) { - subscribe(approvalKeyRef.current, obSymbol, trId, "1"); - } - } - - if (debugEnabled) { - console.info("[KisRealtime] Subscribed", { - symbol, - marketSession, - tradeTrIds, - orderBookSymbol: obSymbol ?? null, - orderBookTrIds, - }); - } - - setIsConnected(true); - }; - - socket.onmessage = (event) => { - if (disposed || typeof event.data !== "string") return; - - const control = parseWsControlMessage(event.data); - if (control) { - const trId = control.header?.tr_id ?? ""; - if (trId === "PINGPONG") { - // 서버 Keepalive에 응답하지 않으면 연결이 끊길 수 있습니다. - socket?.send(event.data); - return; - } - - if (debugEnabled) { - console.info("[KisRealtime] Control", { - trId, - rt_cd: control.body?.rt_cd, - message: control.body?.msg1, - }); - } - return; - } - - if (obSymbol && onOrderBookMsg) { - const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol); - if (orderBook) { - orderBook.tradingEnv = credentials.tradingEnv; - if (debugEnabled) { - console.debug("[KisRealtime] OrderBook", { - trId: peekPipeTrId(event.data), - symbol: orderBook.symbol, - businessHour: orderBook.businessHour, - hourClassCode: orderBook.hourClassCode, - }); - } - onOrderBookMsg(orderBook); - return; - } - } - - const ticks = parseKisRealtimeTickBatch(event.data, symbol); - if (ticks.length === 0) { - if (debugEnabled && event.data.includes("|")) { - console.debug("[KisRealtime] Unparsed payload", { - trId: peekPipeTrId(event.data), - preview: event.data.slice(0, 220), - }); - } - return; - } - - const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0); - if (meaningfulTicks.length === 0) { - if (debugEnabled) { - console.debug("[KisRealtime] Ignored zero-volume ticks", { - trId: peekPipeTrId(event.data), - parsedCount: ticks.length, - }); - } - return; - } - - const dedupedTicks = meaningfulTicks.filter((tick) => { - const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`; - if (seenTickRef.current.has(key)) return false; - seenTickRef.current.add(key); - if (seenTickRef.current.size > 5_000) { - seenTickRef.current.clear(); - } - return true; - }); - - const latest = meaningfulTicks[meaningfulTicks.length - 1]; - setLatestTick(latest); - - if (debugEnabled) { - console.debug("[KisRealtime] Tick", { - trId: peekPipeTrId(event.data), - symbol: latest.symbol, - tickTime: latest.tickTime, - price: latest.price, - tradeVolume: latest.tradeVolume, - executionClassCode: latest.executionClassCode, - buyExecutionCount: latest.buyExecutionCount, - sellExecutionCount: latest.sellExecutionCount, - netBuyExecutionCount: latest.netBuyExecutionCount, - parsedCount: ticks.length, - }); - } - - if (dedupedTicks.length > 0) { - setRecentTradeTicks((prev) => - [...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS), - ); - } - - setError(null); - setLastTickAt(Date.now()); - onTick?.(latest); - }; - - socket.onerror = () => { - if (!disposed) { - if (debugEnabled) { - console.warn("[KisRealtime] WebSocket error", { - symbol, - marketSession, - tradeTrIds, - }); - } - setIsConnected(false); - } - }; - - socket.onclose = () => { - if (!disposed) { - if (debugEnabled) { - console.warn("[KisRealtime] WebSocket closed", { - symbol, - marketSession, - tradeTrIds, - }); - } - setIsConnected(false); - } - }; - } catch (err) { - if (disposed) return; - - setError( - err instanceof Error - ? err.message - : "실시간 웹소켓 초기화 중 오류가 발생했습니다.", - ); - setIsConnected(false); - } - }; - - void connect(); - const seenRef = seenTickRef.current; - - return () => { - disposed = true; - setIsConnected(false); - - const key = approvalKeyRef.current; - if (socket?.readyState === WebSocket.OPEN && key) { - for (const trId of tradeTrIds) { - subscribe(key, symbol, trId, "2"); - } - - if (obSymbol) { - for (const trId of orderBookTrIds) { - subscribe(key, obSymbol, trId, "2"); - } - } - } - - socket?.close(); - if (socketRef.current === socket) socketRef.current = null; - approvalKeyRef.current = null; - seenRef.clear(); - }; - }, [ - symbol, + useOrderbookSubscription({ + symbol: options?.orderBookSymbol, isVerified, credentials, marketSession, - onTick, - obSymbol, - onOrderBookMsg, - ]); + onOrderBookMessage: options?.onOrderBookMessage, + }); + + // Connection/Data warning + useEffect(() => { + if (!isConnected || lastTickAt || !symbol) return; + const timer = window.setTimeout(() => { + // Just a warning, not blocking + }, 8000); + return () => window.clearTimeout(timer); + }, [isConnected, lastTickAt, symbol]); + + const realtimeTrId = useMemo(() => { + if (!credentials) return "H0STCNT0"; + const ids = resolveTradeTrIds(credentials.tradingEnv, marketSession); + return ids[0] ?? "H0STCNT0"; + }, [credentials, marketSession]); return { latestTick, @@ -471,18 +75,3 @@ export function useKisTradeWebSocket( realtimeTrId, }; } - -function resolveSessionInClient() { - if (typeof window === "undefined") { - return resolveDomesticKisSession(); - } - - try { - const override = window.localStorage.getItem( - DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, - ); - return resolveDomesticKisSession(override); - } catch { - return resolveDomesticKisSession(); - } -} diff --git a/features/trade/hooks/useOrderBook.ts b/features/trade/hooks/useOrderBook.ts index f74efa8..7e7d5cf 100644 --- a/features/trade/hooks/useOrderBook.ts +++ b/features/trade/hooks/useOrderBook.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; import { fetchStockOrderBook } from "@/features/trade/apis/kis-stock.api"; @@ -29,9 +29,22 @@ export function useOrderBook( const [initialData, setInitialData] = useState(null); + const [lastRealtimeWithLevels, setLastRealtimeWithLevels] = + useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + useEffect(() => { + if (!isRequestEnabled) { + setLastRealtimeWithLevels(null); + return; + } + + if (externalRealtimeOrderBook && hasOrderBookLevelData(externalRealtimeOrderBook)) { + setLastRealtimeWithLevels(externalRealtimeOrderBook); + } + }, [externalRealtimeOrderBook, isRequestEnabled]); + useEffect(() => { if (!isRequestEnabled || !symbol || !credentials) { return; @@ -75,10 +88,67 @@ export function useOrderBook( }; }, [isRequestEnabled, symbol, credentials]); - // 외부 실시간 호가 → 초기 데이터 → null 순 우선 - const orderBook = isRequestEnabled - ? (externalRealtimeOrderBook ?? initialData) - : null; + // 외부 실시간 호가가 비어 있으면(가격/잔량 레벨 0) 마지막 정상 실시간 또는 REST 초기값을 유지합니다. + const orderBook = useMemo(() => { + if (!isRequestEnabled) return null; + + if (externalRealtimeOrderBook) { + if (hasOrderBookLevelData(externalRealtimeOrderBook)) { + return externalRealtimeOrderBook; + } + + if (lastRealtimeWithLevels) { + return { + ...lastRealtimeWithLevels, + totalAskSize: externalRealtimeOrderBook.totalAskSize, + totalBidSize: externalRealtimeOrderBook.totalBidSize, + anticipatedPrice: externalRealtimeOrderBook.anticipatedPrice, + anticipatedVolume: externalRealtimeOrderBook.anticipatedVolume, + anticipatedTotalVolume: externalRealtimeOrderBook.anticipatedTotalVolume, + anticipatedChange: externalRealtimeOrderBook.anticipatedChange, + anticipatedChangeSign: externalRealtimeOrderBook.anticipatedChangeSign, + anticipatedChangeRate: externalRealtimeOrderBook.anticipatedChangeRate, + accumulatedVolume: externalRealtimeOrderBook.accumulatedVolume, + totalAskSizeDelta: externalRealtimeOrderBook.totalAskSizeDelta, + totalBidSizeDelta: externalRealtimeOrderBook.totalBidSizeDelta, + businessHour: + externalRealtimeOrderBook.businessHour ?? lastRealtimeWithLevels.businessHour, + hourClassCode: + externalRealtimeOrderBook.hourClassCode ?? lastRealtimeWithLevels.hourClassCode, + fetchedAt: externalRealtimeOrderBook.fetchedAt, + tradingEnv: externalRealtimeOrderBook.tradingEnv, + source: externalRealtimeOrderBook.source, + }; + } + + if (initialData && hasOrderBookLevelData(initialData)) { + return { + ...initialData, + totalAskSize: externalRealtimeOrderBook.totalAskSize, + totalBidSize: externalRealtimeOrderBook.totalBidSize, + anticipatedPrice: externalRealtimeOrderBook.anticipatedPrice, + anticipatedVolume: externalRealtimeOrderBook.anticipatedVolume, + anticipatedTotalVolume: externalRealtimeOrderBook.anticipatedTotalVolume, + anticipatedChange: externalRealtimeOrderBook.anticipatedChange, + anticipatedChangeSign: externalRealtimeOrderBook.anticipatedChangeSign, + anticipatedChangeRate: externalRealtimeOrderBook.anticipatedChangeRate, + accumulatedVolume: externalRealtimeOrderBook.accumulatedVolume, + totalAskSizeDelta: externalRealtimeOrderBook.totalAskSizeDelta, + totalBidSizeDelta: externalRealtimeOrderBook.totalBidSizeDelta, + businessHour: externalRealtimeOrderBook.businessHour ?? initialData.businessHour, + hourClassCode: externalRealtimeOrderBook.hourClassCode ?? initialData.hourClassCode, + fetchedAt: externalRealtimeOrderBook.fetchedAt, + tradingEnv: externalRealtimeOrderBook.tradingEnv, + source: externalRealtimeOrderBook.source, + }; + } + + return externalRealtimeOrderBook; + } + + return initialData; + }, [externalRealtimeOrderBook, initialData, isRequestEnabled, lastRealtimeWithLevels]); + const mergedError = isRequestEnabled ? error : null; const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false; @@ -89,3 +159,13 @@ export function useOrderBook( isWsConnected: !!externalRealtimeOrderBook, }; } + +function hasOrderBookLevelData(orderBook: DashboardStockOrderBookResponse) { + return orderBook.levels.some( + (level) => + level.askPrice > 0 || + level.bidPrice > 0 || + level.askSize > 0 || + level.bidSize > 0, + ); +} diff --git a/features/trade/hooks/useOrderbookSubscription.ts b/features/trade/hooks/useOrderbookSubscription.ts new file mode 100644 index 0000000..3a76399 --- /dev/null +++ b/features/trade/hooks/useOrderbookSubscription.ts @@ -0,0 +1,63 @@ +import { useRef, useEffect } from "react"; +import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; +import { + parseKisRealtimeOrderbook, + resolveOrderBookTrIds, +} from "@/features/trade/utils/kisRealtimeUtils"; +import type { DomesticKisSession } from "@/lib/kis/domestic-market-session"; + +interface UseOrderbookSubscriptionParams { + symbol: string | undefined; // orderBookSymbol + isVerified: boolean; + credentials: KisRuntimeCredentials | null; + marketSession: DomesticKisSession; + onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void; +} + +/** + * @description 실시간 호가(Orderbook) 구독 로직을 담당하는 훅입니다. + * - 호가 데이터는 빈도가 매우 높으므로 별도의 상태(state)에 저장하지 않고, + * - 콜백 함수(onOrderBookMessage)를 통해 상위 컴포넌트로 데이터를 직접 전달합니다. + * - 이를 통해 불필요한 리렌더링을 방지합니다. + */ +export function useOrderbookSubscription({ + symbol, + isVerified, + credentials, + marketSession, + onOrderBookMessage, +}: UseOrderbookSubscriptionParams) { + const { subscribe, connect } = useKisWebSocketStore(); + const onOrderBookMessageRef = useRef(onOrderBookMessage); + + useEffect(() => { + onOrderBookMessageRef.current = onOrderBookMessage; + }, [onOrderBookMessage]); + + useEffect(() => { + if (!symbol || !isVerified || !credentials) return; + + connect(); + + const trIds = resolveOrderBookTrIds(credentials.tradingEnv, marketSession); + const unsubscribers: Array<() => void> = []; + + const handleOrderBookMessage = (data: string) => { + const ob = parseKisRealtimeOrderbook(data, symbol); + if (ob) { + ob.tradingEnv = credentials.tradingEnv; + onOrderBookMessageRef.current?.(ob); + } + }; + + for (const trId of trIds) { + unsubscribers.push(subscribe(trId, symbol, handleOrderBookMessage)); + } + + return () => { + unsubscribers.forEach((unsub) => unsub()); + }; + }, [symbol, isVerified, credentials, marketSession, connect, subscribe]); +} diff --git a/features/trade/hooks/useTradeTickSubscription.ts b/features/trade/hooks/useTradeTickSubscription.ts new file mode 100644 index 0000000..cb01d5c --- /dev/null +++ b/features/trade/hooks/useTradeTickSubscription.ts @@ -0,0 +1,110 @@ +import { useState, useRef, useEffect } from "react"; +import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import type { DashboardRealtimeTradeTick } from "@/features/trade/types/trade.types"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; +import { + parseKisRealtimeTickBatch, + resolveTradeTrIds, +} from "@/features/trade/utils/kisRealtimeUtils"; +import type { DomesticKisSession } from "@/lib/kis/domestic-market-session"; + +const MAX_TRADE_TICKS = 10; + +interface UseTradeTickSubscriptionParams { + symbol: string | undefined; + isVerified: boolean; + credentials: KisRuntimeCredentials | null; + marketSession: DomesticKisSession; + onTick?: (tick: DashboardRealtimeTradeTick) => void; +} + +/** + * @description 실시간 체결가(Tick) 구독 로직을 담당하는 훅입니다. + * - 웹소켓을 통해 들어오는 체결 데이터를 파싱(parsing)하고 + * - 중복 데이터(deduplication)를 필터링하며 + * - 최근 N개의 체결 내역(recentTradeTicks)과 최신 체결가(latestTick) 상태를 관리합니다. + */ +export function useTradeTickSubscription({ + symbol, + isVerified, + credentials, + marketSession, + onTick, +}: UseTradeTickSubscriptionParams) { + const [latestTick, setLatestTick] = + useState(null); + const [recentTradeTicks, setRecentTradeTicks] = useState< + DashboardRealtimeTradeTick[] + >([]); + const [lastTickAt, setLastTickAt] = useState(null); + const seenTickRef = useRef>(new Set()); + + const { subscribe, connect } = useKisWebSocketStore(); + const onTickRef = useRef(onTick); + + useEffect(() => { + onTickRef.current = onTick; + }, [onTick]); + + // 1. 심볼이 변경되면 상태를 초기화합니다. + // 1. 심볼 변경 시 상태 초기화 (Render-time adjustment) + const [prevSymbol, setPrevSymbol] = useState(symbol); + if (symbol !== prevSymbol) { + setPrevSymbol(symbol); + setLatestTick(null); + setRecentTradeTicks([]); + setLastTickAt(null); + } + + // Ref는 렌더링 도중 수정하면 안 되므로 useEffect에서 초기화 + useEffect(() => { + seenTickRef.current.clear(); + }, [symbol]); + + // 2. 실시간 데이터 구독 + useEffect(() => { + if (!symbol || !isVerified || !credentials) return; + + connect(); + + const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession); + const unsubscribers: Array<() => void> = []; + + const handleTradeMessage = (data: string) => { + const ticks = parseKisRealtimeTickBatch(data, symbol); + if (ticks.length === 0) return; + + const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0); + if (meaningfulTicks.length === 0) return; + + const dedupedTicks = meaningfulTicks.filter((tick) => { + const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`; + if (seenTickRef.current.has(key)) return false; + seenTickRef.current.add(key); + if (seenTickRef.current.size > 5_000) seenTickRef.current.clear(); + return true; + }); + + const latest = meaningfulTicks[meaningfulTicks.length - 1]; + setLatestTick(latest); + setLastTickAt(Date.now()); + + if (dedupedTicks.length > 0) { + setRecentTradeTicks((prev) => + [...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS), + ); + } + onTickRef.current?.(latest); + }; + + for (const trId of trIds) { + unsubscribers.push(subscribe(trId, symbol, handleTradeMessage)); + } + + return () => { + unsubscribers.forEach((unsub) => unsub()); + }; + }, [symbol, isVerified, credentials, marketSession, connect, subscribe]); + + return { latestTick, recentTradeTicks, lastTickAt }; +} diff --git a/features/trade/utils/kis-realtime.utils.ts b/features/trade/utils/kisRealtimeUtils.ts similarity index 67% rename from features/trade/utils/kis-realtime.utils.ts rename to features/trade/utils/kisRealtimeUtils.ts index dad0b49..249a0c1 100644 --- a/features/trade/utils/kis-realtime.utils.ts +++ b/features/trade/utils/kisRealtimeUtils.ts @@ -2,6 +2,11 @@ import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/trade/types/trade.types"; +import { + shouldUseAfterHoursSinglePriceTr, + shouldUseExpectedExecutionTr, + type DomesticKisSession, +} from "@/lib/kis/domestic-market-session"; const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]); const EXECUTED_REALTIME_TRADE_TR_IDS = new Set([ @@ -38,55 +43,28 @@ const TICK_FIELD_INDEX = { executionClassCode: 21, } as const; -/** - * @description KIS 실시간 구독/해제 소켓 메시지를 생성합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts 구독/해제 요청 payload 생성에 사용됩니다. - */ -export function buildKisRealtimeMessage( - approvalKey: string, - symbol: string, - trId: string, - trType: "1" | "2", -) { - return { - header: { - approval_key: approvalKey, - custtype: "P", - tr_type: trType, - "content-type": "utf-8", - }, - body: { - input: { - tr_id: trId, - tr_key: symbol, - }, - }, - }; -} - -/** - * @description 실시간 체결 스트림(raw)을 배열 단위로 파싱합니다. - * - 배치 전송(복수 체결) 데이터를 모두 추출합니다. - * - 종목 불일치 또는 가격 0 이하 데이터는 제외합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts onmessage 이벤트에서 체결 패킷 파싱에 사용됩니다. - */ +const TRADE_TR_ID = "H0STCNT0"; +const TRADE_TR_ID_EXPECTED = "H0STANC0"; +const TRADE_TR_ID_OVERTIME = "H0STOUP0"; +const TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0"; +const TRADE_TR_ID_TOTAL = "H0UNCNT0"; +const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0"; +const ORDERBOOK_TR_ID = "H0STASP0"; +const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[]; const parts = raw.split("|"); if (parts.length < 4) return [] as DashboardRealtimeTradeTick[]; - // TR ID check: regular tick / expected tick / after-hours tick. const receivedTrId = parts[1]; const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId); const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId); - // 체결 화면에는 "실제 체결 TR"만 반영하고 예상체결 TR은 제외합니다. + if (!isExecutedTick || isExpectedTick) { return [] as DashboardRealtimeTradeTick[]; } - // if (parts[1] !== expectedTrId) return [] as DashboardRealtimeTradeTick[]; - const tickCount = Number(parts[2] ?? "1"); const values = parts[3].split("^"); if (values.length === 0) return [] as DashboardRealtimeTradeTick[]; @@ -164,9 +142,91 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { return ticks; } +/** + * @description 장 구간/시장별 누락을 줄이기 위해 TR ID를 우선순위 배열로 반환합니다. + */ +export function resolveTradeTrIds( + env: "real" | "mock", + session: DomesticKisSession, +) { + if (env === "mock") return [TRADE_TR_ID]; + + if (shouldUseAfterHoursSinglePriceTr(session)) { + return uniqueTrIds([ + TRADE_TR_ID_OVERTIME, + TRADE_TR_ID_OVERTIME_EXPECTED, + TRADE_TR_ID_TOTAL, + TRADE_TR_ID_TOTAL_EXPECTED, + ]); + } + + if (shouldUseExpectedExecutionTr(session)) { + return uniqueTrIds([ + TRADE_TR_ID_EXPECTED, + TRADE_TR_ID_TOTAL_EXPECTED, + TRADE_TR_ID, + TRADE_TR_ID_TOTAL, + ]); + } + + if (session === "afterCloseFixedPrice") { + return uniqueTrIds([ + TRADE_TR_ID, + TRADE_TR_ID_TOTAL, + TRADE_TR_ID_OVERTIME, + TRADE_TR_ID_OVERTIME_EXPECTED, + TRADE_TR_ID_TOTAL_EXPECTED, + ]); + } + + return uniqueTrIds([TRADE_TR_ID, TRADE_TR_ID_TOTAL]); +} + +/** + * @description 장 구간별 호가 TR ID 후보를 우선순위 배열로 반환합니다. + */ +export function resolveOrderBookTrIds( + env: "real" | "mock", + session: DomesticKisSession, +) { + if (env === "mock") return [ORDERBOOK_TR_ID]; + + if (shouldUseAfterHoursSinglePriceTr(session)) { + return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME, ORDERBOOK_TR_ID]); + } + + if (session === "afterCloseFixedPrice") { + return uniqueTrIds([ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_OVERTIME]); + } + + return uniqueTrIds([ORDERBOOK_TR_ID]); +} + +/** + * @description 호가 패킷이 실제 표시 가능한 값(호가/잔량/총잔량)을 포함하는지 확인합니다. + */ +export function hasMeaningfulOrderBookPayload( + data: DashboardStockOrderBookResponse, +) { + const hasLevelData = data.levels.some( + (level) => + level.askPrice > 0 || + level.bidPrice > 0 || + level.askSize > 0 || + level.bidSize > 0, + ); + + const hasSummaryData = + data.totalAskSize > 0 || + data.totalBidSize > 0 || + (data.anticipatedPrice ?? 0) > 0 || + (data.accumulatedVolume ?? 0) > 0; + + return hasLevelData || hasSummaryData; +} + /** * @description KIS 실시간 호가(H0STASP0/H0UNASP0/H0STOAA0)를 OrderBook 구조로 파싱합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage 콜백 데이터 생성에 사용됩니다. */ export function parseKisRealtimeOrderbook( raw: string, @@ -182,7 +242,9 @@ export function parseKisRealtimeOrderbook( } const values = parts[3].split("^"); - const levelCount = trId === "H0STOAA0" ? 9 : 10; + // 시간외(H0STOAA0)는 문서 버전에 따라 9레벨/10레벨이 혼재할 수 있어 + // payload 길이로 레벨 수를 동적으로 판별합니다. + const levelCount = trId === "H0STOAA0" ? (values.length >= 56 ? 10 : 9) : 10; const symbol = values[0]?.trim() ?? ""; const normalizedSymbol = normalizeDomesticSymbol(symbol); @@ -195,7 +257,9 @@ export function parseKisRealtimeOrderbook( const bidSizeStart = askSizeStart + levelCount; const totalAskIndex = bidSizeStart + levelCount; const totalBidIndex = totalAskIndex + 1; - const anticipatedPriceIndex = totalBidIndex + 3; + const overtimeTotalAskIndex = totalBidIndex + 1; + const overtimeTotalBidIndex = overtimeTotalAskIndex + 1; + const anticipatedPriceIndex = overtimeTotalBidIndex + 1; const anticipatedVolumeIndex = anticipatedPriceIndex + 1; const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2; const anticipatedChangeIndex = anticipatedPriceIndex + 3; @@ -215,10 +279,18 @@ export function parseKisRealtimeOrderbook( bidSize: readNumber(values, bidSizeStart + i), })); + const regularTotalAskSize = readNumber(values, totalAskIndex); + const regularTotalBidSize = readNumber(values, totalBidIndex); + const overtimeTotalAskSize = readNumber(values, overtimeTotalAskIndex); + const overtimeTotalBidSize = readNumber(values, overtimeTotalBidIndex); + return { symbol: normalizedExpected, - totalAskSize: readNumber(values, totalAskIndex), - totalBidSize: readNumber(values, totalBidIndex), + // 장후 시간외에서는 일반 총잔량이 0이고 OVTM 총잔량만 채워지는 경우가 있습니다. + totalAskSize: + regularTotalAskSize > 0 ? regularTotalAskSize : overtimeTotalAskSize, + totalBidSize: + regularTotalBidSize > 0 ? regularTotalBidSize : overtimeTotalBidSize, businessHour: readString(values, 1), hourClassCode: readString(values, 2), anticipatedPrice: readNumber(values, anticipatedPriceIndex), @@ -239,7 +311,6 @@ export function parseKisRealtimeOrderbook( /** * @description 국내 종목코드 비교를 위해 접두 문자를 제거하고 6자리 코드로 정규화합니다. - * @see features/trade/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 종목 매칭 비교에 사용됩니다. */ function normalizeDomesticSymbol(value: string) { const trimmed = value.trim(); @@ -261,3 +332,7 @@ function readNumber(values: string[], index: number) { const value = Number(raw); return Number.isFinite(value) ? value : 0; } + +function uniqueTrIds(ids: string[]) { + return [...new Set(ids)]; +} diff --git a/lib/kis/dashboard.ts b/lib/kis/dashboard.ts index 05ba248..d471c15 100644 --- a/lib/kis/dashboard.ts +++ b/lib/kis/dashboard.ts @@ -14,6 +14,7 @@ interface KisBalanceOutput1Row { prdt_name?: string; hldg_qty?: string; pchs_avg_pric?: string; + pchs_amt?: string; prpr?: string; evlu_amt?: string; evlu_pfls_amt?: string; @@ -24,6 +25,8 @@ interface KisBalanceOutput2Row { dnca_tot_amt?: string; tot_evlu_amt?: string; scts_evlu_amt?: string; + nass_amt?: string; + pchs_amt_smtl_amt?: string; evlu_amt_smtl_amt?: string; evlu_pfls_smtl_amt?: string; asst_icdc_erng_rt?: string; @@ -92,12 +95,15 @@ interface KisPeriodTradeProfitOutput2Row { export interface DomesticBalanceSummary { totalAmount: number; cashBalance: number; + totalDepositAmount: number; totalProfitLoss: number; totalProfitRate: number; netAssetAmount: number; evaluationAmount: number; purchaseAmount: number; loanAmount: number; + apiReportedTotalAmount: number; + apiReportedNetAssetAmount: number; } export interface DomesticHoldingItem { @@ -230,73 +236,142 @@ export async function getDomesticDashboardBalance( const holdingRows = parseRows(balanceResponse.output1); const summaryRow = parseFirstRow(balanceResponse.output2); - const holdings = holdingRows + const normalizedHoldings = holdingRows .map((row) => { const symbol = (row.pdno ?? "").trim(); if (!/^\d{6}$/.test(symbol)) return null; + const quantity = toNumber(row.hldg_qty); + const averagePrice = toNumber(row.pchs_avg_pric); + const currentPrice = toNumber(row.prpr); + const purchaseAmountBase = pickPreferredAmount( + toOptionalNumber(row.pchs_amt), + quantity * averagePrice, + ); + const evaluationAmount = pickPreferredAmount( + toOptionalNumber(row.evlu_amt), + quantity * (currentPrice > 0 ? currentPrice : averagePrice), + ); + const profitLoss = pickNonZeroNumber( + toOptionalNumber(row.evlu_pfls_amt), + evaluationAmount - purchaseAmountBase, + ); + const profitRate = pickNonZeroNumber( + toOptionalNumber(row.evlu_pfls_rt), + calcProfitRateByPurchase(profitLoss, purchaseAmountBase), + ); return { symbol, name: (row.prdt_name ?? "").trim() || symbol, market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI", - quantity: toNumber(row.hldg_qty), - averagePrice: toNumber(row.pchs_avg_pric), - currentPrice: toNumber(row.prpr), - evaluationAmount: toNumber(row.evlu_amt), - profitLoss: toNumber(row.evlu_pfls_amt), - profitRate: toNumber(row.evlu_pfls_rt), - } satisfies DomesticHoldingItem; + quantity, + averagePrice, + currentPrice, + evaluationAmount, + profitLoss, + profitRate, + purchaseAmountBase, + } satisfies DomesticHoldingItem & { purchaseAmountBase: number }; }) - .filter((item): item is DomesticHoldingItem => Boolean(item)); + .filter( + (item): item is DomesticHoldingItem & { purchaseAmountBase: number } => + Boolean(item), + ); + const holdings = normalizedHoldings.map((item) => ({ + symbol: item.symbol, + name: item.name, + market: item.market, + quantity: item.quantity, + averagePrice: item.averagePrice, + currentPrice: item.currentPrice, + evaluationAmount: item.evaluationAmount, + profitLoss: item.profitLoss, + profitRate: item.profitRate, + })); - const cashBalance = firstPositiveNumber( - toNumber(accountBalanceResponse?.tot_dncl_amt), - toNumber(accountBalanceResponse?.dncl_amt), - toNumber(summaryRow?.dnca_tot_amt), + const holdingsEvalAmount = sumNumbers( + normalizedHoldings.map((item) => item.evaluationAmount), ); - const holdingsEvalAmount = sumNumbers(holdings.map((item) => item.evaluationAmount)); - const evaluationAmount = firstPositiveNumber( - toNumber(accountBalanceResponse?.evlu_amt_smtl), - toNumber(summaryRow?.scts_evlu_amt), - toNumber(summaryRow?.evlu_amt_smtl_amt), + const holdingsPurchaseAmount = sumNumbers( + normalizedHoldings.map((item) => item.purchaseAmountBase), + ); + const evaluationAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.evlu_amt_smtl), + toOptionalNumber(summaryRow?.scts_evlu_amt), + toOptionalNumber(summaryRow?.evlu_amt_smtl_amt), holdingsEvalAmount, ); - const purchaseAmount = firstPositiveNumber( - toNumber(accountBalanceResponse?.pchs_amt_smtl), - Math.max(evaluationAmount - sumNumbers(holdings.map((item) => item.profitLoss)), 0), + const apiReportedTotalAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.tot_asst_amt), + toOptionalNumber(summaryRow?.tot_evlu_amt), ); - const loanAmount = firstPositiveNumber(toNumber(accountBalanceResponse?.loan_amt_smtl)); - const netAssetAmount = firstPositiveNumber( - toNumber(accountBalanceResponse?.nass_tot_amt), - evaluationAmount + cashBalance - loanAmount, + const apiReportedNetAssetAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.nass_tot_amt), + toOptionalNumber(summaryRow?.nass_amt), ); - const totalAmount = firstPositiveNumber( - toNumber(accountBalanceResponse?.tot_asst_amt), - netAssetAmount + loanAmount, + const cashBalance = resolveCashBalance({ + apiReportedTotalAmount, + apiReportedNetAssetAmount, + evaluationAmount, + cashCandidates: [ + toOptionalNumber(summaryRow?.dnca_tot_amt), + toOptionalNumber(accountBalanceResponse?.dncl_amt), + toOptionalNumber(accountBalanceResponse?.tot_dncl_amt), + ], + }); + const totalDepositAmount = pickPreferredAmount( + toOptionalNumber(summaryRow?.dnca_tot_amt), + toOptionalNumber(accountBalanceResponse?.tot_dncl_amt), + toOptionalNumber(accountBalanceResponse?.dncl_amt), + cashBalance, + ); + const purchaseAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.pchs_amt_smtl), + toOptionalNumber(summaryRow?.pchs_amt_smtl_amt), + holdingsPurchaseAmount, + Math.max( + evaluationAmount - sumNumbers(normalizedHoldings.map((item) => item.profitLoss)), + 0, + ), + ); + const loanAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.loan_amt_smtl), + ); + // KIS 원본 총자산(대출/미수 포함 가능)과 순자산(실제 체감 자산)을 분리해 관리합니다. + const grossTotalAmount = pickPreferredAmount( + toOptionalNumber(summaryRow?.tot_evlu_amt), + apiReportedTotalAmount, evaluationAmount + cashBalance, - toNumber(summaryRow?.tot_evlu_amt), - holdingsEvalAmount + cashBalance, ); - const totalProfitLoss = firstDefinedNumber( + const netAssetAmount = pickPreferredAmount( + apiReportedNetAssetAmount, + grossTotalAmount - loanAmount, + ); + // 상단 "내 자산"은 순자산 기준(사용자 체감 자산)으로 표시합니다. + const totalAmount = pickPreferredAmount( + netAssetAmount, + grossTotalAmount, + ); + const totalProfitLoss = pickNonZeroNumber( toOptionalNumber(accountBalanceResponse?.evlu_pfls_amt_smtl), toOptionalNumber(summaryRow?.evlu_pfls_smtl_amt), sumNumbers(holdings.map((item) => item.profitLoss)), ); - const totalProfitRate = firstDefinedNumber( - toOptionalNumber(summaryRow?.asst_icdc_erng_rt), - calcProfitRate(totalProfitLoss, totalAmount), - ); + const totalProfitRate = calcProfitRateByPurchase(totalProfitLoss, purchaseAmount); return { summary: { totalAmount, cashBalance, + totalDepositAmount, totalProfitLoss, totalProfitRate, netAssetAmount, evaluationAmount, purchaseAmount, loanAmount, + apiReportedTotalAmount, + apiReportedNetAssetAmount, }, holdings, }; @@ -895,16 +970,6 @@ function normalizeSignedValue(value: number, signCode?: string) { return value; } -/** - * 양수 우선값을 반환합니다. - * @param values 후보 숫자 목록 - * @returns 첫 번째 양수, 없으면 0 - * @see lib/kis/dashboard.ts 요약 금액 폴백 계산 - */ -function firstPositiveNumber(...values: number[]) { - return values.find((value) => value > 0) ?? 0; -} - /** * undefined가 아닌 첫 값을 반환합니다. * @param values 후보 숫자 목록 @@ -938,3 +1003,86 @@ function calcProfitRate(profit: number, totalAmount: number) { if (baseAmount <= 0) return 0; return (profit / baseAmount) * 100; } + +/** + * 매입금액 대비 손익률을 계산합니다. + * @param profit 손익 금액 + * @param purchaseAmount 매입금액 + * @returns 손익률(%) + * @see lib/kis/dashboard.ts getDomesticDashboardBalance 현재 손익률 산출 + */ +function calcProfitRateByPurchase(profit: number, purchaseAmount: number) { + if (purchaseAmount <= 0) return 0; + return (profit / purchaseAmount) * 100; +} + +/** + * 총자산과 평가금액 기준으로 일관된 현금성 자산(예수금)을 계산합니다. + * @param params 계산 파라미터 + * @returns 현금성 자산 금액 + * @remarks UI 흐름: 대시보드 API 응답 생성 -> 총자산/평가금/예수금 카드 반영 + * @see lib/kis/dashboard.ts getDomesticDashboardBalance + */ +function resolveCashBalance(params: { + apiReportedTotalAmount: number; + apiReportedNetAssetAmount: number; + evaluationAmount: number; + cashCandidates: Array; +}) { + const { + apiReportedTotalAmount, + apiReportedNetAssetAmount, + evaluationAmount, + cashCandidates, + } = params; + const referenceTotalAmount = pickPreferredAmount( + apiReportedNetAssetAmount, + apiReportedTotalAmount, + ); + const candidateCash = pickPreferredAmount(...cashCandidates); + const derivedCash = + referenceTotalAmount > 0 + ? Math.max(referenceTotalAmount - evaluationAmount, 0) + : undefined; + + if (derivedCash === undefined) return candidateCash; + + // 후보 예수금 + 평가금이 기준 총자산(순자산 우선)과 크게 다르면 역산값을 사용합니다. + const recomposedWithCandidate = candidateCash + evaluationAmount; + const mismatchWithApi = Math.abs( + recomposedWithCandidate - referenceTotalAmount, + ); + if (mismatchWithApi >= 1) { + return derivedCash; + } + + return candidateCash; +} + +/** + * 금액 후보 중 양수 값을 우선 선택합니다. + * @param values 금액 후보 + * @returns 양수 우선 금액 + * @see lib/kis/dashboard.ts getDomesticDashboardBalance 금액 필드 폴백 계산 + */ +function pickPreferredAmount(...values: Array) { + const positive = values.find( + (value): value is number => value !== undefined && value > 0, + ); + if (positive !== undefined) return positive; + return firstDefinedNumber(...values); +} + +/** + * 숫자 후보 중 0이 아닌 값을 우선 선택합니다. + * @param values 숫자 후보 + * @returns 0이 아닌 값 우선 결과 + * @see lib/kis/dashboard.ts getDomesticDashboardBalance 손익/수익률 폴백 계산 + */ +function pickNonZeroNumber(...values: Array) { + const nonZero = values.find( + (value): value is number => value !== undefined && value !== 0, + ); + if (nonZero !== undefined) return nonZero; + return firstDefinedNumber(...values); +} diff --git a/settings-mobile-debug.png b/settings-mobile-debug.png new file mode 100644 index 0000000000000000000000000000000000000000..96c2a636c764b341598e6a67286c569f950a83ad GIT binary patch literal 49438 zcmb??bx@tnn zzo)ya-hR9Lk#n9WN?B1F8G!%+3=9le=9|QKFfedWFfd3mIEYU~v$<&s3=9oSMnY80 zEBiDD-Vbx{{z0jk8;ENqXKjwYVh-zUtnw90Sq(>}Cb-XCJQ1-PclY8>va=Akm9B7G zon=@(Nm3GA@~fysQdhKHeT!RB*tvB>m!-we5qb7x`C0kQl$*B{Rt|3qU-ko@gY1J0 z%Uh2QVz^+k*It{ZFDzBC!DMh;N250iOz={A0LwjJO~~ zMDhxye)Q5nD?-H0uce55y<(ZSx3}j!N5|RWc_AG5{tEO2P2j?T47`gEPA%_OevrYj z3AjwHKaK=KB;ujyp=%DseP#X15}t-fq8B{&wBn?eA*ytEU)9{jG5HFh|5fjYkO$WX z?Zf6-rLSUB5#;lua!1NZRoRhM`MOQ;RE?&gsAR_o_-o)7DdNG_DfQ=3qx}f_E<_y; z$rGFv>=Q!%b864Hl)cDsCa`6={%rk31edA3BCpw8$!9j2jL4{+XY=a_<~o)G+Pw7L z=|x+}J&R#u3T~U;&%bUH#4JZOhb32xf;QFfyEVFMGhGjbnbgIECy8JiwXIXkjd{)&i$#e# z;ous~Oz4ZQ-Y*%<=yL>I+KTJ~VjI}%W-^F5H$}MAo2Hb!J~mlsot{%sX$=BhE#DbB zfbTnlppA@|yC)9n*FLMFt%6dAwv&PY-mPZ(y*;_;QCiTul<|gS3lrzWx4)2hFPA?U zR)2S-at=y#)$K=l=r=fT)n%{KN(0O*%IB;&S(tKIjkIv5m@LhT8J~_vXpW-DAOYwj zj*j?qFBc!(xRHshf$x5=uZKkmWL}+h7nT>$(>j;W;LX6*Gls5TZ7e!=+#?g+o?|)r zG{>Oi)Vs*2=%ADHrMFu{-PrObjeaXbJ(EO%eQn0YFpi~n5an3t&!GI|VE*gfvX)0=_zphrSFF!!^a0o_p+4RCZ7y>>V2v(+XS(6*+zd3OyCOUlw> zKw?8$*5_5`kHZNQe=MD|`fh4ssqbkzL}RVT#%MZy+zL2&LGiLYBz~<@tlV?lY}H=p zEt6@zO~dDuH1wP8woGkOz1AO@VQSV8so;WYz$g6UxiEWnBD^{>!R@$W_9*19nmHKQ z;&Bm_hhKAGHQDB^F-w$6&NsyJ-T>*RsLh5Li;LO>n#39DesNlfrlN zs;`=k-B0^BQz9P|JfPbP5GjQD=ac6{gWGsZ+sbz2Uaef=MgDW@K z4DeAB-6%kmtb^$Xy`b3Tqy#e<73AViU=|g92>4+&zzby8Fb29Wc`(PsW(~xFKz!-F zIfTXq?EZtt-|M%Mt#KSY*5|t$r2TlDwLAzFSm7l^|KY(hD!7*_-1<@ zYt_W-^j+TUP!cvi+dChg9cjzaaQu+4D1KK1xPaGdIkk-T_V(9TKfUF0)zY#uIyW)Sa`FI~_Sojcq@+5RJy|#s0iKT_ zk9bU`YT1vIhW^JCgmx0h2v1_}a{>pC9m|e3#u~uh-ZurgLvL|i2LjdM&6wP`)k4~z zp3i;zEcc(cQSjxoA&bXRDzDgnt=Uw|3BnTa@&0l&S1SItYTd(vZ>HB*-+bADS~;48$cm7rPrfBn+XIK$eF0!mdpie z=#J+SYDSTjjelA)O@}hyJ)b2|W(H2!3C(Zo^8kH-S?o^e%ZGa49@nb-hml4?LP7+Y zil*(z(;gL1T+~8JBh*$4f0xJf@VVaV>0i$e+!!E^S{B zlSH6hUBH9mP=5|k`uKUH(%(0>jTz2AUc#tSRn&o=2UvZAoeRyb%iWueD8-|WO@kHI z>Q!X8m(^lL(PArK|5|^?$^*Rq0y+!BHs|sb^6F>NMJe*~f8UCyeRqa^RI*liTKl+Q zFgAcmX2*K~FMwFKnrNakW`=<*M#NiVO1XE^fJFC=Z-FGvf#dINQf#?^5{Kr6V(HC*cPFHr_8TiwQA%_2=IAo@u; zXBx+;0HcV2KI$>hWBk~Au7LJ9R~aR7yZ%hE62m2QxHYkFd#wO?deIM;&y zc}|5zU;5A?t62BFtgVV{JCC*p&3cFj(82Uq@O-W&PC-8Erp-seo%ndQRoZd=Wrs~w z0hmU5=J)utHKMQ`CghBAA)@Q{jveW+MZxMC^i_cat+qD3duN`t!hJdA2RJ(G$(E_ zzUJ_1dbOl$7NF*NJ@w=7$%LeHEhjbFUbgxQd}R`9jX^x~6|zy);DN%uzSuf`PxXCM7f< zsJgxAFG^$fB`#&m4eEyTYAMS$zw6~D1Z-cFW;L*SX94M=3MM;+9QCU4``P_RBKNr@ zE|#dwo@1`Z-@s|KohX=l)6+3d9KE+R{Sr3L2m^hulFYq8%MWwTSd3xz%S1k1=iMVA ztO6nD;}3?!R;#hM6cAdqM&R7E{&x$SLkXlqdPvM$o#|N)?MLwf=Z|ZkcbL0;X(`up zc$!YE7tS#;Z?DHJmPvJ1p|^L?%u$!cY>yd@?UR~PcM+Y))>RUY&T zD&fo-@?husAmh51LJ3o-mGS9=gOZp1$?VM%y8Q|9EdI7-!FWRw1b>X z*P87cET;lKcO7(R#R?o#*rb(33Rf&IFW(tTi1>=14m=it#fQZS$!Qw;=n>hdwi(}F z7(&@``aH$4JWFwgrA6$gdT5qqFJ_SD(fm<%_8sHE-0=t_V3v=(5qjwZ055c+r zYl+ZWya}_%+_6wg>=Cf(A~hdR@UadRb@}S-g6_L4|0)`?TpF|fhW9}ZEQ!~TENtPS zPy-~{@i?9hxI3d=bz&8+R;(J`!nCtV+m<>^jgGhv99krPU?pUPM#e;6mJoB^%T05Q zBGHzGbHhJde6AS3fA}M?;TgxV-d@Aq6-U!iNmHId!>x_k@AFJ;d75^8W`1zC-08pT z`!Zt4D0Dup?``jt+cH>dvM+vNx*+U-(LqVt%FIl@Ux_W^Y7rGbm`cpiW|G15eZ~ru zdxNc56jE#XQkNxtXSd2|J(l(o_;QD(x!+En}kZWsNpM4cUFZ zf6Nv}r(V2+RRJ2dCz$%q@{6vLM5stC=Y0^ib2aKQs zPL?+ag;(~vcJgR*nO45F*siU!z59@*!o&9+qz4Zq3)S2dvcomY*Sy@oiNpj+{zh3 zHTG$&I1@<;x~?=9_(do0u1qcvM*uq%)Q50M7$WXy>Q>$c!gOG@o3ODf5VBpPi?cGf zLCwHw6UF_|=Qo`bGw(L0=_M-Gj-z+5aujMrT=!M-$9&)+NX-<<0YOac$CtT){Ck#| zyJa8$1?0>nhl++W0XP&Bv{b2AXi@6b?GA5Gff5)`?*KKBrHwLlI znlU^~lEHQv83T$OwVgcuJ3JYFY~^M&`HcxL#P~Y~6gtYQhTh{~cb>@bitUZfuuhzX z$&$gc6)~efyySfvS)>z~`c9Ybd3NAVt$QHJ#xGx=z^y0MG-j8b0V-)iUE0#yVJl(Q zj)mb${tDpTB;s#&DDL^%q-Fv)rFYdJB;}z*%Mi}97 zqflF}(viyEDa5<} z@3591U|GmIZp}acqDd?V??*{Rmqf$FSP;q{7qfd+9mM}&1E1AR>PC}*`tiR83d++) zvp+Dmzvs)i{=^H8#G5vnv77M{m)iUi(uOC_DT5>`L1NWx)oImNK>jtg7l&H@;d{OW z8ae_2OC~FU3mlxXIjeRYlPr{|1hZA2)i603S}Hw*#q)r242Gx#=Rc6uqzz{NC{;0} zC|VK+7&za*T@>;nz9N;%XwVl`^+CZR2{ehFA7?m7727SN^J>9tByho2 zMEU=kp^H4oO9ofy{9`{>>d%*e%GkDe{BiKlDOmY@S?U3+J&vh$Yd;2K$Rnj7GaK~H zL+*WdVF~Wy$q4Xm2)W{3dE|+`FgSV<~l&$P>pOB#8V@@gM*rwW(VyMzHyNGmf)3G=foX#UmjHZ*mdG zFXKp-OlMR((B52ZRmIBXS@U0JtFtNrCCtS$$uB|>O2xTS|~f5a$B~bYgw74)aTXko zmjHoXKEQp;XE3bltXTn}w{RK9fc7^l;|!TAwkF3pgx9UxW1bd{j|9C-Ksh8kUL30h zQu^B!pc*`o{^hP1{h_9h@{IqXsQipvd6awN3AfZr+R*nw^D?23WBoG8+%K*TOIC+2 zs+PR5JGS_A$&=>JHtW2VpH+d?VX_@_6j>pjLpq-AqseY#T#h_8-Ar2tHOjTr>52GA zv+>MzEq00L%>u&zRsYe=cCklyS*!dlniX#*ocS_?b+`RI_@B zSI(*5iB~(C?!|p_nC-RvR~N@~^T+lUvOhp?Wzi$>iP|&6&hKo(^s598xf(u=kn=#I zrRE+Mzd7_A6rmviAe}}}C>ieI=Ao(SqojhE)W5R988ydp1VibFp8QkIBtr~9Y5F>J zfx1Abdq*nbIdNWC2#mR~*&FRYOs@7m^|v_6Y}NKh{B||>qPyiH;&FS&B z__>)F*&Bl!e_03kEk-sU**p6B^I)D&0Iy$O`hWg~EoU^TPmd1}E>=1rui?ljkG*1; zItzdXme-#t-CGMhD@c}Gb-LsR%w_2F^IKm2b|2Sa4!dSfkqE)$Po;mI!nA|owBG#* zlq}w><^T2s!nFXIA zTmuQZUp)g4qPt1*_VkuZx`G>zGdLvk6FqXY*VZ2QH2V&oZ<_tbo*_C@e8UM#fDP@1 zd3;WzSt5njgdM`I6bFw>sxMI770O%JGe`b3j3UwMrGWhmCSM#>IU5o4o2!pF$1spT zkL|aUW~Glq^)9BcN*W~NYNDNG>=(_1sjopg?tyEag${!Q2b@*et@r( zd#bBzfy=e~T{CcnhKI&8znb8ceCBco8s-gPlpi>F4s0MJqSw9q+fb!mU*Bpy7^{H6zoQnUuc|A` zo5b}V$gtx3@zCI4>S}yMJj5u}+pcpU*d#A8xQsnCb>rv%T?O$JxY+%ru(<*f>DzyXJ`TDE0pPM8VIdb{sCuG(Tk9yciBn zG*jf{`;$3O-*$YVFjT$W<_hUs?r`QUkFo82LH8`_S*ln8-BeptjZ7*YHEfVgmK&rh~F){ z4Y7|O_Iqg5a4{U&1Z;T^EbnF9*^*f2ab3Ul3*;UhV)XtNZr~v`eEZg6yZ=sMMfh{~ zGP{l=BUEu#VXA7wJ>FK;l#YqN#rBSahpl_9hGS^*ia^*cFdp;QL&$Q*5T>-FskL;^ zlUWJp`~BOWp~WwnLTn~S*4b7Z0awl0u9GinPfxCVCkH4KM*N%rVVlXC`D3hK)5EB< zRKV97;7^QcvqQi=UG4K+sS?LWQ=muhX+a+hOBY-D&h!dmuKo~}_Oe*O3FQvDeArm5 zs`RoYrq0B>*OixX+yHIUv55PCZO1HaRY<*k$7Bze{SP>vZ|3s_>XjOFTu!%5_^zvf z=Wn1ze3@)Hvo8#SO}0ljHN<;}l2F~bDC+*Ue+1mjk8f_XUrNmUE^mp6b@`4P>Q8U4 zQdtDYJh)j=P8TsES$_wq?O#?^M>XsM2AhrD`=J(&czqWb)}F^-n@(SrhHm}!>xa2& zsxr?elKA*5BU<>c0l)n0rgc?kN=-4k8W-KOvvxH1xqkc^NbtAaYu4ZSg`2A-Y`%F- zpKE(m<8ASF3hp+CBxmJ^-()3X?mF|n;981wmYGUE>`2H_-;R>Q?s@lKIH6i**hy+s z`Ff=)fFr`=@wEeK3zHE43???OL1%d{0gi{$Lu80Wq#kkgz9h{IQ(|Z9kwibDJKztv zMx{6u4znJerp5E^(K=LH`hPS2|1TvGqJuUwiwjoAHoX?mS;##Awmt%Mf@vGk%8)Xv z$mqJ0TW&ID&2Q$xFvoHVVI|Ela4-Uu4H~ucqV8cd{8G5dUv*qF#J*q>$uG25)Q>H@ zUO`7Y{*l2<`^_;p>r|aa%-QaRR#rZ(u1^tl6-He9tG4<_>o{3zIm_XInkHBQS`|YT z8yPe0H}hlYeUpmAv+;mnuJAjUzvkk2ugkO>r^&q5$$dX(S(finZhZD-2>r2j=1Zpv z=MWHYjM~#6r~!2icT#%Xp`0%rM@fI#Cesj_^O75Ubr?pzC$AffpMxq9RcP8t9wT0D zF@n??_PG`W$K?ptf&Y&mg71g=O&N2}c8C_ns@bh5SY#Vyo<^lRqyY!fG`u%A<(igR5(5#x zg+KgYeC=Qkj9*|*m}o@h6cp3o3smu?OpM&U`CcZ~2PZpdCiekEf%F-5|Kb7|=y-46 zuCE_*I1Wu>!|o056tdmZb1rysgM%Yna$o@El~>N<%o8E~Sp}QC9Kb3jHzJ5~?OEy> z*(H0ANJP^Gag-X@fB^t&-AJp)RKgAoJ~^**s&`gH`kEZ}S}VLPiB}kqfB|PS`9wzq zfG?OD8L=Q+gwfW!8Y2U??LuaTK39^O7q3472G)*e{hy(wb7l zkz|7gkfZtJl+Ph5SAECnP1rH_2bsyu>J4NUUY4ZZMjvApi%6bWvZ7Dx%7Q?Afj1!9 zAyPUCvjMfikS)-$hVh}fsnd0hTRbZWzb~#|z5TZc1IYI@J9aw7F{>ZS-25W-wRafd zi~djq>3&um2JB?K(yCtbL~9S5NSo56@3X2YUDY#L|1`kt@2;@eiY)Kl}&b-;VFbCVPb+n;2$ z!m8hjn%W=BTk1f+1(KO`IBf%q!&cwHSZ_1Er|*^q7V8y7wWArC&U@yqNNkh{azf2P z&Wp@#XB>PxL`c1@vFyJX9TP~Vf2Xor89bD4XQ z9wY-IFLlz$o{^EaP1W>3z}(q@if{FU)4%sToV{K+EZ}AC{B+%E0)mJ%?EGYmgNI{M zDUs?Jx=HqqCa=VXa<8FD73BuqOEz6?Z|Ubk9}$1W_|{|BAr%#-Q+Z#H^Ugue8^|Za z!&PVIV}A-W;r0b}>b0s}8rj;2sqzwkU0NwFZS~QR#r*Oa|FkcS)N2J~vUoIhRxZIl zZh05#VP|`c%gSf!Oc0WlHzn-Lg=$XnDVa0J8DZD8z26uv?1iErK{7;iIuy5_dUAq0 zo!l-=D<*tsjb?mCy7iKBul6m!B;<&Hun>y9G*bD{eH>Po-5~xVqCFe)(vLf0Y@9NOGVOjRnOm@+fGm{$vUvBXH>{mRujB_O8mH zva_(Dq)%KIwI#M|#zIh&8cYMfh-@nFd~EE9*@a&uan3-4nKI)Dikf%%;NPjsezf5m z*WX)Qaa?|>ypYMhyE`g>btzXqY_#-ED@u}MRDvipIbAS#sVyv7X9W*d{HZ3L+G91# zLUYtLDlQ^UTadLAn*c#obDAJhFabr{HEtYv0a@3C5-`%y`DoQ%At^VQKz@KFb#I7J3UBv)nGU~n3_VWa)K6qkT z93^JV+oO>Kz5@kejMd&ua{zSKl;`Y*q&x%~i%6`DjzL@$d`fslRG%_$K7+yy8xE#?l&$*5;(w z<*ZdQ{5C#DjvRxD1D=B}{LOG1&lHF&^jLQ^^&!OgoJYQzYKLJDJsk4i-q|A8>JS4q z`@#_SM&H!x|pm?WP+>r8_~tMYVT%-6hrFuf#-_KynW_l(Gbd4puf8nP)#YUPfx z)258fP&EZ4G|@0M8g{y7&8;^aI4)6W5l5DmU?v4jSc%L0BvJ~nTa#}lVlWz98oX$| zpZ{UKR}TqB_G>6wl~a`mS+GP%#rU91RDzv}A!7L<@?t6O-4=-Zg!I$d&pk}c<! z8qZO#%eklKvcQ)3`X87?cXynS2EM#pe1(Fpx-@Yi36U194Xme)vut=5=fioeq3><>%d05|e(|NAIY!b`g!Kr2neAVBH6ObY|+0Pv&CS z`QQblGBdmK1uZBlDE3mQbtByfgV8b!HD9bNHdEbb$VjtE>G7t>A@OR`8zS%Uf_gFc z)nWbE_KAUI5U&t{h7i=SftPp77HvFHqU~=tq#weY?+-mj&IO%JAK5NQ17S}d&K+H< z8;{IN7OrKCuO3)*h&Jn>_CUYHjMulK^TG)n=0NdX-7Pj=+4jY^gX=;g94GN^L03EA z9z)QO<{qZ3^^eG-+q&$ch7KBvpNzn0-2j)N&ibkkY6`A!y3hHFxTvO&Ni_A5^iWPGbdPR!gZjw$JWo z(8a#ZSfXz$S62~)YeH_D-4?O=SlqTitY)XtJ~~Ad&WS^B{k=8sYG^mz<4gqAIi4Gj3Zu zqbR%!){92T!M-6cG`r3IHl6H6s!{K+DBKfS&=0W_PZ3#E#5oHg+U}T8$^3B%L5w%e zezp1YTct#g5z}7xid)3U19p067*!pegvK+THwnG9)j%-zL6N5m^so~p;x)*JT9WAn zRH9Z`VG)hi{MwTsTE|HfqUtCotBZ0{M5))Ei=8=g+e;9t8_=-Q>&}T&FiBGV z9=GbjMuSc4Nu@&a;2#3tG_|rlxKTk2hRIsR_dhP_NyfqTKsgZTEdf$rQfB?W%B}li z74(^w}X;TZT*7TXxy|hj@z$j%WjUpi2MDS%AC?W3~JPpdCnyTQ32w3*i z5lt3)?4E|rZ-mSVai;{_9B2hxM=nmb9^LeA7!apDtmUds+lEqB@VCC>(S&T?fDoP> z3qB%^Cv(J9CJ$Imq&m7za@ep)25a9bHJ&7h{g1dN%QRo4N|&Kn zFJ{)8+P*r}lp-LKH)-2C>_22vzD2~73U-F{3-sVIy_EOzC>`Yq1C1 zg)T1h0ljueJV8CCMcZKY|^iE56MN2p|`IB>(wV9dhcvsCR^_qo7 zbypD*-&-i8yG5YG-j~?xzegk<1Jw*X-6AQ5!sA=j@JYkh>^ToTG1b&IvBG&qCIrfV zh0L^o;OZmCvmJK{CrcmiUjcAuwGY0NQp4#p1G;u$si+dHa1ECA85$(oeEeNjI8gS| zXB{LY93C@g9g^MS%i0kg)%Ur~_}mVst!&BX7<;KJ*hAGMFru^sZX*Oc#boZN0(Uzi z9We_-95GwkB_X@bvyK53z-M}0K$gqh1p4757Bi?FcU6c}Sif0@g^$;3Dn7h$xL$S2Oe>pn3 zV5ThwOJ!Zvyn|`sKi;-;o8xei;7e_Vb!+FQnCYG*jCCe2v6Z!iqI&PwD5+hwfBZOj z%{#9`ij|u)h1c}q_VHHBs8w0s_x>R6#6L1(rujU@^Q1ni2nnlFXMjv65gruZ^ zCea@p9=l(~4PFsj9KVhq9BDTlI+NJ1W}aBGqWlx_-<7+ssWRWfmZ9kQ70y3pKdOI3 zzkdY1OT|F^KfQX0)CV$Fa1?!C8zgrotdI)|4D@5N458I=|3QC#4UzoBk@4@oJ+1an7YlL)=1V)qwA3^K&XL6{2S?I?4d zaek`4p%^HM<%j>`0Q;(Y`El$Qw}(#YUa|38Y@ z|MiIU-#ZT6)k^NM)LtPLVDs;x(qnowpgnxLs6<1d;&2+BjlM-3a-*#gV^oJ7E>7n| z>6$ZFhs5NG>y!L7M7-)MIO1hB`@xLz_4CX_Aqcf9d-UK+FqPj3^&Zg*>&f<*zv06p zOI)gz*dbzdAwQQ)-#SDB{?S+Q8uR6h-eWaIubIY!NAvP`pM4h;(4VL- z=Z3NhYHF{Z{5&=l8x*>C{z~ChA$&yWS9KWO6jtDxAIxXdL{$DKRzzN@l>a3pZgII# zqU}@IR%?v?#3D?Tz@40l=UBpZQs5{GMKPtsrCc>Kdert^tz2L~*B<463euIaA74~` zqy2yUAo_eQe$$CF*)wGee*;s?AH%$*qR1i#!817snlc}2*cV{Qni0{UW_Z84N)>4& zgHe=V8reZYiE^S$|J$06QtAoGaU+y}M*>sp)a!NzKG)Fw+ZxOd^>F;Vc>W!9i6YU; zd*xrq9OsU`b>g@;Q=1a97GoA?{GY;kT$*y_c>D*`llvWPvA5+Tb-6ZE4L(O5$dKlr z(WL1C;U;zLj~xE{EJ+p*61SI~kGJ#Uyz5I;+RvVVZA~q(X0Ur zOTLb$cS7zw$d8rISAskSexCRP-w9LLAhKi0L%$5A+&ryJPoL}hi3tIZ9b60dVUX5!9oHutl(VTI?N^qbk9xrFFo{$?4iW&4GkH1wVX6!C(lX9sX%CWVmidFyvN{T zlf)h{=j!aF-@BZ*#sK=g9w^Bp^U*0%d%qq{>5ITo@4qGyz1nRQFx;%vtqHk)G??|F zG46A?nNYz9dp~I|THsN3g8vRH8A!w)VD1-7USmuw^cGYIIZD+&=$S!wXm zV|yw$l*H~MO8tqA9~D#w!0~jgzUzyLmiR>$tQe^VM!jL&eN%CE@3~G*NMc28H&EsT~GtdlE>Q2Kz+q++7fvQy$qTe?E}d^0Wh^ zeA7N6b-fPu47IsZe$lE4l}C;|^mb9G0UD$+S8RNyl6%wT3eEVE?lG)?#d{(bc-b=IJnU&9gNK_<{^Io#y?tw4JupnX>%{ z%SK|g0+SB>BG&K|KQw-b&=WK0NmKrLI=9cWK`|pZu`#F9osb ztSh(4wzvk9#BViE30+PKnL9nC|Yb8>j<4E~6PQ8|vbXjTheRCXk7I(3`od`TQh zZ!{YBoL6#G!xsJkdMGVKJipPkL-g%|7;I}?o z3Nzp`zZL&FCx*loz9y-4q!RW)#Tgov$ehM|yNwtV_63mzOCHt(9=e}*q2%p*$CTl2g_OMyC`q>`j+C)QYycPyx4Rq^j+ z)6gV#(fL!O;^n+9C)C&Heu^Nh*0SM-1pNdl$yJ<=)l$qlJaJLrQkoxcq#1tywT{gG zb3$wU>QR;(wv$2>3ONqw@L&u5_y^W)1LiJ7Du67Kb$j~j;kMxvM&-h&0;685a`pJ_ z(Og?p_z_FUsMxj`S{gj^B0~aMfifL;Y@;=$`fbON?^}SCUJ{o}cPRY23I&Qycy0ks2qGPdEqLQ$S2rv!gdNg@_KSi!S`>bUl z49;72RXsHjv{{4xXlOpF4K0T~mj+zp_EzTr%Dp@dnn(Ickse}rHNdA%rr(x4*IK3D zS{0FhGe{qkUA3&$zJ}WR4>dK~&&6U2N#I(VVX~*LG+SRES5Zq}MVQ%msAR!Y$hk_J zL9VTm8o!%Xw^2O=$0MS*lAfmCk1ZF&@051jzaZS|`!+)QYM-`yh#SH|wASd?y7jFF ztrt|knZ%Uhxu<(HhL~EJ?)SOqyF#YPX78At6J5>bF?VPtStA=XXFs8`oRE+}Lw9%( z*A88hS63vPY)&^x_o~jU%W`G$+MG#vL^tspI2Cgb1X=pybgH}#PWp#~bpebd}6~cDEQ0Qg$G~__XQ!uEGzYUxxRjp`Uy5 z`-1S6OgB zi#>U*dv4~0AdmZTSD{w^3!$$5Aw)Sa_5Bn5qHYULPa-IMRKt>prR@PgRrqPtG#-4? zDAoZ<{M7!%{fZ1z!T14xAF0f45Ns=`@iZR0rk44~To&4egX+dn&Dj0;#q`=95~5$> zSF{zvizv7UW(%h98f(ESfR=s8?oJQ7f zJGf2djK~qT6CC=TmH8A=XttUf!e#tnv)g<^(~S-@cmKJ0hT-DeyBM< zhibj+v>P#=pTdyacUzSq2gz>cI_b@qrXJ;@k8;|ZcNloqd}fFO|3@Q_F+H88$@#%A z8q?ZQ?%nILHYTdM{={_(&i2ztA4;q3?~6Gq(zuLGe$8*y&L372q@>vj7h>BU8JNsB9~~yG@rgI-*2hMXGzdqNHX%eGd@@KrabYV@+-BJ~ zt}yo+^X~i|+#{OYz-)U|FP9hQv8W!G+4n4jp*Z`H(^*Jh>H1rMy{}mY-2iirr>V{LxN9z<9cSoh z+K~|ZJ2v;zs97K>-|Tf8yl|D(<}@|!V!X{oD-OQ)7Bfu&G{U*{#(l@-aE@16PlxId z&S4UcSBh~T&bT}WE+?zz)}=l|tX`ii+YS4KE%*cbwyRs2di7F{1ASKSUrNU894|97 z{{nepJi?7y|M5#`$T_Ex*xp=x2N}7WIED!StZ~PK_Xdyj=AfsGN#s5?Cd{fFc(Qnp zvDNMFWi{Lu!~^tt+IU(4z=hJ8f3wCik;#`6~)mQYfQ|36l-G zFQGA&xM)tj$j_+~hl{g`@c_$e-9a7pjnLvX1;Z>{k()AC+;wMm1w!F<&8at^#_w{? zI5F7-M~E|s2%!s1_FGm3u1{pv=kJOy4Ln}0ja-54N&OaUz|NdtU)O-TeDs`hExR3Ev~+V5w3h0y#|L+P3zUyxas#hQFik35R4N}< zFim&)kky>K5e7*x^6{}pW0h^A4QpDfyy(>e-zm}sT~3A-&YjRYT_+JmWr!?fbQX+* zRO)qn6$6;xG|zW`nUPXJ5G|h{*i_1l-8&Y=b&A-arRL{B3Uw^ zq!w%dVC10l#xrKZ8i%}m)|-mLvB1SCV0h}@DbW@C!;(Dgw^5SV%NtyBsF^uru-+V& zS$j!a15Rm0LMTbAeaxoua#<5E=^^Z&sxJaynV|tmt=4i-#2XKUKW|bcvcH%Zp=|Ug zCGlF20$n?w*t-D2kc%VPc=3)~%sS?+C2Q|Ey zdOV{KLJ3!J_}03947Wa?!rO~n< z)o{e%h>2a{_NT?!Ts-RjbvriA^DzFkLkr9CJVQ4FT4ixrDL@iI6BMS*{4_m})etkG z2LUT_-|7zNS8B{gfLuG}lrDtypTRtZW}xJIV(5XEQnotPYW*o6J5YW=eUv1(w)4=_ z_+wcd*lazoO0vV$n$z*{?)7h?IWH&Vz&ayQvv{8ec~NYB2vs~5a`x| zImEx!jcthZeGMzOe7<)RMpDGZD%hInML)zxyHV*i6 zv6}RLE(evFXewyol&;tO0KRar*B>>?QsN1B8I}8W5F1PA`&3TrK{8Qw??`rn!kITh z@DHm{j-j6_2;m5Da$>}6*dO)J!`I&Nb#M-eUw$mjS%3S9UlhA_+lgHCXh~B{B64+w z=7G0W!Z7!Q<(g>rFV!rG8brYnvW_9jP;&af?pl&$oP%U#8C5H%<(<~&ap?Xgw zf@U0UZ+uzV+rrfQ4jKWF6WK4qmO6c^JdqT?^_ewOJK-T14?@BTu4cr?VEle)L0J6@ zA@&>7$*eIZfjPDjK0nRxW0)PCNUcoU5-UfT`2lg`fLdmI^yj!)I_x%s{-ZCoAuIcl|-3icWa1GM9yIXLATN)46xCeI%3piTIXx8tFP|9dhe>*RloX|Zc=p!zSKdk&#AR9A+*YpQ+NxPG_1PH@UHK51TTryQo&;a=PQq(>`tS!S>cn7)=U zt*5*o$+tZR9cZ^Z+()kUp|#tqtul+GZkxEuLdU4N{_sjO{YL#*$E{0gA%}cP=bjg= z3L`|njY^Iwcf*thn^n<`62b=6vBudD8#vZGiu+aMZexO10m-If0kU>9LqIZXS`$Y6 zFzI4x_Xzo)4=aD{-_MwJi4JDa0XVWm08`-ysM5uQNh9dNLKNzR+^mD>-a|jbCmQ^Q zvJbWT2}jwtu$-cjwwyo1$gaY1ayvj(?PMLGRPeQiqqCab&25FmjA+@NM7HDX;KZb7 z8}}O7kqh)Ixw}TqvgMtd)$t7k<65i&t-XT%qM@aE=(aCL%!k8R-S!PIMhDrK*uZNw zLmz1#*o}QBMYblvwyi@MEpgQ{ng}iq`|2a<7)exzkhDosmpj;HzLt3MR<_$UcU>5S z>Nn{jr@+m2+aHq`^ZNZh1`mOb%vL;?9RoM3V}e<1d8vh*Ziwqqc9cLn(GSfOP0l29TTA1kIva?F zBLWw7ofS343@c95x2Es!-uGSb=mlX?s1njpy+-6Eds`^>x^JR5L^Cj9qlc@tLUWlj z5fd+7UQTZX+`jAoC%XNdC$*P@DwMu zIqohA%!MK(0l{%hQ)*s*HI|-!OY{pZ0)*?O8XH?pg@Y~5>S$KXdQAoEzp5chFfUn? zd(AA?ADJQbyLMxXQ4y?{1hr?Ltfw6{-v*=DE{3Rd?=pmdFBFVheueY*H5O5{c07}_ zNj@-89!ehKBFL{t9;P9XiE3+MNf4(*rg?o5pH)6Be$$$`C9PUMs=8dVqigq0G2VPJ zfKekzJ6@2>vmPpR^h4gR3GIKOf#eL ztHbw#WZ2ILr0|VIHno1U&G_3GUN|z_k5a3?&gXI@_B%%&B-CCW#(Qs^Y8C6fGW0hs z0xWb>6>HMuaqBT?0WKK?RrhM|>{lO<;(GTOC4YzEYgdnMk}VN`lDI5W+*kDy%*an{ ze}7{0NxYE#xt97g@Qi%P#c751PmX+jd;J%bVemvQqC+yOY$3^AepzX(2nYE&rGrj( zH#QyV1RRhcqW6YtRBAsJ7w-~K)9Ye9&Tmp?qe_gdiX{{lM7RBrTnWoM;Q)*s-01Wh z5r#m(38WeY!ae$p3E#{4jl!6e9!+3W2^|w7F4qY3FQ0Rg&fLjK+QJ@G@5!12ph0jS z)A!DGe=!^-ZH@4P5=fk--7(FwK6L3x(cOus!y_lk9_4*PSC*xs{ln55mB<5~%yPEL z?YjoCr8`L&ds$wYO?Ks$zqtulLyBPchtHvXYb*?2I}d_lql(rZ^!Mp~8)1X99m$TV zx#W>B4TUxd0{9`vO{-8swVvKE=?0QTGw(F?7>k6p_b6_Dt~%KQq=jD?b`V+U7ZVn) zn^af9{da5iQG|&o;^A=?=Yg1h6c3HQkWV0dfdR*cia+-n$iFW4??s_q8wwaJ!T z&JbjJe-Ce!(R8^>YfXb)-yfi_sT!{rZTT=)=IP>S#&I)H5$CZ=={TXaYpgXerSXx%ntL74sr zacU^xO+wSeEr`3A9tj;0VqfKDI(MZ*osyfUu`yvHld0|Ug=MyGgqvv+(vKDcaK|2_ zg(+r}Ajj~;rl!f2tIWU}jyM~nld+ZfmQ3vUVzSh_a1n4W0HF}%7Lui+zL>%POfwbFn+gwkH#YHqSH0mheG{uSS9c;0-(>T z@(byC&-^1CR0=g;0?y&_IuOT^Z-xv$_zPFWU`cd_s)JL zR9xPR-rm#Rt{y;N89+1YJOz$Xoan`ovm<2;Z_ZKO{)yujzBsFx;+CSn5zDu~_U))v zTndYS7C>06%=aPGj?jLbZ?njFiW7a|s?1A>aF8s6PagOZUuavzPNlFd-AO<%N8HIe zsSlq^wR0V|9!S&7o!+J0eP5sxATH)xl4ik4t#ekRyFYS)s|mw%itm|W7 z-Wd(cYDk(zvAvC(<)M(!iP@`9IGS)xxmaQ5?GG;}uK|2iQFvEq`esG{tO@nRW4dCV2k1 zbBB@Is)dn%s&9(v<(iJr*Blh-W)TuEcMv$AP#el%6)Tpze`K!+T>_+utWvm}K|M{v zB>4YLfe08Gx6yy@aRWUG`a{A6F=1JBxV?HG-(*AcWiS@<6!#sgh$?h0BEd5yJJ~on z5tlBKO+9dD;f2$wd%56J_X!j!e*GCXV#+6!d(+pzFL31ih6XrlOnK_gbU9d~`jPZX zO^sL4zBW7q!Pmz%)>+K8SIvC%23djs4fykAnY9(8TM}za-qI&!RRV7XgMp}K1zUY} z-tA1)4o=s+Q)WDF?R~m{s_WwkyB!=k2vOA)Avwp|Yh+{V1#mjf7s1P6PShfA@7x7& zT-C%B5$`jJL#Et?M9|4!7jSelcuOU*sRwwzv%f-8Lsw>30XQh!dl?}p#vwnIZUn_k z2rwJJxwC(=e&=|Rr}o(`L^~Z@r3=x|wxv9NfSxWQM6LqS7n4~L>y|u-vDSiuBYU?I zx1Ak3+dDzgj_pj@rYMQQ=ZngD#ln8qeF15`NR8iwWbCzFTuyhV&nAiW#1+o_5);ov zjCc~>d{#+%Uo4o7mbYyd4Ie^-%Dv)VdSaD3`WDB34luJ8?v$CYfv#!19(b;SNO;m14J zN~`Ms3p#RLa}eY1j3|)&X;wbCSo;1fRozuX?c?^` z-phte=E+87geNMk0bC@RE_@1RdBOeO68&^(yhgz}l zYaZ3S%@uC_3@honli|Am!FJM(y&V|RsQ=w3$yLbMNz{Qb23sx&M~#p{kP;hdUu7$X zhY+ZD(guGsFH-**ThZYPl@sLUn!osn6fOqxt(Wgs#6tEnA`VeN81;W|JC}OV=>LXLz8>(Z*SW?rkXQQ2{Id6tK>Y zCU4?)0hXML$z3F)!J}s1<~o3wSdt*1!Nu$agdM3G81CWd>R47+xkA-A{~DKQ%mSl_ zx5!{4fkh#f2kMUm=5u`19dHf(6*}wd-${2IH5hKum%i90iq#9k{Ga;I{|hw={-^xb zzu&vKfc+m8OG`^mB6LnpF)c!6eL)fA03@39#!WgRauIZN$w`fng?DLV=GC7R3w0y= zBK&w2#3itD;NYc7=ZDpz>(3>KmeM1 z=GJB{cJCgnF6H{>m^t{%m$kmgJ^qXzwKB>+NvQ#mkE&7 ziU1=8cX4qaIvh~zzEfFNCVmqY&9xmjiK^755fTq5ozpcmwBpu_i^u(|daw#nJrJ2q zRY)41QpUyIap5NoQnCDnMW2qUuBYd9WlH$dtk&G>FBwvw?UDOEwkFK7ZmO1>d0kz?b&r`Ocb=2KV=aj^A_lssEceg2_AJ6=6ym z-uZQZHe$?8q__N)JWLuwtE}|q=d$h?Z5UP1=v6L2_Ior8r$wD!&5H`5;hAOY-Y&q-qPVWcUbD3&A?r;`!F zM8!`MMa}FfQ%V#xRV+N%VGfbT=CxrYbr7M3KAagzFAWS$3=*ZcrEy|O34~nhCkzjV zRT5>^rl%(ovQ*9V|DM*YOFslr&@c~#!+!U{)yC?JM^jVtvUT?n+mN#9yjJB@-*HrU z$l~mwWE-uwo-eT)X!5p`eh4sPDXG%w850Aa7OcvmKuVntB{m!1dc57e9C;P8$SdJ2 zuh^4RnyS0CXNgHE*-)(_6);e;grKA398D6+4q!D)S1Xl~yZ z;gBfV6z7}OH6QrPD?s8QhQzLC)by_I8ds|K1S`EiR7`*J>iZVD=ohLIz?(>K=0z?b z{x(%Hjj~4IYIIzMKMx~^fuh;})YtS$!dco@9v~k@>>lKv?*7Yt)O{DroYQ~Um?Z=` zVRj;Ilf^-%u|;%|lZJys0DP>~m0NqUnvjv=F(XivFF*V&R6XR=W0ABpEAd(|!mb?mpL zd-6;8hVx=jlRY}9i0<=Shy~}UjK^I8srkj@tLjA^xjrcqb5;pwq50!F-1W)z0gGfk znczPi4eK=fX(E2Y8tyoB{1*BiCz=al?*0S%Hg%g_wF~MNNKndJkgu$bb6g$eek9<7mHndYoBwX;CZQaRZ0t(T)`^-1=FU z>Y9i31O0fv);6m6#C9KF*z?wJP~?ibw5+GU^@e|2kiHestm zQ@HQCKfB{&aR)6Yb>-WJtt98=rerf1PVpX!8+FFlUOeL_fS;=xA|LM4PdEp zo-l%IwT6G^yn2+$tE@w$oMSw@(cx!9-%#g<=#EEZ`>P+Hrb!>|=a7+z--C$Tcyry^ zZAI{a8$0TSr`T|b81_TqWcMS^~xqifHK?W(OzF}dzms9zHspZB%f5AiB@rv2c}bqKLYHeHx#g3?=q zHXmC(Bm8Jh?|qs1;M1C+Q-6n@YNPsU9eZ;2(d*n7qt&C!2JNV?b+|Q%AGT_v2aK9C z9a|m47X(>~)GNBNcr9Yz#?S9BvkW8os6JTP4?dK)_NKqwV9|pn!GI7F2vibHIl+?R ze?0H{r?y51(`5TcWje6-y$yPVu@t&irHj<(>G1^O)sI6z*V6*^MSfITdFh_B3rI zL@F0X;$p+oYJNTtqU1)}uXswd-=p2TGf?YaMUmeg)i)|AiUlB;Sh_2(CVT19(jFTn zGR=uflSms)nvg5BQwiu#=ed6M=zuiaL=9?k zKI=ghWNlU?7TxBp+`M-gcPLq^XB}5LS3~!-S<0}X^S+vnvp@tdZG^9u@GBd^SbW<3 zIWaW;enh)Bh!8zPu`%dqeO-sN$?9DacZm+BACRDEqXmrb3*jF-dosHGEh*!ZObUUQ zOP=9ZHgFXv!^8c|QQp5lTX>B(&w_tcb*4nex~>eozsS}kZaPzh^SHcc-Dr)P>lVy= z2i51dtS$6K+DC4D*165VhC^Zs`6wGpu12AJtfUXl zJtZ=H3?@Ea02hH&NuOtI0%qD4n|SR{k=-pfd}f?98EUUUKZhp1G`&NEDicXNesE2ouE~*~0mgml6u#TjkvereAq~@}z ze(#R9Fp?SR9el{eUphva^3o@5W2?GdsH)#CQg=}}FZ5u4w@aG2>K3)jttx3a?ta&n zNgf)S(6|SxN*op%6er}r?_FaqsP8B7;}x2J2fZ_L`Z}wt2k+;xGWn}cHdpue^g5?z z#}(d4=0w@TD#0i}>9cu-V(2?$K_$;}ju5Pfx>UZre6~(!x*$IICYM<^QaYJ z^C%RrOe1lUQnBUHSOkq!%NlnSH|}~f!+1ZKV%GsZtcO{`pQ~{NeFagGh`S9yA6zaE zT=hoQbq!6GU#unG3P7x9gMNoA8^vo4y5K`%JqJzced`}~2S4JeCwwh4Y1;N2mD{{T zDum6OE&&dxOi;n*!^O|46`n`(m?N@BVK=?n|PaX+$yWb z!|SYOo_T|TKvC_ruVYuIeMc146qeVJ8s2-FHSq5}idcn)&yX#lSCd0#Y~3GOyF;M) zWBIcL09_vIoKaoJbYMScexb278I|sAUY8e=bc*}=i#t_>dnp-FgLo;KogF$r{bxOb zu}IBoJMWv4;~gjkV~l-JQt?^;1V8X)MEW6Ad*epguYj40AJ|aX7|txlZYgdLKL;n2 zO4CuX#oh&=&mo|i(dqB|8LhwlW1tNc((;+}R z)ug5Ujvc!!zYr&U$7ZK6!$g{kgICm*uAo^WRDq0c0M8t#1qKSk(k!7>Ov4!7445XXR^I%lUI10Ba!za1`((!C z3gBLFE(Ux0q=PXAZ+i(X3)el)j*J=jzSC1wk%W2>9q2yxTH(TUDxO2N$z+Pc==;jz zRKB)eqX7vfLq{g`f0kcf)8iI#VVQ3N^}fuP-Cq(}(w1o!)-1N~6BZ&rMvzUnA>Q~l z81aHG9FtfYx71L%)}FAk1-;I+GxZ9X=?5k@q-dW?qvjn`1{lYopPnfsk?4V-I?Xej z3V$|XysXuyIb8dhSIS{|}aLNGI zuQ6XGM#!sV%DJY@4>j1sH9?1H6ychQCu0I3(r$ktPh{J7rLC<2`T6d7Q^hk!@7lkh z&-tf)YIMlaPk{(fgK)lBx7;36xj#Zs#=&{W+e#474|xWOJIguLI=F z{IRfMQ*8!%e|XIGSumocVDsHhN&9F3Vg|#R<&)1r(z|H2qTzHTnh5Gm0Qy9{<Vir(qat+MW8S8OnYs~lL)xi2WTUS~dJ&sLa1_e>>4rd8u`N3jIB8kE(^ArY6 zP>o~DB$q^ywtG*hsebOb0r^B4#)u-55oUEdw2e;P_=DSTV4vas#Y?<#hiXC2_?~I>O zso;bf@0hcyx04T2NiX~!OtwBrqD0Dl6);G8NXQ0|&He0|2Cb}J@GozpSAJOb7Q6p4 z{LLbhC^LM7hdYf=SeA+=7&GQ3;Ld1qrN^SY`K_kLXbZEUVJ$fAC(2hkh%tP4(%)>ZW0pBgK=%p;cZ+a zmEtfplTWL}=l)QO)~bMqEZ~%3CdezsX!A;sy?Ik-+L3gy=Hi7wi$twPz9E!=m!M{w zhDy__$D&Tf)LXxwmse~Hppyf{M6#HsiYK!Lg# z3C)_Bw7}zM0vE)QqeW=jAERKpZa|ubMA4}+cB*{!Z6AgMUmfXobMhx4$EtM5emd%a z;)EP)t@-92Hxs$v%}C{&N(4f^v7n&|hgKLjjf5@-JZw>Tud=hA7P9mVsua(c>e$Xm zB>7{WG17tfjt8MoG4|k%l?CSFyE8K;k_P;9sOIn=UX}6B+ucwNTCI2FXdbeSd%w0Y z*BUQ+0)wgY-IBDSBtiu0=fU^U#L0114qKrf9KM}2q+X(pQ&8xwK*v$b_QIb~FobWN z2FCpT7XjhGDnvEC6{pBC4_hv#imhJjq11D>no`uH` z4Cq6=p$hWUQD8pO_J7wsM?`7(F<(kN`s4tvOo1`l?|GlS!uh6=naE6Q8c>yI()^t| zU(_SKSv-(<;UtCh0nrnMY4a669o)6f%psJEGoJQsrJDltO}snlI}ZH5`6vC&KI(i^ z&XRjsMi85sZx_doSV;Hn2w^z3tVw@Z(K)NTNzdbU=BQYW^88Cx0IpFN^BP|3CF&Af z?@Lt#II=ZA>(80U{MEdb&c+Ab2K%tjga-z?`C{k%AJXZ%w_Y;8&!K7A^75v?wEg+A`TI~X2#7wf!(0tf2@g7UV(UlXllkuk zR_^kRP`6dH+xS(uXM;H;-=wc-j15uE)_G z-WuO)>{jqlX&Jz4g&T(Pb1U?+>Jo#Yjq;$nMK57-Ft&VgqSvys8E~`y(Tv(^!$~;J zA9X`-W6T5)s&&@(wg(^IC~E;W`B;)Vw9&ey(y|plU30C@Envfo1rVv`qcx)L`23*? z+#>!5kUrNXcw$naC<}Az_LoJewNS9o{e&t zmrN3^jHx=M@^%HR`fHHc)*_pdll@!s(OOrHHX7}F-9uS#(AVurSwvsNag3^tLP9NE zM{o{^;M)Duem#86ei7f#yhwAs6R4{^$glRYAX!BmLv3duyPkBHIi*d=A2%Z%Sgq9? zz(c&L(h|b7+US}dm+-6ssTmEFyWD&;rL`VxyahG5)TS07Q3cGJUvEvf?=hi}iUp;& z?1t;<#o}s5?I`9xP$Q-Gk>lEV72_BRo;nqT5~vY1?g&{!KeB7?si?yfG2`xJZ)dO4 z`@t8ijfbH!M)r08j_ksMr0Y1L?~X=DuDf4GEO_n3%5W(RCeNG%Po3Qinm2{2OgCXm z125Wqs}496n@#RVt1rfqJ1EqJb57cIHkOPI@N_=7O<*QK`Xc>II7AkHOZPF6={M|| z4m|1X44ux`9$}78Mtucuh!$RRu+Lx!JX5tIzPs~AT8Oh;GVvn?T%%D15XKkcwO)67 zWf%Ks4;NiJ`z@Ncg!ayzpYvUZV!s=U6U}vlXVROEV}@oF_r|%=)`NASWm^Eo<=h9? zLUPvhik`4DEj+vi7YTj5aV+P;Cg;pER8?_y;@x{`wfgc?XImB#M!r5CweUN&f_KBR zqYqoCa7nbapM@Jy(OW7lTJxoXtCnOO^ARfiH*NqCsA=2-W=v2tV|r@F>jWYh*4<>4 zOmEwHbgC0q6MF(`fG9M%3sOxqxPcJW-CyXH?sIl@PR*03Fp^>e^bBLx`ax3?LB-fx zQxe+Z7%J+=x9g(z?v3+Ng1|Q~?ySfe>6mUzOyNdlc8jQ$6ozyevv_4FizvXfWB*UW z3)qYFylUW9KlQ7yN0b{+`Z1LqVOJO?tPlvBX5Xi8i*6>rT(DU6TlZy@*5zsz42b@S z?dW{K@qTLz9+5@+5TWif?rne)G4sZxy2FOV%{q)w*4M|bWSW_$5PQ@f=T60(oca@F zfGq!0cQs5`odln*bTT(BNRG*3UAyt_dmzum%@i%Eo%_9`ZSOl%L2&R>F^zx|Rs)7Z zP;Oo->!gqef+7Bz6mZZ_{&=3r1;x-F5-Pz*5X{;x$#RKpgwNJa0l^71(5+V2hs(EP03g+(??d0@N-$98gveHS9?_j$#& z@7PitgMFg9_jE_*@aE2@#2V-XA6}!?J^~^z)>X4t3?JxxS_S7%Uv!}DWT(W-20PfX06W+s9;rWDYk&4wp2C`*}QX)0xd3S5} zo@rj5cD|F8r1$1$S)^;q*4}7Sfq7qJW0}IU-+&w%bM1E5u+FmKsnmAE1*b!_;`nNW z!0qwV(Y5uJ_?qz|tDA|QO_P{gPYmMgvFo-6iPWpLeprB$Qq9LUOAVR5usXAi>!h1K zGJ?2UkZ6n#BBmSJHJgZk_3WW5I}<-B;Ubas-MoZ;<#PhX-IJn`f8o^;mMbdl)i41bEmtM0D0&}mPlxHsR8{xL!c{7v`_)(5VqM)~$%obUE` zURv9=%Po3$!Tte0PqCoi0M<7j0nPr$+puJ5Cw4~fZWXmpH#`@xG)Rby?>n{<2@KEi zi|N@qN_HGo=uSV8pv=X83($}bN-K>DN3b^;do>+UxlS1~2?hrVwJPSUfK$HFwHRKl z2NEc7j4v30M8i2Q@V!lxd-XhqEI#ZNgc73L--JBdm0fJQDB3nyC<*xxLTzRD$;;3> zA8NNe8JL~3nZ^AvMJQ`M^xb_pP+;A>P-|UiN@DY_s!|naq!P#4t&K(UY?{2`v8O-X zN5W5*Dd6cit@_wnLkN!3OnDA`Vz(nvqvu1T2^~e+QrXyy9CPz*qCmW)hOU4CsEit0 zJOh5s&Lo*&#G-39s@ksmZMFHZikxy>4keHkYj{RgzYM6Q6`rmo{b3V8sQOKgtk~Hi z`+Ob*v6!#0XqnP6ik;tvWw!wx<0A$}m%|wNu_J8<)UJ#9iVdPr_VznOYgaig|`L_qFu15xAs2NlRuQ>5xgt>O>+Ke zu~w!9?+KG#0?enSi0m+FMz&w!u+xLt<@J6T&HWsT$!Dym`%n{=r+{&1cs0s#&+^yAe z#?OKn)qn!7k;X<7qbrB;q$R&Hs(6v*(GXo*g?hN*zA1`x!W1^ao(BvB`@uH(a3n~h z8g^9gyLEc^q+)$OTfL({Z1-)B{b>%V{2cS?vyo%F#<>00gIqK0IO>ACDM@&oEpYQj z(u7WC<*3(OLMbMlq{=~NmRH^v#4ZFfO)=suj&7&$D>=O;MNNMxgs4!9lC^96Qh`8gjw<(*)Se!uIK-g0NtY4EIa$IF*D=4cmOHlgq- z+#Bu{dl)jl*|%4A<<+!iB2%nY&%COAA)^bWdw-37`_-Fv@zzK7u7RDgrKpTuj&g&7 zueaXF&bDS7?TR~J`+r{(6WuKs5nYitvbRUc8{CFfeeHii=OMZ(k2;?YV*G!p+5R8( zP|m91McY5AsYLN5^S0c27-|@$V#@0Stj(4fjO}vK$?5Ue8wpxP`ZkX>ycvar&M4G| z90Y`%MMnFw;gM3~OS`8>@FCaIzq2 zi~nQ?yfgj)Rn|*R8;{%uNi_Il`Qru51tj*&;a~-^PL@qJlItTw>H*&Q3n%wh&%G+h zNSKY*TR+3dy#vhg#HT$jWa@+>ia>w@hZ6jdC5z0u)C?Wg-O}0)jc;O-B2s`wL zy0!&3QMBlwe`25}q%zA1LsZ5QJ7>N37=jgNyr-XO5E|Am6ZpU`+sRy4bSDO4;Iyl9 zdPYxTdvkmBrofU+o-&@mf+s;eeSn#NL-o~#jwbUfJ)F$h5qbI5QeTgOM428|bp`TW zfol;qqn*}$sMcD4JO0sD&;%;VA>hff(Q#^EeyA&cdfbY-T|u628<5)b^1*jesNdT; z`I3}5sq`ADY#J#rS}AzK09|2l`r_xZ;Y;;;t#vjk=BgjL##<~VCPwk3Gh0(2A~2}K z=QW2MTkxfSoN!@|%j+<2$%xFXNDV=df5*H~#B2P`P)F0bCw>O8(8I3QVI`nX#K74o zRnTF0mx6Fq;hc$R6716t?uKhripDV)cA6(x`j`59$!NFJkzu5WN)V{`~p#_&Bw!%-pvWEpX(u zivqgao-mw&?DCN09f7p)x&ahM&Xux&N1JSm$8~xdsUAZ6r!8)BA}Z{i5t4O;6=8q9 z!mo80srt~!N{N-$OMmcvmt)WU4PQQ^;$1d?wTK#@Y}wt-F4kPUf^QDXpN#_NEX2Bk z6B2gunNu&8ZiwNM(l=sr&|E+u8nexB7$W75FL&E%h>!Xgi$Ar^D{)4PhIxf0&FI)Q z_V1PNw?x=Z?I)Xgo|}oRhqrF{Vk&>>9(o|^JJzlK1Yj*G=NbMAo|%~UISq>wTr@F< zC3&iN)A4$d67ir@uEoZ6?{PZ?FURQ7^Py)ehf(KORq=9gFjG;j*>WfSFodl2kdVTR ziN&Hzao~*(4Ay+?JKS4p7>)f|!^LNeRsQJjfcy~^MgPWjX5q2Jm|w#VhH`pmK^c!9 z25i@G6zdw-LYk;iJt=WbhSug|^^VU^5ak{2K&vhTk7r(rgJ926_{S2>2`;X}c)AJQ z5Ay*5+Z6@^0JKM1B{fu3RtH9PeSI~3{Ro|1?k~DT(NkCegG#%Ghm(be38?n06vL^HR;pXZU#{2cE6X_bju(_DG#%*JAS22$%| z)$*mnkiYkkPuibo5+ zL>gh!Nd=8d9UvC!cRG*Sa_jV1__cipI`oqmQQ*;iB3lMkmrp z58MT^NPnI3c`MpLb=g?|Vt1U7xLFS|yEL0w4#+=8b0lQO%tulqPFn^1oth6ZfUh`DZ`cWy$0GDcF;pIYS-dtwtAkN ziK{a>h1yUGIODRY@3EBMzHSgFSE^ux%8~=u?CS(Li`ka0D#Y>`0(z}PdUqzHt zoKg{_{5^TM1r9F9 zA4xqWH-6M&qh;v_tzW14sdleIoX~cjg1wHB$H;M;qNIO2+LPQFx8$d6)%z+m{94^| z+W3;PGIl1W&U-%AN^(BF?<*Q|azlyQ^#~#*jZiceK80Sy$aEhwt*7(Dm5Wd}tEX}4 zl#7arXsCOk9XY(*iNA67Gk zcvzC|lg1OD^Q`CRro!TDjQ5Q3pv5GG(ev03yAN+4kB=G2KX(r2G_Yew2Y&k*rCe0E zL_(@L88ke?b3?&-1JMkM?N|$lfr{sL>_1wI?Jm9U0+VtW;D=N}o z$5%#q4f@9`-Q3qhoNFF%*|gg7)8az-06qahQc@Bu1r!rpDLfNmX;>)}IZ|adWDo{t z*&`$cY-(!CRx$*ylr9jToSdBG_6iZf6shON#=YC39aIKEzlx&#O^b#--kc!?koh&LlWJxUvmk3C3yGJDdY z`wz_$*5fOCc=(l+l%={0B>d>Cx5%56l#)tOq>XwfFE7v1b0-02wgYJAH3Fig(`myg z-USo}?FX2{n<&EmGR@DYc9b(xww%nJ>{PLv>^xOo3`h!`XXDRx3`p!eL6w83aVG%s z<>EDv?1@A{jaREIh97EmZP-A4#CSx2*_H{Lpl&M^Ru*btkQ*CB7y@S*B^8J0T@VjC z*!vUf(UB75kzcVg4*LUPya+Hi5Z=CtJ&LH>tDF(-Eoid{}G)0@0{HK zGUhbNXNNpyTJ?;SuI>(!7;+ZyB85$gNltWs@!{d64`~EJw>SvmH<_49^KyR!fzr=2 z)?C<}EEWbCDevEhir5le#~`7&OD);Dx;nHrUti0ol~W1}U-z3?8z+i&6qJ=wG7f)> zi|Zve55jmEA>}?oh3I1e53VrVl8b|a?w6OFhlhWr5~-`HiSu?SPDh83SQ9Xn78g@f zP++_y$u=m(0Vv_J=Q*w70{#Sq1n$FPi$kKHPck6To|Tmn(N`15P@o(*D%v(fBVAAA z3q5dthtS>EhyLO%r%Ne&HLezHVPNlBVYTs@$w$mFj8j#3&oLipE_TX;3e zz(Dc%^z^VoIFpaC#c1{PCe01NHlJ`3!d$V=N#sf)PI|~H=$x&dTDFYxH&#P_$AXQn z0HNCc{@*!T5Nd6?nBK>%Mm{1r3;}M3wp=Ul?=maa?Em~TGOUbOOcF)fj58JQpYT>O z+@_8n0-?!57HhDFg8HFmKaSYN9ZlG!OyxqJju-&<*wBL;q9IKPhb?(oPrCn+U}g{# z4tpuc50~zr;ZHbIjNq-6h4p3K0NkO#!jCNln>z=>U%iSfd~4Z?5XZS;>F;0Xf>&6Z zha1}5)_ul0bT~zdPShvX6CiR;K>hTLXl5o(Yz&GdDMO z+q97Y$06IlSEdf0?fd0aA*nUHn&`)wr!c7KfAUu!5W(hSWf>>XcX~&QMCndVOZ%u# z*3_;z6Xo2Pe4{s2&Qn0JYZUYg4edJ{ttl041>8=Dx8IPCu*YP%5`@~zo^JPfT5EAr zU^ytF;x#>f-NX&9Y~y-sI@32S^y(VO3Pa7`;Qz8-zA3wrk7>%z1t$iV==<`R0b!|F zSwA+iF)^Vg-PB^g7(37CW%@X-3qG5H+tx19crMz{6ED~l$k-_9A4lb>qr#ZAE9#%urR|L$P>9J8I5HHx~h&+PrE z-H75ZpQ}I_JvQ9HbzN7wr@0KKbrQHZg%B1)V2A;m`GcN>n3G)O(1_t_r=8oMngLyf zbJ?y6*ro4r28d2E&}Xs*U^Gh^G2wwQnWMDWs^mFqt`tQOC{ah}>w)UcHOll%n&>4K z54B@e#_!ajBuv9f!7}_mb$H>TSm=nUnnLC5|2Vp1Xa3*`rfedAQ%XY+5hOQ8NrD7U z%=tUw{hzV(VYc0V^oc4Kes#Lc(S1r-V!h_Ip%27j;yw-tfn-eZkO^ft_>u5qDBa1E zrBPkxH&phLSV3-(sq>R%a2?BDo~0Z4uj;+K;N$xOVQeQIjT#RZ?}A&ERd z{L2^8Lh`@YTKVUzdV7}*%@(f`60v4j(lauZPG*z0T0td-M@E*Ha)dq?s zY?a^wknre%1qDfyVco4R;dx#BH@&@p_&A)%NCnC@Y*8a=2n(#^Pdqw3!1yeRBJI5+Miwu-mwqfR%$YJ?zqy zmuQi?sxXyXN$N|xd-VrK`Pk+_gwi={%F?;Ek#{}+$^{?Khno`!z%>bRkR1QV5d}!^{wDsd*ErRut`_Is6E&&L zdp+g0 zZbT(b^HL*Fgj0wk^K+f9=&&O}i8 zCx>Bg=E*?B$1WM@pMz`YWvPO-&goxbmRC0gAiTJ{MGjVw?7@NfT?F_orDDy(`g(3g z`HkeVI7-@F=&nY&(z)!vF2p(!m~u=>$)t?+JzxqD{U6>4L48}d9OLi)fn;n<)~DZj zCRS$$B0|h7a+_i7Ki7EeE!lF*f5wu;>RNG47BNty62X%EjQgsT{?C*_g~d|EuDeQ> z(IcSGGV@q+Q?5P!*u>G}Y%Nbgtr6M%1;hZl8s+u0u?o*-7`Z)3y++c)I!IyJb(KT? z`o|UZwLtndG9~L-XI{DpS|ahE|7?M648UV{p1$i!tJF*%$xd1b2LE~=xRlE~&^4=g zcHLaTZgwJL}c1vVnq(^9UZijlom|!KG*b5t&#>j2j6&tr4sTSMn zpYnTnjVs;#$KM#^t~07%z94S&WP2f=Zv6I9b(@fsOA6h0!DhvlQAA6sXYsaT5?OGZ zRfp-5>u_0a)w_B_-Dv+aKmS{-K^BQn4C|IA>V1^?4r${NYlCx#mFYtI!`ln`bFf@X zEAPkqJ7aT!(BXC2g{(ik`BfsobrGBYvma;%tQrHuKA<|O^AV9ZF@@u_SmD~F*&J? z%LW+kQHJlI;*q~EO52D9xtREk)6g%qzji8NBJcKY^^Kx_?ZxWdi=1|gGX_Eo5PQn- zFk@{E%tzzhJ-qxPp0A<|DES|J9$ z>-qC*_53N0b2#1R%kengLnLD0?>J`l+6yCeHgk{Hv_Mf$WY3+2o${Kq(~K3mhHvZN z0}K#rsAyU+c1d8Dzsny0CSwBJV^(g!T?Cd|x4(w}69@W3EzJeh$7z=c(c4qyz+L!W z8Q3~sT+9xagOSfpd0fK2g;0WqjSM`u4>y3lPj(R`t4vT48}Fu9 zbXN3>j@U`Qh~>>$cYQ4FH2PWC|CxPGx176l4kZYRG;rQBDkdBlH}9US>h9KU>i%U* zQ%g&0sZ57x7Ixf2EL_ZI>A^?|1bku4p%NbReF=1(C3;7M9yy`O>r?XcClK)4QVMeB zUTqewav*>-M3K(NE!je1rf}YJ26}pB`3N4OX!;*0v8$B=O2s~ehNj$aE3TIcq*yud zTs|`=soiGt6n?NYWazJ-Kp?z(uY!E)oOY-!Xf1Lg3tL;X*PQu;B<3gcZF=vf#Y|xdHBi1&x4U`!1Rd!I1XQbPN>0D6Pj{IO zSnu5Y=r00CsSOpK-`tFV3OPTDH-Pqx4NRg<+Dy*APdWrCgCNC$o~Ep%VBKXTxy#Vb ztiT&f!H&M*WK~es{a;dH<`%Bu->rtNM$%e9Wi0Xm(smq0{#&AbI3^y<}xwe zOMy~gV7+1OVI7jtK*&^vi~sS?d1@C)Oc;Sb2mUCY*%_S0L?)&)fd~{X8B8hzA&!`k zqGOcw4I}X9kdzq2VzH7VQy;MQunxWF*Dn)7nuTRT{SxwJ!U+61w}kS(^?|EBa%3R%z6=my2K^|6+K@{ej*R#uYUU<= zUm%Faz-8jg=pWgsZOR_P!RyWI@6peAQd{tb6(;JM9kVW&!aR}DcX8Cz2-CCQu-{HL-0OYT1P|?CM6r8eB0^83E#B&<@@iH5kCO&sNz} z8^6zSoFLw;$+=u^n?4CoU*?yE54^sEo4o#+HIHA5|L1)!(|daqK$_q8#=Oy4 z?qxhUzh?TESqs^%Fp@9uehcRshe#k{{UioWOHqZj=M!fj52e=UNi=Vg7ylw`I~ z1VjY%!PV-a`(062UI&R8%=Et1uy0izF7G`qs{1IgS)jZSPW}uBKCi=RDRlR3VyyI) zDOSc5H|Z~YAW>-7lseu%8EbE6zu^6_(9}n09OA;rP1MX0&aWz+6Q5cZIOU1ibxVjJ z=qy(0;}`^>M2csh)B<}ttla1xnPue!E*vnMEo;}V+qrXBVPO$Yj~h4Y?z`{8XM$)2}nO>>;iUG?0w(GNJj=r@fobM5qx)=cLn z&#zgyJ8S22qdz_GR>sAyw@1I`ti;jGMD{)N?sYHJ`)_ML`kP3J&aVL?2tj9+IUYv>)Nyf(=oeur=V{?t+CSf6KCFYZKnr1+#=Se@H_^6`<8Bghs<^b{t*aV zBtZcUI%TKX(}7Rz1rwpw)%YV?AUt^Io%eU__+`Y1TduykBl}Q~<2(lq8qlp<=i1u3 zoSZ{z)~rJfnmYASnQffUJA3`T)0=ZM*<+{Ox05w{#!rzdzU#|#*FQeG?;UH#a=wgI znZA#&S@XEtvR@Vg5gNi>WnxpbZ3n>CMfQ_B&2C?n7uxU-C(iAM;uD5J(=@yPr>(x^ zRY6YJP2jNMR!=fLcTA2i_>jth!;FIi<9(ncZP}rju`|jMKvl|=9HHUGh;gY#7)H!x zA{t{)-OQc3(-^x!_)=PW@rNIFOrQQlzkb)cebua4I{x8A&z{|`y6Vb>3*Wl`{&6WO z$xu(wN!i&4of=mFCF#pB@BxuCyprbpxwEGs4}STK+hFtifT%Cwt}@A4Ony3dKO-?s zT5G%A>3+Zmzp#{?vgQ4TH*-UBwv@`SH~eP(bnvNl#2t*apEv={P)E4DjH8fc>Gj+M zD>r9s(uo)WyLQ-a=&9{C3wALPleSen8W1A{%y&Ws;c_UIj*Q7JcC+%njAiM1^ktme zVUsfJLGy?p|AL8>3nMh#>2Pk^g>B*D$mHcQF-xyr-Mkm;*6nKi%*{P7qcY92TI~E} z)g9N4IzkB4d=*~ytM=5#H)?$O^MU6Pd;RD^pK*e&K< z+PKm&;_R=Z2$ze&uNH`-p7r0!6u$g=beCLO~m5vou4BxN{!rjvbk z!$HY_g%6A#&Cc|jwq^~R%@ryLn`i0IsfLiOI~Gls+~l5Lc3gk$bVr$cTRgWb?~W`u z|M8O#pl{g!S-#l$HJ_fwr0=*cpnE2{C@rQ*LW#*;6M(3W@k6|Qbv&6m4eH&pw^7`sM&rF??z1TYx^zw zW!K}^vcAkGXRm)I3v$`eiD_eU7DB&i`}NZnWi`*ee%0%%`Yaj?L{JEKm8rgHJH8!m zoT_QsCW_1cmYlNGRWn~j$3kjrJ(vCMd@Eov?@1Ytwq9&|;#;zvsS`h2g4wobw#{3< zjAzS{T*Tzf$MJu^p(iyp<;ENPz46A|fBt!r%l@`*-7+>d7Qd>hsy==C443`gxbZtI z((-3i000mGNkl+NZ0?^ra}n-WRnbmb$aYo|~9W%L8Ad&Gzf`YE}dd)#^b zqJ_*JG37j9`g8woG4Bc>fBKrSRy9!%``?0rxyKhe_C9M%jKFgvTD6l%Af3E!i73 ze2XpF_ue~Z%orvE^3_-0tXlOs*OEPY^hj*U27mF4hJ@|YJhIteuAy2U)GSy0)qM2A zkJtbTi24%&DuX(*Z&lsCRqo68uBzL+3jCwe!dqP!o{qt5V?AdmAt%yrcG0nzkZ~l zXjE@Xp^*f_5c*LFwF&hz*&2EXJe=_UbI_m=^?_=SC!|J?x6Rm88pbM&z@J0Tm!YBF z>In@t$P>C+;NF+o^eQtDVFUgglJBu)-(*1S0glOs89Hu}y;bxHAzr_(R~&;pxk8OQNHr%w`L@Gl7Bi zhP8)vDCsI*ZuqA%0oNB-*E*J7_XeYr3Q=$Hhv0q@Nuj}2aIapU% zV_0ukdl8{BA^36q_GJ*8Es(?FE?m5G8VrX1kDJ@yQUyc7PG~y58rKy#>E1$J zpgN@q__ZrP*v|15RUnd>jOXyE+eBU z1PU)Sc%T-Gh5w{C3abR4ddC|q|EWY`IQZWSu{!}mfX0cI(;Jlokl<2Scp)YxDmFG2 zRYv)dtMVKZ?i;TyF5aoFHKQ)z#R>tzody@R;wK(%R8*Ak@}-M7Sa4OX5x`YD7bq#b zo`fbuKaeDD^$d{%8ZzsJ8T`5={KfCQ40{{YR8`d^B_%+ha3{l^7+t#|KxhUK(8a{9 zOTUp9B1>O5(2&ubC3`aj5&!%V&LL!bh1OzHFkp9vgFmZC{COgjdR8_4QjH08WH7HE z8yn5pHK>Mvz6`LD4(nglmX%dDX;Sa^>ClBs5Df&HFlr0ep+Qw&#%}@Rde&i!CMIn$ zI}HRuL?CFqXu@d7ay>JHh~K^P_?~sx9?CQ~)zISt1QB7N*`o1sT^d5r?jA@m#&y<; zj*iyrwPj@$Bs2sOL7>5+*`o2XCae#^AUJ_UOk7J7s$fhEx?7rxib@Kd2qGdtQ$>SC zvqj?#K}Lonz_v-Qn-=D%&1SO+^VGFwz21>$iZ?z!mb8W-!Uwd_%F1eV1^{d^F-9&>s^^ndAyOG|_C4nw z5#Sz5s;e_=m=UWrad9zGQBhnMK5DBbh)}|M0E}VQ)zwv1)o=@&1)KvI=Wejm=~M z3pVrWKohOe#c1^LT4TI6HbIvduT3^+V;bC!g4U9)9;7t{0j#0b+Gt=1SsQwnL{UkDYX zHN<5L6bs)~7fJ1#CRGc&Vo+qOM= z^yt^GU)Qc(6>g2RhPbTY3@xX8*Z%UI7Q5BP*_{?;23Uz<^+l^={uq_fMQOBAtja`# zQ)p}`F=qC&7AK7H{q>1Mpx&mWuF5DZk81MY|NeK?s#O+?rDe;OMx(Ko#cVd?x9lKT z3?`H5%$YNKy?*T2u@64@AgYg&jg!_8mjlepaO?7aDg7qD=ByQLRy*@jSI+#{hXi6Q z_RLyM3ln6UZ0Tl*!xfCWnnX=Wx~?cwUyz|IiM5-eP-4IwWi#t-wFXZ$si~=0;P>Bu zpFDZ;6Hh#$WEUhfL?ebXw3C$wzAs#9v0KbGs}&s?Tnwl*TD!iT)iBtm&DJT>$?u@MtwMJ(%fxXV2HVG03K%l0imdYq8iSg0QnKRKzLHW3F;X-9) zB}$CXwNPl{YgG}Sjnl(w-^kS08muTWwOU&pstnlb>`ngyP40kz zOey#(qfm*~tXcEaQ%?;VG^nt!@Y1DAO7uktYE5!-az;kR)~#FT&!0bf^k^k(k=786 z3C`AjzqsY6!j-kwIf~3ihmpv%fLZExJJOQIOsmh{gwJYbVNn*_`{f$y!@yLzO{a z#+(x0qfN#`jzGxoPkZyS4L$Q_aJ#tJ^e-tIEm+Z)F@xO#V6kUShm^tKryT`TWfXck zYuB#DFsFU{4(HFGFE1~H`jMEJ*t~i3qeqWo@HJ}GC?zM~h=m3vX6-+~YIZHr8DP{u zNN5@xoUN7AoL=+iER)q#YprE`8JoFflfmtq-5G$ubok_1XkstuF)G%6^2*%T_#Krs z8JVPwW8E1G*sV;er|rKX_A2nlj)JB#3f&yc#oTen9RmjrJagu3{UwG2g$7%uwr$(? z<(FS_IU1km-&loa!+bck*L`8@UNHPAAQ}z)OPs$x@qcH^f2%>2v6#@8X`eB;Th>Sj z2z^Bf=m*?lsv)EsYQzxMMXtlef1T$t}$6_ zYHf8bQo9UoC!I8?HJg4o^4-o8+lwj+-+o{@yG+>1#1MUaf7HdJH68nD?Nu4OA93G8 z)~INk7Td=GFs%&s%zGj8Zcs;Mq@QC_E-~0*jV(%;?hUTQAlL-XwN>FRu!Wo3srM@I zS%4tI5YCz^O~re2S6D1HR!fb=VoEY5jqQYHZ}N7@bZBjRihd+I#kc1Ls#&TWe}8Oj2gbwa+9NlkjRjR(xvP ziCsUR-fOX0duCrfrrYqhc7AkHd$z9DT$+0^#h93AOvp-ZcGuOn^l8z}O=+OCP0|X)y|vn2lUr4A;OwE?3+J!P zyjo+i0{e#(+8tXVA=#di1+iHgbDgH-D6kO{_o=C^u_a%r0VCM8VApA?PTSIlXmrt_ zz=-_n&8t_wvgcQ;R;@w_sI066)xeEsWMtruxS&aRFme^q$l@k!OTreV%cLdymtTHC zr}gp2AD8o1o__ji?oqhL0q@A1yRZ!`X06q#v08L?+g&a1024MV+LIyre{+GrgI+Hv>FXNbhtJ#EipDBrlz_UFZ%rzhu;2a#pGUhGb+I~V)yI^(J{Kc z-#|*Ntu-!>(;oYT@noDHjrPP>(E2azjD~q^;lj`M%%K{-gI|!)d!DnG>JWlWOXzD{ zym--jtn}Dpj{zG69655t?W+L;26XJ$@$0X@&dbY_TOEBR+y(9kcjwk2__7k3%cMmq zDJco_anC*XoR=~ySFW_#Y#66QV*yX6;KEO=#8`D!TdUX>U~(n=_n!N`u>3-$wZ_V1 zYJqtZfla41=s>G6n=K{hON+tYEGr|URa#7(@yDO{Pc~t*w=-hGaHud}bNZ~Ux4uF&WbHFedjE@`!;2P_#wf*z0tIEodTF~-l>*j!jx zt_0a7VbiE?v4@hRNMeKKZ17 z|Nhz8*>D*U8ynlXb7wqXFE7~Hv}x0!Lx&oH%J7a%ZS95fOGmU8o8D&ETkSU__tiL6 zX6>2ntIvLKiL*zW4OJDEm>46jkyfLttE)4m#ahi49m(qrKV9z^7>Hs&vJ_Gb)3URCWexFWO_81lOI>yPdO_ z@$ykL000mGNklTsLgzkfeA0GUiC@CBB^lJn=!zvrHNgzhe8Th5<9j|o2|tK;s# z!%4Xu+?l<5_jc;k=`v9nU^4=(TYC!#F{EltFZsd?wXE#0-eNb{z-+O_*bEt(6ky&q z%Xk;7Pj5FgiMH3)SNF*S?vZZL|HZI+GI^_oyKJ5KBElU%$6AmDG4=| zwMl77(J@i6anUJ;_^eBCOKtOt-0~*PQj(h_W@M%3X)bQhJ)p0%^6QE}&2VLRX4;kt z+H7XYk+YieI$Vy^pfNJzc`|=mZDk&JR1iG0{Mmv`EyyjwUmXPkB>)sKZ{9qViGc$L zjvqgsU&yJ9PhSRifjh$8`8*_r%ZW(j^Upu${8?-bz>DGZ>C+I&2rtgs@?3*Oqql&; zs%frm3N^KiKf-<{C&cS@+Pd>qak^-OP8Wmjj7FCo)f8{;S^2e3rrlh6urL8)QZtfM zuWIsD@owR|lAm@=@)_cB+TP_3&?h*S+oHgtVg9UIs3?>?$ok;ld#v3$%b3k(CB7?q zU#Jv+``h0tDk`3Q@=3mpR-rG0yTBa@Te2FNeB|clV)6wDuM`&-V{^gdEWloV&?sPD zGhbC=uxg`hx@em&-Ol+kb=+r-)^dJt?igc_d%|^m7l7sspi*J{s|U6ST)?ArutIQ?Mn-aeF5QY0LOtT7;HPxYPAa% zEWoA&P^>aa4j`i4nUazcOuhi&wVOuYzJ0&__S>gfa5ghD6Cxi+0@zexu<4?hKYg+# z25Rb9zk~Tac`om%*n$0{T3Nx?yhU1cY*d0--#7XSy~bEwZOT7)Hrp2ac*6C|GXHXK z?bY|hc8{*L@#`x8)YRD+FUHFHGHjD^a1AH$H>F?_&R{U0YA8>|VRO<4AAB%r(xiX= z>tERRgxO#8aePKhxC`7-e0)3*jU6vJdu)!p{`%{Sii)s}DLOhjEiDZq94`HV=A*5% zpvqV+I-5;rtVOAH-FFwH+}^{GRc15U>a6ieaToXI-yYRH)oMsGrj(cGpFMlJc|t0- zkHnj;0~1;dG_-(PrT1*NV`2OaE!%5`4N!vH#-t6{k!jYfSyWV%UhkhyEm^W;&z?QK zd-q1a7kwFYaD4hQxC`77?hYESJ40~b9mUJS8c8bMovtoUYyVTgAVZTTWLBSM>7_ zT_ugvoe6%+tlz#2e-X58+ZLx$0+fe5z$RJH=IVlS(EYozCZsKdpp!I z7k@OwU~1<0xjdY$Y2Ufz;91SG@8a$nq-zzB%a4u>N(|R!MrTwdFXM?4H=%p??p#k6 z@Dp@lX3Us@pN~EE7?+^&4%aJ3vKN*@cP7kf(xgeRUcLC)D_Hnh{0a-ep?F{l0srd2F~LqhZyoq;^rVi~bZ&XH)`-5$ zrSj@arIq=`Rm&4H3|Z;5n@(E~Ui4ho_p})pU&hY*GKK^Qwkc^Ovnq1dD_owFlQU(? zl+@Hz?>%=>N-)Ly<(FT&Egv>)*x zZmSI*xZ&1HTl|@l@`=}7ZTkFo+o2kLyLK9*QP(s<)5Hj|Hcf&Vl1$#WW{U-DoYuxT zG6qdXTU+0IbSz$;&kn-)GM+W!xv)!@E*Q(Cq@-}~LXlhjop;`mT)uJRMm!DlQ(Cuf zed5Fkxz%H0VsIDb<>k0L$yiBA07XHT+9;D4?kf#qFVS%=r)NK;TB~%xh>hpi9a!+YJ_A zY@keM<}xqdL6yPKDkcdoC7$lrJ3ce*q9wH;J2|gebgC{^tEo(YOFAg7f&79!drEto zeY{MuN)HyMAxMWoQ(RIrgGM74D+ER$vc=2I0}niau1dFV-4woe$qm$(Lx&D|={<~& z45lOw9z6K&JMZ$lmN;#@NN9o#Nlas)UOrTCB}NE$F&k4`)Plimw=pf-THD7Nt-o(_ z)o<~s1;*GKyVGN)qgzN z{n}CX?w{*!#C7#$m#h~`>{W$KtYwJGO-kI8c~!rfqg7T+`!ac%nA9wNY+~?7T1+1=1;>aE@qF-=AVaa?Ybzd2LBA% zU2e{}vZnzZC^q%05rj@|1UdW5SBl#N&zw1PMvopnV88&ZEKJivFyLOvv~}y&)vMQd z9tn5m!sy8@`RK=u%CSv}%@E4*zXHRcv57zC`W!~UmkhI8wtp>iThwMW@3i-~?`(Fn z1{-?xFuGUT>K50!t#Pw)^~l)OiI(ar@bF)(?w@s5Mq=t%q3IZNa$diJq|EzXKQYE_ zo8xnTH5m#m8s^PRHmx?fc{`>Xfqc_aU<4tZjIBTOSUPm*fV1@%8ct$<|NZw@U!8{= z2mf&-YLw+`IL{0Mroj?78UVlbG+5Nz>66F-H&{Dhz1r@^j54MpEmYen`?sm zjMG)W-0nmBi8J%R?gZ5)<^?GRgDxiK{t+eBnpl(=v)$gZO)o=K40|{lP&F?T*wu5_ zU3dNZ>#tvb{dL!_U9ow)KA{r^N}R+@p8Uj3H{JBnN6YWNdmP{QZ~5|NfBf+WUl^;S zQ>RW7COin@Q(&ILaLlm9WcFp}KfK~^onS8Mz4zWbckUd&1W)9;>#qCai!biE=N|rC z{`=qm3eN@D{&){R{4iHQ_${uEF)0R*+~=@i!`K2JdN0smh^w`>*0gl#d%3wjY6I9& z0k6IG+E%Sv0o!%+^UptbE3k3nMm#~j7CcBxrA=a*}>nl>%E?fLUZ=20RI)7UfNs-pL++HplYrRMa$VrQ+{is_9gdH=<1 zV`3$-2#;^DRmL`_0ks?3pGf>h#t+u+*{dF3rs9%JqlTD8EH3Bk&P9 z-6k+%%E-uAvSi7iL4%^Bqc2^$$dMHO6jFh)eF;Vc@Q#8?<(t zPHSzOeWA~<(R(Ul)02y?Zt)}7tEw^4t7ilyO7HHfxIT5_PgY`ZEsK@;jMY~?+U3XN zM|8VSrT0&&zM)@ftt}Rtx9aTH_O1KJ$0lo8ga--1AebF+1`ZsE67zrm_kU;rcw4@B z@uI>OZq%3f_;_rz&d<-^vuDqZH{Q5w*Dv^oC&`RNX&3yacEKkoRa?7%i@n&5-LKFH z)dB5Fc%gfaLgw8{<+?-)r`@&>fg)VfM@H12QRsP`wZZomar7DC)-aiBi;9ZynrYpt zwcGAdNip{)*oEtOxzk5)<>{xNUbbx6(4j+9Qc}>795ZH2W@e_;Q*zD&)Cl5D=`)f1 zq3wzmDZ?&k&_K|%VO~b> z@?y+v-j~bBaG$sqGr_LaA*1l_ZoieB>ihRQ{xsR*(2+6QEXhe%w7=qdJOD@hCZF^T zj39UOyZY*@*REar?z``zQ`5F>Tl9YM4ue9&j@`+?HEH4b=vh#YxZb^Zr#f)pK%d@y zKKbO+q(p~{i+8+rU#)W&8N0xlQ*IgVNMAypmsY@bi>Hj|j$Q`8h1}fS7hil4HI#Gd z@ecgTE3XJwK}qE!CeLF~t|RqMnkcp$pQ`IHX;*D+ZUdE3fk7aN#=;omAaC^iW z#sKZsVZ%K8jDs^=@qLnVGkzl*H1Q2O9rQzfzqCIt)*2IQ!-nlgZrinPUp(i9vYu(n}bE zIS+~@c~+rgVpW0#ncFXIK74RLRFb&TYK7LM)5 z-MU|?YxdCk-xpgoRS}LecsnUUzgV6XEUoBZa#}=PZ_hbh1<;fFPsMy<;>c= z`_*+L`<$z_$JwlPcAdd)HTP=qKx$kz?wXev{xeuY6QIg)QKfLgZFy{LENTsU$h&v% z#+u!KVE@UJCov#0o0-wbQDrd2i|Uh=l{IF}*imeV>G72_fV^x9Ug zTq!IsOar57p*gvv#Oc7Hd|-WI+~M4*Xz%#R7HC#jSWtgoMj;**1(H^G`t%vBjY*Rx zq5HOG%^I8laCI2Ly4tg6H&#)!F&YJ^ckez}IVigT?g1u0jjLd87_SqoD|YtOhaY~( zCvG@7#so9S9kJ*=80@n=-cM6z&ac&^zIWb3xXT$Ew9(S-+K%bH@)Abx%v&0*H$~~{ zq7CL@*Zg+)c$Wg*$;7xS$;^vfOOw$3xpXP+%%4||z9Fa19%r{1>>2~uqdGOeFDv;P zE&FUFr!s=N37R+Kt}0PFelLm9Ma(c+e&w_t{H<2M^0e|$1`pJ9z~^I!gQ z|Mu#=!TVs~%3J9Fj?2GJ8IPE2G-!pz;e zb?Y3p!PW;V3$Mb!R!)x|-QAMqc!K}=&wse93nQjGSib%CTW+nEEnD(0VwQZK*8M9t zt&u-xPoK^1s|3hwMzWxANIK4)VGQ1GAKZIHYPRji6aUt0?0PNJJFjc!KYJN;YrelW zIqMe*FUGUK440SDLzI^BfWH3a+6jYyZ`J37*@l^Bi^givYAx-WOlqERtDftF=ad-E ze4KR?9HCL#_gDD@_ldV_^j*1o@6e&Qcy5_HnQu3Ji)FfX>wfUSfmN$N6CIi0a=H6% zCj4HtY9+WGy|c_cRzqo?DZ;650Hu&^?T@h&!AmdeBAnAWWuJ9aFec0u#Q=4$>9 zi~+@i4?c(nBe^=(Czn0N&s%P}#j{tNM*-E5yiT0+ZUK}=@Q(p& zFQAxjzxg(!Sq87yDDJ}g1gBT%zMAmza62wxYZr*mM>;(dxYH=O)Y!^$!sRYcAFNz9 zCAYTKPtkWRj51Ui^_D1uEjHF3rMFux(d&O`UmJfiEu~nlzpud=G!>MlUCg`kfm@Eo zG6Mo@t#+-&rZt1!Vl!qX&ubDh6eWfoS67DbW%x^)Jutm$|xJQO~sd2B-fTTX&$Q|(zBpn?_%hkqhX#=(lbN* z+O?q3#Tj~~#r{3U&`IZz7~H48k?~Ak)!Uy5LiX2-?r1dL+))0<(}*>}nT3(x0$)4-5D2 z-~ZqL{KF&dAf_Ie|cuPeLcoZIb=Yua}ln+Ao3 zc`WbncQyOgmfP$xHh3Xs;M(|`KZ=b9O^gQEOtD65PitN>JgpdmDZSRtI5JTv85*6Y zRu_SLWtyskBA8Z z3YtK0N40ys%Yof3gDdu5>=MsLx+b*ZCC-)kS=UK=giB(*aX$^`S@`f}f?~|h1B77w zxPffP?x6wBY$X5TR$ii3q(w*OAcY}{#?oFa7iNAo{4n_jI`~gTP zhSyElJ<5wVZgV+z92kCUbARWU#Vc@D9P)lV% z-m;XqGr+v4Kw_bD6a0tJnQ(Vr?1E_k`3Vj0r+_H+%h!r1abx#96uaG<#K?tBpiom3 z5lp4f$ax@Mv3vM29+Ph6JzQ50p%J@_ZkMX?l%=%>-0Fniyl*_~Q<&@1$O#RvK@v`@ zj2G;78B44uH!mZ@+>P0hU2_Tz2d6b?S)A^etc7Lf-zw@3Ra;NiS@JBl63|qm#6;_3 zqjgPUqq1W4ZDRGEqcmOhkdC^7>0PEPtfR%6ZM9}~NXz>%jw%s4nr;;&BFHKO{<<@E z_b5qt-vf}2(zzvEWXOFb$ku^dQcCEsa8akr3(|O8WExqC5txVF?SA69(Bov>36d`( zZD1agUT)w%b|$`q`ZyAyYPp$)v_OMcYoxa6?cM>3CDQJ>VfTGWyGU-k74A|axgRAG zV^@5z61C&m&?)q5&_Z#J3!q%8V{<5wTjetsyVhYWOj?(b66TZpr*mduP<1q%599P- zw0v4f;?mNDRAs=I7vl&or8dGPYxp0<;j`FX-+~~9Vl&FFY556Je54UZPl7O?RU(OB zmcM93vLgQ7Bu~?|mHK!z{@9fF4FQBYZj$iJR)TZRFwQkj@@%TM>mHFnIZw?<))?Em^3mydL_#Uzm=Qj zmk4KNB?g=lV|S%*G#ZW6?TcN=d%9ZjtZ%~;nS$Np69f^H`j-=M&dX0b3@LJ z1tK?gp}}Z8Ze!k70lTQOgmDvqM7+Ru_hPor9kBC8W9RNEe1~@7v}k=_5fd)~f(y;g zw_7`#v-m5+?#DR55*La@0k0d7lNis+av2Wq!ZSj`HXAYT-o*B7t&UL_tuwZAt1PY+ ziVKFc8pqQ~t`A3eqydM~)a&Z|^UpthF88}7+q>d;PID7NA4gcD4($*Lh$v{1SVhZU zjqEoXPK+GI>>5|Qq~I#Di$zEJu}Jq%f!2tw53l-S_dH_fse-&qZV!$>B92-74?#`})=w_sSMJ|H9RU_jUd#g&qa_1|<|1=hw`6YCsTp?se@WT}QIg}* zkqMfdt2lxqPNeKiQeCihkjVRby-=mTkP-|Z5@YxB9VehNzbyN$!kepbC$d`usiRkn zU7!X^4nt%wcK1c%XA+$iA=sHy20*k+?{&==(17$aj{+5Q->^{lG#>IIwJZO>lKWmt zVx%{t(YW+d<9rsuEv(l#HHBM)`^TL&iVK&N)HppC)<#^00l}bm@7||Qo#Gys=gH2_ z#^wC17V3vghRrdn;aybui=O?7HLifjDKUIW4R;*~|D0=zwrLkHq;R>G>%x z>}`8?MLV;5Ngsm}O>kDG>)h>uN{P=s+5O(IlnFVP+dcg#&)l7|eQ=%f1@m@C%Zz7H z@q}Fzrh8bqU1CbUxI&3>b7aIj@bTk0jfg3x5eyLF41&0LNXHM`pw;p%NX+oP&Icf! z+p!C`>2XMbGGfFCgTcVh;xsN17T^Zp)5x8n+^qDpT@d34zU6T(wYwx%<8)UAKci4_ zG%h>RIH&pU!gl_4rV&a7Kdq_1VB72a8>oj!d&B)Oy8~IV7JSyQ{Vbh zqrQbds1Skq*xiJNpB4@f<5obJX%B)$cn}*KD}10Ar*Vm}fPZ-zFCh}2dGQRFgsXEC zp5NK|?Ko%bf=)Cbh?T&ZngRE0o@>Hw%Nm*8g&p-v1N)QB6tC#mxy*O|ZPU(wfN07N z{3AnN!!VyG;nl&iD+xTshhgWld>X-*!9q48BXv}6vu-EhG9SD95_f;VV@2tBF?d{k z_0>g1MQ6^OY1y(RPP=`p@!nD^x!g-%7@K>gx~@6l?ac1sbnyO*$TtBAm&7s6e+ctg zylRVEwLKgxyU=ZQ9EDx_Q|4@?QP}V#dp}WlqfrpZDA|zk7h%`oKSYb%HYLpd%2%5B zL%215tL`aCjl!g)uS#}1X2I6n_8|D+)^mCb9(#0txc`$sM#0gFmJ8)qPLD-rB8~eu z!VHe!>|N)s5mdZ$eW$Gme-;4o&m>x1h+q9>eCK7n|K=LGT#R5=B4Tp;N_bmgk&_q~ zc2{}u{H=oP919QI`?rczLL<921mhrBe9E1&I+1ipp&_yRTSB4o%WptMg1IM+I?+&2 za79cSua8!H={}>8X;G4F@Rr;@A1;YB!tTvQbQIt_$k+w`uCu5ruAxu(Mo2H;)_*7L z2{SOI0000(Nklj)PFwLQV8pWhR#fhh#>G0Il)6&r2F8Bngcwri;~9@ ztMf6GU{=;t8$?zJ77dWPG+OFuSpOdY0RR6evZ}QJ000I_L_t&o0OboLK-{90vj6}9 M07*qoM6N<$g51(t0{{R3 literal 0 HcmV?d00001 diff --git a/settings-stacked-layout.png b/settings-stacked-layout.png new file mode 100644 index 0000000000000000000000000000000000000000..bbe9b3d5ea81971d3dc5c9fd071163f33c40cbed GIT binary patch literal 67752 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 z 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 literal 0 HcmV?d00001