전체적인 리팩토링
This commit is contained in:
424
lib/autotrade/strategy.ts
Normal file
424
lib/autotrade/strategy.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 자동매매 기본 전략값/폴백 신호 생성 로직을 제공하는 핵심 유틸입니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - 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;
|
||||
}
|
||||
Reference in New Issue
Block a user