Files
goldenChart/frontend/src/utils/backendApi.ts
T
2026-06-22 12:17:03 +09:00

2020 lines
68 KiB
TypeScript

/**
* GoldenChart Backend API 클라이언트.
* Spring Boot 백엔드와 통신한다.
*
* 환경변수 VITE_API_BASE_URL 로 백엔드 URL 설정 (기본: http://localhost:8080/api)
*/
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
import {
isStrategyMissingOnServer,
markStrategyMissingFromPath,
unmarkStrategyMissingOnServer,
} from './strategyMissingCache';
export { isStrategyMissingOnServer } from './strategyMissingCache';
/** 백엔드 REST base (Docker 빌드: `/api`, 로컬 Vite: `.env` 또는 localhost:8080) */
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api';
/** STOMP WebSocket 연결에 사용할 HTTP base URL (api 경로 제거) */
export function getApiBase(): string {
return API_BASE.replace(/\/api$/, '');
}
function isDesktopRuntime(): boolean {
if (typeof window === 'undefined') return false;
if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) return true;
return document.documentElement.classList.contains('desktop-client');
}
/** secure WebView(https) — ws:// mixed content 회피 */
export function getSecureApiOrigin(): string {
const origin = getApiBase().replace(/\/$/, '');
const pageSecure =
typeof window !== 'undefined'
&& (window.location.protocol === 'https:' || isDesktopRuntime());
if (pageSecure && /^http:\/\//i.test(origin)) {
return origin.replace(/^http:/i, 'https:');
}
return origin;
}
/**
* STOMP SockJS 엔드포인트.
* Spring Boot context-path가 /api 이므로 반드시 /api/ws/trading 경로를 사용한다.
*/
export function getStompSockJsUrl(): string {
if (isDesktopRuntime()) {
return `${getSecureApiOrigin()}/api/ws/trading`;
}
const base = API_BASE.replace(/\/$/, '');
const path = base.endsWith('/api') ? `${base}/ws/trading` : `${base}/api/ws/trading`;
// SockJS: 배포 환경에서 상대 경로만 쓰면 프록시/오리진 불일치 시 연결 실패가 난다.
if (typeof window !== 'undefined' && window.location?.origin) {
const rel = path.startsWith('/') ? path : `/${path}`;
if (window.location.protocol === 'https:' && /^http:\/\//i.test(path)) {
return `${getSecureApiOrigin()}/api/ws/trading`;
}
return `${window.location.origin}${rel}`;
}
return path;
}
/** Desktop — 순수 WebSocket STOMP (SockJS 미사용) */
export function getStompNativeWebSocketUrl(): string {
const secureOrigin = getSecureApiOrigin().replace(/^http:\/\//i, 'https://');
const wsOrigin = secureOrigin.replace(/^https:\/\//i, 'wss://');
return `${wsOrigin}/api/ws/trading/ws`;
}
// UUID 생성 — HTTPS/localhost: crypto.randomUUID() 사용, HTTP: 폴리필
function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// HTTP 환경(비보안 컨텍스트) 폴리필
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// 기기 ID (localStorage 영속)
function getDeviceId(): string {
let id = localStorage.getItem('gc_device_id');
if (!id) {
id = generateUUID();
localStorage.setItem('gc_device_id', id);
}
return id;
}
/** 정식 회원(userId) — 매매·전략·모의 API 필수 */
export function hasRegisteredUser(): boolean {
const id = localStorage.getItem('gc_user_id');
if (!id) return false;
const n = Number(id);
return Number.isFinite(n) && n > 0;
}
function authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-device-id': getDeviceId(),
};
const userId = localStorage.getItem('gc_user_id');
if (userId) headers['x-user-id'] = userId;
return headers;
}
/** 로그인 API 오류 시 메시지 throw */
async function requestOrThrow<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { ...authHeaders(), ...(init?.headers ?? {}) },
});
const text = await res.text().catch(() => '');
if (!res.ok) {
let msg = `요청 실패 (${res.status})`;
if (res.status === 504) {
msg = '게이트웨이 시간 초과(504). LLM 분석은 2~3분 걸릴 수 있습니다. 잠시 후 다시 시도해 주세요.';
} else {
try {
const j = JSON.parse(text) as { message?: string };
if (j.message) msg = j.message;
} catch { /* plain text */ }
if (!text.startsWith('{') && text) {
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (plain) msg = plain.slice(0, 200);
}
}
throw new Error(msg);
}
if (res.status === 204 || !text) return undefined as T;
return JSON.parse(text) as T;
}
/** LLM·AI 검증 등 장시간 API */
async function requestOrThrowWithTimeout<T>(
path: string,
init?: RequestInit,
timeoutMs = 200_000,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${API_BASE}${path}`, {
...init,
signal: controller.signal,
headers: { ...authHeaders(), ...(init?.headers ?? {}) },
});
const text = await res.text().catch(() => '');
if (!res.ok) {
let msg = `요청 실패 (${res.status})`;
if (res.status === 504) {
msg = '게이트웨이 시간 초과(504). LLM 분석은 2~3분 걸릴 수 있습니다. 잠시 후 다시 시도해 주세요.';
} else {
try {
const j = JSON.parse(text) as { message?: string };
if (j.message) msg = j.message;
} catch { /* plain text */ }
if (!text.startsWith('{') && text) {
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (plain) msg = plain.slice(0, 200);
}
}
throw new Error(msg);
}
if (res.status === 204 || !text) return undefined as T;
return JSON.parse(text) as T;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error(
`요청 시간 초과(${Math.round(timeoutMs / 1000)}초). LLM이 느리면 설정 > LLM에서 타임아웃을 늘리거나 잠시 후 다시 시도하세요.`,
);
}
throw e;
} finally {
clearTimeout(timer);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T | null> {
try {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { ...authHeaders(), ...(init?.headers ?? {}) },
});
if (res.status === 204) return null;
if (!res.ok) {
const method = init?.method ?? 'GET';
const guest403 = res.status === 403 && !hasRegisteredUser();
const pathOnly = path.split('?')[0];
if (res.status === 404 && /^\/strategies\/\d+/.test(pathOnly)) {
markStrategyMissingFromPath(pathOnly);
}
const quiet404 = res.status === 404 && (
method === 'GET'
|| pathOnly.includes('/repair-timeframes')
|| /^\/strategies\/\d+/.test(pathOnly)
);
if (!guest403 && !quiet404) {
console.error(`[backendApi] ${method} ${path}${res.status}`);
}
return null;
}
return (await res.json()) as T;
} catch (e) {
console.error(`[backendApi] network error ${path}`, e);
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 타입 정의 (backend DTO 와 동기화)
// ─────────────────────────────────────────────────────────────────────────────
export interface SlotDto {
slotIndex: number;
symbol: string | null;
timeframe: string | null;
chartType: string;
theme: string;
mode: string;
logScale: boolean;
drawingsLocked: boolean;
drawingsVisible: boolean;
indicators: unknown; // IndicatorConfig[]
drawings: unknown; // Drawing[]
paneLayout: unknown; // PaneLayoutEntry[]
mainChartStyle: unknown; // MainChartStyle
}
export interface WorkspaceDto {
id: number;
layoutId: string;
syncOptions: unknown;
slots: SlotDto[];
}
export interface WatchlistItem {
id: number;
symbol: string;
koreanName: string | null;
englishName: string | null;
displayOrder: number;
}
export interface HoldingsItem extends WatchlistItem {
avgPrice: number | null;
quantity: number | null;
}
export interface IndicatorPoint {
time: number;
value: number | null;
color: string | null;
}
export interface IndicatorCalcResponse {
symbol: string;
timeframe: string;
indicatorType: string;
times: number[];
values: Record<string, IndicatorPoint[]>;
}
// ─────────────────────────────────────────────────────────────────────────────
// 워크스페이스 API
// ─────────────────────────────────────────────────────────────────────────────
/** 프론트 시작 시 DB에서 전체 설정 로드 */
export async function loadWorkspace(): Promise<WorkspaceDto | null> {
return request<WorkspaceDto>('/chart/workspace');
}
/** 전체 워크스페이스 저장 */
export async function saveWorkspace(data: {
layoutId: string;
syncOptions: unknown;
slots: unknown[];
}): Promise<WorkspaceDto | null> {
return request<WorkspaceDto>('/chart/workspace', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** 레이아웃만 변경 */
export async function updateLayout(layoutId: string, syncOptions?: unknown): Promise<void> {
await request('/chart/workspace/layout', {
method: 'PUT',
body: JSON.stringify({ layoutId, syncOptions }),
});
}
/** 특정 슬롯 저장 */
export async function saveSlot(slotIndex: number, slotData: Partial<SlotDto>): Promise<void> {
await request(`/chart/slot/${slotIndex}`, {
method: 'PUT',
body: JSON.stringify(slotData),
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 관심종목 API
// ─────────────────────────────────────────────────────────────────────────────
export async function loadWatchlist(): Promise<WatchlistItem[]> {
return (await request<WatchlistItem[]>('/watchlist')) ?? [];
}
export async function addWatchlistItem(item: {
symbol: string;
koreanName?: string;
englishName?: string;
}): Promise<WatchlistItem | null> {
return request<WatchlistItem>('/watchlist', {
method: 'POST',
body: JSON.stringify(item),
});
}
export async function removeWatchlistItem(symbol: string): Promise<void> {
await request(`/watchlist/${encodeURIComponent(symbol)}`, { method: 'DELETE' });
}
export async function reorderWatchlist(
items: { symbol: string; displayOrder: number }[]
): Promise<void> {
await request('/watchlist/order', { method: 'PUT', body: JSON.stringify(items) });
}
// ─────────────────────────────────────────────────────────────────────────────
// 보유종목 API
// ─────────────────────────────────────────────────────────────────────────────
export async function loadHoldings(): Promise<HoldingsItem[]> {
return (await request<HoldingsItem[]>('/holdings')) ?? [];
}
export async function upsertHoldingsItem(item: {
symbol: string;
koreanName?: string;
englishName?: string;
avgPrice?: number;
quantity?: number;
}): Promise<HoldingsItem | null> {
return request<HoldingsItem>('/holdings', {
method: 'POST',
body: JSON.stringify(item),
});
}
export async function removeHoldingsItem(symbol: string): Promise<void> {
await request(`/holdings/${encodeURIComponent(symbol)}`, { method: 'DELETE' });
}
// ─────────────────────────────────────────────────────────────────────────────
// 지표 계산 API (백엔드 Ta4j 사용)
// ─────────────────────────────────────────────────────────────────────────────
export async function calculateIndicator(req: {
symbol?: string;
timeframe?: string;
indicatorType: string;
params?: Record<string, unknown>;
bars?: { time: number; open: number; high: number; low: number; close: number; volume: number }[];
}): Promise<IndicatorCalcResponse | null> {
return request<IndicatorCalcResponse>('/indicators/calculate', {
method: 'POST',
body: JSON.stringify(req),
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 전역 지표 파라미터 설정 API
//
// 역할: 프론트엔드 indicatorRegistry.ts 의 defaultParams 를 완전히 대체.
// 사용자가 변경한 파라미터를 DB 에 저장하고,
// 백엔드 Ta4j 계산 시에도 동일한 값을 사용하도록 한다.
//
// 저장 형식:
// {
// "RSI": { "length": 9, "src": "close" },
// "MACD": { "fastLength": 12, "slowLength": 26, "signalLength": 9 },
// "BollingerBands":{ "length": 20, "mult": 2.0, "src": "close" },
// ...
// }
// ─────────────────────────────────────────────────────────────────────────────
/** 모든 지표 파라미터 로드 (앱 시작 시 호출) */
export async function loadIndicatorSettings(): Promise<
Record<string, Record<string, unknown>>
> {
return (
(await request<Record<string, Record<string, unknown>>>('/indicator-settings')) ?? {}
);
}
/** 특정 지표 파라미터 로드 */
export async function loadIndicatorSettingsForType(
indicatorType: string
): Promise<Record<string, unknown>> {
return (
(await request<Record<string, unknown>>(`/indicator-settings/${encodeURIComponent(indicatorType)}`)) ?? {}
);
}
/**
* 전체 지표 파라미터 저장 (덮어쓰기).
* 앱 초기화 또는 설정 내보내기/가져오기 시 사용.
*/
export async function saveAllIndicatorSettings(
allParams: Record<string, Record<string, unknown>>
): Promise<void> {
await request('/indicator-settings', {
method: 'PUT',
body: JSON.stringify(allParams),
});
}
/**
* 특정 지표 파라미터 저장 (병합).
* 사용자가 특정 지표의 설정값을 변경할 때마다 호출.
*
* @param indicatorType 지표 타입 (e.g. "RSI", "MACD")
* @param params 변경된 파라미터 (e.g. {length: 9, src: "close"})
*/
export async function saveIndicatorSettings(
indicatorType: string,
params: Record<string, unknown>
): Promise<void> {
await request(`/indicator-settings/${encodeURIComponent(indicatorType)}`, {
method: 'PATCH',
body: JSON.stringify(params),
});
}
/**
* 모든 지표의 시각 설정(색상·선굵기·수평선) 로드.
* 앱 시작 시 호출 — 전역 시각 기본값을 DB 에서 가져온다.
*/
export async function loadIndicatorVisualSettings(): Promise<
Record<string, {
plots?: unknown[];
hlines?: unknown[];
plotVisibility?: Record<string, boolean>;
cloudColors?: unknown;
bandBackground?: unknown;
}>
> {
return (
(await request<Record<string, {
plots?: unknown[];
hlines?: unknown[];
plotVisibility?: Record<string, boolean>;
cloudColors?: unknown;
bandBackground?: unknown;
}>>(
'/indicator-settings/visual'
)) ?? {}
);
}
/**
* 특정 지표의 시각 설정(색상·선굵기·수평선·지표선 on/off)을 저장.
* 사용자가 IndicatorSettingsModal 에서 색상·선굵기를 변경할 때 호출.
*
* @param indicatorType 지표 타입 (e.g. "RSI")
* @param visual { plots, hlines, plotVisibility, ... }
*/
export async function saveIndicatorVisualSettings(
indicatorType: string,
visual: {
plots?: unknown[];
hlines?: unknown[];
plotVisibility?: Record<string, boolean>;
cloudColors?: unknown;
bandBackground?: unknown;
},
): Promise<void> {
await request(`/indicator-settings/${encodeURIComponent(indicatorType)}/visual`, {
method: 'PATCH',
body: JSON.stringify(visual),
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 앱 전역 차트 기본 설정 API
//
// 프론트엔드 코드에 하드코딩된 기본값들을 DB 로 대체.
//
// 관리 항목:
// defaultSymbol — 기본 종목 (e.g. "KRW-BTC")
// defaultTimeframe — 기본 타임프레임 (e.g. "1D")
// defaultChartType — 기본 차트 타입 (e.g. "candlestick")
// defaultTheme — 기본 테마 (e.g. "dark")
// defaultLogScale — 기본 로그 스케일 (false)
// defaultLayoutId — 기본 레이아웃 ID (e.g. "1")
// mainChartStyle — 캔들 색상 (DEFAULT_MAIN_CHART_STYLE 대체)
// syncOptions — 멀티차트 동기화 옵션 (DEFAULT_SYNC 대체)
// ─────────────────────────────────────────────────────────────────────────────
export interface AppSettingsDto {
defaultSymbol?: string;
defaultTimeframe?: string;
defaultChartType?: string;
defaultTheme?: string;
defaultLogScale?: boolean;
defaultLayoutId?: string;
mainChartStyle?: Record<string, string> | null;
syncOptions?: Record<string, boolean> | null;
/** 백테스팅 완료 시 결과 팝업 자동 표시 여부 (기본 true) */
btAutoPopup?: boolean;
/** 백테스팅 매수/매도 마커에 금액 표시 여부 (기본 true) */
btShowPrice?: boolean;
/** 캔들 영역 오버레이 지표 가격축 라벨·설명 (기본 true) */
chartCandleAreaPriceLabels?: boolean;
/** 하단 보조지표 pane 가격축 라벨·설명 (기본 true) */
chartSeriesPriceLabels?: boolean;
/** 차트 하단 거래량 바 표시 (기본 true) */
chartVolumeVisible?: boolean;
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (기본 true) */
chartLiveReceiveHighlight?: boolean;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
chartCrosshairInfoVisible?: boolean;
/** 세로 크로스헤어와 지표 선 교차점 점 표시 (기본 true) */
chartCrosshairMarkersVisible?: boolean;
/** 차트 마우스오버 줌·이동 플로팅 툴바 표시 (기본 true) */
chartHoverToolbarVisible?: boolean;
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
chartLegendOptions?: Record<string, boolean> | null;
/** 차트 pane 구분선 — mainToIndicator / indicatorToIndicator */
chartPaneSeparator?: {
mainToIndicator?: {
visible?: boolean;
color?: string;
width?: number;
lineStyle?: string;
};
indicatorToIndicator?: {
visible?: boolean;
color?: string;
width?: number;
lineStyle?: string;
};
/** @deprecated 이전 단일 형식 */
visible?: boolean;
color?: string;
width?: number;
lineStyle?: string;
} | null;
/** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */
tradeAlertPopup?: boolean;
/** 매매 시그널 알림 사운드 ON/OFF */
tradeAlertSoundEnabled?: boolean;
/** 매매 시그널 알림음 ID */
tradeAlertSound?: string;
/** 알림 팝업 표시 위치: right | left | bottom */
tradeAlertPopupPosition?: string;
/** 알림 팝업 배치: stack | grid | strip | single */
tradeAlertPopupLayout?: string;
/** 그리드 배치 열 개수 (2~4) */
tradeAlertPopupGridCols?: number;
/** 검증 이슈 등록·단계 변경 알림 팝업 (기본 true) */
verificationIssueNotify?: boolean;
/** 실시간 전략 체크 마스터 ON — ON이면 DB 관심종목 전체가 체크 대상 */
liveStrategyCheck?: boolean;
/** 관심종목 공통 전략 ID */
liveStrategyId?: number | null;
liveExecutionType?: string;
livePositionMode?: string;
/** 모의투자 */
paperTradingEnabled?: boolean;
paperInitialCapital?: number;
paperFeeRatePct?: number;
paperSlippagePct?: number;
paperMinOrderKrw?: number;
paperAutoTradeEnabled?: boolean;
paperAutoTradeBudgetPct?: number;
/**
* 미체결 주문 체결 모드
* LIMIT_ONLY : 지정가 조건 충족 시에만 체결 (기본)
* NEXT_TICK : 다음 틱 강제 체결
* AUTO_CANCEL : 허용 이탈폭(paperOrderAutoCancelPct %) 초과 시 자동 취소
*/
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
/** AUTO_CANCEL 모드 허용 이탈폭 (%) */
paperOrderAutoCancelPct?: number;
/** 가상매매 투자대상 목록 최대 종목 수 */
virtualTargetMaxCount?: number;
/** PAPER | LIVE | BOTH */
tradingMode?: string;
liveAutoTradeEnabled?: boolean;
upbitAccessKeyMasked?: string | null;
hasUpbitKeys?: boolean;
upbitAccessKey?: string;
upbitSecretKey?: string;
/** BACKEND_STOMP | UPBIT_DIRECT */
chartRealtimeSource?: string;
liveAutoTradeBudgetPct?: number;
fcmPushEnabled?: boolean;
/** 차트·UI 표시 시간대 (IANA, 예: Asia/Seoul) */
displayTimezone?: string;
/** 차트 하단 시간축 포맷 (예: yyyy-MM-dd HH:mm) */
chartTimeFormat?: string;
/** 매매 시그널 알림 팝업 시간 포맷 */
tradeAlertTimeFormat?: string;
/** 추세검색 기본 설정 */
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
/** LLM(AI 검증) 연결·모델 설정 */
llmSettings?: import('./llmSettings').LlmAppSettings | null;
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
uiPreferences?: import('../types/uiPreferences').UiPreferences | null;
}
export interface LiveSummaryDto {
enabled: boolean;
configured: boolean;
tradingMode: string;
krwBalance: number;
positions: { symbol: string; quantity: number; avgPrice: number }[];
}
export interface LiveTradeDto {
id?: number;
symbol: string;
side: string;
source: string;
price: number;
quantity: number;
grossAmount: number;
upbitOrderUuid?: string | null;
createdAt?: string | null;
}
export async function loadLiveSummary(): Promise<LiveSummaryDto | null> {
return request<LiveSummaryDto>('/live/summary');
}
export async function loadLiveTrades(): Promise<LiveTradeDto[]> {
return (await request<LiveTradeDto[]>('/live/trades')) ?? [];
}
export interface LiveOrderRequest {
market: string;
side: 'BUY' | 'SELL';
orderKind?: string;
krwAmount?: number;
quantity?: number;
strategyId?: number;
}
export async function placeLiveOrder(req: LiveOrderRequest): Promise<LiveTradeDto | null> {
return request<LiveTradeDto>('/live/orders', {
method: 'POST',
body: JSON.stringify(req),
});
}
export interface ProcessMonitorDto {
id: string;
name: string;
layer: string;
status: 'healthy' | 'degraded' | 'down' | 'disabled';
metrics: Record<string, unknown>;
}
export interface SystemMonitorDto {
pipelineEnabled: boolean;
sampledAtMs: number;
config: Record<string, unknown>;
jvm: {
heapUsedMb: number;
heapMaxMb: number;
heapPct: number;
nonHeapUsedMb: number;
threadCount: number;
daemonThreadCount: number;
};
traffic: Record<string, number>;
memory: { markets: number; series: number; totalBars: number; maxBarsPerSeries: number };
system?: {
osMemUsedMb: number;
osMemTotalMb: number;
osMemPct: number;
diskUsedGb: number;
diskTotalGb: number;
diskPct: number;
};
queue: { pending: number; capacity: number; fillRatio: number; enqueued: number; dequeued: number; dropped: number };
workers: { processed: number; errors: number; active: boolean; workerThreads: number };
orders: {
pending: number; submitted: number; executed: number; failed: number;
dropped: number; workerAlive: boolean; ratePerSecond: number;
};
processes: ProcessMonitorDto[];
threads: { name: string; id: number; state: string; daemon: boolean }[];
staleMarkets: { market: string; lastTickAgeSec: number }[];
lastTicks: Record<string, number>;
}
export interface DashboardSummaryDto {
app: Record<string, unknown>;
watchlistCount: number;
liveCheckMarkets: number;
monitoredMarkets: number;
paper: PaperSummaryDto;
live: LiveSummaryDto;
recentSignals: TradeSignalDto[];
systemMonitor: SystemMonitorDto;
fcm: { available: boolean; pushEnabled: boolean };
}
export async function loadDashboardSummary(): Promise<DashboardSummaryDto | null> {
return request<DashboardSummaryDto>('/dashboard/summary');
}
export async function registerFcmToken(token: string): Promise<void> {
await request<void>('/fcm/token', {
method: 'POST',
body: JSON.stringify({ token }),
});
}
export async function loadFcmStatus(): Promise<{ available: boolean } | null> {
return request<{ available: boolean }>('/fcm/status');
}
export async function sendFcmTest(): Promise<{ ok: boolean; available: boolean } | null> {
return request<{ ok: boolean; available: boolean }>('/fcm/test', { method: 'POST' });
}
// ── 모바일 앱 배포 ─────────────────────────────────────────────────────────────
export interface MobileAppReleaseInfoDto {
available: boolean;
fileName?: string;
version?: string;
sizeBytes?: number;
updatedAt?: string;
downloadUrl?: string;
installPageUrl?: string;
iosAvailable?: boolean;
iosTestFlightUrl?: string;
}
export async function loadMobileAppReleaseInfo(): Promise<MobileAppReleaseInfoDto | null> {
return request<MobileAppReleaseInfoDto>('/mobile-app/info');
}
// ── PC(데스크톱) 앱 배포 ───────────────────────────────────────────────────────
export interface DesktopAppPlatformReleaseDto {
available: boolean;
fileName?: string;
version?: string;
sizeBytes?: number;
updatedAt?: string;
downloadUrl?: string;
}
export interface DesktopAppReleaseInfoDto {
version?: string;
updatedAt?: string;
mac?: DesktopAppPlatformReleaseDto;
windows?: DesktopAppPlatformReleaseDto;
}
export async function loadDesktopAppReleaseInfo(): Promise<DesktopAppReleaseInfoDto | null> {
return request<DesktopAppReleaseInfoDto>('/desktop-app/info');
}
export interface DesktopAppBuildStatusDto {
jenkinsEnabled: boolean;
building: boolean;
status: string;
buildNumber?: number;
message?: string;
buildUrl?: string;
startedAtMs?: number;
}
export interface DesktopAppBuildTriggerDto {
accepted: boolean;
buildNumber?: number;
message?: string;
}
export async function loadDesktopAppBuildStatus(): Promise<DesktopAppBuildStatusDto | null> {
return request<DesktopAppBuildStatusDto>('/desktop-app/build/status');
}
export interface DesktopAppBuildReadinessDto {
publishedVersion?: string;
nextBuildVersion?: string;
buildRequired: boolean;
status: string;
message?: string;
lastBuiltVersion?: string;
lastBuiltGitShort?: string;
sourceHeadGitShort?: string;
pendingCommitCount?: number;
desktopChangesPending?: boolean;
}
export async function loadDesktopAppBuildReadiness(): Promise<DesktopAppBuildReadinessDto | null> {
return request<DesktopAppBuildReadinessDto>('/desktop-app/build/readiness');
}
export async function triggerDesktopAppBuild(): Promise<DesktopAppBuildTriggerDto> {
return requestOrThrow<DesktopAppBuildTriggerDto>('/desktop-app/build', { method: 'POST' });
}
// ── 모의투자 ───────────────────────────────────────────────────────────────────
export interface PaperPositionDto {
id?: number;
symbol: string;
koreanName?: string | null;
quantity: number;
avgPrice: number;
markPrice?: number | null;
evalAmount?: number;
profitLoss?: number;
profitLossPct?: number;
}
export interface PaperAllocationDto {
id?: number;
symbol: string;
koreanName?: string | null;
maxInvestKrw: number;
budgetPct?: number | null;
strategyId?: number | null;
isActive?: boolean;
pinned?: boolean;
sortOrder?: number;
investedKrw?: number;
availableBuyKrw?: number;
evalAmount?: number;
symbolReturnPct?: number;
weightPct?: number;
buyStage1Krw?: number;
buyStage2Krw?: number;
buyStage3Krw?: number;
sellStage1Krw?: number;
sellStage2Krw?: number;
sellStage3Krw?: number;
buyStageDone?: number;
sellStageDone?: number;
}
export interface PaperSummaryDto {
enabled: boolean;
initialCapital: number;
cashBalance: number;
stockEvalAmount: number;
totalAsset: number;
unrealizedPnl: number;
realizedPnl: number;
totalReturnPct: number;
feeRatePct: number;
slippagePct: number;
minOrderKrw: number;
autoTradeEnabled: boolean;
autoTradeBudgetPct: number;
reservedCash?: number;
orderableCash?: number;
initialCapitalSnapshot?: number;
positions: PaperPositionDto[];
allocations?: PaperAllocationDto[];
}
export interface PaperTradeDto {
id: number;
symbol: string;
side: 'BUY' | 'SELL';
orderKind: string;
source: string;
strategyId?: number | null;
candleType?: string | null;
strategyName?: string | null;
executionType?: string | null;
price: number;
quantity: number;
grossAmount: number;
feeAmount: number;
netAmount: number;
cashAfter: number;
realizedPnlDelta?: number | null;
positionQtyAfter?: number | null;
createdAt?: string | null;
}
export interface PaperPageDto<T> {
content: T[];
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export interface PaperLedgerEntryDto {
id: number;
entryType: string;
amount: number;
cashBefore: number;
cashAfter: number;
refTradeId?: number | null;
symbol?: string | null;
createdAt?: string | null;
}
export interface PaperDailySnapshotDto {
snapshotDate: string;
cashBalance: number;
stockEvalAmount: number;
totalAsset: number;
dailyPnl: number;
dailyReturnPct: number;
cumulativeReturnPct: number;
}
export interface PaperOrderDto {
id: number;
symbol: string;
side: 'BUY' | 'SELL';
orderType: string;
limitPrice?: number | null;
quantity: number;
filledQuantity: number;
status: string;
reservedCash: number;
reservedQty: number;
orderKind: string;
source: string;
strategyId?: number | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PaperPlaceOrderResult {
trade?: PaperTradeDto | null;
order?: PaperOrderDto | null;
}
export interface PaperAllocationBulkItem {
symbol: string;
koreanName?: string;
maxInvestKrw?: number;
budgetPct?: number;
strategyId?: number | null;
isActive?: boolean;
pinned?: boolean;
sortOrder?: number;
buyStage1Krw?: number;
buyStage2Krw?: number;
buyStage3Krw?: number;
sellStage1Krw?: number;
sellStage2Krw?: number;
sellStage3Krw?: number;
}
export interface PaperOrderRequest {
market: string;
side: 'BUY' | 'SELL';
orderKind?: 'limit' | 'market';
orderType?: 'MARKET' | 'LIMIT';
price: number;
quantity: number;
/** 수량 0일 때 매수 예산 비율 (가용현금 %) */
budgetPct?: number;
/** 수량 0일 때 매수 고정 금액 (KRW) */
krwAmount?: number;
source?: string;
strategyId?: number | null;
}
export async function loadPaperSummary(markPrices?: Record<string, number>): Promise<PaperSummaryDto | null> {
if (!hasRegisteredUser()) return null;
if (markPrices && Object.keys(markPrices).length > 0) {
return request<PaperSummaryDto>('/paper/summary', {
method: 'POST',
body: JSON.stringify(markPrices),
});
}
return request<PaperSummaryDto>('/paper/summary');
}
export async function loadPaperTrades(): Promise<PaperTradeDto[]> {
return (await request<PaperTradeDto[]>('/paper/trades')) ?? [];
}
export async function loadPaperTradesPaged(params?: {
symbol?: string;
side?: string;
source?: string;
from?: string;
to?: string;
page?: number;
size?: number;
}): Promise<PaperPageDto<PaperTradeDto>> {
const q = new URLSearchParams();
if (params?.symbol) q.set('symbol', params.symbol);
if (params?.side) q.set('side', params.side);
if (params?.source) q.set('source', params.source);
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
if (params?.page != null) q.set('page', String(params.page));
if (params?.size != null) q.set('size', String(params.size));
const qs = q.toString();
return (await request<PaperPageDto<PaperTradeDto>>(`/paper/trades${qs ? `?${qs}` : ''}`)) ?? {
content: [], page: 0, size: 50, totalElements: 0, totalPages: 0,
};
}
export async function loadPaperAllocations(): Promise<PaperAllocationDto[]> {
return (await request<PaperAllocationDto[]>('/paper/allocations')) ?? [];
}
export async function putPaperAllocationsBulk(items: PaperAllocationBulkItem[]): Promise<PaperAllocationDto[]> {
return (await request<PaperAllocationDto[]>('/paper/allocations/bulk', {
method: 'PUT',
body: JSON.stringify({ items }),
})) ?? [];
}
export async function patchPaperAllocation(
symbol: string,
body: Partial<Pick<PaperAllocationDto,
| 'maxInvestKrw' | 'budgetPct' | 'strategyId' | 'isActive' | 'pinned' | 'sortOrder' | 'koreanName'
| 'buyStage1Krw' | 'buyStage2Krw' | 'buyStage3Krw' | 'sellStage1Krw' | 'sellStage2Krw' | 'sellStage3Krw'>>,
): Promise<PaperAllocationDto | null> {
return request<PaperAllocationDto>(`/paper/allocations/${encodeURIComponent(symbol)}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
}
export async function loadPaperLedger(params?: {
from?: string;
to?: string;
page?: number;
size?: number;
}): Promise<PaperPageDto<PaperLedgerEntryDto>> {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
if (params?.page != null) q.set('page', String(params.page));
if (params?.size != null) q.set('size', String(params.size));
const qs = q.toString();
return (await request<PaperPageDto<PaperLedgerEntryDto>>(`/paper/ledger${qs ? `?${qs}` : ''}`)) ?? {
content: [], page: 0, size: 50, totalElements: 0, totalPages: 0,
};
}
export async function loadPaperDailySnapshots(from?: string, to?: string): Promise<PaperDailySnapshotDto[]> {
const q = new URLSearchParams();
if (from) q.set('from', from);
if (to) q.set('to', to);
const qs = q.toString();
return (await request<PaperDailySnapshotDto[]>(`/paper/snapshots/daily${qs ? `?${qs}` : ''}`)) ?? [];
}
export async function loadPaperPendingOrders(): Promise<PaperOrderDto[]> {
return (await request<PaperOrderDto[]>('/paper/orders')) ?? [];
}
export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperPlaceOrderResult | null> {
return request<PaperPlaceOrderResult>('/paper/orders', {
method: 'POST',
body: JSON.stringify(body),
});
}
export async function cancelPaperOrder(orderId: number): Promise<PaperOrderDto | null> {
return request<PaperOrderDto>(`/paper/orders/${orderId}/cancel`, { method: 'POST' });
}
export async function resetPaperAccount(): Promise<PaperSummaryDto | null> {
return request<PaperSummaryDto>('/paper/reset', { method: 'POST' });
}
/** 매매 시그널 이력 DTO */
export interface TradeSignalDto {
id: number;
market: string;
strategyId: number | null;
strategyName: string | null;
signalType: 'BUY' | 'SELL';
price: number;
candleTime: number;
candleType: string;
executionType: string;
createdAt: string;
}
/** 매매 시그널 이력 조회 */
export async function loadTradeSignals(market?: string): Promise<TradeSignalDto[]> {
const q = market ? `?market=${encodeURIComponent(market)}` : '';
return (await request<TradeSignalDto[]>(`/trade-signals${q}`)) ?? [];
}
/** 매매 시그널 단건 삭제 */
export async function deleteTradeSignal(id: number): Promise<void> {
await requestOrThrow<void>(`/trade-signals/${id}`, { method: 'DELETE' });
}
/** 매매 시그널 ID 목록 일괄 삭제 */
export async function deleteTradeSignalsBatch(ids: number[]): Promise<number> {
if (ids.length === 0) return 0;
const res = await requestOrThrow<{ deleted?: number }>('/trade-signals/delete-batch', {
method: 'POST',
body: JSON.stringify(ids),
});
return res?.deleted ?? ids.length;
}
/** 매매 시그널 이력 전체 삭제 */
export async function deleteAllTradeSignals(): Promise<number> {
const res = await requestOrThrow<{ deleted?: number }>('/trade-signals', { method: 'DELETE' });
return res?.deleted ?? 0;
}
/**
* 앱 시작 시 기본 설정 로드.
* DB 에 없으면 백엔드 엔티티 기본값(KRW-BTC / 1D / dark 등)을 반환.
*/
export async function loadAppSettings(): Promise<AppSettingsDto> {
return (await request<AppSettingsDto>('/app-settings')) ?? {};
}
/**
* 기본 설정 저장.
* 변경된 필드만 포함한 객체를 전달 — 없는 키는 기존 값 유지.
*
* 호출 시점:
* - 테마 변경 → { defaultTheme }
* - 레이아웃 변경 → { defaultLayoutId }
* - 캔들 색상 변경 → { mainChartStyle }
* - 동기화 옵션 변경 → { syncOptions }
* - 기본 심볼/타임프레임 변경 → { defaultSymbol, defaultTimeframe }
*/
export async function saveAppSettings(data: AppSettingsDto): Promise<AppSettingsDto> {
return (await request<AppSettingsDto>('/app-settings', {
method: 'PUT',
body: JSON.stringify(data),
})) ?? data;
}
// ─────────────────────────────────────────────────────────────────────────────
// 인증 API
// ─────────────────────────────────────────────────────────────────────────────
export interface LoginResponse {
userId: number;
username: string;
displayName: string;
role?: string;
}
export interface MenuPermissionsResponse {
role: string;
permissions: Record<string, boolean>;
}
export interface UserDto {
id: number;
username: string;
displayName: string;
role: string;
enabled: boolean;
createdAt?: string | null;
}
export async function fetchMenuPermissions(): Promise<MenuPermissionsResponse> {
const res = await request<MenuPermissionsResponse>('/auth/menu-permissions');
return res ?? { role: 'GUEST', permissions: { chart: true, settings: true } };
}
export async function verifyAdminPassword(password: string): Promise<void> {
await requestOrThrow<{ ok: boolean }>('/admin/verify-password', {
method: 'POST',
body: JSON.stringify({ password }),
});
}
export async function listAdminUsers(): Promise<UserDto[]> {
return (await request<UserDto[]>('/admin/users')) ?? [];
}
export async function createAdminUser(data: {
username: string;
password: string;
displayName?: string;
role: string;
enabled?: boolean;
}): Promise<UserDto> {
return requestOrThrow<UserDto>('/admin/users', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateAdminUser(
id: number,
data: { password?: string; displayName?: string; role?: string; enabled?: boolean },
): Promise<UserDto> {
return requestOrThrow<UserDto>(`/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteAdminUser(id: number): Promise<void> {
await requestOrThrow<void>(`/admin/users/${id}`, { method: 'DELETE' });
}
export async function fetchRolePermissions(): Promise<Record<string, Record<string, boolean>>> {
return (await request<Record<string, Record<string, boolean>>>('/admin/role-permissions')) ?? {};
}
export async function saveRolePermissions(
role: string,
permissions: Record<string, boolean>,
): Promise<void> {
await requestOrThrow<void>('/admin/role-permissions', {
method: 'PUT',
body: JSON.stringify({ role, permissions }),
});
}
export async function loginUser(username: string, password: string): Promise<LoginResponse> {
return requestOrThrow<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
export async function fetchAuthMe(): Promise<LoginResponse | null> {
return request<LoginResponse>('/auth/me');
}
// ─────────────────────────────────────────────────────────────────────────────
// 투자전략 API
// ─────────────────────────────────────────────────────────────────────────────
export interface StrategyDto {
id?: number;
name: string;
description?: string;
buyCondition?: unknown; // LogicNode DSL
sellCondition?: unknown; // LogicNode DSL
/** 편집기 캔버스·START 분봉·고아 노드 (gc_strategy.flow_layout_json) */
flowLayout?: StrategyFlowLayoutStore;
enabled?: boolean;
createdAt?: string;
updatedAt?: string;
}
/** 전략 목록 로드 + localStorage·설정의 stale strategyId 정리 */
export async function loadStrategies(): Promise<StrategyDto[]> {
if (!hasRegisteredUser()) {
console.warn('[backendApi] loadStrategies: hasRegisteredUser=false (gc_user_id=%s)', localStorage.getItem('gc_user_id'));
return [];
}
const list = (await request<StrategyDto[]>('/strategies')) ?? [];
console.debug('[backendApi] loadStrategies: %d건 반환', list.length);
try {
const { applyStrategyReferenceSanitize } = await import('./strategyReferenceSanitize');
await applyStrategyReferenceSanitize(list);
} catch (e) {
console.warn('[backendApi] strategy reference sanitize failed', e);
}
return list;
}
/** 단일 전략 로드 */
export async function loadStrategy(id: number): Promise<StrategyDto | null> {
if (!hasRegisteredUser() || id <= 0 || isStrategyMissingOnServer(id)) return null;
return request<StrategyDto>(`/strategies/${id}`);
}
/** 알림·지표 표시 — missing 캐시 적용, 성공 시 캐시 해제 */
export async function loadStrategyForNotification(id: number): Promise<StrategyDto | null> {
if (!hasRegisteredUser() || id <= 0 || isStrategyMissingOnServer(id)) return null;
const dto = await request<StrategyDto>(`/strategies/${id}`);
if (dto) unmarkStrategyMissingOnServer(id);
return dto;
}
/** 전략 저장 (id 없으면 신규, 있으면 수정) — 실패 시 Error throw */
export async function saveStrategy(dto: StrategyDto): Promise<StrategyDto> {
const res = await fetch(`${API_BASE}/strategies`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(dto),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
const hint =
res.status === 404
? '서버에 투자전략 API(/api/strategies)가 없습니다. 백엔드를 최신으로 배포했는지 확인하세요.'
: res.status === 502 || res.status === 503
? '백엔드에 연결할 수 없습니다. Docker·nginx 상태를 확인하세요.'
: `저장 실패 (HTTP ${res.status})`;
console.error(`[backendApi] POST /strategies → ${res.status}`, body);
throw new Error(body ? `${hint}${body.slice(0, 160)}` : hint);
}
return (await res.json()) as StrategyDto;
}
/** 전략 삭제 */
export async function deleteStrategy(id: number): Promise<void> {
await request(`/strategies/${id}`, { method: 'DELETE' });
}
// ─────────────────────────────────────────────────────────────────────────────
// 백테스팅 API
// ─────────────────────────────────────────────────────────────────────────────
export interface BacktestSignal {
time: number;
type: 'BUY' | 'SELL' | 'SHORT_ENTRY' | 'SHORT_EXIT' | 'PARTIAL_SELL';
price: number;
barIndex: number;
/** 체결 수량 (코인/주식 단위) */
quantity?: number;
}
export interface BacktestStats {
totalSignals: number;
buySignals: number;
sellSignals: number;
totalTrades: number;
winTrades: number;
winRate: number;
totalReturn: number;
maxDrawdown: number;
avgReturn: number;
finalEquity: number;
}
/** Ta4j AnalysisCriterion 전체 계산 결과 */
export interface BacktestAnalysis {
initialCapital: number;
finalEquity: number;
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
cashBalance?: number;
holdingsValue?: number;
realizedPnl?: number;
unrealizedPnl?: number;
totalReturnPct: number;
totalProfitLoss: number;
grossProfit: number;
grossLoss: number;
avgReturnPct: number;
profitLossRatio: number;
numberOfPositions: number;
numberOfWinning: number;
numberOfLosing: number;
numberOfBreakEven: number;
winRate: number;
maxDrawdownPct: number;
maxRunupPct: number;
sharpeRatio: number;
sortinoRatio: number;
calmarRatio: number;
valueAtRisk95: number;
expectedShortfall: number;
buyAndHoldReturnPct: number;
vsBuyAndHold: number;
}
export interface BacktestResponse {
signals: BacktestSignal[];
stats: BacktestStats;
analysis?: BacktestAnalysis;
resultId?: number;
/** 실행에 사용된 시간봉 */
timeframe?: string;
symbol?: string;
}
/** DB 저장 이력 레코드 */
export interface BacktestResultRecord {
id: number;
deviceId?: string;
strategyId?: number;
strategyName: string;
symbol: string;
timeframe: string;
barCount: number;
fromTime: number;
toTime: number;
settingsJson: string;
executionSnapshotJson?: string;
signalsJson: string;
analysisJson: string;
totalReturn: number;
winRate: number;
totalTrades: number;
maxDrawdown: number;
sharpeRatio: number;
finalEquity: number;
createdAt: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// 백테스팅 설정 DTO
// ─────────────────────────────────────────────────────────────────────────────
export interface BacktestSettingsDto {
id?: number;
initialCapital: number;
commissionType: 'LINEAR' | 'ZERO';
commissionRate: number;
slippageRate: number;
entryPriceType: 'CLOSE' | 'NEXT_OPEN';
exitPriceType: 'CLOSE' | 'NEXT_OPEN';
positionDirection: 'LONG' | 'SHORT' | 'BOTH';
tradeSizeType: 'CAPITAL_PCT' | 'FIXED_AMOUNT';
tradeSizeValue: number;
stopLossEnabled: boolean;
stopLossPct: number;
takeProfitEnabled: boolean;
takeProfitPct: number;
trailingStopEnabled: boolean;
trailingStopPct: number;
reentryWaitBars: number;
maxOpenTrades: number;
partialExitEnabled: boolean;
partialExitPct: number;
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 모드 */
positionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY';
/** MARK_TO_MARKET | REALIZED_ONLY — 투자분석 방식 */
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
/** SCAN_SIGNALS | BACKTEST_ENGINE — 매매 체결·시그널 생성 방식 */
tradeExecutionMode?: 'SCAN_SIGNALS' | 'BACKTEST_ENGINE';
}
export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
initialCapital: 10_000_000,
commissionType: 'LINEAR',
commissionRate: 0.0015,
slippageRate: 0.0005,
entryPriceType: 'CLOSE',
exitPriceType: 'CLOSE',
positionDirection: 'LONG',
tradeSizeType: 'CAPITAL_PCT',
tradeSizeValue: 100,
stopLossEnabled: false,
stopLossPct: 2,
takeProfitEnabled: false,
takeProfitPct: 5,
trailingStopEnabled: false,
trailingStopPct: 2,
reentryWaitBars: 0,
maxOpenTrades: 1,
partialExitEnabled: false,
partialExitPct: 50,
positionMode: 'LONG_ONLY',
analysisMethod: 'MARK_TO_MARKET',
tradeExecutionMode: 'BACKTEST_ENGINE',
};
/** 백테스팅 설정 로드 */
export async function loadBacktestSettings(): Promise<BacktestSettingsDto> {
return (await request<BacktestSettingsDto>('/backtest-settings')) ?? DEFAULT_BACKTEST_SETTINGS;
}
/** 백테스팅 설정 저장 */
export async function saveBacktestSettings(dto: BacktestSettingsDto): Promise<BacktestSettingsDto | null> {
return request<BacktestSettingsDto>('/backtest-settings', {
method: 'POST',
body: JSON.stringify(dto),
});
}
export interface BacktestRequest {
strategyId?: number;
buyCondition?: unknown;
sellCondition?: unknown;
bars: { time: number; open: number; high: number; low: number; close: number; volume: number }[];
timeframe: string;
indicatorParams?: Record<string, Record<string, unknown>>;
settings?: BacktestSettingsDto;
symbol?: string;
strategyName?: string;
deviceId?: string;
/** bars 마지막 N봉만 시그널 평가 (앞쪽은 지표 워밍업) */
evaluationBarCount?: number;
}
/** 백테스팅 실행 */
export async function runBacktest(req: BacktestRequest): Promise<BacktestResponse | null> {
return request<BacktestResponse>('/backtesting/run', {
method: 'POST',
body: JSON.stringify({ ...req, deviceId: getDeviceId() }),
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 백테스팅 결과 이력 API
// ─────────────────────────────────────────────────────────────────────────────
/** 결과 이력 목록 로드 */
export async function loadBacktestResults(): Promise<BacktestResultRecord[]> {
return (await request<BacktestResultRecord[]>(
`/backtest-results?deviceId=${encodeURIComponent(getDeviceId())}`
)) ?? [];
}
/** 결과 단건 조회 */
export async function loadBacktestResult(id: number): Promise<BacktestResultRecord | null> {
return request<BacktestResultRecord>(`/backtest-results/${id}`);
}
/** 결과 삭제 */
export async function deleteBacktestResult(id: number): Promise<void> {
await request(`/backtest-results/${id}`, { method: 'DELETE' });
}
/** 모의·가상투자 체결 이력 일괄 삭제 (실시간 매매 목록 그룹 제거) */
export async function deletePaperTradesBatch(ids: number[]): Promise<number> {
if (ids.length === 0) return 0;
const res = await request<{ deleted?: number }>('/paper/trades/batch', {
method: 'DELETE',
body: JSON.stringify({ ids }),
});
return res?.deleted ?? 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// 실시간 전략 체크 설정 API
// ─────────────────────────────────────────────────────────────────────────────
export interface LiveStrategySettingsDto {
market: string;
strategyId: number | null;
isLiveCheck: boolean;
/** "CANDLE_CLOSE" | "REALTIME_TICK" */
executionType: string;
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 모드 */
positionMode?: string;
/** @deprecated 전략 DSL에서 시간봉을 자동 추출합니다 */
candleType?: string;
/** 전략 조건 DSL에 포함된 평가 시간봉 (응답 전용) */
strategyCandleTypes?: string[];
/** true면 관심종목 동기화 생략 (가상투자 per-market) */
skipWatchlistSync?: boolean;
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
skipGlobalTemplate?: boolean;
/** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 */
isPinned?: boolean;
}
/** 실시간 전략 체크 설정 로드 */
export async function loadLiveStrategySettings(
market: string,
): Promise<LiveStrategySettingsDto> {
if (!hasRegisteredUser()) {
return { market, strategyId: null, isLiveCheck: false, executionType: 'CANDLE_CLOSE', positionMode: 'LONG_ONLY' };
}
return (await request<LiveStrategySettingsDto>(
`/strategy/settings?market=${encodeURIComponent(market)}`,
)) ?? { market, strategyId: null, isLiveCheck: false, executionType: 'CANDLE_CLOSE', positionMode: 'LONG_ONLY' };
}
/** 실시간 전략 체크 설정 저장 */
export async function saveLiveStrategySettings(
dto: LiveStrategySettingsDto,
): Promise<LiveStrategySettingsDto | null> {
return requestOrThrow<LiveStrategySettingsDto>('/strategy/settings', {
method: 'PUT',
body: JSON.stringify(dto),
});
}
/** 실시간 전략 STOMP 구독 목록 (종목 × 전략 DSL 시간봉) */
export function expandLiveStrategySubscriptions(
list: LiveStrategySettingsDto[],
): { market: string; candleType: string }[] {
return list.flatMap(s => {
const types = s.strategyCandleTypes?.length ? s.strategyCandleTypes : ['1m'];
return types.map(candleType => ({ market: s.market, candleType }));
});
}
/** 현재 디바이스에서 실시간 체크 ON + 전략 지정된 종목 목록 */
export async function loadActiveLiveStrategySettings(): Promise<LiveStrategySettingsDto[]> {
if (!hasRegisteredUser()) return [];
return (await request<LiveStrategySettingsDto[]>('/strategy/settings/active')) ?? [];
}
/** 관심종목 등 여러 마켓에 동일 실시간 전략 설정 일괄 적용 */
export async function saveLiveStrategySettingsBulk(
markets: string[],
template: Omit<LiveStrategySettingsDto, 'market'>,
): Promise<LiveStrategySettingsDto[]> {
const list = (await request<LiveStrategySettingsDto[]>('/strategy/settings/bulk', {
method: 'PUT',
body: JSON.stringify({ markets, template: { ...template, market: markets[0] ?? 'KRW-BTC' } }),
})) ?? [];
return list;
}
// ─────────────────────────────────────────────────────────────────────────────
// 가상투자 — 실시간 조건 충족 현황 (Ta4j Rule.isSatisfied)
// ─────────────────────────────────────────────────────────────────────────────
export interface LiveConditionRowDto {
id: string;
indicatorType: string;
displayName: string;
conditionType: string;
conditionLabel: string;
thresholdLabel: string;
currentValue: number | null;
targetValue: number | null;
satisfied: boolean | null;
timeframe: string;
side: 'buy' | 'sell';
plotKey?: string;
}
export interface LiveConditionStatusDto {
market: string;
strategyId: number;
timeframe: string;
matchRate: number;
rows: LiveConditionRowDto[];
updatedAt: number;
barTimeSec?: number | null;
evalBarIndex?: number | null;
overallEntryMet?: boolean | null;
overallExitMet?: boolean | null;
}
/** 백엔드 Ta4j 조건 평가 — 실시간 또는 barTimeSec 지정 봉 */
export async function fetchLiveConditionStatus(
market: string,
strategyId: number,
barTimeSec?: number | null,
indicatorParams?: Record<string, Record<string, unknown>> | null,
chartBars?: Array<{
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
}> | null,
chartTimeframe?: string | null,
): Promise<LiveConditionStatusDto | null> {
const normalizedBarTime = barTimeSec != null && Number.isFinite(barTimeSec)
? Math.floor(barTimeSec)
: null;
const useChartBars = chartBars != null && chartBars.length > 0 && chartTimeframe;
if (useChartBars || (indicatorParams && Object.keys(indicatorParams).length > 0)) {
return request<LiveConditionStatusDto>('/strategy/live-conditions/evaluate', {
method: 'POST',
cache: 'no-store',
body: JSON.stringify({
market,
strategyId,
barTimeSec: normalizedBarTime,
indicatorParams: indicatorParams ?? undefined,
bars: useChartBars
? chartBars.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
}))
: undefined,
timeframe: useChartBars ? chartTimeframe : undefined,
}),
});
}
const params = new URLSearchParams({
market,
strategyId: String(strategyId),
});
if (normalizedBarTime != null) {
params.set('barTimeSec', String(normalizedBarTime));
}
return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, {
cache: 'no-store',
});
}
/** 전략 평가 — 차트 마커용 시그널 스캔 (live-conditions 와 동일 Rule) */
export async function fetchLiveConditionScanSignals(
market: string,
strategyId: number,
chartBars: Array<{
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
}>,
chartTimeframe: string,
indicatorParams?: Record<string, Record<string, unknown>> | null,
evaluationBarCount?: number | null,
positionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY' | null,
): Promise<BacktestSignal[]> {
if (chartBars.length === 0 || !chartTimeframe) return [];
const list = await requestOrThrow<BacktestSignal[]>('/strategy/live-conditions/scan-signals', {
method: 'POST',
cache: 'no-store',
body: JSON.stringify({
market,
strategyId,
indicatorParams: indicatorParams ?? undefined,
bars: chartBars.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
timeframe: chartTimeframe,
evaluationBarCount: evaluationBarCount ?? undefined,
positionMode: positionMode ?? undefined,
}),
});
return list ?? [];
}
export interface StrategyEvaluationAiVerifyResponse {
analysis: string;
model: string;
latencyMs: number;
/** 서버 Ta4j 재평가·Rule·trace (백엔드 ai-verify 재평가) */
ta4jDiagnostics?: Record<string, unknown> | null;
}
export type StrategyEvaluationAiVerifyJobStatus =
| 'QUEUED'
| 'RUNNING'
| 'COMPLETED'
| 'FAILED';
export interface StrategyEvaluationAiVerifyJobStartResponse {
jobId: string;
status: StrategyEvaluationAiVerifyJobStatus;
}
export interface StrategyEvaluationAiVerifyJobStatusResponse {
jobId: string;
status: StrategyEvaluationAiVerifyJobStatus;
error?: string | null;
result?: StrategyEvaluationAiVerifyResponse | null;
createdAtMs: number;
completedAtMs?: number | null;
}
/** 전략 평가 — AI 검증 비동기 job 시작 (즉시 jobId 반환) */
export async function startStrategyEvaluationAiVerifyJob(
context: Record<string, unknown>,
): Promise<StrategyEvaluationAiVerifyJobStartResponse> {
return requestOrThrow<StrategyEvaluationAiVerifyJobStartResponse>(
'/strategy/evaluation/ai-verify/jobs',
{
method: 'POST',
cache: 'no-store',
body: JSON.stringify({ context }),
},
);
}
/** AI 검증 job 상태 조회 */
export async function fetchStrategyEvaluationAiVerifyJob(
jobId: string,
): Promise<StrategyEvaluationAiVerifyJobStatusResponse> {
return requestOrThrow<StrategyEvaluationAiVerifyJobStatusResponse>(
`/strategy/evaluation/ai-verify/jobs/${encodeURIComponent(jobId)}`,
{ cache: 'no-store' },
);
}
/** 전략 평가 — LLM 기반 Rule·시그널 일치 검증 (레거시 동기) */
export async function fetchStrategyEvaluationAiVerify(
context: Record<string, unknown>,
): Promise<StrategyEvaluationAiVerifyResponse> {
return requestOrThrowWithTimeout<StrategyEvaluationAiVerifyResponse>(
'/strategy/evaluation/ai-verify',
{
method: 'POST',
cache: 'no-store',
body: JSON.stringify({ context }),
},
620_000,
);
}
export interface LlmConnectionTestResponse {
ok: boolean;
message: string;
model?: string;
latencyMs?: number;
}
/** 설정 화면 — LLM 서버 연결 테스트 (저장된 설정 기준) */
export async function fetchLlmConnectionTest(): Promise<LlmConnectionTestResponse> {
return requestOrThrowWithTimeout<LlmConnectionTestResponse>(
'/strategy/evaluation/llm-test',
{
method: 'POST',
cache: 'no-store',
},
620_000,
);
}
/** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
if (!hasRegisteredUser()) return ['1m'];
const list = await request<string[]>(`/strategies/${strategyId}/timeframes`);
return list?.length ? list : ['1m'];
}
const repairSkipIds = new Set<number>();
const repairInflight = new Set<number>();
/** DB DSL TIMEFRAME 래핑 보정 — 전략 없으면 호출 안 함(404 방지) */
export async function repairStrategyTimeframes(strategyId: number): Promise<boolean> {
if (!hasRegisteredUser() || strategyId <= 0 || isStrategyMissingOnServer(strategyId)) return false;
if (repairSkipIds.has(strategyId) || repairInflight.has(strategyId)) return false;
repairInflight.add(strategyId);
try {
const exists = await loadStrategy(strategyId);
if (!exists) {
repairSkipIds.add(strategyId);
return false;
}
const ok = await request<StrategyDto>(
`/strategies/${strategyId}/repair-timeframes`,
{ method: 'POST' },
);
if (!ok) repairSkipIds.add(strategyId);
return ok != null;
} finally {
repairInflight.delete(strategyId);
}
}
/** 차트 실시간 파이프라인 pin (STOMP warm-up) */
export async function pinCandleWatch(market: string, candleType: string): Promise<void> {
try {
await fetch(
`${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`,
{ method: 'POST', headers: authHeaders() },
);
} catch {
/* pin 실패해도 평가 API는 시도 */
}
}
// ── 추세검색 ─────────────────────────────────────────────────────────────────
export interface TrendSearchConditionDto {
id: string;
category: string;
label: string;
currentValue: number | null;
thresholdLabel: string;
progress: number;
status: 'match' | 'pending' | 'mismatch';
}
export interface TrendSearchResultDto {
market: string;
koreanName: string;
currentPrice: number;
changeRate: number;
matchRate: number;
matchedCount: number;
totalConditions: number;
highMatch: boolean;
conditions: TrendSearchConditionDto[];
}
export interface TrendSearchRequest {
timeframe: string;
limit: number;
scanLimit: number;
sortBy: string;
minMatchRate?: number;
/** 상승추세 검색그룹 (가중 점수화) */
bullishTrendEnabled: boolean;
weightMaAlignment: number;
weightMaSlope: number;
weightAdxTrend: number;
weightMacdMomentum: number;
weightPricePosition: number;
minTrendScore: number;
emaSlopeLookback: number;
adxTrendMin: number;
maAlignEnabled: boolean;
trendEnabled: boolean;
volumePowerEnabled: boolean;
indicatorEnabled: boolean;
priceAboveMa: boolean;
maAlignment: boolean;
maConvergence: boolean;
maConvergencePct: number;
ma20SlopeUp: boolean;
ma20SlopeBars: number;
newHighBreakout: boolean;
newHighBreakoutDays: number;
newHighNearPct: number;
ichimokuAboveCloud: boolean;
volumeVsPrior200: boolean;
volumeVsPriorPct: number;
minTurnover5dAvg: boolean;
minTurnover5dAvgKrw: number;
volMa5Over20: boolean;
macdGoldenCross: boolean;
rsiBand: boolean;
rsiMin: number;
rsiMax: number;
dmiBullish: boolean;
adxMin: number;
}
export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
timeframe: '1d',
limit: 20,
scanLimit: 60,
sortBy: 'matchRate',
minMatchRate: 0,
bullishTrendEnabled: true,
weightMaAlignment: 30,
weightMaSlope: 15,
weightAdxTrend: 25,
weightMacdMomentum: 15,
weightPricePosition: 15,
minTrendScore: 70,
emaSlopeLookback: 5,
adxTrendMin: 20,
maAlignEnabled: false,
trendEnabled: false,
volumePowerEnabled: false,
indicatorEnabled: false,
priceAboveMa: false,
maAlignment: false,
maConvergence: false,
maConvergencePct: 3,
ma20SlopeUp: false,
ma20SlopeBars: 2,
newHighBreakout: false,
newHighBreakoutDays: 20,
newHighNearPct: 5,
ichimokuAboveCloud: false,
volumeVsPrior200: false,
volumeVsPriorPct: 150,
minTurnover5dAvg: false,
minTurnover5dAvgKrw: 1_000_000_000,
volMa5Over20: false,
macdGoldenCross: false,
rsiBand: false,
rsiMin: 50,
rsiMax: 75,
dmiBullish: false,
adxMin: 18,
};
export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const;
export interface TrendSearchResultsResponse {
results: TrendSearchResultDto[];
timeframe?: string;
scannedAt?: string;
}
export async function scanTrendSearch(body: TrendSearchRequest): Promise<TrendSearchResultDto[]> {
return (await request<TrendSearchResultDto[]>('/trend-search/scan', {
method: 'POST',
body: JSON.stringify(body),
})) ?? [];
}
export async function fetchTrendSearchResults(): Promise<TrendSearchResultsResponse> {
return (await request<TrendSearchResultsResponse>('/trend-search/results')) ?? { results: [] };
}
export async function fetchTrendSearchDetail(
market: string,
timeframe?: string,
): Promise<TrendSearchResultDto | null> {
const params = new URLSearchParams({ market });
if (timeframe) params.set('timeframe', timeframe);
return request<TrendSearchResultDto>(`/trend-search/detail?${params}`);
}