diff --git a/backend/ingest/field_mapper.py b/backend/ingest/field_mapper.py index 2af4597d..7a192d57 100644 --- a/backend/ingest/field_mapper.py +++ b/backend/ingest/field_mapper.py @@ -285,17 +285,17 @@ def _parse_dividend(cls, purpose: str, face_value: Optional[float]) -> tuple[Opt # Try Rs format: sum all amounts if multiple exist (e.g. "Dividend - Rs 3 & Special - Rs 3") # 1. Aggressively remove 'face value' and 'fv' context blocks - _clean_purpose = re.sub(r'(?:face value|fv|equity shares? of|shares? of)\s*(?:of\s*)?(?:rs\.?|re\.?|rupees?|inr|[-/]|\s)*\d+(?:\.\d+)?', '', purpose_lower, flags=re.IGNORECASE) + _clean_purpose = re.sub(r'(?:face value|fv|paid-up capital|paid up capital|equity shares? of|shares? of)\s*(?:of\s*)?(?:rs\.?|re\.?|rupees?|inr|[-/]|\s|\u20b9)*\d+(?:\.\d+)?(?:/-)?(?:\s*each)?', '', purpose_lower, flags=re.IGNORECASE) # 2. Check for the 'including' or 'includes' pattern to avoid double counting # e.g. 'Dividend Rs 16/- (including Rs 10 special dividend)' -> We should just extract the 16. if 'including' in _clean_purpose or 'includes' in _clean_purpose: - match = re.search(r'(?:rs\.?|re\.?|rupees?|inr)\s*(\d+(?:\.\d+)?)', _clean_purpose) + match = re.search(r'(?:rs\.?|re\.?|rupees?|inr|\u20b9)\s*(\d+(?:\.\d+)?)', _clean_purpose) if match: return float(match.group(1)), dividend_type # 3. Standard extraction: find all Rs matches and sum them up (for explicitly separate components joined by &) - rs_matches = re.findall(r'(?:rs\.?|re\.?|rupees?|inr)\s*(\d+(?:\.\d+)?)', _clean_purpose) + rs_matches = re.findall(r'(?:rs\.?|re\.?|rupees?|inr|\u20b9)\s*(\d+(?:\.\d+)?)', _clean_purpose) if rs_matches: total_amount = sum(float(m) for m in rs_matches) return total_amount, dividend_type diff --git a/backend/ingest/nse_importer.py b/backend/ingest/nse_importer.py index 44eccec4..8b5e3a70 100644 --- a/backend/ingest/nse_importer.py +++ b/backend/ingest/nse_importer.py @@ -479,24 +479,15 @@ def _process_file(self, db: Session, key: str, trade_date: date, results: dict, from sqlalchemy import delete # To effectively deduplicate synthesized corporate actions that might have # drifted across different `trade_date` imports but belong to the same symbol/purpose: - for rec in synthesized_ca_records: - from sqlalchemy import or_ - # Crucially, do not filter deletions by `parsed_dividend_amount`, to ensure intimation records - # (no amount) are properly overwritten by subsequent announcement records (with amount). - # Crucial fix to preserve actual historical dividends! - # We only want to delete the synthesized records that are being replaced BY THIS EXACT EVENT. - # So we only delete synthesized placeholders from the SAME date or later (which means it's the exact same lifecycle event). - from datetime import timedelta - threshold_date = rec['date'] - timedelta(days=60) # Lifecycle events happen closely + from sqlalchemy import or_ + from datetime import timedelta + for rec in synthesized_ca_records: + # Find potential duplicate placeholders to delete for this specific symbol + # We NEVER include `ca_model.purpose == 'Dividend'` broadly as it wipes out official historical dividends. stmt = delete(ca_model).where( ca_model.symbol == rec['symbol'], - ca_model.date >= threshold_date, - or_( - ca_model.purpose.like('%not yet declared%'), - ca_model.purpose == 'Dividend', - ca_model.purpose.like('Dividend (%') - ) + ca_model.purpose.like('%not yet declared%') ) db.execute(stmt) diff --git a/backend/ingest/nse_lib.py b/backend/ingest/nse_lib.py index 11ce553e..568de6bb 100644 --- a/backend/ingest/nse_lib.py +++ b/backend/ingest/nse_lib.py @@ -648,13 +648,13 @@ def get_board_meetings(self, trade_date: date) -> pd.DataFrame: subject = str(ca.get('subject', '')) # Extract amount from the CA subject: e.g. 'Dividend - Rs 31 Per Share' - _clean_subject = re.sub(r'(?:face value|fv|equity shares? of|shares? of)\s*(?:of\s*)?(?:rs\.?|re\.?|rupees?|inr|[-/]|\s)*\d+(?:\.\d+)?', '', subject, flags=re.IGNORECASE) + _clean_subject = re.sub(r'(?:face value|fv|paid-up capital|paid up capital|equity shares? of|shares? of)\s*(?:of\s*)?(?:rs\.?|re\.?|rupees?|inr|[-/]|\s|\u20b9)*\d+(?:\.\d+)?(?:/-)?(?:\s*each)?', '', subject, flags=re.IGNORECASE) if 'including' in _clean_subject.lower() or 'includes' in _clean_subject.lower(): - match = re.search(r'(?:rs\.?|re\.?|rupees?|inr)\s*(\d+(?:\.\d+)?)', _clean_subject, re.IGNORECASE) + match = re.search(r'(?:rs\.?|re\.?|rupees?|inr|\u20b9)\s*(\d+(?:\.\d+)?)', _clean_subject, re.IGNORECASE) if match: found_amount = float(match.group(1)) else: - matches = re.findall(r'(?:rs\.?|re\.?|rupees?|inr)\s*(\d+(?:\.\d+)?)', _clean_subject, re.IGNORECASE) + matches = re.findall(r'(?:rs\.?|re\.?|rupees?|inr|\u20b9)\s*(\d+(?:\.\d+)?)', _clean_subject, re.IGNORECASE) if matches: found_amount = sum(float(m) for m in matches) @@ -687,14 +687,14 @@ def get_board_meetings(self, trade_date: date) -> pd.DataFrame: # Extract Amount if found_amount is None: - _clean_text = re.sub(r'(?:face value|fv|equity shares? of|shares? of)\s*(?:of\s*)?(?:rs\.?|re\.?|rupees?|inr|[-/]|\s)*\d+(?:\.\d+)?', '', attchmntText, flags=re.IGNORECASE) + _clean_text = re.sub(r'(?:face value|fv|paid-up capital|paid up capital|equity shares? of|shares? of)\s*(?:of\s*)?(?:rs\.?|re\.?|rupees?|inr|[-/]|\s|\u20b9)*\d+(?:\.\d+)?(?:/-)?(?:\s*each)?', '', attchmntText, flags=re.IGNORECASE) if 'including' in _clean_text.lower() or 'includes' in _clean_text.lower(): - match = re.search(r'(?:rs\.?|re\.?|rupees?|inr)\s*(\d+(?:\.\d+)?)', _clean_text, re.IGNORECASE) + match = re.search(r'(?:rs\.?|re\.?|rupees?|inr|\u20b9)\s*(\d+(?:\.\d+)?)', _clean_text, re.IGNORECASE) if match: found_amount = float(match.group(1)) else: - div_pattern = re.compile(r'(?:rs\.?|re\.?|rupees?|inr)\s*(\d+(?:\.\d+)?)', re.IGNORECASE) + div_pattern = re.compile(r'(?:rs\.?|re\.?|rupees?|inr|\u20b9)\s*(\d+(?:\.\d+)?)', re.IGNORECASE) matches = div_pattern.findall(_clean_text) if matches: found_amount = sum(float(m) for m in matches) @@ -713,16 +713,16 @@ def get_board_meetings(self, trade_date: date) -> pd.DataFrame: # Fallback 2: Extracting from bm_desc and bm_purpose if found_amount is None: text_to_search = f"{purpose} {desc}" - _clean_text_2 = re.sub(r'(?:face value|fv|equity shares? of|shares? of)\s*(?:of\s*)?(?:rs\.?|re\.?|rupees?|inr|[-/]|\s)*\d+(?:\.\d+)?', '', text_to_search, flags=re.IGNORECASE) + _clean_text_2 = re.sub(r'(?:face value|fv|paid-up capital|paid up capital|equity shares? of|shares? of)\s*(?:of\s*)?(?:rs\.?|re\.?|rupees?|inr|[-/]|\s|\u20b9)*\d+(?:\.\d+)?(?:/-)?(?:\s*each)?', '', text_to_search, flags=re.IGNORECASE) if 'including' in _clean_text_2.lower() or 'includes' in _clean_text_2.lower(): - match = re.search(r'(?:rs\.?|re\.?|rupees?|inr)\s*(\d+(?:\.\d+)?)', _clean_text_2, re.IGNORECASE) + match = re.search(r'(?:rs\.?|re\.?|rupees?|inr|\u20b9)\s*(\d+(?:\.\d+)?)', _clean_text_2, re.IGNORECASE) if match: found_amount = float(match.group(1)) else: # Extract using the common UI regex patterns ui_patterns = [ - r'(?:rs\.?|re\.?|rupees?|inr)\s*(\d+(?:\.\d+)?)', + r'(?:rs\.?|re\.?|rupees?|inr|\u20b9)\s*(\d+(?:\.\d+)?)', r'(\d+(?:\.\d+)?)\s*\/\-', r'dividend\s+of\s+(\d+(?:\.\d+)?)', r'dividend.*?\s+(\d+(?:\.\d+)?)\s+per' diff --git a/backend/ui/static/js/specialSitTool.js b/backend/ui/static/js/specialSitTool.js index e3f1094a..ead0f8b7 100644 --- a/backend/ui/static/js/specialSitTool.js +++ b/backend/ui/static/js/specialSitTool.js @@ -1369,6 +1369,9 @@ function renderSSDividends() { if (isOverridden) { expectedAmountHTML = `${expectedAmountHTML} *`; + } else if (item.expected_highly_likely && typeof item.expected_highly_likely === 'string' && item.expected_highly_likely.includes('Announced:')) { + // If it's already officially announced, we strictly show the announced value without trend arrows + // Just use the base expectedAmountHTML which is the announced value. } else if (item.expected_amount && item.expected_amount_compare) { let numExpected = parseFloat(item.expected_amount); let numLast = parseFloat(item.expected_amount_compare); diff --git a/backend/ui/templates/workbench.html b/backend/ui/templates/workbench.html index 04417645..c97a16ad 100644 --- a/backend/ui/templates/workbench.html +++ b/backend/ui/templates/workbench.html @@ -3240,8 +3240,20 @@

API Key Management (Secure Session)

const aDate = a.ex_date ? new Date(a.ex_date) : null; // If the corporate action ex_date is after the meeting date, consider it linked if (aDate && aDate >= mDate) { - hasLinkedAction = true; - break; + // Make sure we only link if the dates are reasonably close (e.g., within 180 days) + // Otherwise a meeting in 2022 might link to a 2024 action + const diffDays = Math.abs(aDate - mDate) / (1000 * 60 * 60 * 24); + if (diffDays <= 180) { + hasLinkedAction = true; + // Update amount if missing in action but present in meeting + if ((a.parsed_dividend_amount == null || a.parsed_dividend_amount == "-") && m.extracted_dividend_amount) { + a.parsed_dividend_amount = m.extracted_dividend_amount; + } + if (!a.dividend_type || a.dividend_type === '-') { + a.dividend_type = m.extracted_dividend_type || 'Final'; + } + break; + } } const aPurpose = ((a.subject || '') + ' ' + (a.purpose || '')).trim().toLowerCase(); @@ -3289,7 +3301,8 @@

API Key Management (Secure Session)

if (purpose.includes('final')) divType = 'Final'; // Try to extract amount if not already provided by backend - let amountMatch = purpose.match(/(?:rs\.?|rupees?|re\.?)\s*([0-9]+(?:\.[0-9]+)?)/i) || purpose.match(/([0-9]+(?:\.[0-9]+)?)\s*\/\-/i) || purpose.match(/dividend\s+of\s+([0-9]+(?:\.[0-9]+)?)/i) || purpose.match(/dividend.*?\s+([0-9]+(?:\.[0-9]+)?)\s+per/i) || purpose.match(/dividend\s*-\s*(?:rs\.?|rupees?|re\.?)\s*([0-9]+(?:\.[0-9]+)?)/i); + let cleanPurpose = purpose.replace(/(?:face value|fv|paid-up capital|paid up capital|equity shares? of|shares? of)\s*(?:of\s*)?(?:rs\.?|re\.?|rupees?|inr|[-/]|\s|\u20b9)*\d+(?:\.\d+)?(?:/-)?(?:\s*each)?/gi, ''); + let amountMatch = cleanPurpose.match(/(?:rs\.?|rupees?|re\.?)\s*([0-9]+(?:\.[0-9]+)?)/i) || cleanPurpose.match(/([0-9]+(?:\.[0-9]+)?)\s*\/\-/i) || cleanPurpose.match(/dividend\s+of\s+([0-9]+(?:\.[0-9]+)?)/i) || cleanPurpose.match(/dividend.*?\s+([0-9]+(?:\.[0-9]+)?)\s+per/i) || cleanPurpose.match(/dividend\s*-\s*(?:rs\.?|rupees?|re\.?)\s*([0-9]+(?:\.[0-9]+)?)/i); if (amountMatch && !amount) { amount = amountMatch[1]; } @@ -3317,121 +3330,92 @@

API Key Management (Secure Session)

}); }); - // Deduplicate combinedActions by symbol and time proximity (e.g. within 60 days) - // Group by symbol - let groupedActions = {}; - combinedActions.forEach(action => { - const sym = action.symbol; - if (!groupedActions[sym]) { - groupedActions[sym] = []; - } - groupedActions[sym].push(action); - }); - + // Implement non-destructive timeline linkage: + // We do NOT want two separate rows for the identical cycle (e.g., Row 1: Intimation, Row 2: Ex-Date). + // Instead, if we generated a synthetic Board Meeting intimation (from above), and there IS a + // matching final Corporate Action (ex_date), we MERGE them into ONE row to show the timeline. + // We match them if they belong to the same symbol, have the same dividend type, and happen within ~90 days. let finalCombinedActions = []; + let symGroups = {}; - Object.keys(groupedActions).forEach(sym => { - let actionsForSym = groupedActions[sym]; + combinedActions.forEach(a => { + if (!symGroups[a.symbol]) symGroups[a.symbol] = { officials: [], synthetics: [] }; + if (a.is_synthetic) symGroups[a.symbol].synthetics.push(a); + else symGroups[a.symbol].officials.push(a); + }); - const parseDateString = (dateString) => { - if (!dateString || dateString === '-' || String(dateString).toLowerCase() === 'null') return 0; - if (String(dateString).match(/^\d{4}-\d{2}-\d{2}$/)) { - const t = new Date(dateString).getTime(); - if (!isNaN(t)) return t; - } - if (String(dateString).match(/^\d{2}-[a-zA-Z0-9]{2,3}-\d{4}$/)) { - const parts = String(dateString).split('-'); - const t = new Date(`${parts[2]}-${parts[1]}-${parts[0]}`).getTime(); - if (!isNaN(t)) return t; - } - const fallback = new Date(dateString).getTime(); - return isNaN(fallback) ? 0 : fallback; - }; + Object.values(symGroups).forEach(group => { + let processedOfficials = new Set(); - // Sort by earliest relevant date first so we process them chronologically - actionsForSym.sort((a, b) => { - const getSortTime = (item) => { - let t = parseDateString(item.ex_date); if (t > 0) return t; - t = parseDateString(item.record_date); if (t > 0) return t; - t = parseDateString(item.broadcast_date); if (t > 0) return t; - if (item._matchedMeeting) { t = parseDateString(item._matchedMeeting.meeting_date); if (t > 0) return t; } - t = parseDateString(item.date); if (t > 0) return t; - return 0; - }; - return getSortTime(a) - getSortTime(b); - }); + // Try to link each intimation (synthetic) to its final corporate action (official) + group.synthetics.forEach(syn => { + let matched = false; + const synDate = new Date(syn.broadcast_date || syn.date).getTime(); - // Iterate and merge actions that are within ~60 days of each other - let mergedActions = []; - for (let i = 0; i < actionsForSym.length; i++) { - const currentAction = actionsForSym[i]; - let merged = false; - - for (let j = 0; j < mergedActions.length; j++) { - const existingAction = mergedActions[j]; - - const getTime = (item) => { - let t = parseDateString(item.ex_date); if (t > 0) return t; - t = parseDateString(item.record_date); if (t > 0) return t; - t = parseDateString(item.broadcast_date); if (t > 0) return t; - if (item._matchedMeeting) { t = parseDateString(item._matchedMeeting.meeting_date); if (t > 0) return t; } - t = parseDateString(item.date); if (t > 0) return t; - return 0; - }; - - const timeCurrent = getTime(currentAction); - const timeExisting = getTime(existingAction); - - // If both times are valid and within 60 days, merge them - if (timeCurrent > 0 && timeExisting > 0) { - const diffDays = Math.abs(timeCurrent - timeExisting) / (1000 * 60 * 60 * 24); - if (diffDays <= 60) { - // Merge currentAction INTO existingAction (current is newer chronologically based on our sort) - - // Prefer real dates over nulls - if (currentAction.ex_date) existingAction.ex_date = currentAction.ex_date; - if (currentAction.record_date) existingAction.record_date = currentAction.record_date; - if (currentAction.broadcast_date) existingAction.broadcast_date = currentAction.broadcast_date; - - // Prefer specific amounts - if (currentAction.parsed_dividend_amount && currentAction.parsed_dividend_amount !== "-") { - existingAction.parsed_dividend_amount = currentAction.parsed_dividend_amount; - } + for (let off of group.officials) { + if (processedOfficials.has(off)) continue; - // Prefer actual corporate action over synthetic, or latest purpose - // Synthetic means it came from our frontend board meeting synthesis - // Not synthetic could be from DB (real or backend synthetic) - if (currentAction.purpose && currentAction.purpose.toLowerCase() !== 'dividend') { - // If the current action has a more descriptive purpose than just "Dividend", keep it. - existingAction.purpose = currentAction.purpose; - existingAction.subject = currentAction.subject || currentAction.purpose; - } else if (!existingAction.purpose || existingAction.purpose.toLowerCase().includes('not yet declared')) { - existingAction.purpose = currentAction.purpose || existingAction.purpose; - existingAction.subject = currentAction.subject || existingAction.subject; - } + const offDate = new Date(off.ex_date || off.record_date || off.broadcast_date || off.date).getTime(); - if (currentAction.dividend_type && currentAction.dividend_type !== '-') { - existingAction.dividend_type = currentAction.dividend_type; - } + if (!isNaN(synDate) && !isNaN(offDate)) { + const diffDays = (offDate - synDate) / (1000 * 60 * 60 * 24); + + // Link using a wide 180-day window per requirements + // Check if amounts match exactly, or if official is missing an amount + let amountMatches = true; + if (syn.parsed_dividend_amount != null && off.parsed_dividend_amount != null && off.parsed_dividend_amount !== "-") { + amountMatches = parseFloat(syn.parsed_dividend_amount) === parseFloat(off.parsed_dividend_amount); + } - // If we merge a real action over a synthetic one, mark it as real - if (!currentAction.is_synthetic) { - existingAction.is_synthetic = false; + if (diffDays >= -90 && diffDays <= 180 && amountMatches) { + // Link them! Merge the intimation's broadcast date into the official corporate action. + off.broadcast_date = syn.broadcast_date || off.broadcast_date; + if (!off.parsed_dividend_amount || off.parsed_dividend_amount === "-") { + off.parsed_dividend_amount = syn.parsed_dividend_amount; } + if (syn._matchedMeeting) off._matchedMeeting = syn._matchedMeeting; - merged = true; + processedOfficials.add(off); + matched = true; break; } } } - if (!merged) { - // Create a shallow copy so we don't mutate the raw data arrays directly - mergedActions.push({...currentAction}); + // If this board meeting hasn't dropped a corporate action yet (it's upcoming), keep it! + if (!matched) { + finalCombinedActions.push(syn); } - } + }); + + // Add all official corporate actions + group.officials.forEach(off => finalCombinedActions.push(off)); + }); + + // Finally, sort everything chronologically (newest first) + finalCombinedActions.sort((a, b) => { + const parseDateStringSortLocal = (dateString) => { + if (!dateString || dateString === '-' || dateString.includes('not yet declared')) return 0; + let dateObj = new Date(dateString); + if (!isNaN(dateObj.getTime())) return dateObj.getTime(); + let parts = dateString.split('-'); + if (parts.length === 3) { + if (parts[2].length === 4) { dateObj = new Date(`${parts[2]}-${parts[1]}-${parts[0]}`); } + else if (parts[0].length === 4) { dateObj = new Date(dateString); } + } + return isNaN(dateObj.getTime()) ? 0 : dateObj.getTime(); + }; - finalCombinedActions.push(...mergedActions); + const getT = (x) => { + let t = parseDateStringSortLocal(x.ex_date); if (t > 0) return t; + t = parseDateStringSortLocal(x.announcement_date_obj || x.broadcast_date); if (t > 0) return t; + if (x._matchedMeeting && x._matchedMeeting.meeting_date) { + t = parseDateStringSortLocal(x._matchedMeeting.meeting_date); if (t > 0) return t; + } + t = parseDateStringSortLocal(x.date); if (t > 0) return t; + return 0; + }; + return getT(b) - getT(a); }); let filteredActions = finalCombinedActions.filter(d => { diff --git a/backend/web/api/data/special_sit_routes.py b/backend/web/api/data/special_sit_routes.py index 18c827be..91ff0aab 100644 --- a/backend/web/api/data/special_sit_routes.py +++ b/backend/web/api/data/special_sit_routes.py @@ -7,7 +7,7 @@ import numpy as np from backend.infrastructure.db import get_db -from backend.ingest.nse_models import SecurityMaster, BhavcopyFO, BhavcopyEQ, CorporateAction, SymbolMaster +from backend.ingest.nse_models import SecurityMaster, BhavcopyFO, BhavcopyEQ, CorporateAction, SymbolMaster, BoardMeeting router = APIRouter() @@ -72,12 +72,11 @@ def get_special_sit_dividends(db: Session = Depends(get_db)): "expiry": r.expiry_date.strftime("%d-%b") if r.expiry_date else None }) - # 4. Fetch Corporate Actions (Dividends, Splits, Bonuses) for the last 10 years + # 4. Fetch Corporate Actions and Board Meetings for the last 10 years today = datetime.date.today() ten_years_ago = today - datetime.timedelta(days=365*10) # We also need splits and bonuses to adjust historical dividends. - # dividend_type captures "Bonus" and "Split" from our ingest logic. ca_records = db.query(CorporateAction).filter( CorporateAction.symbol.in_(symbols), CorporateAction.date >= ten_years_ago, @@ -87,6 +86,13 @@ def get_special_sit_dividends(db: Session = Depends(get_db)): ) ).order_by(desc(CorporateAction.date)).all() + # Fetch Board Meetings discussing dividends + bm_records = db.query(BoardMeeting).filter( + BoardMeeting.symbol.in_(symbols), + BoardMeeting.date >= ten_years_ago, + BoardMeeting.purpose.ilike('%dividend%') + ).order_by(desc(BoardMeeting.date)).all() + import re # Group by symbol @@ -159,63 +165,54 @@ def get_special_sit_dividends(db: Session = Depends(get_db)): "raw_amount": r.parsed_dividend_amount }) - # Deduplicate synthesized records if an official record exists - for sym, history in ca_by_symbol.items(): - # A synthesized record is one that was generated by our nse_importer board meetings parser. - # It typically has "not yet declared" OR just "Dividend (" if it parsed the date but isn't a direct CA import yet. - # Alternatively, we can check if it lacks an ex_date or if it matches exactly. - # To be safe, we'll consider any record without an ex_date or with a synthesized purpose pattern as synthesized. - synthesized = [] - official = [] + bm_by_symbol = defaultdict(list) + for bm in bm_records: + bm_by_symbol[bm.symbol.upper()].append(bm) + + # Compile the chain of events strictly without data-loss deductions + all_symbols = set(ca_by_symbol.keys()).union(set(bm_by_symbol.keys())) + + for sym in all_symbols: + history = ca_by_symbol.get(sym, []) + bms = bm_by_symbol.get(sym, []) + chained_history = [] + + # Keep all real Corporate Actions for h in history: - is_syn = False - purp_lower = (h['purpose'] or '').lower() - if 'not yet declared' in purp_lower: - is_syn = True - elif purp_lower.startswith('dividend (') and purp_lower.endswith(')'): - is_syn = True - - if is_syn: - synthesized.append(h) - else: - official.append(h) - - filtered_history = [] - for syn in synthesized: - # Check if there is an official record within 90 days after this synthesized record's date - # with the exact same amount. - has_official = False - # Fallback to announcement_date_obj if ex_date_obj is missing - syn_date = syn['ex_date_obj'] or syn.get('announcement_date_obj') - if syn_date: - for off in official: - off_date = off['ex_date_obj'] or off.get('announcement_date_obj') - # Relaxed condition to check both forward and backward 90 days - if off_date and syn_date - datetime.timedelta(days=90) <= off_date <= syn_date + datetime.timedelta(days=90): - if abs(off['raw_amount'] - syn['raw_amount']) < 0.01: - has_official = True - break - if not has_official: - filtered_history.append(syn) - - # For OFSS and similar cases, also deduplicate official records that might have the same date and amount - unique_officials = [] - seen_officials = set() - for off in official: - off_date = off['ex_date_obj'] or off.get('announcement_date_obj') - amt = off['raw_amount'] - key = (off_date, amt) - if key not in seen_officials: - seen_officials.add(key) - unique_officials.append(off) - - filtered_history.extend(unique_officials) - # Sort back by date descending. Prioritize ex_date, fallback to announcement_date - filtered_history.sort(key=lambda x: x['ex_date_obj'] if x['ex_date_obj'] else (x.get('announcement_date_obj') or datetime.date.min), reverse=True) - ca_by_symbol[sym] = filtered_history + # We match to a BM just to get its intimation date (broadcast_date), nothing else. We don't delete anything. + if h.get('dividend_type') not in ['Bonus', 'Split', 'Demerger']: + for bm in bms: + if bm.extracted_dividend_type == h['dividend_type'] or not bm.extracted_dividend_type: + ca_date = h['ex_date_obj'] or h.get('announcement_date_obj') + if ca_date and bm.date: + diff = (ca_date - bm.date).days + if -10 <= diff <= 90: + h['broadcast_date'] = bm.broadcast_date or h.get('broadcast_date') + h['announcement_date_obj'] = bm.date + bms.remove(bm) # Consume the BM so it doesn't duplicate + break + chained_history.append(h) + + # Append remaining BMs that haven't dropped an official CA yet (Upcoming Dividends/Intimations) + for bm in bms: + amt = bm.extracted_dividend_amount + chained_history.append({ + "ex_date": 'Record date not yet declared', + "ex_date_obj": None, + "announcement_date_obj": bm.date, + "broadcast_date": bm.broadcast_date, + "dividend_type": bm.extracted_dividend_type or 'Interim', + "purpose": bm.purpose or "Dividend Declared in Board Meeting", + "amount": amt, + "raw_amount": amt + }) + + chained_history.sort(key=lambda x: x['ex_date_obj'] if x['ex_date_obj'] else (x.get('announcement_date_obj') or datetime.date.min), reverse=True) + ca_by_symbol[sym] = chained_history # Adjust historical dividends for bonuses and splits - for sym, history in ca_by_symbol.items(): + for sym in all_symbols: + history = ca_by_symbol.get(sym, []) adjustments = adjustments_by_symbol.get(sym, []) if adjustments: for h in history: @@ -454,15 +451,24 @@ def circ_diff(d1, d2): expected_amount_compare = latest['amount'] expected_type = latest.get('dividend_type', 'Interim') - # Instead of "-" use the highly likely date we just forecasted for this cycle if it exists - if upcoming_cycles: - # Try to find a matching cycle type to use its date - matching_cycle = next((c for c in upcoming_cycles if c['type'] == expected_type), upcoming_cycles[0]) - expected_highly_likely = f"Forecasted: {matching_cycle['next_date'].strftime('%d-%m-%Y')}" + # If there's an announcement date, use it instead of just generic forecast + ann_date = latest.get('announcement_date_obj') + if ann_date: + expected_highly_likely = f"Announced: {ann_date.strftime('%d-%m-%Y')}" + expected_less_likely = "Amount declared, date not yet announced" else: - expected_highly_likely = "-" + # Instead of "-" use the highly likely date we just forecasted for this cycle if it exists + if upcoming_cycles: + # Try to find a matching cycle type to use its date + matching_cycle = next((c for c in upcoming_cycles if c['type'] == expected_type), upcoming_cycles[0]) + expected_highly_likely = f"Forecasted: {matching_cycle['next_date'].strftime('%d-%m-%Y')}" + else: + expected_highly_likely = "-" + expected_less_likely = "Amount declared, date not yet announced" - expected_less_likely = "Amount declared, date not yet announced" + # Explicitly round expected_amount for json response + if expected_amount is not None: + expected_amount = round(float(expected_amount), 2) results.append({ "symbol": sym,