"use client";
/**
* [파일 역할]
* 자동매매 제어 패널 UI를 렌더링합니다.
*
* [주요 책임]
* - 프롬프트/기법/리스크 입력값을 받습니다.
* - 시작/중지/검증 버튼 이벤트를 엔진 훅으로 전달합니다.
* - 엔진 상태(RUNNING/STOPPING/최근 신호/로그)를 시각적으로 표시합니다.
*/
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { useAutotradeEngine } from "@/features/autotrade/hooks/useAutotradeEngine";
import { AutotradeWarningBanner } from "@/features/autotrade/components/AutotradeWarningBanner";
import {
AUTOTRADE_AI_MODE_OPTIONS,
AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS,
AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_OPTIONS,
AUTOTRADE_TECHNIQUE_OPTIONS,
} from "@/features/autotrade/types/autotrade.types";
import type { AutotradeRuntimeLog } from "@/features/autotrade/types/autotrade.types";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardRealtimeTradeTick,
DashboardStockItem,
} from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
interface AutotradeControlPanelProps {
selectedStock: DashboardStockItem | null;
latestTick: DashboardRealtimeTradeTick | null;
credentials: KisRuntimeCredentials | null;
canTrade: boolean;
}
export function AutotradeControlPanel({
selectedStock,
latestTick,
credentials,
canTrade,
}: AutotradeControlPanelProps) {
/**
* [데이터 흐름]
* 설정 UI(프롬프트/기법/리스크) -> useAutotradeEngine 훅 액션 호출 ->
* /api/autotrade/strategies/compile, /signals/generate ->
* OpenAI or 구독형 CLI or fallback -> 신호/주문 반영
*/
// 화면 입력값과 런타임 상태는 엔진 훅에서 중앙 관리합니다.
const {
panelOpen,
setupForm,
engineState,
isWorking,
compiledStrategy,
validation,
lastSignal,
orderCountToday,
logs,
setPanelOpen,
patchSetupForm,
setNumberField,
toggleTechnique,
previewValidation,
startAutotrade,
stopAutotrade,
} = useAutotradeEngine({
selectedStock,
latestTick,
credentials,
canTrade,
});
// [State] 자동매매 실행 중 여부 (배너, 시작/중지 버튼 분기)
const isRunning = engineState === "RUNNING";
// [State] 중지 요청 처리 중 여부 (중복 중지 클릭 방지)
const isStopping = engineState === "STOPPING";
// [State] 시작 버튼 활성 조건 (인증/동의/작업 중 여부)
const canStartAutotrade =
canTrade &&
!isWorking &&
setupForm.agreeStopOnExit;
const shouldShowSubscriptionCliConfig =
setupForm.aiMode === "subscription_cli" || setupForm.aiMode === "auto";
const selectedCliModelOptions =
setupForm.subscriptionCliVendor === "codex"
? AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS.codex
: setupForm.subscriptionCliVendor === "gemini"
? AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS.gemini
: [];
const normalizedSubscriptionCliModel = setupForm.subscriptionCliModel.trim();
const hasPresetModel = selectedCliModelOptions.some(
(option) => option.value === normalizedSubscriptionCliModel,
);
const selectedCliModelSelectValue =
setupForm.subscriptionCliVendor === "auto"
? "__auto__"
: !normalizedSubscriptionCliModel
? "__default__"
: hasPresetModel
? normalizedSubscriptionCliModel
: "__custom__";
const [setupTab, setSetupTab] = useState<"quick" | "ai" | "technique">("quick");
const [isLogPanelOpen, setIsLogPanelOpen] = useState(false);
const [showTechnicalLog, setShowTechnicalLog] = useState(false);
const latestSignalRequestLog = logs.find((log) => log.stage === "signal_request") ?? null;
const latestSignalResponseLog = logs.find((log) => log.stage === "signal_response") ?? null;
const latestSignalRequestDetail = parseLogDetail(latestSignalRequestLog?.detail);
const latestSignalResponseDetail = parseLogDetail(latestSignalResponseLog?.detail);
const latestSignalRequestId =
latestSignalRequestDetail && typeof latestSignalRequestDetail.requestId === "string"
? latestSignalRequestDetail.requestId
: null;
const latestSignalResponseId =
latestSignalResponseDetail && typeof latestSignalResponseDetail.requestId === "string"
? latestSignalResponseDetail.requestId
: null;
const isSignalResponsePending =
!!latestSignalRequestId &&
(!!latestSignalRequestLog &&
(!latestSignalResponseLog || latestSignalRequestId !== latestSignalResponseId));
const latestCompletedSignalResponseLog = latestSignalResponseLog;
const latestCompletedSignalResponseDetail = parseLogDetail(
latestCompletedSignalResponseLog?.detail,
);
const latestCompletedRequestId =
latestCompletedSignalResponseDetail &&
typeof latestCompletedSignalResponseDetail.requestId === "string"
? latestCompletedSignalResponseDetail.requestId
: null;
const latestCompletedSignalRequestLog = latestCompletedRequestId
? logs.find((log) => {
if (log.stage !== "signal_request") return false;
const detail = parseLogDetail(log.detail);
return detail?.requestId === latestCompletedRequestId;
}) ?? null
: null;
const latestCompletedSignalRequestDetail = parseLogDetail(
latestCompletedSignalRequestLog?.detail,
);
const latestPromptText =
(latestCompletedSignalRequestDetail &&
typeof latestCompletedSignalRequestDetail.promptExcerpt === "string" &&
latestCompletedSignalRequestDetail.promptExcerpt.trim().length > 0 &&
latestCompletedSignalRequestDetail.promptExcerpt.trim()) ||
(latestSignalRequestDetail &&
typeof latestSignalRequestDetail.promptExcerpt === "string" &&
latestSignalRequestDetail.promptExcerpt.trim().length > 0 &&
latestSignalRequestDetail.promptExcerpt.trim()) ||
setupForm.prompt.trim() ||
"프롬프트 미입력";
const latestRequestDataText =
(latestCompletedSignalRequestDetail &&
typeof latestCompletedSignalRequestDetail.snapshotSummary === "string" &&
latestCompletedSignalRequestDetail.snapshotSummary.trim().length > 0 &&
latestCompletedSignalRequestDetail.snapshotSummary.trim()) ||
(latestSignalRequestDetail &&
typeof latestSignalRequestDetail.snapshotSummary === "string" &&
latestSignalRequestDetail.snapshotSummary.trim().length > 0 &&
latestSignalRequestDetail.snapshotSummary.trim()) ||
null;
const latestAiReasonText =
(latestCompletedSignalResponseDetail &&
typeof latestCompletedSignalResponseDetail.reason === "string" &&
latestCompletedSignalResponseDetail.reason.trim().length > 0 &&
latestCompletedSignalResponseDetail.reason.trim()) ||
null;
const allocationPercentPresets = [5, 10, 15, 20, 30] as const;
const allocationReferenceCash = validation?.cashBalance ?? 0;
const allocationByPercentAmount =
allocationReferenceCash > 0
? Math.floor((allocationReferenceCash * Math.max(0, setupForm.allocationPercent)) / 100)
: 0;
const estimatedBuyableQuantityByCurrentPrice =
validation && latestTick?.price && latestTick.price > 0
? Math.max(0, Math.floor(validation.effectiveAllocationAmount / latestTick.price))
: 0;
const budgetCardValue = validation
? `${validation.effectiveAllocationAmount.toLocaleString("ko-KR")}원`
: `${setupForm.allocationAmount.toLocaleString("ko-KR")}원 (입력 기준)`;
useEffect(() => {
if (setupForm.subscriptionCliVendor === "auto") return;
if (setupForm.subscriptionCliModel.trim().length > 0) return;
patchSetupForm({
subscriptionCliModel:
setupForm.subscriptionCliVendor === "codex" ? "gpt-5.4" : "auto",
});
}, [
patchSetupForm,
setupForm.subscriptionCliModel,
setupForm.subscriptionCliVendor,
]);
const handleOpenPanel = () => {
// [Step 1] 설정 모달을 열어 사용자 입력을 받습니다.
setSetupTab("quick");
setPanelOpen(true);
};
const handleClosePanel = () => {
// [Step 1] 설정 모달을 닫고 현재 입력값은 store에 유지합니다.
setPanelOpen(false);
};
const handleEmergencyStop = () => {
// [Step 1] 경고 배너에서 긴급 중지를 요청합니다.
// [Step 2] 엔진 훅이 stop API와 상태 정리를 수행합니다.
void stopAutotrade("emergency");
};
const handleStartAutotrade = () => {
// [Step 1] 엔진 훅의 시작 플로우(compile -> validate -> session start)를 실행합니다.
void startAutotrade();
};
const handleManualStop = () => {
// [Step 1] 사용자가 수동 중지를 요청합니다.
// [Step 2] 엔진 훅이 세션 stop API 호출 후 상태를 STOPPED로 전환합니다.
void stopAutotrade("manual");
};
const handlePreviewValidation = () => {
// [Step 1] compile + validate를 먼저 실행해 실제 적용 투자금/손실한도를 미리 확인합니다.
void previewValidation();
};
const handleAiModeChange = (nextMode: (typeof AUTOTRADE_AI_MODE_OPTIONS)[number]["id"]) => {
// [Step 1] 선택한 AI 모드를 setupForm에 반영합니다.
patchSetupForm({ aiMode: nextMode });
};
const handlePromptChange = (nextPrompt: string) => {
// [Step 1] 프롬프트 입력값을 store에 반영합니다.
// [Step 2] 시작/검증 시 setupForm.prompt가 compile API로 전달됩니다.
patchSetupForm({ prompt: nextPrompt });
};
const handleSubscriptionCliVendorChange = (
nextVendor: (typeof AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_OPTIONS)[number]["id"],
) => {
// [Step 1] 구독형 CLI 벤더(codex/gemini/auto)를 저장합니다.
// [Step 2] 벤더 변경 시 기본 모델을 자동 적용해 테스트 입력 단계를 줄입니다.
const defaultModel =
nextVendor === "auto"
? ""
: nextVendor === "codex"
? "gpt-5.4"
: "auto";
patchSetupForm({
subscriptionCliVendor: nextVendor,
subscriptionCliModel: defaultModel,
});
};
const handleSubscriptionCliModelSelectChange = (nextValue: string) => {
if (nextValue === "__default__") {
patchSetupForm({ subscriptionCliModel: "" });
return;
}
if (nextValue === "__custom__" || nextValue === "__auto__") {
return;
}
patchSetupForm({ subscriptionCliModel: nextValue });
};
const handleSubscriptionCliModelInputChange = (nextModel: string) => {
patchSetupForm({ subscriptionCliModel: nextModel });
};
const handleAgreeStopOnExitChange = (nextChecked: boolean | "indeterminate") => {
// [Step 1] 브라우저 이탈 시 자동중지 동의 값을 저장합니다.
patchSetupForm({ agreeStopOnExit: nextChecked === true });
};
const handleApplyAllocationPercentPreset = (percent: number) => {
patchSetupForm({ allocationPercent: percent });
};
const handleSyncAllocationAmountFromPercent = () => {
if (allocationByPercentAmount <= 0) return;
patchSetupForm({ allocationAmount: allocationByPercentAmount });
};
const applyPreset = (preset: "starter" | "balanced" | "active") => {
// [Step 1] 프리셋 종류에 따라 권장 리스크 값을 한번에 채웁니다.
if (preset === "starter") {
patchSetupForm({
allocationPercent: 5,
allocationAmount: 300000,
dailyLossPercent: 1.5,
dailyLossAmount: 25000,
confidenceThreshold: 0.72,
});
return;
}
if (preset === "balanced") {
patchSetupForm({
allocationPercent: 10,
allocationAmount: 500000,
dailyLossPercent: 2,
dailyLossAmount: 50000,
confidenceThreshold: 0.67,
});
return;
}
patchSetupForm({
allocationPercent: 15,
allocationAmount: 1000000,
dailyLossPercent: 3,
dailyLossAmount: 100000,
confidenceThreshold: 0.6,
});
};
return (
<>
{/* ===== 1. 실행 중 경고 배너 (중지 버튼 포함) ===== */}
실시간 자동매매 안내
입력:
{" "}
{latestPromptText || "아직 AI 요청 전입니다."}
{(isRunning || isStopping || isSignalResponsePending) && (
▍
)}
요청 데이터:
{" "}
{latestRequestDataText}
답변:
{" "}
{latestAiReasonText ?? "아직 응답이 없습니다."}
{isSignalResponsePending && (
(새 분석 진행 중...)
)}
{friendly.primary}
{friendly.secondary}
{logs.slice(0, 10).map((log) => {
const friendly = toFriendlyLog(log);
return (
)}
{log.detail}
)}
처음 쓰셔도 쉽게 맞출 수 있게 핵심만 모았습니다.
{resolveFriendlyAiModeDescription(setupForm.aiMode)}
{shouldShowSubscriptionCliConfig && (모델 선택
자동 선택은 Codex를 먼저 시도하고, 안 되면 Gemini를 시도합니다.
) : ( <> {(selectedCliModelSelectValue === "__custom__" || normalizedSubscriptionCliModel.length > 0) && ( handleSubscriptionCliModelInputChange(event.target.value) } placeholder="예) gpt-5.4, gpt-5.3-codex, gpt-5-codex-mini" /> )} > )}현재 기준: {" "} {allocationReferenceCash > 0 ? `${setupForm.allocationPercent.toFixed(1)}% ≈ ${allocationByPercentAmount.toLocaleString("ko-KR")}원` : "기준 자금 확인 전"}
빠른 추천값
안전 점검 결과
{validation ? (주문 기준 자금: {validation.cashBalance.toLocaleString("ko-KR")}원
오늘 주문 예산: {validation.effectiveAllocationAmount.toLocaleString("ko-KR")}원
현재가 기준 예상 매수 가능: {" "} {latestTick?.price && latestTick.price > 0 ? `${estimatedBuyableQuantityByCurrentPrice.toLocaleString("ko-KR")}주 (현재가 ${latestTick.price.toLocaleString("ko-KR")}원)` : "현재가 수신 전"}
자동중지 손실선: {validation.effectiveDailyLossLimit.toLocaleString("ko-KR")}원
점검 상태: {validation.isValid ? "통과" : "차단"}
{!validation.isValid && validation.blockedReasons.length > 0 && (차단 이유: {validation.blockedReasons[0]}
)}아직 점검 전입니다. "검증 미리보기"를 눌러 확인해 주세요.
)}{label}
); } function FieldHelp({ text }: { text: string }) { return ({text}
); } function CompactStepCard({ title, description, }: { title: string; description: string; }) { return ({title}
{description}
{label}
{value}