전체적인 리팩토링
This commit is contained in:
293
lib/autotrade/openai.ts
Normal file
293
lib/autotrade/openai.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* OpenAI Chat Completions를 이용해 자동매매 전략/신호 JSON을 생성하는 유틸입니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - OpenAI 연결 가능 여부 확인
|
||||
* - 전략 compile 응답 스키마 검증
|
||||
* - 신호 generate 응답 스키마 검증
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AutotradeCompiledStrategy,
|
||||
AutotradeMarketSnapshot,
|
||||
AutotradeSignalCandidate,
|
||||
AutotradeTechniqueId,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
|
||||
const OPENAI_CHAT_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions";
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
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 function isOpenAiConfigured() {
|
||||
return Boolean(process.env.OPENAI_API_KEY?.trim());
|
||||
}
|
||||
|
||||
// [목적] 자연어 프롬프트 + 기법 선택을 실행 가능한 전략 JSON으로 변환합니다.
|
||||
export async function compileStrategyWithOpenAi(params: {
|
||||
prompt: string;
|
||||
selectedTechniques: AutotradeTechniqueId[];
|
||||
confidenceThreshold: number;
|
||||
}) {
|
||||
// [Step 1] 전략 컴파일용 JSON 스키마 + 프롬프트를 구성합니다.
|
||||
const response = await callOpenAiJson({
|
||||
schemaName: "autotrade_strategy_compile",
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
summary: { type: "string" },
|
||||
confidenceThreshold: { type: "number" },
|
||||
maxDailyOrders: { type: "integer" },
|
||||
cooldownSec: { type: "integer" },
|
||||
maxOrderAmountRatio: { type: "number" },
|
||||
},
|
||||
required: [
|
||||
"summary",
|
||||
"confidenceThreshold",
|
||||
"maxDailyOrders",
|
||||
"cooldownSec",
|
||||
"maxOrderAmountRatio",
|
||||
],
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"너는 자동매매 전략 컴파일러다. 설명문 없이 JSON만 반환하고 리스크를 보수적으로 설정한다.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify(
|
||||
{
|
||||
prompt: params.prompt,
|
||||
selectedTechniques: params.selectedTechniques,
|
||||
techniqueGuide: {
|
||||
orb: "시가 범위 돌파 추세",
|
||||
vwap_reversion: "VWAP 평균회귀",
|
||||
volume_breakout: "거래량 동반 돌파",
|
||||
ma_crossover: "단기/중기 이평 교차",
|
||||
gap_breakout: "갭 이후 돌파/이탈",
|
||||
intraday_box_reversion: "당일 상승 후 박스권 상하단 단타",
|
||||
intraday_breakout_scalp:
|
||||
"1분봉 상승 추세에서 눌림 후 재돌파(거래량 재유입) 단타",
|
||||
},
|
||||
baselineConfidenceThreshold: params.confidenceThreshold,
|
||||
rules: [
|
||||
"confidenceThreshold는 0.45~0.95",
|
||||
"maxDailyOrders는 1~200",
|
||||
"cooldownSec는 10~600",
|
||||
"maxOrderAmountRatio는 0.05~1",
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// [Step 2] 응답이 없거나 스키마 불일치면 null로 반환해 상위 라우트가 fallback을 적용하게 합니다.
|
||||
if (!response) return null;
|
||||
|
||||
const parsed = compileResultSchema.safeParse(response);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
// [목적] 시세 스냅샷을 기반으로 buy/sell/hold 후보 신호 JSON을 생성합니다.
|
||||
export async function generateSignalWithOpenAi(params: {
|
||||
prompt: string;
|
||||
strategy: AutotradeCompiledStrategy;
|
||||
snapshot: AutotradeMarketSnapshot;
|
||||
}) {
|
||||
// [Step 1] 전략 + 시세 스냅샷을 신호 생성 JSON 스키마에 맞춰 전달합니다.
|
||||
const response = await callOpenAiJson({
|
||||
schemaName: "autotrade_signal_candidate",
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
signal: {
|
||||
type: "string",
|
||||
enum: ["buy", "sell", "hold"],
|
||||
},
|
||||
confidence: { type: "number" },
|
||||
reason: { type: "string" },
|
||||
ttlSec: { type: "integer" },
|
||||
riskFlags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
proposedOrder: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
symbol: { type: "string" },
|
||||
side: {
|
||||
type: "string",
|
||||
enum: ["buy", "sell"],
|
||||
},
|
||||
orderType: {
|
||||
type: "string",
|
||||
enum: ["limit", "market"],
|
||||
},
|
||||
price: { type: "number" },
|
||||
quantity: { type: "integer" },
|
||||
},
|
||||
required: ["symbol", "side", "orderType"],
|
||||
},
|
||||
},
|
||||
required: ["signal", "confidence", "reason", "ttlSec", "riskFlags"],
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"너는 주문 실행기가 아니라 신호 생성기다. JSON 외 텍스트를 출력하지 말고, 근거 없는 공격적 신호를 피한다. 스냅샷의 체결/호가/모멘텀/박스권 지표와 최근 1분봉 구조를 함께 보고 판단한다.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify(
|
||||
{
|
||||
operatorPrompt: params.prompt,
|
||||
strategy: {
|
||||
summary: params.strategy.summary,
|
||||
selectedTechniques: params.strategy.selectedTechniques,
|
||||
confidenceThreshold: params.strategy.confidenceThreshold,
|
||||
maxDailyOrders: params.strategy.maxDailyOrders,
|
||||
cooldownSec: params.strategy.cooldownSec,
|
||||
},
|
||||
snapshot: params.snapshot,
|
||||
constraints: [
|
||||
"reason은 한국어 한 줄로 작성",
|
||||
"확신이 낮으면 hold를 우선 선택",
|
||||
"operatorPrompt에 세부 규칙이 있으면 strategy.summary보다 우선 참고하되, 리스크 보수성은 유지",
|
||||
"spreadRate(호가 스프레드), dayRangePosition(당일 범위 위치), volumeRatio(체결량 비율), intradayMomentum(단기 모멘텀), recentReturns(최근 수익률 흐름)을 함께 고려",
|
||||
"tradeVolume은 단일 틱 체결 수량일 수 있으므로 accumulatedVolume, recentTradeCount, recentTradeVolumeSum, liquidityDepth(호가 총잔량), orderBookImbalance를 함께 보고 유동성을 판단",
|
||||
"tickTime/requestAtKst/marketDataLatencySec을 보고 데이터 시차가 큰 경우 과감한 진입을 피한다",
|
||||
"recentTradeVolumes/recentNetBuyTrail/recentTickAgesSec에서 체결 흐름의 연속성(매수 우위 지속/이탈)을 확인한다",
|
||||
"topLevelOrderBookImbalance와 buySellExecutionRatio를 함께 보고 상단 호가에서의 단기 수급 왜곡을 확인한다",
|
||||
"recentMinuteCandles와 minutePatternContext가 있으면 직전 3~10봉 추세와 최근 2~8봉 압축 구간을 우선 판별한다",
|
||||
"minutePatternContext.impulseDirection/impulseChangeRate/consolidationRangePercent/consolidationVolumeRatio/breakoutUpper/breakoutLower를 함께 보고 박스 압축 후 돌파/이탈 가능성을 판단한다",
|
||||
"budgetContext가 있으면 estimatedBuyableQuantity(예산 기준 최대 매수 가능 주수)를 초과하는 공격적 진입을 피한다",
|
||||
"portfolioContext가 있으면 sellableQuantity(실제 매도 가능 수량)가 0인 상태에서는 sell 신호를 매우 보수적으로 본다",
|
||||
"executionCostProfile이 있으면 매도 시 수수료+세금을 고려한 순손익 관점으로 판단한다",
|
||||
"압축 구간이 애매하거나 박스 내부 중간값이면 추격 진입 대신 hold를 우선한다",
|
||||
"intraday_breakout_scalp 기법이 포함되면 상승 추세, 눌림 깊이, 재돌파 거래량 확인이 동시에 만족될 때만 buy를 고려",
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// [Step 2] 응답이 없거나 스키마 불일치면 null 반환 -> 상위 라우트가 fallback 신호로 대체합니다.
|
||||
if (!response) return null;
|
||||
|
||||
const parsed = signalResultSchema.safeParse(response);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed.data,
|
||||
source: "openai",
|
||||
} satisfies AutotradeSignalCandidate;
|
||||
}
|
||||
|
||||
async function callOpenAiJson(params: {
|
||||
schemaName: string;
|
||||
schema: Record<string, unknown>;
|
||||
messages: Array<{ role: "system" | "user"; content: string }>;
|
||||
}) {
|
||||
// [데이터 흐름] route.ts -> callOpenAiJson -> OpenAI chat/completions -> JSON parse -> route.ts 반환
|
||||
const apiKey = process.env.OPENAI_API_KEY?.trim();
|
||||
if (!apiKey) return null;
|
||||
|
||||
const model = process.env.AUTOTRADE_AI_MODEL?.trim() || "gpt-4o-mini";
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
// [Step 1] OpenAI Chat Completions(JSON Schema 강제) 호출
|
||||
response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: 0.2,
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: params.schemaName,
|
||||
strict: true,
|
||||
schema: params.schema,
|
||||
},
|
||||
},
|
||||
messages: params.messages,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
// [Step 2] HTTP 응답 JSON 파싱
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok || !payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content =
|
||||
(payload as { choices?: Array<{ message?: { content?: string } }> }).choices?.[0]?.message
|
||||
?.content ?? "";
|
||||
|
||||
if (!content.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// [Step 3] 모델이 반환한 JSON 문자열을 실제 객체로 변환
|
||||
return JSON.parse(content) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user