전체적인 리팩토링
This commit is contained in:
@@ -21,21 +21,21 @@ interface StartStep {
|
||||
const START_STEPS: StartStep[] = [
|
||||
{
|
||||
step: "01",
|
||||
title: "1분이면 충분해요",
|
||||
title: "앱키 연결, 1분이면 끝",
|
||||
description:
|
||||
"복잡한 서류나 방문 없이, 쓰던 계좌 그대로 안전하게 연결할 수 있어요.",
|
||||
"복잡한 절차 없이, 지금 쓰는 계좌로 바로 시작할 수 있어요.",
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "내 스타일대로 골라보세요",
|
||||
title: "투자금/손실선만 입력하세요",
|
||||
description:
|
||||
"공격적인 투자부터 안정적인 관리까지, 나에게 딱 맞는 전략이 준비되어 있어요.",
|
||||
"어렵게 계산할 필요 없이, 내가 감당 가능한 금액만 정하면 돼요.",
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "이제 일상을 즐기세요",
|
||||
title: "신호 확인 후 자동 실행",
|
||||
description:
|
||||
"차트는 JOORIN-E가 하루 종일 보고 있을게요. 마음 편히 본업에 집중하세요.",
|
||||
"차트 감시는 JOORIN-E가 맡고, 당신은 중요한 순간만 확인하면 됩니다.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,7 +50,7 @@ export default async function HomePage() {
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
|
||||
const primaryCtaLabel = user ? "시작하기" : "지금 무료로 시작하기";
|
||||
const primaryCtaLabel = user ? "내 전략 시작하기" : "무료로 시작하기";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden bg-black text-white selection:bg-brand-500/30">
|
||||
@@ -69,21 +69,21 @@ export default async function HomePage() {
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<span className="inline-flex animate-in fade-in-0 slide-in-from-top-2 items-center gap-2 rounded-full border border-brand-400/30 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-wide text-brand-200 backdrop-blur-md duration-1000">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
자동 매매의 새로운 기준, JOORIN-E
|
||||
처음 하는 자동매매도 쉽게, JOORIN-E
|
||||
</span>
|
||||
|
||||
<h1 className="mt-8 animate-in slide-in-from-bottom-4 text-5xl font-black tracking-tight text-white duration-1000 md:text-8xl">
|
||||
주식, 이제는
|
||||
복잡한 차트 대신
|
||||
<br />
|
||||
<span className="bg-linear-to-b from-brand-300 via-brand-200 to-brand-500 bg-clip-text text-transparent">
|
||||
마음 편하게 하세요.
|
||||
쉬운 자동매매로 시작하세요.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-8 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/60 duration-1000 md:text-xl">
|
||||
어렵고 불안한 주식 투자, 혼자 고민하지 마세요.
|
||||
감으로 사고파는 불안한 투자, 이제 줄여보세요.
|
||||
<br className="hidden md:block" />
|
||||
검증된 원칙으로 24시간 당신의 자산을 지켜드릴게요.
|
||||
예산과 손실선을 먼저 지키는 방식으로, 주식을 더 편하게 도와드립니다.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
|
||||
@@ -111,14 +111,14 @@ export default async function HomePage() {
|
||||
<div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<h2 className="text-3xl font-black md:text-5xl">
|
||||
설계부터 실행까지
|
||||
주식이 처음이어도
|
||||
<br />
|
||||
<span className="text-brand-300">단 3단계면 끝.</span>
|
||||
<span className="text-brand-300">3단계면 준비 끝.</span>
|
||||
</h2>
|
||||
<p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
|
||||
복잡한 계산과 감시는 JOORIN-E가 대신할게요.
|
||||
앱키 연결 -> 투자금/손실선 설정 -> 시작 버튼.
|
||||
<br />
|
||||
당신은 가벼운 마음으로 '시작' 버튼만 누르세요.
|
||||
어려운 용어 없이, 필요한 것만 빠르게 설정해보세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -166,20 +166,18 @@ export default async function HomePage() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-brand-100">
|
||||
내 계좌 정보, 서버에 저장되지 않나요?
|
||||
계좌 키/정보, 어디에 저장되나요?
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-brand-200/70">
|
||||
<strong className="text-brand-200">
|
||||
네, 절대 저장하지 않으니 안심하세요.
|
||||
핵심 정보는 내 브라우저에만 저장됩니다.
|
||||
</strong>
|
||||
<br />
|
||||
JOORIN-E는 여러분의 계좌 비밀번호와 API 키를 서버로 전송하지
|
||||
않습니다.
|
||||
JOORIN-E는 계좌 비밀번호를 저장하지 않으며,
|
||||
<br className="hidden md:block" />
|
||||
모든 중요 정보는 여러분의 기기(브라우저)에만 암호화되어
|
||||
저장되며,
|
||||
API 키도 장기 보관하지 않도록 최소 범위로만 사용합니다.
|
||||
<br className="hidden md:block" />
|
||||
매매 실행 시에만 증권사와 직접 통신하는 데 사용됩니다.
|
||||
매매 요청은 필요한 순간에만 증권사와 통신합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,9 +188,9 @@ export default async function HomePage() {
|
||||
<section className="container mx-auto max-w-5xl px-4 py-32">
|
||||
<div className="relative overflow-hidden rounded-[2.5rem] border border-brand-500/20 bg-linear-to-b from-brand-500/10 to-transparent p-12 text-center md:p-24">
|
||||
<h2 className="text-3xl font-black md:text-6xl">
|
||||
더 이상 미루지 마세요.
|
||||
감으로 매매하던 습관에서
|
||||
<br />
|
||||
지금 바로 경험해보세요.
|
||||
오늘부터 규칙 매매로 바꿔보세요.
|
||||
</h2>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<Button
|
||||
|
||||
227
app/api/autotrade/_shared.ts
Normal file
227
app/api/autotrade/_shared.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { hasKisConfig } from "@/lib/kis/config";
|
||||
import {
|
||||
readKisAccountParts,
|
||||
readKisCredentialsFromHeaders,
|
||||
} from "@/app/api/kis/domestic/_shared";
|
||||
import type {
|
||||
AutotradeSessionInfo,
|
||||
AutotradeStopReason,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
export const AUTOTRADE_DEV_BYPASS_HEADER = "x-autotrade-dev-bypass";
|
||||
export const AUTOTRADE_WORKER_TOKEN_HEADER = "x-autotrade-worker-token";
|
||||
|
||||
export const AUTOTRADE_API_ERROR_CODE = {
|
||||
AUTH_REQUIRED: "AUTOTRADE_AUTH_REQUIRED",
|
||||
INVALID_REQUEST: "AUTOTRADE_INVALID_REQUEST",
|
||||
CREDENTIAL_REQUIRED: "AUTOTRADE_CREDENTIAL_REQUIRED",
|
||||
SESSION_NOT_FOUND: "AUTOTRADE_SESSION_NOT_FOUND",
|
||||
CONFLICT: "AUTOTRADE_CONFLICT",
|
||||
INTERNAL: "AUTOTRADE_INTERNAL",
|
||||
} as const;
|
||||
|
||||
export type AutotradeApiErrorCode =
|
||||
(typeof AUTOTRADE_API_ERROR_CODE)[keyof typeof AUTOTRADE_API_ERROR_CODE];
|
||||
|
||||
export interface AutotradeSessionRecord extends AutotradeSessionInfo {
|
||||
userId: string;
|
||||
strategySummary: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __autotradeSessionMap: Map<string, AutotradeSessionRecord> | undefined;
|
||||
}
|
||||
|
||||
function getSessionMap() {
|
||||
if (!globalThis.__autotradeSessionMap) {
|
||||
globalThis.__autotradeSessionMap = new Map<string, AutotradeSessionRecord>();
|
||||
}
|
||||
|
||||
return globalThis.__autotradeSessionMap;
|
||||
}
|
||||
|
||||
|
||||
export function createAutotradeErrorResponse(options: {
|
||||
status: number;
|
||||
code: AutotradeApiErrorCode;
|
||||
message: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
errorCode: options.code,
|
||||
message: options.message,
|
||||
...(options.extra ?? {}),
|
||||
},
|
||||
{ status: options.status },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAutotradeUserId(headers?: Headers) {
|
||||
if (isAutotradeDevBypass(headers)) {
|
||||
return "dev-autotrade-user";
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (error || !user) return null;
|
||||
return user.id;
|
||||
}
|
||||
|
||||
export async function readJsonBody(request: Request) {
|
||||
const text = await request.text();
|
||||
if (!text.trim()) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAutotradeKisRuntimeHeaders(headers: Headers) {
|
||||
if (isAutotradeDevBypass(headers)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(headers);
|
||||
const account = readKisAccountParts(headers);
|
||||
|
||||
return Boolean(hasKisConfig(credentials) && account);
|
||||
}
|
||||
|
||||
export function upsertAutotradeSession(record: AutotradeSessionRecord) {
|
||||
const map = getSessionMap();
|
||||
map.set(record.userId, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function getAutotradeSession(userId: string) {
|
||||
const map = getSessionMap();
|
||||
const record = map.get(userId) ?? null;
|
||||
if (!record) return null;
|
||||
|
||||
if (record.runtimeState === "RUNNING" && isHeartbeatExpired(record.lastHeartbeatAt)) {
|
||||
const stoppedRecord = {
|
||||
...record,
|
||||
runtimeState: "STOPPED" as const,
|
||||
stopReason: "heartbeat_timeout" as const,
|
||||
endedAt: new Date().toISOString(),
|
||||
};
|
||||
map.set(userId, stoppedRecord);
|
||||
return stoppedRecord;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
export function listAutotradeSessions() {
|
||||
return Array.from(getSessionMap().values()).sort((a, b) =>
|
||||
b.startedAt.localeCompare(a.startedAt),
|
||||
);
|
||||
}
|
||||
|
||||
export function stopAutotradeSession(userId: string, reason: AutotradeStopReason) {
|
||||
const map = getSessionMap();
|
||||
const record = map.get(userId);
|
||||
if (!record) return null;
|
||||
|
||||
const stoppedRecord: AutotradeSessionRecord = {
|
||||
...record,
|
||||
runtimeState: "STOPPED",
|
||||
stopReason: reason,
|
||||
endedAt: new Date().toISOString(),
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
map.set(userId, stoppedRecord);
|
||||
return stoppedRecord;
|
||||
}
|
||||
|
||||
export function sweepExpiredAutotradeSessions() {
|
||||
const map = getSessionMap();
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const [userId, record] of map.entries()) {
|
||||
if (record.runtimeState !== "RUNNING") continue;
|
||||
if (!isHeartbeatExpired(record.lastHeartbeatAt)) continue;
|
||||
|
||||
const stoppedRecord: AutotradeSessionRecord = {
|
||||
...record,
|
||||
runtimeState: "STOPPED",
|
||||
stopReason: "heartbeat_timeout",
|
||||
endedAt: new Date().toISOString(),
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
map.set(userId, stoppedRecord);
|
||||
expiredCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalSessionCount: map.size,
|
||||
expiredCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAutotradeHeartbeatTtlSec() {
|
||||
const parsed = Number.parseInt(process.env.AUTOTRADE_HEARTBEAT_TTL_SEC ?? "90", 10);
|
||||
if (!Number.isFinite(parsed)) return 90;
|
||||
return Math.min(300, Math.max(30, parsed));
|
||||
}
|
||||
|
||||
export function isHeartbeatExpired(lastHeartbeatAt: string) {
|
||||
const lastHeartbeatMs = new Date(lastHeartbeatAt).getTime();
|
||||
if (!Number.isFinite(lastHeartbeatMs)) return true;
|
||||
|
||||
return Date.now() - lastHeartbeatMs > getAutotradeHeartbeatTtlSec() * 1000;
|
||||
}
|
||||
|
||||
export function sanitizeAutotradeError(error: unknown, fallback: string) {
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
return maskSensitiveTokens(message) || fallback;
|
||||
}
|
||||
|
||||
export function maskSensitiveTokens(value: string) {
|
||||
return value
|
||||
.replace(/([A-Za-z0-9]{4})[A-Za-z0-9]{8,}([A-Za-z0-9]{4})/g, "$1********$2")
|
||||
.replace(/(x-kis-app-secret\s*[:=]\s*)([^\s]+)/gi, "$1********")
|
||||
.replace(/(x-kis-app-key\s*[:=]\s*)([^\s]+)/gi, "$1********");
|
||||
}
|
||||
|
||||
export function isAutotradeWorkerAuthorized(headers: Headers) {
|
||||
const providedToken = headers.get(AUTOTRADE_WORKER_TOKEN_HEADER)?.trim();
|
||||
if (!providedToken) return false;
|
||||
|
||||
const expectedToken = process.env.AUTOTRADE_WORKER_TOKEN?.trim();
|
||||
if (expectedToken) {
|
||||
return providedToken === expectedToken;
|
||||
}
|
||||
|
||||
// 운영 환경에서는 토큰 미설정 상태를 허용하지 않습니다.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return providedToken === "autotrade-worker-local";
|
||||
}
|
||||
|
||||
function isAutotradeDevBypass(headers?: Headers) {
|
||||
if (!headers || process.env.NODE_ENV === "production") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const providedToken = headers.get(AUTOTRADE_DEV_BYPASS_HEADER)?.trim();
|
||||
if (!providedToken) return false;
|
||||
|
||||
const expectedToken =
|
||||
process.env.AUTOTRADE_DEV_BYPASS_TOKEN?.trim() || "autotrade-dev-bypass";
|
||||
return providedToken === expectedToken;
|
||||
}
|
||||
25
app/api/autotrade/sessions/active/route.ts
Normal file
25
app/api/autotrade/sessions/active/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeSession,
|
||||
getAutotradeUserId,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const session = getAutotradeSession(userId);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: session && session.runtimeState === "RUNNING" ? session : null,
|
||||
});
|
||||
}
|
||||
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal file
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeSession,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
upsertAutotradeSession,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
|
||||
const heartbeatRequestSchema = z.object({
|
||||
sessionId: z.string().uuid(),
|
||||
leaderTabId: z.string().trim().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = heartbeatRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "heartbeat 요청값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const session = getAutotradeSession(userId);
|
||||
if (!session || session.runtimeState !== "RUNNING") {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 404,
|
||||
code: AUTOTRADE_API_ERROR_CODE.SESSION_NOT_FOUND,
|
||||
message: "실행 중인 자동매매 세션이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (session.sessionId !== parsed.data.sessionId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 409,
|
||||
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
|
||||
message: "세션 식별자가 일치하지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = upsertAutotradeSession({
|
||||
...session,
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
leaderTabId: parsed.data.leaderTabId ?? session.leaderTabId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "heartbeat 처리 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
77
app/api/autotrade/sessions/start/route.ts
Normal file
77
app/api/autotrade/sessions/start/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeUserId,
|
||||
hasAutotradeKisRuntimeHeaders,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
upsertAutotradeSession,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
|
||||
const startRequestSchema = z.object({
|
||||
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||
leaderTabId: z.string().trim().min(1).max(100),
|
||||
effectiveAllocationAmount: z.number().int().positive(),
|
||||
effectiveDailyLossLimit: z.number().int().positive(),
|
||||
strategySummary: z.string().trim().min(1).max(320),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasAutotradeKisRuntimeHeaders(request.headers)) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "자동매매 시작에는 KIS 인증 헤더가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = startRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "세션 시작 입력값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const session = upsertAutotradeSession({
|
||||
userId,
|
||||
sessionId: crypto.randomUUID(),
|
||||
symbol: parsed.data.symbol,
|
||||
runtimeState: "RUNNING",
|
||||
leaderTabId: parsed.data.leaderTabId,
|
||||
startedAt: now,
|
||||
lastHeartbeatAt: now,
|
||||
endedAt: null,
|
||||
stopReason: null,
|
||||
effectiveAllocationAmount: parsed.data.effectiveAllocationAmount,
|
||||
effectiveDailyLossLimit: parsed.data.effectiveDailyLossLimit,
|
||||
strategySummary: parsed.data.strategySummary,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "자동매매 세션 시작 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
78
app/api/autotrade/sessions/stop/route.ts
Normal file
78
app/api/autotrade/sessions/stop/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeSession,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
stopAutotradeSession,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
import type { AutotradeStopReason } from "@/features/autotrade/types/autotrade.types";
|
||||
|
||||
const stopRequestSchema = z.object({
|
||||
sessionId: z.string().uuid().optional(),
|
||||
reason: z
|
||||
.enum([
|
||||
"browser_exit",
|
||||
"external_leave",
|
||||
"manual",
|
||||
"emergency",
|
||||
"heartbeat_timeout",
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = stopRequestSchema.safeParse(rawBody ?? {});
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "세션 종료 입력값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const session = getAutotradeSession(userId);
|
||||
if (!session) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.sessionId && parsed.data.sessionId !== session.sessionId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 409,
|
||||
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
|
||||
message: "세션 식별자가 일치하지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const reason: AutotradeStopReason = parsed.data.reason ?? "manual";
|
||||
const stopped = stopAutotradeSession(userId, reason);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: stopped,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "세션 종료 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
440
app/api/autotrade/signals/generate/route.ts
Normal file
440
app/api/autotrade/signals/generate/route.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 컴파일된 전략 + 시세 스냅샷으로 매수/매도/대기 신호를 생성하는 API 라우트입니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - 요청 검증(strategy/snapshot)
|
||||
* - provider 분기(OpenAI/구독형 CLI/fallback)
|
||||
* - 실패 시 fallback 신호로 대체
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
import { AUTOTRADE_TECHNIQUE_IDS } from "@/features/autotrade/types/autotrade.types";
|
||||
import {
|
||||
generateSignalWithSubscriptionCliDetailed,
|
||||
summarizeSubscriptionCliExecution,
|
||||
} from "@/lib/autotrade/cli-provider";
|
||||
import { generateSignalWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
|
||||
import { createFallbackSignalCandidate } from "@/lib/autotrade/strategy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const strategySchema = z.object({
|
||||
provider: z.enum(["openai", "fallback", "subscription_cli"]),
|
||||
summary: z.string().trim().min(1).max(320),
|
||||
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
|
||||
confidenceThreshold: z.number().min(0.45).max(0.95),
|
||||
maxDailyOrders: z.number().int().min(1).max(200),
|
||||
cooldownSec: z.number().int().min(10).max(600),
|
||||
maxOrderAmountRatio: z.number().min(0.05).max(1),
|
||||
createdAt: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const signalRequestSchema = z.object({
|
||||
aiMode: z
|
||||
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
|
||||
.default("auto"),
|
||||
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
|
||||
subscriptionCliModel: z.string().trim().max(80).optional(),
|
||||
prompt: z.string().trim().max(1200).default(""),
|
||||
strategy: strategySchema,
|
||||
snapshot: z.object({
|
||||
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||
stockName: z.string().trim().max(120).optional(),
|
||||
market: z.enum(["KOSPI", "KOSDAQ"]).optional(),
|
||||
requestAtIso: z.string().trim().max(40).optional(),
|
||||
requestAtKst: z.string().trim().max(40).optional(),
|
||||
tickTime: z.string().trim().max(12).optional(),
|
||||
executionClassCode: z.string().trim().max(10).optional(),
|
||||
isExpected: z.boolean().optional(),
|
||||
trId: z.string().trim().max(32).optional(),
|
||||
currentPrice: z.number().positive(),
|
||||
prevClose: z.number().nonnegative().optional(),
|
||||
changeRate: z.number(),
|
||||
open: z.number().nonnegative(),
|
||||
high: z.number().nonnegative(),
|
||||
low: z.number().nonnegative(),
|
||||
tradeVolume: z.number().nonnegative(),
|
||||
accumulatedVolume: z.number().nonnegative(),
|
||||
tradeStrength: z.number().optional(),
|
||||
askPrice1: z.number().nonnegative().optional(),
|
||||
bidPrice1: z.number().nonnegative().optional(),
|
||||
askSize1: z.number().nonnegative().optional(),
|
||||
bidSize1: z.number().nonnegative().optional(),
|
||||
totalAskSize: z.number().nonnegative().optional(),
|
||||
totalBidSize: z.number().nonnegative().optional(),
|
||||
buyExecutionCount: z.number().int().optional(),
|
||||
sellExecutionCount: z.number().int().optional(),
|
||||
netBuyExecutionCount: z.number().int().optional(),
|
||||
spread: z.number().nonnegative().optional(),
|
||||
spreadRate: z.number().optional(),
|
||||
dayRangePercent: z.number().nonnegative().optional(),
|
||||
dayRangePosition: z.number().min(0).max(1).optional(),
|
||||
volumeRatio: z.number().nonnegative().optional(),
|
||||
recentTradeCount: z.number().int().nonnegative().optional(),
|
||||
recentTradeVolumeSum: z.number().nonnegative().optional(),
|
||||
recentAverageTradeVolume: z.number().nonnegative().optional(),
|
||||
accumulatedVolumeDelta: z.number().nonnegative().optional(),
|
||||
netBuyExecutionDelta: z.number().optional(),
|
||||
orderBookImbalance: z.number().min(-1).max(1).optional(),
|
||||
liquidityDepth: z.number().nonnegative().optional(),
|
||||
topLevelOrderBookImbalance: z.number().min(-1).max(1).optional(),
|
||||
buySellExecutionRatio: z.number().nonnegative().optional(),
|
||||
recentPriceHigh: z.number().positive().optional(),
|
||||
recentPriceLow: z.number().positive().optional(),
|
||||
recentPriceRangePercent: z.number().nonnegative().optional(),
|
||||
recentTradeVolumes: z.array(z.number().nonnegative()).max(20).optional(),
|
||||
recentNetBuyTrail: z.array(z.number()).max(20).optional(),
|
||||
recentTickAgesSec: z.array(z.number().nonnegative()).max(20).optional(),
|
||||
intradayMomentum: z.number().optional(),
|
||||
recentReturns: z.array(z.number()).max(12).optional(),
|
||||
recentPrices: z.array(z.number().positive()).min(3).max(30),
|
||||
marketDataLatencySec: z.number().nonnegative().optional(),
|
||||
recentMinuteCandles: z
|
||||
.array(
|
||||
z.object({
|
||||
time: z.string().trim().max(32),
|
||||
open: z.number().positive(),
|
||||
high: z.number().positive(),
|
||||
low: z.number().positive(),
|
||||
close: z.number().positive(),
|
||||
volume: z.number().nonnegative(),
|
||||
timestamp: z.number().int().optional(),
|
||||
}),
|
||||
)
|
||||
.max(30)
|
||||
.optional(),
|
||||
minutePatternContext: z
|
||||
.object({
|
||||
timeframe: z.literal("1m"),
|
||||
candleCount: z.number().int().min(1).max(30),
|
||||
impulseDirection: z.enum(["up", "down", "flat"]),
|
||||
impulseBarCount: z.number().int().min(1).max(20),
|
||||
consolidationBarCount: z.number().int().min(1).max(12),
|
||||
impulseChangeRate: z.number().optional(),
|
||||
impulseRangePercent: z.number().nonnegative().optional(),
|
||||
consolidationRangePercent: z.number().nonnegative().optional(),
|
||||
consolidationCloseClusterPercent: z.number().nonnegative().optional(),
|
||||
consolidationVolumeRatio: z.number().nonnegative().optional(),
|
||||
breakoutUpper: z.number().positive().optional(),
|
||||
breakoutLower: z.number().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
budgetContext: z
|
||||
.object({
|
||||
setupAllocationPercent: z.number().nonnegative(),
|
||||
setupAllocationAmount: z.number().nonnegative(),
|
||||
effectiveAllocationAmount: z.number().nonnegative(),
|
||||
strategyMaxOrderAmountRatio: z.number().min(0).max(1),
|
||||
effectiveOrderBudgetAmount: z.number().nonnegative(),
|
||||
estimatedBuyUnitCost: z.number().nonnegative(),
|
||||
estimatedBuyableQuantity: z.number().int().nonnegative(),
|
||||
})
|
||||
.optional(),
|
||||
portfolioContext: z
|
||||
.object({
|
||||
holdingQuantity: z.number().int().nonnegative(),
|
||||
sellableQuantity: z.number().int().nonnegative(),
|
||||
averagePrice: z.number().nonnegative(),
|
||||
estimatedSellableNetAmount: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
executionCostProfile: z
|
||||
.object({
|
||||
buyFeeRate: z.number().nonnegative(),
|
||||
sellFeeRate: z.number().nonnegative(),
|
||||
sellTaxRate: z.number().nonnegative(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const signalResultSchema = z.object({
|
||||
signal: z.enum(["buy", "sell", "hold"]),
|
||||
confidence: z.number().min(0).max(1),
|
||||
reason: z.string().min(1).max(160),
|
||||
ttlSec: z.number().int().min(5).max(300),
|
||||
riskFlags: z.array(z.string()).max(10).default([]),
|
||||
proposedOrder: z
|
||||
.object({
|
||||
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||
side: z.enum(["buy", "sell"]),
|
||||
orderType: z.enum(["limit", "market"]),
|
||||
price: z.number().positive().optional(),
|
||||
quantity: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = signalRequestSchema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "신호 생성 요청값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// [Step 1] 안전망: 우선 규칙 기반 fallback 신호를 준비합니다.
|
||||
const fallbackSignal = createFallbackSignalCandidate({
|
||||
strategy: parsed.data.strategy,
|
||||
snapshot: parsed.data.snapshot,
|
||||
});
|
||||
|
||||
// [Step 2] 규칙 기반 강제 모드
|
||||
if (parsed.data.aiMode === "rule_fallback") {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: fallbackSignal,
|
||||
});
|
||||
}
|
||||
|
||||
// [Step 3] OpenAI 모드(auto/openai_api): 성공 시 해당 신호를 그대로 반환
|
||||
const shouldUseOpenAi = parsed.data.aiMode === "openai_api" || parsed.data.aiMode === "auto";
|
||||
if (shouldUseOpenAi && isOpenAiConfigured()) {
|
||||
const aiSignal = await generateSignalWithOpenAi({
|
||||
prompt: parsed.data.prompt,
|
||||
strategy: parsed.data.strategy,
|
||||
snapshot: parsed.data.snapshot,
|
||||
});
|
||||
|
||||
if (aiSignal) {
|
||||
const localizedReason = ensureKoreanReason(aiSignal.reason, aiSignal.signal);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: {
|
||||
...aiSignal,
|
||||
reason: localizedReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI 자동판단
|
||||
const shouldUseCli =
|
||||
parsed.data.aiMode === "subscription_cli" ||
|
||||
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
|
||||
if (shouldUseCli) {
|
||||
const cliResult = await generateSignalWithSubscriptionCliDetailed({
|
||||
prompt: parsed.data.prompt,
|
||||
strategy: parsed.data.strategy,
|
||||
snapshot: parsed.data.snapshot,
|
||||
preferredVendor: parsed.data.subscriptionCliVendor,
|
||||
preferredModel:
|
||||
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
|
||||
? parsed.data.subscriptionCliModel
|
||||
: undefined,
|
||||
});
|
||||
const normalizedCliSignal = normalizeCliSignalCandidate(
|
||||
cliResult.parsed,
|
||||
parsed.data.snapshot.symbol,
|
||||
);
|
||||
const cliParsed = signalResultSchema.safeParse(normalizedCliSignal);
|
||||
if (cliParsed.success) {
|
||||
const localizedReason = ensureKoreanReason(
|
||||
cliParsed.data.reason,
|
||||
cliParsed.data.signal,
|
||||
);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: {
|
||||
...cliParsed.data,
|
||||
reason: localizedReason,
|
||||
source: "subscription_cli",
|
||||
providerVendor: cliResult.vendor ?? undefined,
|
||||
providerModel: cliResult.model ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const cliExecutionSummary = summarizeSubscriptionCliExecution(cliResult);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: {
|
||||
...fallbackSignal,
|
||||
// CLI 응답이 비정상이어도 주문 엔진이 멈추지 않도록 fallback 신호로 대체합니다.
|
||||
reason: `구독형 CLI 응답을 해석하지 못해 규칙 기반 신호로 대체했습니다. (${cliExecutionSummary})`,
|
||||
providerVendor: cliResult.vendor ?? undefined,
|
||||
providerModel: cliResult.model ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: fallbackSignal,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "신호 생성 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCliSignalCandidate(raw: unknown, defaultSymbol: string) {
|
||||
const source = resolveSignalPayloadSource(raw);
|
||||
if (!source) return raw;
|
||||
|
||||
const signal = normalizeSignalValue(source.signal ?? source.action ?? source.side);
|
||||
const confidence = clampNumber(source.confidence ?? source.score ?? source.probability, 0, 1);
|
||||
const reason = normalizeReasonText(source.reason ?? source.rationale ?? source.comment);
|
||||
const ttlSec = normalizeInteger(source.ttlSec ?? source.ttl, 20, 5, 300);
|
||||
const riskFlags = normalizeRiskFlags(source.riskFlags ?? source.risks);
|
||||
const proposedOrder = normalizeProposedOrder(source.proposedOrder ?? source.order, defaultSymbol);
|
||||
|
||||
return {
|
||||
signal: signal ?? source.signal,
|
||||
confidence,
|
||||
reason,
|
||||
ttlSec,
|
||||
riskFlags,
|
||||
proposedOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSignalPayloadSource(raw: unknown): Record<string, unknown> | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const source = raw as Record<string, unknown>;
|
||||
|
||||
if (source.signal || source.action || source.side || source.proposedOrder || source.order) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const nestedCandidate =
|
||||
source.decision ??
|
||||
source.result ??
|
||||
source.data ??
|
||||
source.output ??
|
||||
source.payload;
|
||||
if (!nestedCandidate || typeof nestedCandidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nestedCandidate as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeSignalValue(raw: unknown) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "buy" || normalized === "sell" || normalized === "hold") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clampNumber(raw: unknown, min: number, max: number) {
|
||||
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
|
||||
if (!Number.isFinite(value)) return 0.5;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeInteger(raw: unknown, fallback: number, min: number, max: number) {
|
||||
const value = Number.parseInt(String(raw ?? ""), 10);
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeReasonText(raw: unknown) {
|
||||
const value = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!value) return "신호 사유가 없어 hold로 처리했습니다.";
|
||||
return value.slice(0, 160);
|
||||
}
|
||||
|
||||
function ensureKoreanReason(
|
||||
reason: string,
|
||||
signal: "buy" | "sell" | "hold",
|
||||
) {
|
||||
const normalized = normalizeReasonText(reason);
|
||||
if (/[가-힣]/.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (signal === "buy") {
|
||||
return "상승 신호가 확인되어 매수 관점으로 판단했습니다.";
|
||||
}
|
||||
if (signal === "sell") {
|
||||
return "하락 또는 과열 신호가 확인되어 매도 관점으로 판단했습니다.";
|
||||
}
|
||||
return "명확한 방향성이 부족해 대기 신호로 판단했습니다.";
|
||||
}
|
||||
|
||||
function normalizeRiskFlags(raw: unknown) {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.map((item) => String(item).trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
if (typeof raw === "string") {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeProposedOrder(raw: unknown, defaultSymbol: string) {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const source = raw as Record<string, unknown>;
|
||||
|
||||
const side = normalizeSignalValue(source.side);
|
||||
if (side !== "buy" && side !== "sell") return undefined;
|
||||
|
||||
const orderTypeRaw = String(source.orderType ?? source.type ?? "limit")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const orderType = orderTypeRaw === "market" ? "market" : "limit";
|
||||
const symbolRaw = String(source.symbol ?? defaultSymbol).trim();
|
||||
const symbol = /^\d{6}$/.test(symbolRaw) ? symbolRaw : defaultSymbol;
|
||||
const price = parseOptionalPositiveNumber(source.price);
|
||||
const quantity = parseOptionalPositiveInteger(source.quantity ?? source.qty);
|
||||
|
||||
return {
|
||||
symbol,
|
||||
side,
|
||||
orderType,
|
||||
price,
|
||||
quantity,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalPositiveNumber(raw: unknown) {
|
||||
if (raw === undefined || raw === null || raw === "") return undefined;
|
||||
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw));
|
||||
if (!Number.isFinite(value) || value <= 0) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(raw: unknown) {
|
||||
if (raw === undefined || raw === null || raw === "") return undefined;
|
||||
const value = Number.parseInt(String(raw), 10);
|
||||
if (!Number.isFinite(value) || value <= 0) return undefined;
|
||||
return value;
|
||||
}
|
||||
411
app/api/autotrade/strategies/compile/route.ts
Normal file
411
app/api/autotrade/strategies/compile/route.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 전략 프롬프트를 실행 가능한 자동매매 전략(JSON)으로 컴파일하는 API 라우트입니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - 요청 검증(aiMode/prompt/기법/신뢰도)
|
||||
* - provider 분기(OpenAI/구독형 CLI/fallback)
|
||||
* - 실패 시 fallback 전략으로 안전하게 응답
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
import {
|
||||
AUTOTRADE_DEFAULT_TECHNIQUES,
|
||||
AUTOTRADE_TECHNIQUE_IDS,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
import {
|
||||
compileStrategyWithSubscriptionCliDetailed,
|
||||
summarizeSubscriptionCliExecution,
|
||||
} from "@/lib/autotrade/cli-provider";
|
||||
import { compileStrategyWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
|
||||
import { createFallbackCompiledStrategy } from "@/lib/autotrade/strategy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const compileRequestSchema = z.object({
|
||||
aiMode: z
|
||||
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
|
||||
.default("auto"),
|
||||
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
|
||||
subscriptionCliModel: z.string().trim().max(80).optional(),
|
||||
prompt: z.string().trim().max(1200).default(""),
|
||||
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
|
||||
confidenceThreshold: z.number().min(0.45).max(0.95).optional(),
|
||||
});
|
||||
|
||||
const compileResultSchema = z.object({
|
||||
summary: z.string().min(1).max(320),
|
||||
confidenceThreshold: z.number().min(0.45).max(0.95),
|
||||
maxDailyOrders: z.number().int().min(1).max(200),
|
||||
cooldownSec: z.number().int().min(10).max(600),
|
||||
maxOrderAmountRatio: z.number().min(0.05).max(1),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = compileRequestSchema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "전략 입력값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedTechniques =
|
||||
parsed.data.selectedTechniques.length > 0
|
||||
? parsed.data.selectedTechniques
|
||||
: AUTOTRADE_DEFAULT_TECHNIQUES;
|
||||
|
||||
// [Step 1] 어떤 모드든 공통 최소전략(규칙 기반)을 먼저 준비해 둡니다.
|
||||
const fallback = createFallbackCompiledStrategy({
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
confidenceThreshold: parsed.data.confidenceThreshold ?? 0.65,
|
||||
});
|
||||
|
||||
// [Step 2] 규칙 기반 강제 모드는 즉시 fallback 전략으로 반환합니다.
|
||||
if (parsed.data.aiMode === "rule_fallback") {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: {
|
||||
...fallback,
|
||||
summary: `규칙 기반 모드: ${fallback.summary}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// [Step 3] OpenAI 모드(auto/openai_api): API 키가 있으면 OpenAI 결과를 우선 사용합니다.
|
||||
const shouldUseOpenAi = parsed.data.aiMode === "auto" || parsed.data.aiMode === "openai_api";
|
||||
if (shouldUseOpenAi && isOpenAiConfigured()) {
|
||||
const aiResult = await compileStrategyWithOpenAi({
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
confidenceThreshold: fallback.confidenceThreshold,
|
||||
});
|
||||
|
||||
if (aiResult) {
|
||||
const finalizedSummary = finalizeCompiledSummary({
|
||||
summary: aiResult.summary,
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: {
|
||||
...fallback,
|
||||
provider: "openai",
|
||||
summary: finalizedSummary,
|
||||
confidenceThreshold: aiResult.confidenceThreshold,
|
||||
maxDailyOrders: aiResult.maxDailyOrders,
|
||||
cooldownSec: aiResult.cooldownSec,
|
||||
maxOrderAmountRatio: aiResult.maxOrderAmountRatio,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI를 호출합니다.
|
||||
const shouldUseCli =
|
||||
parsed.data.aiMode === "subscription_cli" ||
|
||||
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
|
||||
if (shouldUseCli) {
|
||||
const cliResult = await compileStrategyWithSubscriptionCliDetailed({
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
confidenceThreshold: fallback.confidenceThreshold,
|
||||
preferredVendor: parsed.data.subscriptionCliVendor,
|
||||
preferredModel:
|
||||
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
|
||||
? parsed.data.subscriptionCliModel
|
||||
: undefined,
|
||||
});
|
||||
const normalizedCliCompile = normalizeCliCompileResult(cliResult.parsed, fallback);
|
||||
const cliParsed = compileResultSchema.safeParse(normalizedCliCompile);
|
||||
if (cliParsed.success) {
|
||||
const finalizedSummary = finalizeCompiledSummary({
|
||||
summary: cliParsed.data.summary,
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: {
|
||||
...fallback,
|
||||
provider: "subscription_cli",
|
||||
providerVendor: cliResult.vendor ?? undefined,
|
||||
providerModel: cliResult.model ?? undefined,
|
||||
summary: finalizedSummary,
|
||||
confidenceThreshold: cliParsed.data.confidenceThreshold,
|
||||
maxDailyOrders: cliParsed.data.maxDailyOrders,
|
||||
cooldownSec: cliParsed.data.cooldownSec,
|
||||
maxOrderAmountRatio: cliParsed.data.maxOrderAmountRatio,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const parseSummary = summarizeCompileParseFailure(cliResult.parsed);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: {
|
||||
...fallback,
|
||||
provider: "subscription_cli",
|
||||
providerVendor: cliResult.vendor ?? undefined,
|
||||
providerModel: cliResult.model ?? undefined,
|
||||
// CLI가 실패해도 자동매매가 멈추지 않도록 fallback 전략으로 안전하게 유지합니다.
|
||||
summary: `구독형 CLI 응답을 해석하지 못해 규칙 기반 전략으로 동작합니다. (${summarizeSubscriptionCliExecution(cliResult)}; parse=${parseSummary})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: fallback,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "전략 컴파일 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCliCompileResult(raw: unknown, fallback: ReturnType<typeof createFallbackCompiledStrategy>) {
|
||||
const source = resolveCompilePayloadSource(raw);
|
||||
if (!source) return raw;
|
||||
|
||||
const summary = normalizeSummaryText(
|
||||
source.summary ??
|
||||
source.strategySummary ??
|
||||
source.description ??
|
||||
source.plan ??
|
||||
source.reason ??
|
||||
fallback.summary,
|
||||
fallback.summary,
|
||||
);
|
||||
const confidenceThreshold = normalizeRatioNumber(
|
||||
source.confidenceThreshold ?? source.confidence ?? source.threshold,
|
||||
fallback.confidenceThreshold,
|
||||
0.45,
|
||||
0.95,
|
||||
);
|
||||
const maxDailyOrders = normalizeIntegerValue(
|
||||
source.maxDailyOrders ?? source.dailyOrderLimit ?? source.maxOrdersPerDay ?? source.orderLimit,
|
||||
fallback.maxDailyOrders,
|
||||
1,
|
||||
200,
|
||||
);
|
||||
const cooldownSec = normalizeIntegerValue(
|
||||
source.cooldownSec ?? source.cooldownSeconds ?? source.cooldown ?? source.minIntervalSec,
|
||||
fallback.cooldownSec,
|
||||
10,
|
||||
600,
|
||||
);
|
||||
const maxOrderAmountRatio = normalizeRatioNumber(
|
||||
source.maxOrderAmountRatio ??
|
||||
source.maxPositionRatio ??
|
||||
source.positionSizeRatio ??
|
||||
source.orderAmountRatio,
|
||||
fallback.maxOrderAmountRatio,
|
||||
0.05,
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
summary,
|
||||
confidenceThreshold,
|
||||
maxDailyOrders,
|
||||
cooldownSec,
|
||||
maxOrderAmountRatio,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCompilePayloadSource(raw: unknown): Record<string, unknown> | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const source = raw as Record<string, unknown>;
|
||||
if (
|
||||
source.summary ||
|
||||
source.strategySummary ||
|
||||
source.confidenceThreshold ||
|
||||
source.maxDailyOrders ||
|
||||
source.cooldownSec ||
|
||||
source.maxOrderAmountRatio
|
||||
) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const nestedCandidate =
|
||||
source.strategy ??
|
||||
source.compiledStrategy ??
|
||||
source.result ??
|
||||
source.output ??
|
||||
source.data ??
|
||||
source.payload;
|
||||
if (!nestedCandidate || typeof nestedCandidate !== "object") {
|
||||
return source;
|
||||
}
|
||||
|
||||
return nestedCandidate as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeSummaryText(raw: unknown, fallback: string) {
|
||||
const text = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!text) return fallback;
|
||||
return text.slice(0, 320);
|
||||
}
|
||||
|
||||
function normalizeRatioNumber(
|
||||
raw: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
) {
|
||||
let value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
if (value > 1 && value <= 100) value /= 100;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeIntegerValue(
|
||||
raw: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
) {
|
||||
const value = Number.parseInt(String(raw ?? ""), 10);
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function summarizeCompileParseFailure(raw: unknown) {
|
||||
if (raw === null || raw === undefined) return "empty";
|
||||
if (typeof raw === "string") return `string:${raw.slice(0, 80)}`;
|
||||
if (typeof raw !== "object") return typeof raw;
|
||||
try {
|
||||
const keys = Object.keys(raw as Record<string, unknown>).slice(0, 8);
|
||||
return `keys:${keys.join("|") || "none"}`;
|
||||
} catch {
|
||||
return "object";
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeCompiledSummary(params: {
|
||||
summary: string;
|
||||
prompt: string;
|
||||
selectedTechniques: readonly string[];
|
||||
}) {
|
||||
const cleanedSummary = params.summary.trim();
|
||||
const prompt = params.prompt.trim();
|
||||
|
||||
if (!prompt) {
|
||||
return cleanedSummary.slice(0, 320);
|
||||
}
|
||||
|
||||
const loweredSummary = cleanedSummary.toLowerCase();
|
||||
const loweredPrompt = prompt.toLowerCase();
|
||||
const suspiciousPhrases = [
|
||||
"테스트 목적",
|
||||
"테스트용",
|
||||
"sample",
|
||||
"example",
|
||||
"for testing",
|
||||
"test purpose",
|
||||
];
|
||||
const hasSuspiciousPhrase =
|
||||
suspiciousPhrases.some((phrase) => loweredSummary.includes(phrase)) &&
|
||||
!suspiciousPhrases.some((phrase) => loweredPrompt.includes(phrase));
|
||||
|
||||
const hasPromptCoverage = detectPromptCoverage(cleanedSummary, prompt);
|
||||
|
||||
if (hasSuspiciousPhrase) {
|
||||
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
|
||||
}
|
||||
|
||||
if (!hasPromptCoverage) {
|
||||
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
|
||||
}
|
||||
|
||||
return cleanedSummary.slice(0, 320);
|
||||
}
|
||||
|
||||
function detectPromptCoverage(summary: string, prompt: string) {
|
||||
const normalizedSummary = normalizeCoverageText(summary);
|
||||
const keywords = extractPromptKeywords(prompt);
|
||||
if (keywords.length === 0) return true;
|
||||
return keywords.some((keyword) => normalizedSummary.includes(keyword));
|
||||
}
|
||||
|
||||
function normalizeCoverageText(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractPromptKeywords(prompt: string) {
|
||||
const stopwords = new Set([
|
||||
"그리고",
|
||||
"그냥",
|
||||
"우선",
|
||||
"위주",
|
||||
"중심",
|
||||
"하게",
|
||||
"하면",
|
||||
"현재",
|
||||
"지금",
|
||||
"please",
|
||||
"with",
|
||||
"from",
|
||||
"that",
|
||||
"this",
|
||||
]);
|
||||
|
||||
return normalizeCoverageText(prompt)
|
||||
.split(" ")
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2 && !stopwords.has(token))
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function buildPromptAnchoredSummary(
|
||||
prompt: string,
|
||||
selectedTechniques: readonly string[],
|
||||
aiSummary?: string,
|
||||
) {
|
||||
const promptExcerpt = prompt.replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
const techniquesText =
|
||||
selectedTechniques.length > 0 ? ` (${selectedTechniques.join(", ")})` : "";
|
||||
const aiSummaryText = aiSummary?.replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
if (!aiSummaryText) {
|
||||
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt}`.slice(0, 320);
|
||||
}
|
||||
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt} | AI요약: ${aiSummaryText}`.slice(
|
||||
0,
|
||||
320,
|
||||
);
|
||||
}
|
||||
43
app/api/autotrade/strategies/validate/route.ts
Normal file
43
app/api/autotrade/strategies/validate/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
import { buildRiskEnvelope } from "@/lib/autotrade/risk";
|
||||
|
||||
const validateRequestSchema = z.object({
|
||||
cashBalance: z.number().nonnegative(),
|
||||
allocationPercent: z.number().nonnegative(),
|
||||
allocationAmount: z.number().positive(),
|
||||
dailyLossPercent: z.number().nonnegative(),
|
||||
dailyLossAmount: z.number().positive(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = validateRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "검증 입력값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
validation: buildRiskEnvelope(parsed.data),
|
||||
});
|
||||
}
|
||||
39
app/api/autotrade/worker/tick/route.ts
Normal file
39
app/api/autotrade/worker/tick/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
AUTOTRADE_WORKER_TOKEN_HEADER,
|
||||
createAutotradeErrorResponse,
|
||||
isAutotradeWorkerAuthorized,
|
||||
listAutotradeSessions,
|
||||
sanitizeAutotradeError,
|
||||
sweepExpiredAutotradeSessions,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAutotradeWorkerAuthorized(request.headers)) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: `${AUTOTRADE_WORKER_TOKEN_HEADER} 인증이 필요합니다.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const sweep = sweepExpiredAutotradeSessions();
|
||||
const sessions = listAutotradeSessions();
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
sweep,
|
||||
runningSessions: sessions.filter((session) => session.runtimeState === "RUNNING").length,
|
||||
stoppedSessions: sessions.filter((session) => session.runtimeState === "STOPPED").length,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "자동매매 워커 점검 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
|
||||
"1m",
|
||||
"5m",
|
||||
"10m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"1d",
|
||||
|
||||
72
app/api/kis/domestic/market-hub/route.ts
Normal file
72
app/api/kis/domestic/market-hub/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardMarketHubResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardMarketHub } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/market-hub/route.ts
|
||||
* @description 국내주식 시장 허브(급등/인기/뉴스) 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 시장 허브 조회 API
|
||||
* @returns 급등주식/인기종목/주요뉴스 목록
|
||||
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/market-hub -> MarketHubSection 렌더링
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDomesticDashboardMarketHub(credentials);
|
||||
const response: DashboardMarketHubResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
gainers: result.gainers,
|
||||
losers: result.losers,
|
||||
popularByVolume: result.popularByVolume,
|
||||
popularByValue: result.popularByValue,
|
||||
news: result.news,
|
||||
pulse: result.pulse,
|
||||
warnings: result.warnings,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(
|
||||
error,
|
||||
"시장 허브 조회 중 오류가 발생했습니다.",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
124
app/api/kis/domestic/orderable-cash/route.ts
Normal file
124
app/api/kis/domestic/orderable-cash/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { executeInquireOrderableCash } from "@/lib/kis/trade";
|
||||
import type { DashboardStockOrderableCashResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import {
|
||||
readKisAccountParts,
|
||||
readKisCredentialsFromHeaders,
|
||||
} from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/orderable-cash/route.ts
|
||||
* @description 국내주식 매수가능금액(주문가능현금) 조회 API
|
||||
*/
|
||||
|
||||
const orderableCashBodySchema = z.object({
|
||||
symbol: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
|
||||
price: z.coerce.number().positive("기준 가격은 0보다 커야 합니다."),
|
||||
orderType: z.enum(["limit", "market"]).default("market"),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const account = readKisAccountParts(request.headers);
|
||||
if (!account) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let rawBody: unknown = {};
|
||||
try {
|
||||
rawBody = (await request.json()) as unknown;
|
||||
} catch {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = orderableCashBodySchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "요청값이 올바르지 않습니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await executeInquireOrderableCash(
|
||||
{
|
||||
symbol: parsed.data.symbol,
|
||||
price: parsed.data.price,
|
||||
orderType: parsed.data.orderType,
|
||||
accountNo: account.accountNo,
|
||||
accountProductCode: account.accountProductCode,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
const response: DashboardStockOrderableCashResponse = {
|
||||
ok: true,
|
||||
tradingEnv,
|
||||
orderableCash: result.orderableCash,
|
||||
noReceivableBuyAmount: result.noReceivableBuyAmount,
|
||||
maxBuyAmount: result.maxBuyAmount,
|
||||
maxBuyQuantity: result.maxBuyQuantity,
|
||||
noReceivableBuyQuantity: result.noReceivableBuyQuantity,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "매수가능금액 조회 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
64
app/api/kis/indices/route.ts
Normal file
64
app/api/kis/indices/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file app/api/kis/indices/route.ts
|
||||
* @description 국내 KOSPI/KOSDAQ 지수 조회 API
|
||||
*
|
||||
* @description [주요 책임]
|
||||
* - 로그인 및 KIS API 설정 여부 확인
|
||||
* - `getDomesticDashboardIndices` 함수를 호출하여 지수 데이터를 조회
|
||||
* - 조회된 데이터를 클라이언트에 JSON 형식으로 반환
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { hasKisConfig } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const indices = await getDomesticDashboardIndices(credentials);
|
||||
return NextResponse.json(
|
||||
{
|
||||
indices,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(
|
||||
error,
|
||||
"지수 조회 중 오류가 발생했습니다.",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: var(--font-noto-sans-kr);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-heading);
|
||||
--font-heading: var(--font-gowun-heading);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -191,4 +191,10 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-jua), var(--font-gowun-sans), sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Outfit } from "next/font/google";
|
||||
import { Geist_Mono, Gowun_Dodum, Noto_Sans_KR } from "next/font/google";
|
||||
import { QueryProvider } from "@/providers/query-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { SessionManager } from "@/features/auth/components/session-manager";
|
||||
@@ -17,9 +17,18 @@ import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal"
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
const gowunDodum = Gowun_Dodum({
|
||||
weight: "400",
|
||||
variable: "--font-gowun-heading",
|
||||
display: "swap",
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const notoSansKr = Noto_Sans_KR({
|
||||
weight: ["400", "500", "700"],
|
||||
variable: "--font-noto-sans-kr",
|
||||
display: "swap",
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
@@ -27,12 +36,6 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const outfit = Outfit({
|
||||
variable: "--font-heading",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
|
||||
description:
|
||||
@@ -52,9 +55,14 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth" suppressHydrationWarning>
|
||||
<html
|
||||
lang="en"
|
||||
className="scroll-smooth"
|
||||
data-scroll-behavior="smooth"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${outfit.variable} antialiased`}
|
||||
className={`${notoSansKr.variable} ${geistMono.variable} ${gowunDodum.variable} font-sans antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
|
||||
Reference in New Issue
Block a user