kussssh/IPO-Analyzer
0
1"""2Module 4 — Valuation vs Peers Analysis3OPTIMIZED: Expanded retrieval queries + increased context + Python median/premium math.4"""5from langchain_core.prompts import ChatPromptTemplate6 7from backend.schemas import ValuationAnalysis8from backend.modules.base import get_llm, _invoke_with_retry9from backend.retriever import AnalysisContext10 11 12def analyze_valuation(13 ensemble_retriever=None,14 reranker=None,15 doc_type: str = "drhp",16 analysis_context: AnalysisContext | None = None,17):18 """Direct retrieval with expanded queries for peer comparison."""19 print("\n▶ Running Module: 4. Valuation vs Peers Analysis")20 21 llm = get_llm()22 23 context = analysis_context or AnalysisContext(ensemble_retriever, reranker)24 all_context_docs = []25 seen = set()26 27 queries = [28 "Comparison with Listed Industry Peers P/E EPS ROE RONW",29 "comparable companies valuation peer group table",30 "peer group comparison listed peers revenue face value EPS earnings per share",31 "price band face value equity share IPO price",32 "Basis for Issue Price weighted average earnings per share diluted EPS",33 "market capitalisation enterprise value EV EBITDA",34 "P/E ratio price earnings listed peer comparison",35 "issue price per share pre-money post-money valuation",36 "industry benchmark comparable listed companies returns",37 "our company versus peers listed comparison table financial metrics",38 ]39 40 # ── Parallel retrieval across all queries ──────────────────────────41 import concurrent.futures42 43 def _fetch(q: str):44 try:45 return context.raw_search(q)46 except Exception:47 return []48 49 with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:50 futures = [pool.submit(_fetch, q) for q in queries]51 for future in concurrent.futures.as_completed(futures):52 for doc in future.result()[:8]:53 content_hash = hash(doc.page_content[:200])54 if content_hash not in seen:55 seen.add(content_hash)56 all_context_docs.append(doc)57 58 all_context_docs = all_context_docs[:20]59 print(f" Retrieved {len(all_context_docs)} unique chunks for valuation analysis")60 61 62 context_parts = []63 for i, doc in enumerate(all_context_docs):64 context_parts.append(f"[Chunk {i+1}]\n{doc.page_content}")65 context = "\n\n" + "=" * 60 + "\n\n".join(context_parts)66 67 structured_llm = llm.with_structured_output(ValuationAnalysis)68 doc_label = doc_type.upper()69 price_note = (70 "Price band may NOT be available in DRHP stage. Return null if not found."71 if doc_type == "drhp"72 else "Price band SHOULD be explicitly stated in an RHP/Prospectus. Extract it carefully."73 )74 75 prompt = ChatPromptTemplate.from_messages([76 ("system", f"""You are an equity research analyst extracting valuation data from an Indian IPO {doc_label}.77Rules:78- Extract ONLY explicitly stated values. Never invent numbers.79- {price_note}80- UNIT CONVERSION (MANDATORY): First, check the table header to determine the unit ("Lakhs", "Millions", or "Crores"). All monetary figures must be in INR Crores.81 * If the document header says '₹ in Lakhs' — divide by 100 to get Crores (e.g., 1000 Lakhs = 10 Crores). CRITICAL: Do NOT divide by 1000.82 * If the document header says '₹ in Millions' — divide by 10 to get Crores (e.g., 1000 Millions = 100 Crores).83 * If '₹ in Crores' — use as-is.84- The peer comparison table may be SPLIT across multiple pages/chunks. Scan ALL chunks to find all rows.85- Merge fragmented table rows into one complete peers list."""),86 ("human", """{task}\n\n{doc_label} CONTEXT:\n{context}""")87 ])88 89 chain = prompt | structured_llm90 91 result = _invoke_with_retry(chain, {92 "task": """93Extract valuation and peer comparison data from ALL provided chunks:94 951. price_band_lower / price_band_upper: Extract if explicitly stated. Return null if not found (common in DRHP).962. market_cap_cr: Implied market capitalisation at upper band in INR Crores, if mentioned.973. ipo_pe: The company's P/E ratio ONLY if explicitly stated in the document.98 CRITICAL: If the document states "Not Applicable", "NA", "Not Disclosed", "N.A.", "[●]", or leaves the P/E row blank — set ipo_pe to null. DO NOT calculate or infer the P/E ratio yourself.994. ipo_ev_ebitda: EV/EBITDA multiple. Search THOROUGHLY across the peer table and "Basis for Issue Price" section. If absolutely not stated, return null.100 1015. peers: SCAN EVERY CHUNK for the peer comparison table. It may be titled:102 "Comparison with Listed Industry Peers", "Peer Group Comparison", "Listed Peer Set", or similar.103 The table rows may be spread across 2-3 chunks/pages — collect ALL rows.104 For EACH peer company extract:105 - company_name (exact name), pe_ratio (P/E), ev_ebitda, price_to_sales, roe_pct (ROE/RONW %)106 1076. Leave peer_median_pe and premium_vs_peers_pct as null (calculated in code).108 1097. signal and signal_reasoning (write 2-3 sentences with SPECIFIC multiples from data):110 - POSITIVE: IPO P/E <= peer median OR premium justified by clearly superior growth/margins111 - NEUTRAL: Priced at up to 20% premium vs peers, OR price band not yet set (DRHP stage), OR P/E not disclosed112 - NEGATIVE: Priced >30% premium vs peers without superior fundamentals113""",114 "doc_label": doc_label,115 "context": context116 })117 118 # Python post-processing: compute peer_median_pe and premium119 peer_pes = [p.pe_ratio for p in result.peers if p.pe_ratio is not None]120 if peer_pes:121 sorted_pes = sorted(peer_pes)122 mid = len(sorted_pes) // 2123 if len(sorted_pes) % 2 == 0:124 result.peer_median_pe = round((sorted_pes[mid - 1] + sorted_pes[mid]) / 2, 2)125 else:126 result.peer_median_pe = sorted_pes[mid]127 128 if result.ipo_pe is not None and result.peer_median_pe is not None and result.peer_median_pe != 0:129 result.premium_vs_peers_pct = round(130 ((result.ipo_pe - result.peer_median_pe) / result.peer_median_pe) * 100, 2131 )132 133 print(f" ✅ 4. Valuation vs Peers Analysis complete — Signal: {result.signal}")134 return result135 