전체적인 리팩토링

This commit is contained in:
2026-03-12 09:26:27 +09:00
parent 406af7408a
commit e51d767878
97 changed files with 13651 additions and 363 deletions

View 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;
}