diff --git a/backend/ui/templates/workbench.html b/backend/ui/templates/workbench.html index 70296c55..6a5dee21 100644 --- a/backend/ui/templates/workbench.html +++ b/backend/ui/templates/workbench.html @@ -1049,6 +1049,7 @@

Fundamental Analysis

+ @@ -3230,7 +3231,8 @@

API Key Management (Secure Session)

meetings.forEach(m => { const purpose = (m.purpose || '').toLowerCase(); const hasAmount = m.extracted_dividend_amount != null && m.extracted_dividend_amount !== ''; - const isAGM = purpose.includes('agm') || purpose.includes('annual general meeting'); + // Ensure robust case-insensitive AGM matching + const isAGM = purpose.includes('agm') || purpose.includes('annual general meeting') || /\bagm\b/i.test(m.purpose || ''); if (purpose.includes('dividend') || purpose.includes('bonus') || purpose.includes('split') || purpose.includes('sub-division') || hasAmount || isAGM) { // Check if there is a corporate action after this meeting date @@ -3308,6 +3310,9 @@

API Key Management (Secure Session)

if (amountMatch && !amount) { amount = amountMatch[1]; } + } else if (isAGM && !purpose.includes('dividend') && !hasAmount) { + // Preserve the explicit AGM context in the Type column if it's purely an AGM intimation + divType = 'AGM'; } else if (purpose.includes('bonus')) { divType = 'Bonus'; } else if (purpose.includes('split') || purpose.includes('sub-division')) { @@ -3359,11 +3364,21 @@

API Key Management (Secure Session)

for (let existing of deduplicatedSynthetics) { const existingMeetingDate = existing._matchedMeeting && existing._matchedMeeting.meeting_date ? new Date(existing._matchedMeeting.meeting_date).getTime() : NaN; - // If meetings are within 5 days and have the same purpose/type, treat as duplicate + if (!isNaN(synMeetingDate) && !isNaN(existingMeetingDate)) { - const diffDays = Math.abs(synMeetingDate - existingMeetingDate) / (1000 * 60 * 60 * 24); - if (diffDays <= 5 && syn.dividend_type === existing.dividend_type) { + const d1 = new Date(synMeetingDate); + const d2 = new Date(existingMeetingDate); + + // Merge synthetics if they are within 60 days of each other and have the same dividend type + // This handles rescheduled meetings like BPCL (12th to 19th) natively by absorbing the older one + const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24); + + if (diffDays <= 60 && syn.dividend_type === existing.dividend_type) { isDuplicate = true; + // Keep the latest broadcast/meeting date info in the existing + if (syn._matchedMeeting && (!existing._matchedMeeting || new Date(syn._matchedMeeting.meeting_date) > new Date(existing._matchedMeeting.meeting_date))) { + existing._matchedMeeting = syn._matchedMeeting; + } // Update amount if the newer duplicate has it if ((!existing.parsed_dividend_amount || existing.parsed_dividend_amount === '-') && syn.parsed_dividend_amount) { existing.parsed_dividend_amount = syn.parsed_dividend_amount; @@ -3384,7 +3399,7 @@

API Key Management (Secure Session)

const synDate = new Date(syn.broadcast_date || syn.date).getTime(); // Sort officials by closest date first to prevent linking to a distant corporate action - let availableOfficials = group.officials.filter(o => !processedOfficials.has(o)); + let availableOfficials = [...group.officials]; // Do not filter out processed ones! Multiple intimations can map to the same CA availableOfficials.sort((a, b) => { const aDate = new Date(a.ex_date || a.record_date || a.broadcast_date || a.date).getTime(); const bDate = new Date(b.ex_date || b.record_date || b.broadcast_date || b.date).getTime(); @@ -3397,18 +3412,19 @@

API Key Management (Secure Session)

if (!isNaN(synDate) && !isNaN(offDate)) { const diffDays = (offDate - synDate) / (1000 * 60 * 60 * 24); - // If the corporate action happens -10 to 90 days after the board meeting, it's the same cycle! - // We strictly check that the dividend types match. - if (diffDays >= -10 && diffDays <= 90 && (syn.dividend_type === off.dividend_type || syn.dividend_type === '-')) { + // If the corporate action happens -10 to 180 days after the board meeting, it's the same cycle! + // We check that the dividend types loosely match. + if (diffDays >= -10 && diffDays <= 180 && (syn.dividend_type === off.dividend_type || syn.dividend_type === '-' || off.dividend_type === '-')) { // Link them! - // Do not overwrite the official broadcast_date with the intimation's date, - // as we want to preserve the actual corporate action announcement time. if (!off.parsed_dividend_amount || off.parsed_dividend_amount === "-") { off.parsed_dividend_amount = syn.parsed_dividend_amount; } - if (syn._matchedMeeting) off._matchedMeeting = syn._matchedMeeting; - processedOfficials.add(off); + // If this official already matched another meeting, keep the most relevant one (newest meeting date) + if (!off._matchedMeeting || (syn._matchedMeeting && new Date(syn._matchedMeeting.meeting_date) > new Date(off._matchedMeeting.meeting_date))) { + off._matchedMeeting = syn._matchedMeeting; + } + matched = true; break; } @@ -3467,6 +3483,7 @@

API Key Management (Secure Session)

if (selectedTypes.includes('Special') && purpose.includes('special')) typeMatchedAny = true; if (selectedTypes.includes('Bonus') && purpose.includes('bonus')) typeMatchedAny = true; if (selectedTypes.includes('Split') && (purpose.includes('split') || purpose.includes('sub-division'))) typeMatchedAny = true; + if (selectedTypes.includes('AGM') && (purpose.includes('agm') || purpose.includes('annual general meeting') || /\bagm\b/i.test(d.purpose || ''))) typeMatchedAny = true; if (!typeMatchedAny) matchType = false; } @@ -3534,21 +3551,24 @@

API Key Management (Secure Session)

} const formatDateTime = (dateStr) => { - if (!dateStr || dateStr === '-') return '-'; + if (!dateStr || dateStr === '-') return { date: '-', time: null }; try { let dt = new Date(dateStr); - if (isNaN(dt.getTime())) return dateStr; // fallback if invalid + if (isNaN(dt.getTime())) return { date: dateStr, time: null }; + + // Keep local timezone exactly as is instead of shifting to UTC let y = dt.getFullYear(), m = ('0' + (dt.getMonth() + 1)).slice(-2), dy = ('0' + dt.getDate()).slice(-2); let datePart = `${y}-${m}-${dy}`; - // Try to extract time from original string - let timePart = dateStr.includes('T') ? dateStr.split('T')[1].split('+')[0].split('.')[0] : (dateStr.includes(' ') ? dateStr.split(' ')[1] : null); - if (timePart && timePart !== '00:00:00') { - return `${datePart} ${timePart}`; + // Try to extract exact time from original string to avoid UTC shifting issues + let timePart = null; + if (typeof dateStr === 'string') { + timePart = dateStr.includes('T') ? dateStr.split('T')[1].split('+')[0].split('.')[0] : (dateStr.includes(' ') ? dateStr.split(' ')[1] : null); } - return datePart; + + return { date: datePart, time: (timePart && timePart !== '00:00:00') ? timePart : null }; } catch(e) { - return dateStr; + return { date: dateStr, time: null }; } }; @@ -3558,17 +3578,17 @@

API Key Management (Secure Session)

let series = d.symbol || '-'; let matchingMeetings = meetingsBySymbol[series.toUpperCase()] || []; - let bmd = '-'; + let bmdObj = { date: '-', time: null }; let bmPurpose = '-'; // Board Meeting and Broadcast Date matching logic - let bcd = '-'; + let bcdObj = { date: '-', time: null }; if (d.is_synthetic && d._matchedMeeting) { let matchedMeeting = d._matchedMeeting; - bmd = formatDateTime(matchedMeeting.meeting_date); + bmdObj = formatDateTime(matchedMeeting.meeting_date); bmPurpose = matchedMeeting.purpose || '-'; - bcd = formatDateTime(matchedMeeting.broadcast_date || matchedMeeting.date); + bcdObj = formatDateTime(matchedMeeting.broadcast_date || matchedMeeting.date); } else if (matchingMeetings.length > 0) { let exDateObj = d.ex_date ? new Date(d.ex_date) : null; @@ -3603,7 +3623,7 @@

API Key Management (Secure Session)

} if (matchedMeeting) { - bmd = formatDateTime(matchedMeeting.meeting_date); + bmdObj = formatDateTime(matchedMeeting.meeting_date); bmPurpose = matchedMeeting.purpose || '-'; } } @@ -3611,10 +3631,10 @@

API Key Management (Secure Session)

// Always prioritize the official action's broadcast date if it has one, // otherwise fallback to the intimation's broadcast date. let officialBcd = formatDateTime(d.broadcast_date); - if (officialBcd !== '-') { - bcd = officialBcd; - } else if (bcd === '-') { - bcd = formatDateTime(d.date); + if (officialBcd.date !== '-') { + bcdObj = officialBcd; + } else if (bcdObj.date === '-') { + bcdObj = formatDateTime(d.date); } let fullPurpose = d.subject || d.purpose || '-'; @@ -3648,13 +3668,17 @@

API Key Management (Secure Session)

else if (lowerType.includes('special')) bg = '#9370DB'; // Subtle Purple else if (lowerType.includes('bonus')) bg = '#E91E63'; // Pink else if (lowerType.includes('split') || lowerType.includes('sub-division')) bg = '#2196F3'; // Blue + else if (lowerType.includes('agm')) bg = '#607D8B'; // Grey Blue for AGM typeBadge = `${divType}`; } + let bcdDisplay = bcdObj.date !== '-' ? `${bcdObj.date} ${bcdObj.time ? `
${bcdObj.time}
` : ''}` : '-'; + let bmdDisplay = bmdObj.date !== '-' ? `${bmdObj.date} ${bmdObj.time ? `
${bmdObj.time}
` : ''}` : '-'; + tr.innerHTML = ` - ${bcd} - ${bmd} + ${bcdDisplay} + ${bmdDisplay} ${bmPurpose} ${d.ex_date || '-'} ${series} diff --git a/backend/web/api/data/special_sit_routes.py b/backend/web/api/data/special_sit_routes.py index 675485a0..c9c70650 100644 --- a/backend/web/api/data/special_sit_routes.py +++ b/backend/web/api/data/special_sit_routes.py @@ -208,16 +208,33 @@ def get_special_sit_dividends(db: Session = Depends(get_db)): # 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 - }) + purpose_lower = (bm.purpose or '').lower() + + # To avoid polluting the Special Situations UI with generic "Financial Results" or "AGM" meetings + # that have no actual declared dividend amount, strictly enforce that an amount must exist. + # However, we MUST preserve upcoming intimations (meetings that haven't happened yet), + # because most companies announce upcoming dividends with the purpose "Financial Results & Dividend". + is_valid_standalone = False + if amt is not None: + is_valid_standalone = True + elif bm.date and bm.date >= today: + # It's an upcoming meeting in the future, we don't have the amount yet. Allow it to show as 'Expected'. + is_valid_standalone = True + elif 'dividend' in purpose_lower and not any(x in purpose_lower for x in ['financial results', 'agm', 'annual general meeting', 'postponed']): + # It's a pure historical dividend intimation without a CA + is_valid_standalone = True + + if is_valid_standalone: + 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 diff --git a/backend/web/api/data/view_routes.py b/backend/web/api/data/view_routes.py index 382c5203..008a9e7d 100644 --- a/backend/web/api/data/view_routes.py +++ b/backend/web/api/data/view_routes.py @@ -748,7 +748,6 @@ async def list_data( model.parsed_dividend_amount != None, model.dividend_type.in_(['Bonus', 'Split']) )) - # Handle FO Instrument filter if type == 'bhavcopy_fo' and instrument and instrument.upper() != 'ALL': inst_upper = instrument.upper()