Browse Source

feat(market): KOSIS 거시 대시보드 + 선물/옵션 세션(정규/야간) UI

- /market/macro 거시지표 대시보드 — 6종(고용률·실업률·취업자수·CPI·수출·수입) 카드(최신값+단위+직전대비+Sparkline) + 최근 12시점 매트릭스(MacroTable). types/macro·lib/api/macro. MarketNav "거시지표" 탭
- 파생 화면 정규/야간 세션 토글(searchParams session, 백엔드 MarketSession 1/2 숫자). 정규 기본화면 기존과 동일, 야간 조회 시만 세션 배지/문구. Futures/OptionsTable showSession prop, types/market MarketSession+라벨맵
- npm build 0에러. 수집 off 시 빈 상태 우아 처리

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 weeks ago
parent
commit
ebb797a0b3

+ 8 - 4
app/(main)/market/_components/FuturesTable.tsx

@@ -1,11 +1,11 @@
 'use client';
 
 import DataTable, { DataTableColumn } from '@/components/table/DataTable';
-import { FuturesRow } from '@/types/market';
+import { FuturesRow, MARKET_SESSION_LABEL } from '@/types/market';
 import { formatNumber } from '@/lib/utils/stock';
 import { formatDecimal } from '@/lib/utils/market';
 
-function buildColumns(): DataTableColumn<FuturesRow>[] {
+function buildColumns(showSession: boolean): DataTableColumn<FuturesRow>[] {
 	return [
 		{
 			key: 'name',
@@ -17,6 +17,9 @@ function buildColumns(): DataTableColumn<FuturesRow>[] {
 				<span className='stock-cell'>
 					<span className='stock-cell__name'>{row.isuName}</span>
 					<span className='stock-cell__code'>{row.isuCode}</span>
+					{showSession && (
+						<span className='market__cls-badge'>{MARKET_SESSION_LABEL[row.session] ?? row.session}</span>
+					)}
 				</span>
 			)
 		},
@@ -65,12 +68,13 @@ function buildColumns(): DataTableColumn<FuturesRow>[] {
 	];
 }
 
-export default function FuturesTable({ rows }: { rows: FuturesRow[] })
+// showSession — 야간 세션 조회 시에만 종목명 옆 세션 배지 표시 (정규 기본 화면은 기존과 동일)
+export default function FuturesTable({ rows, showSession = false }: { rows: FuturesRow[]; showSession?: boolean })
 {
 	return (
 		<DataTable<FuturesRow>
 			caption='선물 목록 — 종목명, 현재가, 대비, 정산가, 거래량, 미결제약정, 상품'
-			columns={buildColumns()}
+			columns={buildColumns(showSession)}
 			rows={rows}
 			rowKey={(row) => row.isuCode}
 			emptyMessage='표시할 선물이 없습니다.'

+ 82 - 0
app/(main)/market/_components/MacroTable.tsx

@@ -0,0 +1,82 @@
+'use client';
+
+import DataTable, { DataTableColumn, ColumnPriority } from '@/components/table/DataTable';
+import { MacroIndicator, MACRO_CODE_LABEL } from '@/types/macro';
+import { macroPeriodLabel, formatMacroValue } from '@/lib/utils/market';
+
+interface MacroTableRow {
+	period: string;
+	// 지표 코드 → 해당 시점 값 (미수록 시점은 null)
+	values: Record<string, number|null>;
+}
+
+// 지표 열 우선순위 — 앞쪽 지표만 mobile 유지 (지표 6열 전부는 협소)
+function columnPriority(index: number): ColumnPriority {
+	if (index < 2) {
+		return 'high';
+	}
+
+	return index < 4 ? 'medium' : 'low';
+}
+
+function buildColumns(indicators: MacroIndicator[]): DataTableColumn<MacroTableRow>[] {
+	const columns: DataTableColumn<MacroTableRow>[] = [
+		{
+			key: 'period',
+			header: '시점',
+			align: 'left',
+			priority: 'high',
+			cell: (row) => macroPeriodLabel(row.period)
+		}
+	];
+
+	indicators.forEach((indicator, index) => {
+		const label = MACRO_CODE_LABEL[indicator.code] ?? indicator.name;
+
+		columns.push({
+			key: indicator.code,
+			header: indicator.unit ? `${label} (${indicator.unit})` : label,
+			align: 'right',
+			priority: columnPriority(index),
+			cell: (row) => formatMacroValue(row.values[indicator.code])
+		});
+	});
+
+	return columns;
+}
+
+// 최근 N기간 매트릭스 — 행 = 시점(내림차순 = 최신 먼저), 열 = 지표
+export default function MacroTable({ indicators, maxRows = 12 }: { indicators: MacroIndicator[]; maxRows?: number })
+{
+	const valueMaps = new Map<string, Map<string, number>>();
+	const periods = new Set<string>();
+
+	for (const indicator of indicators) {
+		const map = new Map<string, number>();
+
+		for (const point of indicator.points) {
+			map.set(point.period, point.value);
+			periods.add(point.period);
+		}
+
+		valueMaps.set(indicator.code, map);
+	}
+
+	const rows: MacroTableRow[] = [...periods]
+		.sort((a, b) => b.localeCompare(a))
+		.slice(0, maxRows)
+		.map(period => ({
+			period,
+			values: Object.fromEntries(indicators.map(indicator => [indicator.code, valueMaps.get(indicator.code)?.get(period) ?? null]))
+		}));
+
+	return (
+		<DataTable<MacroTableRow>
+			caption='거시경제지표 최근 추이 — 시점별 고용률, 실업률, 취업자수, 소비자물가지수, 수출액, 수입액'
+			columns={buildColumns(indicators)}
+			rows={rows}
+			rowKey={(row) => row.period}
+			emptyMessage='표시할 거시지표 데이터가 없습니다.'
+		/>
+	);
+}

+ 1 - 0
app/(main)/market/_components/MarketNav.tsx

@@ -15,6 +15,7 @@ const TABS = [
 	{ href: '/market/meetings', label: '주총' },
 	{ href: '/market/els-risk', label: 'ELS' },
 	{ href: '/market/foreign', label: '외화' },
+	{ href: '/market/macro', label: '거시지표' },
 	{ href: '/disclosure', label: '공시' }
 ];
 

+ 8 - 4
app/(main)/market/_components/OptionsTable.tsx

@@ -1,11 +1,11 @@
 'use client';
 
 import DataTable, { DataTableColumn } from '@/components/table/DataTable';
-import { OptionsRow, OPTION_RIGHT_TYPE_LABEL } from '@/types/market';
+import { OptionsRow, OPTION_RIGHT_TYPE_LABEL, MARKET_SESSION_LABEL } from '@/types/market';
 import { formatNumber } from '@/lib/utils/stock';
 import { formatDecimal } from '@/lib/utils/market';
 
-function buildColumns(): DataTableColumn<OptionsRow>[] {
+function buildColumns(showSession: boolean): DataTableColumn<OptionsRow>[] {
 	return [
 		{
 			key: 'name',
@@ -17,6 +17,9 @@ function buildColumns(): DataTableColumn<OptionsRow>[] {
 				<span className='stock-cell'>
 					<span className='stock-cell__name'>{row.isuName}</span>
 					<span className='stock-cell__code'>{row.isuCode}</span>
+					{showSession && (
+						<span className='market__cls-badge'>{MARKET_SESSION_LABEL[row.session] ?? row.session}</span>
+					)}
 				</span>
 			)
 		},
@@ -65,12 +68,13 @@ function buildColumns(): DataTableColumn<OptionsRow>[] {
 	];
 }
 
-export default function OptionsTable({ rows }: { rows: OptionsRow[] })
+// showSession — 야간 세션 조회 시에만 종목명 옆 세션 배지 표시 (정규 기본 화면은 기존과 동일)
+export default function OptionsTable({ rows, showSession = false }: { rows: OptionsRow[]; showSession?: boolean })
 {
 	return (
 		<DataTable<OptionsRow>
 			caption='옵션 목록 — 종목명, 권리유형, 행사가, 현재가, 내재변동성, 거래량, 미결제약정'
-			columns={buildColumns()}
+			columns={buildColumns(showSession)}
 			rows={rows}
 			rowKey={(row) => row.isuCode}
 			emptyMessage='표시할 옵션이 없습니다.'

+ 37 - 14
app/(main)/market/derivatives/page.tsx

@@ -8,13 +8,13 @@ import FuturesTable from '../_components/FuturesTable';
 import OptionsTable from '../_components/OptionsTable';
 import Pager from '../_components/Pager';
 import { fetchMarketFutures, fetchMarketOptions } from '@/lib/api/market';
-import { FuturesKindName, OptionsKindName } from '@/types/market';
+import { FuturesKindName, OptionsKindName, MarketSession, MarketSessionName } from '@/types/market';
 import { formatNumber } from '@/lib/utils/stock';
 import { tradeDateLabel } from '@/lib/utils/market';
 
 export const metadata: Metadata = {
 	title: '파생 (선물·옵션) — 개미투자',
-	description: '국내 파생상품 시세 — 선물·옵션 현재가·정산가·미결제약정·거래량 (전일 종가 기준)'
+	description: '국내 파생상품 시세 — 선물·옵션 현재가·정산가·미결제약정·거래량 (정규/야간 세션, 전일 종가 기준)'
 };
 
 const SECTION_TABS: { value: 'futures'|'options'; label: string }[] = [
@@ -28,12 +28,19 @@ const KIND_TABS: { value: FuturesKindName&OptionsKindName; label: string }[] = [
 	{ value: 'StockKosdaq', label: '주식(코스닥)' }
 ];
 
+// 세션 토글 (정규/야간) — API 로는 숫자 1/2 전송 (MarketSession)
+const SESSION_TABS: { value: MarketSessionName; label: string }[] = [
+	{ value: 'Regular', label: '정규' },
+	{ value: 'Night', label: '야간' }
+];
+
 const PER_PAGE = 20;
 
 type Props = {
 	searchParams: Promise<{
 		section?: string;
 		kind?: string;
+		session?: string;
 		page?: string;
 	}>;
 };
@@ -43,32 +50,35 @@ export default async function MarketDerivativesPage({ searchParams }: Props)
 	const query = await searchParams;
 	const section: 'futures'|'options' = SECTION_TABS.some(c => c.value === query.section) ? (query.section as 'futures'|'options') : 'futures';
 	const kind = KIND_TABS.some(c => c.value === query.kind) ? (query.kind as FuturesKindName&OptionsKindName) : 'General';
+	const session: MarketSessionName = query.session === 'Night' ? 'Night' : 'Regular';
+	const isNight = session === 'Night';
 	const page = Math.max(Number(query.page) || 1, 1);
 
-	const futuresRes = section === 'futures' ? await fetchMarketFutures({ kind, page }) : null;
-	const optionsRes = section === 'options' ? await fetchMarketOptions({ kind, page }) : null;
+	const futuresRes = section === 'futures' ? await fetchMarketFutures({ kind, page, session: MarketSession[session] }) : null;
+	const optionsRes = section === 'options' ? await fetchMarketOptions({ kind, page, session: MarketSession[session] }) : null;
 
 	const res = section === 'futures' ? futuresRes! : optionsRes!;
 	const data = res.success ? res.data : null;
 	const tradeDate = section === 'futures' ? futuresRes?.data?.tradeDate : optionsRes?.data?.tradeDate;
+	const sectionLabel = section === 'futures' ? '선물' : '옵션';
 
 	return (
 		<div className='market'>
 			<div className='market__head'>
 				<h1 className='market__title'>파생</h1>
-				<span className='market__basis'>{tradeDateLabel(tradeDate)} · 선물/옵션</span>
+				<span className='market__basis'>{tradeDateLabel(tradeDate)} · 선물/옵션{isNight ? ' · 야간 세션' : ''}</span>
 			</div>
 
 			<MarketNav />
 
-			{/* 선물/옵션 섹션 탭 (kind 는 섹션 변경 시 초기화) */}
+			{/* 선물/옵션 섹션 탭 (kind 는 섹션 변경 시 초기화, 세션은 유지) */}
 			<nav className='market__filters' aria-label='파생 구분'>
 				<div className='market__filter-group' role='group'>
 					<span className='market__filter-label'>구분</span>
 					{SECTION_TABS.map(tab => (
 						<Link
 							key={tab.value}
-							href={`/market/derivatives?section=${tab.value}`}
+							href={`/market/derivatives?section=${tab.value}${isNight ? '&session=Night' : ''}`}
 							className={`market__tab${section === tab.value ? ' market__tab--active' : ''}`}
 							{...(section === tab.value ? { 'aria-current': 'page' as const } : {})}
 						>
@@ -83,25 +93,38 @@ export default async function MarketDerivativesPage({ searchParams }: Props)
 					tabs={KIND_TABS}
 					active={kind}
 					basePath='/market/derivatives'
-					preserve={{ section }}
+					preserve={isNight ? { section, session } : { section }}
+				/>
+
+				<MarketFilterTabs
+					label='세션'
+					paramKey='session'
+					tabs={SESSION_TABS}
+					active={session}
+					basePath='/market/derivatives'
+					preserve={{ section, kind }}
 				/>
 			</nav>
 
 			{!res.success ? (
 				<div className='market__error' role='alert'>
-					<strong>{section === 'futures' ? '선물' : '옵션'} 목록을 불러오지 못했습니다.</strong>
+					<strong>{sectionLabel} 목록을 불러오지 못했습니다.</strong>
 					잠시 후 다시 시도해 주세요.
 				</div>
 			) : !data || data.list.length === 0 ? (
-				<div className='market__empty'>표시할 {section === 'futures' ? '선물' : '옵션'}이 없습니다.</div>
+				<div className='market__empty'>
+					{isNight
+						? `야간 세션 ${sectionLabel} 데이터가 없습니다. 야간(글로벌) 시세 수집 전이거나 휴장일 수 있습니다.`
+						: `표시할 ${sectionLabel}이 없습니다.`}
+				</div>
 			) : (
 				<>
 					<p className='market__total'>전체 {formatNumber(data.total)}종목</p>
 					<div className='market__table-wrap'>
 						{section === 'futures' ? (
-							<FuturesTable rows={futuresRes!.data!.list} />
+							<FuturesTable rows={futuresRes!.data!.list} showSession={isNight} />
 						) : (
-							<OptionsTable rows={optionsRes!.data!.list} />
+							<OptionsTable rows={optionsRes!.data!.list} showSession={isNight} />
 						)}
 					</div>
 					<Pager
@@ -109,10 +132,10 @@ export default async function MarketDerivativesPage({ searchParams }: Props)
 						page={page}
 						perPage={PER_PAGE}
 						basePath='/market/derivatives'
-						query={{ section, kind }}
+						query={isNight ? { section, kind, session } : { section, kind }}
 					/>
 				</>
 			)}
 		</div>
 	);
-}
+}

+ 108 - 0
app/(main)/market/macro/page.tsx

@@ -0,0 +1,108 @@
+import '../style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import MarketNav from '../_components/MarketNav';
+import Sparkline from '../_components/Sparkline';
+import MacroTable from '../_components/MacroTable';
+import { fetchMacroIndicators } from '@/lib/api/macro';
+import { MACRO_CODES, MACRO_CODE_LABEL, MacroIndicator } from '@/types/macro';
+import { changeDirection, changeSymbol } from '@/lib/utils/stock';
+import { macroPeriodLabel, formatMacroValue } from '@/lib/utils/market';
+
+export const metadata: Metadata = {
+	title: '거시지표 — 개미투자',
+	description: '한국 거시경제지표 대시보드 — 고용률·실업률·취업자수·소비자물가지수·수출입액 추이 (KOSIS 수집)'
+};
+
+// 지표당 조회할 최근 시점 수 (월간 기준 1년)
+const MACRO_LIMIT = 12;
+
+// 백엔드는 코드 오름차순 반환 → 화면 고정 순서(MACRO_CODES)로 재정렬 (미정의 코드는 뒤로)
+function sortByCode(list: MacroIndicator[]): MacroIndicator[] {
+	const order = new Map<string, number>(MACRO_CODES.map((code, index) => [code, index]));
+
+	return [...list].sort((a, b) => (order.get(a.code) ?? MACRO_CODES.length) - (order.get(b.code) ?? MACRO_CODES.length));
+}
+
+export default async function MarketMacroPage()
+{
+	const res = await fetchMacroIndicators({ codes: [...MACRO_CODES], limit: MACRO_LIMIT });
+	const indicators = res.success ? sortByCode(res.data?.list ?? []) : [];
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>거시지표</h1>
+				<span className='market__basis'>고용·물가·수출입 최근 추이 (KOSIS)</span>
+			</div>
+
+			<MarketNav />
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>거시지표 데이터를 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : indicators.length === 0 ? (
+				<div className='market__empty'>표시할 거시지표 데이터가 없습니다. 수집이 시작되면 자동으로 표시됩니다.</div>
+			) : (
+				<>
+					{/* 지표별 카드 — 최신값·단위·직전 대비 증감·추이 */}
+					<div className='market__series-grid'>
+						{indicators.map(indicator => {
+							const label = MACRO_CODE_LABEL[indicator.code] ?? indicator.name;
+							const previous = indicator.points.length > 1 ? indicator.points[indicator.points.length - 2] : null;
+							const delta = indicator.latestValue !== null && previous !== null ? indicator.latestValue - previous.value : null;
+							const direction = changeDirection(delta);
+
+							return (
+								<div key={indicator.code} className='market__series-card'>
+									<div className='market__series-head'>
+										<span className='market__series-name' title={indicator.name}>{label}</span>
+										<span className='market__cls-badge'>{macroPeriodLabel(indicator.latestPeriod)}</span>
+									</div>
+
+									<span className='market__series-latest'>
+										{formatMacroValue(indicator.latestValue)}
+										{indicator.unit ? <span className='market__series-unit'>{indicator.unit}</span> : null}
+									</span>
+
+									<span className={`market__index-change market__index-change--${direction}`}>
+										{delta === null ? (
+											'직전 대비 —'
+										) : (
+											<>
+												<span aria-hidden='true'>{changeSymbol(direction)}</span> {formatMacroValue(Math.abs(delta))} <span className='market__series-delta-caption'>직전 대비</span>
+											</>
+										)}
+									</span>
+
+									<Sparkline
+										values={indicator.points.map(p => p.value)}
+										label={`${label} 추이`}
+									/>
+
+									<div className='market__series-sub'>
+										<span>{macroPeriodLabel(indicator.points[0]?.period)} ~ {macroPeriodLabel(indicator.latestPeriod)}</span>
+										<span>{indicator.points.length}개 시점</span>
+									</div>
+								</div>
+							);
+						})}
+					</div>
+
+					{/* 최근 N기간 매트릭스 */}
+					<section className='market__section market__section--macro-table'>
+						<div className='market__section-head'>
+							<h2 className='market__section-title'>최근 추이</h2>
+							<span className='market__basis'>지표별 최근 {MACRO_LIMIT}개 시점</span>
+						</div>
+						<div className='market__table-wrap'>
+							<MacroTable indicators={indicators} maxRows={MACRO_LIMIT} />
+						</div>
+					</section>
+				</>
+			)}
+		</div>
+	);
+}

+ 17 - 0
app/(main)/market/style.scss

@@ -299,6 +299,23 @@
 		font-variant-numeric: tabular-nums;
 	}
 
+	// ── 거시지표 카드 보조 (단위 병기·증감 캡션·하단 매트릭스 간격) ──
+	&__series-unit {
+		margin-left: var(--sp-1);
+		font-size: var(--fs-xs);
+		font-weight: 500;
+		color: var(--text-muted);
+	}
+
+	&__series-delta-caption {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	&__section--macro-table {
+		margin-top: var(--sp-6);
+	}
+
 	// 미니 막대 스파크라인 (기준일순 값 추이 — 최대값 대비 비율 높이)
 	&__spark {
 		display: flex;

+ 24 - 0
lib/api/macro.ts

@@ -0,0 +1,24 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import { MacroIndicatorsResponse } from '@/types/macro';
+import { fetchJson } from '@/lib/utils/server';
+
+// 공통 헤더 (익명 · JSON)
+const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+
+// 거시경제지표 (KOSIS) — codes CSV(미지정 시 적재된 전 지표) · limit = 지표당 최근 시점 수 (백엔드 기본 60 · 최대 240)
+export async function fetchMacroIndicators(params: { codes?: string[]; limit?: number }): Promise<ResultDto<MacroIndicatorsResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.codes && params.codes.length > 0) queryParams.set('codes', params.codes.join(','));
+	if (params.limit) queryParams.set('limit', String(params.limit));
+
+	const qs = queryParams.toString();
+
+	return await fetchJson<MacroIndicatorsResponse>(`/api/market/macro${qs ? `?${qs}` : ''}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}

+ 7 - 4
lib/api/market.ts

@@ -11,6 +11,7 @@ import {
 	FuturesKindName,
 	OptionsResponse,
 	OptionsKindName,
+	MarketSessionValue,
 	CommoditiesResponse,
 	CommodityMarketName,
 	EsgSecuritiesResponse,
@@ -64,13 +65,14 @@ export async function fetchMarketBonds(params: { market?: BondMarketName; date?:
 	});
 }
 
-// 선물 (일반/주식선물)
-export async function fetchMarketFutures(params: { kind?: FuturesKindName; date?: string; page?: number }): Promise<ResultDto<FuturesResponse>>
+// 선물 (일반/주식선물) — session 은 숫자 1(정규)/2(야간) 전송, 미지정 시 백엔드 기본 정규
+export async function fetchMarketFutures(params: { kind?: FuturesKindName; date?: string; page?: number; session?: MarketSessionValue }): Promise<ResultDto<FuturesResponse>>
 {
 	const queryParams = new URLSearchParams();
 
 	if (params.kind) queryParams.set('kind', params.kind);
 	if (params.date) queryParams.set('date', params.date);
+	if (params.session) queryParams.set('session', String(params.session));
 	queryParams.set('page', String(params.page ?? 1));
 
 	return await fetchJson<FuturesResponse>(`/api/market/futures?${queryParams.toString()}`, {
@@ -79,13 +81,14 @@ export async function fetchMarketFutures(params: { kind?: FuturesKindName; date?
 	});
 }
 
-// 옵션 (일반/주식옵션)
-export async function fetchMarketOptions(params: { kind?: OptionsKindName; date?: string; page?: number }): Promise<ResultDto<OptionsResponse>>
+// 옵션 (일반/주식옵션) — session 은 숫자 1(정규)/2(야간) 전송, 미지정 시 백엔드 기본 정규
+export async function fetchMarketOptions(params: { kind?: OptionsKindName; date?: string; page?: number; session?: MarketSessionValue }): Promise<ResultDto<OptionsResponse>>
 {
 	const queryParams = new URLSearchParams();
 
 	if (params.kind) queryParams.set('kind', params.kind);
 	if (params.date) queryParams.set('date', params.date);
+	if (params.session) queryParams.set('session', String(params.session));
 	queryParams.set('page', String(params.page ?? 1));
 
 	return await fetchJson<OptionsResponse>(`/api/market/options?${queryParams.toString()}`, {

+ 22 - 0
lib/utils/market.ts

@@ -99,3 +99,25 @@ export function meetingTimeLabel(hhmm: string|null|undefined): string {
 	const padded = hhmm.padStart(4, '0');
 	return `${padded.slice(0, 2)}:${padded.slice(2, 4)}`;
 }
+
+// KOSIS 수록시점 → 표시 라벨 (월간 YYYYMM → "YYYY.MM", 연간 YYYY 그대로, 그 외 원문). null 은 —
+export function macroPeriodLabel(period: string|null|undefined): string {
+	if (!period) {
+		return '—';
+	}
+
+	if (/^\d{6}$/.test(period)) {
+		return `${period.slice(0, 4)}.${period.slice(4, 6)}`;
+	}
+
+	return period;
+}
+
+// 거시지표 값 (천 단위 구분 + 소수 최대 2자리 — 단위는 마크업에서 병기). null 은 —
+export function formatMacroValue(value: number|null|undefined): string {
+	if (value === null || value === undefined) {
+		return '—';
+	}
+
+	return value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
+}

+ 46 - 0
types/macro.ts

@@ -0,0 +1,46 @@
+// 거시경제지표(KOSIS) 도메인 타입 — Backend Application/Features/Api/Stocks/GetMacroIndicators/Response.cs 대응
+// 직렬화 주의: 필드명은 camelCase. period 는 KOSIS 수록시점 원문 문자열 (월간 YYYYMM · 연간 YYYY).
+// decimal 은 number, unit 은 KOSIS UNIT_NM 원문 문자열 (% · 천명 · 2020=100 · 천달러 등).
+
+// ── 지표 코드 (backend Kosis config Code) — 화면 표시 순서 고정 ──
+export const MACRO_CODES = [
+	'EMPLOYMENT_RATE',
+	'UNEMPLOYMENT_RATE',
+	'EMPLOYED_PERSONS',
+	'CPI',
+	'EXPORT_AMT',
+	'IMPORT_AMT'
+] as const;
+export type MacroCode = (typeof MACRO_CODES)[number];
+
+// 코드 → 한글 라벨 (backend Name 은 수집 설정 원문이라 화면용 라벨은 프론트 상수로 관리)
+export const MACRO_CODE_LABEL: Record<string, string> = {
+	EMPLOYMENT_RATE: '고용률',
+	UNEMPLOYMENT_RATE: '실업률',
+	EMPLOYED_PERSONS: '취업자수',
+	CPI: '소비자물가지수',
+	EXPORT_AMT: '수출액',
+	IMPORT_AMT: '수입액'
+};
+
+// ── GET /api/market/macro ──
+export interface MacroPoint {
+	period: string;
+	value: number;
+}
+
+export interface MacroIndicator {
+	code: string;
+	name: string;
+	unit: string|null;
+	// 최신 시점 (points 의 마지막)
+	latestPeriod: string|null;
+	latestValue: number|null;
+	// 시점 오름차순 (마지막 = 최신)
+	points: MacroPoint[];
+}
+
+export interface MacroIndicatorsResponse {
+	total: number;
+	list: MacroIndicator[];
+}

+ 18 - 0
types/market.ts

@@ -2,6 +2,7 @@
 // 직렬화 주의: 필드명은 camelCase. enum 은 Web.Api Program.cs 에 JsonStringEnumConverter 미등록 →
 //    Response DTO 에서 enum 은 숫자(정수)로 직렬화됨 (Paper 의 문자열 enum 과 다름).
 //    쿼리스트링으로 보낼 때는 ASP.NET model binding 이 enum 멤버명 문자열도 허용하므로 이름으로 전송한다.
+//    (예외: session 은 숫자 1/2 로 전송 — MarketSession)
 
 // ── enum (응답 = 숫자, 요청 = 멤버명 문자열) ──
 
@@ -53,6 +54,19 @@ export const BOND_MARKET_LABEL: Record<BondMarketValue, string> = {
 	[BondMarket.Small]: '소액채권'
 };
 
+// 매매 세션 (MarketSession) — 선물/옵션 공용. 요청 파라미터명 'session' (숫자 1/2 로 전송 — model binding 은 숫자·이름 모두 허용)
+export const MarketSession = {
+	Regular: 1,
+	Night: 2
+} as const;
+export type MarketSessionValue = (typeof MarketSession)[keyof typeof MarketSession];
+export type MarketSessionName = keyof typeof MarketSession;
+
+export const MARKET_SESSION_LABEL: Record<MarketSessionValue, string> = {
+	[MarketSession.Regular]: '정규',
+	[MarketSession.Night]: '야간'
+};
+
 // 선물 종류 (FuturesKind) — 요청 파라미터명 'kind'
 export const FuturesKind = {
 	General: 1,
@@ -193,6 +207,8 @@ export interface BondsResponse {
 // ── GET /api/market/futures ──
 export interface FuturesRow {
 	kind: FuturesKindValue;
+	// 매매 세션 (1=정규, 2=야간) — 숫자 직렬화
+	session: MarketSessionValue;
 	isuCode: string;
 	isuName: string;
 	close: number;
@@ -213,6 +229,8 @@ export interface FuturesResponse {
 // ── GET /api/market/options ──
 export interface OptionsRow {
 	kind: OptionsKindValue;
+	// 매매 세션 (1=정규, 2=야간) — 숫자 직렬화
+	session: MarketSessionValue;
 	isuCode: string;
 	isuName: string;
 	rightType: OptionRightTypeValue;