goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+515
View File
@@ -0,0 +1,515 @@
import axios from 'axios';
import { getApiBaseUrl } from './apiConfig';
// API 베이스 URL (통합 설정 사용)
const API_BASE_URL = getApiBaseUrl();
const alertApi = axios.create({
baseURL: API_BASE_URL,
timeout: 60000, // 60초로 증가 (외부 DB 연결 시간 고려)
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
withCredentials: false,
});
// ==================== Types ====================
export type IndicatorType =
| 'VOLUME'
| 'MACD'
| 'STOCHASTIC'
| 'STOCHASTIC_K'
| 'STOCHASTIC_D'
| 'RSI'
| 'ADX'
| 'CCI'
| 'TRIX'
| 'DISPARITY'
| 'NEW_PSYCHOLOGICAL'
| 'INVEST_PSYCHOLOGICAL'
| 'WILLIAMS_R'
| 'BWI'
| 'OBV'
| 'VOLUME_OSC'
| 'VR'
| 'DMI';
export type ConditionType =
| 'CROSS_UP'
| 'CROSS_DOWN'
| 'ABOVE'
| 'BELOW'
| 'EQUAL'
| 'BETWEEN'
| 'ZERO_CROSS_UP'
| 'ZERO_CROSS_DOWN'
| 'SIGNAL_CROSS_UP'
| 'SIGNAL_CROSS_DOWN'
| 'HISTOGRAM_POSITIVE'
| 'HISTOGRAM_NEGATIVE'
| 'HISTOGRAM_INCREASE'
| 'HISTOGRAM_DECREASE'
| 'K_CROSS_D_UP'
| 'K_CROSS_D_DOWN'
| 'VOLUME_SPIKE'
| 'VOLUME_ABOVE_AVG'
| 'VOLUME_BELOW_AVG'
| 'INCREASE'
| 'DECREASE'
| 'GOLDEN_CROSS'
| 'DEAD_CROSS';
export interface AlertConditionDto {
id?: number;
cryptoAlertId?: number;
indicatorType: IndicatorType;
conditionType: ConditionType;
targetValue?: number;
secondaryValue?: number;
period?: number;
comparePeriod?: number;
enabled?: boolean;
orderIndex?: number;
description?: string;
logicalOperator?: 'AND' | 'OR';
}
export interface CryptoAlertDto {
id?: number;
symbol: string;
koreanName: string;
englishName?: string;
enabled?: boolean;
userId?: number;
conditions?: AlertConditionDto[]; // 하위 호환성
createdAt?: string;
updatedAt?: string;
}
export interface AlertNotificationDto {
id?: number;
cryptoAlertId?: number;
symbol?: string;
koreanName?: string;
message: string;
conditionDescription?: string;
isRead?: boolean;
triggeredAt?: string;
notifiedAt?: string;
userId?: number;
/** 조건 충족한 봉 시각(백엔드 LocalDateTime → ISO 또는 배열) */
candleTime?: string;
/** 알림 스케줄/조건의 캔들 간격 (예: 3m, 1h, 1d) */
timeInterval?: string;
}
export interface IndicatorDataDto {
time?: string;
open?: number;
high?: number;
low?: number;
price?: number;
ma10?: number;
ma20?: number;
ma60?: number;
ma120?: number;
rsi?: number;
macdLine?: number;
signalLine?: number;
histogram?: number;
stochK?: number;
stochD?: number;
cci?: number;
adx?: number;
plusDI?: number;
minusDI?: number;
williamsR?: number;
newPsy?: number;
investPsy?: number;
bwi?: number;
obv?: number;
obvSignal?: number; // OBV Signal Line (이동평균)
volumeOsc?: number;
vr?: number;
trix?: number;
trixSignal?: number; // TRIX Signal Line
ichimokuTenkan?: number;
ichimokuKijun?: number;
ichimokuSenkouA?: number;
ichimokuSenkouB?: number;
ichimokuChikou?: number;
disparityMid?: number;
volume?: number;
}
export interface AlertNotificationDetailDto extends AlertNotificationDto {
englishName?: string;
indicatorData?: IndicatorDataDto;
/** 알림 발생 시점 전후 지표 시계열 (조건 관련 차트 렌더링용) */
indicatorDataSeries?: IndicatorDataDto[];
satisfiedConditions?: AlertConditionDto[];
currentPrice?: number;
changeRate?: number;
volume?: number;
/** 백엔드: 알림 발생 봉 기준 OHLCV 스냅샷 */
snapshotOpen?: number;
snapshotHigh?: number;
snapshotLow?: number;
snapshotClose?: number;
snapshotVolume?: number;
}
export interface AlertScheduleConfigDto {
id?: number;
userId?: number;
cronExpression?: string;
timeInterval?: string; // 시간봉 간격 (1m, 3m, 5m, 10m, 15m, 30m, 1h, 4h, 1d, 1w)
/** 전략 조건 평가 주기(초) 3~30, 스케줄러 실행 간격 */
strategyCheckIntervalSeconds?: number;
enabled?: boolean;
lastRunTime?: string;
nextRunTime?: string;
createdAt?: string;
updatedAt?: string;
}
// ==================== CryptoAlert APIs ====================
/**
* 전체 알림 목록 조회
*/
export const getAllAlerts = async (): Promise<CryptoAlertDto[]> => {
const response = await alertApi.get<CryptoAlertDto[]>('/alerts');
return response.data;
};
/**
* 활성화된 알림 목록 조회
*/
export const getEnabledAlerts = async (): Promise<CryptoAlertDto[]> => {
const response = await alertApi.get<CryptoAlertDto[]>('/alerts/enabled');
return response.data;
};
/**
* 알림 상세 조회
*/
export const getAlert = async (alertId: number): Promise<CryptoAlertDto> => {
const response = await alertApi.get<CryptoAlertDto>(`/alerts/${alertId}`);
return response.data;
};
/**
* 심볼로 알림 조회
*/
export const getAlertBySymbol = async (symbol: string): Promise<CryptoAlertDto | null> => {
try {
const response = await alertApi.get<CryptoAlertDto>(`/alerts/symbol/${symbol}`);
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
return null;
}
throw error;
}
};
/**
* 알림 생성
*/
export const createAlert = async (dto: CryptoAlertDto): Promise<CryptoAlertDto> => {
const response = await alertApi.post<CryptoAlertDto>('/alerts', dto);
return response.data;
};
/**
* 알림 ON/OFF 토글
*/
export const toggleAlert = async (alertId: number, enabled: boolean): Promise<CryptoAlertDto> => {
const response = await alertApi.put<CryptoAlertDto>(
`/alerts/${alertId}/toggle`,
null,
{ params: { enabled } }
);
return response.data;
};
/**
* 알림 삭제
*/
export const deleteAlert = async (alertId: number): Promise<void> => {
await alertApi.delete(`/alerts/${alertId}`);
};
// ==================== AlertCondition APIs ====================
/**
* 전역 알림 조건 목록 조회
*/
export const getGlobalConditions = async (): Promise<AlertConditionDto[]> => {
const response = await alertApi.get<AlertConditionDto[]>('/alerts/conditions/global');
return response.data;
};
/**
* 알림 조건 목록 조회 (개별 종목용 - 하위 호환)
*/
export const getConditions = async (alertId: number): Promise<AlertConditionDto[]> => {
const response = await alertApi.get<AlertConditionDto[]>(`/alerts/${alertId}/conditions`);
return response.data;
};
/**
* 전역 조건 추가
*/
export const addGlobalCondition = async (dto: AlertConditionDto): Promise<AlertConditionDto> => {
const response = await alertApi.post<AlertConditionDto>('/alerts/conditions/global', dto);
return response.data;
};
/**
* 조건 추가 (개별 종목용 - 하위 호환)
*/
export const addCondition = async (
alertId: number,
dto: AlertConditionDto
): Promise<AlertConditionDto> => {
const response = await alertApi.post<AlertConditionDto>(
`/alerts/${alertId}/conditions`,
dto
);
return response.data;
};
/**
* 조건 수정
*/
export const updateCondition = async (
conditionId: number,
dto: AlertConditionDto
): Promise<AlertConditionDto> => {
const response = await alertApi.put<AlertConditionDto>(
`/alerts/conditions/${conditionId}`,
dto
);
return response.data;
};
/**
* 조건 삭제
*/
export const deleteCondition = async (conditionId: number): Promise<void> => {
await alertApi.delete(`/alerts/conditions/${conditionId}`);
};
/**
* 조건 활성화/비활성화
*/
export const toggleCondition = async (
conditionId: number,
enabled: boolean
): Promise<AlertConditionDto> => {
const response = await alertApi.put<AlertConditionDto>(
`/alerts/conditions/${conditionId}/toggle`,
null,
{ params: { enabled } }
);
return response.data;
};
// ==================== AlertNotification APIs ====================
/**
* 미읽음 알림 조회
*/
export const getUnreadNotifications = async (userId?: number): Promise<AlertNotificationDto[]> => {
const response = await alertApi.get<AlertNotificationDto[]>(
'/alerts/notifications/unread',
{ params: { userId } }
);
return response.data;
};
/**
* 알림 목록 조회 (페이징)
*/
export const getNotifications = async (
userId?: number,
page: number = 0,
size: number = 20
): Promise<{ content: AlertNotificationDto[]; totalPages: number; totalElements: number }> => {
const response = await alertApi.get('/alerts/notifications', {
params: { userId, page, size },
});
return response.data;
};
/**
* 알림 읽음 처리
*/
export const markAsRead = async (notificationId: number): Promise<void> => {
await alertApi.put(`/alerts/notifications/${notificationId}/read`);
};
/**
* 모든 미읽은 알림 읽음 처리
*/
export const markAllAsRead = async (): Promise<void> => {
await alertApi.put('/alerts/notifications/read-all');
};
/**
* 알림 상세 정보 조회 (분석 데이터 포함)
* @param symbol 통합 알림 등 행별 마켓(예: KRW-BTC). 지정 시 해당 종목 캔들·지표로 스냅샷 (알림 봉 시각은 동일).
*/
export const getNotificationDetail = async (
notificationId: number,
options?: { symbol?: string | null }
): Promise<AlertNotificationDetailDto> => {
const sym = options?.symbol?.trim();
const response = await alertApi.get<AlertNotificationDetailDto>(
`/alerts/notifications/${notificationId}/detail`,
sym ? { params: { symbol: sym } } : undefined
);
return response.data;
};
// ==================== AlertScheduleConfig APIs ====================
/**
* 스케줄 설정 조회
*/
export const getScheduleConfig = async (userId?: number): Promise<AlertScheduleConfigDto> => {
const response = await alertApi.get<AlertScheduleConfigDto>(
'/alerts/schedule',
{ params: { userId } }
);
return response.data;
};
/**
* 스케줄 설정 저장/업데이트
*/
export const saveScheduleConfig = async (
dto: AlertScheduleConfigDto
): Promise<AlertScheduleConfigDto> => {
const response = await alertApi.put<AlertScheduleConfigDto>('/alerts/schedule', dto);
return response.data;
};
/**
* 알림 삭제
*/
export const deleteNotification = async (notificationId: number): Promise<void> => {
await alertApi.delete(`/alerts/notifications/${notificationId}`);
};
/**
* 읽은 알림 모두 삭제
*/
export const deleteReadNotifications = async (userId?: number): Promise<void> => {
await alertApi.delete('/alerts/notifications/read', {
params: { userId },
});
};
// ==================== Scheduler Service APIs ====================
export interface SchedulerStatusDto {
running: boolean;
status?: string;
error?: string;
schedulerRunning?: boolean;
kafkaConnected?: boolean;
}
export interface SchedulerStartResultDto {
success: boolean;
message: string;
manualCommand?: string;
}
/**
* 스케줄러 서비스 상태 조회
*/
export const getSchedulerStatus = async (): Promise<SchedulerStatusDto> => {
const response = await alertApi.get<SchedulerStatusDto>('/scheduler/status');
return response.data;
};
/**
* 스케줄러 서비스 시작 시도
*/
export const startScheduler = async (): Promise<SchedulerStartResultDto> => {
const response = await alertApi.post<SchedulerStartResultDto>('/scheduler/start');
return response.data;
};
/** Internal `/internal/alerts/evaluate` 요청 본문 */
export interface AlertEvaluateRequest {
symbol: string;
strategyRuleId: number;
timeInterval: string;
liveCandles?: Record<string, unknown>[];
useLiveCandleTail?: boolean;
}
export interface AlertEvaluateResponse {
symbol?: string;
strategyRuleId?: number;
conditionMet: boolean;
message?: string;
}
/**
* 알림 전략 조건 평가 (스케줄러와 동일 백엔드 경로)
* `liveCandles`가 있으면 WS 버퍼 기준, 없으면 서버 Redis/REST 200봉.
*/
export const evaluateAlertStrategy = async (body: AlertEvaluateRequest): Promise<AlertEvaluateResponse> => {
const response = await alertApi.post<AlertEvaluateResponse>('/internal/alerts/evaluate', body);
return response.data;
};
/**
* 테스트 알림 생성 (파이프라인 검증용)
* Internal API - 첫 번째 종목으로 테스트 알림 생성
*/
export const createTestNotification = async (): Promise<AlertNotificationDto> => {
const response = await alertApi.post<AlertNotificationDto>('/internal/alerts/test');
return response.data;
};
/**
* 알림 읽음 처리
*/
export const markNotificationAsRead = async (notificationId: number): Promise<void> => {
await alertApi.put(`/alerts/notifications/${notificationId}/read`);
};
export default {
getAllAlerts,
getEnabledAlerts,
getAlert,
getAlertBySymbol,
createAlert,
toggleAlert,
deleteAlert,
getGlobalConditions,
getConditions,
addGlobalCondition,
addCondition,
updateCondition,
deleteCondition,
toggleCondition,
getUnreadNotifications,
getNotifications,
markAsRead,
getScheduleConfig,
saveScheduleConfig,
deleteNotification,
deleteReadNotifications,
markNotificationAsRead,
};
@@ -0,0 +1,119 @@
import axios from 'axios';
import { API_CONFIG } from './apiConfig';
const API_BASE_URL = API_CONFIG.baseURL;
/**
* 알림 조건 전략 DTO
*/
export interface AlertStrategyDto {
id?: number;
strategyRuleId: number;
strategyRuleName?: string;
enabled?: boolean;
createdAt?: string;
updatedAt?: string;
}
const alertStrategyApi = axios.create({
baseURL: `${API_BASE_URL}/alert-strategies`,
headers: {
'Content-Type': 'application/json',
},
});
/**
* 모든 알림 전략 조회
*/
export const getAllAlertStrategies = async (): Promise<AlertStrategyDto[]> => {
console.log('[API] 알림 전략 목록 조회 요청:', {
url: `${API_BASE_URL}/alert-strategies`
});
try {
const response = await alertStrategyApi.get<AlertStrategyDto[]>('');
console.log('[API] 알림 전략 목록 조회 응답:', {
count: response.data?.length || 0,
data: response.data
});
return response.data;
} catch (error: any) {
console.error('[API] 알림 전략 목록 조회 에러:', {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
message: error.message
});
throw error;
}
};
/**
* 활성화된 알림 전략 조회
*/
export const getEnabledAlertStrategies = async (): Promise<AlertStrategyDto[]> => {
const response = await alertStrategyApi.get<AlertStrategyDto[]>('/enabled');
return response.data;
};
/**
* 알림 전략 추가
*/
export const addAlertStrategy = async (strategyRuleId: number): Promise<AlertStrategyDto> => {
console.log('[API] 알림 전략 추가 요청:', {
url: `${API_BASE_URL}/alert-strategies`,
strategyRuleId
});
try {
const response = await alertStrategyApi.post<AlertStrategyDto>('', null, {
params: { strategyRuleId }
});
console.log('[API] 알림 전략 추가 응답:', response.data);
return response.data;
} catch (error: any) {
console.error('[API] 알림 전략 추가 에러:', {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
message: error.message
});
throw error;
}
};
/**
* 알림 전략 삭제
*/
export const deleteAlertStrategy = async (id: number): Promise<void> => {
console.log('[API] 알림 전략 삭제 요청:', {
url: `${API_BASE_URL}/alert-strategies/${id}`,
id
});
try {
await alertStrategyApi.delete(`/${id}`);
console.log('[API] 알림 전략 삭제 완료:', id);
} catch (error: any) {
console.error('[API] 알림 전략 삭제 에러:', {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
message: error.message
});
throw error;
}
};
/**
* 알림 전략 활성화/비활성화 토글
*/
export const toggleAlertStrategy = async (id: number, enabled: boolean): Promise<AlertStrategyDto> => {
const response = await alertStrategyApi.put<AlertStrategyDto>(`/${id}/toggle`, null, {
params: { enabled }
});
return response.data;
};
@@ -0,0 +1,192 @@
/**
* API 응답 캐싱 서비스
* - 메모리 기반 캐시로 동일한 요청 중복 방지
* - TTL(Time To Live) 기반 자동 만료
* - LRU(Least Recently Used) 캐시 정책
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number; // milliseconds
}
interface CacheOptions {
ttl?: number; // 기본 5분
maxSize?: number; // 최대 캐시 항목 수
}
class ApiCacheService {
private cache = new Map<string, CacheEntry<any>>();
private accessOrder: string[] = []; // LRU 추적용
private defaultTTL = 5 * 60 * 1000; // 5분
private maxSize = 100;
/**
* 캐시에서 데이터 가져오기
*/
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// TTL 확인
const now = Date.now();
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
this.removeFromAccessOrder(key);
return null;
}
// LRU 업데이트
this.updateAccessOrder(key);
return entry.data as T;
}
/**
* 캐시에 데이터 저장
*/
set<T>(key: string, data: T, options?: CacheOptions): void {
const ttl = options?.ttl || this.defaultTTL;
// 최대 크기 초과 시 가장 오래된 항목 제거
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
const oldestKey = this.accessOrder[0];
if (oldestKey) {
this.cache.delete(oldestKey);
this.removeFromAccessOrder(oldestKey);
}
}
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl,
});
this.updateAccessOrder(key);
}
/**
* 특정 키 패턴과 일치하는 캐시 무효화
*/
invalidate(pattern: string | RegExp): void {
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
for (const key of Array.from(this.cache.keys())) {
if (regex.test(key)) {
this.cache.delete(key);
this.removeFromAccessOrder(key);
}
}
}
/**
* 특정 키의 캐시 삭제
*/
delete(key: string): void {
this.cache.delete(key);
this.removeFromAccessOrder(key);
}
/**
* 모든 캐시 삭제
*/
clear(): void {
this.cache.clear();
this.accessOrder = [];
}
/**
* 만료된 캐시 항목 정리
*/
cleanup(): void {
const now = Date.now();
for (const [key, entry] of Array.from(this.cache.entries())) {
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
this.removeFromAccessOrder(key);
}
}
}
/**
* 캐시 키 생성 헬퍼
*/
createKey(prefix: string, params: Record<string, any>): string {
const sortedParams = Object.keys(params)
.sort()
.map(k => `${k}=${JSON.stringify(params[k])}`)
.join('&');
return `${prefix}:${sortedParams}`;
}
/**
* LRU 접근 순서 업데이트
*/
private updateAccessOrder(key: string): void {
this.removeFromAccessOrder(key);
this.accessOrder.push(key);
}
/**
* LRU 접근 순서에서 제거
*/
private removeFromAccessOrder(key: string): void {
const index = this.accessOrder.indexOf(key);
if (index > -1) {
this.accessOrder.splice(index, 1);
}
}
/**
* 캐시 통계
*/
getStats() {
return {
size: this.cache.size,
maxSize: this.maxSize,
keys: Array.from(this.cache.keys()),
};
}
}
// 싱글톤 인스턴스
export const apiCache = new ApiCacheService();
// 주기적 정리 (5분마다)
if (typeof window !== 'undefined') {
setInterval(() => {
apiCache.cleanup();
}, 5 * 60 * 1000);
}
/**
* 캐시 가능한 API 호출 래퍼
*/
export async function cachedFetch<T>(
key: string,
fetcher: () => Promise<T>,
options?: CacheOptions
): Promise<T> {
// 캐시 확인
const cached = apiCache.get<T>(key);
if (cached !== null) {
console.log(`✅ [Cache HIT] ${key}`);
return cached;
}
// 캐시 미스 - 데이터 로드
console.log(`🔄 [Cache MISS] ${key}`);
const data = await fetcher();
// 캐시에 저장
apiCache.set(key, data, options);
return data;
}
+84
View File
@@ -0,0 +1,84 @@
// API 기본 설정
// 환경별 URL 결정:
// - 로컬 개발 (npm start): .env.development → localhost:8083
// - 서버 빌드 (deploy-home.sh, deploy-server.sh): .env.server → exdev.co.kr
// - env 미설정 시: 런타임에 window.location 기반 (localhost 접속→localhost, exdev.co.kr 접속→exdev.co.kr)
const REMOTE_SERVER = 'https://exdev.co.kr';
let cachedApiBaseUrl: string | null = null;
/**
* API 베이스 URL 가져오기
* 1. REACT_APP_API_BASE_URL 설정 시 → 사용 (서버 빌드)
* 2. 미설정 시 → 런타임: localhost 접속이면 localhost:8083, 그 외엔 현재 origin
*/
export const getApiBaseUrl = (): string => {
if (cachedApiBaseUrl !== null) {
return cachedApiBaseUrl;
}
// 1. 빌드 타임 환경 변수 우선 사용
const envBase = process.env.REACT_APP_API_BASE_URL;
if (envBase) {
const finalUrl = envBase.endsWith('/api') ? envBase : `${envBase}/api`;
cachedApiBaseUrl = finalUrl;
return finalUrl;
}
// 2. 런타임 환경 감지 (Fallback) - SSR/빌드 시 window 없음 (캐시하지 않음)
if (typeof window === 'undefined') {
return '/api';
}
const isLocalhost =
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '';
if (isLocalhost) {
const url = 'http://localhost:8083/api';
cachedApiBaseUrl = url;
return url;
}
// 서버 환경: 반드시 현재 origin 사용 (localhost 절대 사용 금지)
const url = `${window.location.origin}/api`;
cachedApiBaseUrl = url;
return url;
};
/**
* 알림(SSE) API 베이스 URL
* - 로컬 실행 시 localhost:8083 사용
* - REACT_APP_NOTIFICATION_API_URL 설정 시 해당 URL 사용
*/
export const getNotificationApiBaseUrl = (): string => {
const envUrl = process.env.REACT_APP_NOTIFICATION_API_URL;
if (envUrl) {
return envUrl.endsWith('/api') ? envUrl : `${envUrl}/api`;
}
if (typeof window === 'undefined') {
return '/api';
}
const isLocalhost =
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '';
if (isLocalhost) {
return 'http://localhost:8083/api';
}
return getApiBaseUrl();
};
// API 베이스 URL (동적으로 생성)
export const API_BASE_URL = getApiBaseUrl();
// Axios 공통 설정
export const API_CONFIG = {
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
};
+85
View File
@@ -0,0 +1,85 @@
import axios from 'axios';
import { API_BASE_URL } from './apiConfig';
const authApi = axios.create({
baseURL: `${API_BASE_URL}/auth`,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
export interface SignUpRequest {
username: string;
email: string;
password: string;
name?: string;
}
export interface LoginRequest {
usernameOrEmail: string;
password: string;
}
export interface AuthResponse {
accessToken: string;
tokenType: string;
userId: number;
username: string;
email: string;
name?: string;
}
export interface UserResponse {
id: number;
username: string;
email: string;
name?: string;
role: string;
createdAt: string;
lastLoginAt?: string;
}
export const authService = {
/**
* 회원가입
*/
async signUp(request: SignUpRequest): Promise<AuthResponse> {
const response = await authApi.post<AuthResponse>('/signup', request);
return response.data;
},
/**
* 로그인
*/
async login(request: LoginRequest): Promise<AuthResponse> {
const response = await authApi.post<AuthResponse>('/login', request);
return response.data;
},
/**
* 로그아웃
*/
async logout(): Promise<void> {
try {
await authApi.post('/logout');
} catch (error) {
console.log('로그아웃 요청 실패 (서버 측 처리 없음)');
}
},
/**
* 현재 로그인된 사용자 정보 조회
*/
async getCurrentUser(token: string): Promise<UserResponse> {
const response = await authApi.get<UserResponse>('/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
return response.data;
},
};
export default authService;
@@ -0,0 +1,204 @@
/**
* 자동매매 API 서비스
* - 업비트 API를 통한 자동 매수/매도 기능
* - 백엔드 프록시를 통해 API 키 보안 유지
*/
import { API_BASE_URL } from './apiConfig';
// ============ 타입 정의 ============
/** 주문 유형 */
export type OrderType = 'limit' | 'price' | 'market' | 'best';
/** 주문 상태 */
export type OrderState = 'wait' | 'watch' | 'done' | 'cancel';
/** 자동매매 전략 */
export interface AutoTradingStrategy {
id?: string;
name: string;
market: string;
enabled: boolean;
dryRun: boolean; // 실제 주문 없이 시뮬레이션
buyCondition: BuyCondition;
sellCondition: SellCondition;
riskLimit: RiskLimit;
}
/** 매수 조건 */
export interface BuyCondition {
orderType: OrderType;
amount: number; // KRW 금액 (price 타입) 또는 수량 (limit 타입)
price?: number; // 지정가 (limit 타입)
trigger?: string; // 'manual' | 'signal' | 'schedule'
signalStrategyId?: string; // 전략 규칙 ID
}
/** 매도 조건 */
export interface SellCondition {
orderType: OrderType;
volume?: number; // 비율(0~100) 또는 수량
volumePercent?: number;
trigger?: string;
stopLossPercent?: number; // 손절 %
takeProfitPercent?: number; // 익절 %
}
/** 리스크 제한 */
export interface RiskLimit {
maxOrderAmount: number; // 1회 최대 주문 금액
maxDailyLoss: number; // 일일 최대 손실
maxPosition: number; // 최대 보유 종목 수
}
/** 계정 잔고 */
export interface AccountBalance {
currency: string;
balance: string;
locked: string;
avgBuyPrice: string;
avgBuyPriceModified: boolean;
unitCurrency: string;
}
/** 주문 정보 */
export interface OrderInfo {
uuid: string;
side: 'bid' | 'ask';
ord_type: OrderType;
price: string;
state: OrderState;
market: string;
created_at: string;
volume?: string;
remaining_volume?: string;
executed_volume?: string;
executed_funds?: string;
}
/** 포지션 (보유 종목) */
export interface Position {
market: string;
currency: string;
balance: number;
avgBuyPrice: number;
currentPrice: number;
evalAmount: number;
profitLoss: number;
profitLossRate: number;
}
/** API 키 상태 */
export interface ApiKeyStatus {
configured: boolean;
valid: boolean;
}
// ============ API 함수 ============
/** API 키 상태 확인 */
export const checkApiKeyStatus = async (): Promise<ApiKeyStatus> => {
const res = await fetch(`${API_BASE_URL}/auto-trading/api-key/status`);
if (!res.ok) throw new Error('API 키 상태 확인 실패');
return res.json();
};
/** 계정 잔고 조회 */
export const getAccountBalances = async (): Promise<AccountBalance[]> => {
const res = await fetch(`${API_BASE_URL}/auto-trading/accounts`);
if (!res.ok) throw new Error('잔고 조회 실패');
return res.json();
};
/** KRW 잔고만 조회 */
export const getKrwBalance = async (): Promise<number> => {
const accounts = await getAccountBalances();
const krw = accounts.find((a) => a.currency === 'KRW');
return krw ? parseFloat(krw.balance) : 0;
};
/** 보유 종목 조회 (포지션) */
export const getPositions = async (): Promise<Position[]> => {
const res = await fetch(`${API_BASE_URL}/auto-trading/positions`);
if (!res.ok) throw new Error('포지션 조회 실패');
return res.json();
};
/** 주문 내역 조회 */
export const getOrderHistory = async (market?: string, state?: OrderState): Promise<OrderInfo[]> => {
const params = new URLSearchParams();
if (market) params.append('market', market);
if (state) params.append('state', state);
const res = await fetch(`${API_BASE_URL}/auto-trading/orders?${params}`);
if (!res.ok) throw new Error('주문 내역 조회 실패');
return res.json();
};
/** 자동매매 전략 목록 */
export const getStrategies = async (): Promise<AutoTradingStrategy[]> => {
const res = await fetch(`${API_BASE_URL}/auto-trading/strategies`);
if (!res.ok) throw new Error('전략 조회 실패');
return res.json();
};
/** 자동매매 전략 저장 */
export const saveStrategy = async (strategy: AutoTradingStrategy): Promise<AutoTradingStrategy> => {
const res = await fetch(`${API_BASE_URL}/auto-trading/strategies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(strategy),
});
if (!res.ok) throw new Error('전략 저장 실패');
return res.json();
};
/** 자동매매 전략 수정 */
export const updateStrategy = async (id: string, strategy: Partial<AutoTradingStrategy>): Promise<void> => {
const res = await fetch(`${API_BASE_URL}/auto-trading/strategies/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(strategy),
});
if (!res.ok) throw new Error('전략 수정 실패');
};
/** 매수 주문 */
export const placeBuyOrder = async (params: {
market: string;
orderType: OrderType;
amount?: number;
price?: number;
volume?: number;
}): Promise<OrderInfo> => {
const res = await fetch(`${API_BASE_URL}/auto-trading/orders/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error('매수 주문 실패');
return res.json();
};
/** 매도 주문 */
export const placeSellOrder = async (params: {
market: string;
orderType: OrderType;
volume?: number;
price?: number;
}): Promise<OrderInfo> => {
const res = await fetch(`${API_BASE_URL}/auto-trading/orders/sell`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error('매도 주문 실패');
return res.json();
};
/** 주문 취소 */
export const cancelOrder = async (orderId: string): Promise<void> => {
const res = await fetch(`${API_BASE_URL}/auto-trading/orders/${orderId}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('주문 취소 실패');
};
+364
View File
@@ -0,0 +1,364 @@
import axios from 'axios';
import { getApiBaseUrl } from './apiConfig';
const API_BASE_URL = getApiBaseUrl();
/**
* 백테스팅 요청 데이터
*/
export interface BacktestRequest {
symbol: string;
strategyId: number;
startDate: string; // ISO 8601 format
endDate: string; // ISO 8601 format
initialCapital?: number;
positionSizeRatio?: number;
commissionRate?: number;
timeframe?: string; // 시간봉 (예: "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d", "1w")
// 전략관리 규칙 ID (정배열/역배열 전략)
uptrendStrategyId?: number; // 정배열 전략 규칙 ID
downtrendStrategyId?: number; // 역배열 전략 규칙 ID
// 추세 판단 MA 설정 (프론트엔드에서 선택)
trendShortMA?: number; // 단기 MA (기본값: 10)
trendMediumMA?: number; // 중기 MA (기본값: 20)
trendLongMA?: number; // 장기 MA (기본값: 60)
// 보조지표 파라미터 (설정 > 보조지표 설정에서 동기화)
macdFastPeriod?: number; // MACD Fast EMA 기간 (기본값: 12)
macdSlowPeriod?: number; // MACD Slow EMA 기간 (기본값: 26)
macdSignalPeriod?: number; // MACD Signal 기간 (기본값: 9)
stochasticKPeriod?: number; // Stochastic %K 기간 (기본값: 12)
stochasticDPeriod?: number; // Stochastic %D 기간 (기본값: 5)
stochasticSlowing?: number; // Stochastic Slowing 기간 (기본값: 5)
williamsRPeriod?: number; // Williams %R 기간 (기본값: 11)
rsiPeriod?: number; // RSI 기간 (기본값: 14)
rsiOversold?: number; // RSI 과매도 기준 (기본값: 30)
rsiOverbought?: number; // RSI 과매수 기준 (기본값: 70)
cciOversold?: number; // CCI 침체 기준 (기본값: -100)
cciMiddle?: number; // CCI 중앙 기준 (기본값: 0)
cciOverbought?: number; // CCI 과열 기준 (기본값: 100)
// 매수/매도 금액 설정
buyAmount?: number; // 매수 금액 (KRW)
sellAmount?: number; // 매도 금액 (KRW)
// ✅ 프론트엔드 제공 데이터 (워밍업 데이터 재요청 생략용)
providedCandleData?: ProvidedCandleData[]; // 차트에 표시된 캔들 데이터
useProvidedData?: boolean; // 제공된 데이터 사용 여부
}
/**
* 프론트엔드에서 제공하는 캔들 데이터
*/
export interface ProvidedCandleData {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
// MA 값들
ma5?: number;
ma10?: number;
ma12?: number;
ma20?: number;
ma26?: number;
ma50?: number;
ma60?: number;
ma100?: number;
ma120?: number;
ma200?: number;
}
/**
* 조건 충족 정보
*/
export interface ConditionMatch {
conditionType: string; // 조건 종류 (MA, MACD, RSI 등)
indicatorName?: string; // 지표명 (한글 표시용)
description: string; // 조건 설명
detailDescription?: string; // 상세 설명 (조건 충족 논리 설명)
currentValue?: number; // 현재 값
targetValue?: number; // 목표 값
previousValue?: number; // 이전 값
secondaryValue?: number; // 보조 값 (BETWEEN 조건 등)
compareValue?: number; // 비교 대상 값 (크로스 조건에서 비교 대상)
matched: boolean; // 충족 여부
}
/**
* 해당 시점의 주요 지표 스냅샷
*/
export interface ChartIndicators {
// 이동평균선
ma10?: number;
ma20?: number;
ma60?: number;
ma120?: number;
// MACD
macdLine?: number;
macdSignal?: number;
macdHistogram?: number;
// Stochastic
stochK?: number;
stochD?: number;
// RSI
rsi?: number;
// ADX
adx?: number;
plusDi?: number;
minusDi?: number;
// CCI
cci?: number;
// 일목균형표
ichimokuTenkan?: number;
ichimokuKijun?: number;
ichimokuSenkouA?: number;
ichimokuSenkouB?: number;
ichimokuChikou?: number;
// TRIX
trix?: number;
trixSignal?: number;
// 이격도
disparity?: number;
}
/**
* 거래 기록
*/
export interface TradeRecord {
timestamp: string;
tradeType: string; // "BUY" | "SELL"
price: number;
quantity: number;
totalAmount: number;
commission: number;
balance: number;
holdings: number;
profitLoss?: number;
returnRate?: number;
signalScore?: number;
note?: string;
// 신호 발생 조건 상세 정보
strategyName?: string; // 적용된 전략 이름
trendStatus?: string; // 추세 상태 (정배열/역배열/혼조)
matchedConditions?: ConditionMatch[]; // 충족된 조건 목록
// 해당 시점 차트 정보 (OHLCV)
open?: number; // 시가
high?: number; // 고가
low?: number; // 저가
close?: number; // 종가
volume?: number; // 거래량
// 해당 시점 주요 지표 스냅샷
indicators?: ChartIndicators; // 주요 지표 스냅샷
}
/**
* 성과 스냅샷
*/
export interface PerformanceSnapshot {
timestamp: string;
portfolioValue: number;
returnRate: number;
drawdown: number;
}
/**
* 캔들 데이터
*/
export interface CandleData {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
isBuyPoint?: boolean;
isSellPoint?: boolean;
// ✅ 동적 MA 값 (사용자 설정 기간 지원)
maValues?: { [key: string]: number }; // 예: { "ma11": 25000.5, "ma25": 24500.0 }
// ✅ 인덱스 시그니처 추가 (동적 속성 접근 허용)
[key: string]: any;
}
/**
* 지표 데이터
*/
export interface IndicatorData {
time: string;
timestamp: string; // 실제 백엔드에서 사용하는 필드
// 캔들 데이터 (서브차트 표시용)
open?: number;
high?: number;
low?: number;
close?: number;
volume?: number;
// MACD
macd?: number; // MACD Histogram
macdLine?: number;
signalLine?: number;
signal?: number; // signalLine 별칭
/** WebSocket/일부 API에서 사용 — signalLine과 동일 의미 */
macdSignal?: number;
histogram?: number;
macdHistogram?: number; // 백엔드 필드명
// Stochastic
stochK?: number;
stochD?: number;
// ADX & DMI
adx?: number;
plusDI?: number;
minusDI?: number;
// CCI
cci?: number;
// RSI
rsi?: number; // RSI (Relative Strength Index)
// OBV
obv?: number;
obvSignal?: number; // OBV Signal Line (이동평균)
// Williams %R
williamsR?: number;
// Momentum
momentum?: number;
// TRIX
trix?: number;
trixSignal?: number;
// 이격도 (Disparity Index)
disparity?: number;
// BWI
bwi?: number;
// VolumeOSC
volumeOsc?: number;
// VR (Volume Ratio)
vr?: number;
vr2?: number; // VR2 (독립적인 보조지표)
// Psychological Line
psychologicalLine?: number;
newPsychological?: number;
investPsychological?: number;
// 백엔드 필드명 (호환성)
newPsy?: number;
investPsy?: number;
// Moving Averages
ma5?: number; // 백엔드 실시간 계산
ma10?: number;
ma20?: number;
ma60?: number;
ma120?: number; // 백엔드 실시간 계산
ma200?: number;
// SMA (Simple Moving Average) - 백엔드에서 사용
sma5?: number;
sma10?: number;
sma12?: number;
sma20?: number;
sma26?: number;
sma50?: number;
sma60?: number;
sma120?: number;
sma200?: number;
trendStrength?: number; // 추세 강도 (-100 ~ +100)
// 일목균형표 (Ichimoku Cloud)
ichimokuTenkan?: number; // 전환선 (9일)
ichimokuKijun?: number; // 기준선 (26일)
ichimokuChikou?: number; // 후행스팬 (26일 전으로 이동)
ichimokuSenkouA?: number; // 선행스팬1 (26일 앞으로 이동)
ichimokuSenkouB?: number; // 선행스팬2 (26일 앞으로 이동)
// 볼린저 밴드 (Bollinger Bands)
bollingerUpper?: number; // 상한선 (Middle + stdDev * 2)
bollingerMiddle?: number; // 중간선 (SMA)
bollingerLower?: number; // 하한선 (Middle - stdDev * 2)
// 정배열/역배열 상태 (Backend에서 계산)
isPerfectUptrend?: boolean; // 정배열 여부 (MA10 > MA20 > MA60 && MACD > 0)
isPerfectDowntrend?: boolean; // 역배열 여부 (MA10 < MA20 < MA60 && MACD < 0)
// ✅ 동적 MA 값 (사용자 설정 기간 지원)
maValues?: { [key: string]: number }; // 예: { "ma11": 25000.5, "ma25": 24500.0 }
// ✅ 인덱스 시그니처 추가 (동적 속성 접근 허용)
[key: string]: any;
}
/**
* 백테스팅 결과
*/
export interface BacktestResult {
symbol: string;
strategyId: number;
strategyName: string;
strategyType: string; // STRATEGY_1, STRATEGY_2, ..., TREND_STOCHASTIC 등
initialCapital: number;
finalCapital: number;
netProfit: number;
returnRate: number;
totalTrades: number;
buyCount: number;
sellCount: number;
winningTrades: number;
losingTrades: number;
winRate: number;
maxProfit: number;
maxLoss: number;
avgProfit: number;
avgLoss: number;
maxConsecutiveWins: number;
maxConsecutiveLosses: number;
maxCapital: number;
maxDrawdown: number;
sharpeRatio: number;
totalCommission: number;
tradeRecords: TradeRecord[];
performanceHistory: PerformanceSnapshot[];
candleData: CandleData[];
indicatorData: IndicatorData[];
}
/**
* 백테스팅 실행
*/
export const runBacktest = async (request: BacktestRequest): Promise<BacktestResult> => {
const response = await axios.post<BacktestResult>(`${API_BASE_URL}/backtest`, request, {
headers: {
'Content-Type': 'application/json',
},
});
return response.data;
};
/**
* 빠른 백테스팅 (쿼리 파라미터 방식)
*/
export const quickBacktest = async (
symbol: string,
strategyId: number,
startDate: string,
endDate: string
): Promise<BacktestResult> => {
const response = await axios.get<BacktestResult>(`${API_BASE_URL}/backtest/quick`, {
params: {
symbol,
strategyId,
startDate,
endDate,
},
});
return response.data;
};
/**
* 백테스팅 상태 확인
*/
export const getBacktestStatus = async (): Promise<{ status: string; message: string }> => {
const response = await axios.get(`${API_BASE_URL}/backtest/status`);
return response.data;
};
+271
View File
@@ -0,0 +1,271 @@
import axios from 'axios';
import { getApiBaseUrl } from './apiConfig';
// API 베이스 URL (통합 설정 사용)
const API_BASE_URL = getApiBaseUrl();
const chatbotApi = axios.create({
baseURL: API_BASE_URL,
timeout: 60000, // 60초 (AI 분석 시간 고려)
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
withCredentials: false,
});
// ==================== Types ====================
export type MessageType = 'BUY' | 'SELL' | 'HOLD' | 'INFO';
export interface IndicatorValues {
rsi?: number;
macd?: number;
macdSignal?: number;
macdHistogram?: number;
bollingerUpper?: number;
bollingerMiddle?: number;
bollingerLower?: number;
ma5?: number;
ma20?: number;
ma60?: number;
volume?: number;
}
export interface ChatbotMessage {
id: number;
configId: number;
symbol: string;
symbolName: string;
messageType: MessageType;
title: string;
content: string;
price?: number;
analysisData?: Record<string, any>;
indicators?: IndicatorValues;
readStatus: boolean;
createdAt: string;
}
export interface ChatbotConfig {
id?: number;
userId: number;
enabled: boolean;
scheduleType: 'CRON' | 'INTERVAL';
cronExpression?: string;
intervalMinutes?: number;
candleInterval?: string;
strategyConditions?: Record<string, any>;
createdAt?: string;
updatedAt?: string;
}
export interface ChatbotScheduleLog {
id: number;
configId: number;
executedAt: string;
symbolsAnalyzed: number;
messagesCreated: number;
success: boolean;
errorMessage?: string;
}
export interface ChatbotStats {
totalMessages: number;
unreadCount: number;
messageTypeCount: {
BUY: number;
SELL: number;
HOLD: number;
INFO: number;
};
}
export interface PageResponse<T> {
content: T[];
totalElements: number;
totalPages: number;
size: number;
number: number;
}
// ==================== API Functions ====================
// ========== 설정 관리 ==========
/**
* 챗봇 설정 조회
*/
export const getChatbotConfig = (userId: number = 1) => {
return chatbotApi.get<ChatbotConfig>(`/chatbot/config?userId=${userId}`);
};
/**
* 챗봇 설정 저장/수정
*/
export const saveChatbotConfig = (config: ChatbotConfig) => {
return chatbotApi.put<ChatbotConfig>('/chatbot/config', config);
};
/**
* 챗봇 활성화/비활성화 토글
*/
export const toggleChatbotEnabled = (userId: number = 1) => {
return chatbotApi.post<ChatbotConfig>('/chatbot/config/toggle', { userId });
};
/**
* 챗봇 설정 삭제
*/
export const deleteChatbotConfig = (userId: number = 1) => {
return chatbotApi.delete(`/chatbot/config?userId=${userId}`);
};
// ========== 메시지 관리 ==========
/**
* 메시지 목록 조회 (페이징)
*/
export const getChatbotMessages = (params: {
userId?: number;
page?: number;
size?: number;
messageType?: MessageType | 'ALL';
symbol?: string;
}) => {
const { userId = 1, page = 0, size = 20, messageType, symbol } = params;
const queryParams: Record<string, any> = {
userId,
page,
size,
};
if (messageType && messageType !== 'ALL') {
queryParams.messageType = messageType;
}
if (symbol) {
queryParams.symbol = symbol;
}
return chatbotApi.get<PageResponse<ChatbotMessage>>('/chatbot/messages', {
params: queryParams,
});
};
/**
* 메시지 상세 조회
*/
export const getChatbotMessage = (messageId: number) => {
return chatbotApi.get<ChatbotMessage>(`/chatbot/messages/${messageId}`);
};
/**
* 종목별 메시지 조회
*/
export const getChatbotMessagesBySymbol = (
symbol: string,
userId: number = 1,
page: number = 0,
size: number = 20
) => {
return chatbotApi.get<PageResponse<ChatbotMessage>>(
`/chatbot/messages/symbol/${symbol}`,
{
params: { userId, page, size },
}
);
};
/**
* 읽지 않은 메시지 조회
*/
export const getUnreadMessages = (userId: number = 1) => {
return chatbotApi.get<ChatbotMessage[]>('/chatbot/messages/unread', {
params: { userId },
});
};
/**
* 메시지 읽음 처리
*/
export const markMessageAsRead = (messageId: number) => {
return chatbotApi.post(`/chatbot/messages/${messageId}/read`);
};
/**
* 여러 메시지 읽음 처리
*/
export const markMultipleMessagesAsRead = (messageIds: number[]) => {
return chatbotApi.post('/chatbot/messages/read-multiple', messageIds);
};
/**
* 메시지 삭제
*/
export const deleteChatbotMessage = (messageId: number) => {
return chatbotApi.delete(`/chatbot/messages/${messageId}`);
};
/**
* 전체 메시지 삭제
*/
export const clearAllMessages = (userId: number = 1) => {
return chatbotApi.post('/chatbot/messages/clear', null, {
params: { userId },
});
};
// ========== 스케줄 제어 ==========
/**
* 수동 분석 실행
*/
export const triggerManualAnalysis = () => {
return chatbotApi.post<{ status: string; message: string }>(
'/chatbot/schedule/trigger'
);
};
/**
* 스케줄 상태 조회
*/
export const getChatbotScheduleStatus = () => {
return chatbotApi.get<{
enabled: boolean;
analyzing: boolean;
message: string;
}>('/chatbot/schedule/status');
};
/**
* 스케줄 실행 로그 조회 (페이징)
*/
export const getChatbotScheduleLogs = (
userId: number = 1,
page: number = 0,
size: number = 20
) => {
return chatbotApi.get<PageResponse<ChatbotScheduleLog>>(
'/chatbot/schedule/logs',
{
params: { userId, page, size },
}
);
};
// ========== 통계 ==========
/**
* 챗봇 통계 정보 조회
*/
export const getChatbotStats = (userId: number = 1) => {
return chatbotApi.get<ChatbotStats>('/chatbot/stats', {
params: { userId },
});
};
// ==================== Export ====================
export default chatbotApi;
@@ -0,0 +1,78 @@
import axios from 'axios';
import { getApiBaseUrl } from './apiConfig';
// API 베이스 URL (통합 설정 사용)
const API_BASE_URL = getApiBaseUrl().replace('/api', ''); // /api 제거
/**
* 즐겨찾기 암호화폐 DTO
*/
export interface CryptoFavoriteDto {
id?: number;
symbol: string;
koreanName: string;
englishName?: string;
displayOrder?: number;
createdAt?: string;
}
/**
* 모든 즐겨찾기 조회
*/
export const getAllFavorites = async (): Promise<CryptoFavoriteDto[]> => {
const response = await axios.get(`${API_BASE_URL}/api/favorites`);
return response.data;
};
/**
* 심볼로 즐겨찾기 조회
*/
export const getFavoriteBySymbol = async (symbol: string): Promise<CryptoFavoriteDto | null> => {
try {
const response = await axios.get(`${API_BASE_URL}/api/favorites/${symbol}`);
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
return null;
}
throw error;
}
};
/**
* 즐겨찾기 존재 여부 확인
*/
export const checkFavorite = async (symbol: string): Promise<boolean> => {
const response = await axios.get(`${API_BASE_URL}/api/favorites/check/${symbol}`);
return response.data.isFavorite;
};
/**
* 즐겨찾기 추가
*/
export const addFavorite = async (dto: CryptoFavoriteDto): Promise<CryptoFavoriteDto> => {
const response = await axios.post(`${API_BASE_URL}/api/favorites`, dto);
return response.data;
};
/**
* 즐겨찾기 삭제
*/
export const removeFavorite = async (symbol: string): Promise<void> => {
await axios.delete(`${API_BASE_URL}/api/favorites/${symbol}`);
};
/**
* 즐겨찾기 토글 (추가/삭제)
*/
export const toggleFavorite = async (dto: CryptoFavoriteDto): Promise<boolean> => {
const response = await axios.post(`${API_BASE_URL}/api/favorites/toggle`, dto);
return response.data.isFavorite;
};
/**
* 즐겨찾기 순서 변경
*/
export const updateDisplayOrder = async (id: number, displayOrder: number): Promise<void> => {
await axios.put(`${API_BASE_URL}/api/favorites/${id}/order`, { displayOrder });
};
@@ -0,0 +1,32 @@
import axios from 'axios';
import { getApiBaseUrl } from './apiConfig';
const API_BASE_URL = getApiBaseUrl().replace('/api', '');
export interface CryptoHoldingDto {
id?: number;
symbol: string;
koreanName: string;
englishName?: string;
displayOrder?: number;
createdAt?: string;
}
export const getAllHoldings = async (): Promise<CryptoHoldingDto[]> => {
const response = await axios.get(`${API_BASE_URL}/api/holdings`);
return response.data;
};
export const addHolding = async (dto: CryptoHoldingDto): Promise<CryptoHoldingDto> => {
const response = await axios.post(`${API_BASE_URL}/api/holdings`, dto);
return response.data;
};
export const removeHolding = async (symbol: string): Promise<void> => {
await axios.delete(`${API_BASE_URL}/api/holdings/${symbol}`);
};
export const toggleHolding = async (dto: CryptoHoldingDto): Promise<boolean> => {
const response = await axios.post(`${API_BASE_URL}/api/holdings/toggle`, dto);
return response.data.isHolding;
};
@@ -0,0 +1,130 @@
import axios from 'axios';
import { API_BASE_URL } from './apiConfig';
export interface StrategyStats {
strategyId: number;
strategyName: string;
strategyType: string;
avgReturnRate: number;
avgWinRate: number;
avgMaxDrawdown: number;
avgSharpeRatio: number;
analysisCount: number;
totalTrades: number;
bestReturn?: number;
worstReturn?: number;
}
export interface StrategyPerformance {
id: number;
strategyId: number;
strategyName: string;
strategyType: string;
market: string;
timeUnit: string;
analysisPeriodStart: string;
analysisPeriodEnd: string;
initialCapital: number;
finalCapital: number;
netProfit: number;
returnRate: number;
totalTrades: number;
buyCount: number;
sellCount: number;
winningTrades: number;
losingTrades: number;
winRate: number;
maxDrawdown: number;
sharpeRatio: number;
analysisDate: string;
}
export interface DataCollectionSummary {
totalProcessedFiles: number;
totalRecords: number;
successCount: number;
failCount: number;
skipCount: number;
lastProcessedAt?: string;
marketCounts: Record<string, number>;
timeUnitCounts: Record<string, number>;
}
export interface DashboardSummary {
totalMarkets: number;
totalTimeUnits: number;
totalCandleRecords: number;
totalProcessedFiles: number;
strategyStats: StrategyStats[];
topReturnStrategy?: StrategyPerformance;
topWinRateStrategy?: StrategyPerformance;
topSharpeStrategy?: StrategyPerformance;
recentAnalyses: StrategyPerformance[];
dataCollectionSummary: DataCollectionSummary;
}
export interface TimeUnitDataStatus {
timeUnit: string;
recordCount: number;
fileCount: number;
minDate: string | null;
maxDate: string | null;
daysCovered: number;
}
export interface MarketDataStatus {
market: string;
timeUnitStatus: Record<string, TimeUnitDataStatus>;
totalRecords: number;
totalFiles: number;
oldestDate: string | null;
newestDate: string | null;
}
export const dashboardApi = {
getSummary: async (): Promise<DashboardSummary> => {
const response = await axios.get(`${API_BASE_URL}/dashboard/summary`);
return response.data;
},
getStrategyStats: async (): Promise<StrategyStats[]> => {
const response = await axios.get(`${API_BASE_URL}/dashboard/strategy-stats`);
return response.data;
},
getStrategyPerformances: async (strategyId: number): Promise<StrategyPerformance[]> => {
const response = await axios.get(`${API_BASE_URL}/dashboard/performance/${strategyId}`);
return response.data;
},
getPerformancesByMarket: async (market: string): Promise<StrategyPerformance[]> => {
const response = await axios.get(`${API_BASE_URL}/dashboard/performance/market/${market}`);
return response.data;
},
getAvailableMarkets: async (): Promise<string[]> => {
const response = await axios.get(`${API_BASE_URL}/dashboard/markets`);
return response.data;
},
getAvailableTimeUnits: async (market: string): Promise<string[]> => {
const response = await axios.get(`${API_BASE_URL}/dashboard/time-units/${market}`);
return response.data;
},
getMarketDataStatus: async (): Promise<MarketDataStatus[]> => {
const response = await axios.get(`${API_BASE_URL}/dashboard/data-status`);
return response.data;
},
getMarketDataStatusByMarket: async (market: string): Promise<MarketDataStatus> => {
const response = await axios.get(`${API_BASE_URL}/dashboard/data-status/${market}`);
return response.data;
},
getTimeUnitDataStatus: async (): Promise<Record<string, any>> => {
const response = await axios.get(`${API_BASE_URL}/dashboard/data-status/by-timeunit`);
return response.data;
},
};
@@ -0,0 +1,240 @@
import axios from 'axios';
import { API_BASE_URL } from './apiConfig';
export interface FolderInfo {
path: string;
name: string;
type: string;
fileCount: number;
totalSize: number;
processedCount: number;
pendingCount: number;
hasChildren: boolean; // 하위 폴더/파일 존재 여부 (lazy loading용)
/** 업비트 crix-data (type FILE) */
downloadUrl?: string;
children?: FolderInfo[];
}
export interface FileInfo {
filePath: string;
fileName: string;
market: string;
timeUnit: string;
dataDate: string;
year: number;
fileSize: number;
isProcessed: boolean;
processedAt?: string;
status?: string;
recordCount?: number;
}
export interface DataCollectionRequest {
targetPath?: string;
markets?: string[];
timeUnits?: string[];
processAll?: boolean;
maxFiles?: number;
}
export interface DataCollectionStatus {
isRunning: boolean;
totalFiles: number;
processedFiles: number;
successCount: number;
failCount: number;
skipCount: number;
totalRecords: number;
progressPercent: number;
currentFile: string;
startTime: string;
estimatedEndTime?: string;
recentLogs: string[];
errorMessage?: string;
// 종목 기반 진행률
totalMarkets: number;
processedMarkets: number;
currentMarket: string;
}
export interface DataUpdateHistory {
id: number;
filePath: string;
fileName: string;
market: string;
timeUnit: string;
dataDate: string;
year: number;
recordCount: number;
fileSize: number;
status: string;
errorMessage?: string;
processedAt: string;
processingDurationMs: number;
}
export interface DataCollectionSummary {
totalProcessedFiles: number;
totalRecords: number;
successCount: number;
failCount: number;
skipCount: number;
lastProcessedAt?: string;
marketCounts: Record<string, number>;
timeUnitCounts: Record<string, number>;
}
export interface UpbitArchiveImportResult {
success: boolean;
message: string;
recordCount: number;
durationMs: number;
logicalPath?: string;
batchTotalZips?: number;
batchSuccessZips?: number;
batchFailZips?: number;
batchSkipZips?: number;
}
export interface UpbitImportProgress {
jobId: string;
phase: string;
percent: number;
message: string;
done: boolean;
/** 완료 시 */
result?: UpbitArchiveImportResult;
/** 날짜·봉 배치 */
totalPlanned?: number;
currentIndex?: number;
}
export interface UpbitImportStartResult {
jobId: string;
}
export interface UpbitByDateBatchPreview {
year: number;
month: number;
/** 있으면 해당 일만, 없으면 월 전체(가능한 일) */
day?: number | null;
marketCount: number;
totalZips: number;
message: string;
}
export const dataCollectionApi = {
getFolderStructure: async (path?: string): Promise<FolderInfo> => {
const response = await axios.get(`${API_BASE_URL}/data-collection/folders`, {
params: { path },
});
return response.data;
},
getPendingFiles: async (path?: string, limit?: number): Promise<FileInfo[]> => {
const response = await axios.get(`${API_BASE_URL}/data-collection/files`, {
params: { path, limit },
});
return response.data;
},
startCollection: async (request: DataCollectionRequest): Promise<DataCollectionStatus> => {
const response = await axios.post(`${API_BASE_URL}/data-collection/process`, request);
return response.data;
},
stopCollection: async (): Promise<DataCollectionStatus> => {
const response = await axios.post(`${API_BASE_URL}/data-collection/stop`);
return response.data;
},
getStatus: async (): Promise<DataCollectionStatus> => {
const response = await axios.get(`${API_BASE_URL}/data-collection/status`);
return response.data;
},
getHistory: async (limit?: number): Promise<DataUpdateHistory[]> => {
const response = await axios.get(`${API_BASE_URL}/data-collection/history`, {
params: { limit },
});
return response.data;
},
getSummary: async (): Promise<DataCollectionSummary> => {
const response = await axios.get(`${API_BASE_URL}/data-collection/summary`);
return response.data;
},
/** 업비트 crix-data — 가상 경로 한 단계 자식(마켓/봉/연/월, 월은 일 zip 목록) */
browseUpbitArchive: async (path?: string): Promise<FolderInfo> => {
const response = await axios.get(`${API_BASE_URL}/data-collection/upbit-archive/browse`, {
params: { path: path || 'upbit-site' },
});
return response.data;
},
importUpbitArchive: async (path: string, reprocess: boolean): Promise<UpbitArchiveImportResult> => {
const response = await axios.post(`${API_BASE_URL}/data-collection/upbit-archive/import`, {
path,
reprocess,
});
return response.data;
},
startUpbitImport: async (path: string, reprocess: boolean): Promise<UpbitImportStartResult> => {
const response = await axios.post(`${API_BASE_URL}/data-collection/upbit-archive/import/start`, {
path,
reprocess,
});
return response.data;
},
getUpbitImportProgress: async (jobId: string): Promise<UpbitImportProgress> => {
const response = await axios.get(
`${API_BASE_URL}/data-collection/upbit-archive/import/progress/${encodeURIComponent(jobId)}`
);
return response.data;
},
previewUpbitByDate: async (params: {
year: number;
month: number;
timeUnits: string[];
day?: number;
}): Promise<UpbitByDateBatchPreview> => {
const search = new URLSearchParams();
search.set('year', String(params.year));
search.set('month', String(params.month));
if (params.day != null && params.day > 0) {
search.set('day', String(params.day));
}
for (const t of params.timeUnits) {
search.append('timeUnits', t);
}
const response = await axios.get(
`${API_BASE_URL}/data-collection/upbit-archive/by-date/preview?${search.toString()}`
);
return response.data;
},
startUpbitImportByDate: async (body: {
year: number;
month: number;
timeUnits: string[];
reprocess: boolean;
day?: number;
}): Promise<UpbitImportStartResult> => {
const response = await axios.post(
`${API_BASE_URL}/data-collection/upbit-archive/by-date/import/start`,
{
year: body.year,
month: body.month,
timeUnits: body.timeUnits,
reprocess: body.reprocess,
...(body.day != null && body.day > 0 ? { day: body.day } : {}),
}
);
return response.data;
},
};
@@ -0,0 +1,105 @@
import axios from 'axios';
import { API_BASE_URL } from './apiConfig';
const base = `${API_BASE_URL}/db-explorer`;
export interface DbExplorerConnection {
id: string;
label: string;
kind: string;
jdbcMasked?: string;
createdAt?: string;
lastAccessAt?: string;
}
export interface DbExplorerTable {
name: string;
approxSizeKb?: number | null;
tableRowsApprox?: number | null;
}
export interface DbExplorerColumn {
name: string;
dataType: string;
nullable: string;
}
export interface DbExplorerRowsResponse {
columnNames: string[];
rows: Record<string, unknown>[];
total: number;
page: number;
size: number;
}
export interface ConnectBody {
label: string;
host: string;
port: number;
database: string;
username: string;
password: string;
useSsl?: boolean;
allowPublicKeyRetrieval?: boolean;
}
function errMessage(e: unknown): string {
const ax = e as {
response?: { status?: number; data?: { message?: string; error?: string } };
message?: string;
};
const data = ax?.response?.data;
const m = typeof data === 'object' && data !== null ? data.message || data.error : undefined;
return m || ax?.message || '요청 실패';
}
export const dbExplorerApi = {
listConnections: async (): Promise<DbExplorerConnection[]> => {
const { data } = await axios.get<DbExplorerConnection[]>(`${base}/connections`);
return data;
},
connect: async (body: ConnectBody): Promise<DbExplorerConnection> => {
const { data } = await axios.post<DbExplorerConnection>(`${base}/connections`, body);
return data;
},
disconnect: async (connectionId: string): Promise<void> => {
await axios.delete(`${base}/connections/${encodeURIComponent(connectionId)}`);
},
listTables: async (connectionId: string): Promise<DbExplorerTable[]> => {
const { data } = await axios.get<DbExplorerTable[]>(
`${base}/connections/${encodeURIComponent(connectionId)}/tables`
);
return data;
},
listColumns: async (connectionId: string, tableName: string): Promise<DbExplorerColumn[]> => {
const { data } = await axios.get<DbExplorerColumn[]>(
`${base}/connections/${encodeURIComponent(connectionId)}/tables/${encodeURIComponent(tableName)}/columns`
);
return data;
},
fetchRows: async (
connectionId: string,
tableName: string,
params: {
page?: number;
size?: number;
sortColumn?: string;
sortDir?: 'ASC' | 'DESC';
searchColumn?: string;
search?: string;
}
): Promise<DbExplorerRowsResponse> => {
const { data } = await axios.get<DbExplorerRowsResponse>(
`${base}/connections/${encodeURIComponent(connectionId)}/tables/${encodeURIComponent(tableName)}/rows`,
{ params }
);
return data;
},
errMessage,
};
@@ -0,0 +1,74 @@
import axios from 'axios';
import { getApiBaseUrl } from './apiConfig';
const API_BASE_URL = getApiBaseUrl().replace('/api', ''); // /api 제거
export interface MAPriceType {
value: string;
koreanName: string;
description: string;
}
export interface IndicatorCalculationSetting {
id: number;
indicatorType: string;
indicatorName: string;
priceType: string;
priceTypeName: string;
priceTypeDescription: string;
description: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
/**
* 모든 보조지표 계산 설정 조회
*/
export const getAllSettings = async (): Promise<IndicatorCalculationSetting[]> => {
const response = await axios.get(`${API_BASE_URL}/api/indicator-calculation-settings`);
return response.data;
};
/**
* 특정 지표의 계산 설정 조회
*/
export const getSetting = async (indicatorType: string): Promise<IndicatorCalculationSetting> => {
const response = await axios.get(`${API_BASE_URL}/api/indicator-calculation-settings/${indicatorType}`);
return response.data;
};
/**
* 보조지표 계산 설정 수정
*/
export const updateSetting = async (
id: number,
data: Partial<IndicatorCalculationSetting>
): Promise<IndicatorCalculationSetting> => {
const response = await axios.put(`${API_BASE_URL}/api/indicator-calculation-settings/${id}`, data);
return response.data;
};
/**
* 사용 가능한 가격 타입 목록 조회
*/
export const getPriceTypes = async (): Promise<MAPriceType[]> => {
const response = await axios.get(`${API_BASE_URL}/api/indicator-calculation-settings/price-types`);
return response.data;
};
/**
* 설정 초기화 (기본값으로 복원)
*/
export const resetToDefaults = async (): Promise<string> => {
const response = await axios.post(`${API_BASE_URL}/api/indicator-calculation-settings/reset`);
return response.data;
};
/**
* 권장 설정 적용 (골든/데드크로스 감지 최적화)
*/
export const applyRecommended = async (): Promise<string> => {
const response = await axios.post(`${API_BASE_URL}/api/indicator-calculation-settings/apply-recommended`);
return response.data;
};
@@ -0,0 +1,111 @@
import axios from 'axios';
import { API_CONFIG } from './apiConfig';
const API_BASE_URL = API_CONFIG.baseURL;
/**
* 보조지표 메타데이터 DTO
*/
export interface IndicatorMetadataDto {
id: number;
indicatorType: string;
displayName: string;
category: string;
description?: string;
minValue: number;
maxValue: number;
defaultValue: number;
step: number;
targetValueRequired: boolean;
helperText?: string;
allowedConditions: string;
displayOrder: number;
enabled: boolean;
}
/**
* 활성화된 보조지표 목록 조회 (전략 설정용)
*/
export const getEnabledIndicators = async (): Promise<IndicatorMetadataDto[]> => {
const response = await axios.get(`${API_BASE_URL}/indicator-metadata`);
return response.data;
};
/**
* 카테고리별 보조지표 목록 조회
*/
export const getIndicatorsByCategory = async (category: string): Promise<IndicatorMetadataDto[]> => {
const response = await axios.get(`${API_BASE_URL}/indicator-metadata/category/${category}`);
return response.data;
};
/**
* 카테고리 목록 조회
*/
export const getIndicatorCategories = async (): Promise<string[]> => {
const response = await axios.get(`${API_BASE_URL}/indicator-metadata/categories`);
return response.data;
};
/**
* 모든 보조지표 목록 조회 (관리용)
*/
export const getAllIndicators = async (): Promise<IndicatorMetadataDto[]> => {
const response = await axios.get(`${API_BASE_URL}/indicator-metadata/all`);
return response.data;
};
/**
* 특정 지표의 허용 조건 목록 조회
*/
export const getAllowedConditions = async (indicatorType: string): Promise<string[]> => {
const response = await axios.get(`${API_BASE_URL}/indicator-metadata/${indicatorType}/conditions`);
return response.data;
};
/**
* 보조지표 저장 (관리용)
*/
export const saveIndicator = async (indicator: Partial<IndicatorMetadataDto>): Promise<IndicatorMetadataDto> => {
const response = await axios.post(`${API_BASE_URL}/indicator-metadata`, indicator);
return response.data;
};
/**
* 보조지표 수정 (관리용)
*/
export const updateIndicator = async (id: number, indicator: Partial<IndicatorMetadataDto>): Promise<IndicatorMetadataDto> => {
const response = await axios.put(`${API_BASE_URL}/indicator-metadata/${id}`, indicator);
return response.data;
};
/**
* 보조지표 삭제 (관리용)
*/
export const deleteIndicator = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/indicator-metadata/${id}`);
};
/**
* 보조지표 활성화/비활성화 토글 (관리용)
*/
export const toggleIndicator = async (id: number, enabled: boolean): Promise<IndicatorMetadataDto> => {
const response = await axios.patch(`${API_BASE_URL}/indicator-metadata/${id}/toggle`, null, {
params: { enabled }
});
return response.data;
};
/**
* 카테고리 한글명 매핑
*/
export const getCategoryDisplayName = (category: string): string => {
const names: Record<string, string> = {
'MOMENTUM': '모멘텀 지표',
'TREND': '추세 지표',
'VOLUME': '거래량 지표',
'VOLATILITY': '변동성 지표',
'PSYCHOLOGY': '심리 지표',
};
return names[category] || category;
};
@@ -0,0 +1,158 @@
import axios from 'axios';
import { API_BASE_URL } from './apiConfig';
// 기간 정보 타입
export interface PeriodInfo {
id: number;
period: number;
isDefault: boolean;
enabled: boolean;
displayOrder: number;
}
// 지표별 기간 응답 타입
export interface IndicatorPeriodsResponse {
indicatorType: string;
indicatorDisplayName: string;
periods: PeriodInfo[];
defaultPeriod: number | null;
}
// 기간 요청 타입
export interface PeriodRequest {
indicatorType: string;
period: number;
isDefault?: boolean;
displayOrder?: number;
enabled?: boolean;
}
// 기간 설정 DTO
export interface IndicatorPeriodSettingDto {
id: number;
indicatorType: string;
period: number;
isDefault: boolean;
displayOrder: number;
enabled: boolean;
}
// 기간이 필요 없는 지표 목록
export const PERIOD_NOT_REQUIRED_INDICATORS = [
'MACD', 'MACD_HISTOGRAM'
];
/**
* 기간이 필요한 지표인지 확인
*/
export const isPeriodRequired = (indicatorType: string): boolean => {
return !PERIOD_NOT_REQUIRED_INDICATORS.includes(indicatorType);
};
/**
* 모든 지표의 기간 설정 조회
*/
export const getAllIndicatorPeriods = async (): Promise<IndicatorPeriodsResponse[]> => {
const response = await axios.get(`${API_BASE_URL}/indicator-periods`);
return response.data;
};
/**
* 특정 지표의 기간 설정 조회
*/
export const getIndicatorPeriods = async (indicatorType: string): Promise<IndicatorPeriodsResponse> => {
const response = await axios.get(`${API_BASE_URL}/indicator-periods/${indicatorType}`);
return response.data;
};
/**
* 특정 지표의 활성화된 기간 목록 조회 (드롭다운용)
*/
export const getEnabledPeriods = async (indicatorType: string): Promise<number[]> => {
const response = await axios.get(`${API_BASE_URL}/indicator-periods/${indicatorType}/enabled`);
return response.data;
};
/**
* 모든 지표의 활성화된 기간 맵 조회
*/
export const getAllEnabledPeriodsMap = async (): Promise<Record<string, number[]>> => {
const response = await axios.get(`${API_BASE_URL}/indicator-periods/map`);
return response.data;
};
/**
* 기간 추가
*/
export const addPeriod = async (request: PeriodRequest): Promise<IndicatorPeriodSettingDto> => {
const response = await axios.post(`${API_BASE_URL}/indicator-periods`, request);
return response.data;
};
/**
* 기간 수정
*/
export const updatePeriod = async (id: number, request: Partial<PeriodRequest>): Promise<IndicatorPeriodSettingDto> => {
const response = await axios.put(`${API_BASE_URL}/indicator-periods/${id}`, request);
return response.data;
};
/**
* 기간 삭제
*/
export const deletePeriod = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/indicator-periods/${id}`);
};
/**
* 지표 타입 표시명 가져오기 - 업비트 표준 16개
*/
export const getIndicatorDisplayName = (indicatorType: string): string => {
const displayNames: Record<string, string> = {
VOLUME: '거래량',
MACD: 'MACD',
STOCHASTIC: 'Stochastic Slow',
RSI: 'RSI',
ADX: 'ADX',
CCI: 'CCI',
TRIX: 'TRIX',
DISPARITY: '이격도',
NEW_PSYCHOLOGICAL: '신심리도',
INVEST_PSYCHOLOGICAL: '투자심리도',
WILLIAMS_R: 'Williams %R',
BWI: '볼린저밴드 %B',
OBV: 'OBV',
VOLUME_OSC: 'VolumeOSC',
VR: 'VR',
DMI: 'DMI',
};
return displayNames[indicatorType] || indicatorType;
};
// 캐시된 기간 맵 (성능 최적화)
let cachedPeriodsMap: Record<string, number[]> | null = null;
let cacheTimestamp: number = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5분
/**
* 캐시된 기간 맵 조회 (성능 최적화)
*/
export const getCachedPeriodsMap = async (): Promise<Record<string, number[]>> => {
const now = Date.now();
if (cachedPeriodsMap && (now - cacheTimestamp) < CACHE_DURATION) {
return cachedPeriodsMap;
}
cachedPeriodsMap = await getAllEnabledPeriodsMap();
cacheTimestamp = now;
return cachedPeriodsMap;
};
/**
* 캐시 무효화
*/
export const invalidatePeriodsCache = (): void => {
cachedPeriodsMap = null;
cacheTimestamp = 0;
};
@@ -0,0 +1,349 @@
import axios from 'axios';
// Docker 환경에서는 Nginx 프록시를 통해 상대 경로로 API 호출
// 개발 환경에서 직접 백엔드 연결이 필요한 경우 REACT_APP_API_BASE_URL 환경 변수 사용
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '';
const api = axios.create({
baseURL: API_BASE_URL,
withCredentials: true,
});
// 인증 토큰을 헤더에 추가하는 인터셉터
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// ==================== 로그인 사용자용 API ====================
export const getUserIndicatorSettings = async (): Promise<string> => {
try {
const response = await api.get<string>('/api/settings/indicator-visibility');
console.log('🔍 사용자 보조지표 설정 API 응답:', response.data, 'type:', typeof response.data);
// 응답이 객체인 경우 문자열로 변환
if (typeof response.data === 'object' && response.data !== null) {
return JSON.stringify(response.data);
}
return response.data || '{}';
} catch (error) {
console.error('보조지표 설정 조회 실패:', error);
return '{}';
}
};
export const saveUserIndicatorSettings = async (settings: string): Promise<void> => {
try {
await api.post('/api/settings/indicator-visibility', { settings });
console.log('보조지표 설정 저장 성공');
} catch (error) {
console.error('보조지표 설정 저장 실패:', error);
throw error;
}
};
export const deleteUserIndicatorSettings = async (): Promise<void> => {
try {
await api.delete('/api/settings/indicator-visibility');
} catch (error) {
console.error('보조지표 설정 삭제 실패:', error);
throw error;
}
};
// ==================== 게스트(비로그인) 사용자용 API ====================
// 디바이스 ID 생성 및 관리
const DEVICE_ID_KEY = 'guest_device_id';
// UUID v4 생성 함수 (crypto.randomUUID의 폴백 포함)
const generateUUID = (): string => {
// crypto.randomUUID가 지원되는 경우 (HTTPS 또는 localhost)
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
try {
return crypto.randomUUID();
} catch (e) {
console.warn('crypto.randomUUID 사용 실패, 폴백 메서드 사용:', e);
}
}
// 폴백: UUID v4 형식 생성 (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
export const getDeviceId = (): string => {
let deviceId = localStorage.getItem(DEVICE_ID_KEY);
if (!deviceId) {
// UUID 형식의 고유 ID 생성
deviceId = 'guest_' + generateUUID();
localStorage.setItem(DEVICE_ID_KEY, deviceId);
}
return deviceId;
};
export const getGuestIndicatorSettings = async (): Promise<string> => {
try {
const deviceId = getDeviceId();
const response = await api.get<string>(`/api/settings/indicator-visibility/guest/${deviceId}`);
// 응답이 객체인 경우 문자열로 변환
if (typeof response.data === 'object' && response.data !== null) {
return JSON.stringify(response.data);
}
return response.data || '{}';
} catch (error: any) {
// 403/404 에러는 정상(게스트 설정이 없는 경우) - 경고 수준 로그만
if (error?.response?.status === 403 || error?.response?.status === 404) {
console.warn('게스트 보조지표 설정 없음 (정상)');
} else {
console.error('게스트 보조지표 설정 조회 실패:', error);
}
return '{}';
}
};
export const saveGuestIndicatorSettings = async (settings: string): Promise<void> => {
try {
const deviceId = getDeviceId();
await api.post(`/api/settings/indicator-visibility/guest/${deviceId}`, { settings });
console.log('게스트 보조지표 설정 저장 성공');
} catch (error: any) {
// 403 에러는 권한 문제 - 조용히 무시
if (error?.response?.status === 403) {
console.warn('게스트 보조지표 설정 저장 권한 없음 (정상)');
return;
}
console.error('게스트 보조지표 설정 저장 실패:', error);
throw error;
}
};
export const deleteGuestIndicatorSettings = async (): Promise<void> => {
try {
const deviceId = getDeviceId();
await api.delete(`/api/settings/indicator-visibility/guest/${deviceId}`);
} catch (error) {
console.error('게스트 보조지표 설정 삭제 실패:', error);
throw error;
}
};
// ==================== 차트 색상 설정 API ====================
// 로그인 사용자 - 차트 색상 설정 조회
export const getUserChartColorSettings = async (): Promise<string> => {
try {
const response = await api.get<string>('/api/settings/chart-colors');
console.log('🔍 사용자 차트 색상 설정 API 응답:', response.data, 'type:', typeof response.data);
// 응답이 객체인 경우 문자열로 변환
if (typeof response.data === 'object' && response.data !== null) {
return JSON.stringify(response.data);
}
return response.data || '{}';
} catch (error) {
console.error('차트 색상 설정 조회 실패:', error);
return '{}';
}
};
// 로그인 사용자 - 차트 색상 설정 저장
export const saveUserChartColorSettings = async (settings: string): Promise<void> => {
try {
await api.post('/api/settings/chart-colors', { settings });
console.log('차트 색상 설정 저장 성공');
} catch (error) {
console.error('차트 색상 설정 저장 실패:', error);
throw error;
}
};
// 게스트 사용자 - 차트 색상 설정 조회
export const getGuestChartColorSettings = async (): Promise<string> => {
try {
const deviceId = getDeviceId();
const response = await api.get<string>(`/api/settings/chart-colors/guest/${deviceId}`);
console.log('🔍 게스트 차트 색상 설정 API 응답:', response.data, 'type:', typeof response.data);
// 응답이 객체인 경우 문자열로 변환
if (typeof response.data === 'object' && response.data !== null) {
return JSON.stringify(response.data);
}
return response.data || '{}';
} catch (error) {
console.error('게스트 차트 색상 설정 조회 실패:', error);
return '{}';
}
};
// 게스트 사용자 - 차트 색상 설정 저장
export const saveGuestChartColorSettings = async (settings: string): Promise<void> => {
try {
const deviceId = getDeviceId();
await api.post(`/api/settings/chart-colors/guest/${deviceId}`, { settings });
console.log('게스트 차트 색상 설정 저장 성공');
} catch (error) {
console.error('게스트 차트 색상 설정 저장 실패:', error);
throw error;
}
};
// ==================== 사용자 정의 기준선 API ====================
// 로그인 사용자 - 기준선 설정 조회
export const getUserCustomReferenceLines = async (): Promise<string> => {
try {
const response = await api.get<string>('/api/settings/reference-lines');
console.log('🔍 사용자 기준선 설정 API 응답:', response.data, 'type:', typeof response.data);
// 응답이 객체인 경우 문자열로 변환
if (typeof response.data === 'object' && response.data !== null) {
return JSON.stringify(response.data);
}
return response.data || '{}';
} catch (error) {
console.error('기준선 설정 조회 실패:', error);
return '{}';
}
};
// 로그인 사용자 - 기준선 설정 저장
export const saveUserCustomReferenceLines = async (referenceLines: string): Promise<void> => {
try {
await api.post('/api/settings/reference-lines', { referenceLines });
console.log('기준선 설정 저장 성공');
} catch (error) {
console.error('기준선 설정 저장 실패:', error);
throw error;
}
};
// 게스트 사용자 - 기준선 설정 조회
export const getGuestCustomReferenceLines = async (): Promise<string> => {
try {
const deviceId = getDeviceId();
const response = await api.get<string>(`/api/settings/reference-lines/guest/${deviceId}`);
// 응답이 객체인 경우 문자열로 변환
if (typeof response.data === 'object' && response.data !== null) {
return JSON.stringify(response.data);
}
return response.data || '{}';
} catch (error: any) {
// 403/404 에러는 정상(게스트 설정이 없는 경우) - 경고 수준 로그만
if (error?.response?.status === 403 || error?.response?.status === 404) {
console.warn('게스트 기준선 설정 없음 (정상)');
} else {
console.error('게스트 기준선 설정 조회 실패:', error);
}
return '{}';
}
};
// 게스트 사용자 - 기준선 설정 저장
export const saveGuestCustomReferenceLines = async (referenceLines: string): Promise<void> => {
try {
const deviceId = getDeviceId();
await api.post(`/api/settings/reference-lines/guest/${deviceId}`, { referenceLines });
console.log('게스트 기준선 설정 저장 성공');
} catch (error: any) {
// 403 에러는 권한 문제 - 조용히 무시
if (error?.response?.status === 403) {
console.warn('게스트 기준선 설정 저장 권한 없음 (정상)');
return;
}
console.error('게스트 기준선 설정 저장 실패:', error);
throw error;
}
};
// ==================== 보조지표 기간 설정 API ====================
// 로그인 사용자 - 보조지표 기간 설정 조회
export const getUserIndicatorPeriods = async (): Promise<string> => {
try {
const response = await api.get<string>('/api/settings/indicator-periods');
console.log('🔍 사용자 보조지표 기간 설정 API 응답:', response.data, 'type:', typeof response.data);
// 응답이 객체인 경우 문자열로 변환
if (typeof response.data === 'object' && response.data !== null) {
return JSON.stringify(response.data);
}
return response.data || '{}';
} catch (error) {
console.error('보조지표 기간 설정 조회 실패:', error);
return '{}';
}
};
// 로그인 사용자 - 보조지표 기간 설정 저장
export const saveUserIndicatorPeriods = async (periods: string): Promise<void> => {
try {
await api.post('/api/settings/indicator-periods', { periods });
console.log('✅ 보조지표 기간 설정 저장 성공');
} catch (error) {
console.error('❌ 보조지표 기간 설정 저장 실패:', error);
throw error;
}
};
// 게스트 사용자 - 보조지표 기간 설정 조회
export const getGuestIndicatorPeriods = async (): Promise<string> => {
try {
const deviceId = getDeviceId();
const response = await api.get<string>(`/api/settings/indicator-periods/guest/${deviceId}`);
// 응답이 객체인 경우 문자열로 변환
if (typeof response.data === 'object' && response.data !== null) {
return JSON.stringify(response.data);
}
return response.data || '{}';
} catch (error: any) {
// 403/404 에러는 정상(게스트 설정이 없는 경우) - 경고 수준 로그만
if (error?.response?.status === 403 || error?.response?.status === 404) {
console.warn('게스트 보조지표 기간 설정 없음 (정상)');
} else {
console.error('게스트 보조지표 기간 설정 조회 실패:', error);
}
return '{}';
}
};
// 게스트 사용자 - 보조지표 기간 설정 저장
export const saveGuestIndicatorPeriods = async (periods: string): Promise<void> => {
try {
const deviceId = getDeviceId();
await api.post(`/api/settings/indicator-periods/guest/${deviceId}`, { periods });
console.log('✅ 게스트 보조지표 기간 설정 저장 성공');
} catch (error: any) {
// 403 에러는 권한 문제 - 조용히 무시
if (error?.response?.status === 403) {
console.warn('게스트 보조지표 기간 설정 저장 권한 없음 (정상)');
return;
}
console.error('❌ 게스트 보조지표 기간 설정 저장 실패:', error);
throw error;
}
};
@@ -0,0 +1,114 @@
/**
* 투자분석 화면 설정 저장 방식 및 파일 저장
* - 저장 방식: 'file' (기본) | 'db'
* - file 모드: localStorage 기반 설정 파일
*/
import type { IndicatorVisibilitySettings } from '../contexts/IndicatorVisibilityContext';
import type { IndicatorOrder } from '../contexts/IndicatorVisibilityContext';
export type InvestmentAnalysisStorageMode = 'file' | 'db';
const STORAGE_MODE_KEY = 'investmentAnalysisSettingsStorageMode';
const CONFIG_FILE_KEY = 'investmentAnalysisSettingsFile';
/** 추세 판단 전략 */
export type TrendStrategyType = 'none' | 'aggressive' | 'balanced' | 'conservative';
export interface InvestmentAnalysisConfig {
version: number;
strategyType?: string; // 하위 호환
timeInterval: string;
/** 추세 판단 전략 */
trendStrategy?: TrendStrategyType;
/** 추세 사용안함 시: 일반 투자전략 ID */
generalStrategyId?: number | null;
/** 정배열 전략 ID */
uptrendStrategyId?: number | null;
/** 역배열 전략 ID */
downtrendStrategyId?: number | null;
indicatorSettings: Partial<IndicatorVisibilitySettings>;
indicatorOrder: IndicatorOrder[];
exportedAt: string;
}
const DEFAULT_STRATEGY = 'TREND_STOCHASTIC';
const DEFAULT_TIME_INTERVAL = '5m';
/** 저장 방식 조회 (기본값: file) */
export const getStorageMode = (): InvestmentAnalysisStorageMode => {
try {
const mode = localStorage.getItem(STORAGE_MODE_KEY);
return (mode === 'db' ? 'db' : 'file') as InvestmentAnalysisStorageMode;
} catch {
return 'file';
}
};
/** 저장 방식 설정 */
export const setStorageMode = (mode: InvestmentAnalysisStorageMode): void => {
try {
localStorage.setItem(STORAGE_MODE_KEY, mode);
} catch (e) {
console.error('저장 방식 설정 실패:', e);
}
};
/** 파일 모드: 설정 로드 */
export const loadFromFile = (): Partial<InvestmentAnalysisConfig> | null => {
try {
const raw = localStorage.getItem(CONFIG_FILE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as InvestmentAnalysisConfig;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
};
/** 파일 모드: 설정 저장 */
export const saveToFile = (config: Partial<InvestmentAnalysisConfig>): void => {
try {
const toSave: InvestmentAnalysisConfig = {
version: 1,
strategyType: config.strategyType ?? DEFAULT_STRATEGY,
timeInterval: config.timeInterval ?? DEFAULT_TIME_INTERVAL,
trendStrategy: config.trendStrategy,
generalStrategyId: config.generalStrategyId ?? null,
uptrendStrategyId: config.uptrendStrategyId ?? null,
downtrendStrategyId: config.downtrendStrategyId ?? null,
indicatorSettings: config.indicatorSettings ?? {},
indicatorOrder: config.indicatorOrder ?? [],
exportedAt: new Date().toISOString(),
...config,
};
localStorage.setItem(CONFIG_FILE_KEY, JSON.stringify(toSave, null, 2));
} catch (e) {
console.error('설정 파일 저장 실패:', e);
}
};
/** 설정 파일 다운로드 (JSON) */
export const downloadConfigFile = (config: InvestmentAnalysisConfig): void => {
const json = JSON.stringify(config, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `investment-analysis-config-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
/** 설정 파일 업로드 후 적용 */
export const applyConfigFromFile = (content: string): { success: boolean; message: string } => {
try {
const parsed = JSON.parse(content) as Partial<InvestmentAnalysisConfig>;
if (!parsed || typeof parsed !== 'object') {
return { success: false, message: '잘못된 설정 파일 형식입니다.' };
}
saveToFile(parsed);
return { success: true, message: '설정이 적용되었습니다.' };
} catch {
return { success: false, message: '설정 파일을 읽을 수 없습니다.' };
}
};
@@ -0,0 +1,102 @@
/**
* Market Data API 서비스
* - 런타임 환경에 따라 동적으로 URL 결정
*/
const getMarketDataUrl = (): string => {
const envUrl = process.env.REACT_APP_MARKET_DATA_URL;
if (envUrl) return envUrl.replace(/\/$/, '');
// 런타임 환경 감지
if (typeof window === 'undefined') {
return '/market-data'; // SSR fallback
}
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '';
if (isLocalhost) {
// 로컬 개발 시 서버로 직접 연결
return 'http://localhost:8086';
}
// 서버 환경: 현재 origin 사용 (nginx가 프록시)
return `${window.location.origin}/market-data`;
};
export const MARKET_DATA_API_URL = getMarketDataUrl();
export const MARKET_DATA_WS_URL = MARKET_DATA_API_URL.replace('http', 'ws');
export interface CandleSnapshot {
timestamp: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface IndicatorSnapshot {
rsi_14?: number;
ema_9?: number;
ema_21?: number;
macd?: number;
macd_signal?: number;
macd_histogram?: number;
bb_upper?: number;
bb_middle?: number;
bb_lower?: number;
stoch_k?: number;
stoch_d?: number;
adx?: number;
plus_di?: number;
minus_di?: number;
}
export interface CandleSnapshotResponse {
market: string;
timeframe: string;
candles: CandleSnapshot[];
indicators: IndicatorSnapshot;
count: number;
}
/**
* 초기 캔들 스냅샷 가져오기
*/
export const fetchCandleSnapshot = async (
market: string,
timeframe: string,
limit: number = 200
): Promise<CandleSnapshotResponse> => {
const url = `${MARKET_DATA_API_URL}/candles?market=${market}&timeframe=${timeframe}&limit=${limit}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch candle snapshot: ${response.status}`);
}
return await response.json();
};
/**
* 서버 상태 확인
*/
export const checkHealth = async (): Promise<boolean> => {
try {
const response = await fetch(`${MARKET_DATA_API_URL}/health`);
const data = await response.json();
return data.status === 'healthy';
} catch {
return false;
}
};
/**
* 서버 통계 조회
*/
export const fetchStats = async (): Promise<any> => {
const response = await fetch(`${MARKET_DATA_API_URL}/stats`);
return await response.json();
};
@@ -0,0 +1,390 @@
/**
* Market WebSocket Service
* Spring Boot Backend STOMP WebSocket 연결 및 실시간 캔들 데이터 수신
*/
import SockJS from 'sockjs-client';
import { Client, IMessage, StompSubscription } from '@stomp/stompjs';
import { getApiBaseUrl } from './apiConfig';
const API_BASE_URL = getApiBaseUrl();
// SockJS는 HTTP/HTTPS URL을 받아서 내부적으로 WebSocket으로 변환
// 따라서 http:// 또는 https:// 프로토콜을 사용해야 함
const getWebSocketUrl = (): string => {
// API_BASE_URL에서 /api를 제거하고 /ws 추가
// 예: http://localhost:8083/api → http://localhost:8083/ws
const baseUrl = API_BASE_URL.replace('/api', '');
return `${baseUrl}/ws`;
};
const WS_URL = getWebSocketUrl();
export interface RealtimeCandleUpdate {
market: string;
interval: string;
candles: CandleUpdateData[];
timestamp: number;
type: 'snapshot' | 'update';
}
export interface CandleUpdateData {
time: string; // "2026-03-07T01:12"
open: number;
high: number;
low: number;
close: number;
volume: number;
isBuyPoint?: boolean;
isSellPoint?: boolean;
crossState?: string | null;
}
type CandleUpdateCallback = (update: RealtimeCandleUpdate) => void;
type ConnectionStatusCallback = (connected: boolean) => void;
type ErrorCallback = (error: Error) => void;
interface Subscription {
market: string;
interval: string;
callback: CandleUpdateCallback;
stompSubscription?: StompSubscription;
}
/**
* Market WebSocket Client
* STOMP over SockJS를 사용한 실시간 캔들 데이터 구독
*/
class MarketSocketClient {
private client: Client | null = null;
private subscriptions: Map<string, Subscription> = new Map();
private connectionStatusCallbacks: Set<ConnectionStatusCallback> = new Set();
private errorCallbacks: Set<ErrorCallback> = new Set();
private isConnecting = false;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 3000;
private reconnectTimer: NodeJS.Timeout | null = null;
constructor() {
console.log('[MarketSocket] Client initialized');
}
/**
* WebSocket 연결
*/
connect(): Promise<void> {
if (this.client?.connected) {
console.log('[MarketSocket] Already connected');
return Promise.resolve();
}
if (this.isConnecting) {
console.log('[MarketSocket] Connection in progress');
return Promise.resolve();
}
return new Promise((resolve, reject) => {
try {
this.isConnecting = true;
// SockJS 소켓 생성
const socket = new SockJS(WS_URL);
// STOMP 클라이언트 생성
this.client = new Client({
webSocketFactory: () => socket as any,
debug: (str) => {
if (process.env.NODE_ENV === 'development') {
console.log('[STOMP Debug]', str);
}
},
reconnectDelay: this.reconnectDelay,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
onConnect: () => {
console.log('✅ [MarketSocket] Connected to WebSocket');
this.isConnecting = false;
this.reconnectAttempts = 0;
this.notifyConnectionStatus(true);
this.resubscribeAll();
resolve();
},
onDisconnect: () => {
console.log('🔌 [MarketSocket] Disconnected from WebSocket');
this.isConnecting = false;
this.notifyConnectionStatus(false);
},
onStompError: (frame) => {
console.error('❌ [MarketSocket] STOMP error:', frame.headers['message']);
this.isConnecting = false;
this.notifyError(new Error(frame.headers['message'] || 'STOMP error'));
this.handleReconnect();
reject(new Error(frame.headers['message']));
},
onWebSocketError: (event) => {
console.error('❌ [MarketSocket] WebSocket error:', event);
this.isConnecting = false;
this.notifyError(new Error('WebSocket connection error'));
this.handleReconnect();
reject(new Error('WebSocket connection error'));
},
});
// 연결 시작
this.client.activate();
} catch (error) {
console.error('❌ [MarketSocket] Failed to connect:', error);
this.isConnecting = false;
this.notifyError(error as Error);
reject(error);
}
});
}
/**
* WebSocket 연결 해제
*/
disconnect(): void {
console.log('[MarketSocket] Disconnecting...');
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.subscriptions.clear();
if (this.client?.connected) {
this.client.deactivate();
}
this.client = null;
this.notifyConnectionStatus(false);
}
/**
* 자동 재연결 처리
*/
private handleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[MarketSocket] Max reconnect attempts reached');
return;
}
if (this.reconnectTimer) {
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1);
console.log(`[MarketSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect().catch((error) => {
console.error('[MarketSocket] Reconnect failed:', error);
});
}, delay);
}
/**
* 특정 마켓/타임프레임 구독
*
* @param market 마켓 코드 (예: KRW-BTC)
* @param interval 타임프레임 (예: 1m, 5m, 10m)
* @param callback 캔들 업데이트 콜백
* @returns 구독 해제 함수
*/
subscribe(
market: string,
interval: string,
callback: CandleUpdateCallback
): () => void {
const key = `${market}:${interval}`;
console.log(`[MarketSocket] Subscribing to ${key}`);
// 기존 구독 해제
if (this.subscriptions.has(key)) {
this.unsubscribe(market, interval);
}
// 구독 정보 저장
const subscription: Subscription = {
market,
interval,
callback,
};
this.subscriptions.set(key, subscription);
// 연결되어 있으면 즉시 구독
if (this.client?.connected) {
this.subscribeToTopic(subscription);
} else {
// 연결되지 않았으면 연결 시도
console.log('[MarketSocket] Not connected, connecting...');
this.connect().catch((error) => {
console.error('[MarketSocket] Failed to connect for subscription:', error);
});
}
// 구독 해제 함수 반환
return () => this.unsubscribe(market, interval);
}
/**
* STOMP 토픽 구독
*/
private subscribeToTopic(subscription: Subscription): void {
if (!this.client?.connected) {
console.warn('[MarketSocket] Cannot subscribe: not connected');
return;
}
const { market, interval, callback } = subscription;
const topic = `/topic/candles/${market}/${interval}`;
const key = `${market}:${interval}`;
try {
const stompSubscription = this.client.subscribe(topic, (message: IMessage) => {
try {
const update: RealtimeCandleUpdate = JSON.parse(message.body);
if (typeof window !== 'undefined') {
(window as any).__lastRealtimeUpdate = { raw: message.body, parsed: update, receivedAt: Date.now() };
}
console.log(`📊 [MarketSocket] Received update for ${market} ${interval}:`, {
type: update.type,
candleCount: update.candles?.length || 0,
timestamp: update.timestamp
});
callback(update);
} catch (error) {
console.error('[MarketSocket] Failed to parse message:', error);
this.notifyError(error as Error);
}
});
// STOMP 구독 객체 저장
const sub = this.subscriptions.get(key);
if (sub) {
sub.stompSubscription = stompSubscription;
}
console.log(`✅ [MarketSocket] Subscribed to ${topic}`);
} catch (error) {
console.error(`[MarketSocket] Failed to subscribe to ${topic}:`, error);
this.notifyError(error as Error);
}
}
/**
* 구독 해제
*/
unsubscribe(market: string, interval: string): void {
const key = `${market}:${interval}`;
const subscription = this.subscriptions.get(key);
if (!subscription) {
return;
}
console.log(`[MarketSocket] Unsubscribing from ${key}`);
// STOMP 구독 해제
if (subscription.stompSubscription) {
try {
subscription.stompSubscription.unsubscribe();
} catch (error) {
console.error('[MarketSocket] Failed to unsubscribe:', error);
}
}
this.subscriptions.delete(key);
}
/**
* 모든 구독 재구독 (재연결 시)
*/
private resubscribeAll(): void {
console.log(`[MarketSocket] Resubscribing to ${this.subscriptions.size} subscriptions`);
this.subscriptions.forEach((subscription) => {
this.subscribeToTopic(subscription);
});
}
/**
* 연결 상태 콜백 등록
*/
onConnectionStatusChange(callback: ConnectionStatusCallback): () => void {
this.connectionStatusCallbacks.add(callback);
// 현재 상태 즉시 전달
if (this.client?.connected) {
callback(true);
}
return () => this.connectionStatusCallbacks.delete(callback);
}
/**
* 에러 콜백 등록
*/
onError(callback: ErrorCallback): () => void {
this.errorCallbacks.add(callback);
return () => this.errorCallbacks.delete(callback);
}
/**
* 연결 상태 알림
*/
private notifyConnectionStatus(connected: boolean): void {
this.connectionStatusCallbacks.forEach((callback) => {
try {
callback(connected);
} catch (error) {
console.error('[MarketSocket] Error in connection status callback:', error);
}
});
}
/**
* 에러 알림
*/
private notifyError(error: Error): void {
this.errorCallbacks.forEach((callback) => {
try {
callback(error);
} catch (err) {
console.error('[MarketSocket] Error in error callback:', err);
}
});
}
/**
* 연결 상태 확인
*/
isConnected(): boolean {
return this.client?.connected || false;
}
/**
* 활성 구독 수
*/
getSubscriptionCount(): number {
return this.subscriptions.size;
}
}
// 전역 싱글톤 인스턴스
export const marketSocket = new MarketSocketClient();
// 개발 환경에서 디버깅용
if (process.env.NODE_ENV === 'development') {
(window as any).marketSocket = marketSocket;
}
@@ -0,0 +1,98 @@
/**
* 브라우저 알림 서비스
*/
let notificationPermission: NotificationPermission = 'default';
/**
* 알림 권한 요청
*/
export const requestNotificationPermission = async (): Promise<boolean> => {
if (!('Notification' in window)) {
console.warn('이 브라우저는 알림을 지원하지 않습니다.');
return false;
}
if (Notification.permission === 'granted') {
notificationPermission = 'granted';
return true;
}
if (Notification.permission === 'denied') {
notificationPermission = 'denied';
return false;
}
try {
const permission = await Notification.requestPermission();
notificationPermission = permission;
return permission === 'granted';
} catch (error) {
console.error('알림 권한 요청 실패:', error);
return false;
}
};
/**
* 브라우저 알림 표시
*/
export const showNotification = (title: string, options?: NotificationOptions): Notification | null => {
if (!('Notification' in window)) {
console.warn('이 브라우저는 알림을 지원하지 않습니다.');
return null;
}
if (Notification.permission !== 'granted') {
console.warn('알림 권한이 허용되지 않았습니다.');
return null;
}
try {
const notification = new Notification(title, {
icon: '/favicon.ico',
badge: '/favicon.ico',
...options,
});
// 알림 클릭 시 창 포커스
notification.onclick = () => {
window.focus();
notification.close();
// 알림 페이지로 이동 (옵션)
if (options?.data?.page) {
window.location.hash = options.data.page;
}
};
return notification;
} catch (error) {
console.error('알림 표시 실패:', error);
return null;
}
};
/**
* 알림 권한 상태 확인
*/
export const getNotificationPermission = (): NotificationPermission => {
if (!('Notification' in window)) {
return 'denied';
}
return Notification.permission;
};
/**
* 알림 지원 여부 확인
*/
export const isNotificationSupported = (): boolean => {
return 'Notification' in window;
};
export default {
requestNotificationPermission,
showNotification,
getNotificationPermission,
isNotificationSupported,
};
@@ -0,0 +1,205 @@
/**
* 캔들 데이터 API 최적화 버전
* - 캐싱 적용
* - 중복 요청 방지 (Request Deduplication)
* - 배치 요청 지원
*/
import axios from 'axios';
import { getApiBaseUrl } from './apiConfig';
import { apiCache, cachedFetch } from './apiCacheService';
import type { CandleData, IndicatorData } from './backtestApi';
const API_BASE_URL = getApiBaseUrl();
// 진행 중인 요청 추적 (중복 요청 방지)
const pendingRequests = new Map<string, Promise<any>>();
/**
* 중복 요청 방지 래퍼
*/
async function deduplicatedFetch<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
// 이미 진행 중인 요청이 있으면 재사용
if (pendingRequests.has(key)) {
console.log(`⏳ [Request DEDUP] ${key}`);
return pendingRequests.get(key) as Promise<T>;
}
// 새 요청 시작
const promise = fetcher().finally(() => {
pendingRequests.delete(key);
});
pendingRequests.set(key, promise);
return promise;
}
interface CandleApiParams {
symbol: string;
interval: string;
count: number;
to?: string;
}
/**
* 캔들 데이터 로드 (캐싱 + 중복 방지)
*/
export async function fetchCandlesOptimized(
params: CandleApiParams
): Promise<CandleData[]> {
const cacheKey = apiCache.createKey('candles', params);
return deduplicatedFetch(cacheKey, async () => {
return cachedFetch(
cacheKey,
async () => {
console.log(`📡 [API] 캔들 데이터 요청:`, params);
const response = await axios.get(`${API_BASE_URL}/pattern/candles`, {
params: {
market: params.symbol,
timeframe: params.interval,
count: params.count,
to: params.to,
},
});
// 백엔드 응답: {data: [...], success: true, ...}
// data 배열만 추출
if (response.data && response.data.data) {
return response.data.data;
}
return response.data;
},
{ ttl: 30 * 1000 } // 30초 캐시 (실시간성 유지)
);
});
}
interface IndicatorApiParams {
symbol: string;
interval: string;
candles: CandleData[];
maPeriods?: number[];
indicatorSettings?: Record<string, any>;
vrPeriod?: number;
vr2Enabled?: boolean;
vr2Period?: number;
}
/**
* 보조지표 데이터 로드 (캐싱 + 중복 방지)
*/
export async function fetchIndicatorsOptimized(
params: IndicatorApiParams
): Promise<IndicatorData[]> {
// 캔들 데이터는 해시로 변환 (정확한 캐시 키 생성)
const candlesHash = hashCandles(params.candles);
const cacheParams = {
symbol: params.symbol,
interval: params.interval,
candlesHash,
maPeriods: params.maPeriods,
indicatorSettings: params.indicatorSettings,
vrPeriod: params.vrPeriod,
vr2Enabled: params.vr2Enabled,
vr2Period: params.vr2Period,
};
const cacheKey = apiCache.createKey('indicators', cacheParams);
return deduplicatedFetch(cacheKey, async () => {
return cachedFetch(
cacheKey,
async () => {
console.log(`📡 [API] 지표 데이터 요청:`, {
symbol: params.symbol,
interval: params.interval,
candlesCount: params.candles.length,
});
const response = await axios.post(
`${API_BASE_URL}/upbit/analyze`,
{
symbol: params.symbol,
interval: params.interval,
candles: params.candles,
maPeriods: params.maPeriods || [],
indicatorSettings: params.indicatorSettings || {},
vrPeriod: params.vrPeriod,
vr2Enabled: params.vr2Enabled,
vr2Period: params.vr2Period,
}
);
return response.data;
},
{ ttl: 60 * 1000 } // 1분 캐시
);
});
}
/**
* 캔들 데이터 해시 생성 (캐시 키용)
* - 첫/마지막 캔들의 시간과 가격만 사용 (성능 최적화)
*/
function hashCandles(candles: CandleData[]): string {
if (candles.length === 0) return 'empty';
const first = candles[0];
const last = candles[candles.length - 1];
return `${candles.length}_${first.time}_${first.close}_${last.time}_${last.close}`;
}
/**
* 종목/시간봉 변경 시 관련 캐시 무효화
*/
export function invalidateCandleCache(symbol?: string, interval?: string): void {
if (symbol && interval) {
apiCache.invalidate(`candles:.*symbol="${symbol}".*interval="${interval}"`);
apiCache.invalidate(`indicators:.*symbol="${symbol}".*interval="${interval}"`);
} else if (symbol) {
apiCache.invalidate(`candles:.*symbol="${symbol}"`);
apiCache.invalidate(`indicators:.*symbol="${symbol}"`);
} else {
apiCache.invalidate(/^(candles|indicators):/);
}
console.log(`🧹 [Cache] 캐시 무효화: symbol=${symbol}, interval=${interval}`);
}
/**
* 배치 캔들 로드 (여러 시간대 동시 로드)
*/
export async function fetchCandlesBatch(
requests: CandleApiParams[]
): Promise<CandleData[][]> {
console.log(`📦 [API] 배치 요청 (${requests.length}개)`);
const promises = requests.map(params => fetchCandlesOptimized(params));
return Promise.all(promises);
}
/**
* 증분 업데이트 (마지막 N개 캔들만 로드)
*/
export async function fetchCandlesIncremental(
symbol: string,
interval: string,
count: number = 10
): Promise<CandleData[]> {
// 증분 업데이트는 캐시하지 않음 (항상 최신 데이터)
const key = `candles_incremental_${symbol}_${interval}_${count}`;
return deduplicatedFetch(key, async () => {
console.log(`📡 [API] 증분 캔들 요청: ${symbol} ${interval} (최근 ${count}개)`);
const response = await axios.get(`${API_BASE_URL}/pattern/candles`, {
params: {
symbol,
interval,
count,
},
});
return response.data;
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
import { API_BASE_URL } from './apiConfig';
/**
* 상승 종목 DTO
*/
export interface RisingStock {
code: string;
name: string;
currentPrice: number;
changeRate: number;
totalScore: number;
trendScore: number;
volumeScore: number;
flowScore: number;
momentumScore: number;
ranking: number;
signalStrength: 'STRONG' | 'MEDIUM' | 'WEAK';
buySignal: boolean;
passStage1: boolean;
passStage2: boolean;
passStage3: boolean;
passStage4: boolean;
volume?: number;
volumeRatio?: number;
foreignNet?: number;
instNet?: number;
rsi?: number;
macd?: number;
updatedAt: string;
}
/**
* 검색 필터 옵션
*/
export interface SearchFilter {
id: string;
name: string;
category: string;
enabled: boolean;
}
/**
* 검색 요청 DTO
*
* 단계별 필터:
* - 1단계: 하락추세 제거 (손실 방지)
* - 2단계: 상승추세 판별 (방향성)
* - 3단계: 모멘텀 확인 (상승 강도)
* - 4단계: 추가 확인 (신뢰도)
*/
export interface RisingStockSearchRequest {
market?: string;
maxResults?: number;
minTotalScore?: number;
// ===== 1단계: 하락추세 제거 =====
enableCloseMa60Filter?: boolean; // 종가 > MA60
enableMa20Ma60Filter?: boolean; // MA20 > MA60
enableAdxDiFilter?: boolean; // ADX 하락추세 제거
enableHigh120Filter?: boolean; // 120일 고점 대비
high120DropThreshold?: number;
// ===== 2단계: 상승추세 판별 =====
enableMaAlignmentFilter?: boolean; // MA 정배열
enableMaSlopeFilter?: boolean; // MA20 기울기 양수
enableMacdFilter?: boolean; // MACD > Signal
enableRsiFilter?: boolean; // RSI 범위
rsiMin?: number;
rsiMax?: number;
// ===== 3단계: 모멘텀 확인 =====
enableBullishCandleFilter?: boolean; // 양봉 필터 (신규)
enableMinChangeRateFilter?: boolean; // 최소 등락률 (신규)
minChangeRate?: number; // 최소 등락률 값 (%)
enableVolume20dFilter?: boolean; // 20일 평균 거래량 돌파
volume20dMultiplier?: number;
enableVolume5dFilter?: boolean; // 5일 평균 거래량 + 양봉
volume5dMultiplier?: number;
// ===== 4단계: 추가 확인 (주식/고급) =====
enableForeignNetFilter?: boolean;
foreignNetThreshold?: number;
enableInstNetFilter?: boolean;
instNetThreshold?: number;
enableProgramNetFilter?: boolean;
enableTradeStrengthFilter?: boolean;
tradeStrengthThreshold?: number;
enableBidAskRatioFilter?: boolean;
bidAskRatioThreshold?: number;
// ===== 시장 상태 =====
enableMarketStatusFilter?: boolean;
// ===== 정렬 =====
sortBy?: string; // totalScore, trendScore, momentumScore, changeRate
sortDirection?: string; // DESC, ASC
}
/**
* 시장 상태 DTO
*/
export interface MarketStatus {
marketType: string;
currentIndex: number;
indexMa20: number;
vix: number;
vixSpike: boolean;
status: 'NORMAL' | 'CAUTION' | 'DANGER';
buyAllowed: boolean;
advanceRatio: number;
declineRatio: number;
tradeValue: number;
updatedAt: string;
}
/**
* API 응답 타입
*/
interface ApiResponse<T> {
success: boolean;
data: T;
count?: number;
source?: string;
error?: string;
timestamp: string;
}
/** 정렬 기준: totalScore | trendScore | volumeScore */
export type TopRisingSort = 'totalScore' | 'trendScore' | 'volumeScore';
/**
* TOP 상승 종목 조회
* @param limit 최대 개수
* @param sort totalScore(종합), trendScore(추세순-차트 상승추세와 일치), volumeScore(거래량순)
*/
export const getTopRisingStocks = async (
limit: number = 20,
sort: TopRisingSort = 'totalScore'
): Promise<RisingStock[]> => {
try {
const response = await fetch(
`${API_BASE_URL}/rising-stocks/top?limit=${limit}&sort=${sort}`
);
const result: ApiResponse<RisingStock[]> = await response.json();
if (result.success) {
return result.data;
} else {
console.error('TOP 상승 종목 조회 실패:', result.error);
return [];
}
} catch (error) {
console.error('TOP 상승 종목 조회 오류:', error);
return [];
}
};
/**
* 조건별 상승 종목 검색
*/
export const searchRisingStocks = async (request: RisingStockSearchRequest): Promise<RisingStock[]> => {
try {
const response = await fetch(`${API_BASE_URL}/rising-stocks/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
const result: ApiResponse<RisingStock[]> = await response.json();
if (result.success) {
return result.data;
} else {
console.error('상승 종목 검색 실패:', result.error);
return [];
}
} catch (error) {
console.error('상승 종목 검색 오류:', error);
return [];
}
};
/**
* 특정 종목 점수 조회
*/
export const getStockScore = async (code: string): Promise<RisingStock | null> => {
try {
const response = await fetch(`${API_BASE_URL}/rising-stocks/${code}/score`);
const result: ApiResponse<RisingStock> = await response.json();
if (result.success) {
return result.data;
} else {
console.error('종목 점수 조회 실패:', result.error);
return null;
}
} catch (error) {
console.error('종목 점수 조회 오류:', error);
return null;
}
};
/**
* 시장 상태 조회
*/
export const getMarketStatus = async (): Promise<MarketStatus | null> => {
try {
const response = await fetch(`${API_BASE_URL}/rising-stocks/market/status`);
const result: ApiResponse<MarketStatus> = await response.json();
if (result.success) {
return result.data;
} else {
console.error('시장 상태 조회 실패:', result.error);
return null;
}
} catch (error) {
console.error('시장 상태 조회 오류:', error);
return null;
}
};
/**
* 실시간 매수 신호 조회
*/
export const getRealtimeSignal = async (): Promise<{ stocks: RisingStock[], marketStatus: string, tradingAllowed: boolean }> => {
try {
const response = await fetch(`${API_BASE_URL}/rising-stocks/realtime/signal`);
const result = await response.json();
if (result.success) {
return {
stocks: result.data,
marketStatus: result.marketStatus,
tradingAllowed: result.tradingAllowed,
};
} else {
console.error('실시간 신호 조회 실패:', result.error);
return { stocks: [], marketStatus: 'UNKNOWN', tradingAllowed: false };
}
} catch (error) {
console.error('실시간 신호 조회 오류:', error);
return { stocks: [], marketStatus: 'UNKNOWN', tradingAllowed: false };
}
};
/**
* 수동 스캔 트리거
*/
export const triggerScan = async (): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE_URL}/rising-stocks/scan`, {
method: 'POST',
});
const result = await response.json();
return result.success;
} catch (error) {
console.error('수동 스캔 트리거 오류:', error);
return false;
}
};
/**
* 스캔 상태 조회
*/
export const getScanStatus = async (): Promise<{ isScanning: boolean, totalScanCount: number }> => {
try {
const response = await fetch(`${API_BASE_URL}/rising-stocks/scan/status`);
const result = await response.json();
if (result.success) {
return {
isScanning: result.isScanning,
totalScanCount: result.totalScanCount,
};
}
return { isScanning: false, totalScanCount: 0 };
} catch (error) {
console.error('스캔 상태 조회 오류:', error);
return { isScanning: false, totalScanCount: 0 };
}
};
/**
* 검색 필터 기본값 조회
*/
export const getFilterDefaults = async (): Promise<{ filters: SearchFilter[], defaults: RisingStockSearchRequest }> => {
try {
const response = await fetch(`${API_BASE_URL}/rising-stocks/filters/defaults`);
const result = await response.json();
if (result.success) {
return {
filters: result.filters,
defaults: result.defaults,
};
}
return { filters: [], defaults: {} };
} catch (error) {
console.error('필터 기본값 조회 오류:', error);
return { filters: [], defaults: {} };
}
};
/**
* 캐시 초기화
*/
export const clearCache = async (): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE_URL}/rising-stocks/cache/clear`, {
method: 'POST',
});
const result = await response.json();
return result.success;
} catch (error) {
console.error('캐시 초기화 오류:', error);
return false;
}
};
/**
* 기본 검색 조건 (암호화폐 상승추세 탐지 최적화)
*
* 설계 원칙:
* - 더 많은 조건을 충족할수록 → 더 강한 상승추세
* - 단계별로 점진적 필터링
* - 암호화폐 특성 반영 (수급 데이터 없음)
*/
export const DEFAULT_SEARCH_REQUEST: RisingStockSearchRequest = {
market: 'KRW',
maxResults: 30,
minTotalScore: 0, // 모든 종목 표시, 점수로 정렬
// ===== 1단계: 하락추세 제거 (기본 ON) =====
// 손실 위험 최소화
enableCloseMa60Filter: true, // 가격 > MA60: ON (기본)
enableMa20Ma60Filter: true, // MA20 > MA60: ON (기본)
enableAdxDiFilter: false, // ADX 하락추세: OFF (선택)
enableHigh120Filter: false, // 120일 고점: OFF (신규 코인)
high120DropThreshold: -30,
// ===== 2단계: 상승추세 판별 (핵심) =====
// 실제 상승 중인지 확인
enableMaAlignmentFilter: false, // MA 정배열: OFF (MA120 데이터 부족)
enableMaSlopeFilter: true, // MA20 기울기: ON (상승 방향)
enableMacdFilter: true, // MACD > Signal: ON (모멘텀)
enableRsiFilter: false, // RSI 범위: OFF (선택)
rsiMin: 35,
rsiMax: 75,
// ===== 3단계: 모멘텀 확인 (상승 강도) =====
// 더 많이 ON할수록 강한 상승 종목만 검색
enableBullishCandleFilter: true, // 양봉 필터: ON (매수세)
enableMinChangeRateFilter: false, // 최소 등락률: OFF (선택)
minChangeRate: 1,
enableVolume20dFilter: false, // 20일 거래량: OFF (변동성 큼)
volume20dMultiplier: 2,
enableVolume5dFilter: false, // 5일 거래량: OFF (선택)
volume5dMultiplier: 1.5,
// ===== 4단계: 추가 확인 (수급/고급) =====
// 암호화폐는 모두 OFF
enableForeignNetFilter: false,
foreignNetThreshold: 10,
enableInstNetFilter: false,
instNetThreshold: 5,
enableProgramNetFilter: false,
enableTradeStrengthFilter: false,
tradeStrengthThreshold: 140,
enableBidAskRatioFilter: false,
bidAskRatioThreshold: 1.5,
// ===== 시장 상태 =====
enableMarketStatusFilter: false,
// ===== 정렬 =====
sortBy: 'totalScore', // 종합 점수 (권장)
sortDirection: 'DESC',
};
@@ -0,0 +1,48 @@
import axios from 'axios';
import { API_BASE_URL } from './apiConfig';
export interface SchedulerStatus {
isEnabled: boolean;
isRunning: boolean;
cronExpression: string;
nextRunTime: string;
lastRunTime: string;
lastRunResult: string;
downloadedFilesCount: number;
failedFilesCount: number;
downloadDir: string;
maxWorkers: number;
currentTask: string;
progressPercent: number;
successCount?: number;
failCount?: number;
totalRunCount?: number;
recentLogs?: string[];
}
export interface SchedulerConfig {
[key: string]: string;
}
export const schedulerApi = {
getStatus: async (): Promise<SchedulerStatus> => {
const response = await axios.get(`${API_BASE_URL}/scheduler/status`);
return response.data;
},
startDownload: async (): Promise<SchedulerStatus> => {
const response = await axios.post(`${API_BASE_URL}/scheduler/start`);
return response.data;
},
stopDownload: async (): Promise<SchedulerStatus> => {
const response = await axios.post(`${API_BASE_URL}/scheduler/stop`);
return response.data;
},
updateConfig: async (config: SchedulerConfig): Promise<SchedulerStatus> => {
const response = await axios.put(`${API_BASE_URL}/scheduler/config`, config);
return response.data;
},
};
+514
View File
@@ -0,0 +1,514 @@
import axios from 'axios';
import { API_BASE_URL, API_CONFIG } from './apiConfig';
export interface SettingDto {
key: string;
value: string;
type: string;
category: string;
description?: string;
isActive?: boolean;
}
// 사용자 설정 관련 인터페이스
export interface UserSettings {
userId: number;
username: string;
buyAlertEnabled: boolean;
dataUpdateIntervalMinutes?: number;
}
export interface UpdateUserSettingsRequest {
buyAlertEnabled?: boolean;
dataUpdateIntervalMinutes?: number;
}
export interface BuySignalStatus {
hasSignal: boolean;
lastSignalTime: number;
timestamp: number;
}
// 사용자 설정 API 인스턴스
const userSettingsApi = axios.create({
...API_CONFIG,
baseURL: `${API_BASE_URL}/user/settings`,
});
userSettingsApi.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
/**
* 사용자 설정 조회
*/
export const getUserSettings = async (): Promise<UserSettings> => {
try {
const response = await userSettingsApi.get<UserSettings>('');
return response.data;
} catch (error) {
// 사용자 설정이 없으면 기본값 반환
return {
userId: 0,
username: '',
buyAlertEnabled: false,
dataUpdateIntervalMinutes: 1,
};
}
};
/**
* 사용자 설정 업데이트
*/
export const updateUserSettings = async (
settings: UpdateUserSettingsRequest
): Promise<UserSettings> => {
const response = await userSettingsApi.put<UserSettings>('', settings);
return response.data;
};
/**
* 최근 매수 신호 확인
*/
export const checkBuySignal = async (): Promise<BuySignalStatus> => {
try {
const response = await userSettingsApi.get<BuySignalStatus>('/check-buy-signal');
return response.data;
} catch (error) {
return { hasSignal: false, lastSignalTime: 0, timestamp: 0 };
}
};
// 설정 Export/Import 관련 인터페이스
export interface SettingsExportData {
version: string;
exportedAt: string;
analysisSettings: SettingDto[];
indicatorPeriodSettings: any[];
tradingStrategies: any[];
strategyRules: any[];
}
export interface ImportResult {
success: boolean;
message: string;
importedAt?: string;
}
export const settingsApi = {
getAllSettings: async (): Promise<SettingDto[]> => {
const response = await axios.get(`${API_BASE_URL}/settings`);
return response.data;
},
getSettingsByCategory: async (category: string): Promise<SettingDto[]> => {
const response = await axios.get(`${API_BASE_URL}/settings/category/${category}`);
return response.data;
},
getSetting: async (key: string): Promise<SettingDto> => {
const response = await axios.get(`${API_BASE_URL}/settings/${key}`);
return response.data;
},
saveSetting: async (setting: SettingDto): Promise<SettingDto> => {
const response = await axios.post(`${API_BASE_URL}/settings`, setting);
return response.data;
},
saveSettings: async (settings: SettingDto[]): Promise<SettingDto[]> => {
const response = await axios.post(`${API_BASE_URL}/settings/batch`, settings);
return response.data;
},
deleteSetting: async (key: string): Promise<void> => {
await axios.delete(`${API_BASE_URL}/settings/${key}`);
},
getSettingsAsMap: async (category: string): Promise<Record<string, string>> => {
const response = await axios.get(`${API_BASE_URL}/settings/map/${category}`);
return response.data;
},
saveSettingsFromMap: async (category: string, settings: Record<string, string>): Promise<void> => {
await axios.put(`${API_BASE_URL}/settings/map/${category}`, settings);
},
/**
* 모든 설정 Export (JSON 다운로드)
*/
exportSettings: async (): Promise<SettingsExportData> => {
const response = await axios.get(`${API_BASE_URL}/settings/export`);
return response.data;
},
/**
* 설정 Import (JSON 업로드)
*/
importSettings: async (importData: SettingsExportData): Promise<ImportResult> => {
const response = await axios.post(`${API_BASE_URL}/settings/import`, importData);
return response.data;
},
/**
* 설정 Export 후 파일 다운로드 (localStorage의 보조지표 설정 포함)
*/
downloadExportFile: async (): Promise<void> => {
try {
const exportData = await settingsApi.exportSettings();
// ✅ localStorage에서 보조지표 색상/스타일 설정 가져오기
const indicatorColorSettings: Record<string, any> = {};
const colorKeys = [
// MACD
'macdLineColor', 'macdSignalColor', 'macdHistogramUpColor', 'macdHistogramDownColor',
'macdLineWidth', 'macdSignalWidth', 'macdLineStyle', 'macdSignalLineStyle',
'macdZeroLineColor', 'macdZeroLineWidth', 'macdZeroLineStyle',
// RSI
'rsiColor', 'rsiWidth', 'rsiLineStyle', 'rsiOverboughtColor', 'rsiOversoldColor',
'rsiOverboughtWidth', 'rsiOversoldWidth', 'rsiOverboughtLineStyle', 'rsiOversoldLineStyle',
// Stochastic
'stochasticFastColor', 'stochasticSlowColor', 'stochasticFastWidth', 'stochasticSlowWidth',
'stochasticFastLineStyle', 'stochasticSlowLineStyle',
'stochasticOverboughtColor', 'stochasticOversoldColor', 'stochasticOverboughtWidth', 'stochasticOversoldWidth',
'stochasticOverboughtLineStyle', 'stochasticOversoldLineStyle',
// CCI
'cciColor', 'cciSignalColor', 'cciWidth', 'cciSignalWidth', 'cciLineStyle', 'cciSignalLineStyle',
'cciOverboughtColor', 'cciOversoldColor', 'cciOverboughtWidth', 'cciOversoldWidth',
'cciOverboughtLineStyle', 'cciOversoldLineStyle',
// ADX/DMI
'adxColor', 'adxWidth', 'adxLineStyle', 'plusDiColor', 'minusDiColor',
'plusDiWidth', 'minusDiWidth', 'plusDiLineStyle', 'minusDiLineStyle',
'adxStrongColor', 'adxWeakColor', 'adxStrongWidth', 'adxWeakWidth',
'adxStrongLineStyle', 'adxWeakLineStyle',
// TRIX
'trixColor', 'trixSignalColor', 'trixWidth', 'trixSignalWidth',
'trixLineStyle', 'trixSignalLineStyle',
// Williams %R
'williamsRColor', 'williamsRWidth', 'williamsRLineStyle',
'williamsROverboughtColor', 'williamsROversoldColor',
'williamsROverboughtWidth', 'williamsROversoldWidth',
'williamsROverboughtLineStyle', 'williamsROversoldLineStyle',
// BWI
'bwiColor', 'bwiWidth', 'bwiLineStyle',
'bwiOverboughtColor', 'bwiMiddleColor', 'bwiOversoldColor',
'bwiOverboughtWidth', 'bwiMiddleWidth', 'bwiOversoldWidth',
'bwiOverboughtLineStyle', 'bwiMiddleLineStyle', 'bwiOversoldLineStyle',
// OBV
'obvColor', 'obvSignalColor', 'obvWidth', 'obvSignalWidth',
'obvLineStyle', 'obvSignalLineStyle',
// Volume Oscillator
'volumeOscColor', 'volumeOscWidth', 'volumeOscLineStyle',
// VR
'vrColor', 'vrWidth', 'vrLineStyle',
'vrOverboughtColor', 'vrBaseLineColor', 'vrOversoldColor',
'vrOverboughtWidth', 'vrBaseLineWidth', 'vrOversoldWidth',
'vrOverboughtLineStyle', 'vrBaseLineLineStyle', 'vrOversoldLineStyle',
// Disparity
'disparityUltraShortColor', 'disparityShortColor', 'disparityMidColor', 'disparityLongColor',
'disparityUltraShortWidth', 'disparityShortWidth', 'disparityMidWidth', 'disparityLongWidth',
'disparityUltraShortLineStyle', 'disparityShortLineStyle', 'disparityMidLineStyle', 'disparityLongLineStyle',
// New/Invest Psychological
'newPsyColor', 'newPsyWidth', 'newPsyLineStyle',
'newPsyOverboughtColor', 'newPsyOversoldColor',
'newPsyOverboughtWidth', 'newPsyOversoldWidth',
'newPsyOverboughtLineStyle', 'newPsyOversoldLineStyle',
'investPsyColor', 'investPsyWidth', 'investPsyLineStyle',
'investPsyOverboughtColor', 'investPsyOversoldColor',
'investPsyOverboughtWidth', 'investPsyOversoldWidth',
'investPsyOverboughtLineStyle', 'investPsyOversoldLineStyle',
// Momentum
'momentumColor', 'momentumWidth', 'momentumLineStyle',
];
colorKeys.forEach(key => {
const value = localStorage.getItem(key);
if (value !== null) {
// 숫자 타입 변환 시도
if (key.includes('Width')) {
indicatorColorSettings[key] = parseFloat(value);
} else {
indicatorColorSettings[key] = value;
}
}
});
// ✅ localStorage에서 보조지표 설정 가져오기
const indicatorSettings: Record<string, any> = {};
const settingKeys = [
// 기간 설정
'macdFastPeriod', 'macdSlowPeriod', 'macdSignalPeriod',
'rsiPeriod', 'stochasticKPeriod', 'stochasticDPeriod',
'cciPeriod', 'cciSignalPeriod', 'adxPeriod', 'dmiPeriod',
'trixPeriod', 'trixSignalPeriod', 'williamsRPeriod',
'bwiPeriod', 'obvPeriod', 'obvSignalPeriod',
'volumeOscShortPeriod', 'volumeOscLongPeriod',
'vrPeriod', 'disparityUltraShortPeriod', 'disparityShortPeriod',
'disparityMidPeriod', 'disparityLongPeriod',
'newPsychologicalPeriod', 'investPsychologicalPeriod',
'momentumPeriod',
// 활성화 상태
'macdZeroLineEnabled', 'macdSignalEnabled',
'rsiOverboughtEnabled', 'rsiOversoldEnabled', 'rsiSignalEnabled',
'stochasticOverboughtEnabled', 'stochasticOversoldEnabled',
'cciOverboughtEnabled', 'cciOversoldEnabled', 'cciSignalEnabled',
'adxStrongEnabled', 'adxWeakEnabled', 'dmiEnabled',
'trixEnabled', 'trixSignalEnabled',
'williamsROverboughtEnabled', 'williamsROversoldEnabled',
'bwiOverboughtEnabled', 'bwiMiddleEnabled', 'bwiOversoldEnabled',
'obvSignalEnabled',
'vrOverboughtEnabled', 'vrBaseLineEnabled', 'vrOversoldEnabled',
'disparityUltraShortEnabled', 'disparityShortEnabled',
'disparityMidEnabled', 'disparityLongEnabled', 'disparityEnabled',
'newPsyOverboughtEnabled', 'newPsyOversoldEnabled',
'investPsyOverboughtEnabled', 'investPsyOversoldEnabled',
// 기준값
'rsiOverbought', 'rsiOversold',
'stochasticOverbought', 'stochasticOversold',
'cciOverbought', 'cciOversold',
'adxStrongThreshold', 'adxWeakThreshold',
'williamsROverbought', 'williamsROversold',
'bwiOverbought', 'bwiMiddle', 'bwiOversold',
'vrOverbought', 'vrBaseLine', 'vrOversold',
'newPsyOverbought', 'newPsyOversold',
'investPsyOverbought', 'investPsyOversold',
];
settingKeys.forEach(key => {
const value = localStorage.getItem(key);
if (value !== null) {
// boolean 타입 변환
if (key.includes('Enabled')) {
indicatorSettings[key] = value === 'true';
}
// 숫자 타입 변환
else if (key.includes('Period') || key.includes('Threshold') ||
key.includes('bought') || key.includes('sold') ||
key.includes('Middle') || key.includes('Base')) {
indicatorSettings[key] = parseFloat(value);
} else {
indicatorSettings[key] = value;
}
}
});
// ✅ 보조지표 설정 추가
const completeExportData = {
...exportData,
indicatorColorSettings,
indicatorSettings,
};
// JSON 파일 생성 및 다운로드
const blob = new Blob([JSON.stringify(completeExportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const now = new Date();
const timestamp = now.toISOString().slice(0, 19).replace(/[:-]/g, '').replace('T', '-');
link.download = `golden-analysis-settings-${timestamp}.json`;
link.href = url;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log('✅ 설정 Export 완료 - 보조지표 색상 설정:', Object.keys(indicatorColorSettings).length, '개');
console.log('✅ 설정 Export 완료 - 보조지표 설정:', Object.keys(indicatorSettings).length, '개');
} catch (error) {
console.error('설정 Export 실패:', error);
throw error;
}
},
/**
* 파일에서 설정 Import (localStorage의 보조지표 설정 포함)
*/
importFromFile: async (file: File): Promise<ImportResult> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
const importData: any = JSON.parse(content);
// 유효성 검사
if (!importData.version) {
reject(new Error('유효하지 않은 설정 파일입니다. (버전 정보 없음)'));
return;
}
// ✅ 백엔드로 서버 설정 Import
const result = await settingsApi.importSettings(importData);
// ✅ localStorage에 보조지표 색상/스타일 설정 저장
if (importData.indicatorColorSettings) {
console.log('📥 보조지표 색상 설정 Import 시작:', Object.keys(importData.indicatorColorSettings).length, '개');
Object.entries(importData.indicatorColorSettings).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
localStorage.setItem(key, String(value));
}
});
console.log('✅ 보조지표 색상 설정 Import 완료');
}
// ✅ localStorage에 보조지표 설정 저장
if (importData.indicatorSettings) {
console.log('📥 보조지표 설정 Import 시작:', Object.keys(importData.indicatorSettings).length, '개');
Object.entries(importData.indicatorSettings).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
localStorage.setItem(key, String(value));
}
});
console.log('✅ 보조지표 설정 Import 완료');
}
// ✅ Context 갱신을 위해 페이지 새로고침 권장
if (importData.indicatorColorSettings || importData.indicatorSettings) {
console.log('💡 보조지표 설정이 Import되었습니다. 페이지를 새로고침하여 변경사항을 적용하세요.');
}
resolve(result);
} catch (error: any) {
if (error instanceof SyntaxError) {
reject(new Error('JSON 파일 형식이 올바르지 않습니다.'));
} else {
reject(error);
}
}
};
reader.onerror = () => {
reject(new Error('파일 읽기 실패'));
};
reader.readAsText(file);
});
},
// ==================== 피보나치 타임존 설정 API ====================
/**
* 게스트 사용자 피보나치 설정 조회
*/
getGuestFibonacciSettings: async (deviceId: string): Promise<string> => {
try {
const response = await axios.get(
`${API_BASE_URL}/settings/fibonacci/guest/${deviceId}`,
{ responseType: 'text' } // ✅ 응답을 텍스트로 받기 (자동 JSON 파싱 방지)
);
console.log('📂 [API] 피보나치 설정 조회 응답 (원본):', response.data);
console.log('📂 [API] 피보나치 설정 조회 응답 타입:', typeof response.data);
return response.data;
} catch (error) {
console.error('❌ [API] 피보나치 설정 조회 실패:', error);
return '{}';
}
},
/**
* 게스트 사용자 피보나치 설정 저장
*/
saveGuestFibonacciSettings: async (
deviceId: string,
settings: string
): Promise<void> => {
try {
console.log('💾 [API] 피보나치 설정 저장 요청:', { deviceId, settings });
await axios.post(
`${API_BASE_URL}/settings/fibonacci/guest/${deviceId}`,
{ settings }
);
console.log('✅ [API] 피보나치 설정 저장 성공');
} catch (error) {
console.error('❌ [API] 피보나치 설정 저장 실패:', error);
throw error;
}
},
/**
* 게스트 사용자 피보나치 그려진 객체들 조회
*/
getGuestFibonacciObjects: async (deviceId: string): Promise<string> => {
try {
const response = await axios.get(
`${API_BASE_URL}/settings/fibonacci/objects/guest/${deviceId}`,
{ responseType: 'text' }
);
console.log('📂 [API] 피보나치 객체 조회 응답:', response.data);
return response.data;
} catch (error) {
console.error('❌ [API] 피보나치 객체 조회 실패:', error);
return '[]';
}
},
/**
* 게스트 사용자 피보나치 그려진 객체들 저장
*/
saveGuestFibonacciObjects: async (
deviceId: string,
objects: string
): Promise<void> => {
try {
console.log('💾 [API] 피보나치 객체 저장 요청:', { deviceId, objectsLength: objects.length });
await axios.post(
`${API_BASE_URL}/settings/fibonacci/objects/guest/${deviceId}`,
{ objects }
);
console.log('✅ [API] 피보나치 객체 저장 성공');
} catch (error) {
console.error('❌ [API] 피보나치 객체 저장 실패:', error);
throw error;
}
},
// ==================== 기간 측정 도구 설정 API ====================
/**
* 게스트 사용자 기간 측정 설정 조회
*/
getGuestMeasureSettings: async (deviceId: string): Promise<string> => {
try {
const response = await axios.get(
`${API_BASE_URL}/settings/measure/guest/${deviceId}`,
{ responseType: 'text' }
);
console.log('📂 [API] 기간 측정 설정 조회 응답:', response.data);
return response.data;
} catch (error) {
console.error('❌ [API] 기간 측정 설정 조회 실패:', error);
return '{}';
}
},
/**
* 게스트 사용자 기간 측정 설정 저장
*/
saveGuestMeasureSettings: async (
deviceId: string,
settings: string
): Promise<void> => {
try {
console.log('💾 [API] 기간 측정 설정 저장 요청:', { deviceId, settings });
await axios.post(
`${API_BASE_URL}/settings/measure/guest/${deviceId}`,
{ settings }
);
console.log('✅ [API] 기간 측정 설정 저장 성공');
} catch (error) {
console.error('❌ [API] 기간 측정 설정 저장 실패:', error);
throw error;
}
}
};
@@ -0,0 +1,140 @@
import axios from 'axios';
// Docker 환경에서는 Nginx 프록시를 통해 상대 경로로 API 호출
// 개발 환경에서 직접 백엔드 연결이 필요한 경우 REACT_APP_API_BASE_URL 환경 변수 사용
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '';
const api = axios.create({
baseURL: API_BASE_URL,
withCredentials: true,
});
export interface SignalWeightSettings {
// MACD 관련
macdGoldenCross: number;
macdDeadCross: number;
macdHistogramUp: number;
macdHistogramDown: number;
macdZeroCrossUp: number;
macdZeroCrossDown: number;
// Stochastic Slow 관련
stochOversoldCrossUp: number;
stochOverboughtCrossDown: number;
stochOversoldThreshold: number;
stochOverboughtThreshold: number;
// ADX 관련
adxStrongUptrend: number;
adxStrongDowntrend: number;
adxThreshold: number;
// ADX11 관련
adx11StrongUptrend: number;
adx11StrongDowntrend: number;
adx11Threshold: number;
// CCI13 관련
cci13StrongUp: number;
cci13StrongDown: number;
cci13OverboughtThreshold: number;
cci13OversoldThreshold: number;
// 거래량 관련
volumeIncrease: number;
volumeDecrease: number;
volumeIncreaseThreshold: number;
volumeDecreaseThreshold: number;
// 종합 스코어 기준
buySignalThreshold: number;
sellSignalThreshold: number;
}
/**
* 신호 가중치 설정 조회
*/
export const getSignalWeights = async (): Promise<SignalWeightSettings> => {
try {
const response = await api.get<SignalWeightSettings>('/api/settings/signal-weights');
return response.data;
} catch (error) {
console.error('신호 가중치 조회 실패:', error);
// 기본값 반환
return getDefaultWeights();
}
};
/**
* 신호 가중치 설정 저장
*/
export const saveSignalWeights = async (weights: SignalWeightSettings): Promise<void> => {
try {
await api.post('/api/settings/signal-weights', weights);
console.log('신호 가중치 저장 성공');
} catch (error) {
console.error('신호 가중치 저장 실패:', error);
throw error;
}
};
/**
* 신호 가중치 초기화 (DEFAULT로 복구)
*/
export const resetSignalWeights = async (): Promise<void> => {
try {
await api.post('/api/settings/reset/SIGNAL_WEIGHT');
console.log('신호 가중치 초기화 성공');
} catch (error) {
console.error('신호 가중치 초기화 실패:', error);
throw error;
}
};
/**
* 기본 가중치 값
*/
export const getDefaultWeights = (): SignalWeightSettings => {
return {
// MACD
macdGoldenCross: 3,
macdDeadCross: -3,
macdHistogramUp: 1,
macdHistogramDown: -1,
macdZeroCrossUp: 2,
macdZeroCrossDown: -2,
// Stochastic
stochOversoldCrossUp: 2,
stochOverboughtCrossDown: -2,
stochOversoldThreshold: 20,
stochOverboughtThreshold: 80,
// ADX
adxStrongUptrend: 1,
adxStrongDowntrend: -1,
adxThreshold: 25,
// ADX11
adx11StrongUptrend: 1,
adx11StrongDowntrend: -1,
adx11Threshold: 25,
// CCI13
cci13StrongUp: 1,
cci13StrongDown: -1,
cci13OverboughtThreshold: 100,
cci13OversoldThreshold: -100,
// 거래량
volumeIncrease: 1,
volumeDecrease: -1,
volumeIncreaseThreshold: 30,
volumeDecreaseThreshold: -30,
// 종합 스코어
buySignalThreshold: 3,
sellSignalThreshold: -3,
};
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,923 @@
import axios from 'axios';
import { API_CONFIG } from './apiConfig';
const API_BASE_URL = API_CONFIG.baseURL;
// ==================== 타입 정의 ====================
/**
* 신호 타입 (매수/매도)
*/
export type SignalType = 'BUY' | 'SELL';
/**
* 추세 타입
*/
export type TrendType = 'ALL' | 'UPTREND' | 'DOWNTREND' | 'SIDEWAYS' | 'RANGE';
/**
* 논리 연산자 (AND/OR)
*/
export type LogicalOperator = 'AND' | 'OR';
/**
* 보조지표 타입 (업비트 표준 16개)
*/
export type IndicatorType =
| 'VOLUME' // 거래량
| 'MACD' // MACD
| 'STOCHASTIC' // Stochastic Slow
| 'RSI' // RSI
| 'ADX' // ADX
| 'CCI' // CCI
| 'TRIX' // TRIX
| 'DISPARITY' // 이격도
| 'NEW_PSYCHOLOGICAL' // 신심리도
| 'INVEST_PSYCHOLOGICAL' // 투자심리도
| 'WILLIAMS_R' // Williams %R
| 'BWI' // 볼린저밴드 %B
| 'OBV' // OBV
| 'VOLUME_OSC' // VolumeOSC
| 'VR' // VR
| 'DMI'; // DMI
/**
* 조건 타입
*/
export type ConditionType =
| 'CROSS_UP'
| 'CROSS_DOWN'
| 'GOLDEN_CROSS'
| 'DEAD_CROSS'
| 'GOLDEN_HOLD_CHECK'
| 'DEAD_HOLD_CHECK'
| 'ABOVE'
| 'BELOW'
| 'ABOVE_STRICT'
| 'BELOW_STRICT'
| 'EQ'
| 'NEQ'
| 'BETWEEN'
| 'OUTSIDE'
| 'INCREASING'
| 'DECREASING'
| 'TURNING_UP'
| 'TURNING_DOWN'
| 'DIVERGENCE_BULLISH'
| 'DIVERGENCE_BEARISH'
| 'OVERBOUGHT'
| 'OVERSOLD'
| 'ZERO_CROSS_UP'
| 'ZERO_CROSS_DOWN';
/**
* 개별 조건 DTO
*/
export interface StrategyConditionDto {
id?: number;
conditionGroupId?: number;
indicatorType: IndicatorType;
conditionType: ConditionType;
targetValue?: number;
secondaryValue?: number;
period?: number;
comparePeriod?: number; // 골든/데드 크로스에서 비교 대상 장기 기간
enabled?: boolean;
orderIndex?: number;
description?: string;
logicalOperator?: LogicalOperator; // 다음 조건과의 논리 연산자 (AND/OR)
candleRange?: number; // 조건 체크 캔들 범위 (1=현재캔들만, 2=현재+이전1개, 3=현재+이전2개, ...)
}
/**
* 조건 그룹 DTO
*/
export interface ConditionGroupDto {
id?: number;
strategyRuleId?: number;
parentGroupId?: number;
logicalOperator: LogicalOperator;
orderIndex?: number;
name?: string;
groupType?: 'BUY' | 'SELL'; // 매수/매도 보조지표 구분용
conditions: StrategyConditionDto[];
childGroups?: ConditionGroupDto[];
interGroupOperator?: LogicalOperator; // 다음 그룹과의 논리 연산자 (AND/OR)
}
/**
* MA 크로스 타입
*/
export type MaCrossType = 'GOLDEN_CROSS' | 'DEAD_CROSS';
/**
* 일목균형표 크로스 타입
*/
export type IchimokuCrossType = 'CROSS_UP' | 'CROSS_DOWN';
/**
* 볼린저밴드 타입
*/
export type BollingerBandType = 'UPPER' | 'MIDDLE' | 'LOWER';
/**
* 볼린저밴드 크로스 타입
*/
export type BollingerCrossType = 'CROSS_UP' | 'CROSS_DOWN';
/**
* 이동평균선 조건 DTO
*/
export interface MaConditionDto {
id?: number;
maShortPeriod: number;
maLongPeriod: number;
maCrossType: MaCrossType;
logicalOperator?: LogicalOperator; // 다음 조건과의 논리 연산자 (AND/OR)
orderIndex?: number;
}
/**
* 볼린저밴드 조건 DTO
*/
export interface BollingerBandConditionDto {
id?: number;
period: number; // 기간 (기본: 20)
stdDev: number; // 표준편차 배수 (기본: 2.0)
bandType: BollingerBandType; // 밴드 타입 (UPPER/MIDDLE/LOWER)
crossType: BollingerCrossType; // 크로스 방향 (CROSS_UP/CROSS_DOWN)
logicalOperator?: LogicalOperator; // 다음 조건과의 논리 연산자 (AND/OR)
orderIndex?: number;
}
/**
* 전략 규칙 DTO
*/
export interface StrategyRuleDto {
id?: number;
name: string;
description?: string;
signalType: SignalType;
trendType?: TrendType;
enabled?: boolean;
strategyType?: string; // DSL/LEGACY/FORM
createdByUi?: string; // TREE/FORM/LEGACY
// ==================== 매수 조건 설정 ====================
// 매수: 이동평균선 조건 (새로운 배열 구조)
enableMaBuy?: boolean;
maConditionsBuy?: MaConditionDto[];
// 매수: 이동평균선 조건 1 (하위 호환성을 위해 유지)
maShortPeriod1Buy?: number;
maLongPeriod1Buy?: number;
maCrossType1Buy?: MaCrossType;
// 매수: 이동평균선 조건 2 (OR 조건) (하위 호환성을 위해 유지)
enableMaCondition2Buy?: boolean;
maShortPeriod2Buy?: number;
maLongPeriod2Buy?: number;
maCrossType2Buy?: MaCrossType;
// 매수: 일목균형표
enableIchimokuBuy?: boolean;
ichimokuTenkanBuy?: number;
ichimokuKijunBuy?: number;
ichimokuCrossTypeBuy?: IchimokuCrossType;
// 매수: 볼린저밴드 조건
enableBollingerBuy?: boolean;
bollingerConditionsBuy?: BollingerBandConditionDto[];
// 매수: 거래량 필터 (골든크로스용)
enableVolumeFilterBuy?: boolean;
// ==================== 매도 조건 설정 ====================
// 매도: 이동평균선 조건 (새로운 배열 구조)
enableMaSell?: boolean;
maConditionsSell?: MaConditionDto[];
// 매도: 이동평균선 조건 1 (하위 호환성을 위해 유지)
maShortPeriod1Sell?: number;
maLongPeriod1Sell?: number;
maCrossType1Sell?: MaCrossType;
// 매도: 이동평균선 조건 2 (OR 조건) (하위 호환성을 위해 유지)
enableMaCondition2Sell?: boolean;
maShortPeriod2Sell?: number;
maLongPeriod2Sell?: number;
maCrossType2Sell?: MaCrossType;
// 매도: 일목균형표
enableIchimokuSell?: boolean;
ichimokuTenkanSell?: number;
ichimokuKijunSell?: number;
ichimokuCrossTypeSell?: IchimokuCrossType;
// 매도: 볼린저밴드 조건
enableBollingerSell?: boolean;
bollingerConditionsSell?: BollingerBandConditionDto[];
// 보조지표 ON/OFF
enableAuxBuy?: boolean;
enableAuxSell?: boolean;
// 그룹 간 논리연산자 (AND: 모든 그룹 충족, OR: 하나 이상 충족)
interGroupOperatorBuy?: LogicalOperator;
interGroupOperatorSell?: LogicalOperator;
// 매수 보조지표 조건 그룹
buyConditionGroups?: ConditionGroupDto[];
// 매도 보조지표 조건 그룹
sellConditionGroups?: ConditionGroupDto[];
createdAt?: string;
updatedAt?: string;
conditionSummary?: string;
totalConditionCount?: number;
}
/**
* 종목 검색 요청 DTO
*/
export interface StrategyRuleSearchRequest {
symbols?: string[];
dataSource?: 'REALTIME' | 'HISTORICAL';
timeframe?: string;
startDate?: string;
endDate?: string;
minMatchScore?: number;
sortBy?: 'MATCH_SCORE' | 'CHANGE_RATE' | 'VOLUME' | 'TRADE_VALUE' | 'SYMBOL';
sortDirection?: 'ASC' | 'DESC';
limit?: number;
}
/**
* 매칭된 조건 상세
*/
export interface MatchedConditionDetail {
conditionId: number;
description: string;
indicatorType: string;
currentValue: number;
previousValue?: number;
targetValue?: number;
matched: boolean;
groupId: number;
}
/**
* 종목 검색 결과 DTO
*/
export interface SymbolMatchResultDto {
symbol: string;
name: string;
currentPrice: number;
changeRate: number;
changePrice: number;
volume: number;
tradeValue: number;
matchScore: number;
matchedConditionCount: number;
totalConditionCount: number;
matchedConditions: MatchedConditionDetail[];
indicatorValues: Record<string, number>;
dataTime: string;
dataSource: string;
}
/**
* 지표/조건 타입 정보
*/
export interface TypeInfo {
value: string;
label: string;
description: string;
}
// ==================== API 함수 ====================
/**
* 전략 규칙 목록 조회
*/
export const getStrategyRules = async (
signalType?: SignalType,
enabledOnly: boolean = false,
trendType?: TrendType
): Promise<StrategyRuleDto[]> => {
try {
const params: Record<string, any> = {};
if (signalType) params.signalType = signalType;
if (enabledOnly) params.enabledOnly = true;
if (trendType) params.trendType = trendType;
const response = await axios.get(`${API_BASE_URL}/strategy-rules`, { params });
return response.data;
} catch (error) {
console.warn('전략 목록 조회 실패:', error);
// 에러 발생 시 빈 배열 반환 (UI가 계속 동작하도록)
return [];
}
};
/**
* 추세 타입 목록 조회
*/
export const getTrendTypes = async (): Promise<TypeInfo[]> => {
const response = await axios.get(`${API_BASE_URL}/strategy-rules/trend-types`);
return response.data;
};
/**
* 전략 규칙 상세 조회
*/
export const getStrategyRule = async (id: number): Promise<StrategyRuleDto> => {
const response = await axios.get(`${API_BASE_URL}/strategy-rules/${id}`);
return response.data;
};
/**
* 전략 규칙 생성
*/
export const createStrategyRule = async (rule: StrategyRuleDto): Promise<StrategyRuleDto> => {
const response = await axios.post(`${API_BASE_URL}/strategy-rules`, rule);
return response.data;
};
/**
* 전략 규칙 수정
*/
export const updateStrategyRule = async (id: number, rule: StrategyRuleDto): Promise<StrategyRuleDto> => {
const response = await axios.put(`${API_BASE_URL}/strategy-rules/${id}`, rule);
return response.data;
};
/**
* 전략 규칙 삭제
*/
export const deleteStrategyRule = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/strategy-rules/${id}`);
};
/**
* 전략 규칙 활성화/비활성화 토글
*/
export const toggleStrategyRule = async (id: number, enabled: boolean): Promise<StrategyRuleDto> => {
const response = await axios.patch(
`${API_BASE_URL}/strategy-rules/${id}/toggle`,
null,
{ params: { enabled } }
);
return response.data;
};
/**
* 조건에 맞는 종목 검색
*/
export const searchSymbolsByRule = async (
ruleId: number,
request: StrategyRuleSearchRequest
): Promise<SymbolMatchResultDto[]> => {
const response = await axios.post(`${API_BASE_URL}/strategy-rules/${ruleId}/search`, request);
return response.data;
};
// ==================== 전략 검증 관련 타입 ====================
/**
* 검증 상태
*/
export type ValidationStatus = 'PASS' | 'WARNING' | 'FAIL';
/**
* 검증 오류
*/
export interface ValidationError {
groupIndex: number;
conditionIndex: number;
errorType: string;
message: string;
suggestion?: string;
}
/**
* 검증 경고
*/
export interface ValidationWarning {
groupIndex: number;
conditionIndex: number;
warningType: string;
message: string;
}
/**
* 전략 검증 결과 DTO
*/
export interface StrategyValidationResultDto {
valid: boolean;
status: ValidationStatus;
message: string;
errors: ValidationError[];
warnings: ValidationWarning[];
totalConditions: number;
errorCount: number;
warningCount: number;
}
/**
* 전략 규칙 검증
*/
export const validateStrategyRule = async (
rule: StrategyRuleDto
): Promise<StrategyValidationResultDto> => {
const response = await axios.post(`${API_BASE_URL}/strategy-rules/validate`, rule);
return response.data;
};
/**
* 지표 타입 목록 조회
*/
export const getIndicatorTypes = async (): Promise<TypeInfo[]> => {
const response = await axios.get(`${API_BASE_URL}/strategy-rules/indicator-types`);
return response.data;
};
/**
* 조건 타입 목록 조회
*/
export const getConditionTypes = async (): Promise<TypeInfo[]> => {
const response = await axios.get(`${API_BASE_URL}/strategy-rules/condition-types`);
return response.data;
};
// ==================== 헬퍼 함수 ====================
/**
* 지표 타입 표시 이름
*/
export const getIndicatorDisplayName = (type: IndicatorType): string => {
const names: Record<IndicatorType, string> = {
// 업비트 표준 16개 보조지표
VOLUME: '거래량',
MACD: 'MACD',
STOCHASTIC: 'Stochastic Slow',
RSI: 'RSI',
ADX: 'ADX',
CCI: 'CCI',
TRIX: 'TRIX',
DISPARITY: '이격도',
NEW_PSYCHOLOGICAL: '신심리도',
INVEST_PSYCHOLOGICAL: '투자심리도',
WILLIAMS_R: 'Williams %R',
BWI: '볼린저밴드 %B',
OBV: 'OBV',
VOLUME_OSC: 'VolumeOSC',
VR: 'VR',
DMI: 'DMI',
};
return names[type] || type;
};
/**
* 추세 타입 표시 이름
*/
export const getTrendDisplayName = (type: TrendType): string => {
const names: Record<TrendType, string> = {
ALL: '전체',
UPTREND: '상승추세 (정배열)',
DOWNTREND: '하락추세 (역배열)',
SIDEWAYS: '횡보',
RANGE: '박스권',
};
return names[type] || type;
};
/**
* 조건 타입 표시 이름
*/
export const getConditionDisplayName = (type: ConditionType): string => {
const names: Record<ConditionType, string> = {
CROSS_UP: '기준값 돌파',
CROSS_DOWN: '기준값 하향 돌파',
GOLDEN_CROSS: '골든크로스',
DEAD_CROSS: '데드크로스',
GOLDEN_HOLD_CHECK: '골든크로스 유지',
DEAD_HOLD_CHECK: '데드크로스 유지',
ABOVE: '기준값 이상',
BELOW: '기준값 이하',
ABOVE_STRICT: '기준값 초과',
BELOW_STRICT: '기준값 미만',
EQ: '같음',
NEQ: '다름',
BETWEEN: '범위',
OUTSIDE: '범위 외',
INCREASING: '상승 중',
DECREASING: '하락 중',
TURNING_UP: '상승 전환',
TURNING_DOWN: '하락 전환',
DIVERGENCE_BULLISH: '상승 다이버전스',
DIVERGENCE_BEARISH: '하락 다이버전스',
OVERBOUGHT: '과매수',
OVERSOLD: '과매도',
ZERO_CROSS_UP: '0선 상향',
ZERO_CROSS_DOWN: '0선 하향',
};
return names[type] || type;
};
/**
* 조건 설명 생성 (업비트 16개 지표 기준)
*/
export const generateConditionDescription = (condition: StrategyConditionDto): string => {
const indicator = getIndicatorDisplayName(condition.indicatorType);
const conditionName = getConditionDisplayName(condition.conditionType);
// MACD의 CROSS_UP/CROSS_DOWN은 Signal 크로스 (targetValue 무시)
if (condition.indicatorType === 'MACD' &&
(condition.conditionType === 'CROSS_UP' || condition.conditionType === 'GOLDEN_CROSS')) {
return `${indicator} 골든 크로스 (Signal 상향 돌파)`;
}
if (condition.indicatorType === 'MACD' &&
(condition.conditionType === 'CROSS_DOWN' || condition.conditionType === 'DEAD_CROSS')) {
return `${indicator} 데드 크로스 (Signal 하향 돌파)`;
}
// TRIX의 CROSS_UP/CROSS_DOWN은 Signal 크로스 (targetValue 무시)
if (condition.indicatorType === 'TRIX' &&
(condition.conditionType === 'CROSS_UP' || condition.conditionType === 'GOLDEN_CROSS')) {
return `${indicator} Signal 상향 돌파`;
}
if (condition.indicatorType === 'TRIX' &&
(condition.conditionType === 'CROSS_DOWN' || condition.conditionType === 'DEAD_CROSS')) {
return `${indicator} Signal 하향 돌파`;
}
// Stochastic의 GOLDEN_CROSS/DEAD_CROSS는 %K vs %D 크로스 (targetValue 무시)
if (condition.indicatorType === 'STOCHASTIC' && condition.conditionType === 'GOLDEN_CROSS') {
const period = condition.period ? `(${condition.period})` : '';
return `Stochastic${period} 골든 크로스 (%K > %D)`;
}
if (condition.indicatorType === 'STOCHASTIC' && condition.conditionType === 'DEAD_CROSS') {
const period = condition.period ? `(${condition.period})` : '';
return `Stochastic${period} 데드 크로스 (%K < %D)`;
}
// DMI의 CROSS_UP/CROSS_DOWN은 +DI vs -DI 크로스 (targetValue 무시)
if (condition.indicatorType === 'DMI' && condition.conditionType === 'CROSS_UP') {
return `DMI 골든 크로스 (+DI > -DI)`;
}
if (condition.indicatorType === 'DMI' && condition.conditionType === 'CROSS_DOWN') {
return `DMI 데드 크로스 (+DI < -DI)`;
}
let desc = indicator;
if (condition.period) {
desc += `(${condition.period})`;
}
desc += ` ${conditionName}`;
// 일반 조건에서 targetValue 표시
if (condition.targetValue !== undefined && condition.targetValue !== null) {
desc += ` ${condition.targetValue}`;
if (condition.secondaryValue !== undefined && condition.conditionType === 'BETWEEN') {
desc += ` ~ ${condition.secondaryValue}`;
}
}
return desc;
};
/**
* 그룹 설명 생성
*/
export const generateGroupDescription = (group: ConditionGroupDto): string => {
if (group.conditions.length === 0 && (!group.childGroups || group.childGroups.length === 0)) {
return '빈 그룹';
}
const parts: string[] = [];
group.conditions.forEach(condition => {
parts.push(condition.description || generateConditionDescription(condition));
});
group.childGroups?.forEach(child => {
parts.push(`(${generateGroupDescription(child)})`);
});
return parts.join(` ${group.logicalOperator} `);
};
/**
* 빈 MA 조건 생성
*/
export const createEmptyMaCondition = (orderIndex: number = 0, signalType: SignalType = 'BUY'): MaConditionDto => ({
maShortPeriod: signalType === 'BUY' ? 20 : 20,
maLongPeriod: signalType === 'BUY' ? 60 : 60,
maCrossType: signalType === 'BUY' ? 'GOLDEN_CROSS' : 'DEAD_CROSS',
logicalOperator: 'OR',
orderIndex,
});
/**
* 빈 볼린저밴드 조건 생성
*/
export const createEmptyBollingerCondition = (orderIndex: number = 0, signalType: SignalType = 'BUY'): BollingerBandConditionDto => ({
period: 20,
stdDev: 2.0,
bandType: signalType === 'BUY' ? 'LOWER' : 'UPPER',
crossType: signalType === 'BUY' ? 'CROSS_UP' : 'CROSS_DOWN',
logicalOperator: 'OR',
orderIndex,
});
/**
* 기존 하드코딩 필드를 새로운 배열 구조로 마이그레이션
*/
export const migrateMaConditionsToArray = (rule: StrategyRuleDto): StrategyRuleDto => {
const migratedRule = { ...rule };
// 매수 조건 마이그레이션
if (!migratedRule.maConditionsBuy || migratedRule.maConditionsBuy.length === 0) {
const buyConditions: MaConditionDto[] = [];
// 조건 1이 있으면 추가
if (migratedRule.maShortPeriod1Buy && migratedRule.maLongPeriod1Buy) {
buyConditions.push({
maShortPeriod: migratedRule.maShortPeriod1Buy,
maLongPeriod: migratedRule.maLongPeriod1Buy,
maCrossType: migratedRule.maCrossType1Buy || 'GOLDEN_CROSS',
logicalOperator: 'OR',
orderIndex: 0,
});
}
// 조건 2가 활성화되어 있으면 추가
if (migratedRule.enableMaCondition2Buy && migratedRule.maShortPeriod2Buy && migratedRule.maLongPeriod2Buy) {
buyConditions.push({
maShortPeriod: migratedRule.maShortPeriod2Buy,
maLongPeriod: migratedRule.maLongPeriod2Buy,
maCrossType: migratedRule.maCrossType2Buy || 'GOLDEN_CROSS',
logicalOperator: 'OR',
orderIndex: 1,
});
}
// 조건이 없으면 기본 1개 생성
if (buyConditions.length === 0) {
buyConditions.push(createEmptyMaCondition(0, 'BUY'));
}
migratedRule.maConditionsBuy = buyConditions;
}
// 매도 조건 마이그레이션
if (!migratedRule.maConditionsSell || migratedRule.maConditionsSell.length === 0) {
const sellConditions: MaConditionDto[] = [];
// 조건 1이 있으면 추가
if (migratedRule.maShortPeriod1Sell && migratedRule.maLongPeriod1Sell) {
sellConditions.push({
maShortPeriod: migratedRule.maShortPeriod1Sell,
maLongPeriod: migratedRule.maLongPeriod1Sell,
maCrossType: migratedRule.maCrossType1Sell || 'DEAD_CROSS',
logicalOperator: 'OR',
orderIndex: 0,
});
}
// 조건 2가 활성화되어 있으면 추가
if (migratedRule.enableMaCondition2Sell && migratedRule.maShortPeriod2Sell && migratedRule.maLongPeriod2Sell) {
sellConditions.push({
maShortPeriod: migratedRule.maShortPeriod2Sell,
maLongPeriod: migratedRule.maLongPeriod2Sell,
maCrossType: migratedRule.maCrossType2Sell || 'DEAD_CROSS',
logicalOperator: 'OR',
orderIndex: 1,
});
}
// 조건이 없으면 기본 1개 생성
if (sellConditions.length === 0) {
sellConditions.push(createEmptyMaCondition(0, 'SELL'));
}
migratedRule.maConditionsSell = sellConditions;
}
return migratedRule;
};
/**
* 빈 조건 생성
*/
export const createEmptyCondition = (orderIndex: number = 0): StrategyConditionDto => ({
indicatorType: 'RSI',
conditionType: 'ABOVE', // 기본값: RSI의 첫 번째 조건
targetValue: 30, // RSI 기본 기준값 (과매도)
enabled: true,
orderIndex,
candleRange: 1, // 기본값: 현재 캔들만
});
/**
* 빈 그룹 생성
*/
export const createEmptyGroup = (orderIndex: number = 0, groupType: 'BUY' | 'SELL' = 'BUY'): ConditionGroupDto => ({
logicalOperator: 'AND',
orderIndex,
groupType,
conditions: [createEmptyCondition(0)],
childGroups: [],
});
/**
* 빈 규칙 생성
*/
export const createEmptyRule = (signalType: SignalType = 'BUY', trendType: TrendType = 'ALL'): StrategyRuleDto => ({
name: '',
description: '',
signalType,
trendType,
enabled: true,
// ==================== 매수 조건 기본값 ====================
// 매수: 이동평균선 조건 (새로운 배열 구조)
enableMaBuy: false,
maConditionsBuy: [createEmptyMaCondition(0, 'BUY')],
// 매수: 이동평균선 조건 1 (하위 호환성)
maShortPeriod1Buy: 20,
maLongPeriod1Buy: 60,
maCrossType1Buy: 'GOLDEN_CROSS',
// 매수: 이동평균선 조건 2 (OR) (하위 호환성)
enableMaCondition2Buy: false,
maShortPeriod2Buy: 5,
maLongPeriod2Buy: 20,
maCrossType2Buy: 'GOLDEN_CROSS',
// 매수: 일목균형표
enableIchimokuBuy: false,
ichimokuTenkanBuy: 9,
ichimokuKijunBuy: 26,
ichimokuCrossTypeBuy: 'CROSS_UP',
// 매수: 볼린저밴드 조건
enableBollingerBuy: false,
bollingerConditionsBuy: [createEmptyBollingerCondition(0, 'BUY')],
// 매수: 거래량 필터 (골든크로스용, 기본 활성화)
enableVolumeFilterBuy: true,
// ==================== 매도 조건 기본값 ====================
// 매도: 이동평균선 조건 (새로운 배열 구조)
enableMaSell: false,
maConditionsSell: [createEmptyMaCondition(0, 'SELL')],
// 매도: 이동평균선 조건 1 (하위 호환성)
maShortPeriod1Sell: 20,
maLongPeriod1Sell: 60,
maCrossType1Sell: 'DEAD_CROSS',
// 매도: 이동평균선 조건 2 (OR) (하위 호환성)
enableMaCondition2Sell: false,
maShortPeriod2Sell: 5,
maLongPeriod2Sell: 20,
maCrossType2Sell: 'DEAD_CROSS',
// 매도: 일목균형표
enableIchimokuSell: false,
ichimokuTenkanSell: 9,
ichimokuKijunSell: 26,
ichimokuCrossTypeSell: 'CROSS_DOWN',
// 매도: 볼린저밴드 조건
enableBollingerSell: false,
bollingerConditionsSell: [createEmptyBollingerCondition(0, 'SELL')],
// 보조지표 ON/OFF
enableAuxBuy: false,
enableAuxSell: false,
// 그룹 간 논리연산자 (기본값: OR - 하나 이상 충족 시 신호 발생)
interGroupOperatorBuy: 'OR',
interGroupOperatorSell: 'OR',
// 매수/매도 보조지표 조건 그룹
buyConditionGroups: [createEmptyGroup(0, 'BUY')],
sellConditionGroups: [createEmptyGroup(0, 'SELL')],
});
/**
* 기본 매수 조건 템플릿
*/
export const getBuyConditionTemplates = (): StrategyRuleDto[] => [
{
name: 'RSI 과매도 매수',
description: 'RSI가 30 이하로 하락 후 상향 돌파 시 매수',
signalType: 'BUY',
enabled: true,
buyConditionGroups: [{
logicalOperator: 'AND',
orderIndex: 0,
groupType: 'BUY',
conditions: [{
indicatorType: 'RSI',
conditionType: 'CROSS_UP',
targetValue: 30,
period: 14,
enabled: true,
orderIndex: 0,
}],
childGroups: [],
}],
},
{
name: 'MACD 골든크로스 매수',
description: 'MACD 골든크로스 발생 시 매수',
signalType: 'BUY',
enabled: true,
buyConditionGroups: [{
logicalOperator: 'AND',
orderIndex: 0,
groupType: 'BUY',
conditions: [{
indicatorType: 'MACD',
conditionType: 'GOLDEN_CROSS',
enabled: true,
orderIndex: 0,
}],
childGroups: [],
}],
},
{
name: 'Stochastic 과매도 + RSI 상승',
description: 'Stochastic 20 상향 돌파 AND RSI 상승 중',
signalType: 'BUY',
enabled: true,
buyConditionGroups: [{
logicalOperator: 'AND',
orderIndex: 0,
groupType: 'BUY',
conditions: [
{
indicatorType: 'STOCHASTIC',
conditionType: 'GOLDEN_CROSS',
period: 14,
enabled: true,
orderIndex: 0,
},
{
indicatorType: 'RSI',
conditionType: 'INCREASING',
period: 14,
enabled: true,
orderIndex: 1,
},
],
childGroups: [],
}],
},
];
/**
* 기본 매도 조건 템플릿
*/
export const getSellConditionTemplates = (): StrategyRuleDto[] => [
{
name: 'RSI 과매수 매도',
description: 'RSI가 70 이상으로 상승 후 하향 돌파 시 매도',
signalType: 'SELL',
enabled: true,
sellConditionGroups: [{
logicalOperator: 'AND',
orderIndex: 0,
groupType: 'SELL',
conditions: [{
indicatorType: 'RSI',
conditionType: 'CROSS_DOWN',
targetValue: 70,
period: 14,
enabled: true,
orderIndex: 0,
}],
childGroups: [],
}],
},
{
name: 'MACD 데드크로스 매도',
description: 'MACD 데드크로스 발생 시 매도',
signalType: 'SELL',
enabled: true,
sellConditionGroups: [{
logicalOperator: 'AND',
orderIndex: 0,
groupType: 'SELL',
conditions: [{
indicatorType: 'MACD',
conditionType: 'DEAD_CROSS',
enabled: true,
orderIndex: 0,
}],
childGroups: [],
}],
},
];
@@ -0,0 +1,190 @@
import axios from 'axios';
import { CandleData } from './backtestApi';
import { getApiBaseUrl } from './apiConfig';
const API_BASE_URL = getApiBaseUrl();
/**
* 보조지표 데이터 인터페이스 (백엔드 IndicatorDataDto와 동일)
*/
export interface IndicatorData {
time: string;
macdLine?: number;
macdSignal?: number;
macdHistogram?: number;
stochasticK?: number;
stochasticD?: number;
rsi?: number;
obv?: number;
obvSignal?: number; // OBV Signal Line (이동평균)
// ❌ EMA 사용하지 않음 - SMA만 사용
// ema5?: number;
// ema10?: number;
// ema20?: number;
// ema60?: number;
// ema120?: number;
}
/**
* 시그널 분석 요청 인터페이스
* ✅ 개선: 이미 계산된 indicatorData를 전송하여 중복 계산 방지 및 정합성 보장
*/
export interface SignalAnalysisRequest {
symbol: string;
interval: string;
candleData: CandleData[];
uptrendStrategyId?: number;
downtrendStrategyId?: number;
// ✅ 핵심 개선: 프론트엔드에서 계산한 지표 데이터 전송
indicatorData?: IndicatorData[];
// 추세 판단 MA 설정 (프론트엔드에서 선택)
trendShortMA?: number;
trendMediumMA?: number;
trendLongMA?: number;
// MACD 파라미터 (indicatorData가 없을 때 사용)
macdFastPeriod?: number;
macdSlowPeriod?: number;
macdSignalPeriod?: number;
// Stochastic 파라미터 (indicatorData가 없을 때 사용)
stochasticKPeriod?: number;
stochasticDPeriod?: number;
stochasticSlowing?: number;
// Williams %R 파라미터 (indicatorData가 없을 때 사용)
williamsRPeriod?: number;
// RSI 파라미터 (indicatorData가 없을 때 사용)
rsiPeriod?: number;
rsiOversold?: number;
rsiOverbought?: number;
// CCI 파라미터
cciOversold?: number; // CCI 침체 기준 (기본값: -100)
cciMiddle?: number; // CCI 중앙 기준 (기본값: 0)
cciOverbought?: number; // CCI 과열 기준 (기본값: 100)
}
/**
* 캔들 시그널 정보
*/
export interface CandleSignal {
time: string;
isBuyPoint: boolean;
isSellPoint: boolean;
signal: 'BUY' | 'SELL' | 'HOLD';
strategyName?: string;
trendStatus?: string;
matchedConditions?: string[];
}
/**
* 시그널 분석 응답 인터페이스
*/
export interface SignalAnalysisResponse {
success: boolean;
message?: string;
signals: CandleSignal[];
buySignalCount: number;
sellSignalCount: number;
}
/**
* 전략 기반 시그널 분석
* ✅ 개선: 이미 계산된 indicatorData를 전송하여 중복 계산 방지 및 정합성 보장
*
* @param request - 시그널 분석 요청
* - indicatorData: 프론트엔드에서 이미 계산한 지표 데이터 (권장)
* - candleData: 시간 매칭용 캔들 데이터
*/
export const analyzeSignals = async (request: SignalAnalysisRequest): Promise<SignalAnalysisResponse> => {
console.log('📊 시그널 분석 요청:', {
symbol: request.symbol,
interval: request.interval,
candleCount: request.candleData?.length,
indicatorDataCount: request.indicatorData?.length,
hasPreCalculatedIndicators: !!request.indicatorData,
uptrendStrategyId: request.uptrendStrategyId,
downtrendStrategyId: request.downtrendStrategyId
});
try {
const response = await axios.post<SignalAnalysisResponse>(
`${API_BASE_URL}/strategy-rules/analyze-signals`,
request
);
// ✅ 검증: 시간 매칭 정확성 확인
validateSignalTimeMatching(request.candleData, response.data.signals);
// ✅ 검증: 중복 시그널 확인
validateNoDuplicateSignals(response.data.signals);
console.log('✅ 시그널 분석 완료:', {
success: response.data.success,
buySignals: response.data.buySignalCount,
sellSignals: response.data.sellSignalCount,
totalSignals: response.data.signals?.length,
usedPreCalculatedData: !!request.indicatorData
});
return response.data;
} catch (error: any) {
console.error('❌ 시그널 분석 실패:', error.response?.data || error.message);
throw error;
}
};
/**
* ✅ 시간 매칭 정확성 검증
* 백엔드에서 반환한 시그널의 시간이 요청한 캔들 데이터의 시간과 정확히 일치하는지 확인
*/
function validateSignalTimeMatching(candleData: any[], signals: CandleSignal[]): void {
if (!signals || signals.length === 0) return;
const candleTimeSet = new Set(candleData.map(c => c.time));
const unmatchedSignals = signals.filter(s => !candleTimeSet.has(s.time));
if (unmatchedSignals.length > 0) {
console.warn('⚠️ 시간 매칭 오류: 다음 시그널의 시간이 캔들 데이터에 없습니다:',
unmatchedSignals.map(s => s.time));
console.warn(' → 해당 시그널은 무시됩니다.');
} else {
console.log('✅ 시간 매칭 검증 통과: 모든 시그널이 정확한 캔들 시간과 매칭됨');
}
}
/**
* ✅ 중복 시그널 검증
* 동일한 시간에 BUY와 SELL이 동시에 발생하거나, 동일한 시간에 중복 시그널이 있는지 확인
*/
function validateNoDuplicateSignals(signals: CandleSignal[]): void {
if (!signals || signals.length === 0) return;
// 1. 동일 시간에 BUY와 SELL 동시 발생 확인
const conflictingSignals = signals.filter(s => s.isBuyPoint && s.isSellPoint);
if (conflictingSignals.length > 0) {
console.error('❌ 시그널 충돌: 동일 시간에 BUY와 SELL이 동시 발생:',
conflictingSignals.map(s => s.time));
}
// 2. 동일 시간에 중복 시그널 확인
const timeCount = new Map<string, number>();
signals.forEach(s => {
timeCount.set(s.time, (timeCount.get(s.time) || 0) + 1);
});
const duplicates = Array.from(timeCount.entries()).filter(([_, count]) => count > 1);
if (duplicates.length > 0) {
console.error('❌ 중복 시그널: 동일 시간에 여러 시그널 존재:',
duplicates.map(([time, count]) => `${time} (${count}개)`));
}
if (conflictingSignals.length === 0 && duplicates.length === 0) {
console.log('✅ 중복 검증 통과: 모든 시그널이 고유하며 충돌 없음');
}
}
@@ -0,0 +1,106 @@
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8083';
export interface SystemMonitorData {
pipelineStatus: PipelineStatus;
websocketStats: WebSocketStats;
databaseStatus: DatabaseStatus;
memoryStats: MemoryStats;
systemMetrics: SystemMetrics;
timestamp: string;
}
export interface PipelineStatus {
components: PipelineComponent[];
}
export interface PipelineComponent {
name: string;
type: string; // agent, kafka, processor, database
status: 'healthy' | 'warning' | 'error' | 'unknown';
description: string;
messagesProcessed: number | null;
latencyMs: number | null;
details: string;
}
export interface WebSocketStats {
totalConnections: number;
activeConnections: number;
serviceStats: Record<string, ServiceWebSocketStats>;
}
export interface ServiceWebSocketStats {
serviceName: string;
connections: number;
messagesReceived: number;
messagesSent: number;
messagesPerSecond: number;
lastActivity: string | null;
}
export interface DatabaseStatus {
connected: boolean;
activeConnections: number;
maxConnections: number;
totalQueries: number;
avgQueryTimeMs: number | null;
tableStats: Record<string, TableStats>;
}
export interface TableStats {
tableName: string;
rowCount: number;
sizeBytes: number;
sizeFormatted: string;
lastUpdated: string;
}
export interface MemoryStats {
jvmHeapUsed: number;
jvmHeapMax: number;
jvmHeapUsagePercent: number;
jvmNonHeapUsed: number;
systemTotalMemory: number;
systemFreeMemory: number;
systemUsedMemory: number;
systemMemoryUsagePercent: number;
gcCount: number;
gcTimeMs: number;
}
export interface SystemMetrics {
cpuUsagePercent: number;
availableProcessors: number;
threadCount: number;
peakThreadCount: number;
uptimeMs: number;
uptimeFormatted: string;
diskTotal: number;
diskFree: number;
diskUsed: number;
diskUsagePercent: number;
networkBytesReceived: number;
networkBytesSent: number;
}
export const systemMonitorApi = {
/**
* 시스템 모니터링 정보 조회
*/
getSystemMonitorInfo: async (): Promise<SystemMonitorData> => {
const response = await axios.get<SystemMonitorData>(`${API_BASE_URL}/api/system-monitor`, {
timeout: 10000,
});
return response.data;
},
/**
* 헬스 체크
*/
healthCheck: async (): Promise<string> => {
const response = await axios.get<string>(`${API_BASE_URL}/api/system-monitor/health`);
return response.data;
},
};
@@ -0,0 +1,860 @@
import axios from 'axios';
import { API_CONFIG } from './apiConfig';
const API_BASE_URL = API_CONFIG.baseURL;
export interface TradingStrategy {
id?: number;
name: string;
strategyType: 'STRATEGY_1' | 'STRATEGY_2' | 'STRATEGY_3' | 'STRATEGY_4' | 'STRATEGY_5' | 'TREND_STOCHASTIC' | 'TREND_STOCHASTIC_CCI' | 'STOCHASTIC_ONLY' | 'COMPOSITE_INDICATOR' | 'INTEGRATED_INDICATOR';
description?: string;
enabled: boolean;
stochOversoldThreshold?: number;
stochOverboughtThreshold?: number;
stochNeutralThreshold?: number;
adxStrongThreshold?: number;
cciOverboughtThreshold?: number;
cciOversoldThreshold?: number;
macdWeight?: number;
stochasticWeight?: number;
adxWeight?: number;
cciWeight?: number;
// TREND_STOCHASTIC 전용 설정 (정배열/역배열 + Stochastic)
enableMacdCondition?: boolean; // MACD/추세 조건 사용 여부 (OFF 시 정배열/역배열 무관)
enableCciCondition?: boolean; // CCI/추세 조건 사용 여부 (OFF 시 CCI 무관)
enableBullishAllLevels?: boolean; // 정배열 시 모든 레벨 신호 활성화
enableBearishOnlyOverbought?: boolean; // 역배열 시 80 레벨만 활성화
// STOCHASTIC_ONLY 전용 설정 (레벨별 on/off)
enableLevel20?: boolean; // 20 레벨 돌파 조건 활성화
enableLevel50?: boolean; // 50 레벨 돌파 조건 활성화
enableLevel80?: boolean; // 80 레벨 돌파 조건 활성화
// TREND_STOCHASTIC 전용 설정 (분할 매매 비율)
level1BuyRatio?: number; // 1차 매수 비율 (20레벨 상향돌파, 기본: 0.33)
level1SellRatio?: number; // 1차 매도 비율 (20레벨 하향돌파, 기본: 0.33)
level2BuyRatio?: number; // 2차 매수 비율 (50레벨 상향돌파, 기본: 0.33)
level2SellRatio?: number; // 2차 매도 비율 (50레벨 하향돌파, 기본: 0.33)
level3BuyRatio?: number; // 3차 매수 비율 (80레벨 상향돌파, 기본: 0.34)
level3SellRatio?: number; // 3차 매도 비율 (80레벨 하향돌파, 기본: 0.34)
// COMPOSITE_INDICATOR 전용 설정 (복합지표 전략)
rsiPeriod?: number; // RSI 기간 (기본: 14)
rsiOversold?: number; // RSI 과매도 (기본: 30)
rsiOverbought?: number; // RSI 과매수 (기본: 70)
bbPeriod?: number; // 볼린저밴드 기간 (기본: 20)
bbStdDev?: number; // 볼린저밴드 표준편차 배수 (기본: 2.0)
atrPeriod?: number; // ATR 기간 (기본: 14)
volumeIncreaseThreshold?: number; // 거래량 증가 임계값 (기본: 1.5 = 150%)
trendWeight?: number; // 추세 가중치 (기본: 0.4)
momentumWeight?: number; // 모멘텀 가중치 (기본: 0.3)
volatilityWeight?: number; // 변동성 가중치 (기본: 0.2)
volumeWeight?: number; // 거래량 가중치 (기본: 0.1)
minSignalScore?: number; // 최소 신호 점수 (기본: 70)
// INTEGRATED_INDICATOR 전용 설정 (통합지표 전략)
// 이평선(MA) 설정
enableMA?: boolean; // 이평선 사용 여부
maShortPeriod?: number; // 단기 이평선 기간 (기본: 12)
maMidPeriod?: number; // 중기 이평선 기간 (기본: 26)
maLongPeriod?: number; // 장기 이평선 기간 (기본: 60)
// MACD 설정
enableMACD?: boolean; // MACD 사용 여부
macdFastPeriod?: number; // MACD Fast 기간 (기본: 12)
macdSlowPeriod?: number; // MACD Slow 기간 (기본: 26)
macdSignalPeriod?: number; // MACD Signal 기간 (기본: 9)
// Stochastic Slow 설정
enableStochastic?: boolean; // Stochastic 사용 여부
stochKPeriod?: number; // Stochastic %K 기간 (기본: 14)
stochDPeriod?: number; // Stochastic %D 기간 (기본: 3)
stochSlowing?: number; // Stochastic Slowing (기본: 3)
// RSI 설정
enableRSI?: boolean; // RSI 사용 여부
intRsiPeriod?: number; // RSI 기간 (기본: 14)
intRsiOversold?: number; // RSI 과매도 (기본: 30)
intRsiOverbought?: number; // RSI 과매수 (기본: 70)
// CCI 설정
enableCCI?: boolean; // CCI 사용 여부
cciPeriod?: number; // CCI 기간 (기본: 20)
intCciOversold?: number; // CCI 과매도 (기본: -100)
intCciOverbought?: number; // CCI 과매수 (기본: 100)
// ADX 설정
enableADX?: boolean; // ADX 사용 여부
adxPeriod?: number; // ADX 기간 (기본: 14)
intAdxStrong?: number; // ADX 강한 추세 (기본: 25)
// 통합 매매 조건
intBuyCondition?: 'ALL' | 'ANY' | 'MAJORITY'; // 매수 조건 (모두/하나라도/과반수)
intSellCondition?: 'ALL' | 'ANY' | 'MAJORITY'; // 매도 조건 (모두/하나라도/과반수)
createdAt?: string;
updatedAt?: string;
}
/**
* 트레이딩 전략 목록 조회
*/
export const getStrategies = async (activeOnly: boolean = false): Promise<TradingStrategy[]> => {
const response = await axios.get(`${API_BASE_URL}/strategies`, {
params: { activeOnly }
});
return response.data;
};
/**
* 트레이딩 전략 상세 조회
*/
export const getStrategy = async (id: number): Promise<TradingStrategy> => {
const response = await axios.get(`${API_BASE_URL}/strategies/${id}`);
return response.data;
};
/**
* 트레이딩 전략 생성
*/
export const createStrategy = async (strategy: TradingStrategy): Promise<TradingStrategy> => {
const response = await axios.post(`${API_BASE_URL}/strategies`, strategy);
return response.data;
};
/**
* 트레이딩 전략 수정
*/
export const updateStrategy = async (id: number, strategy: TradingStrategy): Promise<TradingStrategy> => {
const response = await axios.put(`${API_BASE_URL}/strategies/${id}`, strategy);
return response.data;
};
/**
* 트레이딩 전략 삭제
*/
export const deleteStrategy = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/strategies/${id}`);
};
/**
* 트레이딩 전략 활성화/비활성화
*/
export const toggleStrategy = async (id: number, enabled: boolean): Promise<TradingStrategy> => {
const response = await axios.patch(`${API_BASE_URL}/strategies/${id}/toggle`, null, {
params: { enabled }
});
return response.data;
};
/**
* 전략 타입별 설명
*/
export const getStrategyTypeDescription = (type: string): string => {
switch (type) {
case 'STRATEGY_1':
return 'MACD 단독: MACD 골든/데드 크로스만 사용';
case 'STRATEGY_2':
return 'MACD + Stochastic: Stochastic 우선, MACD 보조 (권장)';
case 'STRATEGY_3':
return 'MACD + Stochastic + ADX: 추세 강도 고려';
case 'STRATEGY_4':
return '종합 전략: 모든 지표 활용 (고급)';
case 'STRATEGY_5':
return 'MACD Zero Cross: MACD 0선 교차 전략';
case 'TREND_STOCHASTIC':
return 'MACD + Stochastic Slow: 정배열/역배열 추세에 따른 Stochastic 레벨 돌파 매매';
case 'TREND_STOCHASTIC_CCI':
return 'CCI + Stochastic Slow: CCI 추세에 따른 Stochastic 레벨 돌파 매매';
case 'STOCHASTIC_ONLY':
return 'Stochastic Slow Only: 정배열/역배열 무관, 20/50/80 레벨 돌파 매매';
case 'COMPOSITE_INDICATOR':
return '복합지표 전략: 추세+모멘텀+변동성+거래량 4단계 복합 분석 (70점 이상 시 매매)';
case 'INTEGRATED_INDICATOR':
return '통합지표 전략: 이평선/MACD/Stochastic/RSI/CCI/ADX 개별 on/off 및 파라미터 설정 가능';
default:
return '알 수 없음';
}
};
/**
* 전략 타입별 이름
*/
export const getStrategyTypeName = (type: string): string => {
switch (type) {
case 'STRATEGY_1':
return 'MACD 단독';
case 'STRATEGY_2':
return 'MACD + Stochastic';
case 'STRATEGY_3':
return 'MACD + Stochastic + ADX';
case 'STRATEGY_4':
return '종합 전략 (전체)';
case 'STRATEGY_5':
return 'MACD Zero Cross';
case 'TREND_STOCHASTIC':
return 'MACD + Stochastic Slow (추세 기반)';
case 'TREND_STOCHASTIC_CCI':
return 'CCI + Stochastic Slow (CCI 기반)';
case 'STOCHASTIC_ONLY':
return 'Stochastic Slow Only';
case 'COMPOSITE_INDICATOR':
return '복합지표 전략';
case 'INTEGRATED_INDICATOR':
return '통합지표 전략';
default:
return type;
}
};
/**
* 전략 타입별 상세 설명 가져오기
*/
export const getStrategyDetailedDescription = (strategyType: string): string => {
switch (strategyType) {
case 'STRATEGY_1':
return `
[MACD 단독 전략]
▶ MACD (Moving Average Convergence Divergence)를 활용한 기본 전략
【매수 신호】
• MACD 골든 크로스: MACD 선이 Signal 선을 상향 돌파
- 단기 이동평균이 장기 이동평균보다 빠르게 상승
- 상승 모멘텀 발생 시점
【매도 신호】
• MACD 데드 크로스: MACD 선이 Signal 선을 하향 돌파
- 단기 이동평균이 장기 이동평균보다 빠르게 하락
- 하락 모멘텀 발생 시점
【보조 지표】
• MACD 히스토그램: MACD와 Signal의 차이
- 양수(+) → 상승 모멘텀
- 음수(-) → 하락 모멘텀
※ 가장 기본적인 추세 추종 전략
※ 단독 사용 시 잦은 거짓 신호 발생 가능
`.trim();
case 'STRATEGY_2':
return `
[MACD + Stochastic 전략 (권장)]
▶ Stochastic 우선, MACD 보조 전략
▶ 과매수/과매도 구간에서 MACD 신호 확인
【매수 신호】
• Stochastic Slow %K < ${20} (과매도 구간)
• MACD 골든 크로스 발생
• 두 조건 동시 만족 시 매수
【매도 신호】
• Stochastic Slow %K > ${80} (과매수 구간)
• MACD 데드 크로스 발생
• 두 조건 동시 만족 시 매도
【신호 점수 계산】
• Stochastic 가중치: 60%
• MACD 가중치: 40%
• 종합 점수로 매매 판단
【장점】
✓ 과매수/과매도 구간 필터링으로 거짓 신호 감소
✓ 두 지표의 상호 보완
✓ 중기 트레이딩에 적합
`.trim();
case 'STRATEGY_3':
return `
[MACD + Stochastic + ADX 전략]
▶ 추세 강도(ADX)를 추가로 고려하는 전략
▶ 강한 추세 구간에서만 매매
【매수 신호】
• Stochastic Slow %K < ${20} (과매도)
• MACD 골든 크로스
• ADX > ${25} (강한 추세)
• 세 조건 동시 만족 시 매수
【매도 신호】
• Stochastic Slow %K > ${80} (과매수)
• MACD 데드 크로스
• ADX > ${25} (강한 추세)
• 세 조건 동시 만족 시 매도
【신호 점수 계산】
• Stochastic 가중치: 50%
• MACD 가중치: 30%
• ADX 가중치: 20%
【ADX (Average Directional Index)】
• ADX > 25: 강한 추세
• ADX < 25: 약한 추세 또는 횡보
• 추세 강도만 측정 (방향 무관)
【장점】
✓ 횡보장에서 거짓 신호 필터링
✓ 강한 추세에서만 진입
✓ 승률 향상 가능
`.trim();
case 'STRATEGY_4':
return `
[종합 전략 (MACD + Stochastic + ADX + CCI)]
▶ 모든 지표를 종합적으로 사용하는 고급 전략
▶ 다양한 시장 상황 대응
【사용 지표】
• MACD: 추세 방향 및 모멘텀
• Stochastic: 과매수/과매도
• ADX: 추세 강도
• CCI: 가격 변동성
【매수 신호】
• Stochastic < ${20} (과매도)
• MACD 골든 크로스
• ADX > ${25} (강한 추세)
• CCI < -100 (과매도)
• 종합 점수 계산 후 매수 판단
【매도 신호】
• Stochastic > ${80} (과매수)
• MACD 데드 크로스
• ADX > ${25} (강한 추세)
• CCI > 100 (과매수)
• 종합 점수 계산 후 매도 판단
【신호 점수 계산】
• MACD 가중치: 25%
• Stochastic 가중치: 40%
• ADX 가중치: 20%
• CCI 가중치: 15%
【특징】
✓ 가장 복합적인 분석
✓ 높은 승률 목표
✓ 신호 발생 빈도 낮음
⚠ 과도한 필터링으로 기회 손실 가능
`.trim();
case 'STRATEGY_5':
return `
[MACD Zero Cross 전략]
▶ MACD의 0선 교차를 활용한 전략
▶ 이동평균선 크로스와 동일한 개념
【매수 신호】
• MACD 선이 0선을 상향 돌파
- 단기 이동평균(EMA12) > 장기 이동평균(EMA26)
- 본격적인 상승 추세 시작
- 골든 크로스보다 늦지만 신뢰도 높음
【매도 신호】
• MACD 선이 0선을 하향 돌파
- 단기 이동평균(EMA12) < 장기 이동평균(EMA26)
- 본격적인 하락 추세 시작
- 데드 크로스보다 늦지만 신뢰도 높음
【0선의 의미】
• 0선 위: 상승 추세 (단기MA > 장기MA)
• 0선 아래: 하락 추세 (단기MA < 장기MA)
• 0선 교차: 추세 전환 확인
【장점】
✓ Signal 선 없이 단순한 판단
✓ 추세 전환 확실성 높음
✓ 중장기 트레이딩에 적합
【단점】
⚠ 진입 시점이 다소 늦음
⚠ 초기 수익 기회 놓칠 수 있음
`.trim();
case 'STRATEGY_6_GRANVILLE':
return `
[그랜빌의 법칙 전략]
▶ 200일 이동평균선을 기준으로 한 8가지 매매 법칙
▶ 조지프 그랜빌(Joseph Granville)이 제시한 고전 전략
【매수 신호 (4가지)】
1️⃣ 평균선 상향 전환 매수
• 200일선이 하락 후 횡보/상승 전환
• 주가가 평균선을 상향 돌파
2️⃣ 평균선 위 일시 하락 매수
• 200일선이 상승 중
• 주가가 일시적으로 평균선 아래로 하락
• 평균선 지지 확인 후 매수
3️⃣ 평균선 지지 매수
• 200일선이 상승 중
• 주가가 평균선 위에서 하락하다가 반등
• 평균선을 터치하지 않고 재상승
4️⃣ 급락 반등 매수 (고급)
• 200일선이 하락 중
• 주가가 평균선에서 크게 이탈
• 단기 반등 기대
【매도 신호 (4가지)】
1️⃣ 평균선 하향 전환 매도
• 200일선이 상승 후 횡보/하락 전환
• 주가가 평균선을 하향 돌파
2️⃣ 평균선 아래 일시 상승 매도
• 200일선이 하락 중
• 주가가 일시적으로 평균선 위로 상승
• 평균선 저항 확인 후 매도
3️⃣ 평균선 저항 매도
• 200일선이 하락 중
• 주가가 평균선 아래에서 상승하다가 하락
• 평균선을 터치하지 않고 재하락
4️⃣ 급등 조정 매도 (고급)
• 200일선이 상승 중
• 주가가 평균선에서 크게 이탈
• 단기 조정 예상
【특징】
✓ 중장기 투자에 적합
✓ 200일선은 강력한 지지/저항
✓ 추세 전환 시점 포착
⚠ 단기 변동성에 민감하지 않음
`.trim();
case 'STRATEGY_7_OBV':
return `
[OBV (On Balance Volume) 전략]
▶ 거래량과 가격의 관계를 분석하는 지표
▶ 가격 상승 시 거래량 증가, 하락 시 거래량 감소가 건강한 추세
【OBV 계산 방법】
• 오늘 종가 > 어제 종가 → OBV += 오늘 거래량
• 오늘 종가 < 어제 종가 → OBV -= 오늘 거래량
• 오늘 종가 = 어제 종가 → OBV 유지
【매수 신호】
• OBV 상승 + 가격 상승
- 거래량 증가를 동반한 건강한 상승
- 매수 세력 유입 확인
• OBV 상승 + 가격 횡보/하락 (다이버전스)
- 거래량은 증가하는데 가격 정체
- 곧 가격 상승 가능성
【매도 신호】
• OBV 하락 + 가격 하락
- 거래량 증가를 동반한 건강한 하락
- 매도 세력 우세 확인
• OBV 하락 + 가격 횡보/상승 (다이버전스)
- 거래량은 감소하는데 가격 상승
- 곧 가격 하락 가능성
【해석 원칙】
✓ OBV와 가격이 같은 방향 → 추세 지속
✓ OBV와 가격이 반대 방향 → 추세 전환 임박
✓ OBV 신고점/신저점 → 강한 신호
【활용 팁】
• OBV 추세선 돌파 시 강한 신호
• 다이버전스는 조기 경고 신호
• 중장기 분석에 효과적
`.trim();
case 'STRATEGY_8_PSYCHOLOGICAL':
return `
[심리선 (Psychological Line) 전략]
▶ 최근 N일 중 상승일수 비율로 과매수/과매도 판단
▶ 투자 심리 과열/위축 측정
【심리선 계산】
심리선 = (최근 12일 중 상승일수 / 12) × 100
예시:
• 12일 중 9일 상승 → 심리선 75%
• 12일 중 3일 상승 → 심리선 25%
【매수 신호】
• 심리선 < 25% (과매도)
- 12일 중 3일 이하 상승
- 투자 심리 극도로 위축
- 반등 가능성 높음
• 심리선이 25% 돌파 후 상승
- 과매도 탈출
- 추세 전환 시작
【매도 신호】
• 심리선 > 75% (과매수)
- 12일 중 9일 이상 상승
- 투자 심리 극도로 과열
- 조정 가능성 높음
• 심리선이 75% 하향 이탈
- 과매수 탈출
- 하락 추세 시작
【표준 해석】
• 75% 이상: 과매수 (조정 대기)
• 50~75%: 상승 추세
• 25~50%: 하락 추세
• 25% 이하: 과매도 (반등 대기)
【특징】
✓ 계산 간단하고 이해하기 쉬움
✓ 단기 과열/침체 판단에 유용
✓ Stochastic과 유사한 개념
⚠ 강한 추세장에서는 오래 극단값 유지
`.trim();
case 'STRATEGY_9_CHART_PATTERN':
return `
[차트 패턴 인식 전략]
▶ 주요 차트 패턴 자동 인식
▶ 기술적 분석의 고전적 접근법
【반전 패턴 - 매수】
📈 이중 바닥 (Double Bottom)
• W자 모양 형성
• 두 번째 저점에서 지지
• 네크라인 돌파 시 매수
📈 역 헤드앤숄더 (Inverse H&S)
• 머리와 양 어깨 형성
• 중간 저점이 가장 낮음
• 네크라인 돌파 시 강한 매수
📈 라운딩 바텀 (Rounding Bottom)
• 완만한 U자 곡선
• 장기간에 걸쳐 형성
• 중장기 상승 전환
【반전 패턴 - 매도】
📉 이중 천장 (Double Top)
• M자 모양 형성
• 두 번째 고점에서 저항
• 네크라인 하향 돌파 시 매도
📉 헤드앤숄더 (Head & Shoulders)
• 머리와 양 어깨 형성
• 중간 고점이 가장 높음
• 네크라인 하향 돌파 시 강한 매도
【지속 패턴】
🔺 상승 삼각형 (Ascending Triangle)
• 상승 추세 중 휴식
• 고점은 수평, 저점은 상승
• 상단 돌파 시 지속 상승
🔻 하락 삼각형 (Descending Triangle)
• 하락 추세 중 휴식
• 저점은 수평, 고점은 하락
• 하단 붕괴 시 지속 하락
🔹 깃발/페넌트 (Flag/Pennant)
• 급등/급락 후 짧은 조정
• 작은 삼각형 또는 직사각형
• 이전 방향으로 재돌파
【목표가 설정】
• 패턴 높이만큼 추가 이동 예상
• 예: 이중바닥 높이 10% → 돌파 후 +10%
【특징】
✓ 시각적 직관성
✓ 목표가 설정 가능
✓ 중기 매매에 적합
⚠ 패턴 인식의 주관성
⚠ 완성 전 실패 가능성
`.trim();
case 'STRATEGY_10_CANDLE_PATTERN':
return `
[캔들스틱 패턴 인식 전략]
▶ 일본식 캔들 차트 패턴 분석
▶ 1~3개 봉으로 단기 추세 전환 파악
【강력한 매수 패턴】
🟢 망치형 (Hammer)
• 긴 아래 꼬리 + 작은 몸통
• 하락 추세 끝에 출현
• 매도 압력 → 매수 전환
🟢 샛별형 (Morning Star)
• 3봉 패턴
• 긴 음봉 → 작은 봉(십자) → 긴 양봉
• 강력한 반전 신호
🟢 상승 관통형 (Piercing Pattern)
• 2봉 패턴
• 음봉 다음 날 갭 하락 → 양봉으로 반 이상 회복
• 강한 매수 유입
🟢 역망치형 (Inverted Hammer)
• 긴 위 꼬리 + 작은 몸통
• 하락 끝에 출현
• 상승 시도 확인
【강력한 매도 패턴】
🔴 교수형 (Hanging Man)
• 긴 아래 꼬리 + 작은 몸통
• 상승 추세 끝에 출현
• 매수 약화 경고
🔴 저녁별형 (Evening Star)
• 3봉 패턴
• 긴 양봉 → 작은 봉(십자) → 긴 음봉
• 강력한 하락 전환
🔴 하락 먹구름형 (Dark Cloud Cover)
• 2봉 패턴
• 양봉 다음 날 갭 상승 → 음봉으로 반 이상 하락
• 강한 매도 압력
🔴 유성형 (Shooting Star)
• 긴 위 꼬리 + 작은 몸통
• 상승 끝에 출현
• 저항 확인
【중립/전환 패턴】
⚪ 십자형 (Doji)
• 시가 = 종가
• 매수/매도 균형
• 추세 전환 가능성
⚪ 팽이형 (Spinning Top)
• 작은 몸통 + 긴 위아래 꼬리
• 방향성 상실
• 관망 필요
【활용 원칙】
✓ 추세 전환 시점에서 더 신뢰도 높음
✓ 거래량 증가 동반 시 강한 신호
✓ 여러 패턴 복합 출현 시 신뢰도 ↑
⚠ 단일 패턴만으로 판단 위험
⚠ 다른 지표와 함께 사용 권장
`.trim();
case 'STRATEGY_11_VOLUME':
return `
[거래량 분석 전략]
▶ 가격과 거래량의 관계 종합 분석
▶ "거래량은 주가에 선행한다"
【건강한 상승 (매수 신호)】
📈 가격 상승 + 거래량 증가
• 매수 세력 적극 참여
• 상승 추세 지속 가능
• 강한 매수 신호
📈 거래량 급증 + 가격 상승
• 평균 거래량의 2배 이상
• 신규 자금 대량 유입
• 추세 시작 또는 가속
📈 거래량 돌파 (Volume Breakout)
• 장기 횡보 후 거래량 폭발
• 저항선 돌파
• 새로운 상승 사이클 시작
【불건강한 상승 (경고)】
⚠️ 가격 상승 + 거래량 감소
• 매수 동력 약화
• 곧 조정 가능성
• 익절 타이밍
【건강한 하락 (매도 신호)】
📉 가격 하락 + 거래량 증가
• 매도 압력 강함
• 하락 추세 지속 가능
• 손절 또는 공매도 기회
📉 거래량 급증 + 가격 하락
• 공포 매도 또는 패닉
• 추세 가속화
• 추가 하락 예상
【불건강한 하락 (반등 기회)】
✅ 가격 하락 + 거래량 감소
• 매도 동력 약화
• 하락 끝물
• 반등 준비
【특수 패턴】
🔹 클라이맥스 (Climax)
• 극단적 거래량 폭발
• 급등 또는 급락 종료
• 반전 신호
🔹 거래량 건조 (Drying Up)
• 거래량 극도로 감소
• 큰 변동 임박
• 돌파 대기
【거래량 비율 기준】
• 2배 이상: 매우 높음 (강한 신호)
• 1.5~2배: 높음 (주목)
• 0.5~1.5배: 보통
• 0.5배 이하: 낮음 (신뢰도 낮음)
【활용 원칙】
✓ 가격 + 거래량 동시 분석 필수
✓ 추세 초기 거래량 폭발에 주목
✓ 거래량 감소는 추세 약화 신호
⚠ 거래량만으로는 방향 판단 불가
⚠ 가격 움직임과 종합 판단 필요
`.trim();
case 'TREND_STOCHASTIC':
return `
[MACD + Stochastic Slow 전략 (추세 기반)]
▶ 정배열 조건: 추세 강도 > 0 (MA12 > MA26 > MA60)
▶ 역배열 조건: 추세 강도 < 0 (MA12 < MA26 < MA60)
【MACD 조건 ON (기본)】
• 정배열일 때: 20/50/80 모든 레벨에서 신호 발생
• 역배열일 때: 80 레벨에서만 신호 발생
【MACD 조건 OFF】
• 정배열/역배열 상관없이 모든 레벨(20/50/80)에서 신호 발생
• Stochastic Slow Only 전략과 동일하게 동작
【매수 신호 (상향 돌파)】
• Stochastic Slow 20 상향 돌파 → 1차 매수
• Stochastic Slow 50 상향 돌파 → 2차 매수
• Stochastic Slow 80 상향 돌파 → 3차 매수
【매도 신호 (하향 돌파)】
• Stochastic Slow 20 하향 돌파 → 1차 매도
• Stochastic Slow 50 하향 돌파 → 2차 매도
• Stochastic Slow 80 하향 돌파 → 3차 매도
※ 돌파 판단: 이전 캔들 대비 교차로 판단
※ MACD 조건 ON/OFF로 추세 적용 여부 설정 가능
`.trim();
case 'TREND_STOCHASTIC_CCI':
return `
[CCI + Stochastic Slow 전략 (CCI 기반)]
▶ CCI 상승추세: CCI > 0 (매수세 우위)
▶ CCI 하락추세: CCI < 0 (매도세 우위)
【CCI 조건 ON (기본)】
• CCI > 0일 때: 20/50/80 모든 레벨에서 신호 발생
• CCI < 0일 때: 80 레벨에서만 신호 발생
【CCI 조건 OFF】
• CCI 상태와 무관하게 모든 레벨(20/50/80)에서 신호 발생
• Stochastic Slow Only 전략과 동일하게 동작
【매수 신호 (상향 돌파)】
• Stochastic Slow 20 상향 돌파 → 1차 매수
• Stochastic Slow 50 상향 돌파 → 2차 매수
• Stochastic Slow 80 상향 돌파 → 3차 매수
【매도 신호 (하향 돌파)】
• Stochastic Slow 20 하향 돌파 → 1차 매도
• Stochastic Slow 50 하향 돌파 → 2차 매도
• Stochastic Slow 80 하향 돌파 → 3차 매도
【CCI vs MACD 차이점】
• CCI: 현재 가격이 평균에서 얼마나 벗어났는지 측정 (단기 민감)
• MACD: 이동평균선의 수렴/발산을 측정 (추세 지속성)
• CCI가 더 빠른 추세 변화 감지, MACD는 더 안정적
※ CCI 파라미터는 설정 > 보조지표 설정에서 조정 가능
`.trim();
case 'STOCHASTIC_ONLY':
return `
[Stochastic Slow Only 전략]
▶ 정배열/역배열 무관: 추세 상태와 상관없이 Stochastic만으로 매매
▶ Stochastic Slow %K 값 기준으로 레벨 돌파 시 신호 발생
【매수 신호 (상향 돌파)】
• Stochastic 20 상향 돌파 → 1차 매수 (33%)
• Stochastic 50 상향 돌파 → 2차 매수 (33%)
• Stochastic 80 상향 돌파 → 3차 매수 (34%)
【매도 신호 (하향 돌파)】
• Stochastic 20 하향 돌파 → 1차 매도 (33%)
• Stochastic 50 하향 돌파 → 2차 매도 (33%)
• Stochastic 80 하향 돌파 → 3차 매도 (34%)
【특징】
✓ MACD, 정배열/역배열 조건 없이 순수 Stochastic만 사용
✓ 분할 매수/매도로 평균 단가 조정
✓ 단순하고 기계적인 매매로 감정 개입 최소화
✓ 횡보장에서도 레벨 돌파 시 신호 발생
※ 돌파 판단: 이전 캔들 K값과 비교하여 레벨 교차 여부 확인
`.trim();
case 'INTEGRATED_INDICATOR':
return `
[통합지표 전략]
▶ 모든 보조지표를 개별적으로 on/off 할 수 있는 유연한 전략
▶ 각 지표별 파라미터를 세밀하게 조정 가능
【사용 가능한 보조지표】
📈 이평선 (MA): 12/26/60일 이동평균선 정배열/역배열 판단
📊 MACD: 골든/데드 크로스 및 0선 돌파
📉 Stochastic Slow: 과매수/과매도 및 레벨 돌파
💹 RSI: 상대강도지수 과매수/과매도
📐 CCI: 상품채널지수 과매수/과매도
🔺 ADX: 추세 강도 판단
【매수 조건 설정】
• ALL (모두): 활성화된 모든 지표가 매수 신호일 때
• ANY (하나라도): 하나의 지표라도 매수 신호일 때
• MAJORITY (과반수): 과반수 이상 지표가 매수 신호일 때
【매도 조건 설정】
• ALL (모두): 활성화된 모든 지표가 매도 신호일 때
• ANY (하나라도): 하나의 지표라도 매도 신호일 때
• MAJORITY (과반수): 과반수 이상 지표가 매도 신호일 때
【특징】
✓ 지표별 개별 on/off로 맞춤형 전략 구성
✓ 각 지표의 파라미터를 세밀하게 조정 가능
✓ 매매 조건을 유연하게 설정 (모두/하나라도/과반수)
✓ 다양한 시장 상황에 적응 가능
※ 각 지표 옆의 ⚙️ 버튼을 클릭하여 상세 파라미터 설정
`.trim();
default:
return '';
}
};
/**
* TREND_STOCHASTIC 전략 상세 설명 (하위 호환성 유지)
*/
export const getTrendStochasticDetailedDescription = (): string => {
return getStrategyDetailedDescription('TREND_STOCHASTIC');
};
/**
* 🆕 기본 전략 초기화 (모든 기본 전략 생성)
*/
export const initializeDefaultStrategies = async (): Promise<any> => {
const response = await axios.post(`${API_BASE_URL}/strategies/initialize`);
return response.data;
};
/**
* 🆕 누락된 전략 추가 (기존 전략 유지)
*/
export const addMissingStrategies = async (): Promise<any> => {
const response = await axios.post(`${API_BASE_URL}/strategies/add-missing`);
return response.data;
};
+256
View File
@@ -0,0 +1,256 @@
import axios from 'axios';
import { StockRequest, MacdResult } from '../types/stock';
import { getApiBaseUrl } from './apiConfig';
// API 베이스 URL (통합 설정 사용)
const API_BASE_URL = getApiBaseUrl();
const upbitApi = axios.create({
baseURL: API_BASE_URL,
timeout: 60000, // 60초로 증가 (외부 DB 연결 시간 고려)
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
withCredentials: false,
});
export interface UpbitMarket {
market: string;
korean_name: string;
english_name: string;
market_warning?: string;
}
export interface CryptoInfo {
symbol: string;
koreanName: string;
englishName: string;
marketWarning?: string;
isActive: boolean;
isFavorite: boolean;
lastUpdated?: string;
}
export interface TrendStrengthResult {
symbol: string;
koreanName: string;
englishName?: string;
trendStrength: number;
ma10: number;
ma20: number;
ma60: number;
atr14: number;
dataAvailable: boolean;
trendUpdatedAt?: string;
}
export interface UpbitTicker {
market: string;
trade_date: string;
trade_time: string;
trade_price: number;
prev_closing_price: number;
change: string;
change_price: number;
change_rate: number;
signed_change_price: number;
signed_change_rate: number;
trade_volume: number;
acc_trade_price_24h: number;
acc_trade_volume_24h: number;
timestamp: number;
}
export const upbitApiService = {
async getMarkets(): Promise<UpbitMarket[]> {
try {
const cryptos = await this.getActiveCryptos();
return cryptos.map(crypto => ({
market: crypto.symbol,
korean_name: crypto.koreanName,
english_name: crypto.englishName,
market_warning: crypto.marketWarning
}));
} catch (error) {
console.error('업비트 마켓 목록 조회 실패:', error);
throw new Error('업비트 마켓 목록을 가져올 수 없습니다.');
}
},
async getActiveCryptos(): Promise<CryptoInfo[]> {
try {
const response = await upbitApi.get<CryptoInfo[]>('/crypto/list');
return response.data;
} catch (error) {
console.error('활성화된 암호화폐 목록 조회 실패:', error);
throw new Error('암호화폐 목록을 가져올 수 없습니다.');
}
},
async refreshCryptoList(): Promise<string> {
try {
const response = await upbitApi.post<string>('/crypto/refresh');
return response.data;
} catch (error) {
console.error('암호화폐 목록 새로고침 실패:', error);
throw new Error('암호화폐 목록 새로고침에 실패했습니다.');
}
},
async getFavoriteCryptos(): Promise<CryptoInfo[]> {
try {
const response = await upbitApi.get<CryptoInfo[]>('/crypto/favorites');
return response.data;
} catch (error) {
console.error('즐겨찾기 암호화폐 목록 조회 실패:', error);
throw new Error('즐겨찾기 암호화폐 목록을 가져올 수 없습니다.');
}
},
async toggleFavorite(symbol: string, isFavorite: boolean): Promise<string> {
try {
const response = await upbitApi.put<string>(`/crypto/${symbol}/favorite?isFavorite=${isFavorite}`);
return response.data;
} catch (error) {
console.error('즐겨찾기 상태 변경 실패:', error);
throw new Error('즐겨찾기 상태 변경에 실패했습니다.');
}
},
async clearAllFavorites(): Promise<string> {
try {
const response = await upbitApi.delete<string>('/crypto/favorites/all');
return response.data;
} catch (error) {
console.error('모든 즐겨찾기 삭제 실패:', error);
throw new Error('즐겨찾기 삭제에 실패했습니다.');
}
},
async analyzeCryptoMacd(request: StockRequest): Promise<{ data: MacdResult[], backendSettings?: any }> {
if (!request.symbol.startsWith('KRW-')) {
throw new Error('업비트 암호화폐는 KRW- 형식이어야 합니다.');
}
console.log(`🚀 [빠른조회] ${request.symbol} ${request.interval}`, request);
const response = await upbitApi.post('/upbit/analyze', request, {
timeout: 30000,
});
console.log('📥 API 응답:', response.data);
if (!response.data || response.data.length === 0) {
throw new Error(`데이터가 없습니다. (심볼: ${request.symbol}, 인터벌: ${request.interval})`);
}
// ✅ 백엔드 설정값 파싱 (정수 + 소수점 모두 지원)
const backendSettingsHeader = response.headers['x-indicator-settings'];
let backendSettings = null;
if (backendSettingsHeader) {
try {
const settings: any = {};
const parts = backendSettingsHeader.split(';');
parts.forEach((part: string) => {
const [key, values] = part.split(':');
if (key && values) {
// parseFloat으로 정수와 소수점 모두 처리 (볼린저 밴드 표준편차 등)
const nums = values.split(',').map((v: string) => parseFloat(v));
settings[key.trim()] = nums;
}
});
backendSettings = settings;
console.log('📊 백엔드 설정값:', backendSettings);
} catch (error) {
console.error('백엔드 설정값 파싱 실패:', error);
}
}
console.log(`✅ 수신: ${response.data.length}`);
return { data: response.data, backendSettings };
},
async getTrendStrength(sort: 'bullish' | 'bearish' | 'default' = 'default'): Promise<TrendStrengthResult[]> {
try {
const response = await upbitApi.get<TrendStrengthResult[]>(`/upbit/trend-strength?sort=${sort}`, {
timeout: 30000,
});
return response.data;
} catch (error) {
console.error('추세 강도 조회 실패:', error);
throw new Error('추세 강도를 가져올 수 없습니다.');
}
},
async getCurrentPrice(markets: string[]): Promise<UpbitTicker[]> {
try {
const marketString = markets.join(',');
const response = await upbitApi.get<UpbitTicker[]>(
`/upbit/ticker?markets=${encodeURIComponent(marketString)}`
);
return response.data;
} catch (error) {
console.error('업비트 현재가 조회 실패:', error);
return [];
}
},
/** 상위 종목 조회 - 전체 KRW 마켓 기준 (상승추세/거래량 순) */
async getTopTickers(sort: 'uptrend' | 'volume', limit: number): Promise<Array<{ market: string; koreanName: string }>> {
try {
const response = await upbitApi.get<Array<{ market: string; koreanName: string }>>(
`/upbit/ticker/top?sort=${sort}&limit=${limit}`
);
return response.data || [];
} catch (error) {
console.error('상위 티커 조회 실패:', error);
return [];
}
},
/** 티커 조회 - 상승추세/거래량 정렬용 (백엔드 API 사용) */
async getTickers(markets: string[]): Promise<Array<{ market: string; signedChangeRate: number; accTradeVolume24h: number }>> {
const marketString = markets.join(',');
type TickerRaw = { market: string; signed_change_rate?: number; acc_trade_volume_24h?: number; signedChangeRate?: number; accTradeVolume24h?: number };
const normalize = (raw: TickerRaw[]) =>
(raw || []).map((t) => ({
market: t.market,
signedChangeRate: t.signedChangeRate ?? t.signed_change_rate ?? 0,
accTradeVolume24h: t.accTradeVolume24h ?? t.acc_trade_volume_24h ?? 0,
}));
try {
const response = await upbitApi.get<TickerRaw[]>(
`/upbit/ticker?markets=${encodeURIComponent(marketString)}`
);
return normalize(response.data || []);
} catch (error) {
console.error('티커 조회 실패:', error);
return [];
}
},
validateCryptoSymbol(symbol: string): boolean {
return symbol.startsWith('KRW-') && symbol.length > 4;
},
getCryptoDisplayName(symbol: string): string {
const cryptoNames: Record<string, string> = {
'KRW-BTC': '비트코인',
'KRW-ETH': '이더리움',
'KRW-XRP': '리플',
'KRW-ADA': '에이다',
'KRW-DOT': '폴카닷',
'KRW-LINK': '체인링크',
'KRW-SOL': '솔라나',
'KRW-AVAX': '아발란체',
'KRW-MATIC': '폴리곤',
'KRW-DOGE': '도지코인',
};
return cryptoNames[symbol] || symbol.replace('KRW-', '');
},
};
export default upbitApiService;
@@ -0,0 +1,66 @@
import axios from 'axios';
import { API_BASE_URL } from './apiConfig';
export interface MarketCount {
market: string;
count: number;
}
export interface TypeCount {
candleTypeKey: string;
count: number;
}
export interface CandleSeriesStats {
market: string;
candleType: string;
unit: number | null;
timeFrameLabel: string;
minKst: string | null;
maxKst: string | null;
count: number;
}
export interface IngestionRow {
sourceKey: string;
market: string;
unitPath: string;
targetDate: string | null;
status: string;
fileHash: string | null;
lastError: string | null;
updatedAt: string | null;
}
export interface IngestionCountByStatus {
status: string;
count: number;
}
export interface WsSubRow {
market: string;
intervalCode: string;
active: boolean;
}
export interface UpbitDbStatus {
totalCandleRows: number;
/** upbit_candles MAX(updated_at) — 총 행이 늘지 않아도 UPSERT 시 갱신됨 */
candlesMaxUpdatedAt?: string | null;
distinctMarketCount: number;
fallbackToRest: string;
candleRowsByMarket: MarketCount[];
candleRowsByCandleType: TypeCount[];
series: CandleSeriesStats[];
recentIngestion: IngestionRow[];
ingestionByStatus: IngestionCountByStatus[];
tradeArchiveRows: number;
wsSubscriptions: WsSubRow[];
}
export const upbitDbStatusApi = {
getStatus: async (): Promise<UpbitDbStatus> => {
const { data } = await axios.get<UpbitDbStatus>(`${API_BASE_URL}/upbit/db-status`);
return data;
},
};
@@ -0,0 +1,48 @@
import { getApiBaseUrl } from './apiConfig';
function apiBase(): string {
return getApiBaseUrl().replace(/\/+$/, '');
}
/** 차트 timeframe(1m, 1h, 4h, 1d, 1M 등)을 DB interval_code로 정규화 (월봉만 대문자 M 유지) */
export function intervalCodeForWsSubscription(timeframe: string): string {
const t = (timeframe || '1m').trim();
if (t === '1M' || t === 'M') return '1M';
return t.toLowerCase();
}
export async function acquireWsCandleSubscription(market: string, intervalCode: string): Promise<void> {
try {
const res = await fetch(`${apiBase()}/upbit/ws-subscriptions/acquire`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
market: market.trim(),
intervalCode: intervalCodeForWsSubscription(intervalCode),
}),
});
if (!res.ok) {
console.warn('[upbitWsSubscription] acquire HTTP', res.status);
}
} catch (e) {
console.warn('[upbitWsSubscription] acquire', e);
}
}
export async function releaseWsCandleSubscription(market: string, intervalCode: string): Promise<void> {
try {
const res = await fetch(`${apiBase()}/upbit/ws-subscriptions/release`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
market: market.trim(),
intervalCode: intervalCodeForWsSubscription(intervalCode),
}),
});
if (!res.ok) {
console.warn('[upbitWsSubscription] release HTTP', res.status);
}
} catch (e) {
console.warn('[upbitWsSubscription] release', e);
}
}