1509 lines
52 KiB
TypeScript
1509 lines
52 KiB
TypeScript
/**
|
||
* GoldenChart Backend API 클라이언트.
|
||
* Spring Boot 백엔드와 통신한다.
|
||
*
|
||
* 환경변수 VITE_API_BASE_URL 로 백엔드 URL 설정 (기본: http://localhost:8080/api)
|
||
*/
|
||
|
||
import { storageGetSync, storageSetSync } from '../storage/index.js';
|
||
import { getRuntimePlatform } from '../platform.js';
|
||
|
||
/** Flow layout stored with strategy (matches frontend StrategyFlowLayoutStore). */
|
||
export type StrategyFlowLayoutStore = Record<string, unknown>;
|
||
|
||
/** 배포 APK·exdev (모바일 APK — http) */
|
||
export const PRODUCTION_API_BASE = 'http://exdev.co.kr/api';
|
||
|
||
/** Tauri desktop — exdev HTTP (HTTPS /api는 Caddy에서 빈 응답) */
|
||
export const DESKTOP_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;
|
||
}
|
||
|
||
/** desktop 빌드 — vite define __DESKTOP_CLIENT__ (localhost API 사용 금지) */
|
||
declare const __DESKTOP_CLIENT__: boolean | undefined;
|
||
|
||
function isDesktopClient(): boolean {
|
||
if (typeof __DESKTOP_CLIENT__ !== 'undefined' && __DESKTOP_CLIENT__) return true;
|
||
return typeof window !== 'undefined' && getRuntimePlatform() === 'desktop';
|
||
}
|
||
|
||
function resolveApiBase(): string {
|
||
if (isDesktopClient()) {
|
||
return DESKTOP_API_BASE;
|
||
}
|
||
|
||
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;
|
||
const platform = typeof window !== 'undefined' ? getRuntimePlatform() : 'web';
|
||
if (platform === 'mobile' || 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;
|
||
|
||
type HttpFetchFn = typeof fetch;
|
||
let httpFetchImpl: HttpFetchFn | null = null;
|
||
|
||
/** Tauri 등 WebView fetch 대신 네이티브 HTTP 사용 (desktop bootstrap에서 호출) */
|
||
export function setHttpFetch(fn: HttpFetchFn): void {
|
||
httpFetchImpl = fn;
|
||
}
|
||
|
||
function httpFetch(): HttpFetchFn {
|
||
return httpFetchImpl ?? fetch;
|
||
}
|
||
|
||
function wrapNetworkError(e: unknown): Error {
|
||
if (e instanceof TypeError || (e instanceof Error && /failed to fetch|load failed/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 httpFetch()(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} · Wi‑Fi를 확인하세요.`,
|
||
);
|
||
}
|
||
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 httpFetch()(`${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 httpFetch()(
|
||
`${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}`);
|
||
}
|
||
|