You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

686 lines
19 KiB
TypeScript

// API 기본 설정
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:18001/api/v1';
// Fetch 기반 API 클라이언트
const createApiClient = () => {
const request = async (
endpoint: string,
options: RequestInit = {}
): Promise<Response> => {
const url = `${API_BASE_URL}${endpoint}`;
console.log(`🚀 API Request: ${options.method || 'GET'} ${url}`);
const config: RequestInit = {
signal: AbortSignal.timeout(30000),
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
console.log(`✅ API Response: ${response.status} ${endpoint}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
} catch (error) {
console.error('❌ API Error:', error);
throw error;
}
};
return {
get: async (endpoint: string) => {
const response = await request(endpoint, { method: 'GET' });
return { data: await response.json() };
},
post: async (endpoint: string, data?: any) => {
const response = await request(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
return { data: await response.json() };
},
patch: async (endpoint: string, data?: any) => {
const response = await request(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
return { data: await response.json() };
},
delete: async (endpoint: string) => {
const response = await request(endpoint, { method: 'DELETE' });
return { data: await response.json() };
}
};
};
export const api = createApiClient();
// 타입 정의
export interface Company {
ticker: string;
name: string;
cik?: string;
sector?: string;
industry?: string;
business_description?: string;
}
export interface FinancialData {
period_date: string;
period_type: string;
filing_type?: string;
revenue?: number;
gross_profit?: number;
operating_income?: number;
net_income?: number;
eps?: number;
total_assets?: number;
total_equity?: number;
total_debt?: number;
cash?: number;
shares_outstanding?: number;
operating_cash_flow?: number;
free_cash_flow?: number;
capex?: number;
data_source: string;
is_estimated: boolean;
// Calculated metrics
pe_ratio?: number;
pb_ratio?: number;
ps_ratio?: number;
roe?: number;
roa?: number;
gross_margin?: number;
operating_margin?: number;
net_margin?: number;
debt_to_equity?: number;
debt_to_assets?: number;
ocf_margin?: number;
fcf_margin?: number;
market_cap?: number;
}
export interface PriceData {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
adj_close?: number;
}
export interface FinancialDataRequest {
ticker: string;
start_date: string;
end_date: string;
period_type?: 'quarterly' | 'annual' | 'all';
include_metrics?: boolean;
force_refresh?: boolean;
}
export interface PriceDataRequest {
ticker: string;
start_date: string;
end_date: string;
interval?: '1d' | '1wk' | '1mo';
}
export interface FinancialDataResponse {
company: Company;
financial_data: FinancialData[];
metadata: {
request_id: string;
data_points: number;
period_type: string;
date_range: {
start: string;
end: string;
};
last_updated: string;
};
}
export interface PriceDataResponse {
ticker: string;
data: PriceData[];
interval: string;
}
export interface DatabaseStats {
companies: {
total: number;
with_financial_data: number;
with_price_data: number;
};
financial_data: {
total_records: number;
real_data: number;
estimated_data: number;
date_range: {
earliest: string;
latest: string;
};
by_source: {
[key: string]: number;
};
};
price_data: {
total_records: number;
date_range: {
earliest: string;
latest: string;
};
tickers: string[];
};
calculated_metrics: {
total_records: number;
date_range: {
earliest: string;
latest: string;
};
};
}
// News and Social Media Types
export interface NewsArticle {
title: string;
summary?: string;
content?: string;
url: string;
source: string;
published_at?: string;
author?: string;
sentiment_score?: number;
relevance_score?: number;
image_url?: string;
tags: string[];
}
export interface SocialPost {
title: string;
content: string;
url: string;
platform: string;
author: string;
published_at?: string;
score?: number;
comments_count?: number;
upvotes?: number;
downvotes?: number;
sentiment_score?: number;
subreddit?: string;
}
export interface NewsSocialRequest {
ticker: string;
days_back?: number;
max_articles?: number;
max_social_posts?: number;
include_social?: boolean;
}
export interface NewsSocialResponse {
ticker: string;
retrieved_at: string;
news: {
total_articles: number;
sources: {
yahoo_finance: number;
newsapi: number;
};
articles: NewsArticle[];
};
social_media: {
total_posts: number;
platforms: {
reddit: number;
};
posts: SocialPost[];
};
summary: {
total_items: number;
time_range_days: number;
oldest_item?: string;
newest_item?: string;
};
}
// API 함수들
export const stockApi = {
// 재무 데이터 조회
getFinancialData: async (request: FinancialDataRequest): Promise<FinancialDataResponse> => {
const response = await api.post('/financial/data', request);
return response.data;
},
// 주가 데이터 조회
getPriceData: async (request: PriceDataRequest): Promise<PriceDataResponse> => {
const response = await api.post('/price/data', request);
return response.data;
},
// 데이터베이스 통계 조회
getDatabaseStats: async (): Promise<DatabaseStats> => {
const response = await api.get('/database/stats');
return response.data;
},
// ETF snapshots list
getEtfSnapshots: async (params?: { ticker?: string; start_date?: string; end_date?: string; limit?: number; offset?: number }) => {
const qp = new URLSearchParams();
if (params?.ticker) qp.set('ticker', params.ticker);
if (params?.start_date) qp.set('start_date', params.start_date);
if (params?.end_date) qp.set('end_date', params.end_date);
if (params?.limit) qp.set('limit', String(params.limit));
if (params?.offset) qp.set('offset', String(params.offset));
const suffix = qp.toString() ? `?${qp.toString()}` : '';
const { data } = await api.get(`/database/etf/snapshots${suffix}`);
return data as { results: Array<{ id: string; ticker: string; snapshot_date: string; source?: string; cik?: string; holdings_count: number }>; count: number };
},
// Single ETF snapshot by id
getEtfSnapshotById: async (snapshotId: string) => {
const { data } = await api.get(`/database/etf/snapshot/${snapshotId}`);
return data as { snapshot: { id: string; ticker: string; snapshot_date: string; source?: string; cik?: string; filing_accession?: string; xml_url?: string }; holdings: Array<{ name?: string; cusip?: string; ticker?: string; shares?: number; value?: number; percentage?: number }>; holdings_count: number };
},
// Financial records list
getFinancialRecords: async (params?: { ticker?: string; period_type?: 'quarterly' | 'annual'; start_date?: string; end_date?: string; limit?: number; offset?: number }) => {
const qp = new URLSearchParams();
if (params?.ticker) qp.set('ticker', params.ticker);
if (params?.period_type) qp.set('period_type', params.period_type);
if (params?.start_date) qp.set('start_date', params.start_date);
if (params?.end_date) qp.set('end_date', params.end_date);
if (params?.limit) qp.set('limit', String(params.limit));
if (params?.offset) qp.set('offset', String(params.offset));
const suffix = qp.toString() ? `?${qp.toString()}` : '';
const { data } = await api.get(`/database/financial/records${suffix}`);
return data as { results: Array<{ ticker: string; period_date: string; period_type: string; data_source: string; is_estimated: boolean; revenue?: number; net_income?: number }>; count: number };
},
// 사용 가능한 종목 목록 조회
getAvailableTickers: async (): Promise<string[]> => {
const response = await api.get('/tickers');
return response.data.tickers || [];
},
// 회사 정보 조회
getCompanyInfo: async (ticker: string): Promise<Company> => {
const response = await api.get(`/company/${ticker}`);
return response.data;
},
// API 상태 확인
getHealthCheck: async (): Promise<{ status: string; timestamp: string }> => {
const response = await api.get('/health');
return response.data;
},
// 뉴스 & 소셜 미디어 데이터 조회
getNewsSocialData: async (params: NewsSocialRequest): Promise<NewsSocialResponse> => {
const { ticker, days_back = 7, max_articles = 20, max_social_posts = 15, include_social = true } = params;
const queryParams = new URLSearchParams({
days_back: days_back.toString(),
max_articles: max_articles.toString(),
max_social_posts: max_social_posts.toString(),
include_social: include_social.toString(),
});
const response = await api.get(`/news/${ticker}?${queryParams}`);
return response.data;
},
// 뉴스만 조회 (더 빠른 응답)
getNewsOnly: async (params: Pick<NewsSocialRequest, 'ticker' | 'days_back' | 'max_articles'>) => {
const { ticker, days_back = 7, max_articles = 30 } = params;
const queryParams = new URLSearchParams({
days_back: days_back.toString(),
max_articles: max_articles.toString(),
});
const response = await api.get(`/news/${ticker}/news-only?${queryParams}`);
return response.data;
},
// 소셜 미디어만 조회
getSocialOnly: async (params: Pick<NewsSocialRequest, 'ticker' | 'days_back' | 'max_social_posts'>) => {
const { ticker, days_back = 7, max_social_posts = 20 } = params;
const queryParams = new URLSearchParams({
days_back: days_back.toString(),
max_social_posts: max_social_posts.toString(),
});
const response = await api.get(`/news/${ticker}/social-only?${queryParams}`);
return response.data;
},
};
// FRED API Types
export interface FredUsageStats {
daily_limit: number;
used_today: number;
remaining_today: number;
usage_percentage: number;
can_make_requests: boolean;
daily_stats: Array<{
date: string;
total_calls: number;
successful_calls: number;
total_records: number;
success_rate: number;
}>;
endpoint_stats: Array<{
endpoint: string;
call_count: number;
}>;
cache_stats: {
cached_series: number;
cached_observations: number;
cache_duration_hours: number;
};
proxy_info: {
mode: string;
cache_duration_hours: number;
supported_endpoints: string;
permanent_storage: boolean;
smart_caching: boolean;
};
}
export interface FredSeriesData {
id: string;
title: string;
units: string;
frequency: string;
last_updated: string;
popularity?: number;
notes?: string;
cached?: boolean;
cached_at?: string;
}
export interface FredObservation {
date: string;
value: string;
realtime_start?: string;
realtime_end?: string;
}
export interface FredObservationsData {
series_id: string;
observations: FredObservation[];
count: number;
cached?: boolean;
cached_at?: string;
}
export interface FredEndpointsData {
series_endpoints: string[];
category_endpoints: string[];
release_endpoints: string[];
source_endpoints: string[];
tag_endpoints: string[];
other_endpoints: string[];
note: string;
}
export interface FredProxyRequest {
endpoint: string;
params?: {
series_id?: string;
category_id?: number;
release_id?: number;
source_id?: number;
search_text?: string;
limit?: number;
offset?: number;
observation_start?: string;
observation_end?: string;
realtime_start?: string;
realtime_end?: string;
order_by?: string;
sort_order?: 'asc' | 'desc';
frequency?: string;
aggregation_method?: string;
[key: string]: any;
};
force_refresh?: boolean;
bypass_limit_check?: boolean;
}
// FRED API 함수들
export const fredApi = {
// FRED API 사용량 통계 조회
getUsageStats: async (days: number = 7, useProxyStats: boolean = true): Promise<FredUsageStats> => {
const queryParams = new URLSearchParams({
days: days.toString(),
use_proxy_stats: useProxyStats.toString(),
});
const response = await api.get(`/fred/stats/usage?${queryParams}`);
return response.data.data;
},
// FRED 지원 엔드포인트 목록 조회
getSupportedEndpoints: async (): Promise<FredEndpointsData> => {
const response = await api.get('/fred/endpoints');
return response.data.data;
},
// FRED API 프록시 요청 (범용)
proxyRequest: async (request: FredProxyRequest): Promise<any> => {
const { endpoint, params = {}, force_refresh = false, bypass_limit_check = false } = request;
const queryParams = new URLSearchParams();
// 기본 파라미터들 추가
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.set(key, value.toString());
}
});
// 제어 파라미터들 추가
if (force_refresh) queryParams.set('force_refresh', 'true');
if (bypass_limit_check) queryParams.set('bypass_limit_check', 'true');
const response = await api.get(`/fred/proxy/${endpoint}?${queryParams}`);
return response.data;
},
// 인기 경제 지표 시리즈 조회 (미리 정의된)
getPopularSeries: async (): Promise<FredSeriesData[]> => {
const popularSeriesIds = ['GDP', 'UNRATE', 'FEDFUNDS', 'CPIAUCSL', 'PAYEMS', 'HOUST', 'INDPRO'];
const promises = popularSeriesIds.map(async (seriesId) => {
try {
const queryParams = new URLSearchParams();
queryParams.set('series_id', seriesId);
const response = await api.get(`/fred/proxy/series?${queryParams}`);
if (response.data.success && response.data.data.seriess && response.data.data.seriess.length > 0) {
return response.data.data.seriess[0];
}
return null;
} catch (error) {
console.error(`Failed to fetch series ${seriesId}:`, error);
return null;
}
});
const results = await Promise.all(promises);
return results.filter(series => series !== null);
},
// 시리즈 검색
searchSeries: async (searchText: string, limit: number = 25): Promise<FredSeriesData[]> => {
const queryParams = new URLSearchParams();
queryParams.set('search_text', searchText);
queryParams.set('limit', limit.toString());
const response = await api.get(`/fred/proxy/series/search?${queryParams}`);
if (response.data.success && response.data.data.seriess) {
return response.data.data.seriess;
}
return [];
},
// 시리즈 관측값 조회
getSeriesObservations: async (
seriesId: string,
limit?: number,
observationStart?: string,
observationEnd?: string
): Promise<FredObservationsData> => {
const queryParams = new URLSearchParams();
queryParams.set('series_id', seriesId);
if (limit) queryParams.set('limit', limit.toString());
if (observationStart) queryParams.set('observation_start', observationStart);
if (observationEnd) queryParams.set('observation_end', observationEnd);
queryParams.set('sort_order', 'desc');
const response = await api.get(`/fred/proxy/series/observations?${queryParams}`);
if (response.data.success && response.data.data.observations) {
return {
series_id: seriesId,
observations: response.data.data.observations,
count: response.data.data.count || response.data.data.observations.length,
cached: response.data.metadata?.cached,
cached_at: response.data.metadata?.cached_at
};
}
return {
series_id: seriesId,
observations: [],
count: 0
};
},
};
// Admin API 함수들 (로그 관리)
export const adminApi = {
getRequestLogs: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.get(`/admin/requests/logs?${qp}`);
return data;
},
getErrorLogs: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.get(`/admin/errors/logs?${qp}`);
return data;
},
getRequestStats: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.get(`/admin/requests/stats?${qp}`);
return data;
},
getErrorStats: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.get(`/admin/errors/stats?${qp}`);
return data;
},
deleteRequestLogs: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.delete(`/admin/requests/logs?${qp}`);
return data;
},
deleteErrorLogs: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.delete(`/admin/errors/logs?${qp}`);
return data;
},
getErrorByRequestId: async (requestId: string) => {
const { data } = await api.get(`/admin/errors/by-request/${requestId}`);
return data;
},
resolveError: async (logId: number, isResolved: boolean, notes?: string) => {
const { data } = await api.patch(`/admin/errors/logs/${logId}`, {
is_resolved: isResolved,
resolution_notes: notes || undefined,
});
return data;
},
};
// 유틸리티 함수들
export const formatCurrency = (value: number | undefined): string => {
if (value === undefined || value === null) return 'N/A';
if (Math.abs(value) >= 1e12) {
return `$${(value / 1e12).toFixed(1)}T`;
} else if (Math.abs(value) >= 1e9) {
return `$${(value / 1e9).toFixed(1)}B`;
} else if (Math.abs(value) >= 1e6) {
return `$${(value / 1e6).toFixed(1)}M`;
} else if (Math.abs(value) >= 1e3) {
return `$${(value / 1e3).toFixed(1)}K`;
} else {
return `$${value.toFixed(2)}`;
}
};
export const formatNumber = (value: number | undefined): string => {
if (value === undefined || value === null) return 'N/A';
if (Math.abs(value) >= 1e9) {
return `${(value / 1e9).toFixed(2)}B`;
} else if (Math.abs(value) >= 1e6) {
return `${(value / 1e6).toFixed(2)}M`;
} else if (Math.abs(value) >= 1e3) {
return `${(value / 1e3).toFixed(2)}K`;
} else {
return value.toFixed(2);
}
};
export const formatPercentage = (value: number | undefined): string => {
if (value === undefined || value === null) return 'N/A';
return `${(value * 100).toFixed(2)}%`;
};
export const formatDate = (date: string, format?: string): string => {
const dateObj = new Date(date);
if (format === 'MM/dd') {
return dateObj.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
});
}
if (format === 'MM/yy') {
return dateObj.toLocaleDateString('en-US', {
month: '2-digit',
year: '2-digit',
});
}
return dateObj.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};