"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. 실행 중 경고 배너 (중지 버튼 포함) ===== */}
{/* ===== 2. 상단 상태/액션 영역 ===== */}
자동매매 {engineState} {selectedStock ? ( 대상 종목: {selectedStock.name} ({selectedStock.symbol}) ) : ( 대상 종목 미선택 )} 당일 자동주문 {orderCountToday}건
{!isRunning ? ( ) : ( )}
{/* ===== 3. 상태 카드 영역 ===== */}
{/* ===== 4. 최근 로그 요약 ===== */} {(logs.length > 0 || isRunning || isStopping) && (

실시간 자동매매 안내

{isLogPanelOpen && ( )}

입력: {" "} {latestPromptText || "아직 AI 요청 전입니다."} {(isRunning || isStopping || isSignalResponsePending) && ( )}

{latestRequestDataText && (

요청 데이터: {" "} {latestRequestDataText}

)}

답변: {" "} {latestAiReasonText ?? "아직 응답이 없습니다."} {isSignalResponsePending && ( (새 분석 진행 중...) )}

{isLogPanelOpen && (
    {logs.slice(0, 10).map((log) => { const friendly = toFriendlyLog(log); return (
  • {new Date(log.createdAt).toLocaleTimeString("ko-KR")} {log.level.toUpperCase()} {log.stage && ( {resolveLogStageLabel(log.stage)} )}

    {friendly.primary}

    {friendly.secondary && (

    {friendly.secondary}

    )} {showTechnicalLog && log.detail && (
                                {log.detail}
                              
    )}
  • ); })}
)}
)}
{/* ===== 5. 설정 모달 ===== */} {panelOpen && (

자동매매 간편 설정

처음 쓰셔도 쉽게 맞출 수 있게 핵심만 모았습니다.

{/* 좌측: 판단 소스/전략 설정 */} {setupTab === "ai" && ( <>

{resolveFriendlyAiModeDescription(setupForm.aiMode)}

{shouldShowSubscriptionCliConfig && (

모델 선택

{setupForm.subscriptionCliVendor === "auto" ? (

자동 선택은 Codex를 먼저 시도하고, 안 되면 Gemini를 시도합니다.

) : ( <> {(selectedCliModelSelectValue === "__custom__" || normalizedSubscriptionCliModel.length > 0) && ( handleSubscriptionCliModelInputChange(event.target.value) } placeholder="예) gpt-5.4, gpt-5.3-codex, gpt-5-codex-mini" /> )} )}
)} )} {setupTab === "quick" && ( <> {/* [프롬프트 흐름] 1) textarea 입력 -> handlePromptChange -> patchSetupForm(prompt) */} {/* [프롬프트 흐름] 2) 시작/검증 클릭 -> startAutotrade or previewValidation -> prepareStrategy */} {/* [프롬프트 흐름] 3) prepareStrategy -> compileAutotradeStrategy -> /api/autotrade/strategies/compile */}