Jelajahi Sumber

feat(market): SEIBro 데이터 화면 9종 — 배당/락업/주총/ELS/외화/채권상세 + 종목 탭

market 페이지 6종:
- /market/dividends 배당 캘린더(권리락일·현금/주식·시가배당율)
- /market/lockup 보호예수(락업) 해제 캘린더
- /market/meetings 주총 캘린더(종류·전자투표·안건수)
- /market/els-risk ELS 기초자산별 미상환잔액(지수 리스크)
- /market/foreign 국가별 외화증권 보관금액
- /market/bonds/[isin] 채권 상세(만기·표면이자율·신용등급 4사·옵션+KRX 시세)

종목 상세 탭 3종(stock/[code]?tab=): 배당 이력·대차 시계열(외국인 대여비율)·CB/BW 행사

- types/seibro.ts(9 응답 타입+코드 라벨맵) + lib/api/seibro.ts(서버 조회)
- D6 DataTable·MarketNav 재사용, 차트 부재→SCSS Sparkline, enum/코드 숫자→라벨맵
- 프록시 변경 없음(기존 market·stocks 캐치올 커버). 빈 데이터 우아 처리(수집 off 대비)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 minggu lalu
induk
melakukan
c8a885fd55

+ 17 - 4
app/(main)/market/_components/BondsTable.tsx

@@ -1,10 +1,16 @@
 'use client';
 
+import Link from 'next/link';
 import DataTable, { DataTableColumn } from '@/components/table/DataTable';
 import { BondRow, BOND_MARKET_LABEL } from '@/types/market';
 import { formatNumber } from '@/lib/utils/stock';
 import { formatDecimal, formatRatePercent } from '@/lib/utils/market';
 
+// KRX 채권 Code 는 12자리 ISIN → 채권 상세 링크 대상
+function isIsin(code: string): boolean {
+	return /^[A-Za-z0-9]{12}$/.test(code);
+}
+
 function buildColumns(): DataTableColumn<BondRow>[] {
 	return [
 		{
@@ -14,10 +20,17 @@ function buildColumns(): DataTableColumn<BondRow>[] {
 			priority: 'high',
 			cellClassName: 'data-table__cell--name',
 			cell: (row) => (
-				<span className='stock-cell'>
-					<span className='stock-cell__name'>{row.name}</span>
-					<span className='stock-cell__code'>{row.code}</span>
-				</span>
+				isIsin(row.code) ? (
+					<Link href={`/market/bonds/${row.code}`} className='stock-cell'>
+						<span className='stock-cell__name'>{row.name}</span>
+						<span className='stock-cell__code'>{row.code}</span>
+					</Link>
+				) : (
+					<span className='stock-cell'>
+						<span className='stock-cell__name'>{row.name}</span>
+						<span className='stock-cell__code'>{row.code}</span>
+					</span>
+				)
 			)
 		},
 		{

+ 66 - 0
app/(main)/market/_components/DividendCalendarTable.tsx

@@ -0,0 +1,66 @@
+'use client';
+
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { DividendCalendarRow, DIVIDEND_SORT_LABEL, DIVIDEND_FIX_LABEL, DIVIDEND_SETTLE_LABEL } from '@/types/seibro';
+import { codeLabel } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<DividendCalendarRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => <span className='stock-cell__name'>{row.name}</span>
+		},
+		{
+			key: 'rgtStdDt',
+			header: '권리기준일',
+			align: 'center',
+			priority: 'high',
+			cell: (row) => row.rgtStdDt
+		},
+		{
+			key: 'xrgtDt',
+			header: '권리락일',
+			align: 'center',
+			priority: 'medium',
+			cell: (row) => row.xrgtDt ?? '—'
+		},
+		{
+			key: 'sortCode',
+			header: '배당구분',
+			align: 'center',
+			priority: 'high',
+			cell: (row) => <span className='market__cls-badge'>{codeLabel(row.sortCode, DIVIDEND_SORT_LABEL)}</span>
+		},
+		{
+			key: 'settleType',
+			header: '결산',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => codeLabel(row.settleType, DIVIDEND_SETTLE_LABEL)
+		},
+		{
+			key: 'fixType',
+			header: '확정',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => codeLabel(row.fixType, DIVIDEND_FIX_LABEL)
+		}
+	];
+}
+
+export default function DividendCalendarTable({ rows }: { rows: DividendCalendarRow[] })
+{
+	return (
+		<DataTable<DividendCalendarRow>
+			caption='배당 캘린더 — 종목명, 권리기준일, 권리락일, 배당구분, 결산, 확정'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row, index) => `${row.issucoCustno}-${row.rgtStdDt}-${row.sortCode}-${index}`}
+			emptyMessage='표시할 배당 일정이 없습니다.'
+		/>
+	);
+}

+ 78 - 0
app/(main)/market/_components/LockupCalendarTable.tsx

@@ -0,0 +1,78 @@
+'use client';
+
+import Link from 'next/link';
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { LockupCalendarRow, LOCKUP_OCCR_LABEL, LOCKUP_REASON_LABEL } from '@/types/seibro';
+import { codeLabel, formatShares, dateOrDash } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<LockupCalendarRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				row.shortCode ? (
+					<Link href={`/stock/${row.shortCode}`} className='stock-cell'>
+						<span className='stock-cell__name'>{row.name}</span>
+						<span className='stock-cell__code'>{row.shortCode}</span>
+					</Link>
+				) : (
+					<span className='stock-cell'>
+						<span className='stock-cell__name'>{row.name}</span>
+					</span>
+				)
+			)
+		},
+		{
+			key: 'occrSeq',
+			header: '구분',
+			align: 'center',
+			priority: 'high',
+			cell: (row) => <span className='market__cls-badge'>{codeLabel(row.occrSeq, LOCKUP_OCCR_LABEL)}</span>
+		},
+		{
+			key: 'safedpDt',
+			header: '예수일',
+			align: 'center',
+			priority: 'medium',
+			cell: (row) => row.safedpDt
+		},
+		{
+			key: 'returnDt',
+			header: '해제일',
+			align: 'center',
+			priority: 'high',
+			cell: (row) => dateOrDash(row.returnDt)
+		},
+		{
+			key: 'qty',
+			header: '수량',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatShares(row.returnQty ?? row.safedpQty)
+		},
+		{
+			key: 'reason',
+			header: '사유',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => codeLabel(row.reasonCode, LOCKUP_REASON_LABEL)
+		}
+	];
+}
+
+export default function LockupCalendarTable({ rows }: { rows: LockupCalendarRow[] })
+{
+	return (
+		<DataTable<LockupCalendarRow>
+			caption='락업(보호예수) 캘린더 — 종목명, 구분, 예수일, 해제일, 수량, 사유'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row, index) => `${row.shortCode}-${row.safedpDt}-${row.occrSeq}-${index}`}
+			emptyMessage='표시할 락업 일정이 없습니다.'
+		/>
+	);
+}

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

@@ -10,6 +10,11 @@ const TABS = [
 	{ href: '/market/derivatives', label: '파생' },
 	{ href: '/market/commodities', label: '상품' },
 	{ href: '/market/esg', label: 'ESG' },
+	{ href: '/market/dividends', label: '배당' },
+	{ href: '/market/lockup', label: '락업' },
+	{ href: '/market/meetings', label: '주총' },
+	{ href: '/market/els-risk', label: 'ELS' },
+	{ href: '/market/foreign', label: '외화' },
 	{ href: '/disclosure', label: '공시' }
 ];
 

+ 86 - 0
app/(main)/market/_components/MeetingCalendarTable.tsx

@@ -0,0 +1,86 @@
+'use client';
+
+import Link from 'next/link';
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { MeetingCalendarRow, MEETING_KIND_LABEL } from '@/types/seibro';
+import { codeLabel, dateOrDash, meetingTimeLabel } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<MeetingCalendarRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				row.code ? (
+					<Link href={`/stock/${row.code}`} className='stock-cell'>
+						<span className='stock-cell__name'>{row.name ?? row.code}</span>
+						<span className='stock-cell__code'>{row.code}</span>
+					</Link>
+				) : (
+					<span className='stock-cell'>
+						<span className='stock-cell__name'>{row.name ?? `발행사 ${row.issucoCustno}`}</span>
+					</span>
+				)
+			)
+		},
+		{
+			key: 'gmetDt',
+			header: '총회일',
+			align: 'center',
+			priority: 'high',
+			cell: (row) => {
+				const time = meetingTimeLabel(row.gmetTtm);
+
+				return (
+					<span>
+						{dateOrDash(row.gmetDt)}{time && <span className='market__disclosure-date'> {time}</span>}
+					</span>
+				);
+			}
+		},
+		{
+			key: 'meetingKind',
+			header: '종류',
+			align: 'center',
+			priority: 'high',
+			cell: (row) => <span className='market__cls-badge'>{codeLabel(row.meetingKind, MEETING_KIND_LABEL)}</span>
+		},
+		{
+			key: 'evote',
+			header: '전자투표',
+			align: 'center',
+			priority: 'medium',
+			cell: (row) => (row.evote === null ? '—' : row.evote ? '가능' : '불가')
+		},
+		{
+			key: 'agendaCount',
+			header: '안건수',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => row.agendaCount.toLocaleString('ko-KR')
+		},
+		{
+			key: 'place',
+			header: '장소',
+			align: 'left',
+			priority: 'low',
+			cell: (row) => row.place ?? '—'
+		}
+	];
+}
+
+export default function MeetingCalendarTable({ rows }: { rows: MeetingCalendarRow[] })
+{
+	return (
+		<DataTable<MeetingCalendarRow>
+			caption='주총 캘린더 — 종목명, 총회일, 종류, 전자투표, 안건수, 장소'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row, index) => `${row.issucoCustno}-${row.rgtStdDt}-${index}`}
+			emptyMessage='표시할 주총 일정이 없습니다.'
+		/>
+	);
+}

+ 33 - 0
app/(main)/market/_components/Sparkline.tsx

@@ -0,0 +1,33 @@
+// 미니 막대 스파크라인 — 시계열 값 배열을 최대값 대비 비율 높이 막대로 표시 (RSC, 순수 표시).
+// 차트 라이브러리 없이 SCSS(.market__spark) 만으로 렌더. null 은 0 높이.
+
+interface Props {
+	values: (number|null)[];
+	label?: string;
+}
+
+export default function Sparkline({ values, label }: Props)
+{
+	const nums = values.map(v => (v === null || v === undefined ? 0 : v));
+	const max = Math.max(...nums, 0);
+
+	if (nums.length === 0 || max === 0) {
+		return <div className='market__spark-empty'>추이 데이터 없음</div>;
+	}
+
+	return (
+		<div className='market__spark' role='img' aria-label={label ?? '값 추이'}>
+			{nums.map((v, index) => {
+				const height = Math.max((v / max) * 100, v > 0 ? 4 : 0);
+
+				return (
+					<span
+						key={index}
+						className='market__spark-bar'
+						style={{ height: `${height}%` }}
+					/>
+				);
+			})}
+		</div>
+	);
+}

+ 222 - 0
app/(main)/market/bonds/[isin]/page.tsx

@@ -0,0 +1,222 @@
+import '../../style.scss';
+import { cache } from 'react';
+import { Metadata } from 'next';
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+import { fetchBondDetail } from '@/lib/api/seibro';
+import { codeLabel, formatAmountCompact, formatDecimal, formatRatePercent, formatShares, dateOrDash } from '@/lib/utils/market';
+import {
+	BOND_OPTION_LABEL,
+	BOND_RATE_CHG_LABEL,
+	BOND_GUARANTEE_LABEL,
+	BOND_RANK_LABEL,
+	BOND_INT_PAY_WAY_LABEL,
+	BOND_INT_CYCLE_LABEL,
+	BOND_INT_TIMING_LABEL,
+	BOND_PARTICUL_KIND_LABEL,
+	BOND_SECN_KACD_LABEL
+} from '@/types/seibro';
+
+type Props = {
+	params: Promise<{
+		isin: string;
+	}>;
+};
+
+// generateMetadata + 본문 이중 호출 방지 (요청 단위 dedupe)
+const getDetail = cache(async (isin: string) => {
+	return await fetchBondDetail(isin);
+});
+
+export async function generateMetadata({ params }: Props): Promise<Metadata>
+{
+	const { isin } = await params;
+	const res = await getDetail(isin);
+	const name = res.success && res.data?.master?.name ? res.data.master.name : isin;
+
+	return {
+		title: `${name} 채권 상세 — 개미투자`,
+		description: `${name}(${isin}) 채권 상세 — 만기·표면이자율·신용등급·이자지급·종가/YTM (SEIBro)`
+	};
+}
+
+export default async function BondDetailPage({ params }: Props)
+{
+	const { isin } = await params;
+	const res = await getDetail(isin);
+
+	if (!res.success && res.status === 404) {
+		return notFound();
+	}
+
+	if (!res.success || !res.data) {
+		return (
+			<div className='market'>
+				<Link href='/market/bonds' className='market__detail-back'>← 채권 목록</Link>
+				<div className='market__error' role='alert'>
+					<strong>채권 정보를 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			</div>
+		);
+	}
+
+	const detail = res.data;
+	const master = detail.master;
+	const interest = detail.interest;
+	const market = detail.market;
+	const name = master?.name ?? detail.isin;
+	const grades = [
+		{ agency: 'KIS', grade: master?.kisGrade },
+		{ agency: 'NICE', grade: master?.niceGrade },
+		{ agency: 'SCI', grade: master?.sciGrade },
+		{ agency: 'KR', grade: master?.krGrade }
+	].filter(c => c.grade);
+
+	return (
+		<div className='market'>
+			<Link href='/market/bonds' className='market__detail-back'>← 채권 목록</Link>
+
+			<div className='market__head'>
+				<h1 className='market__title'>{name}</h1>
+				<span className='market__basis'>{detail.isin} · 채권 상세 (SEIBro)</span>
+			</div>
+
+			{/* KRX 최신 시세 */}
+			{market && (
+				<div className='market__spec-card'>
+					<h2 className='market__spec-card-title'>최신 시세 <span className='market__basis'>({market.tradeDate} 기준)</span></h2>
+					<dl className='market__spec-grid'>
+						<div className='market__spec-item'>
+							<dt>종가</dt>
+							<dd>{formatDecimal(market.close)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>만기수익률(YTM)</dt>
+							<dd>{formatRatePercent(market.yieldToMaturity, 3)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>거래량</dt>
+							<dd>{formatShares(market.volume)}</dd>
+						</div>
+					</dl>
+				</div>
+			)}
+
+			{/* 채권 마스터 */}
+			{master ? (
+				<div className='market__spec-card'>
+					<h2 className='market__spec-card-title'>발행 정보</h2>
+					<dl className='market__spec-grid'>
+						<div className='market__spec-item'>
+							<dt>종목종류</dt>
+							<dd>{codeLabel(master.secnKacd, BOND_SECN_KACD_LABEL)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>발행일</dt>
+							<dd>{dateOrDash(master.issueDate)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>만기일</dt>
+							<dd>{dateOrDash(master.maturityDate)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>표면이자율</dt>
+							<dd>{formatRatePercent(master.couponRate, 3)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>통화</dt>
+							<dd>{master.currency ?? '—'}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>최초발행금액</dt>
+							<dd>{formatAmountCompact(master.firstIssueAmount)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>발행잔액</dt>
+							<dd>{formatAmountCompact(master.issueRemaining)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>금리유형</dt>
+							<dd>{codeLabel(master.rateChangeTpcd, BOND_RATE_CHG_LABEL)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>보증</dt>
+							<dd>{codeLabel(master.guaranteeTpcd, BOND_GUARANTEE_LABEL)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>순위</dt>
+							<dd>{codeLabel(master.rankTpcd, BOND_RANK_LABEL)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>이자지급방법</dt>
+							<dd>{codeLabel(master.intPayWayTpcd, BOND_INT_PAY_WAY_LABEL)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>옵션</dt>
+							<dd>{codeLabel(master.optionTpcd, BOND_OPTION_LABEL)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>특이종류</dt>
+							<dd>{codeLabel(master.particularBondKindTpcd, BOND_PARTICUL_KIND_LABEL)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>조기상환</dt>
+							<dd>{master.forcedEarlyRedemption === null ? '—' : master.forcedEarlyRedemption ? '있음' : '없음'}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>전자증권</dt>
+							<dd>{master.isElectronicSecurity === null ? '—' : master.isElectronicSecurity ? '전자' : '예탁'}</dd>
+						</div>
+					</dl>
+
+					{grades.length > 0 && (
+						<div className='market__grades'>
+							{grades.map(c => (
+								<div key={c.agency} className='market__grade'>
+									<span>{c.agency}</span>
+									<strong>{c.grade}</strong>
+								</div>
+							))}
+						</div>
+					)}
+				</div>
+			) : (
+				<div className='market__empty'>채권 발행 마스터 정보가 없습니다 (KRX 시세만 수집됨).</div>
+			)}
+
+			{/* 이자지급 정보 */}
+			{interest && (
+				<div className='market__spec-card'>
+					<h2 className='market__spec-card-title'>이자지급</h2>
+					<dl className='market__spec-grid'>
+						<div className='market__spec-item'>
+							<dt>표면이자율</dt>
+							<dd>{formatRatePercent(interest.couponRate, 3)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>지급방법</dt>
+							<dd>{codeLabel(interest.intPayWayTpcd, BOND_INT_PAY_WAY_LABEL)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>지급주기</dt>
+							<dd>{interest.cycleTerms ?? '—'}{interest.cycleTpcd ? codeLabel(interest.cycleTpcd, BOND_INT_CYCLE_LABEL) : ''}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>지급시기</dt>
+							<dd>{codeLabel(interest.payTimingTpcd, BOND_INT_TIMING_LABEL)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>직전 이표일</dt>
+							<dd>{dateOrDash(interest.beforeCouponDate)}</dd>
+						</div>
+						<div className='market__spec-item'>
+							<dt>차기 이표일</dt>
+							<dd>{dateOrDash(interest.afterCouponDate)}</dd>
+						</div>
+					</dl>
+				</div>
+			)}
+		</div>
+	);
+}

+ 64 - 0
app/(main)/market/dividends/page.tsx

@@ -0,0 +1,64 @@
+import '../style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import MarketNav from '../_components/MarketNav';
+import DividendCalendarTable from '../_components/DividendCalendarTable';
+import Pager from '../_components/Pager';
+import { fetchDividendCalendar } from '@/lib/api/seibro';
+import { formatNumber } from '@/lib/utils/stock';
+
+export const metadata: Metadata = {
+	title: '배당 캘린더 — 개미투자',
+	description: '국내 배당 일정 — 권리기준일·권리락일·배당구분(주식/현금)·시가배당율 (SEIBro 수집)'
+};
+
+const PER_PAGE = 30;
+
+type Props = {
+	searchParams: Promise<{
+		page?: string;
+	}>;
+};
+
+export default async function MarketDividendsPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await fetchDividendCalendar({ page, perPage: PER_PAGE });
+	const data = res.success ? res.data : null;
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>배당 캘린더</h1>
+				<span className='market__basis'>권리기준일 최신순 · 주식/현금 구분 (SEIBro)</span>
+			</div>
+
+			<MarketNav />
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>배당 일정을 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='market__empty'>표시할 배당 일정이 없습니다.</div>
+			) : (
+				<>
+					<p className='market__total'>전체 {formatNumber(data.total)}건</p>
+					<div className='market__table-wrap'>
+						<DividendCalendarTable rows={data.list} />
+					</div>
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/market/dividends'
+						query={{}}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

+ 67 - 0
app/(main)/market/els-risk/page.tsx

@@ -0,0 +1,67 @@
+import '../style.scss';
+import { Metadata } from 'next';
+import MarketNav from '../_components/MarketNav';
+import Sparkline from '../_components/Sparkline';
+import { fetchElsRisk } from '@/lib/api/seibro';
+import { codeLabel, formatAmountCompact, formatShares } from '@/lib/utils/market';
+import { ELS_SECN_TPCD_LABEL } from '@/types/seibro';
+
+export const metadata: Metadata = {
+	title: 'ELS 리스크보드 — 개미투자',
+	description: 'ELS/DLS 기초자산별 미상환잔액 추이 — 지수 하락 리스크 지표 (SEIBro 수집)'
+};
+
+export default async function MarketElsRiskPage()
+{
+	const res = await fetchElsRisk({});
+	const assets = res.success ? (res.data?.assets ?? []) : [];
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>ELS 리스크보드</h1>
+				<span className='market__basis'>기초자산별 미상환잔액 추이 · 지수 하락 리스크 (SEIBro)</span>
+			</div>
+
+			<MarketNav />
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>ELS 리스크 데이터를 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : assets.length === 0 ? (
+				<div className='market__empty'>표시할 ELS 리스크 데이터가 없습니다.</div>
+			) : (
+				<div className='market__series-grid'>
+					{assets.map((series, index) => {
+						const latest = series.points.length > 0 ? series.points[series.points.length - 1] : null;
+
+						return (
+							<div key={`${series.bassetCd}-${series.secnTpcd}-${index}`} className='market__series-card'>
+								<div className='market__series-head'>
+									<span className='market__series-name' title={series.bassetContent ?? series.bassetCd}>
+										{series.bassetContent ?? series.bassetCd}
+									</span>
+									<span className='market__cls-badge'>{codeLabel(series.secnTpcd, ELS_SECN_TPCD_LABEL)}</span>
+								</div>
+
+								<span className='market__series-latest'>{formatAmountCompact(latest?.unredeemedRemaining)}</span>
+
+								<Sparkline
+									values={series.points.map(p => p.unredeemedRemaining)}
+									label={`${series.bassetContent ?? series.bassetCd} 미상환잔액 추이`}
+								/>
+
+								<div className='market__series-sub'>
+									<span>미상환 {formatShares(latest?.unredeemedCount)}종목</span>
+									<span>{latest?.stdDt ?? '—'} 기준</span>
+								</div>
+							</div>
+						);
+					})}
+				</div>
+			)}
+		</div>
+	);
+}

+ 68 - 0
app/(main)/market/foreign/page.tsx

@@ -0,0 +1,68 @@
+import '../style.scss';
+import { Metadata } from 'next';
+import MarketNav from '../_components/MarketNav';
+import Sparkline from '../_components/Sparkline';
+import { fetchForeignCustody } from '@/lib/api/seibro';
+import { codeLabel, formatAmountCompact } from '@/lib/utils/market';
+import { NATION_LABEL, FOREIGN_SECN_TPCD_LABEL } from '@/types/seibro';
+
+export const metadata: Metadata = {
+	title: '외화 커스터디 — 개미투자',
+	description: '국가별 외화증권 보관금액 추이 — 서학개미 자금 흐름 (USD, SEIBro 수집)'
+};
+
+export default async function MarketForeignPage()
+{
+	const res = await fetchForeignCustody({});
+	const nations = res.success ? (res.data?.nations ?? []) : [];
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>외화 커스터디</h1>
+				<span className='market__basis'>국가별 외화증권 보관금액 추이 · USD 기준 (SEIBro)</span>
+			</div>
+
+			<MarketNav />
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>외화 커스터디 데이터를 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : nations.length === 0 ? (
+				<div className='market__empty'>표시할 외화 커스터디 데이터가 없습니다.</div>
+			) : (
+				<div className='market__series-grid'>
+					{nations.map((series, index) => {
+						const latest = series.points.length > 0 ? series.points[series.points.length - 1] : null;
+						const secnLabel = series.secnTpcd ? codeLabel(series.secnTpcd, FOREIGN_SECN_TPCD_LABEL) : '전체 합산';
+
+						return (
+							<div key={`${series.nationCd}-${series.secnTpcd ?? 'all'}-${index}`} className='market__series-card'>
+								<div className='market__series-head'>
+									<span className='market__series-name'>
+										{codeLabel(series.nationCd, NATION_LABEL)}
+									</span>
+									<span className='market__cls-badge'>{secnLabel}</span>
+								</div>
+
+								<span className='market__series-latest'>{formatAmountCompact(latest?.custodyAmount, ' USD')}</span>
+
+								<Sparkline
+									values={series.points.map(p => p.custodyAmount)}
+									label={`${codeLabel(series.nationCd, NATION_LABEL)} 보관금액 추이`}
+								/>
+
+								<div className='market__series-sub'>
+									<span>{series.nationCd}</span>
+									<span>{latest?.stdDt ?? '—'} 기준</span>
+								</div>
+							</div>
+						);
+					})}
+				</div>
+			)}
+		</div>
+	);
+}

+ 64 - 0
app/(main)/market/lockup/page.tsx

@@ -0,0 +1,64 @@
+import '../style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import MarketNav from '../_components/MarketNav';
+import LockupCalendarTable from '../_components/LockupCalendarTable';
+import Pager from '../_components/Pager';
+import { fetchLockupCalendar } from '@/lib/api/seibro';
+import { formatNumber } from '@/lib/utils/stock';
+
+export const metadata: Metadata = {
+	title: '락업(보호예수) 캘린더 — 개미투자',
+	description: '보호예수 예수·해제 일정 — 종목·해제일·수량·사유 (오버행 리스크 점검, SEIBro 수집)'
+};
+
+const PER_PAGE = 30;
+
+type Props = {
+	searchParams: Promise<{
+		page?: string;
+	}>;
+};
+
+export default async function MarketLockupPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await fetchLockupCalendar({ page, perPage: PER_PAGE });
+	const data = res.success ? res.data : null;
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>락업 (보호예수)</h1>
+				<span className='market__basis'>예수/반환 최신순 · 해제일·수량·사유 (SEIBro)</span>
+			</div>
+
+			<MarketNav />
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>락업 일정을 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='market__empty'>표시할 락업 일정이 없습니다.</div>
+			) : (
+				<>
+					<p className='market__total'>전체 {formatNumber(data.total)}건</p>
+					<div className='market__table-wrap'>
+						<LockupCalendarTable rows={data.list} />
+					</div>
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/market/lockup'
+						query={{}}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

+ 64 - 0
app/(main)/market/meetings/page.tsx

@@ -0,0 +1,64 @@
+import '../style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import MarketNav from '../_components/MarketNav';
+import MeetingCalendarTable from '../_components/MeetingCalendarTable';
+import Pager from '../_components/Pager';
+import { fetchMeetingCalendar } from '@/lib/api/seibro';
+import { formatNumber } from '@/lib/utils/stock';
+
+export const metadata: Metadata = {
+	title: '주총 캘린더 — 개미투자',
+	description: '주주총회 일정 — 총회일·종류(정기/임시)·전자투표·안건수 (SEIBro 수집)'
+};
+
+const PER_PAGE = 30;
+
+type Props = {
+	searchParams: Promise<{
+		page?: string;
+	}>;
+};
+
+export default async function MarketMeetingsPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await fetchMeetingCalendar({ page, perPage: PER_PAGE });
+	const data = res.success ? res.data : null;
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>주총 캘린더</h1>
+				<span className='market__basis'>총회일 최신순 · 전자투표·안건수 (SEIBro)</span>
+			</div>
+
+			<MarketNav />
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>주총 일정을 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='market__empty'>표시할 주총 일정이 없습니다.</div>
+			) : (
+				<>
+					<p className='market__total'>전체 {formatNumber(data.total)}건</p>
+					<div className='market__table-wrap'>
+						<MeetingCalendarTable rows={data.list} />
+					</div>
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/market/meetings'
+						query={{}}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

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

@@ -248,6 +248,163 @@
 		margin-bottom: var(--sp-3);
 		color: var(--text-secondary);
 	}
+
+	// ── 시계열 시리즈 보드 (ELS 리스크·외화 커스터디) ──
+	&__series-grid {
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+		gap: var(--sp-4);
+	}
+
+	&__series-card {
+		display: flex;
+		flex-direction: column;
+		gap: var(--sp-3);
+		padding: var(--sp-4);
+		border: 1px solid var(--border-default);
+		border-radius: 10px;
+		background: var(--bg-card);
+	}
+
+	&__series-head {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		gap: var(--sp-3);
+	}
+
+	&__series-name {
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		color: var(--text-primary);
+		overflow: hidden;
+		white-space: nowrap;
+		text-overflow: ellipsis;
+	}
+
+	&__series-latest {
+		font-size: var(--fs-lg);
+		font-weight: 700;
+		color: var(--text-primary);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__series-sub {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		gap: var(--sp-3);
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	// 미니 막대 스파크라인 (기준일순 값 추이 — 최대값 대비 비율 높이)
+	&__spark {
+		display: flex;
+		align-items: flex-end;
+		gap: 2px;
+		height: 48px;
+	}
+
+	&__spark-bar {
+		flex: 1 1 0;
+		min-width: 2px;
+		background: var(--az-accent);
+		border-radius: 1px 1px 0 0;
+		opacity: 0.85;
+
+		&:last-child {
+			opacity: 1;
+		}
+	}
+
+	&__spark-empty {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		height: 48px;
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	// ── 상세 스펙 (채권 상세 — 마스터/이자/시세 카드) ──
+	&__detail-back {
+		display: inline-block;
+		margin-bottom: var(--sp-4);
+		font-size: var(--fs-sm);
+		color: var(--text-link);
+		text-decoration: none;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	&__spec-card {
+		margin-bottom: var(--sp-5);
+		padding: var(--sp-5);
+		border: 1px solid var(--border-default);
+		border-radius: 10px;
+		background: var(--bg-card);
+	}
+
+	&__spec-card-title {
+		margin-bottom: var(--sp-4);
+		font-size: var(--fs-base);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__spec-grid {
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+		gap: var(--sp-3) var(--sp-5);
+	}
+
+	&__spec-item {
+		display: flex;
+		flex-direction: column;
+		gap: 2px;
+
+		dt {
+			font-size: var(--fs-xs);
+			color: var(--text-muted);
+		}
+
+		dd {
+			font-size: var(--fs-sm);
+			color: var(--text-primary);
+			font-variant-numeric: tabular-nums;
+		}
+	}
+
+	// 신용등급 4사 나열
+	&__grades {
+		display: flex;
+		flex-wrap: wrap;
+		gap: var(--sp-3);
+		margin-top: var(--sp-4);
+	}
+
+	&__grade {
+		display: inline-flex;
+		flex-direction: column;
+		gap: 2px;
+		padding: var(--sp-2) var(--sp-4);
+		border: 1px solid var(--border-default);
+		border-radius: 8px;
+
+		span:first-child {
+			font-size: var(--fs-2xs);
+			color: var(--text-muted);
+		}
+
+		strong {
+			font-size: var(--fs-sm);
+			color: var(--text-primary);
+		}
+	}
 }
 
 // ─────────────────────────────────────────────────────────────

+ 65 - 0
app/(main)/stock/[code]/_components/StockCbExercises.tsx

@@ -0,0 +1,65 @@
+'use client';
+
+import '@/app/styles/data-table.scss';
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { CbExerciseRow } from '@/types/seibro';
+import { formatDecimal, formatShares, dateOrDash } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<CbExerciseRow>[] {
+	return [
+		{
+			key: 'rgtStdDt',
+			header: '행사일',
+			align: 'left',
+			priority: 'high',
+			cell: (row) => row.rgtStdDt
+		},
+		{
+			key: 'bond',
+			header: '채권',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				<span className='stock-cell'>
+					<span className='stock-cell__name'>{row.bondName ?? row.bondIsin}</span>
+					{row.bondKind && <span className='market__cls-badge'>{row.bondKind}</span>}
+				</span>
+			)
+		},
+		{
+			key: 'exercisePrice',
+			header: '행사가',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => (row.exercisePrice === null ? '—' : `${formatDecimal(row.exercisePrice, 0)}원`)
+		},
+		{
+			key: 'exerciseQty',
+			header: '행사주수',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => (row.exerciseQty === null ? '—' : formatShares(Math.round(row.exerciseQty)))
+		},
+		{
+			key: 'listDt',
+			header: '상장일',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => dateOrDash(row.listDt)
+		}
+	];
+}
+
+export default function StockCbExercises({ rows }: { rows: CbExerciseRow[] })
+{
+	return (
+		<DataTable<CbExerciseRow>
+			caption='CB/BW 행사 내역 — 행사일, 채권, 행사가, 행사주수, 상장일'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row, index) => `${row.bondIsin}-${row.rgtStdDt}-${index}`}
+			emptyMessage='수집된 CB/BW 행사 내역이 없습니다.'
+		/>
+	);
+}

+ 73 - 0
app/(main)/stock/[code]/_components/StockDividends.tsx

@@ -0,0 +1,73 @@
+'use client';
+
+import '@/app/styles/data-table.scss';
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { StockDividendRow, DIVIDEND_FIX_LABEL } from '@/types/seibro';
+import { codeLabel, formatDecimal, formatRatePercent, dateOrDash } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<StockDividendRow>[] {
+	return [
+		{
+			key: 'rgtStdDt',
+			header: '권리기준일',
+			align: 'left',
+			priority: 'high',
+			cell: (row) => row.rgtStdDt
+		},
+		{
+			key: 'xrgtDt',
+			header: '권리락일',
+			align: 'center',
+			priority: 'medium',
+			cell: (row) => dateOrDash(row.xrgtDt)
+		},
+		{
+			key: 'cashPerShare',
+			header: '주당배당금',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => (row.cashPerShare === null ? '—' : `${formatDecimal(row.cashPerShare, 0)}원`)
+		},
+		{
+			key: 'marketDividendRate',
+			header: '시가배당율',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatRatePercent(row.marketDividendRate)
+		},
+		{
+			key: 'stockAllocRatio',
+			header: '주식배정비율',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => (row.stockAllocRatio === null ? '—' : formatDecimal(row.stockAllocRatio, 4))
+		},
+		{
+			key: 'cashPayDate',
+			header: '지급일',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => dateOrDash(row.cashPayDate)
+		},
+		{
+			key: 'fixType',
+			header: '확정',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => codeLabel(row.fixType, DIVIDEND_FIX_LABEL)
+		}
+	];
+}
+
+export default function StockDividends({ rows }: { rows: StockDividendRow[] })
+{
+	return (
+		<DataTable<StockDividendRow>
+			caption='배당 이력 — 권리기준일, 권리락일, 주당배당금, 시가배당율, 주식배정비율, 지급일, 확정'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row, index) => `${row.rgtStdDt}-${index}`}
+			emptyMessage='수집된 배당 이력이 없습니다.'
+		/>
+	);
+}

+ 90 - 0
app/(main)/stock/[code]/_components/StockLending.tsx

@@ -0,0 +1,90 @@
+'use client';
+
+import '@/app/styles/data-table.scss';
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import Sparkline from '../../../market/_components/Sparkline';
+import { LendingHistoryRow } from '@/types/seibro';
+import { formatShares, formatRatePercent } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<LendingHistoryRow>[] {
+	return [
+		{
+			key: 'stdDt',
+			header: '기준일',
+			align: 'left',
+			priority: 'high',
+			cell: (row) => row.stdDt
+		},
+		{
+			key: 'lendingBalanceQty',
+			header: '대차잔고',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatShares(row.lendingBalanceQty)
+		},
+		{
+			key: 'matchedQty',
+			header: '체결량',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => formatShares(row.matchedQty)
+		},
+		{
+			key: 'redeemedQty',
+			header: '상환량',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatShares(row.redeemedQty)
+		},
+		{
+			key: 'foreignLendRatio',
+			header: '외국인 대여비율',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatRatePercent(row.foreignLendRatio)
+		},
+		{
+			key: 'foreignBorrowRatio',
+			header: '외국인 차입비율',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatRatePercent(row.foreignBorrowRatio)
+		}
+	];
+}
+
+interface Props {
+	rows: LendingHistoryRow[];
+}
+
+// 대차 시계열 — 외국인 대여비율 추이(스파크라인, 오래된→최신) + 상세 테이블(최신순).
+export default function StockLending({ rows }: Props)
+{
+	if (rows.length === 0) {
+		return <p className='stock-detail__empty'>수집된 대차 시계열이 없습니다.</p>;
+	}
+
+	// rows 는 최신순 → 스파크라인은 오래된→최신 순서로 뒤집어 표시
+	const trend = [...rows].reverse().map(c => c.foreignLendRatio);
+	const latest = rows[0];
+
+	return (
+		<div className='stock-lending'>
+			<div className='stock-lending__trend'>
+				<div className='stock-lending__trend-head'>
+					<span className='stock-lending__trend-label'>외국인 대여잔고비율 추이</span>
+					<span className='stock-lending__trend-latest'>{formatRatePercent(latest.foreignLendRatio)}</span>
+				</div>
+				<Sparkline values={trend} label='외국인 대여잔고비율 추이' />
+			</div>
+
+			<DataTable<LendingHistoryRow>
+				caption='대차 시계열 — 기준일, 대차잔고, 체결량, 상환량, 외국인 대여비율, 외국인 차입비율'
+				columns={buildColumns()}
+				rows={rows}
+				rowKey={(row) => row.stdDt}
+				emptyMessage='수집된 대차 시계열이 없습니다.'
+			/>
+		</div>
+	);
+}

+ 69 - 0
app/(main)/stock/[code]/page.tsx

@@ -6,14 +6,21 @@ import Link from 'next/link';
 import { notFound } from 'next/navigation';
 import { fetchStockDetail, fetchStockHistory } from '@/lib/api/stock';
 import { fetchStockDisclosures } from '@/lib/api/market';
+import { fetchStockDividends, fetchStockLending, fetchStockCbExercises } from '@/lib/api/seibro';
 import { fetchStockBoardPosts } from '@/lib/api/community';
 import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
 import Pager from '../_components/Pager';
 import DisclosureList from '../../market/_components/DisclosureList';
 import StockDiscussion from './_components/StockDiscussion';
+import StockDividends from './_components/StockDividends';
+import StockLending from './_components/StockLending';
+import StockCbExercises from './_components/StockCbExercises';
 
 const TABS = [
 	{ value: 'price', label: '시세' },
+	{ value: 'dividends', label: '배당' },
+	{ value: 'lending', label: '대차' },
+	{ value: 'cb', label: '전환권' },
 	{ value: 'news', label: '뉴스' },
 	{ value: 'disclosure', label: '공시' },
 	{ value: 'discussion', label: '토론' }
@@ -21,6 +28,9 @@ const TABS = [
 
 const HISTORY_PER_PAGE = 30;
 const DISCUSSION_PER_PAGE = 20;
+const DIVIDENDS_PER_PAGE = 30;
+const LENDING_PER_PAGE = 90;
+const CB_PER_PAGE = 30;
 
 type Props = {
 	params: Promise<{
@@ -94,6 +104,11 @@ export default async function StockDetailPage({ params, searchParams }: Props)
 	// 토론 탭 — 종목 가상보드 글 목록 (request-time)
 	const discussion = tab === 'discussion' ? await fetchStockBoardPosts(code, { page, perPage: DISCUSSION_PER_PAGE }) : null;
 
+	// SEIBro 종목 탭 — 배당 이력 / 대차 시계열 / CB·BW 행사 (request-time)
+	const dividends = tab === 'dividends' ? await fetchStockDividends(code, { page, perPage: DIVIDENDS_PER_PAGE }) : null;
+	const lending = tab === 'lending' ? await fetchStockLending(code, { page, perPage: LENDING_PER_PAGE }) : null;
+	const cbExercises = tab === 'cb' ? await fetchStockCbExercises(code, { page, perPage: CB_PER_PAGE }) : null;
+
 	return (
 		<div id='stock-detail'>
 			{/* 상단 요약 — T+1 데이터임을 명시 */}
@@ -184,6 +199,60 @@ export default async function StockDetailPage({ params, searchParams }: Props)
 							data={discussion.data}
 						/>
 					)
+				) : tab === 'dividends' ? (
+					!dividends || !dividends.success || !dividends.data ? (
+						<div className='stock-detail__error' role='alert'>
+							<strong>배당 이력을 불러오지 못했습니다.</strong>
+							잠시 후 다시 시도해 주세요.
+						</div>
+					) : (
+						<>
+							<StockDividends rows={dividends.data.list} />
+							<Pager
+								total={dividends.data.total}
+								page={page}
+								perPage={DIVIDENDS_PER_PAGE}
+								basePath={`/stock/${detail.code}`}
+								query={{ tab: 'dividends' }}
+							/>
+						</>
+					)
+				) : tab === 'lending' ? (
+					!lending || !lending.success || !lending.data ? (
+						<div className='stock-detail__error' role='alert'>
+							<strong>대차 시계열을 불러오지 못했습니다.</strong>
+							잠시 후 다시 시도해 주세요.
+						</div>
+					) : (
+						<>
+							<StockLending rows={lending.data.list} />
+							<Pager
+								total={lending.data.total}
+								page={page}
+								perPage={LENDING_PER_PAGE}
+								basePath={`/stock/${detail.code}`}
+								query={{ tab: 'lending' }}
+							/>
+						</>
+					)
+				) : tab === 'cb' ? (
+					!cbExercises || !cbExercises.success || !cbExercises.data ? (
+						<div className='stock-detail__error' role='alert'>
+							<strong>전환권 행사 내역을 불러오지 못했습니다.</strong>
+							잠시 후 다시 시도해 주세요.
+						</div>
+					) : (
+						<>
+							<StockCbExercises rows={cbExercises.data.list} />
+							<Pager
+								total={cbExercises.data.total}
+								page={page}
+								perPage={CB_PER_PAGE}
+								basePath={`/stock/${detail.code}`}
+								query={{ tab: 'cb' }}
+							/>
+						</>
+					)
 				) : tab !== 'price' ? (
 					<p className='stock-detail__placeholder'>준비 중입니다.</p>
 				) : !history || !history.success || !history.data ? (

+ 29 - 0
app/(main)/stock/[code]/style.scss

@@ -178,6 +178,35 @@
 		}
 	}
 
+	// 대차 시계열 — 외국인 대여비율 추이 스파크라인 블록
+	.stock-lending__trend {
+		margin-bottom: var(--sp-5);
+		padding: var(--sp-4);
+		border: 1px solid var(--border-default);
+		border-radius: 10px;
+		background: var(--bg-card);
+	}
+
+	.stock-lending__trend-head {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		gap: var(--sp-3);
+		margin-bottom: var(--sp-3);
+	}
+
+	.stock-lending__trend-label {
+		font-size: var(--fs-sm);
+		color: var(--text-secondary);
+	}
+
+	.stock-lending__trend-latest {
+		font-size: var(--fs-lg);
+		font-weight: 700;
+		color: var(--text-primary);
+		font-variant-numeric: tabular-nums;
+	}
+
 	// 준비 중 placeholder / 에러 / 빈 데이터
 	.stock-detail__placeholder,
 	.stock-detail__empty,

+ 156 - 0
lib/api/seibro.ts

@@ -0,0 +1,156 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import {
+	DividendCalendarResponse,
+	LockupCalendarResponse,
+	MeetingCalendarResponse,
+	ElsRiskResponse,
+	ForeignCustodyResponse,
+	StockDividendsResponse,
+	LendingHistoryResponse,
+	CbExercisesResponse,
+	BondDetailResponse
+} from '@/types/seibro';
+import { fetchJson } from '@/lib/utils/server';
+
+// 공통 헤더 (익명 · JSON)
+const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+
+// 배당 캘린더 (권리기준일 from/to 기간 · 페이징)
+export async function fetchDividendCalendar(params: { from?: string; to?: string; page?: number; perPage?: number }): Promise<ResultDto<DividendCalendarResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.from) queryParams.set('from', params.from);
+	if (params.to) queryParams.set('to', params.to);
+	queryParams.set('page', String(params.page ?? 1));
+	if (params.perPage) queryParams.set('perPage', String(params.perPage));
+
+	return await fetchJson<DividendCalendarResponse>(`/api/market/dividend-calendar?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 락업(보호예수) 캘린더 (예수일/반환일 from/to 기간 · 페이징)
+export async function fetchLockupCalendar(params: { from?: string; to?: string; page?: number; perPage?: number }): Promise<ResultDto<LockupCalendarResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.from) queryParams.set('from', params.from);
+	if (params.to) queryParams.set('to', params.to);
+	queryParams.set('page', String(params.page ?? 1));
+	if (params.perPage) queryParams.set('perPage', String(params.perPage));
+
+	return await fetchJson<LockupCalendarResponse>(`/api/market/lockup-calendar?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 주총 캘린더 (총회일 from/to 기간 · 페이징)
+export async function fetchMeetingCalendar(params: { from?: string; to?: string; page?: number; perPage?: number }): Promise<ResultDto<MeetingCalendarResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.from) queryParams.set('from', params.from);
+	if (params.to) queryParams.set('to', params.to);
+	queryParams.set('page', String(params.page ?? 1));
+	if (params.perPage) queryParams.set('perPage', String(params.perPage));
+
+	return await fetchJson<MeetingCalendarResponse>(`/api/market/meeting-calendar?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// ELS 리스크보드 (기초자산·종목구분·기간 필터 — 기초자산별 시계열 묶음)
+export async function fetchElsRisk(params: { bassetCd?: string; secnTpcd?: string; from?: string; to?: string }): Promise<ResultDto<ElsRiskResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.bassetCd) queryParams.set('bassetCd', params.bassetCd);
+	if (params.secnTpcd) queryParams.set('secnTpcd', params.secnTpcd);
+	if (params.from) queryParams.set('from', params.from);
+	if (params.to) queryParams.set('to', params.to);
+
+	const qs = queryParams.toString();
+
+	return await fetchJson<ElsRiskResponse>(`/api/market/els-risk${qs ? `?${qs}` : ''}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 외화 커스터디 (국가·종목구분·기간 필터 — 국가별 시계열 묶음)
+export async function fetchForeignCustody(params: { nation?: string; secnTpcd?: string; from?: string; to?: string }): Promise<ResultDto<ForeignCustodyResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.nation) queryParams.set('nation', params.nation);
+	if (params.secnTpcd) queryParams.set('secnTpcd', params.secnTpcd);
+	if (params.from) queryParams.set('from', params.from);
+	if (params.to) queryParams.set('to', params.to);
+
+	const qs = queryParams.toString();
+
+	return await fetchJson<ForeignCustodyResponse>(`/api/market/foreign-custody${qs ? `?${qs}` : ''}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 종목 배당 이력 (6자리 단축코드 · 페이징)
+export async function fetchStockDividends(code: string, params: { page?: number; perPage?: number }): Promise<ResultDto<StockDividendsResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	queryParams.set('page', String(params.page ?? 1));
+	if (params.perPage) queryParams.set('perPage', String(params.perPage));
+
+	return await fetchJson<StockDividendsResponse>(`/api/stocks/${encodeURIComponent(code)}/dividends?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 종목 대차 시계열 (6자리 단축코드 · 기간 · 페이징)
+export async function fetchStockLending(code: string, params: { from?: string; to?: string; page?: number; perPage?: number }): Promise<ResultDto<LendingHistoryResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.from) queryParams.set('from', params.from);
+	if (params.to) queryParams.set('to', params.to);
+	queryParams.set('page', String(params.page ?? 1));
+	if (params.perPage) queryParams.set('perPage', String(params.perPage));
+
+	return await fetchJson<LendingHistoryResponse>(`/api/stocks/${encodeURIComponent(code)}/lending?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 종목 CB/BW 행사 내역 (6자리 단축코드 · byBond · 페이징)
+export async function fetchStockCbExercises(code: string, params: { byBond?: boolean; page?: number; perPage?: number }): Promise<ResultDto<CbExercisesResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.byBond) queryParams.set('byBond', 'true');
+	queryParams.set('page', String(params.page ?? 1));
+	if (params.perPage) queryParams.set('perPage', String(params.perPage));
+
+	return await fetchJson<CbExercisesResponse>(`/api/stocks/${encodeURIComponent(code)}/cb-exercises?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 채권 상세 (12자리 ISIN)
+export async function fetchBondDetail(isin: string): Promise<ResultDto<BondDetailResponse>>
+{
+	return await fetchJson<BondDetailResponse>(`/api/stocks/bonds/${encodeURIComponent(isin)}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}

+ 60 - 0
lib/utils/market.ts

@@ -39,3 +39,63 @@ export function tradeDateLabel(date: string|null|undefined): string {
 export function dartViewerUrl(rceptNo: string): string {
 	return `https://dart.fss.or.kr/dsaf001/main.do?rcpNo=${encodeURIComponent(rceptNo)}`;
 }
+
+// SEIBro 코드 → 라벨 조회 (미매칭 시 원문 코드, 값 없으면 —)
+export function codeLabel(code: string|null|undefined, map: Record<string, string>): string {
+	if (code === null || code === undefined || code === '') {
+		return '—';
+	}
+
+	return map[code] ?? code;
+}
+
+// 주식수 등 정수 수량 (null 은 —). 예: 12345678 → "12,345,678"
+export function formatShares(value: number|null|undefined): string {
+	if (value === null || value === undefined) {
+		return '—';
+	}
+
+	return value.toLocaleString('ko-KR');
+}
+
+// 큰 금액(원/USD) → 조/억/만 축약 (외화커스터디·ELS 미상환·채권 발행액). null·0 은 —
+export function formatAmountCompact(value: number|null|undefined, unit = '원'): string {
+	if (!value) {
+		return '—';
+	}
+
+	const eok = value / 100_000_000;
+
+	if (eok >= 10_000) {
+		const jo = Math.floor(eok / 10_000);
+		const rest = Math.floor(eok % 10_000);
+		return rest > 0 ? `${jo.toLocaleString('ko-KR')}조 ${rest.toLocaleString('ko-KR')}억${unit}` : `${jo.toLocaleString('ko-KR')}조${unit}`;
+	}
+
+	if (eok >= 1) {
+		return `${Math.floor(eok).toLocaleString('ko-KR')}억${unit}`;
+	}
+
+	const man = value / 10_000;
+
+	if (man >= 1) {
+		return `${Math.floor(man).toLocaleString('ko-KR')}만${unit}`;
+	}
+
+	return `${value.toLocaleString('ko-KR')}${unit}`;
+}
+
+// yyyy-MM-dd 없으면 대체문구
+export function dateOrDash(date: string|null|undefined): string {
+	return date && date.length > 0 ? date : '—';
+}
+
+// 총회시각 HHmm → "HH:mm" (없으면 빈 문자열)
+export function meetingTimeLabel(hhmm: string|null|undefined): string {
+	if (!hhmm || hhmm.length < 3) {
+		return '';
+	}
+
+	const padded = hhmm.padStart(4, '0');
+	return `${padded.slice(0, 2)}:${padded.slice(2, 4)}`;
+}

+ 361 - 0
types/seibro.ts

@@ -0,0 +1,361 @@
+// SEIBro 데이터 도메인 타입 — Backend Application/Features/Api/Stocks/{GetDividendCalendar,GetStockDividends,
+//   GetLendingHistory,GetLockupCalendar,GetMeetingCalendar,GetCbExercises,GetBondDetail,GetElsRiskBoard,
+//   GetForeignCustody}/Response.cs 대응.
+// 직렬화 주의: 필드명은 camelCase. Web.Api 는 JsonStringEnumConverter 미등록이나, 아래 응답들의 코드값은
+//   전부 C# string(원문 코드 문자열)이므로 문자열로 옴 → 라벨맵으로 표시.
+// DateOnly 는 "yyyy-MM-dd" 문자열, decimal 은 number 로 직렬화됨.
+
+// ── 코드 라벨맵 (SEIBro 원문 코드 문자열 → 한글 라벨) ──
+
+// 배당 세부유형 (SORT_CODE) — 01주식 02현금 03동시 04무배당
+export const DIVIDEND_SORT_LABEL: Record<string, string> = {
+	'01': '주식',
+	'02': '현금',
+	'03': '동시',
+	'04': '무배당'
+};
+
+// 확정구분 (FIX_TYPE / FIX_TPCD) — 1확정 2예고 3미정
+export const DIVIDEND_FIX_LABEL: Record<string, string> = {
+	'1': '확정',
+	'2': '예고',
+	'3': '미정'
+};
+
+// 결산구분 (SETTLE_TYPE) — 1결산 2반기 3분기
+export const DIVIDEND_SETTLE_LABEL: Record<string, string> = {
+	'1': '결산',
+	'2': '반기',
+	'3': '분기'
+};
+
+// 락업 업무구분 (OCCR_SEQ) — 1예수 2반환
+export const LOCKUP_OCCR_LABEL: Record<string, string> = {
+	'1': '예수',
+	'2': '반환'
+};
+
+// 보호예수 사유코드 (DUTY_SAFEDP_RACD) — 주요 사유 (미매칭 시 원문 코드 노출)
+export const LOCKUP_REASON_LABEL: Record<string, string> = {
+	'01': '유상증자',
+	'02': '무상증자',
+	'03': '기업공개(IPO)',
+	'04': '주식분할',
+	'05': '합병',
+	'06': '주식교환',
+	'07': '전환사채',
+	'08': '신주인수권',
+	'09': '스톡옵션',
+	'99': '기타'
+};
+
+// 주총 종류코드 (GMET_KACD) — 001정기 002임시 003종류 009기타
+export const MEETING_KIND_LABEL: Record<string, string> = {
+	'001': '정기',
+	'002': '임시',
+	'003': '종류',
+	'009': '기타'
+};
+
+// ELS/DLS 종목구분코드 (SECN_TPCD)
+export const ELS_SECN_TPCD_LABEL: Record<string, string> = {
+	'41': 'ELS',
+	'43': 'DLS'
+};
+
+// 외화증권 종목구분코드 (SECN_TPCD) — 71외화주식~78 (합산은 null)
+export const FOREIGN_SECN_TPCD_LABEL: Record<string, string> = {
+	'71': '외화주식',
+	'72': '외화채권',
+	'73': '외화수익증권',
+	'74': '외화ETF',
+	'75': '외화DR',
+	'76': '외화ETN',
+	'77': '외화워런트',
+	'78': '기타 외화증권'
+};
+
+// 국가코드 (NATION_CD) — 주요국 (미매칭 시 원문 코드 노출)
+export const NATION_LABEL: Record<string, string> = {
+	US: '미국',
+	JP: '일본',
+	HK: '홍콩',
+	CN: '중국',
+	GB: '영국',
+	DE: '독일',
+	FR: '프랑스',
+	VN: '베트남',
+	SG: '싱가포르',
+	CH: '스위스',
+	E1: '유럽(유로존)'
+};
+
+// 채권 옵션구분코드 (OPTION_TPCD) — 9401CALL 9402PUT 9403CALL+PUT 9404NOTE 0000해당없음
+export const BOND_OPTION_LABEL: Record<string, string> = {
+	'9401': 'Call',
+	'9402': 'Put',
+	'9403': 'Call+Put',
+	'9404': 'Note',
+	'0000': '해당없음'
+};
+
+// 채권 금리변동구분코드 (MR_CHG_TPCD) — 1고정 2변동 3고정+변동
+export const BOND_RATE_CHG_LABEL: Record<string, string> = {
+	'1': '고정금리',
+	'2': '변동금리',
+	'3': '고정+변동'
+};
+
+// 채권 보증구분코드 (GRTY_TPCD) — 1보증 2무보증 3담보부 4일반
+export const BOND_GUARANTEE_LABEL: Record<string, string> = {
+	'1': '보증',
+	'2': '무보증',
+	'3': '담보부',
+	'4': '일반'
+};
+
+// 채권 순위구분코드 (RANK_TPCD) — 1선순위 2후순위 3중순위 9해당없음
+export const BOND_RANK_LABEL: Record<string, string> = {
+	'1': '선순위',
+	'2': '후순위',
+	'3': '중순위',
+	'9': '해당없음'
+};
+
+// 채권 이자지급방법구분코드 (INT_PAY_WAY_TPCD) — 1이표 2할인 3복리 4단리
+export const BOND_INT_PAY_WAY_LABEL: Record<string, string> = {
+	'1': '이표채',
+	'2': '할인채',
+	'3': '복리채',
+	'4': '단리채'
+};
+
+// 채권 이자지급주기구분코드 (INT_PAY_CYCLE_TPCD) — 23개월 24분기 25반기 26년
+export const BOND_INT_CYCLE_LABEL: Record<string, string> = {
+	'23': '월',
+	'24': '분기',
+	'25': '반기',
+	'26': '년'
+};
+
+// 채권 이자지급시기구분코드 (INT_PAY_TIMS_TPCD) — 01후급 02선급
+export const BOND_INT_TIMING_LABEL: Record<string, string> = {
+	'01': '후급',
+	'02': '선급'
+};
+
+// 특이채권종류구분코드 (PARTICUL_BOND_KIND_TPCD) — 1전환 2교환 3신주인수권 4분리형신주인수권 6이익참가 9해당없음
+export const BOND_PARTICUL_KIND_LABEL: Record<string, string> = {
+	'1': '전환사채(CB)',
+	'2': '교환사채(EB)',
+	'3': '신주인수권부사채(BW)',
+	'4': '분리형 신주인수권부사채',
+	'6': '이익참가부사채',
+	'9': '해당없음'
+};
+
+// 종목종류코드 (SECN_KACD) — 대표값 (미매칭 시 원문 코드 노출)
+export const BOND_SECN_KACD_LABEL: Record<string, string> = {
+	'1108': '일반회사채',
+	'1105': '국채',
+	'1106': '지방채',
+	'1107': '특수채',
+	'1110': '금융채'
+};
+
+// ── GET /api/market/dividend-calendar ──
+export interface DividendCalendarRow {
+	issucoCustno: number;
+	name: string;
+	rgtStdDt: string;
+	xrgtDt: string|null;
+	sortCode: string;
+	fixType: string|null;
+	settleType: string|null;
+}
+
+export interface DividendCalendarResponse {
+	total: number;
+	list: DividendCalendarRow[];
+}
+
+// ── GET /api/market/lockup-calendar ──
+export interface LockupCalendarRow {
+	issucoCustno: number;
+	shortCode: string;
+	name: string;
+	occrSeq: string;
+	safedpDt: string;
+	safedpQty: number|null;
+	reasonCode: string;
+	returnDt: string|null;
+	returnQty: number|null;
+	totalStkCnt: number|null;
+}
+
+export interface LockupCalendarResponse {
+	total: number;
+	list: LockupCalendarRow[];
+}
+
+// ── GET /api/market/meeting-calendar ──
+export interface MeetingCalendarRow {
+	issucoCustno: number;
+	name: string|null;
+	code: string|null;
+	rgtStdDt: string;
+	gmetDt: string|null;
+	gmetTtm: string|null;
+	meetingKind: string;
+	place: string|null;
+	evote: boolean|null;
+	deferred: boolean|null;
+	agendaCount: number;
+}
+
+export interface MeetingCalendarResponse {
+	total: number;
+	list: MeetingCalendarRow[];
+}
+
+// ── GET /api/market/els-risk ──
+export interface ElsRiskPoint {
+	stdDt: string;
+	unredeemedCount: number|null;
+	unredeemedRemaining: number|null;
+}
+
+export interface ElsRiskSeries {
+	bassetCd: string;
+	bassetContent: string|null;
+	secnTpcd: string;
+	points: ElsRiskPoint[];
+}
+
+export interface ElsRiskResponse {
+	assets: ElsRiskSeries[];
+}
+
+// ── GET /api/market/foreign-custody ──
+export interface ForeignCustodyPoint {
+	stdDt: string;
+	custodyAmount: number;
+}
+
+export interface ForeignCustodySeries {
+	nationCd: string;
+	secnTpcd: string|null;
+	points: ForeignCustodyPoint[];
+}
+
+export interface ForeignCustodyResponse {
+	nations: ForeignCustodySeries[];
+}
+
+// ── GET /api/stocks/{code}/dividends ──
+export interface StockDividendRow {
+	rgtStdDt: string;
+	name: string;
+	securityKind: string|null;
+	faceValue: number|null;
+	cashPerShare: number|null;
+	stockAllocRatio: number|null;
+	marketDividendRate: number|null;
+	cashPayDate: string|null;
+	xrgtDt: string|null;
+	fixType: string|null;
+}
+
+export interface StockDividendsResponse {
+	isin: string;
+	total: number;
+	list: StockDividendRow[];
+}
+
+// ── GET /api/stocks/{code}/lending ──
+export interface LendingHistoryRow {
+	stdDt: string;
+	lendingBalanceQty: number|null;
+	matchedQty: number|null;
+	redeemedQty: number|null;
+	tradeQty: number|null;
+	foreignLendRatio: number|null;
+	nativeLendRatio: number|null;
+	foreignBorrowRatio: number|null;
+	nativeBorrowRatio: number|null;
+}
+
+export interface LendingHistoryResponse {
+	isin: string;
+	total: number;
+	list: LendingHistoryRow[];
+}
+
+// ── GET /api/stocks/{code}/cb-exercises ──
+export interface CbExerciseRow {
+	bondIsin: string;
+	bondName: string|null;
+	bondKind: string|null;
+	exerciseStockIsin: string|null;
+	exerciseStockName: string|null;
+	rgtStdDt: string;
+	exercisePrice: number|null;
+	exerciseQty: number|null;
+	exerciseAmt: number|null;
+	listDt: string|null;
+}
+
+export interface CbExercisesResponse {
+	isin: string;
+	total: number;
+	list: CbExerciseRow[];
+}
+
+// ── GET /api/stocks/bonds/{isin} ──
+export interface BondDetailMaster {
+	name: string|null;
+	issucoCustno: number|null;
+	secnKacd: string|null;
+	issueDate: string|null;
+	maturityDate: string|null;
+	currency: string|null;
+	firstIssueAmount: number|null;
+	issueRemaining: number|null;
+	couponRate: number|null;
+	optionTpcd: string|null;
+	forcedEarlyRedemption: boolean|null;
+	rateChangeTpcd: string|null;
+	guaranteeTpcd: string|null;
+	rankTpcd: string|null;
+	intPayWayTpcd: string|null;
+	particularBondKindTpcd: string|null;
+	kisGrade: string|null;
+	niceGrade: string|null;
+	sciGrade: string|null;
+	krGrade: string|null;
+	isElectronicSecurity: boolean|null;
+}
+
+export interface BondDetailInterest {
+	couponRate: number|null;
+	intPayWayTpcd: string|null;
+	cycleTerms: number|null;
+	cycleTpcd: string|null;
+	payTimingTpcd: string|null;
+	beforeCouponDate: string|null;
+	afterCouponDate: string|null;
+}
+
+export interface BondDetailMarket {
+	tradeDate: string;
+	close: number;
+	yieldToMaturity: number|null;
+	volume: number;
+}
+
+export interface BondDetailResponse {
+	isin: string;
+	master: BondDetailMaster|null;
+	interest: BondDetailInterest|null;
+	market: BondDetailMarket|null;
+}