mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-07-28 02:57:44 +00:00
d337c63976
Architecture (3,909-line monolith → 28 focused modules, all < 300 lines):
- config/: constants.py (company lists, row maps, Damodaran baselines), theme.py (CSS/HTML)
- utils/: prefs, formatting, ticker, dcf, charts, ui_helpers
- data/: sec_parser, sec_fetcher, sec_downloader, financials, fundamentals,
valuation, ratios, scores, scores_ai, market
- ai/: gemini_core, gemini_sec, gemini_insights
- views/: sidebar, tab1_quant, tab1_ai, tab1_filings, tab2_dcf,
tab3_comps, tab4_news, tab5_markets, tab6_crypto, tab7_technical
- app.py: thin orchestrator (~118 lines)
- Strict unidirectional dependency graph (no circular imports)
- All @st.cache_data TTLs and st.session_state keys preserved identically
SEC filing viewer fix:
- Rebuilt EDGAR fetch chain: company_tickers.json → CIK → submissions API
→ filings.recent.primaryDocument[] (replaces deprecated directory.item)
- Filing type selectbox (10-K, 10-Q, 8-K, 20-F, 6-K) connected to backend
- Native HTML rendered via streamlit.components.v1.html() with CSS reset
- Errors surfaced explicitly with st.error()
- DART direct links restored for Korean-listed companies
.gitignore: data/ → data/*.json + data/*.html (preserve Python modules)
README: full rewrite for master's portfolio — 7-tab layout, architecture
diagram, modular structure tree, technical challenges, design rationale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
151 lines
5.7 KiB
Python
151 lines
5.7 KiB
Python
"""
|
|
SEC 10-K text extraction: HTML parsing, item section extraction, text cleaning.
|
|
"""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
from config.constants import (
|
|
ITEM1A_PATTERNS, ITEM3_PATTERNS, ITEM7_PATTERNS, ITEM8_PATTERNS, ITEM9A_PATTERNS,
|
|
)
|
|
|
|
|
|
def _slice_html_items_1a_to_9a(raw_html: str) -> str:
|
|
"""Fast string slice: keep only Item 1A through end of Item 9A to avoid parsing 50MB+ full file. Uses .find()/regex on raw string only."""
|
|
if not raw_html or len(raw_html) < 5000:
|
|
return raw_html
|
|
start = -1
|
|
for needle in ("Item 1A", "ITEM 1A", "Item 1a"):
|
|
i = raw_html.find(needle)
|
|
if i != -1 and (start == -1 or i < start):
|
|
start = i
|
|
if start == -1:
|
|
m = re.search(r"Item\s+1A\s", raw_html, re.IGNORECASE)
|
|
start = m.start() if m else 0
|
|
else:
|
|
start = max(0, start - 200)
|
|
search_region = raw_html[start:]
|
|
end_match = re.search(r"Item\s+10\s|Item\s+12\s|Part\s+III\b|PART\s+III\b", search_region, re.IGNORECASE)
|
|
end = start + end_match.start() if end_match else len(raw_html)
|
|
end = min(end, start + 8_000_000)
|
|
return raw_html[start:end]
|
|
|
|
|
|
def _extract_text_from_html_string(html_str: str) -> str:
|
|
"""Parse HTML string with lxml; drop table/img/svg/style/script immediately to reduce memory and speed."""
|
|
if not html_str or not html_str.strip():
|
|
return ""
|
|
try:
|
|
soup = BeautifulSoup(html_str, "lxml")
|
|
except Exception:
|
|
soup = BeautifulSoup(html_str, "html.parser")
|
|
for tag in soup.find_all(["table", "img", "svg", "style", "script"]):
|
|
tag.decompose()
|
|
return soup.get_text(separator="\n", strip=True)
|
|
|
|
|
|
def extract_text_from_html(html_path: Path) -> str:
|
|
try:
|
|
with open(html_path, "r", encoding="utf-8", errors="replace") as f:
|
|
raw = f.read()
|
|
except Exception:
|
|
with open(html_path, "r", encoding="latin-1", errors="replace") as f:
|
|
raw = f.read()
|
|
chunk = _slice_html_items_1a_to_9a(raw)
|
|
return _extract_text_from_html_string(chunk)
|
|
|
|
|
|
def extract_text_from_file(file_path: Path) -> str:
|
|
suf = file_path.suffix.lower()
|
|
if suf in (".htm", ".html"):
|
|
return extract_text_from_html(file_path)
|
|
if suf == ".txt":
|
|
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
text = f.read()
|
|
text = re.sub(r"<[^>]+>", " ", text)
|
|
text = re.sub(r"\s+", " ", text)
|
|
return text
|
|
return ""
|
|
|
|
|
|
def _find_section_start(text: str, patterns: list, item_num: int) -> int:
|
|
for pat in patterns:
|
|
m = re.search(pat, text, re.IGNORECASE)
|
|
if m:
|
|
return m.start()
|
|
m = re.search(r"\bItem\s+" + str(item_num) + r"\b", text, re.IGNORECASE)
|
|
return m.start() if m else -1
|
|
|
|
|
|
def find_item_section_generic(text: str, patterns: list, item_num: int, title_keywords: list, max_chars: int = 120000) -> str:
|
|
start = _find_section_start(text, patterns, item_num)
|
|
if start == -1:
|
|
pattern = re.compile(
|
|
r"\bItem\s+" + str(item_num) + r"\b[.\s]*[^\n]*(" + "|".join(re.escape(k) for k in title_keywords) + r")?",
|
|
re.IGNORECASE,
|
|
)
|
|
match = pattern.search(text)
|
|
if not match:
|
|
return ""
|
|
start = match.start()
|
|
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
|
|
if next_item:
|
|
end = start + 100 + next_item.start()
|
|
else:
|
|
end = min(start + max_chars, len(text))
|
|
return text[start:end].strip()
|
|
|
|
|
|
def clean_text_for_llm(html_content: str) -> str:
|
|
"""Aggressive cleaning for LLM: strip tables/code, collapse whitespace, drop non-ASCII. Uses lxml for speed; drops table/img/svg/style/script."""
|
|
if not html_content or not html_content.strip():
|
|
return ""
|
|
try:
|
|
soup = BeautifulSoup(html_content, "lxml")
|
|
for tag in soup.find_all(["table", "img", "style", "script", "svg", "math"]):
|
|
tag.decompose()
|
|
text = soup.get_text(separator=" ")
|
|
except Exception:
|
|
text = re.sub(r"<[^>]+>", " ", html_content)
|
|
text = re.sub(r"\s+", " ", text)
|
|
text = " ".join(text.split())
|
|
text = re.sub(r"[^\x20-\x7E\n]", " ", text)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
lines = []
|
|
for line in text.split("\n"):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line):
|
|
continue
|
|
if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20:
|
|
continue
|
|
lines.append(line)
|
|
result = " ".join(lines)
|
|
result = re.sub(r"\s+", " ", result).strip()
|
|
return result
|
|
|
|
|
|
def smart_chunk(section: str, max_chars: int = 10000, head_ratio: float = 0.5) -> str:
|
|
"""Limit payload for Gemini; 10k chars ~ 2.5k tokens for fast response."""
|
|
if not section or len(section) <= max_chars:
|
|
return section
|
|
head_size = int(max_chars * head_ratio)
|
|
tail_size = max_chars - head_size - 100
|
|
return section[:head_size] + " [ ... middle omitted ... ] " + section[-tail_size:]
|
|
|
|
|
|
def _extract_item_from_full(text: str, patterns: list, item_num: int, keywords: list, max_chars: int = 60000) -> str:
|
|
"""Extract one item section from full 10-K text."""
|
|
start = _find_section_start(text, patterns, item_num)
|
|
if start < 0:
|
|
pattern = re.compile(r"\bItem\s+" + str(item_num) + r"[A-Z]?\b[.\s]*[^\n]*", re.IGNORECASE)
|
|
match = pattern.search(text)
|
|
start = match.start() if match else -1
|
|
if start < 0:
|
|
return ""
|
|
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
|
|
end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text))
|
|
return text[start:end].strip()
|