425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
|
|
/**
|
||
|
|
* [파일 역할]
|
||
|
|
* 자동매매 기본 전략값/폴백 신호 생성 로직을 제공하는 핵심 유틸입니다.
|
||
|
|
*
|
||
|
|
* [주요 책임]
|
||
|
|
* - AI 실패/비활성 상황에서도 동작할 fallback 전략 생성
|
||
|
|
* - 시세 기반 fallback 신호(buy/sell/hold) 계산
|
||
|
|
* - 자동매매 설정 기본값 제공
|
||
|
|
*/
|
||
|
|
|
||
|
|
import {
|
||
|
|
AUTOTRADE_DEFAULT_TECHNIQUES,
|
||
|
|
type AutotradeCompiledStrategy,
|
||
|
|
type AutotradeMarketSnapshot,
|
||
|
|
type AutotradeSignalCandidate,
|
||
|
|
type AutotradeSetupFormValues,
|
||
|
|
type AutotradeTechniqueId,
|
||
|
|
} from "@/features/autotrade/types/autotrade.types";
|
||
|
|
import { clampNumber } from "@/lib/autotrade/risk";
|
||
|
|
|
||
|
|
interface CompileFallbackInput {
|
||
|
|
prompt: string;
|
||
|
|
selectedTechniques: AutotradeTechniqueId[];
|
||
|
|
confidenceThreshold: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function createFallbackCompiledStrategy(
|
||
|
|
input: CompileFallbackInput,
|
||
|
|
): AutotradeCompiledStrategy {
|
||
|
|
// [Step 1] 사용자가 입력한 프롬프트를 요약해 최소 전략 설명문을 만듭니다.
|
||
|
|
const summary =
|
||
|
|
input.prompt.trim().length > 0
|
||
|
|
? `프롬프트 기반 전략: ${input.prompt.trim().slice(0, 120)}`
|
||
|
|
: "기본 보수형 자동매매 전략";
|
||
|
|
|
||
|
|
// [Step 2] AI 없이도 동작 가능한 보수형 기본 파라미터를 반환합니다.
|
||
|
|
return {
|
||
|
|
provider: "fallback",
|
||
|
|
summary,
|
||
|
|
selectedTechniques: input.selectedTechniques,
|
||
|
|
confidenceThreshold: clampNumber(input.confidenceThreshold, 0.45, 0.95),
|
||
|
|
maxDailyOrders: resolveMaxDailyOrders(),
|
||
|
|
cooldownSec: 60,
|
||
|
|
maxOrderAmountRatio: 0.25,
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function createFallbackSignalCandidate(params: {
|
||
|
|
strategy: AutotradeCompiledStrategy;
|
||
|
|
snapshot: AutotradeMarketSnapshot;
|
||
|
|
}): AutotradeSignalCandidate {
|
||
|
|
// [Step 1] 선택한 기법별로 buy/sell 점수를 계산합니다.
|
||
|
|
const { strategy, snapshot } = params;
|
||
|
|
const activeTechniques =
|
||
|
|
strategy.selectedTechniques.length > 0
|
||
|
|
? strategy.selectedTechniques
|
||
|
|
: AUTOTRADE_DEFAULT_TECHNIQUES;
|
||
|
|
const reasons: string[] = [];
|
||
|
|
let buyScore = 0;
|
||
|
|
let sellScore = 0;
|
||
|
|
|
||
|
|
const shortMa = average(snapshot.recentPrices.slice(-3));
|
||
|
|
const longMa = average(snapshot.recentPrices.slice(-7));
|
||
|
|
const vwap = average(snapshot.recentPrices);
|
||
|
|
const gapRate = snapshot.open
|
||
|
|
? ((snapshot.currentPrice - snapshot.open) / snapshot.open) * 100
|
||
|
|
: 0;
|
||
|
|
|
||
|
|
if (activeTechniques.includes("ma_crossover")) {
|
||
|
|
if (shortMa > longMa * 1.001) {
|
||
|
|
buyScore += 1;
|
||
|
|
reasons.push("단기 이평이 중기 이평 위로 교차했습니다.");
|
||
|
|
} else if (shortMa < longMa * 0.999) {
|
||
|
|
sellScore += 1;
|
||
|
|
reasons.push("단기 이평이 중기 이평 아래로 교차했습니다.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (activeTechniques.includes("vwap_reversion") && vwap > 0) {
|
||
|
|
if (snapshot.currentPrice < vwap * 0.995) {
|
||
|
|
buyScore += 1;
|
||
|
|
reasons.push("가격이 VWAP 대비 과매도 구간입니다.");
|
||
|
|
} else if (snapshot.currentPrice > vwap * 1.005) {
|
||
|
|
sellScore += 1;
|
||
|
|
reasons.push("가격이 VWAP 대비 과매수 구간입니다.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (activeTechniques.includes("volume_breakout")) {
|
||
|
|
const volumeRatio =
|
||
|
|
snapshot.volumeRatio ??
|
||
|
|
(snapshot.accumulatedVolume > 0
|
||
|
|
? snapshot.tradeVolume / Math.max(snapshot.accumulatedVolume / 120, 1)
|
||
|
|
: 1);
|
||
|
|
|
||
|
|
if (volumeRatio > 1.8 && snapshot.changeRate > 0.25) {
|
||
|
|
buyScore += 1;
|
||
|
|
reasons.push("거래량 증가와 상승 모멘텀이 같이 나타났습니다.");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (volumeRatio > 1.8 && snapshot.changeRate < -0.25) {
|
||
|
|
sellScore += 1;
|
||
|
|
reasons.push("거래량 증가와 하락 모멘텀이 같이 나타났습니다.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (activeTechniques.includes("gap_breakout")) {
|
||
|
|
if (gapRate > 0.8 && snapshot.currentPrice >= snapshot.high * 0.998) {
|
||
|
|
buyScore += 1;
|
||
|
|
reasons.push("상승 갭 이후 고점 돌파 구간입니다.");
|
||
|
|
} else if (gapRate < -0.8 && snapshot.currentPrice <= snapshot.low * 1.002) {
|
||
|
|
sellScore += 1;
|
||
|
|
reasons.push("하락 갭 이후 저점 이탈 구간입니다.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (activeTechniques.includes("orb")) {
|
||
|
|
const nearHighBreak = snapshot.currentPrice >= snapshot.high * 0.999;
|
||
|
|
const nearLowBreak = snapshot.currentPrice <= snapshot.low * 1.001;
|
||
|
|
|
||
|
|
if (nearHighBreak && snapshot.changeRate > 0) {
|
||
|
|
buyScore += 1;
|
||
|
|
reasons.push("시가 범위 상단 돌파 조건이 충족되었습니다.");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (nearLowBreak && snapshot.changeRate < 0) {
|
||
|
|
sellScore += 1;
|
||
|
|
reasons.push("시가 범위 하단 이탈 조건이 충족되었습니다.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (activeTechniques.includes("intraday_box_reversion")) {
|
||
|
|
const boxReversion = resolveIntradayBoxReversionSignal(snapshot);
|
||
|
|
|
||
|
|
if (boxReversion.side === "buy") {
|
||
|
|
buyScore += 1.2;
|
||
|
|
reasons.push(boxReversion.reason);
|
||
|
|
} else if (boxReversion.side === "sell") {
|
||
|
|
sellScore += 1.2;
|
||
|
|
reasons.push(boxReversion.reason);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (activeTechniques.includes("intraday_breakout_scalp")) {
|
||
|
|
const breakoutScalp = resolveIntradayBreakoutScalpSignal(snapshot);
|
||
|
|
|
||
|
|
if (breakoutScalp.side === "buy") {
|
||
|
|
buyScore += 1.3;
|
||
|
|
reasons.push(breakoutScalp.reason);
|
||
|
|
} else if (breakoutScalp.side === "sell") {
|
||
|
|
sellScore += 0.9;
|
||
|
|
reasons.push(breakoutScalp.reason);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const riskFlags: string[] = [];
|
||
|
|
if ((snapshot.marketDataLatencySec ?? 0) > 5) {
|
||
|
|
riskFlags.push("market_data_stale");
|
||
|
|
}
|
||
|
|
|
||
|
|
// [Step 2] 방향성이 부족하면 hold 신호를 우선 반환합니다.
|
||
|
|
if (buyScore === 0 && sellScore === 0) {
|
||
|
|
return {
|
||
|
|
signal: "hold",
|
||
|
|
confidence: 0.55,
|
||
|
|
reason: "선택한 기법에서 유의미한 방향 신호가 아직 없습니다.",
|
||
|
|
ttlSec: 20,
|
||
|
|
riskFlags,
|
||
|
|
source: "fallback",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const isBuy = buyScore >= sellScore;
|
||
|
|
const scoreDiff = Math.abs(buyScore - sellScore);
|
||
|
|
const confidence = clampNumber(
|
||
|
|
0.58 + scoreDiff * 0.12 + Math.min(0.12, Math.abs(snapshot.changeRate) / 10),
|
||
|
|
0.5,
|
||
|
|
0.95,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (confidence < strategy.confidenceThreshold) {
|
||
|
|
riskFlags.push("confidence_low");
|
||
|
|
}
|
||
|
|
|
||
|
|
// [Step 3] 점수 우세 방향 + 신뢰도를 기반으로 주문 후보를 구성합니다.
|
||
|
|
return {
|
||
|
|
signal: isBuy ? "buy" : "sell",
|
||
|
|
confidence,
|
||
|
|
reason: reasons[0] ?? "복합 신호 점수가 기준치를 넘었습니다.",
|
||
|
|
ttlSec: 20,
|
||
|
|
riskFlags,
|
||
|
|
proposedOrder: {
|
||
|
|
symbol: snapshot.symbol,
|
||
|
|
side: isBuy ? "buy" : "sell",
|
||
|
|
orderType: "limit",
|
||
|
|
price: snapshot.currentPrice,
|
||
|
|
quantity: 1,
|
||
|
|
},
|
||
|
|
source: "fallback",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function resolveSetupDefaults(): AutotradeSetupFormValues {
|
||
|
|
const subscriptionCliVendor = resolveDefaultSubscriptionCliVendor();
|
||
|
|
|
||
|
|
return {
|
||
|
|
aiMode: resolveDefaultAiMode(),
|
||
|
|
subscriptionCliVendor,
|
||
|
|
subscriptionCliModel: resolveDefaultSubscriptionCliModel(subscriptionCliVendor),
|
||
|
|
prompt: "",
|
||
|
|
selectedTechniques: [],
|
||
|
|
allocationPercent: 10,
|
||
|
|
allocationAmount: 500000,
|
||
|
|
dailyLossPercent: 2,
|
||
|
|
dailyLossAmount: 50000,
|
||
|
|
confidenceThreshold: resolveDefaultConfidenceThreshold(),
|
||
|
|
agreeStopOnExit: false,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveDefaultAiMode(): AutotradeSetupFormValues["aiMode"] {
|
||
|
|
const raw = (process.env.AUTOTRADE_AI_MODE ?? "auto").trim().toLowerCase();
|
||
|
|
if (raw === "openai_api") return "openai_api";
|
||
|
|
if (raw === "subscription_cli") return "subscription_cli";
|
||
|
|
if (raw === "rule_fallback") return "rule_fallback";
|
||
|
|
return "auto";
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveDefaultSubscriptionCliVendor(): AutotradeSetupFormValues["subscriptionCliVendor"] {
|
||
|
|
const raw = (process.env.AUTOTRADE_SUBSCRIPTION_CLI ?? "auto").trim().toLowerCase();
|
||
|
|
if (raw === "codex") return "codex";
|
||
|
|
if (raw === "gemini") return "gemini";
|
||
|
|
return "auto";
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveDefaultSubscriptionCliModel(vendor: AutotradeSetupFormValues["subscriptionCliVendor"]) {
|
||
|
|
const commonModel = (process.env.AUTOTRADE_SUBSCRIPTION_CLI_MODEL ?? "").trim();
|
||
|
|
if (commonModel) return commonModel;
|
||
|
|
|
||
|
|
if (vendor === "codex") {
|
||
|
|
const codexModel = (process.env.AUTOTRADE_CODEX_MODEL ?? "").trim();
|
||
|
|
return codexModel || "gpt-5-codex";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (vendor === "gemini") {
|
||
|
|
const geminiModel = (process.env.AUTOTRADE_GEMINI_MODEL ?? "").trim();
|
||
|
|
return geminiModel || "auto";
|
||
|
|
}
|
||
|
|
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveMaxDailyOrders() {
|
||
|
|
const raw = Number.parseInt(
|
||
|
|
process.env.AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT ?? "20",
|
||
|
|
10,
|
||
|
|
);
|
||
|
|
if (!Number.isFinite(raw)) return 20;
|
||
|
|
return clampNumber(raw, 1, 200);
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveDefaultConfidenceThreshold() {
|
||
|
|
const raw = Number.parseFloat(
|
||
|
|
process.env.AUTOTRADE_CONFIDENCE_THRESHOLD_DEFAULT ?? "0.65",
|
||
|
|
);
|
||
|
|
if (!Number.isFinite(raw)) return 0.65;
|
||
|
|
return clampNumber(raw, 0.45, 0.95);
|
||
|
|
}
|
||
|
|
|
||
|
|
function average(values: number[]) {
|
||
|
|
const normalized = values.filter((value) => Number.isFinite(value) && value > 0);
|
||
|
|
if (normalized.length === 0) return 0;
|
||
|
|
const sum = normalized.reduce((acc, value) => acc + value, 0);
|
||
|
|
return sum / normalized.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveIntradayBoxReversionSignal(
|
||
|
|
snapshot: AutotradeMarketSnapshot,
|
||
|
|
): { side: "buy" | "sell" | null; reason: string } {
|
||
|
|
const windowPrices = snapshot.recentPrices
|
||
|
|
.slice(-12)
|
||
|
|
.filter((price) => Number.isFinite(price) && price > 0);
|
||
|
|
if (windowPrices.length < 6) {
|
||
|
|
return { side: null, reason: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
const boxHigh = Math.max(...windowPrices);
|
||
|
|
const boxLow = Math.min(...windowPrices);
|
||
|
|
const boxRange = boxHigh - boxLow;
|
||
|
|
if (boxRange <= 0 || snapshot.currentPrice <= 0) {
|
||
|
|
return { side: null, reason: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
const dayRise = snapshot.changeRate;
|
||
|
|
const boxRangeRate = boxRange / snapshot.currentPrice;
|
||
|
|
const oscillationCount = countDirectionFlips(windowPrices);
|
||
|
|
const isRisingDay = dayRise >= 0.8;
|
||
|
|
const isBoxRange = boxRangeRate >= 0.003 && boxRangeRate <= 0.02;
|
||
|
|
const isOscillating = oscillationCount >= 2;
|
||
|
|
|
||
|
|
if (!isRisingDay || !isBoxRange || !isOscillating) {
|
||
|
|
return { side: null, reason: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
const rangePosition = clampNumber(
|
||
|
|
(snapshot.currentPrice - boxLow) / boxRange,
|
||
|
|
0,
|
||
|
|
1,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (rangePosition <= 0.28) {
|
||
|
|
return {
|
||
|
|
side: "buy",
|
||
|
|
reason: `당일 상승(+${dayRise.toFixed(2)}%) 뒤 박스권 하단에서 반등 단타 조건이 확인됐습니다.`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (rangePosition >= 0.72) {
|
||
|
|
return {
|
||
|
|
side: "sell",
|
||
|
|
reason: `당일 상승(+${dayRise.toFixed(2)}%) 뒤 박스권 상단에서 되돌림 단타 조건이 확인됐습니다.`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return { side: null, reason: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveIntradayBreakoutScalpSignal(
|
||
|
|
snapshot: AutotradeMarketSnapshot,
|
||
|
|
): { side: "buy" | "sell" | null; reason: string } {
|
||
|
|
// [Step 1] 1분봉 최근 구간에서 추세/눌림/돌파 판단에 필요한 표본을 준비합니다.
|
||
|
|
const prices = snapshot.recentPrices
|
||
|
|
.slice(-18)
|
||
|
|
.filter((price) => Number.isFinite(price) && price > 0);
|
||
|
|
if (prices.length < 12 || snapshot.currentPrice <= 0) {
|
||
|
|
return { side: null, reason: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
const trendBase = prices.slice(0, prices.length - 6);
|
||
|
|
const pullbackWindow = prices.slice(prices.length - 6, prices.length - 1);
|
||
|
|
if (trendBase.length < 5 || pullbackWindow.length < 4) {
|
||
|
|
return { side: null, reason: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
const trendHigh = Math.max(...trendBase);
|
||
|
|
const trendLow = Math.min(...trendBase);
|
||
|
|
const pullbackHigh = Math.max(...pullbackWindow);
|
||
|
|
const pullbackLow = Math.min(...pullbackWindow);
|
||
|
|
const pullbackRange = pullbackHigh - pullbackLow;
|
||
|
|
|
||
|
|
if (trendHigh <= 0 || pullbackRange <= 0) {
|
||
|
|
return { side: null, reason: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
const trendRiseRate = ((trendHigh - trendLow) / trendLow) * 100;
|
||
|
|
const pullbackDepthRate = ((trendHigh - pullbackLow) / trendHigh) * 100;
|
||
|
|
const pullbackRangeRate = (pullbackRange / snapshot.currentPrice) * 100;
|
||
|
|
const volumeRatio = snapshot.volumeRatio ?? 1;
|
||
|
|
const tradeStrength = snapshot.tradeStrength ?? 100;
|
||
|
|
const hasBidSupport =
|
||
|
|
(snapshot.totalBidSize ?? 0) > (snapshot.totalAskSize ?? 0) * 1.02;
|
||
|
|
const momentum = snapshot.intradayMomentum ?? 0;
|
||
|
|
|
||
|
|
// 추세 필터: 상승 구간인지 먼저 확인
|
||
|
|
const isRisingTrend =
|
||
|
|
trendRiseRate >= 0.7 && snapshot.changeRate >= 0.4 && momentum >= 0.15;
|
||
|
|
|
||
|
|
// 눌림 필터: 박스형 눌림인지 확인 (과도한 급락 제외)
|
||
|
|
const isControlledPullback =
|
||
|
|
pullbackDepthRate >= 0.2 &&
|
||
|
|
pullbackDepthRate <= 1.6 &&
|
||
|
|
pullbackRangeRate >= 0.12 &&
|
||
|
|
pullbackRangeRate <= 0.9;
|
||
|
|
|
||
|
|
if (!isRisingTrend || !isControlledPullback) {
|
||
|
|
return { side: null, reason: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
// [Step 2] 눌림 상단 재돌파 + 거래량 재유입 조건이 맞으면 매수 우세로 판단합니다.
|
||
|
|
const breakoutTrigger = snapshot.currentPrice >= pullbackHigh * 1.0007;
|
||
|
|
const breakoutConfirmed = breakoutTrigger && volumeRatio >= 1.15;
|
||
|
|
const orderflowConfirmed = tradeStrength >= 98 || hasBidSupport;
|
||
|
|
|
||
|
|
if (breakoutConfirmed && orderflowConfirmed) {
|
||
|
|
return {
|
||
|
|
side: "buy",
|
||
|
|
reason: `상승 추세(+${trendRiseRate.toFixed(2)}%)에서 눌림 상단 재돌파(거래량비 ${volumeRatio.toFixed(2)})가 확인됐습니다.`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// [Step 3] 재돌파 실패 후 눌림 하단 이탈 시 보수적으로 매도(또는 익절/청산) 신호를 냅니다.
|
||
|
|
const failedBreakdown =
|
||
|
|
snapshot.currentPrice <= pullbackLow * 0.9995 &&
|
||
|
|
volumeRatio >= 1.05 &&
|
||
|
|
(snapshot.netBuyExecutionCount ?? 0) < 0;
|
||
|
|
|
||
|
|
if (failedBreakdown) {
|
||
|
|
return {
|
||
|
|
side: "sell",
|
||
|
|
reason: "눌림 하단 이탈로 상승 단타 시나리오가 약화되어 보수적 청산 신호를 냅니다.",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return { side: null, reason: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
function countDirectionFlips(prices: number[]) {
|
||
|
|
let flips = 0;
|
||
|
|
let prevDirection = 0;
|
||
|
|
|
||
|
|
for (let i = 1; i < prices.length; i += 1) {
|
||
|
|
const diff = prices[i] - prices[i - 1];
|
||
|
|
const direction = diff > 0 ? 1 : diff < 0 ? -1 : 0;
|
||
|
|
if (direction === 0) continue;
|
||
|
|
|
||
|
|
if (prevDirection !== 0 && direction !== prevDirection) {
|
||
|
|
flips += 1;
|
||
|
|
}
|
||
|
|
prevDirection = direction;
|
||
|
|
}
|
||
|
|
|
||
|
|
return flips;
|
||
|
|
}
|