Skip to content
Closed
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
9 changes: 5 additions & 4 deletions backend/ui/static/js/specialSitTool.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '-');

Expand Down Expand Up @@ -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)} <span style="font-size: 0.8em; color: #aaa;">(${item.expected_type || 'Interim'})</span>` : '-';
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} <span style="color: #60a5fa; margin-left: 5px;">&#8593;</span>`; // Up arrow blue
} else if (numExpected < numLast) {
Expand Down
58 changes: 38 additions & 20 deletions backend/web/api/data/special_sit_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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')}"
Expand All @@ -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
Expand Down
95 changes: 0 additions & 95 deletions patch_js.py

This file was deleted.

10 changes: 0 additions & 10 deletions test.py

This file was deleted.

Loading