전체적인 리팩토링
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user