# Day 18 (100天计划): 格雷厄姆价值选股
# Graham's Defensive Investor Criteria: 安全边际 + 财务健康 + 估值合理

# ==================== 可切换参数（在这里修改）====================
VERSION = 'v1'  # 'v1': 严格7条件 | 'v2': 宽松条件 | 'v3': Graham Number | 'v4': Graham+动量
# ================================================================

import pandas as pd
import numpy as np
from jqdata import *

"""
回测参数：2020-01-01 至 2026-02-01，100万，沪深300，月度调仓，持仓20只

格雷厄姆七大标准（A股适配版）：
  1. 规模：总市值 ≥ 10亿元（A股适配，原版1亿美元）
  2. 财务健康：流动比率 ≥ 1.5（原版2.0，A股适配）
  3. 盈利稳定：连续5年净利润 > 0（原版10年，A股历史短适配）
  4. 盈利增长：近3年净利润增长 > 0（排除衰退企业）
  5. PE估值：0 < PE ≤ 20（原版15，A股估值中枢更高）
  6. PB估值：0 < PB ≤ 2.5（原版1.5，A股适配）

Graham Number = √(22.5 × EPS × BVPS)
  22.5 = 15 × 1.5（PE上限 × PB上限）
  安全边际 = (Graham Number - 股价) / Graham Number

V1: 严格6条件（规模+流动比率+盈利稳定+盈利增长+PE+PB），按PB升序选Top20
V2: 宽松条件（阈值放宽），扩大选股池
V3: Graham Number安全边际排序（综合PE+PB的量化安全边际）
V4: Graham宽松条件 + 20日动量过滤（价值底仓+趋势确认）
"""


def initialize(context):
    g.stock_num = 20
    g.index = '000300.XSHG'
    set_option('use_real_price', True)
    log.set_level('order', 'error')
    run_monthly(trade, 1)


# ==================== 筛选函数 ====================

def filter_by_size(stocks, date, min_cap_yi=10):
    """
    条件1：总市值 ≥ min_cap_yi 亿元
    聚宽 market_cap 单位是亿元
    """
    try:
        q = query(valuation.code, valuation.market_cap).filter(
            valuation.code.in_(stocks),
            valuation.market_cap >= min_cap_yi
        )
        df = get_fundamentals(q, date=date)
        return df['code'].tolist() if not df.empty else []
    except Exception as e:
        log.error(f"规模筛选失败: {e}")
        return []


def filter_by_liquidity_ratio(stocks, date, min_ratio=1.5):
    """
    条件2：流动比率 = 流动资产 / 流动负债 ≥ min_ratio
    衡量短期偿债能力，防止流动性危机
    """
    try:
        q = query(
            valuation.code,
            balance.total_current_assets,
            balance.total_current_liability
        ).filter(
            valuation.code.in_(stocks),
            balance.total_current_liability > 0
        )
        df = get_fundamentals(q, date=date)
        if df.empty:
            return []
        df['liq_ratio'] = df['total_current_assets'] / df['total_current_liability']
        return df[df['liq_ratio'] >= min_ratio]['code'].tolist()
    except Exception as e:
        log.error(f"流动比率筛选失败: {e}")
        return []


def filter_by_earnings_stability(stocks, date, years=5):
    """
    条件3：连续N年净利润 > 0
    商业模式可持续，排除周期性亏损企业
    """
    qualified = []
    for stock in stocks:
        try:
            all_positive = True
            for y in range(years):
                q_date = date - pd.DateOffset(years=y)
                df = get_fundamentals(
                    query(income.net_profit).filter(valuation.code == stock),
                    date=q_date
                )
                if df.empty or df['net_profit'].values[0] <= 0:
                    all_positive = False
                    break
            if all_positive:
                qualified.append(stock)
        except:
            continue
    return qualified


def filter_by_earnings_growth(stocks, date):
    """
    条件4：近3年净利润增长 > 0（排除衰退企业）
    比较当前净利润 vs 3年前净利润
    """
    qualified = []
    for stock in stocks:
        try:
            df_now = get_fundamentals(
                query(income.net_profit).filter(valuation.code == stock),
                date=date
            )
            df_3y = get_fundamentals(
                query(income.net_profit).filter(valuation.code == stock),
                date=date - pd.DateOffset(years=3)
            )
            if df_now.empty or df_3y.empty:
                continue
            profit_now = df_now['net_profit'].values[0]
            profit_3y = df_3y['net_profit'].values[0]
            # 3年前盈利 > 0，且现在更好
            if profit_3y > 0 and profit_now > profit_3y:
                qualified.append(stock)
        except:
            continue
    return qualified


def filter_by_pe_pb(stocks, date, max_pe=20, max_pb=2.5):
    """
    条件5+6：PE ≤ max_pe 且 PB ≤ max_pb
    核心安全边际：不为成长性支付溢价，资产有保障
    """
    try:
        q = query(valuation.code, valuation.pe_ratio, valuation.pb_ratio).filter(
            valuation.code.in_(stocks),
            valuation.pe_ratio > 0,
            valuation.pe_ratio <= max_pe,
            valuation.pb_ratio > 0,
            valuation.pb_ratio <= max_pb
        )
        df = get_fundamentals(q, date=date)
        return df['code'].tolist() if not df.empty else []
    except Exception as e:
        log.error(f"PE/PB筛选失败: {e}")
        return []


def get_graham_number_scores(stocks, date):
    """
    Graham Number = √(22.5 × EPS × BVPS)
    安全边际 = (Graham Number - 股价) / Graham Number

    推导：
      股价 = EPS × PE
      BVPS = 股价 / PB = EPS × PE / PB
      Graham Number = √(22.5 × EPS × EPS × PE / PB)
                    = EPS × √(22.5 × PE / PB)
    安全边际越大 = 股价越低于理论价值
    """
    try:
        q = query(
            valuation.code,
            valuation.pe_ratio,
            valuation.pb_ratio,
            income.basic_eps
        ).filter(
            valuation.code.in_(stocks),
            valuation.pe_ratio > 0,
            valuation.pb_ratio > 0,
            income.basic_eps > 0
        )
        df = get_fundamentals(q, date=date)
        if df.empty:
            return pd.Series(dtype=float)

        scores = {}
        for _, row in df.iterrows():
            try:
                code = row['code']
                eps = row['basic_eps']
                pe = row['pe_ratio']
                pb = row['pb_ratio']

                # 推导股价和BVPS
                price = eps * pe
                bvps = price / pb

                # Graham Number（只有EPS > 0 且 BVPS > 0 才有意义）
                if eps > 0 and bvps > 0:
                    graham_num = np.sqrt(22.5 * eps * bvps)
                    margin_of_safety = (graham_num - price) / graham_num
                    scores[code] = margin_of_safety
            except:
                continue

        return pd.Series(scores)
    except Exception as e:
        log.error(f"Graham Number计算失败: {e}")
        return pd.Series(dtype=float)


def filter_by_momentum(stocks, date):
    """
    V4动量过滤：近20日价格上涨才买入
    价值底仓 + 趋势确认，避免接"价值陷阱"的下跌刀
    """
    try:
        price_df = get_price(stocks, end_date=date, count=21,
                             fields=['close'], panel=False, fq='pre')
        if price_df is None or price_df.empty:
            return stocks
        close_p = price_df.pivot(index='time', columns='code', values='close')
        trending = []
        for stock in close_p.columns:
            cl = close_p[stock].dropna()
            if len(cl) >= 2 and cl.iloc[-1] > cl.iloc[0]:
                trending.append(stock)
        return trending
    except:
        return stocks


def rank_by_pb(stocks, date, top_n):
    """
    辅助：按PB从低到高排序取Top N
    PB越低 = 每单位净资产付出的价格越少 = 安全边际越大
    """
    if not stocks:
        return []
    try:
        q = query(valuation.code, valuation.pb_ratio).filter(
            valuation.code.in_(stocks),
            valuation.pb_ratio > 0
        )
        df = get_fundamentals(q, date=date)
        if df.empty:
            return stocks[:top_n]
        return df.sort_values('pb_ratio').head(top_n)['code'].tolist()
    except:
        return stocks[:top_n]


# ==================== 交易 ====================

def trade(context):
    stocks = get_index_stocks(g.index)
    dt = context.current_dt

    if VERSION == 'v1':
        # 严格格雷厄姆：6条件全满足，按PB升序选Top20
        pool = filter_by_size(stocks, dt, min_cap_yi=10)
        log.info(f"[V1] 规模(≥10亿)筛选后: {len(pool)}只")
        if not pool:
            return

        pool = filter_by_liquidity_ratio(pool, dt, min_ratio=1.5)
        log.info(f"[V1] 流动比率(≥1.5)筛选后: {len(pool)}只")
        if not pool:
            return

        pool = filter_by_earnings_stability(pool, dt, years=5)
        log.info(f"[V1] 盈利稳定(5年正)筛选后: {len(pool)}只")
        if not pool:
            return

        pool = filter_by_earnings_growth(pool, dt)
        log.info(f"[V1] 盈利增长(3年)筛选后: {len(pool)}只")
        if not pool:
            return

        pool = filter_by_pe_pb(pool, dt, max_pe=20, max_pb=2.5)
        log.info(f"[V1] PE(≤20)+PB(≤2.5)筛选后: {len(pool)}只")

        target = rank_by_pb(pool, dt, g.stock_num)

    elif VERSION == 'v2':
        # 宽松格雷厄姆：放宽阈值，扩大选股池
        pool = filter_by_size(stocks, dt, min_cap_yi=5)
        log.info(f"[V2] 规模(≥5亿)筛选后: {len(pool)}只")
        if not pool:
            return

        pool = filter_by_liquidity_ratio(pool, dt, min_ratio=1.2)
        log.info(f"[V2] 流动比率(≥1.2)筛选后: {len(pool)}只")
        if not pool:
            return

        pool = filter_by_earnings_stability(pool, dt, years=3)
        log.info(f"[V2] 盈利稳定(3年正)筛选后: {len(pool)}只")
        if not pool:
            return

        pool = filter_by_earnings_growth(pool, dt)
        log.info(f"[V2] 盈利增长筛选后: {len(pool)}只")
        if not pool:
            return

        pool = filter_by_pe_pb(pool, dt, max_pe=25, max_pb=3.5)
        log.info(f"[V2] PE(≤25)+PB(≤3.5)筛选后: {len(pool)}只")

        target = rank_by_pb(pool, dt, g.stock_num)

    elif VERSION == 'v3':
        # Graham Number安全边际排序
        # 先做初步估值过滤，再用Graham Number精选
        pool = filter_by_size(stocks, dt, min_cap_yi=5)
        pool = filter_by_pe_pb(pool, dt, max_pe=30, max_pb=5.0)
        log.info(f"[V3] 初步过滤后: {len(pool)}只")
        if not pool:
            return

        scores = get_graham_number_scores(pool, dt)
        if scores.empty:
            return

        # 只保留安全边际 > 0 的（股价低于Graham Number才有安全边际）
        positive = scores[scores > 0]
        log.info(f"[V3] 安全边际>0: {len(positive)}只，最大安全边际: {positive.max():.2%}")

        target = positive.sort_values(ascending=False).head(g.stock_num).index.tolist()

    elif VERSION == 'v4':
        # 宽松格雷厄姆 + 动量过滤
        # 价值底仓：保证买的不贵；动量过滤：避免价值陷阱
        pool = filter_by_size(stocks, dt, min_cap_yi=5)
        pool = filter_by_liquidity_ratio(pool, dt, min_ratio=1.2)
        pool = filter_by_earnings_stability(pool, dt, years=3)
        pool = filter_by_earnings_growth(pool, dt)
        pool = filter_by_pe_pb(pool, dt, max_pe=25, max_pb=3.5)
        log.info(f"[V4] 格雷厄姆筛选后: {len(pool)}只")
        if not pool:
            return

        pool = filter_by_momentum(pool, dt)
        log.info(f"[V4] 动量过滤后: {len(pool)}只")

        target = rank_by_pb(pool, dt, g.stock_num)

    else:
        return

    # ──── 调仓 ────
    if not target:
        log.warn(f"[{VERSION}] 本月无满足条件的股票，维持原仓位")
        return

    for stock in list(context.portfolio.positions.keys()):
        if stock not in target:
            order_target(stock, 0)

    w = 1.0 / len(target)
    for stock in target:
        order_target_value(stock, context.portfolio.total_value * w)

    log.info(f"[{VERSION}] 持仓{len(target)}只，月度换仓完成")
