Files
auto-trade/features/autotrade/hooks/useAutotradeEngine.ts

2167 lines
75 KiB
TypeScript

"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<AutotradeExecutionCostProfileSnapshot>(
() => resolveExecutionCostProfile(),
[],
);
// [Ref] 현재 브라우저 탭 식별자 (세션 heartbeat leader 판별용)
const tabId = useMemo(() => getOrCreateAutotradeTabId(), []);
// [Ref] 최근 가격 캐시 (신호 생성 API에 recentPrices로 전달)
const recentPricesRef = useRef<number[]>([]);
// [Ref] 최근 체결 캐시 (유동성/체결흐름 파생 지표 계산)
const recentTicksRef = useRef<RecentSignalTick[]>([]);
// [Ref] 최근 1분봉 캐시 (신규 패턴 프롬프트용 봉 구조 보강)
const minuteChartCacheRef = useRef<SignalMinuteChartCache>({
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<string, unknown>;
}
async function resolveExecutableOrderQuantity(
params: ResolveExecutableOrderQuantityParams,
): Promise<ResolveExecutableOrderQuantityResult> {
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;
}