Files
goldenChart/packages/shared/src/api/backendApi.ts
T
2026-06-12 14:39:17 +09:00

1478 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GoldenChart Backend API 클라이언트.
* Spring Boot 백엔드와 통신한다.
*
* 환경변수 VITE_API_BASE_URL 로 백엔드 URL 설정 (기본: http://localhost:8080/api)
*/
import { storageGetSync, storageSetSync } from '../storage/index.js';
/** Flow layout stored with strategy (matches frontend StrategyFlowLayoutStore). */
export type StrategyFlowLayoutStore = Record<string, unknown>;
/** 배포 APK·exdev 기본 (HTTPS 443 미개방 — 반드시 http) */
export const PRODUCTION_API_BASE = 'http://exdev.co.kr/api';
function viteEnv(): Record<string, string> | undefined {
return typeof import.meta !== 'undefined'
? (import.meta as ImportMeta & { env?: Record<string, string> }).env
: undefined;
}
/** 저장된 API URL 정규화 — https exdev·localhost 제거 */
export function normalizeApiBaseUrl(url: string | null | undefined): string | null {
if (!url?.trim()) return null;
let u = url.trim().replace(/\/$/, '');
if (/localhost|127\.0\.0\.1/i.test(u)) return null;
if (/^https:\/\/exdev\.co\.kr/i.test(u)) {
u = u.replace(/^https:/i, 'http:');
}
return u;
}
function resolveApiBase(): string {
const env = viteEnv();
const fromStorage = normalizeApiBaseUrl(storageGetSync('gc_api_base_url'));
const fromEnv = normalizeApiBaseUrl(env?.VITE_API_BASE_URL);
if (fromStorage) return fromStorage;
if (fromEnv) return fromEnv;
if (env?.PROD) return PRODUCTION_API_BASE;
return 'http://localhost:8080/api';
}
/** 백엔드 REST base */
export let API_BASE = resolveApiBase();
export function setApiBase(url: string): void {
API_BASE = url.replace(/\/$/, '');
storageSetSync('gc_api_base_url', API_BASE);
}
/** initStorage() 이후 Preferences에 저장된 API 주소를 반영 */
export function refreshApiBaseFromStorage(): void {
API_BASE = resolveApiBase();
}
/** STOMP WebSocket 연결에 사용할 HTTP base URL (api 경로 제거) */
export function getApiBase(): string {
return API_BASE.replace(/\/api$/, '');
}
/**
* STOMP SockJS 엔드포인트.
* Spring Boot context-path가 /api 이므로 반드시 /api/ws/trading 경로를 사용한다.
*/
export function getStompSockJsUrl(): string {
const base = API_BASE.replace(/\/$/, '');
return base.endsWith('/api') ? `${base}/ws/trading` : `${base}/api/ws/trading`;
}
// 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 (Preferences / localStorage 영속)
function getDeviceId(): string {
let id = storageGetSync('gc_device_id');
if (!id) {
id = generateUUID();
storageSetSync('gc_device_id', id);
}
return id;
}
export function getStoredDeviceId(): string {
return getDeviceId();
}
function authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-device-id': getDeviceId(),
};
const userId = storageGetSync('gc_user_id');
if (userId) headers['x-user-id'] = userId;
return headers;
}
const DEFAULT_FETCH_TIMEOUT_MS = 45_000;
/** exdev 첫 로그인 응답이 60초 이상 걸리는 경우 있음 */
const LOGIN_FETCH_TIMEOUT_MS = 180_000;
function wrapNetworkError(e: unknown): Error {
if (e instanceof TypeError || (e instanceof Error && /failed to fetch/i.test(e.message))) {
return new Error(
`서버에 연결할 수 없습니다 (${API_BASE}). Wi‑Fi·API 주소·앱 재설치 후 설정의 API URL을 확인하세요.`,
);
}
return e instanceof Error ? e : new Error(String(e));
}
async function fetchWithTimeout(
url: string,
init: RequestInit | undefined,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
headers: { ...authHeaders(), ...(init?.headers ?? {}) },
});
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error(
`서버 응답 시간 초과 (${Math.round(timeoutMs / 1000)}초). ${API_BASE} · WiFi를 확인하세요.`,
);
}
throw wrapNetworkError(e);
} finally {
clearTimeout(timer);
}
}
/** 로그인 API 오류 시 메시지 throw */
async function requestOrThrow<T>(
path: string,
init?: RequestInit,
timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
): Promise<T> {
let res: Response;
try {
res = await fetchWithTimeout(`${API_BASE}${path}`, init, timeoutMs);
} catch (e) {
if (e instanceof Error) throw e;
throw wrapNetworkError(e);
}
const text = await res.text().catch(() => '');
if (!res.ok) {
let msg = `요청 실패 (${res.status})`;
try {
const j = JSON.parse(text) as { message?: string };
if (j.message) msg = j.message;
} catch { /* plain text */ }
if (!text.startsWith('{') && text) msg = text.slice(0, 200);
throw new Error(msg);
}
if (res.status === 204 || !text) return undefined as T;
return JSON.parse(text) as T;
}
async function request<T>(path: string, init?: RequestInit): Promise<T | null> {
try {
const res = await fetchWithTimeout(`${API_BASE}${path}`, init, DEFAULT_FETCH_TIMEOUT_MS);
if (res.status === 204) return null;
if (!res.ok) {
console.error(`[backendApi] ${init?.method ?? 'GET'} ${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[]; cloudColors?: unknown; bandBackground?: unknown }>
> {
return (
(await request<Record<string, { plots?: unknown[]; hlines?: unknown[]; cloudColors?: unknown; bandBackground?: unknown }>>(
'/indicator-settings/visual'
)) ?? {}
);
}
/**
* 특정 지표의 시각 설정(색상·선굵기·수평선)을 저장.
* 사용자가 IndicatorSettingsModal 에서 색상·선굵기를 변경할 때 호출.
*
* @param indicatorType 지표 타입 (e.g. "RSI")
* @param visual { plots: PlotDef[], hlines: HLineDef[] }
*/
export async function saveIndicatorVisualSettings(
indicatorType: string,
visual: { plots?: unknown[]; hlines?: unknown[]; 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;
chartSeriesPriceLabels?: boolean;
/** 차트 하단 거래량 바 표시 (기본 true) */
chartVolumeVisible?: boolean;
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (기본 true) */
chartLiveReceiveHighlight?: 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;
};
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;
/** 가상매매 투자대상 목록 최대 종목 수 */
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;
/** 추세검색 기본 설정 */
trendSearchSettings?: Record<string, unknown> | null;
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
uiPreferences?: Record<string, unknown> | 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 };
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 deleteFcmToken(): Promise<void> {
await request<void>('/fcm/token', { method: 'DELETE' });
}
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 PaperPositionDto {
id?: number;
symbol: string;
koreanName?: string | null;
quantity: number;
avgPrice: number;
markPrice?: number | null;
evalAmount?: number;
profitLoss?: number;
profitLossPct?: number;
}
export interface PaperAllocationDto {
symbol: string;
maxInvestKrw: number;
availableBuyKrw?: number;
strategyId?: number | null;
isActive?: boolean;
}
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;
orderableCash?: number;
reservedCash?: number;
initialCapitalSnapshot?: number;
positions: PaperPositionDto[];
allocations?: PaperAllocationDto[];
}
export interface PaperTradeDto {
id: number;
symbol: string;
side: 'BUY' | 'SELL';
orderKind: string;
source: string;
strategyId?: number | null;
price: number;
quantity: number;
grossAmount: number;
feeAmount: number;
netAmount: number;
cashAfter: number;
realizedPnlDelta?: number | null;
createdAt?: string | null;
}
export interface PaperPlaceOrderResult {
trade?: PaperTradeDto | null;
order?: { id: number; symbol: string; side: string; status: string } | null;
}
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 (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 placePaperOrder(body: PaperOrderRequest): Promise<PaperPlaceOrderResult | null> {
return request<PaperPlaceOrderResult>('/paper/orders', {
method: 'POST',
body: JSON.stringify(body),
});
}
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 }),
},
LOGIN_FETCH_TIMEOUT_MS,
);
}
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;
}
/** 전략 목록 로드 */
export async function loadStrategies(): Promise<StrategyDto[]> {
return (await request<StrategyDto[]>('/strategies')) ?? [];
}
/** 단일 전략 로드 */
export async function loadStrategy(id: number): Promise<StrategyDto | null> {
return request<StrategyDto>(`/strategies/${id}`);
}
/** 전략 저장 (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;
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;
}
/** DB 저장 이력 레코드 */
export interface BacktestResultRecord {
id: number;
deviceId?: string;
strategyId?: number;
strategyName: string;
symbol: string;
timeframe: string;
barCount: number;
fromTime: number;
toTime: number;
settingsJson: 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';
}
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',
};
/** 백테스팅 설정 로드 */
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;
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' });
}
// ─────────────────────────────────────────────────────────────────────────────
// 실시간 전략 체크 설정 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> {
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[]> {
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;
}
/** 백엔드 Ta4j 조건 평가 — 실시간 또는 barTimeSec·지표 파라미터 오버라이드 */
export async function fetchLiveConditionStatus(
market: string,
strategyId: number,
barTimeSec?: number | null,
indicatorParams?: Record<string, Record<string, unknown>> | null,
): Promise<LiveConditionStatusDto | null> {
if (indicatorParams && Object.keys(indicatorParams).length > 0) {
return request<LiveConditionStatusDto>('/strategy/live-conditions/evaluate', {
method: 'POST',
cache: 'no-store',
body: JSON.stringify({
market,
strategyId,
barTimeSec: barTimeSec != null && Number.isFinite(barTimeSec)
? Math.floor(barTimeSec)
: null,
indicatorParams,
}),
});
}
const params = new URLSearchParams({
market,
strategyId: String(strategyId),
});
if (barTimeSec != null && Number.isFinite(barTimeSec)) {
params.set('barTimeSec', String(Math.floor(barTimeSec)));
}
return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, {
cache: 'no-store',
});
}
/** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
const list = await request<string[]>(`/strategies/${strategyId}/timeframes`);
return list?.length ? list : ['1m'];
}
/** DB DSL TIMEFRAME 래핑 보정 (3분봉 전략이 1m으로 평가되는 레거시 수정) */
export async function repairStrategyTimeframes(strategyId: number): Promise<void> {
await request(`/strategies/${strategyId}/repair-timeframes`, { method: 'POST' });
}
/** 차트 실시간 파이프라인 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' },
);
} 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}`);
}