294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
|
|
/**
|
||
|
|
* [파일 역할]
|
||
|
|
* 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;
|
||
|
|
}
|
||
|
|
}
|