"use client"; /** * [파일 역할] * 자동매매 엔진의 실제 실행 로직을 담당하는 훅입니다. * * [주요 책임] * - 설정값으로 전략 compile/validate를 수행합니다. * - 세션 start/heartbeat/stop를 관리합니다. * - 실시간 시세로 신호 생성 후 리스크 게이트 통과 시 주문을 실행합니다. */ import { useCallback, useEffect, useMemo, useRef } from "react"; import { useShallow } from "zustand/react/shallow"; import { compileAutotradeStrategy, fetchActiveAutotradeSession, generateAutotradeSignal, heartbeatAutotradeSession, sendAutotradeStopBeacon, startAutotradeSession, stopAutotradeSession, validateAutotradeStrategy, } from "@/features/autotrade/apis/autotrade.api"; import { useAutotradeEngineStore } from "@/features/autotrade/stores/use-autotrade-engine-store"; import type { AutotradeCompiledStrategy, AutotradeExecutionCostProfileSnapshot, AutotradeMarketSnapshot, AutotradeMinuteCandle, AutotradeMinutePatternContext, AutotradeSetupFormValues, AutotradeSignalCandidate, AutotradeStopReason, } from "@/features/autotrade/types/autotrade.types"; import { fetchDashboardActivity, fetchDashboardBalance, } from "@/features/dashboard/apis/dashboard.api"; import { useGlobalAlert } from "@/features/layout/hooks/use-global-alert"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import { fetchOrderableCashEstimate, fetchOrderCash, fetchStockChart, } from "@/features/trade/apis/kis-stock.api"; import type { DashboardRealtimeTradeTick, StockCandlePoint, DashboardStockItem, } from "@/features/trade/types/trade.types"; import { parseKisAccountParts } from "@/lib/kis/account"; import { clampNumber, evaluateSignalBlockers, resolveValidationCashBalance, resolveOrderQuantity, } from "@/lib/autotrade/risk"; import { estimateBuyUnitCost, estimateOrderCost, estimateSellNetUnit, resolveExecutionCostProfile, } from "@/lib/autotrade/execution-cost"; import { clampExecutableOrderQuantity } from "@/lib/autotrade/executable-order-quantity"; /** * [목적] * 자동매매 화면 상태와 서버 API, 주문 실행 흐름을 하나로 연결하는 메인 훅입니다. * * [데이터 흐름] * 설정 UI -> 전략 compile/validate -> 세션 start -> heartbeat/signal 루프 -> 리스크 게이트 -> 주문 실행 */ const SIGNAL_REQUEST_INTERVAL_MS = 12_000; const HEARTBEAT_INTERVAL_MS = 10_000; const RECENT_PRICE_CACHE_MAX = 20; const RECENT_TICK_CACHE_MAX = 40; const SIGNAL_MINUTE_CANDLE_LIMIT = 24; const SIGNAL_MINUTE_CHART_REFRESH_MS = 25_000; const ONE_DAY_MS = 24 * 60 * 60 * 1000; const FUTURE_TICK_TOLERANCE_MS = 5_000; const MIDNIGHT_WRAP_THRESHOLD_MS = 12 * 60 * 60 * 1000; function formatSignalProviderLabel(params: { source: AutotradeSignalCandidate["source"] | AutotradeCompiledStrategy["provider"]; vendor?: AutotradeSignalCandidate["providerVendor"] | AutotradeCompiledStrategy["providerVendor"]; model?: AutotradeSignalCandidate["providerModel"] | AutotradeCompiledStrategy["providerModel"]; }) { if (params.source !== "subscription_cli") { return params.source; } const vendor = params.vendor ?? "unknown"; const model = params.model ?? "default"; return `subscription_cli:${vendor}:${model}`; } interface UseAutotradeEngineOptions { selectedStock: DashboardStockItem | null; latestTick: DashboardRealtimeTradeTick | null; credentials: KisRuntimeCredentials | null; canTrade: boolean; } interface StopAutotradeOptions { silent?: boolean; } interface RecentSignalTick { price: number; tradeVolume: number; accumulatedVolume: number; netBuyExecutionCount: number; tickAt: number; } interface SignalMinuteChartCache { symbol: string | null; minuteBucketKey: string | null; fetchedAtMs: number; candles: AutotradeMinuteCandle[]; } export function useAutotradeEngine({ selectedStock, latestTick, credentials, canTrade, }: UseAutotradeEngineOptions) { const { alert } = useGlobalAlert(); const { panelOpen, setupForm, engineState, isWorking, activeSession, compiledStrategy, validation, lastSignal, orderCountToday, consecutiveFailures, logs, setPanelOpen, patchSetupForm, setEngineState, setWorking, setActiveSession, setCompiledStrategy, setValidation, increaseFailure, appendLog, clearRuntime, } = useAutotradeEngineStore( useShallow((state) => ({ panelOpen: state.panelOpen, setupForm: state.setupForm, engineState: state.engineState, isWorking: state.isWorking, activeSession: state.activeSession, compiledStrategy: state.compiledStrategy, validation: state.validation, lastSignal: state.lastSignal, orderCountToday: state.orderCountToday, consecutiveFailures: state.consecutiveFailures, logs: state.logs, setPanelOpen: state.setPanelOpen, patchSetupForm: state.patchSetupForm, setEngineState: state.setEngineState, setWorking: state.setWorking, setActiveSession: state.setActiveSession, setCompiledStrategy: state.setCompiledStrategy, setValidation: state.setValidation, increaseFailure: state.increaseFailure, appendLog: state.appendLog, clearRuntime: state.clearRuntime, })), ); // [State] 주문 비용 추정 기본값(수수료/세금). 환경변수로 override 가능합니다. const executionCostProfile = useMemo( () => resolveExecutionCostProfile(), [], ); // [Ref] 현재 브라우저 탭 식별자 (세션 heartbeat leader 판별용) const tabId = useMemo(() => getOrCreateAutotradeTabId(), []); // [Ref] 최근 가격 캐시 (신호 생성 API에 recentPrices로 전달) const recentPricesRef = useRef([]); // [Ref] 최근 체결 캐시 (유동성/체결흐름 파생 지표 계산) const recentTicksRef = useRef([]); // [Ref] 최근 1분봉 캐시 (신규 패턴 프롬프트용 봉 구조 보강) const minuteChartCacheRef = useRef({ symbol: null, minuteBucketKey: null, fetchedAtMs: 0, candles: [], }); // [Ref] 마지막 신호 요청 시각 (짧은 주기 중복 호출 방지) const lastSignalRequestedAtRef = useRef(0); // [Ref] 신호 요청 in-flight 여부 (이전 요청 완료 전 중복 호출 방지) const signalRequestInFlightRef = useRef(false); useEffect(() => { const tick = latestTick; const price = tick?.price; if (!price || !Number.isFinite(price)) return; recentPricesRef.current = [...recentPricesRef.current, price].slice( -RECENT_PRICE_CACHE_MAX, ); recentTicksRef.current = [ ...recentTicksRef.current, { price, tradeVolume: Math.max(0, tick.tradeVolume ?? 0), accumulatedVolume: Math.max(0, tick.accumulatedVolume ?? 0), netBuyExecutionCount: tick.netBuyExecutionCount ?? 0, tickAt: Date.now(), }, ].slice(-RECENT_TICK_CACHE_MAX); }, [latestTick]); useEffect(() => { minuteChartCacheRef.current = { symbol: selectedStock?.symbol ?? null, minuteBucketKey: null, fetchedAtMs: 0, candles: [], }; }, [selectedStock?.symbol]); const prepareStrategy = useCallback(async () => { // [Step 1] 시작 전 필수값(종목/인증) 확인 if (!selectedStock) { throw new Error("자동매매 대상 종목을 먼저 선택해 주세요."); } if (!credentials) { throw new Error("KIS 인증이 완료되지 않아 자동매매를 시작할 수 없습니다."); } const promptForCompile = setupForm.prompt.trim(); appendLog( "info", `전략 컴파일 요청: prompt="${promptForCompile.slice(0, 80)}", techniques=${setupForm.selectedTechniques.join(", ") || "none"}`, { stage: "strategy_compile", detail: { aiMode: setupForm.aiMode, subscriptionCliVendor: setupForm.subscriptionCliVendor, subscriptionCliModel: setupForm.subscriptionCliVendor !== "auto" ? setupForm.subscriptionCliModel.trim() : "auto", confidenceThreshold: setupForm.confidenceThreshold, selectedTechniques: setupForm.selectedTechniques.length > 0 ? setupForm.selectedTechniques : ["(서버 기본기법 자동적용)"], promptExcerpt: promptForCompile.slice(0, 180), }, }, ); // [Step 2] 전략 컴파일(선택 모드: OpenAI/CLI/규칙 기반) const compileResponse = await compileAutotradeStrategy({ aiMode: setupForm.aiMode, subscriptionCliVendor: setupForm.subscriptionCliVendor, subscriptionCliModel: setupForm.subscriptionCliVendor !== "auto" ? setupForm.subscriptionCliModel.trim() : undefined, prompt: promptForCompile, selectedTechniques: setupForm.selectedTechniques, confidenceThreshold: setupForm.confidenceThreshold, }); setCompiledStrategy(compileResponse.compiledStrategy); appendLog( "info", `전략 컴파일 완료 [${formatSignalProviderLabel({ source: compileResponse.compiledStrategy.provider, vendor: compileResponse.compiledStrategy.providerVendor, model: compileResponse.compiledStrategy.providerModel, })}]: ${compileResponse.compiledStrategy.summary}`, { stage: "strategy_compile", detail: { provider: compileResponse.compiledStrategy.provider, providerVendor: compileResponse.compiledStrategy.providerVendor ?? null, providerModel: compileResponse.compiledStrategy.providerModel ?? null, selectedTechniques: compileResponse.compiledStrategy.selectedTechniques, confidenceThreshold: compileResponse.compiledStrategy.confidenceThreshold, maxDailyOrders: compileResponse.compiledStrategy.maxDailyOrders, cooldownSec: compileResponse.compiledStrategy.cooldownSec, maxOrderAmountRatio: compileResponse.compiledStrategy.maxOrderAmountRatio, }, }, ); // [Step 3] 계좌 가용자산 + 매수가능금액 기준 리스크 검증 const balanceResponse = await fetchDashboardBalance(credentials); const priceForEstimate = Math.max(latestTick?.price ?? selectedStock.currentPrice ?? 0, 0); let orderableCashForValidation: number | null = null; if (priceForEstimate > 0) { try { const orderableCash = await fetchOrderableCashEstimate( { symbol: selectedStock.symbol, price: priceForEstimate, orderType: "market", }, credentials, ); orderableCashForValidation = Math.floor(Math.max(0, orderableCash.orderableCash)); } catch (error) { const message = error instanceof Error ? error.message : "매수가능금액 조회에 실패했습니다."; appendLog("warning", `매수가능금액 조회 실패: ${message}`, { stage: "strategy_validate", detail: { originalCashBalance: Math.floor( Math.max(0, balanceResponse.summary.cashBalance), ), priceForEstimate, }, }); } } const resolvedValidationCash = resolveValidationCashBalance({ cashBalance: balanceResponse.summary.cashBalance, orderableCash: orderableCashForValidation, }); const cashBalanceForValidation = resolvedValidationCash.cashBalance; appendLog( "info", `검증 기준 자금을 ${cashBalanceForValidation.toLocaleString("ko-KR")}원으로 산정했습니다. (${toValidationCashSourceLabel(resolvedValidationCash.source)})`, { stage: "strategy_validate", detail: { source: resolvedValidationCash.source, originalCashBalance: resolvedValidationCash.originalCashBalance, orderableCash: resolvedValidationCash.originalOrderableCash, validationCashBalance: resolvedValidationCash.cashBalance, symbol: selectedStock.symbol, priceForEstimate, }, }, ); const validateResponse = await validateAutotradeStrategy({ cashBalance: cashBalanceForValidation, allocationPercent: setupForm.allocationPercent, allocationAmount: setupForm.allocationAmount, dailyLossPercent: setupForm.dailyLossPercent, dailyLossAmount: setupForm.dailyLossAmount, }); setValidation(validateResponse.validation); return { strategy: compileResponse.compiledStrategy, validation: validateResponse.validation, }; }, [ selectedStock, credentials, latestTick?.price, setupForm, appendLog, setCompiledStrategy, setValidation, ]); const loadSignalMinuteCandles = useCallback( async (params: { symbol: string; credentials: KisRuntimeCredentials; latestTick: DashboardRealtimeTradeTick; nowMs: number; }) => { const minuteBucketKey = resolveMinuteBucketKey(params.latestTick.tickTime); const cache = minuteChartCacheRef.current; const shouldRefresh = cache.symbol !== params.symbol || cache.candles.length < 12 || params.nowMs - cache.fetchedAtMs >= SIGNAL_MINUTE_CHART_REFRESH_MS || (minuteBucketKey !== null && cache.minuteBucketKey !== minuteBucketKey); if (!shouldRefresh) { return cache.candles; } try { const response = await fetchStockChart(params.symbol, "1m", params.credentials); const normalizedCandles = normalizeSignalMinuteCandles(response.candles).slice( -SIGNAL_MINUTE_CANDLE_LIMIT, ); minuteChartCacheRef.current = { symbol: params.symbol, minuteBucketKey, fetchedAtMs: params.nowMs, candles: normalizedCandles, }; return normalizedCandles; } catch { if (cache.symbol === params.symbol && cache.candles.length > 0) { return cache.candles; } return []; } }, [], ); const previewValidation = useCallback(async () => { // [Step 1] 검증 버튼 클릭 시 작업 상태를 켭니다. setWorking(true); try { // [Step 2] 전략 compile + validate를 실행합니다. const result = await prepareStrategy(); if (!result.validation.isValid) { appendLog("warning", "자동매매 검증 실패: 필수 리스크 조건이 미충족입니다.", { stage: "strategy_validate", detail: { blockedReasons: result.validation.blockedReasons, warnings: result.validation.warnings, }, }); alert.warning( result.validation.blockedReasons[0] ?? "리스크 검증을 통과하지 못했습니다.", ); return; } // [Step 3] 검증 통과 시 유효 투자금/손실한도를 사용자에게 안내합니다. appendLog( "info", `검증 통과: 오늘 주문 예산 ${result.validation.effectiveAllocationAmount.toLocaleString("ko-KR")}원, 자동중지 손실선 ${result.validation.effectiveDailyLossLimit.toLocaleString("ko-KR")}원`, { stage: "strategy_validate", detail: { cashBalance: result.validation.cashBalance, effectiveAllocationAmount: result.validation.effectiveAllocationAmount, effectiveDailyLossLimit: result.validation.effectiveDailyLossLimit, warnings: result.validation.warnings, }, }, ); alert.success("자동매매 검증이 완료되었습니다."); } catch (error) { // [Step 3] 오류 발생 시 로그/알림으로 즉시 피드백합니다. const message = error instanceof Error ? error.message : "자동매매 검증 중 오류가 발생했습니다."; appendLog("error", message, { stage: "engine_error", }); alert.error(message); } finally { setWorking(false); } }, [alert, appendLog, prepareStrategy, setWorking]); const stopAutotrade = useCallback( async (reason: AutotradeStopReason = "manual", options?: StopAutotradeOptions) => { const state = useAutotradeEngineStore.getState(); // [Step 1] 서버 activeSession이 없으면 클라이언트 상태만 STOPPED로 정리합니다. if (!state.activeSession) { state.setEngineState("STOPPED"); state.setLastSignal(null); return; } // [Step 2] 종료 진행 상태로 전환한 뒤 stop API를 호출합니다. state.setEngineState("STOPPING"); try { await stopAutotradeSession({ sessionId: state.activeSession.sessionId, reason, }); } catch { // 종료 API 실패는 클라이언트 종료 상태를 우선 반영합니다. } // [Step 3] 클라이언트 런타임 상태를 일괄 정리하고 완료 로그를 남깁니다. state.setActiveSession(null); state.setEngineState("STOPPED"); state.setLastSignal(null); state.appendLog("info", `자동매매가 중지되었습니다. (사유: ${reason})`, { stage: "session", detail: { reason, }, }); if (!options?.silent) { alert.info("자동매매를 중지했습니다."); } }, [alert], ); const startAutotrade = useCallback(async () => { // [Step 1] 시작 전 인증/종목/동의 필수 조건을 확인합니다. if (!canTrade || !credentials) { alert.warning("KIS 인증을 완료한 뒤 자동매매를 시작해 주세요."); return; } if (!selectedStock) { alert.warning("자동매매 대상 종목을 먼저 선택해 주세요."); return; } if (!setupForm.agreeStopOnExit) { alert.warning("브라우저 종료/외부 이탈 중지 동의가 필요합니다."); return; } // [Step 2] 시작 준비 중 표시를 켭니다. setWorking(true); try { // [Step 3] 전략 compile/validate를 완료합니다. const prepared = await prepareStrategy(); if (!prepared.validation.isValid) { setEngineState("ERROR"); appendLog("warning", "자동매매 시작 차단: 리스크 검증 미통과", { stage: "strategy_validate", detail: { blockedReasons: prepared.validation.blockedReasons, warnings: prepared.validation.warnings, }, }); alert.warning( prepared.validation.blockedReasons[0] ?? "리스크 검증을 통과하지 못해 시작할 수 없습니다.", ); return; } // [Step 4] 검증 통과 시 세션 start API를 호출해 서버 실행 세션을 생성합니다. const sessionResponse = await startAutotradeSession( { symbol: selectedStock.symbol, leaderTabId: tabId, effectiveAllocationAmount: prepared.validation.effectiveAllocationAmount, effectiveDailyLossLimit: prepared.validation.effectiveDailyLossLimit, strategySummary: prepared.strategy.summary, }, credentials, ); // [Step 5] 런타임 상태를 초기화 후 RUNNING으로 전환합니다. clearRuntime(); setCompiledStrategy(prepared.strategy); setValidation(prepared.validation); setActiveSession(sessionResponse.session); setEngineState("RUNNING"); setPanelOpen(false); appendLog( "info", `${selectedStock.name}(${selectedStock.symbol}) 자동매매를 시작했습니다.`, { stage: "session", detail: { sessionId: sessionResponse.session.sessionId, symbol: sessionResponse.session.symbol, effectiveAllocationAmount: sessionResponse.session.effectiveAllocationAmount, effectiveDailyLossLimit: sessionResponse.session.effectiveDailyLossLimit, }, }, ); alert.success("자동매매가 시작되었습니다."); } catch (error) { // [Step 5] 실패 시 ERROR 상태와 오류 로그/알림을 남깁니다. const message = error instanceof Error ? error.message : "자동매매 시작 중 오류가 발생했습니다."; setEngineState("ERROR"); appendLog("error", message, { stage: "engine_error", }); alert.error(message); } finally { setWorking(false); } }, [ alert, appendLog, canTrade, clearRuntime, credentials, prepareStrategy, selectedStock, setActiveSession, setCompiledStrategy, setEngineState, setPanelOpen, setValidation, setWorking, setupForm.agreeStopOnExit, tabId, ]); useEffect(() => { let canceled = false; const syncActiveSession = async () => { try { // [Step 1] 새로 진입한 탭에서 기존 잔존 세션이 있으면 조회합니다. const response = await fetchActiveAutotradeSession(); if (canceled || !response.session) return; // [Step 2] 잔존 세션은 heartbeat_timeout으로 안전 종료합니다. await stopAutotradeSession({ sessionId: response.session.sessionId, reason: "heartbeat_timeout", }); appendLog( "warning", "이전 브라우저 세션에서 남아 있던 자동매매를 안전하게 종료했습니다.", { stage: "session", detail: { stoppedReason: "heartbeat_timeout", staleSessionId: response.session.sessionId, }, }, ); } catch { // 세션 동기화 실패는 화면 동작을 막지 않습니다. } }; void syncActiveSession(); return () => { canceled = true; }; }, [appendLog]); useEffect(() => { if (engineState !== "RUNNING" || !activeSession) return; // 실행 중에는 주기적으로 서버에 생존 신호를 보냅니다. const intervalId = window.setInterval(() => { void heartbeatAutotradeSession({ sessionId: activeSession.sessionId, leaderTabId: tabId, }).catch(async () => { appendLog("error", "heartbeat 전송 실패로 자동매매를 중지합니다.", { stage: "engine_error", detail: { sessionId: activeSession.sessionId, leaderTabId: tabId, }, }); await stopAutotrade("heartbeat_timeout", { silent: true }); }); }, HEARTBEAT_INTERVAL_MS); return () => { window.clearInterval(intervalId); }; }, [activeSession, appendLog, engineState, stopAutotrade, tabId]); useEffect(() => { if (engineState !== "RUNNING" || !activeSession) return; // 브라우저 종료 직전에는 stop beacon을 먼저 보내 세션 정리를 시도합니다. const handleBeforeUnload = (event: BeforeUnloadEvent) => { sendAutotradeStopBeacon({ sessionId: activeSession.sessionId, reason: "browser_exit", }); event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", handleBeforeUnload); return () => { window.removeEventListener("beforeunload", handleBeforeUnload); }; }, [activeSession, engineState]); useEffect(() => { if (engineState !== "RUNNING" || !setupForm.agreeStopOnExit) return; // 외부 도메인으로 벗어나는 링크는 자동매매 중지 확인 후 이동시킵니다. const handleDocumentClick = (event: MouseEvent) => { const target = event.target as HTMLElement | null; const link = target?.closest("a[href]") as HTMLAnchorElement | null; if (!link?.href) return; const nextUrl = new URL(link.href, window.location.href); if (nextUrl.origin === window.location.origin) return; event.preventDefault(); const shouldLeave = window.confirm( "외부 페이지로 이동하면 자동매매가 즉시 중지됩니다. 이동하시겠습니까?", ); if (!shouldLeave) return; const state = useAutotradeEngineStore.getState(); sendAutotradeStopBeacon({ sessionId: state.activeSession?.sessionId, reason: "external_leave", }); void stopAutotrade("external_leave", { silent: true }).finally(() => { window.location.href = nextUrl.toString(); }); }; document.addEventListener("click", handleDocumentClick, true); return () => { document.removeEventListener("click", handleDocumentClick, true); }; }, [engineState, setupForm.agreeStopOnExit, stopAutotrade]); useEffect(() => { if (engineState !== "RUNNING") return; if (!selectedStock || !latestTick || !credentials) return; const run = async () => { const runtime = useAutotradeEngineStore.getState(); if (!runtime.activeSession || !runtime.compiledStrategy || !runtime.validation) { return; } if (runtime.cumulativeLossAmount >= runtime.validation.effectiveDailyLossLimit) { runtime.appendLog( "warning", "누적 손실이 자동중지 손실선을 넘어 자동매매를 즉시 중지합니다.", { stage: "risk_gate", detail: { cumulativeLossAmount: runtime.cumulativeLossAmount, effectiveDailyLossLimit: runtime.validation.effectiveDailyLossLimit, }, }, ); await stopAutotrade("emergency", { silent: true }); return; } const sessionIdAtRequest = runtime.activeSession.sessionId; const symbolAtRequest = runtime.activeSession.symbol; const now = Date.now(); if (signalRequestInFlightRef.current) { return; } if (now - lastSignalRequestedAtRef.current < SIGNAL_REQUEST_INTERVAL_MS) { return; } signalRequestInFlightRef.current = true; const requestAt = new Date(); // [Step 1] 실시간 체결값을 스냅샷으로 묶어서 신호 API에 전달합니다. const recentPrices = recentPricesRef.current.length >= 3 ? recentPricesRef.current : [ latestTick.price, latestTick.price, latestTick.price, ...recentPricesRef.current, ].slice(-3); const dayRange = Math.max(latestTick.high - latestTick.low, 0); const spread = Math.max(latestTick.askPrice1 - latestTick.bidPrice1, 0); const rangePosition = dayRange > 0 ? (latestTick.price - latestTick.low) / dayRange : 0.5; const recentTicks = recentTicksRef.current.slice(-20); const recentTradeCount = recentTicks.filter((tick) => tick.tradeVolume > 0).length; const recentTradeVolumeSum = recentTicks.reduce( (acc, tick) => acc + Math.max(0, tick.tradeVolume), 0, ); const recentAverageTradeVolume = recentTradeCount > 0 ? recentTradeVolumeSum / recentTradeCount : 0; const liquidityVolumeRatio = recentAverageTradeVolume > 0 ? latestTick.tradeVolume / recentAverageTradeVolume : 1; const oldestTickForDelta = recentTicks[0]; const accumulatedVolumeDelta = oldestTickForDelta ? Math.max(0, latestTick.accumulatedVolume - oldestTickForDelta.accumulatedVolume) : 0; const netBuyExecutionDelta = oldestTickForDelta ? latestTick.netBuyExecutionCount - oldestTickForDelta.netBuyExecutionCount : 0; const liquidityDepth = Math.max(0, latestTick.totalAskSize + latestTick.totalBidSize); const orderBookImbalance = liquidityDepth > 0 ? (latestTick.totalBidSize - latestTick.totalAskSize) / liquidityDepth : 0; const topLevelDepth = Math.max(0, latestTick.askSize1 + latestTick.bidSize1); const topLevelOrderBookImbalance = topLevelDepth > 0 ? (latestTick.bidSize1 - latestTick.askSize1) / topLevelDepth : 0; const recentPriceHigh = recentPrices.length > 0 ? Math.max(...recentPrices) : latestTick.price; const recentPriceLow = recentPrices.length > 0 ? Math.min(...recentPrices) : latestTick.price; const recentPriceRangePercent = recentPriceLow > 0 ? ((recentPriceHigh - recentPriceLow) / recentPriceLow) * 100 : 0; const buySellExecutionRatio = latestTick.sellExecutionCount > 0 ? latestTick.buyExecutionCount / latestTick.sellExecutionCount : latestTick.buyExecutionCount > 0 ? 99 : 1; const recentTradeVolumes = recentTicks .slice(-12) .map((tick) => Math.max(0, tick.tradeVolume)); const recentNetBuyTrail = recentTicks .slice(-12) .map((tick) => tick.netBuyExecutionCount); const recentTickAgesSec = recentTicks .slice(-12) .map((tick) => Number( Math.max(0, (requestAt.getTime() - tick.tickAt) / 1000).toFixed(2), ), ); const marketDataLatencySec = resolveTickLatencySec(latestTick.tickTime, requestAt); const signalRequestId = createSignalRequestId(); const recentMinuteCandles = await loadSignalMinuteCandles({ symbol: selectedStock.symbol, credentials, latestTick, nowMs: now, }); const minutePatternContext = buildMinutePatternContext(recentMinuteCandles); const strategyBudgetRatio = clampNumber( runtime.compiledStrategy.maxOrderAmountRatio, 0.05, 1, ); const effectiveOrderBudgetAmount = Math.floor( Math.max(0, runtime.validation.effectiveAllocationAmount) * strategyBudgetRatio, ); const estimatedBuyUnitCost = estimateBuyUnitCost(latestTick.price, executionCostProfile); const estimatedBuyableQuantity = resolveOrderQuantity({ side: "buy", price: latestTick.price, unitCost: estimatedBuyUnitCost, effectiveAllocationAmount: runtime.validation.effectiveAllocationAmount, maxOrderAmountRatio: runtime.compiledStrategy.maxOrderAmountRatio, }); let holdingQuantity = 0; let sellableQuantity = 0; let averageHoldingPrice = 0; const balanceForSnapshot = await fetchDashboardBalance(credentials).catch(() => null); const holdingForSnapshot = balanceForSnapshot?.holdings.find( (item) => item.symbol === selectedStock.symbol, ); if (holdingForSnapshot) { holdingQuantity = Math.max(0, Math.floor(holdingForSnapshot.quantity)); sellableQuantity = Math.max(0, Math.floor(holdingForSnapshot.sellableQuantity)); averageHoldingPrice = Math.max(0, holdingForSnapshot.averagePrice); } const estimatedSellableNetAmount = sellableQuantity > 0 ? estimateSellNetUnit(latestTick.price, executionCostProfile) * sellableQuantity : 0; const snapshot: AutotradeMarketSnapshot = { symbol: selectedStock.symbol, stockName: selectedStock.name, market: selectedStock.market, requestAtIso: requestAt.toISOString(), requestAtKst: formatKstDateTime(requestAt), tickTime: latestTick.tickTime, executionClassCode: latestTick.executionClassCode, isExpected: latestTick.isExpected, trId: latestTick.trId, currentPrice: latestTick.price, prevClose: selectedStock.prevClose, changeRate: latestTick.changeRate, open: latestTick.open, high: latestTick.high, low: latestTick.low, tradeVolume: latestTick.tradeVolume, accumulatedVolume: latestTick.accumulatedVolume, tradeStrength: latestTick.tradeStrength, askPrice1: latestTick.askPrice1, bidPrice1: latestTick.bidPrice1, askSize1: latestTick.askSize1, bidSize1: latestTick.bidSize1, totalAskSize: latestTick.totalAskSize, totalBidSize: latestTick.totalBidSize, buyExecutionCount: latestTick.buyExecutionCount, sellExecutionCount: latestTick.sellExecutionCount, netBuyExecutionCount: latestTick.netBuyExecutionCount, spread, spreadRate: latestTick.price > 0 ? (spread / latestTick.price) * 100 : 0, dayRangePercent: latestTick.open > 0 ? (dayRange / latestTick.open) * 100 : 0, dayRangePosition: clampRangePosition(rangePosition), volumeRatio: Number.isFinite(liquidityVolumeRatio) ? liquidityVolumeRatio : 1, recentTradeCount, recentTradeVolumeSum, recentAverageTradeVolume, accumulatedVolumeDelta, netBuyExecutionDelta, orderBookImbalance, liquidityDepth, topLevelOrderBookImbalance, buySellExecutionRatio, recentPriceHigh, recentPriceLow, recentPriceRangePercent, recentTradeVolumes, recentNetBuyTrail, recentTickAgesSec, intradayMomentum: calculateIntradayMomentum(recentPrices), recentReturns: toRecentReturns(recentPrices), recentPrices, marketDataLatencySec, recentMinuteCandles: recentMinuteCandles.length > 0 ? recentMinuteCandles : undefined, minutePatternContext, budgetContext: { setupAllocationPercent: setupForm.allocationPercent, setupAllocationAmount: setupForm.allocationAmount, effectiveAllocationAmount: runtime.validation.effectiveAllocationAmount, strategyMaxOrderAmountRatio: strategyBudgetRatio, effectiveOrderBudgetAmount, estimatedBuyUnitCost, estimatedBuyableQuantity, }, portfolioContext: { holdingQuantity, sellableQuantity, averagePrice: averageHoldingPrice, estimatedSellableNetAmount: Number(estimatedSellableNetAmount.toFixed(2)), }, executionCostProfile, }; runtime.appendLog("info", "AI 신호 생성 요청을 전송합니다.", { stage: "signal_request", detail: { aiMode: setupForm.aiMode, subscriptionCliVendor: setupForm.subscriptionCliVendor, subscriptionCliModel: setupForm.subscriptionCliVendor !== "auto" ? setupForm.subscriptionCliModel.trim() || "(provider default)" : "auto", requestId: signalRequestId, promptExcerpt: setupForm.prompt.trim().slice(0, 180), strategyProvider: runtime.compiledStrategy.provider, strategySummary: runtime.compiledStrategy.summary.slice(0, 120), selectedTechniques: runtime.compiledStrategy.selectedTechniques, snapshotSummary: buildSignalSnapshotSummary(snapshot), snapshot: buildSignalSnapshotLog(snapshot), }, }); try { const signalResponse = await generateAutotradeSignal({ aiMode: setupForm.aiMode, subscriptionCliVendor: setupForm.subscriptionCliVendor, subscriptionCliModel: setupForm.subscriptionCliVendor !== "auto" ? setupForm.subscriptionCliModel.trim() : undefined, prompt: setupForm.prompt.trim(), strategy: runtime.compiledStrategy, snapshot, }); const runtimeAfterSignal = useAutotradeEngineStore.getState(); if ( runtimeAfterSignal.engineState !== "RUNNING" || !runtimeAfterSignal.activeSession || runtimeAfterSignal.activeSession.sessionId !== sessionIdAtRequest || runtimeAfterSignal.activeSession.symbol !== symbolAtRequest ) { return; } if (!runtimeAfterSignal.compiledStrategy || !runtimeAfterSignal.validation) { return; } const signal = signalResponse.signal; runtimeAfterSignal.setLastSignal(signal); const providerLabel = formatSignalProviderLabel({ source: signal.source, vendor: signal.providerVendor, model: signal.providerModel, }); runtimeAfterSignal.appendLog( "info", `신호 수신 [${providerLabel}]: ${signal.signal.toUpperCase()} (${signal.confidence.toFixed(2)}) - ${signal.reason}`, { stage: "signal_response", detail: { source: signal.source, requestId: signalRequestId, providerVendor: signal.providerVendor ?? null, providerModel: signal.providerModel ?? null, signal: signal.signal, confidence: Number(signal.confidence.toFixed(4)), reason: signal.reason, riskFlags: signal.riskFlags, proposedOrder: signal.proposedOrder ?? null, }, }, ); if (setupForm.aiMode !== "rule_fallback" && signal.source === "fallback") { runtimeAfterSignal.appendLog("warning", "AI 응답 실패/제약으로 규칙 기반 신호로 대체되었습니다.", { stage: "provider_fallback", detail: { aiMode: setupForm.aiMode, fallbackReasonPreview: signal.reason, }, }); } // [Step 2] hold 신호는 주문 없이 루프를 종료합니다. if (signal.signal === "hold") { return; } // [Step 3] 신뢰도/쿨다운/일일건수 등 리스크 게이트를 통과한 경우만 주문합니다. const blockers = evaluateSignalBlockers({ signal, strategy: runtimeAfterSignal.compiledStrategy, validation: runtimeAfterSignal.validation, dailyOrderCount: runtimeAfterSignal.orderCountToday, lastOrderAtMs: runtimeAfterSignal.lastOrderAtBySymbol[selectedStock.symbol], nowMs: Date.now(), }); if (blockers.length > 0) { runtimeAfterSignal.appendLog("warning", `주문 차단: ${blockers[0]}`, { stage: "risk_gate", detail: { blockers, signal: signal.signal, confidence: signal.confidence, threshold: runtimeAfterSignal.compiledStrategy.confidenceThreshold, dailyOrderCount: runtimeAfterSignal.orderCountToday, maxDailyOrders: runtimeAfterSignal.compiledStrategy.maxDailyOrders, }, }); return; } const accountParts = parseKisAccountParts(credentials.accountNo); if (!accountParts) { runtime.appendLog("error", "계좌번호 파싱 실패로 자동매매를 중지합니다.", { stage: "engine_error", }); await stopAutotrade("emergency", { silent: true }); return; } const side = (signal.proposedOrder?.side ?? signal.signal) as "buy" | "sell"; const orderType = signal.proposedOrder?.orderType ?? "limit"; const orderPrice = signal.proposedOrder?.price && signal.proposedOrder.price > 0 ? signal.proposedOrder.price : latestTick.price; const quantity = resolveOrderQuantity({ side, price: orderPrice, unitCost: side === "buy" ? estimateBuyUnitCost(orderPrice, executionCostProfile) : undefined, requestedQuantity: signal.proposedOrder?.quantity, effectiveAllocationAmount: runtimeAfterSignal.validation.effectiveAllocationAmount, maxOrderAmountRatio: runtimeAfterSignal.compiledStrategy.maxOrderAmountRatio, }); if (quantity <= 0) { runtimeAfterSignal.appendLog("warning", "주문 가능 수량이 0주로 계산되어 주문을 차단했습니다.", { stage: "order_blocked", detail: { signal: signal.signal, side, orderPrice, effectiveAllocationAmount: runtimeAfterSignal.validation.effectiveAllocationAmount, }, }); return; } const executableOrder = await resolveExecutableOrderQuantity({ credentials, symbol: selectedStock.symbol, side, orderType, orderPrice, requestedQuantity: quantity, costProfile: executionCostProfile, }); if (!executableOrder.ok || executableOrder.quantity <= 0) { runtimeAfterSignal.appendLog( "warning", `주문 차단: ${executableOrder.reason}`, { stage: "order_blocked", detail: { symbol: selectedStock.symbol, side, orderType, orderPrice, requestedQuantity: quantity, ...executableOrder.detail, }, }, ); return; } const executableQuantity = executableOrder.quantity; const estimatedOrderCost = estimateOrderCost({ side, price: orderPrice, quantity: executableQuantity, profile: executionCostProfile, }); const estimatedAveragePrice = typeof executableOrder.detail?.averagePrice === "number" ? executableOrder.detail.averagePrice : undefined; const estimatedNetPnl = side === "sell" && typeof estimatedAveragePrice === "number" && estimatedAveragePrice > 0 ? (orderPrice - estimatedAveragePrice) * executableQuantity - estimatedOrderCost.feeAmount - estimatedOrderCost.taxAmount : undefined; const activitySummaryBeforeOrder = await fetchDashboardActivity(credentials) .then((response) => response.journalSummary) .catch(() => null); if (executableQuantity !== quantity) { runtimeAfterSignal.appendLog( "info", `계좌 상태 반영으로 주문 수량을 ${quantity.toLocaleString("ko-KR")}주 -> ${executableQuantity.toLocaleString("ko-KR")}주로 조정했습니다.`, { stage: "order_execution", detail: { symbol: selectedStock.symbol, side, orderType, orderPrice, requestedQuantity: quantity, executableQuantity, ...executableOrder.detail, }, }, ); } runtimeAfterSignal.appendLog("info", "자동주문 요청을 전송합니다.", { stage: "order_execution", detail: { symbol: selectedStock.symbol, side, orderType, orderPrice, quantity: executableQuantity, confidence: signal.confidence, reason: signal.reason, estimatedOrderCost: { grossAmount: Number(estimatedOrderCost.grossAmount.toFixed(2)), feeAmount: Number(estimatedOrderCost.feeAmount.toFixed(2)), taxAmount: Number(estimatedOrderCost.taxAmount.toFixed(2)), netAmount: Number(estimatedOrderCost.netAmount.toFixed(2)), }, estimatedNetPnl: typeof estimatedNetPnl === "number" ? Number(estimatedNetPnl.toFixed(2)) : undefined, }, }); // [Step 4] 최종 주문 API를 호출하고 체결 카운터/로그를 갱신합니다. await fetchOrderCash( { symbol: selectedStock.symbol, side, orderType, price: orderPrice, quantity: executableQuantity, accountNo: `${accountParts.accountNo}-${accountParts.accountProductCode}`, accountProductCode: accountParts.accountProductCode, }, credentials, ); const runtimeAfterOrder = useAutotradeEngineStore.getState(); if ( runtimeAfterOrder.engineState !== "RUNNING" || !runtimeAfterOrder.activeSession || runtimeAfterOrder.activeSession.sessionId !== sessionIdAtRequest || runtimeAfterOrder.activeSession.symbol !== symbolAtRequest ) { return; } runtimeAfterOrder.increaseOrderCount(1); runtimeAfterOrder.setLastOrderAt(selectedStock.symbol, Date.now()); runtimeAfterOrder.resetFailure(); const [balanceAfterOrder, activityAfterOrder] = await Promise.all([ fetchDashboardBalance(credentials).catch(() => null), fetchDashboardActivity(credentials).catch(() => null), ]); const netBeforeOrder = resolveJournalNetAmount(activitySummaryBeforeOrder); const netAfterOrder = resolveJournalNetAmount(activityAfterOrder?.journalSummary); let realizedLossDelta = 0; if ( Number.isFinite(netBeforeOrder) && Number.isFinite(netAfterOrder) && netAfterOrder < netBeforeOrder ) { realizedLossDelta = Math.abs(netAfterOrder - netBeforeOrder); } else if ( side === "sell" && typeof estimatedNetPnl === "number" && Number.isFinite(estimatedNetPnl) && estimatedNetPnl < 0 ) { // 활동 API 차이값이 없을 때를 대비한 보수적 추정 손실 반영 realizedLossDelta = Math.abs(estimatedNetPnl); } if (realizedLossDelta > 0) { runtimeAfterOrder.addLossAmount(realizedLossDelta); } if (balanceAfterOrder) { const latestHolding = balanceAfterOrder.holdings.find( (item) => item.symbol === selectedStock.symbol, ); runtimeAfterOrder.appendLog( "info", "체결 후 잔고를 다시 조회해 보유/매도가능 수량을 동기화했습니다.", { stage: "order_execution", detail: { symbol: selectedStock.symbol, holdingQuantity: Math.max(0, Math.floor(latestHolding?.quantity ?? 0)), sellableQuantity: Math.max( 0, Math.floor(latestHolding?.sellableQuantity ?? 0), ), averagePrice: Math.max(0, latestHolding?.averagePrice ?? 0), }, }, ); } const runtimeAfterAccounting = useAutotradeEngineStore.getState(); if ( runtimeAfterAccounting.validation && runtimeAfterAccounting.cumulativeLossAmount >= runtimeAfterAccounting.validation.effectiveDailyLossLimit ) { runtimeAfterAccounting.appendLog( "warning", "체결 후 정산 결과 누적 손실이 손실 한도를 초과해 자동매매를 중지합니다.", { stage: "risk_gate", detail: { cumulativeLossAmount: runtimeAfterAccounting.cumulativeLossAmount, effectiveDailyLossLimit: runtimeAfterAccounting.validation.effectiveDailyLossLimit, realizedLossDelta: Number(realizedLossDelta.toFixed(2)), }, }, ); await stopAutotrade("emergency", { silent: true }); return; } runtimeAfterOrder.appendLog( "info", `자동주문 실행: ${selectedStock.symbol} ${side.toUpperCase()} ${executableQuantity.toLocaleString("ko-KR")}주`, { stage: "order_execution", detail: { symbol: selectedStock.symbol, side, quantity: executableQuantity, orderPrice, estimatedOrderCost: { grossAmount: Number(estimatedOrderCost.grossAmount.toFixed(2)), feeAmount: Number(estimatedOrderCost.feeAmount.toFixed(2)), taxAmount: Number(estimatedOrderCost.taxAmount.toFixed(2)), netAmount: Number(estimatedOrderCost.netAmount.toFixed(2)), }, realizedLossDelta: Number(realizedLossDelta.toFixed(2)), cumulativeLossAmount: runtimeAfterAccounting.cumulativeLossAmount, executedAt: new Date().toISOString(), }, }, ); } finally { lastSignalRequestedAtRef.current = Date.now(); signalRequestInFlightRef.current = false; } }; void run().catch(async (error) => { const before = useAutotradeEngineStore.getState().consecutiveFailures; increaseFailure(); const message = error instanceof Error ? error.message : "자동매매 주문 처리 중 오류가 발생했습니다."; appendLog("error", message, { stage: "engine_error", }); if (before + 1 >= 3) { appendLog("error", "연속 실패 3회로 자동매매를 비상 중지합니다.", { stage: "engine_error", detail: { consecutiveFailures: before + 1, }, }); await stopAutotrade("emergency", { silent: true }); } }); }, [ activeSession, appendLog, compiledStrategy, credentials, engineState, increaseFailure, latestTick, loadSignalMinuteCandles, selectedStock, setupForm.aiMode, setupForm.allocationAmount, setupForm.allocationPercent, setupForm.prompt, setupForm.subscriptionCliVendor, setupForm.subscriptionCliModel, stopAutotrade, validation, executionCostProfile, ]); const toggleTechnique = useCallback( (techniqueId: AutotradeSetupFormValues["selectedTechniques"][number], checked: boolean) => { // [Step 1] 체크 여부에 따라 기법 배열에 추가/제거합니다. const next = checked ? Array.from(new Set([...setupForm.selectedTechniques, techniqueId])) : setupForm.selectedTechniques.filter((item) => item !== techniqueId); // [Step 2] 다음 선택 목록을 setupForm에 반영합니다. patchSetupForm({ selectedTechniques: next }); }, [patchSetupForm, setupForm.selectedTechniques], ); const setNumberField = useCallback( (field: keyof Pick< AutotradeSetupFormValues, | "allocationPercent" | "allocationAmount" | "dailyLossPercent" | "dailyLossAmount" | "confidenceThreshold" >, rawValue: string, ) => { // [Step 1] 입력 문자열을 숫자로 정규화합니다. const value = Number.parseFloat(rawValue.replace(/,/g, "")); const safeValue = Number.isFinite(value) ? value : 0; // [Step 2] 필드별 허용 범위로 보정해 비정상 입력을 차단합니다. const normalizedValue = field === "allocationPercent" ? clampNumber(safeValue, 0, 100) : field === "dailyLossPercent" ? clampNumber(safeValue, 0, 100) : field === "confidenceThreshold" ? clampNumber(safeValue, 0.45, 0.95) : Math.max(0, Math.floor(safeValue)); // [Step 3] 정규화된 값만 setupForm에 반영합니다. patchSetupForm({ [field]: normalizedValue, }); }, [patchSetupForm], ); return { panelOpen, setupForm, engineState, isWorking, activeSession, compiledStrategy, validation, lastSignal, orderCountToday, consecutiveFailures, logs, setPanelOpen, patchSetupForm, setNumberField, toggleTechnique, previewValidation, startAutotrade, stopAutotrade, }; } interface ResolveExecutableOrderQuantityParams { credentials: KisRuntimeCredentials; symbol: string; side: "buy" | "sell"; orderType: "limit" | "market"; orderPrice: number; requestedQuantity: number; costProfile: AutotradeExecutionCostProfileSnapshot; } interface ResolveExecutableOrderQuantityResult { ok: boolean; quantity: number; reason: string; detail?: Record; } async function resolveExecutableOrderQuantity( params: ResolveExecutableOrderQuantityParams, ): Promise { const safeRequestedQuantity = Math.max(0, Math.floor(params.requestedQuantity)); if (safeRequestedQuantity <= 0) { return { ok: false, quantity: 0, reason: "요청 주문 수량이 0주로 계산되었습니다.", detail: { requestedQuantity: params.requestedQuantity, }, }; } if (params.side === "buy") { try { const orderable = await fetchOrderableCashEstimate( { symbol: params.symbol, price: Math.max(1, params.orderPrice), orderType: params.orderType, }, params.credentials, ); const maxBuyQuantity = Math.max( 0, Math.floor(orderable.maxBuyQuantity || orderable.noReceivableBuyQuantity || 0), ); const buyClamp = clampExecutableOrderQuantity({ side: "buy", requestedQuantity: safeRequestedQuantity, maxBuyQuantity, }); if (!buyClamp.ok || buyClamp.quantity <= 0) { return { ok: false, quantity: 0, reason: "현재 예수금/주문가능금액 기준으로 매수 가능 수량이 없습니다.", detail: { requestedQuantity: safeRequestedQuantity, maxBuyQuantity, orderableCash: orderable.orderableCash, maxBuyAmount: orderable.maxBuyAmount, }, }; } return { ok: true, quantity: buyClamp.quantity, reason: "매수 가능 수량 검증 완료", detail: { requestedQuantity: safeRequestedQuantity, maxBuyQuantity, adjusted: buyClamp.adjusted, orderableCash: orderable.orderableCash, maxBuyAmount: orderable.maxBuyAmount, estimatedBuyUnitCost: Number( estimateBuyUnitCost(params.orderPrice, params.costProfile).toFixed(2), ), }, }; } catch (error) { const balance = await fetchDashboardBalance(params.credentials).catch(() => null); const cashBalance = Math.max(0, Math.floor(balance?.summary.cashBalance ?? 0)); const buyUnitCost = estimateBuyUnitCost(params.orderPrice, params.costProfile); const affordableQuantity = buyUnitCost > 0 ? Math.floor(cashBalance / buyUnitCost) : 0; const buyClamp = clampExecutableOrderQuantity({ side: "buy", requestedQuantity: safeRequestedQuantity, maxBuyQuantity: affordableQuantity, }); if (!buyClamp.ok || buyClamp.quantity <= 0) { return { ok: false, quantity: 0, reason: "매수가능금액 조회에 실패했고, 예수금 기준으로도 매수 가능 수량이 없어 주문을 차단했습니다.", detail: { requestedQuantity: safeRequestedQuantity, cashBalance, orderPrice: params.orderPrice, estimatedBuyUnitCost: Number(buyUnitCost.toFixed(2)), fallbackError: error instanceof Error ? error.message : "orderable_cash_error", }, }; } return { ok: true, quantity: buyClamp.quantity, reason: "매수가능금액 조회 실패로 예수금 기준 수량 검증으로 대체", detail: { requestedQuantity: safeRequestedQuantity, adjusted: buyClamp.adjusted, cashBalance, orderPrice: params.orderPrice, estimatedBuyUnitCost: Number(buyUnitCost.toFixed(2)), fallbackError: error instanceof Error ? error.message : "orderable_cash_error", }, }; } } const balance = await fetchDashboardBalance(params.credentials).catch((error) => { return { __errorMessage: error instanceof Error ? error.message : "매도 가능 수량 확인을 위한 잔고 조회에 실패했습니다.", } as const; }); if ("__errorMessage" in balance) { return { ok: false, quantity: 0, reason: "매도 가능 수량 확인용 잔고 조회에 실패해 주문을 차단했습니다.", detail: { requestedQuantity: safeRequestedQuantity, balanceError: balance.__errorMessage, }, }; } const holding = balance.holdings.find((item) => item.symbol === params.symbol); const holdingQuantity = Math.max(0, Math.floor(holding?.quantity ?? 0)); const sellableQuantity = Math.max(0, Math.floor(holding?.sellableQuantity ?? 0)); const sellClamp = clampExecutableOrderQuantity({ side: "sell", requestedQuantity: safeRequestedQuantity, holdingQuantity, sellableQuantity, }); if (!sellClamp.ok || sellClamp.quantity <= 0) { return { ok: false, quantity: 0, reason: "보유/매도가능 수량이 없어 매도 주문을 차단했습니다.", detail: { requestedQuantity: safeRequestedQuantity, sellableQuantity, holdingQuantity, averagePrice: Math.max(0, holding?.averagePrice ?? 0), }, }; } return { ok: true, quantity: sellClamp.quantity, reason: "매도 가능 수량 검증 완료", detail: { requestedQuantity: safeRequestedQuantity, adjusted: sellClamp.adjusted, sellableQuantity, holdingQuantity, averagePrice: Math.max(0, holding?.averagePrice ?? 0), }, }; } function toRecentReturns(prices: number[]) { if (prices.length < 2) { return []; } const returns: number[] = []; for (let index = 1; index < prices.length; index += 1) { const prev = prices[index - 1]; const next = prices[index]; if (!Number.isFinite(prev) || !Number.isFinite(next) || prev <= 0) { continue; } returns.push(((next - prev) / prev) * 100); } return returns.slice(-8); } function resolveMinuteBucketKey(tickTime: string | undefined) { if (!tickTime || !/^\d{6}$/.test(tickTime)) { return null; } return tickTime.slice(0, 4); } function normalizeSignalMinuteCandles(candles: StockCandlePoint[]) { const normalized: AutotradeMinuteCandle[] = []; for (const candle of candles) { const close = candle.close ?? candle.price; const open = candle.open ?? close; const high = candle.high ?? Math.max(open, close); const low = candle.low ?? Math.min(open, close); const volume = candle.volume ?? 0; if ( !Number.isFinite(open) || !Number.isFinite(high) || !Number.isFinite(low) || !Number.isFinite(close) || open <= 0 || high <= 0 || low <= 0 || close <= 0 ) { continue; } normalized.push({ time: candle.time, open, high, low, close, volume: Math.max(0, volume), timestamp: typeof candle.timestamp === "number" && Number.isFinite(candle.timestamp) ? candle.timestamp : undefined, }); } return normalized.sort((a, b) => { if (typeof a.timestamp === "number" && typeof b.timestamp === "number") { return a.timestamp - b.timestamp; } return a.time.localeCompare(b.time); }); } function buildMinutePatternContext( candles: AutotradeMinuteCandle[], ): AutotradeMinutePatternContext | undefined { const recentCandles = candles.slice(-18); if (recentCandles.length < 8) { return undefined; } const consolidationBarCount = Math.min(6, recentCandles.length - 2); const impulseBarCount = recentCandles.length - consolidationBarCount; const impulseCandles = recentCandles.slice(0, impulseBarCount); const consolidationCandles = recentCandles.slice(-consolidationBarCount); if (impulseCandles.length < 3 || consolidationCandles.length < 2) { return undefined; } const impulseOpen = impulseCandles[0]?.open ?? 0; const impulseClose = impulseCandles.at(-1)?.close ?? 0; const impulseHigh = Math.max(...impulseCandles.map((candle) => candle.high)); const impulseLow = Math.min(...impulseCandles.map((candle) => candle.low)); const consolidationHigh = Math.max(...consolidationCandles.map((candle) => candle.high)); const consolidationLow = Math.min(...consolidationCandles.map((candle) => candle.low)); const closeHigh = Math.max(...consolidationCandles.map((candle) => candle.close)); const closeLow = Math.min(...consolidationCandles.map((candle) => candle.close)); const impulseChangeRate = impulseOpen > 0 ? ((impulseClose - impulseOpen) / impulseOpen) * 100 : 0; const impulseDirection = impulseChangeRate >= 0.18 ? "up" : impulseChangeRate <= -0.18 ? "down" : "flat"; return { timeframe: "1m", candleCount: recentCandles.length, impulseDirection, impulseBarCount: impulseCandles.length, consolidationBarCount: consolidationCandles.length, impulseChangeRate: round3(impulseChangeRate), impulseRangePercent: impulseLow > 0 ? round3(((impulseHigh - impulseLow) / impulseLow) * 100) : undefined, consolidationRangePercent: consolidationLow > 0 ? round3(((consolidationHigh - consolidationLow) / consolidationLow) * 100) : undefined, consolidationCloseClusterPercent: closeLow > 0 ? round3(((closeHigh - closeLow) / closeLow) * 100) : undefined, consolidationVolumeRatio: round3( averageNumbers(consolidationCandles.map((candle) => candle.volume)) / Math.max(averageNumbers(impulseCandles.map((candle) => candle.volume)), 1), ), breakoutUpper: consolidationHigh, breakoutLower: consolidationLow, }; } function buildSignalSnapshotLog(snapshot: AutotradeMarketSnapshot) { return { symbol: snapshot.symbol, stockName: snapshot.stockName, market: snapshot.market, requestAtIso: snapshot.requestAtIso, requestAtKst: snapshot.requestAtKst, tickTime: snapshot.tickTime, executionClassCode: snapshot.executionClassCode, isExpected: snapshot.isExpected, trId: snapshot.trId, currentPrice: snapshot.currentPrice, prevClose: snapshot.prevClose, changeRate: round2(snapshot.changeRate), ohlc: { open: snapshot.open, high: snapshot.high, low: snapshot.low, }, volume: { tradeVolume: snapshot.tradeVolume, accumulatedVolume: snapshot.accumulatedVolume, volumeRatio: round3(snapshot.volumeRatio), recentTradeCount: snapshot.recentTradeCount, recentTradeVolumeSum: round2(snapshot.recentTradeVolumeSum), recentAverageTradeVolume: round2(snapshot.recentAverageTradeVolume), accumulatedVolumeDelta: round2(snapshot.accumulatedVolumeDelta), }, orderbook: { askPrice1: snapshot.askPrice1, bidPrice1: snapshot.bidPrice1, askSize1: snapshot.askSize1, bidSize1: snapshot.bidSize1, totalAskSize: snapshot.totalAskSize, totalBidSize: snapshot.totalBidSize, spread: snapshot.spread, spreadRate: round4(snapshot.spreadRate), orderBookImbalance: round4(snapshot.orderBookImbalance), topLevelOrderBookImbalance: round4(snapshot.topLevelOrderBookImbalance), liquidityDepth: round2(snapshot.liquidityDepth), }, executions: { tradeStrength: round2(snapshot.tradeStrength), buyExecutionCount: snapshot.buyExecutionCount, sellExecutionCount: snapshot.sellExecutionCount, buySellExecutionRatio: round3(snapshot.buySellExecutionRatio), netBuyExecutionCount: snapshot.netBuyExecutionCount, netBuyExecutionDelta: round2(snapshot.netBuyExecutionDelta), }, derived: { marketDataLatencySec: round2(snapshot.marketDataLatencySec), dayRangePercent: round3(snapshot.dayRangePercent), dayRangePosition: round3(snapshot.dayRangePosition), recentPriceHigh: snapshot.recentPriceHigh, recentPriceLow: snapshot.recentPriceLow, recentPriceRangePercent: round3(snapshot.recentPriceRangePercent), intradayMomentum: round3(snapshot.intradayMomentum), recentReturns: (snapshot.recentReturns ?? []).map((value) => round3(value)), recentPricesTail: snapshot.recentPrices.slice(-8), recentTradeVolumesTail: (snapshot.recentTradeVolumes ?? []).slice(-8), recentNetBuyTrailTail: (snapshot.recentNetBuyTrail ?? []).slice(-8), recentTickAgesSecTail: (snapshot.recentTickAgesSec ?? []).slice(-8), }, minutePattern: snapshot.minutePatternContext ? { ...snapshot.minutePatternContext, impulseChangeRate: round3(snapshot.minutePatternContext.impulseChangeRate), impulseRangePercent: round3(snapshot.minutePatternContext.impulseRangePercent), consolidationRangePercent: round3( snapshot.minutePatternContext.consolidationRangePercent, ), consolidationCloseClusterPercent: round3( snapshot.minutePatternContext.consolidationCloseClusterPercent, ), consolidationVolumeRatio: round3( snapshot.minutePatternContext.consolidationVolumeRatio, ), } : null, budget: snapshot.budgetContext ? { setupAllocationPercent: round2(snapshot.budgetContext.setupAllocationPercent), setupAllocationAmount: Math.floor(snapshot.budgetContext.setupAllocationAmount), effectiveAllocationAmount: Math.floor( snapshot.budgetContext.effectiveAllocationAmount, ), strategyMaxOrderAmountRatio: round3( snapshot.budgetContext.strategyMaxOrderAmountRatio, ), effectiveOrderBudgetAmount: Math.floor( snapshot.budgetContext.effectiveOrderBudgetAmount, ), estimatedBuyUnitCost: round2(snapshot.budgetContext.estimatedBuyUnitCost), estimatedBuyableQuantity: snapshot.budgetContext.estimatedBuyableQuantity, } : null, portfolio: snapshot.portfolioContext ? { holdingQuantity: snapshot.portfolioContext.holdingQuantity, sellableQuantity: snapshot.portfolioContext.sellableQuantity, averagePrice: round2(snapshot.portfolioContext.averagePrice), estimatedSellableNetAmount: round2( snapshot.portfolioContext.estimatedSellableNetAmount, ), } : null, executionCostProfile: snapshot.executionCostProfile ? { buyFeeRate: round4(snapshot.executionCostProfile.buyFeeRate), sellFeeRate: round4(snapshot.executionCostProfile.sellFeeRate), sellTaxRate: round4(snapshot.executionCostProfile.sellTaxRate), } : null, recentMinuteCandlesTail: (snapshot.recentMinuteCandles ?? []).slice(-8), }; } function buildSignalSnapshotSummary(snapshot: AutotradeMarketSnapshot) { const requestTimeText = typeof snapshot.requestAtKst === "string" && snapshot.requestAtKst.trim().length > 0 ? snapshot.requestAtKst : "-"; const tickTimeText = typeof snapshot.tickTime === "string" && snapshot.tickTime.trim().length > 0 ? snapshot.tickTime : "-"; const latencyText = typeof snapshot.marketDataLatencySec === "number" && Number.isFinite(snapshot.marketDataLatencySec) ? `${snapshot.marketDataLatencySec.toFixed(2)}초` : "-"; const spreadRateText = typeof snapshot.spreadRate === "number" && Number.isFinite(snapshot.spreadRate) ? `${snapshot.spreadRate.toFixed(3)}%` : "-"; const rangePositionText = typeof snapshot.dayRangePosition === "number" && Number.isFinite(snapshot.dayRangePosition) ? `${(snapshot.dayRangePosition * 100).toFixed(1)}%` : "-"; const recentAvgVolumeText = typeof snapshot.recentAverageTradeVolume === "number" && Number.isFinite(snapshot.recentAverageTradeVolume) ? snapshot.recentAverageTradeVolume.toFixed(1) : "-"; const orderBookImbalanceText = typeof snapshot.orderBookImbalance === "number" && Number.isFinite(snapshot.orderBookImbalance) ? snapshot.orderBookImbalance.toFixed(3) : "-"; const topLevelImbalanceText = typeof snapshot.topLevelOrderBookImbalance === "number" && Number.isFinite(snapshot.topLevelOrderBookImbalance) ? snapshot.topLevelOrderBookImbalance.toFixed(3) : "-"; const recentPriceRangeText = typeof snapshot.recentPriceRangePercent === "number" && Number.isFinite(snapshot.recentPriceRangePercent) ? `${snapshot.recentPriceRangePercent.toFixed(3)}%` : "-"; const buySellExecutionRatioText = typeof snapshot.buySellExecutionRatio === "number" && Number.isFinite(snapshot.buySellExecutionRatio) ? snapshot.buySellExecutionRatio.toFixed(3) : "-"; const intradayMomentumText = typeof snapshot.intradayMomentum === "number" && Number.isFinite(snapshot.intradayMomentum) ? `${snapshot.intradayMomentum.toFixed(3)}%` : "-"; const tradeStrengthText = typeof snapshot.tradeStrength === "number" && Number.isFinite(snapshot.tradeStrength) ? snapshot.tradeStrength.toFixed(2) : "-"; const minutePatternText = snapshot.minutePatternContext ? [ `1분봉 ${snapshot.minutePatternContext.candleCount}개`, `직전방향 ${snapshot.minutePatternContext.impulseDirection}`, `직전변화 ${ typeof snapshot.minutePatternContext.impulseChangeRate === "number" ? `${snapshot.minutePatternContext.impulseChangeRate.toFixed(3)}%` : "-" }`, `압축범위 ${ typeof snapshot.minutePatternContext.consolidationRangePercent === "number" ? `${snapshot.minutePatternContext.consolidationRangePercent.toFixed(3)}%` : "-" }`, `압축거래량비 ${ typeof snapshot.minutePatternContext.consolidationVolumeRatio === "number" ? snapshot.minutePatternContext.consolidationVolumeRatio.toFixed(3) : "-" }`, ].join(" / ") : "1분봉 요약 -"; const budgetText = snapshot.budgetContext ? `${Math.floor(snapshot.budgetContext.effectiveOrderBudgetAmount).toLocaleString("ko-KR")}원` : "-"; const buyableQuantityText = snapshot.budgetContext ? `${Math.max(0, snapshot.budgetContext.estimatedBuyableQuantity).toLocaleString("ko-KR")}주` : "-"; const portfolioText = snapshot.portfolioContext ? `보유 ${Math.max(0, snapshot.portfolioContext.holdingQuantity).toLocaleString("ko-KR")} / 매도가능 ${Math.max(0, snapshot.portfolioContext.sellableQuantity).toLocaleString("ko-KR")}` : "보유정보 -"; const feeTaxText = snapshot.executionCostProfile ? `매수수수료 ${((snapshot.executionCostProfile.buyFeeRate ?? 0) * 100).toFixed(3)}% · 매도수수료 ${((snapshot.executionCostProfile.sellFeeRate ?? 0) * 100).toFixed(3)}% · 매도세 ${((snapshot.executionCostProfile.sellTaxRate ?? 0) * 100).toFixed(3)}%` : "수수료/세금 -"; return [ `요청시각 ${requestTimeText}`, `틱시각 ${tickTimeText}`, `데이터지연 ${latencyText}`, `${snapshot.symbol} ${Math.round(snapshot.currentPrice).toLocaleString("ko-KR")}원`, `등락률 ${snapshot.changeRate.toFixed(2)}%`, `틱체결 ${Math.max(0, snapshot.tradeVolume).toLocaleString("ko-KR")}`, `최근체결 ${snapshot.recentTradeCount ?? 0}건 / 평균 ${recentAvgVolumeText}`, `최근누적증가 ${Math.max(0, snapshot.accumulatedVolumeDelta ?? 0).toLocaleString("ko-KR")}`, `호가깊이 ${Math.max(0, snapshot.liquidityDepth ?? 0).toLocaleString("ko-KR")}`, `호가불균형 ${orderBookImbalanceText}`, `1호가불균형 ${topLevelImbalanceText}`, `매수/매도체결비 ${buySellExecutionRatioText}`, `스프레드율 ${spreadRateText}`, `당일범위위치 ${rangePositionText}`, `최근가격범위 ${recentPriceRangeText}`, `단기모멘텀 ${intradayMomentumText}`, `체결강도 ${tradeStrengthText}`, `주문예산 ${budgetText}`, `예상매수가능 ${buyableQuantityText}`, portfolioText, feeTaxText, minutePatternText, ].join(" | "); } function resolveJournalNetAmount( summary: | { totalRealizedProfit: number; totalFee: number; totalTax: number; } | null | undefined, ) { if (!summary) { return Number.NaN; } const realizedProfit = Number(summary.totalRealizedProfit); const totalFee = Number(summary.totalFee); const totalTax = Number(summary.totalTax); if (!Number.isFinite(realizedProfit) || !Number.isFinite(totalFee) || !Number.isFinite(totalTax)) { return Number.NaN; } return realizedProfit - totalFee - totalTax; } function resolveTickLatencySec(tickTime: string | undefined, requestAt: Date) { if (!tickTime || !/^\d{6}$/.test(tickTime)) { return undefined; } const hour = Number.parseInt(tickTime.slice(0, 2), 10); const minute = Number.parseInt(tickTime.slice(2, 4), 10); const second = Number.parseInt(tickTime.slice(4, 6), 10); if ( !Number.isFinite(hour) || !Number.isFinite(minute) || !Number.isFinite(second) || hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 ) { return undefined; } const tickAt = new Date(requestAt); tickAt.setHours(hour, minute, second, 0); let latencyMs = requestAt.getTime() - tickAt.getTime(); // 틱시각이 요청시각보다 약간 앞서는 경우(클럭 오차/전송 순서)는 0초로 처리합니다. if (latencyMs < 0 && Math.abs(latencyMs) <= FUTURE_TICK_TOLERANCE_MS) { return 0; } // 자정 경계 보정은 '반나절 이상 음수'인 경우에만 적용합니다. // (예: 00:00 직후 요청, tickTime이 전일 23:59:xx) if (latencyMs < 0) { if (Math.abs(latencyMs) >= MIDNIGHT_WRAP_THRESHOLD_MS) { latencyMs += ONE_DAY_MS; } else { return undefined; } } return latencyMs >= 0 ? Number((latencyMs / 1000).toFixed(2)) : undefined; } function toValidationCashSourceLabel(source: "cash_balance" | "orderable_cash" | "min_of_both" | "none") { if (source === "min_of_both") { return "예수금/매수가능금액 중 더 보수적인 값"; } if (source === "orderable_cash") { return "매수가능금액 기준"; } if (source === "cash_balance") { return "예수금 기준"; } return "가용 자금 없음"; } function formatKstDateTime(date: Date) { return new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", hour12: false, month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }).format(date); } function averageNumbers(values: number[]) { if (values.length === 0) { return 0; } return values.reduce((sum, value) => sum + value, 0) / values.length; } function round2(value: number | undefined) { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; } return Number(value.toFixed(2)); } function round3(value: number | undefined) { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; } return Number(value.toFixed(3)); } function round4(value: number | undefined) { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; } return Number(value.toFixed(4)); } function calculateIntradayMomentum(prices: number[]) { if (prices.length < 2) { return 0; } const first = prices[0]; const last = prices[prices.length - 1]; if (!Number.isFinite(first) || !Number.isFinite(last) || first <= 0) { return 0; } return ((last - first) / first) * 100; } function clampRangePosition(value: number) { if (!Number.isFinite(value)) { return 0.5; } return Math.min(1, Math.max(0, value)); } function createSignalRequestId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `sig-${crypto.randomUUID()}`; } return `sig-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } function getOrCreateAutotradeTabId() { if (typeof window === "undefined") { return "server-tab"; } const key = "autotrade-tab-id"; const existing = window.sessionStorage.getItem(key); if (existing) return existing; const nextId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; window.sessionStorage.setItem(key, nextId); return nextId; }