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,