Skill: rix-zhang/quant-stock (version 1.0.0)

--- name: quant-stock-picker description: AI量化选股系统 - 基于多维度评分模型的A股选股分析工具。扫描新能源、电力、半导体、医药、AI、机器人、军工、贵金属等行业，输出每日量化选股报告。 --- # 量化选股系统 (Quant Stock Picker) 基于6大模块评分系统的A股量化选股工具，自动分析股票池并生成投资建议。 ## 使用场景 - 每日盘后自动选股分析 - 多行业股票池扫描（新能源、电力、半导体、医药、AI、机器人、军工、贵金属） - 生成飞书/文本格式的投资报告 ## 核心功能 ### 6大评分模块 | 模块 | 说明 | 关键指标 | |------|------|----------| | 技术指标 | 动量分析 | MACD金叉、神奇九转、均值回归 | | 市场情绪 | 资金流向 | 激进买入、长期观察、高位震荡 | | 大盘趋势 | 环境判断 | 逆势超跌、顺势过热 | | 行业主题 | 潜力评估 | 景气向上/向下 | | 新闻舆情 | 信息增益 | 重大利好/利空、突发利好/利空 | | 行业优选 | 后分析 | 高频行业加分 | ### 输出格式 - TOP3 精选股票 - 4-10名候选股票 - 今日最有潜力行业板块 - 命中标签（仅显示加分项） ## 使用方法 ### 1. 初始化股票池 ```bash python scripts/init_pool.py ``` ### 2. 运行每日分析 ```bash python scripts/main.py ``` ### 3. 查看报告 - 文本报告：`daily_report.txt` - 飞书推送：自动发送至配置群聊 ## 配置文件 ### 飞书配置 创建 `feishu_config.json`： ```json { "app_id": "your_app_id", "app_secret": "your_app_secret" } ``` ### 股票池 `stock_pool.csv` - 包含股票代码、名称、行业分类 ## 评分规则详解 ### 加分项 (+) - MACD金叉: +3 - 神奇九转: +5 - 均值回归: +5 - 激进买入: +3 - 逆势超跌: +3 - 景气向上: +3 - 重大利好: +5 - 突发利好: +6 - 行业优选: +6 ### 扣分项 (-) - 长期观察: -5 - 高位震荡: -5 - 顺势过热: -3 - 景气向下: -3 - 重大利空: -10 - 突发利空: -6 **注意**: 输出报告中仅显示加分项标签 ## 依赖安装 ```bash pip install pandas requests ``` ## 定时任务 ```bash # 安装每日定时任务（每日15:30运行） bash scripts/install_cron.sh ``` ## 文件结构 ``` quant-stock-skill/ ├── SKILL.md # 本文件 ├── scripts/ │ ├── main.py # 主分析程序 │ ├── init_pool.py # 股票池初始化 │ ├── news_module.py # 新闻舆情模块 │ ├── update_pool_hot.py # 热度更新 │ ├── update_sectors.py # 行业更新 │ ├── run_task.sh # 运行脚本 │ └── install_cron.sh # 定时任务安装 ├── references/ │ └── RULES.md # 详细评分规则 └── stock_pool.csv # 股票池数据 ```

path: _meta.json
content: {
  "name": "quant-stock-picker",
  "version": "1.0.0",
  "description": "AI量化选股系统 - 基于多维度评分模型的A股选股分析工具",
  "author": "Rix",
  "tags": ["quant", "stock", "a股", "选股", "投资"],
  "entry_point": "scripts/main.py"
}


path: scripts/update_pool_hot.py
content: import pandas as pd
import requests
import os
import time

POOL_FILE = "workspace/quant_engine/stock_pool.csv"
HEADERS = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'}

def fetch_top_turnover_stocks(limit=300):
    """
    Fetch top stocks by turnover from Tencent Web Interface.
    URL: http://qt.gtimg.cn/q=rank:col%3Damount%26sort%3Ddes%26num%3D{limit}
    Note: Tencent rank interface is tricky. Better to use EastMoney or Sina rank if possible, 
    BUT since we want stability, let's try a proven Tencent rank URL if known, 
    OR use EastMoney Rank (which is usually separate from the blocked quote interface).
    
    Actually, EastMoney Rank Interface (http://push2.eastmoney.com/api/qt/clist/get) is what akshare uses.
    If that is blocked, we can try Sina.
    
    Let's try Sina Rank (http://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData?page=1&num=100&sort=amount&asc=0&node=hs_a&symbol=&_s_r_a=page)
    """
    all_stocks = []
    page = 1
    while len(all_stocks) < limit:
        url = f"http://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData?page={page}&num=100&sort=amount&asc=0&node=hs_a&symbol=&_s_r_a=page"
        try:
            r = requests.get(url, headers=HEADERS, timeout=5)
            if r.status_code != 200: break
            
            data = r.json()
            if not data: break
            
            for item in data:
                # Sina format: {symbol:"sh600000", code:"600000", name:"...", amount:"..."}
                all_stocks.append({
                    'code': item['code'],
                    'name': item['name'],
                    'turnover': float(item['amount'])
                })
                
            page += 1
            time.sleep(1) # Be nice
            
        except Exception as e:
            print(f"Error fetching rank page {page}: {e}")
            break
            
    return pd.DataFrame(all_stocks)

def update_hot_pool():
    print("Fetching top turnover stocks (Hot 200)...")
    
    # 1. Fetch Top 500 by Turnover (to ensure we have enough after filtering)
    top_df = fetch_top_turnover_stocks(limit=500)
    if top_df.empty:
        print("Failed to fetch hot stocks.")
        return

    # 2. Load existing pool
    if os.path.exists(POOL_FILE):
        pool = pd.read_csv(POOL_FILE)
        # Keep BlueChips (source != '热点')
        # Actually, let's keep '指数' or 'CSI300/500' sources
        # Identify existing blue chips
        blue_chips = pool[pool['source'].isin(['CSI300', 'CSI500', '蓝筹'])]
    else:
        print("No existing pool found. Please run init_pool.py first.")
        return

    print(f"Existing Blue Chips: {len(blue_chips)}")
    
    # 3. Filter: Remove stocks already in BlueChips
    # Ensure code is string
    top_df['code'] = top_df['code'].astype(str)
    blue_chips['code'] = blue_chips['code'].astype(str)
    
    existing_codes = set(blue_chips['code'])
    
    # Candidates: Not in existing
    candidates = top_df[~top_df['code'].isin(existing_codes)].copy()
    
    # 4. Take Top 200
    hot_200 = candidates.head(200).copy()
    hot_200['source'] = '热点'
    hot_200 = hot_200[['code', 'name', 'source']]
    
    print(f"Found {len(hot_200)} new hot stocks.")
    
    # 5. Merge and Save
    final_pool = pd.concat([blue_chips, hot_200])
    final_pool.to_csv(POOL_FILE, index=False)
    
    print(f"Updated pool saved to {POOL_FILE}. Total: {len(final_pool)} stocks.")

if __name__ == "__main__":
    update_hot_pool()


path: scripts/run_task.sh
content: #!/bin/bash
# 杭州AI投资社-每日量化选股 (Daily Quant Selection)
# 
# Usage: ./run_task.sh

LOG_FILE="workspace/quant_engine/run.log"
OUTPUT_FILE="workspace/quant_engine/daily_report.txt"
PYTHON_SCRIPT="workspace/quant_engine/main.py"
PYTHON_BIN="workspace/quant_engine/venv/bin/python3"
PIP_BIN="workspace/quant_engine/venv/bin/pip"

# Ensure Python dependencies are installed (first run check)
if ! $PIP_BIN freeze | grep -q "akshare"; then
    echo "Installing dependencies..."
    $PIP_BIN install akshare pandas requests lxml
fi

# Run the Python Analysis Engine
$PYTHON_BIN "$PYTHON_SCRIPT" >> "$LOG_FILE" 2>&1
EXIT_CODE=$?

if [ $EXIT_CODE -ne 0 ]; then
    echo "Python script failed with code $EXIT_CODE. Retrying in 30 seconds..." >> "$LOG_FILE"
    sleep 30
    $PYTHON_BIN "$PYTHON_SCRIPT" >> "$LOG_FILE" 2>&1
    EXIT_CODE=$?
fi

if [ $EXIT_CODE -eq 0 ]; then
    echo "Task completed successfully." >> "$LOG_FILE"
    
    # Read report content
    REPORT_CONTENT=$(cat "$OUTPUT_FILE")
    
    # Send to Feishu via OpenClaw CLI
    echo "Sending report via OpenClaw..." >> "$LOG_FILE"
    openclaw message send --target [REDACTED_SECRET] --message "$REPORT_CONTENT" --channel feishu >> "$LOG_FILE" 2>&1
else
    echo "Task failed. Check logs." >> "$LOG_FILE"
    openclaw message send --target [REDACTED_SECRET] --message "⚠️ Quant Task Failed: Check $LOG_FILE" --channel feishu >> "$LOG_FILE" 2>&1
fi


path: scripts/news_module.py
content: import requests
import re
import datetime
from bs4 import BeautifulSoup
import time

# --- Config ---
HEADERS = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}

# --- Knowledge Base: Keyword -> Impact ---
# Adjusted scores per user request (+/- 5 for Major Events)

IMPACT_RULES = {
    # Macro Policy (Positive)
    '降准': {'score': 5, 'industries': ['银行', '房地产', '证券', '保险']},
    '降息': {'score': 5, 'industries': ['房地产', '有色金属', '证券']},
    '逆回购': {'score': 2, 'industries': ['证券', '银行']}, 
    '减税': {'score': 5, 'industries': ['制造业', '消费']},
    '基建': {'score': 5, 'industries': ['建筑', '建材', '工程机械']},
    '新能源': {'score': 5, 'industries': ['光伏', '风电', '锂电池', '新能源车']},
    '人工智能': {'score': 8, 'industries': ['计算机', '软件', '通信', '半导体']}, # Adjusted
    '数字经济': {'score': 5, 'industries': ['计算机', '通信', '软件']},

    # Macro Policy (Negative)
    '加息': {'score': -5, 'industries': ['房地产', '有色金属']}, 
    '监管': {'score': -5, 'industries': ['互联网', '金融']}, 

    # Sudden Events (Global/China) - Adjusted to +/- 6 per user request (5.3 / 5.4)
    '战争': {'score': 6, 'industries': ['军工', '黄金', '石油', '有色金属']}, 
    '冲突': {'score': 6, 'industries': ['军工', '黄金', '石油']},
    '疫情': {'score': -6, 'industries': ['旅游', '酒店', '航空', '餐饮', '消费']}, 
    '自然灾害': {'score': -6, 'industries': ['农业', '保险', '电力']}, 
    '地震': {'score': 6, 'industries': ['建材', '医药']}, # Reconstruction/Medical
    '突发': {'score': 6, 'industries': []}, # General sudden event keyword 

    # General Positive
    '增长': {'score': 3, 'industries': []},
    '突破': {'score': 5, 'industries': []},
    '中标': {'score': 5, 'industries': []},
    '回购': {'score': 5, 'industries': []},
    
    # General Negative
    '亏损': {'score': -5, 'industries': []},
    '减持': {'score': -5, 'industries': []}, # Changed to -5
    '立案': {'score': -5, 'industries': []}, # Changed to -5
    '违规': {'score': -5, 'industries': []}  # Changed to -5
}

def fetch_eastmoney_news(limit=50):
    print("Fetching news from EastMoney...")
    try:
        url = "https://newsapi.eastmoney.com/kuaixun/v1/getlist_102_ajaxResult_50_1_.html"
        r = requests.get(url, headers=HEADERS, timeout=5)
        text = r.text
        # Clean up var ajaxResult = {...} wrapper if present
        if "var ajaxResult=" in text:
            text = text.split("var ajaxResult=")[1].strip()
        
        # Sometimes it might not be valid JSON directly if it has trailing char, but usually requests.json() handles it if clean
        # Let's try simple regex extract to be safe
        import json
        match = re.search(r'({.*})', text)
        if match:
            data = json.loads(match.group(1))
            news_list = []
            if 'LivesList' in data:
                for item in data['LivesList']:
                    title = item.get('title', '')
                    digest = item.get('digest', '')
                    full = f"{title} {digest}"
                    if full.strip():
                        news_list.append(full.strip())
            return news_list
        return []
    except Exception as e:
        print(f"EastMoney news error: {e}")
        return []

def fetch_cls_news(limit=50):
    print("Fetching news from CLS (Cailian Press)...")
    try:
        url = "https://www.cls.cn/nodeapi/telegraphList?rn=20"
        r = requests.get(url, headers=HEADERS, timeout=5)
        data = r.json()
        news_list = []
        if 'data' in data and 'roll_data' in data['data']:
            for item in data['data']['roll_data']:
                title = item.get('title', '')
                content = item.get('content', '')
                full = f"{title} {content}"
                if full.strip():
                    news_list.append(full.strip())
        return news_list
    except Exception as e:
        print(f"CLS news error: {e}")
        return []

def fetch_sina_news(limit=50):
    print("Fetching news from Sina Finance...")
    try:
        url = f"http://zhibo.sina.com.cn/api/zhibo/feed?page=1&page_size={limit}&zhibo_id=152"
        r = requests.get(url, headers=HEADERS, timeout=10)
        data = r.json()
        
        news_list = []
        if 'result' in data and 'data' in data['result']:
            for item in data['result']['data']['feed']['list']:
                html = item.get('rich_text', '')
                soup = BeautifulSoup(html, 'lxml')
                text = soup.get_text().strip()
                if text:
                    news_list.append(text)
        return news_list
    except Exception as e:
        print(f"Sina news error: {e}")
        return []

def fetch_news_multi_source(limit=50):
    # Strategy: Try EastMoney -> CLS -> Sina
    # Combine results? Or just take first success? 
    # Taking first success is faster and usually sufficient for recent news.
    # Combining might duplicate logic but cover more ground. Let's combine unique.
    
    all_news = []
    
    # 1. EastMoney
    em_news = fetch_eastmoney_news(limit)
    if em_news:
        print(f"Got {len(em_news)} items from EastMoney.")
        all_news.extend(em_news)
    
    # 2. CLS (if EastMoney failed or provided few)
    if len(all_news) < 10:
        cls_news = fetch_cls_news(limit)
        if cls_news:
             print(f"Got {len(cls_news)} items from CLS.")
             all_news.extend(cls_news)
             
    # 3. Sina (Fallback)
    if len(all_news) < 5:
        sina_news = fetch_sina_news(limit)
        if sina_news:
            print(f"Got {len(sina_news)} items from Sina.")
            all_news.extend(sina_news)
            
    return list(set(all_news)) # Dedup

def analyze_sentiment(news_items, pool_df):
    stock_scores = {} 
    matched_tags = {} 
    
    name_map = dict(zip(pool_df['name'], pool_df['code']))
    
    industry_map = {}
    for _, row in pool_df.iterrows():
        ind = str(row.get('industry', '其他'))
        if ind not in industry_map: industry_map[ind] = []
        industry_map[ind].append(str(row['code']).zfill(6))
        
    print(f"Analyzing {len(news_items)} news items against {len(pool_df)} stocks...")
    
    for news in news_items:
        for kw, rule in IMPACT_RULES.items():
            if kw in news:
                score = rule['score']
                target_industries = rule['industries']
                
                if target_industries:
                    matched_pool_inds = [i for i in industry_map.keys() if any(ti in i for ti in target_industries)]
                    for pool_ind in matched_pool_inds:
                        codes = industry_map[pool_ind]
                        for c in codes:
                            stock_scores[c] = stock_scores.get(c, 0) + score
                            if c not in matched_tags: matched_tags[c] = []
                            matched_tags[c].append(f"{kw}利{'好' if score>0 else '空'}")
                else:
                    for name, code in name_map.items():
                        if name in news:
                            # Direct mention score logic:
                            # Rule score is usually small (3-5), maybe boost for direct mention?
                            # User says: "符合重大利好... [+5分]"
                            # Let's use the rule score directly as base, or ensure it hits +/- 5
                            # If rule score is small (3), we boost it to 5 for direct mention if significant keyword?
                            # Actually, let's trust the rule score.
                            stock_scores[code] = stock_scores.get(code, 0) + score
                            if code not in matched_tags: matched_tags[code] = []
                            matched_tags[code].append(kw)
                            
    return stock_scores, matched_tags


path: scripts/main.py
content: import pandas as pd
import requests
import datetime
import time
import sys
import concurrent.futures
import os
import re
import json

import io

# --- Config ---
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OUTPUT_FILE = os.path.join(BASE_DIR, "daily_report.txt")
POOL_FILE = os.path.join(BASE_DIR, "stock_pool.csv")
HEADERS = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}

# Feishu Config
FEISHU_CONFIG_FILE = os.path.join(os.path.dirname(BASE_DIR), "..", "feishu_config.json")
FEISHU_CHAT_ID = [REDACTED_SECRET]  # Updated to standalone bot chat ID

def fetch_tencent_history(code):
    # ... (Keep existing fetch logic)
    code = str(code).zfill(6)
    prefix = "sh" if code.startswith(('6', '9')) else "sz"
    symbol = f"{prefix}{code}"
    url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?_var=kline_dayqfq&param={symbol},day,,,100,qfq"
    try:
        r = requests.get(url, headers=HEADERS, timeout=5)
        if r.status_code != 200: return pd.DataFrame()
        content = r.text
        json_str = content.split('=', 1)[1]
        data = pd.read_json(io.StringIO(json_str))
        stock_data = data['data'].get(symbol, {})
        k_data = stock_data.get('qfqday', stock_data.get('day', []))
        if not k_data: return pd.DataFrame()
        df = pd.DataFrame(k_data)
        if df.shape[1] < 6: return pd.DataFrame()
        df = df.iloc[:, :6]
        df.columns = ['date', 'open', 'close', 'high', 'low', 'vol']
        for col in ['open', 'close', 'high', 'low', 'vol']:
            df[col] = pd.to_numeric(df[col], errors='coerce')
        return df
    except:
        return pd.DataFrame()

def analyze_stock_basic(row):
    """
    Round 1: Individual Technical & Trend Analysis
    Returns: {code, name, industry, score, tags, price, change_pct, vol, close_series}
    """
    code = str(row['code']).zfill(6)
    name = row['name']
    industry = row.get('industry', '其他')
    
    df = fetch_tencent_history(code)
    if df.empty or len(df) < 30: return None
    
    score = 0
    tags = []
    
    close = df['close']
    ma5 = close.rolling(5).mean()
    
    # 1. Technical
    # MACD
    exp12 = close.ewm(span=12, adjust=False).mean()
    exp26 = close.ewm(span=26, adjust=False).mean()
    macd = exp12 - exp26
    signal = macd.ewm(span=9, adjust=False).mean()
    
    if macd.iloc[-1] > signal.iloc[-1] and macd.iloc[-2] <= signal.iloc[-2]:
        score += 3
        tags.append("MACD金叉")
        
    # Magic 9
    c_ref4 = close.shift(4)
    if (close < c_ref4).iloc[-9:].all():
        score += 5
        tags.append("神奇九转")
        
    # MA Regression
    recent_ma5 = ma5.iloc[-20:]
    if len(recent_ma5) == 20 and ma5.iloc[-1] <= recent_ma5.min() * 1.01:
        score += 5
        tags.append("均线回归")

    # 3. Trend (Individual)
    p_now = close.iloc[-1]
    p_3ago = close.iloc[-4] if len(close) > 4 else close.iloc[0]
    pct_3d = (p_now - p_3ago) / p_3ago
    
    # Calculate daily change for Market Sentiment
    p_prev = close.iloc[-2]
    daily_change = (p_now - p_prev) / p_prev
    vol_now = df['vol'].iloc[-1]
    vol_prev = df['vol'].iloc[-2]
    vol_ratio = vol_now / vol_prev if vol_prev > 0 else 1

    # Calculate 2-day amplitude for Module 2.3
    high1, low1 = df['high'].iloc[-1], df['low'].iloc[-1]
    high2, low2 = df['high'].iloc[-2], df['low'].iloc[-2]
    pre_close1 = p_prev
    pre_close2 = df['close'].iloc[-3] if len(df) > 2 else pre_close1
    
    amp1 = (high1 - low1) / pre_close1 if pre_close1 > 0 else 0
    amp2 = (high2 - low2) / pre_close2 if pre_close2 > 0 else 0
    high_vol_2days = (amp1 > 0.03 and amp2 > 0.03)

    return {
        'code': code,
        'name': name,
        'industry': industry,
        'base_score': score, # Round 1 Score
        'tags': tags,
        'price': p_now,
        'daily_change': daily_change,
        'vol_ratio': vol_ratio,
        'pct_3d': pct_3d,
        'high_vol_2days': high_vol_2days
    }

def get_feishu_token():
    """Get Feishu tenant access token"""
    try:
        with open(FEISHU_CONFIG_FILE, 'r') as f:
            config = json.load(f)
        
        url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal'
        response = requests.post(url, json={
            'app_id': config['app_id'],
            'app_secret': config['app_secret']
        }, timeout=10)
        
        if response.status_code == 200:
            data = response.json()
            if data.get('code') == 0:
                return data.get('tenant_access_token')
    except Exception as e:
        print(f"Feishu auth error: {e}")
    return None

def send_feishu_message(token, chat_id, text):
    """Send message to Feishu user"""
    try:
        url = 'https://open.feishu.cn/open-apis/im/v1/messages'
        headers = {
            'Authorization': f'Bearer {token}',
            'Content-Type': 'application/json'
        }
        
        # Use open_id to send message
        params = {'receive_id_type': 'chat_id'}
        data = {
            'receive_id': chat_id,
            'msg_type': 'text',
            'content': json.dumps({'text': text})
        }
        
        response = requests.post(url, headers=headers, params=params, json=data, timeout=10)
        result = response.json()
        
        if result.get('code') == 0:
            print("Message sent to Feishu successfully!")
            return True
        else:
            print(f"Feishu send error: {result}")
            return False
    except Exception as e:
        print(f"Feishu send exception: {e}")
        return False

def main():
    if not os.path.exists(POOL_FILE):
        print("Stock pool file not found.")
        return

    pool = pd.read_csv(POOL_FILE)
    
    # FIX: Remove duplicates based on code
    pool['code'] = pool['code'].astype(str).str.zfill(6)
    pool = pool.drop_duplicates(subset=['code'])
    
    tasks = [row for _, row in pool.iterrows()]
    results = []
    
    print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] Round 1: Analyzing {len(tasks)} unique stocks...")
    
    with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
        futures = [executor.submit(analyze_stock_basic, t) for t in tasks]
        completed = 0
        for f in concurrent.futures.as_completed(futures):
            res = f.result()
            if res: results.append(res)
            completed += 1
            if completed % 100 == 0:
                print(f"Progress: {completed}/{len(tasks)}")
                
    if not results:
        print("No results.")
        return

    print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] Round 2: Market & Industry Analysis...")
    
    # Module 3: Market Trend (Environment)
    url_index = "http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?_var=kline_dayqfq&param=sh000001,day,,,100,qfq"
    market_pct_3d = 0
    try:
        r_idx = requests.get(url_index, headers=HEADERS, timeout=5)
        if r_idx.status_code == 200:
            content = r_idx.text
            json_str = content.split('=', 1)[1]
            data = pd.read_json(json_str)
            sh_data = data['data'].get('sh000001', {}).get('day', [])
            if len(sh_data) >= 4:
                p_now_idx = float(sh_data[-1][2])
                p_3ago_idx = float(sh_data[-4][2])
                market_pct_3d = (p_now_idx - p_3ago_idx) / p_3ago_idx
    except Exception as e:
        print(f"Index fetch error: {e}")

    
    up_count = sum(1 for r in results if r['daily_change'] > 0)
    total_count = len(results)
    up_ratio = up_count / total_count if total_count > 0 else 0
    avg_vol_ratio = sum(r['vol_ratio'] for r in results) / total_count if total_count > 0 else 1
    
    market_sentiment_score = 0
    market_tags = []
    
    # --- Round 2: Market & Industry Analysis ---
    # 2.3 High Volatility Check
    high_vol_count = sum(1 for r in results if r.get('high_vol_2days', False))
    if total_count > 0 and (high_vol_count / total_count) > 0.3:
        market_sentiment_score -= 5
        market_tags.append("高位震荡")

    if up_ratio > 0.65 and avg_vol_ratio > 1.15:
        market_sentiment_score += 3
        market_tags.append("市场积极")
    down_ratio = 1 - up_ratio
    # 2.2 Long-term Watch
    if down_ratio > 0.40 and avg_vol_ratio < 0.8:
        market_sentiment_score -= 5
        market_tags.append("市场观望")
        
    # Module 3: Market Trend (Environment)
    if market_pct_3d < -0.01:
        market_sentiment_score += 3
        market_tags.append("逆势超跌")
    elif market_pct_3d > 0.01:
        market_sentiment_score -= 3
        market_tags.append("顺势过热")
        
    for r in results:
        r['score'] = r['base_score'] + market_sentiment_score
        if market_sentiment_score != 0:
            r['tags'].extend(market_tags)
            
    df_res = pd.DataFrame(results)
    industry_stats = df_res.groupby('industry')['daily_change'].mean()
    top_industries = industry_stats.nlargest(int(len(industry_stats) * 0.1)).index.tolist()
    bottom_industries = industry_stats.nsmallest(int(len(industry_stats) * 0.1)).index.tolist()
    
    # Industry scoring update (4.1 & 4.2)
    # Replaced simple top/bottom 10% with +3/-3 rule based on logic
    # Here we map "top 10%" to "Industry Up" (+3) and "bottom 10%" to "Industry Down" (-3)
    # to simulate the "predicted inflow/outflow" logic with available data.
    
    for r in results:
        if r['industry'] in top_industries:
            r['score'] += 3
            r['tags'].append("景气向上")
        elif r['industry'] in bottom_industries:
            r['score'] -= 3
            r['tags'].append("景气向下")
            
    # News scoring update (5.2)
    try:
        from news_module import fetch_news_multi_source, analyze_sentiment, IMPACT_RULES
        news_items = fetch_news_multi_source(limit=50)
        pool_df = pd.DataFrame(results)
        stock_scores, news_tags = analyze_sentiment(news_items, pool_df)
        
        for r in results:
            code = r['code']
            n_score = stock_scores.get(code, 0)
            if n_score != 0:
                # 5.2 Major Negative: [-10]
                if n_score <= -10: 
                    r['score'] -= 10 
                    r['tags'].append("重大利空")
                
                # 5.3 Sudden Positive (War/Disaster/Policy): [+6]
                # In news_module, we set war/quake to 6.
                elif n_score == 6:
                    r['score'] += 6
                    r['tags'].append("突发利好")
                
                # 5.4 Sudden Negative (War/Disaster/Policy): [-6]
                # In news_module, we set pandemic/disaster to -6.
                elif n_score == -6:
                    r['score'] -= 6
                    r['tags'].append("突发利空")

                # 5.1 Major Positive (Standard): [+5] or others
                elif n_score >= 5:
                     r['score'] += n_score 
                     r['tags'].append("重大利好")
                else:
                     r['score'] += n_score

                specific_tags = news_tags.get(code, [])
                unique_tags = list(set(specific_tags))[:2]
                r['tags'].extend(unique_tags)
    except Exception as e:
        print(f"News module error: {e}")
    
    # --- 6.1 Industry Selection (Module 6) ---
    # Top 200 stocks by current score
    top_200 = sorted(results, key=lambda x: x['score'], reverse=True)[:200]
    
    best_industry = "无"
    if top_200:
        # Count total industry frequency in the whole pool
        total_counts = {}
        for r in results:
            i = r.get('industry', '其他')
            total_counts[i] = total_counts.get(i, 0) + 1
            
        # Count industry frequency in Top 200
        top_counts = {}
        for r in top_200:
            i = r.get('industry', '其他')
            top_counts[i] = top_counts.get(i, 0) + 1
            
        # Calculate ratio (top_count / total_count), require at least 5 stocks in pool to avoid small sample bias
        industry_ratios = {}
        for ind, count in top_counts.items():
            if total_counts.get(ind, 0) >= 5:
                industry_ratios[ind] = count / total_counts[ind]
        
        # Find the max ratio industry
        if industry_ratios:
            best_industry = max(industry_ratios, key=industry_ratios.get)
            
            # Apply +6 score to ALL stocks in this industry
            for r in results:
                if r.get('industry') == best_industry:
                    r['score'] += 6
                    r['tags'].append("行业优选")
                    
    # --- Final Sort & Output ---
    results.sort(key=lambda x: x['score'], reverse=True)
    
    # Dedup in Results
    seen_codes = set()
    unique_results = []
    for r in results:
        if r['code'] not in seen_codes:
            unique_results.append(r)
            seen_codes.add(r['code'])
    results = unique_results

    top3 = results[:3]
    rank4_10 = results[3:10]
    
    positive_keywords = ['金叉', '九转', '回归', '超跌', '向上', '优选', '积极', '利好']
    def filter_tags(tags):
        return [t for t in tags if any(k in t for k in positive_keywords)]

    now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
    
    lines = []
    lines.append(f"【 🔔AI投资研究院-每日量化选股 {now_str}】")
    lines.append(f"核心选股池 (精选{total_count})")
    lines.append("　") # Full-width space (IDEO_SPACE)

    lines.append("🏆 总分TOP3：")
    for i, s in enumerate(top3):
        pos_tags = filter_tags(s['tags'])
        pos_tags.sort(key=lambda x: 0 if '重大' in x else 1)
        final_tags = pos_tags
        t_str = f"[{' '.join(final_tags)}]" if final_tags else ""
        lines.append(f"{i+1}. {s['name']} [{s['code']}] 评分：{s['score']} {t_str}")
        
    lines.append("　") # Full-width space
    lines.append("🎉 4-10名：")
    for i, s in enumerate(rank4_10):
        pos_tags = filter_tags(s['tags'])
        pos_tags.sort(key=lambda x: 0 if '重大' in x else 1)
        final_tags = pos_tags
        t_str = f"[{' '.join(final_tags)}]" if final_tags else ""
        lines.append(f"{i+1}. {s['name']} [{s['code']}] 评分：{s['score']} {t_str}")
        
    lines.append("　") # Full-width space
    lines.append(f"📈 今日最有潜力上涨行业板块：{best_industry}")
    
    report = "\n".join(lines)
    
    with open(OUTPUT_FILE, "w") as f:
        f.write(report)
    
    # Send to Feishu
    print("Sending report to Feishu...")
    feishu_token = get_feishu_token()
    if feishu_token:
        send_feishu_message(feishu_token, FEISHU_CHAT_ID, report)
    else:
        print("Failed to get Feishu token, skipping message send.")

if __name__ == "__main__":
    main()


path: scripts/init_pool.py
content: import akshare as ak
import pandas as pd
import os

POOL_FILE = "workspace/quant_engine/stock_pool.csv"

def init_pool():
    print("Initializing stock pool from Indices (CSI300 + CSI500)...")
    try:
        # 1. CSI 300
        df300 = ak.index_stock_cons(symbol="000300")
        df300['source'] = 'CSI300'
        print(f"Got {len(df300)} from CSI300")
        
        # 2. CSI 500
        df500 = ak.index_stock_cons(symbol="000905")
        df500['source'] = 'CSI500'
        print(f"Got {len(df500)} from CSI500")
        
        # Combine
        # Columns usually: 品种代码, 品种名称, 纳入日期
        # Rename to: code, name, source
        df300 = df300.rename(columns={'品种代码': 'code', '品种名称': 'name'})
        df500 = df500.rename(columns={'品种代码': 'code', '品种名称': 'name'})
        
        final = pd.concat([df300[['code', 'name', 'source']], df500[['code', 'name', 'source']]])
        final = final.drop_duplicates(subset=['code'])
        
        print(f"Total: {len(final)} unique stocks.")
        
        # Save
        final.to_csv(POOL_FILE, index=False)
        print(f"Saved to {POOL_FILE}")
        
    except Exception as e:
        print(f"Error initializing pool: {e}")

if __name__ == "__main__":
    init_pool()


path: scripts/install_cron.sh
content: #!/bin/bash
# Install Quant Engine Cron Jobs

# Get absolute path
WORK_DIR="$(pwd)/workspace/quant_engine"
CRON_FILE="quant.cron"

echo "Detected Quant Engine path: $WORK_DIR"

# 1. Daily Quant Selection (Weekdays 09:15)
# Note: Source .zshrc or .bash_profile to ensure env vars (like PATH for python) are correct if needed.
# But run_task.sh uses absolute path to venv python, so it should be fine.
JOB1="15 09 * * 1-5 cd $WORK_DIR && ./run_task.sh >> run.log 2>&1"

# 2. Weekly Hot Pool Update (Monday 08:30)
JOB2="30 08 * * 1 cd $WORK_DIR && ./update_hot.sh >> pool_update.log 2>&1"

# Backup existing cron
crontab -l > mycron.bak 2>/dev/null

# Create new cron file from existing (excluding old quant jobs to avoid duplicates)
grep -v "quant_engine" mycron.bak > "$CRON_FILE" 2>/dev/null || true

# Append new jobs
echo "# --- Hangzhou AI Quant Engine ---" >> "$CRON_FILE"
echo "$JOB1" >> "$CRON_FILE"
echo "$JOB2" >> "$CRON_FILE"
echo "" >> "$CRON_FILE" # Newline

# Install
crontab "$CRON_FILE"

echo "✅ Crontab installed successfully:"
crontab -l | grep "quant_engine"
rm "$CRON_FILE"


path: scripts/update_sectors.py
content: import pandas as pd
import baostock as bs
import os

POOL_FILE = "workspace/quant_engine/stock_pool.csv"

def update_sectors_with_baostock():
    if not os.path.exists(POOL_FILE):
        print("Pool file not found.")
        return

    df = pd.read_csv(POOL_FILE)
    
    # Login
    print("Logging into Baostock...")
    lg = bs.login()
    if lg.error_code != '0':
        print(f"Login failed: {lg.error_msg}")
        return
        
    # Query Industry Info
    # query_stock_industry() returns code, code_name, industry, industry_classification
    print("Fetching industry data...")
    rs = bs.query_stock_industry()
    
    industries = []
    while (rs.error_code == '0') and rs.next():
        industries.append(rs.get_row_data())
        
    result = pd.DataFrame(industries, columns=rs.fields)
    
    # Clean code: sh.600000 -> 600000
    # Or pool uses 600000, Baostock uses sh.600000
    # Let's standardize
    
    result['code'] = result['code'].apply(lambda x: x.split('.')[1] if '.' in x else x)
    
    # Map industry
    industry_map = dict(zip(result['code'], result['industry']))
    
    # Apply
    df['code'] = df['code'].astype(str).str.zfill(6)
    df['industry'] = df['code'].map(industry_map)
    df['industry'] = df['industry'].fillna("其他")
    
    # Save
    df.to_csv(POOL_FILE, index=False)
    print(f"Updated {POOL_FILE} with industry info (Baostock source).")
    
    bs.logout()

if __name__ == "__main__":
    update_sectors_with_baostock()


path: references/RULES.md
content: # Stock Quant Manager - Scoring Rules (v12 - 2026-03-08)

## Core Task
Scan specific industries (New Energy, Power, Semiconductor, Medicine, AI, Robotics, Military, Gold/Silver) and score stocks based on 6 modules.

## Scoring System (Cumulative)

### Module 1: Technical Indicators (Momentum)
- **1.1 MACD Gold Cross:** Previous day MACD (DIF crosses up DEA). [+3]
- **1.2 Magic Nine:** Price closing lower for 8 or 9 consecutive days (bottom signal). [+5]
- **1.3 Mean Reversion:** 5-day MA at lowest point in last 20 days. [+5]

### Module 2: Market Sentiment (Fund Flow)
- **2.1 Aggressive Buy:** Total market up > 65% AND Volume > 1.15x previous day. [+3]
- **2.2 Long-term Watch:** Market down > 7 days, Volume shrinking, Up ratio 45-55%. [-5]
- **2.3 High Volatility (高位震荡):** > 30% of pool stocks have daily amplitude > 3% (2 days). [-5 for ALL]

### Module 3: Market Trend (Environment)
- **3.1 Counter-trend Oversold:** Market index down > 1% in last 3 days. [+3]
- **3.2 Pro-trend Overheated:** Market index up > 1% in last 3 days. [-3]

### Module 4: Industry Theme (Potential)
- **4.1 Outlook Up (景气向上):** Industry in top 10% (inflow/policy support). [+3]
- **4.2 Outlook Down (景气向下):** Industry in bottom 10% (outflow/correction). [-3]

### Module 5: News/Sentiment (Info Gain)
- **5.1 Major Positive:** National policy, big order, earnings beat (last 24h). [+5]
- **5.2 Major Negative (重大利空):** Regulation, major shareholder selling, negative news (last 24h). [-10]
- **5.3 Sudden Positive (突发利好):** War, conflict, earthquake (reconstruction), sudden policy (last 48h). [+6]
- **5.4 Sudden Negative (突发利空):** Pandemic, disaster, negative sudden event (last 48h). [-6]

### Module 6: Industry Selection (Post-Analysis)
- **6.1 Industry Selection (行业优选):** Most frequent industry in Top 200 stocks (after modules 1-5). [+6 for ALL in that industry]

## Final Recommendation Logic
4. 最终输出：
4.1 筛选出总分[评分]最高的 前 3 名。
4.2 除去前3名，再严格按照[评分]的排名段：4-10名，分类别输出符合的股票。
4.3 标签过滤：输出结果中，**保留所有命中的加分项标签，必须去除所有扣分项的标签**（例如：“景气向下”、“高位震荡”等负面标签不可出现），不再限制只展示3个。

### # 输出要求
请直接给出最终分析结果，格式如下：
【 🔔AI投资研究院-每日量化选股 [当日日期+时间]】
【核心选股池 (精选1000)】

🏆 总分TOP3：
1. [股票名称] [代码] 评分：[总得分] [命中标签]
2. [股票名称] [代码] 评分：[总得分] [命中标签]
3. [股票名称] [代码] 评分：[总得分] [命中标签]

🎉 4-10名：
1. [股票名称] [代码] 评分：[总得分] [命中标签]
2. [股票名称] [代码] 评分：[总得分] [命中标签]
3. [股票名称] [代码] 评分：[总得分] [命中标签]
4. ...

📈 今日最有潜力上涨行业板块：[行业优选名称]

## Industries
New Energy, Power, Semiconductor, Medicine, AI, Robotics, Military, Gold/Silver.


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)