Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 55 additions & 31 deletions backend/ui/templates/workbench.html
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,7 @@ <h2 style="margin-top: 0;">Fundamental Analysis</h2>
<label><input type="checkbox" value="Special" onchange="renderDividendsData()"> Special</label>
<label><input type="checkbox" value="Bonus" onchange="renderDividendsData()"> Bonus</label>
<label><input type="checkbox" value="Split" onchange="renderDividendsData()"> Split</label>
<label><input type="checkbox" value="AGM" onchange="renderDividendsData()"> AGM</label>
</div>
</div>

Expand Down Expand Up @@ -3230,7 +3231,8 @@ <h2>API Key Management (Secure Session)</h2>
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
Expand Down Expand Up @@ -3308,6 +3310,9 @@ <h2>API Key Management (Secure Session)</h2>
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')) {
Expand Down Expand Up @@ -3359,11 +3364,21 @@ <h2>API Key Management (Secure Session)</h2>

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;
Expand All @@ -3384,7 +3399,7 @@ <h2>API Key Management (Secure Session)</h2>
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();
Expand All @@ -3397,18 +3412,19 @@ <h2>API Key Management (Secure Session)</h2>
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;
}
Expand Down Expand Up @@ -3467,6 +3483,7 @@ <h2>API Key Management (Secure Session)</h2>
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;
}
Expand Down Expand Up @@ -3534,21 +3551,24 @@ <h2>API Key Management (Secure Session)</h2>
}

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 };
}
};

Expand All @@ -3558,17 +3578,17 @@ <h2>API Key Management (Secure Session)</h2>
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;

Expand Down Expand Up @@ -3603,18 +3623,18 @@ <h2>API Key Management (Secure Session)</h2>
}

if (matchedMeeting) {
bmd = formatDateTime(matchedMeeting.meeting_date);
bmdObj = formatDateTime(matchedMeeting.meeting_date);
bmPurpose = matchedMeeting.purpose || '-';
}
}

// 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 || '-';
Expand Down Expand Up @@ -3648,13 +3668,17 @@ <h2>API Key Management (Secure Session)</h2>
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 = `<span class="badge" style="background:${bg}; padding:2px 6px; border-radius:3px; color:white; font-size:11px;">${divType}</span>`;
}

let bcdDisplay = bcdObj.date !== '-' ? `${bcdObj.date} ${bcdObj.time ? `<span style="color:#aaa;font-size:10px;"><br>${bcdObj.time}</span>` : ''}` : '-';
let bmdDisplay = bmdObj.date !== '-' ? `${bmdObj.date} ${bmdObj.time ? `<span style="color:#aaa;font-size:10px;"><br>${bmdObj.time}</span>` : ''}` : '-';

tr.innerHTML = `
<td>${bcd}</td>
<td>${bmd}</td>
<td>${bcdDisplay}</td>
<td>${bmdDisplay}</td>
<td style="white-space:normal; max-width:200px;">${bmPurpose}</td>
<td>${d.ex_date || '-'}</td>
<td><strong style="color: #4da6ff;">${series}</strong></td>
Expand Down
37 changes: 27 additions & 10 deletions backend/web/api/data/special_sit_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion backend/web/api/data/view_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down