Files
auto-trade/features/autotrade/components/AutotradeControlPanel.tsx

1344 lines
52 KiB
TypeScript

"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. 실행 중 경고 배너 (중지 버튼 포함) ===== */}
<AutotradeWarningBanner
visible={isRunning || isStopping}
isStopping={isStopping}
onStop={handleEmergencyStop}
/>
<section className="border-b border-border/70 bg-background/95 px-3 py-2 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/20 sm:px-4">
<div className="mx-auto flex w-full max-w-450 flex-col gap-1.5">
{/* ===== 2. 상단 상태/액션 영역 ===== */}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={resolveStatusBadgeVariant(engineState)}>
{engineState}
</Badge>
{selectedStock ? (
<Badge variant="outline">
: {selectedStock.name} ({selectedStock.symbol})
</Badge>
) : (
<Badge variant="outline"> </Badge>
)}
<Badge variant="outline"> {orderCountToday}</Badge>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleOpenPanel}
disabled={!canTrade}
>
</Button>
{!isRunning ? (
<Button
type="button"
size="sm"
onClick={handleStartAutotrade}
disabled={isWorking || !canTrade}
>
{isWorking ? "시작 준비 중..." : "자동매매 시작"}
</Button>
) : (
<Button
type="button"
size="sm"
variant="destructive"
onClick={handleManualStop}
disabled={isStopping}
>
{isStopping ? "중지 중..." : "자동매매 중지"}
</Button>
)}
</div>
</div>
{/* ===== 3. 상태 카드 영역 ===== */}
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<StatusItem
label="전략 요약"
value={compiledStrategy?.summary ?? "아직 컴파일된 전략이 없습니다."}
/>
<StatusItem
label="오늘 자동매매 예산"
value={budgetCardValue}
/>
<StatusItem
label="오늘 자동중지 손실선"
value={
validation
? `${validation.effectiveDailyLossLimit.toLocaleString("ko-KR")}`
: "-"
}
/>
<StatusItem
label="최근 신호"
value={
lastSignal
? `${lastSignal.signal.toUpperCase()} (${lastSignal.confidence.toFixed(2)})`
: "신호 없음"
}
tone={
lastSignal?.signal === "buy"
? "buy"
: lastSignal?.signal === "sell"
? "sell"
: "default"
}
/>
</div>
{/* ===== 4. 최근 로그 요약 ===== */}
{(logs.length > 0 || isRunning || isStopping) && (
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2 text-xs dark:border-brand-800/45 dark:bg-brand-900/24">
<div className="flex items-center justify-between gap-2">
<p className="font-semibold text-foreground dark:text-brand-50">
</p>
<div className="flex items-center gap-1">
{isLogPanelOpen && (
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-[11px]"
onClick={() => setShowTechnicalLog((prev) => !prev)}
>
{showTechnicalLog ? "쉬운 보기" : "개발자 상세"}
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-[11px]"
onClick={() => setIsLogPanelOpen((prev) => !prev)}
>
{isLogPanelOpen ? "접기" : "펼치기"}
</Button>
</div>
</div>
<div className="mt-1 rounded bg-background/70 px-2 py-1.5 text-[12px] text-foreground dark:bg-brand-900/35 dark:text-brand-50">
<p>
:
{" "}
{latestPromptText || "아직 AI 요청 전입니다."}
{(isRunning || isStopping || isSignalResponsePending) && (
<span className="ml-1 inline-block animate-pulse text-primary"></span>
)}
</p>
{latestRequestDataText && (
<p className="mt-1 text-muted-foreground dark:text-brand-100/75">
:
{" "}
{latestRequestDataText}
</p>
)}
<p className="mt-1 text-muted-foreground dark:text-brand-100/75">
:
{" "}
{latestAiReasonText ?? "아직 응답이 없습니다."}
{isSignalResponsePending && (
<span className="ml-1 text-primary">
( ...)
</span>
)}
</p>
</div>
{isLogPanelOpen && (
<ul className="mt-2 max-h-44 space-y-2 overflow-y-auto pr-1">
{logs.slice(0, 10).map((log) => {
const friendly = toFriendlyLog(log);
return (
<li
key={log.id}
className="rounded-md border border-border/60 bg-background/80 px-2 py-1.5 dark:border-brand-800/40 dark:bg-brand-900/24"
>
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="text-muted-foreground dark:text-brand-100/75">
{new Date(log.createdAt).toLocaleTimeString("ko-KR")}
</span>
<span
className={cn(
"rounded px-1.5 py-0.5 font-semibold",
resolveLogLevelTone(log.level),
)}
>
{log.level.toUpperCase()}
</span>
{log.stage && (
<span className="rounded bg-muted px-1.5 py-0.5 font-medium text-foreground dark:bg-brand-900/45 dark:text-brand-50">
{resolveLogStageLabel(log.stage)}
</span>
)}
</div>
<p className="mt-1 text-foreground dark:text-brand-50">
{friendly.primary}
</p>
{friendly.secondary && (
<p className="mt-0.5 text-muted-foreground dark:text-brand-100/75">
{friendly.secondary}
</p>
)}
{showTechnicalLog && log.detail && (
<pre className="mt-1 overflow-x-auto rounded bg-muted/35 p-1.5 text-[11px] leading-relaxed text-foreground dark:bg-brand-900/40 dark:text-brand-50">
{log.detail}
</pre>
)}
</li>
);
})}
</ul>
)}
</div>
)}
</div>
</section>
{/* ===== 5. 설정 모달 ===== */}
{panelOpen && (
<div className="fixed inset-0 z-50 overflow-y-auto bg-black/55 p-3 sm:p-4">
<div className="relative mx-auto w-full max-w-6xl rounded-2xl border border-border bg-background p-4 shadow-2xl max-h-[92vh] overflow-y-auto dark:border-brand-700/55 dark:bg-brand-950 sm:p-5">
<button
type="button"
className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground hover:bg-muted dark:hover:bg-brand-900/35"
onClick={handleClosePanel}
aria-label="자동매매 설정 닫기"
>
<X className="h-4 w-4" />
</button>
<h2 className="text-lg font-semibold text-foreground dark:text-brand-50">
</h2>
<p className="mt-1 text-sm text-muted-foreground dark:text-brand-100/70">
.
</p>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<CompactStepCard
title="1. 빠른 설정"
description="투자금/손실 한도부터 입력"
/>
<CompactStepCard
title="2. AI 선택"
description="자동 또는 원하는 모델 고르기"
/>
<CompactStepCard
title="3. 기법 선택"
description="원하는 기법만 체크 후 시작"
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={setupTab === "quick" ? "default" : "outline"}
onClick={() => setSetupTab("quick")}
>
</Button>
<Button
type="button"
size="sm"
variant={setupTab === "ai" ? "default" : "outline"}
onClick={() => setSetupTab("ai")}
>
AI/
</Button>
<Button
type="button"
size="sm"
variant={setupTab === "technique" ? "default" : "outline"}
onClick={() => setSetupTab("technique")}
>
</Button>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-4">
{/* 좌측: 판단 소스/전략 설정 */}
{setupTab === "ai" && (
<>
<FieldLabel label="AI 두뇌 선택" />
<FieldHelp text="모르겠으면 자동(권장)으로 두면 됩니다." />
<select
id="autotrade-ai-mode-select"
aria-label="AI 판단 모드"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring dark:border-brand-700/55 dark:bg-brand-900/22"
value={setupForm.aiMode}
onChange={(event) =>
handleAiModeChange(
event.target.value as (typeof AUTOTRADE_AI_MODE_OPTIONS)[number]["id"],
)
}
>
{AUTOTRADE_AI_MODE_OPTIONS.map((mode) => (
<option key={mode.id} value={mode.id}>
{mode.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground dark:text-brand-100/70">
{resolveFriendlyAiModeDescription(setupForm.aiMode)}
</p>
{shouldShowSubscriptionCliConfig && (
<div className="space-y-2 rounded-md border border-border/65 bg-muted/20 px-3 py-2 text-xs dark:border-brand-700/45 dark:bg-brand-900/18">
<p className="font-semibold text-foreground dark:text-brand-50">
</p>
<FieldHelp text="모델은 성능과 속도의 균형입니다. 추천 모델부터 시작하세요." />
<select
aria-label="구독형 CLI 벤더 선택"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring dark:border-brand-700/55 dark:bg-brand-900/22"
value={setupForm.subscriptionCliVendor}
onChange={(event) =>
handleSubscriptionCliVendorChange(
event.target
.value as (typeof AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_OPTIONS)[number]["id"],
)
}
>
{AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
{setupForm.subscriptionCliVendor === "auto" ? (
<p className="text-muted-foreground dark:text-brand-100/70">
Codex를 , Gemini를 .
</p>
) : (
<>
<select
aria-label="구독형 CLI 모델 선택"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring dark:border-brand-700/55 dark:bg-brand-900/22"
value={selectedCliModelSelectValue}
onChange={(event) =>
handleSubscriptionCliModelSelectChange(event.target.value)
}
>
<option value="__default__">CLI ()</option>
{selectedCliModelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
<option value="__custom__"> </option>
</select>
{(selectedCliModelSelectValue === "__custom__" ||
normalizedSubscriptionCliModel.length > 0) && (
<Input
type="text"
aria-label="구독형 CLI 모델 직접 입력"
value={setupForm.subscriptionCliModel}
onChange={(event) =>
handleSubscriptionCliModelInputChange(event.target.value)
}
placeholder="예) gpt-5.4, gpt-5.3-codex, gpt-5-codex-mini"
/>
)}
</>
)}
</div>
)}
</>
)}
{setupTab === "quick" && (
<>
<FieldLabel label="전략 한 줄 요청 (선택)" />
<FieldHelp text="비워도 기본 기법으로 동작합니다. 한 줄로 간단히 적어주세요." />
{/* [프롬프트 흐름] 1) textarea 입력 -> handlePromptChange -> patchSetupForm(prompt) */}
{/* [프롬프트 흐름] 2) 시작/검증 클릭 -> startAutotrade or previewValidation -> prepareStrategy */}
{/* [프롬프트 흐름] 3) prepareStrategy -> compileAutotradeStrategy -> /api/autotrade/strategies/compile */}
<textarea
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring dark:border-brand-700/55 dark:bg-brand-900/22"
value={setupForm.prompt}
onChange={(event) => handlePromptChange(event.target.value)}
placeholder="예) 오전에는 짧게, 오후에는 보수적으로 매매해줘"
/>
</>
)}
{setupTab === "ai" && (
<div className="rounded-md border border-border/65 bg-muted/20 px-3 py-2 text-xs dark:border-brand-700/45 dark:bg-brand-900/18">
<p className="font-semibold text-foreground dark:text-brand-50">
AI가
</p>
<p className="mt-1 text-muted-foreground dark:text-brand-100/75">
, , /, , , ,
.
</p>
</div>
)}
{setupTab === "technique" && (
<>
<FieldLabel label="매매 기법 선택" />
<FieldHelp text="기법을 선택하지 않으면 기본 기법(이평교차, VWAP, 박스권 단타)을 자동 적용합니다." />
<div className="grid max-h-[22rem] gap-2 overflow-y-auto pr-1 md:grid-cols-2">
{AUTOTRADE_TECHNIQUE_OPTIONS.map((technique) => {
const checked = setupForm.selectedTechniques.includes(technique.id);
return (
<label
key={technique.id}
className={cn(
"flex cursor-pointer items-start gap-3 rounded-md border px-3 py-2",
checked
? "border-primary/50 bg-primary/10"
: "border-border/65 bg-muted/20 dark:border-brand-700/45 dark:bg-brand-900/18",
)}
>
<Checkbox
checked={checked}
onCheckedChange={(nextChecked) => {
toggleTechnique(technique.id, nextChecked === true);
}}
/>
<span className="space-y-0.5">
<span className="block text-sm font-semibold text-foreground dark:text-brand-50">
{technique.label}
</span>
<span className="block text-xs text-muted-foreground dark:text-brand-100/70">
{technique.description}
</span>
</span>
</label>
);
})}
</div>
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">
: 처음에는 2~3 .
</p>
</>
)}
</div>
<div className="space-y-4">
{/* 우측: 리스크/동의/검증 요약 */}
{setupTab === "quick" && (
<>
<FieldLabel label="오늘 투자금 (비율 + 금액)" />
<FieldHelp text="비율을 먼저 고르고, 금액은 자동입력 버튼으로 맞추면 가장 쉽습니다." />
<div className="space-y-2 rounded-md border border-border/65 bg-muted/20 px-3 py-2 dark:border-brand-700/45 dark:bg-brand-900/18">
<div className="flex flex-wrap gap-2">
{allocationPercentPresets.map((percent) => (
<Button
key={percent}
type="button"
size="sm"
variant={Math.round(setupForm.allocationPercent) === percent ? "default" : "outline"}
className="h-7"
onClick={() => handleApplyAllocationPercentPreset(percent)}
>
{percent}%
</Button>
))}
</div>
<Input
type="range"
min={1}
max={100}
step={1}
value={Math.max(1, Math.min(100, Math.round(setupForm.allocationPercent)))}
onChange={(event) =>
setNumberField("allocationPercent", event.target.value)
}
aria-label="투자 비율 슬라이더"
/>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
min={0}
max={100}
step={0.5}
value={setupForm.allocationPercent}
onChange={(event) =>
setNumberField("allocationPercent", event.target.value)
}
placeholder="투자 비율(%)"
/>
<Input
type="number"
min={0}
step={1000}
value={setupForm.allocationAmount}
onChange={(event) =>
setNumberField("allocationAmount", event.target.value)
}
placeholder="실제 금액(원)"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground dark:text-brand-100/70">
<p>
:
{" "}
{allocationReferenceCash > 0
? `${setupForm.allocationPercent.toFixed(1)}% ≈ ${allocationByPercentAmount.toLocaleString("ko-KR")}`
: "기준 자금 확인 전"}
</p>
<Button
type="button"
size="sm"
variant="outline"
className="h-7"
onClick={handleSyncAllocationAmountFromPercent}
disabled={allocationByPercentAmount <= 0}
>
</Button>
</div>
</div>
<FieldLabel label="하루 손실 한도 (비율 + 금액)" />
<FieldHelp text="손실 한도에 도달하면 자동으로 중지합니다." />
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
min={0}
step={0.1}
value={setupForm.dailyLossPercent}
onChange={(event) =>
setNumberField("dailyLossPercent", event.target.value)
}
placeholder="참고 비율(%)"
/>
<Input
type="number"
min={0}
step={1000}
value={setupForm.dailyLossAmount}
onChange={(event) =>
setNumberField("dailyLossAmount", event.target.value)
}
placeholder="실제 금액(원)"
/>
</div>
<FieldLabel label="신뢰도 기준 (0.45~0.95)" />
<FieldHelp text="0.65라면 65점 이상 신호만 주문합니다." />
<Input
type="number"
min={0.45}
max={0.95}
step={0.01}
value={setupForm.confidenceThreshold}
onChange={(event) =>
setNumberField("confidenceThreshold", event.target.value)
}
placeholder="0.65"
/>
<div className="space-y-2 rounded-md border border-border/65 bg-muted/20 px-3 py-2 text-xs dark:border-brand-700/45 dark:bg-brand-900/18">
<p className="font-semibold text-foreground dark:text-brand-50">
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-7"
onClick={() => applyPreset("starter")}
>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7"
onClick={() => applyPreset("balanced")}
>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7"
onClick={() => applyPreset("active")}
>
</Button>
</div>
</div>
<label className="flex items-center gap-2 rounded-md border border-border/65 bg-muted/20 px-3 py-2 text-sm dark:border-brand-700/45 dark:bg-brand-900/18">
<Checkbox
checked={setupForm.agreeStopOnExit}
onCheckedChange={handleAgreeStopOnExitChange}
/>
<span>
.
</span>
</label>
</>
)}
<div className="rounded-md border border-border/65 bg-muted/20 px-3 py-2 text-xs dark:border-brand-700/45 dark:bg-brand-900/18">
<p className="font-semibold text-foreground dark:text-brand-50">
</p>
{validation ? (
<div className="mt-1 space-y-1 text-muted-foreground dark:text-brand-100/75">
<p> : {validation.cashBalance.toLocaleString("ko-KR")}</p>
<p> : {validation.effectiveAllocationAmount.toLocaleString("ko-KR")}</p>
<p>
:
{" "}
{latestTick?.price && latestTick.price > 0
? `${estimatedBuyableQuantityByCurrentPrice.toLocaleString("ko-KR")}주 (현재가 ${latestTick.price.toLocaleString("ko-KR")}원)`
: "현재가 수신 전"}
</p>
<p> : {validation.effectiveDailyLossLimit.toLocaleString("ko-KR")}</p>
<p> : {validation.isValid ? "통과" : "차단"}</p>
{!validation.isValid && validation.blockedReasons.length > 0 && (
<p className="text-red-500 dark:text-red-400">
: {validation.blockedReasons[0]}
</p>
)}
</div>
) : (
<p className="mt-1 text-muted-foreground dark:text-brand-100/75">
. &quot; &quot; .
</p>
)}
</div>
</div>
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleClosePanel}
disabled={isWorking}
>
</Button>
<Button
type="button"
variant="outline"
onClick={handlePreviewValidation}
disabled={isWorking || !canTrade}
>
</Button>
<Button
type="button"
onClick={handleStartAutotrade}
disabled={!canStartAutotrade}
>
{isWorking ? "시작 준비 중..." : "자동매매 시작하기"}
</Button>
</div>
</div>
</div>
)}
</>
);
}
function FieldLabel({ label }: { label: string }) {
return (
<p className="text-sm font-semibold text-foreground dark:text-brand-50">{label}</p>
);
}
function FieldHelp({ text }: { text: string }) {
return (
<p className="text-xs text-muted-foreground dark:text-brand-100/70">
{text}
</p>
);
}
function CompactStepCard({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="rounded-md border border-border/65 bg-muted/20 px-3 py-2 text-xs dark:border-brand-800/45 dark:bg-brand-900/24">
<p className="font-semibold text-foreground dark:text-brand-50">{title}</p>
<p className="mt-1 text-muted-foreground dark:text-brand-100/70">{description}</p>
</div>
);
}
function StatusItem({
label,
value,
tone = "default",
}: {
label: string;
value: string;
tone?: "default" | "buy" | "sell";
}) {
return (
<div className="rounded-md border border-border/65 bg-muted/20 px-3 py-2 text-xs dark:border-brand-800/45 dark:bg-brand-900/24">
<p className="text-muted-foreground dark:text-brand-100/70">{label}</p>
<p
className={cn(
"mt-1 line-clamp-2 font-semibold",
tone === "buy" && "text-red-500 dark:text-red-400",
tone === "sell" && "text-blue-600 dark:text-blue-400",
tone === "default" && "text-foreground dark:text-brand-50",
)}
>
{value}
</p>
</div>
);
}
function resolveLogStageLabel(stage: NonNullable<AutotradeRuntimeLog["stage"]>) {
switch (stage) {
case "session":
return "세션";
case "strategy_compile":
return "전략컴파일";
case "strategy_validate":
return "리스크검증";
case "signal_request":
return "신호요청";
case "signal_response":
return "신호응답";
case "risk_gate":
return "리스크게이트";
case "order_execution":
return "주문실행";
case "order_blocked":
return "주문차단";
case "provider_fallback":
return "대체처리";
case "engine_error":
return "오류";
default:
return "기타";
}
}
function resolveLogLevelTone(level: "info" | "warning" | "error") {
if (level === "error") {
return "bg-red-500/15 text-red-600 dark:text-red-300";
}
if (level === "warning") {
return "bg-amber-500/20 text-amber-700 dark:text-amber-200";
}
return "bg-emerald-500/15 text-emerald-700 dark:text-emerald-200";
}
function toFriendlyLog(log: AutotradeRuntimeLog) {
const detail = parseLogDetail(log.detail);
if (!log.stage) {
return {
id: log.id,
primary: log.message,
secondary: undefined,
};
}
if (log.stage === "strategy_compile") {
if (typeof detail?.provider === "string") {
return {
id: log.id,
primary: "AI가 자동매매 전략 구성을 완료했어요.",
secondary: `엔진: ${toProviderLabel(detail.provider)} · 신뢰도 기준 ${toPercentText(detail.confidenceThreshold)}`,
};
}
return {
id: log.id,
primary: "AI에게 전략 생성을 요청했어요.",
secondary: `기법: ${toTechniqueText(detail?.selectedTechniques)}`,
};
}
if (log.stage === "strategy_validate") {
if (log.level === "warning") {
return {
id: log.id,
primary: "리스크 검증에서 주의가 필요해요.",
secondary: extractFirstReason(detail) ?? log.message,
};
}
return {
id: log.id,
primary: "리스크 검증을 진행했어요.",
secondary:
detail && typeof detail.effectiveAllocationAmount === "number"
? `오늘 주문 예산 ${toWon(detail.effectiveAllocationAmount)} · 자동중지 ${toWon(detail.effectiveDailyLossLimit)}`
: undefined,
};
}
if (log.stage === "signal_request") {
const snapshot = isRecord(detail?.snapshot) ? detail.snapshot : null;
const volume = snapshot && isRecord(snapshot.volume) ? snapshot.volume : null;
const currentPrice =
snapshot && typeof snapshot.currentPrice === "number"
? snapshot.currentPrice
: undefined;
const changeRate =
snapshot && typeof snapshot.changeRate === "number"
? snapshot.changeRate
: undefined;
const volumeRatio =
volume && typeof volume.volumeRatio === "number"
? volume.volumeRatio
: undefined;
return {
id: log.id,
primary: "AI 판단을 요청했어요. 지금 시세를 분석 중입니다.",
secondary:
currentPrice && Number.isFinite(currentPrice)
? `현재가 ${toWon(currentPrice)} · 등락률 ${toSignedPercent(changeRate)} · 거래량비 ${toRatioText(volumeRatio)}`
: undefined,
};
}
if (log.stage === "signal_response") {
const signalText =
detail && typeof detail.signal === "string"
? toSignalLabel(detail.signal)
: "판단 수신";
const confidenceText =
detail && typeof detail.confidence === "number"
? `${Math.round(detail.confidence * 100)}`
: "";
const reasonText =
detail && typeof detail.reason === "string" ? detail.reason : log.message;
return {
id: log.id,
primary: `AI 판단 결과: ${signalText}${confidenceText ? ` (${confidenceText})` : ""}`,
secondary: reasonText,
};
}
if (log.stage === "provider_fallback") {
return {
id: log.id,
primary: "AI 응답이 불안정해서 규칙 기반 판단으로 안전 전환했어요.",
secondary: typeof detail?.fallbackReasonPreview === "string" ? detail.fallbackReasonPreview : undefined,
};
}
if (log.stage === "risk_gate") {
return {
id: log.id,
primary: "리스크 규칙 때문에 주문을 잠시 보류했어요.",
secondary: extractFirstReason(detail) ?? log.message,
};
}
if (log.stage === "order_blocked") {
return {
id: log.id,
primary: "주문 조건이 맞지 않아 이번 신호는 실행하지 않았어요.",
secondary: log.message,
};
}
if (log.stage === "order_execution") {
if (typeof detail?.executedAt === "string") {
return {
id: log.id,
primary: "자동주문이 실제로 실행됐어요.",
secondary: `${toSideLabel(detail.side)} ${toQuantityText(detail.quantity)} · 가격 ${toWon(detail.orderPrice)}`,
};
}
return {
id: log.id,
primary: "자동주문을 거래소로 전송 중입니다.",
secondary: `${toSideLabel(detail?.side)} ${toQuantityText(detail?.quantity)} · 가격 ${toWon(detail?.orderPrice)}`,
};
}
if (log.stage === "session") {
return {
id: log.id,
primary: "자동매매 세션 상태가 변경됐어요.",
secondary: log.message,
};
}
if (log.stage === "engine_error") {
return {
id: log.id,
primary: "오류가 감지되어 안전하게 처리 중입니다.",
secondary: log.message,
};
}
return {
id: log.id,
primary: log.message,
secondary: undefined,
};
}
function parseLogDetail(detail: string | undefined): Record<string, unknown> | null {
if (!detail) {
return null;
}
try {
const parsed = JSON.parse(detail) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function toProviderLabel(value: unknown) {
const provider = String(value ?? "");
if (provider === "openai") return "OpenAI";
if (provider === "subscription_cli") return "구독형 CLI";
if (provider === "fallback") return "규칙 기반";
return provider || "-";
}
function toTechniqueText(value: unknown) {
if (!Array.isArray(value) || value.length === 0) {
return "기본 기법 자동 적용";
}
return value.map((item) => String(item)).join(", ");
}
function toWon(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "-";
}
return `${Math.round(value).toLocaleString("ko-KR")}`;
}
function toPercentText(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "-";
}
return `${Math.round(value * 100)}%`;
}
function toSignedPercent(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "-";
}
const prefix = value > 0 ? "+" : "";
return `${prefix}${value.toFixed(2)}%`;
}
function toRatioText(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "-";
}
return `${value.toFixed(2)}`;
}
function toSignalLabel(signal: unknown) {
const normalized = String(signal ?? "").toLowerCase();
if (normalized === "buy") return "매수";
if (normalized === "sell") return "매도";
if (normalized === "hold") return "대기";
return "판단";
}
function toSideLabel(side: unknown) {
const normalized = String(side ?? "").toLowerCase();
if (normalized === "buy") return "매수";
if (normalized === "sell") return "매도";
return "주문";
}
function toQuantityText(quantity: unknown) {
if (typeof quantity !== "number" || !Number.isFinite(quantity) || quantity <= 0) {
return "-";
}
return `${Math.floor(quantity).toLocaleString("ko-KR")}`;
}
function extractFirstReason(detail: Record<string, unknown> | null) {
if (!detail) return undefined;
const reasons = detail.blockers ?? detail.blockedReasons;
if (Array.isArray(reasons) && reasons.length > 0) {
return String(reasons[0]);
}
return undefined;
}
function resolveStatusBadgeVariant(
state: "IDLE" | "RUNNING" | "STOPPING" | "STOPPED" | "ERROR",
): "default" | "secondary" | "destructive" | "outline" {
if (state === "RUNNING") return "default";
if (state === "STOPPING" || state === "ERROR") return "destructive";
if (state === "STOPPED") return "secondary";
return "outline";
}
function resolveFriendlyAiModeDescription(
mode: (typeof AUTOTRADE_AI_MODE_OPTIONS)[number]["id"],
) {
if (mode === "auto") {
return "자동으로 가장 사용 가능한 AI를 선택합니다. 초보자에게 추천합니다.";
}
if (mode === "openai_api") {
return "OpenAI API 키를 사용해 판단합니다. 응답 품질을 일정하게 유지하기 좋습니다.";
}
if (mode === "subscription_cli") {
return "Codex/Gemini CLI를 사용해 판단합니다. 모델을 직접 고르고 싶을 때 좋습니다.";
}
return "AI 호출 없이 내부 규칙만으로 동작합니다. 가장 보수적인 모드입니다.";
}