441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
|
|
/**
|
||
|
|
* [파일 역할]
|
||
|
|
* 컴파일된 전략 + 시세 스냅샷으로 매수/매도/대기 신호를 생성하는 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;
|
||
|
|
}
|