Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- builders/build_docx.py +12 -0
- builders/build_excel.py +134 -0
- builders/build_pptx.py +7 -7
- pipeline/analyst.py +11 -3
- pipeline/fetcher.py +70 -2
builders/build_docx.py
CHANGED
|
@@ -158,6 +158,18 @@ def _swot_section(doc, data):
|
|
| 158 |
("Opportunities", "opportunities", "DDEBF7", NAVY),
|
| 159 |
("Threats", "threats", "FFEB9C", RGBColor(0x7F,0x60,0x00)),
|
| 160 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
t = doc.add_table(rows=2, cols=2)
|
| 162 |
t.alignment = WD_TABLE_ALIGNMENT.LEFT
|
| 163 |
idx = 0
|
|
|
|
| 158 |
("Opportunities", "opportunities", "DDEBF7", NAVY),
|
| 159 |
("Threats", "threats", "FFEB9C", RGBColor(0x7F,0x60,0x00)),
|
| 160 |
]
|
| 161 |
+
|
| 162 |
+
parsed_any = any(len(swot.get(k, [])) > 0 for _, k, _, _ in configs)
|
| 163 |
+
|
| 164 |
+
if not parsed_any and swot.get("raw"):
|
| 165 |
+
for line in swot["raw"].split("\n"):
|
| 166 |
+
if line.strip():
|
| 167 |
+
p = doc.add_paragraph()
|
| 168 |
+
r = p.add_run(line.strip())
|
| 169 |
+
r.font.size = Pt(11); r.font.name = "Calibri"
|
| 170 |
+
doc.add_paragraph()
|
| 171 |
+
return
|
| 172 |
+
|
| 173 |
t = doc.add_table(rows=2, cols=2)
|
| 174 |
t.alignment = WD_TABLE_ALIGNMENT.LEFT
|
| 175 |
idx = 0
|
builders/build_excel.py
CHANGED
|
@@ -394,6 +394,138 @@ def _swot_sheet(wb, data):
|
|
| 394 |
_widths(ws, [90])
|
| 395 |
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
def _sources_sheet(wb, data):
|
| 398 |
ws = wb.create_sheet("Sources")
|
| 399 |
_title(ws, "Sources", "All data sources and retrieval dates")
|
|
@@ -426,9 +558,11 @@ def build_workbook(data: dict, output_path: str) -> None:
|
|
| 426 |
_cashflow_sheet(wb, data)
|
| 427 |
_ratios_sheet(wb, data)
|
| 428 |
_dupont_sheet(wb, data)
|
|
|
|
| 429 |
_growth_sheet(wb, data)
|
| 430 |
_peers_sheet(wb, data)
|
| 431 |
_swot_sheet(wb, data)
|
|
|
|
| 432 |
_sources_sheet(wb, data)
|
| 433 |
wb.save(output_path)
|
| 434 |
|
|
|
|
| 394 |
_widths(ws, [90])
|
| 395 |
|
| 396 |
|
| 397 |
+
def _dcf_sheet(wb, data):
|
| 398 |
+
ws = wb.create_sheet("DCF Valuation")
|
| 399 |
+
_title(ws, "Discounted Cash Flow (DCF) Model", "Interactive Valuation Model")
|
| 400 |
+
|
| 401 |
+
co = data.get("company", {})
|
| 402 |
+
cf = data.get("cash_flow", {})
|
| 403 |
+
inc = data.get("income_statement", {})
|
| 404 |
+
bal = data.get("balance_sheet", {})
|
| 405 |
+
|
| 406 |
+
# Get latest data
|
| 407 |
+
fcf_history = [v for v in cf.get("free_cash_flow", []) if v is not None]
|
| 408 |
+
latest_fcf = fcf_history[-1] if fcf_history else 0
|
| 409 |
+
|
| 410 |
+
shares_history = [v for v in inc.get("diluted_shares", []) if v is not None]
|
| 411 |
+
latest_shares = shares_history[-1] if shares_history else 1
|
| 412 |
+
|
| 413 |
+
cash_history = [v for v in bal.get("cash_and_equivalents", []) if v is not None]
|
| 414 |
+
cash = cash_history[-1] if cash_history else 0
|
| 415 |
+
|
| 416 |
+
debt_history = [v for v in bal.get("total_liabilities", []) if v is not None]
|
| 417 |
+
debt = debt_history[-1] if debt_history else 0
|
| 418 |
+
|
| 419 |
+
# Inputs
|
| 420 |
+
r = 4
|
| 421 |
+
_sec(ws, r, "Model Inputs (Editable - Yellow Cells)", 2); r += 1
|
| 422 |
+
|
| 423 |
+
ws.cell(row=r, column=1, value="Starting FCF (Base)").font = LBL_FONT
|
| 424 |
+
ws.cell(row=r, column=2, value=latest_fcf).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
|
| 425 |
+
|
| 426 |
+
ws.cell(row=r, column=1, value="WACC (Discount Rate)").font = LBL_FONT
|
| 427 |
+
ws.cell(row=r, column=2, value=0.10).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_PCT; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
|
| 428 |
+
|
| 429 |
+
ws.cell(row=r, column=1, value="Terminal Growth Rate").font = LBL_FONT
|
| 430 |
+
ws.cell(row=r, column=2, value=0.025).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_PCT; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
|
| 431 |
+
|
| 432 |
+
ws.cell(row=r, column=1, value="Cash & Equivalents").font = LBL_FONT
|
| 433 |
+
ws.cell(row=r, column=2, value=cash).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
|
| 434 |
+
|
| 435 |
+
ws.cell(row=r, column=1, value="Total Debt").font = LBL_FONT
|
| 436 |
+
ws.cell(row=r, column=2, value=debt).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
|
| 437 |
+
|
| 438 |
+
ws.cell(row=r, column=1, value="Diluted Shares").font = LBL_FONT
|
| 439 |
+
ws.cell(row=r, column=2, value=latest_shares).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
|
| 440 |
+
|
| 441 |
+
r += 1
|
| 442 |
+
_sec(ws, r, "Projected Cash Flows (5 Years)", 6); r += 1
|
| 443 |
+
_hdr(ws, r, ["Year 1", "Year 2", "Year 3", "Year 4", "Year 5"], start=2); r += 1
|
| 444 |
+
|
| 445 |
+
# Growth rates
|
| 446 |
+
ws.cell(row=r, column=1, value="Projected Growth Rate").font = LBL_FONT
|
| 447 |
+
for c in range(2, 7):
|
| 448 |
+
ws.cell(row=r, column=c, value=0.05).font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_PCT; ws.cell(row=r, column=c).fill = PatternFill("solid", fgColor="FFFFE0")
|
| 449 |
+
r += 1
|
| 450 |
+
|
| 451 |
+
# FCF Projection Formulas
|
| 452 |
+
ws.cell(row=r, column=1, value="Projected FCF").font = LBL_FONT
|
| 453 |
+
ws.cell(row=r, column=2, value="=B5*(1+B14)") # Year 1 = Base * (1+g1)
|
| 454 |
+
ws.cell(row=r, column=3, value="=B15*(1+C14)")
|
| 455 |
+
ws.cell(row=r, column=4, value="=C15*(1+D14)")
|
| 456 |
+
ws.cell(row=r, column=5, value="=D15*(1+E14)")
|
| 457 |
+
ws.cell(row=r, column=6, value="=E15*(1+F14)")
|
| 458 |
+
for c in range(2, 7):
|
| 459 |
+
ws.cell(row=r, column=c).font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_MONEY
|
| 460 |
+
r += 1
|
| 461 |
+
|
| 462 |
+
# Discount Factor Formulas
|
| 463 |
+
ws.cell(row=r, column=1, value="Discount Factor").font = LBL_FONT
|
| 464 |
+
ws.cell(row=r, column=2, value="=1/((1+$B$6)^1)")
|
| 465 |
+
ws.cell(row=r, column=3, value="=1/((1+$B$6)^2)")
|
| 466 |
+
ws.cell(row=r, column=4, value="=1/((1+$B$6)^3)")
|
| 467 |
+
ws.cell(row=r, column=5, value="=1/((1+$B$6)^4)")
|
| 468 |
+
ws.cell(row=r, column=6, value="=1/((1+$B$6)^5)")
|
| 469 |
+
for c in range(2, 7):
|
| 470 |
+
ws.cell(row=r, column=c).font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_RATIO
|
| 471 |
+
r += 1
|
| 472 |
+
|
| 473 |
+
# PV of FCF Formulas
|
| 474 |
+
ws.cell(row=r, column=1, value="PV of FCF").font = LBL_FONT
|
| 475 |
+
for c, col_l in enumerate(["B", "C", "D", "E", "F"], start=2):
|
| 476 |
+
ws.cell(row=r, column=c, value=f"={col_l}15*{col_l}16").font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_MONEY
|
| 477 |
+
r += 2
|
| 478 |
+
|
| 479 |
+
# Valuation Output
|
| 480 |
+
_sec(ws, r, "Valuation Output", 2); r += 1
|
| 481 |
+
|
| 482 |
+
ws.cell(row=r, column=1, value="Sum of PV of FCF").font = LBL_FONT
|
| 483 |
+
ws.cell(row=r, column=2, value="=SUM(B17:F17)").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
|
| 484 |
+
|
| 485 |
+
ws.cell(row=r, column=1, value="Terminal Value").font = LBL_FONT
|
| 486 |
+
ws.cell(row=r, column=2, value="=(F15*(1+B7))/(B6-B7)").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
|
| 487 |
+
|
| 488 |
+
ws.cell(row=r, column=1, value="PV of Terminal Value").font = LBL_FONT
|
| 489 |
+
ws.cell(row=r, column=2, value="=B21*F16").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
|
| 490 |
+
|
| 491 |
+
ws.cell(row=r, column=1, value="Implied Enterprise Value").font = LBL_FONT
|
| 492 |
+
ws.cell(row=r, column=2, value="=B20+B22").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
|
| 493 |
+
|
| 494 |
+
ws.cell(row=r, column=1, value="Implied Equity Value").font = LBL_FONT
|
| 495 |
+
ws.cell(row=r, column=2, value="=B23+B8-B9").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
|
| 496 |
+
|
| 497 |
+
ws.cell(row=r, column=1, value="Implied Share Price").font = Font(name="Calibri", size=12, bold=True, color="1F4E78")
|
| 498 |
+
ws.cell(row=r, column=2, value="=B24/B10").font = Font(name="Calibri", size=12, bold=True); ws.cell(row=r, column=2).number_format = FMT_DEC; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="DDEBF7")
|
| 499 |
+
|
| 500 |
+
_widths(ws, [30, 20, 20, 20, 20, 20])
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def _narrative_sheet(wb, data):
|
| 504 |
+
ws = wb.create_sheet("Industry & AI Insights")
|
| 505 |
+
_title(ws, "AI Narrative Analysis", "Generated by AI Models")
|
| 506 |
+
r = 4
|
| 507 |
+
|
| 508 |
+
exec_summary = data.get("executive_summary", "")
|
| 509 |
+
if exec_summary:
|
| 510 |
+
_sec(ws, r, "Executive Summary", 2); r += 1
|
| 511 |
+
c = ws.cell(row=r, column=1, value=exec_summary)
|
| 512 |
+
c.font = LBL_FONT; c.alignment = Alignment(wrap_text=True, vertical="top")
|
| 513 |
+
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=4)
|
| 514 |
+
ws.row_dimensions[r].height = 100
|
| 515 |
+
r += 2
|
| 516 |
+
|
| 517 |
+
industry_trends = data.get("industry_trends", "")
|
| 518 |
+
if industry_trends:
|
| 519 |
+
_sec(ws, r, "Industry Trends", 2); r += 1
|
| 520 |
+
c = ws.cell(row=r, column=1, value=industry_trends)
|
| 521 |
+
c.font = LBL_FONT; c.alignment = Alignment(wrap_text=True, vertical="top")
|
| 522 |
+
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=4)
|
| 523 |
+
ws.row_dimensions[r].height = 120
|
| 524 |
+
r += 2
|
| 525 |
+
|
| 526 |
+
_widths(ws, [100])
|
| 527 |
+
|
| 528 |
+
|
| 529 |
def _sources_sheet(wb, data):
|
| 530 |
ws = wb.create_sheet("Sources")
|
| 531 |
_title(ws, "Sources", "All data sources and retrieval dates")
|
|
|
|
| 558 |
_cashflow_sheet(wb, data)
|
| 559 |
_ratios_sheet(wb, data)
|
| 560 |
_dupont_sheet(wb, data)
|
| 561 |
+
_dcf_sheet(wb, data)
|
| 562 |
_growth_sheet(wb, data)
|
| 563 |
_peers_sheet(wb, data)
|
| 564 |
_swot_sheet(wb, data)
|
| 565 |
+
_narrative_sheet(wb, data)
|
| 566 |
_sources_sheet(wb, data)
|
| 567 |
wb.save(output_path)
|
| 568 |
|
builders/build_pptx.py
CHANGED
|
@@ -226,17 +226,17 @@ def build_presentation(data: Dict, output_path: str):
|
|
| 226 |
_style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE)
|
| 227 |
|
| 228 |
metrics = [
|
| 229 |
-
("Revenue", "income_statement", 1.0),
|
| 230 |
-
("Gross Profit", "income_statement", 1.0),
|
| 231 |
-
("Operating Income (EBIT)", "income_statement", 1.0),
|
| 232 |
-
("Net Income", "income_statement", 1.0),
|
| 233 |
-
("Operating Cash Flow (OCF)", "cash_flow", 1.0)
|
| 234 |
]
|
| 235 |
|
| 236 |
currency = data.get("company", {}).get("currency", "USD")
|
| 237 |
units = data.get("company", {}).get("units", "millions")
|
| 238 |
|
| 239 |
-
for r_idx, (m_label, sheet_name, scale) in enumerate(metrics):
|
| 240 |
curr_row = r_idx + 1
|
| 241 |
bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
|
| 242 |
|
|
@@ -245,7 +245,7 @@ def build_presentation(data: Dict, output_path: str):
|
|
| 245 |
|
| 246 |
# Values cells
|
| 247 |
sheet_data = data.get(sheet_name, {})
|
| 248 |
-
val_list = sheet_data.get(
|
| 249 |
for c_idx, y in enumerate(years):
|
| 250 |
val_str = "N/A"
|
| 251 |
if c_idx < len(val_list) and val_list[c_idx] is not None:
|
|
|
|
| 226 |
_style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE)
|
| 227 |
|
| 228 |
metrics = [
|
| 229 |
+
("Revenue", "revenue", "income_statement", 1.0),
|
| 230 |
+
("Gross Profit", "gross_profit", "income_statement", 1.0),
|
| 231 |
+
("Operating Income (EBIT)", "operating_income", "income_statement", 1.0),
|
| 232 |
+
("Net Income", "net_income", "income_statement", 1.0),
|
| 233 |
+
("Operating Cash Flow (OCF)", "cash_from_operations", "cash_flow", 1.0)
|
| 234 |
]
|
| 235 |
|
| 236 |
currency = data.get("company", {}).get("currency", "USD")
|
| 237 |
units = data.get("company", {}).get("units", "millions")
|
| 238 |
|
| 239 |
+
for r_idx, (m_label, dict_key, sheet_name, scale) in enumerate(metrics):
|
| 240 |
curr_row = r_idx + 1
|
| 241 |
bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
|
| 242 |
|
|
|
|
| 245 |
|
| 246 |
# Values cells
|
| 247 |
sheet_data = data.get(sheet_name, {})
|
| 248 |
+
val_list = sheet_data.get(dict_key, [])
|
| 249 |
for c_idx, y in enumerate(years):
|
| 250 |
val_str = "N/A"
|
| 251 |
if c_idx < len(val_list) and val_list[c_idx] is not None:
|
pipeline/analyst.py
CHANGED
|
@@ -183,6 +183,12 @@ def _build_context(data: Dict, meta: Dict) -> str:
|
|
| 183 |
f"ROE={_pct(p.get('roe'))} | "
|
| 184 |
f"D/E={_num(p.get('debt_to_equity'))}"
|
| 185 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
return "\n".join(lines)
|
| 188 |
|
|
@@ -512,9 +518,9 @@ def _hf_generate(context: str, section: str, log_fn=None) -> Optional[str]:
|
|
| 512 |
|
| 513 |
# ββ Parse structured sections βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 514 |
|
| 515 |
-
def _parse_swot(text: str) -> Dict
|
| 516 |
"""Parse SWOT text into a structured dict."""
|
| 517 |
-
result = {"strengths": [], "weaknesses": [], "opportunities": [], "threats": []}
|
| 518 |
current = None
|
| 519 |
for line in text.split("\n"):
|
| 520 |
l = line.strip()
|
|
@@ -527,7 +533,9 @@ def _parse_swot(text: str) -> Dict[str, List[str]]:
|
|
| 527 |
elif l.upper().startswith("THREAT"):
|
| 528 |
current = "threats"
|
| 529 |
elif current and l and not l.upper() in ("STRENGTHS:", "WEAKNESSES:", "OPPORTUNITIES:", "THREATS:"):
|
| 530 |
-
|
|
|
|
|
|
|
| 531 |
return result
|
| 532 |
|
| 533 |
|
|
|
|
| 183 |
f"ROE={_pct(p.get('roe'))} | "
|
| 184 |
f"D/E={_num(p.get('debt_to_equity'))}"
|
| 185 |
)
|
| 186 |
+
|
| 187 |
+
# Optional Annual Report Text (MD&A)
|
| 188 |
+
ar_text = data.get("annual_report_context", "")
|
| 189 |
+
if ar_text:
|
| 190 |
+
lines += ["", "=== ANNUAL REPORT EXCERPT (10-K MD&A) ==="]
|
| 191 |
+
lines.append(ar_text[:15000]) # Cap at 15k chars to save tokens
|
| 192 |
|
| 193 |
return "\n".join(lines)
|
| 194 |
|
|
|
|
| 518 |
|
| 519 |
# ββ Parse structured sections βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 520 |
|
| 521 |
+
def _parse_swot(text: str) -> Dict:
|
| 522 |
"""Parse SWOT text into a structured dict."""
|
| 523 |
+
result = {"strengths": [], "weaknesses": [], "opportunities": [], "threats": [], "raw": text}
|
| 524 |
current = None
|
| 525 |
for line in text.split("\n"):
|
| 526 |
l = line.strip()
|
|
|
|
| 533 |
elif l.upper().startswith("THREAT"):
|
| 534 |
current = "threats"
|
| 535 |
elif current and l and not l.upper() in ("STRENGTHS:", "WEAKNESSES:", "OPPORTUNITIES:", "THREATS:"):
|
| 536 |
+
clean = l.lstrip("-β’* ")
|
| 537 |
+
if clean:
|
| 538 |
+
result[current].append(clean)
|
| 539 |
return result
|
| 540 |
|
| 541 |
|
pipeline/fetcher.py
CHANGED
|
@@ -596,7 +596,6 @@ def fetch_peer_snapshot(ticker: str) -> Optional[Dict]:
|
|
| 596 |
except Exception:
|
| 597 |
return None
|
| 598 |
|
| 599 |
-
|
| 600 |
def fetch_valuation(ticker: str) -> Dict:
|
| 601 |
try:
|
| 602 |
import yfinance as yf
|
|
@@ -613,8 +612,70 @@ def fetch_valuation(ticker: str) -> Dict:
|
|
| 613 |
except Exception:
|
| 614 |
return {}
|
| 615 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
|
| 617 |
-
# ββ Main
|
| 618 |
|
| 619 |
def fetch_all(meta: CompanyMeta, years: int = DEFAULT_YEARS,
|
| 620 |
force_refresh: bool = False, log_fn=None) -> Dict:
|
|
@@ -684,6 +745,13 @@ def fetch_all(meta: CompanyMeta, years: int = DEFAULT_YEARS,
|
|
| 684 |
return {"warnings": [msg], "years": [], "income_statement": {},
|
| 685 |
"balance_sheet": {}, "cash_flow": {}}
|
| 686 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
result["warnings"] = warnings
|
| 688 |
result.setdefault("currency", meta.currency)
|
| 689 |
set_cache(ticker, cache_key, result)
|
|
|
|
| 596 |
except Exception:
|
| 597 |
return None
|
| 598 |
|
|
|
|
| 599 |
def fetch_valuation(ticker: str) -> Dict:
|
| 600 |
try:
|
| 601 |
import yfinance as yf
|
|
|
|
| 612 |
except Exception:
|
| 613 |
return {}
|
| 614 |
|
| 615 |
+
def _scrape_edgar_10k(ticker: str, log_fn=None) -> str:
|
| 616 |
+
"""Fetch the latest 10-K for a US company from SEC EDGAR and extract a chunk of text."""
|
| 617 |
+
headers = {"User-Agent": "FinancialAnalyzer/1.0 (contact@example.com)"}
|
| 618 |
+
try:
|
| 619 |
+
if log_fn: log_fn(f" β Looking up SEC CIK for {ticker}...")
|
| 620 |
+
r = requests.get("https://www.sec.gov/files/company_tickers.json", headers=headers, timeout=10)
|
| 621 |
+
tickers = r.json()
|
| 622 |
+
cik = None
|
| 623 |
+
for k, v in tickers.items():
|
| 624 |
+
if v["ticker"].upper() == ticker.upper():
|
| 625 |
+
cik = str(v["cik_str"]).zfill(10)
|
| 626 |
+
break
|
| 627 |
+
|
| 628 |
+
if not cik:
|
| 629 |
+
if log_fn: log_fn(" β CIK not found in SEC database.")
|
| 630 |
+
return ""
|
| 631 |
+
|
| 632 |
+
if log_fn: log_fn(f" β Found CIK {cik}. Fetching recent filings...")
|
| 633 |
+
sub_r = requests.get(f"https://data.sec.gov/submissions/CIK{cik}.json", headers=headers, timeout=10)
|
| 634 |
+
data = sub_r.json()
|
| 635 |
+
filings = data.get("filings", {}).get("recent", {})
|
| 636 |
+
|
| 637 |
+
form_types = filings.get("form", [])
|
| 638 |
+
accessions = filings.get("accessionNumber", [])
|
| 639 |
+
primary_docs = filings.get("primaryDocument", [])
|
| 640 |
+
|
| 641 |
+
idx = -1
|
| 642 |
+
for i, form in enumerate(form_types):
|
| 643 |
+
if form == "10-K":
|
| 644 |
+
idx = i
|
| 645 |
+
break
|
| 646 |
+
|
| 647 |
+
if idx == -1:
|
| 648 |
+
if log_fn: log_fn(" β No 10-K found in recent filings.")
|
| 649 |
+
return ""
|
| 650 |
+
|
| 651 |
+
acc_num = accessions[idx]
|
| 652 |
+
acc_num_no_dash = acc_num.replace("-", "")
|
| 653 |
+
doc = primary_docs[idx]
|
| 654 |
+
|
| 655 |
+
url = f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{acc_num_no_dash}/{doc}"
|
| 656 |
+
if log_fn: log_fn(f" β Downloading 10-K from {url}...")
|
| 657 |
+
|
| 658 |
+
doc_r = requests.get(url, headers=headers, timeout=20)
|
| 659 |
+
from bs4 import BeautifulSoup
|
| 660 |
+
soup = BeautifulSoup(doc_r.content, "lxml")
|
| 661 |
+
|
| 662 |
+
text = soup.get_text(separator="\n", strip=True)
|
| 663 |
+
import re
|
| 664 |
+
match = re.search(r'(?i)Item\s+7\.\s*Management[β\']s\s+Discussion', text)
|
| 665 |
+
if match:
|
| 666 |
+
start = match.start()
|
| 667 |
+
extract = text[start:start+40000]
|
| 668 |
+
else:
|
| 669 |
+
extract = text[len(text)//4 : len(text)//4 + 40000]
|
| 670 |
+
|
| 671 |
+
if log_fn: log_fn(" β 10-K text extracted successfully.")
|
| 672 |
+
return extract
|
| 673 |
+
except Exception as e:
|
| 674 |
+
if log_fn: log_fn(f" β Failed to scrape EDGAR: {str(e)[:80]}")
|
| 675 |
+
return ""
|
| 676 |
+
|
| 677 |
|
| 678 |
+
# ββ Main Fetcher ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 679 |
|
| 680 |
def fetch_all(meta: CompanyMeta, years: int = DEFAULT_YEARS,
|
| 681 |
force_refresh: bool = False, log_fn=None) -> Dict:
|
|
|
|
| 745 |
return {"warnings": [msg], "years": [], "income_statement": {},
|
| 746 |
"balance_sheet": {}, "cash_flow": {}}
|
| 747 |
|
| 748 |
+
# 4. Optional Annual Report Scrape (for context)
|
| 749 |
+
if meta.currency != "INR":
|
| 750 |
+
ar_context = _scrape_edgar_10k(ticker, log_fn)
|
| 751 |
+
if ar_context:
|
| 752 |
+
result["annual_report_context"] = ar_context
|
| 753 |
+
warnings.append("AI analysis enhanced with raw MD&A excerpts from SEC EDGAR 10-K.")
|
| 754 |
+
|
| 755 |
result["warnings"] = warnings
|
| 756 |
result.setdefault("currency", meta.currency)
|
| 757 |
set_cache(ticker, cache_key, result)
|