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
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',
|
|
});
|
|
}; |