From 1fb58d23da0be8e3490503312c4df72c01c29177 Mon Sep 17 00:00:00 2001 From: hubian <908234780@qq.com> Date: Fri, 10 Apr 2026 17:52:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=AD=98=E5=82=A8=E5=92=8C=E9=95=BF=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=B7=A8=E5=BA=A6=E5=88=86=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQLite数据库存储每日板块数据 - 连续上涨/下跌板块分析 - 板块轮动分析 - 近5日资金流向趋势 - 查看历史数据命令 (history) --- README.md | 58 ++++- board_monitor.py | 392 ++++++++++++++++++++++++++++++++- data/board_detail_20260410.csv | 2 +- data/board_history.db | Bin 0 -> 65536 bytes 4 files changed, 440 insertions(+), 12 deletions(-) create mode 100644 data/board_history.db diff --git a/README.md b/README.md index 07603f3..4130fad 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # A股板块盘后分析系统 -自动获取东方财富板块数据,生成盘后分析报告并发送邮件。 +自动获取东方财富板块数据,生成盘后分析报告并发送邮件。支持历史数据存储和长时间跨度分析。 ## 功能特点 - 每个交易日17:00自动执行 - 获取行业板块和概念板块完整数据 +- **历史数据存储**(SQLite数据库) +- **长时间跨度分析**(连续涨跌、板块轮动、资金趋势) - 生成分析总结邮件正文 - 详细数据CSV文件作为附件 - 发送到指定邮箱 @@ -14,11 +16,34 @@ ### 正文(分析总结) -- 市场整体趋势判断 -- 热门概念板块 TOP5 -- 行业涨幅/跌幅 TOP5 -- 主力资金大幅流入 TOP10 -- 主力资金大幅流出 TOP10 +**一、市场情绪分析** +- 市场评级(强势上涨/偏强/平稳/偏弱/弱势下跌) +- 涨跌板块统计、平均涨跌幅、资金净流 + +**二、资金流向分析** +- TOP5板块资金合计、资金集中度 + +**三、板块强弱分析** +- 强势板块数量、弱势板块数量及示例 + +**四、概念板块热度** +- 热门概念TOP5、冷门概念TOP5 + +**五、行业板块排行** +- 涨幅TOP5、跌幅TOP5 + +**六、主力资金排行** +- 大幅流入TOP10、大幅流出TOP10 + +**七、投资建议** +- 根据市场情绪给出策略建议 + +**八、历史趋势分析** ⭐新增 +- 近期市场趋势(连续上涨/下跌/震荡) +- 近5日资金流向详情 +- 连续上涨板块(近3日累计涨幅) +- 连续下跌板块(近3日累计跌幅) +- 板块轮动分析(新进入涨幅TOP10) ### 附件(详细数据) @@ -52,6 +77,16 @@ python3 board_monitor.py report -v python3 board_monitor.py report --to other@example.com ``` +### 查看历史数据 + +```bash +# 查看近5日市场统计 +python3 board_monitor.py history --days 5 + +# 查看指定板块历史 +python3 board_monitor.py history --board "电力设备" --days 10 +``` + ## 定时任务配置 每个交易日(周一至周五)17:00自动执行: @@ -60,11 +95,16 @@ python3 board_monitor.py report --to other@example.com 0 17 * * 1-5 python3 board_monitor.py report ``` -## 数据来源 +## 数据存储 -东方财富HTTP API (http://push2.eastmoney.com) +历史数据保存在 `data/board_history.db` SQLite数据库中,包含: +- 每日板块涨跌幅 +- 主力资金流向 +- 领涨股信息 ## 版本历史 -- v1.1.0 (2026-04-10) - 改为盘后报告模式,正文分析+附件详细数据 +- v1.3.0 (2026-04-10) - 新增历史数据存储和长时间跨度分析 +- v1.2.0 (2026-04-10) - 增加专业分析内容 +- v1.1.0 (2026-04-10) - 改为盘后报告模式 - v1.0.0 (2026-04-10) - 初始版本 \ No newline at end of file diff --git a/board_monitor.py b/board_monitor.py index 72c9f64..15b9b47 100644 --- a/board_monitor.py +++ b/board_monitor.py @@ -2,6 +2,7 @@ """ A股板块盘后分析系统 获取东方财富板块数据,生成分析报告,发送邮件通知 +支持历史数据存储和长时间跨度分析 """ import urllib.request @@ -9,9 +10,11 @@ import json import os import sys import subprocess -from datetime import datetime -from typing import List, Dict, Optional +import sqlite3 +from datetime import datetime, timedelta +from typing import List, Dict, Optional, Tuple from pathlib import Path +from collections import defaultdict # 清除代理环境变量(解决代理问题) for proxy_var in ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY']: @@ -22,6 +25,9 @@ SCRIPT_DIR = Path(__file__).parent DATA_DIR = SCRIPT_DIR / "data" DATA_DIR.mkdir(exist_ok=True) +# 数据库文件 +DB_FILE = DATA_DIR / "board_history.db" + # 东方财富API配置 EASTMONEY_BASE_URL = "http://push2.eastmoney.com/api/qt/clist/get" @@ -35,6 +41,134 @@ BOARD_TYPES = { FIELDS = "f12,f14,f2,f3,f62,f66,f84,f104,f125,f126,f127,f128" +# ==================== 数据库操作 ==================== + +def init_db(): + """初始化数据库""" + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + # 创建板块数据表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS board_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + board_type TEXT NOT NULL, + board_code TEXT NOT NULL, + board_name TEXT NOT NULL, + pct_change REAL, + main_flow REAL, + leader_name TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(date, board_type, board_code) + ) + ''') + + # 创建索引 + cursor.execute('CREATE INDEX IF NOT EXISTS idx_date ON board_data(date)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_board ON board_data(board_type, board_code)') + + conn.commit() + conn.close() + + +def save_to_db(board_type: str, boards: List[Dict], date: str = None): + """保存板块数据到数据库""" + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + for board in boards: + cursor.execute(''' + INSERT OR REPLACE INTO board_data + (date, board_type, board_code, board_name, pct_change, main_flow, leader_name) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + date, + board_type, + board['code'], + board['name'], + board['pct_change'], + board['main_flow'], + board['leader_name'] + )) + + conn.commit() + conn.close() + + +def get_history_data(board_type: str, days: int = 5) -> Dict[str, List[Dict]]: + """ + 获取历史板块数据 + + 返回: + Dict[date, List[Dict]]: 按日期分组的数据 + """ + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + cursor.execute(''' + SELECT date, board_code, board_name, pct_change, main_flow, leader_name + FROM board_data + WHERE board_type = ? AND date >= ? + ORDER BY date DESC + ''', (board_type, start_date)) + + rows = cursor.fetchall() + conn.close() + + # 按日期分组 + result = defaultdict(list) + for row in rows: + result[row[0]].append({ + 'code': row[1], + 'name': row[2], + 'pct_change': row[3], + 'main_flow': row[4], + 'leader_name': row[5] + }) + + return dict(result) + + +def get_board_history(board_type: str, board_name: str, days: int = 20) -> List[Dict]: + """获取单个板块的历史数据""" + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + cursor.execute(''' + SELECT date, pct_change, main_flow + FROM board_data + WHERE board_type = ? AND board_name = ? AND date >= ? + ORDER BY date ASC + ''', (board_type, board_name, start_date)) + + rows = cursor.fetchall() + conn.close() + + return [{'date': r[0], 'pct_change': r[1], 'main_flow': r[2]} for r in rows] + + +def get_available_dates() -> List[str]: + """获取有数据的日期列表""" + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + cursor.execute('SELECT DISTINCT date FROM board_data ORDER BY date DESC LIMIT 30') + dates = [r[0] for r in cursor.fetchall()] + + conn.close() + return dates + + +# ==================== 数据获取 ==================== + def get_board_data(board_type: str, sort_by: str = "f3", limit: int = 100) -> Optional[List[Dict]]: """ 获取板块数据 @@ -213,6 +347,142 @@ def analyze_market(all_industry: List, all_concept: List) -> Dict: return analysis +def analyze_history(all_industry: List, all_concept: List, history_days: int = 5) -> Dict: + """ + 历史数据分析 + + 参数: + all_industry: 今日行业板块数据 + all_concept: 今日概念板块数据 + history_days: 分析天数 + + 返回: + Dict: 历史分析结果 + """ + result = { + 'available_dates': [], + 'trend_analysis': {}, + 'continuous_up': [], + 'continuous_down': [], + 'strong_rotation': {}, + 'fund_trend': {}, + } + + # 获取有数据的日期 + available_dates = get_available_dates() + result['available_dates'] = available_dates[:history_days] + + if len(available_dates) < 2: + return result + + # 获取历史数据 + industry_history = get_history_data('industry', history_days + 2) + concept_history = get_history_data('concept', history_days + 2) + + # 1. 连续上涨/下跌板块分析 + if len(available_dates) >= 3: + recent_dates = available_dates[:3] # 最近3天 + + for board in all_industry: + history = get_board_history('industry', board['name'], days=5) + + if len(history) >= 3: + # 检查连续上涨 + if all(h['pct_change'] > 0 for h in history[-3:]): + total_pct = sum(h['pct_change'] for h in history[-3:]) + result['continuous_up'].append({ + 'name': board['name'], + 'days': len([h for h in history if h['pct_change'] > 0]), + 'total_pct': total_pct, + }) + + # 检查连续下跌 + elif all(h['pct_change'] < 0 for h in history[-3:]): + total_pct = sum(h['pct_change'] for h in history[-3:]) + result['continuous_down'].append({ + 'name': board['name'], + 'days': len([h for h in history if h['pct_change'] < 0]), + 'total_pct': total_pct, + }) + + # 排序 + result['continuous_up'] = sorted(result['continuous_up'], key=lambda x: x['total_pct'], reverse=True)[:10] + result['continuous_down'] = sorted(result['continuous_down'], key=lambda x: x['total_pct'])[:10] + + # 2. 板块轮动分析 + if len(available_dates) >= 2: + today_date = available_dates[0] + yesterday_date = available_dates[1] + + today_industry = industry_history.get(today_date, []) + yesterday_industry = industry_history.get(yesterday_date, []) + + if today_industry and yesterday_industry: + # 今日涨幅TOP10 + today_top = sorted(today_industry, key=lambda x: x['pct_change'], reverse=True)[:10] + today_top_names = {b['name'] for b in today_top} + + # 昨日涨幅TOP10 + yesterday_top = sorted(yesterday_industry, key=lambda x: x['pct_change'], reverse=True)[:10] + yesterday_top_names = {b['name'] for b in yesterday_top} + + # 新进入TOP10的板块(轮动进入) + new_in = today_top_names - yesterday_top_names + # 跌出TOP10的板块(轮动离开) + out_of = yesterday_top_names - today_top_names + + result['strong_rotation'] = { + 'new_in': [b for b in today_top if b['name'] in new_in], + 'out_of': list(out_of), + } + + # 3. 资金流向趋势分析 + if len(available_dates) >= 5: + dates_5 = available_dates[:5] + fund_trend = {} + + for date in dates_5: + if date in industry_history: + total_flow = sum(b['main_flow'] for b in industry_history[date]) + fund_trend[date] = total_flow + + result['fund_trend'] = fund_trend + + # 计算资金趋势 + flows = list(fund_trend.values()) + if len(flows) >= 3: + if all(f > 0 for f in flows[-3:]): + result['trend_analysis']['fund'] = '连续流入' + elif all(f < 0 for f in flows[-3:]): + result['trend_analysis']['fund'] = '连续流出' + else: + result['trend_analysis']['fund'] = '波动' + + # 4. 市场趋势判断 + if len(available_dates) >= 5: + dates_5 = available_dates[:5] + avg_pcts = {} + + for date in dates_5: + if date in industry_history: + avg_pct = sum(b['pct_change'] for b in industry_history[date]) / len(industry_history[date]) + avg_pcts[date] = avg_pct + + result['avg_pcts'] = avg_pcts + + # 判断趋势 + pcts = list(avg_pcts.values()) + if len(pcts) >= 3: + if all(p > 0 for p in pcts[-3:]): + result['trend_analysis']['market'] = '连续上涨' + elif all(p < 0 for p in pcts[-3:]): + result['trend_analysis']['market'] = '连续下跌' + else: + result['trend_analysis']['market'] = '震荡' + + return result + + def generate_daily_report(boards_data: Dict, to_email: str = "wlq@tphai.com") -> bool: """ 生成盘后分析报告并发送邮件 @@ -243,6 +513,9 @@ def generate_daily_report(boards_data: Dict, to_email: str = "wlq@tphai.com") -> # 执行专业分析 analysis = analyze_market(all_industry, all_concept) + # 执行历史分析 + history_analysis = analyze_history(all_industry, all_concept, history_days=5) + # ========== 生成HTML正文 ========== html_lines = [ "

📊 A股板块盘后分析报告

", @@ -404,6 +677,62 @@ def generate_daily_report(boards_data: Dict, to_email: str = "wlq@tphai.com") -> html_lines.append("
  • 等待市场企稳后再介入
  • ") html_lines.append("") + # ===== 八、历史趋势分析 ===== + html_lines.append("") + html_lines.append("
    ") + html_lines.append("

    八、历史趋势分析

    ") + + if history_analysis.get('available_dates'): + dates = history_analysis['available_dates'] + html_lines.append(f"

    数据覆盖: 近 {len(dates)} 个交易日 ({', '.join(dates[:3])} 等)

    ") + + # 市场趋势 + if history_analysis.get('trend_analysis', {}).get('market'): + trend = history_analysis['trend_analysis']['market'] + html_lines.append(f"

    近期趋势: {trend}

    ") + + # 资金趋势 + if history_analysis.get('trend_analysis', {}).get('fund'): + fund_trend = history_analysis['trend_analysis']['fund'] + html_lines.append(f"

    资金流向: {fund_trend}

    ") + + # 资金流向详情 + if history_analysis.get('fund_trend'): + html_lines.append("

    近5日资金流向:

    ") + html_lines.append("") + html_lines.append("") + for date, flow in history_analysis['fund_trend'].items(): + flow_str = f"+{flow:.2f}" if flow > 0 else f"{flow:.2f}" + color = "green" if flow > 0 else "red" + html_lines.append(f"") + html_lines.append("
    日期净流入(亿)
    {date}{flow_str}
    ") + + # 连续上涨板块 + if history_analysis.get('continuous_up'): + html_lines.append("

    🔥 连续上涨板块 (近3日):

    ") + html_lines.append("") + html_lines.append("") + for b in history_analysis['continuous_up'][:5]: + html_lines.append(f"") + html_lines.append("
    板块连涨天数累计涨幅
    {b['name']}{b['days']}天+{b['total_pct']:.2f}%
    ") + + # 连续下跌板块 + if history_analysis.get('continuous_down'): + html_lines.append("

    ❄️ 连续下跌板块 (近3日):

    ") + html_lines.append("") + html_lines.append("") + for b in history_analysis['continuous_down'][:5]: + html_lines.append(f"") + html_lines.append("
    板块连跌天数累计跌幅
    {b['name']}{b['days']}天{b['total_pct']:.2f}%
    ") + + # 板块轮动 + if history_analysis.get('strong_rotation', {}).get('new_in'): + html_lines.append("

    🔄 板块轮动 (新进入涨幅TOP10):

    ") + html_lines.append("") + html_lines.append("") html_lines.append("
    ") html_lines.append("

    📊 详细数据请查看附件 CSV 文件

    ") @@ -487,6 +816,9 @@ def run_daily_report(to_email: str = "wlq@tphai.com", verbose: bool = False) -> 返回: bool: 是否成功 """ + # 初始化数据库 + init_db() + if verbose: print(f"\n📊 A股板块盘后分析") print("=" * 50) @@ -506,6 +838,11 @@ def run_daily_report(to_email: str = "wlq@tphai.com", verbose: bool = False) -> boards_data[board_type] = boards if verbose: print(f"✅ 成功获取 {len(boards)} 条数据") + + # 保存到数据库 + save_to_db(board_type, boards) + if verbose: + print(f" 已保存到数据库") else: print(f"❌ 获取 {board_type} 数据失败") boards_data[board_type] = [] @@ -538,6 +875,11 @@ def main(): report_parser.add_argument("--to", default="wlq@tphai.com", help="收件人邮箱") report_parser.add_argument("-v", "--verbose", action="store_true", help="显示详细日志") + # 查看历史命令 + history_parser = subparsers.add_parser("history", help="查看历史数据统计") + history_parser.add_argument("--days", type=int, default=5, help="查看天数") + history_parser.add_argument("--board", type=str, help="查看指定板块历史") + args = parser.parse_args() if args.command == "test": @@ -568,6 +910,52 @@ def main(): elif args.command == "report": run_daily_report(to_email=args.to, verbose=args.verbose) + elif args.command == "history": + init_db() + dates = get_available_dates() + + if not dates: + print("❌ 暂无历史数据,请先执行 report 命令收集数据") + return + + print(f"\n📅 可用数据日期: {len(dates)} 天") + print(f" 最新: {dates[0] if dates else '无'}") + print(f" 范围: {dates[-1] if dates else '无'} ~ {dates[0] if dates else '无'}") + + if args.board: + # 查看指定板块历史 + print(f"\n📊 板块 '{args.board}' 近 {args.days} 日数据:") + history = get_board_history('industry', args.board, days=args.days) + + if history: + print("-" * 50) + for h in history: + pct_str = f"+{h['pct_change']:.2f}%" if h['pct_change'] > 0 else f"{h['pct_change']:.2f}%" + flow_str = f"+{h['main_flow']:.2f}亿" if h['main_flow'] > 0 else f"{h['main_flow']:.2f}亿" + print(f" {h['date']}: {pct_str}, 资金{flow_str}") + else: + print(f"❌ 未找到板块 '{args.board}' 的历史数据") + else: + # 显示整体统计 + print(f"\n📊 近 {args.days} 日市场统计:") + + # 资金流向 + fund_trend = {} + for date in dates[:args.days]: + industry_data = get_history_data('industry', args.days).get(date, []) + if industry_data: + total_flow = sum(b['main_flow'] for b in industry_data) + avg_pct = sum(b['pct_change'] for b in industry_data) / len(industry_data) + fund_trend[date] = {'flow': total_flow, 'avg_pct': avg_pct} + + print("-" * 50) + print(f"{'日期':<12} {'资金净流(亿)':<15} {'平均涨跌':<10}") + print("-" * 50) + for date, data in sorted(fund_trend.items(), reverse=True): + flow_str = f"+{data['flow']:.2f}" if data['flow'] > 0 else f"{data['flow']:.2f}" + pct_str = f"+{data['avg_pct']:.2f}%" if data['avg_pct'] > 0 else f"{data['avg_pct']:.2f}%" + print(f"{date:<12} {flow_str:<15} {pct_str:<10}") + else: parser.print_help() diff --git a/data/board_detail_20260410.csv b/data/board_detail_20260410.csv index b4adeb4..47914b2 100644 --- a/data/board_detail_20260410.csv +++ b/data/board_detail_20260410.csv @@ -1,5 +1,5 @@ # A股板块详细数据 -# 生成时间: 2026-04-10 17:42:51 +# 生成时间: 2026-04-10 17:51:40 === 行业板块涨跌幅排行 === 板块名称,涨跌幅(%),主力资金(亿),领涨股 diff --git a/data/board_history.db b/data/board_history.db new file mode 100644 index 0000000000000000000000000000000000000000..5f37c8b1ae3c8aed5b4e4e731837ebb8f4ca6a49 GIT binary patch literal 65536 zcmeHw33wD$)^=6zdkcsVBM?-Oovz*+gldEUVad`-AYqH_pzN|ENLL6Xfk47e*msb9 z6#^jvM`s)d6a)kmQA{tv4HOVj^gp+{JEU)_D)TWj|MxjhwRj~z0!X%~sCPQ8X?Mg=$^4t;!?v5!U|5El;+J!9ijT7+G}-epQ`T1rfO z0{o=*=n-xYd=&C#OttSqey;vq=(dI4HE!_4jKL!Yju}2Q1|Bz7oY4bEj>#A{YU~sa z+o+)fhYX#NQGp+RBZDUlg{LdOd;{tNV{R{y>J3;kXFL;XV}MqUvQL_84jK*R$P4@5i=@j%1_5f4N>5b;370}&5I zJn*OSfP$$7WmQ#_SMDAIe=C^|@LR)-6~RBG{&Qqj2mV2SMqUvQL_84jK*R$P4@5i= z@j%1_5f4N>5b;370}&5IJn$#-fU1GCVZA7k(O|T;G?-gb1_%n6^v1*q(_ZOru$ZmH zCny%5)c-)h|B+Y30}&5IJP`3f!~+ozL_84jK*R$P4@5i=@j%1_5fA)#ctB5z8;TyM z0g(AB$msu@_|<6fZT)Tim->(OrTYE)4f=fjZ2dU>tNKLom-=@4mil^nt?rTTJKc5N zSzWPik8Z6lPyDfNy6!byA6++H8(lNqvpSXbq4t*cn)dJFQtdmC=f6TbPdi0BT-#gQ zMQhW(q^+Zsi}!2pYRWVhHGk0@)@;!%)y&aM)C|$2XgY~EXedoXO$`mHzN7w1eO`T9 zeL%fYU7&W0^VMV21Jv>A_UcyZ`f8o(ch&c*FI4ALr^K^WyHu-H^HnocudDj1x~tl% znyaE!YUQuuaZ11Pit=yDW6B-MmC9`8ROJX|nzF0XE`C+nNLgDcRoqi}6qgk5DUK+% zDwZj-6q6J~6{(6?g+d%XKb zw!>rtCcl8m&tS4LTqb_Q3x#^h0${5mGThRGu_c?2d8$K+v{JQS0MVDey09)!sQ zG5J+Y&cNgWnA{(e`(bilOzwlp>6n~`$-ObT7bd4-atbCVW3mgAlQ20ElM^sG9+P`w zat}=Ij>+9HIS!M%VsaNu?u^N?nA{1IJ7V%HnA`!AotR8xa(n1BG1beM+!mADV6p>~ z?U-!CWGg0HFxiaBCQLSBGF9~!T4Qf%g~=^3xdkRS$K+<1+!T|WVDd|t+!&J^Ve*Sr zZ{d0DEe$a_29q0La(zs$hsn=jax^ACi^)-#To;q;RK11T*jt{)7_s z$7CHQYcW}a$!bhSfyJ0gfyr`AmSM6KlO>oe#$*vDlbDQ^hxjAhPW+C^kE+}P@hkS0 zhnV~eCO^RBpE3D9Cf~#4yO{hFCjW@ZcQE;O)m!);d&_s2{4FMbgUPorxg3-InC!!3 zFD831xeSxPu6he!VQ={olW$=17npn5b;370}&7WIX$3zS^}8>T=D;n#mz{~1I=blSM@LIZR!-Y1kTkHls_rg zDwE)(e4ipk|F*ubUZFdp>#b93k81nKe}_}?9hX0sa*7{$8mhSymuKssi+rvFZML;r5 zlw7ViJwDQZ+@L74+2NVH-+Ooky=3Ca*Jl0l8GZhv%ij&EyT$hS?J)*N8w=IOY)E&H zX5lEgyao@Pf$|j<`A*F9uF3On&hqa)<=HZiUb3^9A=NmL{`FQ}TdUWmP!tDthI?bthcZQYnEj-~{oaxCvT0VONy|nWi?oU6vOM7SjJ|eOE1P4d4)7`IC;}VmJ@-Ex& z*|Wl*yU&+14_xBk3k81l?IC9#*qt4dM)&!-%Oo?GOOo9qLj~q>x5Z@hX6JjBxIKH1 zdJbgKOQ(O;W$)87=$jjUyS4FyTa=Zf#EI?^ENTOOM;q+kHAS9PJIacRDj;}?Q#zb zIf17Nn~aptz00@sNU*joee~1O*o}Xux7eaLMCDJaR8cDmoa!FRf;Q)^3Ko;WvurMu z(_dWdKfK*vyp3LF9QbDRwInAo({$a{Jlf9Th+gg?AzZ#EW-)oT6qX$;^v-sduRiKo zx|d$&mc4krbu@iD&wpp!>iU$K6L@enz(%7f;9+otXHWjktc~u<9ARTdbUF^`Xp22=Ukb>&Nrmd`8jugjxXyf`s=;ZHrC-BNPKHj}(+<9c#w?%pga zbZtD<%;>NL{19l(p0^i6x#=|zcVG3l??_*~xoqR`G4VzY7bm-WRa0OCW$|YgdyDd+ zz@7u^=(XO$i!EMW?`-Zpvb+AL{V1+B7w1l8QETxQ*lMK8mooHv*?rWHIiG)UarlOF z&v5nYaqg6Av5e)*^5_lAPptgw%}l5Mw4<+cbS8JFl`MR>D2Fr9%c9>!Tj&j&KUK9G zx5fGV@SGp1qXvqrgz0V+#TPbG zTumU|om3sR$p+n(FK2=GXtw|G@jwfsH~s$J=zb6P(D(PB9e$xld(NlS%bmz#Lr23? zSxp8b;CT;h@GMyF*|QCGijA818gvv0>Jc^z%b_Q zGO@5D#eM zv(>j_K3!Yzb!*i%Ri>(?@+0MRWh4C#eNWvFx+S{y+8?y5v>g?nC}t^I$#2N>m%F)a~X6C%E4KY@P&Wuok!temowi^`6jfa(@PE&1ElB|U!WeVeN? z^kmd5DDR}%U^5%M%U1cBgLM8MXi~F?{_(SqXMP_4A?M1$#i*(&q{V6}J6Y&Cuq-%r z45F5-D?B@ye$bw)km-z)1?6ArSS)7Pn}9t8U(QbS`@|xD{#LrcEV~{YYfu>7)7<*)`_U*cPThz=ss<=^d@*|_sCuBn&HJ-wP@n(a1E)^7hh`_TSYNj|-J z%!<5(8!ywP&0CLYu{VPobXql_&Ol7oO@Lm!S(@Cq&Kaj@+@rdkqH?(^XR>=rsL0%pAF8xD zF!>Cw#1_>8@czT28% zf^~n@jZ~L=QV5o(Zkg@YvJ*=@%#L7vcqn#Co40!6Bz=% z|LCm=jkN|NmuLIA$5!J5gUQV0gN>mwI~%SV`t#-d_c?1W%=u&lk<5sMi%yv+n02Ay z)qg4r4X*{v&L!$EZ#X5(bAH$}WdirsEJIdyP{Q?g@^vK~&mz@JIcl_y(MN zXgwI9f)_r-qKw}CJA6e)L!~S!7*{y{v!WSXU9HRgR;Z9XZOrJf`nS(5-&7FX<1JY8 z<)r(Mo~KWlPkz|&w@X~0>F!x9sKB7@sMtnd4{bNNaZ>P2R{dj1-_mDJ)lE^JnPTF? z?(3e(V)OTp7K=6DPw2*BDge6^f%-u&mYyUxk7-SRI_unp`7<|F>ZvM@LekuChDy%k z4l@9c9N?vS%&Z=*4ZC z{&m{6BhKh6Z`#ctm2wps@&C0VmJ|9d`Xt@&x}CaookY7=n+)*)n>2~)pViydNvdB} zn^bYiACwD~Z4_mS`3j5tro2Gj4%YvRW$oeof2FjYA*F`xZJNY%a zkZeQv0sMdbN?Yhb6_B&m)2w-jngn})$pj2 zLL=9GCNNGxM1#X-^KCi=kHxpK;_R=qmGMZ9CwjAqt8YnSXciHhRaYvb-RN2MwrBJH z@|6d@2T#z;R=1P?t$Y8H%sNwQtCF2uyHGBseF(GiNKk=kfc<6|tGx%dc(*a0k|T8R zkca+W-E`K)@%<>STX!+-ghB0gQ}9&v@OIBJ*nv#D88x)|C(alD(W_6&5r>UyAdF{T z4*9ckRj(*1MNy8#9vy?lT$#ZhoY3DL968bUWe2VSAc<)kDkV1{tS4ZW3d@P$^dAmS z80R~)>AS~UIX-H&oonIKn`y&hVw=*H3TdJ|`3t;zw)!@^%U7O2bIdhG^eXmM=p>5f zKDVgFRW4)~;|P_O^FdLRjopu5UlR1dszV>YIp@gd&X-TCH&1Ez(8%@lQyDu8iXCuN z6xZOe+shYk@NGK*hmxqQuoGPGF*d=%8f+8=TcxOC1!`5{ z>xuh+oN>t6t*dy;$F1BJt_qOMSVN$dXKj@VYqfh;7s1hNu%xTm66&3CA9W}D*8h^? zJUUEdEG+5^;rqswPz{u+leb`wCv%Z!%VB!W`}EV$`khwtb8e%2>k((6x6{@Y7!9a?PhUq_Qo?53yQ6bDqLZ(TP(h!L%sut;J}yO)FsybgZZ*kaqnd7*y4}4 zDnSxs2$hnj(K?LfMT^Ue)(1zix1dX{*m1z@sa zMwQ!D<<$^Hc@Gq$D2n-8zfS)7+%>0R#I&but&&6^5w))dV%iLjV-jdow zTWp;^b(E7NS`NHd0H!gE$$u(o zc2K@u`#jnE;Wr+*75s>jVb?Bv;4M$*stmoEMj;<_7aCjL2pF8cD_%e zHdA0Dmt#HHi+M4GT)EjketHI*(E=xtWhHyzclp-+XxnuGS`0M&gZ}vG8CTk^YR|b4 z>czYu3{07A_Hf`${}gaC^E?a8TZ;{5h_qsZ!JuuRquEe#ziFQPlYLE!m20A&%rq26 zrA!VB3JEL9g#%Ad);o_;OFAvNQZ$OUv4)I-eiVz4SS6ZLV8SX6jYrPy@{30?X?_ph^g|>CUt!^K}IluE&(dJjbG9Q^QK- zgi#qqKCw|mfg>1HV;4p%(3FdQ@OFWHll&1^fzz1iYM{*&RRwy}KLef2JX@VF%x2%x zN{ix9d3Utg;yjss5IVZ5TRAQ!Duh~j6tCn9GfXxX?(k-pz%Y(NRzj#7zg)I^di1Yc z%cGu5T^5wT#u_NQmkoTN7iN|<+UqrzeA->g#1KgocOmOkQ%Ix9;agSg&pC*;$CnAW(MsDu3QSdQq-Y^aXLjsXV(?_ubw? z6IaDbWNKFjX}0)sj{COfp~f17q<`=J%^SV6e{c@B>CDqCBsPPs^n6SXIKM$5q2;p+ z9|vqL`mtINZd`Wl*dW$iq0uMSNy8)Lax-zQq@oyMES~3w7vDi)L)a+$Ud?~^7k|k$+qJ6 z#m&`QWoJbuMF#nfGEKEdoGmk|7K%NZ&%_#O8&!hnX^~5=mPl0p&^;yFtI3dktF?>9 zOJ315l+{um)<&tDXq@V~3a6x4aZ(qr?jreIHAXc?avcy(suE@6q$?y6X}Wv_TyIdO z$>a1&{V?5!iemju&aDZ_DWWl_Axv3c3Of{)$lWTY(J-qtkR1ig1}piBkl8?X;5DO- z3ew4IrVMr?N%NY~1_Ifh-way;WIJ9nTGW#-^P6FbPPXMY!#*h4M!;+{kq%xnTKtlB zelx6LNgKb}VYQQ10keT3Excy5L?q4pW?1HtCSEgIs*y&1Gc3ADiq}jT97fW>Yc{}Y zh-}SkMhhIW6~7tg%Vd^-*<>T<@S5SXEo3II8TLRdWM^J8S{#ur<;{rtVXZ1m;J?^n zvXXOo&6E{N)T)bmcfW~eQ8GLF}5usa;&d;v3MBy;)A zR=7Hc-)uLT$ZUSI1zO2GezU`BAiMFJq4k@|1^i|M6shyv*=n+r z$^2%E(M-B{&EPdNnZ#>`uEk8oDyKYI6SNr}WEw9vWi{K$-uz|*bPm0E%?2wpu~Y#w zbWABi?b~Q1rTmvWtPZljfZ1js`|+FMhzNEt`9^<(6-MAbyk_Ve%w)Rinl&-%P=cjpj8&RW*>K_{}!6iF}>k?10+x z8m}1!XcIY=-)w-6Z3@5HYP6G+`OWai$eFxm7$XhjoBU=wWg%zqo2_PwoGxTGlhbr# zo~)i*DH}PP7n_3gR`M-gGaMb5$XP{iOEzEj zy39cKm;NYTE4?5+D4i_nFYP6@N{S^96@4Y`^r!V3^=?tIZlr3IXs!5>?gNM@$kC3M z%+$5g>9ps?Gqnda_chbCR`QXi4>?m)BzmMNB(JMlXc|b?s{c^mAdA(T)tP#^x}$!e zS|Mtu`n$NEYJsSps)u^8sK4?KxmI~dxkTJw*-L3v{333rI3d3NzvMcC;IHl|5}K?b zV22oFQZHbKBxX_wALby$BcbcrY9^Zt+Kps=Av?@9>j>G6X5wdg z*C(qDM%XiHAp~wP5clPCpU`V?$Vfgb|N6;_1}DtqO9DlM=vLwf#b-oau16wx9Yxj? zvKtNLb3%5?PSzE$L%s>A;kTpI5mGH=H`$5X{B}4;rie?*s3&|R7HB5FDwdbQC?YSr&Pky{)T*;wedVBb+EU^l~=heFV9BOYq# zCmJjuYJz-02phWdT7q_p_(pT>iQED`YP0|}9A}bG3)sytxvnW>hsn=x+FnoO7KqLu z8}ehLxCpWVznwB0DB?TqohNY%gg22*`Jqt&4f!I!oif2Dw^+z-H4}Gr@lV$En5-sJ zCJ0Uu|In2`(R~|jWHTXX*jkVa+9~3ypxr=R(ZBSB&EM=W5Z~&*BHD0`q1EdI$^{Yo z#IFK&NYx@92-=OrkAilJ@IncNjx7T5|8EicUHTrnpLFYVaoW4uHQElEZ#645o#DIq z)~GwEzEv$%(aKxO0wt~RDGC(r~E;RrJbe>Yclh} zEY2v$1O~CQ2~;caCscy++3PEXNjdA(YSQ^x=Q_@6mC6ib5wWP($Hjybf;_BJlhx)?;uCUE<3CgMGM&@=a)V7*$HFgfGOp53K2yB^3!b8 z9hHta+4w8yqH^zmV}4xcn>o3|HVbzHdo={g)4=Rz zPu{|^V@1K}??tI7p{R$mLDuf1E4q77^Ei4f7lT-cgo9Vb7jlJjcZurqLe3b9Wf9IfWrQ)Z~F;E)v$D5*@=}Ep!$bXi8E{0bCxunK^#PEXUJUx zpaZF#3?F=A`6#jIecEZfy=7%$#~~0x$XO2%84-Vh(Xn)pjBm*fPu3ASh+6Vsbv zuhwt|)+aHDl86PZ95`ChcfpL+;mX16dohSVkbg#G zu$pXTMT`CJrM}{VU@CNo1SJ0IAZqFxVu~|g!NnjlLH-&5z;;N2LUfwh`QU;OYlk!8 z>g|oEn#b9={OV#5m!JSB#HV-`F)+tMGcBN!3o>gsUv64!eWC6!Cs%7KlN53RclQEO zEx?0yPg+(z&!5((^cJKV0PzE%8q4COR+pY9- zj$Ua@0t?DFgMo8XHr$_`yLxlB`!_B?Q-fe0v{PO0<8E0;teoAfi$OesLglql0fLcW zCldm#wdLYhbq91db7g?In4ZF*5DVp9xxReG+ab@MXK72E&1bA@zGYn&XV7y3gUAH= zn*odmrdU@ZhWJ!L-i(Xt&p1gclNiJ&$lg0=*8*XY4uA1p?>HkZz*gi>x1V|W_He^V;7h2wEJ$Mzks$vJ7DWSM z>4j*;5#;hHm2Wi+d$C7b6W25&mFdbt^Gyv=n4)+8`T%ua(6=EL57phqEuTssY2;eT zxR@@2en8QaKsNz|L(mhHFDt3wgGl|J+BoRMJDl@a7t>i76rw?cgwX4Y&~$r=Gk(jC z(GrJ0{{~JoR zgnqZak6xlXpi9*e+O66|&2R9106ou414zmu}tTJr21qO=0IL)hQh^LTS7i0tfA$M}!Agv!J@_lnCz9E8zP{8RxB zM@Lrzof{y@D6Kisf5fsogLAT-oQe1cS%>o6J7x4A-tC!v(z9rVZ*^`6)EP5>TI{g4 z>nP6jI#(tlAQX5^kj~-B+JX461FPBG0Q<31vaZvX`d1EdIy5;`5d!5YDv&?#Uj#G` z!N-&vAb`Ad`qnPC;|mAY7>^cX|iU%yks!WGFdP zCR|9!;DMpB&ryve!5=*A$LvFNDUJpQC$ z7pp^DVn=#DO9^$>*|Pc6i#xP4Ox!DW#409GLI^2`8!n%{IP`Rav>?*7zmn3j@tifu z#UMU00Z<4;51ft!)+!0iQW63qXz+x@o3p2^Xc4_&w}NV1K9#;mPM)$WyES)t z5lbe2c~O2!U{Az9`w-e^31T`s=%0PDquXrd+PFw(5KAWiMmEZ7sr>aHp@$YIuCr68 zN&3mlav+p~qf<>}5LqVMsdCma$an=BfK$K|$sX(mAv?U=V9S^Hrg1|iGl(fusM^{M zfodCI19|L&?8bDf@&^>p)-WQ-6zKgS`^LW@%e!D6?7LS$CiLl*w=4b}Ct?zVI5PR` zDj(gBKC$=kBYV%;>F4x(%nsRQeX58c~JDD?4WF&bgp!QXu2#{ zQBQtc^mplAd6ZNqyC%6HJ}18?y(KPEq>F~hx5{ct#>l1emeR|L(c+urHEE&v73mp~ zNZvsfFMd|}K%x>Y6mJu^5*?L1l(kXKRgG7zR(!1Lr`oSLuWP8Fv>r{W@{%%65vy#d znxxsPyQDs%8mJ$rU#oYhll0%Ir>PF;bF@1($2A>vT1C1xUHc1s=fP>+9(8TSX#IQo z1L~%l0^KV0P^DH?Lt9VlR%WR;s_*D5Du-67+@p=vUsW|ycr-ILQEF04DRye4szURzOK$wUDb`(oK=>qKhX8pJ<``82t~0_SV}DkFbq6CPGNIN za2-k@!E|gUPq;3Sz7wtsNJ$FU1@f;j9h-E8 z>DWxGa9to@E?kGY6LKP^V>9Ezb%As^OvmQH;X0J~7Oo3qyJ0#u#SPQ3d2QjkKr$Pq zV>8$=9h>rnP#xDG`wkRvc1jGg3gOb5;)hhaK46*63hPVLDd;W~8aP7V&&1@cF6 z9ZDL-btqG`iVo64!*zk2&~P2>!9!{;u0#2_n2t@t#dNSSP4)@b1=4N9b?8`BUjNu-ZrLVleNQjfeh^`I>>d$bZmkwxGuoBfazEQh;UsXZGTnCQ?*hnxPOFYpH0N>mF1Mc zbS#ksreirbFda*@5v~g`dxYy?@Bp3?T!+X>a2;ZS!E`LmP}OGvDgITIC@;8*645tQ zQKCe8f{-Tu*`8gbyb%vXJP`4~|3wd|6z7QnvT=m^JN<6iUsQ#Pr*)^)>-47;$@(|+ z1N1i8IK5u();*NJqWeU79c;9X>$=GQ&>400v_C6mYHw=KDo$#*YZogHJ zRqaqet=O*mLv>5GPxTkoKFwWKmbRtpRe4>NUA{~8lw7Rz>c5e5bgnNQXk++!Idoe39e)rPjDrCg%@!TQ=)H* zB5q?!U@ro~lPXHYa)K*aYLhBT#Ai}PiAYRvCCglbD_OddDoVsrf-6~K5?sl$k>E;} zf&^Exyd$`hB^$w&EW-${WNAfkCCep(D_H^&T*F9apm1 z>s6E}WnF!}nli(IDEVB~OCg)SiV~%vYdQ#)Ign6}U&$uGS5czW@+wM{Pp+9+T^(mL z$E#io>EfEN1j|RU3Gr2~MA_g~m5>5nRS9|DT4l9$lFjhex(U5NZ84!7b^HU^1a4f( zW^GqhLaKIECFEyoPge8HS+0~Smm-EDou;}=5f@RFOASCkR8@(EU;1j3c@l>%g4TnXPvO8l&UvASx?()?7p z7IFLNdsKTLEQ^mmySmR{`FilrU`cwQ>b+R0`f>h=T^OLEz?2ZO0@OXYl4YC2mGE`d uq>l50BFcydA|8l%AmV|D2O=Jbcp&0|hzBAbh