"] 시 한글 label 포함 전체 키 제안."""
+ return list(self._data.keys()) + list(self._labelToKey.keys())
+
+ # ── 표시 ──
+
+ def __repr__(self) -> str:
+ from dartlab.review.catalog import getBlockMeta, listSections
+
+ lines = []
+ bySection: dict[str, list[str]] = {}
+ for key in self._data:
+ meta = getBlockMeta(key)
+ sec = meta.section if meta else "기타"
+ bySection.setdefault(sec, []).append(key)
+
+ for sec in listSections():
+ blockKeys = bySection.get(sec.key, [])
+ if not blockKeys:
+ continue
+ lines.append(f"\n [{sec.key}] {sec.title}\n")
+ for key in blockKeys:
+ meta = getBlockMeta(key)
+ label = meta.label if meta else key
+ hasData = bool(self._data.get(key))
+ marker = " " if hasData else "x "
+ lines.append(f" {marker}{key:25s} {label}\n")
+
+ header = f"BlockMap ({len(self._data)} blocks)\n"
+ return header + "".join(lines)
+
+ def _repr_html_(self) -> str:
+ """Jupyter HTML 렌더링."""
+ from dartlab.review.catalog import getBlockMeta, listSections
+
+ rows = []
+ for sec in listSections():
+ rows.append(
+ f'| {sec.key} -- {sec.title} |
'
+ )
+ for key in self._data:
+ meta = getBlockMeta(key)
+ if not meta or meta.section != sec.key:
+ continue
+ hasData = bool(self._data.get(key))
+ color = "#333" if hasData else "#ccc"
+ rows.append(
+ f""
+ f"{key} | "
+ f"{meta.label} | "
+ f"{meta.description} |
"
+ )
+
+ return (
+ ""
+ "| key | label | description |
"
+ "" + "".join(rows) + "
"
+ )
diff --git a/src/dartlab/review/blocks.py b/src/dartlab/review/blocks.py
new file mode 100644
index 0000000000000000000000000000000000000000..ee6c9c345f1ef45954884374b2d69240bc377f49
--- /dev/null
+++ b/src/dartlab/review/blocks.py
@@ -0,0 +1,96 @@
+"""review 블록 타입."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class TextBlock:
+ """서술형 텍스트 블록."""
+
+ text: str
+ style: str = ""
+ indent: str = "body" # "body" (6칸) | "h2" (3칸)
+ emphasized: bool = False
+
+
+@dataclass
+class HeadingBlock:
+ """섹션 제목 블록."""
+
+ title: str
+ level: int = 1
+ helper: str = "" # 이 소제목에서 봐야 할 것
+ emphasized: bool = False
+
+ @property
+ def htmlTag(self) -> str:
+ """HTML 태그명 (h3 또는 h4)."""
+ return "h3" if self.level == 1 else "h4"
+
+ @property
+ def markdownPrefix(self) -> str:
+ """마크다운 heading prefix (### 또는 ####)."""
+ return "###" if self.level == 1 else "####"
+
+
+@dataclass
+class TableBlock:
+ """Polars DataFrame 테이블 블록."""
+
+ label: str
+ df: object # pl.DataFrame
+ caption: str = ""
+ emphasized: bool = False
+
+
+@dataclass
+class EnrichedFlag:
+ """정밀도·기저율 등 진단 메타를 포함하는 구조화된 플래그."""
+
+ code: str # "BENEISH_MANIPULATOR", "ALTMAN_DISTRESS" 등
+ message: str # 사용자 표시 메시지
+ precision: float | None = None # 모델 정밀도 (0~1)
+ baseRate: str = "" # 모델 학습 표본 설명
+ reference: str = "" # 학술 출처
+ sectorNote: str = "" # 업종별 주의사항
+
+
+@dataclass
+class FlagBlock:
+ """경고/기회 플래그 블록."""
+
+ flags: list[str]
+ kind: str = "warning" # warning | opportunity
+ enrichedFlags: list[EnrichedFlag] | None = None # 구조화된 플래그 (하위호환)
+ emphasized: bool = False
+
+ @property
+ def icon(self) -> str:
+ """플래그 아이콘 (warning=⚠, opportunity=✦)."""
+ return "\u26a0" if self.kind == "warning" else "\u2726"
+
+
+@dataclass
+class MetricBlock:
+ """핵심 지표 블록 (라벨: 값 형태)."""
+
+ metrics: list[tuple[str, str]] # [(라벨, 값), ...]
+ emphasized: bool = False
+
+
+@dataclass
+class ChartBlock:
+ """차트 시각화 블록.
+
+ spec은 ChartSpec JSON dict (VizSpec 호환).
+ Svelte ChartRenderer가 직접 소비하는 형식.
+ """
+
+ spec: dict
+ caption: str = ""
+ emphasized: bool = False
+
+
+Block = TextBlock | HeadingBlock | TableBlock | FlagBlock | MetricBlock | ChartBlock
diff --git a/src/dartlab/review/builders.py b/src/dartlab/review/builders.py
new file mode 100644
index 0000000000000000000000000000000000000000..64bee06f487e8b27f091b692671883ec6360fb9e
--- /dev/null
+++ b/src/dartlab/review/builders.py
@@ -0,0 +1,4428 @@
+"""review 블록 빌더 — calc* 결과(dict) → Block 리스트 변환.
+
+analysis.financial 의 calc* 함수는 dict/숫자만 반환한다.
+여기서 그 dict를 Block으로 조립한다.
+"""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.review.blocks import (
+ FlagBlock,
+ HeadingBlock,
+ MetricBlock,
+ TableBlock,
+ TextBlock,
+)
+from dartlab.review.catalog import getBlockMeta as _meta
+from dartlab.review.narrate import (
+ narrateCashFlow,
+ narrateCashQuality,
+ narrateConcentration,
+ narrateDistress,
+ narrateGrowth,
+ narrateLeverage,
+ narrateMargin,
+ narrateROIC,
+ narrateStrategy,
+ narrateTechnicalVerdict,
+ narrateValuation,
+)
+from dartlab.review.utils import unifyTableScale
+
+# ── notes enrichment 렌더링 ──
+
+
+def _notesDetailBlocks(data: dict, keyLabels: dict[str, str]) -> list:
+ """notesDetail enrichment → TextBlock + TableBlock 리스트.
+
+ calc 함수가 notesDetail 필드를 반환했을 때, 주석 테이블로 렌더링.
+ notes accessor 는 원 단위로 노출되므로(`tableBuilder.buildTableDf`에서 정규화)
+ 추가 단위 변환 불필요.
+
+ all-null row 제거 — parser 가 sub-row 헤더 (예: "OAT Nego", "Banker's Usance"
+ 등) 를 데이터 row 로 추출한 노이즈를 차단한다.
+ """
+ notesDetail = data.get("notesDetail")
+ if not notesDetail:
+ return []
+ blocks: list = []
+ for key, rows in notesDetail.items():
+ if not rows:
+ continue
+ # all-null row (항목/snakeId 외 모든 값 None) 제거
+ cleaned = []
+ for row in rows:
+ hasValue = any(
+ v is not None and v != ""
+ for k, v in row.items()
+ if k not in ("항목", "snakeId", "account", "tag", "label")
+ )
+ if hasValue:
+ cleaned.append(row)
+ if not cleaned:
+ continue
+ label = keyLabels.get(key, key)
+ try:
+ blocks.append(TextBlock(f"▸ 주석: {label}", style="dim", indent="h2"))
+ blocks.append(TableBlock("", pl.DataFrame(cleaned)))
+ except (ValueError, TypeError):
+ pass
+ return blocks
+
+
+# ── 수익구조 (revenue) 빌더 ──
+
+
+def profileBlock(data: dict) -> list:
+ """calcCompanyProfile 결과 → TextBlock."""
+ if not data:
+ return []
+ parts = []
+ if "sector" in data:
+ parts.append(data["sector"])
+ if "products" in data:
+ parts.append(data["products"])
+ if not parts:
+ return []
+ return [TextBlock(" | ".join(parts), style="dim", indent="h2")]
+
+
+def segmentCompositionBlock(data: dict) -> list:
+ """calcSegmentComposition 결과 → HeadingBlock + TableBlock."""
+ if not data:
+ return []
+ segments = data.get("segments", [])
+ if not segments:
+ return []
+
+ totalRev = data["totalRevenue"]
+ hasOp = data.get("hasOpIncome", False)
+
+ rows = []
+ for seg in segments:
+ rev = seg["revenue"]
+ pct = rev / totalRev * 100 if totalRev else 0
+ row = {"부문": seg["name"], "매출": rev, "비중": f"{pct:.0f}%"}
+ if hasOp and seg.get("opIncome") is not None:
+ row["영업이익"] = seg["opIncome"]
+ margin = seg.get("opMargin")
+ row["이익률"] = f"{margin:.1f}%" if margin is not None else "-"
+ rows.append(row)
+
+ valueCols = ["매출"]
+ if hasOp:
+ valueCols.append("영업이익")
+
+ unified = unifyTableScale(rows, "부문", valueCols, unit="millions")
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("segmentComposition").label,
+ level=2,
+ helper="매출 비중 + 이익률로 수익 구조 편중을 본다",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(unified)))
+
+ # 다년간 비중 변화 테이블
+ history = data.get("compositionHistory")
+ if history and len(history) >= 2:
+ # {year, shares: {seg: pct}} → 부문×연도 테이블
+ allSegs = []
+ for h in history:
+ for s in h["shares"]:
+ if s not in allSegs:
+ allSegs.append(s)
+ [h["year"] for h in history]
+ histRows = []
+ for seg in allSegs:
+ row: dict = {"부문": seg}
+ for h in history:
+ row[h["year"]] = f"{h['shares'].get(seg, 0):.1f}%"
+ histRows.append(row)
+ blocks.append(TableBlock("비중 변화", pl.DataFrame(histRows)))
+
+ return blocks
+
+
+def segmentTrendBlock(data: dict) -> list:
+ """calcSegmentTrend 결과 → HeadingBlock + TableBlock."""
+ if not data:
+ return []
+ yearCols = data.get("yearCols", [])
+ trendRows = data.get("rows", [])
+ if not yearCols or not trendRows:
+ return []
+
+ rows = []
+ for tr in trendRows:
+ row: dict = {"부문": tr["name"]}
+ for yc in yearCols:
+ row[yc] = tr["values"].get(yc)
+ if tr.get("yoy") is not None:
+ row["YoY"] = f"{tr['yoy']:+.0f}%"
+ else:
+ row["YoY"] = "-"
+ rows.append(row)
+
+ unified = unifyTableScale(rows, "부문", yearCols, unit="millions")
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("segmentTrend").label,
+ level=2,
+ helper="부문별 성장/정체를 연도 비교로 식별",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(unified)))
+ return blocks
+
+
+def breakdownBlock(data: dict, sub: str) -> list:
+ """calcBreakdown 결과 → HeadingBlock + TableBlock."""
+ if not data:
+ return []
+ items = data.get("items", [])
+ if not items:
+ return []
+
+ meta = _meta(sub)
+ title = meta.label if meta else f"{sub}별 매출"
+
+ rows = []
+ for item in items:
+ rows.append(
+ {
+ "구분": item["name"],
+ "매출": item["value"],
+ "비중": f"{item['pct']:.0f}%",
+ }
+ )
+
+ unified = unifyTableScale(rows, "구분", ["매출"], unit="millions")
+ blocks: list = []
+ blocks.append(HeadingBlock(title, level=2))
+ blocks.append(TableBlock("", pl.DataFrame(unified)))
+
+ # 다년간 비중 변화
+ history = data.get("breakdownHistory")
+ if history and len(history) >= 2:
+ allNames: list[str] = []
+ for h in history:
+ for n in h["shares"]:
+ if n not in allNames:
+ allNames.append(n)
+ histRows = []
+ for name in allNames:
+ row: dict = {"구분": name}
+ for h in history:
+ row[h["year"]] = f"{h['shares'].get(name, 0):.1f}%"
+ histRows.append(row)
+ blocks.append(TableBlock("비중 변화", pl.DataFrame(histRows)))
+
+ return blocks
+
+
+def revenueGrowthBlock(data: dict) -> list:
+ """calcRevenueGrowth 결과 → MetricBlock + 분기 매출 TableBlock."""
+ if not data:
+ return []
+
+ blocks: list = []
+ metrics = []
+ yoy = data.get("yoy")
+ cagr = data.get("cagr3y")
+ if yoy is not None:
+ metrics.append(("매출 YoY", f"{yoy:+.1f}%"))
+ if cagr is not None:
+ metrics.append(("3Y CAGR", f"{cagr:+.1f}%"))
+
+ # 분기 매출 테이블 (최근 8분기)
+ quarterly = data.get("quarterlySelect")
+ qTable = _quarterlyRevenueTable(quarterly)
+
+ if not metrics and qTable is None:
+ return []
+
+ blocks.append(
+ HeadingBlock(
+ _meta("growth").label,
+ level=2,
+ helper="YoY vs 3Y CAGR 방향이 다르면 추세 전환 의심",
+ )
+ )
+
+ narration = narrateGrowth(yoy, cagr)
+ if narration:
+ blocks.append(TextBlock(narration))
+
+ if metrics:
+ blocks.append(MetricBlock(metrics))
+ if qTable is not None:
+ blocks.append(qTable)
+
+ return blocks
+
+
+_MAX_QUARTERS = 8
+
+
+def _quarterlyRevenueTable(selectResult) -> TableBlock | None:
+ """SelectResult → 최근 N분기 매출 TableBlock."""
+ if selectResult is None:
+ return None
+
+ df = selectResult.df
+ if df is None or df.is_empty():
+ return None
+
+ # 기간 컬럼만 추출 (Q 포함)
+ periodCols = [c for c in df.columns if "Q" in c]
+ periodCols = sorted(periodCols, reverse=True)[:_MAX_QUARTERS]
+ if not periodCols:
+ return None
+
+ labelCol = "항목" if "항목" in df.columns else df.columns[0]
+ keepCols = [labelCol] + periodCols
+
+ rows = []
+ for row in df.select(keepCols).iter_rows(named=True):
+ label = row[labelCol]
+ rowDict = {"": label}
+ for pc in periodCols:
+ rowDict[pc] = row.get(pc)
+ rows.append(rowDict)
+
+ if not rows:
+ return None
+
+ unified = unifyTableScale(rows, "", periodCols, unit=_unitForCurrency())
+ return TableBlock("분기별 매출", pl.DataFrame(unified))
+
+
+def concentrationBlock(data: dict) -> list:
+ """calcConcentration 결과 → MetricBlock."""
+ if not data:
+ return []
+
+ metrics = []
+ metrics.append(("HHI", f"{data['hhi']:,.0f} ({data['hhiLabel']})"))
+ metrics.append(("1위 부문 비중", f"{data['topPct']:.0f}%"))
+ if data.get("domesticPct") is not None:
+ metrics.append(("내수 비중", f"{data['domesticPct']:.0f}%"))
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("concentration").label,
+ level=2,
+ helper="HHI > 5000 고집중, > 2500 중간 집중",
+ )
+ )
+
+ narration = narrateConcentration(data)
+ if narration:
+ blocks.append(TextBlock(narration))
+
+ blocks.append(MetricBlock(metrics))
+
+ # HHI 시계열
+ hhiHistory = data.get("hhiHistory")
+ hhiDir = data.get("hhiDirection")
+ if hhiHistory and len(hhiHistory) >= 2:
+ hhiRows = [{"연도": h["year"], "HHI": f"{h['hhi']:,.0f}"} for h in hhiHistory]
+ blocks.append(TableBlock("HHI 추이", pl.DataFrame(hhiRows)))
+ if hhiDir:
+ blocks.append(TextBlock(f"방향: {hhiDir}", style="dim", indent="h2"))
+
+ return blocks
+
+
+def revenueQualityBlock(data: dict) -> list:
+ """calcRevenueQuality 결과 → MetricBlock."""
+ if not data:
+ return []
+
+ metrics = []
+ cc = data.get("cashConversion")
+ if cc is not None:
+ metrics.append(("영업CF/순이익", f"{cc:.0f}% ({data['cashConversionLabel']})"))
+ gm = data.get("grossMargin")
+ if gm is not None:
+ metrics.append(("매출총이익률", f"{gm:.1f}%"))
+
+ gmDir = data.get("grossMarginDirection", "안정")
+ if gmDir and gmDir != "안정":
+ metrics.append(("총이익률 방향", gmDir))
+
+ if not metrics:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("revenueQuality").label,
+ level=2,
+ helper="영업CF/순이익 80%+ 양호, 총이익률 하락 추세 주의",
+ )
+ )
+ blocks.append(MetricBlock(metrics))
+ return blocks
+
+
+def growthContributionBlock(data: dict) -> list:
+ """calcGrowthContribution 결과 → MetricBlock + TextBlock."""
+ if not data:
+ return []
+
+ totalPct = data.get("totalGrowthPct")
+ contributions = data.get("contributions", [])
+ driver = data.get("driver", "")
+
+ if not contributions:
+ return []
+
+ period = data.get("period", "")
+
+ blocks: list = []
+ periodSuffix = f" ({period})" if period else ""
+ blocks.append(
+ HeadingBlock(
+ f"{_meta('growthContribution').label}{periodSuffix}",
+ level=2,
+ helper="어느 부문이 전체 성장을 이끌었는가",
+ )
+ )
+
+ metrics = []
+ if totalPct is not None:
+ metrics.append(("전체 매출 변화", f"{totalPct:+.1f}%"))
+ for c in contributions[:5]:
+ sign = "+" if c["amount"] > 0 else ""
+ metrics.append((c["name"], f"기여 {sign}{c['pct']:.0f}%"))
+ blocks.append(MetricBlock(metrics))
+
+ if driver:
+ blocks.append(TextBlock(driver, style="dim", indent="h2"))
+
+ return blocks
+
+
+def revenueFlagsBlock(flags: list[tuple[str, str]]) -> list:
+ """calcFlags 결과 → FlagBlock."""
+ if not flags:
+ return []
+ warnings = [f for f, k in flags if k == "warning"]
+ opportunities = [f for f, k in flags if k == "opportunity"]
+ blocks: list = []
+ if warnings:
+ blocks.append(FlagBlock(warnings, kind="warning"))
+ if opportunities:
+ blocks.append(FlagBlock(opportunities, kind="opportunity"))
+ return blocks
+
+
+# ── 자금구조 (capital) 빌더 ──
+
+
+def fundingSourcesBlock(data: dict) -> list:
+ """calcFundingSources 결과 → 조달원 비중 테이블 + 시계열."""
+ if not data:
+ return []
+
+ latest = data.get("latest")
+ if not latest:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("fundingSources").label,
+ level=2,
+ helper="내부유보 = 사업으로 번 돈, 금융차입 = 이자 붙는 빚, 영업조달 = 자연 발생 자금",
+ )
+ )
+
+ # 최신 비중 메트릭
+ fmtAmt = _fmtAmtShort(latest["totalAssets"])
+ metrics = [("총자산", fmtAmt)]
+ metrics.append(("내부유보 (이익잉여금)", f"{_fmtAmtShort(latest['retained'])} ({latest['retainedPct']:.0f}%)"))
+ metrics.append(("외부-주주 (자본금+잉여금)", f"{_fmtAmtShort(latest['paidIn'])} ({latest['paidInPct']:.0f}%)"))
+ metrics.append(("외부-금융차입", f"{_fmtAmtShort(latest['finDebt'])} ({latest['finDebtPct']:.0f}%)"))
+ if latest["opFundingPct"] > 0.5:
+ metrics.append(
+ ("영업조달 (매입채무·선수금 등)", f"{_fmtAmtShort(latest['opFunding'])} ({latest['opFundingPct']:.0f}%)")
+ )
+ blocks.append(MetricBlock(metrics))
+
+ # 시계열 테이블 (행=항목, 열=기간)
+ history = data.get("history", [])
+ if len(history) >= 2:
+ cols = {"": ["내부유보", "주주자본", "금융차입", "영업조달"]}
+ for h in history:
+ cols[h["period"]] = [
+ f"{h['retainedPct']:.0f}%",
+ f"{h['paidInPct']:.0f}%",
+ f"{h['finDebtPct']:.0f}%",
+ f"{h['opFundingPct']:.0f}%",
+ ]
+ blocks.append(TableBlock("조달원 비중 추이", pl.DataFrame(cols)))
+
+ # 보충 지표 (순차입금/EBITDA, 암묵적 차입금리)
+ suppMetrics = []
+ ndEbitda = data.get("netDebtEbitda")
+ if ndEbitda is not None:
+ if ndEbitda == 0:
+ suppMetrics.append(("순차입금/EBITDA", "순현금 (차입 없음)"))
+ else:
+ suppMetrics.append(("순차입금/EBITDA", f"{ndEbitda:.1f}배"))
+ impliedRate = data.get("impliedBorrowingRate")
+ if impliedRate is not None:
+ suppMetrics.append(("암묵적 차입금리", f"{impliedRate:.1f}%"))
+ if suppMetrics:
+ blocks.append(MetricBlock(suppMetrics))
+
+ # 진단 + 비중 변화 방향
+ diagnosis = data.get("diagnosis", "")
+ leverageTrend = data.get("leverageTrend")
+ diagParts = [p for p in [diagnosis, leverageTrend] if p]
+ if diagParts:
+ blocks.append(TextBlock(" | ".join(diagParts), style="dim", indent="h2"))
+
+ blocks.extend(_notesDetailBlocks(data, {"borrowings": "차입금 상세"}))
+
+ return blocks
+
+
+import contextvars
+
+_review_currency: contextvars.ContextVar[str] = contextvars.ContextVar("review_currency", default="KRW")
+
+
+def _unitForCurrency() -> str:
+ """현재 통화에 맞는 unifyTableScale unit 반환."""
+ return "usd" if _review_currency.get() == "USD" else "won"
+
+
+def _fmtAmtShort(value) -> str:
+ """금액 간략 포맷 (KRW: 조/억, USD: B/M)."""
+ if value is None or value == 0:
+ return "-"
+ absVal = abs(value)
+ sign = "-" if value < 0 else ""
+ if _review_currency.get() == "USD":
+ if absVal >= 1_000_000_000:
+ return f"{sign}${absVal / 1_000_000_000:.1f}B"
+ if absVal >= 1_000_000:
+ return f"{sign}${absVal / 1_000_000:.0f}M"
+ return f"{sign}${absVal:,.0f}"
+ if absVal >= 1_0000_0000_0000:
+ return f"{sign}{absVal / 1_0000_0000_0000:.1f}조"
+ if absVal >= 1_0000_0000:
+ return f"{sign}{absVal / 1_0000_0000:.0f}억"
+ return f"{sign}{absVal:,.0f}"
+
+
+def capitalOverviewBlock(data: dict) -> list:
+ """calcCapitalOverview 결과 → MetricBlock."""
+ if not data:
+ return []
+ metrics = data.get("metrics", [])
+ if not metrics:
+ return []
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("capitalOverview").label,
+ level=2,
+ helper="부채비율 100% 이하 안정, 순현금이면 재무 여유",
+ )
+ )
+ blocks.append(MetricBlock(metrics))
+ return blocks
+
+
+def capitalTimelineBlock(data: dict) -> list:
+ """calcCapitalTimeline 결과 → TableBlock."""
+ if not data:
+ return []
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("capitalTimeline").label,
+ level=2,
+ helper="이익잉여금 = 사업으로 번 돈, 자본금+잉여금 = 외부 조달",
+ )
+ )
+ for label, tableRows, cols in data.get("tables", []):
+ if tableRows and cols:
+ unified = unifyTableScale(tableRows, "", cols, unit=_unitForCurrency())
+ blocks.append(TableBlock(label, pl.DataFrame(unified)))
+ if len(blocks) <= 1:
+ return []
+ return blocks
+
+
+def debtTimelineBlock(data: dict) -> list:
+ """calcDebtTimeline 결과 → TableBlock."""
+ if not data:
+ return []
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("debtTimeline").label,
+ level=2,
+ helper="영업부채 = 자연 발생, 금융부채 = 이자 붙는 차입",
+ )
+ )
+ for label, tableRows, cols in data.get("tables", []):
+ if tableRows and cols:
+ unified = unifyTableScale(tableRows, "", cols, unit=_unitForCurrency())
+ blocks.append(TableBlock(label, pl.DataFrame(unified)))
+ if len(blocks) <= 1:
+ return []
+ return blocks
+
+
+def interestBurdenBlock(data: dict) -> list:
+ """calcInterestBurden 결과 → MetricBlock."""
+ if not data:
+ return []
+ metrics = data.get("metrics", [])
+ if not metrics:
+ return []
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("interestBurden").label,
+ level=2,
+ helper="이자보상배율 3배 이상 안정, 1.5배 이하 주의",
+ )
+ )
+ blocks.append(MetricBlock(metrics))
+ return blocks
+
+
+def liquidityBlock(data: dict) -> list:
+ """calcLiquidity 결과 → MetricBlock."""
+ if not data:
+ return []
+ metrics = data.get("metrics", [])
+ if not metrics:
+ return []
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("liquidity").label,
+ level=2,
+ helper="유동비율 100% 이하 → 단기 지급 리스크",
+ )
+ )
+ blocks.append(MetricBlock(metrics))
+ return blocks
+
+
+def cashFlowBlock(data: dict) -> list:
+ """calcCashFlowStructure 결과 → TableBlock + TextBlock + MetricBlock."""
+ if not data:
+ return []
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("cashFlowStructure").label,
+ level=2,
+ helper="영업CF(+)/투자CF(-)/재무CF(-) → 건전한 패턴",
+ )
+ )
+ tableRows = data.get("tableRows")
+ cols = data.get("cols")
+ if tableRows and cols:
+ unified = unifyTableScale(tableRows, "", cols, unit=_unitForCurrency())
+ blocks.append(TableBlock("", pl.DataFrame(unified)))
+ pattern = data.get("pattern")
+ if pattern:
+ blocks.append(TextBlock(f"CF 패턴: {pattern}", style="dim", indent="h2"))
+ metrics = data.get("metrics")
+ if metrics:
+ blocks.append(MetricBlock(metrics))
+ if len(blocks) <= 1:
+ return []
+ return blocks
+
+
+def distressBlock(data: dict) -> list:
+ """calcDistressIndicators 결과 → MetricBlock."""
+ if not data:
+ return []
+ metrics = data.get("metrics", [])
+ if not metrics:
+ return []
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("distressIndicators").label,
+ level=2,
+ helper="Altman Z > 2.99 안전, Piotroski F ≥ 7 건전",
+ )
+ )
+ blocks.append(MetricBlock(metrics))
+ return blocks
+
+
+def capitalFlagsBlock(flags: list[tuple[str, str]]) -> list:
+ """calcCapitalFlags 결과 → FlagBlock."""
+ if not flags:
+ return []
+ warnings = [f for f, k in flags if k == "warning"]
+ opportunities = [f for f, k in flags if k == "opportunity"]
+ blocks: list = []
+ if warnings:
+ blocks.append(FlagBlock(warnings, kind="warning"))
+ if opportunities:
+ blocks.append(FlagBlock(opportunities, kind="opportunity"))
+ return blocks
+
+
+# ── 자산구조 (asset) 빌더 ──
+
+
+def assetStructureBlock(data: dict) -> list:
+ """calcAssetStructure 결과 → 영업/비영업 재분류 시계열."""
+ if not data:
+ return []
+
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("assetStructure").label,
+ level=2,
+ helper="영업자산 = 사업에 투입된 자산, 비영업 = 현금/투자/금융자산",
+ )
+ )
+
+ # 비중 시계열 테이블
+ rows = ["총자산", "영업자산", "비영업자산", "순영업자산(NOA)", "순운전자본", "고정영업자산"]
+ cols = {"": rows}
+ for h in history:
+ ta = h.get("totalAssets", 0)
+ cols[h["period"]] = [
+ _fmtAmtShort(ta),
+ f"{_fmtAmtShort(h['opAssets'])} ({h['opAssetsPct']:.0f}%)",
+ f"{_fmtAmtShort(h['nonOpAssets'])} ({h['nonOpAssetsPct']:.0f}%)",
+ _fmtAmtShort(h["noa"]),
+ _fmtAmtShort(h["wc"]),
+ _fmtAmtShort(h["fixedOp"]),
+ ]
+ blocks.append(TableBlock("자산 재분류 추이", pl.DataFrame(cols)))
+
+ # 세부 구성 시계열 (영업+비영업 주요 항목)
+ detailRows = ["매출채권", "재고자산", "유형자산", "무형자산+영업권", "건설중인자산", "현금성자산", "투자자산"]
+ detailCols = {"": detailRows}
+ for h in history:
+ intGw = h.get("intangibles", 0) + h.get("goodwill", 0)
+ detailCols[h["period"]] = [
+ _fmtAmtShort(h.get("receivables", 0)),
+ _fmtAmtShort(h.get("inventory", 0)),
+ _fmtAmtShort(h.get("ppe", 0)),
+ _fmtAmtShort(intGw),
+ _fmtAmtShort(h.get("cip", 0)),
+ _fmtAmtShort(h.get("cash", 0)),
+ _fmtAmtShort(h.get("investments", 0)),
+ ]
+ blocks.append(TableBlock("자산 구성 상세 추이", pl.DataFrame(detailCols)))
+
+ # 진단
+ diagnosis = data.get("diagnosis")
+ if diagnosis:
+ blocks.append(TextBlock(diagnosis, style="dim", indent="h2"))
+
+ blocks.extend(
+ _notesDetailBlocks(
+ data, {"inventory": "재고자산 상세", "tangibleAsset": "유형자산 변동", "intangibleAsset": "무형자산 상세"}
+ )
+ )
+
+ return blocks
+
+
+def workingCapitalBlock(data: dict) -> list:
+ """calcWorkingCapital 결과 → 운전자본 + CCC."""
+ if not data:
+ return []
+
+ latest = data.get("latest")
+ if not latest:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("workingCapital").label,
+ level=2,
+ helper="CCC = 재고회전일 + 매출채권회전일 - 매입채무회전일",
+ )
+ )
+
+ metrics = [
+ ("순운전자본", _fmtAmtShort(latest["wc"])),
+ ]
+ for label, key, suffix in [
+ ("매출채권 회전일", "receivableDays", "일"),
+ ("재고 회전일", "inventoryDays", "일"),
+ ("매입채무 회전일", "payableDays", "일"),
+ ("CCC", "ccc", "일"),
+ ]:
+ val = latest.get(key)
+ if val is not None:
+ metrics.append((label, f"{val:.0f}{suffix}"))
+ blocks.append(MetricBlock(metrics))
+
+ # CCC 시계열 (행=항목, 열=기간)
+ history = data.get("history", [])
+ if len(history) >= 2:
+ hasData = any(h.get("ccc") is not None for h in history)
+ if hasData:
+ cols = {"": ["매출채권일", "재고일", "매입채무일", "CCC"]}
+ for h in history:
+ cols[h["period"]] = [
+ f"{h['receivableDays']:.0f}" if h.get("receivableDays") is not None else "-",
+ f"{h['inventoryDays']:.0f}" if h.get("inventoryDays") is not None else "-",
+ f"{h['payableDays']:.0f}" if h.get("payableDays") is not None else "-",
+ f"{h['ccc']:.0f}" if h.get("ccc") is not None else "-",
+ ]
+ blocks.append(TableBlock("CCC 추이", pl.DataFrame(cols)))
+
+ return blocks
+
+
+def capexBlock(data: dict) -> list:
+ """calcCapexPattern 결과 → CAPEX/감가상각 + 건설중인자산."""
+ if not data:
+ return []
+
+ latest = data.get("latest")
+ if not latest:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("capexPattern").label,
+ level=2,
+ helper="CAPEX/감가상각 > 1 → 성장 투자, < 1 → 유지/수확",
+ )
+ )
+
+ metrics = [
+ ("CAPEX", _fmtAmtShort(latest["capex"])),
+ ("감가상각", _fmtAmtShort(latest["depreciation"])),
+ ]
+ ratio = latest.get("capexToDepRatio")
+ if ratio is not None:
+ metrics.append(("CAPEX/감가상각", f"{ratio:.1f}배"))
+ cip = latest.get("cip", 0)
+ if cip > 0:
+ metrics.append(("건설중인자산", f"{_fmtAmtShort(cip)} ({latest['cipPct']:.0f}%)"))
+ blocks.append(MetricBlock(metrics))
+
+ investType = latest.get("investmentType")
+ if investType:
+ blocks.append(TextBlock(investType, style="dim", indent="h2"))
+
+ # 시계열 (행=항목, 열=기간)
+ history = data.get("history", [])
+ if len(history) >= 2:
+ cols = {"": ["CAPEX", "감가상각", "CAPEX/감가상각", "건설중인자산"]}
+ for h in history:
+ r = h.get("capexToDepRatio")
+ cols[h["period"]] = [
+ _fmtAmtShort(h["capex"]),
+ _fmtAmtShort(h["depreciation"]),
+ f"{r:.1f}배" if r is not None else "-",
+ _fmtAmtShort(h["cip"]),
+ ]
+ blocks.append(TableBlock("CAPEX 추이", pl.DataFrame(cols)))
+
+ return blocks
+
+
+def assetEfficiencyBlock(data: dict) -> list:
+ """calcAssetEfficiency 결과 → 회전율 시계열."""
+ if not data:
+ return []
+
+ history = data.get("history", [])
+ if len(history) < 2:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("assetEfficiency").label,
+ level=2,
+ helper="회전율이 높을수록 같은 자산으로 매출을 더 뽑는다",
+ )
+ )
+
+ cols = {"": ["총자산회전율", "유형자산회전율"]}
+ for h in history:
+ ta = h.get("totalAssetTurnover")
+ ppe = h.get("ppeTurnover")
+ cols[h["period"]] = [
+ f"{ta:.2f}회" if ta is not None else "-",
+ f"{ppe:.2f}회" if ppe is not None else "-",
+ ]
+ blocks.append(TableBlock("회전율 추이", pl.DataFrame(cols)))
+
+ return blocks
+
+
+def assetFlagsBlock(flags: list[str]) -> list:
+ """calcAssetFlags 결과 → FlagBlock."""
+ if not flags:
+ return []
+ return [FlagBlock(flags, kind="warning")]
+
+
+# ── 1-4 현금흐름 빌더 ──
+
+
+def cashFlowOverviewBlock(data: dict) -> list:
+ """calcCashFlowOverview 결과 → CF 3구간 + FCF 시계열 테이블."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("cashFlowOverview").label,
+ level=2,
+ helper="영업CF(+)/투자CF(-)/재무CF(-) = 건전한 패턴",
+ )
+ )
+
+ narration = narrateCashFlow(data, fmtAmt=_fmtAmtShort)
+ if narration:
+ blocks.append(TextBlock(narration))
+
+ rows = ["영업CF", "투자CF", "재무CF", "CAPEX", "FCF"]
+ cols = {"": rows}
+ for h in history:
+ cols[h["period"]] = [
+ _fmtAmtShort(h["ocf"]),
+ _fmtAmtShort(h["icf"]),
+ _fmtAmtShort(h["fcfFinancing"]),
+ _fmtAmtShort(h["capex"]),
+ _fmtAmtShort(h["fcf"]),
+ ]
+ blocks.append(TableBlock("현금흐름 추이", pl.DataFrame(cols)))
+
+ # CF 패턴 시계열
+ patternRows = ["CF 패턴"]
+ patternCols = {"": patternRows}
+ for h in history:
+ pat = h.get("pattern")
+ label = pat.split(" — ")[0] if pat else "-"
+ patternCols[h["period"]] = [label]
+ blocks.append(TableBlock("CF 패턴 추이", pl.DataFrame(patternCols)))
+
+ return blocks
+
+
+def cashQualityBlock(data: dict) -> list:
+ """calcCashQuality 결과 → 영업CF/순이익, 영업CF 마진 시계열."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("cashQuality").label,
+ level=2,
+ helper="영업CF/순이익 > 100%이면 이익이 현금으로 회수됨",
+ )
+ )
+
+ narration = narrateCashQuality(data)
+ if narration:
+ blocks.append(TextBlock(narration))
+
+ rows = ["영업CF", "당기순이익", "영업CF/순이익", "영업CF 마진"]
+ cols = {"": rows}
+ for h in history:
+ ratio = h.get("ocfToNi")
+ margin = h.get("ocfMargin")
+ cols[h["period"]] = [
+ _fmtAmtShort(h["ocf"]),
+ _fmtAmtShort(h["netIncome"]),
+ f"{ratio:.0f}%" if ratio is not None else "-",
+ f"{margin:.1f}%" if margin is not None else "-",
+ ]
+ blocks.append(TableBlock("현금 품질 추이", pl.DataFrame(cols)))
+
+ return blocks
+
+
+def cashFlowFlagsBlock(flags: list[str]) -> list:
+ """calcCashFlowFlags 결과 → FlagBlock."""
+ if not flags:
+ return []
+ return [FlagBlock(flags, kind="warning")]
+
+
+# ── 2부: 재무비율 분석 빌더 ──
+
+
+def _extractSeries(data: dict, field: str) -> list[dict]:
+ """history 기반 calc 결과에서 [{"period": p, "value": v}, ...] 추출."""
+ history = data.get("history", [])
+ if not history:
+ return data.get(field, [])
+ return [{"period": h["period"], "value": h.get(field)} for h in history if h.get(field) is not None]
+
+
+def _timelineTable(
+ specs: list[tuple[list[dict], str]],
+ rowLabels: list[str],
+) -> dict[str, list[str]] | None:
+ """[{period, value}, ...] 시계열 → 행=지표, 열=기간 dict.
+
+ specs: [(series, fmt), ...] -- series는 buildTimeline 결과, fmt는 f-string 포맷.
+ rowLabels: 각 행 라벨 (specs와 동일 순서).
+ 반환: polars DataFrame으로 변환 가능한 dict. 기간 데이터 없으면 None.
+ """
+ cols: dict[str, list[str]] = {"": rowLabels}
+ for idx, (series, fmt) in enumerate(specs):
+ for item in series:
+ period = item["period"]
+ if period not in cols:
+ cols[period] = ["-"] * len(rowLabels)
+ v = item["value"]
+ cols[period][idx] = fmt.format(v) if v is not None else "-"
+ if len(cols) <= 1:
+ return None
+ return cols
+
+
+_POSITIVE_KEYWORDS = ("안정", "건전", "양호", "우량", "순현금", "충분", "개선", "고성장")
+
+
+def _flagsBlock(flags: list[str]) -> list:
+ """플래그 리스트 → FlagBlock. 긍정/경고 자동 분류."""
+ if not flags:
+ return []
+ warnings = []
+ opportunities = []
+ for f in flags:
+ if any(kw in f for kw in _POSITIVE_KEYWORDS):
+ opportunities.append(f)
+ else:
+ warnings.append(f)
+ result = []
+ if warnings:
+ result.append(FlagBlock(warnings, kind="warning"))
+ if opportunities:
+ result.append(FlagBlock(opportunities, kind="opportunity"))
+ return result
+
+
+def _enrichedFlagsBlock(flags: list[str], enrichedFlags: list[dict] | None = None) -> list:
+ """플래그 + enrichedFlags → FlagBlock. 정밀도 메타 포함."""
+ if not flags:
+ return []
+ # EnrichedFlag 변환
+ efList = None
+ if enrichedFlags:
+ from dartlab.review.blocks import EnrichedFlag
+
+ efList = [
+ EnrichedFlag(
+ code=ef.get("code", ""),
+ message=ef.get("message", ""),
+ precision=ef.get("precision"),
+ baseRate=ef.get("baseRate", ""),
+ reference=ef.get("reference", ""),
+ sectorNote=ef.get("sectorNote", ""),
+ )
+ for ef in enrichedFlags
+ ]
+ # enrichedFlags의 메시지에 정밀도 주석 추가
+ {ef.get("code") for ef in enrichedFlags}
+ augmented = []
+ for f in flags:
+ matched = next((ef for ef in enrichedFlags if ef.get("message") == f), None)
+ if matched and matched.get("precision") is not None:
+ p = matched["precision"]
+ ref = matched.get("reference", "")
+ note = matched.get("sectorNote", "")
+ suffix = f" (정밀도 {p:.0%}, {ref})"
+ if note:
+ suffix += f" [{note}]"
+ augmented.append(f + suffix)
+ else:
+ augmented.append(f)
+ flags = augmented
+
+ warnings = []
+ opportunities = []
+ for f in flags:
+ if any(kw in f for kw in _POSITIVE_KEYWORDS):
+ opportunities.append(f)
+ else:
+ warnings.append(f)
+ result = []
+ if warnings:
+ result.append(FlagBlock(warnings, kind="warning", enrichedFlags=efList))
+ if opportunities:
+ result.append(FlagBlock(opportunities, kind="opportunity"))
+ return result
+
+
+# ── 2-1 수익성 ──
+
+
+def marginTrendBlock(data: dict) -> list:
+ """calcMarginTrend 결과 → 마진 시계열 테이블."""
+ if not data:
+ return []
+
+ cols = _timelineTable(
+ [
+ (_extractSeries(data, "grossMargin"), "{:.1f}%"),
+ (_extractSeries(data, "operatingMargin"), "{:.1f}%"),
+ (_extractSeries(data, "netMargin"), "{:.1f}%"),
+ ],
+ ["매출총이익률", "영업이익률", "순이익률"],
+ )
+ if cols is None:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("marginTrend").label,
+ level=2,
+ helper="매출총이익률 안정 + 영업이익률 상승 = 원가 통제 + 판관비 효율",
+ ),
+ ]
+
+ narration = narrateMargin(data)
+ if narration:
+ blocks.append(TextBlock(narration))
+
+ blocks.append(TableBlock("마진 추이", pl.DataFrame(cols)))
+ return blocks
+
+
+def returnTrendBlock(data: dict) -> list:
+ """calcReturnTrend 결과 → ROE/ROA 시계열."""
+ if not data:
+ return []
+
+ cols = _timelineTable(
+ [
+ (_extractSeries(data, "roe"), "{:.1f}%"),
+ (_extractSeries(data, "roa"), "{:.1f}%"),
+ (_extractSeries(data, "leverage"), "{:.2f}배"),
+ ],
+ ["ROE", "ROA", "레버리지(ROE/ROA)"],
+ )
+ if cols is None:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("returnTrend").label,
+ level=2,
+ helper="ROE/ROA > 2 → 레버리지로 수익률 확대",
+ ),
+ TableBlock("수익률 추이", pl.DataFrame(cols)),
+ ]
+
+
+def dupontBlock(data: dict) -> list:
+ """calcDupont 결과 → 듀퐁 분해 시계열."""
+ if not data:
+ return []
+
+ cols = _timelineTable(
+ [
+ (_extractSeries(data, "operatingMargin"), "{:.1f}%"),
+ (_extractSeries(data, "assetTurnover"), "{:.2f}"),
+ (_extractSeries(data, "leverage"), "{:.2f}"),
+ ],
+ ["영업이익률(%)", "자산회전율(회)", "재무레버리지(배)"],
+ )
+ if cols is None:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("dupont").label,
+ level=2,
+ helper="ROE = 순이익률 x 자산회전율 x 재무레버리지",
+ ),
+ TableBlock("듀퐁 분해", pl.DataFrame(cols)),
+ ]
+
+
+def profitabilityFlagsBlock(flags: list[str]) -> list:
+ """calcProfitabilityFlags 결과 → FlagBlock."""
+ return _flagsBlock(flags)
+
+
+# ── 2-2 성장성 ──
+
+
+def growthTrendBlock(data: dict) -> list:
+ """calcGrowthTrend 결과 → 성장률 시계열."""
+ if not data:
+ return []
+
+ cols = _timelineTable(
+ [
+ (_extractSeries(data, "revenueYoy"), "{:+.1f}%"),
+ (_extractSeries(data, "operatingIncomeYoy"), "{:+.1f}%"),
+ (_extractSeries(data, "netIncomeYoy"), "{:+.1f}%"),
+ (_extractSeries(data, "totalAssetsYoy"), "{:+.1f}%"),
+ ],
+ ["매출 성장률", "영업이익 성장률", "순이익 성장률", "자산 성장률"],
+ )
+ if cols is None:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("growthTrend").label,
+ level=2,
+ helper="매출 성장 > 이익 성장이면 수익성 희석 가능",
+ ),
+ TableBlock("성장률 추이", pl.DataFrame(cols)),
+ ]
+
+
+def growthQualityBlock(data: dict) -> list:
+ """calcGrowthQuality 결과 → CAGR + 성장 품질."""
+ if not data:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("growthQuality").label,
+ level=2,
+ helper="CAGR로 단기 변동 너머의 중기 추세를 본다",
+ )
+ )
+
+ periods = data.get("periods", 0)
+ metrics = []
+ revCagr = data.get("revenueCagr")
+ opCagr = data.get("operatingProfitCagr")
+ npCagr = data.get("netProfitCagr")
+ quality = data.get("quality", "")
+
+ if revCagr is not None:
+ metrics.append((f"매출 CAGR ({periods}Y)", f"{revCagr:+.1f}%"))
+ if opCagr is not None:
+ metrics.append((f"영업이익 CAGR ({periods}Y)", f"{opCagr:+.1f}%"))
+ if npCagr is not None:
+ metrics.append((f"순이익 CAGR ({periods}Y)", f"{npCagr:+.1f}%"))
+ if quality:
+ metrics.append(("성장 품질", quality))
+
+ if not metrics:
+ return []
+ blocks.append(MetricBlock(metrics))
+ return blocks
+
+
+def growthFlagsBlock(flags: list[str]) -> list:
+ """calcGrowthFlags 결과 → FlagBlock."""
+ return _flagsBlock(flags)
+
+
+# ── 2-3 안정성 ──
+
+
+def leverageTrendBlock(data: dict) -> list:
+ """calcLeverageTrend 결과 → 레버리지 시계열."""
+ if not data:
+ return []
+
+ cols = _timelineTable(
+ [
+ (_extractSeries(data, "debtRatio"), "{:.0f}%"),
+ (_extractSeries(data, "netDebtRatio"), "{:.0f}%"),
+ (_extractSeries(data, "equityRatio"), "{:.0f}%"),
+ ],
+ ["부채비율", "순부채비율", "자기자본비율"],
+ )
+ if cols is None:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("leverageTrend").label,
+ level=2,
+ helper="부채비율 200% 이상 위험, 50% 이하 매우 안정",
+ ),
+ ]
+
+ narration = narrateLeverage(data)
+ if narration:
+ blocks.append(TextBlock(narration))
+
+ blocks.append(TableBlock("레버리지 추이", pl.DataFrame(cols)))
+ blocks.extend(_notesDetailBlocks(data, {"borrowings": "차입금 구성", "lease": "리스부채"}))
+ return blocks
+
+
+def coverageTrendBlock(data: dict) -> list:
+ """calcCoverageTrend 결과 → 이자보상배율 시계열."""
+ if not data:
+ return []
+
+ cols = _timelineTable(
+ [(_extractSeries(data, "interestCoverage"), "{:.1f}배")],
+ ["이자보상배율"],
+ )
+ if cols is None:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("coverageTrend").label,
+ level=2,
+ helper="이자보상배율 3배 이상 안정, 1배 미만 이자 지급 불능",
+ ),
+ TableBlock("이자보상 추이", pl.DataFrame(cols)),
+ ]
+
+
+def distressScoreBlock(data: dict) -> list:
+ """calcDistressScore 결과 → Z-Score 시계열 + 등급."""
+ if not data:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("distressScore").label,
+ level=2,
+ helper="Z > 2.99 안전, 1.81~2.99 회색, < 1.81 위험",
+ )
+ )
+
+ narration = narrateDistress(data)
+ if narration:
+ blocks.append(TextBlock(narration))
+
+ metrics = []
+ latest = data.get("latestScore")
+ zone = data.get("zone", "")
+ if latest is not None:
+ metrics.append(("최신 Z-Score", f"{latest:.2f}"))
+ if zone:
+ metrics.append(("판정", zone))
+ if metrics:
+ blocks.append(MetricBlock(metrics))
+
+ cols = _timelineTable(
+ [(_extractSeries(data, "altmanZScore"), "{:.2f}")],
+ ["Altman Z-Score"],
+ )
+ if cols is not None:
+ blocks.append(TableBlock("Z-Score 추이", pl.DataFrame(cols)))
+
+ # 충당부채 주석은 위험/회색 구간일 때만 표시
+ zone = data.get("zone", "")
+ if zone in ("위험", "회색"):
+ blocks.extend(_notesDetailBlocks(data, {"provisions": "충당부채 상세"}))
+
+ return blocks
+
+
+def stabilityFlagsBlock(data) -> list:
+ """calcStabilityFlags 결과 → FlagBlock."""
+ if isinstance(data, dict):
+ return _enrichedFlagsBlock(data.get("flags", []), data.get("enrichedFlags"))
+ flags = data if isinstance(data, list) else []
+ return _flagsBlock(flags)
+
+
+# ── 2-4 효율성 ──
+
+
+def turnoverTrendBlock(data: dict) -> list:
+ """calcTurnoverTrend 결과 → 회전율 시계열."""
+ if not data:
+ return []
+
+ cols = _timelineTable(
+ [
+ (_extractSeries(data, "totalAssetTurnover"), "{:.2f}회"),
+ (_extractSeries(data, "receivablesTurnover"), "{:.2f}회"),
+ (_extractSeries(data, "inventoryTurnover"), "{:.2f}회"),
+ ],
+ ["총자산회전율", "매출채권회전율", "재고회전율"],
+ )
+ if cols is None:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("turnoverTrend").label,
+ level=2,
+ helper="회전율 상승 = 같은 자산으로 매출을 더 뽑는다",
+ ),
+ TableBlock("회전율 추이", pl.DataFrame(cols)),
+ ]
+
+
+def cccTrendBlock(data: dict) -> list:
+ """calcCccTrend 결과 → CCC 구성요소 시계열."""
+ if not data:
+ return []
+
+ cols = _timelineTable(
+ [
+ (_extractSeries(data, "dso"), "{:.0f}일"),
+ (_extractSeries(data, "dio"), "{:.0f}일"),
+ (_extractSeries(data, "dpo"), "{:.0f}일"),
+ (_extractSeries(data, "ccc"), "{:.0f}일"),
+ ],
+ ["DSO(매출채권일)", "DIO(재고일)", "DPO(매입채무일)", "CCC"],
+ )
+ if cols is None:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("cccTrend").label,
+ level=2,
+ helper="CCC = DSO + DIO - DPO, 마이너스면 운전자본 유리",
+ ),
+ TableBlock("CCC 추이", pl.DataFrame(cols)),
+ ]
+
+
+def efficiencyFlagsBlock(flags: list[str]) -> list:
+ """calcEfficiencyFlags 결과 → FlagBlock."""
+ return _flagsBlock(flags)
+
+
+# ── 2-5 종합 평가 ──
+
+
+def scorecardBlock(data: dict) -> list:
+ """calcScorecard 결과 → 5영역 등급 테이블."""
+ if not data:
+ return []
+
+ items = data.get("items", [])
+ if not items:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("scorecard").label,
+ level=2,
+ helper="F 등급 영역을 최우선으로 개선 검토",
+ )
+ )
+
+ rows = []
+ for item in items:
+ rows.append({"영역": item["area"], "등급": item["grade"]})
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+
+ profile = data.get("profile", "")
+ if profile:
+ blocks.append(TextBlock(f"재무 프로필: {profile}", style="dim", indent="h2"))
+
+ return blocks
+
+
+def piotroskiBlock(data: dict) -> list:
+ """calcPiotroskiDetail 결과 → 9개 항목 상세."""
+ if not data:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("piotroski").label,
+ level=2,
+ helper="9점 만점, 7+ 건전, 3- 심각",
+ )
+ )
+
+ total = data.get("total", 0)
+ interp = data.get("interpretation", "")
+ interpKor = {"strong": "건전", "moderate": "보통", "weak": "취약"}.get(interp, interp)
+ blocks.append(MetricBlock([("F-Score", f"{total}/9 ({interpKor})")]))
+
+ items = data.get("items", [])
+ if items:
+ rows = []
+ for item in items:
+ rows.append(
+ {
+ "항목": item["signal"],
+ "충족": "O" if item["pass"] else "X",
+ }
+ )
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+
+ return blocks
+
+
+def summaryFlagsBlock(flags: list[str]) -> list:
+ """calcSummaryFlags 결과 → FlagBlock."""
+ if not flags:
+ return []
+ return [FlagBlock(flags, kind="warning")]
+
+
+# ── 3부: 심화 분석 ──
+
+
+def _historyTable(
+ data: dict | None,
+ fields: list[tuple[str, str, str]],
+) -> dict[str, list[str]] | None:
+ """history 기반 시계열 → 행=지표, 열=기간 dict.
+
+ fields: [(key, rowLabel, fmt), ...] -- history item의 key, 행 라벨, 포맷.
+ """
+ if not data:
+ return None
+ history = data.get("history", [])
+ if not history:
+ return None
+
+ rowLabels = [f[1] for f in fields]
+ cols: dict[str, list[str]] = {"": rowLabels}
+ for h in history:
+ period = h["period"]
+ vals = []
+ for key, _, fmt in fields:
+ v = h.get(key)
+ if v is None:
+ vals.append("-")
+ elif fmt == "amt":
+ vals.append(_fmtAmtShort(v))
+ else:
+ vals.append(fmt.format(v))
+ cols[period] = vals
+ return cols if len(cols) > 1 else None
+
+
+# ── 3-1 이익품질 ──
+
+
+def accrualAnalysisBlock(data: dict) -> list:
+ """calcAccrualAnalysis 결과 → 발생액 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("sloanAccrualRatio", "Sloan 발생액비율", "{:.2f}"),
+ ("accrualToRevenue", "발생액/매출(%)", "{:.1f}%"),
+ ("ocfToNi", "영업CF/순이익(%)", "{:.0f}%"),
+ ],
+ )
+ if cols is None:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("accrualAnalysis").label,
+ level=2,
+ helper="발생액비율 0.10 이상 = 이익 현금화 부족",
+ ),
+ TableBlock("발생액 추이", pl.DataFrame(cols)),
+ ]
+ blocks.extend(_notesDetailBlocks(data, {"receivables": "매출채권 상세"}))
+ return blocks
+
+
+def earningsPersistenceBlock(data: dict) -> list:
+ """calcEarningsPersistence 결과 → 이익 지속성."""
+ if not data:
+ return []
+
+ cols = _historyTable(
+ data,
+ [
+ ("operatingIncome", "영업이익", "amt"),
+ ("nonOperatingIncome", "영업외손익", "amt"),
+ ("nonOpRatio", "영업외비중(%)", "{:.1f}%"),
+ ],
+ )
+ if cols is None:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("earningsPersistence").label,
+ level=2,
+ helper="영업외비중 30%+ = 일회성 이익 의존",
+ ),
+ TableBlock("이익 구성 추이", pl.DataFrame(cols)),
+ ]
+
+ cv = data.get("earningsVolatility")
+ if cv is not None:
+ blocks.append(MetricBlock([("이익 변동계수(CV)", f"{cv:.2f}")]))
+
+ return blocks
+
+
+def beneishMScoreBlock(data: dict) -> list:
+ """calcBeneishTimeline 결과 → M-Score 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("mScore", "M-Score", "{:.2f}"),
+ ],
+ )
+ if cols is None:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("beneishMScore").label,
+ level=2,
+ helper="M-Score > -1.78 임계값 초과 = 이익 조작 가능성",
+ ),
+ TableBlock("M-Score 추이", pl.DataFrame(cols)),
+ ]
+
+ threshold = data.get("threshold")
+ if threshold is not None:
+ blocks.append(TextBlock(f"임계값: {threshold}", style="dim", indent="h2"))
+
+ return blocks
+
+
+def earningsQualityFlagsBlock(data) -> list:
+ """calcEarningsQualityFlags 결과 → FlagBlock."""
+ if isinstance(data, dict):
+ return _enrichedFlagsBlock(data.get("flags", []), data.get("enrichedFlags"))
+ # 하위호환: list[str] 직접 전달
+ return _flagsBlock(data if isinstance(data, list) else [])
+
+
+# ── 3-2 비용구조 ──
+
+
+def costBreakdownBlock(data: dict) -> list:
+ """calcCostBreakdown 결과 → 비용 비중 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("costOfSalesRatio", "매출원가율(%)", "{:.1f}%"),
+ ("sgaRatio", "판관비율(%)", "{:.1f}%"),
+ ("operatingCostRatio", "영업비용률(%)", "{:.1f}%"),
+ ],
+ )
+ if cols is None:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("costBreakdown").label,
+ level=2,
+ helper="원가율+판관비율 = 영업비용률, 100에서 빼면 영업이익률",
+ ),
+ TableBlock("비용 비중 추이", pl.DataFrame(cols)),
+ ]
+ blocks.extend(_notesDetailBlocks(data, {"costByNature": "비용 성격별 분류"}))
+ return blocks
+
+
+def operatingLeverageBlock(data: dict) -> list:
+ """calcOperatingLeverage 결과 → DOL 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("dol", "DOL", "{:.1f}"),
+ ("contributionProxy", "매출총이익/영업이익", "{:.1f}"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("operatingLeverage").label,
+ level=2,
+ helper="DOL > 3 = 매출 변동에 이익이 크게 반응",
+ ),
+ TableBlock("영업레버리지 추이", pl.DataFrame(cols)),
+ ]
+
+
+def breakevenEstimateBlock(data: dict) -> list:
+ """calcBreakevenEstimate 결과 → BEP 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("revenue", "실제 매출", "amt"),
+ ("bepRevenue", "BEP 매출", "amt"),
+ ("marginOfSafety", "안전마진(%)", "{:.1f}%"),
+ ("variableCostRatio", "변동비율", "{:.2f}"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("breakevenEstimate").label,
+ level=2,
+ helper="안전마진 10% 미만 = 손익분기점 근접",
+ ),
+ TableBlock("손익분기점 추이", pl.DataFrame(cols)),
+ ]
+
+
+def costStructureFlagsBlock(flags: list[str]) -> list:
+ """calcCostStructureFlags 결과 → FlagBlock."""
+ return _flagsBlock(flags)
+
+
+# ── 3-3 자본배분 ──
+
+
+def dividendPolicyBlock(data: dict) -> list:
+ """calcDividendPolicy 결과 → 배당 정책 시계열."""
+ if not data:
+ return []
+
+ cols = _historyTable(
+ data,
+ [
+ ("dividendsPaid", "배당금", "amt"),
+ ("payoutRatio", "배당성향(%)", "{:.1f}%"),
+ ("dividendGrowth", "배당성장률(%)", "{:+.1f}%"),
+ ],
+ )
+ if cols is None:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("dividendPolicy").label,
+ level=2,
+ helper="배당성향 100%+ = 이익 초과 배당",
+ ),
+ TableBlock("배당 추이", pl.DataFrame(cols)),
+ ]
+
+ consecutive = data.get("consecutiveYears", 0)
+ if consecutive > 0:
+ blocks.append(MetricBlock([("연속 배당", f"{consecutive}년")]))
+
+ return blocks
+
+
+def shareholderReturnBlock(data: dict) -> list:
+ """calcShareholderReturn 결과 → 주주환원 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("dividendsPaid", "배당금", "amt"),
+ ("treasuryStockPurchase", "자사주 매입", "amt"),
+ ("totalReturn", "총환원", "amt"),
+ ("fcf", "FCF", "amt"),
+ ("returnToFcf", "환원/FCF(%)", "{:.0f}%"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("shareholderReturn").label,
+ level=2,
+ helper="환원/FCF 100%+ = FCF 초과 환원, 지속 불가",
+ ),
+ TableBlock("주주환원 추이", pl.DataFrame(cols)),
+ ]
+
+
+def reinvestmentBlock(data: dict) -> list:
+ """calcReinvestment 결과 → 재투자 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("capex", "CAPEX", "amt"),
+ ("capexToRevenue", "CAPEX/매출(%)", "{:.1f}%"),
+ ("retentionRate", "유보율(%)", "{:.1f}%"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("reinvestment").label,
+ level=2,
+ helper="유보율 = 1 - 배당성향, 재투자 여력",
+ ),
+ TableBlock("재투자 추이", pl.DataFrame(cols)),
+ ]
+
+
+def fcfUsageBlock(data: dict) -> list:
+ """calcFcfUsage 결과 → FCF 사용처 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("fcf", "FCF", "amt"),
+ ("dividendsPaid", "배당", "amt"),
+ ("debtRepaid", "부채상환", "amt"),
+ ("residual", "잔여", "amt"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("fcfUsage").label,
+ level=2,
+ helper="잔여 = FCF - 배당 - 부채상환 (현금 축적 또는 투자)",
+ ),
+ TableBlock("FCF 사용처 추이", pl.DataFrame(cols)),
+ ]
+
+
+def capitalAllocationFlagsBlock(flags: list[str]) -> list:
+ """calcCapitalAllocationFlags 결과 → FlagBlock."""
+ return _flagsBlock(flags)
+
+
+# ── 3-4 투자효율 ──
+
+
+def roicTimelineBlock(data: dict) -> list:
+ """calcRoicTimeline 결과 → ROIC/WACC/Spread 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("roic", "ROIC(%)", "{:.1f}%"),
+ ("waccEstimate", "WACC 추정(%)", "{:.1f}%"),
+ ("spread", "Spread(%p)", "{:+.1f}%p"),
+ ],
+ )
+ if cols is None:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("roicTimeline").label,
+ level=2,
+ helper="Spread > 0 = 가치 창출, < 0 = 가치 파괴",
+ ),
+ ]
+ narration = narrateROIC(data)
+ if narration:
+ blocks.append(TextBlock(narration))
+ blocks.append(TableBlock("ROIC vs WACC 추이", pl.DataFrame(cols)))
+ return blocks
+
+
+def investmentIntensityBlock(data: dict) -> list:
+ """calcInvestmentIntensity 결과 → 투자 강도 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("capexToRevenue", "CAPEX/매출(%)", "{:.1f}%"),
+ ("tangibleRatio", "유형자산/총자산(%)", "{:.1f}%"),
+ ("intangibleRatio", "무형자산/총자산(%)", "{:.1f}%"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("investmentIntensity").label,
+ level=2,
+ helper="무형자산비율 급등 = 대규모 인수 또는 영업권 증가",
+ ),
+ TableBlock("투자 강도 추이", pl.DataFrame(cols)),
+ ]
+
+
+def evaTimelineBlock(data: dict) -> list:
+ """calcEvaTimeline 결과 → EVA 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("nopat", "NOPAT", "amt"),
+ ("investedCapital", "투하자본", "amt"),
+ ("waccEstimate", "WACC(%)", "{:.1f}%"),
+ ("eva", "EVA", "amt"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("evaTimeline").label,
+ level=2,
+ helper="EVA > 0 = 자본비용 이상 수익 창출",
+ ),
+ TableBlock("EVA 추이", pl.DataFrame(cols)),
+ ]
+
+
+def investmentFlagsBlock(flags: list[str]) -> list:
+ """calcInvestmentFlags 결과 → FlagBlock."""
+ return _flagsBlock(flags)
+
+
+# ── 3-5 재무정합성 ──
+
+
+def isCfDivergenceBlock(data: dict) -> list:
+ """calcIsCfDivergence 결과 → IS-CF 괴리 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("netIncome", "순이익", "amt"),
+ ("ocf", "영업CF", "amt"),
+ ("divergence", "괴리율(%)", "{:+.0f}%"),
+ ("direction", "방향", "{}"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("isCfDivergence").label,
+ level=2,
+ helper="괴리 > 50% = 순이익 대비 현금흐름 극심한 차이",
+ ),
+ TableBlock("IS-CF 괴리 추이", pl.DataFrame(cols)),
+ ]
+
+
+def isBsDivergenceBlock(data: dict) -> list:
+ """calcIsBsDivergence 결과 → IS-BS 괴리 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("revenueGrowth", "매출성장(%)", "{:+.1f}%"),
+ ("receivableGrowth", "매출채권성장(%)", "{:+.1f}%"),
+ ("inventoryGrowth", "재고성장(%)", "{:+.1f}%"),
+ ("revRecGap", "채권-매출 갭(%p)", "{:+.1f}%p"),
+ ("revInvGap", "재고-매출 갭(%p)", "{:+.1f}%p"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("isBsDivergence").label,
+ level=2,
+ helper="채권/재고 성장이 매출보다 20%p+ 빠르면 의심",
+ ),
+ TableBlock("IS-BS 괴리 추이", pl.DataFrame(cols)),
+ ]
+
+
+def anomalyScoreBlock(data: dict) -> list:
+ """calcAnomalyScore 결과 → 이상 점수 시계열."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("anomalyScore").label,
+ level=2,
+ helper="70점 이상 = 재무제표 신뢰성 주의",
+ ),
+ ]
+
+ # 점수 시계열
+ cols: dict[str, list[str]] = {"": ["종합 점수"]}
+ for h in history:
+ cols[h["period"]] = [f"{h['score']:.0f}"]
+ blocks.append(TableBlock("이상 점수 추이", pl.DataFrame(cols)))
+
+ # 최신 구성요소
+ h0 = history[0]
+ components = h0.get("components", {})
+ if components:
+ metrics = [(k, f"{v:.1f}") for k, v in components.items()]
+ blocks.append(MetricBlock(metrics))
+
+ return blocks
+
+
+def effectiveTaxRateBlock(data: dict) -> list:
+ """calcEffectiveTaxRate 결과 → 유효세율 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("effectiveTaxRate", "유효세율(%)", "{:.1f}%"),
+ ("statutoryRate", "법정세율(%)", "{:.0f}%"),
+ ("taxGap", "세율갭(%p)", "{:+.1f}%p"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("effectiveTaxRate").label,
+ level=2,
+ helper="유효세율 < 10% 극저, > 35% 고세율",
+ ),
+ TableBlock("유효세율 추이", pl.DataFrame(cols)),
+ ]
+
+
+def deferredTaxBlock(data: dict) -> list:
+ """calcDeferredTax 결과 → 이연법인세 시계열."""
+ cols = _historyTable(
+ data,
+ [
+ ("deferredTaxAsset", "이연법인세자산", "amt"),
+ ("deferredTaxLiability", "이연법인세부채", "amt"),
+ ("netDeferredTax", "순이연법인세", "amt"),
+ ("dtaToTotalAssets", "DTA/총자산(%)", "{:.2f}%"),
+ ],
+ )
+ if cols is None:
+ return []
+ return [
+ HeadingBlock(
+ _meta("deferredTax").label,
+ level=2,
+ helper="이연법인세자산 급증 = 미래 과세소득 가정 검토",
+ ),
+ TableBlock("이연법인세 추이", pl.DataFrame(cols)),
+ ]
+
+
+def crossStatementFlagsBlock(flags: list[str]) -> list:
+ """교차검증+세금 플래그 통합 -> FlagBlock."""
+ return _flagsBlock(flags)
+
+
+# ── 4부: 가치평가 빌더 ──
+
+
+def dcfValuationBlock(data: dict) -> list:
+ """calcDcf 결과 -> HeadingBlock + MetricBlock + TableBlock."""
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("dcfValuation").label,
+ level=2,
+ helper="FCF 기반 기업가치 추정 -- 할인율과 성장률 가정 확인 필수",
+ ),
+ ]
+ metrics = []
+ if data.get("perShareValue") is not None:
+ metrics.append(("적정가", f"{data['perShareValue']:,.0f}"))
+ if data.get("currentPrice") is not None:
+ metrics.append(("현재가", f"{data['currentPrice']:,.0f}"))
+ if data.get("marginOfSafety") is not None:
+ metrics.append(("안전마진", f"{data['marginOfSafety']:.1f}%"))
+ metrics.append(("할인율", f"{data.get('discountRate', 0):.1f}%"))
+ metrics.append(("영구성장률", f"{data.get('terminalGrowth', 0):.1f}%"))
+ if metrics:
+ blocks.append(MetricBlock(metrics))
+
+ # FCF 추정 테이블
+ projections = data.get("fcfProjections", [])
+ if projections:
+ rows = [{"연차": f"Y{i + 1}", "FCF(조원)": round(v / 1e12, 1)} for i, v in enumerate(projections)]
+ blocks.append(TableBlock("FCF 추정", pl.DataFrame(rows)))
+
+ for w in data.get("warnings", []):
+ blocks.append(TextBlock(f"-- {w}", style="dim"))
+ return blocks
+
+
+def ddmValuationBlock(data: dict) -> list:
+ """calcDdm 결과 -> MetricBlock."""
+ if not data:
+ return []
+ if data.get("modelUsed") == "N/A":
+ return [
+ HeadingBlock(_meta("ddmValuation").label, level=2),
+ TextBlock("DDM 적용 불가 (무배당 또는 데이터 부족)", style="dim"),
+ ]
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("ddmValuation").label,
+ level=2,
+ helper="배당 기반 가치 -- 배당 지속성이 핵심 가정",
+ ),
+ ]
+ metrics: list[tuple[str, str]] = []
+ if data.get("intrinsicValue") is not None:
+ metrics.append(("적정가", f"{data['intrinsicValue']:,.0f}"))
+ if data.get("dividendPerShare") is not None:
+ metrics.append(("주당배당금", f"{data['dividendPerShare']:,.0f}"))
+ if data.get("dividendGrowth") is not None:
+ metrics.append(("배당성장률", f"{data['dividendGrowth']:.1f}%"))
+ if data.get("payoutRatio") is not None:
+ metrics.append(("배당성향", f"{data['payoutRatio']:.1f}%"))
+ if metrics:
+ blocks.append(MetricBlock(metrics))
+ return blocks
+
+
+def relativeValuationBlock(data: dict) -> list:
+ """calcRelativeValuation 결과 -> TableBlock."""
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("relativeValuation").label,
+ level=2,
+ helper="섹터 배수 대비 현재 배수 비교 -- 업종 평균과 괴리 확인",
+ ),
+ ]
+
+ implied = data.get("impliedValues", {})
+ sectorMults = data.get("sectorMultiples", {})
+ currentMults = data.get("currentMultiples", {})
+ premium = data.get("premiumDiscount", {})
+
+ rows = []
+ for key in ["PER", "PBR", "EV/EBITDA", "PSR", "PEG"]:
+ iv = implied.get(key)
+ if iv is None:
+ continue
+ row = {
+ "지표": key,
+ "섹터배수": f"{sectorMults.get(key, 0):.1f}" if sectorMults.get(key) else "-",
+ "현재배수": f"{currentMults.get(key, 0):.1f}" if currentMults.get(key) else "-",
+ "적정가": f"{iv:,.0f}",
+ }
+ pd = premium.get(key)
+ row["할증/할인"] = f"{pd:+.1f}%" if pd is not None else "-"
+ rows.append(row)
+
+ if rows:
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+
+ consensus = data.get("consensusValue")
+ if consensus:
+ blocks.append(MetricBlock([("종합 적정가", f"{consensus:,.0f}")]))
+ return blocks
+
+
+def residualIncomeBlock(data: dict) -> list:
+ """calcResidualIncome 결과 -> MetricBlock."""
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("residualIncome").label,
+ level=2,
+ helper="BPS + 초과이익 현가 -- 자기자본비용 대비 초과수익 평가",
+ ),
+ ]
+ metrics: list[tuple[str, str]] = []
+ if data.get("bps"):
+ metrics.append(("BPS", f"{data['bps']:,.0f}원"))
+ metrics.append(("자기자본비용", f"{data.get('coe', 0):.1f}%"))
+ if data.get("intrinsicValue") is not None:
+ metrics.append(("적정가", f"{data['intrinsicValue']:,.0f}원"))
+ if data.get("upside") is not None:
+ metrics.append(("업사이드", f"{data['upside']:+.1f}%"))
+ if metrics:
+ blocks.append(MetricBlock(metrics))
+ return blocks
+
+
+def priceTargetBlock(data: dict) -> list:
+ """calcPriceTarget 결과 -> MetricBlock + TableBlock(시나리오)."""
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("priceTarget").label,
+ level=2,
+ helper="5개 거시 시나리오 x DCF + Monte Carlo 분포",
+ ),
+ ]
+ metrics: list[tuple[str, str]] = [("가중 목표가", f"{data.get('weightedTarget', 0):,.0f}원")]
+ if data.get("currentPrice"):
+ metrics.append(("현재가", f"{data['currentPrice']:,.0f}원"))
+ if data.get("upside") is not None:
+ metrics.append(("업사이드", f"{data['upside']:+.1f}%"))
+ metrics.append(("투자 신호", data.get("signal", "-")))
+ metrics.append(("신뢰도", data.get("confidence", "-")))
+ blocks.append(MetricBlock(metrics))
+
+ # 시나리오 테이블
+ scenarios = data.get("scenarios", [])
+ if scenarios:
+ rows = []
+ for s in scenarios:
+ rows.append(
+ {
+ "시나리오": s["name"],
+ "확률": f"{s['probability'] * 100:.0f}%",
+ "목표가(원)": f"{s['perShareValue']:,.0f}",
+ }
+ )
+ blocks.append(TableBlock("시나리오별 목표가", pl.DataFrame(rows)))
+
+ # Monte Carlo 백분위
+ pctls = data.get("percentiles", {})
+ if pctls:
+ rows = [{"백분위": k, "주가(원)": f"{v:,.0f}"} for k, v in sorted(pctls.items())]
+ blocks.append(TableBlock("Monte Carlo 분포", pl.DataFrame(rows)))
+ return blocks
+
+
+def reverseImpliedBlock(data: dict) -> list:
+ """calcReverseImplied 결과 -> MetricBlock."""
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("reverseImplied").label,
+ level=2,
+ helper="현재 시가총액이 내재하는 매출 성장률 -- 시장 기대와 엔진 예측 비교",
+ ),
+ ]
+ metrics: list[tuple[str, str]] = [
+ ("내재성장률", f"{data.get('impliedGrowthRate', 0):.1f}%"),
+ ("최근 매출", f"{data.get('latestRevenue', 0) / 1e8:,.0f}억"),
+ ("가정 WACC", f"{data.get('assumedWacc', 0):.1f}%"),
+ ]
+ signal = data.get("signal")
+ if signal:
+ metrics.append(("신호", signal))
+ blocks.append(MetricBlock(metrics))
+ return blocks
+
+
+def sensitivityBlock(data: dict) -> list:
+ """calcSensitivity 결과 -> TableBlock(그리드)."""
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("sensitivity").label,
+ level=2,
+ helper="WACC x 영구성장률 조합별 적정가 변화",
+ ),
+ ]
+
+ grid = data.get("grid", [])
+ if not grid:
+ return blocks
+
+ # 피벗: 행=WACC, 열=성장률
+ waccVals = sorted({g["wacc"] for g in grid})
+ growthVals = sorted({g["terminalGrowth"] for g in grid})
+
+ lookup = {(g["wacc"], g["terminalGrowth"]): g.get("perShareValue") for g in grid}
+
+ rows = []
+ for wacc in waccVals:
+ row: dict = {"WACC": f"{wacc:.1f}%"}
+ for tg in growthVals:
+ val = lookup.get((wacc, tg))
+ colName = f"g={tg:.1f}%"
+ row[colName] = f"{val:,.0f}원" if val is not None else "-"
+ rows.append(row)
+
+ if rows:
+ blocks.append(TableBlock("WACC x 성장률 민감도 (주당 적정가)", pl.DataFrame(rows)))
+
+ baseVal = data.get("baseValue")
+ if baseVal is not None:
+ blocks.append(MetricBlock([("기준 적정가", f"{baseVal:,.0f}원")]))
+ return blocks
+
+
+def valuationSynthesisBlock(data: dict, priceTargetData: dict | None = None) -> list:
+ """calcValuationSynthesis 결과 -> MetricBlock + TextBlock.
+
+ priceTargetData 인자를 받아 두 모델 (synthesis 보수적 vs priceTarget 시나리오
+ 가중) 차이를 narration 으로 자동 추가하여 사용자 혼란을 해소한다.
+ """
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("valuationSynthesis").label,
+ level=2,
+ helper="DCF + DDM + 상대가치 종합 -- 모델 간 범위와 판정",
+ ),
+ ]
+
+ narration = narrateValuation(data)
+ if narration:
+ blocks.append(TextBlock(narration))
+
+ fvr = data.get("fairValueRange")
+ metrics: list[tuple[str, str]] = []
+ if fvr:
+ metrics.append(("적정가 범위", f"{fvr[0]:,.0f}원 ~ {fvr[1]:,.0f}원"))
+ if data.get("currentPrice"):
+ metrics.append(("현재가", f"{data['currentPrice']:,.0f}원"))
+ metrics.append(("판정", data.get("verdict", "-")))
+ blocks.append(MetricBlock(metrics))
+
+ # 모델별 추정치
+ estimates = data.get("estimates", [])
+ if estimates:
+ rows = [{"모델": e["method"], "적정가(원)": f"{e['value']:,.0f}"} for e in estimates]
+ blocks.append(TableBlock("모델별 적정가", pl.DataFrame(rows)))
+
+ # 두 모델 통합 narration
+ if priceTargetData:
+ synthFair = data.get("weightedFairValue")
+ ptFair = priceTargetData.get("weightedTarget")
+ data.get("currentPrice") or priceTargetData.get("currentPrice")
+ if synthFair and ptFair and synthFair > 0 and ptFair > 0:
+ divergence = abs(ptFair - synthFair) / synthFair * 100
+ ratio = ptFair / synthFair
+ if divergence > 30:
+ if ptFair > synthFair:
+ direction = (
+ f"시장 기대가 모델 평균보다 **{ratio:.1f}배 낙관적**. "
+ f"확률가중 목표가 ({ptFair:,.0f}원) 가 모델 종합 적정가 ({synthFair:,.0f}원) 를 크게 상회"
+ )
+ else:
+ direction = (
+ f"시장 기대가 모델 평균보다 **{1 / ratio:.1f}배 보수적**. "
+ f"확률가중 목표가 ({ptFair:,.0f}원) 가 모델 종합 적정가 ({synthFair:,.0f}원) 보다 낮음"
+ )
+ blocks.append(
+ TextBlock(
+ f"두 모델 차이 {divergence:.0f}%: {direction}. "
+ f"종합 적정가는 DCF/DDM/RIM/상대가치 모델 평균(보수적), "
+ f"확률가중 목표가는 5개 거시 시나리오 시뮬레이션(시장 기대 기반)."
+ )
+ )
+ else:
+ blocks.append(
+ TextBlock(
+ f"두 모델 수렴 (차이 {divergence:.0f}%): "
+ f"종합 적정가 {synthFair:,.0f}원 vs 확률가중 목표가 {ptFair:,.0f}원. "
+ f"보수적 모델 평균과 시장 기대 시나리오가 일치 — 신뢰도 높음."
+ )
+ )
+
+ return blocks
+
+
+def valuationFlagsBlock(flags: list[dict]) -> list:
+ """calcValuationFlags 결과 -> FlagBlock."""
+ if not flags:
+ return []
+ flagTexts = []
+ for f in flags:
+ prefix = {"warning": "[!]", "opportunity": "[+]", "info": "[i]"}.get(f.get("signal", ""), "")
+ flagTexts.append(f"{prefix} {f.get('label', '')}")
+ return _flagsBlock(flagTexts)
+
+
+# ── 5-1 지배구조 ──
+
+
+def ownershipTrendBlock(data: dict) -> list:
+ """calcOwnershipTrend 결과 -> 지분 추이 테이블 + 주주 구성."""
+ if not data:
+ return []
+ blocks: list = []
+
+ history = data.get("history", [])
+ if history:
+ rows = []
+ for h in history:
+ row: dict = {"연도": h["year"]}
+ row["지분율(%)"] = h["ratio"]
+ row["변동(%p)"] = h.get("change")
+ rows.append(row)
+ blocks.append(
+ HeadingBlock(
+ _meta("ownershipTrend").label,
+ level=2,
+ helper="연도별 최대주주 지분율 추이",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+
+ holders = data.get("latestHolders", [])
+ if holders:
+ hRows = []
+ for h in holders[:5]:
+ hRows.append(
+ {
+ "성명": h.get("name", ""),
+ "관계": h.get("relate", ""),
+ "지분율(%)": h.get("ratio"),
+ }
+ )
+ blocks.append(TableBlock("최근 주요 주주", pl.DataFrame(hRows)))
+
+ return blocks
+
+
+def boardCompositionBlock(data: dict) -> list:
+ """calcBoardComposition 결과 -> 이사회 구성 메트릭."""
+ if not data:
+ return []
+ metrics = [
+ ("전체 임원", f"{data['totalCount']}명"),
+ ("사내이사", f"{data['registeredCount']}명"),
+ ("사외이사", f"{data['outsideCount']}명"),
+ ]
+ outsideRatio = data.get("outsideRatio")
+ if outsideRatio is not None:
+ metrics.append(("사외이사비율", f"{outsideRatio:.1f}%"))
+ return [
+ HeadingBlock(
+ _meta("boardComposition").label,
+ level=2,
+ helper="사외이사비율로 이사회 독립성을 판단한다",
+ ),
+ MetricBlock(metrics),
+ ]
+
+
+def auditOpinionTrendBlock(data: dict) -> list:
+ """calcAuditOpinionTrend 결과 -> 감사의견 시계열."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+ rows = []
+ for h in history:
+ row: dict = {"연도": h["year"], "감사의견": h.get("opinion", "")}
+ auditor = h.get("auditor", "")
+ if auditor:
+ row["감사인"] = auditor
+ if h.get("auditorChanged"):
+ row["변경"] = "Y"
+ rows.append(row)
+ return [
+ HeadingBlock(
+ _meta("auditOpinionTrend").label,
+ level=2,
+ helper="감사의견과 감사인 변경을 추적한다",
+ ),
+ TableBlock("", pl.DataFrame(rows)),
+ ]
+
+
+def governanceFlagsBlock(flags: list[tuple[str, str]]) -> list:
+ """calcGovernanceFlags 결과 -> FlagBlock."""
+ return _tupleFlags(flags)
+
+
+# ── 5-2 공시변화 ──
+
+
+def disclosureChangeSummaryBlock(data: dict) -> list:
+ """calcDisclosureChangeSummary 결과 -> 요약 메트릭 + 상위 변화 테이블."""
+ if not data:
+ return []
+ blocks: list = []
+
+ metrics = [
+ ("변화 건수", f"{data['totalChanges']}건"),
+ ("변화 topic", f"{data['changedTopics']}/{data['totalTopics']}"),
+ ("무변화 topic", f"{data['unchangedTopics']}"),
+ ]
+ blocks.append(
+ HeadingBlock(
+ _meta("disclosureChangeSummary").label,
+ level=2,
+ helper="전체 topic 변화 현황",
+ )
+ )
+ blocks.append(MetricBlock(metrics))
+
+ topChanged = data.get("topChanged", [])
+ if topChanged:
+ rows = [
+ {"topic": t["topic"], "변화횟수": t["changedCount"], "변화율": f"{t['changeRate']:.0%}"}
+ for t in topChanged[:5]
+ ]
+ blocks.append(TableBlock("변화율 상위 topic", pl.DataFrame(rows)))
+
+ return blocks
+
+
+def keyTopicChangesBlock(data: dict) -> list:
+ """calcKeyTopicChanges 결과 -> 핵심 topic 변화 테이블."""
+ if not data:
+ return []
+ keyTopics = data.get("keyTopics", [])
+ if not keyTopics:
+ return []
+ rows = [
+ {
+ "topic": kt["topic"],
+ "기간수": kt["totalPeriods"],
+ "변화": kt["changedCount"],
+ "변화율": f"{kt['changeRate']:.0%}",
+ }
+ for kt in keyTopics
+ ]
+ return [
+ HeadingBlock(
+ _meta("keyTopicChanges").label,
+ level=2,
+ helper="핵심 공시 topic의 변화 이력",
+ ),
+ TableBlock("", pl.DataFrame(rows)),
+ ]
+
+
+def changeIntensityBlock(data: dict) -> list:
+ """calcChangeIntensity 결과 -> 변화 크기 테이블."""
+ if not data:
+ return []
+ topByDelta = data.get("topByDelta", [])
+ if not topByDelta:
+ return []
+ rows = [{"topic": t["topic"], "변화량(bytes)": t["totalDeltaBytes"]} for t in topByDelta[:5]]
+ return [
+ HeadingBlock(
+ _meta("changeIntensity").label,
+ level=2,
+ helper="바이트 기준 변화량 상위 topic",
+ ),
+ TableBlock("", pl.DataFrame(rows)),
+ ]
+
+
+def disclosureDeltaFlagsBlock(flags: list[tuple[str, str]]) -> list:
+ """calcDisclosureDeltaFlags 결과 -> FlagBlock."""
+ return _tupleFlags(flags)
+
+
+# ── 5-3 비교분석 ──
+
+
+def peerRankingBlock(data: dict) -> list:
+ """calcPeerRanking 결과 -> 백분위 순위 테이블."""
+ if not data:
+ return []
+ rankings = data.get("rankings", [])
+ if not rankings:
+ return []
+ rows = [
+ {
+ "지표": r["label"],
+ "값": r["value"],
+ "백분위": f"{r['percentile']:.0f}%",
+ "순위": f"{r['rank']}/{r['total']}",
+ }
+ for r in rankings
+ ]
+ return [
+ HeadingBlock(
+ _meta("peerRanking").label,
+ level=2,
+ helper="전종목 대비 핵심 비율 위치 (백분위가 높을수록 상위)",
+ ),
+ TableBlock("", pl.DataFrame(rows)),
+ ]
+
+
+def riskReturnPositionBlock(data: dict) -> list:
+ """calcRiskReturnPosition 결과 -> 사분면 메트릭."""
+ if not data:
+ return []
+ metrics = [
+ ("ROE", f"{data['roe']:.1f}% (상위 {100 - data['roePercentile']:.0f}%)"),
+ ("부채비율", f"{data['debtRatio']:.1f}% (상위 {100 - data['debtRatioPercentile']:.0f}%)"),
+ ("포지션", data["quadrant"]),
+ ("평가", data["assessment"]),
+ ]
+ return [
+ HeadingBlock(
+ _meta("riskReturnPosition").label,
+ level=2,
+ helper="ROE(수익) x 부채비율(위험) 사분면 위치",
+ ),
+ MetricBlock(metrics),
+ ]
+
+
+def peerBenchmarkFlagsBlock(flags: list[tuple[str, str]]) -> list:
+ """calcPeerBenchmarkFlags 결과 -> FlagBlock."""
+ return _tupleFlags(flags)
+
+
+def _tupleFlags(flags: list[tuple[str, str]]) -> list:
+ """(message, kind) 튜플 리스트 -> FlagBlock(s)."""
+ if not flags:
+ return []
+ warnings = [f for f, k in flags if k == "warning"]
+ opportunities = [f for f, k in flags if k == "opportunity"]
+ blocks: list = []
+ if warnings:
+ blocks.append(FlagBlock(warnings, kind="warning"))
+ if opportunities:
+ blocks.append(FlagBlock(opportunities, kind="opportunity"))
+ return blocks
+
+
+# ── 6-1 매출전망 빌더 ──
+
+
+def _fmtEstimate(value: float | None, currency: str = "KRW") -> str:
+ """추정치 포맷팅 (억원/M 단위)."""
+ if value is None:
+ return "-"
+ if currency == "KRW":
+ return f"{value / 1e8:,.0f}억(E)"
+ return f"${value / 1e6:,.0f}M(E)"
+
+
+def revenueForecastBlock(data: dict) -> list:
+ """calcRevenueForecast -> 시나리오 테이블 + 신뢰도."""
+ if not data:
+ return []
+
+ # 예측 불가 판정 시 경고만 표시
+ if not data.get("forecastable", True):
+ reason = data.get("unforecastableReason", "")
+ return [
+ HeadingBlock(
+ _meta("revenueForecast").label,
+ level=2,
+ helper="7-소스 앙상블 매출 예측 -- 모든 수치는 추정치",
+ ),
+ TextBlock(f"이 기업은 현재 정량 예측이 불가능합니다: {reason}"),
+ ]
+
+ cur = data.get("currency", "KRW")
+ blocks: list = [
+ HeadingBlock(
+ _meta("revenueForecast").label,
+ level=2,
+ helper="7-소스 앙상블 매출 예측 -- 모든 수치는 추정치",
+ ),
+ ]
+
+ # 신뢰도 + 방법론 요약
+ metrics = [
+ ("방법", data.get("method", "")),
+ ("신뢰도", data.get("confidence", "")),
+ ]
+ lifecycle = data.get("lifecycle", "")
+ if lifecycle:
+ metrics.append(("라이프사이클", lifecycle))
+ blocks.append(MetricBlock(metrics))
+
+ # 시나리오 테이블
+ scenarios = data.get("scenarios", {})
+ if scenarios:
+ rows = []
+ for label in ("bull", "base", "bear"):
+ sc = scenarios.get(label)
+ if not sc:
+ continue
+ proj = sc.get("projected", [])
+ gr = sc.get("growthRates", [])
+ prob = sc.get("probability", 0)
+ row = {"시나리오": f"{label.title()} ({prob:.0f}%)"}
+ for i, (p, g) in enumerate(zip(proj, gr)):
+ row[f"+{i + 1}년"] = f"{_fmtEstimate(p, cur)} ({g:+.1f}%)"
+ rows.append(row)
+ if rows:
+ blocks.append(TableBlock("[추정] 시나리오별 매출 전망", pl.DataFrame(rows)))
+ else:
+ # 시나리오 없이 기본 전망만
+ projected = data.get("projected", [])
+ growthRates = data.get("growthRates", [])
+ if projected:
+ rows = []
+ for i, (p, g) in enumerate(zip(projected, growthRates)):
+ rows.append({"연차": f"+{i + 1}년", "매출": _fmtEstimate(p, cur), "성장률": f"{g:+.1f}%"})
+ blocks.append(TableBlock("[추정] 매출 전망", pl.DataFrame(rows)))
+
+ blocks.append(TextBlock(data.get("disclaimer", ""), style="dim"))
+ return blocks
+
+
+def segmentForecastBlock(data: dict) -> list:
+ """calcSegmentForecast -> 세그먼트별 성장 테이블."""
+ if not data:
+ return []
+ segments = data.get("segments", [])
+ if not segments:
+ return []
+
+ data.get("currency", "KRW")
+ blocks: list = [
+ HeadingBlock(
+ _meta("segmentForecast").label,
+ level=2,
+ helper="부문별 개별 매출 성장 전망",
+ ),
+ ]
+
+ rows = []
+ for seg in segments:
+ gr = seg.get("growthRates", [])
+ row = {
+ "부문": seg.get("name", ""),
+ "매출비중": f"{seg.get('shareOfRevenue', 0):.1f}%",
+ "방법": seg.get("method", ""),
+ }
+ for i, g in enumerate(gr):
+ row[f"+{i + 1}년"] = f"{g:+.1f}%"
+ rows.append(row)
+ if rows:
+ blocks.append(TableBlock("[추정] 세그먼트별 성장률", pl.DataFrame(rows)))
+
+ return blocks
+
+
+def proFormaHighlightsBlock(data: dict) -> list:
+ """calcProFormaHighlights -> IS 요약 전망 테이블."""
+ if not data:
+ return []
+ years = data.get("years", [])
+ if not years:
+ return []
+
+ cur = data.get("currency", "KRW")
+ blocks: list = [
+ HeadingBlock(
+ _meta("proFormaHighlights").label,
+ level=2,
+ helper="매출 성장 경로에 따른 IS/CF 핵심 전망",
+ ),
+ ]
+
+ # WACC + 성장률
+ metrics = [("WACC", f"{data.get('wacc', 0):.1f}%")]
+ grPath = data.get("revenueGrowthPath", [])
+ if grPath:
+ metrics.append(("성장률 경로", " -> ".join(f"{g:+.1f}%" for g in grPath)))
+ blocks.append(MetricBlock(metrics))
+
+ # 전망 테이블
+ rows = []
+ for yr in years:
+ rows.append(
+ {
+ "연차": f"+{yr['yearOffset']}년",
+ "매출": _fmtEstimate(yr.get("revenue"), cur),
+ "영업이익": _fmtEstimate(yr.get("operatingIncome"), cur),
+ "순이익": _fmtEstimate(yr.get("netIncome"), cur),
+ "FCF": _fmtEstimate(yr.get("fcf"), cur),
+ }
+ )
+ blocks.append(TableBlock("[추정] Pro-Forma IS 요약", pl.DataFrame(rows)))
+
+ for w in data.get("warnings", []):
+ blocks.append(TextBlock(f"-- {w}", style="dim"))
+ return blocks
+
+
+def scenarioImpactBlock(data: dict) -> list:
+ """calcScenarioImpact -> 매크로 시나리오 비교 그리드."""
+ if not data:
+ return []
+ scenarios = data.get("scenarios", {})
+ if not scenarios:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("scenarioImpact").label,
+ level=2,
+ helper="거시경제 시나리오별 매출/마진 영향 비교",
+ ),
+ ]
+
+ rows = []
+ for name, sc in scenarios.items():
+ rows.append(
+ {
+ "시나리오": sc.get("label", name),
+ "매출변화": f"{sc.get('revenueChangePct', 0):+.1f}%",
+ "마진변화": f"{sc.get('marginChangeBps', 0):+.0f}bps",
+ }
+ )
+ blocks.append(TableBlock("[추정] 매크로 시나리오 영향", pl.DataFrame(rows)))
+ return blocks
+
+
+def forecastMethodologyBlock(data: dict) -> list:
+ """calcForecastMethodology -> 소스 가중치 + 가정."""
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("forecastMethodology").label,
+ level=2,
+ helper="예측 방법론 투명성 공개",
+ ),
+ ]
+
+ # 소스 가중치
+ weights = data.get("sourceWeights", {})
+ if weights:
+ metrics = [(src, f"{w:.0%}") for src, w in weights.items()]
+ blocks.append(MetricBlock(metrics))
+
+ # 가정
+ assumptions = data.get("assumptions", [])
+ if assumptions:
+ for a in assumptions:
+ blocks.append(TextBlock(f"- {a}", style="dim"))
+
+ # 경고
+ for w in data.get("warnings", []):
+ blocks.append(TextBlock(f"-- {w}", style="dim"))
+
+ return blocks
+
+
+def historicalRatiosBlock(data: dict) -> list:
+ """calcHistoricalRatios -> 과거 구조 비율."""
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("historicalRatios").label,
+ level=2,
+ helper="Pro-Forma의 기반이 되는 과거 재무 비율",
+ ),
+ ]
+
+ metrics = [
+ ("매출총이익률", f"{data.get('grossMargin', 0):.1f}%"),
+ ("판관비율", f"{data.get('sgaRatio', 0):.1f}%"),
+ ("유효세율", f"{data.get('effectiveTaxRate', 0):.1f}%"),
+ ("CAPEX/매출", f"{data.get('capexToRevenue', 0):.1f}%"),
+ ("NWC/매출", f"{data.get('nwcToRevenue', 0):.1f}%"),
+ ("배당성향", f"{data.get('dividendPayout', 0):.1f}%"),
+ ("사용 연수", f"{data.get('yearsUsed', 0)}년"),
+ ("신뢰도", data.get("confidence", "")),
+ ]
+ blocks.append(MetricBlock(metrics))
+
+ for w in data.get("warnings", []):
+ blocks.append(TextBlock(f"-- {w}", style="dim"))
+ return blocks
+
+
+def forecastFlagsBlock(data: dict) -> list:
+ """calcForecastFlags -> FlagBlock."""
+ if not data:
+ return []
+ flags = data.get("flags", [])
+ if not flags:
+ return []
+ messages = [msg for _, msg in flags]
+ return [FlagBlock(messages, kind="warning")]
+
+
+def calibrationReportBlock(data: dict) -> list:
+ """calcCalibrationReport -> Brier Score + bin 테이블."""
+ if not data:
+ return []
+ blocks: list = [
+ HeadingBlock(
+ _meta("calibrationReport").label,
+ level=2,
+ helper="과거 예측 확률의 실제 적중률 검증 (Brier Score)",
+ ),
+ ]
+ metrics = [
+ ("Brier Score", f"{data['brierScore']:.4f}"),
+ ("평가 건수", str(data.get("nRecords", 0))),
+ ]
+ blocks.append(MetricBlock(metrics))
+
+ bins = data.get("bins", [])
+ if bins:
+ import polars as pl
+
+ rows = [
+ {
+ "구간": f"{b['binLower']:.0%}~{b['binUpper']:.0%}",
+ "평균 예측": f"{b['meanPredicted']:.1%}",
+ "실제 적중": f"{b['meanActual']:.1%}",
+ "괴리": f"{b['gap']:.1%}",
+ "건수": str(b["count"]),
+ }
+ for b in bins
+ ]
+ blocks.append(TableBlock("확률 구간별 적중률", pl.DataFrame(rows)))
+
+ return blocks
+
+
+# ── Penman 분해 빌더 ──
+
+
+def penmanDecompositionBlock(data: dict) -> list:
+ """calcPenmanDecomposition → HeadingBlock + TableBlock."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ rows = []
+ for h in history:
+ rows.append(
+ {
+ "기간": h["period"],
+ "RNOA": f"{h['rnoa']:.1f}%" if h.get("rnoa") is not None else "-",
+ "FLEV": f"{h['flev']:.2f}" if h.get("flev") is not None else "-",
+ "NBC": f"{h['nbc']:.1f}%" if h.get("nbc") is not None else "-",
+ "SPREAD": f"{h['spread']:.1f}%p" if h.get("spread") is not None else "-",
+ "레버리지효과": f"{h['leverageEffect']:.1f}%p" if h.get("leverageEffect") is not None else "-",
+ "ROCE": f"{h['roce']:.1f}%" if h.get("roce") is not None else "-",
+ }
+ )
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("penmanDecomposition").label,
+ level=2,
+ helper="RNOA > NBC이면 차입이 주주에게 유리 (양의 SPREAD)",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+ return blocks
+
+
+# ── ROIC Tree 빌더 ──
+
+
+def roicTreeBlock(data: dict) -> list:
+ """calcRoicTree → HeadingBlock + TableBlock + TextBlock(driver)."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ rows = []
+ for h in history:
+ rows.append(
+ {
+ "기간": h["period"],
+ "ROIC": f"{h['roic']:.1f}%" if h.get("roic") is not None else "-",
+ "영업마진": f"{h['operatingMargin']:.1f}%" if h.get("operatingMargin") is not None else "-",
+ "자본회전": f"{h['capitalTurnover']:.2f}x" if h.get("capitalTurnover") is not None else "-",
+ "매출총이익률": f"{h['grossMargin']:.1f}%" if h.get("grossMargin") is not None else "-",
+ "판관비율": f"{h['sgaRatio']:.1f}%" if h.get("sgaRatio") is not None else "-",
+ }
+ )
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("roicTree").label,
+ level=2,
+ helper="ROIC = 영업마진 × 자본회전. 어느 쪽이 ROIC를 결정하는가",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+
+ latest = history[-1]
+ drivers = []
+ if latest.get("marginDriver"):
+ drivers.append(f"마진 드라이버: {latest['marginDriver']}")
+ if latest.get("turnoverDriver"):
+ drivers.append(f"회전 드라이버: {latest['turnoverDriver']}")
+ if drivers:
+ blocks.append(TextBlock(" | ".join(drivers), style="dim"))
+
+ return blocks
+
+
+# ── OCF 분해 빌더 ──
+
+
+def ocfDecompositionBlock(data: dict) -> list:
+ """calcOcfDecomposition → HeadingBlock + TableBlock."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ rows = []
+ for h in history:
+ rows.append(
+ {
+ "기간": h["period"],
+ "순이익": h.get("ni"),
+ "감가상각(추정)": h.get("depEstimate"),
+ "운전자본효과": h.get("wcEffect"),
+ "영업CF": h.get("ocf"),
+ "잔차": h.get("residual"),
+ }
+ )
+
+ unified = unifyTableScale(rows, "기간", ["순이익", "감가상각(추정)", "운전자본효과", "영업CF", "잔차"], unit="won")
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("ocfDecomposition").label,
+ level=2,
+ helper="OCF ≈ NI + 감가상각 + 운전자본. 잔차가 크면 비경상 항목",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(unified)))
+ return blocks
+
+
+# ── Richardson 3계층 발생액 빌더 ──
+
+
+def richardsonAccrualBlock(data: dict) -> list:
+ """calcRichardsonAccrual → HeadingBlock + TableBlock."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ rows = []
+ for h in history:
+ rows.append(
+ {
+ "기간": h["period"],
+ "WCACC": h.get("wcacc"),
+ "LTOACC": h.get("ltoacc"),
+ "FINACC": h.get("finacc"),
+ "총발생액": h.get("totalAccrual"),
+ "신뢰도": h.get("reliabilityScore", "-"),
+ }
+ )
+
+ valueCols = ["WCACC", "LTOACC", "FINACC", "총발생액"]
+ unified = unifyTableScale(rows, "기간", valueCols, unit="millions")
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("richardsonAccrual").label,
+ level=2,
+ helper="LTOACC 비중이 높을수록 이익 지속성 낮음 (신뢰도↓)",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(unified)))
+ return blocks
+
+
+# ── 영업외손익 분해 빌더 ──
+
+
+def nonOperatingBreakdownBlock(data: dict) -> list:
+ """calcNonOperatingBreakdown → HeadingBlock + TableBlock."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ rows = []
+ for h in history:
+ rows.append(
+ {
+ "기간": h["period"],
+ "영업이익": h.get("opIncome"),
+ "금융수익": h.get("finIncome"),
+ "금융비용": h.get("finCost"),
+ "지분법": h.get("associateIncome"),
+ "기타수익": h.get("otherIncome"),
+ "기타비용": h.get("otherExpense"),
+ "영업외비율": f"{h['nonOpRatio']:.0f}%" if h.get("nonOpRatio") is not None else "-",
+ }
+ )
+
+ valueCols = ["영업이익", "금융수익", "금융비용", "지분법", "기타수익", "기타비용"]
+ unified = unifyTableScale(rows, "기간", valueCols, unit="millions")
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("nonOperatingBreakdown").label,
+ level=2,
+ helper="영업외 > 30%이면 영업만으로 기업 판단 불가",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(unified)))
+ blocks.extend(_notesDetailBlocks(data, {"affiliates": "관계기업 투자"}))
+ return blocks
+
+
+# ── CAGR 비교 빌더 ──
+
+
+def cagrComparisonBlock(data: dict) -> list:
+ """calcCagrComparison → HeadingBlock + TableBlock."""
+ if not data:
+ return []
+ comparisons = data.get("comparisons", [])
+ if not comparisons:
+ return []
+
+ rows = []
+ for c in comparisons:
+ rows.append(
+ {
+ "비교": c["label"],
+ c["item1"]: f"{c['cagr1']:+.1f}%" if c.get("cagr1") is not None else "-",
+ c["item2"]: f"{c['cagr2']:+.1f}%" if c.get("cagr2") is not None else "-",
+ "갭": f"{c['gap']:+.1f}%p" if c.get("gap") is not None else "-",
+ "시그널": c.get("signal", "-"),
+ }
+ )
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("cagrComparison").label,
+ level=2,
+ helper="매출 vs 이익 CAGR 갭 → 마진 방향, 자산 vs 매출 갭 → 효율 방향",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+ return blocks
+
+
+# ── BS-CF 정합성 빌더 ──
+
+
+def articulationCheckBlock(data: dict) -> list:
+ """calcArticulationCheck → HeadingBlock + TableBlock."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ rows = []
+ for h in history:
+ rows.append(
+ {
+ "기간": h["period"],
+ "PPE오차": f"{h['ppeError']:.1f}%" if h.get("ppeError") is not None else "-",
+ "현금오차": f"{h['cashError']:.1f}%" if h.get("cashError") is not None else "-",
+ "자본오차": f"{h['equityError']:.1f}%" if h.get("equityError") is not None else "-",
+ "최대오차": f"{h['maxErrorPct']:.1f}%" if h.get("maxErrorPct") is not None else "-",
+ }
+ )
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("articulationCheck").label,
+ level=2,
+ helper="오차 > 10%이면 연결범위 변동/환율효과/재분류 의심",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+ return blocks
+
+
+# ── 3-6 신용평가 빌더 ──
+
+
+def creditMetricsBlock(data: dict) -> list:
+ """calcCreditMetrics 결과 → 핵심 지표 시계열 테이블."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ rows = []
+ for h in history:
+ rows.append(
+ {
+ "기간": h["period"],
+ "EBITDA/이자": f"{h['ebitdaInterestCoverage']:.1f}x"
+ if h.get("ebitdaInterestCoverage") is not None
+ else "-",
+ "Debt/EBITDA": f"{h['debtToEbitda']:.1f}x" if h.get("debtToEbitda") is not None else "-",
+ "FFO/Debt": f"{h['ffoToDebt']:.0f}%" if h.get("ffoToDebt") is not None else "-",
+ "부채비율": f"{h['debtRatio']:.0f}%" if h.get("debtRatio") is not None else "-",
+ "유동비율": f"{h['currentRatio']:.0f}%" if h.get("currentRatio") is not None else "-",
+ "OCF/매출": f"{h['ocfToSales']:.1f}%" if h.get("ocfToSales") is not None else "-",
+ }
+ )
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("creditMetrics").label,
+ level=2,
+ helper="ICR>10 AA급 | Debt/EBITDA<1.5 A급 | FFO/Debt>40% 양호",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+ return blocks
+
+
+def creditScoreBlock(data: dict) -> list:
+ """calcCreditScore 결과 → 등급 종합 메트릭."""
+ if not data:
+ return []
+
+ grade = data.get("grade", "?")
+ desc = data.get("gradeDescription", "")
+ score = data.get("score", 0)
+ pd_est = data.get("pdEstimate", 0)
+ ecr = data.get("eCR", "?")
+ outlook = data.get("outlook", "N/A")
+ sector = data.get("sector", "")
+ inv = "투자적격" if data.get("investmentGrade") else "투기등급"
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("creditScore").label,
+ level=2,
+ helper=f"등급 {grade} ({desc}) | {inv} | PD {pd_est:.2f}%",
+ )
+ )
+ blocks.append(
+ MetricBlock(
+ [
+ ("신용등급", f"{grade} ({desc})"),
+ ("종합 점수", f"{score:.1f}/100"),
+ ("부도확률(1Y)", f"{pd_est:.2f}%"),
+ ("현금흐름등급", ecr),
+ ("등급 전망", outlook),
+ ("업종", sector),
+ ]
+ )
+ )
+
+ # 5축 상세
+ axes = data.get("axes", [])
+ if axes:
+ axisRows = []
+ for a in axes:
+ axisRows.append(
+ {
+ "축": a.get("name", ""),
+ "점수": f"{a['score']:.1f}" if a.get("score") is not None else "-",
+ "비중": f"{a.get('weight', 0)}%",
+ "지표수": str(len(a.get("metrics", []))),
+ }
+ )
+ blocks.append(TableBlock("5축 가중평균 상세", pl.DataFrame(axisRows)))
+
+ return blocks
+
+
+def creditHistoryBlock(data: dict) -> list:
+ """calcCreditHistory 결과 → 등급 시계열."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ rows = []
+ for h in history:
+ rows.append(
+ {
+ "기간": h["period"],
+ "등급": h.get("grade", "?"),
+ "점수": f"{h['score']:.1f}" if h.get("score") is not None else "-",
+ "PD": f"{h['pdEstimate']:.2f}%" if h.get("pdEstimate") is not None else "-",
+ }
+ )
+
+ stable = "안정적" if data.get("stable") else "변동 있음"
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("creditHistory").label,
+ level=2,
+ helper=f"등급 안정성: {stable}",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+ return blocks
+
+
+def cashFlowGradeBlock(data: dict) -> list:
+ """calcCashFlowGrade 결과 → eCR 시계열."""
+ if not data:
+ return []
+ history = data.get("history", [])
+ if not history:
+ return []
+
+ rows = []
+ for h in history:
+ rows.append(
+ {
+ "기간": h["period"],
+ "eCR": h.get("eCR", "?"),
+ "OCF/매출": f"{h['ocfToSales']:.1f}%" if h.get("ocfToSales") is not None else "-",
+ "FCF양수": "O" if h.get("fcfPositive") else "X",
+ "OCF/Debt": f"{h['ocfToDebt']:.0f}%" if h.get("ocfToDebt") is not None else "-",
+ }
+ )
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("cashFlowGrade").label,
+ level=2,
+ helper="eCR-1(최상): OCF/매출>15% + FCF양수 + OCF/Debt>30%",
+ )
+ )
+ blocks.append(TableBlock("", pl.DataFrame(rows)))
+ return blocks
+
+
+def creditPeerPositionBlock(data: dict) -> list:
+ """calcCreditPeerPosition 결과 → 업종 내 위치."""
+ if not data:
+ return []
+
+ metrics = data.get("metrics", {})
+ if not metrics:
+ return []
+
+ blocks: list = []
+ blocks.append(
+ HeadingBlock(
+ _meta("creditPeerPosition").label,
+ level=2,
+ )
+ )
+ metricList = []
+ for k, v in metrics.items():
+ if v is not None:
+ metricList.append((k, f"{v:.1f}"))
+ if metricList:
+ blocks.append(MetricBlock(metricList))
+ return blocks
+
+
+def creditFlagsBlock(data: dict) -> list:
+ """calcCreditFlags 결과 → 경고/기회 플래그."""
+ if not data:
+ return []
+ flagList = data.get("flags", [])
+ if not flagList:
+ return []
+
+ warnings = [f"{f['signal']}: {f['detail']}" for f in flagList if f.get("type") == "warning"]
+ opportunities = [f"{f['signal']}: {f['detail']}" for f in flagList if f.get("type") == "opportunity"]
+
+ blocks: list = []
+ if warnings:
+ blocks.append(FlagBlock(warnings, kind="warning"))
+ if opportunities:
+ blocks.append(FlagBlock(opportunities, kind="opportunity"))
+ return blocks
+
+
+def creditNarrativeBlock(data: dict) -> list:
+ """calcCreditNarrative 결과 → 7축 신용 서사 (severity별)."""
+ if not data:
+ return []
+
+ axes = data.get("axes", [])
+ if not axes:
+ return []
+
+ grade = data.get("grade", "?")
+ grade_desc = data.get("gradeDescription", "")
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("creditNarrative").label,
+ level=2,
+ helper=f"등급 {grade} ({grade_desc}) — 7축 서사",
+ ),
+ ]
+
+ severity_label = {
+ "strong": "🟢 우수",
+ "adequate": "🟡 양호",
+ "weak": "🟠 주의",
+ "critical": "🔴 위험",
+ }
+
+ for axis in axes:
+ label = severity_label.get(axis.get("severity", ""), "")
+ title = f"{axis.get('axisName', '?')} {label}".strip()
+ summary = axis.get("summary", "")
+ details = axis.get("details", [])
+
+ text = f"**{title}** — {summary}"
+ if details:
+ details_text = " · ".join(d for d in details[:3] if d)
+ if details_text:
+ text += f"\n {details_text}"
+ blocks.append(TextBlock(text))
+
+ return blocks
+
+
+def creditAuditBlock(data: dict) -> list:
+ """calcCreditAudit 결과 → 외부 신평사 대조."""
+ if not data:
+ return []
+
+ external = data.get("externalGrades", {})
+ notch_diffs = data.get("notchDifferences", {})
+ avg_diff = data.get("avgNotchDiff", 0.0)
+ agreements = data.get("agreements", [])
+ disagreements = data.get("disagreements", [])
+
+ if not external:
+ # 외부 등급 데이터 없으면 블록 생략
+ return []
+
+ dcr_grade = data.get("dcrGrade", "?")
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("creditAudit").label,
+ level=2,
+ helper=f"dCR {dcr_grade} vs 신평사 평균 notch 차이 {avg_diff:+.1f}",
+ ),
+ ]
+
+ # 외부 등급 비교 테이블
+ rows = []
+ for agency, grade in external.items():
+ diff = notch_diffs.get(agency, 0)
+ rows.append(
+ {
+ "신평사": agency,
+ "등급": grade,
+ "notch 차이": f"{diff:+d}" if diff != 99 else "비교불가",
+ }
+ )
+ if rows:
+ blocks.append(TableBlock("외부 신평사 대조", pl.DataFrame(rows)))
+
+ # 동의/비동의 근거
+ if agreements:
+ blocks.append(TextBlock("**동의 근거**:\n" + "\n".join(f"- {a}" for a in agreements[:3])))
+ if disagreements:
+ blocks.append(TextBlock("**비동의 근거**:\n" + "\n".join(f"- {d}" for d in disagreements[:3])))
+
+ return blocks
+
+
+# ── 시장분석 (technicalAnalysis) 빌더 ──
+
+
+def technicalVerdictBlock(data: dict) -> list:
+ """calcTechnicalVerdict 결과 → 기술적 종합 판단."""
+ if not data:
+ return []
+
+ verdict = data.get("verdict", "")
+ score = data.get("score", 0)
+ rsi = data.get("rsi")
+ adx = data.get("adx")
+ above20 = data.get("aboveSma20")
+ above60 = data.get("aboveSma60")
+ bbPos = data.get("bbPosition")
+
+ metrics = [("종합 판단", f"{verdict} (score {score:+d})")]
+ if rsi is not None:
+ rsiLabel = "과매수" if rsi >= 70 else "과매도" if rsi <= 30 else "중립"
+ metrics.append(("RSI (14)", f"{rsi:.1f} ({rsiLabel})"))
+ if adx is not None:
+ adxLabel = "강한 추세" if adx >= 25 else "추세 약함"
+ metrics.append(("ADX (14)", f"{adx:.1f} ({adxLabel})"))
+ if above20 is not None:
+ metrics.append(("SMA 20일", "위" if above20 else "아래"))
+ if above60 is not None:
+ metrics.append(("SMA 60일", "위" if above60 else "아래"))
+ if bbPos is not None:
+ metrics.append(("BB 위치", f"{bbPos:.0f}%"))
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("technicalVerdict").label,
+ level=2,
+ helper="카테고리 간 우선순위 없음 — audit 통과한 독립 분해. 미래 예측 아님.",
+ ),
+ MetricBlock(metrics),
+ ]
+
+ # ── 카테고리 서브섹션 (Phase 5 verdict 강화, audit 통과 라벨만) ──
+ categories = data.get("categories") or {}
+ # 12년 audit 통과: trend 만 (momentum/volatility 12년 전라벨 fail → drop)
+ cat_ko = {"trend": "추세"}
+ for key, ko in cat_ko.items():
+ cat = categories.get(key)
+ if not cat:
+ continue
+ cat_metrics = [
+ ("점수", f"{cat['score']:.0f}/100"),
+ ("라벨", cat["label"]),
+ ]
+ inds = cat.get("indicators", {})
+ for ik, iv in inds.items():
+ if isinstance(iv, bool):
+ cat_metrics.append((ik, "예" if iv else "아니오"))
+ elif isinstance(iv, float):
+ cat_metrics.append((ik, f"{iv:.1f}"))
+ elif isinstance(iv, (list, dict)):
+ continue # 중첩 구조 skip
+ else:
+ cat_metrics.append((ik, str(iv)))
+ blocks.append(HeadingBlock(f"{ko} ({key})", level=3))
+ blocks.append(MetricBlock(cat_metrics))
+
+ # narrate (Phase 5 E)
+ narrative = narrateTechnicalVerdict(data)
+ if narrative:
+ blocks.append(TextBlock(narrative))
+
+ return blocks
+
+
+def technicalSignalsBlock(data: dict) -> list:
+ """calcTechnicalSignals 결과 → 최근 매매 신호."""
+ if not data:
+ return []
+
+ summary = data.get("signalSummary", {})
+ bullish = summary.get("bullish", 0)
+ bearish = summary.get("bearish", 0)
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("technicalSignals").label,
+ level=2,
+ helper="최근 20거래일 기준 매매 신호 집계",
+ ),
+ MetricBlock(
+ [
+ ("매수 신호", f"{bullish}건"),
+ ("매도 신호", f"{bearish}건"),
+ ]
+ ),
+ ]
+
+ # 신호별 상세
+ signals = data.get("signals", {})
+ signalNames = {
+ "goldenCross": "골든/데드크로스",
+ "rsiSignal": "RSI 신호",
+ "macdSignal": "MACD 신호",
+ "bollingerSignal": "볼린저 신호",
+ }
+ sigMetrics = []
+ for key, label in signalNames.items():
+ val = signals.get(key, 0)
+ if val > 0:
+ sigMetrics.append((label, f"매수 {val}건"))
+ elif val < 0:
+ sigMetrics.append((label, f"매도 {abs(val)}건"))
+ if sigMetrics:
+ blocks.append(MetricBlock(sigMetrics))
+
+ # 최근 이벤트 테이블
+ events = data.get("recentEvents", [])
+ if events:
+ eventNames = {
+ "goldenCross": "골든/데드크로스",
+ "rsiSignal": "RSI",
+ "macdSignal": "MACD",
+ "bollingerSignal": "볼린저",
+ }
+ rows = {
+ "날짜": [e.get("date", "") for e in events],
+ "신호": [eventNames.get(e.get("type", ""), e.get("type", "")) for e in events],
+ "방향": [e.get("direction", "") for e in events],
+ }
+ blocks.append(TableBlock("최근 신호 이벤트", pl.DataFrame(rows)))
+
+ return blocks
+
+
+def quantModuleBlock(key: str, data: dict | None) -> list:
+ """quant 서사 모듈 1개 → Block (analysis calc 패턴).
+
+ 각 calc*Narrative 함수가 독립으로 반환한 dict 를 review 블록으로 변환.
+ analysis 의 개별 calc → builder 패턴과 동일.
+ """
+ if not data:
+ return []
+ narrative = data.get("narrative", "")
+ if not narrative or narrative.endswith("데이터 없음.") or narrative.endswith("데이터 부족."):
+ return []
+ label = _meta(key).label if _has_meta(key) else key
+ return [
+ HeadingBlock(label, level=3),
+ TextBlock(narrative),
+ ]
+
+
+def _has_meta(key: str) -> bool:
+ """catalog 에 해당 key 의 BlockMeta 가 있는지."""
+ try:
+ _meta(key)
+ return True
+ except (KeyError, AttributeError):
+ return False
+
+
+def strategySnapshotBlock(data: dict) -> list:
+ """calcStrategySnapshot 결과 → 8 검증 스타일 진입 진단 카드.
+
+ Strategy DSL 의 review 6막 (전망) 진입점. 시총 의존 0.
+ """
+ if not data:
+ return []
+
+ style_labels = {
+ "trendFollow": "추세추종",
+ "meanReversion": "평균회귀",
+ "breakout": "돌파",
+ "dipBuy": "눌림목매수",
+ "eventDriven": "이벤트드리븐",
+ "flowFollow": "수급추종(KR)",
+ "lowVolDefensive": "저변동방어",
+ "seasonalKR": "한국캘린더(KR)",
+ }
+
+ rows_label = []
+ rows_sharpe = []
+ rows_mdd = []
+ rows_dsr = []
+ rows_entry = []
+ rows_exit = []
+ rows_trades = []
+ rows_verdict = []
+ for key, label in style_labels.items():
+ snap = data.get(key)
+ if snap is None or snap.get("status") != "ok":
+ continue
+ sharpe = snap.get("sharpe", 0.0)
+ rows_label.append(label)
+ rows_sharpe.append(f"{sharpe:+.2f}")
+ rows_mdd.append(f"{snap.get('mdd', 0.0) * 100:+.1f}%")
+ rows_dsr.append(f"{snap.get('dsr', 0.0):.2f}")
+ rows_entry.append("●" if snap.get("entry_today") else "—")
+ rows_exit.append("●" if snap.get("exit_today") else "—")
+ rows_trades.append(str(snap.get("trades", 0)))
+ # 판정
+ if sharpe >= 1.2:
+ rows_verdict.append("강함")
+ elif sharpe >= 0.6:
+ rows_verdict.append("양호")
+ elif sharpe >= 0.2:
+ rows_verdict.append("보통")
+ elif sharpe > -1e-6:
+ rows_verdict.append("약함")
+ else:
+ rows_verdict.append("부정")
+
+ if not rows_label:
+ return []
+
+ table = pl.DataFrame(
+ {
+ "스타일": rows_label,
+ "Sharpe": rows_sharpe,
+ "MDD": rows_mdd,
+ "DSR": rows_dsr,
+ "오늘진입": rows_entry,
+ "오늘청산": rows_exit,
+ "Trades": rows_trades,
+ "판정": rows_verdict,
+ }
+ )
+
+ # 활성 진입 신호 narrate (1줄)
+ active_styles = [style_labels[k] for k, v in data.items() if v.get("entry_today") and v.get("status") == "ok"]
+ helper = (
+ "8 검증 스타일 백테스트 결과 + 오늘 시점 진입/청산 진단. "
+ "Sharpe ≥ 1.2 강함, ≥ 0.6 양호, < 0.2 약함. DSR (Bailey-Lopez) 가 우연성 검증."
+ )
+ if active_styles:
+ helper += f"\n오늘 진입 신호 활성 스타일: {', '.join(active_styles)}"
+
+ blocks: list = [
+ HeadingBlock(
+ "전략별 진입 진단",
+ level=2,
+ helper=helper,
+ ),
+ TableBlock("스타일 백테스트 매트릭스", table),
+ ]
+
+ # 학술 출처 + 5년 결과 + 오늘 신호 narrate (Phase 4 R5)
+ narrative = narrateStrategy(data)
+ if narrative:
+ blocks.append(TextBlock(narrative))
+
+ return blocks
+
+
+def marketBetaBlock(data: dict) -> list:
+ """calcMarketBeta 결과 → 시장 베타 + CAPM."""
+ if not data:
+ return []
+
+ metrics = []
+ beta = data.get("value")
+ if beta is not None:
+ metrics.append(("베타 (β)", f"{beta:.3f}"))
+ alpha = data.get("alpha")
+ if alpha is not None:
+ metrics.append(("연간 알파", f"{alpha:+.2f}%"))
+ r2 = data.get("rSquared")
+ if r2 is not None:
+ metrics.append(("R²", f"{r2:.4f}"))
+ capm = data.get("capm")
+ if capm is not None:
+ metrics.append(("CAPM 기대수익률", f"{capm:.1f}%"))
+ rs = data.get("relativeStrength")
+ if rs is not None:
+ rsLabel = "상대 강세" if rs > 0 else "상대 약세"
+ metrics.append(("상대강도 (RSI 차이)", f"{rs:+.1f} ({rsLabel})"))
+
+ if not metrics:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("marketBeta").label,
+ level=2,
+ helper="β < 1 시장보다 안정, β > 1 시장보다 변동 큼. α > 0이면 시장 초과 수익",
+ ),
+ MetricBlock(metrics),
+ ]
+
+ interp = data.get("interpretation")
+ if interp:
+ blocks.append(TextBlock(interp, style="dim", indent="h2"))
+
+ return blocks
+
+
+def fundamentalDivergenceBlock(data: dict) -> list:
+ """calcFundamentalDivergence 결과 → 재무-시장 괴리 진단."""
+ if not data:
+ return []
+
+ metrics = []
+ fg = data.get("financialGrade")
+ tv = data.get("technicalVerdict")
+ div = data.get("divergence")
+
+ if fg:
+ metrics.append(("재무 등급", fg))
+ if tv:
+ ts = data.get("technicalScore", 0)
+ metrics.append(("기술적 판단", f"{tv} (score {ts:+d})"))
+ if div:
+ metrics.append(("교차검증", div))
+
+ if not metrics:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("fundamentalDivergence").label,
+ level=2,
+ helper="재무 분석과 시장 반응이 일치하면 신뢰도 ↑, 괴리하면 원인 분석 필요",
+ ),
+ MetricBlock(metrics),
+ ]
+
+ diagnosis = data.get("diagnosis")
+ if diagnosis:
+ blocks.append(TextBlock(diagnosis, indent="h2"))
+
+ return blocks
+
+
+def marketRiskBlock(data: dict) -> list:
+ """calcMarketRisk 결과 → 안정성 섹션에 배치되는 시장 리스크."""
+ if not data:
+ return []
+
+ metrics = []
+ beta = data.get("beta")
+ if beta is not None:
+ metrics.append(("시장 베타", f"{beta:.3f}"))
+ atrPct = data.get("atrPercent")
+ volGrade = data.get("volatilityGrade")
+ if atrPct is not None:
+ metrics.append(("일일 변동성 (ATR%)", f"{atrPct:.1f}%"))
+ if volGrade:
+ metrics.append(("변동성 등급", volGrade))
+ rs = data.get("relativeStrength")
+ if rs is not None:
+ metrics.append(("시장 대비 상대강도", f"{rs:+.1f}"))
+
+ if not metrics:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("marketRisk").label,
+ level=2,
+ helper="β > 1.5 고위험, ATR% > 5% 고변동. 상대강도 양수면 시장보다 강함",
+ ),
+ MetricBlock(metrics),
+ ]
+
+
+def marketAnalysisFlagsBlock(data) -> list:
+ """calcMarketAnalysisFlags 결과 → FlagBlock."""
+ flags = data if isinstance(data, list) else []
+ return _flagsBlock(flags)
+
+
+# ── 매크로 블록 ──
+
+
+def macroPositionBlock(data: dict) -> list:
+ """calcMacroEnvironment 결과 → 경제 사이클 + 기업 포지션."""
+ if not data:
+ return []
+
+ phase_label = data.get("phaseLabel", "미정")
+ confidence = data.get("confidence", "low")
+ position_label = data.get("positionLabel", "중립")
+ cyclicality = data.get("cyclicality", "moderate")
+ signals = data.get("signals", [])
+
+ metrics = [
+ ("경제 국면", f"{phase_label} ({confidence})"),
+ ("업종 경기민감도", cyclicality),
+ ("현재 포지션", position_label),
+ ]
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("macroEnvironment").label,
+ level=2,
+ helper="경제 사이클 4국면(침체/회복/확장/둔화) 판별 + 업종별 투자 전략",
+ ),
+ MetricBlock(metrics),
+ ]
+
+ if signals:
+ blocks.append(TextBlock("판별 근거: " + " | ".join(signals[:4])))
+
+ implication = data.get("implication")
+ if implication:
+ blocks.append(TextBlock(f"시사점: {implication}"))
+
+ return blocks
+
+
+def assetSignalsBlock(data: dict) -> list:
+ """calcAssetSignals 결과 → 5대 자산 해석."""
+ if not data:
+ return []
+
+ assets = data.get("assets", [])
+ if not assets:
+ return []
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("assetSignals").label,
+ level=2,
+ helper="금리·환율·금·VIX 현재 상태와 해석",
+ ),
+ ]
+
+ for a in assets:
+ line = f"{a['label']}: {a['interpretation']}"
+ relevance = a.get("companyRelevance")
+ if relevance:
+ line += f" → {relevance}"
+ blocks.append(TextBlock(line))
+
+ return blocks
+
+
+def macroEnvironmentBlock(summary: dict) -> list:
+ """macro 종합 결과 → 경제 환경 신호등."""
+ if not summary:
+ return []
+
+ overall_label = summary.get("overallLabel", "")
+ score = summary.get("score", 0)
+ reasons = summary.get("reasons", []) or []
+ contributions = summary.get("contributions", {}) or {}
+ allocation = summary.get("allocation") or {}
+
+ if not overall_label:
+ return []
+
+ metrics = [
+ ("종합 판정", f"{overall_label} (score {score:+.1f})"),
+ ]
+ if reasons:
+ metrics.append(("핵심 근거", ", ".join(reasons[:3])))
+
+ contrib_parts = []
+ for axis, val in sorted(contributions.items(), key=lambda x: abs(x[1]), reverse=True):
+ if val != 0:
+ contrib_parts.append(f"{axis} {val:+.1f}")
+ if contrib_parts:
+ metrics.append(("축별 기여", ", ".join(contrib_parts[:5])))
+
+ if allocation:
+ eq = allocation.get("equity", 0)
+ bd = allocation.get("bond", 0)
+ gd = allocation.get("gold", 0)
+ cs = allocation.get("cash", 0)
+ metrics.append(("자산배분", f"주식 {eq}% / 채권 {bd}% / 금 {gd}% / 현금 {cs}%"))
+ regime = allocation.get("regime", "")
+ if regime:
+ metrics.append(("투자 국면", regime))
+
+ from dartlab.review.narrate import narrateMacroEnvironment
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("macroEnvironment").label,
+ level=2,
+ helper="매크로 11축 종합 판정 — 경제 환경이 투자에 우호적인가",
+ ),
+ MetricBlock(metrics),
+ ]
+ narrative = narrateMacroEnvironment(summary)
+ if narrative:
+ blocks.append(TextBlock(narrative))
+ return blocks
+
+
+def macroCycleBlock(data: dict) -> list:
+ """dartlab.macro("사이클") 결과 → 경기 사이클 + 전환 시퀀스 + 섹터 전략."""
+ if not data:
+ return []
+
+ phase = data.get("phase", "")
+ phase_label = data.get("phaseLabel", "")
+ confidence = data.get("confidence", "")
+ signals = data.get("signals", []) or []
+ sector_strategy = data.get("sectorStrategy", {}) or {}
+ transition = data.get("transition") or {}
+
+ if not phase:
+ return []
+
+ metrics = [
+ ("경기 국면", f"{phase_label or phase}"),
+ ("판정 신뢰도", confidence or "?"),
+ ]
+ if signals:
+ metrics.append(("핵심 신호", ", ".join(signals[:3])))
+
+ if transition:
+ t_from = transition.get("from", "")
+ t_to = transition.get("to", "")
+ progress = transition.get("progress")
+ if t_from and t_to:
+ prog_str = f" ({progress:.0%})" if isinstance(progress, (int, float)) else ""
+ metrics.append(("전환 시퀀스", f"{t_from} → {t_to}{prog_str}"))
+ triggered = transition.get("triggered", [])
+ pending = transition.get("pending", [])
+ if triggered:
+ metrics.append(("확인된 신호", ", ".join(triggered[:3])))
+ if pending:
+ metrics.append(("대기 신호", ", ".join(pending[:3])))
+
+ sector_lines = []
+ for k_label, k in [
+ ("경기민감 (high)", "high"),
+ ("중간민감 (moderate)", "moderate"),
+ ("방어주 (defensive)", "defensive"),
+ ("저민감 (low)", "low"),
+ ]:
+ v = sector_strategy.get(k)
+ if v:
+ sector_lines.append((k_label, v))
+
+ blocks: list = [
+ HeadingBlock(
+ _meta("macroCycle").label,
+ level=2,
+ helper="회복/확장/둔화/침체 4국면 + 전환 시퀀스 + 섹터별 전략",
+ ),
+ MetricBlock(metrics),
+ ]
+ if sector_lines:
+ blocks.append(MetricBlock(sector_lines))
+
+ # Bridgewater 4 Quadrant (Growth × Inflation)
+ quadrant = data.get("quadrant")
+ if isinstance(quadrant, dict):
+ q_label = quadrant.get("quadrantLabel", "")
+ q_growth = "성장↑" if quadrant.get("growth") == "rising" else "성장↓"
+ q_infl = "인플레↑" if quadrant.get("inflation") == "rising" else "인플레↓"
+ q_conf = quadrant.get("confidence", "")
+ q_lines = [
+ ("매크로 체제", f"{q_label} ({q_growth}, {q_infl})"),
+ ("체제 신뢰도", q_conf),
+ ]
+ asset_impl = quadrant.get("assetImplication", {})
+ if asset_impl:
+ ow = [k for k, v in asset_impl.items() if v == "overweight"]
+ uw = [k for k, v in asset_impl.items() if v == "underweight"]
+ if ow:
+ q_lines.append(("Overweight", ", ".join(ow)))
+ if uw:
+ q_lines.append(("Underweight", ", ".join(uw)))
+ blocks.append(MetricBlock(q_lines))
+
+ return blocks
+
+
+def macroRatesBlock(data: dict) -> list:
+ """macro 금리 결과 → 금리 환경."""
+ if not data:
+ return []
+
+ outlook = data.get("outlook") or {}
+ expectation = data.get("expectation") or {}
+ yield_curve = data.get("yieldCurve") or {}
+ real_rate = data.get("realRateRegime", "")
+
+ direction = outlook.get("direction", "")
+ if not direction:
+ return []
+
+ _DIR_LABELS = {"cut": "인하 기대", "hold": "동결", "hike": "인상 가능"}
+ metrics: list[tuple[str, str]] = [
+ ("금리 방향", _DIR_LABELS.get(direction, direction)),
+ ]
+
+ spread_2y_ff = expectation.get("spread2yFf")
+ if spread_2y_ff is not None:
+ metrics.append(("2Y-FF 스프레드", f"{spread_2y_ff:+.2f}%p"))
+
+ slope = yield_curve.get("slope")
+ if slope is not None:
+ shape = "정상" if slope > 0 else "역전"
+ metrics.append(("수익률곡선", f"{shape} (Slope {slope:+.2f}%p)"))
+
+ if real_rate:
+ if isinstance(real_rate, dict):
+ metrics.append(("실질금리 국면", real_rate.get("regimeLabel", "")))
+ else:
+ metrics.append(("실질금리 국면", str(real_rate)))
+
+ # ACM Term Premium
+ tp = data.get("termPremium")
+ if isinstance(tp, dict):
+ tp_val = tp.get("value")
+ tp_label = tp.get("zoneLabel", "")
+ if tp_val is not None:
+ metrics.append(("텀프리미엄", f"{tp_val:+.2f}%p ({tp_label})"))
+
+ # Cochrane-Piazzesi Bond Risk Premium
+ cp = data.get("bondRiskPremium")
+ if isinstance(cp, dict):
+ cp_val = cp.get("cpFactor")
+ cp_label = cp.get("zoneLabel", "")
+ if cp_val is not None:
+ metrics.append(("CP 팩터", f"{cp_val:+.2f} ({cp_label})"))
+
+ return [
+ HeadingBlock(
+ _meta("macroRates").label,
+ level=2,
+ helper="금리 방향 + 수익률곡선 + 텀프리미엄 + 채권 리스크프리미엄",
+ ),
+ MetricBlock(metrics),
+ ]
+
+
+def macroLiquidityBlock(data: dict) -> list:
+ """macro 유동성 결과 → 유동성 환경."""
+ if not data:
+ return []
+
+ regime = data.get("regime", "")
+ if not regime:
+ return []
+
+ _REGIME_LABELS = {"abundant": "풍부", "normal": "보통", "tight": "긴축"}
+ metrics: list[tuple[str, str]] = [
+ ("유동성 국면", _REGIME_LABELS.get(regime, regime)),
+ ]
+
+ fci = data.get("fci")
+ if isinstance(fci, dict):
+ fci_val = fci.get("value")
+ fci_label = fci.get("label", "")
+ if fci_val is not None:
+ metrics.append(("FCI", f"{fci_val:+.2f} ({fci_label})"))
+ elif isinstance(fci, (int, float)):
+ label = "완화" if fci < 0 else "긴축"
+ metrics.append(("FCI", f"{fci:+.2f} ({label})"))
+
+ nfci = data.get("nfci")
+ if isinstance(nfci, dict):
+ nfci_val = nfci.get("value")
+ if nfci_val is not None:
+ metrics.append(("NFCI", f"{nfci_val:+.2f}"))
+ elif isinstance(nfci, (int, float)):
+ metrics.append(("NFCI", f"{nfci:+.2f}"))
+
+ capex = data.get("capexPressure")
+ if isinstance(capex, dict):
+ level = capex.get("level", "")
+ if level:
+ metrics.append(("설비투자 압력", level))
+
+ return [
+ HeadingBlock(
+ _meta("macroLiquidity").label,
+ level=2,
+ helper="유동성 환경 + FCI → 기업 자금 접근성",
+ ),
+ MetricBlock(metrics),
+ ]
+
+
+def macroSentimentBlock(data: dict) -> list:
+ """macro 심리 결과 → 시장 심리."""
+ if not data:
+ return []
+
+ fg = data.get("fearGreed") or {}
+ vix = data.get("vixRegime") or {}
+
+ fg_score = fg.get("score")
+ if fg_score is None:
+ return []
+
+ fg_label = fg.get("label", "")
+ metrics: list[tuple[str, str]] = [
+ ("공포탐욕", f"{fg_score:.0f} ({fg_label})"),
+ ]
+
+ vix_level = vix.get("level", "")
+ vix_value = vix.get("value")
+ if vix_level:
+ vix_str = f"{vix_level}"
+ if vix_value is not None:
+ vix_str += f" ({vix_value:.1f})"
+ metrics.append(("VIX 구간", vix_str))
+
+ buy_signal = vix.get("buySignal")
+ if buy_signal is not None:
+ _BUY_LABELS = {0: "대기", 1: "1차 매수", 2: "2차 매수", 3: "3차 매수"}
+ metrics.append(("분할매수 신호", f"{buy_signal}차 ({_BUY_LABELS.get(buy_signal, '')})"))
+
+ # JLN Macro Uncertainty
+ mu = data.get("macroUncertainty")
+ if isinstance(mu, dict):
+ mu_val = mu.get("value")
+ mu_label = mu.get("zoneLabel", "")
+ vs_vix = mu.get("vsVix", "")
+ if mu_val is not None:
+ metrics.append(("실물 불확실성", f"{mu_val:.3f} ({mu_label})"))
+ if vs_vix == "divergent":
+ metrics.append(("VIX-JLN 괴리", "금융과 실물 불확실성 방향 다름"))
+
+ return [
+ HeadingBlock(
+ _meta("macroSentiment").label,
+ level=2,
+ helper="공포탐욕 + VIX + JLN 실물 불확실성 — 극단값에서 역투자 기회",
+ ),
+ MetricBlock(metrics),
+ ]
+
+
+def macroForecastBlock(data: dict | None) -> list:
+ """macro 예측 결과 → 경기 전망."""
+ if not data:
+ return []
+
+ metrics: list[tuple[str, str]] = []
+
+ rp = data.get("recessionProb")
+ if rp and rp.get("probability") is not None:
+ prob = rp["probability"]
+ if prob > 0.4:
+ label = "경계"
+ elif prob > 0.2:
+ label = "주의"
+ elif prob > 0.1:
+ label = "보통"
+ else:
+ label = "낮음"
+ metrics.append(("침체확률", f"{prob * 100:.0f}% ({label})"))
+
+ lei = data.get("lei")
+ if isinstance(lei, dict):
+ signal = lei.get("signal", "")
+ _LEI_LABELS = {
+ "expansion": "확장 지속",
+ "caution": "주의",
+ "recession_warning": "침체 경고",
+ }
+ if signal:
+ metrics.append(("LEI 신호", _LEI_LABELS.get(signal, signal)))
+
+ sahm = data.get("sahmRule")
+ if isinstance(sahm, dict):
+ val = sahm.get("value")
+ triggered = sahm.get("triggered", False)
+ if val is not None:
+ status = "트리거" if triggered else "정상"
+ metrics.append(("Sahm Rule", f"{val:.2f}%p ({status})"))
+
+ momentum = data.get("momentumSignal")
+ if isinstance(momentum, dict):
+ direction = momentum.get("direction", "")
+ if direction:
+ metrics.append(("성장 모멘텀", direction))
+
+ # Growth-at-Risk (Adrian 2019)
+ gar = data.get("growthAtRisk")
+ if isinstance(gar, dict):
+ gar5 = gar.get("currentGaR5")
+ median = gar.get("median")
+ tail = gar.get("tailRiskLabel", "")
+ if gar5 is not None:
+ metrics.append(("GaR 5th", f"{gar5:+.1f}% ({tail})"))
+ if median is not None:
+ metrics.append(("GaR 중위", f"{median:+.1f}%"))
+
+ if not metrics:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("macroForecast").label,
+ level=2,
+ helper="침체확률 + LEI + Sahm + Growth-at-Risk → 경기 방향 선행 지표",
+ ),
+ MetricBlock(metrics),
+ ]
+
+
+def macroCorporateBlock(data: dict | None) -> list:
+ """macro 기업집계 결과 → 시장 전체 이익사이클."""
+ if not data:
+ return []
+
+ metrics: list[tuple[str, str]] = []
+
+ ec = data.get("earningsCycle")
+ if ec:
+ direction = ec.get("currentDirection", "")
+ label = ec.get("currentLabel", direction)
+ yoy = ec.get("latestYoY")
+ yoy_str = f" (YoY {yoy:+.1f}%)" if yoy is not None else ""
+ if label:
+ metrics.append(("이익사이클", f"{label}{yoy_str}"))
+
+ pr = data.get("ponziRatio")
+ if pr:
+ ratio = pr.get("currentRatio")
+ if ratio is not None:
+ metrics.append(("Ponzi 비율", f"{ratio:.1%} (ICR<1 기업 비중)"))
+
+ lc = data.get("leverageCycle")
+ if lc:
+ median = lc.get("currentMedian")
+ if median is not None:
+ metrics.append(("레버리지 중간값", f"{median:.1f}%"))
+
+ if not metrics:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("macroCorporate").label,
+ level=2,
+ helper="전종목 재무제표 집계 — 시장 전체의 이익 건강도",
+ ),
+ MetricBlock(metrics),
+ ]
+
+
+def macroTradeBlock(data: dict | None) -> list:
+ """macro 교역 결과 → 교역조건 (KR만)."""
+ if not data:
+ return []
+
+ metrics: list[tuple[str, str]] = []
+
+ tot = data.get("termsOfTrade")
+ if tot:
+ direction = tot.get("direction", "")
+ _DIR_LABELS = {"improving": "개선", "stable": "안정", "deteriorating": "악화"}
+ if direction:
+ metrics.append(("교역조건", _DIR_LABELS.get(direction, direction)))
+
+ ep = data.get("exportProfit")
+ if isinstance(ep, dict):
+ signal = ep.get("signal", "")
+ if signal:
+ metrics.append(("수출이익 함의", signal))
+
+ lrs = data.get("leadingRelativeStrength")
+ if isinstance(lrs, dict):
+ direction = lrs.get("direction", "")
+ if direction:
+ metrics.append(("양국 선행지수", direction))
+
+ if not metrics:
+ return []
+
+ return [
+ HeadingBlock(
+ _meta("macroTrade").label,
+ level=2,
+ helper="교역조건 + 수출이익 선행 — 한국 수출기업 영향",
+ ),
+ MetricBlock(metrics),
+ ]
+
+
+def macroFlagsBlock(summary: dict) -> list:
+ """macro 종합에서 경고/기회 플래그 집계."""
+ if not summary:
+ return []
+
+ warnings: list[str] = []
+ opportunities: list[str] = []
+
+ crisis = summary.get("crisis")
+ if isinstance(crisis, dict):
+ cg = crisis.get("creditGap")
+ if cg and cg.get("zone") in ("warning", "danger"):
+ warnings.append(f"Credit-to-GDP gap {cg.get('zoneLabel', '경계')}")
+ ghs = crisis.get("ghsScore")
+ if isinstance(ghs, dict) and ghs.get("score", 0) > 50:
+ warnings.append(f"GHS 위기 점수 {ghs['score']:.0f}")
+ minsky = crisis.get("minskyPhase")
+ if isinstance(minsky, dict) and minsky.get("phase") in ("overtrading", "discredit", "revulsion"):
+ warnings.append(f"Minsky {minsky.get('phaseLabel', minsky['phase'])}")
+ # Excess Bond Premium (Gilchrist 2012)
+ ebp = crisis.get("excessBondPremium")
+ if isinstance(ebp, dict) and ebp.get("recessionSignal"):
+ warnings.append(f"EBP {ebp.get('ebp', 0):.2f} — 침체 12개월 신호")
+ elif isinstance(ebp, dict) and ebp.get("zone") == "stress":
+ warnings.append(f"EBP {ebp.get('ebp', 0):.2f} — 신용 스트레스")
+ # Verdad Credit Cycle
+ cc = crisis.get("creditCycle")
+ if isinstance(cc, dict):
+ cc_phase = cc.get("phase")
+ if cc_phase == "trough":
+ opportunities.append("신용사이클 저점 — 역발상 매수 기회")
+ elif cc_phase == "peak":
+ warnings.append("신용사이클 정점 — 리스크 축소 시작")
+
+ corporate = summary.get("corporate")
+ if isinstance(corporate, dict):
+ pr = corporate.get("ponziRatio")
+ if pr and pr.get("currentRatio", 0) > 0.3:
+ warnings.append(f"Ponzi비율 {pr['currentRatio']:.0%} — 금융 취약")
+
+ sentiment = summary.get("sentiment")
+ if isinstance(sentiment, dict):
+ fg = sentiment.get("fearGreed")
+ if fg and fg.get("score") is not None:
+ s = fg["score"]
+ if s < 25:
+ opportunities.append(f"극단공포 ({s:.0f}) — 역투자 기회")
+ elif s > 75:
+ warnings.append(f"극단탐욕 ({s:.0f}) — 과열 경계")
+
+ forecast = summary.get("forecast")
+ if isinstance(forecast, dict):
+ rp = forecast.get("recessionProb")
+ if rp and rp.get("probability", 0) > 0.3:
+ warnings.append(f"침체확률 {rp['probability'] * 100:.0f}%")
+
+ overall = summary.get("overall", "")
+ score = summary.get("score", 0)
+ if overall == "favorable" and score >= 2.0:
+ cycle = summary.get("cycle", {})
+ phase = cycle.get("phaseLabel", "")
+ opportunities.append(f"사이클 {phase} + 종합 우호 — 위험자산 긍정")
+
+ if not warnings and not opportunities:
+ return []
+
+ blocks: list = []
+ if warnings:
+ blocks.append(FlagBlock(warnings, kind="warning"))
+ if opportunities:
+ blocks.append(FlagBlock(opportunities, kind="opportunity"))
+ return blocks
+
+
+def valuationBandBlock(data: dict) -> list:
+ """calcValuationBand 결과 → PER/PBR 밴드."""
+ if not data:
+ return []
+
+ bands = data.get("bands", {})
+ overall = data.get("overallZone", "적정")
+
+ if not bands:
+ return []
+
+ metrics = []
+ for key, band in bands.items():
+ m = band["metric"]
+ metrics.append((f"{m} 현재", f"{band['current']:.1f}x"))
+ metrics.append((f"{m} 평균", f"{band['mean']:.1f}x (±{band['std']:.1f})"))
+ metrics.append((f"{m} 백분위", f"{band['percentile']:.0f}%"))
+ metrics.append((f"{m} 판정", band["zoneLabel"]))
+
+ metrics.append(("종합", overall))
+
+ return [
+ HeadingBlock(
+ _meta("valuationBand").label,
+ level=2,
+ helper="과거 PER/PBR 정규분포에서 현재 위치. -1σ 이하=저평가, +1σ 이상=고평가",
+ ),
+ MetricBlock(metrics),
+ ]
diff --git a/src/dartlab/review/catalog.py b/src/dartlab/review/catalog.py
new file mode 100644
index 0000000000000000000000000000000000000000..6786e9e60d1dac06d766630252673d83241b2c26
--- /dev/null
+++ b/src/dartlab/review/catalog.py
@@ -0,0 +1,320 @@
+"""review 블록 카탈로그 -- 순서 + 메타데이터 단일 진실의 원천.
+
+블록과 섹션의 정의, 순서, 라벨을 이 파일 하나에서 관리한다.
+순서 변경은 _BLOCKS / SECTIONS 리스트에서만 한다.
+
+규칙:
+ - key는 불변 -- 한번 등록된 key는 변경/재사용 금지
+ - label은 자유 -- 사용자 표시명은 언제든 변경 가능
+ - 리스트 정의 순서 = 렌더링 순서
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class BlockMeta:
+ """블록 메타 정보."""
+
+ key: str
+ label: str
+ section: str
+ description: str
+
+
+@dataclass
+class SectionMeta:
+ """섹션 메타 정보."""
+
+ key: str
+ partId: str
+ title: str
+
+
+# ── 섹션 정의 (리스트 순서 = 렌더링 순서) ──
+
+SECTIONS: list[SectionMeta] = [
+ # ── 제1막: 이 회사는 뭘 하는가 (사업 이해) ──
+ SectionMeta("수익구조", "1", "수익 구조 -- 이 회사는 무엇으로 돈을 버는가"),
+ SectionMeta("성장성", "1-2", "성장성 -- 얼마나 빨리 성장하는가"),
+ # ── 제2막: 얼마나 잘 하는가 (수익성 + 원천) ──
+ SectionMeta("수익성", "2", "수익성 -- 번 돈이 얼마나 남는가"),
+ SectionMeta("비용구조", "2-2", "비용 구조 -- 왜 이만큼 남는가"),
+ # ── 제3막: 현금이 실제로 도는가 (현금 전환) ──
+ SectionMeta("현금흐름", "3", "현금흐름 -- 이익이 현금으로 전환되는가"),
+ SectionMeta("이익품질", "3-2", "이익의 질 -- 이익이 진짜인가"),
+ # ── 제4막: 자본 구조는 안전한가 (안정성) ──
+ SectionMeta("자금조달", "4", "자본/부채 -- 돈의 출처와 구조는 건전한가"),
+ SectionMeta("안정성", "4-2", "안정성 -- 이 현금흐름으로 부채를 감당하는가"),
+ # ── 제5막: 번 돈을 어떻게 쓰는가 (자본배분) ──
+ SectionMeta("자산구조", "5", "자산 구조 -- 자산을 어떻게 배치했는가"),
+ SectionMeta("효율성", "5-2", "효율성 -- 자산을 잘 굴리는가"),
+ SectionMeta("투자효율", "5-3", "투자 효율 -- 투자가 가치를 만드는가"),
+ SectionMeta("자본배분", "5-4", "자본 배분 -- 번 돈을 어디에 쓰는가"),
+ SectionMeta("재무정합성", "5-5", "재무 정합성 -- 재무제표가 서로 맞는가"),
+ SectionMeta("종합평가", "5-6", "종합 평가 -- 재무 상태를 한마디로"),
+ SectionMeta("신용평가", "5-7", "신용평가 -- 이 회사의 신용등급은 어디인가"),
+ # ── 제6막: 앞으로 어떻게 될 것인가 (전망 + 가치) ──
+ SectionMeta("가치평가", "6", "가치평가 -- 이 회사의 적정 가치는 얼마인가"),
+ SectionMeta("지배구조", "6-2", "지배구조 -- 이 회사의 주인은 누구이며 감시는 작동하는가"),
+ SectionMeta("공시변화", "6-3", "공시변화 -- 이 회사의 공시가 뭐가 달라졌는가"),
+ SectionMeta("비교분석", "6-4", "비교분석 -- 이 회사는 시장에서 어디에 서 있는가"),
+ SectionMeta("매출전망", "6-5", "매출 전망 -- 이 회사의 매출은 어디로 가는가"),
+ SectionMeta("시장분석", "6-6", "시장 분석 -- 시장은 이 회사를 어떻게 보는가"),
+ SectionMeta("매크로", "6-7", "매크로 환경 -- 경제 사이클이 이 회사에 어떤 의미인가"),
+]
+
+# ── 블록 정의 (리스트 순서 = 렌더링 순서. 순서 변경은 여기서만.) ──
+
+_BLOCKS: list[BlockMeta] = [
+ # ── 수익구조 ──
+ BlockMeta("profile", "기업 개요", "수익구조", "기업명, 업종, 결산월 등 기본 정보"),
+ BlockMeta("segmentComposition", "부문별 매출 구성", "수익구조", "주요 사업부문별 매출액과 영업이익 비중"),
+ BlockMeta("segmentTrend", "부문별 매출 추이", "수익구조", "사업부문별 매출 시계열 변화"),
+ BlockMeta("region", "지역별 매출", "수익구조", "내수/수출 또는 지역별 매출 비중"),
+ BlockMeta("product", "제품별 매출", "수익구조", "제품/서비스별 매출 비중"),
+ BlockMeta("growth", "매출 성장률", "수익구조", "YoY 성장률과 3개년 CAGR"),
+ BlockMeta("growthContribution", "성장 기여 분해", "수익구조", "부문별 매출 성장 기여도"),
+ BlockMeta("concentration", "매출 집중도", "수익구조", "HHI 기반 매출 편중도"),
+ BlockMeta("revenueQuality", "매출 품질", "수익구조", "영업CF/순이익, 총이익률 추세"),
+ BlockMeta("revenueFlags", "수익구조 플래그", "수익구조", "수익 관련 경고/기회 신호"),
+ # ── 자금조달 ──
+ BlockMeta("fundingSources", "자금 원천 구성", "자금조달", "내부유보/주주자본/금융차입/영업조달 비중"),
+ BlockMeta("capitalOverview", "자본 구조 개요", "자금조달", "자기자본/부채 비율과 구성"),
+ BlockMeta("capitalTimeline", "자본 구조 추이", "자금조달", "자본 구성 시계열 변화"),
+ BlockMeta("debtTimeline", "부채 추이", "자금조달", "차입금/사채 시계열 변화"),
+ BlockMeta("interestBurden", "이자 부담", "자금조달", "이자보상배율과 금융비용 추이"),
+ BlockMeta("liquidity", "유동성", "자금조달", "유동비율, 당좌비율, 단기 지급 능력"),
+ BlockMeta("cashFlowStructure", "자금흐름 구조", "자금조달", "영업/투자/재무CF 요약"),
+ BlockMeta("distressIndicators", "재무 위험 지표", "자금조달", "Altman Z, 이자보상, 부채비율 종합"),
+ BlockMeta("capitalFlags", "자금조달 플래그", "자금조달", "자금 관련 경고/기회 신호"),
+ # ── 자산구조 ──
+ BlockMeta("assetStructure", "자산 재분류", "자산구조", "영업/비영업 자산 재분류와 NOA"),
+ BlockMeta("workingCapital", "운전자본 순환", "자산구조", "CCC, 매출채권/재고/매입채무 회전"),
+ BlockMeta("capexPattern", "CAPEX 패턴", "자산구조", "설비투자 규모와 감가상각 대비"),
+ BlockMeta("assetEfficiency", "자산 효율성", "자산구조", "총자산/고정자산 회전율"),
+ BlockMeta("assetFlags", "자산구조 플래그", "자산구조", "자산 관련 경고/기회 신호"),
+ # ── 현금흐름 ──
+ BlockMeta("cashFlowOverview", "현금흐름 종합", "현금흐름", "영업/투자/재무CF 패턴과 FCF"),
+ BlockMeta("cashQuality", "이익의 현금 전환", "현금흐름", "영업CF/순이익, 영업CF 마진"),
+ BlockMeta("ocfDecomposition", "영업CF 분해", "현금흐름", "OCF = NI + 감가상각 + 운전자본 변동"),
+ BlockMeta("cashFlowFlags", "현금흐름 플래그", "현금흐름", "현금 관련 경고/기회 신호"),
+ # ── 수익성 ──
+ BlockMeta("marginTrend", "마진 추이", "수익성", "매출총이익률, 영업이익률, 순이익률 시계열"),
+ BlockMeta("returnTrend", "수익률 추이", "수익성", "ROE, ROA 시계열과 레버리지 분해"),
+ BlockMeta("dupont", "듀퐁 분해", "수익성", "순이익률 x 자산회전율 x 재무레버리지"),
+ BlockMeta("penmanDecomposition", "Penman 분해", "수익성", "ROCE = RNOA + FLEV×SPREAD (영업력 vs 레버리지)"),
+ BlockMeta("roicTree", "ROIC Tree", "수익성", "ROIC = 마진×회전 분해 + 원인 추적"),
+ BlockMeta("profitabilityFlags", "수익성 플래그", "수익성", "수익성 관련 경고/기회 신호"),
+ # ── 성장성 ──
+ BlockMeta("growthTrend", "성장률 추이", "성장성", "매출/영업이익/순이익 YoY 시계열"),
+ BlockMeta("growthQuality", "성장 품질", "성장성", "외형 성장 vs 내실 성장 괴리, CAGR"),
+ BlockMeta("cagrComparison", "CAGR 비교", "성장성", "계정별 CAGR 교차비교 — 구조적 변화 감지"),
+ BlockMeta("growthFlags", "성장성 플래그", "성장성", "성장성 관련 경고/기회 신호"),
+ # ── 안정성 ──
+ BlockMeta("leverageTrend", "레버리지 추이", "안정성", "부채비율, 차입금의존도 시계열"),
+ BlockMeta("coverageTrend", "이자보상 추이", "안정성", "이자보상배율 시계열"),
+ BlockMeta("distressScore", "부실 판별", "안정성", "Altman Z-Score 시계열과 종합 등급"),
+ BlockMeta("marketRisk", "시장 리스크", "안정성", "베타, 변동성(ATR), 상대강도 — 시장 관점 리스크"),
+ BlockMeta("stabilityFlags", "안정성 플래그", "안정성", "안정성 관련 경고/기회 신호"),
+ # ── 효율성 ──
+ BlockMeta("turnoverTrend", "회전율 추이", "효율성", "총자산/매출채권/재고 회전율 시계열"),
+ BlockMeta("cccTrend", "CCC 추이", "효율성", "현금전환주기 구성요소 시계열"),
+ BlockMeta("efficiencyFlags", "효율성 플래그", "효율성", "효율성 관련 경고/기회 신호"),
+ # ── 종합평가 ──
+ BlockMeta("scorecard", "재무 스코어카드", "종합평가", "5영역 등급(A-F) 요약"),
+ BlockMeta("piotroski", "Piotroski F-Score", "종합평가", "9점 만점 재무 건전성 상세"),
+ BlockMeta("summaryFlags", "종합 플래그", "종합평가", "전체 경고/기회 요약"),
+ # ── 3-1 이익품질 ──
+ BlockMeta("accrualAnalysis", "발생액 분석", "이익품질", "Sloan 발생액비율, 영업CF/순이익 시계열"),
+ BlockMeta("earningsPersistence", "이익 지속성", "이익품질", "영업외손익 비중, 이익 변동성"),
+ BlockMeta("beneishMScore", "Beneish M-Score", "이익품질", "이익 조작 가능성 8변수 모델"),
+ BlockMeta("richardsonAccrual", "Richardson 3계층 발생액", "이익품질", "WCACC/LTOACC/FINACC 분해 + 신뢰도"),
+ BlockMeta("nonOperatingBreakdown", "영업외손익 분해", "이익품질", "금융/지분법/기타 항목별 영업외 추적"),
+ BlockMeta("earningsQualityFlags", "이익품질 플래그", "이익품질", "이익 품질 경고 신호"),
+ # ── 3-2 비용구조 ──
+ BlockMeta("costBreakdown", "비용 비중 분해", "비용구조", "매출원가율, 판관비율 시계열"),
+ BlockMeta("operatingLeverage", "영업레버리지", "비용구조", "DOL — 매출 변동 대비 이익 민감도"),
+ BlockMeta("breakevenEstimate", "손익분기점", "비용구조", "BEP 매출, 안전마진 시계열"),
+ BlockMeta("costStructureFlags", "비용구조 플래그", "비용구조", "비용 구조 경고 신호"),
+ # ── 3-3 자본배분 ──
+ BlockMeta("dividendPolicy", "배당 정책", "자본배분", "배당성향, 연속배당, 배당성장 시계열"),
+ BlockMeta("shareholderReturn", "주주환원", "자본배분", "배당+자사주 vs FCF"),
+ BlockMeta("reinvestment", "재투자", "자본배분", "CAPEX/매출, 유보율 시계열"),
+ BlockMeta("fcfUsage", "FCF 사용처", "자본배분", "배당/부채상환/잔여 분해"),
+ BlockMeta("capitalAllocationFlags", "자본배분 플래그", "자본배분", "자본배분 경고 신호"),
+ # ── 3-4 투자효율 ──
+ BlockMeta("roicTimeline", "ROIC 시계열", "투자효율", "ROIC, WACC 추정, Spread"),
+ BlockMeta("investmentIntensity", "투자 강도", "투자효율", "CAPEX/매출, 유무형자산 비율"),
+ BlockMeta("evaTimeline", "EVA 시계열", "투자효율", "경제적 부가가치 — NOPAT vs 자본비용"),
+ BlockMeta("investmentFlags", "투자효율 플래그", "투자효율", "투자 분석 경고 신호"),
+ # ── 3-5 재무정합성 ──
+ BlockMeta("isCfDivergence", "IS-CF 괴리", "재무정합성", "순이익 vs 영업CF 괴리 시계열"),
+ BlockMeta("isBsDivergence", "IS-BS 괴리", "재무정합성", "매출 vs 매출채권/재고 성장 괴리"),
+ BlockMeta("anomalyScore", "이상 점수", "재무정합성", "교차검증 종합 이상 점수 0-100"),
+ BlockMeta("effectiveTaxRate", "유효세율", "재무정합성", "유효세율, 법정세율 대비 갭"),
+ BlockMeta("deferredTax", "이연법인세", "재무정합성", "이연법인세 자산/부채 추세"),
+ BlockMeta("articulationCheck", "BS-CF 정합성", "재무정합성", "PPE/현금/자본 3표 연결 검증"),
+ BlockMeta("crossStatementFlags", "재무정합성 플래그", "재무정합성", "교차검증+세금 경고 신호"),
+ # ── 3-6 신용평가 ──
+ BlockMeta(
+ "creditMetrics", "신용평가 지표", "신용평가", "16개 핵심 지표 시계열 (채무상환/레버리지/유동성/현금흐름)"
+ ),
+ BlockMeta("creditScore", "신용등급 종합", "신용평가", "20단계 등급(AAA~D) + 5축 가중평균 + 업종 조정"),
+ BlockMeta("creditHistory", "신용등급 시계열", "신용평가", "5개년 등급 변화 궤적"),
+ BlockMeta("cashFlowGrade", "현금흐름등급", "신용평가", "eCR-1~6 현금흐름창출능력 별도 평가"),
+ BlockMeta("creditPeerPosition", "업종 내 신용 순위", "신용평가", "동종업계 대비 핵심 지표 위치"),
+ BlockMeta("creditFlags", "신용 플래그", "신용평가", "신용 등급 하방/상방 신호"),
+ BlockMeta("creditNarrative", "신용 서사", "신용평가", "7축 서사 — 왜 이 등급인가 (인과 체인)"),
+ BlockMeta(
+ "creditAudit", "신평사 대조", "신용평가", "외부 신평사(KIS/KR/NICE) 등급과 notch 차이 + 동의/비동의 근거"
+ ),
+ # ── 4-1 가치평가 ──
+ BlockMeta("dcfValuation", "DCF 밸류에이션", "가치평가", "현금흐름 할인 모델 적정가치"),
+ BlockMeta("ddmValuation", "DDM 밸류에이션", "가치평가", "배당 할인 모델 적정가치"),
+ BlockMeta("relativeValuation", "상대가치", "가치평가", "PER/PBR/EV-EBITDA/PSR/PEG 배수 비교"),
+ BlockMeta("residualIncome", "RIM 밸류에이션", "가치평가", "잔여이익모델 기반 적정가치"),
+ BlockMeta("priceTarget", "확률 가중 목표주가", "가치평가", "5 시나리오 + Monte Carlo 목표가"),
+ BlockMeta("reverseImplied", "역내재성장률", "가치평가", "시장이 내재하는 매출 성장률 역산"),
+ BlockMeta("sensitivity", "민감도 분석", "가치평가", "WACC x 성장률 그리드"),
+ BlockMeta("valuationSynthesis", "종합 적정가치", "가치평가", "DCF+DDM+상대가치 통합 판정"),
+ BlockMeta("valuationFlags", "가치평가 플래그", "가치평가", "가치평가 관련 경고/기회 신호"),
+ # ── 5-1 지배구조 ──
+ BlockMeta("ownershipTrend", "최대주주 지분 추이", "지배구조", "최대주주 지분율 시계열과 주주 구성"),
+ BlockMeta("boardComposition", "이사회 구성", "지배구조", "사외이사비율, 전체 임원 수"),
+ BlockMeta("auditOpinionTrend", "감사의견 시계열", "지배구조", "감사의견과 감사인 변경 이력"),
+ BlockMeta("governanceFlags", "지배구조 플래그", "지배구조", "지배구조 관련 경고/기회 신호"),
+ # ── 5-2 공시변화 ──
+ BlockMeta("disclosureChangeSummary", "공시변화 종합", "공시변화", "전체 topic 변화 요약과 상위 변화"),
+ BlockMeta("keyTopicChanges", "핵심 공시 변화", "공시변화", "사업개요/리스크/회계정책 등 핵심 topic 변화"),
+ BlockMeta("changeIntensity", "변화 크기 분석", "공시변화", "바이트 기준 변화량 상위 topic"),
+ BlockMeta("disclosureDeltaFlags", "공시변화 플래그", "공시변화", "공시변화 관련 경고/기회 신호"),
+ # ── 5-3 비교분석 ──
+ BlockMeta("peerRanking", "시장 내 백분위 순위", "비교분석", "핵심 재무비율 시장 내 백분위"),
+ BlockMeta("riskReturnPosition", "수익-위험 포지션", "비교분석", "ROE x 부채비율 사분면 위치"),
+ BlockMeta("peerBenchmarkFlags", "비교분석 플래그", "비교분석", "비교분석 관련 경고/기회 신호"),
+ # ── 6-1 매출전망 ──
+ BlockMeta("revenueForecast", "[추정] 매출 예측", "매출전망", "7-소스 앙상블 3-시나리오 매출 전망"),
+ BlockMeta("segmentForecast", "[추정] 세그먼트별 전망", "매출전망", "부문별 개별 매출 성장 전망"),
+ BlockMeta("proFormaHighlights", "[추정] Pro-Forma 전망", "매출전망", "매출->영업이익->순이익->FCF 전망"),
+ BlockMeta("scenarioImpact", "[추정] 시나리오 영향", "매출전망", "매크로 시나리오별 매출/마진 영향"),
+ BlockMeta("forecastMethodology", "예측 방법론", "매출전망", "소스 가중치, 가정, 데이터 품질"),
+ BlockMeta("historicalRatios", "과거 구조 비율", "매출전망", "Pro-Forma 기반 과거 재무 비율"),
+ BlockMeta("forecastFlags", "매출전망 플래그", "매출전망", "예측 관련 경고/제한 사항"),
+ BlockMeta("calibrationReport", "예측 정확도 검증", "매출전망", "과거 예측의 확률 캘리브레이션 (Brier Score)"),
+ # ── 시장분석 ──
+ BlockMeta("technicalVerdict", "기술적 종합 판단", "시장분석", "강세/중립/약세 판정, RSI, ADX, SMA/BB 위치"),
+ BlockMeta("technicalSignals", "매매 신호", "시장분석", "골든크로스/RSI/MACD/볼린저 신호 최근 20일"),
+ BlockMeta(
+ "strategySnapshot",
+ "전략별 진입 진단",
+ "시장분석",
+ "8 검증 스타일 백테스트 + 오늘 진입/청산 진단 (Sharpe/MDD/DSR)",
+ ),
+ BlockMeta("marketBeta", "시장 베타", "시장분석", "실측 베타, 알파, CAPM 기대수익률"),
+ BlockMeta("fundamentalDivergence", "재무-시장 괴리", "시장분석", "재무 스코어 vs 기술적 판단 교차검증"),
+ # ── 비교분석 (scan 교차 조합 관점) ──
+ BlockMeta("peerPosition", "시장 내 위치", "비교분석", "전종목 수익성/성장/부채 백분위 + 교차 관점"),
+ BlockMeta("governanceSummary", "지배구조 요약", "비교분석", "5축 점수/등급"),
+ # ── 시장분석 (quant 서사) ──
+ BlockMeta("trendNarrative", "추세 서사", "시장분석", "MA 정배열 + ADX + 12년 audit 근거"),
+ BlockMeta("riskNarrative", "리스크 서사", "시장분석", "ATR 변동성 + 베타 + RSI"),
+ BlockMeta("signalNarrative", "수급 신호 서사", "시장분석", "최근 20일 매수/매도 신호 집계"),
+ BlockMeta("strategyNarrative", "전략 검증 서사", "시장분석", "8 스타일 Sharpe + 오늘 진입 신호"),
+ BlockMeta("crosscheckNarrative", "재무-시장 교차 서사", "시장분석", "재무 등급 vs 기술적 판단"),
+ BlockMeta("quantConclusion", "시장 결론", "시장분석", "5 서사 방향 카운트 → 매수/매도/혼조"),
+ BlockMeta("marketAnalysisFlags", "시장분석 플래그", "시장분석", "기술적 신호 경고/기회"),
+ # ── 매크로 (시장 환경 + 기업-매크로 연결) ──
+ BlockMeta("macroEnvironment", "경제 환경 종합", "매크로", "매크로 종합 판정 + 축별 기여도 + 자산배분 시사점"),
+ BlockMeta("macroCycle", "경기 사이클", "매크로", "회복/확장/둔화/침체 4국면 + 전환 시퀀스 + 섹터 전략"),
+ BlockMeta("macroRates", "금리 환경", "매크로", "금리 방향 + 수익률곡선 + 실질금리 국면"),
+ BlockMeta("macroLiquidity", "유동성 환경", "매크로", "유동성 regime + FCI + 신용스프레드"),
+ BlockMeta("macroSentiment", "시장 심리", "매크로", "공포탐욕 지수 + VIX 구간 + 분할매수 신호"),
+ BlockMeta("macroForecast", "경기 전망", "매크로", "침체확률 + LEI 신호 + 성장 모멘텀"),
+ BlockMeta("macroCorporate", "기업집계", "매크로", "전종목 이익사이클 + Ponzi비율 + 레버리지 추세"),
+ BlockMeta("macroTrade", "교역조건", "매크로", "교역조건 방향 + 수출이익 함의 (KR)"),
+ BlockMeta("macroFlags", "매크로 플래그", "매크로", "매크로 경고/기회 신호 집계"),
+ BlockMeta("valuationBand", "밸류에이션 밴드", "매크로", "PER/PBR 과거 정규분포 대비 현재 위치"),
+]
+
+# ── 파생 인덱스 (자동 생성, 직접 수정 금지) ──
+
+_INDEX: dict[str, BlockMeta] = {b.key: b for b in _BLOCKS}
+
+_BY_SECTION: dict[str, list[BlockMeta]] = {}
+for _b in _BLOCKS:
+ _BY_SECTION.setdefault(_b.section, []).append(_b)
+
+_SECTION_INDEX: dict[str, SectionMeta] = {s.key: s for s in SECTIONS}
+
+# ── 한글 label → key 역인덱스 ──
+
+_LABEL_TO_KEY: dict[str, str] = {b.label: b.key for b in _BLOCKS}
+
+
+def _suggest(query: str) -> str:
+ """오타 시 유사 key/label 제안 메시지."""
+ from difflib import get_close_matches
+
+ candidates = list(_INDEX.keys()) + list(_LABEL_TO_KEY.keys())
+ matches = get_close_matches(query, candidates, n=3, cutoff=0.4)
+ if matches:
+ return f" -- 혹시: {', '.join(matches)}?"
+ return ""
+
+
+def resolveKey(keyOrLabel: str) -> str | None:
+ """영문 key 또는 한글 label → key 반환. 못 찾으면 None."""
+ if keyOrLabel in _INDEX:
+ return keyOrLabel
+ mapped = _LABEL_TO_KEY.get(keyOrLabel)
+ if mapped:
+ return mapped
+ return None
+
+
+# ── 공개 API ──
+
+
+def listBlocks(section: str | None = None) -> list[BlockMeta]:
+ """블록 카탈로그 조회 (순서 보장).
+
+ section이 None이면 전체, "수익구조" 등 지정하면 해당 섹션만.
+ """
+ if section is None:
+ return list(_BLOCKS)
+ return list(_BY_SECTION.get(section, []))
+
+
+def getBlockMeta(key: str) -> BlockMeta | None:
+ """블록 키로 메타 조회."""
+ return _INDEX.get(key)
+
+
+def listSections() -> list[SectionMeta]:
+ """섹션 목록 (순서 보장)."""
+ return list(SECTIONS)
+
+
+def keysForSection(section: str) -> list[str]:
+ """섹션에 속한 블록 key 리스트 (순서 보장)."""
+ return [b.key for b in _BY_SECTION.get(section, [])]
+
+
+def getSectionMeta(key: str) -> SectionMeta | None:
+ """섹션 키로 메타 조회."""
+ return _SECTION_INDEX.get(key)
+
+
+# ── 6막 헤더 (단일 진실의 원천) ──
+
+ACT_HEADERS: dict[str, tuple[str, str]] = {
+ "1": ("제1막: 이 회사는 뭘 하는가", "매출의 원천은 무엇이고 얼마나 빨리 성장하는가?"),
+ "2": ("제2막: 얼마나 잘 하는가", "번 돈이 얼마나 남고, 왜 그만큼 남는가?"),
+ "3": ("제3막: 현금이 실제로 도는가", "이익이 현금으로 전환되는가? 이익이 진짜인가?"),
+ "4": ("제4막: 자본 구조는 안전한가", "부채를 감당할 수 있는가?"),
+ "5": ("제5막: 번 돈을 어떻게 쓰는가", "자산, 배당, 재투자 — 가치를 만드는 배분인가?"),
+ "6": ("제6막: 앞으로 어떻게 될 것인가", "적정 가치는 얼마이고, 어떤 리스크가 있는가?"),
+}
diff --git a/src/dartlab/review/formats.py b/src/dartlab/review/formats.py
new file mode 100644
index 0000000000000000000000000000000000000000..386587b55d5b3a8c4c49f7df9df5d99601fa77e9
--- /dev/null
+++ b/src/dartlab/review/formats.py
@@ -0,0 +1,401 @@
+"""review 멀티포맷 렌더링 (html/markdown/json)."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+
+def renderHtml(review) -> str:
+ """HTML 렌더링."""
+ from dartlab.review.blocks import (
+ ChartBlock,
+ FlagBlock,
+ HeadingBlock,
+ MetricBlock,
+ TableBlock,
+ TextBlock,
+ )
+
+ parts: list[str] = []
+ parts.append(
+ f"{review.corpName} ({review.stockCode})
"
+ )
+
+ if review.summaryCard:
+ card = review.summaryCard
+ cardParts = []
+ if card.conclusion:
+ cardParts.append(f"{card.conclusion}")
+ if card.grades:
+ gradeStr = " | ".join(f"{k} {v}" for k, v in card.grades.items())
+ cardParts.append(f"{gradeStr}")
+ for s in card.strengths:
+ cardParts.append(f"+ {s}")
+ for w in card.warnings:
+ cardParts.append(f"- {w}")
+ parts.append(
+ ""
+ + "
".join(cardParts)
+ + "
"
+ )
+
+ if review.circulationSummary:
+ parts.append(
+ f""
+ f"재무 순환 서사
"
+ f"{'
'.join(review.circulationSummary.split(chr(10)))}
"
+ )
+
+ detailMode = getattr(review.layout, "detail", True)
+
+ for section in review.sections:
+ if not detailMode:
+ parts.append(f"{section.title}
")
+ if section.summary:
+ parts.append(f"{section.summary}
")
+ continue
+ if section.threads:
+ for t in section.threads:
+ colorMap = {"critical": "#d32f2f", "warning": "#f9a825", "positive": "#2e7d32", "neutral": "#757575"}
+ color = colorMap.get(t.severity, "#757575")
+ parts.append(
+ f""
+ f">> {t.title}
"
+ )
+ for block in section.blocks:
+ if isinstance(block, HeadingBlock):
+ parts.append(f"<{block.htmlTag}>{block.title}{block.htmlTag}>")
+ elif isinstance(block, TextBlock):
+ parts.append(f"{block.text}
")
+ elif isinstance(block, MetricBlock):
+ rows = "".join(f"| {label} | {value} |
" for label, value in block.metrics)
+ parts.append(f"")
+ elif isinstance(block, TableBlock):
+ if hasattr(block.df, "_repr_html_"):
+ parts.append(block.df._repr_html_())
+ elif isinstance(block, ChartBlock):
+ spec_json = json.dumps(block.spec, ensure_ascii=False, default=str)
+ title = block.spec.get("title", "") if isinstance(block.spec, dict) else ""
+ parts.append(
+ f""
+ )
+ elif isinstance(block, FlagBlock):
+ for f in block.flags:
+ parts.append(f"{block.icon} {f}
")
+ elif hasattr(block, "render"):
+ parts.append(block.render("html"))
+
+ return "" + "".join(parts) + "
"
+
+
+def _polarsToMarkdown(df) -> str:
+ """Polars DataFrame을 마크다운 테이블로 변환 (박스 아트 제거)."""
+ from dartlab.review.utils import fmtAmt
+
+ try:
+ cols = df.columns
+ header = "| " + " | ".join(str(c) for c in cols) + " |"
+ sep = "| " + " | ".join("---" for _ in cols) + " |"
+ rows = []
+ for row in df.iter_rows():
+ cells = []
+ for v in row:
+ if v is None:
+ cells.append("-")
+ elif isinstance(v, float):
+ cells.append(fmtAmt(v))
+ else:
+ cells.append(str(v))
+ rows.append("| " + " | ".join(cells) + " |")
+ return "\n".join([header, sep] + rows)
+ except (TypeError, ValueError, AttributeError):
+ return str(df)
+
+
+def renderMarkdown(review) -> str:
+ """마크다운 렌더링."""
+ from dartlab.review.blocks import (
+ ChartBlock,
+ FlagBlock,
+ HeadingBlock,
+ MetricBlock,
+ TableBlock,
+ TextBlock,
+ )
+ from dartlab.review.catalog import ACT_HEADERS
+
+ _ACT_HEADERS = ACT_HEADERS
+
+ # ── 6막 전환 경계 매핑 (섹션 key → 전환 key) ──
+ _ACT_BOUNDARIES: dict[str, str] = {
+ "수익성": "1→2",
+ "현금흐름": "2→3",
+ "자금조달": "3→4",
+ "자산구조": "4→5",
+ "가치평가": "5→6",
+ }
+
+ # 스토리 템플릿에서 actFocus 로드
+ actFocus: dict[str, str] = {}
+ templateName = getattr(review, "template", None)
+ if templateName:
+ from dartlab.review.templates import STORY_TEMPLATES
+
+ tmplInfo = STORY_TEMPLATES.get(templateName, {})
+ actFocus = tmplInfo.get("actFocus", {})
+
+ parts: list[str] = []
+ parts.append(f"## {review.corpName} ({review.stockCode})\n")
+
+ # ── 요약 카드 ──
+ if review.summaryCard:
+ card = review.summaryCard
+ cardLines = []
+ if card.conclusion:
+ cardLines.append(f"**{card.conclusion}**")
+ for s in card.strengths:
+ cardLines.append(f"- [+] {s}")
+ for w in card.warnings:
+ cardLines.append(f"- [-] {w}")
+ if cardLines:
+ parts.append("\n".join(cardLines))
+
+ # ── 스토리 템플릿 표시 ──
+ allTemplates = getattr(review, "templates", []) or []
+ if templateName:
+ if len(allTemplates) > 1:
+ descs = []
+ for t in allTemplates:
+ STORY_TEMPLATES.get(t, {})
+ descs.append(f"{t}")
+ parts.append(f"**스토리: {' + '.join(descs)}**")
+ else:
+ desc = tmplInfo.get("description", "") if tmplInfo else ""
+ parts.append(f"**스토리: {templateName}** — {desc}")
+
+ # 핵심 질문 표시
+ keyQuestions = tmplInfo.get("keyQuestions", []) if tmplInfo else []
+ if keyQuestions:
+ qLines = [f" - {q}" for q in keyQuestions]
+ parts.append("> **핵심 질문**\n" + "\n".join(f"> {q}" for q in qLines))
+
+ # ── 순환 서사 ──
+ if review.circulationSummary:
+ parts.append(f"> **재무 순환 서사**\n> {review.circulationSummary.replace(chr(10), chr(10) + '> ')}")
+
+ detailMode = getattr(review.layout, "detail", True)
+
+ # 이미 렌더링된 막 번호 + thread title + 막 결론 thread 추적
+ renderedActs: set[str] = set()
+ renderedThreads: set[str] = set()
+ actSummaryUsedIds: set[str] = set()
+ # 막별 섹션 수집 (막 결론용)
+ currentAct: str = ""
+ currentActSections: list = []
+ # threads 수집 (전체)
+ allThreads: list = []
+ for sec in review.sections:
+ if sec.threads:
+ allThreads.extend(sec.threads)
+
+ for section in review.sections:
+ # ── 6막 헤더 삽입 (해당 막의 첫 섹션일 때) ──
+ actNum = getattr(section, "partId", "")
+ mainAct = actNum.split("-")[0] if actNum else ""
+
+ # 막이 바뀌면 이전 막 결론 삽입
+ if mainAct and mainAct != currentAct and currentAct and currentActSections:
+ from dartlab.review.narrate import buildActSummary
+
+ actSummary = buildActSummary(currentAct, currentActSections, allThreads, actSummaryUsedIds)
+ if actSummary:
+ parts.append(f"\n{actSummary}\n")
+ currentActSections = []
+
+ if mainAct:
+ currentAct = mainAct
+
+ # 6막에 매핑되지 않는 섹션(macro 보고서 등)은 section.title을 직접 표시
+ if mainAct and mainAct not in _ACT_HEADERS and mainAct not in renderedActs:
+ renderedActs.add(mainAct)
+ parts.append(f"\n---\n\n# {section.title}\n")
+ if section.helper:
+ parts.append(f"> {section.helper}")
+
+ if mainAct and mainAct in _ACT_HEADERS and mainAct not in renderedActs:
+ renderedActs.add(mainAct)
+ actTitle, actQuestion = _ACT_HEADERS[mainAct]
+ parts.append(f"\n---\n\n# {actTitle}\n")
+ parts.append(f"> **핵심 질문**: {actQuestion}")
+
+ # actFocus 인트로 (스토리 템플릿이 있을 때)
+ focus = actFocus.get(mainAct)
+ if focus:
+ parts.append(f"> **이 기업의 관전 포인트**: {focus}")
+
+ # ── 막 전환 인과 문장 삽입 ──
+ transKey = _ACT_BOUNDARIES.get(section.key)
+ if transKey and hasattr(review, "actTransitions") and review.actTransitions:
+ trans = review.actTransitions.get(transKey)
+ if trans:
+ parts.append(f"\n> **{transKey}** {trans}\n")
+
+ if not detailMode:
+ parts.append(f"### {section.title}")
+ if section.summary:
+ parts.append(f"*{section.summary}*")
+ continue
+
+ # ── Threads (인과 패턴) — 중복 건너뛰기 ──
+ if section.threads:
+ for t in section.threads:
+ if t.title in renderedThreads:
+ continue
+ renderedThreads.add(t.title)
+ icon = {"critical": "[!!]", "warning": "[!]", "positive": "[+]", "neutral": "[-]"}
+ parts.append(f"**{icon.get(t.severity, '')} {t.title}**")
+
+ # ── 블록 렌더링 ──
+ for block in section.blocks:
+ isEmphasized = getattr(block, "emphasized", False)
+
+ if isinstance(block, HeadingBlock):
+ prefix = block.markdownPrefix
+ mark = "\u2605 " if isEmphasized else ""
+ parts.append(f"{prefix} {mark}{block.title}")
+ elif isinstance(block, TextBlock):
+ parts.append(block.text)
+ elif isinstance(block, MetricBlock):
+ for label, value in block.metrics:
+ parts.append(f"- **{label}**: {value}")
+ elif isinstance(block, TableBlock):
+ rendered = False
+ if hasattr(block.df, "to_pandas"):
+ try:
+ pdf = block.df.to_pandas()
+ pdf.columns = [c[:-2] if isinstance(c, str) and c.endswith("Q4") else c for c in pdf.columns]
+ parts.append(pdf.to_markdown(index=False))
+ rendered = True
+ except (ImportError, Exception):
+ pass
+ if not rendered:
+ parts.append(_polarsToMarkdown(block.df))
+ elif isinstance(block, ChartBlock):
+ title = block.spec.get("title", "\ucc28\ud2b8") if isinstance(block.spec, dict) else "\ucc28\ud2b8"
+ parts.append(f"*[chart: {title}]*")
+ elif isinstance(block, FlagBlock):
+ for f in block.flags:
+ parts.append(f"- {block.icon} {f}")
+ elif hasattr(block, "render"):
+ parts.append(block.render("markdown"))
+
+ currentActSections.append(section)
+
+ # 마지막 막 결론
+ if currentAct and currentActSections:
+ from dartlab.review.narrate import buildActSummary
+
+ actSummary = buildActSummary(currentAct, currentActSections, allThreads, actSummaryUsedIds)
+ if actSummary:
+ parts.append(f"\n{actSummary}\n")
+
+ return "\n\n".join(parts)
+
+
+def renderJson(review) -> str:
+ """JSON 렌더링."""
+ from dartlab.review.blocks import (
+ ChartBlock,
+ FlagBlock,
+ HeadingBlock,
+ MetricBlock,
+ TableBlock,
+ TextBlock,
+ )
+
+ detailMode = getattr(review.layout, "detail", True)
+
+ sections: list[dict[str, Any]] = []
+ for section in review.sections:
+ if not detailMode:
+ sectionDict: dict[str, Any] = {
+ "key": section.key,
+ "title": section.title,
+ "summary": section.summary,
+ }
+ sections.append(sectionDict)
+ continue
+ items: list[dict] = []
+ for block in section.blocks:
+ if isinstance(block, HeadingBlock):
+ items.append({"type": "heading", "title": block.title, "level": block.level})
+ elif isinstance(block, TextBlock):
+ items.append({"type": "text", "text": block.text})
+ elif isinstance(block, MetricBlock):
+ items.append(
+ {
+ "type": "metrics",
+ "metrics": [{"label": l, "value": v} for l, v in block.metrics],
+ }
+ )
+ elif isinstance(block, TableBlock):
+ if hasattr(block.df, "to_dicts"):
+ items.append({"type": "table", "label": block.label, "data": block.df.to_dicts()})
+ elif isinstance(block, ChartBlock):
+ items.append({"type": "chart", "spec": block.spec, "caption": block.caption})
+ elif isinstance(block, FlagBlock):
+ items.append({"type": "flags", "kind": block.kind, "flags": block.flags})
+ elif hasattr(block, "toJson"):
+ raw = block.toJson()
+ if isinstance(raw, str):
+ try:
+ items.append(json.loads(raw))
+ except json.JSONDecodeError:
+ items.append({"text": raw})
+ else:
+ items.append(raw)
+ threadDicts = []
+ for t in section.threads:
+ threadDicts.append(
+ {
+ "threadId": t.threadId,
+ "title": t.title,
+ "story": t.story,
+ "severity": t.severity,
+ "involvedSections": t.involvedSections,
+ "evidence": t.evidence,
+ }
+ )
+ sectionDict = {
+ "key": section.key,
+ "title": section.title,
+ "summary": section.summary,
+ "blocks": items,
+ }
+ if threadDicts:
+ sectionDict["threads"] = threadDicts
+ sections.append(sectionDict)
+
+ result: dict[str, Any] = {
+ "stockCode": review.stockCode,
+ "corpName": review.corpName,
+ "sections": sections,
+ }
+ if review.summaryCard:
+ card = review.summaryCard
+ result["summaryCard"] = {
+ "conclusion": card.conclusion,
+ "strengths": card.strengths,
+ "warnings": card.warnings,
+ "grades": card.grades,
+ }
+ if review.circulationSummary:
+ result["circulationSummary"] = review.circulationSummary
+
+ return json.dumps(
+ result,
+ ensure_ascii=False,
+ default=str,
+ )
diff --git a/src/dartlab/review/layout.py b/src/dartlab/review/layout.py
new file mode 100644
index 0000000000000000000000000000000000000000..8f8001896da1f4d94bd3c822992749caf1fc9c1c
--- /dev/null
+++ b/src/dartlab/review/layout.py
@@ -0,0 +1,35 @@
+"""review 레이아웃 설정."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class ReviewLayout:
+ """리뷰 렌더링 레이아웃 설정."""
+
+ # 들여쓰기 (칸)
+ indentH1: int = 0 # 대제목
+ indentH2: int = 3 # 중제목
+ indentBody: int = 6 # 콘텐츠 (텍스트, 지표, 테이블, 플래그)
+
+ # 간격 (빈 줄 수)
+ gapAfterH1: int = 1 # 대제목 아래
+ gapAfterH2: int = 1 # 중제목 아래
+ gapBetween: int = 2 # 섹션 간 구분 (중제목 앞)
+
+ # 구분선
+ separatorWidth: int = 56
+
+ # 섹션 순서 (None이면 레지스트리 등록 순서 그대로)
+ sectionOrder: list[str] | None = None
+
+ # 섹션 헬퍼 텍스트 표시 여부
+ helper: bool = True
+
+ # detail 모드: True면 전체 블록, False면 summary만 표시
+ detail: bool = True
+
+
+DEFAULT_LAYOUT = ReviewLayout()
diff --git a/src/dartlab/review/narrate.py b/src/dartlab/review/narrate.py
new file mode 100644
index 0000000000000000000000000000000000000000..87cf0f4ec9f9c40cc765bf512b03f3811ca0466c
--- /dev/null
+++ b/src/dartlab/review/narrate.py
@@ -0,0 +1,667 @@
+"""Conditional Narrative Assembly — 데이터 값 기반 해석 문장 생성.
+
+AP통신/Arria NLG 패턴: 변화율/수준/추세에 따라 조건 분기 → 해석 문장.
+임계값과 레이블을 _THRESHOLDS에서 중앙 관리.
+기준을 바꾸면 모든 해석 문장이 자동 반영.
+"""
+
+from __future__ import annotations
+
+# ── 임계값 중앙 관리 ──
+# (cutoff, label) 쌍.
+# higher_is_better(기본): 값 > cutoff → label. 내림차순.
+# lower_is_better: 값 < cutoff → label. 오름차순.
+
+_THRESHOLDS: dict[str, dict] = {
+ "growth_yoy": {
+ "breakpoints": [(20, "급성장"), (5, "성장"), (-5, "보합"), (-20, "역성장"), (None, "급감")],
+ },
+ "growth_cagr": {
+ "breakpoints": [(10, "견조"), (0, "완만한 성장"), (-5, "정체"), (None, "구조적 역성장")],
+ },
+ "margin_delta": {
+ "breakpoints": [(5, "대폭 확대"), (1, "개선"), (-1, "보합"), (-5, "하락"), (None, "급락")],
+ },
+ "margin_level": {
+ "breakpoints": [(20, "높은 마진"), (10, "양호한 마진"), (0, "낮은 마진"), (None, "영업적자")],
+ },
+ "debt_ratio": {
+ "lower_is_better": True,
+ "breakpoints": [
+ (50, "매우 안정적인 자본 구조"),
+ (100, "안정적인 자본 구조"),
+ (200, "보통 수준의 레버리지"),
+ (300, "다소 높은 레버리지"),
+ (None, "과도한 레버리지"),
+ ],
+ },
+ "ocf_to_ni": {
+ "breakpoints": [
+ (150, "이익 대비 현금 회수가 매우 우수하다 (감가상각 등 비현금 항목이 크다)"),
+ (100, "이익이 현금으로 잘 뒷받침된다"),
+ (60, "현금 전환이 다소 부족하다"),
+ (0, "이익의 현금 뒷받침이 크게 부족하다"),
+ (None, "영업현금흐름이 적자다"),
+ ],
+ },
+ "hhi": {
+ "breakpoints": [
+ (5000, "매출이 단일 부문에 과도하게 집중되어 있다"),
+ (2500, "상위 부문 의존도가 높다"),
+ (1500, "적절히 다각화되어 있다"),
+ (None, "매출이 고르게 분산되어 있다"),
+ ],
+ },
+ "strategy_sharpe": {
+ "breakpoints": [
+ (1.0, "강한 통계적 우위"),
+ (0.4, "양호한 우위"),
+ (0.0, "중립"),
+ (-0.3, "약한 비효율"),
+ (None, "불리"),
+ ],
+ },
+}
+
+# 학술 출처 — 8 스타일별 (Phase 4 R5)
+_STRATEGY_SOURCES: dict[str, str] = {
+ "trendFollow": "TSMOM (Moskowitz-Ooi-Pedersen 2012)",
+ "meanReversion": "Statistical Arbitrage (Avellaneda-Lee 2008)",
+ "breakout": "Turtle System 1 (Faith 1980s, Donchian 1960s)",
+ "dipBuy": "BTFD (강세장 단기 약세 진입)",
+ "eventDriven": "PEAD (Bernard-Thomas 1989, 한국 Park-Chai 2020)",
+ "flowFollow": "외국인 수급 단기 효과 (Choe-Kho-Stulz 2005)",
+ "lowVolDefensive": "BAB (Frazzini-Pedersen 2014, self z-score 변형)",
+ "seasonalKR": "TOM (Lakonishok-Smidt 1988, 한국재무학회 2018)",
+}
+
+_STRATEGY_LABELS: dict[str, str] = {
+ "trendFollow": "추세추종",
+ "meanReversion": "평균회귀",
+ "breakout": "돌파",
+ "dipBuy": "눌림목매수",
+ "eventDriven": "이벤트드리븐",
+ "flowFollow": "수급추종",
+ "lowVolDefensive": "저변동방어",
+ "seasonalKR": "한국캘린더",
+}
+
+_Z_SCORE_LABELS: dict[str, str] = {
+ "안전": "단기 부실 위험은 낮다",
+ "회색": "재무 악화 시 부실 전이 가능성이 있다",
+ "위험": "부실 가능성에 주의가 필요하다",
+}
+
+
+def _classify(value: float, key: str) -> str:
+ """값을 임계값 테이블에서 레이블로 변환."""
+ cfg = _THRESHOLDS.get(key)
+ if not cfg:
+ return ""
+ breakpoints = cfg["breakpoints"]
+ lower_is_better = cfg.get("lower_is_better", False)
+
+ if lower_is_better:
+ for cutoff, label in breakpoints:
+ if cutoff is None:
+ return label
+ if value < cutoff:
+ return label
+ return breakpoints[-1][1]
+
+ for cutoff, label in breakpoints:
+ if cutoff is None or value > cutoff:
+ return label
+ return breakpoints[-1][1] if breakpoints else ""
+
+
+# ── 추세 감지 유틸 ──
+
+
+def _detectTrend(values: list, min_count: int = 3) -> str | None:
+ """숫자 리스트에서 추세 감지. 최신이 앞(index 0)."""
+ valid = [v for v in values if v is not None]
+ if len(valid) < min_count:
+ return None
+ improving = all(valid[i] >= valid[i + 1] for i in range(len(valid) - 1))
+ declining = all(valid[i] <= valid[i + 1] for i in range(len(valid) - 1))
+ if improving:
+ return "improving"
+ if declining:
+ return "declining"
+ return "mixed"
+
+
+# ── Narrate 함수 ──
+
+
+# ── Quant 서사 5단계 (Phase 6) ──────────────────────────────────────────────
+# analysis 가 재무 서사를 만들듯, quant 는 주가 서사를 만들어 review 에 준다.
+# 5 분석 엔진 공통 패턴: 데이터 소비 → 분석기준·서사·관점·근거 → review 도구.
+
+
+def narrateTrend(verdict_data: dict | None) -> str:
+ """추세 서사 — MA 정배열 + ADX + 12년 audit 근거."""
+ if not verdict_data:
+ return "추세 데이터 없음."
+ cats = verdict_data.get("categories", {})
+ trend = cats.get("trend", {})
+ inds = trend.get("indicators", {})
+
+ label = trend.get("label", "")
+ ma = inds.get("ma_alignment", "혼조")
+ ma_score = inds.get("ma_alignment_score", 0)
+ adx = verdict_data.get("adx") or inds.get("adx", 0)
+ st = inds.get("supertrend", "")
+ psar = inds.get("psar", "")
+
+ if label == "강한 상승":
+ text = f"추세 강한 상승 (MA {ma} {ma_score}단계, ADX {adx:.0f})"
+ if st == "long" and psar == "long":
+ text += ". Supertrend + PSAR 모두 상승 확인"
+ text += ". 12년 audit t=7.63 검증 — 다음 20봉 수익률 통계 우위."
+ else:
+ text = f"추세 횡보 (MA {ma}, ADX {adx:.0f})"
+ text += ". 방향성 약함 — 추세 전략 효과 제한적."
+ return text
+
+
+def narrateQuantRisk(risk_data: dict | None, verdict_data: dict | None) -> str:
+ """리스크 서사 — ATR + 베타 + 변동성 등급."""
+ if not risk_data:
+ return "리스크 데이터 없음."
+ atr_pct = risk_data.get("atrPercent", 0)
+ vol_grade = risk_data.get("volatilityGrade", "")
+ beta_dict = risk_data.get("beta") or (verdict_data or {}).get("beta") or {}
+ beta_val = beta_dict.get("value") if isinstance(beta_dict, dict) else beta_dict
+ rsi = (verdict_data or {}).get("rsi")
+
+ parts = []
+ if atr_pct:
+ grade_ko = {"high": "높음", "medium": "보통", "low": "낮음"}.get(vol_grade, vol_grade)
+ parts.append(f"일일 변동성 ATR {atr_pct:.1f}% ({grade_ko})")
+ if beta_val is not None:
+ if beta_val > 1.3:
+ parts.append(f"베타 {beta_val:.2f} — 시장 하락 시 더 큰 손실")
+ elif beta_val < 0.7:
+ parts.append(f"베타 {beta_val:.2f} — 시장 대비 방어적")
+ else:
+ parts.append(f"베타 {beta_val:.2f}")
+ if rsi is not None:
+ if rsi >= 70:
+ parts.append(f"RSI {rsi:.0f} 과매수 — 단기 조정 가능")
+ elif rsi <= 30:
+ parts.append(f"RSI {rsi:.0f} 과매도 — 반등 기대")
+ else:
+ parts.append(f"RSI {rsi:.0f} 중립")
+ return ". ".join(parts) + "." if parts else "리스크 데이터 부족."
+
+
+def narrateSignals(signals_data: dict | None) -> str:
+ """수급 신호 서사 — 최근 20일 매수/매도 신호 집계."""
+ if not signals_data:
+ return "신호 데이터 없음."
+ summary = signals_data.get("signalSummary", {})
+ bullish = summary.get("bullish", 0)
+ bearish = summary.get("bearish", 0)
+ events = signals_data.get("recentEvents", [])
+
+ parts = [f"최근 20일 매수 {bullish}건 / 매도 {bearish}건"]
+ # 가장 최근 이벤트 1~2개 언급
+ if events:
+ recent = events[-2:]
+ for ev in recent:
+ parts.append(f"{ev.get('type', '')} {ev.get('direction', '')}")
+ if bullish > bearish + 1:
+ parts.append("→ 단기 매수 우위")
+ elif bearish > bullish + 1:
+ parts.append("→ 단기 매도 우위")
+ else:
+ parts.append("→ 매수·매도 균형")
+ return ". ".join(parts) + "."
+
+
+def narrateStrategyVerdict(strategy_data: dict | None) -> str:
+ """전략 검증 서사 — 스타일별 Sharpe + 오늘 진입 신호."""
+ if not strategy_data:
+ return "전략 데이터 없음."
+ style_ko = {
+ "trendFollow": "추세추종",
+ "meanReversion": "평균회귀",
+ "breakout": "돌파",
+ "dipBuy": "눌림목매수",
+ "eventDriven": "이벤트드리븐",
+ "flowFollow": "수급추종",
+ "lowVolDefensive": "저변동방어",
+ "seasonalKR": "한국캘린더",
+ }
+ strong = []
+ active = []
+ for key, ko in style_ko.items():
+ snap = strategy_data.get(key)
+ if not snap or snap.get("status") != "ok":
+ continue
+ sharpe = snap.get("sharpe", 0)
+ dsr = snap.get("dsr", 0)
+ if sharpe >= 0.5 and dsr >= 0.7:
+ strong.append(f"{ko} Sharpe {sharpe:+.2f} (DSR {dsr:.2f})")
+ if snap.get("entry_today"):
+ active.append(ko)
+
+ parts = []
+ if strong:
+ parts.append("검증된 전략: " + ", ".join(strong[:3]))
+ if active:
+ parts.append("오늘 진입 활성: " + ", ".join(active))
+ if not strong:
+ parts.append("12년 검증에서 강한 우위 스타일 없음")
+ return ". ".join(parts) + "."
+
+
+def narrateCrosscheck(divergence_data: dict | None) -> str:
+ """재무-시장 교차 서사 — analysis 등급 vs 기술적 판단 일치/불일치."""
+ if not divergence_data:
+ return "교차진단 데이터 없음."
+ diagnosis = divergence_data.get("diagnosis", "")
+ fin_grade = divergence_data.get("financialGrade", "")
+ tech_verdict = divergence_data.get("technicalVerdict", "")
+
+ if diagnosis:
+ return f"재무-시장 교차: {diagnosis}."
+ parts = []
+ if fin_grade:
+ parts.append(f"재무 등급 {fin_grade}")
+ if tech_verdict:
+ parts.append(f"기술 {tech_verdict}")
+ if parts:
+ return " + ".join(parts) + "."
+ return "교차진단 정보 부족."
+
+
+def narrateQuantConclusion(
+ trend_label: str,
+ bullish_signals: int,
+ bearish_signals: int,
+ active_styles: list[str],
+ diagnosis: str,
+) -> str:
+ """결론 — 5 서사 방향 카운트 (가중치 X)."""
+ bull = 0
+ bear = 0
+ if trend_label == "강한 상승":
+ bull += 1
+ elif trend_label == "횡보":
+ pass # 중립
+ else:
+ bear += 1
+ if bullish_signals > bearish_signals + 1:
+ bull += 1
+ elif bearish_signals > bullish_signals + 1:
+ bear += 1
+ if active_styles:
+ bull += 1
+ if "정합" in diagnosis or "일치" in diagnosis:
+ bull += 1
+ elif "괴리" in diagnosis or "불일치" in diagnosis:
+ bear += 1
+
+ if bull >= 3:
+ return "**결론: 매수 우위** — 추세·수급·전략 정합."
+ if bear >= 3:
+ return "**결론: 매도 우위** — 추세 약세·수급 이탈·리스크 확대."
+ if bull >= 2:
+ return "**결론: 약한 매수 우위** — 일부 지표 긍정."
+ if bear >= 2:
+ return "**결론: 약한 매도 우위** — 일부 지표 부정."
+ return "**결론: 혼조** — 분석 엔진 간 불일치, 관망 권장."
+
+
+def narrateTechnicalVerdict(data: dict) -> str | None:
+ """verdict 축 카테고리 → 한국어 1~2문장 (Phase 5, 12년 audit 통과분만).
+
+ 12년 audit 결과: trend 카테고리의 "강한 상승" (t=7.63@20d) 과
+ "횡보" (t=-6.26@20d) 만 통계적 유의미. momentum/volatility/volume/pattern 전부 fail.
+ 과적합 아닌 진짜 신호만 narrate.
+ """
+ categories = data.get("categories") or {}
+ trend = categories.get("trend")
+ if not trend:
+ return None
+
+ inds = trend.get("indicators", {})
+ ma = inds.get("ma_alignment", "")
+ adx = inds.get("adx", 0)
+ score = trend.get("score", 50)
+ label = trend.get("label", "")
+
+ # 기존 verdict 최상위 지표 참조 (rsi/adx/bbPosition — audit 통과 아니지만 참고 정보)
+ rsi = data.get("rsi")
+ bb = data.get("bbPosition")
+
+ parts: list[str] = []
+
+ if label == "강한 상승":
+ parts.append(f"추세 강한 상승 (MA {ma}, ADX {adx:.0f}) — 12년 검증 유의미 (t=7.63)")
+ else:
+ parts.append(f"추세 횡보 (MA {ma}, ADX {adx:.0f}) — 방향성 약함")
+
+ # 참고 지표 (audit 미통과이지만 사실 데이터로 노출)
+ ref = []
+ if rsi is not None:
+ ref.append(f"RSI {rsi:.0f}")
+ if bb is not None:
+ ref.append(f"BB {bb:.0f}%")
+ if ref:
+ parts.append(f"참고: {', '.join(ref)}")
+
+ return ". ".join(parts) + "."
+
+
+def narrateGrowth(yoy: float | None, cagr: float | None) -> str | None:
+ """매출 성장률 해석."""
+ if yoy is None and cagr is None:
+ return None
+ parts = []
+ if yoy is not None:
+ label = _classify(yoy, "growth_yoy")
+ parts.append(
+ f"매출이 전년 대비 {yoy:+.1f}% {label}했다" if label != "보합" else f"매출이 {yoy:+.1f}%로 보합 수준이다"
+ )
+ if cagr is not None:
+ label = _classify(cagr, "growth_cagr")
+ parts.append(
+ f"중기 CAGR {cagr:+.1f}%로 {label}"
+ + ("하다" if label == "견조" else " 기조다" if "성장" in label else "다")
+ )
+ if yoy is not None and cagr is not None:
+ if yoy > 10 and cagr < 0:
+ parts.append("단기 반등이지만 중기 추세는 아직 하락이다")
+ elif yoy < -10 and cagr > 5:
+ parts.append("일시적 역성장이며 중기 성장 기조는 유효하다")
+ return ". ".join(parts) + "." if parts else None
+
+
+def narrateMargin(data: dict) -> str | None:
+ """마진 추이 해석."""
+ history = data.get("history", [])
+ if len(history) < 2:
+ return None
+ latest, prev = history[0], history[1]
+ opm = latest.get("operatingMargin")
+ opm_prev = prev.get("operatingMargin")
+ if opm is None or opm_prev is None:
+ return None
+
+ delta = opm - opm_prev
+ direction = _classify(delta, "margin_delta")
+ level = _classify(opm, "margin_level")
+ text = f"영업이익률 {opm_prev:.1f}% → {opm:.1f}% ({direction}). {level} 수준"
+
+ margins = [h.get("operatingMargin") for h in history[:4] if h.get("operatingMargin") is not None]
+ trend = _detectTrend(margins)
+ if trend == "improving":
+ text += f"이며, {len(margins)}기 연속 개선 중이다"
+ elif trend == "declining":
+ text += f"이며, {len(margins)}기 연속 악화 추세다"
+ else:
+ text += "이다"
+
+ return text + "."
+
+
+def narrateCashFlow(data: dict, fmtAmt=None) -> str | None:
+ """현금흐름 해석. fmtAmt는 금액 포맷 함수."""
+ history = data.get("history", [])
+ if not history:
+ return None
+ latest = history[0]
+ fcf = latest.get("fcf")
+ pattern = latest.get("pattern", "")
+ if latest.get("ocf") is None:
+ return None
+
+ fmt = fmtAmt or (lambda v: f"{v:,.0f}")
+ parts = []
+ patLabel = pattern.split(" — ")[0] if pattern else ""
+ if patLabel:
+ parts.append(f"현금흐름 패턴은 '{patLabel}'")
+ if fcf is not None:
+ if fcf > 0:
+ parts.append(f"FCF {fmt(fcf)}로 잉여현금 창출 중이다")
+ else:
+ parts.append(f"FCF {fmt(fcf)}로 투자가 영업현금을 초과한다")
+ return ". ".join(parts) + "." if parts else None
+
+
+def narrateCashQuality(data: dict) -> str | None:
+ """이익의 현금 전환 해석."""
+ history = data.get("history", [])
+ if not history:
+ return None
+ ratio = history[0].get("ocfToNi")
+ if ratio is None:
+ return None
+ quality = _classify(ratio, "ocf_to_ni")
+ return f"영업CF/순이익 {ratio:.0f}% — {quality}."
+
+
+def narrateLeverage(data: dict) -> str | None:
+ """레버리지 추이 해석."""
+ history = data.get("history", [])
+ if not history:
+ return None
+ latest = history[0]
+ dr = latest.get("debtRatio")
+ ndr = latest.get("netDebtRatio")
+ if dr is None:
+ return None
+
+ level = _classify(dr, "debt_ratio")
+ text = f"부채비율 {dr:.0f}% — {level}"
+ if ndr is not None and ndr < 0:
+ text += f". 순부채비율 {ndr:.0f}%로 순현금 상태다"
+
+ drs = [h.get("debtRatio") for h in history[:4] if h.get("debtRatio") is not None]
+ trend = _detectTrend(drs)
+ if trend == "improving":
+ text += ". 부채가 지속적으로 증가하는 추세다"
+ elif trend == "declining":
+ text += ". 부채를 꾸준히 줄이고 있다"
+
+ return text + "."
+
+
+def narrateDistress(data: dict) -> str | None:
+ """부실 판별 해석."""
+ latest = data.get("latestScore")
+ zone = data.get("zone", "")
+ if latest is None:
+ return None
+ desc = _Z_SCORE_LABELS.get(zone, "판정 불가")
+ return f"Altman Z-Score {latest:.2f} — {zone} 구간. {desc}."
+
+
+def narrateROIC(data: dict) -> str | None:
+ """ROIC vs WACC 해석."""
+ history = data.get("history", [])
+ if not history:
+ return None
+ latest = history[0]
+ roic = latest.get("roic")
+ wacc = latest.get("waccEstimate")
+ spread = latest.get("spread")
+ if roic is None:
+ return None
+
+ text = f"ROIC {roic:.1f}%"
+ if wacc is not None and spread is not None:
+ if spread > 5:
+ text += f", WACC {wacc:.1f}% 대비 Spread +{spread:.1f}%p — 투자한 자본이 높은 가치를 창출하고 있다"
+ elif spread > 0:
+ text += f", WACC 대비 Spread +{spread:.1f}%p — 자본비용을 상회하여 가치를 창출 중이다"
+ else:
+ text += f", WACC 대비 Spread {spread:+.1f}%p — 투자 자본이 가치를 파괴하고 있다"
+ return text + "."
+
+
+def narrateValuation(data: dict) -> str | None:
+ """가치평가 종합 해석."""
+ verdict = data.get("verdict", "")
+ fvr = data.get("fairValueRange")
+ price = data.get("currentPrice")
+ if not verdict:
+ return None
+
+ text = f"종합 판정: {verdict}"
+ if fvr and price and price > 0:
+ mid = (fvr[0] + fvr[1]) / 2
+ margin = (mid - price) / price * 100
+ if margin > 30:
+ text += f". 적정가 대비 {margin:.0f}% 할인 — 안전마진이 충분하다"
+ elif margin > 0:
+ text += f". 적정가 대비 {margin:.0f}% 할인 — 소폭 저평가"
+ elif margin > -20:
+ text += f". 적정가 대비 {margin:.0f}% 프리미엄 — 적정 수준"
+ else:
+ text += f". 적정가 대비 {abs(margin):.0f}% 프리미엄 — 고평가 주의"
+ return text + "."
+
+
+def narrateStrategy(snap: dict) -> str | None:
+ """전략별 진입 진단 narrate (review 시장분석 6막 prospect).
+
+ 8 검증 스타일 백테스트 결과를 학술 출처 + 5년 결과 + 오늘 신호로 한국어 narrate.
+ """
+ if not snap:
+ return None
+
+ parts: list[str] = []
+
+ # 1) 강한 우위 스타일 (sharpe ≥ 0.6 + DSR ≥ 0.7)
+ strong = []
+ for key, label in _STRATEGY_LABELS.items():
+ s = snap.get(key)
+ if not s or s.get("status") != "ok":
+ continue
+ if s.get("sharpe", 0) >= 0.6 and s.get("dsr", 0) >= 0.7:
+ src = _STRATEGY_SOURCES.get(key, "")
+ sharpe = s.get("sharpe", 0)
+ dsr = s.get("dsr", 0)
+ wr = s.get("winrate", 0) * 100
+ strong.append(f"**{label}** ({src}) — Sharpe {sharpe:+.2f}, DSR {dsr:.2f}, 승률 {wr:.0f}%")
+ if strong:
+ parts.append("[5년 백테스트 강한 우위] " + " / ".join(strong) + ".")
+
+ # 2) 오늘 진입 활성 신호
+ active_today = []
+ for key, label in _STRATEGY_LABELS.items():
+ s = snap.get(key)
+ if not s or s.get("status") != "ok":
+ continue
+ if s.get("entry_today"):
+ stop = s.get("stop_level")
+ stop_str = f", ATR stop {stop:,.0f}" if stop else ""
+ active_today.append(f"**{label}**{stop_str}")
+ if active_today:
+ parts.append("[오늘 진입 신호 활성] " + ", ".join(active_today) + ".")
+
+ # 3) 청산 신호
+ exit_today = []
+ for key, label in _STRATEGY_LABELS.items():
+ s = snap.get(key)
+ if not s or s.get("status") != "ok":
+ continue
+ if s.get("exit_today"):
+ exit_today.append(label)
+ if exit_today:
+ parts.append("[오늘 청산 신호] " + ", ".join(exit_today) + ".")
+
+ # 4) 데이터 한계/NotApplicable 명시
+ limited = []
+ for key, label in _STRATEGY_LABELS.items():
+ s = snap.get(key)
+ if not s:
+ continue
+ if s.get("status") == "data_limited":
+ limited.append(f"{label}(데이터 부족)")
+ elif s.get("status") == "not_applicable":
+ limited.append(f"{label}(KR 전용)")
+ if limited:
+ parts.append("[제한] " + ", ".join(limited) + ".")
+
+ # 5) 통계 신뢰도 경고 (DSR < 0.5)
+ weak_dsr = []
+ for key, label in _STRATEGY_LABELS.items():
+ s = snap.get(key)
+ if not s or s.get("status") != "ok":
+ continue
+ dsr = s.get("dsr", 0)
+ sharpe = s.get("sharpe", 0)
+ if abs(sharpe) > 0.3 and dsr < 0.5:
+ weak_dsr.append(f"{label}(DSR {dsr:.2f})")
+ if weak_dsr:
+ parts.append("[⚠ 통계 신뢰도 약함] " + ", ".join(weak_dsr) + " — 우연 가능성 배제 어려움.")
+
+ return " ".join(parts) if parts else None
+
+
+def narrateConcentration(data: dict) -> str | None:
+ """매출 집중도 해석."""
+ hhi = data.get("hhi")
+ label = data.get("hhiLabel", "")
+ topPct = data.get("topPct")
+ if hhi is None:
+ return None
+
+ level = _classify(hhi, "hhi")
+ text = f"HHI {hhi:,.0f} ({label}). {level}"
+ if topPct is not None:
+ text += f". 1위 부문이 전체의 {topPct:.0f}%를 차지한다"
+ return text + "."
+
+
+def narrateMacroEnvironment(summary: dict) -> str | None:
+ """종합 매크로 환경 → 1-2문장 서술."""
+ if not summary:
+ return None
+ overall_label = summary.get("overallLabel", "")
+ score = summary.get("score", 0)
+ reasons = summary.get("reasons", [])
+ if not overall_label:
+ return None
+
+ if score >= 1.0:
+ tone = "경제 환경이 우호적이다"
+ elif score <= -1.0:
+ tone = "경제 환경이 비우호적이다"
+ else:
+ tone = "경제 환경이 혼조세다"
+
+ parts = [f"{tone} (종합 {score:+.1f})."]
+ if reasons:
+ top = ", ".join(reasons[:2])
+ parts.append(f"주요 근거: {top}.")
+ return " ".join(parts)
+
+
+# ── 막 결론 ──
+
+
+def buildActSummary(actNum: str, sections: list, threads: list, usedIds: set[str] | None = None) -> str | None:
+ """막 결론 문장 자동 생성. usedIds로 이미 사용한 thread를 추적한다."""
+ actSectionKeys = {s.key for s in sections}
+ used = usedIds if usedIds is not None else set()
+
+ actThreads = [t for t in threads if actSectionKeys & set(t.involvedSections) and t.threadId not in used]
+
+ if actThreads:
+ priority = {"critical": 0, "warning": 1, "positive": 2, "neutral": 3}
+ actThreads.sort(key=lambda t: priority.get(t.severity, 9))
+ main = actThreads[0]
+ used.add(main.threadId)
+ return f"**{actNum}막 결론**: {main.story}"
+
+ summaries = [s.summary for s in sections if getattr(s, "summary", None)]
+ if summaries:
+ return f"**{actNum}막 결론**: {' / '.join(summaries[:2])}"
+ return None
diff --git a/src/dartlab/review/narrative.py b/src/dartlab/review/narrative.py
new file mode 100644
index 0000000000000000000000000000000000000000..c2bfe81b24961c36599613acd6b607a17478f07a
--- /dev/null
+++ b/src/dartlab/review/narrative.py
@@ -0,0 +1,717 @@
+"""재무제표 순환 서사(Narrative) — 섹션 간 인과관계 자동 감지.
+
+재무제표는 유기체다. BS/IS/CF가 하나의 순환계를 이루고,
+한 영역의 문제가 다른 영역에 전파된다.
+이 모듈은 company.select()로 원본 시계열을 읽어
+7가지 인과 패턴을 감지하고 NarrativeThread로 반환한다.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+_MAX_YEARS = 5
+
+
+@dataclass
+class NarrativeThread:
+ """섹션 간 인과 연결 하나."""
+
+ threadId: str
+ title: str
+ story: str
+ involvedSections: list[str] = field(default_factory=list)
+ severity: str = "warning" # critical | warning | neutral | positive
+ evidence: list[str] = field(default_factory=list)
+
+
+# ── 유틸 ──
+
+
+def _toDict(selectResult) -> tuple[dict[str, dict], list[str]] | None:
+ from dartlab.analysis.financial._helpers import toDictBySnakeId
+
+ return toDictBySnakeId(selectResult)
+
+
+def _annualCols(periods: list[str], maxYears: int = _MAX_YEARS) -> list[str]:
+ cols = sorted([c for c in periods if "Q" not in c], reverse=True)
+ if cols:
+ return cols[:maxYears]
+ return sorted([c for c in periods if c.endswith("Q4")], reverse=True)[:maxYears]
+
+
+def _get(row: dict, col: str) -> float:
+ v = row.get(col) if row else None
+ return v if v is not None else 0
+
+
+def _yoy(cur: float, prev: float) -> float | None:
+ """YoY 변화율(%). prev가 0이면 None."""
+ if prev == 0:
+ return None
+ return (cur - prev) / abs(prev) * 100
+
+
+def _collectFlags(blockMap, *keys: str) -> list[str]:
+ """BlockMap에서 FlagBlock의 flags를 수집."""
+ from dartlab.review.blocks import FlagBlock
+
+ result = []
+ for key in keys:
+ blocks = blockMap.get(key)
+ if not blocks:
+ continue
+ for b in blocks:
+ if isinstance(b, FlagBlock):
+ result.extend(b.flags)
+ return result
+
+
+# ── 패턴 1: 매출 하락 → 마진 압박 → 현금 악화 ──
+
+
+def _detectRevenueDeclineChain(company, blockMap) -> NarrativeThread | None:
+ isResult = company.select("IS", ["매출액", "영업이익", "당기순이익"])
+ cfResult = company.select("CF", ["영업활동현금흐름"])
+
+ isParsed = _toDict(isResult)
+ cfParsed = _toDict(cfResult)
+ if isParsed is None or cfParsed is None:
+ return None
+
+ isData, isPeriods = isParsed
+ cfData, cfPeriods = cfParsed
+
+ revRow = isData.get("매출액", {})
+ opRow = isData.get("영업이익", {})
+ ocfRow = cfData.get("영업활동현금흐름", {})
+
+ yCols = _annualCols(isPeriods)
+ if len(yCols) < 2:
+ return None
+
+ col0, col1 = yCols[0], yCols[1]
+ rev0, rev1 = _get(revRow, col0), _get(revRow, col1)
+ op0, op1 = _get(opRow, col0), _get(opRow, col1)
+ ocf0, ocf1 = _get(ocfRow, col0), _get(ocfRow, col1)
+
+ revGrowth = _yoy(rev0, rev1)
+ if revGrowth is None or revGrowth >= 0:
+ return None
+
+ # 영업이익률 하락
+ opm0 = op0 / rev0 * 100 if rev0 > 0 else None
+ opm1 = op1 / rev1 * 100 if rev1 > 0 else None
+ if opm0 is None or opm1 is None or opm0 >= opm1:
+ return None
+
+ # 영업CF도 감소
+ if ocf0 >= ocf1:
+ return None
+
+ evidence = [
+ f"매출 YoY {revGrowth:+.1f}% ({col1}→{col0})",
+ f"영업이익률 {opm1:.1f}%→{opm0:.1f}%",
+ f"영업CF {ocf1:,.0f}→{ocf0:,.0f}",
+ ]
+
+ return NarrativeThread(
+ threadId="revenue_decline_chain",
+ title="매출 하락 -> 마진 압박 -> 현금 악화",
+ story=(
+ f"매출이 {revGrowth:+.1f}% 감소하며 영업이익률이 "
+ f"{opm1:.1f}%에서 {opm0:.1f}%로 하락했다. "
+ f"영업현금흐름도 동반 감소하여 수익-현금 순환이 약화되고 있다."
+ ),
+ involvedSections=["수익구조", "수익성", "현금흐름"],
+ severity="critical",
+ evidence=evidence,
+ )
+
+
+# ── 패턴 2: 차입 증가 → 이자부담 → 수익성 악화 ──
+
+
+def _detectDebtBurdenChain(company, blockMap) -> NarrativeThread | None:
+ bsResult = company.select("BS", ["부채총계", "자본총계"])
+ isResult = company.select("IS", ["영업이익", "이자비용"])
+
+ bsParsed = _toDict(bsResult)
+ isParsed = _toDict(isResult)
+ if bsParsed is None or isParsed is None:
+ return None
+
+ bsData, bsPeriods = bsParsed
+ isData, isPeriods = isParsed
+
+ tlRow = bsData.get("부채총계", {})
+ eqRow = bsData.get("자본총계", {})
+ opRow = isData.get("영업이익", {})
+ intRow = isData.get("이자비용", {})
+
+ yCols = _annualCols(bsPeriods)
+ if len(yCols) < 2:
+ return None
+
+ col0, col1 = yCols[0], yCols[1]
+ tl0, tl1 = _get(tlRow, col0), _get(tlRow, col1)
+ eq0 = _get(eqRow, col0)
+ eq1 = _get(eqRow, col1)
+ op0, op1 = _get(opRow, col0), _get(opRow, col1)
+ int0, int1 = _get(intRow, col0), _get(intRow, col1)
+
+ # 부채 증가
+ debtGrowth = _yoy(tl0, tl1)
+ if debtGrowth is None or debtGrowth <= 5:
+ return None
+
+ # 이자보상배율 하락
+ icr0 = op0 / abs(int0) if int0 != 0 else None
+ icr1 = op1 / abs(int1) if int1 != 0 else None
+ if icr0 is None or icr1 is None or icr0 >= icr1:
+ return None
+
+ # ROE 하락
+ roe0 = op0 / eq0 * 100 if eq0 > 0 else None
+ roe1 = op1 / eq1 * 100 if eq1 > 0 else None
+ if roe0 is None or roe1 is None or roe0 >= roe1:
+ return None
+
+ evidence = [
+ f"부채 YoY {debtGrowth:+.1f}%",
+ f"이자보상배율 {icr1:.1f}x→{icr0:.1f}x",
+ f"ROE {roe1:.1f}%→{roe0:.1f}%",
+ ]
+
+ return NarrativeThread(
+ threadId="debt_burden_chain",
+ title="차입 증가 -> 이자부담 -> 수익성 악화",
+ story=(
+ f"부채가 {debtGrowth:+.1f}% 증가하며 이자보상배율이 "
+ f"{icr1:.1f}x에서 {icr0:.1f}x로 하락했다. "
+ f"ROE도 {roe1:.1f}%에서 {roe0:.1f}%로 떨어져 "
+ f"차입 확대가 수익성을 잠식하고 있다."
+ ),
+ involvedSections=["자금조달", "안정성", "수익성"],
+ severity="critical",
+ evidence=evidence,
+ )
+
+
+# ── 패턴 3: 운전자본 팽창 → 현금 고갈 → 유동성 위기 ──
+
+
+def _detectWorkingCapitalStrain(company, blockMap) -> NarrativeThread | None:
+ bsResult = company.select("BS", ["매출채권및기타채권", "재고자산", "매입채무", "유동자산", "유동부채"])
+ isResult = company.select("IS", ["매출액", "당기순이익"])
+ cfResult = company.select("CF", ["영업활동현금흐름"])
+
+ bsParsed = _toDict(bsResult)
+ isParsed = _toDict(isResult)
+ cfParsed = _toDict(cfResult)
+ if bsParsed is None or isParsed is None or cfParsed is None:
+ return None
+
+ bsData, bsPeriods = bsParsed
+ isData, isPeriods = isParsed
+ cfData, cfPeriods = cfParsed
+
+ recRow = bsData.get("매출채권및기타채권", {})
+ invRow = bsData.get("재고자산", {})
+ payRow = bsData.get("매입채무", {})
+ caRow = bsData.get("유동자산", {})
+ clRow = bsData.get("유동부채", {})
+ revRow = isData.get("매출액", {})
+ niRow = isData.get("당기순이익", {})
+ ocfRow = cfData.get("영업활동현금흐름", {})
+
+ yCols = _annualCols(bsPeriods)
+ if len(yCols) < 2:
+ return None
+
+ col0, col1 = yCols[0], yCols[1]
+ _get(revRow, col0)
+ ni0 = _get(niRow, col0)
+ ocf0 = _get(ocfRow, col0)
+
+ # 매출채권 + 재고 - 매입채무 = 순운전자본
+ nwc0 = _get(recRow, col0) + _get(invRow, col0) - _get(payRow, col0)
+ nwc1 = _get(recRow, col1) + _get(invRow, col1) - _get(payRow, col1)
+
+ nwcGrowth = _yoy(nwc0, nwc1)
+ if nwcGrowth is None or nwcGrowth <= 10:
+ return None
+
+ # 영업CF/순이익 괴리 (현금 전환 부족)
+ if ni0 <= 0 or ocf0 / ni0 > 0.7:
+ return None
+
+ # 유동비율 하락
+ cr0 = _get(caRow, col0) / _get(clRow, col0) if _get(clRow, col0) > 0 else None
+ cr1 = _get(caRow, col1) / _get(clRow, col1) if _get(clRow, col1) > 0 else None
+ if cr0 is None or cr1 is None or cr0 >= cr1:
+ return None
+
+ # 순현금 상태이면 "유동성 위기" 서사 억제
+ try:
+ ratios = company._finance.ratios
+ nd = getattr(ratios, "netDebt", None)
+ if nd is not None and nd < 0:
+ return None # 순현금이면 유동성 위기 아님
+ except (AttributeError, ValueError):
+ pass
+
+ ocfNiRatio = ocf0 / ni0 * 100
+
+ evidence = [
+ f"순운전자본 YoY {nwcGrowth:+.1f}%",
+ f"영업CF/순이익 {ocfNiRatio:.0f}%",
+ f"유동비율 {cr1:.2f}→{cr0:.2f}",
+ ]
+
+ return NarrativeThread(
+ threadId="working_capital_strain",
+ title="운전자본 팽창 -> 현금 고갈 -> 유동성 위기",
+ story=(
+ f"순운전자본이 {nwcGrowth:+.1f}% 팽창하며 현금이 묶이고 있다. "
+ f"영업CF/순이익 비율이 {ocfNiRatio:.0f}%로 이익 대비 현금 회수가 부족하고, "
+ f"유동비율도 {cr1:.2f}에서 {cr0:.2f}로 하락했다."
+ ),
+ involvedSections=["자산구조", "현금흐름", "자금조달"],
+ severity="warning",
+ evidence=evidence,
+ )
+
+
+# ── 패턴 4: 과잉투자 → ROIC 하락 → EVA 음수 ──
+
+
+def _detectOverinvestment(company, blockMap) -> NarrativeThread | None:
+ cfResult = company.select("CF", ["유형자산의취득"])
+ isResult = company.select("IS", ["영업이익", "감가상각비", "법인세비용", "법인세차감전순이익", "세전이익"])
+ bsResult = company.select("BS", ["자산총계", "자본총계", "부채총계"])
+
+ cfParsed = _toDict(cfResult)
+ isParsed = _toDict(isResult)
+ bsParsed = _toDict(bsResult)
+ if cfParsed is None or isParsed is None or bsParsed is None:
+ return None
+
+ cfData, cfPeriods = cfParsed
+ isData, isPeriods = isParsed
+ bsData, _ = bsParsed
+
+ capexRow = cfData.get("유형자산의취득", {})
+ depRow = isData.get("감가상각비", {})
+ opRow = isData.get("영업이익", {})
+ taxRow = isData.get("법인세비용", {})
+ ptRow = isData.get("법인세차감전순이익", isData.get("세전이익", {}))
+ taRow = bsData.get("자산총계", {})
+ eqRow = bsData.get("자본총계", {})
+
+ yCols = _annualCols(cfPeriods)
+ if len(yCols) < 2:
+ return None
+
+ col0 = yCols[0]
+ capex = abs(_get(capexRow, col0))
+ dep = _get(depRow, col0)
+ op = _get(opRow, col0)
+ tax = _get(taxRow, col0)
+ pt = _get(ptRow, col0)
+ ta = _get(taRow, col0)
+ eq = _get(eqRow, col0)
+
+ # CAPEX/감가상각 > 2
+ if dep <= 0 or capex / dep <= 2:
+ return None
+
+ capexDepRatio = capex / dep
+
+ # ROIC 추정: NOPAT / 투하자본
+ effTaxRate = tax / pt if pt > 0 else 0.25
+ nopat = op * (1 - min(max(effTaxRate, 0), 0.5))
+ eq + (_get(bsData.get("부채총계", {}), col0) - _get(bsData.get("자본총계", {}), col0) * 0)
+ # 간이 투하자본 = 자산총계 (현금 차감 없는 단순화)
+ roic = nopat / ta * 100 if ta > 0 else None
+
+ if roic is None or roic > 8:
+ return None
+
+ # EVA 추정 (WACC 8% 가정)
+ wacc = 8.0
+ eva = nopat - ta * (wacc / 100)
+
+ if eva >= 0:
+ return None
+
+ evidence = [
+ f"CAPEX/감가상각 {capexDepRatio:.1f}x",
+ f"ROIC 추정 {roic:.1f}%",
+ f"EVA 추정 {eva:,.0f} (WACC {wacc}% 가정)",
+ ]
+
+ return NarrativeThread(
+ threadId="overinvestment_chain",
+ title="과잉투자 -> ROIC 하락 -> EVA 음수",
+ story=(
+ f"CAPEX가 감가상각의 {capexDepRatio:.1f}배로 공격적 투자가 진행 중이나, "
+ f"ROIC가 {roic:.1f}%로 자본비용(WACC {wacc}%)을 하회하여 "
+ f"경제적 부가가치가 마이너스다."
+ ),
+ involvedSections=["자산구조", "투자효율", "수익성"],
+ severity="warning",
+ evidence=evidence,
+ )
+
+
+# ── 패턴 5: 이익 조작 징후 복합 ──
+
+
+def _detectEarningsManipulation(company, blockMap) -> NarrativeThread | None:
+ isResult = company.select("IS", ["당기순이익", "매출액"])
+ cfResult = company.select("CF", ["영업활동현금흐름"])
+ bsResult = company.select("BS", ["자산총계", "매출채권및기타채권"])
+
+ isParsed = _toDict(isResult)
+ cfParsed = _toDict(cfResult)
+ bsParsed = _toDict(bsResult)
+ if isParsed is None or cfParsed is None or bsParsed is None:
+ return None
+
+ isData, isPeriods = isParsed
+ cfData, cfPeriods = cfParsed
+ bsData, _ = bsParsed
+
+ niRow = isData.get("당기순이익", {})
+ revRow = isData.get("매출액", {})
+ ocfRow = cfData.get("영업활동현금흐름", {})
+ taRow = bsData.get("자산총계", {})
+
+ yCols = _annualCols(isPeriods)
+ if not yCols:
+ return None
+
+ col0 = yCols[0]
+ ni = _get(niRow, col0)
+ ocf = _get(ocfRow, col0)
+ ta = _get(taRow, col0)
+ _get(revRow, col0)
+
+ if ta <= 0 or ni == 0:
+ return None
+
+ # Sloan 발생액비율
+ accrualRatio = (ni - ocf) / ta
+ if accrualRatio <= 0.10:
+ return None
+
+ # IS-CF 괴리
+ divergence = (ni - ocf) / abs(ni) * 100
+ if divergence <= 50:
+ return None
+
+ evidence = [
+ f"Sloan 발생액비율 {accrualRatio:.1%}",
+ f"IS-CF 괴리 {divergence:.0f}%",
+ ]
+
+ # M-Score가 있으면 보조 근거로 추가
+ from dartlab.analysis.financial.earningsQuality import calcBeneishTimeline
+
+ beneish = None
+ try:
+ beneish = calcBeneishTimeline(company)
+ except (KeyError, ValueError, TypeError, AttributeError):
+ pass
+
+ mScoreExceeded = False
+ if beneish and beneish.get("history"):
+ ms = beneish["history"][0].get("mScore")
+ if ms is not None and ms > -1.78:
+ evidence.append(f"Beneish M-Score {ms:.2f} (임계값 -1.78 초과)")
+ mScoreExceeded = True
+
+ severity = "critical" if mScoreExceeded else "warning"
+
+ return NarrativeThread(
+ threadId="earnings_manipulation_signal",
+ title="이익 조작 징후 복합",
+ story=(
+ f"발생액비율이 {accrualRatio:.1%}로 이익 중 현금이 아닌 비중이 크고, "
+ f"순이익 대비 영업CF 괴리가 {divergence:.0f}%에 달한다. "
+ + (
+ "Beneish M-Score도 임계값을 초과하여 복합적 주의가 필요하다."
+ if mScoreExceeded
+ else "재무제표 신뢰성에 주의가 필요하다."
+ )
+ ),
+ involvedSections=["이익품질", "현금흐름", "재무정합성"],
+ severity=severity,
+ evidence=evidence,
+ )
+
+
+# ── 패턴 6: 성장 + 수익성 동반 개선 (긍정) ──
+
+
+def _detectGrowthProfitability(company, blockMap) -> NarrativeThread | None:
+ isResult = company.select("IS", ["매출액", "영업이익", "당기순이익"])
+ cfResult = company.select("CF", ["영업활동현금흐름", "유형자산의취득"])
+
+ isParsed = _toDict(isResult)
+ cfParsed = _toDict(cfResult)
+ if isParsed is None or cfParsed is None:
+ return None
+
+ isData, isPeriods = isParsed
+ cfData, cfPeriods = cfParsed
+
+ revRow = isData.get("매출액", {})
+ opRow = isData.get("영업이익", {})
+ ocfRow = cfData.get("영업활동현금흐름", {})
+ capexRow = cfData.get("유형자산의취득", {})
+
+ yCols = _annualCols(isPeriods)
+ if len(yCols) < 2:
+ return None
+
+ col0, col1 = yCols[0], yCols[1]
+ rev0, rev1 = _get(revRow, col0), _get(revRow, col1)
+ op0, op1 = _get(opRow, col0), _get(opRow, col1)
+ ocf0 = _get(ocfRow, col0)
+ capex0 = abs(_get(capexRow, col0))
+
+ revGrowth = _yoy(rev0, rev1)
+ if revGrowth is None or revGrowth <= 3:
+ return None
+
+ # 영업이익률 확대 — 적자(음수)에서 적자 축소는 "확대"가 아님
+ opm0 = op0 / rev0 * 100 if rev0 > 0 else None
+ opm1 = op1 / rev1 * 100 if rev1 > 0 else None
+ if opm0 is None or opm1 is None or opm0 <= opm1 or opm0 < 0:
+ return None
+
+ # FCF 양수
+ fcf = ocf0 - capex0
+ if fcf <= 0:
+ return None
+
+ evidence = [
+ f"매출 YoY {revGrowth:+.1f}%",
+ f"영업이익률 {opm1:.1f}%→{opm0:.1f}%",
+ f"FCF {fcf:,.0f}",
+ ]
+
+ return NarrativeThread(
+ threadId="growth_profitability_positive",
+ title="매출 성장 + 마진 확대 + FCF 양수",
+ story=(
+ f"매출이 {revGrowth:+.1f}% 성장하면서 영업이익률이 "
+ f"{opm1:.1f}%에서 {opm0:.1f}%로 확대되었다. "
+ f"FCF도 양수를 유지하여 질적 성장이 확인된다."
+ ),
+ involvedSections=["수익구조", "수익성", "현금흐름", "성장성"],
+ severity="positive",
+ evidence=evidence,
+ )
+
+
+# ── 패턴 7: 구조적 효율화 (긍정) ──
+
+
+def _detectStructuralEfficiency(company, blockMap) -> NarrativeThread | None:
+ isResult = company.select("IS", ["매출액", "매출원가", "판매비와관리비", "영업이익"])
+ bsResult = company.select("BS", ["자산총계", "자본총계"])
+
+ isParsed = _toDict(isResult)
+ bsParsed = _toDict(bsResult)
+ if isParsed is None or bsParsed is None:
+ return None
+
+ isData, isPeriods = isParsed
+ bsData, _ = bsParsed
+
+ revRow = isData.get("매출액", {})
+ cogsRow = isData.get("매출원가", {})
+ sgaRow = isData.get("판매비와관리비", {})
+ opRow = isData.get("영업이익", {})
+ taRow = bsData.get("자산총계", {})
+ eqRow = bsData.get("자본총계", {})
+
+ yCols = _annualCols(isPeriods)
+ if len(yCols) < 2:
+ return None
+
+ col0, col1 = yCols[0], yCols[1]
+ rev0, rev1 = _get(revRow, col0), _get(revRow, col1)
+ cogs0, cogs1 = _get(cogsRow, col0), _get(cogsRow, col1)
+ sga0, sga1 = _get(sgaRow, col0), _get(sgaRow, col1)
+ op0 = _get(opRow, col0)
+ ta0, ta1 = _get(taRow, col0), _get(taRow, col1)
+ eq0, eq1 = _get(eqRow, col0), _get(eqRow, col1)
+
+ if rev0 <= 0 or rev1 <= 0 or ta0 <= 0 or ta1 <= 0 or eq0 <= 0 or eq1 <= 0:
+ return None
+
+ # 비용률 하락
+ costRatio0 = (cogs0 + sga0) / rev0 * 100
+ costRatio1 = (cogs1 + sga1) / rev1 * 100
+ if costRatio0 >= costRatio1:
+ return None
+
+ # 자산회전율 개선
+ turnover0 = rev0 / ta0
+ turnover1 = rev1 / ta1
+ if turnover0 <= turnover1:
+ return None
+
+ # ROE 상승
+ roe0 = op0 / eq0 * 100
+ roe1 = _get(opRow, col1) / eq1 * 100
+ if roe0 <= roe1:
+ return None
+
+ evidence = [
+ f"영업비용률 {costRatio1:.1f}%→{costRatio0:.1f}%",
+ f"자산회전율 {turnover1:.2f}→{turnover0:.2f}",
+ f"ROE {roe1:.1f}%→{roe0:.1f}%",
+ ]
+
+ return NarrativeThread(
+ threadId="structural_efficiency_positive",
+ title="비용 절감 + 자산 효율화 + ROE 상승",
+ story=(
+ f"영업비용률이 {costRatio1:.1f}%에서 {costRatio0:.1f}%로 하락하고, "
+ f"자산회전율이 {turnover1:.2f}에서 {turnover0:.2f}로 개선되었다. "
+ f"ROE도 {roe1:.1f}%에서 {roe0:.1f}%로 상승하여 구조적 효율화가 진행 중이다."
+ ),
+ involvedSections=["비용구조", "효율성", "수익성"],
+ severity="positive",
+ evidence=evidence,
+ )
+
+
+# ── 메인 ──
+
+
+_DETECTORS: list[tuple] = [
+ (_detectRevenueDeclineChain, {"수익구조", "수익성", "현금흐름"}),
+ (_detectDebtBurdenChain, {"자금조달", "안정성", "수익성"}),
+ (_detectWorkingCapitalStrain, {"자산구조", "효율성", "현금흐름"}),
+ (_detectOverinvestment, {"자산구조", "투자효율", "현금흐름"}),
+ (_detectEarningsManipulation, {"이익품질", "재무정합성"}),
+ (_detectGrowthProfitability, {"성장성", "수익성", "수익구조"}),
+ (_detectStructuralEfficiency, {"효율성", "비용구조", "수익성"}),
+]
+
+
+def detectThreads(company, blockMap, sections: set[str] | None = None) -> list[NarrativeThread]:
+ """7가지 인과 패턴을 감지하여 NarrativeThread 리스트 반환.
+
+ sections가 지정되면 관련 섹션이 겹치는 detector만 실행.
+ """
+ threads = []
+ for detect, involved in _DETECTORS:
+ if sections is not None and not (sections & involved):
+ continue
+ try:
+ thread = detect(company, blockMap)
+ if thread is not None:
+ threads.append(thread)
+ except (KeyError, ValueError, TypeError, AttributeError, ArithmeticError, IndexError):
+ continue
+ return threads
+
+
+def buildActTransitions(company, blockMap: dict) -> dict[str, str]:
+ """6막 전환 시점의 인과 문장 생성.
+
+ 각 막의 핵심 숫자를 뽑아서 다음 막으로 연결하는 한 문장.
+ 반환: {"1→2": "...", "2→3": "...", "3→4": "...", "4→5": "...", "5→6": "..."}
+ """
+ transitions = {}
+
+ try:
+ ratios = company._finance.ratios
+ except (AttributeError, ValueError):
+ return transitions
+
+ from dartlab.review.builders import _fmtAmtShort
+
+ # 1막→2막: 매출 구조 → 수익성
+ rev = getattr(ratios, "revenueTTM", None)
+ opMargin = getattr(ratios, "operatingMargin", None) or getattr(ratios, "operatingMarginTTM", None)
+ if rev and opMargin is not None:
+ revStr = _fmtAmtShort(rev)
+ transitions["1→2"] = f"매출 {revStr}에서 영업이익률 {opMargin:.1f}% — 이 마진의 원천은?"
+
+ # 2막→3막: 수익성 → 현금 전환
+ ni = getattr(ratios, "netIncomeTTM", None)
+ ocf = getattr(ratios, "operatingCashflowTTM", None)
+ if ni and ocf:
+ niStr = _fmtAmtShort(ni)
+ ocfStr = _fmtAmtShort(ocf)
+ ratio = ocf / ni * 100 if ni != 0 else 0
+ transitions["2→3"] = f"순이익 {niStr} → 영업CF {ocfStr} ({ratio:.0f}%) — 이익이 현금으로 뒷받침되는가?"
+
+ # 3막→4막: 현금 → 안정성
+ fcf = getattr(ratios, "fcf", None) or getattr(ratios, "fcfTTM", None)
+ ic = getattr(ratios, "interestCoverage", None)
+ if fcf is not None and ic is not None:
+ fcfStr = _fmtAmtShort(fcf)
+ transitions["3→4"] = f"FCF {fcfStr}, 이자보상 {ic:.1f}배 — 이 현금으로 부채를 감당할 수 있는가?"
+
+ # 4막→5막: 안정성 → 자본배분
+ nd = getattr(ratios, "netDebt", None)
+ dr = getattr(ratios, "debtRatio", None)
+ if nd is not None and dr is not None:
+ status = "순현금" if nd < 0 else f"순차입금 {_fmtAmtShort(nd)}"
+ transitions["4→5"] = f"{status}, 부채비율 {dr:.0f}% — 안전한 자본 안에서 어떻게 배분하는가?"
+
+ # 5막→6막: 자본배분 → 전망/가치
+ roic = getattr(ratios, "roic", None)
+ if roic is not None:
+ transitions["5→6"] = f"ROIC {roic:.1f}% — 이 수익률이 지속되면 이 회사의 가치는?"
+
+ return transitions
+
+
+def buildCirculationSummary(threads: list[NarrativeThread]) -> str:
+ """감지된 threads를 종합 서사로 합성."""
+ if not threads:
+ return ""
+
+ criticals = [t for t in threads if t.severity == "critical"]
+ warnings = [t for t in threads if t.severity == "warning"]
+ positives = [t for t in threads if t.severity == "positive"]
+
+ parts = []
+
+ if criticals:
+ parts.append("핵심 위험: " + " / ".join(t.title for t in criticals) + ".")
+
+ if warnings:
+ parts.append("주의 신호: " + " / ".join(t.title for t in warnings) + ".")
+
+ if positives:
+ parts.append("긍정 신호: " + " / ".join(t.title for t in positives) + ".")
+
+ if not parts:
+ return ""
+
+ # 각 thread의 story 중 가장 심각한 것 1개 + 가장 긍정적인 것 1개 요약
+ details = []
+ if criticals:
+ details.append(criticals[0].story)
+ elif warnings:
+ details.append(warnings[0].story)
+ if positives:
+ details.append(positives[0].story)
+
+ summary = " ".join(parts)
+ if details:
+ summary += "\n" + "\n".join(details)
+
+ return summary
diff --git a/src/dartlab/review/presets.py b/src/dartlab/review/presets.py
new file mode 100644
index 0000000000000000000000000000000000000000..acc598a625a66178a4c15503f6c91fd1de997274
--- /dev/null
+++ b/src/dartlab/review/presets.py
@@ -0,0 +1,31 @@
+"""review 프리셋 -- 관점별 섹션 조합."""
+
+from __future__ import annotations
+
+PRESETS: dict[str, dict] = {
+ "executive": {
+ "sections": ["종합평가", "수익구조", "현금흐름", "가치평가"],
+ "detail": False,
+ "description": "경영진/투자자용 핵심 요약",
+ },
+ "audit": {
+ "sections": ["이익품질", "재무정합성", "안정성", "지배구조", "공시변화"],
+ "detail": True,
+ "description": "감사/회계 검토용",
+ },
+ "credit": {
+ "sections": ["안정성", "현금흐름", "자금조달", "효율성"],
+ "detail": True,
+ "description": "신용분석/여신심사용",
+ },
+ "growth": {
+ "sections": ["수익구조", "성장성", "투자효율", "매출전망"],
+ "detail": True,
+ "description": "성장성 분석용",
+ },
+ "valuation": {
+ "sections": ["가치평가", "수익성", "성장성", "매출전망"],
+ "detail": True,
+ "description": "밸류에이션 중심",
+ },
+}
diff --git a/src/dartlab/review/publisher.py b/src/dartlab/review/publisher.py
new file mode 100644
index 0000000000000000000000000000000000000000..e4e73a7637ad33f17199bbcb27069464b566ba11
--- /dev/null
+++ b/src/dartlab/review/publisher.py
@@ -0,0 +1,317 @@
+"""기업분석 보고서 발간 파이프라인.
+
+review 엔진(6막 서사 + 스토리 템플릿)으로 생성한 기업분석보고서를
+블로그 포스트 형태로 blog/05-company-reports/에 저장한다.
+
+credit/publisher.py와 동일 패턴이지만 보고서 성격이 다르다:
+- credit: 7축 신용등급 + 12섹션 정량 보고서
+- review: 6막 인과 서사 + 업종별 스토리 템플릿 기반 종합 분석
+"""
+
+from __future__ import annotations
+
+import gc
+import json
+from datetime import datetime
+from pathlib import Path
+
+_BLOG_DIR = Path("blog/05-company-reports")
+_REGISTRY_PATH = _BLOG_DIR / "_registry.json"
+
+
+def publishReport(
+ stockCode: str,
+ *,
+ template: str = "auto",
+ basePeriod: str | None = None,
+) -> Path:
+ """기업분석 보고서 발간.
+
+ 1. Company 로드 + 스토리 템플릿 자동 판별
+ 2. review(template=template, detail=True) 빌드
+ 3. frontmatter + 보고서 헤더 + 본문 마크다운 생성
+ 4. blog/05-company-reports/{순번}-{종목코드}-{기업명}/index.md 저장
+ 5. _registry.json 업데이트
+ """
+ from dartlab import Company
+
+ company = Company(stockCode)
+ path = publishReportFromCompany(company, template=template, basePeriod=basePeriod)
+ del company
+ gc.collect()
+ return path
+
+
+def publishReportFromCompany(
+ company,
+ *,
+ template: str = "auto",
+ basePeriod: str | None = None,
+) -> Path:
+ """Company 객체로 보고서 생성 + 블로그 포스트 저장."""
+ from dartlab.review.registry import buildReview
+
+ rv = buildReview(company, template=template, detail=True, basePeriod=basePeriod)
+
+ corpName = getattr(company, "corpName", "") or ""
+ stockCode = getattr(company, "stockCode", "") or ""
+ sector = _extractSector(company)
+
+ # credit 등급 참조 (실패해도 계속)
+ grade = _extractCreditGrade(company)
+
+ # 마크다운 생성
+ md = _buildFullReport(rv, corpName, stockCode, sector, grade, basePeriod)
+
+ # 블로그 포스트 저장
+ order, slug = _resolveSlug(stockCode, corpName)
+ postDir = _BLOG_DIR / f"{order:02d}-{slug}"
+ postDir.mkdir(parents=True, exist_ok=True)
+ path = postDir / "index.md"
+ path.write_text(md, encoding="utf-8")
+
+ # 레지스트리 업데이트
+ _updateRegistry(stockCode, corpName, order, slug, rv, grade)
+
+ return path
+
+
+def publishBatch(
+ stockCodes: list[str],
+ *,
+ template: str = "auto",
+ basePeriod: str | None = None,
+) -> list[Path]:
+ """배치 발간 (순차 — 메모리 안전)."""
+ paths = []
+ for i, code in enumerate(stockCodes):
+ try:
+ path = publishReport(code, template=template, basePeriod=basePeriod)
+ paths.append(path)
+ if (i + 1) % 5 == 0:
+ print(f"[review] {i + 1}/{len(stockCodes)} 발간 완료")
+ except (ValueError, KeyError, TypeError, FileNotFoundError) as e:
+ print(f"[review] {code} 발간 실패: {e}")
+ gc.collect()
+ print(f"[review] 배치 완료: {len(paths)}/{len(stockCodes)} 성공")
+ return paths
+
+
+# ── 내부 함수 ──
+
+
+def _buildFullReport(
+ review,
+ corpName: str,
+ stockCode: str,
+ sector: str,
+ grade: str,
+ basePeriod: str | None,
+) -> str:
+ """frontmatter + 보고서 헤더 + 본문 마크다운 결합."""
+ templateName = getattr(review, "template", None) or ""
+ today = datetime.now().strftime("%Y-%m-%d")
+ periodLabel = basePeriod or "최신"
+
+ # frontmatter
+ title = f"{corpName} — {_makeTitle(templateName, corpName)}"
+ description = f"{corpName} 6막 재무 서사 — 수익구조부터 가치평가까지."
+ if templateName:
+ from dartlab.review.templates import STORY_TEMPLATES
+
+ tmplDesc = STORY_TEMPLATES.get(templateName, {}).get("description", "")
+ if tmplDesc:
+ description += f" {tmplDesc}."
+
+ fm = (
+ "---\n"
+ f'title: "{title}"\n'
+ f"date: {today}\n"
+ f'description: "{description}"\n'
+ f"category: company-reports\n"
+ f"series: company-reports\n"
+ f'stockCode: "{stockCode}"\n'
+ f'corpName: "{corpName}"\n'
+ )
+ if templateName:
+ fm += f'storyTemplate: "{templateName}"\n'
+ if sector:
+ fm += f'sector: "{sector}"\n'
+ if grade:
+ fm += f'grade: "{grade}"\n'
+ fm += "---\n\n"
+
+ # 보고서 헤더
+ header = f"> **{templateName or '종합'}** | {sector or '—'} | {today} 기준\n"
+ header += f"> 데이터: {periodLabel} 연결 | 엔진: dartlab review\n"
+ header += "\n---\n"
+
+ # 본문 — 신용평가 섹션에 creditNarrative + creditAudit 자동 포함됨
+ body = review.toMarkdown()
+
+ # 면책
+ disclaimer = (
+ "\n\n---\n\n"
+ "*이 보고서는 dartlab 엔진이 공시 데이터를 분석하여 자동 생성한 것입니다. "
+ "투자 권유가 아니며, 투자 판단의 참고 자료로만 활용하십시오. "
+ "모든 수치는 공시 기준이며, 실제 투자 시 추가 검증이 필요합니다.*"
+ )
+
+ return fm + header + "\n" + body + disclaimer
+
+
+def _makeTitle(templateName: str, corpName: str) -> str:
+ """보고서 제목 생성."""
+ _TITLE_MAP = {
+ "사이클": "사이클의 파도 위에서",
+ "프랜차이즈": "안정의 기계",
+ "턴어라운드": "반전의 시작",
+ "성장": "성장의 질을 묻다",
+ "자본집약": "자산의 무게",
+ "지주": "포트폴리오의 합",
+ "현금부자": "현금의 선택",
+ }
+ return _TITLE_MAP.get(templateName, "6막 재무 서사")
+
+
+def _extractSector(company) -> str:
+ """Company에서 업종 정보 추출."""
+ try:
+ ratios = company._finance.ratios
+ sector = getattr(ratios, "sector", None) or ""
+ industryGroup = getattr(ratios, "industryGroup", None) or ""
+ if sector and industryGroup:
+ return f"{sector} > {industryGroup}"
+ return sector or industryGroup or ""
+ except (AttributeError, ValueError):
+ return ""
+
+
+def _extractCreditGrade(company) -> str:
+ """Credit 등급 참조 (실패 시 빈 문자열)."""
+ try:
+ from dartlab.credit.engine import evaluateCompany
+
+ result = evaluateCompany(company)
+ if result:
+ return result.get("grade", "")
+ except (ImportError, ValueError, KeyError, TypeError, AttributeError):
+ pass
+ return ""
+
+
+def _resolveSlug(stockCode: str, corpName: str) -> tuple[int, str]:
+ """블로그 포스트 순번과 slug 결정."""
+ registry = _loadRegistry()
+
+ # 기존 레지스트리에서 찾기
+ for entry in registry:
+ if entry.get("stockCode") == stockCode:
+ return entry["order"], entry["slug"]
+
+ # 새 순번 계산
+ existingOrders = [e.get("order", 0) for e in registry]
+ nextOrder = max(existingOrders, default=0) + 1
+
+ # 기존 디렉토리에서도 확인
+ if _BLOG_DIR.exists():
+ for d in _BLOG_DIR.iterdir():
+ if d.is_dir() and d.name[0].isdigit():
+ try:
+ dirOrder = int(d.name.split("-")[0])
+ if dirOrder >= nextOrder:
+ nextOrder = dirOrder + 1
+ if stockCode in d.name:
+ slug = d.name[len(d.name.split("-")[0]) + 1 :]
+ return dirOrder, slug
+ except (ValueError, IndexError):
+ pass
+
+ # slug 생성 (종목코드-영문명)
+ slug = f"{stockCode}-{_toSlug(corpName)}"
+ return nextOrder, slug
+
+
+def _toSlug(name: str) -> str:
+ """한글 기업명 → URL slug."""
+ import re
+
+ _SLUG_MAP = {
+ "삼성전자": "samsung-electronics",
+ "SK하이닉스": "sk-hynix",
+ "LG화학": "lg-chem",
+ "네이버": "naver",
+ "카카오": "kakao",
+ "현대차": "hyundai-motor",
+ "기아": "kia",
+ "셀트리온": "celltrion",
+ "한국전력": "kepco",
+ "SK텔레콤": "skt",
+ "LG전자": "lg-electronics",
+ "KT&G": "ktng",
+ "한화": "hanwha",
+ "삼양식품": "samyang-foods",
+ "코스맥스": "cosmax",
+ "대한항공": "korean-air",
+ "크래프톤": "krafton",
+ "BGF리테일": "bgf-retail",
+ "코웨이": "coway",
+ "현대건설": "hyundai-ec",
+ }
+ if name in _SLUG_MAP:
+ return _SLUG_MAP[name]
+ ascii_name = re.sub(r"[^a-zA-Z0-9]", "-", name.lower()).strip("-")
+ return ascii_name or "company"
+
+
+def _loadRegistry() -> list[dict]:
+ """레지스트리 로드."""
+ if _REGISTRY_PATH.exists():
+ try:
+ return json.loads(_REGISTRY_PATH.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError):
+ pass
+ return []
+
+
+def _updateRegistry(
+ stockCode: str,
+ corpName: str,
+ order: int,
+ slug: str,
+ review,
+ grade: str,
+) -> None:
+ """레지스트리 업데이트."""
+ registry = _loadRegistry()
+ today = datetime.now().strftime("%Y-%m-%d")
+ templateName = getattr(review, "template", None) or ""
+
+ entry = {
+ "stockCode": stockCode,
+ "corpName": corpName,
+ "order": order,
+ "slug": slug,
+ "template": templateName,
+ "grade": grade,
+ "publishedAt": today,
+ }
+
+ # 기존 항목 업데이트
+ updated = False
+ for i, e in enumerate(registry):
+ if e.get("stockCode") == stockCode:
+ registry[i] = entry
+ updated = True
+ break
+
+ if not updated:
+ registry.append(entry)
+
+ registry.sort(key=lambda e: e.get("order", 0))
+
+ _REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
+ _REGISTRY_PATH.write_text(
+ json.dumps(registry, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
diff --git a/src/dartlab/review/registry.py b/src/dartlab/review/registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..d8ef2ce7f47eb78bc2c357a866f3aa251b9ac7b5
--- /dev/null
+++ b/src/dartlab/review/registry.py
@@ -0,0 +1,1188 @@
+"""review 레지스트리 — 템플릿 기반 Review 생성."""
+
+from __future__ import annotations
+
+from dartlab.review.layout import ReviewLayout
+from dartlab.review.section import Section
+from dartlab.review.templates import TEMPLATE_ORDER, TEMPLATES
+from dartlab.review.utils import isTerminal
+
+
+def buildBlocks(company, keys: set[str] | None = None, *, basePeriod: str | None = None):
+ """블록 사전 -- analysis calc* 결과를 블록으로 변환.
+
+ keys가 지정되면 해당 블록만 빌드한다 (선택적 빌드).
+ keys=None이면 전체 블록을 빌드한다 (기존 동작).
+
+ [최적화] keys=None (전체 빌드) 시 4엔진의 무거운 외부 데이터 calc를
+ ThreadPoolExecutor로 미리 워밍업한다. 결과는 BoundedCache(thread-safe)에
+ 저장되어 이후 순차 빌드가 캐시 hit으로 즉시 완료된다.
+ """
+ # builders와 analysis의 금액 포맷을 company.currency에 맞게 설정 (contextvars — 스레드 안전)
+ from dartlab.review.builders import _review_currency
+
+ _currency = getattr(company, "currency", "KRW")
+ _review_currency.set(_currency)
+ try:
+ from dartlab.analysis.financial.capital import _analysis_currency
+
+ _analysis_currency.set(_currency)
+ except ImportError:
+ pass
+
+ # [Phase 3 시도 — 워밍업 전략 폐기]
+ # ThreadPoolExecutor로 calc 병렬 워밍업을 시도했으나:
+ # 1. asyncio 코루틴 경고 (quant fetch_async가 thread 내 실행 시 충돌)
+ # 2. scorecard 워밍업이 다른 무거운 calc 트리거 → 메모리 압박 → 캐시 클리어
+ # 3. 결과: 워밍업 비용 > 캐시 hit 이득
+ # 결론: 메모이제이션(Phase 1)만으로 충분. 워밍업은 BoundedCache pressure 한계로 비효율.
+
+ def _safe(fn):
+ try:
+ import polars as pl
+
+ _polarsErr = pl.exceptions.PolarsError
+ except ImportError:
+ _polarsErr = RuntimeError
+ try:
+ return fn()
+ except (
+ KeyError,
+ ValueError,
+ TypeError,
+ AttributeError,
+ ArithmeticError,
+ ImportError,
+ RuntimeError,
+ IndexError,
+ _polarsErr,
+ ) as exc:
+ import logging
+
+ logging.getLogger("dartlab.review").debug(
+ "review block build 실패: %s — %s: %s",
+ getattr(fn, "__name__", "?"),
+ type(exc).__name__,
+ exc,
+ )
+ return []
+
+ def _need(key: str) -> bool:
+ return keys is None or key in keys
+
+ b: dict = {}
+
+ # ── 1부: 사업구조 ──
+ # import는 해당 블록이 필요할 때만 (그룹 단위)
+ if keys is None or keys & {
+ "profile",
+ "segmentComposition",
+ "segmentTrend",
+ "region",
+ "product",
+ "growth",
+ "concentration",
+ "revenueQuality",
+ "growthContribution",
+ "revenueFlags",
+ }:
+ from dartlab.analysis.financial.revenue import (
+ calcBreakdown,
+ calcCompanyProfile,
+ calcConcentration,
+ calcFlags,
+ calcGrowthContribution,
+ calcRevenueGrowth,
+ calcRevenueQuality,
+ calcSegmentComposition,
+ calcSegmentTrend,
+ )
+ from dartlab.review.builders import (
+ breakdownBlock,
+ concentrationBlock,
+ growthContributionBlock,
+ profileBlock,
+ revenueFlagsBlock,
+ revenueGrowthBlock,
+ revenueQualityBlock,
+ segmentCompositionBlock,
+ segmentTrendBlock,
+ )
+
+ if _need("profile"):
+ b["profile"] = _safe(lambda: profileBlock(calcCompanyProfile(company, basePeriod=basePeriod)))
+ if _need("segmentComposition"):
+ b["segmentComposition"] = _safe(
+ lambda: segmentCompositionBlock(calcSegmentComposition(company, basePeriod=basePeriod))
+ )
+ if _need("segmentTrend"):
+ b["segmentTrend"] = _safe(lambda: segmentTrendBlock(calcSegmentTrend(company, basePeriod=basePeriod)))
+ if _need("region"):
+ b["region"] = _safe(
+ lambda: breakdownBlock(calcBreakdown(company, "region", basePeriod=basePeriod), "region")
+ )
+ if _need("product"):
+ b["product"] = _safe(
+ lambda: breakdownBlock(calcBreakdown(company, "product", basePeriod=basePeriod), "product")
+ )
+ if _need("growth"):
+ b["growth"] = _safe(lambda: revenueGrowthBlock(calcRevenueGrowth(company, basePeriod=basePeriod)))
+ if _need("concentration"):
+ b["concentration"] = _safe(lambda: concentrationBlock(calcConcentration(company, basePeriod=basePeriod)))
+ if _need("revenueQuality"):
+ b["revenueQuality"] = _safe(lambda: revenueQualityBlock(calcRevenueQuality(company, basePeriod=basePeriod)))
+ if _need("growthContribution"):
+ b["growthContribution"] = _safe(
+ lambda: growthContributionBlock(calcGrowthContribution(company, basePeriod=basePeriod))
+ )
+ if _need("revenueFlags"):
+ b["revenueFlags"] = _safe(lambda: revenueFlagsBlock(calcFlags(company, basePeriod=basePeriod)))
+
+ if keys is None or keys & {
+ "fundingSources",
+ "capitalOverview",
+ "capitalTimeline",
+ "debtTimeline",
+ "interestBurden",
+ "liquidity",
+ "cashFlowStructure",
+ "distressIndicators",
+ "capitalFlags",
+ }:
+ from dartlab.analysis.financial.capital import (
+ calcCapitalFlags,
+ calcCapitalOverview,
+ calcCapitalTimeline,
+ calcCashFlowStructure,
+ calcDebtTimeline,
+ calcDistressIndicators,
+ calcFundingSources,
+ calcInterestBurden,
+ calcLiquidity,
+ )
+ from dartlab.review.builders import (
+ capitalFlagsBlock,
+ capitalOverviewBlock,
+ capitalTimelineBlock,
+ cashFlowBlock,
+ debtTimelineBlock,
+ distressBlock,
+ fundingSourcesBlock,
+ interestBurdenBlock,
+ liquidityBlock,
+ )
+
+ if _need("fundingSources"):
+ b["fundingSources"] = _safe(lambda: fundingSourcesBlock(calcFundingSources(company, basePeriod=basePeriod)))
+ if _need("capitalOverview"):
+ b["capitalOverview"] = _safe(
+ lambda: capitalOverviewBlock(calcCapitalOverview(company, basePeriod=basePeriod))
+ )
+ if _need("capitalTimeline"):
+ b["capitalTimeline"] = _safe(
+ lambda: capitalTimelineBlock(calcCapitalTimeline(company, basePeriod=basePeriod))
+ )
+ if _need("debtTimeline"):
+ b["debtTimeline"] = _safe(lambda: debtTimelineBlock(calcDebtTimeline(company, basePeriod=basePeriod)))
+ if _need("interestBurden"):
+ b["interestBurden"] = _safe(lambda: interestBurdenBlock(calcInterestBurden(company, basePeriod=basePeriod)))
+ if _need("liquidity"):
+ b["liquidity"] = _safe(lambda: liquidityBlock(calcLiquidity(company, basePeriod=basePeriod)))
+ if _need("cashFlowStructure"):
+ b["cashFlowStructure"] = _safe(lambda: cashFlowBlock(calcCashFlowStructure(company, basePeriod=basePeriod)))
+ if _need("distressIndicators"):
+ b["distressIndicators"] = _safe(
+ lambda: distressBlock(calcDistressIndicators(company, basePeriod=basePeriod))
+ )
+ if _need("capitalFlags"):
+ b["capitalFlags"] = _safe(lambda: capitalFlagsBlock(calcCapitalFlags(company, basePeriod=basePeriod)))
+
+ if keys is None or keys & {"assetStructure", "workingCapital", "capexPattern", "assetFlags"}:
+ from dartlab.analysis.financial.asset import (
+ calcAssetFlags,
+ calcAssetStructure,
+ calcCapexPattern,
+ calcWorkingCapital,
+ )
+ from dartlab.review.builders import (
+ assetFlagsBlock,
+ assetStructureBlock,
+ capexBlock,
+ workingCapitalBlock,
+ )
+
+ if _need("assetStructure"):
+ b["assetStructure"] = _safe(lambda: assetStructureBlock(calcAssetStructure(company, basePeriod=basePeriod)))
+ if _need("workingCapital"):
+ b["workingCapital"] = _safe(lambda: workingCapitalBlock(calcWorkingCapital(company, basePeriod=basePeriod)))
+ if _need("capexPattern"):
+ b["capexPattern"] = _safe(lambda: capexBlock(calcCapexPattern(company, basePeriod=basePeriod)))
+ if _need("assetFlags"):
+ b["assetFlags"] = _safe(lambda: assetFlagsBlock(calcAssetFlags(company, basePeriod=basePeriod)))
+
+ if keys is None or keys & {"cashFlowOverview", "cashQuality", "ocfDecomposition", "cashFlowFlags"}:
+ from dartlab.analysis.financial.cashflow import (
+ calcCashFlowFlags,
+ calcCashFlowOverview,
+ calcCashQuality,
+ calcOcfDecomposition,
+ )
+ from dartlab.review.builders import (
+ cashFlowFlagsBlock,
+ cashFlowOverviewBlock,
+ cashQualityBlock,
+ ocfDecompositionBlock,
+ )
+
+ if _need("cashFlowOverview"):
+ b["cashFlowOverview"] = _safe(
+ lambda: cashFlowOverviewBlock(calcCashFlowOverview(company, basePeriod=basePeriod))
+ )
+ if _need("cashQuality"):
+ b["cashQuality"] = _safe(lambda: cashQualityBlock(calcCashQuality(company, basePeriod=basePeriod)))
+ if _need("ocfDecomposition"):
+ b["ocfDecomposition"] = _safe(
+ lambda: ocfDecompositionBlock(calcOcfDecomposition(company, basePeriod=basePeriod))
+ )
+ if _need("cashFlowFlags"):
+ b["cashFlowFlags"] = _safe(lambda: cashFlowFlagsBlock(calcCashFlowFlags(company, basePeriod=basePeriod)))
+
+ # ── 2부: 재무비율 분석 ──
+ if keys is None or keys & {
+ "marginTrend",
+ "returnTrend",
+ "dupont",
+ "penmanDecomposition",
+ "roicTree",
+ "profitabilityFlags",
+ }:
+ from dartlab.analysis.financial.profitability import (
+ calcDupont,
+ calcMarginTrend,
+ calcPenmanDecomposition,
+ calcProfitabilityFlags,
+ calcReturnTrend,
+ calcRoicTree,
+ )
+ from dartlab.review.builders import (
+ dupontBlock,
+ marginTrendBlock,
+ penmanDecompositionBlock,
+ profitabilityFlagsBlock,
+ returnTrendBlock,
+ roicTreeBlock,
+ )
+
+ if _need("marginTrend"):
+ b["marginTrend"] = _safe(lambda: marginTrendBlock(calcMarginTrend(company, basePeriod=basePeriod)))
+ if _need("returnTrend"):
+ b["returnTrend"] = _safe(lambda: returnTrendBlock(calcReturnTrend(company, basePeriod=basePeriod)))
+ if _need("dupont"):
+ b["dupont"] = _safe(lambda: dupontBlock(calcDupont(company, basePeriod=basePeriod)))
+ if _need("penmanDecomposition"):
+ b["penmanDecomposition"] = _safe(
+ lambda: penmanDecompositionBlock(calcPenmanDecomposition(company, basePeriod=basePeriod))
+ )
+ if _need("roicTree"):
+ b["roicTree"] = _safe(lambda: roicTreeBlock(calcRoicTree(company, basePeriod=basePeriod)))
+ if _need("profitabilityFlags"):
+ b["profitabilityFlags"] = _safe(
+ lambda: profitabilityFlagsBlock(calcProfitabilityFlags(company, basePeriod=basePeriod))
+ )
+
+ if keys is None or keys & {"growthTrend", "growthQuality", "cagrComparison", "growthFlags"}:
+ from dartlab.analysis.financial.growthAnalysis import (
+ calcCagrComparison,
+ calcGrowthFlags,
+ calcGrowthQuality,
+ calcGrowthTrend,
+ )
+ from dartlab.review.builders import (
+ cagrComparisonBlock,
+ growthFlagsBlock,
+ growthQualityBlock,
+ growthTrendBlock,
+ )
+
+ if _need("growthTrend"):
+ b["growthTrend"] = _safe(lambda: growthTrendBlock(calcGrowthTrend(company, basePeriod=basePeriod)))
+ if _need("growthQuality"):
+ b["growthQuality"] = _safe(lambda: growthQualityBlock(calcGrowthQuality(company, basePeriod=basePeriod)))
+ if _need("cagrComparison"):
+ b["cagrComparison"] = _safe(lambda: cagrComparisonBlock(calcCagrComparison(company, basePeriod=basePeriod)))
+ if _need("growthFlags"):
+ b["growthFlags"] = _safe(lambda: growthFlagsBlock(calcGrowthFlags(company, basePeriod=basePeriod)))
+
+ if keys is None or keys & {"leverageTrend", "coverageTrend", "distressScore", "stabilityFlags", "marketRisk"}:
+ from dartlab.analysis.financial.stability import (
+ calcCoverageTrend,
+ calcDistressScore,
+ calcLeverageTrend,
+ calcStabilityFlags,
+ )
+ from dartlab.review.builders import (
+ coverageTrendBlock,
+ distressScoreBlock,
+ leverageTrendBlock,
+ stabilityFlagsBlock,
+ )
+
+ if _need("leverageTrend"):
+ b["leverageTrend"] = _safe(lambda: leverageTrendBlock(calcLeverageTrend(company, basePeriod=basePeriod)))
+ if _need("coverageTrend"):
+ b["coverageTrend"] = _safe(lambda: coverageTrendBlock(calcCoverageTrend(company, basePeriod=basePeriod)))
+ if _need("distressScore"):
+ b["distressScore"] = _safe(lambda: distressScoreBlock(calcDistressScore(company, basePeriod=basePeriod)))
+ if _need("stabilityFlags"):
+ b["stabilityFlags"] = _safe(lambda: stabilityFlagsBlock(calcStabilityFlags(company, basePeriod=basePeriod)))
+ if _need("marketRisk"):
+ from dartlab.quant.extended import calcMarketRisk
+ from dartlab.review.builders import marketRiskBlock
+
+ b["marketRisk"] = _safe(lambda: marketRiskBlock(calcMarketRisk(company)))
+
+ if keys is None or keys & {"turnoverTrend", "cccTrend", "efficiencyFlags"}:
+ from dartlab.analysis.financial.efficiency import (
+ calcCccTrend,
+ calcEfficiencyFlags,
+ calcTurnoverTrend,
+ )
+ from dartlab.review.builders import (
+ cccTrendBlock,
+ efficiencyFlagsBlock,
+ turnoverTrendBlock,
+ )
+
+ if _need("turnoverTrend"):
+ b["turnoverTrend"] = _safe(lambda: turnoverTrendBlock(calcTurnoverTrend(company, basePeriod=basePeriod)))
+ if _need("cccTrend"):
+ b["cccTrend"] = _safe(lambda: cccTrendBlock(calcCccTrend(company, basePeriod=basePeriod)))
+ if _need("efficiencyFlags"):
+ b["efficiencyFlags"] = _safe(
+ lambda: efficiencyFlagsBlock(calcEfficiencyFlags(company, basePeriod=basePeriod))
+ )
+
+ if keys is None or keys & {"scorecard", "piotroski", "summaryFlags"}:
+ from dartlab.analysis.financial.scorecard import (
+ calcPiotroskiDetail,
+ calcScorecard,
+ calcSummaryFlags,
+ )
+ from dartlab.review.builders import (
+ piotroskiBlock,
+ scorecardBlock,
+ summaryFlagsBlock,
+ )
+
+ if _need("scorecard"):
+ b["scorecard"] = _safe(lambda: scorecardBlock(calcScorecard(company, basePeriod=basePeriod)))
+ if _need("piotroski"):
+ b["piotroski"] = _safe(lambda: piotroskiBlock(calcPiotroskiDetail(company, basePeriod=basePeriod)))
+ if _need("summaryFlags"):
+ b["summaryFlags"] = _safe(lambda: summaryFlagsBlock(calcSummaryFlags(company, basePeriod=basePeriod)))
+
+ # ── 3부: 심화 분석 ──
+ if keys is None or keys & {
+ "accrualAnalysis",
+ "earningsPersistence",
+ "beneishMScore",
+ "richardsonAccrual",
+ "nonOperatingBreakdown",
+ "earningsQualityFlags",
+ }:
+ from dartlab.analysis.financial.earningsQuality import (
+ calcAccrualAnalysis,
+ calcBeneishTimeline,
+ calcEarningsPersistence,
+ calcEarningsQualityFlags,
+ calcNonOperatingBreakdown,
+ calcRichardsonAccrual,
+ )
+ from dartlab.review.builders import (
+ accrualAnalysisBlock,
+ beneishMScoreBlock,
+ earningsPersistenceBlock,
+ earningsQualityFlagsBlock,
+ nonOperatingBreakdownBlock,
+ richardsonAccrualBlock,
+ )
+
+ if _need("accrualAnalysis"):
+ b["accrualAnalysis"] = _safe(
+ lambda: accrualAnalysisBlock(calcAccrualAnalysis(company, basePeriod=basePeriod))
+ )
+ if _need("earningsPersistence"):
+ b["earningsPersistence"] = _safe(
+ lambda: earningsPersistenceBlock(calcEarningsPersistence(company, basePeriod=basePeriod))
+ )
+ if _need("beneishMScore"):
+ b["beneishMScore"] = _safe(lambda: beneishMScoreBlock(calcBeneishTimeline(company, basePeriod=basePeriod)))
+ if _need("richardsonAccrual"):
+ b["richardsonAccrual"] = _safe(
+ lambda: richardsonAccrualBlock(calcRichardsonAccrual(company, basePeriod=basePeriod))
+ )
+ if _need("nonOperatingBreakdown"):
+ b["nonOperatingBreakdown"] = _safe(
+ lambda: nonOperatingBreakdownBlock(calcNonOperatingBreakdown(company, basePeriod=basePeriod))
+ )
+ if _need("earningsQualityFlags"):
+ b["earningsQualityFlags"] = _safe(
+ lambda: earningsQualityFlagsBlock(calcEarningsQualityFlags(company, basePeriod=basePeriod))
+ )
+
+ if keys is None or keys & {"costBreakdown", "operatingLeverage", "breakevenEstimate", "costStructureFlags"}:
+ from dartlab.analysis.financial.costStructure import (
+ calcBreakevenEstimate,
+ calcCostBreakdown,
+ calcCostStructureFlags,
+ calcOperatingLeverage,
+ )
+ from dartlab.review.builders import (
+ breakevenEstimateBlock,
+ costBreakdownBlock,
+ costStructureFlagsBlock,
+ operatingLeverageBlock,
+ )
+
+ if _need("costBreakdown"):
+ b["costBreakdown"] = _safe(lambda: costBreakdownBlock(calcCostBreakdown(company, basePeriod=basePeriod)))
+ if _need("operatingLeverage"):
+ b["operatingLeverage"] = _safe(
+ lambda: operatingLeverageBlock(calcOperatingLeverage(company, basePeriod=basePeriod))
+ )
+ if _need("breakevenEstimate"):
+ b["breakevenEstimate"] = _safe(
+ lambda: breakevenEstimateBlock(calcBreakevenEstimate(company, basePeriod=basePeriod))
+ )
+ if _need("costStructureFlags"):
+ b["costStructureFlags"] = _safe(
+ lambda: costStructureFlagsBlock(calcCostStructureFlags(company, basePeriod=basePeriod))
+ )
+
+ if keys is None or keys & {
+ "dividendPolicy",
+ "shareholderReturn",
+ "reinvestment",
+ "fcfUsage",
+ "capitalAllocationFlags",
+ }:
+ from dartlab.analysis.financial.capitalAllocation import (
+ calcCapitalAllocationFlags,
+ calcDividendPolicy,
+ calcFcfUsage,
+ calcReinvestment,
+ calcShareholderReturn,
+ )
+ from dartlab.review.builders import (
+ capitalAllocationFlagsBlock,
+ dividendPolicyBlock,
+ fcfUsageBlock,
+ reinvestmentBlock,
+ shareholderReturnBlock,
+ )
+
+ if _need("dividendPolicy"):
+ b["dividendPolicy"] = _safe(lambda: dividendPolicyBlock(calcDividendPolicy(company, basePeriod=basePeriod)))
+ if _need("shareholderReturn"):
+ b["shareholderReturn"] = _safe(
+ lambda: shareholderReturnBlock(calcShareholderReturn(company, basePeriod=basePeriod))
+ )
+ if _need("reinvestment"):
+ b["reinvestment"] = _safe(lambda: reinvestmentBlock(calcReinvestment(company, basePeriod=basePeriod)))
+ if _need("fcfUsage"):
+ b["fcfUsage"] = _safe(lambda: fcfUsageBlock(calcFcfUsage(company, basePeriod=basePeriod)))
+ if _need("capitalAllocationFlags"):
+ b["capitalAllocationFlags"] = _safe(
+ lambda: capitalAllocationFlagsBlock(calcCapitalAllocationFlags(company, basePeriod=basePeriod))
+ )
+
+ if keys is None or keys & {"roicTimeline", "investmentIntensity", "evaTimeline", "investmentFlags"}:
+ from dartlab.analysis.financial.investmentAnalysis import (
+ calcEvaTimeline,
+ calcInvestmentFlags,
+ calcInvestmentIntensity,
+ calcRoicTimeline,
+ )
+ from dartlab.review.builders import (
+ evaTimelineBlock,
+ investmentFlagsBlock,
+ investmentIntensityBlock,
+ roicTimelineBlock,
+ )
+
+ if _need("roicTimeline"):
+ b["roicTimeline"] = _safe(lambda: roicTimelineBlock(calcRoicTimeline(company, basePeriod=basePeriod)))
+ if _need("investmentIntensity"):
+ b["investmentIntensity"] = _safe(
+ lambda: investmentIntensityBlock(calcInvestmentIntensity(company, basePeriod=basePeriod))
+ )
+ if _need("evaTimeline"):
+ b["evaTimeline"] = _safe(lambda: evaTimelineBlock(calcEvaTimeline(company, basePeriod=basePeriod)))
+ if _need("investmentFlags"):
+ b["investmentFlags"] = _safe(
+ lambda: investmentFlagsBlock(calcInvestmentFlags(company, basePeriod=basePeriod))
+ )
+
+ if keys is None or keys & {
+ "isCfDivergence",
+ "isBsDivergence",
+ "anomalyScore",
+ "articulationCheck",
+ "effectiveTaxRate",
+ "deferredTax",
+ "crossStatementFlags",
+ }:
+ from dartlab.analysis.financial.crossStatement import (
+ calcAnomalyScore,
+ calcArticulationCheck,
+ calcCrossStatementFlags,
+ calcIsBsDivergence,
+ calcIsCfDivergence,
+ )
+ from dartlab.analysis.financial.taxAnalysis import (
+ calcDeferredTax,
+ calcEffectiveTaxRate,
+ calcTaxFlags,
+ )
+ from dartlab.review.builders import (
+ anomalyScoreBlock,
+ articulationCheckBlock,
+ crossStatementFlagsBlock,
+ deferredTaxBlock,
+ effectiveTaxRateBlock,
+ isBsDivergenceBlock,
+ isCfDivergenceBlock,
+ )
+
+ if _need("isCfDivergence"):
+ b["isCfDivergence"] = _safe(lambda: isCfDivergenceBlock(calcIsCfDivergence(company, basePeriod=basePeriod)))
+ if _need("isBsDivergence"):
+ b["isBsDivergence"] = _safe(lambda: isBsDivergenceBlock(calcIsBsDivergence(company, basePeriod=basePeriod)))
+ if _need("anomalyScore"):
+ b["anomalyScore"] = _safe(lambda: anomalyScoreBlock(calcAnomalyScore(company, basePeriod=basePeriod)))
+ if _need("articulationCheck"):
+ b["articulationCheck"] = _safe(
+ lambda: articulationCheckBlock(calcArticulationCheck(company, basePeriod=basePeriod))
+ )
+ if _need("effectiveTaxRate"):
+ b["effectiveTaxRate"] = _safe(
+ lambda: effectiveTaxRateBlock(calcEffectiveTaxRate(company, basePeriod=basePeriod))
+ )
+ if _need("deferredTax"):
+ b["deferredTax"] = _safe(lambda: deferredTaxBlock(calcDeferredTax(company, basePeriod=basePeriod)))
+ if _need("crossStatementFlags"):
+ b["crossStatementFlags"] = _safe(
+ lambda: crossStatementFlagsBlock(
+ calcCrossStatementFlags(company, basePeriod=basePeriod)
+ + calcTaxFlags(company, basePeriod=basePeriod)
+ )
+ )
+
+ # ── 3-6: 신용평가 ──
+ if keys is None or keys & {
+ "creditMetrics",
+ "creditScore",
+ "creditHistory",
+ "cashFlowGrade",
+ "creditPeerPosition",
+ "creditFlags",
+ "creditNarrative",
+ "creditAudit",
+ }:
+ from dartlab.credit.calcs import (
+ calcCashFlowGrade,
+ calcCreditAudit,
+ calcCreditFlags,
+ calcCreditHistory,
+ calcCreditMetrics,
+ calcCreditNarrative,
+ calcCreditPeerPosition,
+ calcCreditScore,
+ )
+ from dartlab.review.builders import (
+ cashFlowGradeBlock,
+ creditAuditBlock,
+ creditFlagsBlock,
+ creditHistoryBlock,
+ creditMetricsBlock,
+ creditNarrativeBlock,
+ creditPeerPositionBlock,
+ creditScoreBlock,
+ )
+
+ if _need("creditMetrics"):
+ b["creditMetrics"] = _safe(lambda: creditMetricsBlock(calcCreditMetrics(company, basePeriod=basePeriod)))
+ if _need("creditScore"):
+ b["creditScore"] = _safe(lambda: creditScoreBlock(calcCreditScore(company, basePeriod=basePeriod)))
+ if _need("creditHistory"):
+ b["creditHistory"] = _safe(lambda: creditHistoryBlock(calcCreditHistory(company, basePeriod=basePeriod)))
+ if _need("cashFlowGrade"):
+ b["cashFlowGrade"] = _safe(lambda: cashFlowGradeBlock(calcCashFlowGrade(company, basePeriod=basePeriod)))
+ if _need("creditPeerPosition"):
+ b["creditPeerPosition"] = _safe(
+ lambda: creditPeerPositionBlock(calcCreditPeerPosition(company, basePeriod=basePeriod))
+ )
+ if _need("creditFlags"):
+ b["creditFlags"] = _safe(lambda: creditFlagsBlock(calcCreditFlags(company, basePeriod=basePeriod)))
+ if _need("creditNarrative"):
+ b["creditNarrative"] = _safe(
+ lambda: creditNarrativeBlock(calcCreditNarrative(company, basePeriod=basePeriod))
+ )
+ if _need("creditAudit"):
+ b["creditAudit"] = _safe(lambda: creditAuditBlock(calcCreditAudit(company, basePeriod=basePeriod)))
+
+ # ── 4부: 가치평가 ──
+ if keys is None or keys & {
+ "dcfValuation",
+ "ddmValuation",
+ "relativeValuation",
+ "residualIncome",
+ "priceTarget",
+ "reverseImplied",
+ "sensitivity",
+ "valuationSynthesis",
+ "valuationFlags",
+ }:
+ from dartlab.analysis.financial.valuation import (
+ calcDcf,
+ calcDdm,
+ calcPriceTarget,
+ calcReverseImplied,
+ calcSensitivity,
+ calcValuationFlags,
+ calcValuationSynthesis,
+ )
+ from dartlab.analysis.financial.valuation import (
+ calcRelativeValuation as calcRelVal,
+ )
+ from dartlab.analysis.financial.valuation import (
+ calcResidualIncome as calcRim,
+ )
+ from dartlab.review.builders import (
+ dcfValuationBlock,
+ ddmValuationBlock,
+ priceTargetBlock,
+ relativeValuationBlock,
+ residualIncomeBlock,
+ reverseImpliedBlock,
+ sensitivityBlock,
+ valuationFlagsBlock,
+ valuationSynthesisBlock,
+ )
+
+ if _need("dcfValuation"):
+ b["dcfValuation"] = _safe(lambda: dcfValuationBlock(calcDcf(company, basePeriod=basePeriod)))
+ if _need("ddmValuation"):
+ b["ddmValuation"] = _safe(lambda: ddmValuationBlock(calcDdm(company, basePeriod=basePeriod)))
+ if _need("relativeValuation"):
+ b["relativeValuation"] = _safe(lambda: relativeValuationBlock(calcRelVal(company, basePeriod=basePeriod)))
+ if _need("residualIncome"):
+ b["residualIncome"] = _safe(lambda: residualIncomeBlock(calcRim(company, basePeriod=basePeriod)))
+ # priceTarget 결과를 valuationSynthesis 에 전달 — 두 모델 차이 narration 자동 추가
+ _ptCache: dict = {}
+
+ def _getPt():
+ if "v" not in _ptCache:
+ _ptCache["v"] = calcPriceTarget(company, basePeriod=basePeriod)
+ return _ptCache["v"]
+
+ if _need("priceTarget"):
+ b["priceTarget"] = _safe(lambda: priceTargetBlock(_getPt()))
+ if _need("reverseImplied"):
+ b["reverseImplied"] = _safe(lambda: reverseImpliedBlock(calcReverseImplied(company, basePeriod=basePeriod)))
+ if _need("sensitivity"):
+ b["sensitivity"] = _safe(lambda: sensitivityBlock(calcSensitivity(company, basePeriod=basePeriod)))
+ if _need("valuationSynthesis"):
+ b["valuationSynthesis"] = _safe(
+ lambda: valuationSynthesisBlock(
+ calcValuationSynthesis(company, basePeriod=basePeriod),
+ priceTargetData=_getPt(),
+ )
+ )
+ if _need("valuationFlags"):
+ b["valuationFlags"] = _safe(lambda: valuationFlagsBlock(calcValuationFlags(company, basePeriod=basePeriod)))
+
+ # ── 5부: 비재무 심화 ──
+ if keys is None or keys & {"ownershipTrend", "boardComposition", "auditOpinionTrend", "governanceFlags"}:
+ from dartlab.analysis.financial.governance import (
+ calcAuditOpinionTrend,
+ calcBoardComposition,
+ calcGovernanceFlags,
+ calcOwnershipTrend,
+ )
+ from dartlab.review.builders import (
+ auditOpinionTrendBlock,
+ boardCompositionBlock,
+ governanceFlagsBlock,
+ ownershipTrendBlock,
+ )
+
+ if _need("ownershipTrend"):
+ b["ownershipTrend"] = _safe(lambda: ownershipTrendBlock(calcOwnershipTrend(company, basePeriod=basePeriod)))
+ if _need("boardComposition"):
+ b["boardComposition"] = _safe(
+ lambda: boardCompositionBlock(calcBoardComposition(company, basePeriod=basePeriod))
+ )
+ if _need("auditOpinionTrend"):
+ b["auditOpinionTrend"] = _safe(
+ lambda: auditOpinionTrendBlock(calcAuditOpinionTrend(company, basePeriod=basePeriod))
+ )
+ if _need("governanceFlags"):
+ b["governanceFlags"] = _safe(
+ lambda: governanceFlagsBlock(calcGovernanceFlags(company, basePeriod=basePeriod))
+ )
+
+ if keys is None or keys & {
+ "disclosureChangeSummary",
+ "keyTopicChanges",
+ "changeIntensity",
+ "disclosureDeltaFlags",
+ }:
+ from dartlab.analysis.financial.disclosureDelta import (
+ calcChangeIntensity,
+ calcDisclosureChangeSummary,
+ calcDisclosureDeltaFlags,
+ calcKeyTopicChanges,
+ )
+ from dartlab.review.builders import (
+ changeIntensityBlock,
+ disclosureChangeSummaryBlock,
+ disclosureDeltaFlagsBlock,
+ keyTopicChangesBlock,
+ )
+
+ if _need("disclosureChangeSummary"):
+ b["disclosureChangeSummary"] = _safe(
+ lambda: disclosureChangeSummaryBlock(calcDisclosureChangeSummary(company, basePeriod=basePeriod))
+ )
+ if _need("keyTopicChanges"):
+ b["keyTopicChanges"] = _safe(
+ lambda: keyTopicChangesBlock(calcKeyTopicChanges(company, basePeriod=basePeriod))
+ )
+ if _need("changeIntensity"):
+ b["changeIntensity"] = _safe(
+ lambda: changeIntensityBlock(calcChangeIntensity(company, basePeriod=basePeriod))
+ )
+ if _need("disclosureDeltaFlags"):
+ b["disclosureDeltaFlags"] = _safe(
+ lambda: disclosureDeltaFlagsBlock(calcDisclosureDeltaFlags(company, basePeriod=basePeriod))
+ )
+
+ if keys is None or keys & {"peerRanking", "riskReturnPosition", "peerBenchmarkFlags"}:
+ from dartlab.analysis.financial.peerBenchmark import (
+ calcPeerBenchmarkFlags,
+ calcPeerRanking,
+ calcRiskReturnPosition,
+ )
+ from dartlab.review.builders import (
+ peerBenchmarkFlagsBlock,
+ peerRankingBlock,
+ riskReturnPositionBlock,
+ )
+
+ if _need("peerRanking"):
+ b["peerRanking"] = _safe(lambda: peerRankingBlock(calcPeerRanking(company, basePeriod=basePeriod)))
+ if _need("riskReturnPosition"):
+ b["riskReturnPosition"] = _safe(
+ lambda: riskReturnPositionBlock(calcRiskReturnPosition(company, basePeriod=basePeriod))
+ )
+ if _need("peerBenchmarkFlags"):
+ b["peerBenchmarkFlags"] = _safe(
+ lambda: peerBenchmarkFlagsBlock(calcPeerBenchmarkFlags(company, basePeriod=basePeriod))
+ )
+
+ # ── 6부: 전망분석 ──
+ if keys is None or keys & {
+ "revenueForecast",
+ "segmentForecast",
+ "proFormaHighlights",
+ "scenarioImpact",
+ "forecastMethodology",
+ "historicalRatios",
+ "forecastFlags",
+ "calibrationReport",
+ }:
+ from dartlab.analysis.financial.forecastCalcs import (
+ calcCalibrationReport,
+ calcForecastFlags,
+ calcForecastMethodology,
+ calcHistoricalRatios,
+ calcProFormaHighlights,
+ calcRevenueForecast,
+ calcScenarioImpact,
+ calcSegmentForecast,
+ )
+ from dartlab.review.builders import (
+ calibrationReportBlock,
+ forecastFlagsBlock,
+ forecastMethodologyBlock,
+ historicalRatiosBlock,
+ proFormaHighlightsBlock,
+ revenueForecastBlock,
+ scenarioImpactBlock,
+ segmentForecastBlock,
+ )
+
+ if _need("revenueForecast"):
+ b["revenueForecast"] = _safe(
+ lambda: revenueForecastBlock(calcRevenueForecast(company, basePeriod=basePeriod))
+ )
+ if _need("segmentForecast"):
+ b["segmentForecast"] = _safe(
+ lambda: segmentForecastBlock(calcSegmentForecast(company, basePeriod=basePeriod))
+ )
+ if _need("proFormaHighlights"):
+ b["proFormaHighlights"] = _safe(
+ lambda: proFormaHighlightsBlock(calcProFormaHighlights(company, basePeriod=basePeriod))
+ )
+ if _need("scenarioImpact"):
+ b["scenarioImpact"] = _safe(lambda: scenarioImpactBlock(calcScenarioImpact(company, basePeriod=basePeriod)))
+ if _need("forecastMethodology"):
+ b["forecastMethodology"] = _safe(
+ lambda: forecastMethodologyBlock(calcForecastMethodology(company, basePeriod=basePeriod))
+ )
+ if _need("historicalRatios"):
+ b["historicalRatios"] = _safe(
+ lambda: historicalRatiosBlock(calcHistoricalRatios(company, basePeriod=basePeriod))
+ )
+ if _need("forecastFlags"):
+ b["forecastFlags"] = _safe(lambda: forecastFlagsBlock(calcForecastFlags(company, basePeriod=basePeriod)))
+ if _need("calibrationReport"):
+ b["calibrationReport"] = _safe(
+ lambda: calibrationReportBlock(calcCalibrationReport(company, basePeriod=basePeriod))
+ )
+
+ # ── 비교분석 (scan 교차 조합 관점 → review 통합) ──
+ if keys is None or keys & {"peerPosition", "governanceSummary"}:
+ from dartlab.review.builders import quantModuleBlock as _scanBlock
+ from dartlab.scan.extended import calcGovernanceSummary, calcPeerPosition
+
+ if _need("peerPosition"):
+ b["peerPosition"] = _safe(lambda: _scanBlock("peerPosition", calcPeerPosition(company)))
+ if _need("governanceSummary"):
+ b["governanceSummary"] = _safe(lambda: _scanBlock("governanceSummary", calcGovernanceSummary(company)))
+
+ # ── 시장분석 (quant 기술적 분석 → review 통합) ──
+ if keys is None or keys & {
+ "technicalVerdict",
+ "technicalSignals",
+ "strategySnapshot",
+ "marketBeta",
+ "fundamentalDivergence",
+ "marketAnalysisFlags",
+ }:
+ from dartlab.quant.extended import (
+ calcCrosscheckNarrative,
+ calcFundamentalDivergence,
+ calcMarketAnalysisFlags,
+ calcMarketBeta,
+ calcQuantConclusion,
+ calcRiskNarrative,
+ calcSignalNarrative,
+ calcStrategyNarrative,
+ calcStrategySnapshot,
+ calcTechnicalSignals,
+ calcTechnicalVerdict,
+ calcTrendNarrative,
+ )
+ from dartlab.review.builders import (
+ fundamentalDivergenceBlock,
+ marketAnalysisFlagsBlock,
+ marketBetaBlock,
+ quantModuleBlock,
+ strategySnapshotBlock,
+ technicalSignalsBlock,
+ technicalVerdictBlock,
+ )
+
+ if _need("technicalVerdict"):
+ b["technicalVerdict"] = _safe(lambda: technicalVerdictBlock(calcTechnicalVerdict(company)))
+ if _need("technicalSignals"):
+ b["technicalSignals"] = _safe(lambda: technicalSignalsBlock(calcTechnicalSignals(company)))
+ # quant 서사 모듈 5+1 — analysis calc 패턴 (각각 독립, review 가 조합)
+ for qkey, qcalc in [
+ ("trendNarrative", calcTrendNarrative),
+ ("riskNarrative", calcRiskNarrative),
+ ("signalNarrative", calcSignalNarrative),
+ ("strategyNarrative", calcStrategyNarrative),
+ ("crosscheckNarrative", calcCrosscheckNarrative),
+ ("quantConclusion", calcQuantConclusion),
+ ]:
+ if _need(qkey):
+ b[qkey] = _safe(lambda c=qcalc: quantModuleBlock(qkey, c(company)))
+ if _need("strategySnapshot"):
+ b["strategySnapshot"] = _safe(lambda: strategySnapshotBlock(calcStrategySnapshot(company)))
+ if _need("marketBeta"):
+ b["marketBeta"] = _safe(lambda: marketBetaBlock(calcMarketBeta(company)))
+ if _need("fundamentalDivergence"):
+ b["fundamentalDivergence"] = _safe(
+ lambda: fundamentalDivergenceBlock(calcFundamentalDivergence(company, basePeriod=basePeriod))
+ )
+ if _need("marketAnalysisFlags"):
+ b["marketAnalysisFlags"] = _safe(lambda: marketAnalysisFlagsBlock(calcMarketAnalysisFlags(company)))
+
+ # ── 매크로 (시장 환경 + 기업-매크로 연결) ──
+ _MACRO_KEYS = {
+ "macroEnvironment",
+ "macroCycle",
+ "macroRates",
+ "macroLiquidity",
+ "macroSentiment",
+ "macroForecast",
+ "macroCorporate",
+ "macroTrade",
+ "macroFlags",
+ "valuationBand",
+ }
+ if keys is None or keys & _MACRO_KEYS:
+ from dartlab.analysis.financial.macroExposure import calcValuationBand
+ from dartlab.review.builders import (
+ macroCorporateBlock,
+ macroCycleBlock,
+ macroEnvironmentBlock,
+ macroFlagsBlock,
+ macroForecastBlock,
+ macroLiquidityBlock,
+ macroRatesBlock,
+ macroSentimentBlock,
+ macroTradeBlock,
+ valuationBandBlock,
+ )
+
+ # macro("종합") 1회 호출 + 캐시 — 11축 전부 포함
+ _macro_summary: list = [None]
+
+ def _ensure_summary():
+ if _macro_summary[0] is None:
+ import dartlab as _dl
+
+ market = getattr(company, "market", "KR")
+ _macro_summary[0] = _dl.macro("종합", market=market)
+ return _macro_summary[0]
+
+ if _need("macroEnvironment"):
+ b["macroEnvironment"] = _safe(lambda: macroEnvironmentBlock(_ensure_summary()))
+ if _need("macroCycle"):
+ b["macroCycle"] = _safe(lambda: macroCycleBlock(_ensure_summary().get("cycle", {})))
+ if _need("macroRates"):
+ b["macroRates"] = _safe(lambda: macroRatesBlock(_ensure_summary().get("rates", {})))
+ if _need("macroLiquidity"):
+ b["macroLiquidity"] = _safe(lambda: macroLiquidityBlock(_ensure_summary().get("liquidity", {})))
+ if _need("macroSentiment"):
+ b["macroSentiment"] = _safe(lambda: macroSentimentBlock(_ensure_summary().get("sentiment", {})))
+ if _need("macroForecast"):
+ b["macroForecast"] = _safe(lambda: macroForecastBlock(_ensure_summary().get("forecast")))
+ if _need("macroCorporate"):
+ b["macroCorporate"] = _safe(lambda: macroCorporateBlock(_ensure_summary().get("corporate")))
+ if _need("macroTrade"):
+ _market = getattr(company, "market", "KR")
+ if _market == "KR":
+ b["macroTrade"] = _safe(lambda: macroTradeBlock(_ensure_summary().get("trade")))
+ if _need("macroFlags"):
+ b["macroFlags"] = _safe(lambda: macroFlagsBlock(_ensure_summary()))
+ if _need("valuationBand"):
+ b["valuationBand"] = _safe(lambda: valuationBandBlock(calcValuationBand(company, basePeriod=basePeriod)))
+
+ from dartlab.review.blockMap import BlockMap
+
+ return BlockMap(b)
+
+
+def buildReview(
+ company,
+ section: str | None = None,
+ layout: ReviewLayout | None = None,
+ helper: bool | None = None,
+ *,
+ preset: str | None = None,
+ template: str | None = None,
+ perspective: str | None = None,
+ detail: bool | None = None,
+ basePeriod: str | None = None,
+):
+ """Company에서 Review를 생성."""
+ from dartlab.review import Review
+
+ ly = layout or ReviewLayout()
+
+ # ── 스토리 템플릿 판별 ──
+ detectedTemplate: str | None = None
+ detectedTemplates: list[str] = []
+ emphasizedKeys: set[str] = set()
+ if template is not None and preset is None:
+ from dartlab.review.templates import STORY_TEMPLATES
+ from dartlab.review.templates import detectTemplate as _detect
+
+ if template == "auto":
+ detectedTemplate = _detect(company)
+ # 복수 매칭도 수집
+ from dartlab.review.templates import detectTemplates as _detectMulti
+
+ try:
+ detectedTemplates = _detectMulti(company)
+ except (AttributeError, ValueError, TypeError):
+ detectedTemplates = []
+ elif template in STORY_TEMPLATES:
+ detectedTemplate = template
+ detectedTemplates = [template]
+ else:
+ detectedTemplates = []
+
+ if detectedTemplate and detectedTemplate in STORY_TEMPLATES:
+ # 주 템플릿의 emphasize + 보조 템플릿의 emphasize 합산
+ for tmplName in detectedTemplates:
+ if tmplName in STORY_TEMPLATES:
+ emphasizedKeys |= STORY_TEMPLATES[tmplName].get("emphasize", set())
+
+ # ── 프리셋 적용 ──
+ if preset is not None:
+ from dartlab.review.presets import PRESETS
+
+ if preset not in PRESETS:
+ raise ValueError(f"알 수 없는 프리셋: {preset}. 사용 가능: {', '.join(PRESETS)}")
+ cfg = PRESETS[preset]
+ ly.sectionOrder = cfg["sections"]
+ if detail is None:
+ ly.detail = cfg.get("detail", True)
+
+ # detail 명시 오버라이드
+ if detail is not None:
+ ly.detail = detail
+
+ showHelper = helper if helper is not None else ly.helper
+
+ corpName = getattr(company, "corpName", "")
+ stockCode = getattr(company, "stockCode", "")
+
+ review = Review(stockCode=stockCode, corpName=corpName, layout=ly)
+ review.template = detectedTemplate
+ review.templates = detectedTemplates if detectedTemplates else ([detectedTemplate] if detectedTemplate else [])
+
+ useSpinner = isTerminal()
+ if useSpinner:
+ from rich.console import Console
+ from rich.live import Live
+ from rich.spinner import Spinner
+
+ console = Console(stderr=True)
+ spinner = Spinner("dots", text="분석 준비 중...")
+ ctx = Live(spinner, console=console, transient=True)
+ else:
+ from contextlib import nullcontext
+
+ ctx = nullcontext()
+
+ with ctx as live:
+ if live is not None:
+ from rich.spinner import Spinner
+
+ live.update(Spinner("dots", text="블록 사전 생성 중..."))
+
+ # 관점별 순서 결정 (perspective)
+ perspectiveOrder: list[str] | None = None
+ if perspective is not None:
+ from dartlab.review.templates import PERSPECTIVE_TEMPLATES, resolvePerspective
+
+ pkey = resolvePerspective(perspective)
+ if pkey and pkey in PERSPECTIVE_TEMPLATES:
+ perspectiveOrder = PERSPECTIVE_TEMPLATES[pkey]["order"]
+ # resolve 실패 시 기본 6막 순서 유지
+
+ # 템플릿 순서 결정 (블록 빌드 전에 필요한 keys 산출)
+ if section is not None:
+ if section not in TEMPLATES:
+ available = ", ".join(sorted(TEMPLATES.keys()))
+ raise ValueError(
+ f"'{section}' 섹션을 찾을 수 없습니다.\n"
+ f" 사용 가능한 섹션: {available}\n"
+ f" 사용법: c.review('수익구조') 또는 c.review() 로 전체 보고서"
+ )
+ templateKeys = [section]
+ elif ly.sectionOrder is not None:
+ templateKeys = [k for k in ly.sectionOrder if k in TEMPLATES]
+ elif perspectiveOrder is not None:
+ templateKeys = [k for k in perspectiveOrder if k in TEMPLATES]
+ else:
+ templateKeys = list(TEMPLATE_ORDER)
+
+ # 필요한 블록 keys만 산출 → 선택적 빌드
+ if section is not None and templateKeys:
+ neededKeys: set[str] | None = set()
+ for tk in templateKeys:
+ neededKeys.update(TEMPLATES[tk]["keys"])
+ else:
+ neededKeys = None # 전체 빌드
+
+ b = buildBlocks(company, keys=neededKeys, basePeriod=basePeriod)
+
+ for tmplKey in templateKeys:
+ tmpl = TEMPLATES[tmplKey]
+ if live is not None:
+ from rich.spinner import Spinner
+
+ live.update(Spinner("dots", text=f"{tmplKey} 조립 중..."))
+
+ sectionBlocks = []
+ for blockKey in tmpl["keys"]:
+ blockList = b.get(blockKey)
+ if blockList:
+ if blockKey in emphasizedKeys:
+ for blk in blockList:
+ if hasattr(blk, "emphasized"):
+ blk.emphasized = True
+ sectionBlocks.extend(blockList)
+
+ if sectionBlocks:
+ review.sections.append(
+ Section(
+ key=tmplKey,
+ partId=tmpl.get("partId", ""),
+ title=tmpl["title"],
+ blocks=sectionBlocks,
+ helper=tmpl.get("helper", "") if showHelper else "",
+ aiGuide=tmpl.get("aiGuide", ""),
+ )
+ )
+
+ # ── 순환 서사 감지 + 주입 ──
+ if live is not None:
+ from rich.spinner import Spinner
+
+ live.update(Spinner("dots", text="순환 서사 감지 중..."))
+
+ from dartlab.review.narrative import buildCirculationSummary, detectThreads
+
+ _sectionSet = set(templateKeys) if section is not None else None
+ threads = detectThreads(company, b, sections=_sectionSet)
+ for thread in threads:
+ for sec in review.sections:
+ if sec.key in thread.involvedSections:
+ sec.threads.append(thread)
+ review.circulationSummary = buildCirculationSummary(threads) if threads else ""
+
+ # ── 6막 전환 인과 문장 ──
+ from dartlab.review.narrative import buildActTransitions
+
+ review.actTransitions = buildActTransitions(company, b)
+
+ # ── 요약 카드 생성 ──
+ from dartlab.review.summary import buildSectionSummary, buildSummaryCard
+
+ scorecardData = None
+ try:
+ from dartlab.analysis.financial.scorecard import calcScorecard
+
+ scorecardData = calcScorecard(company, basePeriod=basePeriod)
+ except (ImportError, KeyError, ValueError, TypeError, AttributeError):
+ pass
+
+ review.summaryCard = buildSummaryCard(threads, scorecardData, review.sections)
+
+ # ── 섹션별 요약 생성 ──
+ for sec in review.sections:
+ sec.summary = buildSectionSummary(sec)
+
+ return review
+
+
+# [Phase 3 워밍업 함수는 제거됨 — 위 buildBlocks 주석 참조]
diff --git a/src/dartlab/review/renderer.py b/src/dartlab/review/renderer.py
new file mode 100644
index 0000000000000000000000000000000000000000..b3b7a0c636b5717b7af4a1de82a89d8cfeabd9be
--- /dev/null
+++ b/src/dartlab/review/renderer.py
@@ -0,0 +1,275 @@
+"""review rich 렌더러."""
+
+from __future__ import annotations
+
+from dartlab.review.blocks import (
+ ChartBlock,
+ FlagBlock,
+ HeadingBlock,
+ MetricBlock,
+ TableBlock,
+ TextBlock,
+)
+from dartlab.review.layout import ReviewLayout
+from dartlab.review.section import Section
+from dartlab.review.utils import padLabel
+
+
+def renderReview(console, review) -> None:
+ """rich Console에 전체 리뷰 출력."""
+ ly = review.layout
+
+ # 헤더
+ console.print()
+ console.print(f" [bold white]{review.corpName}[/] [dim]{review.stockCode}[/]")
+ console.print(f"[dim]{'─' * ly.separatorWidth}[/]")
+ console.print("[dim] dartlab review[/]")
+
+ # 요약 카드 (summaryCard가 있으면 circulationSummary 위에 표시)
+ if review.summaryCard:
+ _renderSummaryCard(console, review.summaryCard, ly)
+
+ # 순환 서사 요약
+ if review.circulationSummary:
+ _renderCirculationSummary(console, review.circulationSummary, ly)
+
+ for section in review.sections:
+ _renderSection(console, section, ly)
+
+ # AI 미설정 안내
+ if review.aiNote:
+ console.print()
+ console.print(f"[dim] {review.aiNote}[/]")
+
+ console.print()
+
+
+def _renderSummaryCard(console, card, ly) -> None:
+ """최상단 요약 카드 렌더링."""
+ from rich.padding import Padding
+ from rich.panel import Panel
+ from rich.text import Text
+
+ lines = Text()
+
+ if card.conclusion:
+ lines.append(card.conclusion, style="bold white")
+ lines.append("\n")
+
+ if card.grades:
+ gradeStr = " | ".join(f"{k} {v}" for k, v in card.grades.items())
+ lines.append(gradeStr, style="dim")
+ lines.append("\n")
+
+ if card.strengths:
+ lines.append("\n")
+ for s in card.strengths:
+ lines.append(f" + {s}\n", style="green")
+
+ if card.warnings:
+ for w in card.warnings:
+ lines.append(f" - {w}\n", style="yellow")
+
+ console.print()
+ panel = Panel(
+ lines,
+ border_style="bold",
+ padding=(0, 2),
+ )
+ console.print(Padding(panel, (0, 0, 0, ly.indentH2)))
+
+
+def _renderCirculationSummary(console, summary: str, ly) -> None:
+ """순환 서사 요약 박스."""
+ from rich.padding import Padding
+ from rich.panel import Panel
+ from rich.text import Text
+
+ console.print()
+ panel = Panel(
+ Text(summary),
+ title="[bold]재무 순환 서사[/]",
+ border_style="dim",
+ padding=(0, 2),
+ )
+ console.print(Padding(panel, (0, 0, 0, ly.indentH2)))
+
+
+def _renderSection(console, section: Section, ly: ReviewLayout) -> None:
+ """한 섹션 렌더링."""
+ from rich.padding import Padding
+ from rich.text import Text
+
+ h1 = " " * ly.indentH1
+
+ # detail=False: summary만 표시
+ if not ly.detail:
+ if section.title:
+ console.print()
+ console.print(f"{h1}[bold cyan]■ {section.title}[/]")
+ if section.summary:
+ console.print(Padding(Text(section.summary, style="dim"), (0, 0, 0, ly.indentH2)))
+ return
+
+ prevBlockType = None
+ helperRendered = False
+
+ h2 = " " * ly.indentH2
+ body = " " * ly.indentBody
+
+ # 섹션 타이틀 (H1 역할)
+ if section.title:
+ console.print()
+ console.print(f"{h1}[bold cyan]■ {section.title}[/]")
+ if section.aiOpinion:
+ console.print()
+ console.print(f"{h2}[bold cyan]AI 분석 요약[/]")
+ for aiLine in section.aiOpinion.split("\n"):
+ if aiLine.strip():
+ console.print(
+ Padding(
+ Text(aiLine.strip(), style="cyan"),
+ (0, 0, 0, ly.indentH2),
+ )
+ )
+ # 순환 서사 threads
+ if section.threads:
+ _renderSectionThreads(console, section.threads, ly)
+ if section.helper:
+ console.print()
+ for line in section.helper.split("\n"):
+ if line.strip():
+ console.print(f"{h2}[dim italic]{line}[/]")
+ console.print()
+ helperRendered = True
+
+ for block in section.blocks:
+ if isinstance(block, HeadingBlock):
+ if block.level == 1:
+ console.print()
+ console.print(f"{h1}[bold cyan]■ {block.title}[/]")
+ # AI 의견: 대제목 바로 아래 (reviewer가 채움)
+ if section.aiOpinion:
+ console.print()
+ console.print(f"{h2}[bold cyan]AI 분석 요약[/]")
+ for aiLine in section.aiOpinion.split("\n"):
+ if aiLine.strip():
+ console.print(
+ Padding(
+ Text(aiLine.strip(), style="cyan"),
+ (0, 0, 0, ly.indentH2),
+ )
+ )
+ # 헬퍼 텍스트: H2 들여쓰기, 위아래 빈줄
+ if section.helper and not helperRendered:
+ console.print()
+ for line in section.helper.split("\n"):
+ if line.strip():
+ console.print(f"{h2}[dim italic]{line}[/]")
+ console.print()
+ helperRendered = True
+ else:
+ for _ in range(ly.gapAfterH1):
+ console.print()
+ else:
+ if prevBlockType is not None:
+ for _ in range(ly.gapBetween):
+ console.print()
+ console.print(f"{h2}[bold white]▸ {block.title}[/]")
+ for _ in range(ly.gapAfterH2):
+ console.print()
+
+ elif isinstance(block, TextBlock):
+ if prevBlockType is not None and prevBlockType is not HeadingBlock:
+ console.print()
+ style = block.style or ""
+ ind = ly.indentH2 if block.indent == "h2" else ly.indentBody
+ console.print(
+ Padding(
+ Text(block.text, style=style),
+ (0, 0, 0, ind),
+ )
+ )
+
+ elif isinstance(block, MetricBlock):
+ if prevBlockType is MetricBlock:
+ console.print()
+ for label, value in block.metrics:
+ padded = padLabel(label, 22)
+ console.print(f"{body}[dim]{padded}[/] {value}")
+
+ elif isinstance(block, TableBlock):
+ console.print()
+ _renderDataFrame(console, block, indent=ly.indentBody)
+
+ elif isinstance(block, ChartBlock):
+ title = block.spec.get("title", "차트") if isinstance(block.spec, dict) else "차트"
+ console.print(f"{body}[dim][chart: {title}][/]")
+
+ elif isinstance(block, FlagBlock):
+ if prevBlockType is not None and not isinstance(prevBlockType, FlagBlock):
+ console.print()
+ color = "yellow" if block.kind == "warning" else "green"
+ for f in block.flags:
+ console.print(f"{body}[{color}]{block.icon} {f}[/]")
+
+ elif hasattr(block, "render"):
+ # SelectResult / ChartResult — render("rich") 위임
+ rendered = block.render("rich")
+ if rendered:
+ console.print(Padding(Text.from_ansi(rendered), (0, 0, 0, ly.indentBody)))
+
+ prevBlockType = type(block)
+
+
+def _renderSectionThreads(console, threads, ly) -> None:
+ """섹션에 연결된 인과 서사 -- title만 표시 (story는 상단 순환 서사에서 1회)."""
+ h2 = " " * ly.indentH2
+
+ console.print()
+ for t in threads:
+ colorMap = {
+ "critical": "bold red",
+ "warning": "yellow",
+ "positive": "green",
+ "neutral": "dim",
+ }
+ color = colorMap.get(t.severity, "dim")
+ console.print(f"{h2}[{color}]>> {t.title}[/]")
+
+
+def _renderDataFrame(console, block: TableBlock, indent: int = 6) -> None:
+ """Polars DataFrame을 rich Table로 렌더링."""
+ import polars as pl
+ from rich import box
+ from rich.padding import Padding
+ from rich.table import Table
+
+ df = block.df
+ if not isinstance(df, pl.DataFrame) or df.is_empty():
+ return
+
+ pad = " " * indent
+ if block.label:
+ console.print(f"{pad}[dim]{block.label}[/]")
+
+ table = Table(
+ box=box.SIMPLE_HEAD,
+ padding=(0, 2),
+ show_edge=False,
+ pad_edge=True,
+ )
+
+ for i, col in enumerate(df.columns):
+ isFirstCol = i == 0
+ justify = "left" if isFirstCol else "right"
+ # Q4 fallback 컬럼 → 연도 라벨 (2025Q4 → 2025)
+ label = col[:-2] if isinstance(col, str) and col.endswith("Q4") else col
+ table.add_column(label, justify=justify)
+
+ for row in df.iter_rows():
+ table.add_row(*(str(v) if v is not None else "-" for v in row))
+
+ console.print(Padding(table, (0, 0, 0, indent)))
+ if block.caption:
+ console.print(f"{pad}[dim italic]{block.caption}[/]")
diff --git a/src/dartlab/review/section.py b/src/dartlab/review/section.py
new file mode 100644
index 0000000000000000000000000000000000000000..7fb76501c0dfa14f36032d1f5fba69a2063c799d
--- /dev/null
+++ b/src/dartlab/review/section.py
@@ -0,0 +1,26 @@
+"""review 섹션."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from dartlab.review.blocks import Block
+
+if TYPE_CHECKING:
+ from dartlab.review.narrative import NarrativeThread
+
+
+@dataclass
+class Section:
+ """분석 리뷰의 한 섹션 (목차 1항목 = 1섹션)."""
+
+ key: str # "수익구조", "자금구조" 등
+ partId: str # "1-1", "1-2" 등
+ title: str # 표시용 제목
+ blocks: list[Block] = field(default_factory=list)
+ helper: str = "" # 이 섹션에서 봐야 할 것 (헬퍼 텍스트)
+ aiOpinion: str = "" # AI 종합의견 (reviewer에서 섹션별로 채움)
+ aiGuide: str = "" # AI에게 전달할 섹션별 분석 관점
+ threads: list[NarrativeThread] = field(default_factory=list) # 섹션 간 인과 연결
+ summary: str = "" # 이 섹션의 1-2줄 핵심 요약 (detail=False 시 이것만 표시)
diff --git a/src/dartlab/review/sections/__init__.py b/src/dartlab/review/sections/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..493b16f0f99d7c9ddf2d0a85107af757510e6f26
--- /dev/null
+++ b/src/dartlab/review/sections/__init__.py
@@ -0,0 +1 @@
+"""review 섹션 빌더 — analysis 계산 결과 → 블록 조립."""
diff --git a/src/dartlab/review/summary.py b/src/dartlab/review/summary.py
new file mode 100644
index 0000000000000000000000000000000000000000..672faebbeeb9c3bd7f5e2fb3f1453d27ce33d5d0
--- /dev/null
+++ b/src/dartlab/review/summary.py
@@ -0,0 +1,147 @@
+"""review 요약 카드 + 섹션 요약 생성."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+
+@dataclass
+class SummaryCard:
+ """review 최상단 요약 카드."""
+
+ conclusion: str = ""
+ strengths: list[str] = field(default_factory=list)
+ warnings: list[str] = field(default_factory=list)
+ grades: dict[str, str] = field(default_factory=dict)
+
+
+def buildSummaryCard(threads, scorecardData, sections) -> SummaryCard:
+ """narrative threads + scorecard 등급 + 섹션 flags로 요약 카드 생성."""
+ from dartlab.review.blocks import FlagBlock
+
+ card = SummaryCard()
+
+ # ── 등급: scorecard 데이터에서 추출 ──
+ if scorecardData:
+ for item in scorecardData.get("items", []):
+ card.grades[item["area"]] = item["grade"]
+
+ # ── 강점/경고: threads에서 추출 ──
+ for t in threads:
+ if t.severity == "positive":
+ card.strengths.append(t.title)
+ elif t.severity in ("critical", "warning"):
+ card.warnings.append(t.title)
+
+ # ── flags에서 보충 (threads가 부족할 때) ──
+ opportunityFlags: list[str] = []
+ warningFlags: list[str] = []
+ for sec in sections:
+ for block in sec.blocks:
+ if isinstance(block, FlagBlock):
+ if block.kind == "opportunity":
+ opportunityFlags.extend(block.flags)
+ else:
+ warningFlags.extend(block.flags)
+
+ # strengths 부족 시 opportunity flags로 보충 (최대 3개)
+ for f in opportunityFlags:
+ if len(card.strengths) >= 3:
+ break
+ if f not in card.strengths:
+ card.strengths.append(f)
+ card.strengths = card.strengths[:3]
+
+ # warnings 부족 시 warning flags로 보충 (최대 3개)
+ for f in warningFlags:
+ if len(card.warnings) >= 3:
+ break
+ if f not in card.warnings:
+ card.warnings.append(f)
+ card.warnings = card.warnings[:3]
+
+ # ── 한줄 결론 ──
+ card.conclusion = _buildConclusion(threads, card.grades)
+
+ return card
+
+
+def _buildConclusion(threads, grades: dict[str, str]) -> str:
+ """threads 톤 + 등급으로 한줄 결론 생성."""
+ criticals = [t for t in threads if t.severity == "critical"]
+ positives = [t for t in threads if t.severity == "positive"]
+
+ # 등급 요약
+ gradeParts = []
+ for area in ("수익성", "성장성", "안정성", "효율성", "현금흐름"):
+ g = grades.get(area)
+ if g:
+ gradeParts.append(f"{area} {g}")
+ gradeStr = " | ".join(gradeParts) if gradeParts else ""
+
+ if criticals and positives:
+ return f"{positives[0].title} -- 다만 {criticals[0].title}"
+ if criticals:
+ return criticals[0].title
+ if positives:
+ return positives[0].title
+ if gradeStr:
+ return gradeStr
+ return ""
+
+
+def buildSectionSummary(section) -> str:
+ """섹션의 blocks + threads에서 1-2줄 핵심 요약 생성."""
+ from dartlab.review.blocks import FlagBlock, MetricBlock, TableBlock, TextBlock
+
+ parts: list[str] = []
+
+ # 핵심 지표 (첫 MetricBlock의 첫 1-2개 metric)
+ for block in section.blocks:
+ if isinstance(block, MetricBlock) and block.metrics:
+ for label, value in block.metrics[:2]:
+ parts.append(f"{label} {value}")
+ break
+
+ # MetricBlock이 없으면 TextBlock에서 첫 문장 (profile 등)
+ if not parts:
+ for block in section.blocks:
+ if isinstance(block, TextBlock) and block.text and block.text.strip():
+ text = block.text.strip()
+ # 첫 문장만 (마침표 또는 줄바꿈 기준)
+ for sep in (".", "\n"):
+ idx = text.find(sep)
+ if idx > 0:
+ text = text[:idx]
+ break
+ if len(text) <= 80:
+ parts.append(text)
+ break
+
+ # TextBlock도 없으면 TableBlock label + 첫 행 요약
+ if not parts:
+ for block in section.blocks:
+ if isinstance(block, TableBlock) and hasattr(block.df, "columns"):
+ try:
+ df = block.df
+ if df.height > 0 and len(df.columns) >= 2:
+ firstRow = df.row(0)
+ parts.append(f"{firstRow[0]}: {firstRow[1]}")
+ except (IndexError, AttributeError):
+ pass
+ break
+
+ # thread title (있으면 1개, 단 parts가 비어있거나 thread가 다른 정보일 때)
+ if section.threads:
+ threadTitle = section.threads[0].title
+ # parts에 이미 같은 내용이 없으면 추가
+ if not any(threadTitle in p for p in parts):
+ parts.append(threadTitle)
+
+ # 가장 중요한 flag (warning 우선, 1개)
+ for block in section.blocks:
+ if isinstance(block, FlagBlock) and block.flags:
+ parts.append(block.flags[0])
+ break
+
+ return " / ".join(parts[:3]) if parts else ""
diff --git a/src/dartlab/review/templates.py b/src/dartlab/review/templates.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a4523b88750af72f7bc76be2994f94048ceede4
--- /dev/null
+++ b/src/dartlab/review/templates.py
@@ -0,0 +1,897 @@
+"""review 템플릿 -- 섹션별 블록 조합 + helper/aiGuide.
+
+블록 메타(key, label, 순서)와 섹션 메타(title, partId)는 catalog.py가 source of truth.
+이 파일은 각 섹션에서 **실제로 보여줄 블록 서브셋**과 helper/aiGuide만 관리한다.
+
+c.review("수익구조") -> 수익구조 템플릿의 visibleKeys 블록만 조립.
+c.review() -> 전체 템플릿 순서대로 조립.
+"""
+
+from __future__ import annotations
+
+from dartlab.review.catalog import SECTIONS, keysForSection
+
+# ── 섹션별 설정 (helper, aiGuide, visibleKeys) ──
+# visibleKeys: 이 섹션에서 실제로 보여줄 블록. None이면 섹션 전체 블록.
+
+_SECTION_CONFIG: dict[str, dict] = {
+ "수익구조": {
+ "visibleKeys": None, # 전체 표시
+ "helper": (
+ "① 매출 집중도(HHI)와 부문별 이익률 차이로 수익 구조 편중을 본다 (HHI 0.25 이상이면 집중, 0.01 이하면 분산)\n"
+ "② 부문별 매출 추이와 YoY(전년 대비 증감률)로 성장 부문을 식별한다\n"
+ "③ 매출 성장률(YoY, 3Y CAGR)로 중기 방향성을 본다 (CAGR = 3년 연평균 성장률)\n"
+ "④ 성장 기여 분해로 어디에서 성장이 왔는지 본다\n"
+ "⑤ 영업CF/순이익, 총이익률 추세로 매출 품질을 확인한다 (100% 미만이면 이익의 현금 뒷받침 부족)"
+ ),
+ "aiGuide": (
+ "매출 집중도(HHI)가 높으면 단일 부문 의존 리스크를 짚어라. "
+ "부문별 이익률 차이가 크면 수익 구조 편중을 언급하라. "
+ "성장 부문과 정체 부문을 구분하고, 매출 YoY/3Y CAGR 방향성을 평가하라. "
+ "내수/수출 비중이 한쪽으로 치우치면 지역 리스크를 언급하라. "
+ "영업CF/순이익이 40% 미만이면 이익의 현금 뒷받침 부족을 경고하라. "
+ "총이익률이 악화 추세면 원가 구조 변화를 짚어라."
+ ),
+ },
+ "자금조달": {
+ "visibleKeys": [
+ "fundingSources",
+ "capitalTimeline",
+ "debtTimeline",
+ "interestBurden",
+ "liquidity",
+ "capitalFlags",
+ ],
+ "helper": (
+ "① 내부유보/주주자본/금융차입/영업조달 4원천 비중을 본다\n"
+ "② 이익잉여금 비중이 높으면 자기 힘으로 성장\n"
+ "③ 금융차입 비중이 높으면 이자보상배율과 만기를 확인한다\n"
+ "④ 유동비율로 단기 지급 능력을 확인한다"
+ ),
+ "aiGuide": (
+ "내부유보 vs 금융차입 비중으로 자금조달 성격을 먼저 판단하라. "
+ "이익잉여금이 자산의 50% 이상이면 자기 힘으로 성장한 회사다. "
+ "금융차입 40% 이상이면 이자보상배율과 차환 리스크를 반드시 짚어라. "
+ "영업조달(매입채무·선수금)이 크면 영업력으로 자금을 조달하는 구조다. "
+ "비중 추이에서 금융차입이 늘고 내부유보가 줄면 자금조달 구조 악화 신호다."
+ ),
+ },
+ "자산구조": {
+ "visibleKeys": None, # 전체 표시
+ "helper": (
+ "① BS를 영업/비영업으로 재분류해 자산의 실질 성격을 본다\n"
+ "② 순영업자산(NOA)이 투자 대비 수익의 분모다 (NOA = 영업자산 - 영업부채)\n"
+ "③ 운전자본 순환(CCC)으로 영업 효율을 본다 (CCC = 재고일수 + 매출채권일수 - 매입채무일수. 짧을수록 현금 회수가 빠름)\n"
+ "④ CAPEX/감가상각으로 성장투자인지 유지투자인지 판단한다 (1 미만이면 유지, 1.5 이상이면 공격 투자)\n"
+ "⑤ 자산회전율로 같은 자산으로 매출을 더 뽑는지 본다"
+ ),
+ "aiGuide": (
+ "영업자산 비중이 높으면 사업 집중 구조, 비영업 비중이 높으면 지주/투자 성격을 짚어라. "
+ "건설중인자산이 크면 대규모 투자 진행 중이며 향후 감가상각 부담을 언급하라. "
+ "CCC가 길어지면 현금 회수 느림, 마이너스면 선수금/매입채무 우위 구조다. "
+ "CAPEX/감가상각이 1 미만이면 유지투자(자산 노후화), 1.5 이상이면 공격적 성장 투자다. "
+ "총자산회전율이 하락하면 자산 팽창 대비 매출 성장이 부족한 신호다."
+ ),
+ },
+ "현금흐름": {
+ "visibleKeys": None, # 전체 표시
+ "helper": (
+ "① 영업CF/투자CF/재무CF 부호 조합으로 CF 패턴을 본다\n"
+ "② FCF(=영업CF-CAPEX)가 양수면 자유현금 창출 능력 있음\n"
+ "③ 영업CF/순이익으로 이익이 현금으로 뒷받침되는지 확인한다\n"
+ "④ 영업CF 마진으로 매출 대비 현금 창출력을 본다"
+ ),
+ "aiGuide": (
+ "영업CF가 적자면 본업에서 현금이 나오지 않는 위험 신호다. "
+ "FCF가 음수면 영업으로 번 것보다 투자가 크다는 뜻이므로 외부 자금 의존도를 확인하라. "
+ "CF 패턴이 확장형(+/-/+)이면 성장 투자 중이므로 투자 효율을 짚어라. "
+ "위기형(-/-/+)이면 영업 적자를 차입으로 메우는 구조이므로 지속 가능성을 경고하라. "
+ "영업CF/순이익이 100% 미만이면 이익의 현금 전환이 부족하다. "
+ "40% 미만이면 이익의 질에 심각한 의문을 제기하라."
+ ),
+ },
+ # ── 2부: 재무비율 분석 ──
+ "수익성": {
+ "visibleKeys": None,
+ "helper": (
+ "① 매출총이익률/영업이익률/순이익률 추이로 마진 방향을 본다\n"
+ "② ROE/ROA 추이로 투하자본 대비 수익력을 본다 (ROE = 자기자본이익률, ROA = 총자산이익률)\n"
+ "③ 듀퐁 분해로 ROE의 원천을 파악한다 (ROE = 순이익률 x 자산회전율 x 재무레버리지)"
+ ),
+ "aiGuide": (
+ "영업이익률이 3년 연속 하락이면 원가/경쟁 구조 변화를 짚어라. "
+ "ROE > 15%이면서 레버리지(ROE/ROA)가 3배 이상이면 부채로 만든 수익성이다. "
+ "듀퐁 분해에서 마진 개선 없이 레버리지로만 ROE가 올랐다면 경고하라. "
+ "매출총이익률이 안정적인데 영업이익률만 하락이면 판관비 구조를 지적하라."
+ ),
+ },
+ "성장성": {
+ "visibleKeys": None,
+ "helper": (
+ "① 매출/영업이익/순이익 YoY로 성장 속도를 본다\n"
+ "② 매출 성장 > 이익 성장이면 외형만 커진 것\n"
+ "③ CAGR로 단기 변동 너머의 중기 추세를 확인한다"
+ ),
+ "aiGuide": (
+ "매출 성장률이 영업이익 성장률보다 높으면 수익성 희석을 짚어라. "
+ "3Y CAGR이 음수면 구조적 역성장이다. "
+ "순이익 성장이 영업이익 성장보다 훨씬 높으면 영업외이익 의존을 경고하라. "
+ "자산 성장이 매출 성장보다 빠르면 비효율적 확장이다."
+ ),
+ },
+ "안정성": {
+ "visibleKeys": None,
+ "helper": (
+ "① 부채비율 추이로 재무 레버리지 방향을 본다 (부채비율 = 부채/자본, 200% 이상이면 주의)\n"
+ "② 이자보상배율로 이자 지급 능력을 본다 (영업이익/이자비용, 3배 미만이면 부담)\n"
+ "③ Altman Z-Score로 부실 가능성을 정량 판별한다 (1.8 미만 위험, 3.0 이상 안전)\n"
+ "④ 시장 리스크(베타, 변동성)로 시장 관점 안정성을 확인한다"
+ ),
+ "aiGuide": (
+ "부채비율이 200% 이상이면 재무 위험을 짚어라. "
+ "이자보상배율이 3배 미만이면 이자 지급 부담을 경고하라. "
+ "Altman Z-Score가 1.8 미만이면 부실 위험 구간이다. "
+ "차입금의존도가 30% 이상이면 금융차입에 과도하게 의존하는 구조다. "
+ "베타가 1.5 이상이면 시장 급락 시 더 큰 손실 가능성을 경고하라. "
+ "변동성(ATR%)이 5% 이상이면 일일 가격 변동이 큰 종목임을 짚어라."
+ ),
+ },
+ "효율성": {
+ "visibleKeys": None,
+ "helper": (
+ "① 총자산/매출채권/재고 회전율로 자산 활용도를 본다 (높을수록 자산을 잘 굴리는 것)\n"
+ "② CCC로 현금이 묶이는 기간을 본다 (CCC = DSO + DIO - DPO, 짧을수록 좋음)\n"
+ "③ 회전율 하락 + CCC 증가면 영업 효율 악화 신호"
+ ),
+ "aiGuide": (
+ "총자산회전율이 하락 추세면 자산 팽창 대비 매출 부진을 짚어라. "
+ "매출채권 회전율 하락은 대금 회수 지연을 의미한다. "
+ "재고 회전율 하락은 재고 적체 위험이다. "
+ "CCC가 마이너스면 선수금/매입채무 우위로 운전자본이 유리한 구조다."
+ ),
+ },
+ "종합평가": {
+ "visibleKeys": None,
+ "helper": (
+ "① 5영역(수익성/성장성/안정성/효율성/현금흐름) 등급으로 전체를 본다\n"
+ "② Piotroski F-Score(0-9)로 재무 건전성을 정량 판별한다 (7점 이상 건전, 3점 이하 심각)"
+ ),
+ "aiGuide": (
+ "스코어카드에서 F 등급이 있는 영역을 최우선으로 짚어라. "
+ "Piotroski F-Score 3점 이하면 재무 상태가 심각하게 나쁘다. "
+ "7점 이상이면 재무적으로 건전한 기업이다. "
+ "등급 간 괴리(수익성 A인데 안정성 F)가 있으면 구조적 불균형을 경고하라."
+ ),
+ },
+ # ── 3부: 심화 분석 ──
+ "이익품질": {
+ "visibleKeys": None,
+ "helper": (
+ "① Sloan 발생액비율로 이익 중 현금이 아닌 비중을 본다 (높으면 이익이 장부상 숫자일 뿐, 0.10 이상 주의)\n"
+ "② 영업외손익 비중과 이익 변동성으로 지속성을 판단한다\n"
+ "③ Beneish M-Score로 이익 조작 가능성을 정량 점검한다 (-1.78 초과 시 조작 가능성 구간)"
+ ),
+ "aiGuide": (
+ "발생액비율이 0.10 이상이면 이익 현금화 부족을 짚어라. "
+ "영업외손익 비중이 30% 이상이면 일회성 이익 의존을 경고하라. "
+ "M-Score가 -1.78 초과면 이익 조작 가능성 구간이므로 반드시 언급하라. "
+ "이익 변동계수(CV)가 0.5 이상이면 실적 변동성이 크다."
+ ),
+ },
+ "비용구조": {
+ "visibleKeys": None,
+ "helper": (
+ "① 매출원가율/판관비율 추이로 비용 구조 변화를 본다\n"
+ "② DOL(영업레버리지)로 매출 변동 대비 이익 민감도를 본다 (DOL 3 이상이면 매출 감소 시 이익 급감)\n"
+ "③ BEP와 안전마진으로 손익분기 여유를 확인한다 (BEP = 손익분기 매출, 안전마진 = 현재 매출이 BEP를 넘는 여유)"
+ ),
+ "aiGuide": (
+ "매출원가율이 3년 연속 상승이면 원가 경쟁력 약화를 짚어라. "
+ "판관비율 상승은 판매/관리 비효율을 의미한다. "
+ "DOL이 3 이상이면 매출 감소 시 이익이 급감할 수 있다. "
+ "안전마진이 10% 미만이면 손익분기점에 근접해 리스크가 크다."
+ ),
+ },
+ "자본배분": {
+ "visibleKeys": None,
+ "helper": (
+ "① 배당성향과 연속 배당으로 배당 정책을 판단한다\n"
+ "② 총주주환원(배당+자사주)과 FCF 비교로 환원 여력을 본다\n"
+ "③ CAPEX/매출과 유보율로 재투자 의지를 확인한다\n"
+ "④ FCF 사용처(배당/부채상환/잔여)로 경영 우선순위를 본다"
+ ),
+ "aiGuide": (
+ "배당성향 100% 초과면 이익 이상의 배당이므로 지속 가능성을 의심하라. "
+ "총환원/FCF가 100% 초과면 외부 자금으로 환원하는 구조다. "
+ "CAPEX/매출이 1% 미만이면 극소 투자로 성장 동력 부족을 짚어라. "
+ "배당이 3년 연속 감소면 주주 가치 환원 약화를 경고하라. "
+ "잔여(FCF-배당-상환)가 꾸준히 양수면 현금 축적 능력이 있다."
+ ),
+ },
+ "투자효율": {
+ "visibleKeys": None,
+ "helper": (
+ "① ROIC vs WACC Spread로 가치 창출/파괴를 판단한다 (ROIC = 투하자본수익률, WACC = 가중평균자본비용. ROIC > WACC면 가치 창출)\n"
+ "② CAPEX/매출과 유무형자산 비율로 투자 강도를 본다\n"
+ "③ EVA로 자본비용 차감 후 실질 가치를 확인한다 (EVA = NOPAT - 자본비용, 양수면 가치 창출)"
+ ),
+ "aiGuide": (
+ "ROIC < WACC이 2년 연속이면 투자한 자본이 가치를 파괴하고 있다. "
+ "EVA가 3년 연속 음수면 경제적 부가가치 적자 상태다. "
+ "무형자산비율이 전년 대비 10%p 이상 급등하면 대규모 인수를 확인하라. "
+ "WACC은 추정치이므로 절대 수치보다 추세와 방향성을 강조하라."
+ ),
+ },
+ "재무정합성": {
+ "visibleKeys": None,
+ "helper": (
+ "① IS-CF 괴리로 순이익 대비 현금 뒷받침을 검증한다 (IS = 손익계산서, CF = 현금흐름표. 괴리가 크면 이익의 질 의심)\n"
+ "② 매출 vs 매출채권/재고 성장 괴리로 이상 징후를 포착한다 (채권/재고가 매출보다 빨리 늘면 주의)\n"
+ "③ 종합 이상점수로 교차검증 결과를 한눈에 본다 (0-100, 70 이상이면 재무제표 신뢰성 주의)\n"
+ "④ 유효세율과 이연법인세로 세금 리스크를 확인한다"
+ ),
+ "aiGuide": (
+ "IS-CF 괴리가 50% 이상이면 순이익의 현금 뒷받침이 심각하게 부족하다. "
+ "매출채권 성장이 매출보다 20%p 빠르면 매출 인식 방식을 의심하라. "
+ "재고 성장이 매출보다 20%p 빠르면 판매 부진 또는 재고 부풀리기를 짚어라. "
+ "이상점수 70 이상이면 재무제표 전체 신뢰성에 주의를 환기하라. "
+ "유효세율이 10% 미만이면 세금 혜택 또는 이연의 원인을 짚어라. "
+ "이연법인세자산 급증은 미래 과세소득을 낙관하고 있을 수 있다."
+ ),
+ },
+ # ── 5부: 비재무 심화 ──
+ "지배구조": {
+ "visibleKeys": None,
+ "helper": (
+ "1 최대주주 지분율 추이로 지배구조 안정성을 본다\n"
+ "2 사외이사비율로 이사회 독립성을 판단한다\n"
+ "3 감사의견과 감사인 변경 이력으로 외부 감시를 확인한다"
+ ),
+ "aiGuide": (
+ "최대주주 지분율이 50% 초과면 과반 지배 구조의 장단점을 짚어라. "
+ "20% 미만이면 경영권 방어 취약 가능성을 언급하라. "
+ "사외이사비율 25% 미만이면 이사회 독립성 부족을 경고하라. "
+ "감사의견이 적정이 아니면 반드시 그 사유를 짚어라. "
+ "감사인 잦은 변경은 감사 독립성 의심 신호다."
+ ),
+ },
+ "공시변화": {
+ "visibleKeys": None,
+ "helper": (
+ "1 전체 topic 변화율로 공시 성실도를 본다\n"
+ "2 사업개요/리스크/회계정책 등 핵심 topic 변화를 추적한다\n"
+ "3 바이트 변화량으로 실질적 변경 규모를 확인한다"
+ ),
+ "aiGuide": (
+ "전 기간 공시 텍스트 변화가 전무하면 보일러플레이트(복붙) 가능성을 지적하라. "
+ "회계정책 공시 변경이 감지되면 정책 변경 여부를 반드시 확인하라. "
+ "사업개요/리스크 topic이 자주 바뀌면 사업 환경 변동이 큰 것이다. "
+ "변화율 80% 이상 topic은 빈번한 변경 사유를 짚어라."
+ ),
+ },
+ "비교분석": {
+ "visibleKeys": None,
+ "helper": (
+ "1 핵심 비율의 시장 내 백분위로 상대적 위치를 본다\n"
+ "2 ROE x 부채비율 사분면으로 수익-위험 포지션을 판단한다\n"
+ "3 상위/하위 10% 지표를 통해 강점과 약점을 식별한다"
+ ),
+ "aiGuide": (
+ "상위 10% 지표는 강점으로, 하위 10%는 개선 과제로 짚어라. "
+ "고수익-저위험 사분면이면 우량 포지션으로 평가하라. "
+ "저수익-고위험이면 구조적 개선 필요성을 경고하라. "
+ "백분위는 전종목 기준이므로 업종 특성(금융업 등)을 감안하라."
+ ),
+ },
+ "신용평가": {
+ "visibleKeys": None,
+ "helper": (
+ "① 20단계 신용등급(AAA~D)으로 기업의 채무상환 능력을 종합 판정한다\n"
+ "② 5축(채무상환 35%/레버리지 25%/유동성 15%/부실모델 15%/이익품질 10%)으로 점수를 산출한다\n"
+ "③ 업종별 차등 기준을 적용한다 (유틸리티/금융은 높은 부채 허용)\n"
+ "④ eCR(현금흐름등급)로 현금흐름창출능력을 별도 평가한다\n"
+ "⑤ 등급 전망(안정적/긍정적/부정적)으로 향후 방향을 제시한다"
+ ),
+ "aiGuide": (
+ "등급이 투자적격(BBB- 이상)인지 투기등급(BB+ 이하)인지 명확히 구분하라. "
+ "ICR < 1.5이면 이자 지급 능력 부족을 최우선으로 경고하라. "
+ "Debt/EBITDA > 5이면 부채 감당 능력 부족을 짚어라. "
+ "eCR-4 이하이면 현금흐름 악화를 언급하라. "
+ "등급 전망이 부정적이면 하방 압력 요인을 구체적으로 설명하라. "
+ "5축 중 점수가 가장 높은(위험한) 축을 핵심 리스크로 짚어라. "
+ "업종 특성(금융업 부채비율 400% 허용 등)을 반영하여 해석하라."
+ ),
+ },
+ # ── 4부: 가치평가 ──
+ "가치평가": {
+ "visibleKeys": None,
+ "helper": (
+ "① DCF로 FCF 기반 내재가치를 추정한다 (DCF = 미래 현금흐름을 할인해 현재 가치로 환산)\n"
+ "② DDM으로 배당 기반 가치를 본다 (DDM = 미래 배당금을 할인해 적정 주가 산출)\n"
+ "③ 상대가치(PER/PBR/EV-EBITDA/PSR/PEG)로 섹터 대비 위치를 본다\n"
+ "④ RIM으로 자기자본 대비 초과이익 가치를 본다 (RIM = 장부가 + 초과이익의 현재가치)\n"
+ "⑤ 목표주가로 5 시나리오 확률 가중 적정가를 본다\n"
+ "⑥ 민감도 그리드로 가정 변화에 따른 가치 범위를 확인한다\n"
+ "⑦ 종합 판정으로 저/적정/고평가를 판단한다"
+ ),
+ "aiGuide": (
+ "DCF와 상대가치 결과가 크게 다르면 어느 모델의 가정이 더 적합한지 설명하라. "
+ "안전마진 30% 이상이면 저평가 가능성을 짚되, 가정(할인율/성장률) 민감도도 함께 언급하라. "
+ "DDM 적용 불가면 무배당 기업임을 짚고 DCF/상대가치에 집중하라. "
+ "PEG 1.0 미만이면 성장 대비 저평가, 2.0 이상이면 성장 대비 고평가 가능성. "
+ "역내재성장률이 엔진 예측보다 낮으면 시장이 보수적, 높으면 시장이 낙관적. "
+ "민감도 그리드에서 모든 시나리오가 현재가 이하면 구조적 고평가를 경고하라. "
+ "종합 판정은 참고용이며 투자 권유가 아님을 명시하라."
+ ),
+ },
+ # ── 6부: 전망분석 ──
+ "매출전망": {
+ "visibleKeys": None,
+ "helper": (
+ "-- 이 섹션의 모든 수치는 추정치이며 실제 실적과 다를 수 있습니다 --\n"
+ "(1) Base/Bull/Bear 3-시나리오 매출 전망과 확률을 본다\n"
+ "(2) 세그먼트별 성장률로 성장 동력을 식별한다\n"
+ "(3) Pro-Forma로 매출 성장이 영업이익/FCF에 미치는 영향을 본다\n"
+ "(4) 소스 가중치와 신뢰도로 예측의 근거를 확인한다"
+ ),
+ "aiGuide": (
+ "모든 예측값은 '추정'임을 명시하라. "
+ "신뢰도 'low'면 데이터 부족을 강조하라. "
+ "소스에서 timeseries만 100%면 컨센서스/매크로 부재를 짚어라. "
+ "시나리오 간 격차가 크면 불확실성이 높다는 의미다. "
+ "Pro-Forma 비율 가정이 과거와 크게 다르면 한계를 밝혀라."
+ ),
+ },
+ # ── 7부: 매크로 ──
+ "매크로": {
+ "visibleKeys": None,
+ "helper": (
+ "① 종합 매크로 환경 판정 (우호/중립/비우호)\n"
+ "② 경기 사이클 4국면 + 전환 시퀀스\n"
+ "③ 금리 방향 + 유동성 환경 → 기업 자금조달 여건\n"
+ "④ 침체확률 + LEI → 매출 전망 교차\n"
+ "⑤ 기업집계 → 시장 전체 이익사이클 속 이 회사 위치\n"
+ "⑥ 교역조건 (한국) → 수출기업 영향"
+ ),
+ "aiGuide": (
+ "매크로 환경은 기업 분석의 배경 — 숫자를 이 기업의 맥락으로 연결하라. "
+ "금리 인하 기대는 차입금 비중이 큰 기업에 긍정적, 현금부자에는 중립. "
+ "침체확률은 방향 지표 — 확률 자체보다 변화 추세가 중요. "
+ "기업집계의 Ponzi비율은 이 회사의 ICR과 비교하라. "
+ "교역조건은 수출기업에만 언급하라."
+ ),
+ },
+ # ── 8부: 시장분석 ──
+ "시장분석": {
+ "visibleKeys": None,
+ "helper": (
+ "① 기술적 종합 판단(강세/중립/약세)으로 시장의 방향성을 본다\n"
+ "② 매매 신호(골든크로스/RSI/MACD/볼린저)로 최근 전환점을 식별한다\n"
+ "③ 8 검증 스타일(추세/평균회귀/돌파/눌림목/이벤트/수급/저변동/캘린더) 백테스트 + 오늘 진입 진단\n"
+ "④ 베타로 시장 대비 변동성, CAPM으로 기대수익률을 확인한다\n"
+ "⑤ 재무-시장 괴리 진단으로 펀더멘털과 시장 반응의 불일치를 분석한다"
+ ),
+ "aiGuide": (
+ "기술적 판단은 참고 지표이며 투자 권유가 아님을 반드시 명시하라. "
+ "재무-시장 괴리가 발생하면 양쪽의 근거를 균형있게 제시하라. "
+ "재무 우량 + 기술적 약세면 '저평가 기회 또는 시장이 선행 반영한 리스크'로 양면 해석하라. "
+ "재무 부진 + 기술적 강세면 '기술적 반등이지만 펀더멘털 리스크 주의'로 경고하라. "
+ "베타 1.5 이상이면 시장 변동 대비 고위험 종목임을 짚어라. "
+ "RSI 70 이상 + 가치평가 고평가면 과열 신호를 강화하라. "
+ "RSI 30 이하 + 가치평가 저평가면 역발상 투자 기회 가능성을 언급하라."
+ ),
+ },
+}
+
+
+def _buildTemplates() -> dict[str, dict]:
+ """catalog SECTIONS 순서로 TEMPLATES dict 생성."""
+ templates: dict[str, dict] = {}
+ for sec in SECTIONS:
+ cfg = _SECTION_CONFIG.get(sec.key, {})
+ visible = cfg.get("visibleKeys")
+ if visible is None:
+ keys = keysForSection(sec.key)
+ else:
+ keys = list(visible)
+ templates[sec.key] = {
+ "title": sec.title,
+ "partId": sec.partId,
+ "keys": keys,
+ "helper": cfg.get("helper", ""),
+ "aiGuide": cfg.get("aiGuide", ""),
+ }
+ return templates
+
+
+TEMPLATES = _buildTemplates()
+TEMPLATE_ORDER = [s.key for s in SECTIONS]
+
+
+# ── 관점별 템플릿 (perspective) — 섹션 순서 재배치 ──────────────
+# 기업유형 STORY_TEMPLATES (강조) 와 독립 차원.
+# perspective × template 동시 사용 가능.
+
+PERSPECTIVE_TEMPLATES: dict[str, dict] = {
+ "bottomUp": {
+ "description": "재무 → 시장 → 매크로 순서 (현재 6막, 기본)",
+ "order": TEMPLATE_ORDER, # 기존 6막 순서 그대로
+ },
+ "topDown": {
+ "description": "매크로 → 시장 → 재무 순서 (거시환경 중심)",
+ "order": [
+ "매크로",
+ "시장분석",
+ "비교분석",
+ "매출전망",
+ "가치평가",
+ "종합평가",
+ "신용평가",
+ "안정성",
+ "자금조달",
+ "현금흐름",
+ "이익품질",
+ "수익성",
+ "비용구조",
+ "수익구조",
+ "성장성",
+ "자산구조",
+ "효율성",
+ "투자효율",
+ "자본배분",
+ "재무정합성",
+ "지배구조",
+ "공시변화",
+ ],
+ },
+ "cycle": {
+ "description": "사이클 관점 — 매크로 + 시장 위치 먼저, 기업이 어디에 있는지",
+ "order": [
+ "매크로",
+ "시장분석",
+ "매출전망",
+ "수익구조",
+ "성장성",
+ "비용구조",
+ "수익성",
+ "현금흐름",
+ "자금조달",
+ "안정성",
+ "가치평가",
+ "종합평가",
+ "비교분석",
+ ],
+ },
+ "value": {
+ "description": "가치투자 관점 — 밸류에이션 + 안전 + 배당 먼저",
+ "order": [
+ "가치평가",
+ "안정성",
+ "자금조달",
+ "현금흐름",
+ "자본배분",
+ "수익성",
+ "종합평가",
+ "신용평가",
+ "수익구조",
+ "성장성",
+ "효율성",
+ "비교분석",
+ "시장분석",
+ ],
+ },
+ "growth": {
+ "description": "성장투자 관점 — 매출성장 + 마진 + 투자효율 먼저",
+ "order": [
+ "수익구조",
+ "성장성",
+ "매출전망",
+ "수익성",
+ "투자효율",
+ "효율성",
+ "자본배분",
+ "현금흐름",
+ "가치평가",
+ "비교분석",
+ "시장분석",
+ ],
+ },
+ "crisis": {
+ "description": "위기 진단 관점 — 매크로 + 부채 + 유동성 + 신용 + 리스크 먼저",
+ "order": [
+ "매크로",
+ "안정성",
+ "자금조달",
+ "현금흐름",
+ "이익품질",
+ "신용평가",
+ "종합평가",
+ "수익성",
+ "수익구조",
+ "가치평가",
+ "시장분석",
+ ],
+ },
+}
+
+# 한글 alias → 영문 key
+PERSPECTIVE_ALIASES: dict[str, str] = {
+ "바텀업": "bottomUp",
+ "탑다운": "topDown",
+ "사이클": "cycle",
+ "가치": "value",
+ "성장": "growth",
+ "위기": "crisis",
+ "bottomup": "bottomUp",
+ "topdown": "topDown",
+}
+
+
+def resolvePerspective(name: str | None) -> str | None:
+ """한글/영문 → 정규 키. None 이면 None (기본 6막)."""
+ if not name:
+ return None
+ s = name.strip()
+ if s in PERSPECTIVE_TEMPLATES:
+ return s
+ if s in PERSPECTIVE_ALIASES:
+ return PERSPECTIVE_ALIASES[s]
+ low = s.lower()
+ if low in PERSPECTIVE_ALIASES:
+ return PERSPECTIVE_ALIASES[low]
+ return None
+
+
+# ── 스토리 템플릿 — 기업 특성별 강조/축소 ──
+# 6막 순서는 불변. emphasize된 블록에 시각적 표시 + aiGuide 조정.
+
+STORY_TEMPLATES: dict[str, dict] = {
+ "사이클": {
+ "description": "업황 사이클이 전부 — 반도체/화학/조선 등",
+ "emphasize": {
+ "segmentComposition",
+ "segmentTrend",
+ "marginTrend",
+ "capexPattern",
+ "workingCapital",
+ "roicTree",
+ "technicalSignals",
+ "marketBeta",
+ "macroCycle",
+ "macroForecast",
+ },
+ "keyQuestions": [
+ "현재 사이클 어디에 있는가 (정점/저점/회복/하강)?",
+ "이 사이클의 진폭은 어느 정도인가?",
+ "다운턴에서 현금흐름이 버티는가?",
+ "CAPEX 사이클이 업황과 얼마나 동조하는가?",
+ ],
+ "actFocus": {
+ "1": "부문별 매출 변동 진폭 + 재고 사이클",
+ "2": "마진 변동계수 + 고정비/변동비 구조 — 업황 호전기 확대 폭과 하락기 방어력",
+ "3": "다운턴 시 FCF 방어력 — 감가상각이 현금 버퍼 역할을 하는가",
+ "5": "CAPEX 타이밍 — 사이클 저점에서 투자하는가, 아니면 호황기에 과잉 투자하는가",
+ },
+ "industryContext": "반도체/화학/조선은 3-5년 사이클. 재고일수와 가동률이 선행지표.",
+ "peerAxes": ["operatingMargin", "capexToRevenue", "inventoryDays"],
+ },
+ "프랜차이즈": {
+ "description": "안정 수익 + 현금 기계 — 프랜차이즈/구독 모델",
+ "emphasize": {
+ "marginTrend",
+ "cashQuality",
+ "ocfDecomposition",
+ "dividendPolicy",
+ "shareholderReturn",
+ "scorecard",
+ },
+ "keyQuestions": [
+ "마진 안정성이 어느 수준인가 (변동계수)?",
+ "현금 전환이 확실한가 (OCF/NI > 100%)?",
+ "배당/환원 여력이 충분한가?",
+ "성장 없이 가치를 유지할 수 있는 구조인가?",
+ ],
+ "actFocus": {
+ "2": "마진 안정성 — 변동계수가 낮고 원가 전가가 가능한 구조",
+ "3": "현금 기계 — OCF/NI 100% 이상, CCC 마이너스 또는 안정",
+ "5": "배당 지속성 — 연속 배당, 성향, FCF 대비 환원 여력",
+ },
+ "industryContext": "프랜차이즈/구독 모델은 전환비용이 높아 매출 안정성이 본질적 강점.",
+ "peerAxes": ["operatingMargin", "dividendYield", "cashConversionCycle"],
+ },
+ "턴어라운드": {
+ "description": "적자→흑자 전환 — 구조조정/사업 전환",
+ "emphasize": {
+ "marginTrend",
+ "leverageTrend",
+ "coverageTrend",
+ "distressScore",
+ "cashFlowOverview",
+ "growthTrend",
+ "fundamentalDivergence",
+ "technicalVerdict",
+ "macroRates",
+ "macroLiquidity",
+ },
+ "keyQuestions": [
+ "흑자 전환이 구조적인가, 일시적인가?",
+ "부채를 감당할 수 있는 수준인가?",
+ "영업CF가 양수로 돌아섰는가?",
+ "시장은 턴어라운드를 인정하는가 (재무-시장 괴리)?",
+ ],
+ "actFocus": {
+ "2": "마진 전환점 — 적자에서 흑자로 전환한 원인과 지속 가능성",
+ "3": "현금 회복 — 영업CF 양수 전환 여부, 운전자본 정상화",
+ "4": "부채 감내력 — 이자보상배율, Altman Z, 차환 리스크",
+ "6": "시장 인식 — 재무 개선 vs 주가 반응 괴리",
+ },
+ "industryContext": "턴어라운드는 1-2년 내 재악화 위험이 높다. 구조적 원인 제거 확인이 핵심.",
+ "peerAxes": ["operatingMargin", "debtRatio", "interestCoverage"],
+ },
+ "성장": {
+ "description": "고성장 + 마진 확대 — 매출 CAGR 15% 이상",
+ "emphasize": {
+ "growthTrend",
+ "cagrComparison",
+ "roicTree",
+ "segmentTrend",
+ "reinvestment",
+ "revenueForecast",
+ },
+ "keyQuestions": [
+ "성장이 수익성을 동반하는가 (마진 확대)?",
+ "성장의 원천은 무엇인가 (부문/지역/제품)?",
+ "재투자가 가치를 창출하는가 (ROIC > WACC)?",
+ "이 성장률이 지속 가능한가?",
+ ],
+ "actFocus": {
+ "1": "성장 원천 분해 — 어느 부문/지역이 성장을 견인하는가",
+ "2": "수익성 동반 성장 — 매출 성장과 마진 확대가 함께 가는가",
+ "5": "재투자 효율 — CAPEX/R&D가 ROIC로 돌아오는가",
+ "6": "성장 지속성 — 컨센서스, 매출 전망, PEG",
+ },
+ "industryContext": "고성장주는 CAGR > 15%지만, 변동성이 높으면 사이클 호황과 구분해야 한다.",
+ "peerAxes": ["revenueGrowthYoY", "operatingMargin", "roic"],
+ },
+ "자본집약": {
+ "description": "설비 의존 + 감가상각 — 항공/전력/중공업",
+ "emphasize": {
+ "capexPattern",
+ "ocfDecomposition",
+ "assetStructure",
+ "turnoverTrend",
+ "leverageTrend",
+ "penmanDecomposition",
+ },
+ "keyQuestions": [
+ "감가상각 대비 CAPEX 수준이 적정한가?",
+ "자산회전율이 개선되고 있는가?",
+ "영업CF에서 감가상각의 현금 버퍼가 충분한가?",
+ "부채를 자산 가치가 뒷받침하는가?",
+ ],
+ "actFocus": {
+ "1": "자산 규모와 구성 — 유형자산 비중, 건설중인자산, 노후도",
+ "3": "감가상각 현금 효과 — OCF = NI + 감가상각, 감가상각이 현금 버퍼",
+ "4": "부채 구조 — 자산 담보, 장기 차입, 이자보상",
+ "5": "CAPEX 사이클 — 유지투자 vs 확장투자, CAPEX/감가상각 비율",
+ },
+ "industryContext": "항공/전력/중공업은 CAPEX가 수년간 회수. 자산회전율과 감가상각 커버가 핵심.",
+ "peerAxes": ["capexToRevenue", "assetTurnover", "debtRatio"],
+ },
+ "지주": {
+ "description": "자회사 포트폴리오 — 영업외손익이 핵심",
+ "emphasize": {
+ "nonOperatingBreakdown",
+ "ownershipTrend",
+ "investmentInOther",
+ "dividendPolicy",
+ "assetStructure",
+ },
+ "keyQuestions": [
+ "지분법손익이 전체 이익에서 차지하는 비중은?",
+ "자회사 포트폴리오가 건전한가?",
+ "연결 vs 별도 재무제표 괴리가 큰가?",
+ "지주 할인이 정당화되는 수준인가?",
+ ],
+ "actFocus": {
+ "1": "사업 포트폴리오 — 자회사 구성, 지분율, 핵심 자회사 실적",
+ "2": "영업외손익 분해 — 지분법/배당/처분 등 비영업 이익 원천",
+ "5": "자본배분 — 자회사 배당 수취 vs 주주 환원, 자체 투자",
+ "6": "지주 할인/프리미엄 — NAV 대비 시가총액, 할인율 추이",
+ },
+ "industryContext": "지주사는 연결 재무제표가 자회사를 합산. 별도 재무제표로 지주 본체를 분리해서 봐야 한다.",
+ "peerAxes": ["nonOperatingRatio", "dividendYield", "pbr"],
+ },
+ "현금부자": {
+ "description": "현금 축적 + 배분 이슈",
+ "emphasize": {
+ "fundingSources",
+ "penmanDecomposition",
+ "dividendPolicy",
+ "shareholderReturn",
+ "cashFlowOverview",
+ "fcfUsage",
+ "macroRates",
+ },
+ "keyQuestions": [
+ "현금을 왜 쌓고 있는가 (전략적 vs 비효율)?",
+ "주주환원 여력 대비 실제 환원 수준은?",
+ "순현금 상태에서 FLEV가 마이너스인가?",
+ "현금 축적이 ROE를 희석하고 있는가?",
+ ],
+ "actFocus": {
+ "2": "Penman FLEV — 순현금이 ROE를 희석하는 구조인지 확인",
+ "3": "FCF 추이 — 매년 양의 FCF가 현금을 쌓는 원천",
+ "5": "배분 정책 — 배당성향, 자사주, FCF 대비 환원율, 잔여 현금 누적",
+ },
+ "industryContext": "순현금 기업은 안전하지만, 현금이 과도하면 ROE가 희석되고 자본 효율이 떨어진다.",
+ "peerAxes": ["cashToAssets", "dividendPayout", "roe"],
+ },
+}
+
+
+def detectTemplates(company) -> list[str]:
+ """기업 재무 데이터에서 해당하는 스토리 템플릿 전부 반환.
+
+ 예: ["사이클", "자본집약"], ["지주", "현금부자"]
+ 우선순위 순서로 정렬 (첫 번째가 주 템플릿).
+ """
+ results: list[str] = []
+ for name, check in _TEMPLATE_CHECKS:
+ try:
+ if check(company):
+ results.append(name)
+ except (AttributeError, ValueError, TypeError, KeyError, IndexError):
+ continue
+ return results
+
+
+def detectTemplate(company) -> str | None:
+ """기업 재무 데이터에서 스토리 템플릿 자동 판별. 첫 매칭 반환."""
+ results = detectTemplates(company)
+ return results[0] if results else None
+
+
+# ── 복수 매칭용 독립 체크 함수 ──
+
+
+def _extractCommon(company):
+ """공통 데이터 추출 (체크 함수 공용)."""
+ try:
+ ratios = company._finance.ratios
+ except (AttributeError, ValueError):
+ return None
+
+ def _g(name, default=None):
+ return getattr(ratios, name, default)
+
+ try:
+ rs = company._finance.ratioSeries
+ if rs:
+ data, _ = rs
+ opMargins = data.get("RATIO", {}).get("operatingMargin", [])
+ else:
+ opMargins = []
+ except (AttributeError, ValueError):
+ opMargins = []
+
+ return {
+ "ratios": ratios,
+ "opMargin": _g("operatingMargin"),
+ "netDebt": _g("netDebt"),
+ "cash": _g("cash"),
+ "totalAssets": _g("totalAssets"),
+ "ppe": _g("ppe") or _g("tangibleAssets"),
+ "opMargins": opMargins,
+ }
+
+
+def _cv(values: list, min_count: int = 4) -> float | None:
+ """변동계수(CV) 계산. None 제거 후 min_count 미만이면 None."""
+ valid = [m for m in values if m is not None]
+ if len(valid) < min_count:
+ return None
+ avg = sum(valid) / len(valid)
+ if avg == 0:
+ return None
+ std = (sum((m - avg) ** 2 for m in valid) / len(valid)) ** 0.5
+ return std / abs(avg)
+
+
+def _checkTurnaround(company) -> bool:
+ ctx = _extractCommon(company)
+ if not ctx:
+ return False
+ opMargins = ctx["opMargins"]
+ if len(opMargins) < 3:
+ return False
+ recent3 = opMargins[-3:]
+ hasNeg = any(m is not None and m < 0 for m in recent3[:-1])
+ lastPos = recent3[-1] is not None and recent3[-1] > 0
+ return hasNeg and lastPos
+
+
+def _checkHolding(company) -> bool:
+ from dartlab.analysis.financial.earningsQuality import calcNonOperatingBreakdown
+
+ nob = calcNonOperatingBreakdown(company)
+ if not nob:
+ return False
+ latest = nob["history"][0] if nob.get("history") else None
+ if not latest:
+ return False
+ opInc = abs(latest.get("opIncome") or 1)
+ assocInc = abs(latest.get("associateIncome") or 0)
+ if opInc > 0 and assocInc / opInc > 0.30:
+ return True
+ finCost = abs(latest.get("finCost") or 0)
+ finIncome = abs(latest.get("finIncome") or 0)
+ nonOpTotal = abs(latest.get("nonOpTotal") or 0)
+ nonOpExFinance = nonOpTotal - finCost - finIncome
+ return opInc > 0 and nonOpExFinance > 0 and nonOpExFinance / opInc > 0.80
+
+
+def _checkGrowth(company) -> bool:
+ ctx = _extractCommon(company)
+ if not ctx:
+ return False
+ from dartlab.analysis.financial.growthAnalysis import calcCagrComparison
+
+ cc = calcCagrComparison(company)
+ if not cc:
+ return False
+ for comp in cc.get("comparisons", []):
+ if comp.get("label") == "마진 방향" and comp.get("cagr1") is not None:
+ if comp["cagr1"] > 15:
+ cv = _cv(ctx["opMargins"])
+ if cv is not None and cv > 0.5:
+ return False
+ return True
+ return False
+
+
+def _checkCashRich(company) -> bool:
+ ctx = _extractCommon(company)
+ if not ctx:
+ return False
+ nd, ta, cash = ctx["netDebt"], ctx["totalAssets"], ctx["cash"]
+ return nd is not None and ta and cash and nd < 0 and cash / ta > 0.20
+
+
+def _checkCyclical(company) -> bool:
+ ctx = _extractCommon(company)
+ if not ctx:
+ return False
+ cv = _cv(ctx["opMargins"])
+ return cv is not None and cv > 0.4
+
+
+def _checkCapitalIntensive(company) -> bool:
+ ctx = _extractCommon(company)
+ if not ctx:
+ return False
+ ppe, ta = ctx["ppe"], ctx["totalAssets"]
+ return ppe is not None and ta and ppe / ta > 0.40
+
+
+def _checkFranchise(company) -> bool:
+ ctx = _extractCommon(company)
+ if not ctx:
+ return False
+ opMargin = ctx["opMargin"]
+ if opMargin is None or opMargin <= 10:
+ return False
+ cv = _cv(ctx["opMargins"])
+ return cv is not None and cv < 0.15
+
+
+# 우선순위 순서
+_TEMPLATE_CHECKS: list[tuple[str, object]] = [
+ ("턴어라운드", _checkTurnaround),
+ ("지주", _checkHolding),
+ ("성장", _checkGrowth),
+ ("현금부자", _checkCashRich),
+ ("사이클", _checkCyclical),
+ ("자본집약", _checkCapitalIntensive),
+ ("프랜차이즈", _checkFranchise),
+]
diff --git a/src/dartlab/review/utils.py b/src/dartlab/review/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..44e2af5ebf34ec6b1a67da66969b17b9f09d32d7
--- /dev/null
+++ b/src/dartlab/review/utils.py
@@ -0,0 +1,163 @@
+"""review 유틸리티 — 금액 포맷팅 등."""
+
+from __future__ import annotations
+
+
+def fmtAmt(value, unit: str = "won") -> str:
+ """금액을 조/억 또는 B/M 단위로 포맷."""
+ if value is None:
+ return "-"
+
+ absVal = abs(value)
+ sign = "-" if value < 0 else ""
+
+ if unit == "usd":
+ if absVal >= 1_000_000_000:
+ return f"{sign}${absVal / 1_000_000_000:.1f}B"
+ if absVal >= 1_000_000:
+ return f"{sign}${absVal / 1_000_000:.0f}M"
+ if absVal >= 1_000:
+ return f"{sign}${absVal / 1_000:.0f}K"
+ return f"{sign}${absVal:,.0f}"
+
+ if unit == "won":
+ if absVal >= 1_0000_0000_0000:
+ return f"{sign}{absVal / 1_0000_0000_0000:.1f}조"
+ if absVal >= 1_0000_0000:
+ return f"{sign}{absVal / 1_0000_0000:.0f}억"
+ if absVal >= 1_0000:
+ return f"{sign}{absVal / 1_0000:.0f}만"
+ return f"{sign}{absVal:,.0f}"
+
+ # 백만원 단위 (segments, salesOrder 등)
+ if absVal >= 1_000_000:
+ return f"{sign}{absVal / 1_000_000:.1f}조"
+ if absVal >= 100:
+ return f"{sign}{absVal / 100:.0f}억"
+ if absVal >= 1:
+ return f"{sign}{absVal:.0f}백만"
+ return f"{sign}{absVal:,.0f}"
+
+
+def fmtAmtScale(value, scale: str) -> str:
+ """고정 스케일(조/억/B/M)로 금액 포맷."""
+ if value is None:
+ return "-"
+ sign = "-" if value < 0 else ""
+ absVal = abs(value)
+ if scale == "조":
+ return f"{sign}{absVal:.1f}조"
+ if scale == "억":
+ return f"{sign}{absVal:.0f}억"
+ if scale == "B":
+ return f"{sign}${absVal:.1f}B"
+ if scale == "M":
+ return f"{sign}${absVal:.0f}M"
+ return f"{sign}{absVal:,.0f}"
+
+
+def unifyTableScale(
+ rawRows: list[dict],
+ labelCol: str,
+ valueCols: list[str],
+ unit: str = "won",
+) -> list[dict]:
+ """테이블의 숫자를 같은 스케일(조/억)로 통일."""
+ # % 행과 금액 행 분리
+ amtRows = []
+ pctRows = []
+ for row in rawRows:
+ label = row.get(labelCol, "")
+ if "비중" in label or "%" in label:
+ pctRows.append(row)
+ else:
+ amtRows.append(row)
+
+ if not amtRows:
+ return rawRows
+
+ # 전체 금액의 최대값으로 스케일 결정
+ maxVal = 0.0
+ for row in amtRows:
+ for vc in valueCols:
+ v = row.get(vc)
+ if v is not None and isinstance(v, (int, float)):
+ maxVal = max(maxVal, abs(v))
+
+ # 원 → 조/억 또는 USD → B/M 변환 기준값 결정
+ if unit == "usd":
+ if maxVal >= 1_000_000_000:
+ scale = "B"
+ divisor = 1_000_000_000
+ elif maxVal >= 1_000_000:
+ scale = "M"
+ divisor = 1_000_000
+ else:
+ scale = ""
+ divisor = 1
+ elif unit == "won":
+ if maxVal >= 1_0000_0000_0000:
+ scale = "조"
+ divisor = 1_0000_0000_0000
+ elif maxVal >= 1_0000_0000:
+ scale = "억"
+ divisor = 1_0000_0000
+ else:
+ scale = ""
+ divisor = 1
+ else: # millions
+ if maxVal >= 1_000_000:
+ scale = "조"
+ divisor = 1_000_000
+ elif maxVal >= 100:
+ scale = "억"
+ divisor = 100
+ else:
+ scale = ""
+ divisor = 1
+
+ # 금액 행 변환
+ result = []
+ for row in amtRows:
+ fmtRow = {labelCol: row[labelCol]}
+ for vc in valueCols:
+ v = row.get(vc)
+ if v is not None and isinstance(v, (int, float)):
+ scaled = v / divisor if divisor != 1 else v
+ fmtRow[vc] = fmtAmtScale(scaled, scale)
+ else:
+ fmtRow[vc] = row.get(vc, "-")
+ result.append(fmtRow)
+
+ # % 행은 포맷 변환 없이 그대로
+ for row in pctRows:
+ fmtRow = {}
+ for k, v in row.items():
+ fmtRow[k] = v if isinstance(v, str) else (f"{v:.0f}%" if v is not None else "-")
+ result.append(fmtRow)
+
+ return result
+
+
+def padLabel(text: str, width: int) -> str:
+ """한글 폭(2) 감안한 고정폭 패딩."""
+ import unicodedata
+
+ w = sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in text)
+ return text + " " * max(0, width - w)
+
+
+def isTerminal() -> bool:
+ """터미널 환경인지 판별."""
+ import sys
+
+ if hasattr(sys, "ps1"):
+ return False
+ try:
+ from IPython import get_ipython
+
+ if get_ipython() is not None:
+ return False
+ except ImportError:
+ pass
+ return sys.stderr.isatty()
diff --git a/src/dartlab/scan/__init__.py b/src/dartlab/scan/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..740b454e2d30eb5564e158594cfdb4e4d0861c04
--- /dev/null
+++ b/src/dartlab/scan/__init__.py
@@ -0,0 +1,761 @@
+"""시장 전체 횡단분석 통합 엔트리포인트.
+
+Company = 기업 하나. Scan = 기업 밖 전부.
+
+사용법::
+
+ import dartlab
+
+ dartlab.scan() # 가이드 (축 목록 + 사용법)
+ dartlab.scan("governance") # 전 상장사 거버넌스
+ dartlab.scan("governance", "005930") # 삼성전자만 필터
+ dartlab.scan("ratio") # 가용 비율 목록
+ dartlab.scan("ratio", "roe") # 전종목 ROE
+ dartlab.scan("account", "매출액") # 전종목 매출액 시계열
+ dartlab.scan("financial") # 재무 8축 가이드
+ dartlab.scan("financial", "수익성") # 2-level: financial 그룹 내 수익성
+"""
+
+from __future__ import annotations
+
+import importlib
+from dataclasses import dataclass
+from typing import Any
+
+import polars as pl
+
+from dartlab.scan.builder import buildChanges, buildFinance, buildReport, buildScan # noqa: F401
+from dartlab.scan.payload import build_scan_payload, build_unified_payload # noqa: F401
+from dartlab.scan.snapshot import buildScanSnapshot, getScanPosition # noqa: F401
+
+# ── 한글 컬럼명 + 종목명 공통 변환 ──
+
+_COLUMN_RENAME = {
+ "stockCode": "종목코드",
+ "opMargin": "영업이익률",
+ "netMargin": "순이익률",
+ "roe": "ROE",
+ "roa": "ROA",
+ "grade": "등급",
+ "nonRecurring": "비경상",
+ "revenueCagr": "매출CAGR",
+ "opIncomeCagr": "영업이익CAGR",
+ "netIncomeCagr": "순이익CAGR",
+ "pattern": "패턴",
+ "assetTurnover": "자산회전율",
+ "invTurnover": "재고회전율",
+ "arTurnover": "매출채권회전율",
+ "ppeTurnover": "유형자산회전율",
+ "invDays": "재고일수",
+ "arDays": "매출채권일수",
+ "ccc": "현금전환주기",
+ "marketCap": "시가총액",
+ "per": "PER",
+ "pbr": "PBR",
+ "psr": "PSR",
+ "dividendYield": "배당수익률",
+ "riskLevel": "위험등급",
+ "riskFlags": "위험플래그",
+ "riskCount": "위험수",
+ "presets": "프리셋",
+ "presetCount": "프리셋수",
+ "ocf": "영업CF",
+ "icf": "투자CF",
+ "finCf": "재무CF",
+ "accrualRatio": "발생액비율",
+ "cfToNi": "CF/NI",
+ "currentRatio": "유동비율",
+ "quickRatio": "당좌비율",
+ "holderPct": "최대주주지분",
+ "holderChange": "지분변동",
+ "treasuryShares": "자기주식",
+ "stability": "경영권안정성",
+ "opinion": "감사의견",
+ "auditor": "감사인",
+ "auditorChanged": "감사인변경",
+ "hasSpecialMatter": "특기사항",
+ "dpsGrowth": "DPS성장",
+}
+
+
+def _enrichWithKorean(df: pl.DataFrame) -> pl.DataFrame:
+ """영문 컬럼 → 한글 rename + 종목명 추가."""
+ # 종목명 매핑
+ if "stockCode" in df.columns:
+ try:
+ import dartlab as _dl
+
+ listing = _dl.listing()
+ if listing is not None:
+ name_col = next((c for c in ("종목명", "회사명") if c in listing.columns), None)
+ if name_col and "종목코드" in listing.columns:
+ name_map = listing.select(["종목코드", name_col]).rename(
+ {name_col: "_종목명", "종목코드": "stockCode"}
+ )
+ df = df.join(name_map, on="stockCode", how="left")
+ except (ImportError, AttributeError, KeyError, ValueError, RuntimeError):
+ pass
+
+ # 한글 rename
+ renames = {k: v for k, v in _COLUMN_RENAME.items() if k in df.columns}
+ if renames:
+ df = df.rename(renames)
+
+ # 종목명 배치 (종목코드 바로 뒤)
+ if "_종목명" in df.columns:
+ df = df.rename({"_종목명": "종목명"})
+ cols = df.columns
+ if "종목코드" in cols:
+ ordered = ["종목코드", "종목명"] + [c for c in cols if c not in ("종목코드", "종목명")]
+ df = df.select(ordered)
+
+ return df
+
+
+# ── Axis Registry ────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class _AxisEntry:
+ """scan 축 메타데이터."""
+
+ module: str
+ fn: str
+ label: str
+ description: str
+ example: str
+ targetParam: str | None = None # None이면 stockCode 필터
+ targetRequired: bool = False
+ returnType: str = "DataFrame"
+ listModule: str | None = None # target 없이 호출 시 목록 반환용
+ listFn: str | None = None
+
+
+_AXIS_REGISTRY: dict[str, _AxisEntry] = {
+ "governance": _AxisEntry(
+ module="dartlab.scan.governance",
+ fn="scan_governance",
+ label="거버넌스",
+ description="지배구조 (지분율, 사외이사, 보수비율, 감사의견, 소액주주 분산)",
+ example='scan("governance")',
+ ),
+ "workforce": _AxisEntry(
+ module="dartlab.scan.workforce",
+ fn="scan_workforce",
+ label="인력/급여",
+ description="직원수, 평균급여, 인건비율, 1인당부가가치, 성장률, 고액보수",
+ example='scan("workforce")',
+ ),
+ "capital": _AxisEntry(
+ module="dartlab.scan.capital",
+ fn="scan_capital",
+ label="주주환원",
+ description="배당, 자사주(취득/처분/소각), 증자/감자, 환원 분류",
+ example='scan("capital")',
+ ),
+ "debt": _AxisEntry(
+ module="dartlab.scan.debt",
+ fn="scan_debt",
+ label="부채구조",
+ description="사채만기, 부채비율, ICR, 위험등급",
+ example='scan("debt")',
+ ),
+ "account": _AxisEntry(
+ module="dartlab.providers.dart.finance.scanAccount",
+ fn="scanAccount",
+ label="계정",
+ description="전종목 단일 계정 시계열 (매출액, 영업이익 등)",
+ example='scan("account", "매출액")',
+ targetParam="snakeId",
+ targetRequired=True,
+ listModule="dartlab.providers.dart.finance.scanAccount",
+ listFn="scanAccountList",
+ ),
+ "ratio": _AxisEntry(
+ module="dartlab.providers.dart.finance.scanAccount",
+ fn="scanRatio",
+ label="비율",
+ description="전종목 단일 재무비율 시계열 (ROE, 부채비율 등)",
+ example='scan("ratio", "roe")',
+ targetParam="ratioName",
+ targetRequired=True,
+ listModule="dartlab.providers.dart.finance.scanAccount",
+ listFn="scanRatioList",
+ ),
+ "network": _AxisEntry(
+ module="dartlab.scan.network",
+ fn="build_graph",
+ label="네트워크",
+ description="상장사 관계 네트워크 (출자/지분/계열)",
+ example='scan("network")',
+ returnType="dict",
+ ),
+ "cashflow": _AxisEntry(
+ module="dartlab.scan.cashflow",
+ fn="scanCashflow",
+ label="현금흐름",
+ description="OCF/ICF/FCF + 현금흐름 패턴 분류 (8종)",
+ example='scan("cashflow")',
+ ),
+ "audit": _AxisEntry(
+ module="dartlab.scan.audit",
+ fn="scanAudit",
+ label="감사리스크",
+ description="감사의견, 감사인변경, 특기사항, 감사독립성비율",
+ example='scan("audit")',
+ ),
+ "insider": _AxisEntry(
+ module="dartlab.scan.insider",
+ fn="scanInsider",
+ label="내부자지분",
+ description="최대주주 지분변동, 자기주식 현황, 경영권 안정성",
+ example='scan("insider")',
+ ),
+ "quality": _AxisEntry(
+ module="dartlab.scan.quality",
+ fn="scanQuality",
+ label="이익의 질",
+ description="Accrual Ratio + CF/NI 비율 — 이익이 현금 뒷받침되는지",
+ example='scan("quality")',
+ ),
+ "liquidity": _AxisEntry(
+ module="dartlab.scan.liquidity",
+ fn="scanLiquidity",
+ label="유동성",
+ description="유동비율 + 당좌비율 — 단기 지급능력",
+ example='scan("liquidity")',
+ ),
+ "growth": _AxisEntry(
+ module="dartlab.scan.growth",
+ fn="scanGrowth",
+ label="성장성",
+ description="매출/영업이익/순이익 CAGR + 성장 패턴 분류 (6종)",
+ example='scan("growth")',
+ ),
+ "profitability": _AxisEntry(
+ module="dartlab.scan.profitability",
+ fn="scanProfitability",
+ label="수익성",
+ description="영업이익률/순이익률/ROE/ROA + 등급",
+ example='scan("profitability")',
+ ),
+ "efficiency": _AxisEntry(
+ module="dartlab.scan.efficiency",
+ fn="scanEfficiency",
+ label="효율성",
+ description="자산/재고/매출채권 회전율 + CCC(현금전환주기) + 등급",
+ example='scan("efficiency")',
+ ),
+ "valuation": _AxisEntry(
+ module="dartlab.scan.valuation",
+ fn="scanValuation",
+ label="밸류에이션",
+ description="PER/PBR/PSR + 시가총액 + 등급 (네이버 실시간)",
+ example='scan("valuation")',
+ ),
+ "dividendTrend": _AxisEntry(
+ module="dartlab.scan.dividendTrend",
+ fn="scanDividendTrend",
+ label="배당추이",
+ description="DPS 3개년 시계열 + 패턴 분류 (연속증가/안정/감소/시작/중단)",
+ example='scan("dividendTrend")',
+ ),
+ "macroBeta": _AxisEntry(
+ module="dartlab.scan.macroBeta",
+ fn="scan_macroBeta",
+ label="거시베타",
+ description="전종목 GDP/금리/환율 베타 횡단면 (OLS 회귀). 사전 수집: Ecos().series('GDP', enrich=True)",
+ example='scan("macroBeta")',
+ ),
+ "screen": _AxisEntry(
+ module="dartlab.scan.screen",
+ fn="scanScreen",
+ label="스크리닝",
+ description="멀티팩터 스크리닝 (value/dividend/growth/risk/quality 프리셋)",
+ example='scan("screen", "value")',
+ targetParam="target",
+ targetRequired=False,
+ ),
+ "disclosureRisk": _AxisEntry(
+ module="dartlab.scan.disclosureRisk",
+ fn="scanDisclosureRisk",
+ label="공시리스크",
+ description="공시 변화 기반 선행 리스크 (우발부채, 감사변경, 계열변화, 사업전환)",
+ example='scan("disclosureRisk")',
+ ),
+}
+
+
+# ── Aliases ──────────────────────────────────────────────
+
+
+_ALIASES: dict[str, str] = {
+ # governance
+ "거버넌스": "governance",
+ "지배구조": "governance",
+ # workforce
+ "인력": "workforce",
+ "급여": "workforce",
+ "인력/급여": "workforce",
+ # capital
+ "주주환원": "capital",
+ "배당": "capital",
+ # debt
+ "부채": "debt",
+ "부채구조": "debt",
+ "사채": "debt",
+ # account
+ "계정": "account",
+ # ratio
+ "비율": "ratio",
+ # network
+ "네트워크": "network",
+ "관계": "network",
+ # cashflow
+ "현금흐름": "cashflow",
+ "현금": "cashflow",
+ # audit
+ "감사": "audit",
+ "감사리스크": "audit",
+ # insider
+ "내부자": "insider",
+ "내부자지분": "insider",
+ "지분": "insider",
+ # quality
+ "이익의질": "quality",
+ "이익의 질": "quality",
+ "이익품질": "quality",
+ "어닝퀄리티": "quality",
+ # liquidity
+ "유동성": "liquidity",
+ "유동비율": "liquidity",
+ # macroBeta
+ "거시베타": "macroBeta",
+ "매크로베타": "macroBeta",
+ "거시민감도": "macroBeta",
+ # growth
+ "성장성": "growth",
+ "성장": "growth",
+ # profitability
+ "수익성": "profitability",
+ # efficiency
+ "효율성": "efficiency",
+ "회전율": "efficiency",
+ # valuation
+ "밸류에이션": "valuation",
+ "밸류": "valuation",
+ # dividendTrend
+ "배당추이": "dividendTrend",
+ "배당시계열": "dividendTrend",
+ "배당트렌드": "dividendTrend",
+ # screen
+ "스크리닝": "screen",
+ "스크린": "screen",
+ "필터": "screen",
+ # disclosureRisk
+ "공시리스크": "disclosureRisk",
+ "공시변화": "disclosureRisk",
+}
+
+
+def _edgarDispatch(axis: str, kwargs: dict) -> pl.DataFrame | None:
+ """EDGAR 전용 scan 축 디스패치. 구현 없으면 None 반환."""
+ # XBRL 기반 7축 — _edgar_helpers.scan_edgar_accounts 활용
+ _EDGAR_XBRL_AXES = {
+ "profitability",
+ "growth",
+ "quality",
+ "liquidity",
+ "efficiency",
+ "cashflow",
+ "dividendTrend",
+ "capital",
+ "debt",
+ }
+ if axis in _EDGAR_XBRL_AXES:
+ from dartlab.scan._edgar_scan import edgarScan
+
+ return edgarScan(axis, **kwargs)
+
+ # account/ratio — 기존 EDGAR scanAccount 사용
+ if axis == "account":
+ from dartlab.providers.edgar.finance.scanAccount import scanAccount
+
+ return scanAccount(kwargs.get("snakeId", "sales"), annual=kwargs.get("annual", False))
+ if axis == "ratio":
+ from dartlab.providers.edgar.finance.scanAccount import scanRatio
+
+ return scanRatio(kwargs.get("ratioName", "roe"), annual=kwargs.get("annual", False))
+
+ return None # 아직 EDGAR 구현 없는 축
+
+
+_SCAN_GROUPS: dict[str, list[str]] = {
+ "financial": [
+ "profitability",
+ "growth",
+ "efficiency",
+ "quality",
+ "liquidity",
+ "valuation",
+ "cashflow",
+ "dividendTrend",
+ ],
+}
+
+_GROUP_ALIASES: dict[str, str] = {
+ "재무": "financial",
+ "재무분석": "financial",
+}
+
+
+def _resolveGroup(name: str) -> str | None:
+ """그룹 이름 또는 alias → 정규 그룹 이름. 그룹이 아니면 None."""
+ if name in _SCAN_GROUPS:
+ return name
+ lower = name.lower()
+ if lower in _SCAN_GROUPS:
+ return lower
+ if name in _GROUP_ALIASES:
+ return _GROUP_ALIASES[name]
+ if lower in _GROUP_ALIASES:
+ return _GROUP_ALIASES[lower]
+ return None
+
+
+def _resolveAxis(axis: str) -> str:
+ """축 이름 또는 alias → 정규 축 이름."""
+ if axis in _AXIS_REGISTRY:
+ return axis
+ lower = axis.lower()
+ if lower in _AXIS_REGISTRY:
+ return lower
+ if axis in _ALIASES:
+ return _ALIASES[axis]
+ if lower in _ALIASES:
+ return _ALIASES[lower]
+ available = ", ".join(sorted(_AXIS_REGISTRY))
+ raise ValueError(
+ f"알 수 없는 scan 축: '{axis}'. 가용 축: {available}\n"
+ f" 사용법: dartlab.scan() 으로 전체 축 가이드를 확인하세요."
+ )
+
+
+# ── Scan Class ───────────────────────────────────────────
+
+
+def available_scans() -> list[str]:
+ """가용 scan 축 이름 목록.
+
+ Capabilities:
+ - 15축 scan 축 이름을 알파벳순 리스트로 반환
+ - 프로그래밍 방식으로 가용 축을 탐색할 때 사용
+
+ Requires:
+ 없음 (레지스트리 메타데이터만 참조)
+
+ AIContext:
+ 사용자가 "어떤 scan이 있어?" 질문 시 축 목록 제공.
+
+ Guide:
+ - "scan 뭐 있어?" -> available_scans()로 축 이름 목록 확인
+ - "어떤 분석 가능해?" -> available_scans() + scan() 가이드 조합
+ - scan()을 인자 없이 호출하면 설명 포함 가이드 DataFrame 반환.
+
+ SeeAlso:
+ - scan: 축 이름으로 실제 횡단분석 실행
+ - Scan.__call__: axis=None이면 설명 포함 가이드 DataFrame 반환
+
+ Args:
+ 없음.
+
+ Returns:
+ list[str] — 알파벳순 축 이름 목록 (예: ["account", "audit", ...]).
+
+ Example::
+
+ from dartlab.scan import available_scans
+ available_scans() # ['account', 'audit', 'capital', ...]
+ """
+ return sorted(_AXIS_REGISTRY.keys())
+
+
+class Scan:
+ """시장 전체 횡단분석 -- 15축, 전부 Polars DataFrame.
+
+ Capabilities:
+ - governance: 최대주주 지분, 사외이사, 감사위원회 종합 등급
+ - workforce: 임직원 수, 평균급여, 근속연수
+ - capital: 배당수익률, 배당성향, 자사주
+ - debt: 사채만기, 부채비율, ICR, 위험등급
+ - account: 전종목 단일 계정 시계열 (매출액, 영업이익 등)
+ - ratio: 전종목 단일 재무비율 시계열 (ROE, 부채비율 등)
+ - cashflow: OCF/ICF/FCF + 현금흐름 패턴 분류
+ - audit: 감사의견, 감사인변경, 특기사항, 감사독립성
+ - insider: 최대주주 지분변동, 자기주식, 경영권 안정성
+ - quality: Accrual Ratio + CF/NI -- 이익의 현금 뒷받침
+ - liquidity: 유동비율 + 당좌비율 -- 단기 지급능력
+ - growth: 매출/영업이익/순이익 CAGR + 성장 패턴 분류
+ - profitability: 영업이익률/순이익률/ROE/ROA + 등급
+ - digest: 시장 전체 공시 변화 다이제스트
+ - network: 상장사 관계 네트워크 (출자/지분/계열)
+
+ Requires:
+ 데이터: 축별로 다름 (dartlab.downloadAll() 참조)
+ - governance/workforce/capital/debt/audit/insider: report
+ - account/ratio: finance
+ - network/digest: docs
+
+ AIContext:
+ 시장 전체 비교/순위 질문에 사용. 개별 종목 분석은 Company 메서드 사용.
+
+ Guide:
+ - "다른 회사랑 비교 가능해?" -> scan("account") 또는 scan("ratio") 안내
+ - "거버넌스 좋은 회사?" -> scan("governance")로 등급 A 필터
+ - "배당 많이 주는 회사?" -> scan("capital")로 배당수익률 정렬
+ - "ROE 높은 회사?" -> scan("ratio", "roe")로 전종목 비교
+ - "삼성전자랑 SK하이닉스 비교" -> scan("account", "sales", code="005930,000660")
+ - API 키 불필요. 사전 다운로드 데이터만으로 동작.
+
+ SeeAlso:
+ - analysis: 개별 종목 14축 전략분석
+ - Company.insights: 단일 종목 7영역 종합 분석
+ - gather: 주가/수급 데이터 (모멘텀 보완)
+
+ Args:
+ axis: 축 이름. None이면 13축 가이드 반환.
+ target: 축별 대상 (종목코드, 항목, 비율명 등).
+ **kwargs: 축별 옵션 (annual, fsPref, market 등).
+
+ Returns
+ -------
+ pl.DataFrame
+ 전종목 횡단 데이터. axis=None이면 가이드 DataFrame.
+ 공통 컬럼: 종목코드 (str), 종목명 (str) + 축별 지표 컬럼.
+
+ Example::
+
+ import dartlab
+ dartlab.scan() # 가이드
+ dartlab.scan("governance") # 전종목 지배구조
+ dartlab.scan("account", "매출액") # 전종목 매출액
+ dartlab.scan("ratio", "roe") # 전종목 ROE
+ """
+
+ def __call__(
+ self,
+ axis: str | None = None,
+ target: str | None = None,
+ **kwargs: Any,
+ ) -> pl.DataFrame | Any:
+ """축(axis)별 전종목 횡단분석.
+
+ 2-level 호출도 지원한다::
+
+ scan("financial") # 재무 8축 가이드
+ scan("financial", "수익성") # financial 그룹 내 수익성 축
+ scan("profitability") # 기존 flat 호출도 그대로 동작
+
+ Returns
+ -------
+ pl.DataFrame
+ axis=None (가이드):
+ axis : str — 축 이름
+ label : str — 한글 레이블
+ description : str — 설명
+ example : str — 사용 예시
+ axis="profitability":
+ 종목코드 : str — 6자리 종목코드
+ 종목명 : str — 회사명
+ 영업이익률 : float — 영업이익률 (%)
+ 순이익률 : float — 순이익률 (%)
+ ROE : float — 자기자본이익률 (%)
+ ROA : float — 총자산이익률 (%)
+ 등급 : str — 수익성 등급
+ axis="account" (target="매출액"):
+ 종목코드 : str — 6자리 종목코드
+ 종목명 : str — 회사명
+ 2024, 2023, ... : float — 연도별 값 (원 단위)
+ axis="ratio" (target="roe"):
+ 종목코드 : str — 6자리 종목코드
+ 종목명 : str — 회사명
+ 2024, 2023, ... : float — 연도별 비율값 (%, 배)
+ 기타 축: 종목코드 + 종목명 + 축별 지표 컬럼
+ """
+ if axis is None:
+ return self._guide()
+
+ # ── 2-level: 그룹 호출 ──
+ group = _resolveGroup(axis)
+ if group is not None:
+ if target is None:
+ return self._financialGuide() if group == "financial" else self._guide()
+ # target을 그룹 내 축으로 resolve
+ try:
+ resolvedTarget = _resolveAxis(target)
+ except ValueError:
+ members = ", ".join(_SCAN_GROUPS[group])
+ raise ValueError(f"'{target}'은(는) '{group}' 그룹에 속하지 않습니다. 가용 축: {members}")
+ if resolvedTarget not in _SCAN_GROUPS[group]:
+ members = ", ".join(_SCAN_GROUPS[group])
+ raise ValueError(f"'{target}'은(는) '{group}' 그룹에 속하지 않습니다. 가용 축: {members}")
+ # 그룹 내 축이면 flat 호출로 위임 (나머지 kwargs 전달)
+ return self(resolvedTarget, **kwargs)
+
+ resolved = _resolveAxis(axis)
+ entry = _AXIS_REGISTRY[resolved]
+
+ # target 없으면 목록 반환 (targetRequired 축)
+ if entry.targetRequired and target is None:
+ return self._listForAxis(resolved, entry)
+
+ # target → 파라미터 변환
+ callKwargs: dict[str, Any] = dict(kwargs)
+ if entry.targetParam and target is not None:
+ callKwargs[entry.targetParam] = target
+
+ # EDGAR market 디스패치 — XBRL 기반 축은 EDGAR 전용 구현으로 분기
+ market = callKwargs.pop("market", None)
+ if market in ("edgar", "us", "US"):
+ result = _edgarDispatch(resolved, callKwargs)
+ if result is not None:
+ return result
+ # fallback: EDGAR 전용 구현 없으면 기본 함수 호출 (account/ratio 등)
+
+ # lazy import + 호출
+ mod = importlib.import_module(entry.module)
+ fn = getattr(mod, entry.fn)
+ result = fn(**callKwargs)
+
+ # stockCode 필터 (target이 있고 targetParam이 None인 축)
+ if target and entry.targetParam is None and isinstance(result, pl.DataFrame):
+ for col in ("종목코드", "stockCode", "stock_code"):
+ if col in result.columns:
+ result = result.filter(pl.col(col) == target)
+ break
+
+ # 종목 필터 후 빈 결과면 사유 안내
+ if target and isinstance(result, pl.DataFrame) and result.height == 0 and entry.targetParam is None:
+ _MISSING_HINTS = {
+ "liquidity": "금융업(은행/보험/증권)은 유동자산/유동부채 계정이 없어 유동성 분석 불가",
+ "debt": "해당 종목에 사채/부채 데이터 없음",
+ "audit": "해당 종목에 감사의견 데이터 없음",
+ }
+ hint = _MISSING_HINTS.get(resolved, f"'{target}'에 해당 데이터 없음")
+ return pl.DataFrame({"info": [hint]})
+
+ # 최종 사용자 반환: 한글 컬럼 + 종목명
+ if isinstance(result, pl.DataFrame) and "stockCode" in result.columns:
+ result = _enrichWithKorean(result)
+
+ return result
+
+ def _guide(self) -> pl.DataFrame:
+ """축 목록 + 사용법 가이드."""
+ rows = [
+ {
+ "axis": key,
+ "label": entry.label,
+ "description": entry.description,
+ "example": entry.example,
+ }
+ for key, entry in _AXIS_REGISTRY.items()
+ ]
+ return pl.DataFrame(rows)
+
+ def _financialGuide(self) -> pl.DataFrame:
+ """financial 그룹 8축 가이드."""
+ rows = []
+ for axisKey in _SCAN_GROUPS["financial"]:
+ entry = _AXIS_REGISTRY[axisKey]
+ rows.append(
+ {
+ "axis": axisKey,
+ "label": entry.label,
+ "description": entry.description,
+ "example": f'scan("financial", "{axisKey}")',
+ }
+ )
+ return pl.DataFrame(rows)
+
+ def _listForAxis(self, axis: str, entry: _AxisEntry) -> pl.DataFrame | list:
+ """target 필수 축의 가용 목록 반환."""
+ if entry.listModule and entry.listFn:
+ mod = importlib.import_module(entry.listModule)
+ fn = getattr(mod, entry.listFn)
+ result = fn()
+ if isinstance(result, list) and result and isinstance(result[0], dict):
+ return pl.DataFrame(result)
+ return result
+ return pl.DataFrame({"info": [f"scan('{axis}', '') 형태로 사용하세요."]})
+
+ def __getattr__(self, name):
+ """accessor 패턴: scan.governance(), scan.financial.profitability() 등."""
+ # 그룹 이름 확인 (financial 등)
+ group = _resolveGroup(name)
+ if group is not None:
+ return _ScanGroupAccessor(self, group)
+
+ # 직접 축 이름 확인 (governance, workforce 등)
+ try:
+ resolved = _resolveAxis(name)
+ except ValueError:
+ raise AttributeError(f"Scan에 '{name}' 속성이 없습니다")
+
+ def _bound_axis(target=None, **kwargs):
+ return self(resolved, target, **kwargs)
+
+ _bound_axis.__name__ = name
+ _bound_axis.__doc__ = f'scan("{resolved}")'
+ return _bound_axis
+
+ def __repr__(self) -> str:
+ lines = [f"Scan -- {len(_AXIS_REGISTRY)}축 시장 횡단분석"]
+ for key, entry in _AXIS_REGISTRY.items():
+ lines.append(f" {key:12s} {entry.label} -- {entry.description}")
+ lines.append("")
+ lines.append("사용법: scan(), scan('축'), scan('축', '대상')")
+ return "\n".join(lines)
+
+
+class _ScanGroupAccessor:
+ """scan.financial 등 그룹 accessor."""
+
+ def __init__(self, scan_instance: Scan, group: str):
+ self._scan = scan_instance
+ self._group = group
+
+ def __call__(self, target=None, **kwargs):
+ """그룹 가이드 또는 그룹 내 축 실행."""
+ return self._scan(self._group, target, **kwargs)
+
+ def __getattr__(self, name):
+ """scan.financial.profitability() 패턴."""
+ try:
+ resolved = _resolveAxis(name)
+ except ValueError:
+ raise AttributeError(f"'{self._group}' 그룹에 '{name}' 축이 없습니다")
+
+ members = _SCAN_GROUPS.get(self._group, [])
+ if resolved not in members:
+ raise AttributeError(f"'{name}' 축은 '{self._group}' 그룹에 속하지 않습니다")
+
+ def _bound_axis(target=None, **kwargs):
+ return self._scan(resolved, target, **kwargs)
+
+ _bound_axis.__name__ = name
+ _bound_axis.__doc__ = f'scan("{resolved}")'
+ return _bound_axis
+
+ def __repr__(self) -> str:
+ members = _SCAN_GROUPS.get(self._group, [])
+ lines = [f"Scan.{self._group} -- {len(members)}축"]
+ for key in members:
+ entry = _AXIS_REGISTRY.get(key)
+ if entry:
+ lines.append(f" {key:12s} {entry.label} -- {entry.description}")
+ return "\n".join(lines)
+
+
+# 모듈 레벨 인스턴스는 만들지 않는다.
+# dartlab.__init__.py에서 lazy로 생성한다.
diff --git a/src/dartlab/scan/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..fd20a3a113af6d72b3cb97e109db2f78a7522dd4
Binary files /dev/null and b/src/dartlab/scan/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/__pycache__/__init__.cpython-313.pyc b/src/dartlab/scan/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..94eba468bf1e332b94514f28768769028f943bf1
Binary files /dev/null and b/src/dartlab/scan/__pycache__/__init__.cpython-313.pyc differ
diff --git a/src/dartlab/scan/__pycache__/_helpers.cpython-312.pyc b/src/dartlab/scan/__pycache__/_helpers.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..b0a405a8dec9af68cd50955fa408f6570dc859e3
Binary files /dev/null and b/src/dartlab/scan/__pycache__/_helpers.cpython-312.pyc differ
diff --git a/src/dartlab/scan/__pycache__/builder.cpython-312.pyc b/src/dartlab/scan/__pycache__/builder.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..aa64219b8259e1379194a013988843ba7bca992b
Binary files /dev/null and b/src/dartlab/scan/__pycache__/builder.cpython-312.pyc differ
diff --git a/src/dartlab/scan/__pycache__/builder.cpython-313.pyc b/src/dartlab/scan/__pycache__/builder.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..e1fa664aa4fc6940b42eabdf0ab1daa442586297
Binary files /dev/null and b/src/dartlab/scan/__pycache__/builder.cpython-313.pyc differ
diff --git a/src/dartlab/scan/__pycache__/extended.cpython-312.pyc b/src/dartlab/scan/__pycache__/extended.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..d25f4d586bbaf3a761f53c67e3e3846e6019f6a7
Binary files /dev/null and b/src/dartlab/scan/__pycache__/extended.cpython-312.pyc differ
diff --git a/src/dartlab/scan/__pycache__/payload.cpython-312.pyc b/src/dartlab/scan/__pycache__/payload.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..80d5632fc33a6e98024eea068495405adb0219c6
Binary files /dev/null and b/src/dartlab/scan/__pycache__/payload.cpython-312.pyc differ
diff --git a/src/dartlab/scan/__pycache__/payload.cpython-313.pyc b/src/dartlab/scan/__pycache__/payload.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..0c2e2c015cc75882f8b9f0773b73b3d94daf86d4
Binary files /dev/null and b/src/dartlab/scan/__pycache__/payload.cpython-313.pyc differ
diff --git a/src/dartlab/scan/__pycache__/rank.cpython-312.pyc b/src/dartlab/scan/__pycache__/rank.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..394bd732268129f9bf2461d993163cd061672f08
Binary files /dev/null and b/src/dartlab/scan/__pycache__/rank.cpython-312.pyc differ
diff --git a/src/dartlab/scan/__pycache__/snapshot.cpython-312.pyc b/src/dartlab/scan/__pycache__/snapshot.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..73a25b9755fd5b528f53523f6fadb42afdf15bc5
Binary files /dev/null and b/src/dartlab/scan/__pycache__/snapshot.cpython-312.pyc differ
diff --git a/src/dartlab/scan/__pycache__/snapshot.cpython-313.pyc b/src/dartlab/scan/__pycache__/snapshot.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..10a10a0da08645baa4d6e867930a1d9fee1e3e13
Binary files /dev/null and b/src/dartlab/scan/__pycache__/snapshot.cpython-313.pyc differ
diff --git a/src/dartlab/scan/_edgar_helpers.py b/src/dartlab/scan/_edgar_helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..a218551c33fe2a1ff8fe1c97a91e53d4606a12ba
--- /dev/null
+++ b/src/dartlab/scan/_edgar_helpers.py
@@ -0,0 +1,109 @@
+"""EDGAR scan 공용 헬퍼 — scanAccount 기반 전종목 재무 지표 계산.
+
+DART scan은 프리빌드 parquet에서 읽지만, EDGAR scan은
+providers/edgar/finance/scanAccount.py를 활용하여 전종목 XBRL 데이터를 읽는다.
+"""
+
+from __future__ import annotations
+
+import polars as pl
+
+
+def scan_edgar_accounts(snake_ids: list[str], *, annual: bool = True) -> pl.DataFrame:
+ """여러 EDGAR 계정을 한번에 스캔하여 wide DataFrame으로 반환.
+
+ Returns:
+ stockCode | corpName | {snakeId}_latest | {snakeId}_prev | ...
+ """
+ from dartlab.providers.edgar.finance.scanAccount import scanAccount
+
+ base: pl.DataFrame | None = None
+ for sid in snake_ids:
+ df = scanAccount(sid, annual=annual)
+ if df.is_empty():
+ continue
+ # 가장 데이터가 많은 기간을 선택 (non-null 최다)
+ period_cols = [c for c in df.columns if c not in ("stockCode", "corpName")]
+ if len(period_cols) < 1:
+ continue
+ best_col = max(period_cols, key=lambda c: df[c].drop_nulls().len())
+ prev_idx = period_cols.index(best_col) + 1
+ prev_col = period_cols[prev_idx] if prev_idx < len(period_cols) else None
+ select_cols = ["stockCode", "corpName", pl.col(best_col).alias(f"{sid}")]
+ if prev_col:
+ select_cols.append(pl.col(prev_col).alias(f"{sid}_prev"))
+ narrow = df.select(select_cols)
+ if base is None:
+ base = narrow
+ else:
+ base = base.join(narrow, on="stockCode", how="outer", suffix=f"_{sid}")
+ if f"corpName_{sid}" in base.columns:
+ base = base.with_columns(
+ pl.coalesce(pl.col("corpName"), pl.col(f"corpName_{sid}")).alias("corpName")
+ ).drop(f"corpName_{sid}")
+ return base if base is not None else pl.DataFrame({"stockCode": []})
+
+
+def safe_div(num: pl.Expr, den: pl.Expr) -> pl.Expr:
+ """안전한 나눗셈 — 0이면 None."""
+ return pl.when(den != 0).then(num / den).otherwise(None)
+
+
+def pct(num: pl.Expr, den: pl.Expr) -> pl.Expr:
+ """백분율 계산."""
+ return (safe_div(num, den) * 100).round(2)
+
+
+def scan_edgar_raw_tags(tags: list[str], *, annual: bool = True) -> pl.DataFrame:
+ """XBRL 태그명으로 직접 ��종목 스캔 (snakeId 매핑 없이).
+
+ Returns: stockCode | corpName | {tag1} | {tag2} | ...
+ """
+ from dartlab.providers.edgar.report import edgarFinancePath
+
+ edgarDir = edgarFinancePath("_").parent
+ if not edgarDir.exists():
+ return pl.DataFrame()
+
+ records = []
+ for fp in edgarDir.glob("*.parquet"):
+ cik = fp.stem
+ try:
+ df = (
+ pl.scan_parquet(fp)
+ .filter(pl.col("tag").is_in(tags) & pl.col("form").is_in(["10-K", "20-F"]))
+ .select("tag", "val", "fy", "entityName")
+ .collect()
+ )
+
+ if df.is_empty():
+ continue
+
+ # 최신 연도
+ latestFy = df["fy"].max()
+ latest = df.filter(pl.col("fy") == latestFy)
+
+ record = {
+ "stockCode": cik,
+ "corpName": latest["entityName"][0] if latest.height > 0 else "",
+ }
+ for tag in tags:
+ tagRows = latest.filter(pl.col("tag") == tag)
+ record[tag] = tagRows["val"][0] if tagRows.height > 0 else None
+
+ records.append(record)
+ except (pl.exceptions.ComputeError, OSError):
+ continue
+
+ return pl.DataFrame(records) if records else pl.DataFrame()
+
+
+def grade_by_value(val: pl.Expr, thresholds: list[tuple[float, str]], default: str = "해당없음") -> pl.Expr:
+ """값 기반 등급 분류. thresholds는 (상한, 등급) 리스트 (오름차순)."""
+ expr = val
+ for i, (threshold, label) in enumerate(thresholds):
+ if i == 0:
+ expr = pl.when(val >= threshold).then(pl.lit(label))
+ else:
+ expr = expr.when(val >= threshold).then(pl.lit(label))
+ return expr.otherwise(pl.lit(default))
diff --git a/src/dartlab/scan/_edgar_scan.py b/src/dartlab/scan/_edgar_scan.py
new file mode 100644
index 0000000000000000000000000000000000000000..adada47d5dc679d69a929cfff8476e59dfdb137b
--- /dev/null
+++ b/src/dartlab/scan/_edgar_scan.py
@@ -0,0 +1,483 @@
+"""EDGAR XBRL 기반 전종목 scan — 7축 (profitability~dividendTrend).
+
+DART scan이 프리빌드 parquet에서 읽듯이,
+EDGAR scan은 개별 CIK parquet에서 전종목 계정을 스캔하여
+비율/등급을 계산한다.
+
+사용법::
+
+ from dartlab.scan._edgar_scan import edgarScan
+ df = edgarScan("profitability") # → stockCode | opMargin | netMargin | roe | roa | grade
+"""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan._edgar_helpers import pct, safe_div, scan_edgar_accounts
+
+
+def edgarScan(axis: str, **kwargs) -> pl.DataFrame:
+ """EDGAR 전종목 scan 디스패치."""
+ fn = _DISPATCH.get(axis)
+ if fn is None:
+ return pl.DataFrame({"info": [f"EDGAR scan '{axis}' 미구현"]})
+ return fn(**kwargs)
+
+
+# ── profitability ──
+
+
+def _scanProfitability(**_kw) -> pl.DataFrame:
+ """수익성 — 영업이익률/순이익률/ROE/ROA + 등급."""
+ df = scan_edgar_accounts(
+ ["sales", "operating_profit", "net_profit", "total_assets", "total_stockholders_equity"],
+ )
+ if df.is_empty():
+ return df
+ result = df.with_columns(
+ pct(pl.col("operating_profit"), pl.col("sales")).alias("opMargin"),
+ pct(pl.col("net_profit"), pl.col("sales")).alias("netMargin"),
+ pct(pl.col("net_profit"), pl.col("total_stockholders_equity")).alias("roe"),
+ pct(pl.col("net_profit"), pl.col("total_assets")).alias("roa"),
+ )
+ result = result.with_columns(
+ pl.when(pl.max_horizontal("opMargin", "roe") >= 20)
+ .then(pl.lit("우수"))
+ .when(pl.max_horizontal("opMargin", "roe") >= 10)
+ .then(pl.lit("양호"))
+ .when(pl.max_horizontal("opMargin", "roe") >= 5)
+ .then(pl.lit("보통"))
+ .when(pl.max_horizontal("opMargin", "roe") >= 0)
+ .then(pl.lit("저수익"))
+ .otherwise(pl.lit("적자"))
+ .alias("grade"),
+ )
+ return result.select(
+ "stockCode",
+ "corpName",
+ "opMargin",
+ "netMargin",
+ "roe",
+ "roa",
+ "grade",
+ ).sort("roe", descending=True, nulls_last=True)
+
+
+# ── growth ──
+
+
+def _scanGrowth(**_kw) -> pl.DataFrame:
+ """성장성 — 매출/영업이익 YoY + 패턴 분류."""
+ df = scan_edgar_accounts(["sales", "operating_profit", "net_profit"])
+ if df.is_empty():
+ return df
+ result = df.with_columns(
+ pct(pl.col("sales") - pl.col("sales_prev"), pl.col("sales_prev")).alias("revenueYoy"),
+ pct(pl.col("operating_profit") - pl.col("operating_profit_prev"), pl.col("operating_profit_prev")).alias(
+ "opYoy"
+ ),
+ pct(pl.col("net_profit") - pl.col("net_profit_prev"), pl.col("net_profit_prev")).alias("niYoy"),
+ )
+ result = result.with_columns(
+ pl.when(pl.col("revenueYoy") >= 20)
+ .then(pl.lit("고성장"))
+ .when(pl.col("revenueYoy") >= 5)
+ .then(pl.lit("성장"))
+ .when(pl.col("revenueYoy") >= -5)
+ .then(pl.lit("정체"))
+ .when(pl.col("revenueYoy") >= -20)
+ .then(pl.lit("역성장"))
+ .otherwise(pl.lit("급감"))
+ .alias("pattern"),
+ )
+ return result.select(
+ "stockCode",
+ "corpName",
+ "revenueYoy",
+ "opYoy",
+ "niYoy",
+ "pattern",
+ ).sort("revenueYoy", descending=True, nulls_last=True)
+
+
+# ── quality ──
+
+
+def _scanQuality(**_kw) -> pl.DataFrame:
+ """이익의 질 — Accrual Ratio + CF/NI."""
+ df = scan_edgar_accounts(["net_profit", "operating_cashflow", "total_assets"])
+ if df.is_empty():
+ return df
+ result = df.with_columns(
+ safe_div(pl.col("operating_cashflow"), pl.col("net_profit")).round(2).alias("cfToNi"),
+ safe_div(
+ pl.col("net_profit") - pl.col("operating_cashflow"),
+ pl.col("total_assets"),
+ )
+ .round(4)
+ .alias("accrualRatio"),
+ )
+ # cfToNi 극단값 제거 (±20 초과 → None)
+ result = result.with_columns(
+ pl.when(pl.col("cfToNi").abs() > 20).then(None).otherwise(pl.col("cfToNi")).alias("cfToNi"),
+ )
+ result = result.with_columns(
+ pl.when((pl.col("cfToNi") >= 0.8) & (pl.col("accrualRatio").abs() < 0.05))
+ .then(pl.lit("우수"))
+ .when((pl.col("cfToNi") >= 0.5) & (pl.col("accrualRatio").abs() < 0.10))
+ .then(pl.lit("양호"))
+ .when(pl.col("cfToNi") >= 0)
+ .then(pl.lit("보통"))
+ .when(pl.col("cfToNi") < 0)
+ .then(pl.lit("주의"))
+ .otherwise(pl.lit("위험"))
+ .alias("grade"),
+ )
+ return result.select(
+ "stockCode",
+ "corpName",
+ "cfToNi",
+ "accrualRatio",
+ "grade",
+ ).sort("cfToNi", descending=True, nulls_last=True)
+
+
+# ── liquidity ──
+
+
+def _scanLiquidity(**_kw) -> pl.DataFrame:
+ """유동성 — 유동비율 + 당좌비율."""
+ df = scan_edgar_accounts(["current_assets", "current_liabilities", "inventories"])
+ if df.is_empty():
+ return df
+ result = df.with_columns(
+ pct(pl.col("current_assets"), pl.col("current_liabilities")).alias("currentRatio"),
+ pct(
+ pl.col("current_assets") - pl.col("inventories").fill_null(0),
+ pl.col("current_liabilities"),
+ ).alias("quickRatio"),
+ )
+ result = result.with_columns(
+ pl.when(pl.col("currentRatio") >= 200)
+ .then(pl.lit("우수"))
+ .when(pl.col("currentRatio") >= 150)
+ .then(pl.lit("양호"))
+ .when(pl.col("currentRatio") >= 100)
+ .then(pl.lit("보통"))
+ .when(pl.col("currentRatio") >= 50)
+ .then(pl.lit("주의"))
+ .otherwise(pl.lit("위험"))
+ .alias("grade"),
+ )
+ return result.select(
+ "stockCode",
+ "corpName",
+ "currentRatio",
+ "quickRatio",
+ "grade",
+ ).sort("currentRatio", descending=True, nulls_last=True)
+
+
+# ── efficiency ──
+
+
+def _scanEfficiency(**_kw) -> pl.DataFrame:
+ """효율성 — 자산회전율 + CCC."""
+ df = scan_edgar_accounts(
+ [
+ "sales",
+ "total_assets",
+ "inventories",
+ "trade_and_other_receivables",
+ "trade_and_other_payables",
+ ]
+ )
+ if df.is_empty():
+ return df
+ result = df.with_columns(
+ safe_div(pl.col("sales"), pl.col("total_assets")).round(2).alias("assetTurnover"),
+ # CCC = 재고일수 + 매출채권일수 - 매입채무일수
+ (
+ safe_div(pl.col("inventories").fill_null(0) * 365, pl.col("sales"))
+ + safe_div(pl.col("trade_and_other_receivables").fill_null(0) * 365, pl.col("sales"))
+ - safe_div(pl.col("trade_and_other_payables").fill_null(0) * 365, pl.col("sales"))
+ )
+ .round(0)
+ .alias("ccc"),
+ )
+ result = result.with_columns(
+ pl.when(pl.col("assetTurnover") >= 1.5)
+ .then(pl.lit("우수"))
+ .when(pl.col("assetTurnover") >= 1.0)
+ .then(pl.lit("양호"))
+ .when(pl.col("assetTurnover") >= 0.5)
+ .then(pl.lit("보통"))
+ .otherwise(pl.lit("비효율"))
+ .alias("grade"),
+ )
+ return result.select(
+ "stockCode",
+ "corpName",
+ "assetTurnover",
+ "ccc",
+ "grade",
+ ).sort("assetTurnover", descending=True, nulls_last=True)
+
+
+# ── cashflow ──
+
+
+def _scanCashflow(**_kw) -> pl.DataFrame:
+ """현금흐름 — OCF/ICF/FCF + 패턴 분류."""
+ df = scan_edgar_accounts(
+ [
+ "operating_cashflow",
+ "investing_cashflow",
+ "financing_cash_flow",
+ "capex",
+ "sales",
+ ]
+ )
+ if df.is_empty():
+ return df
+ result = df.with_columns(
+ (pl.col("operating_cashflow") + pl.col("capex").fill_null(0)).alias("fcf"),
+ )
+ result = result.with_columns(
+ pct(pl.col("operating_cashflow"), pl.col("sales")).alias("ocfMargin"),
+ )
+ # 현금흐름 패턴 분류 (OCF+/-, ICF+/-, FCF+/-)
+ result = result.with_columns(
+ pl.when(
+ (pl.col("operating_cashflow") > 0)
+ & (pl.col("investing_cashflow") < 0)
+ & (pl.col("financing_cash_flow") < 0)
+ )
+ .then(pl.lit("성장투자형"))
+ .when(
+ (pl.col("operating_cashflow") > 0)
+ & (pl.col("investing_cashflow") < 0)
+ & (pl.col("financing_cash_flow") > 0)
+ )
+ .then(pl.lit("공격성장형"))
+ .when((pl.col("operating_cashflow") < 0) & (pl.col("financing_cash_flow") > 0))
+ .then(pl.lit("외부의존형"))
+ .when(
+ (pl.col("operating_cashflow") > 0)
+ & (pl.col("investing_cashflow") > 0)
+ & (pl.col("financing_cash_flow") < 0)
+ )
+ .then(pl.lit("축소정리형"))
+ .when((pl.col("operating_cashflow") < 0) & (pl.col("investing_cashflow") > 0))
+ .then(pl.lit("현금위기형"))
+ .otherwise(pl.lit("기타"))
+ .alias("pattern"),
+ )
+ return result.select(
+ "stockCode",
+ "corpName",
+ "operating_cashflow",
+ "investing_cashflow",
+ "fcf",
+ "ocfMargin",
+ "pattern",
+ ).sort("fcf", descending=True, nulls_last=True)
+
+
+# ── dividendTrend ──
+
+
+def _scanDividendTrend(**_kw) -> pl.DataFrame:
+ """배당추이 — DPS 시계열 + 패턴."""
+ df = scan_edgar_accounts(["dividends_paid", "net_profit"])
+ if df.is_empty():
+ return df
+ result = df.with_columns(
+ pl.col("dividends_paid").abs().alias("dividendAmount"),
+ pct(pl.col("dividends_paid").abs(), pl.col("net_profit")).alias("payoutRatio"),
+ )
+ # payoutRatio 극단값 제거
+ result = result.with_columns(
+ pl.when(pl.col("payoutRatio").abs() > 200).then(None).otherwise(pl.col("payoutRatio")).alias("payoutRatio"),
+ )
+ result = result.with_columns(
+ pl.when(pl.col("dividendAmount").is_null() | (pl.col("dividendAmount") == 0))
+ .then(pl.lit("무배당"))
+ .when(pl.col("payoutRatio").is_not_null() & (pl.col("payoutRatio") >= 30))
+ .then(pl.lit("양호"))
+ .when(pl.col("payoutRatio").is_not_null() & (pl.col("payoutRatio") >= 10))
+ .then(pl.lit("보통"))
+ .otherwise(pl.lit("주의"))
+ .alias("grade"),
+ )
+ return result.select(
+ "stockCode",
+ "corpName",
+ "dividendAmount",
+ "payoutRatio",
+ "grade",
+ ).sort("dividendAmount", descending=True, nulls_last=True)
+
+
+# ── Dispatch Table ──
+
+# ── capital ──
+
+
+def _scanCapital(**_kw) -> pl.DataFrame:
+ """주주환원 — 배당 + 자사주."""
+ df = scan_edgar_accounts(["dividends_paid", "net_profit", "treasury_stock"])
+ if df.is_empty():
+ return df
+ result = df.with_columns(
+ pl.col("dividends_paid").abs().alias("dividendAmount"),
+ pct(pl.col("dividends_paid").abs(), pl.col("net_profit")).alias("payoutRatio"),
+ )
+ result = result.with_columns(
+ pl.when(pl.col("payoutRatio").abs() > 200).then(None).otherwise(pl.col("payoutRatio")).alias("payoutRatio"),
+ )
+ result = result.with_columns(
+ pl.when(
+ (pl.col("dividendAmount") > 0)
+ & (pl.col("treasury_stock").is_not_null())
+ & (pl.col("treasury_stock").abs() > 0)
+ )
+ .then(pl.lit("적극환원"))
+ .when(pl.col("dividendAmount") > 0)
+ .then(pl.lit("환원형"))
+ .when(pl.col("treasury_stock").is_not_null() & (pl.col("treasury_stock").abs() > 0))
+ .then(pl.lit("자사주"))
+ .otherwise(pl.lit("중립"))
+ .alias("classification"),
+ )
+ return result.select(
+ "stockCode",
+ "corpName",
+ "dividendAmount",
+ "payoutRatio",
+ "treasury_stock",
+ "classification",
+ ).sort("dividendAmount", descending=True, nulls_last=True)
+
+
+# ── debt ──
+
+
+def _scanDebt(**_kw) -> pl.DataFrame:
+ """부채구조 — 부채비율 + 차입금 구조."""
+ df = scan_edgar_accounts(
+ [
+ "total_liabilities",
+ "total_stockholders_equity",
+ "shortterm_borrowings",
+ "longterm_borrowings",
+ "operating_profit",
+ "interest_expense",
+ ]
+ )
+ if df.is_empty():
+ return df
+ result = df.with_columns(
+ pct(pl.col("total_liabilities"), pl.col("total_stockholders_equity")).alias("debtRatio"),
+ safe_div(pl.col("operating_profit"), pl.col("interest_expense").abs()).round(2).alias("icr"),
+ pct(
+ pl.col("shortterm_borrowings").fill_null(0),
+ pl.col("shortterm_borrowings").fill_null(0) + pl.col("longterm_borrowings").fill_null(0),
+ ).alias("shortTermRatio"),
+ )
+ result = result.with_columns(
+ pl.when(pl.col("debtRatio") < 100)
+ .then(pl.lit("안전"))
+ .when(pl.col("debtRatio") < 200)
+ .then(pl.lit("주의"))
+ .when(pl.col("debtRatio") < 400)
+ .then(pl.lit("관찰"))
+ .otherwise(pl.lit("고위험"))
+ .alias("riskLevel"),
+ )
+ return result.select(
+ "stockCode",
+ "corpName",
+ "debtRatio",
+ "icr",
+ "shortTermRatio",
+ "riskLevel",
+ ).sort("debtRatio", nulls_last=True)
+
+
+# ── valuation ──
+
+
+def _scanValuation(**_kw) -> pl.DataFrame:
+ """밸류에이션 — PER/PBR/EV/EBITDA. Yahoo 주가 데이터 필요."""
+ df = scan_edgar_accounts(
+ [
+ "net_profit",
+ "total_stockholders_equity",
+ "total_assets",
+ "total_liabilities",
+ "cash_and_cash_equivalents",
+ "operating_profit",
+ "depreciation_amortization",
+ ],
+ )
+ if df.is_empty():
+ return df
+
+ # shares outstanding 추가
+ result = df.with_columns(
+ (pl.col("operating_profit").fill_null(0) + pl.col("depreciation_amortization").fill_null(0)).alias("ebitda"),
+ )
+
+ # PER/PBR은 시가총액 필요 — 현재는 기본 재무비율만 제공
+ result = result.with_columns(
+ safe_div(pl.col("total_assets"), pl.col("total_stockholders_equity")).round(2).alias("equityMultiplier"),
+ pct(pl.col("net_profit"), pl.col("total_stockholders_equity")).alias("roe"),
+ )
+
+ return result.select(
+ "stockCode",
+ "corpName",
+ "ebitda",
+ "equityMultiplier",
+ "roe",
+ ).sort("ebitda", descending=True, nulls_last=True)
+
+
+# ── audit ──
+
+
+def _scanAudit(**_kw) -> pl.DataFrame:
+ """감사 — XBRL AuditFees/NonAuditFees."""
+ df = scan_edgar_accounts(
+ ["sales"], # 기본 계정으로 종목 목록 획득
+ )
+ if df.is_empty():
+ return df
+
+ # XBRL 감사비용 태그 직접 스캔
+ from dartlab.scan._edgar_helpers import scan_edgar_raw_tags
+
+ auditDf = scan_edgar_raw_tags(
+ ["AuditFees", "NonAuditServicesFees", "AllOtherFees", "TaxFees"],
+ )
+ if auditDf.is_empty():
+ return auditDf
+
+ return auditDf.sort("AuditFees", descending=True, nulls_last=True)
+
+
+_DISPATCH = {
+ "profitability": _scanProfitability,
+ "growth": _scanGrowth,
+ "quality": _scanQuality,
+ "liquidity": _scanLiquidity,
+ "efficiency": _scanEfficiency,
+ "cashflow": _scanCashflow,
+ "dividendTrend": _scanDividendTrend,
+ "capital": _scanCapital,
+ "debt": _scanDebt,
+ "valuation": _scanValuation,
+ "audit": _scanAudit,
+}
diff --git a/src/dartlab/scan/_helpers.py b/src/dartlab/scan/_helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa7c24526164e4c277e0a3c791e8bf96517ad1e6
--- /dev/null
+++ b/src/dartlab/scan/_helpers.py
@@ -0,0 +1,303 @@
+"""scan 공용 유틸리티 — report parquet 스캔, 숫자 파싱, listing 로드."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+_scanDownloaded = False
+
+
+def _ensureScanData() -> Path:
+ """scan 프리빌드 디렉토리 확인. 없으면 HF에서 자동 다운로드.
+
+ [주의] '하나라도 있으면 skip' 패턴 금지.
+ 누락된 apiType이 있으면 종목별 순회 fallback이 발동(4초+/file).
+ snapshot_download는 이미 있는 파일을 자동 skip하므로 비용 0.
+
+ 안내: guide.emit으로 사용자에게 다운로드 시작/완료를 알린다. 271MB 침묵 방지.
+ """
+ from dartlab.core.dataLoader import _dataDir
+ from dartlab.guide.messaging import emit
+
+ scanDir = Path(_dataDir("scan"))
+
+ global _scanDownloaded
+ if _scanDownloaded:
+ return scanDir
+
+ # 로컬에 이미 파일이 있는지 사전 확인 — 신규 사용자에게만 "다운로드 시작" 안내
+ hadLocal = scanDir.exists() and any(scanDir.rglob("*.parquet"))
+ if not hadLocal:
+ emit("scan:prebuild_missing")
+
+ try:
+ from dartlab.core.dataLoader import downloadAll
+
+ # 항상 시도 — snapshot_download가 누락 파일만 받음 (이미 있는 건 skip)
+ downloadAll("scan")
+ _scanDownloaded = True
+ if not hadLocal:
+ fileCount = sum(1 for _ in scanDir.rglob("*.parquet"))
+ emit("scan:prebuild_ready", fileCount=fileCount)
+ except (ImportError, RuntimeError, ValueError) as e:
+ # 다운로드 실패해도 기존 파일은 사용 가능
+ if hadLocal:
+ _scanDownloaded = True
+ else:
+ emit("scan:prebuild_failed", error=str(e))
+
+ return scanDir
+
+
+def scan_parquets(api_type: str, keep_cols: list[str]) -> pl.DataFrame:
+ """report parquet에서 특정 apiType만 LazyFrame 스캔.
+
+ scan/report/{apiType}.parquet 프리빌드가 있으면 단일 파일에서 즉시 로드.
+ 없으면 종목별 parquet 순회 (fallback).
+ """
+ # 1순위: 프리빌드 scan parquet (없으면 자동 다운로드 시도)
+ scanDir = _ensureScanData()
+ scan_path = scanDir / "report" / f"{api_type}.parquet"
+ if scan_path.exists():
+ try:
+ lf = pl.scan_parquet(str(scan_path))
+ schema_names = lf.collect_schema().names()
+ available = [c for c in keep_cols if c in schema_names]
+ non_meta = [c for c in available if c not in ("stockCode", "year", "quarter")]
+ if non_meta:
+ return lf.select(available).collect()
+ except (pl.exceptions.PolarsError, OSError):
+ pass # fallback to per-file scan
+
+ # 2순위: 종목별 순회 (fallback)
+ from dartlab.core.dataLoader import _dataDir
+
+ report_dir = Path(_dataDir("report"))
+ parquet_files = sorted(report_dir.glob("*.parquet"))
+
+ if not parquet_files:
+ from dartlab.core.guidance import emit
+
+ emit("hint:market_data_needed", category="report", fn=api_type)
+ return pl.DataFrame()
+
+ frames: list[pl.LazyFrame] = []
+ for pf in parquet_files:
+ try:
+ lf = pl.scan_parquet(str(pf))
+ schema_names = lf.collect_schema().names()
+ if "apiType" not in schema_names:
+ continue
+ available = [c for c in keep_cols if c in schema_names]
+ non_meta = [c for c in available if c not in ("stockCode", "year", "quarter")]
+ if not non_meta:
+ continue
+ lf = lf.filter(pl.col("apiType") == api_type).select(available)
+ frames.append(lf)
+ except (pl.exceptions.ComputeError, OSError):
+ continue
+
+ if not frames:
+ return pl.DataFrame()
+
+ all_cols: set[str] = set()
+ for lf in frames:
+ all_cols.update(lf.collect_schema().names())
+ unified: list[pl.LazyFrame] = []
+ for lf in frames:
+ missing = all_cols - set(lf.collect_schema().names())
+ if missing:
+ lf = lf.with_columns([pl.lit(None).alias(c) for c in missing])
+ unified.append(lf.select(sorted(all_cols)))
+
+ return pl.concat(unified).collect()
+
+
+def parse_num(s) -> float | None:
+ """문자열/숫자 → float. '-', '', None → None."""
+ if s is None:
+ return None
+ if isinstance(s, (int, float)):
+ return float(s)
+ s = str(s).strip().replace(",", "")
+ if s in ("", "-"):
+ return None
+ try:
+ return float(s)
+ except ValueError:
+ return None
+
+
+def extractAccount(sub: pl.DataFrame, ids: set, nms: set, amtCol: str = "thstrm_amount") -> float | None:
+ """DataFrame에서 account_id/account_nm 매칭 → 금액 추출."""
+ for row in sub.iter_rows(named=True):
+ aid = row.get("account_id", "")
+ anm = row.get("account_nm", "")
+ if aid in ids or anm in nms:
+ val = parse_num(row.get(amtCol))
+ if val is not None:
+ return val
+ return None
+
+
+def find_latest_year(raw: pl.DataFrame, check_col: str, min_count: int = 500) -> str | None:
+ """check_col에 유효 데이터가 min_count 이상인 가장 최근 연도 반환."""
+ years_desc = sorted(raw["year"].unique().to_list(), reverse=True)
+ for y in years_desc:
+ sub = raw.filter(pl.col("year") == y)
+ ok = sub.filter(pl.col(check_col).is_not_null() & (pl.col(check_col) != "-") & (pl.col(check_col) != "")).shape[
+ 0
+ ]
+ if ok >= min_count:
+ return y
+ return None
+
+
+QUARTER_ORDER = {"2분기": 1, "4분기": 2, "3분기": 3, "1분기": 4}
+
+
+def pick_best_quarter(df: pl.DataFrame) -> pl.DataFrame:
+ """가장 선호하는 분기만 필터 (Q2 > Q4 > Q3 > Q1)."""
+ quarters = df["quarter"].unique().to_list()
+ best = sorted(quarters, key=lambda q: QUARTER_ORDER.get(q, 99))
+ return df.filter(pl.col("quarter") == best[0]) if best else df
+
+
+def load_listing():
+ """상장사 목록 로드 (network/scanner.py 위임)."""
+ from dartlab.scan.network.scanner import load_listing as _ll
+
+ return _ll()
+
+
+def parse_date_year(s) -> int | None:
+ """'2021.06.15' 또는 '2021-06-15' → 2021."""
+ if s is None:
+ return None
+ s = str(s).strip()
+ if s in ("", "-"):
+ return None
+ for sep in (".", "-"):
+ if sep in s:
+ parts = s.split(sep)
+ if parts:
+ try:
+ y = int(parts[0])
+ if 1990 <= y <= 2030:
+ return y
+ except ValueError:
+ pass
+ return None
+
+
+def _scanFinanceFromMerged(
+ scanPath: Path,
+ sjDivs: list[str],
+ accountIds: set[str],
+ accountNms: set[str],
+ amountCol: str,
+) -> dict[str, float]:
+ """합산 finance parquet에서 종목별 최신 연도 값 추출."""
+ scCol = "stockCode" if "stockCode" in pl.scan_parquet(str(scanPath)).collect_schema().names() else "stock_code"
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ pl.col("sj_div").is_in(sjDivs)
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+
+ if target.is_empty() or "account_id" not in target.columns:
+ return {}
+
+ # 연결 우선
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else target
+
+ # 종목별 최신 연도만
+ latestYear = target.group_by(scCol).agg(pl.col("bsns_year").max().alias("_maxYear"))
+ target = target.join(latestYear, on=scCol).filter(pl.col("bsns_year") == pl.col("_maxYear")).drop("_maxYear")
+
+ # 계정 매칭
+ matched = target.filter(pl.col("account_id").is_in(list(accountIds)) | pl.col("account_nm").is_in(list(accountNms)))
+
+ result: dict[str, float] = {}
+ for row in matched.iter_rows(named=True):
+ code = row.get(scCol, "")
+ if code and code not in result:
+ val = parse_num(row.get(amountCol))
+ if val is not None:
+ result[code] = val
+
+ return result
+
+
+def scan_finance_parquets(
+ statement: str,
+ account_ids: set[str],
+ account_nms: set[str],
+ *,
+ amount_col: str = "thstrm_amount",
+) -> dict[str, float]:
+ """finance parquet 전수 스캔 → {종목코드: 값}.
+
+ scan/finance.parquet 프리빌드가 있으면 단일 파일에서 즉시 필터.
+ 없으면 종목별 parquet 순회 (fallback).
+ """
+ sj_divs = [statement] if statement != "IS" else ["IS", "CIS"]
+
+ # 1순위: 프리빌드 scan parquet (없으면 자동 다운로드 시도)
+ scanDir = _ensureScanData()
+ scan_path = scanDir / "finance.parquet"
+ if scan_path.exists():
+ try:
+ return _scanFinanceFromMerged(scan_path, sj_divs, account_ids, account_nms, amount_col)
+ except (pl.exceptions.PolarsError, OSError):
+ pass # fallback
+
+ # 2순위: 종목별 순회 (fallback)
+ from dartlab.core.dataLoader import _dataDir
+
+ finance_dir = Path(_dataDir("finance"))
+ parquet_files = sorted(finance_dir.glob("*.parquet"))
+
+ result: dict[str, float] = {}
+ for pf in parquet_files:
+ code = pf.stem
+ try:
+ # lazy scan: 필터를 Rust 엔진으로 밀어넣어 메모리 절감
+ target = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ pl.col("sj_div").is_in(sj_divs)
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+
+ if target.is_empty() or "account_id" not in target.columns:
+ continue
+
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else target
+
+ years = sorted(target["bsns_year"].unique().to_list(), reverse=True)
+ if not years:
+ continue
+ latest = target.filter(pl.col("bsns_year") == years[0])
+
+ for row in latest.iter_rows(named=True):
+ aid = row.get("account_id", "")
+ anm = row.get("account_nm", "")
+ val = parse_num(row.get(amount_col))
+ if (aid in account_ids or anm in account_nms) and val is not None:
+ result[code] = val
+ break
+
+ return result
diff --git a/src/dartlab/scan/audit/__init__.py b/src/dartlab/scan/audit/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb5383ddabeb8375862748e0bc598abd865b1c3e
--- /dev/null
+++ b/src/dartlab/scan/audit/__init__.py
@@ -0,0 +1,168 @@
+"""감사 리스크 종합 스코어 — 의견 + 감사인 변경 + 특기사항 + 감사독립성."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan._helpers import scan_parquets
+
+_OPINION_RISK = {
+ "의견거절": 3,
+ "부적정의견": 3,
+ "한정의견": 2,
+ "적정의견": 0,
+ "적정": 0,
+}
+
+
+def _normalizeOpinion(raw: str | None) -> str | None:
+ """감사의견 정규화 — 다양한 표기를 통일."""
+ if not raw:
+ return None
+ s = raw.strip().replace(" ", "").replace("\n", "")
+ if not s or s == "-":
+ return None
+ # "적정" 계열
+ if s in ("적정", "적정의견"):
+ return "적정의견"
+ if "적정" in s and "부적정" not in s and "한정" not in s:
+ return "적정의견"
+ # "한정" 계열
+ if "한정" in s:
+ return "한정의견"
+ # "부적정" 계열
+ if "부적정" in s:
+ return "부적정의견"
+ # "의견거절" 계열
+ if "의견거절" in s or "거절" in s:
+ return "의견거절"
+ # 기타 (해당사항없음, 검토 등)
+ if "해당" in s or "없음" in s or "예외" in s:
+ return None # 감사의견 대상 아님
+ if "검토" in s:
+ return None # 반기검토는 감사의견 아님
+ return raw.strip()
+
+
+def _sortedYears(years: list) -> list[str]:
+ """모든 연도를 정렬: 숫자 연도 우선 (내림차순), 그 다음 한국 회계연도 (문자열 내림차순)."""
+ numeric = []
+ other = []
+ for y in years:
+ s = str(y).strip()
+ if s.isdigit():
+ numeric.append(s)
+ elif s and s != "-":
+ other.append(s)
+ return sorted(numeric, key=lambda y: int(y), reverse=True) + sorted(other, reverse=True)
+
+
+def scanAudit() -> pl.DataFrame:
+ """종목별 감사 리스크 종합 분석.
+
+ 컬럼: stockCode, opinion, auditor, auditorChanged, hasSpecialMatter, riskLevel
+ """
+ raw = scan_parquets(
+ "auditOpinion",
+ ["stockCode", "year", "quarter", "adt_opinion", "adtor", "adt_reprt_spcmnt_matter"],
+ )
+ if raw.is_empty():
+ return pl.DataFrame()
+
+ rows: list[dict] = []
+ for code in raw["stockCode"].unique().to_list():
+ sub = raw.filter(pl.col("stockCode") == code)
+
+ # 종목별 연도 정렬 (숫자 우선, 한국 회계연도 포함)
+ codeYears = _sortedYears(sub["year"].unique().to_list())
+ if not codeYears:
+ continue
+
+ # opinion이 있는 행을 우선 탐색 (최신 연도부터)
+ opinion = None
+ auditor = None
+ specialMatter = None
+ bestYear = None
+ for y in codeYears:
+ ySub = sub.filter(pl.col("year") == y)
+ # Q4 우선
+ q4 = ySub.filter(pl.col("quarter") == "4분기")
+ candidate = q4 if not q4.is_empty() else ySub
+ for r in candidate.iter_rows(named=True):
+ normalized = _normalizeOpinion(r.get("adt_opinion"))
+ if normalized:
+ opinion = normalized
+ auditor = r.get("adtor", "")
+ specialMatter = r.get("adt_reprt_spcmnt_matter", "")
+ bestYear = y
+ break
+ if opinion:
+ break
+
+ # opinion 못 찾으면 최신 연도에서 auditor라도 가져옴
+ if opinion is None:
+ latestSub = sub.filter(pl.col("year") == codeYears[0])
+ q4 = latestSub.filter(pl.col("quarter") == "4분기")
+ best = q4 if not q4.is_empty() else latestSub
+ if not best.is_empty():
+ row = best.row(0, named=True)
+ auditor = row.get("adtor", "")
+ specialMatter = row.get("adt_reprt_spcmnt_matter", "")
+ bestYear = codeYears[0]
+
+ # 감사인 변경 감지: bestYear 직전 연도와 비교
+ auditorChanged = False
+ bestIdx = codeYears.index(bestYear) if bestYear in codeYears else 0
+ if bestIdx + 1 < len(codeYears):
+ prevSub = sub.filter(pl.col("year") == codeYears[bestIdx + 1])
+ if not prevSub.is_empty():
+ prevQ4 = prevSub.filter(pl.col("quarter") == "4분기")
+ prevBest = prevQ4 if not prevQ4.is_empty() else prevSub
+ prevAuditor = prevBest.row(0, named=True).get("adtor", "")
+ if prevAuditor and auditor and str(prevAuditor).strip() != str(auditor).strip():
+ auditorChanged = True
+
+ # 특기사항 유무
+ hasSpecialMatter = bool(
+ specialMatter and str(specialMatter).strip() not in ("", "-", "해당사항없음", "해당없음", "해당사항 없음")
+ )
+
+ # 종합 리스크 레벨
+ opinionRisk = _OPINION_RISK.get(opinion, 1) if opinion else 1
+ riskScore = opinionRisk
+ if auditorChanged:
+ riskScore += 1
+ if hasSpecialMatter:
+ riskScore += 1
+
+ if riskScore >= 3:
+ riskLevel = "고위험"
+ elif riskScore >= 2:
+ riskLevel = "주의"
+ elif riskScore >= 1:
+ riskLevel = "관찰"
+ else:
+ riskLevel = "안전"
+
+ rows.append(
+ {
+ "stockCode": code,
+ "opinion": opinion,
+ "auditor": str(auditor).strip() if auditor else None,
+ "auditorChanged": auditorChanged,
+ "hasSpecialMatter": hasSpecialMatter,
+ "riskLevel": riskLevel,
+ }
+ )
+
+ if not rows:
+ return pl.DataFrame()
+ schema = {
+ "stockCode": pl.Utf8,
+ "opinion": pl.Utf8,
+ "auditor": pl.Utf8,
+ "auditorChanged": pl.Boolean,
+ "hasSpecialMatter": pl.Boolean,
+ "riskLevel": pl.Utf8,
+ }
+ return pl.DataFrame(rows, schema=schema)
diff --git a/src/dartlab/scan/builder.py b/src/dartlab/scan/builder.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d8824f4e32a7b1769ab2ccf0eb555c05d10fd79
--- /dev/null
+++ b/src/dartlab/scan/builder.py
@@ -0,0 +1,568 @@
+"""전종목 scan 프리빌드 빌더.
+
+docs → changes, finance → 합산, report → apiType별 분리.
+실험 014/015에서 검증된 로직을 프로덕션화.
+배치를 중간 파일로 쓰고 마지막에 합산하여 segfault 방지.
+"""
+
+from __future__ import annotations
+
+import shutil
+import time
+from pathlib import Path
+
+import polars as pl
+
+# scanner에서 실제 사용하는 apiType 12개
+SCAN_API_TYPES = [
+ "majorHolder",
+ "executive",
+ "employee",
+ "executivePayAllTotal",
+ "executivePayIndividual",
+ "auditOpinion",
+ "dividend",
+ "treasuryStock",
+ "capitalChange",
+ "corporateBond",
+ "outsideDirector",
+ "minorityHolder",
+]
+
+_BATCH = 200
+
+
+def _fiscalMonthMap() -> dict[str, int]:
+ """종목코드 → 결산월(int) 매핑. 12월 결산은 포함하지 않음 (기본값이므로).
+
+ listing + 데이터 패턴 양쪽에서 비12월 결산을 판별.
+ """
+ result: dict[str, int] = {}
+
+ # 1. listing 기반
+ try:
+ from dartlab.gather.listing import getKindList
+
+ li = getKindList()
+ if li is not None and not li.is_empty():
+ if "결산월" in li.columns and "종목코드" in li.columns:
+ nonDec = li.filter(pl.col("결산월") != "12월")
+ for row in nonDec.select(["종목코드", "결산월"]).iter_rows():
+ code, month_str = row
+ try:
+ result[code] = int(month_str.replace("월", ""))
+ except (ValueError, AttributeError):
+ pass
+ except (ImportError, FileNotFoundError, OSError):
+ pass
+
+ # 2. 데이터 기반 — listing에 없는 종목(상폐 등)은 bsns_year 패턴으로 추론
+ from datetime import date
+
+ today = date.today()
+ calYear = today.year
+ # 12월 결산이면 4월 현재 maxBsnsYear=작년(2025)
+ maxBsnsYear12 = str(calYear - 1) if today.month <= 4 else str(calYear)
+
+ finDir = _financeDir()
+ if finDir.exists():
+ for pf in finDir.glob("*.parquet"):
+ code = pf.stem
+ if code in result:
+ continue # listing에서 이미 파악
+ try:
+ lz = pl.scan_parquet(str(pf))
+ if "bsns_year" not in lz.collect_schema().names():
+ continue
+ maxYear = (
+ lz.select(pl.col("bsns_year").cast(pl.Utf8).max())
+ .collect()
+ .item()
+ )
+ if maxYear is not None and maxYear > maxBsnsYear12:
+ # 정확한 결산월은 모르지만, 비12월 결산 확정
+ # 보수적으로 6월(가장 흔한 비12월)로 추정
+ result[code] = 6
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+
+ return result
+
+
+def _toCalendarPeriod(bsnsYear: int, fiscalQ: int, fiscalMonth: int) -> tuple[int, int]:
+ """사업연도 분기 → 달력 (연도, 분기) 변환.
+
+ Args:
+ bsnsYear: 사업연도 (예: 2026)
+ fiscalQ: 사업연도 분기 (1~4)
+ fiscalMonth: 결산월 (1~12)
+
+ Returns:
+ (calYear, calQ) — 달력 연도와 분기
+
+ 예: 3월 결산(M=3), bsns_year=2026
+ Q1→2025Q2, Q2→2025Q3, Q3→2025Q4, Q4→2026Q1
+ """
+ import math
+
+ endMonth = (fiscalMonth + fiscalQ * 3) % 12
+ if endMonth == 0:
+ endMonth = 12
+ calQ = math.ceil(endMonth / 3)
+ calYear = bsnsYear - 1 if endMonth > fiscalMonth else bsnsYear
+ return calYear, calQ
+
+
+def _scanDir() -> Path:
+ """scan 출력 디렉토리."""
+ from dartlab.core.dataLoader import _dataDir
+
+ return Path(_dataDir("scan"))
+
+
+def _docsDir() -> Path:
+ from dartlab.core.dataLoader import _dataDir
+
+ return Path(_dataDir("docs"))
+
+
+def _financeDir() -> Path:
+ from dartlab.core.dataLoader import _dataDir
+
+ return Path(_dataDir("finance"))
+
+
+def _reportDir() -> Path:
+ from dartlab.core.dataLoader import _dataDir
+
+ return Path(_dataDir("report"))
+
+
+def _log(msg: str) -> None:
+ print(msg)
+
+
+def _mergeBatchFiles(batchDir: Path, outputPath: Path, *, how: str = "vertical") -> int:
+ """배치 파일들을 읽어서 1개로 합산. 반환: 총 행수."""
+ batchFiles = sorted(batchDir.glob("batch_*.parquet"))
+ if not batchFiles:
+ return 0
+
+ parts = [pl.read_parquet(str(f)) for f in batchFiles]
+ merged = pl.concat(parts, how=how)
+ merged.write_parquet(str(outputPath), compression="zstd")
+ totalRows = merged.height
+ del merged, parts
+ return totalRows
+
+
+# ── changes ──────────────────────────────────────────────────────────
+
+
+def _buildRawChanges(parquetPath: Path, stockCode: str, sinceYear: int = 2021) -> pl.DataFrame | None:
+ """raw docs parquet → section 단위 changes."""
+ try:
+ raw = pl.read_parquet(str(parquetPath))
+ except (pl.exceptions.PolarsError, OSError):
+ return None
+
+ needed = {"year", "section_order", "section_title", "section_content"}
+ if not needed.issubset(set(raw.columns)):
+ return None
+
+ raw = raw.filter(pl.col("year").cast(pl.Utf8).str.to_integer(strict=False) >= sinceYear - 1)
+ if raw.height < 2:
+ return None
+
+ work = raw.select(["year", "section_order", "section_title", "section_content"])
+ work = work.sort(["section_order", "section_title", "year"])
+
+ work = work.with_columns(
+ [
+ pl.col("year").shift(1).over(["section_order", "section_title"]).alias("_prevYear"),
+ pl.col("section_content").shift(1).over(["section_order", "section_title"]).alias("_prevContent"),
+ ]
+ )
+
+ work = work.with_columns(
+ [
+ pl.col("section_content").hash().alias("_hash"),
+ pl.col("_prevContent").hash().alias("_prevHash"),
+ pl.col("section_content").str.len_chars().alias("sizeB"),
+ pl.col("_prevContent").str.len_chars().alias("sizeA"),
+ pl.col("section_content").str.slice(0, 200).alias("preview"),
+ ]
+ )
+
+ changes = work.filter(
+ pl.col("_prevYear").is_not_null()
+ & ~(pl.col("section_content").is_null() & pl.col("_prevContent").is_null())
+ & (
+ (pl.col("_hash") != pl.col("_prevHash"))
+ | pl.col("section_content").is_null()
+ | pl.col("_prevContent").is_null()
+ )
+ )
+
+ if changes.height == 0:
+ return None
+
+ numPattern = r"[\d,.]+"
+ changes = changes.with_columns(
+ [
+ pl.col("section_content").str.replace_all(numPattern, "N").alias("_stripped"),
+ pl.col("_prevContent").str.replace_all(numPattern, "N").alias("_prevStripped"),
+ ]
+ )
+
+ changes = changes.with_columns(
+ pl.when(pl.col("_prevContent").is_null())
+ .then(pl.lit("appeared"))
+ .when(pl.col("section_content").is_null())
+ .then(pl.lit("disappeared"))
+ .when(pl.col("_stripped") == pl.col("_prevStripped"))
+ .then(pl.lit("numeric"))
+ .when(
+ (pl.col("sizeA") > 0)
+ & (
+ (pl.col("sizeB").cast(pl.Int64) - pl.col("sizeA").cast(pl.Int64)).abs().cast(pl.Float64)
+ / pl.col("sizeA").cast(pl.Float64)
+ > 0.5
+ )
+ )
+ .then(pl.lit("structural"))
+ .otherwise(pl.lit("wording"))
+ .alias("changeType")
+ )
+
+ changes = changes.filter(pl.col("year").cast(pl.Utf8).str.to_integer(strict=False) >= sinceYear)
+
+ return changes.select(
+ [
+ pl.col("_prevYear").alias("fromPeriod"),
+ pl.col("year").alias("toPeriod"),
+ pl.col("section_title").alias("sectionTitle"),
+ pl.col("changeType"),
+ pl.col("sizeA"),
+ pl.col("sizeB"),
+ (pl.col("sizeB").cast(pl.Int64) - pl.col("sizeA").cast(pl.Int64)).alias("sizeDelta"),
+ pl.col("preview"),
+ pl.lit(stockCode).alias("stockCode"),
+ ]
+ )
+
+
+def buildChanges(*, sinceYear: int = 2021, verbose: bool = True) -> Path | None:
+ """docs → changes 프리빌드. 반환: 출력 parquet 경로."""
+ docsDir = _docsDir()
+ outDir = _scanDir()
+ outDir.mkdir(parents=True, exist_ok=True)
+ outputPath = outDir / "changes.parquet"
+ batchDir = outDir / "_tmp_changes"
+ batchDir.mkdir(parents=True, exist_ok=True)
+
+ allFiles = sorted(docsDir.glob("*.parquet"))
+ if not allFiles:
+ if verbose:
+ _log("docs parquet 없음 — changes 빌드 건너뜀")
+ return None
+
+ if verbose:
+ _log(f"[changes] {len(allFiles)}종목, sinceYear={sinceYear}")
+
+ t0 = time.perf_counter()
+ batchChunks: list[pl.DataFrame] = []
+ success = 0
+ failed = 0
+ totalRows = 0
+ batchIdx = 0
+
+ for i, pf in enumerate(allFiles):
+ result = _buildRawChanges(pf, pf.stem, sinceYear)
+ if result is not None and result.height > 0:
+ batchChunks.append(result)
+ totalRows += result.height
+ success += 1
+ else:
+ failed += 1
+
+ if len(batchChunks) >= _BATCH or i == len(allFiles) - 1:
+ if batchChunks:
+ batch = pl.concat(batchChunks)
+ batch.write_parquet(str(batchDir / f"batch_{batchIdx:03d}.parquet"), compression="zstd")
+ del batch
+ batchChunks = []
+ batchIdx += 1
+
+ if verbose and (i + 1) % 500 == 0:
+ _log(
+ f" [{i + 1}/{len(allFiles)}] {success}ok {failed}fail {totalRows:,}rows {time.perf_counter() - t0:.0f}s"
+ )
+
+ if batchIdx == 0:
+ if verbose:
+ _log(" changes 결과 없음")
+ shutil.rmtree(batchDir, ignore_errors=True)
+ return None
+
+ _mergeBatchFiles(batchDir, outputPath)
+ shutil.rmtree(batchDir, ignore_errors=True)
+
+ elapsed = time.perf_counter() - t0
+ diskMb = outputPath.stat().st_size / 1024 / 1024
+ if verbose:
+ _log(f" 완료: {success}종목, {totalRows:,}행, {diskMb:.1f}MB, {elapsed:.0f}초")
+
+ return outputPath
+
+
+# ── finance ──────────────────────────────────────────────────────────
+
+
+def buildFinance(*, sinceYear: int = 2021, verbose: bool = True) -> Path | None:
+ """finance 전종목 합산. 반환: 출력 parquet 경로."""
+ finDir = _financeDir()
+ outDir = _scanDir()
+ outDir.mkdir(parents=True, exist_ok=True)
+ outputPath = outDir / "finance.parquet"
+ batchDir = outDir / "_tmp_finance"
+ batchDir.mkdir(parents=True, exist_ok=True)
+
+ allFiles = sorted(finDir.glob("*.parquet"))
+ if not allFiles:
+ if verbose:
+ _log("finance parquet 없음 — 빌드 건너뜀")
+ return None
+
+ # 비12월 결산 종목 → 달력 분기 변환 준비
+ fmMap = _fiscalMonthMap()
+ if verbose and fmMap:
+ _log(f"[finance] 비12월 결산 {len(fmMap)}종목 → 달력 분기 변환")
+
+ if verbose:
+ _log(f"[finance] {len(allFiles)}종목, sinceYear={sinceYear}")
+
+ t0 = time.perf_counter()
+ batchChunks: list[pl.DataFrame] = []
+ success = 0
+ totalRows = 0
+ batchIdx = 0
+
+ for i, pf in enumerate(allFiles):
+ try:
+ df = pl.read_parquet(str(pf))
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+
+ if "stockCode" not in df.columns and "stock_code" not in df.columns:
+ df = df.with_columns(pl.lit(pf.stem).alias("stockCode"))
+ elif "stock_code" in df.columns and "stockCode" not in df.columns:
+ df = df.rename({"stock_code": "stockCode"})
+
+ if "bsns_year" in df.columns:
+ df = df.filter(pl.col("bsns_year").cast(pl.Utf8).str.to_integer(strict=False) >= sinceYear)
+
+ if df.height == 0:
+ continue
+
+ # 비12월 결산 → bsns_year/reprt_nm을 달력 기준으로 변환
+ code = pf.stem
+ if code in fmMap and "bsns_year" in df.columns and "reprt_nm" in df.columns:
+ fm = fmMap[code]
+ _FQ_MAP = {"1분기": 1, "2분기": 2, "3분기": 3, "4분기": 4}
+ rows = []
+ for row in df.iter_rows(named=True):
+ fq = _FQ_MAP.get(row["reprt_nm"])
+ if fq is not None:
+ try:
+ calY, calQ = _toCalendarPeriod(int(row["bsns_year"]), fq, fm)
+ r = dict(row)
+ r["bsns_year"] = str(calY)
+ r["reprt_nm"] = f"{calQ}분기"
+ rows.append(r)
+ except (ValueError, TypeError):
+ rows.append(row)
+ else:
+ rows.append(row)
+ df = pl.DataFrame(rows, schema=df.schema)
+
+ batchChunks.append(df)
+ totalRows += df.height
+ success += 1
+
+ if len(batchChunks) >= _BATCH or i == len(allFiles) - 1:
+ if batchChunks:
+ batch = pl.concat(batchChunks, how="diagonal_relaxed")
+ batch.write_parquet(str(batchDir / f"batch_{batchIdx:03d}.parquet"), compression="zstd")
+ del batch
+ batchChunks = []
+ batchIdx += 1
+
+ if verbose and (i + 1) % 500 == 0:
+ _log(f" [{i + 1}/{len(allFiles)}] {success}ok {totalRows:,}rows {time.perf_counter() - t0:.0f}s")
+
+ if batchIdx == 0:
+ if verbose:
+ _log(" finance 결과 없음")
+ shutil.rmtree(batchDir, ignore_errors=True)
+ return None
+
+ _mergeBatchFiles(batchDir, outputPath, how="diagonal_relaxed")
+ shutil.rmtree(batchDir, ignore_errors=True)
+
+ elapsed = time.perf_counter() - t0
+ diskMb = outputPath.stat().st_size / 1024 / 1024
+ if verbose:
+ _log(f" 완료: {success}종목, {totalRows:,}행, {diskMb:.1f}MB, {elapsed:.0f}초")
+
+ return outputPath
+
+
+# ── report ───────────────────────────────────────────────────────────
+
+
+def buildReport(*, sinceYear: int = 2021, verbose: bool = True) -> list[Path]:
+ """report → apiType별 분리 parquet. 반환: 생성된 파일 경로 목록."""
+ repDir = _reportDir()
+ outDir = _scanDir() / "report"
+ outDir.mkdir(parents=True, exist_ok=True)
+
+ allFiles = sorted(repDir.glob("*.parquet"))
+ if not allFiles:
+ if verbose:
+ _log("report parquet 없음 — 빌드 건너뜀")
+ return []
+
+ if verbose:
+ _log(f"[report] {len(allFiles)}종목 → apiType별 분리")
+
+ t0 = time.perf_counter()
+
+ # apiType별 배치 디렉토리
+ apiBatchDirs: dict[str, Path] = {}
+ apiBatchIdx: dict[str, int] = {}
+ apiChunks: dict[str, list[pl.DataFrame]] = {}
+ apiRows: dict[str, int] = {}
+ for at in SCAN_API_TYPES:
+ bd = outDir / f"_tmp_{at}"
+ bd.mkdir(parents=True, exist_ok=True)
+ apiBatchDirs[at] = bd
+ apiBatchIdx[at] = 0
+ apiChunks[at] = []
+ apiRows[at] = 0
+
+ processed = 0
+
+ for i, pf in enumerate(allFiles):
+ try:
+ df = pl.read_parquet(str(pf))
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+
+ if "apiType" not in df.columns:
+ continue
+
+ if "stockCode" not in df.columns and "stock_code" not in df.columns:
+ df = df.with_columns(pl.lit(pf.stem).alias("stockCode"))
+
+ if "year" in df.columns:
+ df = df.with_columns(pl.col("year").cast(pl.Utf8).str.to_integer(strict=False).alias("_yearInt"))
+ df = df.filter(pl.col("_yearInt").is_null() | (pl.col("_yearInt") >= sinceYear)).drop("_yearInt")
+
+ processed += 1
+
+ for apiType in SCAN_API_TYPES:
+ sub = df.filter(pl.col("apiType") == apiType)
+ if sub.height > 0:
+ apiChunks[apiType].append(sub)
+ apiRows[apiType] += sub.height
+
+ if len(apiChunks[apiType]) >= _BATCH:
+ batch = pl.concat(apiChunks[apiType], how="diagonal_relaxed")
+ idx = apiBatchIdx[apiType]
+ batch.write_parquet(
+ str(apiBatchDirs[apiType] / f"batch_{idx:03d}.parquet"),
+ compression="zstd",
+ )
+ del batch
+ apiChunks[apiType] = []
+ apiBatchIdx[apiType] = idx + 1
+
+ if verbose and (i + 1) % 500 == 0:
+ _log(f" [{i + 1}/{len(allFiles)}] {processed}ok {time.perf_counter() - t0:.0f}s")
+
+ # 남은 청크 flush + 합산
+ outputs: list[Path] = []
+ for apiType in SCAN_API_TYPES:
+ # 남은 청크 쓰기
+ if apiChunks[apiType]:
+ batch = pl.concat(apiChunks[apiType], how="diagonal_relaxed")
+ idx = apiBatchIdx[apiType]
+ batch.write_parquet(
+ str(apiBatchDirs[apiType] / f"batch_{idx:03d}.parquet"),
+ compression="zstd",
+ )
+ del batch
+ apiBatchIdx[apiType] = idx + 1
+
+ if apiBatchIdx[apiType] == 0:
+ shutil.rmtree(apiBatchDirs[apiType], ignore_errors=True)
+ continue
+
+ outPath = outDir / f"{apiType}.parquet"
+ _mergeBatchFiles(apiBatchDirs[apiType], outPath, how="diagonal_relaxed")
+ shutil.rmtree(apiBatchDirs[apiType], ignore_errors=True)
+
+ diskMb = outPath.stat().st_size / 1024 / 1024
+ outputs.append(outPath)
+ if verbose:
+ _log(f" {apiType}: {apiRows[apiType]:,}행, {diskMb:.1f}MB")
+
+ elapsed = time.perf_counter() - t0
+ if verbose:
+ _log(f" report 완료: {len(outputs)}개 apiType, {elapsed:.0f}초")
+
+ return outputs
+
+
+# ── 전체 빌드 ────────────────────────────────────────────────────────
+
+
+def _buildSharesOutstandingSafe(*, verbose: bool = True) -> Path | None:
+ """발행주식수 풀 빌드 — 빌드 실패해도 전체 scan은 계속 진행."""
+ try:
+ from dartlab.providers.dart.docs.finance.shareCapital.builder import buildSharesOutstandingScan
+
+ if verbose:
+ _log("[shares] 발행주식수 풀 빌드 시작")
+ df = buildSharesOutstandingScan()
+ if verbose:
+ _log(f"[shares] 완료: rows={df.height} stocks={df['stock_code'].n_unique()}")
+ return _scanDir() / "sharesOutstanding.parquet"
+ except (FileNotFoundError, RuntimeError, OSError, ValueError) as exc:
+ if verbose:
+ _log(f"[shares] 실패: {exc}")
+ return None
+
+
+def buildScan(*, sinceYear: int = 2021, verbose: bool = True) -> dict[str, Path | list[Path] | None]:
+ """changes + finance + report 전체 프리빌드."""
+ if verbose:
+ _log(f"전종목 scan 프리빌드 시작 (sinceYear={sinceYear})")
+ _log("=" * 60)
+
+ results: dict[str, Path | list[Path] | None] = {}
+
+ results["changes"] = buildChanges(sinceYear=sinceYear, verbose=verbose)
+ results["finance"] = buildFinance(sinceYear=sinceYear, verbose=verbose)
+ results["report"] = buildReport(sinceYear=sinceYear, verbose=verbose)
+ results["sharesOutstanding"] = _buildSharesOutstandingSafe(verbose=verbose)
+
+ if verbose:
+ _log("=" * 60)
+ scanDir = _scanDir()
+ if scanDir.exists():
+ totalMb = sum(f.stat().st_size for f in scanDir.rglob("*.parquet")) / 1024 / 1024
+ _log(f"scan 전체: {totalMb:.1f}MB")
+
+ return results
diff --git a/src/dartlab/scan/capital/__init__.py b/src/dartlab/scan/capital/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a80dd8ae07ec3cc6e1d2cd0b890e9106b4459076
--- /dev/null
+++ b/src/dartlab/scan/capital/__init__.py
@@ -0,0 +1,96 @@
+"""주주환원 전수 스캔 — 배당, 자사주, 증자/감자 → 순환원 분류.
+
+Public API:
+ scan_capital() → pl.DataFrame (전체 상장사 주주환원 현황)
+"""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan.capital.classifier import classify_return
+from dartlab.scan.capital.scanner import (
+ scan_capital_change,
+ scan_dividend,
+ scan_treasury_stock,
+)
+
+
+def scan_capital(*, verbose: bool = True) -> pl.DataFrame:
+ """전체 상장사 주주환원 스캔 → 순환원 분류 DataFrame.
+
+ 컬럼: 종목코드, 배당여부, DPS, 배당수익률, 자사주보유, 자사주취득,
+ 자사주처분, 자사주소각, 취득수량, 처분수량, 소각수량,
+ 최근증자, 환원점수, 분류, 모순형
+ """
+
+ def _log(msg: str) -> None:
+ if verbose:
+ print(msg)
+
+ _log("1/3 배당 스캔...")
+ div_map = scan_dividend()
+ _log(f" → {len(div_map)}종목")
+
+ _log("2/3 자사주 스캔...")
+ treasury_map = scan_treasury_stock()
+ _log(f" → {len(treasury_map)}종목")
+
+ _log("3/3 증자/감자 스캔...")
+ cap_map = scan_capital_change()
+ _log(f" → {len(cap_map)}종목")
+
+ all_codes = set(div_map) | set(treasury_map) | set(cap_map)
+
+ results = []
+ for code in all_codes:
+ d = div_map.get(code, {})
+ t = treasury_map.get(code, {})
+ c = cap_map.get(code, {})
+
+ has_dividend = d.get("배당여부", False)
+ has_buyback = t.get("당기취득", False)
+ has_treasury = t.get("자사주보유", False)
+ has_disposal = t.get("당기처분", False)
+ has_cancel = t.get("당기소각", False)
+ recent_increase = c.get("최근증자", False)
+
+ category, contradiction = classify_return(has_dividend, has_buyback, recent_increase)
+
+ # 환원 점수 (참고용) — 소각은 가장 강한 환원 신호
+ return_score = 0
+ if has_dividend:
+ return_score += 1
+ if has_buyback:
+ return_score += 1
+ if has_cancel:
+ return_score += 1
+ if recent_increase:
+ return_score -= 1
+
+ results.append(
+ {
+ "stockCode": code,
+ "배당여부": has_dividend,
+ "DPS": d.get("DPS", 0.0),
+ "배당수익률": d.get("배당수익률", 0.0),
+ "자사주보유": has_treasury,
+ "자사주취득": has_buyback,
+ "자사주처분": has_disposal,
+ "자사주소각": has_cancel,
+ "취득수량": t.get("취득수량", 0),
+ "처분수량": t.get("처분수량", 0),
+ "소각수량": t.get("소각수량", 0),
+ "최근증자": recent_increase,
+ "환원점수": return_score,
+ "분류": category,
+ "모순형": contradiction,
+ }
+ )
+
+ df = pl.DataFrame(results)
+ _log(f"주주환원 스캔 완료: {df.shape[0]}종목")
+ return df
+
+
+__all__ = ["scan_capital"]
diff --git a/src/dartlab/scan/capital/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/capital/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..5c365761bf30091f3e64c89ea2af3bdf5b53ea2a
Binary files /dev/null and b/src/dartlab/scan/capital/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/capital/__pycache__/classifier.cpython-312.pyc b/src/dartlab/scan/capital/__pycache__/classifier.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..1c9661c7897bc17645f0445127b5ac5b54d044c7
Binary files /dev/null and b/src/dartlab/scan/capital/__pycache__/classifier.cpython-312.pyc differ
diff --git a/src/dartlab/scan/capital/__pycache__/scanner.cpython-312.pyc b/src/dartlab/scan/capital/__pycache__/scanner.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..23137b9b8d4fe18e02f11aa91b65777f7291f39c
Binary files /dev/null and b/src/dartlab/scan/capital/__pycache__/scanner.cpython-312.pyc differ
diff --git a/src/dartlab/scan/capital/classifier.py b/src/dartlab/scan/capital/classifier.py
new file mode 100644
index 0000000000000000000000000000000000000000..283b6aa8d9206097fdf930f7b1c5b75c404dfbb1
--- /dev/null
+++ b/src/dartlab/scan/capital/classifier.py
@@ -0,0 +1,36 @@
+"""순주주환원 분류 — 환원형 / 중립 / 희석형."""
+
+from __future__ import annotations
+
+
+def classify_return(
+ has_dividend: bool,
+ has_buyback: bool,
+ recent_increase: bool,
+) -> tuple[str, bool]:
+ """순주주환원 방향 분류.
+
+ Returns:
+ (분류, 모순형여부)
+ 분류: "적극환원" / "환원형" / "중립" / "희석형"
+ 모순형: 배당하면서 최근 증자
+ """
+ return_score = 0
+ if has_dividend:
+ return_score += 1
+ if has_buyback:
+ return_score += 1
+ if recent_increase:
+ return_score -= 1
+
+ if return_score >= 2:
+ category = "적극환원"
+ elif return_score >= 1:
+ category = "환원형"
+ elif return_score == 0:
+ category = "중립"
+ else:
+ category = "희석형"
+
+ contradiction = has_dividend and recent_increase
+ return category, contradiction
diff --git a/src/dartlab/scan/capital/scanner.py b/src/dartlab/scan/capital/scanner.py
new file mode 100644
index 0000000000000000000000000000000000000000..956775a607948d002db6b3e95e6d1f0b507705ee
--- /dev/null
+++ b/src/dartlab/scan/capital/scanner.py
@@ -0,0 +1,181 @@
+"""주주환원 3축 report 스캔 — 배당, 자사주, 증자/감자."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan._helpers import (
+ parse_date_year,
+ parse_num,
+ scan_parquets,
+)
+
+# ── 배당 ──
+
+DPS_KEYS = {"주당 현금배당금(원)", "주당현금배당금(원)", "주당현금배당금", "현금배당금(원)"}
+YIELD_KEYS = {"현금배당수익률(%)", "현금배당수익률"}
+TOTAL_KEYS = {"현금배당금총액(백만원)", "현금배당금총액"}
+
+
+def scan_dividend() -> dict[str, dict]:
+ """dividend → {종목코드: {배당여부, DPS, 배당수익률, 배당총액_백만}}.
+
+ 최신 연도 Q4 기준. DPS 행이 100개 이상인 연도를 선택.
+ """
+ raw = scan_parquets(
+ "dividend",
+ ["stockCode", "year", "quarter", "se", "thstrm"],
+ )
+ if raw.is_empty():
+ return {}
+
+ years_desc = sorted(raw["year"].unique().to_list(), reverse=True)
+ latest_year = None
+ for y in years_desc:
+ sub = raw.filter(pl.col("year") == y)
+ q4 = sub.filter(pl.col("quarter") == "4분기")
+ target = q4 if not q4.is_empty() else sub
+ dps_ok = target.filter(
+ pl.col("se").is_in(list(DPS_KEYS))
+ & pl.col("thstrm").is_not_null()
+ & (pl.col("thstrm") != "-")
+ & (pl.col("thstrm") != "")
+ ).shape[0]
+ if dps_ok >= 100:
+ latest_year = y
+ break
+ if latest_year is None:
+ return {}
+
+ latest = raw.filter(pl.col("year") == latest_year)
+ q4 = latest.filter(pl.col("quarter") == "4분기")
+ if not q4.is_empty():
+ latest = q4
+
+ result: dict[str, dict] = {}
+ for code, group in latest.group_by("stockCode"):
+ code_val = code[0]
+ dps = None
+ div_yield = None
+ total_div = None
+
+ for row in group.iter_rows(named=True):
+ se = row.get("se", "")
+ if not se:
+ continue
+ val = parse_num(row.get("thstrm"))
+ if se in DPS_KEYS and val is not None and val > 0:
+ if dps is None or val > dps:
+ dps = val
+ elif se in YIELD_KEYS and val is not None and val > 0:
+ if div_yield is None or val > div_yield:
+ div_yield = val
+ elif se in TOTAL_KEYS and val is not None and val > 0:
+ total_div = val
+
+ result[code_val] = {
+ "배당여부": dps is not None and dps > 0,
+ "DPS": dps or 0.0,
+ "배당수익률": div_yield or 0.0,
+ "배당총액_백만": total_div or 0.0,
+ }
+ return result
+
+
+# ── 자사주 ──
+
+
+def scan_treasury_stock() -> dict[str, dict]:
+ """treasuryStock → {종목코드: {자사주보유, 당기취득, 당기처분, 당기소각, 소각수량, 처분수량, 취득수량}}."""
+ raw = scan_parquets(
+ "treasuryStock",
+ ["stockCode", "year", "quarter", "trmend_qy", "change_qy_acqs", "change_qy_dsps", "change_qy_incnr"],
+ )
+ if raw.is_empty():
+ return {}
+
+ years_desc = sorted(raw["year"].unique().to_list(), reverse=True)
+ latest_year = None
+ for y in years_desc:
+ sub = raw.filter(pl.col("year") == y)
+ ok = sub.filter(pl.col("trmend_qy").is_not_null() & (pl.col("trmend_qy") != "-")).shape[0]
+ if ok >= 300:
+ latest_year = y
+ break
+ if latest_year is None:
+ return {}
+
+ latest = raw.filter(pl.col("year") == latest_year)
+ result: dict[str, dict] = {}
+ for code, group in latest.group_by("stockCode"):
+ code_val = code[0]
+ total_held = 0
+ total_acqs = 0
+ total_dsps = 0
+ total_incnr = 0
+ for row in group.iter_rows(named=True):
+ held = parse_num(row.get("trmend_qy"))
+ acqs = parse_num(row.get("change_qy_acqs"))
+ dsps = parse_num(row.get("change_qy_dsps"))
+ incnr = parse_num(row.get("change_qy_incnr"))
+ if held and held > 0:
+ total_held += int(held)
+ if acqs and acqs > 0:
+ total_acqs += int(acqs)
+ if dsps and dsps > 0:
+ total_dsps += int(dsps)
+ if incnr and incnr > 0:
+ total_incnr += int(incnr)
+ result[code_val] = {
+ "자사주보유": total_held > 0,
+ "당기취득": total_acqs > 0,
+ "당기처분": total_dsps > 0,
+ "당기소각": total_incnr > 0,
+ "취득수량": total_acqs,
+ "처분수량": total_dsps,
+ "소각수량": total_incnr,
+ }
+ return result
+
+
+# ── 증자/감자 ──
+
+INCREASE_TYPES = {
+ "유상증자(주주배정)",
+ "유상증자(제3자배정)",
+ "유상증자(일반공모)",
+ "전환권행사",
+ "신주인수권행사",
+ "주식매수선택권행사",
+ "무상증자",
+}
+
+
+def scan_capital_change() -> dict[str, dict]:
+ """capitalChange → {종목코드: {최근증자: bool}}.
+
+ 최근 3년(2023~) 이내 증자(INCREASE_TYPES) 여부.
+ """
+ raw = scan_parquets(
+ "capitalChange",
+ ["stockCode", "year", "quarter", "isu_dcrs_stle", "isu_dcrs_de"],
+ )
+ if raw.is_empty():
+ return {}
+
+ valid = raw.filter(
+ pl.col("isu_dcrs_stle").is_not_null() & (pl.col("isu_dcrs_stle") != "-") & (pl.col("isu_dcrs_stle") != "")
+ )
+
+ result: dict[str, dict] = {}
+ for code, group in valid.group_by("stockCode"):
+ code_val = code[0]
+ recent_increase = False
+ for row in group.iter_rows(named=True):
+ stle = row.get("isu_dcrs_stle", "")
+ event_year = parse_date_year(row.get("isu_dcrs_de"))
+ if stle in INCREASE_TYPES and event_year and event_year >= 2023:
+ recent_increase = True
+ break
+ result[code_val] = {"최근증자": recent_increase}
+ return result
diff --git a/src/dartlab/scan/cashflow/__init__.py b/src/dartlab/scan/cashflow/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..48a9c3ea5077d35b74717def5a7edbdba0aabfc5
--- /dev/null
+++ b/src/dartlab/scan/cashflow/__init__.py
@@ -0,0 +1,212 @@
+"""현금흐름 패턴 분류 — OCF/ICF/FCF + 라이프사이클 패턴.
+
+Note: 여기서 FCF는 OCF + ICF (투자활동 후 잔여현금)이다.
+analysis의 FCF(OCF - CAPEX)와 다르다.
+프리빌드 parquet에서 CAPEX를 개별 추출할 수 없으므로 ICF 전체를 사용한다.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from dartlab.scan._helpers import _ensureScanData, parse_num
+
+# ── 영업활동CF ──
+
+OCF_IDS = {
+ "CashFlowsFromUsedInOperatingActivities",
+ "CashFlowsFromOperatingActivities",
+ "cashFlowsFromUsedInOperatingActivities",
+ "ifrs-full_CashFlowsFromUsedInOperatingActivities",
+ "OperatingCashFlows",
+ "CashFromOperations",
+}
+OCF_NMS = {"영업활동현금흐름", "영업활동으로인한현금흐름", "영업활동현금흐름합계"}
+
+# ── 투자활동CF ──
+
+ICF_IDS = {
+ "CashFlowsFromUsedInInvestingActivities",
+ "CashFlowsFromInvestingActivities",
+ "cashFlowsFromUsedInInvestingActivities",
+ "ifrs-full_CashFlowsFromUsedInInvestingActivities",
+ "InvestingCashFlows",
+ "CashFromInvesting",
+}
+ICF_NMS = {"투자활동현금흐름", "투자활동으로인한현금흐름", "투자활동현금흐름합계"}
+
+# ── 재무활동CF ──
+
+FINCF_IDS = {
+ "CashFlowsFromUsedInFinancingActivities",
+ "CashFlowsFromFinancingActivities",
+ "cashFlowsFromUsedInFinancingActivities",
+ "ifrs-full_CashFlowsFromUsedInFinancingActivities",
+ "FinancingCashFlows",
+ "CashFromFinancing",
+}
+FINCF_NMS = {"재무활동현금흐름", "재무활동으로인한현금흐름", "재무활동현금흐름합계"}
+
+
+# ── CF 패턴 분류 ──
+
+_PATTERNS = {
+ ("P", "N", "N"): "성장투자형", # OCF+, ICF-, FINCF- → 자체CF로 투자+상환
+ ("P", "N", "P"): "공격성장형", # OCF+, ICF-, FINCF+ → 차입까지 동원해서 투자
+ ("P", "P", "N"): "구조재편형", # OCF+, ICF+, FINCF- → 자산매각+부채상환
+ ("P", "P", "P"): "현금축적형", # OCF+, ICF+, FINCF+ → 모든 채널에서 현금 유입
+ ("N", "N", "P"): "외부의존형", # OCF-, ICF-, FINCF+ → 차입으로 버팀
+ ("N", "P", "N"): "축소정리형", # OCF-, ICF+, FINCF- → 자산매각으로 부채상환
+ ("N", "P", "P"): "위기대응형", # OCF-, ICF+, FINCF+ → 자산매각+차입
+ ("N", "N", "N"): "현금위기형", # OCF-, ICF-, FINCF- → 모든 채널 유출
+}
+
+
+def _classifyPattern(ocf: float, icf: float, finCf: float) -> str:
+ """OCF/ICF/FINCF 부호 조합 → 패턴 라벨."""
+ key = (
+ "P" if ocf >= 0 else "N",
+ "P" if icf >= 0 else "N",
+ "P" if finCf >= 0 else "N",
+ )
+ return _PATTERNS.get(key, "미분류")
+
+
+def _scanFromMerged(scanPath: Path) -> pl.DataFrame:
+ """프리빌드 finance.parquet → 종목별 CF 패턴."""
+ scCol = "stockCode" if "stockCode" in pl.scan_parquet(str(scanPath)).collect_schema().names() else "stock_code"
+
+ allIds = list(OCF_IDS | ICF_IDS | FINCF_IDS)
+ allNms = list(OCF_NMS | ICF_NMS | FINCF_NMS)
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ pl.col("sj_div").is_in(["CF"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ & (pl.col("account_id").is_in(allIds) | pl.col("account_nm").is_in(allNms))
+ )
+ .collect()
+ )
+ if target.is_empty():
+ return pl.DataFrame()
+
+ # 연결 우선
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ if not cfs.is_empty():
+ target = cfs
+
+ # 종목별 최신 연도
+ latestYear = target.group_by(scCol).agg(pl.col("bsns_year").max().alias("_maxYear"))
+ target = target.join(latestYear, on=scCol).filter(pl.col("bsns_year") == pl.col("_maxYear")).drop("_maxYear")
+
+ rows: list[dict] = []
+ for code in target[scCol].unique().to_list():
+ sub = target.filter(pl.col(scCol) == code)
+ ocf, icf, finCf = None, None, None
+ for row in sub.iter_rows(named=True):
+ aid = row.get("account_id", "")
+ anm = row.get("account_nm", "")
+ val = parse_num(row.get("thstrm_amount"))
+ if val is None:
+ continue
+ if (aid in OCF_IDS or anm in OCF_NMS) and ocf is None:
+ ocf = val
+ elif (aid in ICF_IDS or anm in ICF_NMS) and icf is None:
+ icf = val
+ elif (aid in FINCF_IDS or anm in FINCF_NMS) and finCf is None:
+ finCf = val
+
+ if ocf is None:
+ continue
+
+ fcf = ocf + (icf or 0)
+ rows.append(
+ {
+ "stockCode": code,
+ "ocf": round(ocf),
+ "icf": round(icf) if icf is not None else None,
+ "finCf": round(finCf) if finCf is not None else None,
+ "fcf": round(fcf),
+ "pattern": _classifyPattern(ocf, icf or 0, finCf or 0),
+ }
+ )
+
+ return pl.DataFrame(rows) if rows else pl.DataFrame()
+
+
+def _scanPerFile() -> pl.DataFrame:
+ """종목별 finance parquet 순회 fallback."""
+ from dartlab.core.dataLoader import _dataDir
+
+ financeDir = Path(_dataDir("finance"))
+ parquetFiles = sorted(financeDir.glob("*.parquet"))
+
+ rows: list[dict] = []
+ for pf in parquetFiles:
+ code = pf.stem
+ try:
+ cfDf = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ pl.col("sj_div").is_in(["CF"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if cfDf.is_empty() or "account_id" not in cfDf.columns:
+ continue
+ cfs = cfDf.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else cfDf
+
+ years = sorted(target["bsns_year"].unique().to_list(), reverse=True)
+ if not years:
+ continue
+ latest = target.filter(pl.col("bsns_year") == years[0])
+
+ ocf, icf, finCf = None, None, None
+ for row in latest.iter_rows(named=True):
+ aid = row.get("account_id", "")
+ anm = row.get("account_nm", "")
+ val = parse_num(row.get("thstrm_amount"))
+ if val is None:
+ continue
+ if (aid in OCF_IDS or anm in OCF_NMS) and ocf is None:
+ ocf = val
+ elif (aid in ICF_IDS or anm in ICF_NMS) and icf is None:
+ icf = val
+ elif (aid in FINCF_IDS or anm in FINCF_NMS) and finCf is None:
+ finCf = val
+
+ if ocf is None:
+ continue
+
+ fcf = ocf + (icf or 0)
+ rows.append(
+ {
+ "stockCode": code,
+ "ocf": round(ocf),
+ "icf": round(icf) if icf is not None else None,
+ "finCf": round(finCf) if finCf is not None else None,
+ "fcf": round(fcf),
+ "pattern": _classifyPattern(ocf, icf or 0, finCf or 0),
+ }
+ )
+
+ return pl.DataFrame(rows) if rows else pl.DataFrame()
+
+
+def scanCashflow() -> pl.DataFrame:
+ """종목별 OCF/ICF/FCF + 현금흐름 패턴 분류.
+
+ 프리빌드 finance.parquet 우선, 없으면 per-file fallback.
+ """
+ scanDir = _ensureScanData()
+ scanPath = scanDir / "finance.parquet"
+ if scanPath.exists():
+ return _scanFromMerged(scanPath)
+ return _scanPerFile()
diff --git a/src/dartlab/scan/cashflow/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/cashflow/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..06859d7683450bf9417f8e2bfa0ca6807ea22418
Binary files /dev/null and b/src/dartlab/scan/cashflow/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/debt/__init__.py b/src/dartlab/scan/debt/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..befc11da9ae1170802bd2fefc9f16b561e94a18d
--- /dev/null
+++ b/src/dartlab/scan/debt/__init__.py
@@ -0,0 +1,76 @@
+"""부채 구조 전수 스캔 — 사채 만기, 부채비율, ICR, 위험등급.
+
+Public API:
+ scan_debt() → pl.DataFrame (전체 상장사 부채 현황)
+"""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan.debt.risk import classify_risk, scan_icr
+from dartlab.scan.debt.scanner import scan_bonds, scan_debt_mix, scan_short_debt
+
+
+def scan_debt(*, verbose: bool = True) -> pl.DataFrame:
+ """전체 상장사 부채 스캔 → 종합 DataFrame.
+
+ 컬럼: 종목코드, 사채잔액, 단기잔액, 단기비중, 단기사채잔액, CP잔액,
+ 단기채무합계, 총부채, 부채비율, ICR, 위험등급
+ """
+
+ def _log(msg: str) -> None:
+ if verbose:
+ print(msg)
+
+ _log("1/4 사채 만기...")
+ bond_map = scan_bonds()
+ _log(f" -> {len(bond_map)}종목")
+
+ _log("2/4 단기사채/CP...")
+ short_map = scan_short_debt()
+ _log(f" -> {len(short_map)}종목")
+
+ _log("3/4 부채비율...")
+ debt_map = scan_debt_mix()
+ _log(f" -> {len(debt_map)}종목")
+
+ _log("4/4 이자보상배율...")
+ icr_map = scan_icr()
+ _log(f" -> {len(icr_map)}종목")
+
+ all_codes = set(bond_map) | set(debt_map) | set(icr_map) | set(short_map)
+
+ results = []
+ for code in all_codes:
+ b = bond_map.get(code, {})
+ s = short_map.get(code, {})
+ d = debt_map.get(code, {})
+ icr = icr_map.get(code)
+
+ short_ratio = b.get("단기비중")
+ shortDebtTotal = s.get("단기채무합계")
+ risk = classify_risk(icr, short_ratio, shortDebtTotal) if (b or s or icr is not None) else None
+
+ results.append(
+ {
+ "stockCode": code,
+ "사채잔액": b.get("사채잔액"),
+ "단기잔액": b.get("단기잔액"),
+ "단기비중": short_ratio,
+ "단기사채잔액": s.get("단기사채잔액"),
+ "CP잔액": s.get("CP잔액"),
+ "단기채무합계": shortDebtTotal,
+ "총부채": d.get("총부채"),
+ "부채비율": d.get("부채비율"),
+ "ICR": icr,
+ "위험등급": risk,
+ }
+ )
+
+ df = pl.DataFrame(results)
+ _log(f"부채 스캔 완료: {df.shape[0]}종목")
+ return df
+
+
+__all__ = ["scan_debt"]
diff --git a/src/dartlab/scan/debt/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/debt/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..d720e74f944a943c75a4d23764c4517a640845e2
Binary files /dev/null and b/src/dartlab/scan/debt/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/debt/__pycache__/risk.cpython-312.pyc b/src/dartlab/scan/debt/__pycache__/risk.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..7c1fa726c6526116c50dd240e205e941fe28da1c
Binary files /dev/null and b/src/dartlab/scan/debt/__pycache__/risk.cpython-312.pyc differ
diff --git a/src/dartlab/scan/debt/__pycache__/scanner.cpython-312.pyc b/src/dartlab/scan/debt/__pycache__/scanner.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..b55ba6afaace18c2d83c19f273296d396d3d12da
Binary files /dev/null and b/src/dartlab/scan/debt/__pycache__/scanner.cpython-312.pyc differ
diff --git a/src/dartlab/scan/debt/risk.py b/src/dartlab/scan/debt/risk.py
new file mode 100644
index 0000000000000000000000000000000000000000..14304be939a19062f13591b6256fcc61bcafb2d8
--- /dev/null
+++ b/src/dartlab/scan/debt/risk.py
@@ -0,0 +1,172 @@
+"""이자보상배율(ICR) + 단기비중 교차 → 부채 위험등급."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from dartlab.scan._helpers import parse_num
+
+# ── 영업이익 ──
+
+OP_IDS = {
+ "ProfitLossFromOperatingActivities",
+ "profitLossFromOperatingActivities",
+ "ifrs-full_ProfitLossFromOperatingActivities",
+ "dart_OperatingIncomeLoss",
+}
+OP_NMS = {"영업이익", "영업이익(손실)"}
+
+
+# ── 이자비용 ──
+
+INTEREST_IDS = {
+ "FinanceCosts",
+ "financeCosts",
+ "ifrs-full_FinanceCosts",
+ "InterestExpense",
+ "interestExpense",
+}
+INTEREST_NMS = {"이자비용", "금융비용", "금융원가", "이자비용(수익)"}
+
+
+def _scanIcrFromMerged(scanPath: Path) -> dict[str, float]:
+ """프리빌드 finance.parquet → 종목별 ICR."""
+ scCol = "stockCode" if "stockCode" in pl.scan_parquet(str(scanPath)).collect_schema().names() else "stock_code"
+
+ allIds = list(OP_IDS | INTEREST_IDS)
+ allNms = list(OP_NMS | INTEREST_NMS)
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ & (pl.col("account_id").is_in(allIds) | pl.col("account_nm").is_in(allNms))
+ )
+ .collect()
+ )
+ if target.is_empty():
+ return {}
+
+ # 연결 우선
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ if not cfs.is_empty():
+ target = cfs
+
+ # 종목별 최신 연도만
+ latestYear = target.group_by(scCol).agg(pl.col("bsns_year").max().alias("_maxYear"))
+ target = target.join(latestYear, on=scCol).filter(pl.col("bsns_year") == pl.col("_maxYear")).drop("_maxYear")
+
+ result: dict[str, float] = {}
+ for code in target[scCol].unique().to_list():
+ sub = target.filter(pl.col(scCol) == code)
+ opIncome = None
+ interest = None
+ for row in sub.iter_rows(named=True):
+ aid = row.get("account_id", "")
+ anm = row.get("account_nm", "")
+ val = parse_num(row.get("thstrm_amount"))
+ if val is None:
+ continue
+ if (aid in OP_IDS or anm in OP_NMS) and opIncome is None:
+ opIncome = val
+ elif (aid in INTEREST_IDS or anm in INTEREST_NMS) and interest is None:
+ interest = abs(val) if val != 0 else None
+ if opIncome is not None and interest and interest > 0:
+ result[code] = round(opIncome / interest, 2)
+
+ return result
+
+
+def _scanIcrPerFile() -> dict[str, float]:
+ """종목별 finance parquet 순회 fallback."""
+ from dartlab.core.dataLoader import _dataDir
+
+ financeDir = Path(_dataDir("finance"))
+ parquetFiles = sorted(financeDir.glob("*.parquet"))
+
+ result: dict[str, float] = {}
+ for pf in parquetFiles:
+ code = pf.stem
+ try:
+ isDf = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if isDf.is_empty() or "account_id" not in isDf.columns:
+ continue
+ cfs = isDf.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else isDf
+
+ years = sorted(target["bsns_year"].unique().to_list(), reverse=True)
+ if not years:
+ continue
+ latest = target.filter(pl.col("bsns_year") == years[0])
+
+ opIncome = None
+ interest = None
+ for row in latest.iter_rows(named=True):
+ aid = row.get("account_id", "")
+ anm = row.get("account_nm", "")
+ val = parse_num(row.get("thstrm_amount"))
+ if val is None:
+ continue
+ if (aid in OP_IDS or anm in OP_NMS) and opIncome is None:
+ opIncome = val
+ elif (aid in INTEREST_IDS or anm in INTEREST_NMS) and interest is None:
+ interest = abs(val) if val != 0 else None
+
+ if opIncome is not None and interest and interest > 0:
+ result[code] = round(opIncome / interest, 2)
+
+ return result
+
+
+def scan_icr() -> dict[str, float]:
+ """finance IS → {종목코드: ICR}.
+
+ 프리빌드 finance.parquet 우선, 없으면 per-file fallback.
+ """
+ from dartlab.scan._helpers import _ensureScanData
+
+ scanDir = _ensureScanData()
+ scanPath = scanDir / "finance.parquet"
+ if scanPath.exists():
+ return _scanIcrFromMerged(scanPath)
+ return _scanIcrPerFile()
+
+
+def classify_risk(
+ icr: float | None,
+ short_ratio: float | None,
+ shortDebtTotal: float | None = None,
+) -> str:
+ """ICR x 단기비중 x 단기채무 → 위험등급.
+
+ - 고위험: (단기비중 >= 50% AND ICR < 1) OR (ICR < 1 AND 단기채무 존재)
+ - 주의: 단기비중 >= 50% OR ICR < 1 OR 단기채무 존재
+ - 관찰: ICR < 3
+ - 안전: 그 외
+ """
+ sr = short_ratio if short_ratio is not None else 0
+ hasShortDebt = shortDebtTotal is not None and shortDebtTotal > 0
+
+ if icr is None:
+ if sr >= 50 or hasShortDebt:
+ return "주의"
+ return "관찰"
+ if (sr >= 50 and icr < 1) or (icr < 1 and hasShortDebt):
+ return "고위험"
+ if sr >= 50 or icr < 1 or hasShortDebt:
+ return "주의"
+ if icr < 3:
+ return "관찰"
+ return "안전"
diff --git a/src/dartlab/scan/debt/scanner.py b/src/dartlab/scan/debt/scanner.py
new file mode 100644
index 0000000000000000000000000000000000000000..1e681eb00403c94a5395e58d9c247679e03b600c
--- /dev/null
+++ b/src/dartlab/scan/debt/scanner.py
@@ -0,0 +1,238 @@
+"""부채 구조 스캔 — corporateBond 만기 + finance BS 부채비율."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from dartlab.scan._helpers import parse_num, scan_parquets
+
+
+def scan_bonds() -> dict[str, dict]:
+ """corporateBond → {종목코드: {사채잔액, 단기잔액, 단기비중}}.
+
+ 합계(remndr_exprtn2) 행 기준. 잔액이 0보다 큰 기업만 반환.
+ """
+ raw = scan_parquets(
+ "corporateBond",
+ ["stockCode", "year", "quarter", "remndr_exprtn2", "sm", "yy1_below"],
+ )
+ if raw.is_empty():
+ return {}
+
+ years_desc = sorted(raw["year"].unique().to_list(), reverse=True)
+ latest_year = None
+ for y in years_desc:
+ sub = raw.filter(pl.col("year") == y)
+ ok = sub.filter(pl.col("sm").is_not_null() & (pl.col("sm") != "-") & (pl.col("sm") != "")).shape[0]
+ if ok >= 200:
+ latest_year = y
+ break
+ if latest_year is None:
+ return {}
+
+ latest = raw.filter(pl.col("year") == latest_year)
+ totals = latest.filter(pl.col("remndr_exprtn2") == "합계")
+ if totals.is_empty() or totals["stockCode"].n_unique() < 50:
+ totals = latest
+
+ result: dict[str, dict] = {}
+ for code, group in totals.group_by("stockCode"):
+ code_val = code[0]
+ total_amount = 0
+ short_term = 0
+ for row in group.iter_rows(named=True):
+ sm = parse_num(row.get("sm"))
+ y1 = parse_num(row.get("yy1_below"))
+ if sm and sm > 0:
+ total_amount = max(total_amount, sm)
+ if y1 and y1 > 0:
+ short_term = max(short_term, y1)
+ if total_amount > 0:
+ result[code_val] = {
+ "사채잔액": total_amount,
+ "단기잔액": short_term,
+ "단기비중": round(short_term / total_amount * 100, 1),
+ }
+ return result
+
+
+# ── finance BS 부채비율 ──
+
+LIABILITIES_IDS = {"Liabilities", "liabilities", "ifrs-full_Liabilities", "dart_Liabilities"}
+LIABILITIES_NMS = {"부채총계", "부채 총계"}
+EQUITY_IDS = {"Equity", "equity", "ifrs-full_Equity", "dart_Equity"}
+EQUITY_NMS = {"자본총계", "자본 총계"}
+
+
+def scan_short_debt() -> dict[str, dict]:
+ """shortTermBond + commercialPaper → {종목코드: {단기사채잔액, CP잔액, 단기채무합계}}.
+
+ 회사채(corporateBond)와 별도로, 기업어음/단기사채의 실질 단기 부채 노출을 측정한다.
+ """
+ stb = scan_parquets(
+ "shortTermBond",
+ ["stockCode", "year", "quarter", "sm"],
+ )
+ cp = scan_parquets(
+ "commercialPaper",
+ ["stockCode", "year", "quarter", "sm"],
+ )
+
+ result: dict[str, dict] = {}
+
+ # 단기사채
+ if not stb.is_empty():
+ for code, group in stb.group_by("stockCode"):
+ codeVal = code[0]
+ best = 0
+ for row in group.iter_rows(named=True):
+ val = parse_num(row.get("sm"))
+ if val and val > best:
+ best = val
+ if best > 0:
+ result.setdefault(codeVal, {})["단기사채잔액"] = best
+
+ # 기업어음
+ if not cp.is_empty():
+ for code, group in cp.group_by("stockCode"):
+ codeVal = code[0]
+ best = 0
+ for row in group.iter_rows(named=True):
+ val = parse_num(row.get("sm"))
+ if val and val > best:
+ best = val
+ if best > 0:
+ result.setdefault(codeVal, {})["CP잔액"] = best
+
+ # 합산
+ for code, d in result.items():
+ d["단기채무합계"] = (d.get("단기사채잔액") or 0) + (d.get("CP잔액") or 0)
+
+ return result
+
+
+def scan_debt_mix() -> dict[str, dict]:
+ """finance BS → {종목코드: {총부채, 부채비율}}.
+
+ 부채비율 = 총부채 / 자본총계 x 100.
+ scan/finance.parquet 프리빌드가 있으면 단일 파일에서 즉시 필터.
+ """
+ from dartlab.scan._helpers import _ensureScanData
+
+ scanDir = _ensureScanData()
+ scanPath = scanDir / "finance.parquet"
+
+ if scanPath.exists():
+ try:
+ return _debtMixFromMerged(scanPath)
+ except (pl.exceptions.PolarsError, OSError):
+ pass
+
+ # fallback: 종목별 순회
+ from dartlab.core.dataLoader import _dataDir
+
+ finance_dir = Path(_dataDir("finance"))
+ parquet_files = sorted(finance_dir.glob("*.parquet"))
+
+ result: dict[str, dict] = {}
+ for pf in parquet_files:
+ code = pf.stem
+ try:
+ bs = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ (pl.col("sj_div") == "BS")
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if bs.is_empty() or "account_id" not in bs.columns:
+ continue
+ cfs = bs.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else bs
+
+ years = sorted(target["bsns_year"].unique().to_list(), reverse=True)
+ if not years:
+ continue
+ latest = target.filter(pl.col("bsns_year") == years[0])
+
+ liab = None
+ equity = None
+ for row in latest.iter_rows(named=True):
+ aid = row.get("account_id", "")
+ anm = row.get("account_nm", "")
+ val = parse_num(row.get("thstrm_amount"))
+ if (aid in LIABILITIES_IDS or anm in LIABILITIES_NMS) and val:
+ if liab is None or val > liab:
+ liab = val
+ elif (aid in EQUITY_IDS or anm in EQUITY_NMS) and val:
+ if equity is None or val > equity:
+ equity = val
+
+ if liab and liab > 0:
+ debt_ratio = (liab / equity * 100) if equity and equity > 0 else None
+ result[code] = {
+ "총부채": liab,
+ "부채비율": round(debt_ratio, 1) if debt_ratio else None,
+ }
+ return result
+
+
+def _debtMixFromMerged(scanPath: Path) -> dict[str, dict]:
+ """합산 finance parquet에서 부채/자본 추출."""
+ scCol = "stockCode" if "stockCode" in pl.scan_parquet(str(scanPath)).collect_schema().names() else "stock_code"
+
+ bs = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ (pl.col("sj_div") == "BS")
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ if bs.is_empty() or "account_id" not in bs.columns:
+ return {}
+
+ cfs = bs.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else bs
+
+ # 종목별 최신 연도
+ latestYear = target.group_by(scCol).agg(pl.col("bsns_year").max().alias("_maxYear"))
+ target = target.join(latestYear, on=scCol).filter(pl.col("bsns_year") == pl.col("_maxYear")).drop("_maxYear")
+
+ LIABILITIES_IDS | LIABILITIES_NMS
+ EQUITY_IDS | EQUITY_NMS
+
+ liabRows = target.filter(
+ pl.col("account_id").is_in(list(LIABILITIES_IDS)) | pl.col("account_nm").is_in(list(LIABILITIES_NMS))
+ )
+ eqRows = target.filter(pl.col("account_id").is_in(list(EQUITY_IDS)) | pl.col("account_nm").is_in(list(EQUITY_NMS)))
+
+ liabMap: dict[str, float] = {}
+ for row in liabRows.iter_rows(named=True):
+ code = row.get(scCol, "")
+ val = parse_num(row.get("thstrm_amount"))
+ if code and val and (code not in liabMap or val > liabMap[code]):
+ liabMap[code] = val
+
+ eqMap: dict[str, float] = {}
+ for row in eqRows.iter_rows(named=True):
+ code = row.get(scCol, "")
+ val = parse_num(row.get("thstrm_amount"))
+ if code and val and (code not in eqMap or val > eqMap[code]):
+ eqMap[code] = val
+
+ result: dict[str, dict] = {}
+ for code, liab in liabMap.items():
+ if liab > 0:
+ equity = eqMap.get(code)
+ debt_ratio = (liab / equity * 100) if equity and equity > 0 else None
+ result[code] = {
+ "총부채": liab,
+ "부채비율": round(debt_ratio, 1) if debt_ratio else None,
+ }
+ return result
diff --git a/src/dartlab/scan/disclosureRisk/__init__.py b/src/dartlab/scan/disclosureRisk/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce2bb50533e3db0e4ba28932614b5c986875331a
--- /dev/null
+++ b/src/dartlab/scan/disclosureRisk/__init__.py
@@ -0,0 +1,228 @@
+"""공시 변화 리스크 탐지 — changes.parquet 기반 선행 위험 시그널.
+
+기존 scan 축(debt, audit)이 "결과"를 보는 반면,
+이 축은 공시 "변화 과정"에서 선행 리스크를 탐지한다.
+
+시그널 6개:
+- contingentDebt: 우발부채 섹션 증가 (숨겨진 부채)
+- chronicYears: 우발부채 3년+ 연속 증가 연수 (만성 리스크)
+- riskKeyword: 횡령/배임/과징금/손해배상 신규 등장 (audit 미감지 위험)
+- auditStruct: 감사/내부통제 구조 변경 3건 이상
+- affiliateChange: 계열/타법인 numeric 변화 (M&A 신호)
+- bizPivot: 사업 내용 대규모 변경 (구조 전환)
+
+사용법::
+
+ dartlab.scan("disclosureRisk") # 전 상장사
+ dartlab.scan("disclosureRisk", "005930") # 삼성전자만
+"""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan._helpers import _ensureScanData
+
+# 심각 키워드 (audit 안전 67%가 미감지 — 실험 107-002 검증)
+_SEVERE_KEYWORDS = ["횡령", "배임", "과징금", "손해배상"]
+
+
+def _gradeRisk(activeCount: int, hasSevereKeyword: bool) -> str:
+ """활성 시그널 수 + 심각 키워드 → 등급."""
+ if hasSevereKeyword or activeCount >= 3:
+ return "고위험"
+ if activeCount >= 1:
+ return "주의"
+ return "안정"
+
+
+def _calcChronicYears(fullDf: pl.DataFrame) -> pl.DataFrame:
+ """전 기간 우발부채 연속 증가 연수 계산."""
+ contingent_yearly = (
+ fullDf.filter(pl.col("sectionTitle").str.contains("우발부채") & (pl.col("sizeDelta") > 0))
+ .group_by(["stockCode", "toPeriod"])
+ .agg(pl.col("sizeDelta").sum().alias("delta"))
+ )
+
+ if contingent_yearly.is_empty():
+ return pl.DataFrame(schema={"stockCode": pl.Utf8, "chronicYears": pl.Int64})
+
+ rows: list[dict] = []
+ for code in contingent_yearly["stockCode"].unique().to_list():
+ years = contingent_yearly.filter(pl.col("stockCode") == code).height
+ if years >= 3:
+ rows.append({"stockCode": code, "chronicYears": years})
+
+ return pl.DataFrame(rows) if rows else pl.DataFrame(schema={"stockCode": pl.Utf8, "chronicYears": pl.Int64})
+
+
+def _calcRiskKeyword(latest: pl.DataFrame, prev: pl.DataFrame) -> pl.DataFrame:
+ """리스크 키워드 신규 등장 탐지 (전년 대비 차분)."""
+ now_stocks: set[str] = set()
+ prev_stocks: set[str] = set()
+
+ for kw in _SEVERE_KEYWORDS:
+ now_stocks |= set(latest.filter(pl.col("preview").str.contains(kw))["stockCode"].unique().to_list())
+ prev_stocks |= set(prev.filter(pl.col("preview").str.contains(kw))["stockCode"].unique().to_list())
+
+ new_stocks = now_stocks - prev_stocks
+ if not new_stocks:
+ return pl.DataFrame(schema={"stockCode": pl.Utf8, "riskKeyword": pl.Int8})
+
+ return pl.DataFrame({"stockCode": list(new_stocks), "riskKeyword": [1] * len(new_stocks)})
+
+
+def scanDisclosureRisk(*, verbose: bool = True) -> pl.DataFrame:
+ """전종목 공시 변화 리스크 스캔.
+
+ changes.parquet에서 최신 기간의 공시 변화를 분석하여
+ 6개 선행 리스크 시그널과 종합 등급을 반환한다.
+
+ 컬럼: stockCode, contingentDebt, chronicYears, riskKeyword,
+ auditStruct, affiliateChange, bizPivot, activeSignals, grade
+ """
+ scanDir = _ensureScanData()
+ changesPath = scanDir / "changes.parquet"
+
+ if not changesPath.exists():
+ if verbose:
+ print("changes.parquet 없음 — 공시리스크 스캔 불가")
+ return _emptyDf()
+
+ if verbose:
+ print("공시리스크 스캔: changes.parquet 로드...")
+
+ fullDf = pl.read_parquet(str(changesPath))
+
+ # 최신 기간 자동 탐지
+ latestTo = fullDf["toPeriod"].max()
+ latestFrom = str(int(latestTo) - 1)
+ changes = fullDf.filter((pl.col("fromPeriod") == latestFrom) & (pl.col("toPeriod") == latestTo))
+
+ # 이전 기간 (키워드 차분용)
+ prevTo = latestFrom
+ prevFrom = str(int(prevTo) - 1)
+ prevChanges = fullDf.filter((pl.col("fromPeriod") == prevFrom) & (pl.col("toPeriod") == prevTo))
+
+ if changes.is_empty():
+ del fullDf
+ return _emptyDf()
+
+ if verbose:
+ print(f" 기간: {latestFrom}→{latestTo}, {changes['stockCode'].n_unique()}종목")
+
+ # ── 시그널 계산 ──
+
+ # 1. contingentDebt: 우발부채 섹션 sizeDelta > 0 합
+ contingent = (
+ changes.filter(pl.col("sectionTitle").str.contains("우발부채") & (pl.col("sizeDelta") > 0))
+ .group_by("stockCode")
+ .agg(pl.col("sizeDelta").sum().alias("contingentDebt"))
+ )
+
+ # 2. chronicYears: 전 기간 우발부채 연속 증가 연수
+ chronic = _calcChronicYears(fullDf)
+
+ # 3. riskKeyword: 심각 키워드 신규 등장
+ keyword = _calcRiskKeyword(changes, prevChanges)
+
+ del fullDf, prevChanges
+
+ # 4. auditStruct: 감사/내부통제 structural 3건 이상
+ auditStruct = (
+ changes.filter(
+ (pl.col("sectionTitle").str.contains("감사") | pl.col("sectionTitle").str.contains("내부통제"))
+ & (pl.col("changeType") == "structural")
+ )
+ .group_by("stockCode")
+ .agg(pl.len().alias("auditStruct"))
+ .filter(pl.col("auditStruct") >= 3)
+ )
+
+ # 5. affiliateChange: 계열/타법인 numeric 변화
+ affiliate = (
+ changes.filter(
+ (pl.col("sectionTitle").str.contains("계열") | pl.col("sectionTitle").str.contains("타법인출자"))
+ & (pl.col("changeType") == "numeric")
+ )
+ .group_by("stockCode")
+ .agg(pl.len().alias("affiliateChange"))
+ )
+
+ # 6. bizPivot: 사업의 내용 |sizeDelta| > 5000
+ bizPivot = (
+ changes.filter(pl.col("sectionTitle").str.contains("사업의") & (pl.col("sizeDelta").abs() > 5000))
+ .group_by("stockCode")
+ .agg(pl.col("sizeDelta").abs().max().alias("bizPivot"))
+ )
+
+ del changes
+
+ # ── 병합 + 등급 ──
+
+ allCodes = pl.DataFrame(
+ {
+ "stockCode": list(
+ set(contingent["stockCode"].to_list())
+ | set(chronic["stockCode"].to_list())
+ | set(keyword["stockCode"].to_list())
+ | set(auditStruct["stockCode"].to_list())
+ | set(affiliate["stockCode"].to_list())
+ | set(bizPivot["stockCode"].to_list())
+ )
+ }
+ )
+
+ result = allCodes
+ for right in [contingent, chronic, keyword, auditStruct, affiliate, bizPivot]:
+ result = result.join(right, on="stockCode", how="left")
+
+ result = result.fill_null(0)
+
+ # 활성 시그널 수 (6개)
+ result = result.with_columns(
+ (
+ (pl.col("contingentDebt") > 0).cast(pl.Int8)
+ + (pl.col("chronicYears") >= 3).cast(pl.Int8)
+ + (pl.col("riskKeyword") > 0).cast(pl.Int8)
+ + (pl.col("auditStruct") > 0).cast(pl.Int8)
+ + (pl.col("affiliateChange") > 0).cast(pl.Int8)
+ + (pl.col("bizPivot") > 0).cast(pl.Int8)
+ ).alias("activeSignals")
+ )
+
+ # 등급 (심각 키워드는 단독으로도 고위험)
+ rows = []
+ for row in result.iter_rows(named=True):
+ hasSevere = row["riskKeyword"] > 0
+ rows.append({**row, "grade": _gradeRisk(row["activeSignals"], hasSevere)})
+
+ result = pl.DataFrame(rows).sort("activeSignals", descending=True)
+
+ if verbose:
+ grade_dist = result["grade"].value_counts()
+ for r in grade_dist.to_dicts():
+ print(f" {r['grade']}: {r['count']}종목")
+ print(f"공시리스크 스캔 완료: {result.height}종목")
+
+ return result
+
+
+def _emptyDf() -> pl.DataFrame:
+ """빈 결과."""
+ return pl.DataFrame(
+ schema={
+ "stockCode": pl.Utf8,
+ "contingentDebt": pl.Int64,
+ "chronicYears": pl.Int64,
+ "riskKeyword": pl.Int8,
+ "auditStruct": pl.Int64,
+ "affiliateChange": pl.Int64,
+ "bizPivot": pl.Int64,
+ "activeSignals": pl.Int8,
+ "grade": pl.Utf8,
+ }
+ )
+
+
+__all__ = ["scanDisclosureRisk"]
diff --git a/src/dartlab/scan/disclosureRisk/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/disclosureRisk/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..a6da538f348181e2d2fc4815277b3f13ee0342f3
Binary files /dev/null and b/src/dartlab/scan/disclosureRisk/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/dividendTrend/__init__.py b/src/dartlab/scan/dividendTrend/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d06a3c855edb29f6d85c5694464300538a5c00a8
--- /dev/null
+++ b/src/dartlab/scan/dividendTrend/__init__.py
@@ -0,0 +1,188 @@
+"""배당 추이 스캔 -- DPS 3개년 시계열 + 패턴 분류."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan._helpers import parse_num, scan_parquets
+
+
+def _classifyPattern(
+ dps0: float | None,
+ dps1: float | None,
+ dps2: float | None,
+) -> str:
+ """3개년 DPS → 패턴 분류.
+
+ dps0=당기, dps1=전기, dps2=전전기.
+ """
+ has0 = dps0 is not None and dps0 > 0
+ has1 = dps1 is not None and dps1 > 0
+ has2 = dps2 is not None and dps2 > 0
+
+ if not has0 and not has1 and not has2:
+ return "무배당"
+ if has0 and not has1:
+ return "시작"
+ if not has0 and has1:
+ return "중단"
+ if has0 and has1 and has2:
+ if dps0 > dps1 > dps2:
+ return "연속증가"
+ if dps0 < dps1 < dps2:
+ return "연속감소"
+ # +-10% 이내 안정
+ if dps1 > 0 and abs(dps0 - dps1) / dps1 <= 0.1:
+ return "안정"
+ if dps0 >= dps1:
+ return "증가"
+ return "감소"
+ if has0 and has1:
+ if dps0 > dps1:
+ return "증가"
+ if dps0 < dps1:
+ return "감소"
+ return "안정"
+ return "불규칙"
+
+
+def _gradeDividend(pattern: str, dpsGrowth: float | None) -> str:
+ """배당 등급."""
+ if pattern == "무배당":
+ return "무배당"
+ if pattern in ("연속증가",):
+ return "우수"
+ if pattern in ("안정", "증가"):
+ return "양호"
+ if pattern == "시작":
+ return "양호"
+ if pattern in ("감소", "연속감소"):
+ return "주의"
+ if pattern == "중단":
+ return "위험"
+ return "보통"
+
+
+def scanDividendTrend(*, verbose: bool = True) -> pl.DataFrame:
+ """전종목 배당 추이 스캔 -- DPS 3개년 + 패턴 + 등급."""
+ raw = scan_parquets(
+ "dividend",
+ ["stockCode", "year", "quarter", "se", "thstrm", "frmtrm", "lwfr", "stock_knd"],
+ )
+ if raw.is_empty():
+ return pl.DataFrame()
+
+ if verbose:
+ print(f"배당 추이 스캔: {raw.shape[0]}행 로드")
+
+ # 보통주 + 주당 현금배당금 + Q4 우선
+ dpsRows = raw.filter((pl.col("se") == "주당 현금배당금(원)") & (pl.col("stock_knd") == "보통주"))
+
+ # 배당수익률
+ yieldRows = raw.filter((pl.col("se") == "현금배당수익률(%)") & (pl.col("stock_knd") == "보통주"))
+
+ # 배당성향 (연결 우선)
+ payoutRows = raw.filter(pl.col("se") == "(연결)현금배당성향(%)")
+
+ # 최신 연도 기준
+ years = sorted(
+ [y for y in dpsRows["year"].unique().to_list() if str(y).strip().isdigit()],
+ reverse=True,
+ )
+ if not years:
+ return pl.DataFrame()
+
+ # Q4 기준 유효 데이터 500종목 이상인 최신 연도 (결산 완료 연도)
+ latestYear = None
+ for y in years:
+ q4sub = dpsRows.filter((pl.col("year") == y) & (pl.col("quarter") == "4분기"))
+ if q4sub["stockCode"].n_unique() >= 500:
+ latestYear = y
+ break
+ if latestYear is None:
+ latestYear = years[0]
+
+ if verbose:
+ print(f" 기준 연도: {latestYear}")
+
+ rows: list[dict] = []
+ allCodes = dpsRows["stockCode"].unique().to_list()
+
+ for code in allCodes:
+ codeDps = dpsRows.filter(pl.col("stockCode") == code)
+
+ # Q4 우선, 최신 연도
+ yearSub = codeDps.filter(pl.col("year") == latestYear)
+ if yearSub.is_empty():
+ # 해당 연도 없으면 가장 최근 데이터 사용
+ codeYears = sorted(codeDps["year"].unique().to_list(), reverse=True)
+ if not codeYears:
+ continue
+ yearSub = codeDps.filter(pl.col("year") == codeYears[0])
+
+ q4 = yearSub.filter(pl.col("quarter") == "4분기")
+ best = q4 if not q4.is_empty() else yearSub
+ row = best.row(0, named=True)
+
+ dps0 = parse_num(row.get("thstrm"))
+ dps1 = parse_num(row.get("frmtrm"))
+ dps2 = parse_num(row.get("lwfr"))
+
+ # 배당수익률
+ yieldVal = None
+ yieldSub = yieldRows.filter((pl.col("stockCode") == code) & (pl.col("year") == latestYear))
+ if not yieldSub.is_empty():
+ yq4 = yieldSub.filter(pl.col("quarter") == "4분기")
+ yBest = yq4 if not yq4.is_empty() else yieldSub
+ yieldVal = parse_num(yBest.row(0, named=True).get("thstrm"))
+
+ # 배당성향
+ payoutVal = None
+ payoutSub = payoutRows.filter((pl.col("stockCode") == code) & (pl.col("year") == latestYear))
+ if not payoutSub.is_empty():
+ pq4 = payoutSub.filter(pl.col("quarter") == "4분기")
+ pBest = pq4 if not pq4.is_empty() else payoutSub
+ payoutVal = parse_num(pBest.row(0, named=True).get("thstrm"))
+
+ # DPS 성장률
+ dpsGrowth = None
+ if dps0 is not None and dps1 is not None and dps1 > 0:
+ dpsGrowth = round((dps0 - dps1) / dps1 * 100, 1)
+
+ pattern = _classifyPattern(dps0, dps1, dps2)
+
+ rows.append(
+ {
+ "stockCode": code,
+ "dpsCurrent": dps0,
+ "dpsPrev": dps1,
+ "dpsPrev2": dps2,
+ "dpsGrowth": dpsGrowth,
+ "payoutRatio": payoutVal,
+ "yieldCurrent": yieldVal,
+ "pattern": pattern,
+ "grade": _gradeDividend(pattern, dpsGrowth),
+ }
+ )
+
+ if verbose:
+ print(f"배당 추이 스캔 완료: {len(rows)}종목")
+
+ if not rows:
+ return pl.DataFrame()
+
+ schema = {
+ "stockCode": pl.Utf8,
+ "dpsCurrent": pl.Float64,
+ "dpsPrev": pl.Float64,
+ "dpsPrev2": pl.Float64,
+ "dpsGrowth": pl.Float64,
+ "payoutRatio": pl.Float64,
+ "yieldCurrent": pl.Float64,
+ "pattern": pl.Utf8,
+ "grade": pl.Utf8,
+ }
+ return pl.DataFrame(rows, schema=schema)
+
+
+__all__ = ["scanDividendTrend"]
diff --git a/src/dartlab/scan/dividendTrend/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/dividendTrend/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..b2bf4f9f4508941a6cdeaf207b1d912c2a828e4b
Binary files /dev/null and b/src/dartlab/scan/dividendTrend/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/edgarBuilder.py b/src/dartlab/scan/edgarBuilder.py
new file mode 100644
index 0000000000000000000000000000000000000000..c921202225880c9ec5d6a3e377cdd41a70117ca4
--- /dev/null
+++ b/src/dartlab/scan/edgarBuilder.py
@@ -0,0 +1,171 @@
+"""EDGAR scan 프리빌드 — 전종목 재무 지표를 단일 parquet로 합산.
+
+DART scan/builder.py 패턴을 EDGAR에 이식.
+200개 단위 배치 + 중간 파일 병합 + 메모리 안전.
+
+사용법::
+
+ from dartlab.scan.edgarBuilder import buildEdgarScan
+ path = buildEdgarScan(sinceYear=2021, verbose=True)
+"""
+
+from __future__ import annotations
+
+import gc
+from pathlib import Path
+
+import polars as pl
+
+_BATCH_SIZE = 200
+
+
+def buildEdgarFinance(*, sinceYear: int = 2021, verbose: bool = False) -> Path:
+ """전종목 EDGAR finance → scan/finance.parquet.
+
+ 각 CIK parquet에서 최신 연간 BS/IS/CF 주요 계정을 추출하여
+ 하나의 wide DataFrame으로 합산한다.
+ """
+ from dartlab import config as _cfg
+
+ edgarDir = Path(_cfg.dataDir) / "edgar" / "finance"
+ outDir = Path(_cfg.dataDir) / "edgar" / "scan"
+ outDir.mkdir(parents=True, exist_ok=True)
+
+ if not edgarDir.exists():
+ raise FileNotFoundError(f"EDGAR finance 디렉토리 없음: {edgarDir}")
+
+ parquets = sorted(edgarDir.glob("*.parquet"))
+ if not parquets:
+ raise FileNotFoundError("EDGAR finance parquet 없음")
+
+ if verbose:
+ print(f"[edgarBuilder] {len(parquets)} CIK parquets → scan/finance.parquet")
+
+ # 주요 계정
+ targetAccounts = [
+ "sales",
+ "operating_profit",
+ "net_profit",
+ "total_assets",
+ "current_assets",
+ "total_liabilities",
+ "current_liabilities",
+ "total_stockholders_equity",
+ "operating_cashflow",
+ "investing_cashflow",
+ "financing_cash_flow",
+ "capex",
+ "dividends_paid",
+ "cash_and_cash_equivalents",
+ "inventories",
+ "trade_and_other_receivables",
+ "trade_and_other_payables",
+ "interest_expense",
+ "treasury_stock",
+ "shortterm_borrowings",
+ "longterm_borrowings",
+ "depreciation_amortization",
+ ]
+
+ # snakeId → XBRL 태그 역조회 테이블 (사전 빌드, map_elements 회피)
+ snakeIdToTags = _buildReverseTagMap(targetAccounts)
+
+ batchFiles: list[Path] = []
+ records: list[dict] = []
+
+ for idx, fp in enumerate(parquets):
+ cik = fp.stem
+ try:
+ df = pl.read_parquet(fp)
+ if df.is_empty():
+ continue
+
+ # 10-K만, sinceYear 이후
+ annual = df.filter((pl.col("form") == "10-K") & (pl.col("fy") >= sinceYear))
+ if annual.is_empty():
+ continue
+
+ latestFy = annual["fy"].max()
+ latest = annual.filter(pl.col("fy") == latestFy)
+ entityName = latest["entityName"][0] if latest.height > 0 else ""
+
+ # snakeId 매핑 → 최신값 추출 (is_in 필터, map_elements 회피)
+ record: dict = {"stockCode": cik, "corpName": entityName, "fy": int(latestFy)}
+ usdRows = latest.filter(pl.col("unit").str.contains("(?i)USD"))
+ for snakeId in targetAccounts:
+ candidateTags = snakeIdToTags.get(snakeId, [])
+ if not candidateTags:
+ continue
+ tagRows = usdRows.filter(pl.col("tag").is_in(candidateTags))
+ if tagRows.height > 0:
+ val = tagRows.sort("filed", descending=True)["val"][0]
+ record[snakeId] = val
+
+ records.append(record)
+
+ except (pl.exceptions.ComputeError, OSError):
+ continue
+
+ # 배치 저장
+ if len(records) >= _BATCH_SIZE:
+ batchPath = outDir / f"_batch_{len(batchFiles):04d}.parquet"
+ pl.DataFrame(records).write_parquet(batchPath)
+ batchFiles.append(batchPath)
+ records.clear()
+ gc.collect()
+ if verbose:
+ print(f" batch {len(batchFiles)}: {idx + 1}/{len(parquets)}")
+
+ # 나머지
+ if records:
+ batchPath = outDir / f"_batch_{len(batchFiles):04d}.parquet"
+ pl.DataFrame(records).write_parquet(batchPath)
+ batchFiles.append(batchPath)
+
+ # 병합
+ if not batchFiles:
+ raise ValueError("프리빌드할 데이터 없음")
+
+ frames = [pl.read_parquet(p) for p in batchFiles]
+ merged = pl.concat(frames, how="diagonal_relaxed")
+
+ outPath = outDir / "finance.parquet"
+ merged.write_parquet(outPath, compression="zstd")
+
+ # 임시 파일 정리
+ for bp in batchFiles:
+ bp.unlink(missing_ok=True)
+
+ if verbose:
+ print(f"[edgarBuilder] 완료: {outPath} ({merged.height}행, {merged.width}열)")
+
+ return outPath
+
+
+def buildEdgarScan(*, sinceYear: int = 2021, verbose: bool = False) -> Path:
+ """전체 EDGAR scan 프리빌드."""
+ return buildEdgarFinance(sinceYear=sinceYear, verbose=verbose)
+
+
+def _buildReverseTagMap(snakeIds: list[str]) -> dict[str, list[str]]:
+ """snakeId → XBRL 태그 목록 역조회 테이블.
+
+ EdgarMapper.getTagsForSnakeIds()를 개별 snakeId씩 호출하여
+ snakeId별 후보 태그 목록을 구축. map_elements 회피.
+ """
+ from dartlab.providers.edgar.finance.mapper import EdgarMapper
+
+ result: dict[str, list[str]] = {}
+ for sid in snakeIds:
+ tags = EdgarMapper.getTagsForSnakeIds([sid])
+ result[sid] = sorted(tags)
+ return result
+
+
+def _guessStmt(snakeId: str) -> str:
+ """snakeId로 재무제표 유형 추정."""
+ if snakeId in ("sales", "operating_profit", "net_profit", "interest_expense", "depreciation_amortization"):
+ return "IS"
+ if snakeId in ("operating_cashflow", "investing_cashflow", "financing_cash_flow", "capex", "dividends_paid"):
+ return "CF"
+ return "BS"
diff --git a/src/dartlab/scan/efficiency/__init__.py b/src/dartlab/scan/efficiency/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..65a24427f1916613e5289f5439061065808e037b
--- /dev/null
+++ b/src/dartlab/scan/efficiency/__init__.py
@@ -0,0 +1,129 @@
+"""운영 효율 스캔 -- 자산/재고/매출채권 회전율 + CCC(현금전환주기)."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan._helpers import scan_finance_parquets
+
+# ── 계정 매핑 ──
+
+_REVENUE_IDS = {"Revenue", "revenue", "ifrs-full_Revenue", "dart_Revenue"}
+_REVENUE_NMS = {"매출액", "수익(매출액)", "영업수익"}
+
+_TA_IDS = {"Assets", "totalAssets", "ifrs-full_Assets", "dart_Assets"}
+_TA_NMS = {"자산총계", "자산 총계"}
+
+_INV_IDS = {"Inventories", "inventories", "ifrs-full_Inventories", "dart_Inventories"}
+_INV_NMS = {"재고자산"}
+
+_AR_IDS = {
+ "ShortTermTradeReceivables",
+ "TradeAndOtherCurrentReceivables",
+ "ifrs-full_TradeAndOtherCurrentReceivables",
+}
+_AR_NMS = {"매출채권"}
+
+_PPE_IDS = {"PropertyPlantAndEquipment", "ifrs-full_PropertyPlantAndEquipment"}
+_PPE_NMS = {"유형자산"}
+
+_COGS_IDS = {"CostOfSales", "ifrs-full_CostOfSales", "dart_CostOfGoodsAndServicesSold"}
+_COGS_NMS = {"매출원가"}
+
+_AP_IDS = {"TradeAndOtherCurrentPayables", "ifrs-full_TradeAndOtherCurrentPayables"}
+_AP_NMS = {"매입채무"}
+
+_MIN_REVENUE = 1e8 # 매출 1억 미만 제외
+_CCC_CAP = 3000.0 # CCC +-3000일 초과 클램핑
+
+
+def _gradeEfficiency(ccc: float | None) -> str:
+ """CCC 기준 효율 등급."""
+ if ccc is None:
+ return "해당없음"
+ if ccc < 90:
+ return "우수"
+ if ccc < 180:
+ return "양호"
+ if ccc < 365:
+ return "보통"
+ return "비효율"
+
+
+def scanEfficiency(*, verbose: bool = True) -> pl.DataFrame:
+ """전종목 운영 효율 스캔 -- 회전율 + CCC + 등급."""
+ if verbose:
+ print("효율성 스캔: 계정 수집 중...")
+
+ revMap = scan_finance_parquets("IS", _REVENUE_IDS, _REVENUE_NMS)
+ taMap = scan_finance_parquets("BS", _TA_IDS, _TA_NMS)
+ invMap = scan_finance_parquets("BS", _INV_IDS, _INV_NMS)
+ arMap = scan_finance_parquets("BS", _AR_IDS, _AR_NMS)
+ ppeMap = scan_finance_parquets("BS", _PPE_IDS, _PPE_NMS)
+ cogsMap = scan_finance_parquets("IS", _COGS_IDS, _COGS_NMS)
+ apMap = scan_finance_parquets("BS", _AP_IDS, _AP_NMS)
+
+ allCodes = set(revMap) | set(taMap) | set(invMap) | set(arMap)
+
+ rows: list[dict] = []
+ for code in allCodes:
+ rev = revMap.get(code)
+ if not rev or rev < _MIN_REVENUE:
+ continue
+
+ ta = taMap.get(code)
+ inv = invMap.get(code)
+ ar = arMap.get(code)
+ ppe = ppeMap.get(code)
+ cogs = cogsMap.get(code)
+ ap = apMap.get(code)
+
+ assetTurnover = round(rev / ta, 2) if ta and ta > 0 else None
+ invTurnover = round(rev / inv, 2) if inv and inv > 0 else None
+ arTurnover = round(rev / ar, 2) if ar and ar > 0 else None
+ ppeTurnover = round(rev / ppe, 2) if ppe and ppe > 0 else None
+
+ invDays = round(365 / invTurnover) if invTurnover and invTurnover > 0 else None
+ arDays = round(365 / arTurnover) if arTurnover and arTurnover > 0 else None
+ apDays = round(365 * ap / cogs) if ap and cogs and cogs > 0 else None
+
+ ccc = None
+ if invDays is not None and arDays is not None:
+ rawCcc = invDays + arDays - (apDays or 0)
+ ccc = max(-_CCC_CAP, min(_CCC_CAP, rawCcc))
+
+ rows.append(
+ {
+ "stockCode": code,
+ "assetTurnover": assetTurnover,
+ "invTurnover": invTurnover,
+ "arTurnover": arTurnover,
+ "ppeTurnover": ppeTurnover,
+ "invDays": invDays,
+ "arDays": arDays,
+ "ccc": ccc,
+ "grade": _gradeEfficiency(ccc),
+ }
+ )
+
+ if verbose:
+ print(f"효율성 스캔 완료: {len(rows)}종목")
+
+ if not rows:
+ return pl.DataFrame()
+
+ schema = {
+ "stockCode": pl.Utf8,
+ "assetTurnover": pl.Float64,
+ "invTurnover": pl.Float64,
+ "arTurnover": pl.Float64,
+ "ppeTurnover": pl.Float64,
+ "invDays": pl.Float64,
+ "arDays": pl.Float64,
+ "ccc": pl.Float64,
+ "grade": pl.Utf8,
+ }
+ return pl.DataFrame(rows, schema=schema)
+
+
+__all__ = ["scanEfficiency"]
diff --git a/src/dartlab/scan/efficiency/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/efficiency/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..53a2827d3c41f310d6921cff9ab58f8edf80f8d8
Binary files /dev/null and b/src/dartlab/scan/efficiency/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/extended.py b/src/dartlab/scan/extended.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f19b476f10ca4b2068ab59070a634991d21c947
--- /dev/null
+++ b/src/dartlab/scan/extended.py
@@ -0,0 +1,207 @@
+"""scan → review 모듈 — 전종목 횡단 데이터로 교차 조합 관점 제공.
+
+analysis calc 패턴과 동일: calcXxx(company) → dict, review builders 가 블록으로 조립.
+
+scan 의 진짜 힘 — 2~3축 교차 조합으로 단일 종목에서 안 보이는 뷰 생성:
+- 수익성 × 성장성 → "성숙기 캐시카우" / "고성장 고마진"
+- 부채 × 자본환원 → "레버리지 주주환원"
+- 매출 순위 × 영업이익 순위 → "마진 프리미엄"
+
+데이터: scan/finance.parquet 프리빌드 사용 (이미 존재, _ensureScanData 자동 다운로드).
+"""
+
+from __future__ import annotations
+
+import logging
+
+log = logging.getLogger(__name__)
+
+
+def calcPeerPosition(company, *, basePeriod: str | None = None) -> dict | None:
+ """전종목 횡단 → 이 종목의 시장 내 위치 (2~3축 교차 조합 관점).
+
+ scan/finance.parquet 에서 수익성/성장성/이익품질/부채 4축 백분위 산출 후
+ 교차 조합으로 관점 (crossViews) 생성.
+
+ Args:
+ company: Company 객체 (stockCode 필요)
+ basePeriod: 기준 연도 (None 이면 최신)
+
+ Returns:
+ dict {profitability_pct, growth_pct, quality_pct, debt_pct, total_stocks,
+ crossViews: list[dict], narrative: str}
+ 또는 None
+ """
+ import polars as pl
+
+ from dartlab.scan._helpers import _ensureScanData, parse_num
+
+ code = getattr(company, "stockCode", None) or getattr(company, "stock_code", None)
+ if not code:
+ return None
+
+ scanDir = _ensureScanData()
+ path = scanDir / "finance.parquet"
+ if not path.exists():
+ log.warning("scan/finance.parquet 없음")
+ return None
+
+ try:
+ lf = pl.scan_parquet(str(path))
+ snap = (
+ lf.filter(pl.col("fs_nm").str.contains("연결")).filter(pl.col("reprt_nm").str.contains("4분기")).collect()
+ )
+ except (pl.exceptions.PolarsError, OSError) as e:
+ log.warning("finance.parquet 스캔 실패: %s", e)
+ return None
+
+ if snap.is_empty():
+ return None
+
+ # 최신 연도
+ years = sorted(snap["bsns_year"].unique().to_list(), reverse=True)
+ year = years[0] if years else None
+ if not year:
+ return None
+ cur = snap.filter(pl.col("bsns_year") == year)
+
+ # 이 종목 데이터 추출
+ stock = cur.filter(pl.col("stockCode") == code)
+ if stock.is_empty():
+ return None
+
+ total = cur["stockCode"].n_unique()
+
+ def _extract(df, account_nms):
+ """계정명 매칭 → 금액 추출."""
+ for nm in account_nms:
+ rows = df.filter(pl.col("account_nm") == nm)
+ if not rows.is_empty():
+ v = parse_num(rows["thstrm_amount"][0])
+ if v is not None:
+ return v
+ return None
+
+ def _percentile(df, account_nms, stock_val):
+ """전종목 대비 백분위."""
+ if stock_val is None:
+ return None
+ vals = []
+ for sc in df["stockCode"].unique().to_list():
+ s = df.filter(pl.col("stockCode") == sc)
+ v = _extract(s, account_nms)
+ if v is not None:
+ vals.append(v)
+ if len(vals) < 50:
+ return None
+ below = sum(1 for v in vals if v < stock_val)
+ return round(below / len(vals) * 100, 1)
+
+ # 4축 계정명
+ revenue_nms = ["매출액", "수익(매출액)", "영업수익"]
+ op_nms = ["영업이익", "영업이익(손실)"]
+ ni_nms = ["당기순이익", "당기순이익(손실)"]
+ equity_nms = ["자본총계"]
+ debt_nms = ["부채총계"]
+
+ # 이 종목 값 추출
+ rev = _extract(stock, revenue_nms)
+ op = _extract(stock, op_nms)
+ ni = _extract(stock, ni_nms)
+ eq = _extract(stock, equity_nms)
+ debt = _extract(stock, debt_nms)
+
+ # 파생 비율
+ op_margin = round(op / rev * 100, 1) if rev and op and rev > 0 else None
+ roe = round(ni / eq * 100, 1) if ni and eq and eq > 0 else None
+ debt_ratio = round(debt / eq * 100, 1) if debt and eq and eq > 0 else None
+
+ # 백분위 (전종목 대비)
+ profitability_pct = _percentile(cur.filter(cur["sj_div"] == "IS"), op_nms, op)
+ # 성장성: 전기 대비 (간략 — 전기 데이터 없으면 None)
+ growth_pct = None # 뼈대 — 전기 데이터 비교 필요 (추후)
+
+ quality_pct = None # 뼈대 — OCF/NI 비교 (추후)
+ debt_pct = _percentile(cur.filter(cur["sj_div"] == "BS"), debt_nms, debt)
+
+ # 교차 조합 관점
+ crossViews = []
+ if profitability_pct is not None:
+ if profitability_pct >= 80 and (growth_pct is None or growth_pct <= 40):
+ crossViews.append(
+ {"view": "성숙기 캐시카우", "basis": f"수익성 상위 {100 - profitability_pct:.0f}% + 성장 하위권"}
+ )
+ if profitability_pct >= 70 and growth_pct is not None and growth_pct >= 70:
+ crossViews.append(
+ {
+ "view": "고성장 고마진",
+ "basis": f"수익성 상위 {100 - profitability_pct:.0f}% + 성장 상위 {100 - growth_pct:.0f}%",
+ }
+ )
+ if debt_pct is not None and profitability_pct is not None:
+ if debt_pct >= 70 and profitability_pct >= 60:
+ crossViews.append(
+ {
+ "view": "레버리지 수익형",
+ "basis": f"부채 상위 {100 - debt_pct:.0f}% + 수익성 상위 {100 - profitability_pct:.0f}%",
+ }
+ )
+ if debt_pct <= 30:
+ crossViews.append({"view": "무차입 안정형", "basis": f"부채 하위 {debt_pct:.0f}%"})
+
+ # 서사
+ parts = []
+ if profitability_pct is not None:
+ parts.append(f"수익성 상위 {100 - profitability_pct:.0f}%")
+ if growth_pct is not None:
+ parts.append(f"성장성 상위 {100 - growth_pct:.0f}%")
+ if debt_pct is not None:
+ if debt_pct >= 70:
+ parts.append(f"부채 상위 {100 - debt_pct:.0f}% (높음)")
+ else:
+ parts.append(f"부채 하위 {debt_pct:.0f}% (안정)")
+ if crossViews:
+ parts.append(f"→ {crossViews[0]['view']}")
+ narrative = ". ".join(parts) + "." if parts else "peer 비교 데이터 부족."
+
+ return {
+ "stockCode": code,
+ "year": year,
+ "total_stocks": total,
+ "profitability_pct": profitability_pct,
+ "growth_pct": growth_pct,
+ "quality_pct": quality_pct,
+ "debt_pct": debt_pct,
+ "op_margin": op_margin,
+ "roe": roe,
+ "debt_ratio": debt_ratio,
+ "crossViews": crossViews,
+ "narrative": narrative,
+ }
+
+
+def calcGovernanceSummary(company) -> dict | None:
+ """scan governance → 종목의 지배구조 5축 점수/등급 (뼈대).
+
+ c.governance() 가 이미 구현돼 있으면 위임, 아니면 scan report 에서 추출.
+ """
+ code = getattr(company, "stockCode", None) or getattr(company, "stock_code", None)
+ if not code:
+ return None
+
+ try:
+ gov = company.governance() if hasattr(company, "governance") else None
+ if gov is not None and hasattr(gov, "to_dicts"):
+ rows = gov.to_dicts()
+ if rows:
+ r = rows[0]
+ return {
+ "stockCode": code,
+ "totalScore": r.get("totalScore"),
+ "grade": r.get("grade"),
+ "narrative": f"지배구조 {r.get('grade', '?')}등급 ({r.get('totalScore', '?')}점).",
+ }
+ except (AttributeError, TypeError, ValueError):
+ pass
+
+ return {"stockCode": code, "narrative": "지배구조 데이터 없음."}
diff --git a/src/dartlab/scan/governance/__init__.py b/src/dartlab/scan/governance/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ef652c9898c9346d2bdda01f0d64428a2817ba1
--- /dev/null
+++ b/src/dartlab/scan/governance/__init__.py
@@ -0,0 +1,108 @@
+"""거버넌스 전수 스캔 — 지분율, 사외이사, pay ratio, 감사의견, 소액주주 → 종합 등급.
+
+Public API:
+ scan_governance() → pl.DataFrame (전체 상장사 거버넌스 등급)
+"""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan.governance.scanner import (
+ scan_audit_opinion,
+ scan_major_holder_pct,
+ scan_minority_holder,
+ scan_outside_directors,
+ scan_pay_ratio,
+)
+from dartlab.scan.governance.scorer import (
+ grade,
+ score_audit,
+ score_minority,
+ score_outside_ratio,
+ score_ownership,
+ score_pay_ratio,
+)
+
+
+def scan_governance(*, verbose: bool = True) -> pl.DataFrame:
+ """전체 상장사 거버넌스 스캔 → 종합 등급 DataFrame.
+
+ 컬럼: 종목코드, 지분율, 사외이사비율, 중도사임, 겸직, pay_ratio,
+ 감사의견, 소액주주지분, S_지분, S_사외, S_보수, S_감사, S_분산,
+ 총점, 등급, 유효축수
+ """
+
+ def _log(msg: str) -> None:
+ if verbose:
+ print(msg)
+
+ _log("1/5 최대주주 지분율...")
+ holder_map = scan_major_holder_pct()
+ _log(f" → {len(holder_map)}종목")
+
+ _log("2/5 사외이사 비율...")
+ outside_map = scan_outside_directors()
+ _log(f" → {len(outside_map)}종목")
+
+ _log("3/5 pay ratio...")
+ pay_ratio_map = scan_pay_ratio()
+ _log(f" → {len(pay_ratio_map)}종목")
+
+ _log("4/5 감사의견...")
+ audit_map = scan_audit_opinion()
+ _log(f" → {len(audit_map)}종목")
+
+ _log("5/5 소액주주 지분율...")
+ minority_map = scan_minority_holder()
+ _log(f" → {len(minority_map)}종목")
+
+ all_codes = set(holder_map) | set(outside_map) | set(pay_ratio_map) | set(audit_map) | set(minority_map)
+
+ results = []
+ for code in all_codes:
+ ownership = holder_map.get(code)
+ outside_info = outside_map.get(code, {})
+ outside_ratio = outside_info.get("사외이사비율") if outside_info else None
+ resign = outside_info.get("중도사임", 0) if outside_info else 0
+ concurrent = outside_info.get("겸직", 0) if outside_info else 0
+ pay_r = pay_ratio_map.get(code)
+ audit = audit_map.get(code)
+ minority = minority_map.get(code)
+
+ s1 = score_ownership(ownership)
+ s2 = score_outside_ratio(outside_ratio, resign=resign, concurrent=concurrent)
+ s3 = score_pay_ratio(pay_r)
+ s4 = score_audit(audit)
+ s5 = score_minority(minority)
+ total = s1 + s2 + s3 + s4 + s5
+ g = grade(total)
+ n_valid = sum(1 for v in [ownership, outside_ratio, pay_r, audit, minority] if v is not None)
+
+ results.append(
+ {
+ "stockCode": code,
+ "지분율": round(ownership, 1) if ownership is not None else None,
+ "사외이사비율": round(outside_ratio, 1) if outside_ratio is not None else None,
+ "중도사임": resign,
+ "겸직": concurrent,
+ "pay_ratio": round(pay_r, 1) if pay_r is not None else None,
+ "감사의견": audit or "",
+ "소액주주지분": round(minority, 1) if minority is not None else None,
+ "S_지분": s1,
+ "S_사외": s2,
+ "S_보수": s3,
+ "S_감사": s4,
+ "S_분산": s5,
+ "총점": total,
+ "등급": g,
+ "유효축수": n_valid,
+ }
+ )
+
+ df = pl.DataFrame(results)
+ _log(f"거버넌스 스캔 완료: {df.shape[0]}종목, 5/5")
+ return df
+
+
+__all__ = ["scan_governance"]
diff --git a/src/dartlab/scan/governance/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/governance/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..031a723972b5d69535ccd3d7adc01ff6b007f330
Binary files /dev/null and b/src/dartlab/scan/governance/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/governance/__pycache__/scanner.cpython-312.pyc b/src/dartlab/scan/governance/__pycache__/scanner.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..05154a891f6716a273aa1aeff89274aab2cfa8f5
Binary files /dev/null and b/src/dartlab/scan/governance/__pycache__/scanner.cpython-312.pyc differ
diff --git a/src/dartlab/scan/governance/__pycache__/scorer.cpython-312.pyc b/src/dartlab/scan/governance/__pycache__/scorer.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..054557ea1139a6a47df675aebd03f505f51c8af8
Binary files /dev/null and b/src/dartlab/scan/governance/__pycache__/scorer.cpython-312.pyc differ
diff --git a/src/dartlab/scan/governance/scanner.py b/src/dartlab/scan/governance/scanner.py
new file mode 100644
index 0000000000000000000000000000000000000000..d8d56cfe10cd121a41989a4bb362f8f44643ed2b
--- /dev/null
+++ b/src/dartlab/scan/governance/scanner.py
@@ -0,0 +1,255 @@
+"""거버넌스 6축 report 스캔."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan._helpers import (
+ find_latest_year,
+ parse_num,
+ pick_best_quarter,
+ scan_parquets,
+)
+
+
+def scan_major_holder_pct() -> dict[str, float]:
+ """majorHolder → {종목코드: 최대주주 지분율(%)}."""
+ raw = scan_parquets(
+ "majorHolder",
+ ["stockCode", "year", "quarter", "bsis_posesn_stock_qota_rt"],
+ )
+ if raw.is_empty():
+ return {}
+
+ latest_year = find_latest_year(raw, "bsis_posesn_stock_qota_rt", 1000)
+ if latest_year is None:
+ return {}
+
+ result: dict[str, float] = {}
+ sub = raw.filter(pl.col("year") == latest_year)
+ for code, group in sub.group_by("stockCode"):
+ vals = []
+ for row in group.iter_rows(named=True):
+ v = parse_num(row.get("bsis_posesn_stock_qota_rt"))
+ if v is not None and 0 <= v <= 100:
+ vals.append(v)
+ if vals:
+ result[code[0]] = max(vals)
+ return result
+
+
+def scan_outside_directors() -> dict[str, dict]:
+ """outsideDirector → {종목코드: {사외이사비율, 중도사임, 겸직}}.
+
+ outsideDirector parquet의 drctr_co/otcmp_drctr_co 집계값 사용.
+ fallback: executive parquet의 ofcps 문자열 파싱.
+ """
+ raw = scan_parquets(
+ "outsideDirector",
+ ["stockCode", "year", "quarter", "drctr_co", "otcmp_drctr_co", "mdstrm_resig", "rlsofc"],
+ )
+
+ if not raw.is_empty():
+ return _outsideFromDedicated(raw)
+
+ # fallback: executive parquet
+ return _outsideFromExecutive()
+
+
+def _outsideFromDedicated(raw: pl.DataFrame) -> dict[str, dict]:
+ """outsideDirector parquet → 사외이사 비율 + 중도사임 + 겸직."""
+ latestYear = find_latest_year(raw, "drctr_co", 500)
+ if latestYear is None:
+ return {}
+
+ sub = raw.filter(pl.col("year") == latestYear)
+ result: dict[str, dict] = {}
+
+ for code, group in sub.group_by("stockCode"):
+ codeVal = code[0]
+ qdf = pick_best_quarter(group)
+
+ totalDirectors = 0
+ outsideDirectors = 0
+ resignCount = 0
+ concurrentCount = 0
+
+ for row in qdf.iter_rows(named=True):
+ d = parse_num(row.get("drctr_co"))
+ o = parse_num(row.get("otcmp_drctr_co"))
+ r = parse_num(row.get("mdstrm_resig"))
+ c = parse_num(row.get("rlsofc"))
+
+ if d and d > 0:
+ totalDirectors += int(d)
+ if o and o > 0:
+ outsideDirectors += int(o)
+ if r and r > 0:
+ resignCount += int(r)
+ if c and c > 0:
+ concurrentCount += int(c)
+
+ if totalDirectors > 0:
+ result[codeVal] = {
+ "사외이사비율": outsideDirectors / totalDirectors * 100,
+ "중도사임": resignCount,
+ "겸직": concurrentCount,
+ }
+
+ return result
+
+
+def _outsideFromExecutive() -> dict[str, dict]:
+ """executive parquet fallback → 사외이사 비율만 (중도사임/겸직 없음)."""
+ raw = scan_parquets(
+ "executive",
+ ["stockCode", "year", "quarter", "ofcps"],
+ )
+ if raw.is_empty():
+ return {}
+
+ latestYear = find_latest_year(raw, "ofcps", 1000)
+ if latestYear is None:
+ return {}
+
+ result: dict[str, dict] = {}
+ sub = raw.filter(pl.col("year") == latestYear)
+ for code, group in sub.group_by("stockCode"):
+ total = group.shape[0]
+ outside = sum(1 for row in group.iter_rows(named=True) if row.get("ofcps") and "사외" in row["ofcps"])
+ result[code[0]] = {
+ "사외이사비율": outside / total * 100 if total > 0 else 0,
+ "중도사임": 0,
+ "겸직": 0,
+ }
+ return result
+
+
+def scan_pay_ratio() -> dict[str, float]:
+ """executivePayAllTotal + employee → {종목코드: pay ratio(배)}."""
+ raw_pay = scan_parquets(
+ "executivePayAllTotal",
+ ["stockCode", "year", "quarter", "nmpr", "jan_avrg_mendng_am"],
+ )
+ raw_emp = scan_parquets(
+ "employee",
+ ["stockCode", "year", "quarter", "sm", "jan_salary_am"],
+ )
+ if raw_pay.is_empty() or raw_emp.is_empty():
+ return {}
+
+ # 임원 평균보수
+ pay_map: dict[str, float] = {}
+ latest = find_latest_year(raw_pay, "jan_avrg_mendng_am", 500)
+ if latest:
+ sub = raw_pay.filter(pl.col("year") == latest)
+ for code, group in sub.group_by("stockCode"):
+ qdf = pick_best_quarter(group)
+ wsum, tnmpr = 0.0, 0
+ for row in qdf.iter_rows(named=True):
+ n = parse_num(row.get("nmpr"))
+ p = parse_num(row.get("jan_avrg_mendng_am"))
+ if n and n > 0 and p and p > 0:
+ wsum += n * p
+ tnmpr += int(n)
+ if tnmpr > 0:
+ pay_map[code[0]] = wsum / tnmpr
+
+ # 직원 평균급여
+ sal_map: dict[str, float] = {}
+ latest = find_latest_year(raw_emp, "jan_salary_am", 500)
+ if latest:
+ sub = raw_emp.filter(pl.col("year") == latest)
+ for code, group in sub.group_by("stockCode"):
+ qdf = pick_best_quarter(group)
+ wsum, temp = 0.0, 0
+ for row in qdf.iter_rows(named=True):
+ e = parse_num(row.get("sm"))
+ s = parse_num(row.get("jan_salary_am"))
+ if e and e > 0 and s and s > 0:
+ wsum += e * s
+ temp += int(e)
+ if temp > 0:
+ sal_map[code[0]] = wsum / temp
+
+ result: dict[str, float] = {}
+ for code in pay_map:
+ if code in sal_map and sal_map[code] > 0:
+ ratio = pay_map[code] / sal_map[code]
+ # pay_ratio 극단값 cap: 500배 초과는 데이터 오류
+ if ratio > 500:
+ continue
+ result[code] = ratio
+ return result
+
+
+def scan_audit_opinion() -> dict[str, str]:
+ """auditOpinion → {종목코드: 감사의견 문자열}."""
+ raw = scan_parquets(
+ "auditOpinion",
+ ["stockCode", "year", "quarter", "adt_opinion"],
+ )
+ if raw.is_empty():
+ return {}
+
+ opinion_rank = {"의견거절": 4, "부적정의견": 3, "한정의견": 2, "적정의견": 1}
+ result: dict[str, str] = {}
+ years_desc = sorted(raw["year"].unique().to_list(), reverse=True)
+ for y in years_desc:
+ sub = raw.filter(pl.col("year") == y)
+ if sub.filter(pl.col("adt_opinion").is_not_null()).shape[0] < 500:
+ continue
+ for code, group in sub.group_by("stockCode"):
+ valid_rows = group.filter(pl.col("adt_opinion").is_not_null())
+ if valid_rows.is_empty():
+ continue
+ worst, worst_op = 0, None
+ for row in valid_rows.iter_rows(named=True):
+ op = row.get("adt_opinion")
+ if op:
+ r = opinion_rank.get(op, 0)
+ if r > worst:
+ worst = r
+ worst_op = op
+ elif worst_op is None:
+ worst_op = op
+ if worst_op:
+ result[code[0]] = worst_op
+ break
+ return result
+
+
+def scan_minority_holder() -> dict[str, float]:
+ """minorityHolder → {종목코드: 소액주주 지분율(%)}.
+
+ hold_stock_rate가 높을수록 주주 분산이 양호.
+ """
+ raw = scan_parquets(
+ "minorityHolder",
+ ["stockCode", "year", "quarter", "hold_stock_rate"],
+ )
+ if raw.is_empty():
+ return {}
+
+ latestYear = find_latest_year(raw, "hold_stock_rate", 500)
+ if latestYear is None:
+ return {}
+
+ sub = raw.filter(pl.col("year") == latestYear)
+ result: dict[str, float] = {}
+
+ for code, group in sub.group_by("stockCode"):
+ codeVal = code[0]
+ qdf = pick_best_quarter(group)
+ vals = []
+ for row in qdf.iter_rows(named=True):
+ raw_val = row.get("hold_stock_rate")
+ if raw_val is not None:
+ cleaned = str(raw_val).strip().rstrip("%")
+ v = parse_num(cleaned)
+ if v is not None and 0 <= v <= 100:
+ vals.append(v)
+ if vals:
+ result[codeVal] = max(vals)
+
+ return result
diff --git a/src/dartlab/scan/governance/scorer.py b/src/dartlab/scan/governance/scorer.py
new file mode 100644
index 0000000000000000000000000000000000000000..25d26121fff3a63595395fc4a12c215387259df6
--- /dev/null
+++ b/src/dartlab/scan/governance/scorer.py
@@ -0,0 +1,111 @@
+"""거버넌스 종합 등급 산출 (5축 = 100점).
+
+배점: 지분(20) + 사외(25) + 보수(15) + 감사(25) + 분산(15)
+"""
+
+from __future__ import annotations
+
+
+def score_ownership(pct: float | None) -> float:
+ """최대주주 지분율 점수 (0~20). 30~50%가 최적."""
+ if pct is None:
+ return 10.0
+ if 30 <= pct <= 50:
+ return 20.0
+ if 20 <= pct < 30 or 50 < pct <= 60:
+ return 16.0
+ if 10 <= pct < 20 or 60 < pct <= 70:
+ return 12.0
+ if pct < 10:
+ return 4.0
+ return 8.0 # 70%+
+
+
+def score_outside_ratio(
+ ratio: float | None,
+ *,
+ resign: int = 0,
+ concurrent: int = 0,
+) -> float:
+ """사외이사 비율 점수 (0~25). 중도사임/겸직 페널티 포함."""
+ if ratio is None:
+ return 12.5
+
+ if ratio >= 40:
+ base = 25.0
+ elif ratio >= 30:
+ base = 22.0
+ elif ratio >= 20:
+ base = 18.0
+ elif ratio >= 10:
+ base = 14.0
+ elif ratio > 0:
+ base = 8.0
+ else:
+ base = 3.0
+
+ penalty = 0.0
+ if resign > 0:
+ penalty += min(resign * 3, 6)
+ if concurrent > 0:
+ penalty += min(concurrent * 2, 4)
+
+ return max(base - penalty, 0.0)
+
+
+def score_pay_ratio(ratio: float | None) -> float:
+ """pay ratio 점수 (0~15). 낮을수록 좋음."""
+ if ratio is None:
+ return 7.5
+ if ratio <= 2:
+ return 15.0
+ if ratio <= 3:
+ return 13.0
+ if ratio <= 5:
+ return 11.0
+ if ratio <= 10:
+ return 8.0
+ if ratio <= 20:
+ return 4.0
+ return 1.0
+
+
+def score_audit(opinion: str | None) -> float:
+ """감사의견 점수 (0~25)."""
+ if opinion is None or opinion == "":
+ return 12.5
+ if opinion == "적정의견":
+ return 25.0
+ if opinion == "한정의견":
+ return 5.0
+ return 0.0 # 부적정의견, 의견거절
+
+
+def score_minority(pct: float | None) -> float:
+ """소액주주 지분율 점수 (0~15). 높을수록 분산 양호."""
+ if pct is None:
+ return 7.5
+ if pct >= 60:
+ return 15.0
+ if pct >= 50:
+ return 13.0
+ if pct >= 40:
+ return 11.0
+ if pct >= 30:
+ return 8.0
+ if pct >= 20:
+ return 5.0
+ return 2.0
+
+
+def grade(score: float) -> str:
+ """총점 → A~E 등급."""
+ if score >= 85:
+ return "A"
+ if score >= 70:
+ return "B"
+ if score >= 55:
+ return "C"
+ if score >= 40:
+ return "D"
+ return "E"
diff --git a/src/dartlab/scan/growth/__init__.py b/src/dartlab/scan/growth/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b976e60c13c73d9c6cc337f7bf67580a4a3df1f
--- /dev/null
+++ b/src/dartlab/scan/growth/__init__.py
@@ -0,0 +1,215 @@
+"""성장성 스캔 -- 매출/영업이익/순이익 CAGR + 성장 패턴 분류."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from dartlab.scan._helpers import _ensureScanData, extractAccount
+
+# ── 계정 매핑 ──
+
+_REVENUE_IDS = {"Revenue", "revenue", "ifrs-full_Revenue", "dart_Revenue"}
+_REVENUE_NMS = {"매출액", "수익(매출액)", "영업수익"}
+
+_OP_IDS = {
+ "ProfitLossFromOperatingActivities",
+ "operatingIncome",
+ "ifrs-full_ProfitLossFromOperatingActivities",
+ "dart_OperatingIncomeLoss",
+}
+_OP_NMS = {"영업이익", "영업이익(손실)"}
+
+_NI_IDS = {
+ "ProfitLoss",
+ "netIncome",
+ "ifrs-full_ProfitLoss",
+ "dart_ProfitLoss",
+ "ProfitLossAttributableToOwnersOfParent",
+}
+_NI_NMS = {"당기순이익", "당기순이익(손실)"}
+
+
+from dartlab.core.finance.calc import cagr as _cagr # noqa: E402
+
+
+def _gradeGrowth(revCagr: float | None, opCagr: float | None) -> str:
+ """성장성 등급."""
+ best = max(revCagr or -999, opCagr or -999)
+ if best >= 20:
+ return "고성장"
+ if best >= 10:
+ return "성장"
+ if best >= 0:
+ return "정체"
+ if best >= -10:
+ return "역성장"
+ return "급감"
+
+
+def _classifyPattern(revCagr: float | None, opCagr: float | None, niCagr: float | None) -> str:
+ """성장 패턴 분류."""
+ r = revCagr or 0
+ o = opCagr or 0
+ n = niCagr or 0
+
+ if r > 5 and o > 5 and n > 5:
+ return "균형성장"
+ if r > 5 and o > r:
+ return "수익개선"
+ if r > 5 and o < 0:
+ return "외형성장"
+ if r < -5 and o > 0:
+ return "구조조정"
+ if r < -5 and o < -5:
+ return "전면역성장"
+ return "혼합"
+
+
+def scanGrowth() -> pl.DataFrame:
+ """전종목 성장성 스캔 -- 3년 CAGR + 등급 + 패턴."""
+ scanDir = _ensureScanData()
+ scanPath = scanDir / "finance.parquet"
+
+ if not scanPath.exists():
+ return _scanPerFile()
+
+ return _scanFromMerged(scanPath)
+
+
+def _scanFromMerged(scanPath: Path) -> pl.DataFrame:
+ """프리빌드 finance.parquet에서 성장성 계산."""
+ schema = pl.scan_parquet(str(scanPath)).collect_schema().names()
+ scCol = "stockCode" if "stockCode" in schema else "stock_code"
+
+ allIds = list(_REVENUE_IDS | _OP_IDS | _NI_IDS)
+ allNms = list(_REVENUE_NMS | _OP_NMS | _NI_NMS)
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ & (pl.col("account_id").is_in(allIds) | pl.col("account_nm").is_in(allNms))
+ )
+ .collect()
+ )
+ if target.is_empty():
+ return pl.DataFrame()
+
+ # 연결 우선
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ if not cfs.is_empty():
+ target = cfs
+
+ # 연도별로 분리하여 CAGR 계산
+ return _computeGrowth(target, scCol)
+
+
+def _scanPerFile() -> pl.DataFrame:
+ """종목별 finance parquet 순회 fallback."""
+ from dartlab.core.dataLoader import _dataDir
+
+ financeDir = Path(_dataDir("finance"))
+ parquetFiles = sorted(financeDir.glob("*.parquet"))
+
+ allDfs = []
+ for pf in parquetFiles:
+ try:
+ df = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if df.is_empty():
+ continue
+ cfs = df.filter(pl.col("fs_nm").str.contains("연결"))
+ allDfs.append(cfs if not cfs.is_empty() else df)
+
+ if not allDfs:
+ return pl.DataFrame()
+
+ combined = pl.concat(allDfs, how="diagonal_relaxed")
+ scCol = "stockCode" if "stockCode" in combined.columns else "stock_code"
+ return _computeGrowth(combined, scCol)
+
+
+def _computeGrowth(target: pl.DataFrame, scCol: str) -> pl.DataFrame:
+ """종목별 3년 CAGR 계산."""
+ years = sorted(target["bsns_year"].unique().to_list(), reverse=True)
+ if len(years) < 2:
+ return pl.DataFrame()
+
+ latestYear = years[0]
+ # 3년 전 연도 찾기, 없으면 가장 오래된 연도
+ baseYear = None
+ nYears = 0
+ for y in years:
+ if int(latestYear) - int(y) >= 3:
+ baseYear = y
+ nYears = int(latestYear) - int(y)
+ break
+ if baseYear is None:
+ baseYear = years[-1]
+ nYears = int(latestYear) - int(baseYear)
+ if nYears == 0:
+ return pl.DataFrame()
+
+ latest = target.filter(pl.col("bsns_year") == latestYear)
+ base = target.filter(pl.col("bsns_year") == baseYear)
+
+ rows: list[dict] = []
+ for code in target[scCol].unique().to_list():
+ latSub = latest.filter(pl.col(scCol) == code)
+ baseSub = base.filter(pl.col(scCol) == code)
+
+ revNow = extractAccount(latSub, _REVENUE_IDS, _REVENUE_NMS)
+ revOld = extractAccount(baseSub, _REVENUE_IDS, _REVENUE_NMS)
+ opNow = extractAccount(latSub, _OP_IDS, _OP_NMS)
+ opOld = extractAccount(baseSub, _OP_IDS, _OP_NMS)
+ niNow = extractAccount(latSub, _NI_IDS, _NI_NMS)
+ niOld = extractAccount(baseSub, _NI_IDS, _NI_NMS)
+
+ revCagr = _cagr(revOld, revNow, nYears) if revOld and revOld > 0 else None
+ opCagr = _cagr(opOld, opNow, nYears) if opOld and opOld > 0 else None
+ niCagr = _cagr(niOld, niNow, nYears) if niOld and niOld > 0 else None
+
+ if revCagr is None and opCagr is None and niCagr is None:
+ continue
+
+ rows.append(
+ {
+ "stockCode": code,
+ "revenue": round(revNow) if revNow else None,
+ "revenueCagr": revCagr,
+ "opIncomeCagr": opCagr,
+ "netIncomeCagr": niCagr,
+ "years": nYears,
+ "grade": _gradeGrowth(revCagr, opCagr),
+ "pattern": _classifyPattern(revCagr, opCagr, niCagr),
+ }
+ )
+
+ if not rows:
+ return pl.DataFrame()
+
+ schema = {
+ "stockCode": pl.Utf8,
+ "revenue": pl.Float64,
+ "revenueCagr": pl.Float64,
+ "opIncomeCagr": pl.Float64,
+ "netIncomeCagr": pl.Float64,
+ "years": pl.Int64,
+ "grade": pl.Utf8,
+ "pattern": pl.Utf8,
+ }
+ return pl.DataFrame(rows, schema=schema)
+
+
+__all__ = ["scanGrowth"]
diff --git a/src/dartlab/scan/growth/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/growth/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..fe4082e151d586ad4dcd875c4cf61682b55b07c9
Binary files /dev/null and b/src/dartlab/scan/growth/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/insider/__init__.py b/src/dartlab/scan/insider/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4b3df1400b4c8de8ed636ae82addb460f067d1bb
--- /dev/null
+++ b/src/dartlab/scan/insider/__init__.py
@@ -0,0 +1,140 @@
+"""내부자 지분 변동 + 자기주식 — 경영권 안정성 분석."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan._helpers import find_latest_year, parse_num, scan_parquets
+
+
+def _scanHolderChange() -> dict[str, dict]:
+ """majorHolder 2개년 비교 → 종목별 최대주주 지분 변동."""
+ raw = scan_parquets(
+ "majorHolder",
+ ["stockCode", "year", "quarter", "bsis_posesn_stock_qota_rt"],
+ )
+ if raw.is_empty():
+ return {}
+
+ years = sorted(raw["year"].unique().to_list(), reverse=True)
+ # 유효 데이터 있는 최신 2개 연도
+ validYears: list[str] = []
+ for y in years:
+ sub = raw.filter(pl.col("year") == y)
+ ok = sub.filter(
+ pl.col("bsis_posesn_stock_qota_rt").is_not_null() & (pl.col("bsis_posesn_stock_qota_rt") != "-")
+ ).shape[0]
+ if ok >= 500:
+ validYears.append(y)
+ if len(validYears) == 2:
+ break
+
+ if not validYears:
+ return {}
+
+ def _maxPct(group: pl.DataFrame) -> float | None:
+ vals = []
+ for row in group.iter_rows(named=True):
+ v = parse_num(row.get("bsis_posesn_stock_qota_rt"))
+ if v is not None and 0 <= v <= 100:
+ vals.append(v)
+ return max(vals) if vals else None
+
+ result: dict[str, dict] = {}
+ for code in raw["stockCode"].unique().to_list():
+ sub = raw.filter(pl.col("stockCode") == code)
+ newSub = sub.filter(pl.col("year") == validYears[0])
+ newPct = _maxPct(newSub) if not newSub.is_empty() else None
+ if newPct is None:
+ continue
+
+ entry: dict = {"pct": round(newPct, 2)}
+
+ if len(validYears) >= 2:
+ oldSub = sub.filter(pl.col("year") == validYears[1])
+ oldPct = _maxPct(oldSub) if not oldSub.is_empty() else None
+ if oldPct is not None:
+ entry["prevPct"] = round(oldPct, 2)
+ entry["change"] = round(newPct - oldPct, 2)
+
+ result[code] = entry
+
+ return result
+
+
+def _scanTreasuryStock() -> dict[str, dict]:
+ """treasuryStock → 종목별 자기주식 현황 (기말 보유수량 기준)."""
+ raw = scan_parquets(
+ "treasuryStock",
+ ["stockCode", "year", "quarter", "stock_knd", "trmend_qy"],
+ )
+ if raw.is_empty():
+ return {}
+
+ latestYear = find_latest_year(raw, "trmend_qy", 100)
+ if latestYear is None:
+ return {}
+
+ result: dict[str, dict] = {}
+ sub = raw.filter(pl.col("year") == latestYear)
+ for code in sub["stockCode"].unique().to_list():
+ grp = sub.filter(pl.col("stockCode") == code)
+ totalShares = 0
+ for row in grp.iter_rows(named=True):
+ v = parse_num(row.get("trmend_qy"))
+ if v is not None and v > 0:
+ totalShares += int(v)
+ if totalShares > 0:
+ result[code] = {"treasuryShares": totalShares}
+
+ return result
+
+
+def scanInsider() -> pl.DataFrame:
+ """종목별 내부자 지분 변동 + 자기주식 종합.
+
+ 컬럼: stockCode, holderPct, holderChange, treasuryShares, stability
+ """
+ holderMap = _scanHolderChange()
+ treasuryMap = _scanTreasuryStock()
+
+ allCodes = set(holderMap.keys()) | set(treasuryMap.keys())
+ if not allCodes:
+ return pl.DataFrame()
+
+ rows: list[dict] = []
+ for code in allCodes:
+ h = holderMap.get(code, {})
+ t = treasuryMap.get(code, {})
+
+ pct = h.get("pct")
+ change = h.get("change")
+ treasuryShares = t.get("treasuryShares")
+
+ # 경영권 안정성 판단
+ if pct is not None and pct >= 50:
+ stability = "안정"
+ elif pct is not None and pct >= 30:
+ stability = "보통"
+ elif pct is not None and pct >= 20:
+ stability = "취약"
+ elif pct is not None:
+ stability = "위험"
+ else:
+ stability = "미확인"
+
+ # 대규모 변동 시 경고
+ if change is not None and change <= -5:
+ stability = "경고"
+
+ rows.append(
+ {
+ "stockCode": code,
+ "holderPct": pct,
+ "holderChange": change,
+ "treasuryShares": treasuryShares,
+ "stability": stability,
+ }
+ )
+
+ return pl.DataFrame(rows) if rows else pl.DataFrame()
diff --git a/src/dartlab/scan/liquidity/__init__.py b/src/dartlab/scan/liquidity/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0f51d92b04b01d3faefa411825bd0e13d5163f89
--- /dev/null
+++ b/src/dartlab/scan/liquidity/__init__.py
@@ -0,0 +1,179 @@
+"""유동성 스캔 -- 유동비율 + 당좌비율 + 등급."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from dartlab.scan._helpers import _ensureScanData, extractAccount
+
+# ── 유동자산 ──
+
+CA_IDS = {
+ "CurrentAssets",
+ "currentAssets",
+ "ifrs-full_CurrentAssets",
+ "dart_CurrentAssets",
+}
+CA_NMS = {"유동자산", "유동자산 합계"}
+
+# ── 유동부채 ──
+
+CL_IDS = {
+ "CurrentLiabilities",
+ "currentLiabilities",
+ "ifrs-full_CurrentLiabilities",
+ "dart_CurrentLiabilities",
+}
+CL_NMS = {"유동부채", "유동부채 합계"}
+
+# ── 재고자산 ──
+
+INV_IDS = {
+ "Inventories",
+ "inventories",
+ "ifrs-full_Inventories",
+ "dart_Inventories",
+}
+INV_NMS = {"재고자산"}
+
+
+def _gradeLiquidity(currentRatio: float) -> str:
+ """유동비율 → 등급."""
+ if currentRatio >= 200:
+ return "우수"
+ if currentRatio >= 150:
+ return "양호"
+ if currentRatio >= 100:
+ return "보통"
+ if currentRatio >= 50:
+ return "주의"
+ return "위험"
+
+
+_extractVal = extractAccount # backward compat alias
+
+
+def _scanFromMerged(scanPath: Path) -> pl.DataFrame:
+ """프리빌드 finance.parquet → 종목별 유동성."""
+ schema = pl.scan_parquet(str(scanPath)).collect_schema().names()
+ scCol = "stockCode" if "stockCode" in schema else "stock_code"
+
+ allIds = list(CA_IDS | CL_IDS | INV_IDS)
+ allNms = list(CA_NMS | CL_NMS | INV_NMS)
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ (pl.col("sj_div") == "BS")
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ & (pl.col("account_id").is_in(allIds) | pl.col("account_nm").is_in(allNms))
+ )
+ .collect()
+ )
+ if target.is_empty():
+ return pl.DataFrame()
+
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ if not cfs.is_empty():
+ target = cfs
+
+ latestYear = target.group_by(scCol).agg(pl.col("bsns_year").max().alias("_maxYear"))
+ target = target.join(latestYear, on=scCol).filter(pl.col("bsns_year") == pl.col("_maxYear")).drop("_maxYear")
+
+ rows: list[dict] = []
+ for code in target[scCol].unique().to_list():
+ sub = target.filter(pl.col(scCol) == code)
+
+ ca = _extractVal(sub, CA_IDS, CA_NMS)
+ cl = _extractVal(sub, CL_IDS, CL_NMS)
+ inv = _extractVal(sub, INV_IDS, INV_NMS)
+
+ if ca is None or cl is None or cl == 0:
+ continue
+
+ currentRatio = ca / cl * 100
+ quickAssets = ca - (inv or 0)
+ quickRatio = quickAssets / cl * 100 if cl > 0 else None
+
+ rows.append(
+ {
+ "stockCode": code,
+ "currentAssets": round(ca),
+ "currentLiabilities": round(cl),
+ "inventories": round(inv) if inv is not None else None,
+ "currentRatio": round(currentRatio, 1),
+ "quickRatio": round(quickRatio, 1) if quickRatio is not None else None,
+ "grade": _gradeLiquidity(currentRatio),
+ }
+ )
+
+ return pl.DataFrame(rows) if rows else pl.DataFrame()
+
+
+def _scanPerFile() -> pl.DataFrame:
+ """종목별 finance parquet 순회 fallback."""
+ from dartlab.core.dataLoader import _dataDir
+
+ financeDir = Path(_dataDir("finance"))
+ parquetFiles = sorted(financeDir.glob("*.parquet"))
+
+ rows: list[dict] = []
+ for pf in parquetFiles:
+ code = pf.stem
+ try:
+ df = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ (pl.col("sj_div") == "BS")
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if df.is_empty() or "account_id" not in df.columns:
+ continue
+
+ cfs = df.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else df
+
+ years = sorted(target["bsns_year"].unique().to_list(), reverse=True)
+ if not years:
+ continue
+ latest = target.filter(pl.col("bsns_year") == years[0])
+
+ ca = _extractVal(latest, CA_IDS, CA_NMS)
+ cl = _extractVal(latest, CL_IDS, CL_NMS)
+ inv = _extractVal(latest, INV_IDS, INV_NMS)
+
+ if ca is None or cl is None or cl == 0:
+ continue
+
+ currentRatio = ca / cl * 100
+ quickAssets = ca - (inv or 0)
+ quickRatio = quickAssets / cl * 100 if cl > 0 else None
+
+ rows.append(
+ {
+ "stockCode": code,
+ "currentAssets": round(ca),
+ "currentLiabilities": round(cl),
+ "inventories": round(inv) if inv is not None else None,
+ "currentRatio": round(currentRatio, 1),
+ "quickRatio": round(quickRatio, 1) if quickRatio is not None else None,
+ "grade": _gradeLiquidity(currentRatio),
+ }
+ )
+
+ return pl.DataFrame(rows) if rows else pl.DataFrame()
+
+
+def scanLiquidity() -> pl.DataFrame:
+ """전종목 유동성 스캔 -- 유동비율 + 당좌비율 + 등급."""
+ scanDir = _ensureScanData()
+ scanPath = scanDir / "finance.parquet"
+ if scanPath.exists():
+ return _scanFromMerged(scanPath)
+ return _scanPerFile()
diff --git a/src/dartlab/scan/liquidity/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/liquidity/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..24237bcbfef84afcd018c3ce29c25bf0cb66c9e0
Binary files /dev/null and b/src/dartlab/scan/liquidity/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/macroBeta.py b/src/dartlab/scan/macroBeta.py
new file mode 100644
index 0000000000000000000000000000000000000000..dd11122a979a3850eefd39eb43a0b60ccbd5a3f6
--- /dev/null
+++ b/src/dartlab/scan/macroBeta.py
@@ -0,0 +1,292 @@
+"""scan macroBeta — 전종목 거시경제 베타 횡단면.
+
+사전 조건: ECOS 거시지표 Parquet 캐시 (~/.dartlab/cache/macro/ecos/)가 존재해야 함.
+``Ecos().series("GDP", enrich=True)`` 등으로 사전 수집.
+
+사용법::
+
+ dartlab.scan("macroBeta") # 전종목 GDP/금리/환율 베타
+ dartlab.scan("macroBeta", "005930") # 삼성전자만 필터
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+import polars as pl
+
+log = logging.getLogger(__name__)
+
+
+def scan_macroBeta(
+ *,
+ stockCode: str | None = None,
+) -> pl.DataFrame:
+ """전종목 거시경제 베타 횡단면 계산.
+
+ 각 종목의 매출 성장률 vs GDP/금리/환율 변화율 간 OLS 베타를 계산한다.
+ scan 데이터(ratioSeries)에서 매출 시계열을 가져오고,
+ Parquet 캐시에서 거시지표를 로드한다.
+
+ Returns:
+ DataFrame with columns:
+ - stockCode, companyName, sector
+ - gdpBeta, rateBeta, fxBeta
+ - rSquared, nObs, confidence
+ """
+ from dartlab.scan._helpers import _ensureScanData
+
+ # 전종목 매출 시계열 로드 (프리빌드 finance.parquet에서 추출)
+ try:
+ revDf = _loadRevenueSeries(_ensureScanData())
+ except Exception as exc: # noqa: BLE001
+ log.warning("매출 시계열 로드 실패: %s", exc)
+ return _emptyDf()
+
+ if revDf is None or revDf.is_empty():
+ return _emptyDf()
+
+ # stockCode 필터
+ if stockCode:
+ revDf = revDf.filter(pl.col("stockCode") == stockCode)
+ if revDf.is_empty():
+ return _emptyDf()
+
+ # 기간 컬럼 추출 (연간)
+ periodCols = [c for c in revDf.columns if c.endswith("A") and c[:4].isdigit()]
+ periodCols.sort(reverse=True)
+ if len(periodCols) < 4:
+ log.warning("연간 기간 컬럼 부족: %d개", len(periodCols))
+ return _emptyDf()
+
+ # 거시지표 로드 (Parquet 캐시)
+ macroData = _loadMacroForScan(periodCols)
+ if macroData is None:
+ log.warning("거시지표 캐시 없음. Ecos().series('GDP', enrich=True) 먼저 실행")
+ return _emptyDf()
+
+ # 거시 변화율 계산
+ macroChanges = _calcMacroChanges(macroData)
+
+ # 종목별 OLS
+ rows: list[dict] = []
+ for row in revDf.iter_rows(named=True):
+ code = row.get("stockCode", "")
+ name = row.get("companyName", "")
+ sector = row.get("sector", "")
+
+ # 매출 성장률 계산
+ revGrowth = []
+ for i in range(len(periodCols) - 1):
+ cur = row.get(periodCols[i])
+ prev = row.get(periodCols[i + 1])
+ if cur is not None and prev is not None and prev != 0:
+ revGrowth.append((cur - prev) / abs(prev) * 100)
+ else:
+ revGrowth.append(None)
+
+ # OLS 회귀
+ betas, rSq = _quickOLS(revGrowth, macroChanges)
+ if betas is None:
+ continue
+
+ nValid = sum(1 for g in revGrowth if g is not None)
+ confidence = "high" if nValid >= 8 and (rSq or 0) > 0.3 else ("medium" if nValid >= 5 else "low")
+
+ rows.append(
+ {
+ "stockCode": code,
+ "companyName": name,
+ "sector": sector,
+ "gdpBeta": round(betas.get("gdp", 0), 3),
+ "rateBeta": round(betas.get("rate", 0), 3),
+ "fxBeta": round(betas.get("fx", 0), 3),
+ "rSquared": round(rSq, 4) if rSq is not None else None,
+ "nObs": nValid,
+ "confidence": confidence,
+ }
+ )
+
+ if not rows:
+ return _emptyDf()
+
+ result = pl.DataFrame(rows).sort("gdpBeta", descending=True)
+ return result
+
+
+def _loadRevenueSeries(scanDir: Path) -> pl.DataFrame | None:
+ """프리빌드 finance.parquet에서 종목별 매출 시계열 추출.
+
+ Returns:
+ DataFrame with columns: stockCode, companyName, sector, , ...
+ """
+ from dartlab.scan._helpers import parse_num
+
+ scanPath = scanDir / "finance.parquet"
+ if not scanPath.exists():
+ return None
+
+ schema = pl.scan_parquet(str(scanPath)).collect_schema().names()
+ scCol = "stockCode" if "stockCode" in schema else "stock_code"
+
+ REVENUE_IDS = {
+ "Revenue",
+ "Revenues",
+ "revenue",
+ "revenues",
+ "ifrs-full_Revenue",
+ "dart_Revenue",
+ "RevenueFromContractsWithCustomers",
+ }
+ REVENUE_NMS = {"매출액", "수익(매출액)", "영업수익", "매출", "순영업수익"}
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ & (pl.col("account_id").is_in(list(REVENUE_IDS)) | pl.col("account_nm").is_in(list(REVENUE_NMS)))
+ )
+ .collect()
+ )
+ if target.is_empty():
+ return None
+
+ # 연결 우선
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ if not cfs.is_empty():
+ target = cfs
+
+ # 종목-연도별 매출 pivot
+ rows: dict[str, dict] = {}
+ for row in target.iter_rows(named=True):
+ code = row.get(scCol, "")
+ year = row.get("bsns_year", "")
+ val = parse_num(row.get("thstrm_amount"))
+ if not code or not year or val is None:
+ continue
+ if code not in rows:
+ rows[code] = {"stockCode": code, "companyName": "", "sector": ""}
+ colName = f"{year}A"
+ if colName not in rows[code] or val > 0:
+ rows[code][colName] = val
+
+ if not rows:
+ return None
+
+ return pl.DataFrame(list(rows.values()))
+
+
+def _loadMacroForScan(periodCols: list[str]) -> dict[str, list[float | None]] | None:
+ """Parquet 캐시에서 거시지표 로드 + 기간 정렬."""
+ from dartlab.gather.macro import alignToFinancialPeriods, loadMacroParquet
+
+ indicators = {"gdp": "GDP", "rate": "BASE_RATE", "fx": "USDKRW"}
+ result: dict[str, list[float | None]] = {}
+
+ for key, indicatorId in indicators.items():
+ df = loadMacroParquet(indicatorId, source="ecos")
+ if df is None or df.is_empty():
+ result[key] = [None] * len(periodCols)
+ continue
+
+ aligned = alignToFinancialPeriods(df, periodCols)
+ result[key] = aligned.get_column("value").to_list()
+
+ hasData = any(any(v is not None for v in vals) for vals in result.values())
+ return result if hasData else None
+
+
+def _calcMacroChanges(macroData: dict[str, list[float | None]]) -> dict[str, list[float | None]]:
+ """거시지표 전년대비 변화율."""
+ changes: dict[str, list[float | None]] = {}
+ for key, vals in macroData.items():
+ ch = []
+ for i in range(len(vals) - 1):
+ cur, prev = vals[i], vals[i + 1]
+ if cur is not None and prev is not None and prev != 0:
+ if key == "rate":
+ ch.append(cur - prev)
+ else:
+ ch.append((cur - prev) / abs(prev) * 100)
+ else:
+ ch.append(None)
+ changes[key] = ch
+ return changes
+
+
+def _quickOLS(
+ y: list[float | None],
+ macroChanges: dict[str, list[float | None]],
+) -> tuple[dict[str, float] | None, float | None]:
+ """간이 OLS (scan 용, 속도 우선)."""
+ n = min(len(y), *(len(v) for v in macroChanges.values()))
+ validY: list[float] = []
+ validX: list[list[float]] = []
+
+ for i in range(n):
+ yVal = y[i]
+ xVals = [macroChanges[k][i] for k in ["gdp", "rate", "fx"]]
+ if yVal is not None and all(x is not None for x in xVals):
+ validY.append(yVal)
+ validX.append(xVals)
+
+ if len(validY) < 3:
+ return None, None
+
+ nObs = len(validY)
+ k = 4
+ X = [[1.0] + row for row in validX]
+
+ XtX = [[sum(X[r][i] * X[r][j] for r in range(nObs)) for j in range(k)] for i in range(k)]
+ Xty = [sum(X[r][i] * validY[r] for r in range(nObs)) for i in range(k)]
+
+ inv = _invertMatrix4(XtX)
+ if inv is None:
+ return None, None
+
+ beta = [sum(inv[i][j] * Xty[j] for j in range(k)) for i in range(k)]
+
+ yMean = sum(validY) / nObs
+ ssTot = sum((y_ - yMean) ** 2 for y_ in validY)
+ yPred = [sum(X[r][j] * beta[j] for j in range(k)) for r in range(nObs)]
+ ssRes = sum((validY[r] - yPred[r]) ** 2 for r in range(nObs))
+ rSq = 1 - ssRes / ssTot if ssTot > 0 else 0.0
+
+ return {"gdp": beta[1], "rate": beta[2], "fx": beta[3]}, rSq
+
+
+def _invertMatrix4(m: list[list[float]]) -> list[list[float]] | None:
+ """4x4 가우스-조르단 역행렬."""
+ n = len(m)
+ aug = [row[:] + [1.0 if i == j else 0.0 for j in range(n)] for i, row in enumerate(m)]
+ for col in range(n):
+ maxRow = max(range(col, n), key=lambda r: abs(aug[r][col]))
+ if abs(aug[maxRow][col]) < 1e-12:
+ return None
+ aug[col], aug[maxRow] = aug[maxRow], aug[col]
+ pivot = aug[col][col]
+ aug[col] = [x / pivot for x in aug[col]]
+ for row in range(n):
+ if row != col:
+ factor = aug[row][col]
+ aug[row] = [aug[row][j] - factor * aug[col][j] for j in range(2 * n)]
+ return [row[n:] for row in aug]
+
+
+def _emptyDf() -> pl.DataFrame:
+ """빈 결과 DataFrame."""
+ return pl.DataFrame(
+ schema={
+ "stockCode": pl.Utf8,
+ "companyName": pl.Utf8,
+ "sector": pl.Utf8,
+ "gdpBeta": pl.Float64,
+ "rateBeta": pl.Float64,
+ "fxBeta": pl.Float64,
+ "rSquared": pl.Float64,
+ "nObs": pl.Int64,
+ "confidence": pl.Utf8,
+ }
+ )
diff --git a/src/dartlab/scan/network/__init__.py b/src/dartlab/scan/network/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..89dcd3a4144acf46eaecdeb1e35690a08d8fbbbc
--- /dev/null
+++ b/src/dartlab/scan/network/__init__.py
@@ -0,0 +1,118 @@
+"""한국 상장사 관계 지도 — 출자/지분/인적 관계 그래프.
+
+Public API:
+ build_graph() → data dict (전체 파이프라인)
+ export_full(data) → full JSON dict
+ export_overview(data, full) → overview JSON dict
+ export_ego(data, full, code) → ego JSON dict
+"""
+
+from __future__ import annotations
+
+import time
+
+import polars as pl
+
+from dartlab.scan.network.classifier import classify_balanced
+from dartlab.scan.network.cycles import detect_cycles
+from dartlab.scan.network.edges import (
+ build_holder_edges,
+ build_invest_edges,
+ deduplicate_edges,
+)
+from dartlab.scan.network.export import (
+ export_ego,
+ export_full,
+ export_overview,
+)
+from dartlab.scan.network.scanner import (
+ load_listing,
+ scan_invested,
+ scan_major_holders,
+)
+from dartlab.scan.network.scanner import (
+ scan_affiliate_docs as _scan_affiliate_docs_fn,
+)
+
+
+def build_graph(*, verbose: bool = True) -> dict:
+ """전체 파이프라인 실행 → data dict.
+
+ Returns:
+ dict with keys: listing_meta, code_to_name, code_to_group,
+ invest_edges, corp_edges, person_edges, all_node_ids, cycles
+ """
+ t0 = time.perf_counter()
+
+ def _log(msg: str) -> None:
+ if verbose:
+ print(msg)
+
+ _log("1. 상장사 목록...")
+ name_to_code, code_to_name, listing_codes, listing_meta = load_listing()
+
+ _log("2. investedCompany 스캔...")
+ raw_inv = scan_invested()
+ invest_edges = build_invest_edges(raw_inv, name_to_code, code_to_name)
+ invest_deduped = deduplicate_edges(invest_edges)
+ invest_deduped = invest_deduped.filter(pl.col("from_code") != pl.col("to_code"))
+ _log(f" → {len(invest_deduped):,} edges")
+
+ _log("3. majorHolder 스캔...")
+ raw_mh = scan_major_holders()
+ corp_edges, person_edges = build_holder_edges(raw_mh, name_to_code)
+ _log(f" → corp {len(corp_edges):,}, person {len(person_edges):,}")
+
+ # 노드 수집
+ all_node_ids: set[str] = set()
+ listed_only = invest_deduped.filter(pl.col("is_listed") & pl.col("to_code").is_not_null())
+ for row in listed_only.iter_rows(named=True):
+ all_node_ids.add(row["from_code"])
+ all_node_ids.add(row["to_code"])
+ matched_corp = corp_edges.filter(pl.col("from_code").is_not_null())
+ for row in matched_corp.iter_rows(named=True):
+ all_node_ids.add(row["from_code"])
+ all_node_ids.add(row["to_code"])
+ all_node_ids = all_node_ids & listing_codes
+ _log(f" → {len(all_node_ids)} 상장사 노드")
+
+ _log("4. docs ground truth...")
+ docs_gt = _scan_affiliate_docs_fn(name_to_code, code_to_name)
+ _log(f" → {len(docs_gt)} 종목 매핑")
+
+ _log("5. 균형 분류...")
+ code_to_group = classify_balanced(
+ invest_deduped,
+ corp_edges,
+ person_edges,
+ all_node_ids,
+ code_to_name,
+ docs_gt,
+ verbose=verbose,
+ )
+
+ _log("6. 순환출자 탐지...")
+ cycles = detect_cycles(invest_deduped, code_to_name, max_length=6)
+ _log(f" → {len(cycles)}개")
+
+ elapsed = time.perf_counter() - t0
+ _log(f"\n파이프라인 완료: {elapsed:.1f}초")
+
+ return {
+ "listing_meta": listing_meta,
+ "code_to_name": code_to_name,
+ "code_to_group": code_to_group,
+ "invest_edges": invest_deduped,
+ "corp_edges": corp_edges,
+ "person_edges": person_edges,
+ "all_node_ids": all_node_ids,
+ "cycles": cycles,
+ }
+
+
+__all__ = [
+ "build_graph",
+ "export_full",
+ "export_overview",
+ "export_ego",
+]
diff --git a/src/dartlab/scan/network/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/network/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..dd68e492a4970cfdab17b41ecb48b6fdecdc15d4
Binary files /dev/null and b/src/dartlab/scan/network/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/network/__pycache__/classifier.cpython-312.pyc b/src/dartlab/scan/network/__pycache__/classifier.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..9e361e06af98eb0fc65f7d6a6ef0748d8f4a5997
Binary files /dev/null and b/src/dartlab/scan/network/__pycache__/classifier.cpython-312.pyc differ
diff --git a/src/dartlab/scan/network/__pycache__/cycles.cpython-312.pyc b/src/dartlab/scan/network/__pycache__/cycles.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..92dfbce9b76cf11005768e84581e41177a136ef1
Binary files /dev/null and b/src/dartlab/scan/network/__pycache__/cycles.cpython-312.pyc differ
diff --git a/src/dartlab/scan/network/__pycache__/edges.cpython-312.pyc b/src/dartlab/scan/network/__pycache__/edges.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..1952ae48273de49b40980751f76033780b18fd4e
Binary files /dev/null and b/src/dartlab/scan/network/__pycache__/edges.cpython-312.pyc differ
diff --git a/src/dartlab/scan/network/__pycache__/export.cpython-312.pyc b/src/dartlab/scan/network/__pycache__/export.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..5a97c9bb6d4edf2b28db9e897c9eefec32cf7565
Binary files /dev/null and b/src/dartlab/scan/network/__pycache__/export.cpython-312.pyc differ
diff --git a/src/dartlab/scan/network/__pycache__/scanner.cpython-312.pyc b/src/dartlab/scan/network/__pycache__/scanner.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..720639caab89cb9b67a0e7a92b18affa34743787
Binary files /dev/null and b/src/dartlab/scan/network/__pycache__/scanner.cpython-312.pyc differ
diff --git a/src/dartlab/scan/network/classifier.py b/src/dartlab/scan/network/classifier.py
new file mode 100644
index 0000000000000000000000000000000000000000..5fa6022f9b5690b733dceabb357ac70ec2cb2a20
--- /dev/null
+++ b/src/dartlab/scan/network/classifier.py
@@ -0,0 +1,367 @@
+"""5단계 균형 분류 파이프라인 — docs lock → well-known → 경영참여 → 법인주주 → 공유주주 → 키워드."""
+
+from __future__ import annotations
+
+from collections import Counter, defaultdict
+
+import polars as pl
+
+# ── seed 상수 ─────────────────────────────────────────────
+
+# well-known base seed (007 기반)
+_WELL_KNOWN: dict[str, str] = {
+ "005930": "삼성",
+ "006400": "삼성",
+ "009150": "삼성",
+ "028260": "삼성",
+ "032830": "삼성",
+ "018260": "삼성",
+ "000810": "삼성",
+ "029780": "삼성",
+ "016360": "삼성",
+ "207940": "삼성",
+ "030000": "삼성",
+ "068290": "삼성",
+ "010140": "삼성",
+ "005380": "현대차",
+ "000270": "현대차",
+ "012330": "현대차",
+ "005387": "현대차",
+ "004020": "현대차",
+ "079160": "현대차",
+ "307950": "현대차",
+ "267260": "현대차",
+ "064350": "현대차",
+ "329180": "HD현대",
+ "267250": "HD현대",
+ "009540": "HD현대",
+ "034730": "SK",
+ "017670": "SK",
+ "000660": "SK",
+ "361610": "SK",
+ "018670": "SK",
+ "034020": "SK",
+ "003550": "LG",
+ "066570": "LG",
+ "051910": "LG",
+ "034220": "LG",
+ "373220": "LG",
+ "011070": "LG",
+ "011170": "LG",
+ "023530": "롯데",
+ "004990": "롯데",
+ "071050": "롯데",
+ "004000": "롯데",
+ "023150": "롯데",
+ "004440": "롯데",
+ "000880": "한화",
+ "009830": "한화",
+ "272210": "한화",
+ "042660": "한화",
+ "012450": "한화",
+ "078930": "GS",
+ "006360": "GS",
+ "001120": "GS",
+ "005490": "포스코",
+ "047050": "포스코",
+ "003670": "포스코",
+ "001040": "CJ",
+ "097950": "CJ",
+ "000120": "CJ",
+ "000150": "두산",
+ "336260": "두산",
+ "042670": "두산",
+ "035720": "카카오",
+ "293490": "카카오",
+ "377300": "카카오",
+ "035420": "네이버",
+ "004800": "효성",
+ "298040": "효성",
+ "298050": "효성",
+ "298020": "효성",
+ "004150": "한솔",
+ "213500": "한솔",
+ "009180": "한솔",
+ "007160": "사조",
+ "008060": "사조",
+ "006280": "녹십자",
+ "005250": "녹십자",
+ "136480": "하림",
+ "003380": "하림",
+ "105560": "KB",
+ "055550": "신한",
+ "086790": "하나",
+ "138930": "BNK",
+ "316140": "우리",
+ "010950": "S-Oil",
+ "180640": "한진칼",
+ "003490": "대한항공",
+ "004170": "신세계",
+ "139480": "신세계",
+}
+
+# well-known 확대판 (012 기반)
+WELL_KNOWN_EXT: dict[str, str] = {
+ **_WELL_KNOWN,
+ "001450": "현대차",
+ "086280": "현대차",
+ "004560": "현대차",
+ "011210": "현대차",
+ "000720": "현대차",
+ "214320": "현대차",
+ "001500": "현대차",
+ "088350": "한화",
+ "272450": "대한항공",
+ "020560": "대한항공",
+ "005430": "대한항공",
+ "002320": "대한항공",
+ "267850": "대한항공",
+ "298690": "대한항공",
+ "069960": "현대백화점",
+ "005440": "현대백화점",
+ "057050": "현대백화점",
+ "079430": "현대백화점",
+ "020000": "현대백화점",
+ "030200": "KT",
+ "161390": "한국타이어",
+ "001630": "종근당",
+ "185750": "종근당",
+ "063160": "종근당",
+ "023590": "다우",
+ "175250": "다우",
+ "016880": "웅진",
+ "095720": "웅진",
+ "006260": "LS",
+ "010120": "LS",
+ "086520": "에코프로",
+ "247540": "에코프로",
+ "383310": "에코프로",
+ "068270": "셀트리온",
+ "091990": "셀트리온",
+ "393890": "셀트리온",
+ "028300": "HLB",
+ "024850": "HLB",
+ "067630": "HLB",
+}
+
+# 이름 키워드 → 그룹 매칭
+GROUP_KEYWORDS: dict[str, list[str]] = {
+ "삼성": ["삼성"],
+ "현대차": ["현대", "기아"],
+ "SK": ["SK", "에스케이"],
+ "LG": ["LG", "엘지"],
+ "롯데": ["롯데"],
+ "한화": ["한화"],
+ "GS": ["GS", "지에스"],
+ "포스코": ["POSCO", "포스코"],
+ "CJ": ["CJ", "씨제이"],
+ "두산": ["두산"],
+ "HD현대": ["HD현대", "한국조선해양"],
+ "효성": ["효성"],
+ "한솔": ["한솔"],
+ "카카오": ["카카오"],
+ "네이버": ["네이버", "NAVER"],
+ "현대백화점": ["현대백화점"],
+ "KT": ["케이티", "KT"],
+ "LS": ["LS"],
+ "에코프로": ["에코프로"],
+ "셀트리온": ["셀트리온"],
+ "HLB": ["HLB"],
+ "종근당": ["종근당"],
+ "웅진": ["웅진"],
+}
+
+
+# ── 분류 로직 ─────────────────────────────────────────────
+
+
+def _label_group(members: list[str], code_to_name: dict[str, str]) -> str:
+ """컴포넌트 멤버에서 그룹 라벨."""
+ for m in members:
+ if m in _WELL_KNOWN:
+ return _WELL_KNOWN[m]
+ names = [code_to_name.get(m, "") for m in members]
+ if len(names) >= 2:
+ prefix = names[0]
+ for n in names[1:]:
+ while prefix and not n.startswith(prefix):
+ prefix = prefix[:-1]
+ if not prefix:
+ break
+ if len(prefix) >= 2:
+ return prefix.rstrip()
+ return code_to_name.get(members[0], members[0])
+
+
+def classify_balanced(
+ invest_edges: pl.DataFrame,
+ corp_edges: pl.DataFrame,
+ person_edges: pl.DataFrame,
+ all_node_ids: set[str],
+ code_to_name: dict[str, str],
+ docs_ground_truth: dict[str, str],
+ *,
+ verbose: bool = True,
+) -> dict[str, str]:
+ """5단계 균형 분류.
+
+ Phase 0: docs ground truth lock
+ Phase 1: well-known ext lock
+ Phase 2: 경영참여 방향 확장
+ Phase 3: majorHolder 법인주주
+ Phase 4: 공유 개인주주
+ Phase 5: 이름 키워드 + 나머지 독립
+ """
+ code_to_group: dict[str, str] = {}
+ locked: set[str] = set()
+
+ def _log(msg: str) -> None:
+ if verbose:
+ print(msg)
+
+ # Phase 0: docs lock
+ for code, group in docs_ground_truth.items():
+ if code in all_node_ids:
+ code_to_group[code] = group
+ locked.add(code)
+ phase0 = len(locked)
+
+ # Phase 1: well-known ext lock
+ for code, group in WELL_KNOWN_EXT.items():
+ if code in all_node_ids and code not in locked:
+ code_to_group[code] = group
+ locked.add(code)
+ phase1 = len(locked) - phase0
+ _log(f" Phase 0+1 (docs+well-known lock): {len(locked)} ({phase0} docs + {phase1} well-known)")
+
+ # known 그룹 = locked에 2명+
+ known_groups: dict[str, set[str]] = defaultdict(set)
+ for code in locked:
+ known_groups[code_to_group[code]].add(code)
+ known_group_names = {g for g, members in known_groups.items() if len(members) >= 2}
+ _log(f" known 그룹: {len(known_group_names)}개")
+
+ # Phase 2: 경영참여 방향 확장
+ mgmt = invest_edges.filter(
+ (pl.col("purpose") == "경영참여") & pl.col("is_listed") & pl.col("to_code").is_not_null()
+ )
+ mgmt_directed: dict[str, set[str]] = defaultdict(set)
+ for row in mgmt.iter_rows(named=True):
+ a, b = row["from_code"], row["to_code"]
+ if a != b and a in all_node_ids and b in all_node_ids:
+ mgmt_directed[a].add(b)
+
+ for _round in range(3):
+ newly = 0
+ for parent in list(code_to_group.keys()):
+ pg = code_to_group[parent]
+ for child in mgmt_directed.get(parent, set()):
+ if child in locked or child in code_to_group:
+ continue
+ if pg in known_group_names:
+ continue
+ code_to_group[child] = pg
+ newly += 1
+ if newly == 0:
+ break
+
+ # 미분류 경영참여 클러스터
+ parent_children: dict[str, set[str]] = defaultdict(set)
+ for parent, children in mgmt_directed.items():
+ if parent not in code_to_group:
+ for child in children:
+ if child not in code_to_group:
+ parent_children[parent].add(child)
+
+ for parent in list(code_to_group.keys()):
+ if code_to_group[parent] in known_group_names:
+ uc = [c for c in mgmt_directed.get(parent, set()) if c not in code_to_group and c not in locked]
+ if len(uc) >= 2:
+ for u in uc:
+ if u not in parent_children:
+ parent_children[u] = set()
+ for u2 in uc:
+ if u != u2 and u2 in mgmt_directed.get(u, set()):
+ parent_children[u].add(u2)
+
+ for parent, children in parent_children.items():
+ cluster = {parent} | children
+ if len(cluster) >= 2:
+ label = _label_group(list(cluster), code_to_name)
+ for m in cluster:
+ if m not in code_to_group:
+ code_to_group[m] = label
+
+ phase2 = len(code_to_group) - len(locked)
+ _log(f" Phase 2 (경영참여 확장): +{phase2}")
+
+ # Phase 3: majorHolder 법인 (known 20%+)
+ matched_corp = corp_edges.filter(pl.col("from_code").is_not_null())
+ phase3 = 0
+ for row in matched_corp.iter_rows(named=True):
+ fc, tc = row["from_code"], row["to_code"]
+ pct = row.get("ownership_pct") or 0
+ if fc in code_to_group and tc not in code_to_group and tc in all_node_ids and tc not in locked:
+ pg = code_to_group[fc]
+ if pg in known_group_names and pct < 20:
+ continue
+ code_to_group[tc] = pg
+ phase3 += 1
+ elif tc in code_to_group and fc not in code_to_group and fc in all_node_ids and fc not in locked:
+ pg = code_to_group[tc]
+ if pg in known_group_names and pct < 20:
+ continue
+ code_to_group[fc] = pg
+ phase3 += 1
+ _log(f" Phase 3 (법인주주): +{phase3}")
+
+ # Phase 4: 공유 개인주주
+ person_groups = (
+ person_edges.group_by("person_name")
+ .agg(
+ pl.col("to_code").unique().alias("companies"),
+ )
+ .filter(pl.col("companies").list.len() >= 2)
+ )
+
+ phase4 = 0
+ for row in person_groups.iter_rows(named=True):
+ codes = [c for c in row["companies"] if c in all_node_ids and c not in locked]
+ if len(codes) < 2:
+ continue
+ group_dist: dict[str, list[str]] = defaultdict(list)
+ unassigned: list[str] = []
+ for c in codes:
+ if c in code_to_group:
+ group_dist[code_to_group[c]].append(c)
+ else:
+ unassigned.append(c)
+ if not group_dist or not unassigned:
+ continue
+ best = max(group_dist, key=lambda g: len(group_dist[g]))
+ if len(group_dist[best]) >= 2:
+ for c in unassigned:
+ code_to_group[c] = best
+ phase4 += 1
+ _log(f" Phase 4 (공유주주): +{phase4}")
+
+ # Phase 5: 키워드 + 독립
+ phase5_kw = 0
+ for node in list(all_node_ids - set(code_to_group.keys())):
+ name = code_to_name.get(node, "")
+ for group, keywords in GROUP_KEYWORDS.items():
+ if any(kw in name for kw in keywords):
+ code_to_group[node] = group
+ phase5_kw += 1
+ break
+
+ still = all_node_ids - set(code_to_group.keys())
+ for node in still:
+ code_to_group[node] = code_to_name.get(node, node)
+
+ group_counts = Counter(code_to_group[n] for n in all_node_ids)
+ real_indep = sum(1 for c in group_counts.values() if c == 1)
+ _log(f" Phase 5 (키워드): +{phase5_kw}, 독립: {len(still)}")
+ _log(f" 최종: {len(all_node_ids)} nodes, 독립 {real_indep} ({real_indep / len(all_node_ids):.0%})")
+
+ return code_to_group
diff --git a/src/dartlab/scan/network/cycles.py b/src/dartlab/scan/network/cycles.py
new file mode 100644
index 0000000000000000000000000000000000000000..b9ef495281e9e101523a74552879b0116e0748ff
--- /dev/null
+++ b/src/dartlab/scan/network/cycles.py
@@ -0,0 +1,53 @@
+"""순환출자 DFS 탐지."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+
+import polars as pl
+
+
+def detect_cycles(
+ invest_edges: pl.DataFrame,
+ code_to_name: dict[str, str],
+ *,
+ max_length: int = 6,
+) -> list[list[str]]:
+ """상장사간 directed graph에서 순환출자 DFS 탐지."""
+ adj: dict[str, list[str]] = defaultdict(list)
+ listed = invest_edges.filter(
+ pl.col("is_listed") & pl.col("to_code").is_not_null() & (pl.col("from_code") != pl.col("to_code"))
+ )
+ for row in listed.iter_rows(named=True):
+ adj[row["from_code"]].append(row["to_code"])
+
+ cycles: list[list[str]] = []
+ visited_global: set[str] = set()
+
+ def dfs(node: str, path: list[str], path_set: set[str]) -> None:
+ if len(path) > max_length:
+ return
+ for nb in adj.get(node, []):
+ if nb == path[0] and len(path) >= 2:
+ cycles.append(path + [nb])
+ elif nb not in path_set and nb not in visited_global:
+ path.append(nb)
+ path_set.add(nb)
+ dfs(nb, path, path_set)
+ path.pop()
+ path_set.discard(nb)
+
+ for start in sorted(adj.keys()):
+ if start in visited_global:
+ continue
+ dfs(start, [start], {start})
+ visited_global.add(start)
+
+ unique: list[list[str]] = []
+ seen: set[frozenset[str]] = set()
+ for cycle in cycles:
+ key = frozenset(cycle[:-1])
+ if key not in seen:
+ seen.add(key)
+ unique.append(cycle)
+ return unique
diff --git a/src/dartlab/scan/network/edges.py b/src/dartlab/scan/network/edges.py
new file mode 100644
index 0000000000000000000000000000000000000000..83e41ceec437d73f7769e0c847a4c4eb4957a220
--- /dev/null
+++ b/src/dartlab/scan/network/edges.py
@@ -0,0 +1,202 @@
+"""엣지 구축 — investedCompany, majorHolder 원본 → 정제 엣지 테이블."""
+
+from __future__ import annotations
+
+import re
+
+import polars as pl
+
+from dartlab.scan.network.scanner import _normalize_company_name
+
+# ── investedCompany 엣지 ───────────────────────────────────
+
+
+def build_invest_edges(
+ raw: pl.DataFrame,
+ name_to_code: dict[str, str],
+ code_to_name: dict[str, str],
+) -> pl.DataFrame:
+ """investedCompany 원본 → 정제 엣지 테이블.
+
+ Returns:
+ DataFrame[from_code, from_name, to_name, to_name_norm, to_code,
+ is_listed, ownership_pct, book_value, purpose, year]
+ """
+ noise_names = {"-", "합계", "소계", "", " "}
+ df = raw.filter(pl.col("inv_prm").is_not_null() & ~pl.col("inv_prm").is_in(list(noise_names)))
+
+ # 지분율
+ if df["trmend_blce_qota_rt"].dtype == pl.Utf8:
+ df = df.with_columns(
+ pl.col("trmend_blce_qota_rt")
+ .str.replace_all(",", "")
+ .str.replace_all("-", "")
+ .cast(pl.Float64, strict=False)
+ .alias("ownership_pct")
+ )
+ else:
+ df = df.with_columns(pl.col("trmend_blce_qota_rt").cast(pl.Float64, strict=False).alias("ownership_pct"))
+ df = df.with_columns(
+ pl.when(pl.col("ownership_pct").is_between(0, 100))
+ .then(pl.col("ownership_pct"))
+ .otherwise(None)
+ .alias("ownership_pct")
+ )
+
+ # 장부가액
+ if df["trmend_blce_acntbk_amount"].dtype == pl.Utf8:
+ df = df.with_columns(
+ pl.col("trmend_blce_acntbk_amount")
+ .str.replace_all(",", "")
+ .str.replace_all("-", "")
+ .cast(pl.Float64, strict=False)
+ .alias("book_value")
+ )
+ else:
+ df = df.with_columns(pl.col("trmend_blce_acntbk_amount").cast(pl.Float64, strict=False).alias("book_value"))
+
+ # 투자목적
+ purpose_map = {
+ "경영참여": "경영참여",
+ "단순투자": "단순투자",
+ "일반투자": "단순투자",
+ "투자": "단순투자",
+ }
+ df = df.with_columns(
+ pl.col("invstmnt_purps")
+ .map_elements(
+ lambda v: purpose_map.get(v, "기타") if v and v != "-" else "기타",
+ return_dtype=pl.Utf8,
+ )
+ .alias("purpose")
+ )
+
+ # 법인명 매칭
+ norms, codes, listed = [], [], []
+ for name in df["inv_prm"].to_list():
+ norm = _normalize_company_name(name)
+ code = name_to_code.get(name) or name_to_code.get(norm)
+ norms.append(norm)
+ codes.append(code)
+ listed.append(code is not None)
+
+ df = df.with_columns(
+ pl.Series("to_name_norm", norms),
+ pl.Series("to_code", codes),
+ pl.Series("is_listed", listed),
+ pl.col("stockCode").map_elements(lambda c: code_to_name.get(c, c), return_dtype=pl.Utf8).alias("from_name"),
+ )
+
+ return df.select(
+ [
+ pl.col("stockCode").alias("from_code"),
+ "from_name",
+ pl.col("inv_prm").alias("to_name"),
+ "to_name_norm",
+ "to_code",
+ "is_listed",
+ "ownership_pct",
+ "book_value",
+ "purpose",
+ "year",
+ ]
+ )
+
+
+def deduplicate_edges(edges: pl.DataFrame) -> pl.DataFrame:
+ """최신 연도, (from, to) 중복 제거."""
+ latest_year = edges["year"].max()
+ return (
+ edges.filter(pl.col("year") == latest_year)
+ .sort("ownership_pct", descending=True, nulls_last=True)
+ .unique(subset=["from_code", "to_name_norm"], keep="first")
+ )
+
+
+# ── majorHolder 엣지 ──────────────────────────────────────
+
+
+_CORP_PATTERNS = re.compile(
+ r"㈜|주식회사|\(주\)|법인|조합|재단|기금|공사|은행|증권|보험|캐피탈|투자|펀드|"
+ r"[A-Z]{2,}|Co\.|Corp|Ltd|Inc|LLC|PTE|Fund|Trust|Bank"
+)
+_NOISE_NAMES = {"합계", "-", "소계", "", "계", "기타"}
+
+
+def _classify_holder(name: str) -> str:
+ """주주 유형: 'corp' | 'person' | 'noise'."""
+ if not name or name in _NOISE_NAMES:
+ return "noise"
+ if _CORP_PATTERNS.search(name):
+ return "corp"
+ hangul = re.sub(r"[^가-힣]", "", name)
+ if 2 <= len(hangul) <= 4 and len(name) <= 6:
+ return "person"
+ if len(name) > 8:
+ return "corp"
+ return "person"
+
+
+def build_holder_edges(
+ raw: pl.DataFrame,
+ name_to_code: dict[str, str],
+) -> tuple[pl.DataFrame, pl.DataFrame]:
+ """majorHolder → (corp_edges, person_edges)."""
+ df = raw.filter(pl.col("nm").is_not_null() & ~pl.col("nm").is_in(list(_NOISE_NAMES)))
+ latest_year = df["year"].max()
+ df = df.filter(pl.col("year") == latest_year)
+
+ # 지분율
+ if df["trmend_posesn_stock_qota_rt"].dtype == pl.Utf8:
+ df = df.with_columns(
+ pl.col("trmend_posesn_stock_qota_rt")
+ .str.replace_all(",", "")
+ .str.replace_all("-", "")
+ .cast(pl.Float64, strict=False)
+ .alias("ownership_pct")
+ )
+ else:
+ df = df.with_columns(
+ pl.col("trmend_posesn_stock_qota_rt").cast(pl.Float64, strict=False).alias("ownership_pct")
+ )
+
+ types, holder_codes = [], []
+ for row in df.iter_rows(named=True):
+ nm = row["nm"]
+ t = _classify_holder(nm)
+ types.append(t)
+ if t == "corp":
+ norm = _normalize_company_name(nm)
+ holder_codes.append(name_to_code.get(nm) or name_to_code.get(norm))
+ else:
+ holder_codes.append(None)
+
+ df = df.with_columns(
+ pl.Series("holder_type", types),
+ pl.Series("holder_code", holder_codes),
+ )
+
+ corp = df.filter(pl.col("holder_type") == "corp")
+ corp_edges = corp.select(
+ [
+ pl.col("holder_code").alias("from_code"),
+ pl.col("nm").alias("from_name"),
+ pl.col("stockCode").alias("to_code"),
+ pl.col("relate"),
+ pl.col("ownership_pct"),
+ pl.col("year"),
+ ]
+ )
+
+ person = df.filter(pl.col("holder_type") == "person")
+ person_edges = person.select(
+ [
+ pl.col("nm").alias("person_name"),
+ pl.col("stockCode").alias("to_code"),
+ pl.col("relate"),
+ pl.col("ownership_pct"),
+ pl.col("year"),
+ ]
+ )
+
+ return corp_edges, person_edges
diff --git a/src/dartlab/scan/network/export.py b/src/dartlab/scan/network/export.py
new file mode 100644
index 0000000000000000000000000000000000000000..649415d89b2f70aa292758a072c819eb25680c5e
--- /dev/null
+++ b/src/dartlab/scan/network/export.py
@@ -0,0 +1,325 @@
+"""JSON 내보내기 — full, overview, ego (v4 보강 포맷)."""
+
+from __future__ import annotations
+
+from collections import Counter, defaultdict
+
+import polars as pl
+
+# ── 내부 빌더 ─────────────────────────────────────────────
+
+
+def _build_enriched_edges(data: dict) -> list[dict]:
+ """회사 엣지 — 모든 투자 목적(경영참여+단순투자+기타) 포함."""
+ edges: list[dict] = []
+ seen: set[tuple[str, str, str]] = set()
+
+ # L1: investedCompany — 모든 목적
+ listed = data["invest_edges"].filter(pl.col("is_listed") & pl.col("to_code").is_not_null())
+ for row in listed.iter_rows(named=True):
+ key = (row["from_code"], row["to_code"], "investment")
+ if key in seen:
+ continue
+ seen.add(key)
+ edges.append(
+ {
+ "source": row["from_code"],
+ "target": row["to_code"],
+ "type": "investment",
+ "purpose": row.get("purpose", ""),
+ "ownershipPct": row.get("ownership_pct"),
+ }
+ )
+
+ # L2: majorHolder 법인
+ matched = data["corp_edges"].filter(pl.col("from_code").is_not_null())
+ for row in matched.iter_rows(named=True):
+ key = (row["from_code"], row["to_code"], "shareholder")
+ if key in seen:
+ continue
+ seen.add(key)
+ edges.append(
+ {
+ "source": row["from_code"],
+ "target": row["to_code"],
+ "type": "shareholder",
+ "ownershipPct": row.get("ownership_pct"),
+ }
+ )
+
+ return edges
+
+
+def _build_enriched_nodes(data: dict, edges: list[dict]) -> list[dict]:
+ """degree 재계산 + industry 메타."""
+ in_deg: dict[str, int] = defaultdict(int)
+ out_deg: dict[str, int] = defaultdict(int)
+ for e in edges:
+ if e["type"] != "person_shareholder":
+ out_deg[e["source"]] += 1
+ in_deg[e["target"]] += 1
+
+ nodes = []
+ for code in sorted(data["all_node_ids"]):
+ meta = data["listing_meta"].get(code, {})
+ ind, outd = in_deg.get(code, 0), out_deg.get(code, 0)
+ nodes.append(
+ {
+ "id": code,
+ "label": meta.get("name", code),
+ "type": "company",
+ "group": data["code_to_group"].get(code, ""),
+ "market": meta.get("market", ""),
+ "industry": meta.get("industry", ""),
+ "degree": ind + outd,
+ "inDegree": ind,
+ "outDegree": outd,
+ }
+ )
+ return nodes
+
+
+def _build_person_data(data: dict) -> tuple[list[dict], list[dict]]:
+ """인물 노드 + 인물→회사 엣지."""
+ person_edges = data["person_edges"]
+ code_to_group = data["code_to_group"]
+ all_nodes = data["all_node_ids"]
+ company_names = set(data["code_to_name"].values())
+
+ latest = person_edges.sort("year", descending=True).unique(subset=["person_name", "to_code"], keep="first")
+
+ person_map: dict[str, list[dict]] = defaultdict(list)
+ for row in latest.iter_rows(named=True):
+ if row["person_name"] in company_names:
+ continue
+ tc = row["to_code"]
+ if tc not in all_nodes:
+ continue
+ person_map[row["person_name"]].append(
+ {
+ "code": tc,
+ "group": code_to_group.get(tc, ""),
+ "pct": row.get("ownership_pct"),
+ "relate": row.get("relate", ""),
+ }
+ )
+
+ nodes: list[dict] = []
+ edges: list[dict] = []
+ seen: set[str] = set()
+
+ for name, holdings in person_map.items():
+ gh: dict[str, list[dict]] = defaultdict(list)
+ for h in holdings:
+ gh[h["group"]].append(h)
+ for group, items in gh.items():
+ unique_codes = {h["code"] for h in items}
+ if len(unique_codes) < 2:
+ continue
+ pid = f"person_{name}_{group}"
+ if pid in seen:
+ continue
+ seen.add(pid)
+ nodes.append(
+ {
+ "id": pid,
+ "label": name,
+ "type": "person",
+ "group": group,
+ "market": "",
+ "industry": "",
+ "degree": len(unique_codes),
+ "inDegree": 0,
+ "outDegree": len(unique_codes),
+ }
+ )
+ for h in items:
+ edges.append(
+ {
+ "source": pid,
+ "target": h["code"],
+ "type": "person_shareholder",
+ "ownershipPct": h["pct"] if h["pct"] > 0 else None,
+ "relate": h["relate"],
+ }
+ )
+ return nodes, edges
+
+
+# ── public export 함수 ────────────────────────────────────
+
+
+def export_full(data: dict) -> dict:
+ """full JSON — 전체 노드+엣지+그룹+업종+인물+순환출자."""
+ edges = _build_enriched_edges(data)
+ nodes = _build_enriched_nodes(data, edges)
+
+ # 인물 노드/엣지
+ pn, pe = _build_person_data(data)
+ nodes.extend(pn)
+ edges.extend(pe)
+
+ # 그룹 메타
+ gm: dict[str, list[str]] = defaultdict(list)
+ for code in data["all_node_ids"]:
+ gm[data["code_to_group"].get(code, "")].append(code)
+ groups = [
+ {"name": g, "rank": r, "memberCount": len(m), "members": sorted(m)}
+ for r, (g, m) in enumerate(sorted(gm.items(), key=lambda x: -len(x[1])), 1)
+ if len(m) >= 2
+ ]
+
+ # 업종 클러스터 메타
+ industry_groups: dict[str, list[str]] = defaultdict(list)
+ for code in data["all_node_ids"]:
+ meta = data["listing_meta"].get(code, {})
+ ind = meta.get("industry", "")
+ if ind:
+ industry_groups[ind].append(code)
+ industries = [
+ {"name": ind, "memberCount": len(members), "members": sorted(members)}
+ for ind, members in sorted(industry_groups.items(), key=lambda x: -len(x[1]))
+ if len(members) >= 2
+ ]
+
+ # 순환출자
+ cycles_out = [{"path": [data["code_to_name"].get(c, c) for c in cy], "codes": cy} for cy in data["cycles"]]
+
+ cc = sum(1 for n in nodes if n["type"] == "company")
+ pc = sum(1 for n in nodes if n["type"] == "person")
+
+ edge_types = Counter(e["type"] for e in edges)
+ purpose_counts = Counter(e.get("purpose", "") for e in edges if e["type"] == "investment")
+
+ return {
+ "meta": {
+ "year": 2025,
+ "nodeCount": len(nodes),
+ "edgeCount": len(edges),
+ "companyCount": cc,
+ "personCount": pc,
+ "groupCount": len(groups),
+ "industryCount": len(industries),
+ "cycleCount": len(cycles_out),
+ "edgeTypes": dict(edge_types),
+ "investmentPurposes": dict(purpose_counts),
+ "layers": ["investment", "shareholder", "person_shareholder"],
+ },
+ "nodes": nodes,
+ "edges": edges,
+ "groups": groups,
+ "industries": industries,
+ "cycles": cycles_out,
+ }
+
+
+def export_overview(data: dict, full: dict) -> dict:
+ """overview JSON — 그룹 슈퍼노드."""
+ gi: dict[str, dict] = {}
+ for node in full["nodes"]:
+ if node["type"] != "company":
+ continue
+ g = node["group"]
+ if g not in gi:
+ gi[g] = {"id": g, "label": g, "memberCount": 0, "totalDegree": 0, "members": []}
+ gi[g]["memberCount"] += 1
+ gi[g]["totalDegree"] += node["degree"]
+ gi[g]["members"].append(node["id"])
+
+ ge: dict[tuple[str, str], dict] = {}
+ ctg = data["code_to_group"]
+ for edge in full["edges"]:
+ sg, tg = ctg.get(edge["source"], ""), ctg.get(edge["target"], "")
+ if sg == tg:
+ continue
+ key = (sg, tg) if sg < tg else (tg, sg)
+ if key not in ge:
+ ge[key] = {"source": key[0], "target": key[1], "weight": 0, "types": set()}
+ ge[key]["weight"] += 1
+ ge[key]["types"].add(edge["type"])
+
+ edges_out = [
+ {"source": e["source"], "target": e["target"], "weight": e["weight"], "types": sorted(e["types"])}
+ for e in ge.values()
+ ]
+ super_nodes = [
+ {"id": g, "label": g, "memberCount": info["memberCount"], "totalDegree": info["totalDegree"]}
+ for g, info in sorted(gi.items(), key=lambda x: -x[1]["memberCount"])
+ if info["memberCount"] >= 2
+ ]
+
+ return {
+ "meta": {"type": "overview", "groupCount": len(super_nodes), "edgeCount": len(edges_out)},
+ "nodes": super_nodes,
+ "edges": edges_out,
+ }
+
+
+def export_ego(
+ data: dict,
+ full: dict,
+ code: str,
+ *,
+ hops: int = 1,
+ include_industry: bool = True,
+ max_industry_peers: int = 10,
+) -> dict:
+ """ego 뷰 — 독립 회사면 같은 업종 이웃도 포함."""
+ # BFS ego
+ adj: dict[str, set[str]] = defaultdict(set)
+ for edge in full["edges"]:
+ adj[edge["source"]].add(edge["target"])
+ adj[edge["target"]].add(edge["source"])
+
+ visited: set[str] = {code}
+ frontier = {code}
+ for _ in range(hops):
+ nf: set[str] = set()
+ for node in frontier:
+ for nb in adj.get(node, set()):
+ if nb not in visited:
+ visited.add(nb)
+ nf.add(nb)
+ frontier = nf
+
+ # 업종 이웃 추가 (연결 적은 회사)
+ industry_peers_added = 0
+ if include_industry and len(visited) <= 3:
+ center_meta = data["listing_meta"].get(code, {})
+ center_industry = center_meta.get("industry", "")
+ if center_industry:
+ peers = [
+ n
+ for n in full["nodes"]
+ if n["type"] == "company"
+ and n.get("industry") == center_industry
+ and n["id"] != code
+ and n["id"] not in visited
+ ]
+ peers.sort(key=lambda n: n["degree"], reverse=True)
+ for p in peers[:max_industry_peers]:
+ visited.add(p["id"])
+ industry_peers_added += 1
+
+ # 서브그래프 추출
+ nl = {n["id"]: n for n in full["nodes"]}
+ ego_nodes = [nl[nid] for nid in sorted(visited) if nid in nl]
+ ego_edges = [e for e in full["edges"] if e["source"] in visited and e["target"] in visited]
+
+ name = data["code_to_name"].get(code, code)
+ center_meta = data["listing_meta"].get(code, {})
+
+ return {
+ "meta": {
+ "type": "ego",
+ "center": code,
+ "centerName": name,
+ "centerIndustry": center_meta.get("industry", ""),
+ "hops": hops,
+ "nodeCount": len(ego_nodes),
+ "edgeCount": len(ego_edges),
+ "industryPeersAdded": industry_peers_added,
+ },
+ "nodes": ego_nodes,
+ "edges": ego_edges,
+ }
diff --git a/src/dartlab/scan/network/scanner.py b/src/dartlab/scan/network/scanner.py
new file mode 100644
index 0000000000000000000000000000000000000000..b8a5860e4c416947570029be5b5670c1b20c05ef
--- /dev/null
+++ b/src/dartlab/scan/network/scanner.py
@@ -0,0 +1,358 @@
+"""DART report parquet 스캔 — investedCompany, majorHolder, 계열회사 현황."""
+
+from __future__ import annotations
+
+import re
+from collections import defaultdict
+from pathlib import Path
+
+import polars as pl
+
+# ── 공통 유틸 ──────────────────────────────────────────────
+
+
+def _normalize_company_name(name: str) -> str:
+ """법인명 정규화: 접두/접미사 제거."""
+ if not name:
+ return name
+ s = name.strip()
+ for pat in [
+ r"^\(주\)\s*",
+ r"^㈜\s*",
+ r"^주식회사\s*",
+ r"\s*\(주\)$",
+ r"\s*㈜$",
+ r"\s*주식회사$",
+ r"\s*\(유\)$",
+ r"^유한회사\s*",
+ r"\s*유한회사$",
+ r"\s*㈜",
+ r"\(주\)",
+ ]:
+ s = re.sub(pat, "", s)
+ return s.strip()
+
+
+def load_listing() -> tuple[dict[str, str], dict[str, str], set[str], dict[str, dict]]:
+ """상장사 목록 로드.
+
+ Returns:
+ (name_to_code, code_to_name, listing_codes, listing_meta)
+ """
+ import dartlab
+
+ listing = dartlab.listing()
+ name_to_code: dict[str, str] = {}
+ code_to_name: dict[str, str] = {}
+ listing_meta: dict[str, dict] = {}
+
+ for row in listing.iter_rows(named=True):
+ name = row["회사명"]
+ code = row["종목코드"]
+ code_to_name[code] = name
+ name_to_code[name] = code
+ norm = _normalize_company_name(name)
+ if norm != name:
+ name_to_code[norm] = code
+ for prefix in ["㈜", "(주)", "주식회사 ", "주식회사"]:
+ name_to_code[f"{prefix}{name}"] = code
+ for suffix in [" ㈜", "㈜", " (주)", "(주)", " 주식회사", "주식회사"]:
+ name_to_code[f"{name}{suffix}"] = code
+ listing_meta[code] = {
+ "name": name,
+ "market": row.get("시장구분", ""),
+ "industry": row.get("업종", ""),
+ }
+
+ listing_codes = set(listing["종목코드"].to_list())
+ return name_to_code, code_to_name, listing_codes, listing_meta
+
+
+# ── parquet 스캔 ───────────────────────────────────────────
+
+
+def _scan_parquets(api_type: str, keep_cols: list[str]) -> pl.DataFrame:
+ """report parquet에서 특정 apiType만 LazyFrame 스캔."""
+ from dartlab.core.dataLoader import _dataDir
+
+ report_dir = Path(_dataDir("report"))
+ parquet_files = sorted(report_dir.glob("*.parquet"))
+
+ frames: list[pl.LazyFrame] = []
+ for pf in parquet_files:
+ try:
+ lf = pl.scan_parquet(str(pf))
+ if "apiType" not in lf.collect_schema().names():
+ continue
+ lf = lf.filter(pl.col("apiType") == api_type)
+ available = [c for c in keep_cols if c in lf.collect_schema().names()]
+ lf = lf.select(available)
+ frames.append(lf)
+ except (pl.exceptions.ComputeError, OSError):
+ continue
+
+ all_cols: set[str] = set()
+ for lf in frames:
+ all_cols.update(lf.collect_schema().names())
+ unified: list[pl.LazyFrame] = []
+ for lf in frames:
+ missing = all_cols - set(lf.collect_schema().names())
+ if missing:
+ lf = lf.with_columns([pl.lit(None).alias(c) for c in missing])
+ unified.append(lf.select(sorted(all_cols)))
+
+ return pl.concat(unified).collect()
+
+
+def scan_invested() -> pl.DataFrame:
+ """전종목 investedCompany 스캔."""
+ return _scan_parquets(
+ "investedCompany",
+ [
+ "stockCode",
+ "year",
+ "inv_prm",
+ "invstmnt_purps",
+ "trmend_blce_qota_rt",
+ "trmend_blce_acntbk_amount",
+ "trmend_blce_qy",
+ ],
+ )
+
+
+def scan_major_holders() -> pl.DataFrame:
+ """전종목 majorHolder 스캔."""
+ return _scan_parquets(
+ "majorHolder",
+ [
+ "stockCode",
+ "year",
+ "nm",
+ "relate",
+ "trmend_posesn_stock_co",
+ "trmend_posesn_stock_qota_rt",
+ ],
+ )
+
+
+# ── docs 계열회사 ground truth ─────────────────────────────
+
+
+class UnionFind:
+ """경로 압축 + 랭크 합침."""
+
+ def __init__(self) -> None:
+ self.parent: dict[str, str] = {}
+ self.rank: dict[str, int] = {}
+
+ def find(self, x: str) -> str:
+ """루트 노드 탐색 (경로 압축 적용)."""
+ if x not in self.parent:
+ self.parent[x] = x
+ self.rank[x] = 0
+ if self.parent[x] != x:
+ self.parent[x] = self.find(self.parent[x])
+ return self.parent[x]
+
+ def union(self, a: str, b: str) -> None:
+ """두 노드를 같은 집합으로 병합."""
+ ra, rb = self.find(a), self.find(b)
+ if ra == rb:
+ return
+ if self.rank[ra] < self.rank[rb]:
+ ra, rb = rb, ra
+ self.parent[rb] = ra
+ if self.rank[ra] == self.rank[rb]:
+ self.rank[ra] += 1
+
+ def components(self) -> dict[str, list[str]]:
+ """연결 요소별 노드 목록 반환."""
+ groups: dict[str, list[str]] = defaultdict(list)
+ for x in self.parent:
+ groups[self.find(x)].append(x)
+ return dict(groups)
+
+
+def scan_affiliate_docs(
+ name_to_code: dict[str, str],
+ code_to_name: dict[str, str],
+) -> dict[str, str]:
+ """docs parquet의 '계열회사 현황'에서 ground truth 그룹 매핑 추출."""
+ from dartlab.core.dataLoader import _dataDir
+
+ docs_dir = Path(_dataDir("docs"))
+ parquet_files = sorted(docs_dir.glob("*.parquet"))
+
+ _CORP_RE = re.compile(r"[\((]주[\))]|㈜|주식회사")
+ _REGNUM_RE = re.compile(r"\d{6}-?\d{7}")
+ _TABLE_NOISE = {
+ "상장",
+ "비상장",
+ "합계",
+ "소계",
+ "---",
+ "기업명",
+ "회사수",
+ "법인등록번호",
+ "상장여부",
+ "비고",
+ "단위",
+ "기준일",
+ "☞",
+ "본문",
+ }
+
+ def _extract_companies(text: str) -> list[str]:
+ companies: list[str] = []
+ for line in text.split("\n"):
+ if "|" not in line:
+ continue
+ cells = [c.strip() for c in line.split("|") if c.strip()]
+ if not any(_REGNUM_RE.search(c) for c in cells):
+ continue
+ for cell in cells:
+ if _REGNUM_RE.search(cell):
+ continue
+ if re.match(r"^[\d,.\-\s]+$", cell):
+ continue
+ if cell in _TABLE_NOISE or len(cell) < 2:
+ continue
+ companies.append(cell)
+ if not companies:
+ for line in text.split("\n"):
+ if "|" not in line:
+ continue
+ cells = [c.strip() for c in line.split("|") if c.strip()]
+ for cell in cells:
+ if _CORP_RE.search(cell) and len(cell) >= 3:
+ if not re.match(r"^[\d,.\-\s]+$", cell):
+ companies.append(cell)
+ return companies
+
+ def _normalize_corp(name: str) -> str:
+ name = re.sub(r"[\((]주[\))]", "", name)
+ return name.replace("㈜", "").replace("주식회사", "").strip()
+
+ code_to_affiliate_set: dict[str, set[str]] = {}
+ for pf in parquet_files:
+ code = pf.stem
+ try:
+ affiliate = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ pl.col("section_title").str.contains("계열회사 현황")
+ | pl.col("section_title").str.contains("계열회사에 관한 사항")
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if len(affiliate) == 0:
+ continue
+ if "year" in affiliate.columns:
+ affiliate = affiliate.filter(pl.col("year") == affiliate["year"].max())
+ full_text = "\n".join(c for c in affiliate["section_content"].to_list() if c)
+ if not full_text:
+ continue
+ companies = _extract_companies(full_text)
+ matched: set[str] = {code}
+ for comp in companies:
+ norm = _normalize_corp(comp)
+ c = name_to_code.get(comp) or name_to_code.get(norm)
+ if c:
+ matched.add(c)
+ code_to_affiliate_set[code] = matched
+
+ # Union-Find 클러스터링 (상장사 3개+ 겹침)
+ uf = UnionFind()
+ codes_list = list(code_to_affiliate_set.keys())
+ for i in range(len(codes_list)):
+ for j in range(i + 1, len(codes_list)):
+ ci, cj = codes_list[i], codes_list[j]
+ if len(code_to_affiliate_set[ci] & code_to_affiliate_set[cj]) >= 3:
+ uf.union(ci, cj)
+
+ _GROUP_ALIASES = {
+ "에스케이": "SK",
+ "엘지": "LG",
+ "지에스": "GS",
+ "씨제이": "CJ",
+ "에이치디현대": "HD현대",
+ "케이씨씨": "KCC",
+ }
+ _WELL_KNOWN_LABELS = {
+ "005930": "삼성",
+ "006400": "삼성",
+ "032830": "삼성",
+ "005380": "현대차",
+ "000270": "현대차",
+ "012330": "현대차",
+ "034730": "SK",
+ "000660": "SK",
+ "017670": "SK",
+ "003550": "LG",
+ "066570": "LG",
+ "051910": "LG",
+ "023530": "롯데",
+ "004990": "롯데",
+ "000880": "한화",
+ "009830": "한화",
+ "078930": "GS",
+ "006360": "GS",
+ "005490": "포스코",
+ "047050": "포스코",
+ "001040": "CJ",
+ "097950": "CJ",
+ "000150": "두산",
+ "042670": "두산",
+ "329180": "HD현대",
+ "267250": "HD현대",
+ "035720": "카카오",
+ "293490": "카카오",
+ "035420": "네이버",
+ "004800": "효성",
+ "298040": "효성",
+ "004150": "한솔",
+ "213500": "한솔",
+ "003490": "대한항공",
+ "180640": "한진칼",
+ "069960": "현대백화점",
+ "005440": "현대백화점",
+ "010120": "LS",
+ "006260": "LS",
+ "105560": "KB",
+ "055550": "신한",
+ "086790": "하나",
+ "138930": "BNK",
+ "316140": "우리",
+ }
+
+ code_to_group: dict[str, str] = {}
+ for _root, members in uf.components().items():
+ all_affiliates: set[str] = set()
+ for m in members:
+ all_affiliates.update(code_to_affiliate_set.get(m, set()))
+ if len(all_affiliates) < 2:
+ continue
+
+ group_name = None
+ for c in all_affiliates:
+ if c in _WELL_KNOWN_LABELS:
+ group_name = _WELL_KNOWN_LABELS[c]
+ break
+ if not group_name:
+ names = sorted(code_to_name.get(c, "") for c in all_affiliates if c in code_to_name)
+ if len(names) >= 2:
+ prefix = names[0]
+ for n in names[1:]:
+ while prefix and not n.startswith(prefix):
+ prefix = prefix[:-1]
+ if len(prefix) >= 2:
+ group_name = prefix.rstrip()
+ if not group_name:
+ group_name = code_to_name.get(members[0], members[0])
+
+ for c in all_affiliates:
+ code_to_group[c] = group_name
+
+ return code_to_group
diff --git a/src/dartlab/scan/payload.py b/src/dartlab/scan/payload.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc8807a7f1142ff6e376fed03647e2fe1ff32c83
--- /dev/null
+++ b/src/dartlab/scan/payload.py
@@ -0,0 +1,306 @@
+"""scan 4축 → InsightResult 호환 페이로드 변환.
+
+scan 엔진(governance/workforce/capital/debt) 결과를 insight 7영역과
+동일한 dict 구조로 변환하여 11영역 통합 대시보드를 구성한다.
+075-001 실험으로 검증 (3사 11/11영역 유효, 3.2~4.0KB, null 0개).
+
+사용법::
+
+ from dartlab.scan.payload import (
+ build_scan_payload, build_unified_payload,
+ )
+
+ scan = build_scan_payload(company)
+ unified = build_unified_payload(company)
+"""
+
+from __future__ import annotations
+
+
+def governance_to_insight(row: dict) -> dict | None:
+ """governance 1행 → InsightResult 호환 dict."""
+ grade = row.get("등급")
+ score = row.get("총점")
+ if grade is None:
+ return None
+
+ details: list[str] = []
+ risks: list[dict] = []
+ opportunities: list[dict] = []
+
+ pct = row.get("지분율")
+ if pct is not None:
+ details.append(f"최대주주 지분율 {pct:.1f}%")
+ if pct > 50:
+ risks.append({"level": "warning", "category": "governance", "text": f"최대주주 과점 ({pct:.1f}%)"})
+ elif pct < 20:
+ risks.append({"level": "warning", "category": "governance", "text": f"최대주주 지분 낮음 ({pct:.1f}%)"})
+
+ outside = row.get("사외이사비율")
+ if outside is not None:
+ details.append(f"사외이사 비율 {outside:.1f}%")
+ if outside >= 40:
+ opportunities.append(
+ {"level": "positive", "category": "governance", "text": f"사외이사 비율 우수 ({outside:.1f}%)"}
+ )
+ elif outside == 0:
+ risks.append({"level": "danger", "category": "governance", "text": "사외이사 없음"})
+
+ pay = row.get("pay_ratio")
+ if pay is not None:
+ details.append(f"임원/직원 보수비율 {pay:.1f}배")
+ if pay >= 10:
+ risks.append({"level": "warning", "category": "governance", "text": f"보수비율 과다 ({pay:.1f}배)"})
+
+ audit = row.get("감사의견")
+ if audit is not None:
+ details.append(f"감사의견: {audit}")
+ if audit and "적정" not in audit:
+ risks.append({"level": "danger", "category": "governance", "text": f"감사의견 비적정 ({audit})"})
+
+ return {
+ "grade": grade,
+ "summary": f"지배구조 종합 {score:.0f}점 ({grade}등급)" if score else f"지배구조 {grade}등급",
+ "details": details,
+ "risks": risks,
+ "opportunities": opportunities,
+ }
+
+
+def workforce_to_insight(row: dict) -> dict | None:
+ """workforce 1행 → InsightResult 호환 dict."""
+ emp = row.get("직원수")
+ if emp is None:
+ return None
+
+ details: list[str] = []
+ risks: list[dict] = []
+ opportunities: list[dict] = []
+
+ details.append(f"직원수 {emp:,.0f}명")
+
+ salary = row.get("평균급여_만원")
+ if salary is not None:
+ details.append(f"평균급여 {salary:,.0f}만원")
+
+ rev_per = row.get("직원당매출_억")
+ if rev_per is not None:
+ details.append(f"직원당 매출 {rev_per:.1f}억")
+ if rev_per >= 5:
+ opportunities.append(
+ {"level": "positive", "category": "workforce", "text": f"직원당 매출 우수 ({rev_per:.1f}억)"}
+ )
+ elif rev_per < 1:
+ risks.append({"level": "warning", "category": "workforce", "text": f"직원당 매출 저조 ({rev_per:.1f}억)"})
+
+ burden = row.get("급여매출괴리")
+ if burden is not None:
+ details.append(f"급여매출괴리 {burden:+.1f}%p")
+ if burden > 10:
+ risks.append({"level": "warning", "category": "workforce", "text": f"급여-매출 괴리 ({burden:+.1f}%p)"})
+
+ gender = row.get("남녀격차")
+ if gender is not None and gender > 40:
+ risks.append({"level": "warning", "category": "workforce", "text": f"남녀 급여격차 {gender:.1f}%"})
+
+ # 등급: 직원당매출 기준 간이 산출
+ if rev_per is not None:
+ if rev_per >= 5:
+ grade = "A"
+ elif rev_per >= 3:
+ grade = "B"
+ elif rev_per >= 1.5:
+ grade = "C"
+ elif rev_per >= 0.5:
+ grade = "D"
+ else:
+ grade = "F"
+ else:
+ grade = "C"
+
+ return {
+ "grade": grade,
+ "summary": f"직원 {emp:,.0f}명, 직원당 매출 {rev_per:.1f}억" if rev_per else f"직원 {emp:,.0f}명",
+ "details": details,
+ "risks": risks,
+ "opportunities": opportunities,
+ }
+
+
+def capital_to_insight(row: dict) -> dict | None:
+ """capital 1행 → InsightResult 호환 dict."""
+ cls = row.get("분류")
+ if cls is None:
+ return None
+
+ details: list[str] = []
+ risks: list[dict] = []
+ opportunities: list[dict] = []
+
+ details.append(f"주주환원 분류: {cls}")
+
+ div = row.get("배당여부")
+ if div:
+ dps = row.get("DPS")
+ yld = row.get("배당수익률")
+ if dps is not None:
+ details.append(f"DPS {dps:,.0f}원")
+ if yld is not None:
+ details.append(f"배당수익률 {yld:.1f}%")
+ if yld >= 3:
+ opportunities.append(
+ {"level": "positive", "category": "capital", "text": f"배당수익률 우수 ({yld:.1f}%)"}
+ )
+
+ treasury = row.get("자사주보유")
+ if treasury:
+ details.append("자사주 보유")
+ if row.get("자사주취득"):
+ opportunities.append({"level": "positive", "category": "capital", "text": "당기 자사주 취득"})
+
+ recent = row.get("최근증자")
+ if recent:
+ risks.append({"level": "warning", "category": "capital", "text": "최근 증자 이력"})
+
+ contradict = row.get("모순형")
+ if contradict:
+ risks.append({"level": "warning", "category": "capital", "text": "모순형 (배당+증자 동시)"})
+
+ grade_map = {"환원형": "A", "중립": "C", "희석형": "D"}
+ grade = grade_map.get(cls, "C")
+
+ return {
+ "grade": grade,
+ "summary": f"주주환원: {cls}",
+ "details": details,
+ "risks": risks,
+ "opportunities": opportunities,
+ }
+
+
+def debt_to_insight(row: dict) -> dict | None:
+ """debt 1행 → InsightResult 호환 dict."""
+ risk_level = row.get("위험등급")
+ if risk_level is None:
+ return None
+
+ details: list[str] = []
+ risks: list[dict] = []
+ opportunities: list[dict] = []
+
+ ratio = row.get("부채비율")
+ if ratio is not None:
+ details.append(f"부채비율 {ratio:.1f}%")
+ if ratio > 200:
+ risks.append({"level": "danger", "category": "debt", "text": f"부채비율 과다 ({ratio:.1f}%)"})
+
+ icr = row.get("ICR")
+ if icr is not None:
+ details.append(f"ICR {icr:.1f}배")
+ if icr < 1:
+ risks.append({"level": "danger", "category": "debt", "text": f"ICR 1배 미만 ({icr:.1f})"})
+ elif icr >= 5:
+ opportunities.append({"level": "positive", "category": "debt", "text": f"ICR 양호 ({icr:.1f}배)"})
+
+ bond = row.get("사채잔액")
+ if bond is not None and bond > 0:
+ details.append(f"사채잔액 {bond:,.0f}")
+ short_pct = row.get("단기비중")
+ if short_pct is not None and short_pct >= 50:
+ risks.append({"level": "warning", "category": "debt", "text": f"단기사채 비중 {short_pct:.1f}%"})
+
+ grade_map = {"안전": "A", "관찰": "B", "주의": "C", "고위험": "F"}
+ grade = grade_map.get(risk_level, "C")
+
+ return {
+ "grade": grade,
+ "summary": f"부채 위험등급: {risk_level}",
+ "details": details,
+ "risks": risks,
+ "opportunities": opportunities,
+ }
+
+
+_SCAN_CONVERTERS = {
+ "governance": governance_to_insight,
+ "workforce": workforce_to_insight,
+ "capital": capital_to_insight,
+ "debt": debt_to_insight,
+}
+
+
+def build_scan_payload(company) -> dict[str, dict | None]:
+ """scan 4축 → InsightResult 호환 dict들.
+
+ Args:
+ company: dartlab.Company 인스턴스.
+
+ Returns:
+ {governance: {...}, workforce: {...}, capital: {...}, debt: {...}}
+ """
+ result: dict[str, dict | None] = {}
+ for axis, converter in _SCAN_CONVERTERS.items():
+ method = getattr(company, axis, None)
+ if method is None:
+ result[axis] = None
+ continue
+ try:
+ df = method()
+ if df is not None and len(df) > 0:
+ row = df.row(0, named=True)
+ result[axis] = converter(row)
+ else:
+ result[axis] = None
+ except (AttributeError, FileNotFoundError, KeyError, RuntimeError, ValueError):
+ result[axis] = None
+ return result
+
+
+def build_unified_payload(company) -> dict[str, dict | None]:
+ """insight 7영역 + scan 4축 = 11영역 통합 payload.
+
+ Args:
+ company: dartlab.Company 인스턴스.
+
+ Returns:
+ {performance, profitability, ..., scan_governance, workforce, capital, debt}
+ """
+ # insight 7영역
+ insight_areas: dict[str, dict] = {}
+ try:
+ insights = company.insights
+ if insights and hasattr(insights, "grades"):
+ for area_name in (
+ "performance",
+ "profitability",
+ "health",
+ "cashflow",
+ "governance",
+ "risk",
+ "opportunity",
+ ):
+ area = getattr(insights, area_name, None)
+ if area:
+ insight_areas[area_name] = {
+ "grade": area.grade,
+ "summary": area.summary,
+ "details": area.details,
+ "risks": [{"level": r.level, "category": r.category, "text": r.text} for r in area.risks],
+ "opportunities": [
+ {"level": o.level, "category": o.category, "text": o.text} for o in area.opportunities
+ ],
+ }
+ except (AttributeError, FileNotFoundError, KeyError, RuntimeError, ValueError):
+ pass
+
+ # scan 4축
+ scan_areas = build_scan_payload(company)
+
+ # 통합 (insight.governance와 scan.governance 충돌 → scan_governance)
+ unified: dict[str, dict | None] = {}
+ unified.update(insight_areas)
+ for axis, data in scan_areas.items():
+ key = f"scan_{axis}" if axis in insight_areas else axis
+ unified[key] = data
+
+ return unified
diff --git a/src/dartlab/scan/profitability/__init__.py b/src/dartlab/scan/profitability/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd2ba96bbee9693689380af642e7473e3b8c74b5
--- /dev/null
+++ b/src/dartlab/scan/profitability/__init__.py
@@ -0,0 +1,194 @@
+"""수익성 스캔 -- 영업이익률/순이익률/ROE/ROA + 섹터 대비 위치 + 등급."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from dartlab.scan._helpers import _ensureScanData, extractAccount
+
+# ── 계정 매핑 ──
+
+_REVENUE_IDS = {"Revenue", "revenue", "ifrs-full_Revenue", "dart_Revenue"}
+_REVENUE_NMS = {"매출액", "수익(매출액)", "영업수익"}
+
+_OP_IDS = {
+ "ProfitLossFromOperatingActivities",
+ "operatingIncome",
+ "ifrs-full_ProfitLossFromOperatingActivities",
+ "dart_OperatingIncomeLoss",
+}
+_OP_NMS = {"영업이익", "영업이익(손실)"}
+
+_NI_IDS = {
+ "ProfitLoss",
+ "netIncome",
+ "ifrs-full_ProfitLoss",
+ "dart_ProfitLoss",
+ "ProfitLossAttributableToOwnersOfParent",
+}
+_NI_NMS = {"당기순이익", "당기순이익(손실)"}
+
+_TA_IDS = {"Assets", "totalAssets", "ifrs-full_Assets", "dart_Assets"}
+_TA_NMS = {"자산총계", "자산 총계"}
+
+_EQ_IDS = {
+ "Equity",
+ "equity",
+ "ifrs-full_Equity",
+ "EquityAttributableToOwnersOfParent",
+ "ifrs-full_EquityAttributableToOwnersOfParent",
+}
+_EQ_NMS = {"자본총계", "자본 총계", "지배기업 소유주지분"}
+
+
+def _gradeProfitability(opMargin: float | None, roe: float | None) -> str:
+ """수익성 등급."""
+ best = max(opMargin or -999, roe or -999)
+ if best >= 20:
+ return "우수"
+ if best >= 10:
+ return "양호"
+ if best >= 5:
+ return "보통"
+ if best >= 0:
+ return "저수익"
+ return "적자"
+
+
+def scanProfitability() -> pl.DataFrame:
+ """전종목 수익성 스캔 -- 영업이익률/순이익률/ROE/ROA + 등급."""
+ scanDir = _ensureScanData()
+ scanPath = scanDir / "finance.parquet"
+
+ if not scanPath.exists():
+ return _scanPerFile()
+
+ return _scanFromMerged(scanPath)
+
+
+def _scanFromMerged(scanPath: Path) -> pl.DataFrame:
+ """프리빌드 finance.parquet에서 수익성 계산."""
+ schema = pl.scan_parquet(str(scanPath)).collect_schema().names()
+ scCol = "stockCode" if "stockCode" in schema else "stock_code"
+
+ allIds = list(_REVENUE_IDS | _OP_IDS | _NI_IDS | _TA_IDS | _EQ_IDS)
+ allNms = list(_REVENUE_NMS | _OP_NMS | _NI_NMS | _TA_NMS | _EQ_NMS)
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS", "BS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ & (pl.col("account_id").is_in(allIds) | pl.col("account_nm").is_in(allNms))
+ )
+ .collect()
+ )
+ if target.is_empty():
+ return pl.DataFrame()
+
+ # 연결 우선
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ if not cfs.is_empty():
+ target = cfs
+
+ return _computeProfitability(target, scCol)
+
+
+def _scanPerFile() -> pl.DataFrame:
+ """종목별 finance parquet 순회 fallback."""
+ from dartlab.core.dataLoader import _dataDir
+
+ financeDir = Path(_dataDir("finance"))
+ parquetFiles = sorted(financeDir.glob("*.parquet"))
+
+ allDfs = []
+ for pf in parquetFiles:
+ try:
+ df = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS", "BS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if df.is_empty():
+ continue
+ cfs = df.filter(pl.col("fs_nm").str.contains("연결"))
+ allDfs.append(cfs if not cfs.is_empty() else df)
+
+ if not allDfs:
+ return pl.DataFrame()
+
+ combined = pl.concat(allDfs, how="diagonal_relaxed")
+ scCol = "stockCode" if "stockCode" in combined.columns else "stock_code"
+ return _computeProfitability(combined, scCol)
+
+
+def _computeProfitability(target: pl.DataFrame, scCol: str) -> pl.DataFrame:
+ """종목별 수익성 비율 계산 (최신 연도)."""
+ years = sorted(target["bsns_year"].unique().to_list(), reverse=True)
+ if not years:
+ return pl.DataFrame()
+
+ latestYear = years[0]
+ latest = target.filter(pl.col("bsns_year") == latestYear)
+
+ rows: list[dict] = []
+ for code in latest[scCol].unique().to_list():
+ sub = latest.filter(pl.col(scCol) == code)
+
+ rev = extractAccount(sub, _REVENUE_IDS, _REVENUE_NMS)
+ op = extractAccount(sub, _OP_IDS, _OP_NMS)
+ ni = extractAccount(sub, _NI_IDS, _NI_NMS)
+ ta = extractAccount(sub, _TA_IDS, _TA_NMS)
+ eq = extractAccount(sub, _EQ_IDS, _EQ_NMS)
+
+ opMargin = round(op / rev * 100, 1) if rev and rev != 0 and op is not None else None
+ netMargin = round(ni / rev * 100, 1) if rev and rev != 0 and ni is not None else None
+ roe = round(ni / eq * 100, 1) if eq and eq != 0 and ni is not None else None
+ roa = round(ni / ta * 100, 1) if ta and ta != 0 and ni is not None else None
+
+ if opMargin is None and netMargin is None and roe is None and roa is None:
+ continue
+
+ # netMargin이 opMargin 대비 극단적으로 크면 비경상 이익 의심
+ hasNonRecurring = (
+ netMargin is not None
+ and opMargin is not None
+ and abs(netMargin) > abs(opMargin) * 3
+ and abs(netMargin) > 50
+ )
+
+ rows.append(
+ {
+ "stockCode": code,
+ "opMargin": opMargin,
+ "netMargin": netMargin,
+ "roe": roe,
+ "roa": roa,
+ "grade": _gradeProfitability(opMargin, roe),
+ "nonRecurring": hasNonRecurring,
+ }
+ )
+
+ if not rows:
+ return pl.DataFrame()
+
+ schema = {
+ "stockCode": pl.Utf8,
+ "opMargin": pl.Float64,
+ "netMargin": pl.Float64,
+ "roe": pl.Float64,
+ "roa": pl.Float64,
+ "grade": pl.Utf8,
+ "nonRecurring": pl.Boolean,
+ }
+ return pl.DataFrame(rows, schema=schema)
+
+
+__all__ = ["scanProfitability"]
diff --git a/src/dartlab/scan/profitability/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/profitability/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..9692682c7fc10338c79242d68fc1ac716fcb74ef
Binary files /dev/null and b/src/dartlab/scan/profitability/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/quality/__init__.py b/src/dartlab/scan/quality/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..16e106408882465d4fe34b4d6d9ab8e87bcc1b97
--- /dev/null
+++ b/src/dartlab/scan/quality/__init__.py
@@ -0,0 +1,202 @@
+"""이익의 질 (Earnings Quality) -- Accrual Ratio 기반 전종목 스캔."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from dartlab.scan._helpers import _ensureScanData, extractAccount
+
+# ── 순이익 ──
+
+NI_IDS = {
+ "ProfitLoss",
+ "ProfitLossAttributableToOwnersOfParent",
+ "ifrs-full_ProfitLoss",
+ "ifrs-full_ProfitLossAttributableToOwnersOfParent",
+ "NetIncomeLoss",
+ "dart_ProfitLoss",
+}
+NI_NMS = {"당기순이익", "당기순이익(손실)", "지배기업소유주지분순이익"}
+
+# ── 영업활동CF ──
+
+OCF_IDS = {
+ "CashFlowsFromUsedInOperatingActivities",
+ "CashFlowsFromOperatingActivities",
+ "cashFlowsFromUsedInOperatingActivities",
+ "ifrs-full_CashFlowsFromUsedInOperatingActivities",
+}
+OCF_NMS = {"영업활동현금흐름", "영업활동으로인한현금흐름", "영업활동현금흐름합계"}
+
+# ── 총자산 ──
+
+TA_IDS = {
+ "Assets",
+ "ifrs-full_Assets",
+ "TotalAssets",
+}
+TA_NMS = {"자산총계"}
+
+
+# ── 등급 분류 ──
+
+
+def _gradeQuality(accrualRatio: float) -> str:
+ """accrual ratio → 등급."""
+ if accrualRatio <= -0.05:
+ return "우수" # CF가 이익보다 훨씬 큼
+ if accrualRatio <= 0.05:
+ return "양호" # 이익과 CF가 비슷
+ if accrualRatio <= 0.15:
+ return "보통" # 약간의 accrual
+ if accrualRatio <= 0.25:
+ return "주의" # accrual 비중 높음
+ return "위험" # 이익 대부분이 accrual
+
+
+_extractVal = extractAccount # backward compat alias
+
+
+def _scanFromMerged(scanPath: Path) -> pl.DataFrame:
+ """프리빌드 finance.parquet → 종목별 이익의 질."""
+ schema = pl.scan_parquet(str(scanPath)).collect_schema().names()
+ scCol = "stockCode" if "stockCode" in schema else "stock_code"
+
+ allIds = list(NI_IDS | OCF_IDS | TA_IDS)
+ allNms = list(NI_NMS | OCF_NMS | TA_NMS)
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS", "CF", "BS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ & (pl.col("account_id").is_in(allIds) | pl.col("account_nm").is_in(allNms))
+ )
+ .collect()
+ )
+ if target.is_empty():
+ return pl.DataFrame()
+
+ # 연결 우선
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ if not cfs.is_empty():
+ target = cfs
+
+ # 종목별 최신 연도
+ latestYear = target.group_by(scCol).agg(pl.col("bsns_year").max().alias("_maxYear"))
+ target = target.join(latestYear, on=scCol).filter(pl.col("bsns_year") == pl.col("_maxYear")).drop("_maxYear")
+
+ rows: list[dict] = []
+ for code in target[scCol].unique().to_list():
+ sub = target.filter(pl.col(scCol) == code)
+
+ # IS/CIS에서 순이익
+ isSub = sub.filter(pl.col("sj_div").is_in(["IS", "CIS"]))
+ ni = _extractVal(isSub, NI_IDS, NI_NMS)
+
+ # CF에서 영업CF
+ cfSub = sub.filter(pl.col("sj_div") == "CF")
+ ocf = _extractVal(cfSub, OCF_IDS, OCF_NMS)
+
+ # BS에서 총자산
+ bsSub = sub.filter(pl.col("sj_div") == "BS")
+ ta = _extractVal(bsSub, TA_IDS, TA_NMS)
+
+ if ni is None or ocf is None or ta is None or ta == 0:
+ continue
+
+ accrualRatio = (ni - ocf) / abs(ta)
+ cfToNi = ocf / ni if ni != 0 else None
+ # cfToNi 극단값 cap: ±20 초과는 의미 없음 (분모 극소)
+ if cfToNi is not None and abs(cfToNi) > 20:
+ cfToNi = None
+
+ rows.append(
+ {
+ "stockCode": code,
+ "netIncome": round(ni),
+ "operatingCf": round(ocf),
+ "totalAssets": round(ta),
+ "accrualRatio": round(accrualRatio, 4),
+ "cfToNi": round(cfToNi, 4) if cfToNi is not None else None,
+ "grade": _gradeQuality(accrualRatio),
+ }
+ )
+
+ return pl.DataFrame(rows) if rows else pl.DataFrame()
+
+
+def _scanPerFile() -> pl.DataFrame:
+ """종목별 finance parquet 순회 fallback."""
+ from dartlab.core.dataLoader import _dataDir
+
+ financeDir = Path(_dataDir("finance"))
+ parquetFiles = sorted(financeDir.glob("*.parquet"))
+
+ rows: list[dict] = []
+ for pf in parquetFiles:
+ code = pf.stem
+ try:
+ df = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS", "CF", "BS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if df.is_empty() or "account_id" not in df.columns:
+ continue
+
+ cfs = df.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else df
+
+ years = sorted(target["bsns_year"].unique().to_list(), reverse=True)
+ if not years:
+ continue
+ latest = target.filter(pl.col("bsns_year") == years[0])
+
+ isSub = latest.filter(pl.col("sj_div").is_in(["IS", "CIS"]))
+ ni = _extractVal(isSub, NI_IDS, NI_NMS)
+
+ cfSub = latest.filter(pl.col("sj_div") == "CF")
+ ocf = _extractVal(cfSub, OCF_IDS, OCF_NMS)
+
+ bsSub = latest.filter(pl.col("sj_div") == "BS")
+ ta = _extractVal(bsSub, TA_IDS, TA_NMS)
+
+ if ni is None or ocf is None or ta is None or ta == 0:
+ continue
+
+ accrualRatio = (ni - ocf) / abs(ta)
+ cfToNi = ocf / ni if ni != 0 else None
+ # cfToNi 극단값 cap: ±20 초과는 의미 없음 (분모 극소)
+ if cfToNi is not None and abs(cfToNi) > 20:
+ cfToNi = None
+
+ rows.append(
+ {
+ "stockCode": code,
+ "netIncome": round(ni),
+ "operatingCf": round(ocf),
+ "totalAssets": round(ta),
+ "accrualRatio": round(accrualRatio, 4),
+ "cfToNi": round(cfToNi, 4) if cfToNi is not None else None,
+ "grade": _gradeQuality(accrualRatio),
+ }
+ )
+
+ return pl.DataFrame(rows) if rows else pl.DataFrame()
+
+
+def scanQuality() -> pl.DataFrame:
+ """전종목 이익의 질 스캔 -- Accrual Ratio + CF/NI 비율 + 등급."""
+ scanDir = _ensureScanData()
+ scanPath = scanDir / "finance.parquet"
+ if scanPath.exists():
+ return _scanFromMerged(scanPath)
+ return _scanPerFile()
diff --git a/src/dartlab/scan/quality/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/quality/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..eb5108b544f4fc528301b56aed4069be016caae2
Binary files /dev/null and b/src/dartlab/scan/quality/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/rank.py b/src/dartlab/scan/rank.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a9fd8dbed18dc3909b8e5f4123d4ac1357c1eca
--- /dev/null
+++ b/src/dartlab/scan/rank.py
@@ -0,0 +1,266 @@
+"""종목 규모 랭크.
+
+전체 시장 + 섹터 내 순위를 산출한다.
+첫 호출 시 전체 종목을 순회해서 스냅샷을 생성하고 로컬 캐시에 저장.
+이후 호출은 캐시에서 조회 (빌드 2분 → 조회 즉시).
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import threading
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+from dartlab.core.dataConfig import DATA_RELEASES
+
+
+@dataclass
+class RankInfo:
+ """단일 종목의 랭크 정보."""
+
+ stockCode: str
+ corpName: str
+ sector: str
+ industryGroup: str
+
+ revenue: Optional[float] = None
+ totalAssets: Optional[float] = None
+ revenueGrowth3Y: Optional[float] = None
+
+ revenueRank: Optional[int] = None
+ revenueTotal: int = 0
+ revenueRankInSector: Optional[int] = None
+ revenueSectorTotal: int = 0
+
+ assetRank: Optional[int] = None
+ assetTotal: int = 0
+ assetRankInSector: Optional[int] = None
+ assetSectorTotal: int = 0
+
+ growthRank: Optional[int] = None
+ growthTotal: int = 0
+ growthRankInSector: Optional[int] = None
+ growthSectorTotal: int = 0
+
+ sizeClass: str = ""
+
+ def __repr__(self):
+ revStr = f"매출 {self.revenueRank}/{self.revenueTotal}" if self.revenueRank else "매출 N/A"
+ secStr = f"섹터 {self.revenueRankInSector}/{self.revenueSectorTotal}" if self.revenueRankInSector else ""
+ return f"RankInfo({self.corpName}, {revStr}, {secStr}, {self.sizeClass})"
+
+
+def _cacheDir() -> Path:
+ from dartlab import config
+
+ return Path(config.dataDir) / "_cache"
+
+
+def _cachePath() -> Path:
+ return _cacheDir() / "rank_snapshot.json"
+
+
+def _financeExists(stockCode: str) -> bool:
+ from dartlab import config
+
+ dataDir = Path(config.dataDir) / DATA_RELEASES["finance"]["dir"]
+ return (dataDir / f"{stockCode}.parquet").exists()
+
+
+def buildSnapshot(*, verbose: bool = True) -> dict[str, RankInfo]:
+ """전체 종목 랭크 스냅샷 생성 및 캐시 저장.
+
+ Returns:
+ stockCode → RankInfo 매핑 dict.
+ """
+ from dartlab.core.finance.ratios import calcRatios
+ from dartlab.core.sector import classify
+ from dartlab.gather.listing import getKindList
+ from dartlab.providers.dart.finance.pivot import buildAnnual
+
+ kindDf = getKindList()
+ codes = kindDf["종목코드"].to_list()
+ names = kindDf["회사명"].to_list()
+ industries = kindDf["업종"].to_list() if "업종" in kindDf.columns else [None] * len(codes)
+ products = kindDf["주요제품"].to_list() if "주요제품" in kindDf.columns else [None] * len(codes)
+
+ records: list[dict] = []
+ for i, code in enumerate(codes):
+ if verbose and i % 500 == 0:
+ logger.info("[rank] %d/%d...", i, len(codes))
+
+ info = classify(names[i], industries[i], products[i])
+
+ rec = {
+ "stockCode": code,
+ "corpName": names[i],
+ "sector": info.sector.value,
+ "industryGroup": info.industryGroup.value,
+ "revenue": None,
+ "totalAssets": None,
+ "revenueGrowth3Y": None,
+ }
+
+ if _financeExists(code):
+ aResult = buildAnnual(code)
+ if aResult is not None:
+ aSeries, _ = aResult
+ ratios = calcRatios(aSeries)
+ if ratios.revenueTTM and ratios.revenueTTM > 0:
+ rec["revenue"] = ratios.revenueTTM
+ rec["totalAssets"] = ratios.totalAssets
+ rec["revenueGrowth3Y"] = ratios.revenueGrowth3Y
+
+ records.append(rec)
+
+ revSorted = sorted(
+ [r for r in records if r["revenue"] is not None],
+ key=lambda x: x["revenue"],
+ reverse=True,
+ )
+ assetSorted = sorted(
+ [r for r in records if r["totalAssets"] is not None and r["totalAssets"] > 0],
+ key=lambda x: x["totalAssets"],
+ reverse=True,
+ )
+ growthSorted = sorted(
+ [r for r in records if r["revenueGrowth3Y"] is not None],
+ key=lambda x: x["revenueGrowth3Y"],
+ reverse=True,
+ )
+
+ nRev = len(revSorted)
+ nAsset = len(assetSorted)
+ nGrowth = len(growthSorted)
+
+ revRank = {r["stockCode"]: i + 1 for i, r in enumerate(revSorted)}
+ assetRank = {r["stockCode"]: i + 1 for i, r in enumerate(assetSorted)}
+ growthRank = {r["stockCode"]: i + 1 for i, r in enumerate(growthSorted)}
+
+ from collections import defaultdict
+
+ sectorRevLists: dict[str, list[str]] = defaultdict(list)
+ sectorAssetLists: dict[str, list[str]] = defaultdict(list)
+ sectorGrowthLists: dict[str, list[str]] = defaultdict(list)
+
+ for r in revSorted:
+ sectorRevLists[r["sector"]].append(r["stockCode"])
+ for r in assetSorted:
+ sectorAssetLists[r["sector"]].append(r["stockCode"])
+ for r in growthSorted:
+ sectorGrowthLists[r["sector"]].append(r["stockCode"])
+
+ sectorRevRank: dict[str, tuple[int, int]] = {}
+ for sector, codeList in sectorRevLists.items():
+ for i, c in enumerate(codeList):
+ sectorRevRank[c] = (i + 1, len(codeList))
+
+ sectorAssetRank: dict[str, tuple[int, int]] = {}
+ for sector, codeList in sectorAssetLists.items():
+ for i, c in enumerate(codeList):
+ sectorAssetRank[c] = (i + 1, len(codeList))
+
+ sectorGrowthRank: dict[str, tuple[int, int]] = {}
+ for sector, codeList in sectorGrowthLists.items():
+ for i, c in enumerate(codeList):
+ sectorGrowthRank[c] = (i + 1, len(codeList))
+
+ result: dict[str, RankInfo] = {}
+ for rec in records:
+ code = rec["stockCode"]
+ rr = revRank.get(code)
+ ar = assetRank.get(code)
+ gr = growthRank.get(code)
+ srr = sectorRevRank.get(code)
+ sar = sectorAssetRank.get(code)
+ sgr = sectorGrowthRank.get(code)
+
+ sizeClass = ""
+ if rr is not None:
+ pct = rr / nRev
+ sizeClass = "large" if pct <= 0.10 else "mid" if pct <= 0.30 else "small"
+
+ ri = RankInfo(
+ stockCode=code,
+ corpName=rec["corpName"],
+ sector=rec["sector"],
+ industryGroup=rec["industryGroup"],
+ revenue=rec["revenue"],
+ totalAssets=rec["totalAssets"],
+ revenueGrowth3Y=rec["revenueGrowth3Y"],
+ revenueRank=rr,
+ revenueTotal=nRev,
+ revenueRankInSector=srr[0] if srr else None,
+ revenueSectorTotal=srr[1] if srr else 0,
+ assetRank=ar,
+ assetTotal=nAsset,
+ assetRankInSector=sar[0] if sar else None,
+ assetSectorTotal=sar[1] if sar else 0,
+ growthRank=gr,
+ growthTotal=nGrowth,
+ growthRankInSector=sgr[0] if sgr else None,
+ growthSectorTotal=sgr[1] if sgr else 0,
+ sizeClass=sizeClass,
+ )
+ result[code] = ri
+
+ cacheDir = _cacheDir()
+ cacheDir.mkdir(parents=True, exist_ok=True)
+ cachePath = _cachePath()
+ serializable = {code: asdict(ri) for code, ri in result.items()}
+ cachePath.write_text(json.dumps(serializable, ensure_ascii=False), encoding="utf-8")
+
+ if verbose:
+ logger.info("[rank] %d종목 스냅샷 저장: %s", len(result), cachePath)
+
+ return result
+
+
+def _loadCache() -> dict[str, RankInfo] | None:
+ cachePath = _cachePath()
+ if not cachePath.exists():
+ return None
+ raw = json.loads(cachePath.read_text(encoding="utf-8"))
+ result = {}
+ for code, data in raw.items():
+ result[code] = RankInfo(**data)
+ return result
+
+
+_SNAPSHOT: dict[str, RankInfo] | None = None
+_SNAPSHOT_LOCK = threading.Lock()
+
+
+def _ensureSnapshot() -> dict[str, RankInfo] | None:
+ global _SNAPSHOT
+ if _SNAPSHOT is not None:
+ return _SNAPSHOT
+ with _SNAPSHOT_LOCK:
+ if _SNAPSHOT is not None:
+ return _SNAPSHOT
+ _SNAPSHOT = _loadCache()
+ return _SNAPSHOT
+
+
+def getRank(stockCode: str) -> RankInfo | None:
+ """종목 랭크 정보 조회. 스냅샷이 없으면 None."""
+ snap = _ensureSnapshot()
+ if snap is None:
+ return None
+ return snap.get(stockCode)
+
+
+def getRankOrBuild(stockCode: str, *, verbose: bool = True) -> RankInfo | None:
+ """종목 랭크 정보 조회. 스냅샷이 없으면 빌드 후 조회."""
+ global _SNAPSHOT
+ snap = _ensureSnapshot()
+ if snap is None:
+ if verbose:
+ logger.info("[dartlab] 랭크 스냅샷이 없습니다. 전체 종목 빌드를 시작합니다...")
+ _SNAPSHOT = buildSnapshot(verbose=verbose)
+ return _SNAPSHOT.get(stockCode)
diff --git a/src/dartlab/scan/screen/__init__.py b/src/dartlab/scan/screen/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..51bd46d8dba698e69863071bef8ccbe960266117
--- /dev/null
+++ b/src/dartlab/scan/screen/__init__.py
@@ -0,0 +1,280 @@
+"""멀티팩터 스크리닝 -- 여러 축 조합으로 종목 필터링.
+
+프리셋:
+ value -- 가치투자 후보 (저PBR + 이익 양호 + 부채 안전)
+ dividend -- 배당 성장 우량주 (연속증가/안정 + 부채 안전)
+ growth -- 균형 성장주 (고성장 + 수익 우수 + 이익 양호)
+ risk -- 위험 기업 (부채 고위험 OR 감사 고위험 OR 유동성 위험)
+ quality -- 퀄리티 팩터 (수익 우수 + 이익 우수 + 효율 우수)
+ all -- 전 프리셋 플래그 통합
+
+사용법::
+
+ dartlab.scan("screen") # 프리셋 목록
+ dartlab.scan("screen", "value") # 가치투자 후보
+ dartlab.scan("screen", "risk") # 위험 기업
+ dartlab.scan("screen", "all") # 전체 플래그 통합
+"""
+
+from __future__ import annotations
+
+import polars as pl
+
+_PRESETS = {
+ "value": "가치투자 후보 (저PBR + 이익 양호 + 부채 안전)",
+ "dividend": "배당 성장 우량주 (연속증가/안정 + 부채 안전)",
+ "growth": "균형 성장주 (고성장 + 수익 우수 + 이익 양호)",
+ "risk": "위험 기업 (부채 고위험 OR 감사 고위험 OR 유동성 위험)",
+ "quality": "퀄리티 팩터 (수익 우수 + 이익 우수 + 효율 우수)",
+ "cycle_recovery": "경기 회복 수혜 (경기민감 + 이익양호 + 저PBR)",
+ "cycle_defensive": "경기 방어 (방어주 + 안정 재무 + 배당)",
+ "all": "전 프리셋 플래그 통합",
+}
+
+
+def _loadAxis(name: str) -> pl.DataFrame:
+ """scan 축 로드 (lazy import)."""
+ import dartlab
+
+ return dartlab.scan(name)
+
+
+def _screenValue() -> pl.DataFrame:
+ """가치투자 후보: 저PBR + 이익 양호+ + 부채 안전/관찰."""
+ prof = _loadAxis("profitability")
+ qual = _loadAxis("quality")
+ debt = _loadAxis("debt")
+ val = _loadAxis("valuation")
+
+ goodProf = set(prof.filter(pl.col("등급").is_in(["우수", "양호", "보통"]))["종목코드"].to_list())
+ goodQual = set(qual.filter(pl.col("등급").is_in(["우수", "양호"]))["종목코드"].to_list())
+ safeDbt = set(debt.filter(pl.col("위험등급").is_in(["안전", "관찰"]))["종목코드"].to_list())
+ lowPbr = set(
+ val.filter((pl.col("PBR").is_not_null()) & (pl.col("PBR") > 0) & (pl.col("PBR") < 0.7))["종목코드"].to_list()
+ )
+
+ codes = goodProf & goodQual & safeDbt & lowPbr
+ return val.filter(pl.col("종목코드").is_in(list(codes))).sort("PBR")
+
+
+def _screenDividend() -> pl.DataFrame:
+ """배당 성장 우량주: 연속증가/안정/증가 + 부채 안전/관찰."""
+ div = _loadAxis("dividendTrend")
+ debt = _loadAxis("debt")
+
+ goodDiv = set(div.filter(pl.col("패턴").is_in(["연속증가", "안정", "증가"]))["종목코드"].to_list())
+ safeDbt = set(debt.filter(pl.col("위험등급").is_in(["안전", "관찰"]))["종목코드"].to_list())
+
+ codes = goodDiv & safeDbt
+ return div.filter(pl.col("종목코드").is_in(list(codes))).sort("DPS성장", descending=True, nulls_last=True)
+
+
+def _screenGrowth() -> pl.DataFrame:
+ """균형 성장주: 고성장 + 수익 양호+ + 이익 양호+."""
+ growth = _loadAxis("growth")
+ prof = _loadAxis("profitability")
+ qual = _loadAxis("quality")
+
+ highGrowth = set(growth.filter(pl.col("등급").is_in(["고성장", "성장"]))["종목코드"].to_list())
+ goodProf = set(prof.filter(pl.col("등급").is_in(["우수", "양호"]))["종목코드"].to_list())
+ goodQual = set(qual.filter(pl.col("등급").is_in(["우수", "양호"]))["종목코드"].to_list())
+
+ codes = highGrowth & goodProf & goodQual
+ return growth.filter(pl.col("종목코드").is_in(list(codes))).sort("매출CAGR", descending=True, nulls_last=True)
+
+
+def _screenRisk() -> pl.DataFrame:
+ """위험 기업: 부채 고위험 OR 감사 고위험/주의 OR 유동성 위험."""
+ debt = _loadAxis("debt")
+ audit = _loadAxis("audit")
+ liq = _loadAxis("liquidity")
+
+ debtRisk = set(debt.filter(pl.col("위험등급") == "고위험")["종목코드"].to_list())
+ auditRisk = set(audit.filter(pl.col("위험등급").is_in(["고위험", "주의"]))["종목코드"].to_list())
+ liqRisk = set(liq.filter(pl.col("등급") == "위험")["종목코드"].to_list())
+
+ allRisk = debtRisk | auditRisk | liqRisk
+
+ rows: list[dict] = []
+ for code in allRisk:
+ flags = []
+ if code in debtRisk:
+ flags.append("부채")
+ if code in auditRisk:
+ flags.append("감사")
+ if code in liqRisk:
+ flags.append("유동성")
+ rows.append({"stockCode": code, "위험플래그": "+".join(flags), "위험수": len(flags)})
+
+ if not rows:
+ return pl.DataFrame()
+ return pl.DataFrame(rows).sort("위험수", descending=True)
+
+
+def _screenQuality() -> pl.DataFrame:
+ """퀄리티 팩터: 수익 우수 + 이익 우수/양호 + 효율 우수/양호."""
+ prof = _loadAxis("profitability")
+ qual = _loadAxis("quality")
+ eff = _loadAxis("efficiency")
+
+ goodProf = set(prof.filter(pl.col("등급") == "우수")["종목코드"].to_list())
+ goodQual = set(qual.filter(pl.col("등급").is_in(["우수", "양호"]))["종목코드"].to_list())
+ goodEff = set(eff.filter(pl.col("등급").is_in(["우수", "양호"]))["종목코드"].to_list())
+
+ codes = goodProf & goodQual & goodEff
+ return prof.filter(pl.col("종목코드").is_in(list(codes))).sort("ROE", descending=True, nulls_last=True)
+
+
+def _screenAll() -> pl.DataFrame:
+ """전 프리셋 플래그 통합 — 종목별로 어떤 프리셋에 해당하는지."""
+ vDf = _screenValue()
+ dDf = _screenDividend()
+ gDf = _screenGrowth()
+ rDf = _screenRisk()
+ qDf = _screenQuality()
+
+ value = set(vDf["종목코드"].to_list()) if not vDf.is_empty() else set()
+ dividend = set(dDf["종목코드"].to_list()) if not dDf.is_empty() else set()
+ growth = set(gDf["종목코드"].to_list()) if not gDf.is_empty() else set()
+ risk = set(rDf["종목코드"].to_list()) if not rDf.is_empty() else set()
+ quality = set(qDf["종목코드"].to_list()) if not qDf.is_empty() else set()
+
+ allCodes = value | dividend | growth | risk | quality
+ rows: list[dict] = []
+ for code in allCodes:
+ flags = []
+ if code in value:
+ flags.append("value")
+ if code in dividend:
+ flags.append("dividend")
+ if code in growth:
+ flags.append("growth")
+ if code in quality:
+ flags.append("quality")
+ if code in risk:
+ flags.append("risk")
+ rows.append({"stockCode": code, "프리셋": "+".join(flags), "프리셋수": len(flags)})
+
+ if not rows:
+ return pl.DataFrame()
+ return pl.DataFrame(rows).sort("프리셋수", descending=True)
+
+
+def _screenCycleRecovery() -> pl.DataFrame:
+ """경기 회복 수혜: 경기민감(high beta) + 이익 양호 + 저PBR.
+
+ 실험 109-02: 회복기에 high cyclicality > defensive +51%p.
+ macroBeta에서 GDP beta > 1.0 종목 = 경기민감.
+ """
+ try:
+ macro = _loadAxis("macroBeta")
+ except Exception: # noqa: BLE001
+ macro = pl.DataFrame()
+
+ prof = _loadAxis("profitability")
+ val = _loadAxis("valuation")
+ debt = _loadAxis("debt")
+
+ goodProf = set(prof.filter(pl.col("등급").is_in(["우수", "양호", "보통"]))["종목코드"].to_list())
+ safeDbt = set(debt.filter(pl.col("위험등급").is_in(["안전", "관찰"]))["종목코드"].to_list())
+
+ # macroBeta에서 경기민감 종목 (gdpBeta > 1.0)
+ if not macro.is_empty() and "gdpBeta" in macro.columns:
+ highBeta = set(macro.filter(pl.col("gdpBeta") > 1.0)["종목코드"].to_list())
+ else:
+ # macroBeta 데이터 없으면 profitability 전체로 fallback
+ highBeta = goodProf
+
+ # 저PBR
+ lowPbr = set()
+ if not val.is_empty() and "pbr" in val.columns:
+ lowPbr = set(val.filter(pl.col("pbr") < 1.0)["종목코드"].to_list())
+ elif not val.is_empty() and "PBR" in val.columns:
+ lowPbr = set(val.filter(pl.col("PBR") < 1.0)["종목코드"].to_list())
+
+ # 교집합 (저PBR이 있으면 포함, 없으면 경기민감+이익만)
+ codes = highBeta & goodProf & safeDbt
+ if lowPbr:
+ codes = codes & lowPbr
+
+ if not codes:
+ return pl.DataFrame({"stockCode": [], "프리셋": []})
+ return pl.DataFrame({"stockCode": sorted(codes), "프리셋": ["cycle_recovery"] * len(codes)})
+
+
+def _screenCycleDefensive() -> pl.DataFrame:
+ """경기 방어: 저베타(defensive) + 안정 재무 + 배당.
+
+ 실험 109-02: 둔화기에 defensive > high -7.4%p.
+ macroBeta에서 GDP beta < 0.5 종목 = 방어주.
+ """
+ try:
+ macro = _loadAxis("macroBeta")
+ except Exception: # noqa: BLE001
+ macro = pl.DataFrame()
+
+ debt = _loadAxis("debt")
+ div_df = _loadAxis("dividend")
+
+ safeDbt = set(debt.filter(pl.col("위험등급").is_in(["안전", "관찰"]))["종목코드"].to_list())
+
+ # 배당 안정 종목
+ goodDiv = set()
+ if not div_df.is_empty():
+ for col in ["분류", "유형"]:
+ if col in div_df.columns:
+ goodDiv = set(div_df.filter(pl.col(col).is_in(["연속증가", "안정", "환원형"]))["종목코드"].to_list())
+ break
+ if not goodDiv:
+ goodDiv = set(div_df["종목코드"].to_list())
+
+ # 저베타 (defensive)
+ if not macro.is_empty() and "gdpBeta" in macro.columns:
+ lowBeta = set(macro.filter(pl.col("gdpBeta") < 0.5)["종목코드"].to_list())
+ else:
+ lowBeta = safeDbt # fallback
+
+ codes = lowBeta & safeDbt
+ if goodDiv:
+ codes = codes & goodDiv
+
+ if not codes:
+ return pl.DataFrame({"stockCode": [], "프리셋": []})
+ return pl.DataFrame({"stockCode": sorted(codes), "프리셋": ["cycle_defensive"] * len(codes)})
+
+
+_DISPATCH = {
+ "value": _screenValue,
+ "dividend": _screenDividend,
+ "growth": _screenGrowth,
+ "risk": _screenRisk,
+ "quality": _screenQuality,
+ "cycle_recovery": _screenCycleRecovery,
+ "cycle_defensive": _screenCycleDefensive,
+ "all": _screenAll,
+}
+
+
+def scanScreen(target: str | None = None, *, verbose: bool = True) -> pl.DataFrame:
+ """멀티팩터 스크리닝.
+
+ target 없으면 프리셋 목록 반환. target 지정하면 해당 프리셋 실행.
+ """
+ if target is None:
+ rows = [{"preset": k, "description": v} for k, v in _PRESETS.items()]
+ return pl.DataFrame(rows)
+
+ key = target.lower().strip()
+ if key not in _DISPATCH:
+ available = ", ".join(_PRESETS.keys())
+ raise ValueError(f"알 수 없는 screen 프리셋: '{target}'. 가용: {available}")
+
+ if verbose:
+ print(f"screen({key}): 실행 중...")
+ result = _DISPATCH[key]()
+ if verbose:
+ print(f"screen({key}): {result.shape[0]}종목")
+ return result
+
+
+__all__ = ["scanScreen"]
diff --git a/src/dartlab/scan/screen/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/screen/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..b7203679d5fbfcdc0a49e76587721a4c7c2cc44f
Binary files /dev/null and b/src/dartlab/scan/screen/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/snapshot.py b/src/dartlab/scan/snapshot.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e9e3a496b4edf3cd40f7d83ec9322909788f0bc
--- /dev/null
+++ b/src/dartlab/scan/snapshot.py
@@ -0,0 +1,292 @@
+"""scan 4축 시장 스냅샷 — 전종목 지표 사전 계산 + percentile 즉시 조회.
+
+rank 엔진의 buildSnapshot/getRank 패턴과 동일.
+첫 호출 시 전종목 scan 실행 (~4분) → JSON 저장 → 이후 즉시 조회.
+
+사용법::
+
+ from dartlab.scan.snapshot import buildScanSnapshot, getScanPosition
+
+ buildScanSnapshot() # 최초 1회
+ pos = getScanPosition("005930")
+ # → {governance: {value, percentile, ...}, workforce: ..., capital: ..., debt: ...}
+"""
+
+from __future__ import annotations
+
+import bisect
+import json
+import threading
+from pathlib import Path
+
+
+def _cacheDir() -> Path:
+ from dartlab import config
+
+ return Path(config.dataDir) / "_cache"
+
+
+def _cachePath() -> Path:
+ return _cacheDir() / "scan_snapshot.json"
+
+
+def buildScanSnapshot(*, verbose: bool = True) -> dict[str, dict]:
+ """전종목 scan 4축 핵심 지표 스냅샷 생성.
+
+ 기존 scan 함수를 그대로 호출하여 종목별 핵심 지표를 추출한다.
+ 결과를 JSON으로 저장하여 이후 조회는 즉시 가능.
+
+ Returns:
+ {stockCode: {governance_score, rev_per_employee, capital_class, icr, debt_risk}}
+ """
+ if verbose:
+ print("[scan] 전종목 스냅샷 빌드 시작...")
+
+ # ── governance: 총점 ──
+ if verbose:
+ print(" [1/4] governance 스캔...")
+ from dartlab.scan.governance.scanner import (
+ scan_audit_opinion,
+ scan_major_holder_pct,
+ scan_outside_directors,
+ scan_pay_ratio,
+ )
+ from dartlab.scan.governance.scorer import (
+ grade,
+ score_audit,
+ score_outside_ratio,
+ score_ownership,
+ score_pay_ratio,
+ )
+
+ holder_pct = scan_major_holder_pct()
+ outside_ratio = scan_outside_directors()
+ pay_ratio = scan_pay_ratio()
+ audit_opinion = scan_audit_opinion()
+
+ all_codes = set(holder_pct) | set(outside_ratio) | set(pay_ratio) | set(audit_opinion)
+ governance_scores: dict[str, float] = {}
+ governance_grades: dict[str, str] = {}
+ for code in all_codes:
+ s = (
+ score_ownership(holder_pct.get(code))
+ + score_outside_ratio(outside_ratio.get(code))
+ + score_pay_ratio(pay_ratio.get(code))
+ + score_audit(audit_opinion.get(code))
+ )
+ governance_scores[code] = s
+ governance_grades[code] = grade(s)
+
+ if verbose:
+ print(f" governance: {len(governance_scores)}종목")
+
+ # ── workforce: 직원당매출 ──
+ if verbose:
+ print(" [2/4] workforce 스캔...")
+ from dartlab.scan.workforce.scanner import scan_revenue_per_employee
+
+ rev_per_emp = scan_revenue_per_employee()
+ if verbose:
+ print(f" workforce: {len(rev_per_emp)}종목")
+
+ # ── capital: 분류 ──
+ if verbose:
+ print(" [3/4] capital 스캔...")
+ from dartlab.scan.capital.classifier import classify_return
+ from dartlab.scan.capital.scanner import (
+ scan_capital_change,
+ scan_dividend,
+ scan_treasury_stock,
+ )
+
+ dividends = scan_dividend()
+ treasury = scan_treasury_stock()
+ cap_changes = scan_capital_change()
+
+ capital_classes: dict[str, str] = {}
+ all_cap_codes = set(dividends) | set(treasury) | set(cap_changes)
+ for code in all_cap_codes:
+ div_info = dividends.get(code, {})
+ trs_info = treasury.get(code, {})
+ chg_info = cap_changes.get(code, {})
+
+ has_div = div_info.get("배당여부", False)
+ has_buyback = trs_info.get("당기취득", False)
+ recent_inc = chg_info.get("최근증자", False)
+
+ cls, _ = classify_return(has_div, has_buyback, recent_inc)
+ capital_classes[code] = cls
+
+ if verbose:
+ print(f" capital: {len(capital_classes)}종목")
+
+ # ── debt: ICR + 위험등급 ──
+ if verbose:
+ print(" [4/4] debt 스캔...")
+ from dartlab.scan.debt.risk import classify_risk, scan_icr
+ from dartlab.scan.debt.scanner import scan_bonds
+
+ icr_map = scan_icr()
+ bonds_map = scan_bonds()
+
+ debt_risk: dict[str, str] = {}
+ debt_icr: dict[str, float] = {}
+ all_debt_codes = set(icr_map) | set(bonds_map)
+ for code in all_debt_codes:
+ icr_val = icr_map.get(code)
+ bond_info = bonds_map.get(code, {})
+ short_pct = bond_info.get("단기비중")
+ debt_risk[code] = classify_risk(icr_val, short_pct)
+ if icr_val is not None:
+ debt_icr[code] = icr_val
+
+ if verbose:
+ print(f" debt: {len(debt_risk)}종목 (ICR {len(debt_icr)}종목)")
+
+ # ── 통합 ──
+ all_known = set(governance_scores) | set(rev_per_emp) | set(capital_classes) | set(debt_risk)
+
+ snapshot: dict[str, dict] = {}
+ for code in all_known:
+ snapshot[code] = {
+ "governance_score": governance_scores.get(code),
+ "governance_grade": governance_grades.get(code),
+ "rev_per_employee": rev_per_emp.get(code),
+ "capital_class": capital_classes.get(code),
+ "icr": debt_icr.get(code),
+ "debt_risk": debt_risk.get(code),
+ }
+
+ # ── 분포 통계 (percentile 계산용 정렬 배열) ──
+ gov_sorted = sorted(v for v in governance_scores.values() if v is not None)
+ rpe_sorted = sorted(v for v in rev_per_emp.values() if v is not None)
+ icr_sorted = sorted(v for v in debt_icr.values() if v is not None)
+
+ cap_dist = {}
+ for cls in capital_classes.values():
+ cap_dist[cls] = cap_dist.get(cls, 0) + 1
+
+ distributions = {
+ "governance_score": gov_sorted,
+ "rev_per_employee": rpe_sorted,
+ "icr": icr_sorted,
+ "capital_class_dist": cap_dist,
+ }
+
+ # ── 저장 ──
+ cache_dir = _cacheDir()
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ payload = {"snapshot": snapshot, "distributions": distributions}
+ _cachePath().write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
+
+ if verbose:
+ print(f" [scan] {len(snapshot)}종목 스냅샷 저장: {_cachePath()}")
+
+ return snapshot
+
+
+# ── 조회 ──
+
+_CACHE: dict | None = None
+_CACHE_LOCK = threading.Lock()
+
+
+def _ensureCache() -> dict | None:
+ global _CACHE
+ if _CACHE is not None:
+ return _CACHE
+ with _CACHE_LOCK:
+ if _CACHE is not None:
+ return _CACHE
+ path = _cachePath()
+ if not path.exists():
+ return None
+ _CACHE = json.loads(path.read_text(encoding="utf-8"))
+ return _CACHE
+
+
+def _percentile(sorted_arr: list[float], value: float) -> float:
+ """정렬 배열에서 percentile rank 산출 (0~100)."""
+ if not sorted_arr:
+ return 0.0
+ pos = bisect.bisect_right(sorted_arr, value)
+ return round(pos / len(sorted_arr) * 100, 1)
+
+
+def getScanPosition(stockCode: str) -> dict | None:
+ """종목의 scan 4축 시장 내 위치 조회.
+
+ 스냅샷이 없으면 None. buildScanSnapshot() 선행 필요.
+
+ Returns:
+ {governance: {value, percentile, grade, total},
+ workforce: {value, percentile, total},
+ capital: {class, distribution},
+ debt: {icr, percentile, risk, total}}
+ """
+ cache = _ensureCache()
+ if cache is None:
+ return None
+
+ snapshot = cache.get("snapshot", {})
+ dist = cache.get("distributions", {})
+ company = snapshot.get(stockCode)
+ if company is None:
+ return None
+
+ result: dict[str, dict | None] = {}
+
+ # governance
+ gov_score = company.get("governance_score")
+ gov_sorted = dist.get("governance_score", [])
+ if gov_score is not None:
+ result["governance"] = {
+ "value": gov_score,
+ "percentile": _percentile(gov_sorted, gov_score),
+ "grade": company.get("governance_grade"),
+ "total": len(gov_sorted),
+ }
+ else:
+ result["governance"] = None
+
+ # workforce
+ rpe = company.get("rev_per_employee")
+ rpe_sorted = dist.get("rev_per_employee", [])
+ if rpe is not None:
+ result["workforce"] = {
+ "value": rpe,
+ "percentile": _percentile(rpe_sorted, rpe),
+ "total": len(rpe_sorted),
+ }
+ else:
+ result["workforce"] = None
+
+ # capital (이산 → percentile 대신 분류 분포)
+ cap_cls = company.get("capital_class")
+ cap_dist = dist.get("capital_class_dist", {})
+ if cap_cls is not None:
+ result["capital"] = {
+ "class": cap_cls,
+ "distribution": cap_dist,
+ }
+ else:
+ result["capital"] = None
+
+ # debt
+ icr = company.get("icr")
+ icr_sorted = dist.get("icr", [])
+ if icr is not None:
+ result["debt"] = {
+ "icr": icr,
+ "percentile": _percentile(icr_sorted, icr),
+ "risk": company.get("debt_risk"),
+ "total": len(icr_sorted),
+ }
+ else:
+ debt_risk = company.get("debt_risk")
+ if debt_risk:
+ result["debt"] = {"icr": None, "percentile": None, "risk": debt_risk, "total": len(icr_sorted)}
+ else:
+ result["debt"] = None
+
+ return result
diff --git a/src/dartlab/scan/spec.py b/src/dartlab/scan/spec.py
new file mode 100644
index 0000000000000000000000000000000000000000..06f044a61d71b7503c5cd5e98f197e21605cc870
--- /dev/null
+++ b/src/dartlab/scan/spec.py
@@ -0,0 +1,98 @@
+"""scan 엔진 스펙 -- AI spec 수집기에 제공."""
+
+from __future__ import annotations
+
+
+def buildSpec() -> dict:
+ """scan 엔진 메타데이터."""
+ return {
+ "name": "dart.scan",
+ "description": "상장사 전수 스캔 엔진 -- 15축 시장 횡단분석",
+ "summary": {
+ "governance": "지배구조 (지분율, 사외이사비율, 임원보수비율, 감사의견, 소액주주 분산) -> A~E 등급",
+ "workforce": "인력/급여 (직원수, 평균급여, 인건비율, 1인당부가가치, 급여성장률, 고액보수)",
+ "capital": "주주환원 (배당, 자사주, 증자/감자) -> 환원형/중립/희석형",
+ "debt": "부채구조 (사채잔액, 부채비율, ICR) -> 안전/관찰/주의/고위험",
+ "cashflow": "현금흐름 (OCF/ICF/FCF) -> 8종 패턴 분류",
+ "audit": "감사리스크 (감사의견, 감사인변경, 특기사항, 감사독립성) -> 4단계 리스크",
+ "insider": "내부자지분 (최대주주 지분변동, 자기주식) -> 경영권 안정성 등급",
+ "quality": "이익의 질 (Accrual Ratio, CF/NI) -> 5단계 등급",
+ "liquidity": "유동성 (유동비율, 당좌비율) -> 5단계 등급",
+ "growth": "성장성 (매출/영업이익/순이익 CAGR) -> 등급 + 6종 패턴",
+ "profitability": "수익성 (영업이익률/순이익률/ROE/ROA) -> 5단계 등급",
+ "account": "전종목 단일 계정 시계열 (매출액, 영업이익 등)",
+ "ratio": "전종목 단일 재무비율 시계열 (ROE, 부채비율 등)",
+ "network": "관계 네트워크 (출자/지분/계열 관계, 순환출자 탐지)",
+ "digest": "시장 전체 공시 변화 다이제스트",
+ },
+ "detail": {
+ "governance": {
+ "metrics": [
+ "최대주주_지분율",
+ "사외이사비율",
+ "중도사임",
+ "겸직",
+ "임원_직원_보수비율",
+ "감사의견",
+ "소액주주지분",
+ ],
+ "scoring": "5축 (지분20+사외25+보수15+감사25+분산15) = 100점, A(85+) B(70+) C(55+) D(40+) E(<40)",
+ },
+ "workforce": {
+ "metrics": [
+ "직원수",
+ "평균급여_만원",
+ "남녀격차",
+ "근속_년",
+ "직원당매출_억",
+ "인건비율",
+ "1인당부가가치_억",
+ "급여성장률",
+ "매출성장률",
+ "급여매출괴리",
+ "최고보수_억",
+ "공개인원",
+ ],
+ },
+ "capital": {
+ "metrics": ["배당여부", "DPS", "배당수익률", "자사주보유", "자사주취득", "최근증자"],
+ "classification": "환원점수: +1(배당) +1(자사주) -1(증자) -> 환원형/중립/희석형",
+ },
+ "debt": {
+ "metrics": ["사채잔액", "단기비중", "총부채", "부채비율", "ICR"],
+ "risk_levels": "고위험(단기>=50%+ICR<1) / 주의 / 관찰 / 안전",
+ },
+ "cashflow": {
+ "metrics": ["OCF", "ICF", "FCF", "finCf"],
+ "patterns": "성장투자형/공격성장형/구조재편형/현금축적형/외부의존형/축소정리형/위기대응형/현금위기형",
+ },
+ "audit": {
+ "metrics": ["감사의견", "감사인", "감사인변경", "특기사항", "감사보수", "비감사보수", "독립성비율"],
+ "risk_levels": "고위험/주의/관찰/안전",
+ },
+ "insider": {
+ "metrics": ["최대주주_지분율", "지분변동률", "자기주식"],
+ "stability": "안정/보통/취약/위험/경고",
+ },
+ "quality": {
+ "metrics": ["당기순이익", "영업CF", "총자산", "Accrual Ratio", "CF/NI"],
+ "grades": "우수/양호/보통/주의/위험",
+ },
+ "liquidity": {
+ "metrics": ["유동자산", "유동부채", "재고자산", "유동비율", "당좌비율"],
+ "grades": "우수(>=200%)/양호(>=150%)/보통(>=100%)/주의(>=50%)/위험(<50%)",
+ },
+ "growth": {
+ "metrics": ["매출액", "매출CAGR", "영업이익CAGR", "순이익CAGR"],
+ "grades": "고성장(>=20%)/성장(>=10%)/정체(>=0%)/역성장(>=-10%)/급감",
+ "patterns": "균형성장/수익개선/외형성장/구조조정/전면역성장/혼합",
+ },
+ "profitability": {
+ "metrics": ["영업이익률", "순이익률", "ROE", "ROA"],
+ "grades": "우수(>=20%)/양호(>=10%)/보통(>=5%)/저수익(>=0%)/적자",
+ },
+ "network": {
+ "metrics": ["출자엣지", "지분엣지", "그룹분류", "순환출자"],
+ },
+ },
+ }
diff --git a/src/dartlab/scan/valuation/__init__.py b/src/dartlab/scan/valuation/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f8d2b6620af562f175ec2d551132e257b2ca85ea
--- /dev/null
+++ b/src/dartlab/scan/valuation/__init__.py
@@ -0,0 +1,138 @@
+"""밸류에이션 횡단 스캔 -- PER/PBR/PSR + 등급 (네이버 실시간)."""
+
+from __future__ import annotations
+
+import asyncio
+
+import polars as pl
+
+from dartlab.scan._helpers import scan_finance_parquets
+
+_REVENUE_IDS = {"Revenue", "revenue", "ifrs-full_Revenue", "dart_Revenue"}
+_REVENUE_NMS = {"매출액", "수익(매출액)", "영업수익"}
+
+_CONCURRENCY = 50 # 동시 요청 제한
+
+
+def _gradeValuation(pbr: float | None) -> str:
+ """PBR 기준 밸류에이션 등급."""
+ if pbr is None:
+ return "해당없음"
+ if pbr < 0:
+ return "해당없음"
+ if pbr < 0.5:
+ return "저평가"
+ if pbr <= 1.5:
+ return "적정"
+ if pbr <= 3.0:
+ return "고평가"
+ return "과열"
+
+
+async def _fetchAll(codes: list[str], verbose: bool) -> dict[str, dict]:
+ """네이버 API 배치 수집."""
+ import httpx
+
+ from dartlab.gather.domains.naver import fetch_price
+
+ result: dict[str, dict] = {}
+ sem = asyncio.Semaphore(_CONCURRENCY)
+
+ async def _fetch(code: str, client: httpx.AsyncClient) -> None:
+ async with sem:
+ try:
+ snap = await fetch_price(code, client)
+ if snap and snap.market_cap and snap.market_cap > 0:
+ result[code] = {
+ "marketCap": snap.market_cap,
+ "per": snap.per if snap.per else None,
+ "pbr": snap.pbr if snap.pbr else None,
+ "dividendYield": snap.dividend_yield if snap.dividend_yield else None,
+ "current": snap.current,
+ }
+ except (httpx.HTTPError, ValueError, AttributeError):
+ pass
+
+ async with httpx.AsyncClient(timeout=10) as client:
+ total = len(codes)
+ batch = 200
+ for i in range(0, total, batch):
+ chunk = codes[i : i + batch]
+ tasks = [_fetch(c, client) for c in chunk]
+ await asyncio.gather(*tasks)
+ if verbose:
+ print(f" {min(i + batch, total)}/{total} 수집...")
+
+ return result
+
+
+def scanValuation(*, verbose: bool = True) -> pl.DataFrame:
+ """전종목 밸류에이션 스캔 -- PER/PBR/PSR + 등급.
+
+ 네이버 API에서 실시간 수집. 2700종목 기준 2-3분 소요.
+ """
+ if verbose:
+ print("밸류에이션 스캔: 상장사 목록 수집...")
+
+ # 상장사 코드 목록
+ import dartlab as _dl
+
+ listing = _dl.listing()
+ codes = listing["종목코드"].to_list() if "종목코드" in listing.columns else []
+ if not codes:
+ return pl.DataFrame()
+
+ if verbose:
+ print(f" {len(codes)}종목 → 네이버 API 수집 시작")
+
+ # 매출 데이터 (PSR 계산용)
+ revMap = scan_finance_parquets("IS", _REVENUE_IDS, _REVENUE_NMS)
+
+ # async 수집
+ priceMap = asyncio.run(_fetchAll(codes, verbose))
+
+ if verbose:
+ print(f" 수집 완료: {len(priceMap)}종목")
+
+ rows: list[dict] = []
+ for code, data in priceMap.items():
+ mc = data["marketCap"]
+ per = data["per"]
+ pbr = data["pbr"]
+ dy = data["dividendYield"]
+
+ # PSR = 시가총액(원) / 매출(원) — 둘 다 원 단위
+ rev = revMap.get(code)
+ psr = round(mc / rev, 2) if rev and rev > 0 and mc > 0 else None
+
+ rows.append(
+ {
+ "stockCode": code,
+ "marketCap": round(mc),
+ "per": per,
+ "pbr": pbr,
+ "psr": psr,
+ "dividendYield": dy,
+ "grade": _gradeValuation(pbr),
+ }
+ )
+
+ if verbose:
+ print(f"밸류에이션 스캔 완료: {len(rows)}종목")
+
+ if not rows:
+ return pl.DataFrame()
+
+ schema = {
+ "stockCode": pl.Utf8,
+ "marketCap": pl.Float64,
+ "per": pl.Float64,
+ "pbr": pl.Float64,
+ "psr": pl.Float64,
+ "dividendYield": pl.Float64,
+ "grade": pl.Utf8,
+ }
+ return pl.DataFrame(rows, schema=schema)
+
+
+__all__ = ["scanValuation"]
diff --git a/src/dartlab/scan/valuation/__pycache__/__init__.cpython-312.pyc b/src/dartlab/scan/valuation/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..11864adbf14d765fee8a76aa8f67e4369ad4ba1d
Binary files /dev/null and b/src/dartlab/scan/valuation/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/scan/watch/__init__.py b/src/dartlab/scan/watch/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c1d6f1f4c3854e3995f8749d38c78d28702972f1
--- /dev/null
+++ b/src/dartlab/scan/watch/__init__.py
@@ -0,0 +1,31 @@
+"""공시 변화 감지 엔진 — sections diff 기반 자동 스캔 + 중요도 스코어링.
+
+sections 수평화 위에 구축된 변화 감지 시스템.
+Bloomberg에 없는 기능: 서술형 공시 텍스트의 자동 변화 추적.
+
+사용법::
+
+ import dartlab
+
+ # 단일 기업
+ c = dartlab.Company("005930")
+ c.watch() # 전체 topic 변화 요약 (중요도 순)
+ c.watch("riskManagement") # 특정 topic 상세
+
+ # 시장 다이제스트
+ dartlab.digest() # 전체 시장 TOP 변화
+ dartlab.digest(sector="반도체") # 섹터별
+"""
+
+from dartlab.scan.watch.digest import build_digest
+from dartlab.scan.watch.scanner import scan_company, scan_market
+from dartlab.scan.watch.scorer import score_changes
+
+
+def scanDigest(*, format: str = "dataframe", top_n: int = 30, **kwargs) -> object:
+ """시장 전체 공시 변화 다이제스트 (scan_market + build_digest 래핑)."""
+ df = scan_market(top_n=top_n, **kwargs)
+ return build_digest(df, format=format, top_n=top_n)
+
+
+__all__ = ["scan_company", "scan_market", "score_changes", "build_digest", "scanDigest"]
diff --git a/src/dartlab/scan/watch/digest.py b/src/dartlab/scan/watch/digest.py
new file mode 100644
index 0000000000000000000000000000000000000000..2185a5c488eaceb766bc6a5c84ed492dbdbc57d3
--- /dev/null
+++ b/src/dartlab/scan/watch/digest.py
@@ -0,0 +1,128 @@
+"""시장 변화 다이제스트 생성.
+
+scan_market 결과를 마크다운/JSON/DataFrame 형태의 요약 다이제스트로 변환한다.
+
+사용법::
+
+ from dartlab.scan.watch.digest import build_digest
+
+ df = scan_market(top_n=30)
+ md = build_digest(df, format="markdown")
+ print(md)
+"""
+
+from __future__ import annotations
+
+from datetime import date
+
+import polars as pl
+
+
+def build_digest(
+ scan_df: pl.DataFrame,
+ *,
+ title: str | None = None,
+ format: str = "markdown",
+ top_n: int = 20,
+) -> str | pl.DataFrame | dict:
+ """스캔 결과에서 다이제스트를 생성한다.
+
+ Args:
+ scan_df: scan_market() 결과 DataFrame.
+ title: 다이제스트 제목 (None이면 자동 생성).
+ format: "markdown", "json", "dataframe" 중 택1.
+ top_n: 다이제스트에 포함할 최대 항목 수.
+
+ Returns:
+ format에 따라 str(markdown), dict(json), pl.DataFrame.
+ """
+ if scan_df.height == 0:
+ if format == "dataframe":
+ return scan_df
+ if format == "json":
+ return {"title": title or "변화 없음", "date": str(date.today()), "items": []}
+ return f"# {title or '시장 변화 다이제스트'}\n\n변화가 감지되지 않았습니다.\n"
+
+ df = scan_df.sort("score", descending=True).head(top_n)
+
+ if format == "dataframe":
+ return df
+
+ if format == "json":
+ return _to_json(df, title)
+
+ return _to_markdown(df, title)
+
+
+def _to_markdown(df: pl.DataFrame, title: str | None) -> str:
+ """DataFrame → 마크다운 다이제스트."""
+ today = date.today().isoformat()
+ header = title or f"시장 변화 다이제스트 ({today})"
+
+ lines = [f"# {header}\n"]
+
+ # 기업별 그룹핑
+ if "stockCode" in df.columns and "corpName" in df.columns:
+ grouped = df.group_by(["stockCode", "corpName"], maintain_order=True)
+ for (stock_code, corp_name), group_df in grouped:
+ name_label = corp_name if corp_name else stock_code
+ lines.append(f"\n## {name_label} ({stock_code})\n")
+ for row in group_df.iter_rows(named=True):
+ score = row.get("score", 0)
+ topic = row.get("topic", "")
+ reason = row.get("reason", "")
+ change_rate = row.get("changeRate", 0)
+ period = row.get("latestPeriod", "")
+
+ badge = _score_badge(score)
+ lines.append(f"- {badge} **{topic}** (score: {score:.1f}, 변화율: {change_rate:.1%})")
+ if period:
+ lines.append(f" - 기간: {period}")
+ if reason:
+ lines.append(f" - 근거: {reason}")
+ else:
+ for row in df.iter_rows(named=True):
+ score = row.get("score", 0)
+ topic = row.get("topic", "")
+ reason = row.get("reason", "")
+ badge = _score_badge(score)
+ lines.append(f"- {badge} **{topic}** (score: {score:.1f}) — {reason}")
+
+ lines.append(f"\n---\n생성일: {today}\n")
+ return "\n".join(lines)
+
+
+def _to_json(df: pl.DataFrame, title: str | None) -> dict:
+ """DataFrame → JSON dict."""
+ today = date.today().isoformat()
+ items = []
+ for row in df.iter_rows(named=True):
+ items.append(
+ {
+ "stockCode": row.get("stockCode", ""),
+ "corpName": row.get("corpName", ""),
+ "topic": row.get("topic", ""),
+ "score": round(row.get("score", 0), 1),
+ "changeRate": round(row.get("changeRate", 0), 4),
+ "deltaBytes": row.get("deltaBytes", 0),
+ "latestPeriod": row.get("latestPeriod", ""),
+ "reason": row.get("reason", ""),
+ }
+ )
+ return {
+ "title": title or f"시장 변화 다이제스트 ({today})",
+ "date": today,
+ "count": len(items),
+ "items": items,
+ }
+
+
+def _score_badge(score: float) -> str:
+ """점수에 따른 텍스트 배지."""
+ if score >= 80:
+ return "[!!!]"
+ if score >= 50:
+ return "[!!]"
+ if score >= 25:
+ return "[!]"
+ return "[~]"
diff --git a/src/dartlab/scan/watch/scanner.py b/src/dartlab/scan/watch/scanner.py
new file mode 100644
index 0000000000000000000000000000000000000000..e308d989cba6b1bc227aab959cde3efbbdb2031e
--- /dev/null
+++ b/src/dartlab/scan/watch/scanner.py
@@ -0,0 +1,217 @@
+"""변화 감지 스캐너.
+
+단일 기업 또는 로컬 docs corpus 전체를 순회하며
+sections diff + 중요도 스코어링을 실행한다.
+
+사용법::
+
+ from dartlab.scan.watch.scanner import scan_company, scan_market
+
+ # 단일 기업 (Company 객체)
+ result = scan_company(company)
+
+ # 시장 전체 (로컬에 있는 docs parquet 기준)
+ top = scan_market(sector="반도체", top_n=20)
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import polars as pl
+
+from dartlab.core.docs.diff import DiffResult, sectionsDiff
+from dartlab.scan.watch.scorer import ScoredChange, score_changes, scored_to_dataframe
+
+
+@dataclass
+class ScanResult:
+ """단일 기업 스캔 결과."""
+
+ stockCode: str
+ corpName: str | None
+ diffResult: DiffResult
+ scored: list[ScoredChange]
+
+ @property
+ def topScore(self) -> float:
+ """감지된 변화 중 최고 점수 반환."""
+ if not self.scored:
+ return 0.0
+ return self.scored[0].score
+
+ def to_dataframe(self) -> pl.DataFrame:
+ """스코어링 결과를 DataFrame으로."""
+ df = scored_to_dataframe(self.scored)
+ if df.height > 0:
+ df = df.with_columns(
+ pl.lit(self.stockCode).alias("stockCode"),
+ pl.lit(self.corpName or "").alias("corpName"),
+ )
+ return df
+
+
+def scan_company(
+ company: object,
+ *,
+ topic: str | None = None,
+) -> ScanResult | None:
+ """단일 기업의 sections diff + 중요도 스코어링.
+
+ Args:
+ company: dartlab Company 객체 (docs.sections 속성 필요).
+ topic: 특정 topic만 필터링 (None이면 전체).
+
+ Returns:
+ ScanResult 또는 sections가 없으면 None.
+ """
+ docs_sections = getattr(getattr(company, "docs", None), "sections", None)
+ if docs_sections is None:
+ return None
+
+ if topic is not None and "topic" in docs_sections.columns:
+ docs_sections = docs_sections.filter(pl.col("topic") == topic)
+ if docs_sections.height == 0:
+ return None
+
+ diff_result = sectionsDiff(docs_sections)
+ if not diff_result.summaries:
+ return None
+
+ scored = score_changes(diff_result, sections=docs_sections)
+
+ stock_code = getattr(company, "stockCode", "")
+ corp_name = getattr(company, "corpName", None)
+
+ return ScanResult(
+ stockCode=stock_code,
+ corpName=corp_name,
+ diffResult=diff_result,
+ scored=scored,
+ )
+
+
+def _list_local_docs() -> list[str]:
+ """로컬에 다운로드된 docs parquet 종목코드 목록."""
+ from dartlab.core.dataConfig import DATA_RELEASES
+ from dartlab.core.dataLoader import _getDataRoot
+
+ docs_dir = _getDataRoot() / DATA_RELEASES["docs"]["dir"]
+ if not docs_dir.exists():
+ return []
+ return sorted(p.stem for p in docs_dir.glob("*.parquet"))
+
+
+def scan_market(
+ *,
+ sector: str | None = None,
+ top_n: int = 20,
+ min_score: float = 10.0,
+ stock_codes: list[str] | None = None,
+ verbose: bool = False,
+) -> pl.DataFrame:
+ """시장 전체 또는 섹터별 변화 감지 스캔.
+
+ 로컬에 다운로드된 docs parquet을 순회하며 각 기업의
+ sections diff → 중요도 스코어링을 실행한 뒤 상위 변화를 집계한다.
+
+ Args:
+ sector: 섹터 필터 (예: "반도체", "IT"). None이면 전체.
+ top_n: 상위 N개 결과만 반환.
+ min_score: 이 점수 이상만 포함.
+ stock_codes: 직접 종목코드 목록 지정 (sector 무시).
+ verbose: True이면 진행 상황 출력.
+
+ Returns:
+ stockCode, corpName, topic, score, changeRate, reason 등 컬럼의 DataFrame.
+ """
+ if stock_codes is None:
+ codes = _list_local_docs()
+ else:
+ codes = list(stock_codes)
+
+ if not codes:
+ from dartlab.core.guidance import emit
+
+ emit("hint:market_data_needed", category="docs", fn="digest")
+ return pl.DataFrame(
+ schema={
+ "stockCode": pl.Utf8,
+ "corpName": pl.Utf8,
+ "topic": pl.Utf8,
+ "score": pl.Float64,
+ "changeRate": pl.Float64,
+ "deltaBytes": pl.Int64,
+ "latestPeriod": pl.Utf8,
+ "reason": pl.Utf8,
+ }
+ )
+
+ # 섹터 필터링
+ if sector is not None and stock_codes is None:
+ codes = _filter_by_sector(codes, sector)
+
+ from dartlab.providers.dart.company import Company as DartCompany
+
+ frames: list[pl.DataFrame] = []
+ scanned = 0
+
+ for code in codes:
+ try:
+ c = DartCompany(code)
+ result = scan_company(c)
+ if result is not None and result.scored:
+ df = result.to_dataframe()
+ if df.height > 0:
+ frames.append(df)
+ scanned += 1
+ if verbose and scanned % 50 == 0:
+ print(f"[watch] {scanned}/{len(codes)} 스캔 완료...")
+ except (FileNotFoundError, ValueError, KeyError, OSError):
+ continue
+
+ if not frames:
+ return pl.DataFrame(
+ schema={
+ "stockCode": pl.Utf8,
+ "corpName": pl.Utf8,
+ "topic": pl.Utf8,
+ "score": pl.Float64,
+ "changeRate": pl.Float64,
+ "deltaBytes": pl.Int64,
+ "latestPeriod": pl.Utf8,
+ "reason": pl.Utf8,
+ }
+ )
+
+ combined = pl.concat(frames, how="diagonal_relaxed")
+ combined = combined.filter(pl.col("score") >= min_score)
+ combined = combined.sort("score", descending=True).head(top_n)
+
+ if verbose:
+ print(f"[watch] 스캔 완료: {scanned}개 기업, {combined.height}개 변화 감지")
+
+ return combined
+
+
+def _filter_by_sector(codes: list[str], sector: str) -> list[str]:
+ """종목코드 목록에서 특정 섹터에 해당하는 것만 필터."""
+ try:
+ from dartlab.core.sector.classifier import classify
+
+ filtered = []
+ for code in codes:
+ try:
+ from dartlab.gather.listing import codeToName
+
+ name = codeToName(code)
+ if name is None:
+ continue
+ info = classify(name)
+ if sector in (info.sector.value, info.industryGroup.value):
+ filtered.append(code)
+ except (ValueError, KeyError, ImportError):
+ continue
+ return filtered if filtered else codes
+ except ImportError:
+ return codes
diff --git a/src/dartlab/scan/watch/scorer.py b/src/dartlab/scan/watch/scorer.py
new file mode 100644
index 0000000000000000000000000000000000000000..46a885859f62e8d5cb3101cc1d7b57ba2e74e954
--- /dev/null
+++ b/src/dartlab/scan/watch/scorer.py
@@ -0,0 +1,212 @@
+"""변화 중요도 스코어링.
+
+DiffResult에서 각 topic의 변화에 점수를 부여한다.
+기본 changeRate에 키워드 매칭, 텍스트 크기 변화, topic 유형 가중치를 곱한다.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import polars as pl
+
+from dartlab.core.docs.diff import DiffEntry, DiffResult
+
+# 변화 스코어링용 기본 키워드
+_SIGNAL_KEYWORDS: dict[str, list[str]] = {
+ "트렌드": ["AI", "ESG", "반도체", "바이오", "전기차"],
+ "리스크": ["환율", "금리", "소송", "감사의견", "파산"],
+ "기회": ["수출", "M&A", "특허", "신약"],
+}
+
+
+# 고가중 topic (경영 핵심)
+_HIGH_WEIGHT_TOPICS = frozenset(
+ {
+ "riskManagement",
+ "riskFactors",
+ "businessOverview",
+ "companyOverview",
+ "investmentRisk",
+ "regulatoryRisk",
+ "majorContract",
+ "contingentLiability",
+ "auditOpinion",
+ "relatedPartyTransaction",
+ # EDGAR
+ "10-K::item1ARiskFactors",
+ "10-K::item7MDA",
+ "10-K::item1Business",
+ }
+)
+
+# 저가중 topic (정형/반복적)
+_LOW_WEIGHT_TOPICS = frozenset(
+ {
+ "boardDiversity",
+ "employeeWelfare",
+ "facilityOverview",
+ }
+)
+
+
+@dataclass
+class ScoredChange:
+ """중요도 점수가 부여된 변화."""
+
+ topic: str
+ chapter: str | None
+ changeRate: float
+ score: float # 0~100
+ latestFromPeriod: str | None
+ latestToPeriod: str | None
+ deltaBytes: int # 최근 변화의 바이트 크기 변화
+ reason: str # 점수 주요 근거
+
+
+def score_changes(
+ diff_result: DiffResult,
+ *,
+ sections: pl.DataFrame | None = None,
+) -> list[ScoredChange]:
+ """DiffResult의 각 topic에 중요도 점수를 부여한다.
+
+ 스코어링 요소:
+ 1. changeRate (기본 50%)
+ 2. topic 가중치 — 핵심 경영 topic 가중
+ 3. 텍스트 크기 변화율 — 큰 변화일수록 중요
+ 4. 키워드 매칭 (sections 제공 시) — 트렌드/리스크 키워드 포함 여부
+
+ Args:
+ diff_result: sectionsDiff() 결과.
+ sections: (선택) 키워드 매칭에 사용할 sections DataFrame.
+
+ Returns:
+ ScoredChange 리스트 (score 내림차순 정렬).
+ """
+ {s.topic: s for s in diff_result.summaries}
+
+ # entries에서 topic별 최근 변화 추출
+ latest_entry: dict[str, DiffEntry] = {}
+ for entry in diff_result.entries:
+ prev = latest_entry.get(entry.topic)
+ if prev is None or entry.toPeriod > prev.toPeriod:
+ latest_entry[entry.topic] = entry
+
+ # 키워드 매칭 준비
+ keywords = _SIGNAL_KEYWORDS
+ all_keywords = []
+ for kw_list in keywords.values():
+ all_keywords.extend(kw_list)
+
+ # 최근 텍스트에서 키워드 카운트 (sections 제공 시)
+ topic_keyword_hits: dict[str, int] = {}
+ if sections is not None and all_keywords:
+ periods = [
+ c
+ for c in sections.columns
+ if c not in ("topic", "chapter", "blockType", "textNodeType", "sourceBlockOrder")
+ ]
+ if periods:
+ latest_period = periods[0] # 최신 기간 (역순 정렬 가정)
+ if "topic" in sections.columns and latest_period in sections.columns:
+ for row in sections.iter_rows(named=True):
+ text = str(row.get(latest_period) or "")
+ if not text:
+ continue
+ topic = row.get("topic", "")
+ hits = sum(1 for kw in all_keywords if kw in text)
+ if hits > 0:
+ topic_keyword_hits[topic] = topic_keyword_hits.get(topic, 0) + hits
+
+ scored: list[ScoredChange] = []
+
+ for summary in diff_result.summaries:
+ topic = summary.topic
+ change_rate = summary.changeRate
+
+ # 1. 기본 점수 = changeRate × 50
+ base_score = change_rate * 50.0
+
+ # 2. topic 가중치
+ topic_weight = 1.0
+ reason_parts = []
+ if topic in _HIGH_WEIGHT_TOPICS:
+ topic_weight = 1.5
+ reason_parts.append("핵심 경영 topic")
+ elif topic in _LOW_WEIGHT_TOPICS:
+ topic_weight = 0.6
+
+ # 3. 텍스트 크기 변화율
+ entry = latest_entry.get(topic)
+ delta_bytes = 0
+ delta_score = 0.0
+ if entry is not None:
+ delta_bytes = entry.toLen - entry.fromLen
+ if entry.fromLen > 0:
+ abs_rate = abs(delta_bytes) / entry.fromLen
+ delta_score = min(abs_rate * 30.0, 30.0) # 최대 30점
+ if abs_rate > 0.5:
+ reason_parts.append(f"텍스트 {abs_rate:.0%} 변화")
+
+ # 4. 키워드 가중치
+ kw_hits = topic_keyword_hits.get(topic, 0)
+ kw_score = min(kw_hits * 2.0, 20.0) # 최대 20점
+ if kw_hits >= 3:
+ reason_parts.append(f"트렌드/리스크 키워드 {kw_hits}건")
+
+ # 합산
+ raw_score = (base_score + delta_score + kw_score) * topic_weight
+ final_score = min(raw_score, 100.0)
+
+ if not reason_parts:
+ if change_rate > 0.5:
+ reason_parts.append(f"변화율 {change_rate:.0%}")
+ elif change_rate > 0:
+ reason_parts.append("소폭 변화")
+ else:
+ reason_parts.append("변화 없음")
+
+ scored.append(
+ ScoredChange(
+ topic=topic,
+ chapter=summary.chapter,
+ changeRate=round(change_rate, 3),
+ score=round(final_score, 1),
+ latestFromPeriod=entry.fromPeriod if entry else None,
+ latestToPeriod=entry.toPeriod if entry else None,
+ deltaBytes=delta_bytes,
+ reason=" / ".join(reason_parts),
+ )
+ )
+
+ scored.sort(key=lambda s: s.score, reverse=True)
+ return scored
+
+
+def scored_to_dataframe(scored: list[ScoredChange]) -> pl.DataFrame:
+ """ScoredChange 리스트를 DataFrame으로 변환."""
+ if not scored:
+ return pl.DataFrame(
+ schema={
+ "topic": pl.Utf8,
+ "score": pl.Float64,
+ "changeRate": pl.Float64,
+ "deltaBytes": pl.Int64,
+ "latestPeriod": pl.Utf8,
+ "reason": pl.Utf8,
+ }
+ )
+ return pl.DataFrame(
+ [
+ {
+ "topic": s.topic,
+ "score": s.score,
+ "changeRate": s.changeRate,
+ "deltaBytes": s.deltaBytes,
+ "latestPeriod": f"{s.latestFromPeriod}→{s.latestToPeriod}" if s.latestFromPeriod else "",
+ "reason": s.reason,
+ }
+ for s in scored
+ ]
+ )
diff --git a/src/dartlab/scan/watch/spec.py b/src/dartlab/scan/watch/spec.py
new file mode 100644
index 0000000000000000000000000000000000000000..f1b7d8ea7fd0c53d4796d534c6c2380845d6ef3a
--- /dev/null
+++ b/src/dartlab/scan/watch/spec.py
@@ -0,0 +1,35 @@
+"""watch 엔진 스펙 — 코드에서 자동 추출."""
+
+from __future__ import annotations
+
+
+def buildSpec() -> dict:
+ """watch 엔진 스펙 반환."""
+ from dartlab.scan.watch.scorer import _HIGH_WEIGHT_TOPICS, _LOW_WEIGHT_TOPICS
+
+ return {
+ "name": "watch",
+ "description": "sections diff 기반 공시 변화 감지 + 중요도 스코어링",
+ "summary": {
+ "scoringFactors": 4,
+ "highWeightTopics": len(_HIGH_WEIGHT_TOPICS),
+ "lowWeightTopics": len(_LOW_WEIGHT_TOPICS),
+ "maxScore": 100,
+ },
+ "detail": {
+ "scoringFactors": [
+ "changeRate 기반 기본 점수 (최대 50점)",
+ "topic 유형 가중치 (핵심 경영 1.5x, 저가중 0.6x)",
+ "텍스트 크기 변화율 (최대 30점)",
+ "트렌드/리스크 키워드 매칭 (최대 20점)",
+ ],
+ "highWeightTopics": sorted(_HIGH_WEIGHT_TOPICS),
+ "lowWeightTopics": sorted(_LOW_WEIGHT_TOPICS),
+ "publicAPI": [
+ "Company.watch() — 단일 기업 변화 요약",
+ "Company.watch(topic) — 특정 topic 상세",
+ "dartlab.digest() — 시장 전체 다이제스트",
+ "dartlab.digest(sector=) — 섹터별 다이제스트",
+ ],
+ },
+ }
diff --git a/src/dartlab/scan/workforce/__init__.py b/src/dartlab/scan/workforce/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..641d5dc8f894855c25dc3b9709a54f696ca5b8a4
--- /dev/null
+++ b/src/dartlab/scan/workforce/__init__.py
@@ -0,0 +1,101 @@
+"""인력/급여 전수 스캔 — 직원 현황, 인건비 효율, 급여 성장, 고액 보수.
+
+Public API:
+ scan_workforce() → pl.DataFrame (전체 상장사 인력 현황)
+"""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dartlab.scan.workforce.growth import (
+ compute_salary_vs_revenue,
+ scan_revenue_growth,
+ scan_salary_growth,
+)
+from dartlab.scan.workforce.scanner import (
+ scan_employee,
+ scan_revenue_per_employee,
+ scan_top_pay,
+)
+
+
+def scan_workforce(*, verbose: bool = True) -> pl.DataFrame:
+ """전체 상장사 인력 스캔 → 종합 DataFrame.
+
+ 컬럼: 종목코드, 직원수, 평균급여_만원, 남녀격차, 근속_년,
+ 직원당매출_억, 인건비율, 1인당부가가치_억,
+ 급여성장률, 매출성장률, 급여매출괴리,
+ 최고보수_억, 공개인원
+ """
+
+ def _log(msg: str) -> None:
+ if verbose:
+ print(msg)
+
+ _log("1/6 직원 현황...")
+ emp_map = scan_employee()
+ _log(f" → {len(emp_map)}종목")
+
+ _log("2/6 직원당 매출...")
+ rpe_map = scan_revenue_per_employee()
+ _log(f" → {len(rpe_map)}종목")
+
+ _log("3/5 급여 vs 매출 성장률...")
+ sal_map = scan_salary_growth()
+ rev_map = scan_revenue_growth()
+ growth_df = compute_salary_vs_revenue(sal_map, rev_map)
+ growth_dict: dict[str, dict] = {}
+ for row in growth_df.iter_rows(named=True):
+ growth_dict[row["stockCode"]] = row
+ _log(f" → {len(growth_dict)}종목")
+
+ _log("4/5 고액 보수...")
+ top_map = scan_top_pay()
+ _log(f" → {len(top_map)}종목")
+
+ # 합집합
+ all_codes = set(emp_map) | set(rpe_map) | set(growth_dict) | set(top_map)
+
+ results = []
+ for code in all_codes:
+ emp = emp_map.get(code, {})
+ rpe = rpe_map.get(code)
+ g = growth_dict.get(code, {})
+ tp = top_map.get(code, {})
+
+ results.append(
+ {
+ "stockCode": code,
+ "직원수": emp.get("직원수"),
+ "평균급여_만원": emp.get("평균급여_만원"),
+ "남녀격차": emp.get("남녀격차"),
+ "근속_년": emp.get("근속_년"),
+ "직원당매출_억": rpe,
+ "급여성장률": g.get("급여성장률"),
+ "매출성장률": g.get("매출성장률"),
+ "급여매출괴리": g.get("급여매출괴리"),
+ "최고보수_억": tp.get("최고보수_억"),
+ "공개인원": tp.get("공개인원"),
+ }
+ )
+
+ schema = {
+ "stockCode": pl.Utf8,
+ "직원수": pl.Float64,
+ "평균급여_만원": pl.Float64,
+ "남녀격차": pl.Float64,
+ "근속_년": pl.Float64,
+ "직원당매출_억": pl.Float64,
+ "급여성장률": pl.Float64,
+ "매출성장률": pl.Float64,
+ "급여매출괴리": pl.Float64,
+ "최고보수_억": pl.Float64,
+ "공개인원": pl.Float64,
+ }
+ df = pl.DataFrame(results, schema=schema)
+ _log(f"인력 스캔 완료: {df.shape[0]}종목, 5/5")
+ return df
+
+
+__all__ = ["scan_workforce"]
diff --git a/src/dartlab/scan/workforce/efficiency.py b/src/dartlab/scan/workforce/efficiency.py
new file mode 100644
index 0000000000000000000000000000000000000000..e471bc310d01dcb6190126641ac173a0b634def6
--- /dev/null
+++ b/src/dartlab/scan/workforce/efficiency.py
@@ -0,0 +1,74 @@
+"""인건비 효율 지표 — 인건비율, 1인당 부가가치."""
+
+from __future__ import annotations
+
+from dartlab.scan._helpers import scan_finance_parquets
+from dartlab.scan.workforce.scanner import scan_employee, scan_total_payroll
+
+REVENUE_IDS = {
+ "Revenue",
+ "Revenues",
+ "revenue",
+ "revenues",
+ "ifrs-full_Revenue",
+ "dart_Revenue",
+ "RevenueFromContractsWithCustomers",
+}
+REVENUE_NMS = {"매출액", "수익(매출액)", "영업수익", "매출", "순영업수익"}
+
+OP_IDS = {
+ "dart_OperatingIncomeLoss",
+ "ifrs-full_ProfitLossFromOperatingActivities",
+ "OperatingIncomeLoss",
+}
+OP_NMS = {"영업이익", "영업이익(손실)", "영업손익"}
+
+
+def _revenueMap() -> dict[str, float]:
+ """전종목 매출(원) → {종목코드: 매출}."""
+ return scan_finance_parquets("IS", REVENUE_IDS, REVENUE_NMS)
+
+
+def _opIncomeMap() -> dict[str, float]:
+ """전종목 영업이익(원) → {종목코드: 영업이익}."""
+ return scan_finance_parquets("IS", OP_IDS, OP_NMS)
+
+
+def scan_labor_ratio() -> dict[str, float]:
+ """총급여/매출 → {종목코드: 인건비율(%)}.
+
+ 인건비율이 높을수록 매출 중 인건비 비중이 크다.
+ """
+ payrollMap = scan_total_payroll()
+ revMap = _revenueMap()
+
+ result: dict[str, float] = {}
+ for code, payroll in payrollMap.items():
+ rev = revMap.get(code)
+ if rev and rev > 0:
+ ratio = payroll / rev * 100
+ if 0 < ratio < 500:
+ result[code] = round(ratio, 1)
+ return result
+
+
+def scan_value_added() -> dict[str, float]:
+ """(영업이익+총급여)/직원수 → {종목코드: 1인당부가가치(억)}.
+
+ 부가가치 = 영업이익 + 인건비. 직원 1명이 만들어내는 가치.
+ """
+ payrollMap = scan_total_payroll()
+ opMap = _opIncomeMap()
+ empMap = scan_employee()
+
+ result: dict[str, float] = {}
+ for code, payroll in payrollMap.items():
+ opIncome = opMap.get(code)
+ empInfo = empMap.get(code)
+ if opIncome is None or empInfo is None:
+ continue
+ headcount = empInfo.get("직원수", 0)
+ if headcount and headcount > 0:
+ valueAdded = (opIncome + payroll) / headcount / 1e8
+ result[code] = round(valueAdded, 1)
+ return result
diff --git a/src/dartlab/scan/workforce/growth.py b/src/dartlab/scan/workforce/growth.py
new file mode 100644
index 0000000000000000000000000000000000000000..c42bde8f66b31fcab451b43a6f55d18fa35ca92d
--- /dev/null
+++ b/src/dartlab/scan/workforce/growth.py
@@ -0,0 +1,243 @@
+"""급여 성장률 vs 매출 성장률 — 급여-매출 괴리 분석."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from dartlab.scan._helpers import (
+ parse_num,
+ pick_best_quarter,
+ scan_parquets,
+)
+
+
+def _weighted_avg_salary(group: pl.DataFrame) -> float | None:
+ """직원수 가중평균 급여 (만원/연)."""
+ total_emp, total_wsum = 0, 0.0
+ for row in group.iter_rows(named=True):
+ emp = parse_num(row.get("sm"))
+ sal = parse_num(row.get("jan_salary_am"))
+ if emp and emp > 0 and sal and sal > 0:
+ total_emp += int(emp)
+ total_wsum += emp * sal
+ if total_emp > 0:
+ return total_wsum / total_emp / 10000
+ return None
+
+
+def scan_salary_growth() -> dict[str, dict]:
+ """employee 2개년도 → {종목코드: {급여성장률, 급여_신, 급여_구}}.
+
+ 급여는 만원/연 단위 가중평균.
+ """
+ raw = scan_parquets(
+ "employee",
+ ["stockCode", "year", "quarter", "sm", "jan_salary_am"],
+ )
+ if raw.is_empty():
+ return {}
+
+ years = sorted(raw["year"].unique().to_list(), reverse=True)
+ valid_years = []
+ for y in years:
+ sub = raw.filter(pl.col("year") == y)
+ ok = sub.filter(pl.col("jan_salary_am").is_not_null() & (pl.col("jan_salary_am") != "-")).shape[0]
+ if ok >= 500:
+ valid_years.append(y)
+ if len(valid_years) == 2:
+ break
+ if len(valid_years) < 2:
+ return {}
+
+ y_new, y_old = valid_years[0], valid_years[1]
+ result: dict[str, dict] = {}
+ for code in raw["stockCode"].unique().to_list():
+ grp_new = raw.filter((pl.col("stockCode") == code) & (pl.col("year") == y_new))
+ grp_old = raw.filter((pl.col("stockCode") == code) & (pl.col("year") == y_old))
+ if grp_new.is_empty() or grp_old.is_empty():
+ continue
+ sal_new = _weighted_avg_salary(pick_best_quarter(grp_new))
+ sal_old = _weighted_avg_salary(pick_best_quarter(grp_old))
+ if sal_new and sal_old and sal_old > 100:
+ growth = (sal_new - sal_old) / sal_old * 100
+ result[code] = {
+ "급여성장률": round(growth, 1),
+ "급여_신": round(sal_new, 0),
+ "급여_구": round(sal_old, 0),
+ }
+ return result
+
+
+REVENUE_IDS = {
+ "Revenue",
+ "Revenues",
+ "revenue",
+ "revenues",
+ "ifrs-full_Revenue",
+ "dart_Revenue",
+ "RevenueFromContractsWithCustomers",
+}
+REVENUE_NMS = {"매출액", "수익(매출액)", "영업수익", "매출", "순영업수익"}
+
+
+def _scanRevenueGrowthFromMerged(scanPath: Path) -> dict[str, float]:
+ """프리빌드 finance.parquet → 종목별 매출성장률."""
+ scCol = "stockCode" if "stockCode" in pl.scan_parquet(str(scanPath)).collect_schema().names() else "stock_code"
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ & (pl.col("bsns_year") <= "2024")
+ & (pl.col("account_id").is_in(list(REVENUE_IDS)) | pl.col("account_nm").is_in(list(REVENUE_NMS)))
+ )
+ .collect()
+ )
+ if target.is_empty():
+ return {}
+
+ # 연결 우선
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ if not cfs.is_empty():
+ target = cfs
+
+ result: dict[str, float] = {}
+ for code in target[scCol].unique().to_list():
+ sub = target.filter(pl.col(scCol) == code)
+ years = sorted(sub["bsns_year"].unique().to_list(), reverse=True)
+ if len(years) < 2:
+ continue
+
+ newYear, oldYear = years[0], years[1]
+ newRev = None
+ for row in sub.filter(pl.col("bsns_year") == newYear).iter_rows(named=True):
+ val = parse_num(row.get("thstrm_amount"))
+ if val and val > 0:
+ if newRev is None or val > newRev:
+ newRev = val
+
+ oldRev = None
+ for row in sub.filter(pl.col("bsns_year") == oldYear).iter_rows(named=True):
+ val = parse_num(row.get("thstrm_amount"))
+ if val and val > 0:
+ if oldRev is None or val > oldRev:
+ oldRev = val
+
+ if newRev and oldRev and oldRev > 0:
+ result[code] = round((newRev - oldRev) / oldRev * 100, 1)
+
+ return result
+
+
+def _scanRevenueGrowthPerFile() -> dict[str, float]:
+ """종목별 finance parquet 순회 fallback."""
+ from dartlab.core.dataLoader import _dataDir
+
+ financeDir = Path(_dataDir("finance"))
+ parquetFiles = sorted(financeDir.glob("*.parquet"))
+
+ result: dict[str, float] = {}
+ for pf in parquetFiles:
+ code = pf.stem
+ try:
+ isDf = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if isDf.is_empty() or "account_id" not in isDf.columns:
+ continue
+ cfs = isDf.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else isDf
+
+ revRows = target.filter(
+ pl.col("account_id").is_in(list(REVENUE_IDS)) | pl.col("account_nm").is_in(list(REVENUE_NMS))
+ )
+ if revRows.is_empty():
+ revRows = target.filter(pl.col("account_nm").str.contains("매출"))
+ if revRows.is_empty():
+ continue
+
+ completeYears = sorted(
+ [y for y in revRows["bsns_year"].unique().to_list() if y <= "2024"],
+ reverse=True,
+ )
+ if len(completeYears) < 2:
+ continue
+
+ newRev = None
+ for row in revRows.filter(pl.col("bsns_year") == completeYears[0]).iter_rows(named=True):
+ val = parse_num(row.get("thstrm_amount"))
+ if val and val > 0:
+ if newRev is None or val > newRev:
+ newRev = val
+
+ oldRev = None
+ for row in revRows.filter(pl.col("bsns_year") == completeYears[1]).iter_rows(named=True):
+ val = parse_num(row.get("thstrm_amount"))
+ if val and val > 0:
+ if oldRev is None or val > oldRev:
+ oldRev = val
+
+ if newRev and oldRev and oldRev > 0:
+ result[code] = round((newRev - oldRev) / oldRev * 100, 1)
+
+ return result
+
+
+def scan_revenue_growth() -> dict[str, float]:
+ """finance IS 2개 완전연도 → {종목코드: 매출성장률(%)}.
+
+ 프리빌드 finance.parquet 우선, 없으면 per-file fallback.
+ """
+ from dartlab.scan._helpers import _ensureScanData
+
+ scanDir = _ensureScanData()
+ scanPath = scanDir / "finance.parquet"
+ if scanPath.exists():
+ return _scanRevenueGrowthFromMerged(scanPath)
+ return _scanRevenueGrowthPerFile()
+
+
+def compute_salary_vs_revenue(
+ sal_map: dict[str, dict] | None = None,
+ rev_map: dict[str, float] | None = None,
+) -> pl.DataFrame:
+ """급여성장률 vs 매출성장률 → DataFrame.
+
+ 컬럼: 종목코드, 급여성장률, 매출성장률, 급여매출괴리, 급여>매출
+ """
+ if sal_map is None:
+ sal_map = scan_salary_growth()
+ if rev_map is None:
+ rev_map = scan_revenue_growth()
+
+ _CAP = 500.0 # +-500% 초과 성장률은 의미 없음 (전기 매출 ~0 등)
+ rows = []
+ for code in sal_map:
+ if code not in rev_map:
+ continue
+ sg = sal_map[code]["급여성장률"]
+ rg = rev_map[code]
+ # 극단값 클램핑 — 전기 매출/급여가 극소일 때 수만% 발생 방지
+ sg_c = max(-_CAP, min(_CAP, sg))
+ rg_c = max(-_CAP, min(_CAP, rg))
+ burden = sg_c - rg_c
+ rows.append(
+ {
+ "stockCode": code,
+ "급여성장률": round(sg_c, 1),
+ "매출성장률": round(rg_c, 1),
+ "급여매출괴리": round(burden, 1),
+ "급여>매출": burden > 0,
+ }
+ )
+ return pl.DataFrame(rows)
diff --git a/src/dartlab/scan/workforce/scanner.py b/src/dartlab/scan/workforce/scanner.py
new file mode 100644
index 0000000000000000000000000000000000000000..40ecd4accadafaee985441dd04abf84966470117
--- /dev/null
+++ b/src/dartlab/scan/workforce/scanner.py
@@ -0,0 +1,318 @@
+"""인력/급여 report 스캔 — employee, executivePayIndividual."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from dartlab.scan._helpers import (
+ find_latest_year,
+ parse_num,
+ pick_best_quarter,
+ scan_parquets,
+)
+
+
+def scan_employee() -> dict[str, dict]:
+ """employee → {종목코드: {직원수, 평균급여_만원, 남녀격차%, 근속_년}}.
+
+ 최신 연도 + 최적 분기(Q2) 기준. 급여는 가중평균.
+ """
+ raw = scan_parquets(
+ "employee",
+ ["stockCode", "year", "quarter", "sexdstn", "sm", "jan_salary_am", "avrg_cnwk_sdytrn"],
+ )
+ if raw.is_empty():
+ return {}
+
+ latest_year = find_latest_year(raw, "jan_salary_am", 500)
+ if latest_year is None:
+ return {}
+
+ sub = raw.filter(pl.col("year") == latest_year)
+ result: dict[str, dict] = {}
+
+ for code, group in sub.group_by("stockCode"):
+ code_val = code[0]
+ qdf = pick_best_quarter(group)
+
+ total_emp, total_wsum = 0, 0.0
+ male_emp, male_wsum = 0, 0.0
+ female_emp, female_wsum = 0, 0.0
+ tenure_emp, tenure_wsum = 0, 0.0
+
+ for row in qdf.iter_rows(named=True):
+ emp = parse_num(row.get("sm"))
+ sal = parse_num(row.get("jan_salary_am"))
+ tenure = parse_num(row.get("avrg_cnwk_sdytrn"))
+ sex = row.get("sexdstn", "")
+
+ if emp and emp > 0 and sal and sal > 0:
+ total_emp += int(emp)
+ total_wsum += emp * sal
+ if sex and "남" in sex:
+ male_emp += int(emp)
+ male_wsum += emp * sal
+ elif sex and "여" in sex:
+ female_emp += int(emp)
+ female_wsum += emp * sal
+
+ if emp and emp > 0 and tenure and tenure > 0:
+ tenure_emp += int(emp)
+ tenure_wsum += emp * tenure
+
+ if total_emp > 0 and total_wsum / total_emp > 1_000_000: # 100만원 이상
+ avg_sal = total_wsum / total_emp / 10000 # 만원/연
+ male_avg = male_wsum / male_emp / 10000 if male_emp > 0 else None
+ female_avg = female_wsum / female_emp / 10000 if female_emp > 0 else None
+ gender_gap = None
+ if male_avg and female_avg and male_avg > 0:
+ gender_gap = round((male_avg - female_avg) / male_avg * 100, 1)
+ avg_tenure = tenure_wsum / tenure_emp if tenure_emp > 0 else None
+ # 근속 극단값 cap: 60년 초과는 데이터 파싱 오류
+ if avg_tenure is not None and avg_tenure > 60:
+ avg_tenure = None
+ # 평균급여 극단값 cap: 50억원(50만 만원) 초과는 데이터 오류
+ if avg_sal > 500_000:
+ avg_sal = None
+
+ if avg_sal is None:
+ continue
+
+ result[code_val] = {
+ "직원수": total_emp,
+ "평균급여_만원": round(avg_sal, 0),
+ "남녀격차": gender_gap,
+ "근속_년": round(avg_tenure, 1) if avg_tenure else None,
+ }
+
+ return result
+
+
+def scan_total_payroll() -> dict[str, float]:
+ """employee → {종목코드: 연간 총급여(원)}.
+
+ fyer_salary_totamt 합산 우선, 없으면 sm*jan_salary_am fallback.
+ Q4 우선 (연간 누적). Q4 없으면 Q2*2 연환산.
+ """
+ PAYROLL_QUARTER_ORDER = {"4분기": 1, "2분기": 2, "3분기": 3, "1분기": 4}
+
+ raw = scan_parquets(
+ "employee",
+ ["stockCode", "year", "quarter", "sm", "jan_salary_am", "fyer_salary_totamt"],
+ )
+ if raw.is_empty():
+ return {}
+
+ latestYear = find_latest_year(raw, "jan_salary_am", 500)
+ if latestYear is None:
+ return {}
+
+ sub = raw.filter(pl.col("year") == latestYear)
+ result: dict[str, float] = {}
+
+ for code, group in sub.group_by("stockCode"):
+ codeVal = code[0]
+ quarters = group["quarter"].unique().to_list()
+ bestQ = sorted(quarters, key=lambda q: PAYROLL_QUARTER_ORDER.get(q, 99))
+ qdf = group.filter(pl.col("quarter") == bestQ[0]) if bestQ else group
+ isHalf = bestQ[0] == "2분기" if bestQ else False
+
+ total = 0.0
+ usedDirect = False
+ for row in qdf.iter_rows(named=True):
+ direct = parse_num(row.get("fyer_salary_totamt"))
+ if direct and direct > 0:
+ total += direct
+ usedDirect = True
+ else:
+ emp = parse_num(row.get("sm"))
+ sal = parse_num(row.get("jan_salary_am"))
+ if emp and emp > 0 and sal and sal > 0:
+ total += emp * sal
+
+ if total > 0:
+ if isHalf and not usedDirect:
+ total *= 2
+ result[codeVal] = total
+
+ return result
+
+
+def scan_revenue_per_employee() -> dict[str, float]:
+ """employee + finance IS → {종목코드: 직원당 매출(억)}.
+
+ scan/finance.parquet 프리빌드가 있으면 단일 파일에서 매출 추출.
+ """
+ emp_map = scan_employee()
+
+ REVENUE_IDS = {
+ "Revenue",
+ "Revenues",
+ "revenue",
+ "revenues",
+ "ifrs-full_Revenue",
+ "dart_Revenue",
+ "RevenueFromContractsWithCustomers",
+ }
+ REVENUE_NMS = {"매출액", "수익(매출액)", "영업수익", "매출", "순영업수익"}
+
+ from dartlab.scan._helpers import _ensureScanData
+
+ scanDir = _ensureScanData()
+ scanPath = scanDir / "finance.parquet"
+
+ if scanPath.exists():
+ try:
+ rev_map = _revenueFromMerged(scanPath, REVENUE_IDS, REVENUE_NMS)
+ except (pl.exceptions.PolarsError, OSError):
+ rev_map = _revenueFallback(REVENUE_IDS, REVENUE_NMS)
+ else:
+ rev_map = _revenueFallback(REVENUE_IDS, REVENUE_NMS)
+
+ result: dict[str, float] = {}
+ for code in emp_map:
+ if code in rev_map:
+ emp_count = emp_map[code]["직원수"]
+ if emp_count > 0:
+ result[code] = round(rev_map[code] / emp_count / 1e8, 1)
+ return result
+
+
+def _revenueFromMerged(scanPath: Path, revIds: set[str], revNms: set[str]) -> dict[str, float]:
+ """합산 finance parquet에서 매출 추출."""
+ scCol = "stockCode" if "stockCode" in pl.scan_parquet(str(scanPath)).collect_schema().names() else "stock_code"
+
+ target = (
+ pl.scan_parquet(str(scanPath))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ if target.is_empty() or "account_id" not in target.columns:
+ return {}
+
+ cfs = target.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else target
+
+ # 종목별 최신 연도
+ latestYear = target.group_by(scCol).agg(pl.col("bsns_year").max().alias("_maxYear"))
+ target = target.join(latestYear, on=scCol).filter(pl.col("bsns_year") == pl.col("_maxYear")).drop("_maxYear")
+
+ matched = target.filter(pl.col("account_id").is_in(list(revIds)) | pl.col("account_nm").is_in(list(revNms)))
+
+ rev_map: dict[str, float] = {}
+ for row in matched.iter_rows(named=True):
+ code = row.get(scCol, "")
+ val = parse_num(row.get("thstrm_amount"))
+ if code and val and val > 0 and code not in rev_map:
+ rev_map[code] = val
+
+ # 1차 매칭 실패 종목은 "매출" 포함 fallback
+ matchedCodes = set(rev_map.keys())
+ allCodes = set(target[scCol].unique().to_list())
+ missingCodes = allCodes - matchedCodes
+ if missingCodes:
+ fallbackRows = target.filter(
+ pl.col(scCol).is_in(list(missingCodes)) & pl.col("account_nm").str.contains("매출")
+ )
+ for row in fallbackRows.iter_rows(named=True):
+ code = row.get(scCol, "")
+ val = parse_num(row.get("thstrm_amount"))
+ if code and val and val > 0 and code not in rev_map:
+ rev_map[code] = val
+
+ return rev_map
+
+
+def _revenueFallback(revIds: set[str], revNms: set[str]) -> dict[str, float]:
+ """종목별 finance parquet 순회 (fallback)."""
+ from dartlab.core.dataLoader import _dataDir
+
+ finance_dir = Path(_dataDir("finance"))
+ parquet_files = sorted(finance_dir.glob("*.parquet"))
+
+ rev_map: dict[str, float] = {}
+ for pf in parquet_files:
+ code = pf.stem
+ try:
+ is_df = (
+ pl.scan_parquet(str(pf))
+ .filter(
+ pl.col("sj_div").is_in(["IS", "CIS"])
+ & (pl.col("fs_nm").str.contains("연결") | pl.col("fs_nm").str.contains("재무제표"))
+ )
+ .collect()
+ )
+ except (pl.exceptions.PolarsError, OSError):
+ continue
+ if is_df.is_empty() or "account_id" not in is_df.columns:
+ continue
+ cfs = is_df.filter(pl.col("fs_nm").str.contains("연결"))
+ target = cfs if not cfs.is_empty() else is_df
+
+ rev_rows = target.filter(pl.col("account_id").is_in(list(revIds)) | pl.col("account_nm").is_in(list(revNms)))
+ if rev_rows.is_empty():
+ rev_rows = target.filter(pl.col("account_nm").str.contains("매출"))
+ if rev_rows.is_empty():
+ continue
+
+ years = sorted(rev_rows["bsns_year"].unique().to_list(), reverse=True)
+ if not years:
+ continue
+ latest = rev_rows.filter(pl.col("bsns_year") == years[0])
+ for row in latest.iter_rows(named=True):
+ val = parse_num(row.get("thstrm_amount"))
+ if val and val > 0:
+ rev_map[code] = val
+ break
+ return rev_map
+
+
+def scan_top_pay() -> dict[str, dict]:
+ """executivePayIndividual → {종목코드: {공개인원, 최고보수_억}}.
+
+ 5억 이상 의무공개자. 최신 연도 기준.
+ """
+ raw = scan_parquets(
+ "executivePayIndividual",
+ ["stockCode", "year", "quarter", "nm", "ofcps", "mendng_totamt"],
+ )
+ if raw.is_empty():
+ return {}
+
+ years_desc = sorted(raw["year"].unique().to_list(), reverse=True)
+ latest_year = None
+ for y in years_desc:
+ sub = raw.filter(pl.col("year") == y)
+ valid = sub.filter(
+ pl.col("mendng_totamt").is_not_null() & (pl.col("mendng_totamt") != "-") & (pl.col("mendng_totamt") != "")
+ )
+ if valid["stockCode"].n_unique() >= 200:
+ latest_year = y
+ break
+ if latest_year is None:
+ return {}
+
+ latest = raw.filter(pl.col("year") == latest_year)
+ result: dict[str, dict] = {}
+ for code, group in latest.group_by("stockCode"):
+ code_val = code[0]
+ max_pay = 0.0
+ count = 0
+ for row in group.iter_rows(named=True):
+ amt = parse_num(row.get("mendng_totamt"))
+ if amt and amt > 0:
+ count += 1
+ pay_억 = amt / 1e8
+ if pay_억 > max_pay:
+ max_pay = pay_억
+ if count > 0:
+ result[code_val] = {
+ "공개인원": count,
+ "최고보수_억": round(max_pay, 1),
+ }
+ return result
diff --git a/src/dartlab/server/__init__.py b/src/dartlab/server/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..41f8c3197bdcd7c10ad28e9c57bd36aec1dd08dc
--- /dev/null
+++ b/src/dartlab/server/__init__.py
@@ -0,0 +1,151 @@
+"""DartLab Web Server — FastAPI + SSE 스트리밍.
+
+dartlab ai 명령으로 실행:
+ dartlab ai # http://localhost:8400
+ dartlab ai --port 9000 # 커스텀 포트
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+from contextlib import asynccontextmanager, suppress
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.middleware.gzip import GZipMiddleware
+from starlette.middleware.base import BaseHTTPMiddleware
+
+import dartlab
+
+from .api import ai_router, analysis_router, ask_router, company_router, dart_router, data_router, macro_router, room_router
+from .embed import router as embed_router
+from .runtime import ensure_port, run_server # noqa: F401 — re-exported
+from .services.ai_profile import should_preload_ollama as _should_preload_ollama
+from .web import register_spa
+
+logger = logging.getLogger(__name__)
+
+
+async def _preload_ollama_once() -> None:
+ """서버 시작 직후 Ollama 모델을 미리 깨워 cold start를 줄인다."""
+
+ await asyncio.sleep(2)
+
+ try:
+ from dartlab.ai import get_config
+ from dartlab.ai.providers import create_provider
+
+ config = get_config("ollama")
+ provider = create_provider(config)
+ except (ImportError, OSError, RuntimeError, ValueError) as exc:
+ logger.debug("Ollama preload 준비 실패", exc_info=exc)
+ return
+
+ if not hasattr(provider, "preload"):
+ return
+
+ try:
+ if provider.check_available():
+ ok = await asyncio.to_thread(provider.preload)
+ if ok:
+ logger.info("Ollama 모델 preload 완료: %s", provider.resolved_model)
+ except (ConnectionError, OSError, RuntimeError, TimeoutError, ValueError) as exc:
+ logger.debug("Ollama preload 실행 실패", exc_info=exc)
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI):
+ """앱 수명주기 관리 -- Ollama preload, 룸 생성/정리, 채널 종료."""
+ preload_task = asyncio.create_task(_preload_ollama_once()) if _should_preload_ollama() else None
+
+ # 채널 모드: 협업 룸 자동 생성 + 백그라운드 정리
+ from .room import room_manager
+
+ if os.environ.get("DARTLAB_CHANNEL") == "1":
+ room_manager.create_room()
+ room_manager.start_background_cleanup()
+
+ try:
+ yield
+ finally:
+ from .services.channel_runtime import channel_runtime
+
+ channel_runtime.shutdown_all()
+ room_manager.stop_background_cleanup()
+ room_manager.destroy_room()
+
+ if preload_task is not None and not preload_task.done():
+ preload_task.cancel()
+ with suppress(asyncio.CancelledError):
+ if preload_task is not None:
+ await preload_task
+
+
+app = FastAPI(title="DartLab", version=dartlab.__version__, lifespan=lifespan)
+
+
+def _cors_origins() -> list[str]:
+ raw = os.environ.get("DARTLAB_CORS_ORIGINS")
+ if raw:
+ raw = raw.strip()
+ if raw == "*":
+ return ["*"]
+ return [item.strip() for item in raw.split(",") if item.strip()]
+ # devtunnel 모드 등 외부 접근 시 CORS가 막혀서 fetch hang — 터널 모드면 전체 허용
+ if os.environ.get("DARTLAB_CHANNEL") == "1" or os.environ.get("DARTLAB_TUNNEL") == "1":
+ return ["*"]
+ return [
+ "http://127.0.0.1:8400",
+ "http://localhost:8400",
+ "http://127.0.0.1:5400",
+ "http://localhost:5400",
+ ]
+
+
+class _SecurityHeadersMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request, call_next):
+ """보안 헤더(X-Content-Type-Options 등)를 모든 응답에 추가한다."""
+ response = await call_next(request)
+ response.headers.setdefault("X-Content-Type-Options", "nosniff")
+ response.headers.setdefault("X-Frame-Options", "DENY")
+ response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
+ return response
+
+
+app.add_middleware(_SecurityHeadersMiddleware)
+app.add_middleware(GZipMiddleware, minimum_size=500)
+
+_origins = _cors_origins()
+if _origins == ["*"]:
+ logger.warning("CORS allow_origins='*' — 프로덕션에서는 명시적 origin을 설정하세요")
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=_origins,
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ allow_headers=["Content-Type", "Authorization", "X-Room-Member", "X-Tunnel-Skip-AntiPhishing-Page-Redirect", "*"],
+)
+
+app.include_router(ai_router)
+app.include_router(analysis_router)
+app.include_router(ask_router)
+app.include_router(company_router)
+app.include_router(data_router)
+app.include_router(macro_router)
+app.include_router(room_router)
+app.include_router(dart_router)
+app.include_router(embed_router)
+
+# ── MCP SSE 마운트 (HF Spaces 또는 명시적 활성화 시) ──
+if os.environ.get("SPACE_ID") or os.environ.get("DARTLAB_MCP_HTTP") == "1":
+ try:
+ from dartlab.mcp import create_sse_app
+
+ _mcp_sse = create_sse_app()
+ app.mount("/mcp", _mcp_sse)
+ logger.info("MCP SSE 엔드포인트 활성화: /mcp/sse")
+ except ImportError:
+ logger.info("MCP SDK 미설치 — MCP SSE 비활성")
+
+register_spa(app)
diff --git a/src/dartlab/server/__main__.py b/src/dartlab/server/__main__.py
new file mode 100644
index 0000000000000000000000000000000000000000..3232cd12e0cbd02dbf2425278acf145cc6fae308
--- /dev/null
+++ b/src/dartlab/server/__main__.py
@@ -0,0 +1,31 @@
+"""``python -m dartlab.server`` 진입점."""
+
+from __future__ import annotations
+
+import argparse
+import os
+
+from .runtime import default_host, ensure_port, run_server
+
+
+def main() -> None:
+ """CLI 인자를 파싱하고 웹 서버를 시작한다."""
+ parser = argparse.ArgumentParser(description="DartLab web server")
+ parser.add_argument("--host", default=None, help="bind host (default: 127.0.0.1, HF Spaces: 0.0.0.0)")
+ parser.add_argument("--port", type=int, default=None, help="bind port (default: 8400, HF Spaces: 7860)")
+ args = parser.parse_args()
+
+ # HuggingFace Spaces 자동 감지
+ isHfSpace = os.environ.get("SPACE_ID") is not None
+ host = args.host or ("0.0.0.0" if isHfSpace else default_host())
+ port = args.port or (7860 if isHfSpace else 8400)
+
+ status = ensure_port(port)
+ if status == "failed":
+ raise SystemExit(1)
+
+ run_server(host=host, port=port)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/dartlab/server/__pycache__/__init__.cpython-312.pyc b/src/dartlab/server/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..03e3dac882292eaf9a2f8400d9ba55e51f365490
Binary files /dev/null and b/src/dartlab/server/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/__init__.cpython-313.pyc b/src/dartlab/server/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6387c7c58934e711d0b66155eabf077fc323e936
Binary files /dev/null and b/src/dartlab/server/__pycache__/__init__.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/__main__.cpython-312.pyc b/src/dartlab/server/__pycache__/__main__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2a3310a02d3623f805368fab6d464a5ed31376fb
Binary files /dev/null and b/src/dartlab/server/__pycache__/__main__.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/_ui_path.cpython-312.pyc b/src/dartlab/server/__pycache__/_ui_path.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..7d89b3fb3d91ba51f61649fafd648ebc0928a857
Binary files /dev/null and b/src/dartlab/server/__pycache__/_ui_path.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/_ui_path.cpython-313.pyc b/src/dartlab/server/__pycache__/_ui_path.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..8580c9cfcbeede3916e8681b545e15d0a19253bc
Binary files /dev/null and b/src/dartlab/server/__pycache__/_ui_path.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/cache.cpython-312.pyc b/src/dartlab/server/__pycache__/cache.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..ed48f3f1933572d2e17b8b00c714a7572b875b03
Binary files /dev/null and b/src/dartlab/server/__pycache__/cache.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/cache.cpython-313.pyc b/src/dartlab/server/__pycache__/cache.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..1eb9051b5ef658ae3eb36ccc315a41afd9ca0936
Binary files /dev/null and b/src/dartlab/server/__pycache__/cache.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/chat.cpython-312.pyc b/src/dartlab/server/__pycache__/chat.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..a8d6e085020f92f8cb52579703da4065a9ec94fc
Binary files /dev/null and b/src/dartlab/server/__pycache__/chat.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/chat.cpython-313.pyc b/src/dartlab/server/__pycache__/chat.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c27ee9867e08680fc2d4a97cd8c49d1bfc3926a7
Binary files /dev/null and b/src/dartlab/server/__pycache__/chat.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/embed.cpython-312.pyc b/src/dartlab/server/__pycache__/embed.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..3fef3453ada6994a7d32d79020692d68d3847aad
Binary files /dev/null and b/src/dartlab/server/__pycache__/embed.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/embed.cpython-313.pyc b/src/dartlab/server/__pycache__/embed.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..b7d9614687a0d8477704a7411b55f850ff249652
Binary files /dev/null and b/src/dartlab/server/__pycache__/embed.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/models.cpython-312.pyc b/src/dartlab/server/__pycache__/models.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..95c03ab9f054e9aa20a7e5135d26ac75f4877f43
Binary files /dev/null and b/src/dartlab/server/__pycache__/models.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/models.cpython-313.pyc b/src/dartlab/server/__pycache__/models.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..ac12232b233a96249c36daa42a7e0e4890909187
Binary files /dev/null and b/src/dartlab/server/__pycache__/models.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/resolve.cpython-312.pyc b/src/dartlab/server/__pycache__/resolve.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..4f7f0cfc3263fe0279a1b6f8bb0ba4777cc7b5f5
Binary files /dev/null and b/src/dartlab/server/__pycache__/resolve.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/resolve.cpython-313.pyc b/src/dartlab/server/__pycache__/resolve.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2e82bd6bd203a7282c34e1f4ce926c0f53fdaa5a
Binary files /dev/null and b/src/dartlab/server/__pycache__/resolve.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/room.cpython-312.pyc b/src/dartlab/server/__pycache__/room.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..caf0d5d4ee4359f299c09ca684622dd46931bb8a
Binary files /dev/null and b/src/dartlab/server/__pycache__/room.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/room.cpython-313.pyc b/src/dartlab/server/__pycache__/room.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..4f1ff274ea0230e749770be765a49692b4ee8d93
Binary files /dev/null and b/src/dartlab/server/__pycache__/room.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/runtime.cpython-312.pyc b/src/dartlab/server/__pycache__/runtime.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..3a71d40a6c61d529984cdd72a6ea4d468515cd1a
Binary files /dev/null and b/src/dartlab/server/__pycache__/runtime.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/runtime.cpython-313.pyc b/src/dartlab/server/__pycache__/runtime.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..3da99c2ffa6b00c32421249b76ad8c5484ade8fb
Binary files /dev/null and b/src/dartlab/server/__pycache__/runtime.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/streaming.cpython-312.pyc b/src/dartlab/server/__pycache__/streaming.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c83d8d251b934237a68bca43ce88c276cb6a7dbf
Binary files /dev/null and b/src/dartlab/server/__pycache__/streaming.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/streaming.cpython-313.pyc b/src/dartlab/server/__pycache__/streaming.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..72f888fdd55747aabfb4b608b43e4cfa1e8ff741
Binary files /dev/null and b/src/dartlab/server/__pycache__/streaming.cpython-313.pyc differ
diff --git a/src/dartlab/server/__pycache__/web.cpython-312.pyc b/src/dartlab/server/__pycache__/web.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..49f44d980c711e7f0d6181f992ef90ec80a8ced0
Binary files /dev/null and b/src/dartlab/server/__pycache__/web.cpython-312.pyc differ
diff --git a/src/dartlab/server/__pycache__/web.cpython-313.pyc b/src/dartlab/server/__pycache__/web.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2c91e444aba3c6a729a87b1dec45713a72d4fe04
Binary files /dev/null and b/src/dartlab/server/__pycache__/web.cpython-313.pyc differ
diff --git a/src/dartlab/server/_ui_path.py b/src/dartlab/server/_ui_path.py
new file mode 100644
index 0000000000000000000000000000000000000000..6107520ae2483b7139a7aeb47d4ce01369bf69b6
--- /dev/null
+++ b/src/dartlab/server/_ui_path.py
@@ -0,0 +1,41 @@
+"""UI 빌드 경로 해석 — web.py와 cli/commands/ai.py가 공유한다.
+
+우선순위:
+1. DARTLAB_UI_DIR 환경변수 (dartlab-desktop이 설정)
+2. 패키지 내부: site-packages/dartlab/ui/build/ (pip install)
+3. 개발 환경: project_root/ui/web/build/ (editable install)
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+# dartlab 패키지 루트: site-packages/dartlab/ 또는 src/dartlab/
+_PKG_ROOT = Path(__file__).resolve().parent.parent
+
+
+def resolve_ui_build_dir() -> Path:
+ """UI 빌드 결과물(index.html, assets/) 디렉토리를 반환한다."""
+ # 1. 환경변수 — dartlab-desktop 등 외부 소비자가 명시
+ if env := os.environ.get("DARTLAB_UI_DIR"):
+ return Path(env)
+
+ # 2. 패키지 내부 (pip install 환경)
+ # site-packages/dartlab/ui/build/
+ pip_build = _PKG_ROOT / "ui" / "build"
+ if pip_build.is_dir():
+ return pip_build
+
+ # 3. 개발 환경 (editable install)
+ # project_root/ui/web/build/
+ dev_build = _PKG_ROOT.parent.parent / "ui" / "web" / "build"
+ return dev_build
+
+
+def resolve_ui_source_dir() -> Path:
+ """UI 소스 디렉토리를 반환한다 (dev 모드 npm 명령용)."""
+ if env := os.environ.get("DARTLAB_UI_DIR"):
+ return Path(env)
+
+ return _PKG_ROOT.parent.parent / "ui" / "web"
diff --git a/src/dartlab/server/api/__init__.py b/src/dartlab/server/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7b7b28200c992c75f18bb449933700ed2563ed41
--- /dev/null
+++ b/src/dartlab/server/api/__init__.py
@@ -0,0 +1,19 @@
+from .ai import router as ai_router
+from .analysis import router as analysis_router
+from .ask import router as ask_router
+from .company import router as company_router
+from .dart import router as dart_router
+from .data import router as data_router
+from .macro import router as macro_router
+from .room import router as room_router
+
+__all__ = [
+ "ai_router",
+ "analysis_router",
+ "ask_router",
+ "company_router",
+ "dart_router",
+ "data_router",
+ "macro_router",
+ "room_router",
+]
diff --git a/src/dartlab/server/api/__pycache__/__init__.cpython-312.pyc b/src/dartlab/server/api/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..bc3549cdba039b74d572c532eb5601838f76aa20
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/__init__.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/__init__.cpython-313.pyc b/src/dartlab/server/api/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..f52331c5cc604cbf8d42982aa088c01508936ff9
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/__init__.cpython-313.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/ai.cpython-312.pyc b/src/dartlab/server/api/__pycache__/ai.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..03be04b7eab93e44c65df4354ffe8ccda409243c
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/ai.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/ai.cpython-313.pyc b/src/dartlab/server/api/__pycache__/ai.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..7c97a8e31894e649fed6d46331769f727318b3e4
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/ai.cpython-313.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/analysis.cpython-312.pyc b/src/dartlab/server/api/__pycache__/analysis.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..de5caa725d7d477077f0aa0a3ccc1bf19ad317a7
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/analysis.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/analysis.cpython-313.pyc b/src/dartlab/server/api/__pycache__/analysis.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..1cfc1a165d3b3cef8dcb747cc9c5bb265315ae3a
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/analysis.cpython-313.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/ask.cpython-312.pyc b/src/dartlab/server/api/__pycache__/ask.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c74785422ea5caba5d580f6a5194c8aecbdbdacc
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/ask.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/ask.cpython-313.pyc b/src/dartlab/server/api/__pycache__/ask.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..06833092e7d1249d7417e4cb9d0afbb4790b10b5
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/ask.cpython-313.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/common.cpython-312.pyc b/src/dartlab/server/api/__pycache__/common.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..cfbafa9936436b150eee6ded41d42f5a493fdee1
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/common.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/common.cpython-313.pyc b/src/dartlab/server/api/__pycache__/common.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..3c10bd3ffccf559da3b9a6ea3cc2048e6c8aae70
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/common.cpython-313.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/company.cpython-312.pyc b/src/dartlab/server/api/__pycache__/company.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..f6037e68dc979058cea6295b7075c16d67131302
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/company.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/company.cpython-313.pyc b/src/dartlab/server/api/__pycache__/company.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..cf4ea9044ad9a51fc9a525025ac620f684044587
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/company.cpython-313.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/dart.cpython-312.pyc b/src/dartlab/server/api/__pycache__/dart.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c0a9e3dafb665eb24f8bd623adf63e975c84aa2f
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/dart.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/data.cpython-312.pyc b/src/dartlab/server/api/__pycache__/data.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..201578eb6f5feca1acf41e221f20cb5229df5d8f
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/data.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/data.cpython-313.pyc b/src/dartlab/server/api/__pycache__/data.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..68a62c37b3e7a34f645f24f7abb4333e35b7fb06
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/data.cpython-313.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/macro.cpython-312.pyc b/src/dartlab/server/api/__pycache__/macro.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..9685c457e2005567b083a4124599bd9de2e489a7
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/macro.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/macro.cpython-313.pyc b/src/dartlab/server/api/__pycache__/macro.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2326a7dd8f48d8adb13373c66cf122b91269e888
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/macro.cpython-313.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/room.cpython-312.pyc b/src/dartlab/server/api/__pycache__/room.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..e500de4f1259c75a0c4116e8de3002d4d9cd4949
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/room.cpython-312.pyc differ
diff --git a/src/dartlab/server/api/__pycache__/room.cpython-313.pyc b/src/dartlab/server/api/__pycache__/room.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..ad7101e7b66e757395891f966a029536c7f69874
Binary files /dev/null and b/src/dartlab/server/api/__pycache__/room.cpython-313.pyc differ
diff --git a/src/dartlab/server/api/ai.py b/src/dartlab/server/api/ai.py
new file mode 100644
index 0000000000000000000000000000000000000000..0ba1772eb0fc599d5cc36f419c317a90c25c2768
--- /dev/null
+++ b/src/dartlab/server/api/ai.py
@@ -0,0 +1,644 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from sse_starlette.sse import EventSourceResponse
+
+import dartlab
+from dartlab.core.ai import (
+ build_provider_catalog,
+ get_profile_manager,
+ get_provider_spec,
+ public_provider_ids,
+)
+
+from ..chat import OLLAMA_MODEL_GUIDE
+from ..models import (
+ AiProfileUpdateRequest,
+ AiSecretUpdateRequest,
+ ChannelConnectRequest,
+ ConfigureRequest,
+ DartKeyUpdateRequest,
+)
+from ..services.ai_profile import (
+ build_oauth_codex_detail,
+ build_ollama_detail,
+ probe_provider_availability,
+ validate_provider_connection,
+)
+from .common import (
+ HANDLED_API_ERRORS as _HANDLED_API_ERRORS,
+)
+from .common import (
+ guideDetail as _guideDetail,
+)
+from .common import (
+ normalize_provider_name as _normalize_provider_name,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+UI_PROVIDERS = public_provider_ids()
+STATIC_MODELS: dict[str, list[str]] = {}
+
+_oauth_state: dict[str, Any] = {}
+
+
+def _build_open_dart_status() -> dict[str, Any]:
+ from dartlab.providers.dart.openapi.dartKey import getDartKeyStatus
+
+ return getDartKeyStatus().toDict()
+
+
+def _resolve_credential_source(meta: dict[str, Any], profile_provider: dict[str, Any]) -> str:
+ auth_kind = meta.get("authKind", "none")
+ if auth_kind == "api_key":
+ if profile_provider.get("secretConfigured"):
+ return "secret_store"
+ env_key = meta.get("envKey")
+ if env_key and os.environ.get(env_key):
+ return "env"
+ return "none"
+ if auth_kind == "oauth":
+ return "oauth" if profile_provider.get("secretConfigured") else "none"
+ if auth_kind == "cli":
+ return "cli"
+ return "none"
+
+
+@router.get("/api/status")
+def api_status(
+ provider: str | None = Query(None, description="상태를 적극 확인할 provider"),
+ probe: bool = Query(True, description="True면 provider availability를 실제 점검"),
+):
+ """LLM provider 상태 확인 (설치/인증/모델 포함)."""
+ profile_snapshot = get_profile_manager().serialize()
+ catalog = {item["id"]: item for item in build_provider_catalog()}
+ results = {}
+ target_provider = _normalize_provider_name(provider) or provider
+ if probe and target_provider is None:
+ target_provider = _normalize_provider_name(profile_snapshot.get("defaultProvider")) or profile_snapshot.get(
+ "defaultProvider"
+ )
+ role_bindings = profile_snapshot.get("roles", {})
+
+ for prov in UI_PROVIDERS:
+ meta = catalog.get(prov, {})
+ profile_provider = profile_snapshot.get("providers", {}).get(prov, {})
+ info: dict[str, Any] = {
+ "available": None,
+ "model": profile_provider.get("model"),
+ "checked": False,
+ "label": meta.get("label", prov),
+ "desc": meta.get("description", ""),
+ "auth": meta.get("authKind", "none"),
+ "secretConfigured": bool(profile_provider.get("secretConfigured")),
+ "credentialSource": _resolve_credential_source(meta, profile_provider),
+ "selected": profile_snapshot.get("defaultProvider") == prov,
+ "selectedRoles": [
+ role_name
+ for role_name, binding in role_bindings.items()
+ if isinstance(binding, dict) and binding.get("provider") == prov
+ ],
+ }
+ if meta.get("envKey"):
+ info["envKey"] = meta["envKey"]
+ if meta.get("signupUrl"):
+ info["signupUrl"] = meta["signupUrl"]
+ if meta.get("freeTierHint"):
+ info["freeTierHint"] = meta["freeTierHint"]
+ should_probe = probe and (target_provider is None or prov == target_provider)
+ if should_probe:
+ available, model, checked = probe_provider_availability(prov)
+ info["available"] = available
+ info["model"] = model
+ info["checked"] = checked
+ results[prov] = info
+
+ ollama_detail = build_ollama_detail(probe=probe and (target_provider is None or target_provider == "ollama"))
+ oauth_codex_detail = build_oauth_codex_detail(
+ probe=probe and (target_provider is None or target_provider == "oauth-codex")
+ )
+ codex_detail = {"installed": False, "authenticated": False, "authMode": None, "loginStatus": None, "version": None}
+ try:
+ from dartlab.ai.providers.support.cli_setup import detect_codex
+
+ codex_detail = detect_codex()
+ except (
+ AttributeError,
+ FileNotFoundError,
+ ImportError,
+ OSError,
+ PermissionError,
+ RuntimeError,
+ TypeError,
+ ValueError,
+ ):
+ codex_detail = {
+ "installed": False,
+ "authenticated": False,
+ "authMode": None,
+ "loginStatus": None,
+ "version": None,
+ }
+
+ version = dartlab.__version__ if hasattr(dartlab, "__version__") else "unknown"
+
+ # Room 정보 (터널 모드에서 협업 세션 활성 시)
+ room_info = None
+ try:
+ from ..room import room_manager
+
+ active_room = room_manager.get_room()
+ if active_room is not None:
+ room_info = {
+ "roomId": active_room.room_id,
+ "members": len(active_room.members),
+ }
+ except ImportError:
+ pass
+
+ resp: dict[str, Any] = {
+ "providers": results,
+ "ollama": ollama_detail,
+ "codex": codex_detail,
+ "oauthCodex": oauth_codex_detail,
+ "openDart": _build_open_dart_status(),
+ "profile": profile_snapshot,
+ "version": version,
+ }
+ if room_info is not None:
+ resp["room"] = room_info
+ try:
+ from ..services.channel_runtime import channel_runtime
+
+ resp["channels"] = channel_runtime.status()
+ except ImportError:
+ resp["channels"] = {}
+ return resp
+
+
+@router.get("/api/suggest")
+def api_suggest(stockCode: str = Query(..., description="추천 질문을 생성할 종목코드")):
+ """회사 데이터 상태에 맞는 추천 질문 목록을 반환한다."""
+ try:
+ from ..services.company_api import get_company
+
+ company = get_company(stockCode)
+ return {
+ "stockCode": getattr(company, "stockCode", stockCode),
+ "company": getattr(company, "corpName", stockCode),
+ "suggestions": [],
+ "dataReady": {},
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=_guideDetail(e)) from e
+ except _HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=500, detail=_guideDetail(e)) from e
+
+
+@router.post("/api/provider/validate")
+def api_validate_provider(req: ConfigureRequest):
+ """LLM provider 검증. 전역 상태는 변경하지 않는다."""
+ return validate_provider_connection(req)
+
+
+@router.post("/api/configure")
+def api_configure(req: ConfigureRequest):
+ """구버전 alias. 현재는 provider 검증만 수행한다."""
+ return validate_provider_connection(req)
+
+
+@router.get("/api/ai/profile")
+def api_ai_profile():
+ """공통 AI profile + provider catalog 반환."""
+ return get_profile_manager().serialize()
+
+
+@router.put("/api/ai/profile")
+def api_ai_profile_update(req: AiProfileUpdateRequest):
+ """공통 AI profile 갱신."""
+ manager = get_profile_manager()
+ provider = _normalize_provider_name(req.provider) or req.provider
+ if provider and get_provider_spec(provider) is None:
+ raise HTTPException(status_code=400, detail=f"지원하지 않는 provider: {provider}")
+ profile = manager.update(
+ provider=provider,
+ role=req.role,
+ model=req.model,
+ base_url=req.base_url,
+ temperature=req.temperature,
+ max_tokens=req.max_tokens,
+ system_prompt=req.system_prompt,
+ updated_by="ui",
+ )
+ return manager.serialize() | {"revision": profile.revision}
+
+
+@router.post("/api/ai/profile/secrets")
+def api_ai_profile_secret(req: AiSecretUpdateRequest):
+ """provider secret 저장/삭제."""
+ provider = _normalize_provider_name(req.provider) or req.provider
+ spec = get_provider_spec(provider)
+ if spec is None:
+ raise HTTPException(status_code=400, detail=f"지원하지 않는 provider: {provider}")
+ if spec.auth_kind != "api_key":
+ raise HTTPException(status_code=400, detail=f"{provider} provider는 API key secret을 사용하지 않습니다")
+
+ manager = get_profile_manager()
+ if req.clear or not req.api_key:
+ profile = manager.clear_api_key(provider, updated_by="ui")
+ else:
+ profile = manager.save_api_key(provider, req.api_key, updated_by="ui")
+ return manager.serialize() | {"revision": profile.revision}
+
+
+@router.post("/api/openapi/dart-key/validate")
+def api_validate_dart_key(req: DartKeyUpdateRequest):
+ """OpenDART API 키 유효성만 검증한다."""
+ from dartlab.providers.dart.openapi.dartKey import validateDartApiKey
+
+ api_key = (req.api_key or "").strip()
+ if not api_key:
+ raise HTTPException(status_code=400, detail="DART API 키를 입력하세요.")
+ try:
+ result = validateDartApiKey(api_key)
+ return result | {"openDart": _build_open_dart_status()}
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=_guideDetail(e)) from e
+ except _HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=500, detail=_guideDetail(e)) from e
+
+
+@router.put("/api/openapi/dart-key")
+def api_save_dart_key(req: DartKeyUpdateRequest):
+ """프로젝트 .env에 OpenDART API 키를 저장한다."""
+ from dartlab.providers.dart.openapi.dartKey import saveDartKeyToDotenv
+
+ api_key = (req.api_key or "").strip()
+ if not api_key:
+ raise HTTPException(status_code=400, detail="DART API 키를 입력하세요.")
+ try:
+ env_path = saveDartKeyToDotenv(api_key)
+ return {"ok": True, "envPath": str(env_path), "openDart": _build_open_dart_status()}
+ except OSError as e:
+ raise HTTPException(status_code=500, detail=_guideDetail(e)) from e
+
+
+@router.delete("/api/openapi/dart-key")
+def api_delete_dart_key():
+ """프로젝트 .env의 OpenDART API 키를 제거한다."""
+ from dartlab.providers.dart.openapi.dartKey import clearDartKeyFromDotenv
+
+ try:
+ env_path = clearDartKeyFromDotenv()
+ return {"ok": True, "envPath": str(env_path), "openDart": _build_open_dart_status()}
+ except OSError as e:
+ raise HTTPException(status_code=500, detail=_guideDetail(e)) from e
+
+
+@router.post("/api/channels/{platform}/start")
+def api_channel_start(platform: str, req: ChannelConnectRequest):
+ """외부 채널 어댑터 시작."""
+ try:
+ from ..services.channel_runtime import channel_runtime
+
+ payload = req.model_dump(exclude_none=True)
+ return channel_runtime.start(platform, **payload)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=_guideDetail(e)) from e
+ except _HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=500, detail=_guideDetail(e)) from e
+
+
+@router.post("/api/channels/{platform}/stop")
+def api_channel_stop(platform: str):
+ """외부 채널 어댑터 정지."""
+ try:
+ from ..services.channel_runtime import channel_runtime
+
+ return channel_runtime.stop(platform)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=_guideDetail(e)) from e
+ except _HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=500, detail=_guideDetail(e)) from e
+
+
+@router.get("/api/ai/profile/events")
+async def api_ai_profile_events(request: Request):
+ """profile 변경 SSE 스트림."""
+ manager = get_profile_manager()
+
+ async def _generate():
+ last_fingerprint = ""
+ while True:
+ if await request.is_disconnected():
+ break
+ payload = manager.serialize()
+ fingerprint = manager.fingerprint()
+ if fingerprint != last_fingerprint:
+ last_fingerprint = fingerprint
+ yield {
+ "event": "profile_changed",
+ "data": json.dumps(payload, ensure_ascii=False),
+ }
+ await asyncio.sleep(1.0)
+
+ return EventSourceResponse(_generate())
+
+
+@router.get("/api/models/{provider}")
+def api_models(provider: str):
+ """Provider별 사용 가능한 모델 목록 — SDK/API 자동 조회, 실패시 fallback."""
+ from dartlab.ai.providers import create_provider
+ from dartlab.ai.types import LLMConfig
+
+ provider = _normalize_provider_name(provider) or provider
+
+ if provider == "codex":
+ from dartlab.ai.providers.support.codex_cli import get_codex_model_catalog
+
+ return {"models": get_codex_model_catalog()}
+
+ if provider == "oauth-codex":
+ from dartlab.ai.providers.oauth_codex import AVAILABLE_MODELS
+
+ return {"models": AVAILABLE_MODELS}
+
+ if provider in STATIC_MODELS:
+ return {"models": STATIC_MODELS[provider]}
+
+ if provider == "ollama":
+ try:
+ config = LLMConfig(provider="ollama")
+ prov = create_provider(config)
+ installed = prov.get_installed_models()
+ return {"models": installed, "recommendations": OLLAMA_MODEL_GUIDE}
+ except _HANDLED_API_ERRORS:
+ return {"models": [], "recommendations": OLLAMA_MODEL_GUIDE}
+
+ if provider == "openai":
+ models = _fetch_openai_models()
+ if models:
+ return {"models": models}
+ return {
+ "models": [
+ "o3",
+ "gpt-4.1",
+ "gpt-4.1-mini",
+ "gpt-4.1-nano",
+ "o4-mini",
+ "o3-mini",
+ "gpt-4o",
+ "gpt-4o-mini",
+ ]
+ }
+
+ return {"models": []}
+
+
+def _get_api_key(provider: str) -> str | None:
+ """글로벌 config 또는 환경변수에서 API 키를 가져온다."""
+ from dartlab.ai import get_config
+
+ config = get_config(provider)
+ if config.api_key:
+ return config.api_key
+ env_map = {"openai": "OPENAI_API_KEY"}
+ return os.environ.get(env_map.get(provider, ""))
+
+
+def _fetch_openai_models() -> list[str]:
+ """OpenAI SDK로 모델 목록을 가져온다."""
+ api_key = _get_api_key("openai")
+ if not api_key:
+ return []
+ try:
+ from openai import OpenAI
+
+ client = OpenAI(api_key=api_key)
+ raw = client.models.list()
+ chat_prefixes = ("gpt-5", "gpt-4", "gpt-3.5", "o1", "o3", "o4")
+ exclude = (
+ "realtime",
+ "audio",
+ "search",
+ "instruct",
+ "embedding",
+ "tts",
+ "whisper",
+ "dall-e",
+ "davinci",
+ "babbage",
+ "transcribe",
+ )
+ models = []
+ for model in raw:
+ mid = model.id
+ if any(mid.startswith(prefix) for prefix in chat_prefixes):
+ if not any(excluded in mid for excluded in exclude):
+ models.append(mid)
+ priority = [
+ "gpt-5.4",
+ "gpt-5.4-pro",
+ "gpt-5.3-codex",
+ "gpt-5.2",
+ "gpt-5.2-pro",
+ "gpt-5.2-codex",
+ "gpt-5.1",
+ "gpt-5",
+ "gpt-5-mini",
+ "gpt-4.1",
+ "gpt-4.1-mini",
+ "gpt-4.1-nano",
+ "gpt-4o",
+ "gpt-4o-mini",
+ "o4-mini",
+ "o3",
+ "o3-mini",
+ "o1",
+ "o1-mini",
+ ]
+
+ def sort_key(name: str):
+ for idx, prefix in enumerate(priority):
+ if name == prefix or name.startswith(prefix + "-"):
+ return (idx, name)
+ return (100, name)
+
+ models.sort(key=sort_key)
+ return models
+ except (ImportError, OSError, RuntimeError, ValueError):
+ return []
+
+
+@router.post("/api/codex/logout")
+def api_codex_logout():
+ """Codex CLI에 저장된 계정 인증을 제거한다."""
+ from dartlab.ai.providers.support.codex_cli import logout_codex_cli
+
+ try:
+ logout_codex_cli()
+ except FileNotFoundError as exc:
+ raise HTTPException(status_code=404, detail=_guideDetail(exc)) from exc
+ except RuntimeError as exc:
+ raise HTTPException(status_code=400, detail=_guideDetail(exc)) from exc
+ return {"ok": True}
+
+
+@router.get("/api/oauth/authorize")
+def api_oauth_authorize():
+ """ChatGPT OAuth 인증 시작 — 브라우저 로그인 URL 반환 + 로컬 콜백 서버 시작."""
+ from dartlab.ai.providers.support.oauth_token import OAUTH_REDIRECT_PORT, build_auth_url
+
+ auth_url, verifier, state = build_auth_url()
+
+ _oauth_state["verifier"] = verifier
+ _oauth_state["state"] = state
+ _oauth_state["done"] = False
+ _oauth_state["error"] = None
+
+ _start_oauth_callback_server(OAUTH_REDIRECT_PORT)
+
+ return {"authUrl": auth_url, "state": state}
+
+
+@router.get("/api/oauth/status")
+def api_oauth_status():
+ """OAuth 인증 완료 여부 폴링."""
+ if _oauth_state.get("error"):
+ return {"done": True, "error": _oauth_state["error"]}
+ if _oauth_state.get("done"):
+ return {"done": True, "error": None}
+ return {"done": False}
+
+
+@router.post("/api/oauth/logout")
+def api_oauth_logout():
+ """OAuth 토큰 제거."""
+ from dartlab.ai.providers.support.oauth_token import revoke_token
+
+ revoke_token()
+ get_profile_manager().update(provider="oauth-codex", updated_by="ui")
+ return {"ok": True}
+
+
+def _start_oauth_callback_server(port: int):
+ """OAuth 콜백을 받을 임시 HTTP 서버를 백그라운드 스레드로 시작."""
+ import threading
+ from http.server import BaseHTTPRequestHandler, HTTPServer
+ from urllib.parse import parse_qs, urlparse
+
+ class CallbackHandler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ parsed = urlparse(self.path)
+ if parsed.path != "/auth/callback":
+ self.send_response(404)
+ self.end_headers()
+ return
+
+ params = parse_qs(parsed.query)
+ code = (params.get("code") or [None])[0]
+ state = (params.get("state") or [None])[0]
+ error = (params.get("error") or [None])[0]
+
+ if error:
+ _oauth_state["error"] = error
+ _oauth_state["done"] = True
+ self._respond_html("인증 실패", f"오류: {error}")
+ return
+
+ if state != _oauth_state.get("state"):
+ _oauth_state["error"] = "state_mismatch"
+ _oauth_state["done"] = True
+ self._respond_html("인증 실패", "보안 검증 실패 (state mismatch)")
+ return
+
+ if not code:
+ _oauth_state["error"] = "no_code"
+ _oauth_state["done"] = True
+ self._respond_html("인증 실패", "인증 코드를 받지 못했습니다")
+ return
+
+ try:
+ from dartlab.ai.providers.support.oauth_token import exchange_code
+
+ exchange_code(code, _oauth_state["verifier"])
+ get_profile_manager().update(provider="oauth-codex", updated_by="ui")
+ _oauth_state["done"] = True
+ self._respond_html("인증 성공", "DartLab 인증이 완료되었습니다. 이 창을 닫아주세요.")
+ except _HANDLED_API_ERRORS as exc:
+ _oauth_state["error"] = str(exc)
+ _oauth_state["done"] = True
+ self._respond_html("인증 실패", f"토큰 교환 실패: {exc}")
+
+ def _respond_html(self, title: str, message: str):
+ import html as _html
+
+ safe_title = _html.escape(title)
+ safe_message = _html.escape(message)
+ markup = (
+ ""
+ f"{safe_title}"
+ ""
+ f"{safe_title}
{safe_message}
"
+ ""
+ ""
+ )
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(markup.encode("utf-8"))
+
+ def log_message(self, fmt, *args):
+ pass
+
+ def _run_server():
+ server = HTTPServer(("127.0.0.1", port), CallbackHandler)
+ server.timeout = 120
+ server.handle_request()
+ server.server_close()
+
+ thread = threading.Thread(target=_run_server, daemon=True)
+ thread.start()
+
+
+@router.post("/api/ollama/pull")
+async def api_ollama_pull(req: dict):
+ """Ollama 모델 다운로드 (SSE 스트리밍 진행률)."""
+ model_name = req.get("model")
+ if not model_name:
+ raise HTTPException(400, "model name required")
+
+ async def _stream_pull():
+ import httpx
+
+ try:
+ with httpx.Client(timeout=600) as client:
+ with client.stream(
+ "POST",
+ "http://localhost:11434/api/pull",
+ json={"model": model_name, "stream": True},
+ ) as resp:
+ for line in resp.iter_lines():
+ if line:
+ yield {
+ "event": "progress",
+ "data": line,
+ }
+ yield {"event": "done", "data": "{}"}
+ except _HANDLED_API_ERRORS as exc:
+ yield {"event": "error", "data": json.dumps({"error": _guideDetail(exc)}, ensure_ascii=False)}
+
+ return EventSourceResponse(_stream_pull(), media_type="text/event-stream")
diff --git a/src/dartlab/server/api/analysis.py b/src/dartlab/server/api/analysis.py
new file mode 100644
index 0000000000000000000000000000000000000000..4adf0a2499d067d61ccff982459aa1b2d341a30a
--- /dev/null
+++ b/src/dartlab/server/api/analysis.py
@@ -0,0 +1,373 @@
+"""Company 분석 엔드포인트 — diff, bridge, graph, search, modules."""
+
+from __future__ import annotations
+
+import re as _re
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Query, Request, Response
+
+from ..services.company_api import (
+ build_diff_summary,
+ get_company,
+ safe_topic_label,
+)
+from .common import (
+ HANDLED_API_ERRORS,
+ etag_response,
+ guideDetail,
+ serialize_payload,
+)
+
+router = APIRouter()
+
+
+@router.get("/api/company/{code}/diff")
+def api_company_diff(code: str):
+ """Company sections 전체 diff 요약."""
+ try:
+ c = get_company(code)
+ return {
+ "stockCode": c.stockCode,
+ "corpName": c.corpName,
+ "payload": serialize_payload(c.diff()),
+ }
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+
+@router.get("/api/company/{code}/diff/matrix")
+def api_company_diff_matrix(
+ code: str,
+ textOnly: bool = Query(False),
+ topN: int = Query(20),
+):
+ """topic × period 변화 매트릭스 + 히트맵 스펙."""
+ try:
+ c = get_company(code)
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ from dartlab.core.docs.diff import build_diff_matrix, build_heatmap_spec
+
+ try:
+ sections = c._docs.sections.raw
+ matrix_data = build_diff_matrix(sections, textOnly=textOnly)
+ heatmap = build_heatmap_spec(matrix_data, c.corpName, top_n=topN)
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ return {
+ "stockCode": c.stockCode,
+ "corpName": c.corpName,
+ "matrix": matrix_data,
+ "heatmap": heatmap,
+ }
+
+
+@router.get("/api/company/{code}/diff/{topic}/summary")
+def api_company_diff_topic_summary(code: str, topic: str):
+ """뷰어용 diff 요약 — changeRate + 최신 변경의 added/removed 미리보기."""
+ try:
+ c = get_company(code)
+ result = build_diff_summary(c, topic)
+ if result is None:
+ raise HTTPException(status_code=404, detail="sections 없음")
+ return result
+ except HTTPException:
+ raise
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+
+@router.get("/api/company/{code}/diff/{topic}")
+def api_company_diff_topic(
+ code: str,
+ topic: str,
+ fromPeriod: str = Query(..., alias="from"),
+ toPeriod: str = Query(..., alias="to"),
+):
+ """Company 특정 topic의 두 기간 줄+글자 단위 diff."""
+ try:
+ c = get_company(code)
+ result = c.diff(topic, fromPeriod, toPeriod)
+
+ diff_chunks: list[dict] = []
+ if result is not None:
+ from dartlab.core.docs.diff import charDiff
+
+ rows = result.to_dicts()
+ i = 0
+ while i < len(rows):
+ row = rows[i]
+ status = row.get("status", " ")
+ text = row.get("text", "")
+
+ if status == " ":
+ diff_chunks.append({"kind": "same", "text": text})
+ i += 1
+ elif status == "-":
+ del_lines = [text]
+ j = i + 1
+ while j < len(rows) and rows[j].get("status") == "-":
+ del_lines.append(rows[j].get("text", ""))
+ j += 1
+ add_lines = []
+ while j < len(rows) and rows[j].get("status") == "+":
+ add_lines.append(rows[j].get("text", ""))
+ j += 1
+
+ if add_lines:
+ from_text = "\n".join(del_lines)
+ to_text = "\n".join(add_lines)
+ parts = [{"kind": p.kind, "text": p.text} for p in charDiff(from_text, to_text)]
+ diff_chunks.append({"kind": "removed", "text": from_text, "parts": parts})
+ diff_chunks.append({"kind": "added", "text": to_text, "parts": parts})
+ else:
+ for line in del_lines:
+ diff_chunks.append({"kind": "removed", "text": line})
+ i = j
+ elif status == "+":
+ diff_chunks.append({"kind": "added", "text": text})
+ i += 1
+ else:
+ i += 1
+
+ return {
+ "stockCode": c.stockCode,
+ "corpName": c.corpName,
+ "topic": topic,
+ "fromPeriod": fromPeriod,
+ "toPeriod": toPeriod,
+ "diff": diff_chunks,
+ }
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+
+@router.get("/api/company/{code}/bridge/{topic}")
+def api_company_bridge(
+ code: str,
+ topic: str,
+ period: str = Query("latest"),
+ tolerance: float = Query(0.05),
+):
+ """텍스트-재무 숫자 교차 참조."""
+ try:
+ c = get_company(code)
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ import polars as pl
+
+ from dartlab.core.docs.bridge import (
+ extract_amounts_from_text,
+ get_finance_amounts,
+ match_amounts,
+ )
+
+ try:
+ sections = c._docs.sections.raw
+ periods = sorted(
+ [col for col in sections.columns if _re.fullmatch(r"\d{4}(Q[1-4])?", col)],
+ reverse=True,
+ )
+ if not periods:
+ raise HTTPException(status_code=404, detail="기간 없음")
+
+ target_period = periods[0] if period == "latest" else period
+ if target_period not in sections.columns:
+ raise HTTPException(status_code=400, detail=f"기간 {target_period} 없음")
+
+ topic_rows = sections.filter((pl.col("topic") == topic) & (pl.col("blockType") == "text"))
+ if topic_rows.height == 0:
+ raise HTTPException(status_code=404, detail=f"topic {topic} 텍스트 없음")
+
+ texts = topic_rows[target_period].drop_nulls().to_list()
+ full_text = "\n".join(str(t) for t in texts if t)
+
+ text_amounts = extract_amounts_from_text(full_text)
+ finance_amounts = get_finance_amounts(c, target_period)
+ matched = match_amounts(text_amounts, finance_amounts, tolerance=tolerance)
+ except HTTPException:
+ raise
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ return {
+ "stockCode": c.stockCode,
+ "corpName": c.corpName,
+ "topic": topic,
+ "period": target_period,
+ "extracted": len(text_amounts),
+ "matched": len(matched),
+ "matchRate": round(len(matched) / max(len(text_amounts), 1), 3),
+ "matches": matched,
+ }
+
+
+@router.get("/api/company/{code}/topics/graph")
+def api_company_topics_graph(
+ code: str,
+ threshold: int = Query(3),
+):
+ """topic간 상호 참조 그래프."""
+ try:
+ c = get_company(code)
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ from dartlab.core.docs.topicGraph import (
+ analyze_graph,
+ build_mention_matrix,
+ )
+
+ try:
+ sections = c._docs.sections.raw
+ matrix = build_mention_matrix(sections)
+ analysis = analyze_graph(matrix.get("adjacency", {}), threshold=threshold)
+
+ edges = [{"source": src, "target": tgt, "weight": cnt} for (src, tgt), cnt in analysis.get("top_edges", [])]
+ hubs = [{"topic": t, "degree": d} for t, d in analysis.get("hubs", [])]
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ return {
+ "stockCode": c.stockCode,
+ "corpName": c.corpName,
+ "period": matrix.get("period"),
+ "edges": analysis.get("edges", 0),
+ "nodes": analysis.get("nodes", 0),
+ "avgDegree": analysis.get("avg_degree", 0),
+ "hubs": hubs,
+ "topEdges": edges,
+ }
+
+
+@router.get("/api/company/{code}/search")
+def api_company_search_sections(code: str, q: str = Query("", description="검색어")):
+ """현재 회사의 sections 전체 텍스트에서 substring 검색."""
+ try:
+ c = get_company(code)
+ sec = c.sections
+ if sec is None or not q.strip():
+ return {"stockCode": c.stockCode, "corpName": c.corpName, "results": []}
+
+ query = q.strip().lower()
+ period_re = _re.compile(r"^\d{4}(Q[1-4])?$")
+ period_cols = [col for col in sec.columns if period_re.fullmatch(col)]
+
+ results = []
+ seen_topics: set[str] = set()
+ for row in sec.iter_rows(named=True):
+ topic = row.get("topic", "")
+ if not topic or topic in seen_topics:
+ continue
+ bt = str(row.get("blockType", ""))
+ if bt != "text":
+ continue
+ for p in period_cols:
+ val = row.get(p)
+ if not val or not isinstance(val, str):
+ continue
+ lower_val = val.lower()
+ if query in lower_val:
+ idx = lower_val.index(query)
+ start = max(0, idx - 40)
+ end = min(len(val), idx + len(query) + 60)
+ snippet = ("..." if start > 0 else "") + val[start:end].strip() + ("..." if end < len(val) else "")
+ match_count = lower_val.count(query)
+ results.append(
+ {
+ "topic": topic,
+ "label": safe_topic_label(c, topic),
+ "period": p,
+ "snippet": snippet,
+ "matchCount": match_count,
+ }
+ )
+ seen_topics.add(topic)
+ break
+ if len(results) >= 30:
+ break
+
+ return {"stockCode": c.stockCode, "corpName": c.corpName, "query": q, "results": results}
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+
+@router.get("/api/company/{code}/searchIndex")
+def api_company_search_index(code: str, request: Request, response: Response):
+ """MiniSearch 인덱스용 flat document list.
+
+ 회사의 sections 전체를 topic × period × blockType 단위 문서로 평탄화하여 반환.
+ 브라우저에서 MiniSearch.addAll() → 즉시 fuzzy/prefix/BM25 검색 가능.
+ text는 300자로 제한, 문서 수는 최대 1000개로 경량화.
+ """
+ _MAX_DOCS = 1000
+ _MAX_TEXT_LEN = 300
+
+ try:
+ c = get_company(code)
+ sec = c.sections
+ if sec is None:
+ return {"stockCode": c.stockCode, "corpName": c.corpName, "documents": []}
+
+ period_re = _re.compile(r"^\d{4}(Q[1-4])?$")
+ period_cols = [col for col in sec.columns if period_re.fullmatch(col)]
+
+ documents: list[dict[str, Any]] = []
+ doc_id = 0
+ for row in sec.iter_rows(named=True):
+ if doc_id >= _MAX_DOCS:
+ break
+ topic = row.get("topic", "")
+ block_type = str(row.get("blockType", ""))
+ block_order = row.get("blockOrder", 0)
+ chapter = row.get("chapter", "")
+ label = safe_topic_label(c, topic)
+
+ for p in period_cols:
+ if doc_id >= _MAX_DOCS:
+ break
+ val = row.get(p)
+ if not val or not isinstance(val, str):
+ continue
+ text = val[:_MAX_TEXT_LEN] if len(val) > _MAX_TEXT_LEN else val
+ documents.append(
+ {
+ "id": doc_id,
+ "topic": topic,
+ "label": label,
+ "chapter": chapter,
+ "period": p,
+ "blockType": block_type,
+ "blockOrder": block_order,
+ "text": text,
+ }
+ )
+ doc_id += 1
+
+ data = {
+ "stockCode": c.stockCode,
+ "corpName": c.corpName,
+ "documentCount": len(documents),
+ "truncated": doc_id >= _MAX_DOCS,
+ "documents": documents,
+ }
+ return etag_response(request, response, data, max_age=600, swr=1800)
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+
+@router.get("/api/company/{code}/modules")
+def api_company_modules(code: str):
+ """기업의 사용 가능한 데이터 모듈 목록."""
+ try:
+ c = get_company(code)
+ # scan_available_modules 제거됨 — topics 목록으로 대체
+ topics = getattr(c, "topics", None)
+ modules = list(topics) if topics else []
+ return {"stockCode": c.stockCode, "corpName": c.corpName, "modules": modules}
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
diff --git a/src/dartlab/server/api/ask.py b/src/dartlab/server/api/ask.py
new file mode 100644
index 0000000000000000000000000000000000000000..895959169e1b304938690cd824c3371e5074cd0c
--- /dev/null
+++ b/src/dartlab/server/api/ask.py
@@ -0,0 +1,110 @@
+"""LLM 질문 엔드포인트 — api_ask, plain_chat."""
+
+from __future__ import annotations
+
+import asyncio
+
+from fastapi import APIRouter, HTTPException
+from sse_starlette.sse import EventSourceResponse
+
+import dartlab
+from dartlab import Company
+
+from ..cache import company_cache
+from ..models import AskRequest
+from ..resolve import (
+ ResolveResult,
+ _collect_candidates,
+ build_ambiguous_msg,
+ build_not_found_msg,
+ needs_match_verification,
+ try_resolve_company,
+ try_resolve_from_history,
+ verify_match_with_llm,
+)
+from ..services.ai_analysis import run_plain_chat
+from ..streaming import stream_ask
+from .common import (
+ HANDLED_API_ERRORS,
+ guideDetail,
+ normalize_provider_name,
+)
+
+router = APIRouter()
+
+
+@router.post("/api/ask")
+async def api_ask(req: AskRequest):
+ """LLM 질문 — 종목이 있으면 데이터 기반 분석, 없으면 순수 대화."""
+ dartlab.verbose = False
+
+ resolved = try_resolve_company(req)
+ c: Company | None = resolved.company
+
+ resolved_from_explicit_context = bool(req.company) or bool(req.viewContext and req.viewContext.company)
+
+ if c and not resolved_from_explicit_context and needs_match_verification(req.question, c.corpName):
+ corrected = await asyncio.to_thread(
+ verify_match_with_llm,
+ req.question,
+ c.corpName,
+ c.stockCode,
+ )
+ if corrected:
+ try:
+ c = Company(corrected)
+ except (ValueError, OSError):
+ candidates = _collect_candidates(corrected, strict=False)
+ if candidates:
+ resolved = ResolveResult(ambiguous=True, suggestions=candidates)
+ c = None
+
+ if c:
+ cached = company_cache.get(c.stockCode)
+ if cached:
+ c = cached[0]
+
+ if not c and not resolved.not_found and not resolved.ambiguous and req.history:
+ c = try_resolve_from_history(req.history)
+ if c:
+ cached = company_cache.get(c.stockCode)
+ if cached:
+ c = cached[0]
+
+ disambig_msg: str | None = None
+ if resolved.not_found:
+ disambig_msg = build_not_found_msg(resolved.suggestions)
+ elif resolved.ambiguous:
+ disambig_msg = build_ambiguous_msg(resolved.suggestions)
+
+ if req.stream:
+ return EventSourceResponse(
+ stream_ask(c, req, not_found_msg=disambig_msg),
+ media_type="text/event-stream",
+ )
+
+ if disambig_msg:
+ return {"answer": disambig_msg}
+
+ if c is None:
+ return await run_plain_chat(req)
+
+ try:
+ answer = await asyncio.to_thread(
+ c.ask,
+ req.question,
+ include=req.include,
+ exclude=req.exclude,
+ provider=normalize_provider_name(req.provider) or req.provider,
+ role=req.role,
+ model=req.model,
+ api_key=req.api_key,
+ base_url=req.base_url,
+ )
+ return {
+ "company": c.corpName,
+ "stockCode": c.stockCode,
+ "answer": answer,
+ }
+ except HANDLED_API_ERRORS as e:
+ raise HTTPException(status_code=500, detail=guideDetail(e, feature="ai"))
diff --git a/src/dartlab/server/api/common.py b/src/dartlab/server/api/common.py
new file mode 100644
index 0000000000000000000000000000000000000000..42fce41f10501cad44f8bc750f972c10414ddc3c
--- /dev/null
+++ b/src/dartlab/server/api/common.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import re as _re
+from typing import Any
+
+from fastapi import Request, Response
+
+from dartlab.core.ai import normalize_provider
+
+HANDLED_API_ERRORS = (
+ AttributeError,
+ FileNotFoundError,
+ ImportError,
+ KeyError,
+ OSError,
+ PermissionError,
+ RuntimeError,
+ TimeoutError,
+ TypeError,
+ ValueError,
+)
+
+_PATH_PATTERN = _re.compile(
+ r"(?:[A-Za-z]:\\|/(?:home|Users|tmp|var|usr|etc|root)/)[\w\\/.~\- ]+",
+)
+_CREDENTIAL_PATTERN = _re.compile(
+ r"(api[_-]?key|token|secret|password|authorization|bearer)[\s:=]+\S+",
+ _re.IGNORECASE,
+)
+
+
+def sanitize_error(exc: BaseException) -> str:
+ """에러 메시지에서 파일 경로와 인증 정보를 마스킹한다."""
+ msg = _PATH_PATTERN.sub("", str(exc))
+ msg = _CREDENTIAL_PATTERN.sub(r"\1=***", msg)
+ return msg
+
+
+def guideDetail(exc: BaseException, *, feature: str | None = None) -> str:
+ """sanitize_error + guide 안내 포함. Server API 에러 응답 표준."""
+ detail = sanitize_error(exc)
+ try:
+ from dartlab.guide.integration import inferFeature
+
+ resolvedFeature = feature or inferFeature(exc) # type: ignore[arg-type]
+ from dartlab.guide import guide
+
+ guideMsg = guide.handleError(exc, feature=resolvedFeature) # type: ignore[arg-type]
+ if guideMsg and guideMsg != f"오류: {exc}":
+ detail = f"{detail}\n\n{guideMsg}"
+ except ImportError:
+ pass
+ return detail
+
+
+def normalize_provider_name(provider: str | None) -> str | None:
+ """Provider 이름을 정규화한다."""
+ return normalize_provider(provider)
+
+
+def serialize_payload(payload: Any, *, max_rows: int = 200) -> dict[str, Any]:
+ """DataFrame/dict/str 등 다양한 페이로드를 JSON 직렬화 가능한 dict로 변환한다."""
+ import polars as pl
+
+ if payload is None:
+ return {"type": "none", "data": None}
+
+ if isinstance(payload, pl.DataFrame):
+ preview = payload.head(max_rows)
+ rows = preview.to_dicts()
+ for row in rows:
+ for key, value in row.items():
+ if value is not None and not isinstance(value, (str, int, float, bool)):
+ row[key] = str(value)
+ return {
+ "type": "table",
+ "columns": preview.columns,
+ "rows": rows,
+ "totalRows": payload.height,
+ "truncated": payload.height > max_rows,
+ }
+
+ if isinstance(payload, dict):
+ return {"type": "dict", "data": payload}
+
+ if isinstance(payload, str):
+ return {"type": "text", "data": payload}
+
+ return {"type": "unknown", "data": str(payload)}
+
+
+def compute_etag(data: Any) -> str:
+ """데이터의 MD5 기반 ETag 해시를 계산한다."""
+ raw = json.dumps(data, sort_keys=True, ensure_ascii=False).encode()
+ return f'"{hashlib.md5(raw, usedforsecurity=False).hexdigest()[:16]}"'
+
+
+def etag_response(
+ request: Request,
+ response: Response,
+ data: dict[str, Any],
+ *,
+ max_age: int = 300,
+ swr: int = 1800,
+) -> dict[str, Any] | Response:
+ """ETag/Cache-Control 헤더를 설정하고 304 응답을 처리한다."""
+ etag = compute_etag(data)
+ cache_control = f"private, max-age={max_age}, stale-while-revalidate={swr}"
+
+ if_none_match = request.headers.get("if-none-match")
+ if if_none_match == etag:
+ return Response(status_code=304, headers={"ETag": etag, "Cache-Control": cache_control})
+
+ response.headers["ETag"] = etag
+ response.headers["Cache-Control"] = cache_control
+
+ return data
diff --git a/src/dartlab/server/api/company.py b/src/dartlab/server/api/company.py
new file mode 100644
index 0000000000000000000000000000000000000000..88124f68607819203cbda8dd36985862e3f15d8d
--- /dev/null
+++ b/src/dartlab/server/api/company.py
@@ -0,0 +1,556 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException, Query, Request, Response
+from sse_starlette.sse import EventSourceResponse
+
+import dartlab
+
+from ..services.ai_analysis import stream_topic_summary
+from ..services.company_api import (
+ build_diff_summary,
+ build_toc,
+ build_viewer,
+ filter_blocks_by_period,
+ get_company,
+ safe_topic_label,
+)
+from .common import (
+ HANDLED_API_ERRORS,
+ etag_response,
+ guideDetail,
+ normalize_provider_name,
+ serialize_payload,
+)
+
+router = APIRouter()
+
+
+@router.get("/api/search")
+def api_search(q: str = Query(..., min_length=1)):
+ """종목 검색 — substring 우선, 결과 없으면 fuzzy(초성/Levenshtein) fallback."""
+ try:
+ df = dartlab.searchName(q)
+ rows = df.to_dicts() if len(df) > 0 else []
+ fuzzy_used = False
+
+ if not rows:
+ from dartlab.gather.listing import fuzzySearch
+
+ df = fuzzySearch(q, maxResults=20)
+ rows = df.to_dicts() if len(df) > 0 else []
+ fuzzy_used = bool(rows)
+
+ mapped = []
+ for row in rows[:20]:
+ mapped.append(
+ {
+ "corpName": row.get("회사명", row.get("corpName", "")),
+ "stockCode": row.get("종목코드", row.get("stockCode", "")),
+ "market": row.get("시장구분", ""),
+ "sector": row.get("업종", ""),
+ }
+ )
+ return {"results": mapped, "fuzzy": fuzzy_used}
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=400, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}")
+def api_company(code: str):
+ """종목 기본 정보 + 사용 가능 API surface 목록."""
+ try:
+ company = get_company(code)
+ return {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "surface": {
+ "index": f"/api/company/{company.stockCode}/index",
+ "show": f"/api/company/{company.stockCode}/show/{{topic}}",
+ "trace": f"/api/company/{company.stockCode}/trace/{{topic}}",
+ },
+ "profile": {
+ "status": "roadmap",
+ "description": "변화 지점을 문서처럼 읽는 company report view가 추후 추가될 예정입니다.",
+ },
+ }
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/index")
+def api_company_index(code: str, request: Request, response: Response):
+ """회사 데이터 구조 인덱스 DataFrame."""
+ try:
+ company = get_company(code)
+ data = {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "payload": serialize_payload(company.index),
+ }
+ return etag_response(request, response, data, max_age=300, swr=1800)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/sections")
+def api_company_sections(code: str, request: Request, response: Response):
+ """merged topic x period 수평화 테이블."""
+ try:
+ company = get_company(code)
+ data = {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "payload": serialize_payload(company.sections, max_rows=5000),
+ }
+ return etag_response(request, response, data, max_age=300, swr=1800)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/init")
+def api_company_init(code: str, request: Request, response: Response):
+ """SPA 초기 로드용 번들 — toc + 첫 topic viewer + diff 요약."""
+ try:
+ company = get_company(code)
+ toc_data = build_toc(company)
+
+ first_topic: str | None = None
+ first_chapter: str | None = None
+ chapters = toc_data.get("chapters", [])
+ if chapters:
+ topics = chapters[0].get("topics", [])
+ if topics:
+ first_topic = topics[0]["topic"]
+ first_chapter = chapters[0]["chapter"]
+
+ viewer_data = None
+ diff_data = None
+ if first_topic:
+ viewer_data = build_viewer(company, first_topic)
+ diff_data = build_diff_summary(company, first_topic)
+
+ result = {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "toc": toc_data,
+ "firstTopic": first_topic,
+ "firstChapter": first_chapter,
+ "viewer": viewer_data,
+ "diffSummary": diff_data,
+ }
+ return etag_response(request, response, result, max_age=300, swr=1800)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/toc")
+def api_company_toc(code: str, request: Request, response: Response):
+ """목차(TOC) — chapter/topic 트리 구조."""
+ try:
+ company = get_company(code)
+ data = build_toc(company)
+ return etag_response(request, response, data, max_age=300, swr=1800)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/viewer/{topic}")
+def api_company_viewer_topic(
+ code: str,
+ topic: str,
+ request: Request,
+ period: str | None = Query(None, description="특정 기간만 반환 (타임라인 클릭 최적화)"),
+ response: Response = None,
+):
+ """단일 topic의 viewer 데이터 — sections 블록 + 텍스트 문서."""
+ try:
+ company = get_company(code)
+
+ if period is not None:
+ from dartlab.providers.dart.docs.viewer import (
+ serializeViewerBlock,
+ serializeViewerTextDocument,
+ viewerBlocks,
+ viewerTextDocument,
+ )
+
+ if not hasattr(company, "_viewer_cache"):
+ company._viewer_cache = {}
+ if topic in company._viewer_cache:
+ blocks = company._viewer_cache[topic]
+ else:
+ blocks = viewerBlocks(company, topic)
+ company._viewer_cache[topic] = blocks
+ blocks = filter_blocks_by_period(blocks, period)
+ data = {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "topic": topic,
+ "topicLabel": safe_topic_label(company, topic),
+ "period": period,
+ "blocks": [serializeViewerBlock(block) for block in blocks],
+ "textDocument": serializeViewerTextDocument(viewerTextDocument(topic, blocks)),
+ }
+ else:
+ data = build_viewer(company, topic)
+
+ return etag_response(request, response, data, max_age=120, swr=600)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/viewer2/{topic}")
+def apiViewerDoc(
+ code: str,
+ topic: str,
+ request: Request,
+ base: str | None = Query(None),
+ compare: str | None = Query(None),
+ response: Response = None,
+):
+ """sections 기반 신구대조 뷰어 — viewer() dict 반환."""
+ import re
+
+ try:
+ company = get_company(code)
+ sec = company._docs.sections.raw
+ if sec is None:
+ raise HTTPException(status_code=404, detail="sections 없음")
+
+ if not base:
+ periodRe = re.compile(r"^\d{4}(Q[1-4])?$")
+ periods = sorted(
+ [c for c in sec.columns if periodRe.fullmatch(c)],
+ reverse=True,
+ )
+ base = periods[0] if periods else None
+
+ from dartlab.core.docs.viewer import viewer
+
+ doc = viewer(sec, topic, base, compare)
+ doc["stockCode"] = company.stockCode
+ doc["corpName"] = company.corpName
+ doc["topicLabel"] = safe_topic_label(company, topic)
+ return etag_response(request, response, doc, max_age=120, swr=600)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.post("/api/company/{code}/viewer/batch")
+async def api_company_viewer_batch(code: str, request: Request, response: Response):
+ """여러 topic의 viewer 데이터를 한 번에 반환 — chapter 확장 시 N+1 제거."""
+ body = await request.json()
+ topics = body.get("topics", [])
+ if not topics or not isinstance(topics, list):
+ raise HTTPException(status_code=400, detail="topics 배열 필요")
+ topics = topics[:20] # 최대 20개 제한
+
+ try:
+ company = get_company(code)
+ results = {}
+ for topic in topics:
+ if not isinstance(topic, str):
+ continue
+ try:
+ results[topic] = build_viewer(company, topic)
+ except HANDLED_API_ERRORS:
+ results[topic] = None
+ return etag_response(request, response, {"results": results}, max_age=120, swr=600)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/show/{topic}/all")
+def api_company_show_all(code: str, topic: str, raw: bool = Query(False)):
+ """topic의 전 기간 viewer 블록 일괄 반환."""
+ try:
+ from dartlab.providers.dart.docs.viewer import (
+ serializeViewerBlock,
+ serializeViewerTextDocument,
+ viewerBlocks,
+ viewerTextDocument,
+ )
+
+ company = get_company(code)
+ blocks = viewerBlocks(company, topic)
+ return {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "topic": topic,
+ "blocks": [serializeViewerBlock(block) for block in blocks],
+ "textDocument": serializeViewerTextDocument(viewerTextDocument(topic, blocks)),
+ }
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.post("/api/company/{code}/show/{topic}/{block_idx}/parse")
+async def api_parse_raw_table(code: str, topic: str, block_idx: int):
+ """원문 테이블 블록을 구조화 DataFrame으로 파싱."""
+ try:
+ from dartlab.providers.dart.docs.tableAI import parseRawMarkdownBlock
+ from dartlab.providers.dart.docs.viewer import viewerBlocks
+
+ company = get_company(code)
+ blocks = viewerBlocks(company, topic)
+ target = None
+ for block in blocks:
+ if block.block == block_idx and block.kind == "raw_markdown":
+ target = block
+ break
+ if target is None:
+ raise HTTPException(status_code=404, detail="raw_markdown 블록이 아님")
+
+ result = await parseRawMarkdownBlock(target.rawMarkdown, topic)
+ return {
+ "stockCode": company.stockCode,
+ "topic": topic,
+ "block": block_idx,
+ "parsed": result,
+ }
+ except HTTPException:
+ raise
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=500, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/show/{topic}")
+def api_company_show(code: str, topic: str, block: int | None = Query(None), raw: bool = Query(False)):
+ """topic payload 조회 — show(topic) API 대응."""
+ try:
+ company = get_company(code)
+ if block is not None:
+ result = company.show(topic, block, period=None, raw=raw)
+ else:
+ result = company.show(topic)
+ return {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "topic": topic,
+ "block": block,
+ "payload": serialize_payload(result),
+ }
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/trace/{topic}")
+def api_company_trace(code: str, topic: str):
+ """source provenance 조회 — trace(topic) API 대응."""
+ try:
+ company = get_company(code)
+ return {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "topic": topic,
+ "payload": serialize_payload(company.trace(topic)),
+ }
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+
+@router.get("/api/company/{code}/summary/{topic}")
+async def api_company_topic_summary(
+ code: str,
+ topic: str,
+ provider: str | None = None,
+ model: str | None = None,
+):
+ """topic 데이터를 LLM으로 요약하여 SSE 스트리밍 반환."""
+ try:
+ company = get_company(code)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+ try:
+ overview = company.show(topic)
+ except (AttributeError, KeyError, TypeError, ValueError):
+ overview = None
+ if overview is None:
+ raise HTTPException(status_code=404, detail=f"topic '{topic}' 데이터 없음")
+
+ return EventSourceResponse(
+ stream_topic_summary(
+ company,
+ topic,
+ provider=normalize_provider_name(provider) or provider,
+ model=model,
+ )
+ )
+
+
+@router.get("/api/company/{code}/insights")
+def api_company_insights(code: str):
+ """7영역 인사이트 등급 (A~F) + 이상 징후."""
+ try:
+ company = get_company(code)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+ from dartlab.analysis.financial.insight.pipeline import analyze as insight_analyze
+
+ try:
+ result = insight_analyze(company.stockCode, company=company)
+ except HANDLED_API_ERRORS:
+ result = None
+
+ if result is None:
+ return {"stockCode": company.stockCode, "corpName": company.corpName, "available": False}
+
+ def _flag_dict(flag):
+ return {"level": flag.level, "category": flag.category, "text": flag.text}
+
+ def _insight_dict(insight):
+ return {
+ "grade": insight.grade,
+ "summary": insight.summary,
+ "details": insight.details,
+ "risks": [_flag_dict(risk) for risk in insight.risks],
+ "opportunities": [_flag_dict(opportunity) for opportunity in insight.opportunities],
+ }
+
+ def _anomaly_dict(anomaly):
+ return {
+ "severity": anomaly.severity,
+ "category": anomaly.category,
+ "text": anomaly.text,
+ "value": anomaly.value,
+ }
+
+ return {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "available": True,
+ "isFinancial": result.isFinancial,
+ "grades": result.grades(),
+ "areas": {
+ "performance": _insight_dict(result.performance),
+ "profitability": _insight_dict(result.profitability),
+ "health": _insight_dict(result.health),
+ "cashflow": _insight_dict(result.cashflow),
+ "governance": _insight_dict(result.governance),
+ "risk": _insight_dict(result.risk),
+ "opportunity": _insight_dict(result.opportunity),
+ },
+ "anomalies": [_anomaly_dict(anomaly) for anomaly in result.anomalies],
+ "summary": result.summary,
+ "profile": result.profile,
+ }
+
+
+@router.get("/api/company/{code}/network")
+def api_company_network(code: str, hops: int = 1):
+ """관계사 네트워크 그래프 — ego 중심 N-hop."""
+ hops = max(1, min(hops, 3))
+ try:
+ company = get_company(code)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+ try:
+ result = company._ensureNetwork()
+ if result is None:
+ return {"stockCode": company.stockCode, "corpName": company.corpName, "available": False}
+ data, full = result
+
+ from dartlab.scan.network.export import export_ego
+
+ ego = export_ego(data, full, company.stockCode, hops=hops)
+ return {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "available": True,
+ **ego,
+ }
+ except HANDLED_API_ERRORS:
+ return {"stockCode": company.stockCode, "corpName": company.corpName, "available": False}
+
+
+@router.get("/api/company/{code}/scan/{axis}")
+def api_company_scan(code: str, axis: str):
+ """6-Axis 스캔 단일 축 결과 + 시장 내 위치."""
+ try:
+ company = get_company(code)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+ valid_axes = {"governance", "workforce", "capital", "debt"}
+
+ if axis == "all":
+ results = {}
+ for current_axis in valid_axes:
+ method = getattr(company, current_axis, None)
+ if method is None:
+ continue
+ try:
+ df = method()
+ if df is not None and not df.is_empty():
+ results[current_axis] = serialize_payload(df)
+ except HANDLED_API_ERRORS:
+ continue
+ return {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "available": bool(results),
+ "scans": results,
+ }
+
+ if axis not in valid_axes:
+ raise HTTPException(status_code=400, detail=f"유효한 축: {', '.join(sorted(valid_axes))}, all")
+
+ method = getattr(company, axis, None)
+ if method is None:
+ return {"stockCode": company.stockCode, "corpName": company.corpName, "available": False}
+
+ try:
+ df = method()
+ if df is None or df.is_empty():
+ return {"stockCode": company.stockCode, "corpName": company.corpName, "available": False}
+ return {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "available": True,
+ "scanType": axis,
+ "payload": serialize_payload(df),
+ }
+ except HANDLED_API_ERRORS:
+ return {"stockCode": company.stockCode, "corpName": company.corpName, "available": False}
+
+
+@router.get("/api/company/{code}/scan/position")
+def api_company_scan_position(code: str):
+ """6-Axis 전체 포지션 요약 — 사전 빌드 스냅샷 기반."""
+ from dartlab.scan.snapshot import getScanPosition
+
+ position = getScanPosition(code)
+ if position is None:
+ raise HTTPException(status_code=404, detail="scan 스냅샷 없음 — buildScanSnapshot() 선행 필요")
+
+ return {
+ "stockCode": code,
+ "available": True,
+ "position": position,
+ }
+
+
+@router.get("/api/company/{code}/insights/unified")
+def api_company_insights_unified(code: str):
+ """통합 인사이트 — 등급 + 스캔 + 피어 결합."""
+ try:
+ company = get_company(code)
+ except HANDLED_API_ERRORS as exc:
+ raise HTTPException(status_code=404, detail=guideDetail(exc)) from exc
+
+ from dartlab.scan.payload import build_unified_payload
+
+ try:
+ unified = build_unified_payload(company)
+ except HANDLED_API_ERRORS:
+ unified = {}
+
+ return {
+ "stockCode": company.stockCode,
+ "corpName": company.corpName,
+ "available": bool(unified),
+ "areas": unified,
+ }
diff --git a/src/dartlab/server/api/dart.py b/src/dartlab/server/api/dart.py
new file mode 100644
index 0000000000000000000000000000000000000000..ee56bb6acb39aae7841a364f0646a4aa4673bce9
--- /dev/null
+++ b/src/dartlab/server/api/dart.py
@@ -0,0 +1,100 @@
+"""DART API 프록시 — 키 없는 사용자를 위한 서버 측 OpenDART 호출.
+
+서버에 DART_API_KEY를 두고, 사용자는 키 없이 실시간 공시 조회.
+Rate limit으로 남용 방지, crtfc_key 필드 제거로 키 노출 방지.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, HTTPException, Query
+
+router = APIRouter(prefix="/api/dart", tags=["dart"])
+_log = logging.getLogger(__name__)
+
+_MAX_ROWS = 100
+
+
+def _get_client():
+ """서버 측 DartClient 싱글톤."""
+ from dartlab.providers.dart.openapi.client import DartClient
+
+ return DartClient()
+
+
+def _sanitize(data: dict | list) -> dict | list:
+ """응답에서 crtfc_key 필드를 제거."""
+ if isinstance(data, dict):
+ return {k: _sanitize(v) for k, v in data.items() if k != "crtfc_key"}
+ if isinstance(data, list):
+ return [_sanitize(item) if isinstance(item, (dict, list)) else item for item in data]
+ return data
+
+
+@router.get("/filings")
+def dart_filings(
+ corp: str | None = Query(None, description="종목코드 또는 corp_code"),
+ start: str | None = Query(None, description="시작일 (YYYYMMDD)"),
+ end: str | None = Query(None, description="종료일 (YYYYMMDD)"),
+ type: str | None = Query(None, description="공시유형 필터"),
+):
+ """공시 목록 조회."""
+ try:
+ from dartlab.providers.dart.openapi.dart import Dart
+
+ dart = Dart()
+ df = dart.filings(corp=corp, start=start or "20250101", end=end, type=type)
+ rows = df.head(_MAX_ROWS).to_dicts()
+ return _sanitize({"count": len(rows), "total": df.height, "rows": rows})
+ except (ValueError, KeyError, RuntimeError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.get("/company/{corp}")
+def dart_company_info(corp: str):
+ """기업 기본 정보."""
+ try:
+ from dartlab.providers.dart.openapi.dart import Dart
+
+ dart = Dart()
+ info = dart.company(corp)
+ return _sanitize(info)
+ except (ValueError, KeyError, RuntimeError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.get("/finance/{corp}")
+def dart_finance(
+ corp: str,
+ year: int | None = Query(None, description="사업연도"),
+ quarter: int | None = Query(None, description="분기 (0=연간, 1~3=분기)"),
+):
+ """재무제표 조회."""
+ try:
+ from dartlab.providers.dart.openapi.dart import Dart
+
+ dart = Dart()
+ df = dart.finstate(corp, start=year, q=quarter)
+ rows = df.head(_MAX_ROWS).to_dicts()
+ return _sanitize({"count": len(rows), "total": df.height, "rows": rows})
+ except (ValueError, KeyError, RuntimeError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@router.get("/report/{corp}/{category}")
+def dart_report(
+ corp: str,
+ category: str,
+ year: int | None = Query(None, description="사업연도"),
+):
+ """보고서 API (배당, 직원, 임원 등 56개 카테고리)."""
+ try:
+ from dartlab.providers.dart.openapi.dart import Dart
+
+ dart = Dart()
+ df = dart.report(corp, category, start=year)
+ rows = df.head(_MAX_ROWS).to_dicts()
+ return _sanitize({"count": len(rows), "total": df.height, "rows": rows})
+ except (ValueError, KeyError, RuntimeError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
diff --git a/src/dartlab/server/api/data.py b/src/dartlab/server/api/data.py
new file mode 100644
index 0000000000000000000000000000000000000000..05730aac5f6a5705ebc8a129320286aba2f8c04b
--- /dev/null
+++ b/src/dartlab/server/api/data.py
@@ -0,0 +1,422 @@
+"""데이터 소스, 미리보기, 통계, Excel 내보내기 엔드포인트."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Query
+from fastapi.responses import FileResponse
+
+import dartlab
+from dartlab import Company
+
+from .common import (
+ HANDLED_API_ERRORS,
+ guideDetail,
+ serialize_payload,
+)
+
+router = APIRouter()
+
+
+# ── Data Sources ──
+
+
+@router.get("/api/data/sources/{code}")
+async def api_data_sources(code: str):
+ """경량 데이터 소스 목록 — registry 메타 + 파일 존재 여부만 확인 (빠름)."""
+ try:
+ c = await asyncio.to_thread(Company, code)
+ except (ValueError, OSError) as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ from dartlab.core.registry import getEntries
+
+ hasFlags = {
+ "finance": c._hasFinance,
+ "docs": c._hasDocs,
+ "report": c._hasReport,
+ }
+
+ categoryOrder = ["finance", "report", "disclosure", "notes", "analysis", "raw"]
+ categories: dict[str, list[dict]] = {}
+ totalAvailable = 0
+
+ for entry in getEntries():
+ req = entry.requires or ""
+ if req:
+ available = hasFlags.get(req, False)
+ else:
+ available = True
+
+ item = {
+ "name": entry.name,
+ "label": entry.label,
+ "dataType": entry.dataType,
+ "description": entry.description,
+ "available": available,
+ }
+ categories.setdefault(entry.category, []).append(item)
+ if available:
+ totalAvailable += 1
+
+ ordered: dict[str, list[dict]] = {}
+ for cat in categoryOrder:
+ if cat in categories:
+ ordered[cat] = categories[cat]
+ for cat in categories:
+ if cat not in ordered:
+ ordered[cat] = categories[cat]
+
+ totalSources = sum(len(v) for v in ordered.values())
+
+ return {
+ "stockCode": c.stockCode,
+ "corpName": c.corpName,
+ "totalSources": totalSources,
+ "availableSources": totalAvailable,
+ "categories": ordered,
+ }
+
+
+@router.get("/api/data/preview/{code}/{module}")
+async def api_data_preview(code: str, module: str, max_rows: int = Query(50, ge=1, le=500)):
+ """데이터 미리보기 — 모듈 데이터를 JSON으로 반환 (테이블/텍스트)."""
+ try:
+ c = await asyncio.to_thread(Company, code)
+ except (ValueError, OSError) as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ from dartlab.core.registry import getEntry
+
+ entry = getEntry(module)
+ if entry is None:
+ raise HTTPException(status_code=404, detail=f"모듈 '{module}'을 찾을 수 없습니다")
+
+ import polars as pl
+
+ try:
+ data = await asyncio.to_thread(_resolve_module_data, c, entry)
+ except (AttributeError, ValueError, OSError, KeyError, TypeError) as e:
+ raise HTTPException(status_code=404, detail=f"데이터를 가져올 수 없습니다: {e}")
+
+ if data is None:
+ raise HTTPException(status_code=404, detail="데이터가 없습니다")
+
+ if isinstance(data, pl.DataFrame):
+ if "year" in data.columns:
+ data = data.sort("year")
+ serialized = serialize_payload(data, max_rows=max_rows)
+ result: dict[str, Any] = {
+ **serialized,
+ "module": module,
+ "label": entry.label,
+ "unit": entry.unit,
+ }
+ financeMeta = _build_finance_meta(module)
+ if financeMeta:
+ result["meta"] = financeMeta
+ return result
+
+ if isinstance(data, dict):
+ flat: dict[str, Any] = {}
+ for k, v in data.items():
+ if isinstance(v, __import__("polars").DataFrame):
+ continue
+ if isinstance(v, list) and v and isinstance(v[0], dict):
+ flat[k] = json.dumps(v, ensure_ascii=False, default=str)
+ elif isinstance(v, dict):
+ flat[k] = json.dumps(v, ensure_ascii=False, default=str)
+ elif isinstance(v, (str, int, float, bool, type(None))):
+ flat[k] = v
+ else:
+ flat[k] = str(v)
+ return {
+ "type": "dict",
+ "module": module,
+ "label": entry.label,
+ "unit": entry.unit,
+ "data": flat,
+ }
+
+ if isinstance(data, str):
+ truncated = len(data) > 5000
+ return {
+ "type": "text",
+ "module": module,
+ "label": entry.label,
+ "text": data[:5000] if truncated else data,
+ "truncated": truncated,
+ }
+
+ return {
+ "type": "unknown",
+ "module": module,
+ "label": entry.label,
+ "data": str(data)[:2000],
+ }
+
+
+@router.get("/api/data/stats")
+def api_data_stats():
+ """로컬 데이터 현황 — 문서/재무 파일 수, dartlab 버전."""
+ from dartlab.core.dataLoader import _dataDir
+
+ stats: dict[str, Any] = {
+ "version": dartlab.__version__ if hasattr(dartlab, "__version__") else "unknown",
+ }
+ for category in ("docs", "finance"):
+ try:
+ d = _dataDir(category)
+ if d.exists():
+ files = list(d.glob("*.parquet"))
+ stats[category] = {"count": len(files), "exists": True}
+ else:
+ stats[category] = {"count": 0, "exists": False}
+ except HANDLED_API_ERRORS:
+ stats[category] = {"count": 0, "exists": False}
+ return stats
+
+
+@router.get("/api/spec")
+def api_spec():
+ """시스템 스펙 조회 — LLM/MCP/외부 클라이언트용 (deprecated)."""
+ raise HTTPException(
+ status_code=501,
+ detail="스펙 조회 API는 현재 사용할 수 없습니다 (ai.spec 모듈 제거됨)",
+ )
+
+
+# ── Export ──
+
+
+@router.get("/api/export/modules/{code}")
+async def api_export_modules(code: str):
+ """Excel 내보내기 가능한 모듈 목록."""
+ try:
+ c = await asyncio.to_thread(Company, code)
+ except (ValueError, OSError) as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ from dartlab.export.excel import listAvailableModules
+
+ modules = await asyncio.to_thread(listAvailableModules, c)
+ return {
+ "stockCode": c.stockCode,
+ "corpName": c.corpName,
+ "modules": modules,
+ }
+
+
+@router.get("/api/export/sources/{code}")
+async def api_export_sources(code: str):
+ """데이터 소스 디스커버리 — registry 기반 전체 소스 트리."""
+ try:
+ c = await asyncio.to_thread(Company, code)
+ except (ValueError, OSError) as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ from dartlab.export.sources import discoverSources
+
+ tree = await asyncio.to_thread(discoverSources, c)
+ return tree.toDict()
+
+
+@router.get("/api/export/templates")
+def api_export_templates():
+ """저장된 템플릿 목록 (프리셋 포함)."""
+ from dartlab.export.store import TemplateStore
+
+ store = TemplateStore()
+ templates = store.list()
+ return {
+ "templates": [t.toDict() for t in templates],
+ }
+
+
+@router.get("/api/export/templates/{template_id}")
+def api_export_template_get(template_id: str):
+ """단일 템플릿 조회."""
+ from dartlab.export.store import TemplateStore
+
+ store = TemplateStore()
+ t = store.get(template_id)
+ if t is None:
+ raise HTTPException(status_code=404, detail=f"템플릿 '{template_id}'을 찾을 수 없습니다")
+ return t.toDict()
+
+
+@router.post("/api/export/templates")
+def api_export_template_save(req: dict):
+ """템플릿 저장 (신규 or 업데이트)."""
+ from dartlab.export.store import TemplateStore
+ from dartlab.export.template import ExcelTemplate
+
+ store = TemplateStore()
+ t = ExcelTemplate.fromDict(req)
+ tid = store.save(t)
+ return {"ok": True, "templateId": tid}
+
+
+@router.delete("/api/export/templates/{template_id}")
+def api_export_template_delete(template_id: str):
+ """템플릿 삭제."""
+ from dartlab.export.store import TemplateStore
+
+ store = TemplateStore()
+ deleted = store.delete(template_id)
+ if not deleted:
+ raise HTTPException(status_code=400, detail="프리셋 템플릿은 삭제할 수 없습니다")
+ return {"ok": True}
+
+
+@router.get("/api/export/excel/{code}")
+async def api_export_excel(
+ code: str,
+ modules: str | None = Query(None, description="쉼표 구분 모듈: IS,BS,CF,ratios,dividend,employee"),
+ template_id: str | None = Query(None, description="템플릿 ID (preset_full, preset_summary 등)"),
+):
+ """Excel 파일 내보내기 — .xlsx 다운로드."""
+ import tempfile
+
+ try:
+ c = await asyncio.to_thread(Company, code)
+ except (ValueError, OSError) as e:
+ raise HTTPException(status_code=404, detail=guideDetail(e))
+
+ tmpDir = Path(tempfile.gettempdir())
+ safeName = c.corpName.replace("/", "_").replace("\\", "_")
+
+ if template_id:
+ from dartlab.export.excel import exportWithTemplate
+ from dartlab.export.store import TemplateStore
+
+ store = TemplateStore()
+ tmpl = store.get(template_id)
+ if tmpl is None:
+ raise HTTPException(status_code=404, detail=f"템플릿 '{template_id}'을 찾을 수 없습니다")
+ templateSafe = tmpl.name.replace("/", "_").replace("\\", "_")
+ outPath = tmpDir / f"{c.stockCode}_{safeName}_{templateSafe}.xlsx"
+ try:
+ await asyncio.to_thread(exportWithTemplate, c, tmpl, outPath)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=guideDetail(e))
+ return FileResponse(
+ path=str(outPath),
+ filename=f"{c.stockCode}_{safeName}_{templateSafe}.xlsx",
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ )
+
+ modList = [m.strip() for m in modules.split(",")] if modules else None
+ outPath = tmpDir / f"{c.stockCode}_{safeName}.xlsx"
+
+ try:
+ from dartlab.export.excel import exportToExcel
+
+ await asyncio.to_thread(exportToExcel, c, outputPath=outPath, modules=modList)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=guideDetail(e))
+
+ return FileResponse(
+ path=str(outPath),
+ filename=f"{c.stockCode}_{safeName}.xlsx",
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ )
+
+
+# ── Internal Helpers ──
+
+
+def _resolve_module_data(c: Company, entry) -> Any:
+ """registry entry에서 실제 데이터를 추출한다."""
+ import dataclasses
+ import enum
+
+ import polars as pl
+
+ name = entry.name
+
+ if name.startswith("annual.") or name.startswith("timeseries."):
+ prefix, stmt = name.split(".", 1)
+ prop = "annual" if prefix == "annual" else "timeseries"
+ result = getattr(c, prop, None)
+ if result is None:
+ return None
+ series, periods = result
+ stmt_data = series.get(stmt)
+ if not stmt_data or not periods:
+ return None
+
+ from dartlab.providers.dart.finance.mapper import AccountMapper
+
+ order = AccountMapper.get().sortOrder(stmt)
+
+ rows = []
+ for account, values in stmt_data.items():
+ row = {"항목": account}
+ for i, p in enumerate(periods):
+ row[str(p)] = values[i] if i < len(values) else None
+ rows.append(row)
+ if not rows:
+ return None
+ if order:
+ rows.sort(key=lambda r: order.get(r["항목"], 9999))
+ return pl.DataFrame(rows)
+
+ attrName = entry.funcName or entry.name
+ if name in ("IS", "BS", "CF"):
+ attrName = name
+
+ data = getattr(c, attrName, None)
+ if data is None:
+ return None
+
+ if callable(data) and not isinstance(data, (pl.DataFrame, dict, str)):
+ data = data()
+
+ if entry.extractor:
+ try:
+ data = entry.extractor(data)
+ except (AttributeError, TypeError):
+ pass
+
+ if dataclasses.is_dataclass(data) and not isinstance(data, type):
+ data = {k: v for k, v in dataclasses.asdict(data).items() if v is not None}
+
+ if isinstance(data, dict):
+ cleaned = {}
+ for k, v in data.items():
+ if isinstance(v, enum.Enum):
+ cleaned[k] = v.value
+ elif isinstance(v, (list, tuple)):
+ cleaned[k] = [item.value if isinstance(item, enum.Enum) else item for item in v]
+ else:
+ cleaned[k] = v
+ data = cleaned
+
+ return data
+
+
+def _build_finance_meta(moduleName: str) -> dict[str, Any]:
+ """finance 시계열 모듈의 메타데이터 — 한글 라벨, 정렬, 레벨 정보."""
+ if not moduleName.startswith("annual.") and not moduleName.startswith("timeseries."):
+ return {}
+
+ _, stmt = moduleName.split(".", 1)
+ from dartlab.providers.dart.finance.mapper import AccountMapper
+
+ mapper = AccountMapper.get()
+ labels = mapper.labelMap()
+ order = mapper.sortOrder(stmt)
+ levels = mapper.levelMap(stmt)
+
+ return {
+ "labels": labels,
+ "sortOrder": order,
+ "levels": levels,
+ "unit": "원",
+ "stmtType": stmt,
+ }
diff --git a/src/dartlab/server/api/macro.py b/src/dartlab/server/api/macro.py
new file mode 100644
index 0000000000000000000000000000000000000000..8ee013d5e784680a0babc0cbbc41064db43c88ae
--- /dev/null
+++ b/src/dartlab/server/api/macro.py
@@ -0,0 +1,180 @@
+"""FRED 매크로 경제지표 API 엔드포인트."""
+
+from __future__ import annotations
+
+import asyncio
+
+from fastapi import APIRouter, HTTPException, Query
+
+router = APIRouter()
+
+
+def _get_fred():
+ """Fred 인스턴스 생성 — API 키 없으면 503."""
+ from dartlab.gather.fred import Fred
+ from dartlab.gather.fred.types import AuthenticationError
+
+ try:
+ return Fred()
+ except AuthenticationError as exc:
+ raise HTTPException(status_code=503, detail=str(exc))
+
+
+def _to_records(df):
+ """Polars DataFrame → list[dict] (JSON serializable)."""
+ if df.is_empty():
+ return []
+ return df.to_dicts()
+
+
+# ── 시계열 ──
+
+
+@router.get("/api/fred/series/{series_id}")
+async def api_fred_series(
+ series_id: str,
+ start: str | None = Query(None),
+ end: str | None = Query(None),
+ frequency: str | None = Query(None),
+ transform: str = Query("raw"),
+ window: int = Query(12),
+):
+ """FRED 시계열 조회 + 변환."""
+ from dartlab.gather.fred.types import FredError
+
+ f = _get_fred()
+ try:
+ if transform == "yoy":
+ df = await asyncio.to_thread(f.yoy, series_id, start=start, end=end)
+ elif transform == "mom":
+ df = await asyncio.to_thread(f.mom, series_id, start=start, end=end)
+ elif transform == "ma":
+ df = await asyncio.to_thread(f.movingAverage, series_id, window=window, start=start, end=end)
+ else:
+ df = await asyncio.to_thread(f.series, series_id, start=start, end=end, frequency=frequency)
+ except FredError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+
+ meta = await asyncio.to_thread(f.meta, series_id)
+ return {
+ "meta": {
+ "id": meta.id,
+ "title": meta.title,
+ "frequency": meta.frequency,
+ "units": meta.units,
+ },
+ "data": _to_records(df),
+ }
+
+
+# ── 검색 ──
+
+
+@router.get("/api/fred/search")
+async def api_fred_search(
+ q: str = Query(...),
+ limit: int = Query(20),
+):
+ """FRED 시리즈 검색."""
+ from dartlab.gather.fred.types import FredError
+
+ f = _get_fred()
+ try:
+ df = await asyncio.to_thread(f.search, q, limit=limit)
+ except FredError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+
+ return {"results": _to_records(df)}
+
+
+# ── 비교 ──
+
+
+@router.get("/api/fred/compare")
+async def api_fred_compare(
+ ids: str = Query(..., description="콤마 구분 시리즈 ID"),
+ start: str | None = Query(None),
+ end: str | None = Query(None),
+ normalize_to: str | None = Query(None),
+):
+ """복수 시계열 비교."""
+ from dartlab.gather.fred import transform as _transform
+ from dartlab.gather.fred.types import FredError
+
+ series_ids = [s.strip() for s in ids.split(",") if s.strip()]
+ if len(series_ids) < 2:
+ raise HTTPException(status_code=400, detail="시리즈 ID 2개 이상 필요")
+
+ f = _get_fred()
+ try:
+ df = await asyncio.to_thread(f.compare, series_ids, start=start, end=end)
+ except FredError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+
+ if normalize_to:
+ df = _transform.normalize_multi(df, base_date=normalize_to)
+
+ return {"data": _to_records(df)}
+
+
+# ── 카탈로그 ──
+
+
+@router.get("/api/fred/catalog")
+async def api_fred_catalog(
+ group: str | None = Query(None),
+):
+ """주요 경제지표 카탈로그."""
+ from dartlab.gather.fred.catalog import get_groups, to_dataframe
+
+ if group and group not in get_groups():
+ raise HTTPException(status_code=400, detail=f"그룹 없음. 사용 가능: {', '.join(get_groups())}")
+
+ df = to_dataframe(group)
+ return {"catalog": _to_records(df)}
+
+
+# ── 상관분석 ──
+
+
+@router.get("/api/fred/correlation")
+async def api_fred_correlation(
+ ids: str = Query(..., description="콤마 구분 시리즈 ID"),
+ start: str | None = Query(None),
+ end: str | None = Query(None),
+ max_lag: int = Query(12),
+ lead_lag: str | None = Query(None, description="선행/후행 분석 쌍 (콤마 구분 2개)"),
+):
+ """시계열 상관분석 + 선행/후행."""
+ from dartlab.gather.fred.types import FredError
+
+ series_ids = [s.strip() for s in ids.split(",") if s.strip()]
+ if len(series_ids) < 2:
+ raise HTTPException(status_code=400, detail="시리즈 ID 2개 이상 필요")
+
+ f = _get_fred()
+ result: dict = {}
+
+ try:
+ corr = await asyncio.to_thread(f.correlation, series_ids, start=start, end=end)
+ result["correlation"] = _to_records(corr)
+ except FredError as exc:
+ result["correlation_error"] = str(exc)
+
+ if lead_lag:
+ pair = [s.strip() for s in lead_lag.split(",") if s.strip()]
+ if len(pair) == 2:
+ try:
+ ll = await asyncio.to_thread(
+ f.leadLag,
+ pair[0],
+ pair[1],
+ max_lag=max_lag,
+ start=start,
+ end=end,
+ )
+ result["lead_lag"] = _to_records(ll)
+ except FredError as exc:
+ result["lead_lag_error"] = str(exc)
+
+ return result
diff --git a/src/dartlab/server/api/room.py b/src/dartlab/server/api/room.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec85764375dc63048a0198de8946f4fc7b797a3a
--- /dev/null
+++ b/src/dartlab/server/api/room.py
@@ -0,0 +1,240 @@
+"""Room 협업 세션 API — SSE fan-out + POST-back."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+
+from fastapi import APIRouter, HTTPException, Request
+from sse_starlette.sse import EventSourceResponse
+
+from ..models import (
+ RoomAskRequest,
+ RoomChatRequest,
+ RoomJoinRequest,
+ RoomNavigateRequest,
+ RoomReactRequest,
+)
+from ..room import Room, RoomMember, room_manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+# ---------------------------------------------------------------------------
+# 헬퍼
+# ---------------------------------------------------------------------------
+
+
+def _get_room() -> Room:
+ room = room_manager.get_room()
+ if room is None:
+ raise HTTPException(status_code=404, detail="협업 세션이 활성화되지 않았습니다.")
+ return room
+
+
+def _get_member(request: Request, room: Room) -> RoomMember:
+ member_id = request.headers.get("x-room-member", "")
+ member = room.get_member(member_id)
+ if member is None:
+ raise HTTPException(status_code=401, detail="룸에 참여하지 않은 사용자입니다.")
+ return member
+
+
+def _require_full(member: RoomMember) -> None:
+ if member.access_level != "full":
+ raise HTTPException(status_code=403, detail="읽기 전용 토큰으로는 이 작업을 수행할 수 없습니다.")
+
+
+# ---------------------------------------------------------------------------
+# 엔드포인트
+# ---------------------------------------------------------------------------
+
+
+@router.post("/api/room/join")
+async def room_join(req: RoomJoinRequest):
+ """룸 참여 — member_id + 현재 상태 반환."""
+ room = _get_room()
+ member = await room.join(req.name)
+ if member is None:
+ raise HTTPException(status_code=409, detail="룸 정원이 초과되었습니다.")
+
+ state = room.get_state()
+ return {
+ "memberId": member.member_id,
+ "roomId": room.room_id,
+ "members": state["members"],
+ "navState": state["navState"],
+ "chatHistory": state["chatHistory"],
+ }
+
+
+@router.post("/api/room/leave")
+async def room_leave(request: Request):
+ """룸 퇴장."""
+ room = _get_room()
+ member = _get_member(request, room)
+ await room.leave(member.member_id)
+ return {"status": "ok"}
+
+
+@router.post("/api/room/heartbeat")
+async def room_heartbeat(request: Request):
+ """프레즌스 유지."""
+ room = _get_room()
+ member_id = request.headers.get("x-room-member", "")
+ if not room.heartbeat(member_id):
+ raise HTTPException(status_code=401, detail="룸에 참여하지 않은 사용자입니다.")
+ return {"status": "ok", "members": len(room.members)}
+
+
+@router.get("/api/room/state")
+async def room_state():
+ """현재 룸 상태."""
+ room = _get_room()
+ return room.get_state()
+
+
+@router.get("/api/room/stream")
+async def room_stream(request: Request):
+ """SSE 스트림 — 브로드캐스트 수신."""
+ room = _get_room()
+ member_id = request.query_params.get("member", "")
+ member = room.get_member(member_id)
+ if member is None:
+ raise HTTPException(status_code=401, detail="룸에 참여하지 않은 사용자입니다.")
+
+ async def _generate():
+ try:
+ while True:
+ try:
+ msg = await asyncio.wait_for(member.queue.get(), timeout=30)
+ yield {
+ "event": msg["event"],
+ "data": json.dumps(msg["data"], ensure_ascii=False),
+ }
+ except TimeoutError:
+ # keepalive — SSE comment
+ yield {"comment": "keepalive"}
+ except asyncio.CancelledError:
+ return
+
+ return EventSourceResponse(_generate(), media_type="text/event-stream")
+
+
+@router.post("/api/room/ask")
+async def room_ask(req: RoomAskRequest, request: Request):
+ """질문 → 전체 브로드캐스트."""
+ room = _get_room()
+ member = _get_member(request, room)
+ _require_full(member)
+
+ if room._analyzing:
+ raise HTTPException(status_code=409, detail="이미 분석이 진행 중입니다.")
+
+ room._analyzing = True
+ try:
+ # 시작 브로드캐스트
+ await room.broadcast(
+ "ask_start",
+ {
+ "memberId": member.member_id,
+ "name": member.name,
+ "question": req.question,
+ "company": req.company,
+ },
+ )
+
+ # 기존 스트리밍 인프라 재사용
+ from ..models import AskRequest
+ from ..resolve import try_resolve_company
+ from ..streaming import stream_ask
+
+ ask_req = AskRequest(
+ company=req.company,
+ question=req.question,
+ stream=True,
+ )
+ resolved = try_resolve_company(ask_req)
+ c = resolved.company
+
+ if c:
+ from ..cache import company_cache
+
+ cached = company_cache.get(c.stockCode)
+ if cached:
+ c = cached[0]
+
+ async for sse_event in stream_ask(c, ask_req):
+ event_name = sse_event.get("event", "chunk")
+ try:
+ data = json.loads(sse_event.get("data", "{}"))
+ except (json.JSONDecodeError, ValueError):
+ data = {"raw": sse_event.get("data", "")}
+ await room.broadcast(event_name, data)
+
+ # 네비게이션 상태 업데이트 (meta 이벤트에서 종목 정보 추출)
+ if event_name == "meta":
+ room.nav_state.update({k: v for k, v in data.items() if k in ("stockCode", "corpName")})
+
+ finally:
+ room._analyzing = False
+
+ return {"status": "ok"}
+
+
+@router.post("/api/room/navigate")
+async def room_navigate(req: RoomNavigateRequest, request: Request):
+ """네비게이션 동기화."""
+ room = _get_room()
+ member = _get_member(request, room)
+ _require_full(member)
+
+ nav_update = {k: v for k, v in req.model_dump().items() if v is not None}
+ room.nav_state.update(nav_update)
+
+ await room.broadcast(
+ "navigate",
+ {
+ "memberId": member.member_id,
+ "name": member.name,
+ **nav_update,
+ },
+ )
+ return {"status": "ok"}
+
+
+@router.post("/api/room/chat")
+async def room_chat(req: RoomChatRequest, request: Request):
+ """채팅 메시지."""
+ room = _get_room()
+ member = _get_member(request, room)
+
+ msg = room.add_chat(member.member_id, req.text)
+ if msg is None:
+ raise HTTPException(status_code=401, detail="룸에 참여하지 않은 사용자입니다.")
+
+ await room.broadcast("chat", msg.to_dict())
+ return {"status": "ok"}
+
+
+@router.post("/api/room/react")
+async def room_react(req: RoomReactRequest, request: Request):
+ """이모지 반응."""
+ room = _get_room()
+ member = _get_member(request, room)
+
+ await room.broadcast(
+ "react",
+ {
+ "memberId": member.member_id,
+ "name": member.name,
+ "emoji": req.emoji,
+ "targetEvent": req.targetEvent,
+ "timestamp": time.time(),
+ },
+ )
+ return {"status": "ok"}
diff --git a/src/dartlab/server/cache.py b/src/dartlab/server/cache.py
new file mode 100644
index 0000000000000000000000000000000000000000..adab2fab633d4076ff421b41040a5bd374de5d13
--- /dev/null
+++ b/src/dartlab/server/cache.py
@@ -0,0 +1,107 @@
+"""세션 레벨 Company + snapshot 캐시.
+
+동일 종목 반복 질문 시 Company 객체 재생성/데이터 재로드를 스킵한다.
+LRU 방식, 최대 MAX_SIZE 종목 유지.
+적응형 TTL: 자주 접근되는 종목은 TTL 연장, 메모리 압박 시 캐시 축소.
+"""
+
+from __future__ import annotations
+
+import time
+from collections import OrderedDict
+
+from dartlab import Company
+
+MAX_SIZE = 5
+BASE_TTL = 600
+MAX_TTL = 3000
+_MEMORY_THRESHOLD_MB = 1500
+
+
+class _CacheEntry:
+ __slots__ = ("company", "snapshot", "created_at", "access_count", "ttl")
+
+ def __init__(self, company: Company, snapshot: dict | None):
+ self.company = company
+ self.snapshot = snapshot
+ self.created_at = time.monotonic()
+ self.access_count = 1
+ self.ttl = BASE_TTL
+
+ def touch(self) -> None:
+ """접근 횟수 증가 및 TTL 연장."""
+ self.access_count += 1
+ self.ttl = min(BASE_TTL + self.access_count * 300, MAX_TTL)
+
+ def is_expired(self) -> bool:
+ """TTL 초과 여부를 반환한다."""
+ return (time.monotonic() - self.created_at) > self.ttl
+
+
+class CompanyCache:
+ """스레드 안전은 불필요 (uvicorn single-worker, asyncio.to_thread 직렬)."""
+
+ def __init__(self):
+ self._store: OrderedDict[str, _CacheEntry] = OrderedDict()
+ self._max_size = MAX_SIZE
+
+ def _check_memory_pressure(self) -> None:
+ """메모리 압박 시 캐시 크기 자동 축소."""
+ try:
+ from dartlab.core.memory import get_memory_mb
+
+ mem = get_memory_mb()
+ if mem <= 0:
+ return
+ if mem > _MEMORY_THRESHOLD_MB * 1.5:
+ self._max_size = 1
+ elif mem > _MEMORY_THRESHOLD_MB:
+ self._max_size = 3
+ else:
+ self._max_size = MAX_SIZE
+ except ImportError:
+ pass
+
+ def get(self, stock_code: str) -> tuple[Company, dict | None] | None:
+ """캐시에서 Company와 snapshot을 조회한다."""
+ entry = self._store.get(stock_code)
+ if entry is None:
+ return None
+ if entry.is_expired():
+ self._store.pop(stock_code, None)
+ return None
+ entry.touch()
+ self._store.move_to_end(stock_code)
+ return entry.company, entry.snapshot
+
+ def put(self, stock_code: str, company: Company, snapshot: dict | None) -> None:
+ """Company와 snapshot을 캐시에 저장한다."""
+ self._check_memory_pressure()
+ if stock_code in self._store:
+ old = self._store[stock_code]
+ new_entry = _CacheEntry(company, snapshot)
+ new_entry.access_count = old.access_count + 1
+ new_entry.ttl = min(BASE_TTL + new_entry.access_count * 300, MAX_TTL)
+ self._store.move_to_end(stock_code)
+ self._store[stock_code] = new_entry
+ else:
+ self._store[stock_code] = _CacheEntry(company, snapshot)
+ while len(self._store) > self._max_size:
+ self._store.popitem(last=False)
+
+ def update_snapshot(self, stock_code: str, snapshot: dict | None) -> None:
+ """기존 캐시 항목의 snapshot만 갱신한다."""
+ entry = self._store.get(stock_code)
+ if entry:
+ entry.snapshot = snapshot
+
+ def clear(self) -> None:
+ """캐시 전체를 비우고 크기 제한을 초기화한다."""
+ self._store.clear()
+ self._max_size = MAX_SIZE
+
+ def __len__(self) -> int:
+ return len(self._store)
+
+
+company_cache = CompanyCache()
diff --git a/src/dartlab/server/chat.py b/src/dartlab/server/chat.py
new file mode 100644
index 0000000000000000000000000000000000000000..da19b10fca2ec2f2b67d762586ad1c09925f9062
--- /dev/null
+++ b/src/dartlab/server/chat.py
@@ -0,0 +1,53 @@
+"""서버 대화 헬퍼."""
+
+from __future__ import annotations
+
+from .models import HistoryMessage
+
+OLLAMA_MODEL_GUIDE: list[dict[str, str]] = [
+ {
+ "name": "qwen3",
+ "size": "8B",
+ "vram": "~6GB",
+ "quality": "높음",
+ "speed": "보통",
+ "note": "한국어 재무분석에 가장 추천",
+ },
+ {"name": "gemma2", "size": "9B", "vram": "~7GB", "quality": "높음", "speed": "보통", "note": "다국어 성능 우수"},
+ {"name": "llama3.2", "size": "3B", "vram": "~3GB", "quality": "보통", "speed": "빠름", "note": "저사양 PC 추천"},
+ {"name": "mistral", "size": "7B", "vram": "~5GB", "quality": "보통", "speed": "빠름", "note": "영문 질문에 강함"},
+ {"name": "phi4", "size": "14B", "vram": "~10GB", "quality": "매우높음", "speed": "느림", "note": "GPU 12GB+ 추천"},
+ {
+ "name": "qwen3:14b",
+ "size": "14B",
+ "vram": "~10GB",
+ "quality": "매우높음",
+ "speed": "느림",
+ "note": "최고 품질, 고사양 PC",
+ },
+]
+
+
+def extract_last_stock_code(history: list[HistoryMessage] | None) -> str | None:
+ """히스토리에서 가장 최근 분석된 종목코드를 추출."""
+ if not history:
+ return None
+ for h in reversed(history):
+ if h.meta and h.meta.stockCode:
+ return h.meta.stockCode
+ return None
+
+
+def build_topic_summary_question(topic: str) -> str:
+ """Topic summary를 core.analyze()에 요청할 때 쓰는 canonical 질문."""
+ return (
+ f"현재 보고 있는 '{topic}' 섹션만 기준으로 핵심을 3~5문장으로 요약해줘. "
+ "수치가 있으면 포함하고, 기간 변화가 있으면 짚어주고, 마지막에 한 줄 판단을 붙여줘."
+ )
+
+
+__all__ = [
+ "OLLAMA_MODEL_GUIDE",
+ "build_topic_summary_question",
+ "extract_last_stock_code",
+]
diff --git a/src/dartlab/server/dialogue.py b/src/dartlab/server/dialogue.py
new file mode 100644
index 0000000000000000000000000000000000000000..a7622f04457b74d2886cacff22815743389b8353
--- /dev/null
+++ b/src/dartlab/server/dialogue.py
@@ -0,0 +1,7 @@
+"""하위호환 stub — 원본 dartlab.ai.conversation.dialogue 모듈 제거됨.
+
+이 모듈을 import하는 코드가 있을 경우 ImportError 방지용.
+실제 기능은 제거되었으므로 사용하지 않는다.
+"""
+
+from __future__ import annotations
diff --git a/src/dartlab/server/embed.py b/src/dartlab/server/embed.py
new file mode 100644
index 0000000000000000000000000000000000000000..3063b713995c4ef15cc1c5d4d2dd100c85cabf4f
--- /dev/null
+++ b/src/dartlab/server/embed.py
@@ -0,0 +1,34 @@
+"""DartLab Embed — embed.js 서빙.
+
+외부 사이트에서
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+