goldenChat base source add
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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('주문 취소 실패');
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user