From 4f679cfa3b3abbb238966dc3c6f8341a1a0733cb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 05:31:04 +0000 Subject: [PATCH] fix: improve special sit dividend forecasting accuracy - Split Final and Interim dividends into independent projection clusters - Increase temporal shifting tolerance for Interim clusters to 90 days to prevent cycle fragmentation - Skip legacy cycles with no dividend activity in the past calendar year - Cap projected Compound Annual Growth Rates (CAGR) between -100% and +50% to prevent hyper-inflated anomalies - Ignore "Special" and extraordinary dividends during cyclical modeling - Correctly correlate frontend up/down projection arrows to the specific historical cycle rather than the absolute last payout - Append predicted dividend type (Interim/Final) payload directly to UI outputs - Hide expected amount entirely if payout is already announced to prevent dual-display redundancy Co-authored-by: letssayx <56231955+letssayx@users.noreply.github.com> --- backend/ui/static/js/specialSitTool.js | 9 +- backend/web/api/data/special_sit_routes.py | 58 ++++++++----- patch_js.py | 95 ---------------------- test.py | 10 --- 4 files changed, 43 insertions(+), 129 deletions(-) delete mode 100644 patch_js.py delete mode 100644 test.py diff --git a/backend/ui/static/js/specialSitTool.js b/backend/ui/static/js/specialSitTool.js index 1c779def..618b7e86 100644 --- a/backend/ui/static/js/specialSitTool.js +++ b/backend/ui/static/js/specialSitTool.js @@ -898,7 +898,8 @@ function exportSSDivCSV() { row.push(item.last_ex_date || '-'); row.push(item.last_amount || '-'); row.push(item.is_above_2_percent ? 'Yes' : 'No'); - row.push(item.expected_amount || '-'); + let expectedCSV = item.expected_amount ? `${item.expected_amount} (${item.expected_type || 'Interim'})` : '-'; + row.push(expectedCSV); row.push(item.expected_highly_likely || '-'); row.push(item.expected_less_likely || '-'); @@ -1166,10 +1167,10 @@ function renderSSDividends() { } } - let expectedAmountHTML = item.expected_amount ? parseFloat(item.expected_amount).toFixed(2) : '-'; - if (item.expected_amount && item.last_amount) { + let expectedAmountHTML = item.expected_amount ? `${parseFloat(item.expected_amount).toFixed(2)} (${item.expected_type || 'Interim'})` : '-'; + if (item.expected_amount && item.expected_amount_compare) { let numExpected = parseFloat(item.expected_amount); - let numLast = parseFloat(item.last_amount); + let numLast = parseFloat(item.expected_amount_compare); if (numExpected > numLast) { expectedAmountHTML = `${expectedAmountHTML} ↑`; // Up arrow blue } else if (numExpected < numLast) { diff --git a/backend/web/api/data/special_sit_routes.py b/backend/web/api/data/special_sit_routes.py index c3acadae..04125cfc 100644 --- a/backend/web/api/data/special_sit_routes.py +++ b/backend/web/api/data/special_sit_routes.py @@ -184,6 +184,8 @@ def circ_diff(d1, d2): expected_amount = None expected_highly_likely = None expected_less_likely = None + expected_type = None + expected_amount_compare = None if history: # Most recent overall dividend (just for table display purposes) @@ -196,36 +198,45 @@ def circ_diff(d1, d2): # Sort ascending for cycle processing history_asc = sorted(history, key=lambda x: x['ex_date_obj'] if x['ex_date_obj'] else datetime.date.min) - # Cluster historical dividends into "Cycles" based on Month mapping (approximate) - # We will use the month of the ex_date as the primary key for cycles - # To account for dividends shifting by a few days (e.g. May 30th to June 2nd) - # We map the Month, but with a +/- 30 day tolerance. + final_cluster = [] + interim_clusters = [] - clusters = [] five_years_ago = today - datetime.timedelta(days=365*5) recent_hist = [h for h in history_asc if h['ex_date_obj'] and h['ex_date_obj'] >= five_years_ago] for h in recent_hist: - doy = get_doy(h['ex_date_obj']) - placed = False - for c in clusters: - mean_doy = sum(get_doy(x['ex_date_obj']) for x in c) / len(c) - if circ_diff(doy, mean_doy) <= 45: # 45 days threshold to group shifting months - # Avoid clustering multiple dividends from the exact same year in one cluster - # (e.g. if there's a special and final in the same month) - if not any(x['ex_date_obj'].year == h['ex_date_obj'].year for x in c): - c.append(h) - placed = True - break - if not placed: - clusters.append([h]) + # Skip special dividends entirely for forecasting + if 'special' in (h.get('purpose') or '').lower() or h.get('dividend_type') == 'Special': + continue + + if h.get('dividend_type') == 'Final': + final_cluster.append(h) + else: + doy = get_doy(h['ex_date_obj']) + placed = False + for c in interim_clusters: + mean_doy = sum(get_doy(x['ex_date_obj']) for x in c) / len(c) + if circ_diff(doy, mean_doy) <= 90: # 90 days threshold to handle larger shifts like May->June + if not any(x['ex_date_obj'].year == h['ex_date_obj'].year for x in c): + c.append(h) + placed = True + break + if not placed: + interim_clusters.append([h]) + + clusters = [final_cluster] + interim_clusters if final_cluster else interim_clusters # For each cycle, find its next upcoming date upcoming_cycles = [] for c in clusters: + if not c: continue most_recent = c[-1] mr_date = most_recent['ex_date_obj'] + # Skip clusters that haven't paid in the last 2 years (kill the cycle) + if mr_date.year < today.year - 1: + continue + if mr_date >= today: # Already announced for future next_date = mr_date @@ -271,6 +282,7 @@ def circ_diff(d1, d2): if years_diff >= 1 and prev_amt and curr_amt and prev_amt > 0: cagr = ((curr_amt / prev_amt) ** (1 / years_diff)) - 1 + cagr = min(max(cagr, -1.0), 0.5) # Cap CAGR between -100% and +50% growth_rates.append(cagr) avg_growth = np.mean(growth_rates) if growth_rates else 0 @@ -284,9 +296,11 @@ def circ_diff(d1, d2): upcoming_cycles.append({ 'next_date': next_date, 'is_announced': is_announced, - 'exp_amt': exp_amt, + 'exp_amt': None if is_announced else exp_amt, 'highly_likely_month': highly_likely_month, - 'less_likely_months': less_likely_m + 'less_likely_months': less_likely_m, + 'type': most_recent.get('dividend_type') or 'Interim', + 'last_amt_in_cycle': most_recent['amount'] }) # Pick the chronologically next cycle @@ -296,6 +310,8 @@ def circ_diff(d1, d2): if next_cycle['exp_amt'] is not None: expected_amount = round(next_cycle['exp_amt'], 2) + expected_amount_compare = next_cycle['last_amt_in_cycle'] + expected_type = next_cycle['type'] if next_cycle['is_announced']: expected_highly_likely = f"Announced: {next_cycle['next_date'].strftime('%d-%m-%Y')}" @@ -319,6 +335,8 @@ def circ_diff(d1, d2): "last_amount": last_amount, "is_above_2_percent": is_above_2_percent, "expected_amount": expected_amount, + "expected_amount_compare": expected_amount_compare, + "expected_type": expected_type, "expected_highly_likely": expected_highly_likely, "expected_less_likely": expected_less_likely, "history": history diff --git a/patch_js.py b/patch_js.py deleted file mode 100644 index 209b3038..00000000 --- a/patch_js.py +++ /dev/null @@ -1,95 +0,0 @@ -import re - -with open('backend/ui/static/js/specialSitTool.js', 'r') as f: - js_code = f.read() - -new_funcs = """ - -function clearSSDivSearch() { - const input = document.getElementById('ss-div-search'); - if (input) { - input.value = ''; - filterSSDividends(); - } -} - -function exportSSDivCSV() { - if (!ssDivData || ssDivData.length === 0) return; - let csv = 'Index / Scrip,Lot size,Spot,Future 1,Future 2,Future 3,Type,Ex-date,Amount,Is above 2% (Extra-ordinary),Expected Amount,Expected Dividend highly likely,Expected Dividend Less Likely\\n'; - - const filter = document.getElementById('ss-div-search').value.trim().toUpperCase(); - - ssDivData.forEach(item => { - if (filter && !item.symbol.includes(filter)) return; - let row = []; - row.push(item.symbol || '-'); - row.push(item.lot_size || '-'); - row.push(item.spot ? item.spot.toFixed(2) : '-'); - row.push(item.futures && item.futures[0] ? item.futures[0].toFixed(2) : '-'); - row.push(item.futures && item.futures[1] ? item.futures[1].toFixed(2) : '-'); - row.push(item.futures && item.futures[2] ? item.futures[2].toFixed(2) : '-'); - row.push(item.last_type || '-'); - row.push(item.last_ex_date || '-'); - row.push(item.last_amount || '-'); - row.push(item.is_above_2_percent ? 'Yes' : 'No'); - row.push(item.expected_amount || '-'); - row.push(item.expected_highly_likely || '-'); - row.push(item.expected_less_likely || '-'); - - csv += '"' + row.join('","') + '"\\n'; - }); - - const blob = new Blob([csv], { type: 'text/csv' }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.setAttribute('hidden', ''); - a.setAttribute('href', url); - a.setAttribute('download', 'dividend_arbitrage.csv'); - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); -} - -function exportSSDivPDF() { - const table = document.getElementById('ss-div-table'); - if (!table) return; - - const printWindow = window.open('', '', 'height=600,width=800'); - printWindow.document.write('