#!/usr/bin/env python3 """达播业务可视化图表生成""" import openpyxl import glob import numpy as np import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import matplotlib.ticker as mticker from matplotlib.patches import FancyBboxPatch from datetime import datetime, timedelta from collections import defaultdict import os # ============ Chinese font setup ============ plt.rcParams['font.sans-serif'] = ['WenQuanYi Micro Hei', 'WenQuanYi Zen Hei', 'Noto Sans CJK SC', 'SimHei', 'DejaVu Sans'] plt.rcParams['axes.unicode_minus'] = False # ============ Color palette ============ C_PRIME = '#2563EB' # 主色 C_ACCENT = '#F59E0B' # 强调 C_DANGER = '#EF4444' # 危险/退款 C_SUCCESS = '#10B981' # 健康 C_PURPLE = '#8B5CF6' C_TEAL = '#14B8A6' C_PINK = '#EC4899' C_GRAY = '#6B7280' C_LIGHT = '#F3F4F6' C_DARK = '#1F2937' PALETTE = ['#2563EB','#F59E0B','#EF4444','#8B5CF6','#10B981','#EC4899','#14B8A6','#F97316','#6366F1','#84CC16'] # ============ Data loading ============ files = glob.glob('/root/.openclaw/media/inbound/*3403f15a*') wb = openpyxl.load_workbook(files[0]) ws = wb['Sheet1'] def excel_date_to_str(val): if val is None: return None if isinstance(val, datetime): return val.strftime('%Y-%m-%d') if isinstance(val, str): return val if isinstance(val, (int, float)): try: return (datetime(1899,12,30)+timedelta(days=int(val))).strftime('%Y-%m-%d') except: return str(val) return str(val) all_data = [] for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=True): name = str(row[0]).strip() if row[0] else '' if '合计' in name or '总计' in name: continue orders = row[3] if orders is None: continue all_data.append({ 'name': name, 'date': excel_date_to_str(row[1]), 'platform': str(row[2]).strip() if row[2] else '', 'orders': orders, 'gmv': row[4] or 0, 'refund_orders': row[5] or 0, 'refund_amount': row[6] or 0, 'gsv': row[10] or 0, }) # Name normalization name_map = { '晚柠也是个妈妈了':'晚柠','晚柠':'晚柠', '念妈讲学习规划':'念妈','念妈':'念妈', '学霸三人行':'学霸三人行','学霸老王':'学霸老王', '开心妈妈学习宝藏':'开心妈妈','开心爸育儿':'开心爸', '小花生网':'小花生网','小花生':'小花生网', '盈姐':'盈姐','百克力':'百克力','亮爸':'亮爸', '万物分销':'万物分销','万物内购':'万物分销','万物团购':'万物分销', '小小鹰萱妈':'小小鹰萱妈','老狼聊育儿':'老狼聊育儿', '海淀妈妈优选':'海淀妈妈优选','神奇瓜妈聊成长':'神奇瓜妈', '宣儿妈妈':'宣儿妈妈','宣儿麻麻':'宣儿妈妈', '四个娃的组合生活':'四个娃的组合生活','肆个葫芦娃的妈':'四个娃的组合生活', '小暖阿姨慢一点':'小暖阿姨', } for r in all_data: r['norm_name'] = name_map.get(r['name'], r['name']) # Monthly summary data (from the spreadsheet summary rows) monthly = [ ('2025-09', 382, 763618, 197, 393803, 369615, 2), ('2025-10', 390, 779610, 156, 311844, 466116, 6), ('2025-11', 222, 443778, 105, 209895, 232683, 4), ('2025-12', 190, 327241, 44, 74976, 251365, 4), ('2026-01', 154, 305250, 40, 79311, 224939, 10), ('2026-02', 239, 477761, 161, 321839, 143927.5, 1), ('2026-03', 838, 2703044, 254, 831703, 1695826, 11), ('2026-04', 1480, 4645465, 679, 2110850, 2507226.5, 23), ('2026-05', 492, 1616312, 150, 485334, 1130978, 15), ] months = [m[0] for m in monthly] month_labels = ['9月','10月','11月','12月','1月','2月','3月','4月','5月'] m_orders = [m[1] for m in monthly] m_gmv = [m[2] for m in monthly] m_gsv = [m[4] for m in monthly] m_refund_amt = [m[5] for m in monthly] m_refund_rate = [(m[3]/m[1]*100) if m[1]>0 else 0 for m in monthly] m_sessions = [m[6] for m in monthly] # Influencer aggregate inf = defaultdict(lambda:{'gmv':0,'gsv':0,'orders':0,'ref_ords':0}) for r in all_data: n = r['norm_name'] inf[n]['gmv'] += r['gmv'] inf[n]['gsv'] += r['gsv'] inf[n]['orders'] += r['orders'] inf[n]['ref_ords'] += r['refund_orders'] inf_sorted = sorted(inf.items(), key=lambda x:x[1]['gmv'], reverse=True) top10 = inf_sorted[:10] top_names = [x[0] for x in top10] top_gmv = [x[1]['gmv']/10000 for x in top10] top_gsv = [x[1]['gsv']/10000 for x in top10] top_ref_rate = [(x[1]['ref_ords']/x[1]['orders']*100) if x[1]['orders']>0 else 0 for x in top10] # Platform aggregate plat = defaultdict(lambda:{'gmv':0,'gsv':0,'orders':0,'ref_ords':0}) for r in all_data: p = r['platform'] if r['platform'] else '未标注' plat[p]['gmv'] += r['gmv'] plat[p]['gsv'] += r['gsv'] plat[p]['orders'] += r['orders'] plat[p]['ref_ords'] += r['refund_orders'] # Merge similar platforms plat_merged = defaultdict(lambda:{'gmv':0,'gsv':0,'orders':0,'ref_ords':0}) merge_map = { '三开':'三开','小红书':'小红书','小红书-混场':'小红书', '抖音+视频号':'抖音+视频号','抖音&视频号':'抖音+视频号', '视频号、抖音':'抖音+视频号','视频号+抖音':'抖音+视频号','视频号1+抖音':'抖音+视频号', '视频号':'视频号','视频号2':'视频号','视频号+小红书':'视频号+小红书', '视频号+小红书+抖音':'三开(全平台)','抖音、视频号、小红书':'三开(全平台)', '抖音':'抖音','抖音+微信小店':'抖音', '微信小店':'微信小店','公众号':'公众号','社群':'社群', '分销-内购':'分销','分销-开团':'分销','分销-团购下架':'分销', '万物':'万物', } for p, s in plat.items(): mp = merge_map.get(p, p) plat_merged[mp]['gmv'] += s['gmv'] plat_merged[mp]['gsv'] += s['gsv'] plat_merged[mp]['orders'] += s['orders'] plat_merged[mp]['ref_ords'] += s['ref_ords'] plat_sorted = sorted(plat_merged.items(), key=lambda x:x[1]['gmv'], reverse=True) plat_names = [x[0] for x in plat_sorted] plat_gmv = [x[1]['gmv']/10000 for x in plat_sorted] plat_ref_rate = [(x[1]['ref_ords']/x[1]['orders']*100) if x[1]['orders']>0 else 0 for x in plat_sorted] # ============================================ # FIGURE 1: 月度趋势 —— GMV/GSV + 退款率 # ============================================ fig, axes = plt.subplots(2, 3, figsize=(20, 12)) fig.patch.set_facecolor('#FAFBFC') # Chart 1: GMV & GSV Trend ax1 = axes[0, 0] x = np.arange(len(months)) bars = ax1.bar(x - 0.15, np.array(m_gmv)/10000, 0.3, color=C_PRIME, alpha=0.85, label='GMV (万元)', zorder=3) bars2 = ax1.bar(x + 0.15, np.array(m_gsv)/10000, 0.3, color=C_SUCCESS, alpha=0.85, label='GSV (万元)', zorder=3) # Add value labels for bar, val in zip(bars, m_gmv): ax1.text(bar.get_x()+bar.get_width()/2, bar.get_height()+0.5, f'{val/10000:.1f}', ha='center', va='bottom', fontsize=7.5, fontweight='bold', color=C_PRIME) for bar, val in zip(bars2, m_gsv): ax1.text(bar.get_x()+bar.get_width()/2, bar.get_height()+0.5, f'{val/10000:.1f}', ha='center', va='bottom', fontsize=7.5, fontweight='bold', color=C_SUCCESS) ax1.set_xticks(x) ax1.set_xticklabels(month_labels, fontsize=10) ax1.set_title('月度 GMV & GSV 趋势', fontsize=14, fontweight='bold', pad=12) ax1.set_ylabel('万元', fontsize=10) ax1.legend(loc='upper left', fontsize=9, framealpha=0.9) ax1.grid(axis='y', alpha=0.3, zorder=0) ax1.set_ylim(0, max(m_gmv)/10000*1.2) # Chart 2: Refund Rate + Sessions ax2 = axes[0, 1] ax2_twin = ax2.twinx() x = np.arange(len(months)) line1 = ax2.plot(x, m_refund_rate, 'o-', color=C_DANGER, linewidth=2.5, markersize=8, zorder=4, label='退款率(%)') bars3 = ax2_twin.bar(x, m_sessions, 0.5, color=C_PURPLE, alpha=0.25, zorder=2, label='直播场次') # Mark danger zone ax2.axhline(y=40, color=C_DANGER, linestyle='--', alpha=0.4, linewidth=1) ax2.text(len(months)-0.5, 41, '40%警戒线', fontsize=8, color=C_DANGER, alpha=0.7) for i, (r, s) in enumerate(zip(m_refund_rate, m_sessions)): ax2.annotate(f'{r:.1f}%', (i, r), textcoords="offset points", xytext=(0,12), ha='center', fontsize=8.5, fontweight='bold', color=C_DANGER) ax2.set_xticks(x) ax2.set_xticklabels(month_labels, fontsize=10) ax2.set_title('月度退款率 & 直播场次', fontsize=14, fontweight='bold', pad=12) ax2.set_ylabel('退款率 (%)', fontsize=10, color=C_DANGER) ax2_twin.set_ylabel('场次', fontsize=10, color=C_PURPLE) ax2.tick_params(axis='y', colors=C_DANGER) ax2_twin.tick_params(axis='y', colors=C_PURPLE) ax2.grid(axis='y', alpha=0.3, zorder=0) ax2.set_ylim(0, 80) # Chart 3: Monthly Refund Amount Breakdown (stacked bar) ax3 = axes[0, 2] x = np.arange(len(months)) gmv_arr = np.array(m_gmv)/10000 gsv_arr = np.array(m_gsv)/10000 refund_arr = gmv_arr - gsv_arr ax3.bar(x, gsv_arr, color=C_SUCCESS, alpha=0.85, label='GSV 实收', zorder=3) ax3.bar(x, refund_arr, bottom=gsv_arr, color=C_DANGER, alpha=0.6, label='退款金额', zorder=3) for i in range(len(months)): total = gmv_arr[i] net = gsv_arr[i] rate = (total-net)/total*100 if total>0 else 0 ax3.text(i, total+1, f'{total:.1f}万', ha='center', fontsize=8, fontweight='bold', color=C_DARK) ax3.text(i, net/2, f'{net/total*100:.0f}%', ha='center', fontsize=7.5, color='white', fontweight='bold') ax3.set_xticks(x) ax3.set_xticklabels(month_labels, fontsize=10) ax3.set_title('GMV 构成:实收 vs 退款', fontsize=14, fontweight='bold', pad=12) ax3.set_ylabel('万元', fontsize=10) ax3.legend(loc='upper right', fontsize=9) ax3.grid(axis='y', alpha=0.3, zorder=0) # Chart 4: TOP10 Influencer GMV ax4 = axes[1, 0] y_pos = np.arange(len(top_names)) colors_bar = [C_PRIME if i==0 else C_ACCENT if i==1 else '#94A3B8' for i in range(len(top_names))] bars4 = ax4.barh(y_pos, top_gmv, color=colors_bar, height=0.65, zorder=3) for bar, val, gsv_val in zip(bars4, top_gmv, top_gsv): ax4.text(bar.get_width()+1, bar.get_y()+bar.get_height()/2, f'GMV ¥{val:.0f}万 | GSV ¥{gsv_val:.0f}万', va='center', fontsize=8, color=C_DARK) ax4.set_yticks(y_pos) ax4.set_yticklabels(top_names, fontsize=10) ax4.set_title('达人 GMV 排行 TOP10', fontsize=14, fontweight='bold', pad=12) ax4.set_xlabel('GMV (万元)', fontsize=10) ax4.invert_yaxis() ax4.grid(axis='x', alpha=0.3, zorder=0) ax4.set_xlim(0, max(top_gmv)*1.25) # Chart 5: TOP10 Refund Rate Comparison ax5 = axes[1, 1] y_pos = np.arange(len(top_names)) rate_colors = [C_SUCCESS if r < 30 else C_DANGER if r > 45 else C_ACCENT for r in top_ref_rate] bars5 = ax5.barh(y_pos, top_ref_rate, color=rate_colors, height=0.65, zorder=3) for bar, val in zip(bars5, top_ref_rate): ax5.text(bar.get_width()+1, bar.get_y()+bar.get_height()/2, f'{val:.1f}%', va='center', fontsize=9, fontweight='bold', color=C_DARK) ax5.axvline(x=40, color=C_DANGER, linestyle='--', alpha=0.4, linewidth=1.5) ax5.text(41, len(top_names)-0.5, '整体均值 40%', fontsize=8, color=C_DANGER, alpha=0.7) ax5.set_yticks(y_pos) ax5.set_yticklabels(top_names, fontsize=10) ax5.set_title('达人退款率对比', fontsize=14, fontweight='bold', pad=12) ax5.set_xlabel('退款率 (%)', fontsize=10) ax5.invert_yaxis() ax5.grid(axis='x', alpha=0.3, zorder=0) ax5.set_xlim(0, max(top_ref_rate)*1.3) # Chart 6: Platform GMV + refund rate ax6 = axes[1, 2] y_pos = np.arange(len(plat_names)) plat_rate_colors = [C_SUCCESS if r < 30 else C_DANGER if r > 45 else C_ACCENT for r in plat_ref_rate] bars6 = ax6.barh(y_pos, plat_gmv, color=plat_rate_colors, height=0.65, alpha=0.85, zorder=3) for bar, val, rate in zip(bars6, plat_gmv, plat_ref_rate): ax6.text(bar.get_width()+1, bar.get_y()+bar.get_height()/2, f'¥{val:.0f}万 (退率{rate:.0f}%)', va='center', fontsize=8, color=C_DARK) ax6.set_yticks(y_pos) ax6.set_yticklabels(plat_names, fontsize=10) ax6.set_title('平台/渠道 GMV 对比', fontsize=14, fontweight='bold', pad=12) ax6.set_xlabel('GMV (万元)', fontsize=10) ax6.invert_yaxis() ax6.grid(axis='x', alpha=0.3, zorder=0) ax6.set_xlim(0, max(plat_gmv)*1.3) plt.suptitle('瓦拉英语 · 达人直播业务全景分析 (2025.09 - 2026.05)', fontsize=18, fontweight='bold', y=1.01) plt.tight_layout(pad=3) out_path = '/root/.openclaw/workspace/output/daren_biz_charts.png' plt.savefig(out_path, dpi=180, bbox_inches='tight', facecolor=fig.get_facecolor(), edgecolor='none') plt.close() print(f"✅ Chart 1 saved: {out_path}") print(f" File size: {os.path.getsize(out_path)/1024:.0f} KB") # ============================================ # FIGURE 2: 月度GMV瀑布图 & 达人月度贡献矩阵 # ============================================ fig2, axes2 = plt.subplots(1, 2, figsize=(20, 8)) fig2.patch.set_facecolor('#FAFBFC') # Chart 7: Monthly waterfall by influencer ax7 = axes2[0] # Get top5 influencers monthly data top5_names = [x[0] for x in inf_sorted[:5]] inf_monthly = defaultdict(lambda: defaultdict(float)) for r in all_data: n = r['norm_name'] if n not in top5_names: n = '其他达人' d = r['date'] if d and len(d) >= 7: m = d[:7] # Map to correct month labels month_keys = { '2026-09':'2025-09','2026-10':'2025-10','2026-11':'2025-11','2026-12':'2025-12', '2026-01':'2026-01','2026-02':'2026-02','2026-03':'2026-03', '2026-04':'2026-04','2026-05':'2026-05', '2025-12':'2025-12' } m = month_keys.get(m, m) inf_monthly[m][n] += r['gmv']/10000 all_months_sorted = ['2025-09','2025-10','2025-11','2025-12','2026-01','2026-02','2026-03','2026-04','2026-05'] x = np.arange(len(all_months_sorted)) bottom = np.zeros(len(all_months_sorted)) colors_stack = [C_PRIME, C_ACCENT, C_PURPLE, C_DANGER, C_TEAL, '#CBD5E1'] for idx, name in enumerate(top5_names + ['其他达人']): vals = [inf_monthly[m].get(name, 0) for m in all_months_sorted] ax7.bar(x, vals, bottom=bottom, color=colors_stack[idx], alpha=0.88, label=name, zorder=3) bottom += np.array(vals) ax7.set_xticks(x) ax7.set_xticklabels(month_labels, fontsize=10) ax7.set_title('月度 GMV 达人贡献拆解 (万元)', fontsize=14, fontweight='bold', pad=12) ax7.set_ylabel('GMV (万元)', fontsize=10) ax7.legend(loc='upper left', fontsize=9, framealpha=0.95, ncol=2) ax7.grid(axis='y', alpha=0.3, zorder=0) # Chart 8: Efficiency Matrix (bubble chart) - GMV vs Refund Rate, bubble size = orders ax8 = axes2[1] # Filter top 15 influencers for clarity top15 = inf_sorted[:15] for name, s in top15: rr = (s['ref_ords']/s['orders']*100) if s['orders']>0 else 0 gmv_w = s['gmv']/10000 order_sz = s['orders'] color = C_SUCCESS if rr < 30 else C_DANGER if rr > 45 else C_ACCENT ax8.scatter(gmv_w, rr, s=order_sz*8, color=color, alpha=0.7, edgecolors='white', linewidth=1.5, zorder=4) ax8.annotate(name, (gmv_w, rr), textcoords="offset points", xytext=(8,5), fontsize=8.5, fontweight='bold', color=C_DARK) ax8.axhline(y=40, color=C_DARK, linestyle='--', alpha=0.3, linewidth=1) ax8.set_xlabel('GMV (万元)', fontsize=11) ax8.set_ylabel('退款率 (%)', fontsize=11) ax8.set_title('达人效率矩阵 (气泡大小=订单量)', fontsize=14, fontweight='bold', pad=12) # Add quadrant labels x_max = ax8.get_xlim()[1] y_max = ax8.get_ylim()[1] ax8.text(x_max*0.75, 15, '★ 优质区\n高GMV 低退率', fontsize=9, color=C_SUCCESS, fontweight='bold', alpha=0.8) ax8.text(x_max*0.75, y_max*0.75, '风险区\n高GMV 高退率', fontsize=9, color=C_DANGER, fontweight='bold', alpha=0.8) ax8.grid(alpha=0.3, zorder=0) plt.suptitle('瓦拉英语 · 达人结构分析 & 效率评估', fontsize=16, fontweight='bold', y=1.02) plt.tight_layout(pad=3) out_path2 = '/root/.openclaw/workspace/output/daren_biz_charts2.png' plt.savefig(out_path2, dpi=180, bbox_inches='tight', facecolor=fig2.get_facecolor(), edgecolor='none') plt.close() print(f"✅ Chart 2 saved: {out_path2}") print(f" File size: {os.path.getsize(out_path2)/1024:.0f} KB") # ============================================ # FIGURE 3: 退款率热力图 & 月度场次+单均GMV # ============================================ fig3, axes3 = plt.subplots(2, 1, figsize=(20, 10)) fig3.patch.set_facecolor('#FAFBFC') # Chart 9: Refund rate heatmap by month × top influencer ax9 = axes3[0] top_heat = top_names[:8] # Top 8 influencers months_heat = ['2025-09','2025-10','2025-11','2025-12','2026-01','2026-02','2026-03','2026-04','2026-05'] month_labels_short = ['9月','10月','11月','12月','1月','2月','3月','4月','5月'] heatmap_data = np.zeros((len(top_heat), len(months_heat))) inf_m_ref = defaultdict(lambda: defaultdict(lambda: {'orders':0,'ref_ords':0})) for r in all_data: n = r['norm_name'] d = r['date'] if d and len(d) >= 7: m = d[:7] month_keys2 = {'2026-09':'2025-09','2026-10':'2025-10','2026-11':'2025-11','2026-12':'2025-12'} m = month_keys2.get(m, m) inf_m_ref[n][m]['orders'] += r['orders'] inf_m_ref[n][m]['ref_ords'] += r['refund_orders'] for i, name in enumerate(top_heat): for j, m in enumerate(months_heat): d = inf_m_ref[name][m] if d['orders'] > 0: heatmap_data[i, j] = d['ref_ords']/d['orders']*100 else: heatmap_data[i, j] = np.nan masked = np.ma.masked_invalid(heatmap_data) im = ax9.imshow(masked, cmap='RdYlGn_r', aspect='auto', vmin=10, vmax=75) ax9.set_xticks(np.arange(len(months_heat))) ax9.set_xticklabels(month_labels_short, fontsize=10) ax9.set_yticks(np.arange(len(top_heat))) ax9.set_yticklabels(top_heat, fontsize=10) for i in range(len(top_heat)): for j in range(len(months_heat)): val = heatmap_data[i, j] if not np.isnan(val): color = 'white' if val > 45 else C_DARK ax9.text(j, i, f'{val:.0f}%', ha='center', va='center', fontsize=9, fontweight='bold', color=color) cbar = plt.colorbar(im, ax=ax9, shrink=0.85, pad=0.02) cbar.set_label('退款率 (%)', fontsize=10) ax9.set_title('退款率月度热力图 (达人×月份)', fontsize=14, fontweight='bold', pad=12) # Chart 10: Sessions & avg GMV per order ax10 = axes3[1] ax10_twin = ax10.twinx() x = np.arange(len(months)) # avg GMV per order avg_gmv = [m_gmv[i]/m_orders[i] if m_orders[i]>0 else 0 for i in range(len(months))] bars10 = ax10.bar(x - 0.18, m_sessions, 0.35, color=C_PURPLE, alpha=0.3, label='直播场次', zorder=3) line10 = ax10.plot(x, avg_gmv, 'D-', color=C_PRIME, linewidth=2.5, markersize=10, zorder=4, label='单均GMV(元)') for i, (s, a) in enumerate(zip(m_sessions, avg_gmv)): ax10.text(i-0.18, s+0.5, str(s), ha='center', fontsize=9, color=C_PURPLE, fontweight='bold') for i, a in enumerate(avg_gmv): ax10.annotate(f'¥{a:,.0f}', (i, a), textcoords="offset points", xytext=(0,12), ha='center', fontsize=9, fontweight='bold', color=C_PRIME) # GSV rate gsv_rate = [m_gsv[i]/m_gmv[i]*100 if m_gmv[i]>0 else 0 for i in range(len(months))] ax10_twin.plot(x, gsv_rate, 's--', color=C_SUCCESS, linewidth=2, markersize=9, alpha=0.8, label='GSV率(%)') for i, r in enumerate(gsv_rate): ax10_twin.annotate(f'{r:.0f}%', (i, r), textcoords="offset points", xytext=(0,-16), ha='center', fontsize=8.5, color=C_SUCCESS, fontweight='bold') ax10.set_xticks(x) ax10.set_xticklabels(month_labels, fontsize=10) ax10.set_title('月度运营效率:场次 & 单均GMV & 净收入率', fontsize=14, fontweight='bold', pad=12) ax10.set_ylabel('场次 / 单均GMV(元)', fontsize=10) ax10_twin.set_ylabel('GSV率 (%)', fontsize=10, color=C_SUCCESS) ax10_twin.tick_params(axis='y', colors=C_SUCCESS) ax10.grid(axis='y', alpha=0.3, zorder=0) # Combined legend lines1, labels1 = ax10.get_legend_handles_labels() lines2, labels2 = ax10_twin.get_legend_handles_labels() ax10.legend(lines1+lines2, labels1+labels2, loc='upper left', fontsize=9, framealpha=0.9) plt.suptitle('瓦拉英语 · 退款率分布 & 运营效率趋势', fontsize=16, fontweight='bold', y=1.02) plt.tight_layout(pad=3) out_path3 = '/root/.openclaw/workspace/output/daren_biz_charts3.png' plt.savefig(out_path3, dpi=180, bbox_inches='tight', facecolor=fig3.get_facecolor(), edgecolor='none') plt.close() print(f"✅ Chart 3 saved: {out_path3}") print(f" File size: {os.path.getsize(out_path3)/1024:.0f} KB") print("\n🎉 All charts generated successfully!") print(f"\nOutput files:") print(f" 1. {out_path}") print(f" 2. {out_path2}") print(f" 3. {out_path3}")