2167 lines
75 KiB
TypeScript
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;
|
||
|
|
}
|