#!/usr/bin/env python3
"""亚盘-1year V3 预测生成脚本"""
from __future__ import annotations
import joblib, json, time, warnings, requests, sys
import pandas as pd
import numpy as np
from pathlib import Path
from datetime import datetime, timedelta
from collections import defaultdict
warnings.filterwarnings('ignore')
np.random.seed(42)

REPO = Path('/root/.openclaw/workspace-flj_bot1/football-predictor-repro')
API_KEY = '57b64e857ebeffdc156f0c41d43c7894'
API_BASE = 'https://v3.football.api-sports.io'
AH1_MODEL_DIR = REPO / 'models' / 'ah_1year_v6'
HIST_DIR = REPO / 'historical_data'
ODDS_DIR = REPO / 'artifacts' / 'odds_api_history'

LEAGUE_NAMES = {
    'e0':'英超','e1':'英甲','e2':'英冠','e2n':'英乙','e3':'英格兰第五级',
    'd1':'德甲','d2':'德乙','f1':'法甲','f2':'法乙',
    'i1':'意甲','i2':'意乙','sp1':'西甲','sp2':'西乙',
    'tr1':'土超','p1':'葡超','b1':'比甲','g1':'希腊超',
    'n1':'荷兰甲','a1':'澳超','r1':'俄超','br1':'巴甲','m1':'美职联',
    'sc1':'苏超','cz1':'捷甲',
}
LEAGUE_IDS = {
    'e0':39,'e2n':42,'e3':43,
    'd1':78,'d2':79,'f1':61,
    'i1':135,'i2':136,'sp1':140,'sp2':141,
    'tr1':203,'p1':94,'b1':144,'g1':197,
    'n1':88,'a1':188,'r1':235,'br1':71,'m1':253,
    'sc1':180,'cz1':0,
}
PREFERRED_AH_BOOKMAKERS = {'Bet365':3,'Pinnacle':0,'SBO':1,'188Bet':2,'Marathonbet':4,'1xBet':5}
_req_times = []
def _rate_call():
    global _req_times
    now = time.time()
    _req_times = [t for t in _req_times if now - t < 60]
    if _req_times:
        elapsed = now - _req_times[0]
        if len(_req_times) >= 45 and elapsed < 60:
            time.sleep(60 - elapsed + 0.5)
            _req_times[:] = []
    _req_times.append(now)

def _map_team(name):
    if not name: return name
    s = str(name).strip()
    if s in TEAM_MAP: return TEAM_MAP[s]
    sl = s.lower()
    for k, v in TEAM_MAP.items():
        if k.lower() == sl: return v
    return s

def team_name_zh(name):
    if not name: return name
    s = str(name).strip()
    if s in TEAM_ZH: return TEAM_ZH[s]
    sl = s.lower()
    for k, v in TEAM_ZH.items():
        if k.lower() == sl: return v
    return s

# 完整 TEAM_MAP (API名→历史名)
TEAM_MAP = {
    'Bayern Munchen':'Bayern Munich','Bayern München':'Bayern Munich','Freiburg':'Freiburg',
    'Ein Frankfurt':'Ein Frankfurt','Eintracht Frankfurt':'Ein Frankfurt',
    'Hertha Berlin':'Hertha','FC Augsburg':'Augsburg','Hamburger SV':'Hamburg',
    'Monchengladbach':'Mönchengladbach','Mönchengladbach':'Mönchengladbach',
    'Hoffenheim':'Hoffenheim','Leverkusen':'Leverkusen','Wolfsburg':'Wolfsburg',
    'Dortmund':'Dortmund','RB Leipzig':'RB Leipzig','VfL Bochum':'Bochum',
    'Union Berlin':'Union Berlin','Stuttgart':'Stuttgart','Werder Bremen':'Werder Bremen',
    'FC St. Pauli':'St. Pauli','1. FC Heidenheim':'Heidenheim','Holstein Kiel':'Holstein Kiel',
    '1. FC Koln':'FC Koln','Arminia Bielefeld':'Bielefeld','SV Darmstadt 98':'Darmstadt',
    'SC Paderborn 07':'Paderborn','FC Schalke 04':'Schalke 04','1. FC Nurnberg':'Nürnberg',
    'Greuther Furth':'Greuther Furth','Arsenal':'Arsenal','Aston Villa':'Aston Villa',
    'Brentford':'Brentford','Brighton':'Brighton','Burnley':'Burnley',
    'Chelsea':'Chelsea','Crystal Palace':'Crystal Palace','Everton':'Everton',
    'Fulham':'Fulham','Liverpool':'Liverpool','Man City':'Man City',
    'Man United':'Man United','Newcastle':'Newcastle','Tottenham':'Tottenham',
    'West Ham':'West Ham','Wolves':'Wolves','Leicester':'Leicester',
    'Southampton':'Southampton','Norwich':'Norwich','Watford':'Watford',
    'Sheffield Utd':'Sheffield United','West Brom':'West Brom',
    'Middlesbrough':'Middlesbrough','Swansea':'Swansea','Coventry':'Coventry',
    'Derby':'Derby','Preston':'Preston','QPR':'QPR','Bristol City':'Bristol City',
    'Stoke':'Stoke','Hull':'Hull','Sunderland':'Sunderland','Cardiff':'Cardiff',
    'Birmingham':'Birmingham','Blackburn':'Blackburn','Reading':'Reading',
    'Millwall':'Millwall','Portsmouth':'Portsmouth','Ipswich':'Ipswich',
    'Inter Milan':'Inter','AC Milan':'Milan','Juventus':'Juventus',
    'AS Roma':'Roma','Lazio':'Lazio','Napoli':'Napoli','Atalanta':'Atalanta',
    'Fiorentina':'Fiorentina','Torino':'Torino','Bologna':'Bologna',
    'Udinese':'Udinese','Genoa':'Genoa','Cagliari':'Cagliari',
    'Sassuolo':'Sassuolo','Verona':'Verona','Empoli':'Empoli',
    'Lecce':'Lecce','Frosinone':'Frosinone','Parma':'Parma','Monza':'Monza',
    'Real Madrid':'Real Madrid','Barcelona':'Barcelona',
    'Atletico Madrid':'Ath Madrid','Sevilla':'Sevilla','Betis':'Betis',
    'Real Sociedad':'Sociedad','Villarreal':'Villarreal','Athletic Bilbao':'Ath Bilbao',
    'Valencia':'Valencia','Getafe':'Getafe','Osasuna':'Osasuna',
    'Celta':'Celta','Mallorca':'Mallorca','Rayo Vallecano':'Rayo Vallecano',
    'Espanyol':'Espanyol','Las Palmas':'Las Palmas','Alaves':'Alaves',
    'Girona':'Girona','Cadiz':'Cadiz','Granada':'Granada','Levante':'Levante',
    'Paris SG':'Paris SG','Marseille':'Marseille','Lyon':'Lyon','Monaco':'Monaco',
    'Lille':'Lille','Nice':'Nice','Rennes':'Rennes','Lens':'Lens',
    'Nantes':'Nantes','Montpellier':'Montpellier','Strasbourg':'Strasbourg',
    'Bordeaux':'Bordeaux','Brest':'Brest','Reims':'Reims','Metz':'Metz',
    'Saint Etienne':'St Etienne','Nimes':'Nimes','Clermont':'Clermont',
    'Lorient':'Lorient','Troyes':'Troyes','Angers':'Angers','Auxerre':'Auxerre',
    'Le Havre':'Le Havre','PSV Eindhoven':'PSV Eindhoven','Ajax':'Ajax',
    'Feyenoord':'Feyenoord','AZ':'AZ','FC Utrecht':'Utrecht',
    'FC Groningen':'Groningen','sc Heerenveen':'Heerenveen','Sparta':'Sparta',
    'Willem II':'Willem II','Twente':'Twente','NEC Nijmegen':'NEC Nijmegen',
    'Fortuna Sittard':'Fortuna Sittard','Go Ahead Eagles':'Go Ahead Eagles',
    'PEC Zwolle':'Zwolle','Volendam':'Volendam','Almere City':'Almere City',
    'NAC Breda':'Breda','Porto':'Porto','Benfica':'Benfica',
    'Sporting CP':'Sporting CP','Braga':'Braga','Guimaraes':'Guimaraes',
    'Club Brugge':'Club Brugge','Anderlecht':'Anderlecht','Standard Liege':'Standard',
    'AA Gent':'Gent','Genk':'Genk','Galatasaray':'Galatasaray',
    'Fenerbahce':'Fenerbahce','Besiktas':'Besiktas','Trabzonspor':'Trabzonspor',
    'Basaksehir':'Basaksehir','Bournemouth':'Bournemouth',
    'Westhoek':'Westhoek','Bayern':'Bayern Munich',
}

# 完整 TEAM_ZH
TEAM_ZH = {
    'Arsenal':'阿森纳','Aston Villa':'阿斯顿维拉','Brentford':'布伦特福德',
    'Brighton':'布莱顿','Burnley':'伯恩利','Chelsea':'切尔西',
    'Crystal Palace':'水晶宫','Everton':'埃弗顿','Fulham':'富勒姆',
    'Liverpool':'利物浦','Man City':'曼城','Man United':'曼联',
    'Newcastle':'纽卡斯尔','Tottenham':'热刺','West Ham':'西汉姆',
    'Wolves':'狼队','Leicester':'莱斯特城','Southampton':'南安普顿',
    'Norwich':'诺维奇','Watford':'沃特福德','Bournemouth':'伯恩茅斯',
    'Sheffield Utd':'谢菲联','Nottm Forest':'诺丁汉森林','Luton':'卢顿',
    'West Brom':'西布朗','Middlesbrough':'米德尔斯堡','Swansea':'斯旺西',
    'Coventry':'考文垂','Derby':'德比郡','Preston':'普雷斯顿',
    'QPR':'女王公园','Bristol City':'布里斯托城','Stoke':'斯托克城',
    'Hull':'赫尔城','Sunderland':'桑德兰','Cardiff':'卡迪夫城',
    'Birmingham':'伯明翰','Blackburn':'布莱克本','Reading':'雷丁',
    'Millwall':'米尔沃尔','Portsmouth':'朴茨茅斯','Bristol Rovers':'布里斯托流浪者',
    'Luton Town':'卢顿','Barnsley':'巴恩斯利','Huddersfield':'哈德斯菲尔德',
    'Rotherham':'罗瑟勒姆','Stevenage':'斯蒂文尼奇','Burton Albion':'伯顿阿尔比恩',
    'Bradford':'布拉德福德','Northampton':'北安普顿','Blackpool':'布莱克浦',
    'Exeter City':'埃克塞特城','Bolton':'博尔顿','Oxford United':'牛津联',
    'Lincoln':'林肯城','Accrington ST':'阿克灵顿','Fleetwood Town':'弗利特伍德',
    'Wycombe':'威科姆','Sheffield Wednesday':'谢周三','Wrexham':'雷克斯姆',
    'Charlton':'查尔顿','Barrow':'巴罗','Hartlepool':'哈特利普尔',
    'Tranmere':'特兰米尔','Scunthorpe':'斯肯索普','Gateshead':'盖茨黑德',
    'Altrincham':'奥特林厄姆','York':'约克城','Aldershot Town':'奥尔德肖特',
    'Solihull Moors':'索利赫尔穆尔','Barnet':'巴尼特','AFC Wimbledon':'AFC温布尔登',
    'Woking':'沃金','Ebbsfleet United':'埃布斯弗利特联','Oldham':'奥尔德姆',
    'Rochdale':'罗奇代尔','Colchester':'科尔切斯特','Newport County':'纽波特郡',
    'Carlisle':'卡莱尔','Crewe':'克鲁','Forest Green':'森林格林',
    'Swindon':'斯温顿','Cambridge United':'剑桥联','Crawley Town':'克劳利',
    'Salford City':'索尔福德城','Grimsby':'格里姆斯比','Cheltenham':'切尔滕纳姆',
    'Morecambe':'莫克姆','Walsall':'沃尔索尔','Mansfield':'曼斯菲尔德',
    'Plymouth':'普利茅斯','Chesterfield':'切斯特菲尔德','Bromley':'布罗姆利',
    'Eastleigh':'伊斯特利','Torquay':'托基联','Sutton Utd':'萨顿联',
    'Boston United':'波士顿联','FC Halifax Town':'哈利法克斯镇','Maidstone':'梅德斯通',
    'Wealdstone':'威尔德斯通','Yeovil Town':'约维尔城','Tamworth':'坦沃斯',
    'Dartford':'达特福德','St Albans':'圣阿尔本斯','Boreham Wood':'博勒姆伍德',
    'Dover Athletic':'多佛竞技','Notts County':'诺茨郡',
    'Hartlepool United':'哈特利普尔联','Scunthorpe United':'斯肯索普联',
    'Stockport County':'斯托克波特','Stoke City':'斯托克城',
    'Milton Keynes Dons':'米尔顿凯恩斯','Shrewsbury Town':'什鲁斯伯里','Southend United':'绍森德联',
    'Bayern Munich':'拜仁慕尼黑','Borussia Dortmund':'多特蒙德','RB Leipzig':'莱比锡红牛',
    'Bayer Leverkusen':'勒沃库森','SC Freiburg':'弗赖堡','Eintracht Frankfurt':'法兰克福',
    'Monchengladbach':'门兴格拉德巴赫','Hoffenheim':'霍芬海姆','Wolfsburg':'沃尔夫斯堡',
    'Union Berlin':'柏林联合','Stuttgart':'斯图加特','Werder Bremen':'不莱梅',
    'FC Augsburg':'奥格斯堡','Hertha BSC':'柏林赫塔','VfL Bochum':'波鸿',
    '1. FC Koln':'科隆','1. FC Heidenheim':'海登海姆','Holstein Kiel':'荷尔斯泰因基尔',
    'Hamburger SV':'汉堡','Arminia Bielefeld':'比勒菲尔德','SV Darmstadt 98':'达姆斯塔特',
    'SC Paderborn 07':'帕德博恩','FC Schalke 04':'沙尔克04','1. FC Nurnberg':'纽伦堡',
    'Greuther Furth':'格雷特霍夫','Dynamo Dresden':'德累斯顿迪纳摩',
    '1. FC Magdeburg':'马格德堡','1. FC Kaiserslautern':'凯泽斯劳滕',
    'Hannover 96':'汉诺威96','1899 Hoffenheim':'霍芬海姆','FSV Mainz 05':'美因茨',
    'VfB Stuttgart':'斯图加特','VfL Wolfsburg':'沃尔夫斯堡','Hertha Berlin':'柏林赫塔',
    'FC St. Pauli':'圣保利','Karlsruher SC':'卡尔斯鲁厄','SV Elversberg':'埃尔弗斯贝格',
    'Preussen Munster':'普鲁士明斯特','Inter Milan':'国际米兰','AC Milan':'AC米兰',
    'Juventus':'尤文图斯','AS Roma':'罗马','Lazio':'拉齐奥','Napoli':'那不勒斯',
    'Atalanta':'亚特兰大','Fiorentina':'佛罗伦萨','Torino':'都灵','Bologna':'博洛尼亚',
    'Udinese':'乌迪内斯','Genoa':'热那亚','Cagliari':'卡利亚里','Sassuolo':'萨索洛',
    'Verona':'维罗纳','Empoli':'恩波利','Lecce':'莱切','Frosinone':'弗罗西诺内',
    'Parma':'帕尔马','Monza':'蒙扎','Cremonese':'克雷莫内塞','Salernitana':'萨勒尼塔纳',
    'Real Madrid':'皇家马德里','Barcelona':'巴塞罗那','Atletico Madrid':'马德里竞技',
    'Sevilla':'塞维利亚','Real Betis':'贝蒂斯','Real Sociedad':'皇家社会',
    'Villarreal':'比利亚雷亚尔','Athletic Bilbao':'毕尔巴鄂','Valencia':'瓦伦西亚',
    'Getafe':'赫塔菲','Osasuna':'奥萨苏纳','Celta Vigo':'塞尔塔','Mallorca':'马洛卡',
    'Rayo Vallecano':'巴列卡诺','Espanyol':'西班牙人','Las Palmas':'拉斯帕尔马斯',
    'Alaves':'阿拉维斯','Girona':'赫罗纳','Cadiz':'加的斯','Granada':'格拉纳达',
    'Valladolid':'巴拉多利德','Almeria':'阿尔梅里亚','Levante':'莱万特','Betis':'贝蒂斯',
    'Athletic Club':'毕尔巴鄂','Elche':'埃尔切',
    'Paris Saint Germain':'巴黎圣日耳曼','Marseille':'马赛','Lyon':'里昂','Monaco':'摩纳哥',
    'Lille':'里尔','Nice':'尼斯','Rennes':'雷恩','Lens':'朗斯','Nantes':'南特',
    'Montpellier':'蒙彼利埃','Strasbourg':'斯特拉斯堡','Bordeaux':'波尔多','Brest':'布雷斯特',
    'Reims':'兰斯','Metz':'梅斯','Saint Etienne':'圣埃蒂安','Nimes':'尼姆','Clermont':'克莱蒙',
    'Lorient':'洛里昂','Troyes':'特鲁瓦','Angers':'昂热','Auxerre':'欧塞尔','Le Havre':'勒阿弗尔',
    'PSG':'巴黎圣日耳曼','Stade Brestois 29':'布雷斯特','Stade Rennais':'雷恩',
    'Clermont Foot':'克莱蒙','Paris FC':'巴黎FC','Sochaux':'索肖','Dijon':'第戎',
    'Grenoble':'格勒诺布尔','Bastia':'巴斯蒂亚','Guingamp':'甘冈','Amiens':'亚眠',
    'Laval':'拉瓦勒','Nancy':'南锡','Toulouse':'图卢兹','Rodez':'罗德兹',
    'Saint-Etienne':'圣埃蒂安','Stade Briochin':'圣布里欧金','Caen':'卡昂','Fleury 91':'弗勒里91',
    'PSV Eindhoven':'埃因霍温','Ajax':'阿贾克斯','Feyenoord':'费耶诺德','AZ':'阿尔克马尔',
    'FC Utrecht':'乌得勒支','FC Groningen':'格罗宁根','sc Heerenveen':'海伦芬',
    'Sparta Rotterdam':'斯巴达','Willem II':'威廉二世','Twente':'特温特',
    'NEC Nijmegen':'NEC奈梅亨','Fortuna Sittard':'锡塔德','Go Ahead Eagles':'前进之鹰',
    'PEC Zwolle':'兹沃勒','Volendam':'福伦丹','Almere City':'阿尔梅勒城','NAC Breda':'布雷达',
    'Porto':'波尔图','Benfica':'本菲卡','Sporting CP':'葡萄牙体育','Braga':'布拉加',
    'Guimaraes':'吉马良斯','Santa Clara':'圣克拉拉','Famalicao':'法马利康','Arouca':'阿鲁卡',
    'Gil Vicente':'吉维森特','Vizela':'维泽拉','Chaves':'查维斯','Estoril':'埃斯托里尔',
    'Casa Pia':'卡萨皮亚','Moreirense':'莫雷伦塞','Rio Ave':'里奥阿维','Nacional':'纳cional',
    'Farense':'法伦斯','Tondela':'通德拉','Club Brugge':'布鲁日','Anderlecht':'安德莱赫特',
    'Standard Liege':'标准列日','AA Gent':'根特','Royal Antwerp':'安特卫普','Genk':'亨克',
    'Charleroi':'沙勒罗瓦','KV Mechelen':'梅赫伦','Union SG':'圣吉罗斯','OH Leuven':'OH卢文',
    'Cercle Brugge':'塞尔克布鲁日','Westerlo':'韦斯特洛','St. Truiden':'圣特鲁伊登',
    'Zulte Waregem':'祖尔特瓦雷格姆','Oostende':'奥斯坦德','Beerschot':'贝尔肖特',
    'Galatasaray':'加拉塔萨雷','Fenerbahce':'费内巴切','Besiktas':'贝西克塔斯',
    'Trabzonspor':'特拉布宗','Basaksehir':'巴萨克希尔','Antalyaspor':'安塔利亚',
    'Alanyaspor':'阿拉尼亚','Kayserispor':'开塞利','Kasimpasa':'卡西姆帕萨',
    'Konyaspor':'科尼亚','Gaziantep FK':'加济安泰普','Goztepe':'格兹泰佩',
    'Rizespor':'里泽斯','Eyupspor':'埃于普斯','Samsunspor':'萨姆松斯',
    'Genclerbirligi':'根奇莱尔比利','Olympiacos':'奥林匹亚科斯','PAOK':'PAOK',
    'AEK Athens':'AEK雅典','Panathinaikos':'潘纳辛纳科斯','Aris Thessalonikis':'萨洛尼卡阿里斯',
    'Atromitos':'阿特罗米托','Asteras Tripolis':'阿斯特拉斯','Lamia':'拉米亚',
    'Levadiakos':'莱瓦迪亚科斯','OFI':'OFI','Panetolikos':'帕内托利科斯',
    'Panserraikos':'潘塞拉伊科斯','Volos NFC':'沃洛斯','Larisa':'拉里萨','Kifisia':'基菲西亚',
    'LA Galaxy':'洛杉矶银河','Los Angeles FC':'洛杉矶FC','Seattle Sounders':'西雅图海湾',
    'Portland Timbers':'波特兰木材','Atlanta United':'亚特兰大联','Austin FC':'奥斯汀FC',
    'Charlotte FC':'夏洛特FC','Chicago Fire':'芝加哥火焰','FC Cincinnati':'辛辛那提FC',
    'Colorado Rapids':'科罗拉多急流','Columbus Crew':'哥伦布机员','FC Dallas':'达拉斯FC',
    'DC United':'华盛顿联','Houston Dynamo':'休斯顿迪纳摩','Inter Miami':'迈阿密国际',
    'Sporting Kansas City':'堪萨斯城体育','Minnesota United':'明尼苏达联',
    'Montreal':'蒙特利尔冲击','Nashville SC':'纳什维尔SC',
    'New England Revolution':'新英格兰革命','NY Red Bulls':'纽约红牛','NYCFC':'纽约城FC',
    'Orlando City':'奥兰多城','Philadelphia Union':'费城联合','Real Salt Lake':'皇家盐湖城',
    'San Jose':'圣何塞地震','St. Louis City':'圣路易斯城','Toronto FC':'多伦多FC',
    'Vancouver':'温哥华白浪','Kansas City':'堪萨斯城',
    'Flamengo':'弗拉门戈','Palmeiras':'帕尔梅拉斯','Santos':'桑托斯','Sao Paulo':'圣保罗',
    'Gremio':'格雷米奥','Internacional':'巴西国际','Corinthians':'科林蒂安',
    'Athletico Paranaense':'巴拉纳竞技','Cruzeiro':'克鲁塞罗','Botafogo':'博塔弗戈',
    'Fluminense':'弗鲁米嫩塞','Vasco':'瓦斯科达迦马','Bahia':'巴伊亚','Ceara':'塞阿拉',
    'Goias':'戈亚斯','Sport Recife':'累西腓体育','Atletico Goianiense':'戈亚尼亚竞技',
    'Coritiba':'科里蒂巴','Fortaleza':'福塔莱萨','Bragantino':'布拉甘蒂诺','Cuiaba':'库亚巴',
    'Melbourne Victory':'墨尔本胜利','Sydney FC':'悉尼FC','Melbourne City':'墨尔本城',
    'Brisbane Roar':'布里斯班狮吼','Adelaide United':'阿德莱德联','Perth Glory':'珀斯光荣',
    'Western Sydney':'西悉尼流浪者','Wellington Phoenix':'惠灵顿凤凰',
    'Newcastle Jets':'纽卡斯尔喷气机','Central Coast':'中岸水手','Western United':'西部联',
    'Auckland FC':'奥克兰城','Rangers':'格拉斯哥流浪者','Celtic':'凯尔特人',
    'Aberdeen':'阿伯丁','Hearts':'哈茨','Hibernian':'希伯尼安','Motherwell':'马瑟韦尔',
    'Livingston':'利文斯顿','St. Johnstone':'圣约翰斯通','Dundee Utd':'邓迪联',
    'Dundee':'邓迪','Partick':'帕蒂克','Inverness':'因弗内斯',
    'Zenit':'圣彼得堡泽尼特','CSKA Moscow':'CSKA莫斯科','Spartak Moscow':'莫斯科斯巴达',
    'Lokomotiv Moscow':'莫斯科火车头','Dynamo Moscow':'莫斯科迪纳摩','Krasnodar':'克拉斯诺达尔',
    'Rostov':'罗斯托夫','Sochi':'索契','Westhoek':'西胡克','Dunkerque':'敦克尔克',
    'LASK Linz':'LASK林茨','St. Pölten':'圣珀尔滕','Rapid Wien':'维也纳快速',
    'Sturm Graz':'格拉茨风暴','Hartberg':'哈特贝格','Wattens':'瓦滕斯',
    'Young Boys':'伯尔尼年轻人','Servette':'塞尔维特','St. Gallen':'圣加伦','Basel':'巴塞尔','Zurich':'苏黎世',
    'FC Versailles':'凡尔赛','Chateauroux':'沙托鲁','Aubagne':'欧巴涅',
    'Stockport':'斯托克波特','Huddesfield':'哈德斯菲尔德','Southend':'绍森德',
}
def recommend_level(conf):
    if conf >= 72: return '★★★ 强烈推荐', 3
    if conf >= 62: return '★★ 推荐', 2
    if conf >= 52: return '★ 谨慎推荐', 1
    return '✗ 不建议', 0

def confidence_band(conf):
    if conf >= 70: return '高'
    if conf >= 60: return '中'
    return '低'

def _load_model_configs():
    configs = {}
    for code in LEAGUE_IDS:
        cfg_path = AH1_MODEL_DIR / code / 'optimized_config.json'
        if cfg_path.exists():
            with open(cfg_path) as f:
                cfg = json.load(f)
                configs[code] = {
                    'threshold': cfg.get('threshold', 0.55),
                    'direction': cfg.get('direction', 'over'),
                    'model_type': cfg.get('model_name', 'unknown'),
                    'accuracy': cfg.get('test_acc', 0),
                }
        else:
            configs[code] = {'threshold': 0.55, 'direction': 'over', 'model_type': 'unknown', 'accuracy': 0}
    return configs

_MODEL_CONFIGS = None
def _get_cfg(code):
    global _MODEL_CONFIGS
    if _MODEL_CONFIGS is None:
        _MODEL_CONFIGS = _load_model_configs()
    return _MODEL_CONFIGS.get(code, {'threshold': 0.55, 'direction': 'over', 'model_type': 'unknown', 'accuracy': 0})

ODDS_AH = ['B365AHH', 'B365AHA', 'AvgAHH', 'AvgAHA', 'PAHH', 'PAHA', 'AHh']
ODDS_OU = ['B365>2.5', 'B365<2.5', 'Avg>2.5', 'Avg<2.5']
FEAT_COLS = ['home_gavg','home_gavga','home_tavg','home_winr','home_undr','home_gstd',
    'home_hgf','home_hga','home_hgc','home_agf','home_aga','home_agc','home_mom','home_rf',
    'home_gf3','home_ga3','home_gf5','home_ga5','home_gf10','home_ga10',
    'away_gavg','away_gavga','away_tavg','away_winr','away_undr','away_gstd',
    'away_hgf','away_hga','away_hgc','away_agf','away_aga','away_agc','away_mom','away_rf',
    'away_gf3','away_ga3','away_gf5','away_ga5','away_gf10','away_ga10',
    'hg_diff','ga_diff','tot_diff','win_diff','mom_diff',
    'B365AHH','B365AHA','AvgAHH','AvgAHA','PAHH','PAHA','AHh',
    'B365>2.5','B365<2.5','Avg>2.5','Avg<2.5','ah_ratio','ah_prob_home']

def _build_team_hist():
    return {'gf':[],'ga':[],'tot':[],'win':[],'und':[],'hgf':[],'hga':[],'hgc':[],'agf':[],'aga':[],'agc':[],'mom':[],'rf':[]}

def _mn(v, n):
    v = list(v)
    return float(np.mean(v[-n:])) if len(v) >= n else (float(np.mean(v)) if v else 0.0)

def _stdv(v):
    v = list(v)
    return float(np.std(v[-10:])) if len(v) >= 2 else 0.5

def _arr(v):
    if isinstance(v, np.ndarray): return list(v)
    return list(v) if v else []

def _mean(v):
    v = _arr(v)
    return float(np.mean(v)) if v else 0.0

def _build_feature_row(handicap, ahh, aha, home_name, away_name, team_cache):
    ht = _map_team(home_name); at = _map_team(away_name)
    hh = team_cache.get(ht, _build_team_hist()); aa = team_cache.get(at, _build_team_hist())
    d = {}
    for p, s in [('home', hh), ('away', aa)]:
        g = _arr(s['gf']); ga_ = _arr(s['ga']); tot = _arr(s['tot'])
        win = _arr(s['win']); und = _arr(s['und'])
        hgf = _arr(s['hgf']); hga_ = _arr(s['hga']); hgc = _arr(s['hgc'])
        agf = _arr(s['agf']); aga_ = _arr(s['aga']); agc = _arr(s['agc'])
        mom = _arr(s['mom']); rf = _arr(s['rf'])
        d[f'{p}_gavg'] = _mean(g); d[f'{p}_gavga'] = _mean(ga_)
        d[f'{p}_tavg'] = _mean(tot); d[f'{p}_winr'] = _mean(win)
        d[f'{p}_undr'] = _mean(und); d[f'{p}_gstd'] = _stdv(g)
        d[f'{p}_hgf'] = _mean(hgf); d[f'{p}_hga'] = _mean(hga_)
        d[f'{p}_hgc'] = _mean(hgc); d[f'{p}_agf'] = _mean(agf)
        d[f'{p}_aga'] = _mean(aga_); d[f'{p}_agc'] = _mean(agc)
        d[f'{p}_mom'] = _mean(mom[-5:]) if len(mom) >= 2 else 0.0
        d[f'{p}_rf'] = _mean(rf[-5:]) if len(rf) >= 2 else 0.0
        d[f'{p}_gf3'] = _mn(g, 3); d[f'{p}_ga3'] = _mn(ga_, 3)
        d[f'{p}_gf5'] = _mn(g, 5); d[f'{p}_ga5'] = _mn(ga_, 5)
        d[f'{p}_gf10'] = _mn(g, 10); d[f'{p}_ga10'] = _mn(ga_, 10)
    d['hg_diff'] = d['home_gavg'] - d['away_agf']
    d['ga_diff'] = d['home_gavga'] - d['away_aga']
    d['tot_diff'] = d['home_tavg'] - d['away_tavg']
    d['win_diff'] = d['home_winr'] - d['away_winr']
    d['mom_diff'] = d['home_mom'] - d['away_mom']
    for c in ODDS_AH + ODDS_OU: d[c] = 0.0
    d['AHh'] = float(handicap) if handicap else 0.0
    h = float(ahh) if ahh and float(ahh) > 1 else 0.0
    a = float(aha) if aha and float(aha) > 1 else 0.0
    d['B365AHH'] = h; d['B365AHA'] = a
    d['AvgAHH'] = h; d['AvgAHA'] = a; d['PAHH'] = h; d['PAHA'] = a
    if h > 1 and a > 1:
        d['ah_ratio'] = h / a; d['ah_prob_home'] = a / (h + a)
    else:
        d['ah_ratio'] = 1.0; d['ah_prob_home'] = 0.5
    df = pd.DataFrame([d]).reindex(columns=FEAT_COLS, fill_value=0)
    return df.fillna(0).replace([np.inf, -np.inf], 0)

def _update_cache(cache, home_name, away_name, fthg, ftag):
    ht = _map_team(home_name); at = _map_team(away_name)
    for team, gf, ga, win_ in [(ht, fthg, ftag, 1 if fthg > ftag else 0),(at, ftag, fthg, 1 if ftag > fthg else 0)]:
        if team not in cache: cache[team] = _build_team_hist()
        tg = cache[team]
        tg['gf'].append(float(gf)); tg['ga'].append(float(ga))
        tg['win'].append(float(win_)); tg['und'].append(1.0-float(win_))
        tg['tot'].append(float(gf)+float(ga))
        tg['hgf'].append(float(gf)); tg['hga'].append(float(ga)); tg['hgc'].append(float(win_))
        tg['agf'].append(float(ga)); tg['aga'].append(float(fthg)); tg['agc'].append(1.0-float(win_))
        tg['mom'].append(float(win_)); tg['rf'].append(float(gf)-float(ga))

def _load_historical_stats():
    cache = defaultdict(_build_team_hist)
    for hf in sorted(HIST_DIR.glob('*_history.csv')):
        try:
            df = pd.read_csv(hf, usecols=['Date','HomeTeam','AwayTeam','FTHG','FTAG'], low_memory=False)
            df['Date'] = pd.to_datetime(df['Date'], dayfirst=True, errors='coerce')
            df = df[df['Date'].notna()].sort_values('Date')
            for _, r in df.iterrows():
                fthg = float(r['FTHG']) if pd.notna(r.get('FTHG')) else 0
                ftag = float(r['FTAG']) if pd.notna(r.get('FTAG')) else 0
                _update_cache(cache, str(r['HomeTeam']), str(r['AwayTeam']), fthg, ftag)
        except: pass
    for mf in sorted(ODDS_DIR.glob('*_merged*.csv')):
        try:
            mdf = pd.read_csv(mf, usecols=['HomeTeam','AwayTeam','FTHG','FTAG'], low_memory=False)
            mdf = mdf[mdf['FTHG'].notna() & mdf['FTAG'].notna()]
            for _, r in mdf.iterrows():
                _update_cache(cache, str(r['HomeTeam']), str(r['AwayTeam']), float(r['FTHG']), float(r['FTAG']))
        except: pass
    print(f"  Loaded {len(cache)} teams from history", file=sys.stderr)
    return cache

def _asian_price_band(home_odd, away_odd):
    if 1.80 <= home_odd <= 2.10 and 1.80 <= away_odd <= 2.10: return 0
    if 1.70 <= home_odd <= 2.25 and 1.70 <= away_odd <= 2.25: return 1
    return 2

def parse_asian_handicap(bookmakers):
    if not bookmakers: return None, None, None
    candidates = []
    for bm in bookmakers:
        for bet in bm.get('bets', []):
            if bet.get('name') != 'Asian Handicap': continue
            grouped = {}
            for value in bet.get('values', []):
                raw = value.get('value', ''); odd = value.get('odd')
                parts = raw.split()
                if len(parts) < 2 or odd in (None, ''): continue
                side = parts[0]
                try:
                    num = float(parts[1].replace('+', ''))
                    price = float(odd)
                except: continue
                handicap = -num if side in ('Home', 'H') else num
                home_odd = price if side in ('Home', 'H') else price
                away_odd = price if side in ('Away', 'A') else price
                key = (handicap, bm.get('name', ''))
                if key not in grouped:
                    grouped[key] = {'handicap': handicap, 'bookmaker': bm.get('name', ''),
                                   'home_odd': home_odd, 'away_odd': away_odd,
                                   'price_band': _asian_price_band(home_odd, away_odd),
                                   'balance_gap': abs(home_odd - away_odd)}
                else:
                    if side in ('Home', 'H'): grouped[key]['home_odd'] = price
                    else: grouped[key]['away_odd'] = price
            for v in grouped.values():
                if v['home_odd'] > 0 and v['away_odd'] > 0:
                    candidates.append(v)
    if not candidates: return None, None, None
    candidates.sort(key=lambda r: (r['price_band'], PREFERRED_AH_BOOKMAKERS.get(r['bookmaker'], 999), r['balance_gap']))
    best = candidates[0]
    return best['handicap'], best['home_odd'], best['away_odd']

def save_outputs(df, hours=24, prefix='ah_1year_v3'):
    out_dir = REPO / 'predictions'
    out_dir.mkdir(exist_ok=True)
    csv_path = out_dir / f'{prefix}_{hours}h.csv'
    xlsx_path = out_dir / f'{prefix}_{hours}h.xlsx'
    df.to_csv(csv_path, index=False, encoding='utf-8')
    try:
        df.to_excel(xlsx_path, index=False)
    except Exception as e:
        print(f"XLSX save failed: {e}", file=sys.stderr)

def generate_predictions(hours=24, model_dir=None):
    if model_dir is None:
        model_dir = AH1_MODEL_DIR
    now = datetime.now()
    from datetime import timezone
    tz = timezone(timedelta(hours=8))
    # Extend range to include yesterday through 2 days ahead (catch evening/night matches)
    # From today to 3 days ahead (local time)
    start_dt = now.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
    end_dt = (now + timedelta(days=3)).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)

    print("Loading historical team stats...", file=sys.stderr)
    team_hist = _load_historical_stats()

    rows = []
    session = requests.Session()
    session.headers.update({'x-apisports-key': API_KEY})

    for code, league_id in LEAGUE_IDS.items():
        cfg_path = model_dir / code / 'optimized_pipeline.pkl'
        if not cfg_path.exists(): continue
        pipeline = joblib.load(cfg_path)
        mc = _get_cfg(code)

        _rate_call()
        try:
            # Query both NS and live statuses separately (API doesn't support comma-separated statuses)
            all_fixtures = []
            for sf in ['NS', '1H', '2H', 'HT', 'ET', 'P', 'FT']:
                resp = session.get(f'{API_BASE}/fixtures', params={
                    'league': league_id, 'season': 2025,
                    'from': start_dt.strftime('%Y-%m-%d'), 'to': end_dt.strftime('%Y-%m-%d'),
                    'timezone': 'Asia/Shanghai', 'status': sf
                }, timeout=15)
                if resp.status_code == 200:
                    all_fixtures.extend(resp.json().get('response', []))
                if sf != 'NS':
                    _rate_call()
            fixtures = all_fixtures
            print(f"[{code}] {len(fixtures)} fixtures (incl. live)", file=sys.stderr)
        except Exception as e:
            print(f"[{code}] Error: {e}", file=sys.stderr)
            continue

        for fix_data in fixtures:
            fi = fix_data.get('fixture', {})
            teams = fix_data.get('teams', {})
            home_name = teams.get('home', {}).get('name', '')
            away_name = teams.get('away', {}).get('name', '')
            fix_id = fi.get('id')
            kickoff_ts = fi.get('timestamp')
            kickoff = datetime.fromtimestamp(kickoff_ts, tz=tz).strftime('%m-%d %H:%M') if kickoff_ts else ''

            # Get live score
            goals_data = fix_data.get('goals', {})
            home_goals = goals_data.get('home')
            away_goals = goals_data.get('away')
            status_data = fi.get('status', {})
            match_status = status_data.get('short', 'NS')
            elapsed = status_data.get('elapsed')
            if match_status in ('1H','2H','HT','ET','P') and home_goals is not None:
                score_display = f"{int(home_goals)}-{int(away_goals)} {elapsed}'"
            elif match_status == 'FT' and home_goals is not None:
                score_display = f"{int(home_goals)}-{int(away_goals)} 全场"
            else:
                score_display = '-'

            _rate_call()
            try:
                r2 = session.get(f'{API_BASE}/odds', params={
                    'fixture': fix_id, 'timezone': 'Asia/Shanghai'
                }, timeout=15)
                if r2.status_code != 200:
                    handicap, ahh, aha = None, None, None
                else:
                    odds_data = r2.json().get('response', [])
                    handicap, ahh, aha = parse_asian_handicap(
                        odds_data[0].get('bookmakers', []) if odds_data else [])
            except:
                handicap, ahh, aha = None, None, None

            if handicap is None or ahh is None or aha is None: continue
            if ahh < 1.0 or aha < 1.0: continue

            feat_df = _build_feature_row(handicap, ahh, aha, home_name, away_name, team_hist)
            try:
                prob = float(pipeline.predict_proba(feat_df.values)[0, 1])
            except:
                prob = 0.5

            th_p = mc['threshold']
            direction = mc['direction']

            if direction == 'over':
                if prob >= th_p:
                    pred_side = '主队'   # Changed from '上盘'
                    rec_level, rec_idx = recommend_level(prob * 100)
                elif prob <= (1 - th_p):
                    pred_side = '客队'   # Changed from '下盘'
                    rec_level, rec_idx = recommend_level((1 - prob) * 100)
                else:
                    pred_side = '不确定'
                    rec_level, rec_idx = '✗ 不建议', 0
            else:
                if prob <= (1 - th_p):
                    pred_side = '主队'
                    rec_level, rec_idx = recommend_level(prob * 100)
                elif prob >= th_p:
                    pred_side = '客队'
                    rec_level, rec_idx = recommend_level((1 - prob) * 100)
                else:
                    pred_side = '不确定'
                    rec_level, rec_idx = '✗ 不建议', 0

            conf = abs(prob - 0.5) * 2 * 100
            conf_str = f'{conf:.1f}'
            model_acc = mc.get('accuracy', 0) * 100

            rows.append({
                'fixture_id': fix_id,
                '联赛代码': code,
                '预测生成时间': now.strftime('%Y-%m-%d %H:%M:%S'),
                '预测日期': now.strftime('%Y-%m-%d'),
                '预测窗口小时': hours,
                '联赛': LEAGUE_NAMES.get(code, code),
                '开赛时间': kickoff,
                '比分': score_display,
                '主队': team_name_zh(home_name),
                '客队': team_name_zh(away_name),
                '主队原名': home_name,
                '客队原名': away_name,
                '让球数': handicap,
                '上盘赔率': ahh,
                '下盘赔率': aha,
                '上盘概率': f'{prob * 100:.1f}',
                '下盘概率': f'{(1 - prob) * 100:.1f}',
                '预测方向': pred_side,
                '置信区间': confidence_band(conf),
                '置信度': conf_str,
                '推荐等级': rec_level,
                '推荐级别': rec_idx,
                '模型类型': '亚盘-1year-V6',
                '模型准确率': f'{model_acc:.1f}',
                '模型配置': f"{mc.get('model_type','')} th={th_p:.2f} {direction}",
            })

        time.sleep(0.2)

    return pd.DataFrame(rows)

if __name__ == '__main__':
    df = generate_predictions(hours=24)
    save_outputs(df, hours=24, prefix='ah_1year_v6')
    print(f"生成 {len(df)} 条预测 → predictions/ah_1year_v6_24h.csv")
