"""
pelotonmarkt — WorldTour rider salary estimation model v0.4 (12 Sep 2026)

Method (published with the data):
1. Team rider-wage pool = estimated 2026 team budget x rider-wage share (UCI 2024 league ratio 45%; per-team share rises with budget, 37%-52%, see wage_share()).
2. Each rider's "value points" VP = 0.6 x PCS points 2026 + 0.4 x career points per pro season
   (career points / max(1, age-19)). Blends current form with track record, so injured/quiet
   stars (Bernal, Roglic) are not priced at zero.
3. Raw weight w = VP^alpha x age_factor x role_factor.  alpha=1.15 (salaries are more convex than points).
   age_factor: <23 -> 0.75 (neo-pro contracts), 23-25 -> 0.9, 26-32 -> 1.0, 33-35 -> 0.9, 36+ -> 0.8
4. Publicly reported salaries (Gazzetta/Cyclingnews 2025-26) are ANCHORS: fixed, subtracted from the pool.
   Remaining pool is distributed over the other riders proportional to w, with UCI floors
   (employed WT min EUR 44,150; neo-pro EUR 35,721; self-employed min applied as EUR 72,404 for riders on
   non-FR/BE teams with VP>0 — we use a blended floor of EUR 60,000 for non-neo, EUR 40,000 for neo/stagiaire).
5. Stagiaires and mid-season duplicates excluded from pools.
"""
import pandas as pd, numpy as np, re, json, sys

ROSTER = '/home/claude/pelotonmarkt/data/wt_roster_2026.csv'
OUT = '/home/claude/pelotonmarkt/data/wt_salary_estimates_v04.csv'

# --- 1. team budgets 2026 (EUR M, our estimate from Gazzetta/CN/CW/Wielerkrant/PGE ranges) ---
BUDGET = {
 'UAE Team Emirates - XRG': 60, 'INEOS Grenadiers': 52, 'Team Visma | Lease a Bike': 50,
 'Red Bull - BORA - hansgrohe': 48, 'Lidl - Trek': 45, 'Decathlon CMA CGM Team': 42,
 'Soudal Quick-Step': 35, 'EF Education - EasyPost': 30, 'Movistar Team': 28,
 'Alpecin - Premier Tech': 28, 'Bahrain - Victorious': 28, 'Groupama - FDJ United': 25,
 'Lotto Intermarché': 24, 'XDS Astana Team': 25, 'NSN Cycling Team': 24, 'Team Jayco AlUla': 24,
 'Team Picnic PostNL': 22, 'Uno-X Mobility': 20,
}
BUDGET_RANGE = {  # published low-high where available (for the methodology page)
 'UAE Team Emirates - XRG': (50,70), 'INEOS Grenadiers': (45,60), 'Team Visma | Lease a Bike': (48,52),
 'Red Bull - BORA - hansgrohe': (45,55), 'Lidl - Trek': (45,50), 'Decathlon CMA CGM Team': (40,50),
 'Soudal Quick-Step': (25,40), 'EF Education - EasyPost': (28,32), 'Movistar Team': (25,30),
 'Alpecin - Premier Tech': (25,30), 'Bahrain - Victorious': (25,30), 'Groupama - FDJ United': (23,27),
 'Lotto Intermarché': (22,26), 'XDS Astana Team': (20,28), 'NSN Cycling Team': (20,28), 'Team Jayco AlUla': (20,26),
 'Team Picnic PostNL': (18,24), 'Uno-X Mobility': (16,22),
}
WAGE_SHARE = 0.45   # league average (UCI 2024: EUR 226.5M rider salaries / EUR 499M budgets)
def wage_share(budget_M):
    # v0.4: the rider share rises with budget. UCI 2024 per-team extremes: max rider spend EUR 27.3M on a
    # ~EUR 50-55M budget (~52%), min EUR 5.2M on ~EUR 14M (~37%); staff/vehicles/travel are semi-fixed and
    # eat a bigger slice of a small budget. Linear 37% at EUR 14M -> 50% at EUR 55M, clipped 36-55%;
    # league total stays at ~EUR 272M (= 0.45 x EUR 610M).
    return max(0.36, min(0.55, 0.37 + (budget_M - 14) / (55 - 14) * (0.50 - 0.37)))
ALPHA = 1.15

# --- 4. anchors: reported base salaries (EUR) ---
ANCHORS = {
 'POGAČAR Tadej': 8_200_000, 'EVENEPOEL Remco': 6_600_000, 'VINGEGAARD Jonas': 5_000_000,
 'VAN DER POEL Mathieu': 4_000_000, 'VAN AERT Wout': 4_000_000, 'ROGLIČ Primož': 4_000_000,
 'PHILIPSEN Jasper': 3_200_000, 'AYUSO Juan': 3_000_000, 'PEDERSEN Mads': 2_500_000,
 'YATES Adam': 2_700_000, 'BERNAL Egan': 2_500_000, 'RODRÍGUEZ Carlos': 2_500_000,
 'DEL TORO Isaac': 2_000_000, 'GANNA Filippo': 2_000_000, 'CARAPAZ Richard': 2_300_000,
 'HINDLEY Jai': 2_500_000, 'ALMEIDA João': 2_500_000, 'THOMAS Geraint': 2_000_000,
}
ANCHOR_SRC = 'Gazzetta dello Sport Jan 2026 / Cyclingnews rich list 2025 / Cyclingnews Dec 2025'

df = pd.read_csv(ROSTER)
df['pts'] = pd.to_numeric(df['PCS points 2026'], errors='coerce').fillna(0)
df['career'] = pd.to_numeric(df['Career PCS points'], errors='coerce').fillna(0)
df['age'] = pd.to_numeric(df['Age'], errors='coerce').fillna(26)
df['seasons'] = (df['age'] - 19).clip(lower=1)
df['career_py'] = df['career'] / df['seasons']
df['VP'] = 0.6 * df['pts'] + 0.4 * df['career_py']

def age_factor(a):
    if a < 23: return 0.75
    if a <= 25: return 0.9
    if a <= 32: return 1.0
    if a <= 35: return 0.9
    return 0.8
df['age_f'] = df['age'].apply(age_factor)

# exclude stagiaires, retired-during-season, and mid-season duplicates from the old team
dup_note = df['Note / verification'].fillna('').str.contains('Mid-season move')
old_team_dup = dup_note & (df['Status'] == 'Leaving - new team')
df['in_pool'] = ~df['Status'].isin(['Stagiaire', 'Retired during 2026']) & ~old_team_dup

df['w'] = np.where(df['in_pool'], (df['VP'].clip(lower=5)) ** ALPHA * df['age_f'], 0)
df['anchor'] = df['Rider'].map(ANCHORS)

AVG_BUDGET = sum(BUDGET.values()) / len(BUDGET)
def floor_for(row):
    # v0.2 (12 Sep 2026): floors reflect what WorldTeams actually pay for a roster place, not the UCI minimum.
    # UCI 2024: median EMPLOYED rider EUR 216k, mean EUR 384k; CN archetype: WT domestique ~EUR 200k.
    # Points-based allocation alone priced experienced domestiques (Novak, Van Lerberghe, Laengen) at
    # EUR 60-90k, which is not credible. Floors scale with team budget (rich teams pay more for the same job).
    if row['Status'] == 'Stagiaire': return 0
    bf = (BUDGET[row['Team']] / AVG_BUDGET) ** 0.5          # 0.77 (Uno-X) .. 1.33 (UAE)
    seasons = row['age'] - 19
    if row['age'] < 23:                    return round(50_000 * bf, -3)   # neo-pro / first contract
    if seasons >= 5 and row['career'] >= 1500: return round(160_000 * bf, -3)  # established pro with results
    if seasons >= 5 or row['career'] >= 800:   return round(120_000 * bf, -3)  # experienced domestique
    return round(80_000 * bf, -3)                                          # 23-25, 2nd contract

df['floor'] = df.apply(floor_for, axis=1)
df['salary_est'] = 0.0

for team, g in df.groupby('Team'):
    pool = BUDGET[team] * 1e6 * wage_share(BUDGET[team])
    idx = g.index
    anchored = g['anchor'].notna() & g['in_pool']
    pool_left = pool - g.loc[anchored, 'anchor'].sum()
    free = g['in_pool'] & ~anchored
    # iterative allocation with floors
    sal = pd.Series(0.0, index=idx)
    sal[anchored] = g.loc[anchored, 'anchor']
    remaining = free.copy()
    budget_left = pool_left
    for _ in range(10):
        wsum = g.loc[remaining, 'w'].sum()
        alloc = g.loc[remaining, 'w'] / wsum * budget_left
        below = alloc < g.loc[remaining, 'floor']
        if not below.any():
            sal[remaining] = alloc; break
        fl_idx = alloc[below].index
        sal[fl_idx] = g.loc[fl_idx, 'floor']
        budget_left -= g.loc[fl_idx, 'floor'].sum()
        remaining = remaining & ~g.index.isin(fl_idx)
    df.loc[idx, 'salary_est'] = sal.round(-3)

df['salary_team_alloc'] = df['salary_est']

# --- 6. global blend: half of the estimate comes from a league-wide allocation so that leaders of
#        small teams are not overpriced simply because their team-mates score little.
free_all = df['in_pool'] & ~df['is_anchor'] if 'is_anchor' in df else df['in_pool'] & df['anchor'].isna()
total_pool = sum(b * 1e6 * wage_share(b) for b in BUDGET.values()) - df.loc[df['anchor'].notna() & df['in_pool'], 'anchor'].sum()
avg_b = np.mean(list(BUDGET.values()))
df['w_global'] = np.where(free_all, df['w'] * (df['Team'].map(BUDGET) / avg_b) ** 0.5, 0)
df['salary_global_alloc'] = np.where(free_all, df['w_global'] / df['w_global'].sum() * total_pool, df['salary_est'])
df['salary_est'] = np.where(free_all, 0.5 * df['salary_team_alloc'] + 0.5 * df['salary_global_alloc'], df['salary_est'])
df['salary_est'] = np.maximum(df['salary_est'], np.where(df['in_pool'], df['floor'], 0))
# --- 7. youth cap: unanchored riders under 23 capped at EUR 2.0M (first/second pro contracts)
young_cap = free_all & (df['age'] < 23)
df.loc[young_cap, 'salary_est'] = df.loc[young_cap, 'salary_est'].clip(upper=2_000_000)
# --- 8. team consistency (v0.3): the league-wide half of the blend does not see a team's reported salaries,
#        so teams with many anchors (UAE, Red Bull) overshot their pool by up to EUR 5M. Rescale the
#        unanchored riders of each team so that anchors + estimates = the team's wage pool, respecting floors.
for team, g in df.groupby('Team'):
    pool = BUDGET[team] * 1e6 * wage_share(BUDGET[team])
    anch = g['anchor'].notna() & g['in_pool']
    free = g['in_pool'] & ~anch
    target = pool - g.loc[anch, 'anchor'].sum()
    fixed = pd.Series(False, index=g.index)
    for _ in range(8):
        movable = free & ~fixed
        base = g.loc[fixed & free, 'salary_est'].sum() if fixed.any() else 0.0
        cur = df.loc[movable[movable].index, 'salary_est'].sum()
        if cur <= 0: break
        scale = (target - base) / cur
        newv = df.loc[movable[movable].index, 'salary_est'] * scale
        below = newv < df.loc[newv.index, 'floor']
        df.loc[newv.index, 'salary_est'] = np.where(below, df.loc[newv.index, 'floor'], newv)
        if not below.any(): break
        fixed.loc[newv.index[below]] = True
    # youth cap again after rescale
    yc = free & (g['age'] < 23)
    df.loc[yc[yc].index, 'salary_est'] = df.loc[yc[yc].index, 'salary_est'].clip(upper=2_000_000)
df['salary_est'] = df['salary_est'].round(-3)

df['salary_est_k'] = (df['salary_est'] / 1000).round(0)
df['is_anchor'] = df['anchor'].notna()
df['budget_est_M'] = df['Team'].map(BUDGET)
df['pts_per_100k'] = np.where(df['salary_est'] > 0, df['pts'] / (df['salary_est'] / 1e5), np.nan).round(1)

# --- sanity stats ---
p = df[df['in_pool']]
stats = {
 'riders_in_pool': int(len(p)), 'total_wage_M': round(p['salary_est'].sum()/1e6, 1),
 'mean_k': round(p['salary_est'].mean()/1e3), 'median_k': round(p['salary_est'].median()/1e3),
 'over_1M': int((p['salary_est'] >= 1e6).sum()), 'over_2M': int((p['salary_est'] >= 2e6).sum()),
 'under_100k': int((p['salary_est'] < 1e5).sum()), 'at_floor': int((p['salary_est'] <= p['floor']).sum()),
 'max': p.loc[p['salary_est'].idxmax(), 'Rider'],
}
print(json.dumps(stats, indent=1))
print(p.sort_values('salary_est', ascending=False)[['Rider','Team','pts','VP','salary_est_k','is_anchor']].head(40).to_string())
print('\nTeam totals (M):'); print((p.groupby('Team')['salary_est'].sum()/1e6).round(1).sort_values(ascending=False).to_string())
df.to_csv(OUT, index=False)
print('saved', OUT)
