1344 lines
52 KiB
TypeScript
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">
|
|
아직 점검 전입니다. "검증 미리보기"를 눌러 확인해 주세요.
|
|
</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 호출 없이 내부 규칙만으로 동작합니다. 가장 보수적인 모드입니다.";
|
|
}
|