#!/usr/bin/env python3
"""当前持仓(已买入未卖出)按强度top10/池 交易详情图(40格模板v3)"""
import pandas as pd
import numpy as np
import json, sys
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
plt.rcParams['font.sans-serif'] = ['STHeiti', 'Noto Sans CJK SC', 'WenQuanYi Zen Hei', 'SimHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
sys.path.insert(0, '/Users/xpresso/zt_app/code')
import grid_or as g
from scan_daily_tb import COMBO, SELL_COMBO, simulate_sell, PN
from plot_ideal_top10 import chain_for_buy
from build_strength_model import feats   # 唯一构建器(裁定 §4.6)

BASE = g.BASE
sb = pd.read_pickle(f'{BASE}/stock_basic.pkl')
name_map = dict(zip(sb['ts_code'], sb['name']))
ind_map = dict(zip(sb['ts_code'], sb['industry']))
model = json.load(open(f'{BASE}/strength_model.json'))
w = model['weights']
pools_m = model['pools']

def pct(pool, fc, v):
    qs = pools_m[pool]['percentiles'][fc]
    if qs is None or v is None or (isinstance(v, float) and np.isnan(v)):
        return 0.5
    return float(np.searchsorted(qs, v) / 100.0)

def strength_of(pool, fdict):
    raw = sum(w[fc] * pct(pool, fc, fdict[fc]) for fc in w)
    rs = pools_m[pool]['raw_scores']
    return float(np.searchsorted(rs, raw) / len(rs) * 100)

cache = {}
def load(pool):
    if pool not in cache:
        cache[pool] = g.load_pool(pool)
    return cache[pool]

# 1. 收集持仓+特征+强度
holdings = []
# 强度直接从baseline读(v6 14特征含上下文, 绘图侧无池统计无法重算; baseline为scan写入的同源值)
bdf_ref = pd.read_csv(f'{BASE}/strength_baseline.csv', dtype={'buy_date': str})
for pool in COMBO:
    bp = COMBO[pool]
    members, dailies = load(pool)
    sig = g.build_sig(dailies, bp)
    dates = sorted(set().union(*[set(d['trade_date']) for d in dailies.values()]))
    latest = dates[-1]
    # 口径: 全部历史信号(2024-02起)中, 假设买入并按卖出规则模拟后截止最新交易日仍未卖出(或最新日当天触发卖出)的
    # 买入日须早于最新交易日(bd<latest; 最新日突破的明日候选由plot_signals_today.py单独画)
    # 注: 各池hold=15日超时强制卖, 因此天然只含近15交易日买入的信号, 无需再手动限窗
    # 当期成分过滤(与回测引擎run_backtest的active_members口径一致):
    # 已调出成分的历史股票(如美锦能源2023-11调出hs300)不得在本池产生持仓
    active = g.active_members(members, dates[-1])
    for code, bs in sig.items():
        if code not in active:
            continue
        for (bd, t0d) in bs:
            if bd >= latest:
                continue
            d = dailies[code].sort_values('trade_date').reset_index(drop=True)
            res = simulate_sell(d, {'code': code, 'buy_date': bd}, SELL_COMBO[pool])
            if res is not None and res['sell_date'] != dates[-1]:
                continue  # 已卖出且非今日: 不在持仓图
            # res is None = 持仓中(未卖出); res.sell_date==今日 = 今日卖出(也展示)
            t0i, tai, tbi = chain_for_buy(d, code, bp, bd)
            if t0i is None:
                continue
            f = feats(d, t0i, tai, tbi, None)
            # strength优先读baseline(scan同源); 缺失时用feats+14特征兜底(缺上下文特征按中位0.5)
            brow = bdf_ref[(bdf_ref['code'] == code) & (bdf_ref['buy_date'] == bd)]
            if len(brow) and not np.isnan(brow['strength'].iloc[0]):
                std = float(brow['strength'].iloc[0])
            else:
                f.setdefault('f12_crowd_pool', np.nan); f.setdefault('f13_pool_zt', np.nan); f.setdefault('f14_pool_avgret', np.nan)
                std = strength_of(pool, f)
            rows = d[d['trade_date'] == bd]
            if len(rows) == 0:
                continue
            buy_px = float(rows.iloc[0]['raw_open']) * (1 + g.SLIP)   # 成交价=原始价
            last = float(d.iloc[-1]['raw_close'])                     # 估值=原始价
            ret = last / buy_px - 1
            hold_n = len(d[d['trade_date'] > bd])
            holdings.append({'pool': pool, 'code': code, 'buy_date': bd, 't0i': t0i, 'tai': tai, 'tbi': tbi,
                             'buy_px': buy_px, 'cur': last, 'ret': ret, 'hold_days': hold_n, 'std': std,
                             'name': name_map.get(code, '?'), 'ind': ind_map.get(code, '?'),
                             'sell': res})  # sell=None=持仓中, dict=今日卖出信息
    print(f"{PN[pool]}: 未卖出信号{sum(1 for h in holdings if h['pool']==pool)}只", flush=True)

# 同池同股去重(与回测引擎run_backtest的"持仓中跳过新信号"一致):
# 引擎中同股同时只会有1笔未卖出持仓, 图须同口径, 保留最早买入的那笔
dedup = {}
for h in holdings:
    k = (h['pool'], h['code'])
    if k not in dedup or h['buy_date'] < dedup[k]['buy_date']:
        dedup[k] = h
holdings = list(dedup.values())

# 2. 每池强度top10
samples = {}
for pool in COMBO:
    ph = sorted([h for h in holdings if h['pool'] == pool], key=lambda x: x['std'], reverse=True)[:10]
    samples[pool] = ph
    if ph:
        print(f"{PN[pool]} top10: 强度{ph[0]['std']:.0f}~{ph[-1]['std']:.0f} 浮盈{ph[0]['ret']*100:+.1f}%~{ph[-1]['ret']*100:+.1f}%", flush=True)

# 3. 画图
def draw_holding(gs, row, col, h, d):
    t0i, tai, tbi = h['t0i'], h['tai'], h['tbi']
    std = h['std']
    tier = '强' if std >= 85 else ('中' if std >= 30 else '弱')
    tier_c = {'强': '#E91E63', '中': '#FF8F00', '弱': '#7f8c8d'}[tier]
    gss = gs[row, col].subgridspec(2, 1, height_ratios=[2.6, 1], hspace=0.06)
    ax = fig.add_subplot(gss[0])
    axv = fig.add_subplot(gss[1], sharex=ax)
    bd = pd.Timestamp(h['buy_date'])
    t0d = pd.Timestamp(d['trade_date'].iloc[t0i])
    win_start = t0d - pd.Timedelta(days=4)
    mask = pd.to_datetime(d['trade_date']) >= win_start
    sub = d[mask].reset_index(drop=True)
    x = np.arange(len(sub))
    for xi, r in sub.iterrows():
        o, hi, lo, c_ = r['raw_open'], r['raw_high'], r['raw_low'], r['raw_close']   # K线=原始价: 与下方 raw 买卖标注同轴, 避免除权日错位
        up = c_ >= o
        color = '#e74c3c' if up else '#2ecc71'
        ax.plot([xi, xi], [lo, hi], color=color, linewidth=0.7, zorder=2)
        ax.add_patch(plt.Rectangle((xi - 0.35, min(o, c_)), 0.7, max(abs(c_ - o), 0.01), facecolor=color, edgecolor=color, linewidth=0.3, zorder=3))
        axv.bar(xi, r['vol'], color=color, alpha=0.7, width=0.7)
    # 信号链 (日期→sub内序号) + 文字标注
    ymax = ax.get_ylim()[1]
    ymin = ax.get_ylim()[0]
    for idx, color, txt in [(t0i, '#e74c3c', 'T0'), (tai, '#1565C0', 'ta'), (tbi, '#FF8F00', 'tb')]:
        dt_s = str(d['trade_date'].iloc[idx])
        pos = np.where(sub['trade_date'].values == dt_s)[0]
        if len(pos):
            ax.axvline(pos[0], color=color, linestyle='--', linewidth=0.9, alpha=0.8)
            axv.axvline(pos[0], color=color, linestyle='--', linewidth=0.9, alpha=0.8)
            ax.text(pos[0], ymin + (ymax - ymin) * 0.97, txt, fontsize=8, color=color, ha='center', fontweight='bold',
                    bbox=dict(facecolor='white', alpha=0.7, edgecolor='none', pad=0.6))
    # 买入点
    bd_num = x[(sub['trade_date'] == h['buy_date']).idxmax()] if (sub['trade_date'] == h['buy_date']).any() else None
    if bd_num is not None:
        ax.scatter(bd_num, h['buy_px'], marker='^', color='#E91E63', s=70, zorder=6, edgecolors='white', linewidths=0.8)
        ax.annotate(f"买{h['buy_px']:.1f}", (bd_num, h['buy_px']), textcoords='offset points', xytext=(0, 8), fontsize=8, ha='center', color='#E91E63', fontweight='bold')
    # 持仓标注(最新): sell=None=持仓中, 有值=今日卖出
    xlast = x[-1]
    if h.get('sell'):
        s = h['sell']
        ax.scatter(xlast, s['sell_price'], marker='x', color='#2c3e50', s=60, zorder=6, edgecolors='white', linewidths=1.2)
        ax.annotate(f"今卖{s['reason']} {s['ret_net']*100:+.1f}%", (xlast, s['sell_price']), textcoords='offset points', xytext=(0, -14), fontsize=7.5, ha='center', color='#2c3e50', fontweight='bold')
        ax.set_title(f"{h['name']}·{h['ind']}\n{h['code'][:9]} {PN[h['pool']]} 持{h['hold_days']}天 今日卖出", fontsize=8.5, fontweight='bold')
    else:
        ax.scatter(xlast, h['cur'], marker='o', color='#2c3e50', s=50, zorder=6, edgecolors='white', linewidths=0.8)
        ax.annotate(f"持仓{h['ret']*100:+.1f}%", (xlast, h['cur']), textcoords='offset points', xytext=(0, -11), fontsize=8, ha='center', color='#2c3e50', fontweight='bold')
        ax.set_title(f"{h['name']}·{h['ind']}\n{h['code'][:9]} {PN[h['pool']]} 持{h['hold_days']}天 浮{h['ret']*100:+.1f}%", fontsize=8.5, fontweight='bold')
    ax.text(0.98, 0.02, f"强度{std:.0f}·{tier}", transform=ax.transAxes, fontsize=7.5, fontweight='bold',
            ha='right', va='bottom', color=tier_c, bbox=dict(facecolor='white', alpha=0.85, edgecolor=tier_c, linewidth=0.8, pad=1.5))
    # 关键节点x轴(年月日) - 统一sub内序号
    key_ticks = []
    for idx in [t0i, tai, tbi]:
        dt_s = str(d['trade_date'].iloc[idx])
        pos = np.where(sub['trade_date'].values == dt_s)[0]
        if len(pos):
            key_ticks.append((pos[0], dt_s))
    if bd_num is not None:
        key_ticks.append((bd_num, h['buy_date']))
    key_ticks.append((xlast, d.iloc[-1]['trade_date']))
    ticks = sorted(set(p for p, _ in key_ticks))
    labs = dict(key_ticks)
    ax.set_xticks(ticks)
    axv.set_xticks(ticks)
    axv.set_xticklabels([f"{labs[p][2:4]}-{labs[p][4:6]}-{labs[p][6:]}" for p in ticks], fontsize=6, rotation=45)
    ax.tick_params(labelbottom=False)
    ax.tick_params(labelsize=5)
    axv.tick_params(labelsize=4)
    ax.grid(True, alpha=0.12)
    axv.grid(True, alpha=0.12)

fig = plt.figure(figsize=(26, 31))
gs = fig.add_gridspec(10, 4, hspace=0.34, wspace=0.22)
# 列标题: 左→右 = 四池 (统计框下方, 不与标题/统计框重叠)
for pi, pool in enumerate(COMBO):
    fig.text(0.05 + (pi + 0.5) * (0.935 / 4), 0.954, PN[pool], fontsize=14, fontweight='bold', ha='center', color='#2c3e50')
for pi, pool in enumerate(COMBO):
    for j in range(10):
        row, col = j, pi  # 行=强度排名(上→下递减), 列=池(左→右四池)
        if j < len(samples[pool]):
            h = samples[pool][j]
            d = cache[pool][1][h['code']].sort_values('trade_date').reset_index(drop=True)
            draw_holding(gs, row, col, h, d)
        else:
            ax = fig.add_subplot(gs[row, col])
            ax.text(0.5, 0.5, f"{PN[pool]}\n持仓不足10只\n(当前{len(samples[pool])}只)",
                    ha='center', va='center', fontsize=13, color='#999', fontweight='bold')
            ax.set_xticks([]); ax.set_yticks([])
            for sp in ax.spines.values():
                sp.set_visible(False)
from matplotlib.lines import Line2D
leg = [Line2D([0], [0], marker='^', color='w', markerfacecolor='#E91E63', markersize=9, label='买入(次日开盘)'),
       Line2D([0], [0], marker='o', color='w', markerfacecolor='#2c3e50', markersize=9, label='当前持仓(浮盈)'),
       Line2D([0], [0], color='#e74c3c', linestyle='--', label='T0涨停日'),
       Line2D([0], [0], color='#1565C0', linestyle='--', label='ta缩量回调日'),
       Line2D([0], [0], color='#FF8F00', linestyle='--', label='tb突破日')]
fig.legend(handles=leg, loc='lower center', ncol=5, fontsize=12, framealpha=0.9, bbox_to_anchor=(0.5, 0.012))
fig.suptitle('四池近期历史信号(近5交易日买入·未卖出)强度Top10共40只: 强度v6分档标注(右上角)', fontsize=15, y=0.997)
n_total = len(holdings)
n_strong = sum(1 for h in holdings if h['std'] >= 85)
n_mid = sum(1 for h in holdings if 30 <= h['std'] < 85)
n_weak = sum(1 for h in holdings if h['std'] < 30)
bs_ = bdf_ref['strength'].dropna()
rt_ = bdf_ref.loc[bs_.index, 'ret']
fig.text(0.5, 0.978, f"当前持仓共{n_total}只(近5交易日信号): 强{n_strong} 中{n_mid} 弱{n_weak}   |   分档(全{len(bs_)}笔): 强≥85均{rt_[bs_>=85].mean()*100:+.1f}%胜{(rt_[bs_>=85]>0).mean()*100:.0f}% 中30-85均{rt_[(bs_>=30)&(bs_<85)].mean()*100:+.1f}%胜{(rt_[(bs_>=30)&(bs_<85)]>0).mean()*100:.0f}% 弱<30均{rt_[bs_<30].mean()*100:+.1f}%胜{(rt_[bs_<30]>0).mean()*100:.0f}%   |   买入门槛=强档线: ≥85",
         fontsize=11.5, ha='center', bbox=dict(facecolor='#f8f8f8', edgecolor='#999', linewidth=0.8, boxstyle='round,pad=0.4'))
fig.subplots_adjust(top=0.945, bottom=0.05, left=0.05, right=0.985, hspace=0.34, wspace=0.22)
out = f'{BASE}/backtest_zt_holdings_top10.png'
plt.savefig(out, dpi=200)
print(f"近期历史信号top10图: {out}")
