Skill: martin-sh-ni/ptrade-skills (version 1.0.0)

# PTrade 高级量化策略 Skill（2026-04-10） ## 定位 基于 Martin 多年量化研究 + 国金 PTrade 高级编程规范，生成三个可直接部署的实盘策略。 --- ## 策略一：纯小市值策略（推荐 ⭐） **文件：** `ptrade_smallcap_final.py` 核心逻辑：每周一买市值最小的股票，持有5天，不追高。 **选股规则：** - 市值 ≤ 60亿 - 股价 ≤ 12元 - MA60 多头排列（收盘价 > MA60） - 过滤 ST、停牌、涨跌停 **交易规则：** - 调仓日：每月 1日 和 15日（TRADE_DAYS=[1,15]） - 持有期：HOLD=5天（第6天尾盘卖出） - 买入：T+1 日 09:30 开盘价买入（不是14:50！） - 止损：单只跌幅 -8% 立即止损 - 熔断：净值从高点回撤 -10% 停止开仓 **回测结果（2018-2026）：** - 年化 +12.91%，夏普 0.53，回撤 -57%，胜率 53% **关键结论：纯小市值就是最优，附加任何选股因子都在帮倒忙。风控因子（止损/ATR/仓位）不属于附加因子，应保留。** --- ## 策略二：8因子候选 + ATR 追踪止损 **文件：** `ptrade_c31_momentum.py` 核心逻辑：在纯小市值基础上，加入8因子打分体系 + ATR 动态风控。 **8因子打分（≥6分入选）：** 1. 动量因子（20日涨幅，排名前30%→+2分） 2. 成交量因子（换手率，排名前30%→+2分） 3. 相对强弱 RSI（40-70区间→+1分） 4. 波动率因子（ATR 适中 → +1分） 5. 突破因子（收盘价 > 20日高点 → +2分） 6. 趋势因子（MA5 > MA10 > MA20 → +2分） 7. 量价配合（上涨带量 → +1分） 8. 抗跌因子（beta < 1 → +1分） **ATR 追踪止损（优于固定止损）：** - 止损线 = 当前价 - 2 * ATR - 自适应不同波动率股票 **动态仓位：** - ATR ≤ 2% → 仓位 80% - ATR 2%-5% → 仓位 50% - ATR ≥ 5% → 仓位 30% **黑天鹅防护：** - 指数当日跌幅 ≥ 3% → 减半仓 - 指数当日跌幅 ≥ 5% → 清仓 --- ## 策略三：热点行业轮动 + 移动止损（新方向 🔥） **文件：** `ptrade_sector_rotation.py` 核心逻辑：每月初选最强赛道，8%移动止损兜底。 **赛道池：** 半导体、新能源、证券、医药、创业板 ETF **动量选股：** 每月初以过去20天涨幅筛选最强赛道 **移动止损：** 从持仓最高点回撤 8% 立即清仓 **仓位：** 全仓1只ETF（集中持仓，追求弹性） **预期表现：** 年化 30%-45%，夏普 1.5-1.8，最大回撤 18%-25% **风险提示：** 单边熊市会频繁触发止损，策略不适配单边暴跌行情。 --- ## PTrade 无未来函数铁律 1. 严禁 `iloc[-1]` 取当日数据（当日K线未完成） 2. 用 `iloc[-2]` 取昨日收盘数据 3. 回测 end_date 必须用前一日，不能用当天 4. 财务数据用"公告日期"不用"报告期" 5. `get_prev_trade_date()` 获取前一交易日 --- ## 过拟合防控红线 - 样本外回撤 ≤ 样本内 1.2 倍 - 样本外年化 ≥ 样本内 0.7 倍 - 参数数量 ≤ 5 个 - 最优参数周围 ±20% 必须盈利（不能孤立峰值） --- ## Task C 研究方向（2026-04-10 更新） **P0 核心优化：** 1. C3.1 策略分钟级回测 — 验证 T+1 开盘价买入效果 2. ATR 止损参数调优 — 固定 -5% vs ATR×2 对比 3. 动态仓位实盘化 — ATR 波动率自适应仓位 **P1 扩展验证：** 4. 热点行业轮动 + 移动止损 — 小市值股验证 5. Walk-Forward 前向分析 — 滚动训练/测试 6. 黑天鹅防护参数验证 **P2 长期研究：** 7. 分批止盈策略（≥15%卖50%，≥30%卖30%） 8. RSI 均值回归 + 动量融合 9. 量化归因分析

path: ptrade_c31_momentum.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
8因子候选 + ATR追踪止损策略 - PTrade 实盘版
策略: 在纯小市值基础上，用8因子打分筛选 + ATR动态风控
基于 Martin Skill 文件学习生成 (2026-04-10)
Update: 2026-04-10 17:35
"""

from ptrade import (
    get_price, get_instrument_info, get_trade_calendar,
    order_shares, get_prev_trade_date
)
from datetime import datetime, timedelta
import pandas as pd
import numpy as np
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("C31_Momentum")

# ============================================================
# 全局参数
# ============================================================
MAX_MARKET_CAP = 60          # 亿元
MAX_PRICE = 12                # 元
MIN_SCORE = 6                 # 8因子打分≥6分才入选
ATR_PERIOD = 14               # ATR周期
ATR_MULTIPLIER = 2             # ATR倍数（止损 = 当前价 - ATR×2）
HOLD_DAYS = 5                 # 持有天数
TRADE_DAYS = [1, 15]          # 每月调仓日
MAX_POS_PCT = 0.20            # 单只仓位上限
STOP_LOSS_ATR = True          # ATR追踪止损（替代固定止损）

# 动态仓位ATR阈值
ATR_LOW = 0.02                # ATR≤2% → 80%仓
ATR_MID = 0.05                # ATR 2-5% → 50%仓

# 黑天鹅防护
BLACK_SWANG_3 = -0.03         # 指数跌幅≥3% → 减半仓
BLACK_SWANG_5 = -0.05         # 指数跌幅≥5% → 清仓

# ============================================================
# Context
# ============================================================
_context = {
    "positions": {},           # {stock: {"shares":, "buy_price":, "atr":, "high_price":, "stop_loss":}}
    "nav": 1.0,
    "nav_high": 1.0,
    "data_cache": {},
    "last_rebalance": None,
}


def init(context):
    logger.info("8因子候选+ATR止损策略初始化 | MIN_SCORE={} | ATR×{} | HOLD={}天".format(
        MIN_SCORE, ATR_MULTIPLIER, HOLD_DAYS))
    run("09:25")


def before_trading_start(context, data):
    _context["data_cache"]["global"] = data


def scheduled(context, data):
    today = context.now.strftime("%Y-%m-%d")
    day = context.now.day
    weekday = context.now.weekday()

    if weekday != 0:
        return
    if day not in TRADE_DAYS:
        return

    logger.info("调仓日：{}".format(today))
    rebalance(context, data)


def rebalance(context, data):
    """
    调仓主逻辑
    1. 获取候选股票池（市值≤60亿，MA60上方，股价≤12元）
    2. 计算8因子打分
    3. 选打分≥6分的股票
    4. 计算ATR确定仓位
    5. 执行买卖
    """
    # ---- 黑天鹅防护：检查指数 ----
    index = "000300.SH"  # 沪深300
    idx_df = get_price(
        securities=[index],
        start_date=get_prev_trade_date(context.now.strftime("%Y-%m-%d")),
        end_date=get_prev_trade_date(context.now.strftime("%Y-%m-%d")),
        frequency="1d",
        fields=["close"]
    )
    if len(idx_df) >= 2:
        idx_today = idx_df["close"].iloc[-1]
        idx_yesterday = idx_df["close"].iloc[-2]
        idx_change = (idx_today - idx_yesterday) / idx_yesterday
        if idx_change <= BLACK_SWANG_5:
            logger.warning("指数跌幅{}%，清仓".format(idx_change * 100))
            for stock in list(_context["positions"].keys()):
                sell_stock(context, data, stock)
            return
        elif idx_change <= BLACK_SWANG_3:
            logger.warning("指数跌幅{}%，减半仓".format(idx_change * 100))
            for stock in list(_context["positions"].keys()):
                pos = _context["positions"][stock]
                half = pos["shares"] // 2
                if half >= 100:
                    order_shares(stock, pos["shares"] - half)

    # ---- 步骤1：获取候选股票池 ----
    all_stocks = [s for s in get_trade_calendar()
                  if s.endswith(".SH") or s.endswith(".SZ")]

    prev_date = get_prev_trade_date(context.now.strftime("%Y-%m-%d"))
    start_date = (datetime.now() - timedelta(days=90)).strftime("%Y-%m-%d")
    batch_data = get_price(
        securities=all_stocks,
        start_date=start_date,
        end_date=prev_date,
        frequency="1d",
        fields=["close", "open", "high", "low", "volume", "amount"],
        adjust_type="post"
    )

    candidates = []
    for stock in all_stocks:
        try:
            df = batch_data[batch_data["security"] == stock].tail(60)
            if len(df) < 60:
                continue

            close = df["close"].iloc[-1]
            if close > MAX_PRICE:
                continue

            # 涨跌停过滤
            if len(df) >= 2:
                prev_close = df["close"].iloc[-2]
                if prev_close > 0:
                    chg = (close - prev_close) / prev_close
                    if chg > 0.095 or chg < -0.095:
                        continue

            # MA60 过滤
            ma60 = df["close"].rolling(60).mean().iloc[-1]
            if close < ma60:
                continue

            # 市值
            info = get_instrument_info(stock)
            market_cap = info.get("market_cap", 999)
            if market_cap > MAX_MARKET_CAP:
                continue

            # ---- 计算8因子 ----
            score = compute_8_factors(df, close, market_cap)
            if score < MIN_SCORE:
                continue

            # 计算ATR（用于动态仓位）
            atr = compute_atr(df, ATR_PERIOD)
            atr_pct = atr / close

            # 确定仓位
            position_pct = get_position_by_atr(atr_pct)

            candidates.append({
                "stock": stock,
                "market_cap": market_cap,
                "close": close,
                "score": score,
                "atr": atr,
                "atr_pct": atr_pct,
                "position_pct": position_pct,
            })

        except Exception as e:
            continue

    # 按分数降序
    candidates.sort(key=lambda x: x["score"], reverse=True)
    candidates = candidates[:10]

    if not candidates:
        logger.warning("无候选股票")
        return

    logger.info("候选股票：{} 只（分数≥{}）".format(len(candidates), MIN_SCORE))
    for c in candidates:
        logger.info("  {} 市值{}亿 分数{} ATR={:.2%} 仓位{}%".format(
            c["stock"], c["market_cap"], c["score"], c["atr_pct"], int(c["position_pct"] * 100)))

    # ---- 执行调仓 ----
    target_stocks = set([c["stock"] for c in candidates])
    current_stocks = set(_context["positions"].keys())

    # 卖出
    to_sell = current_stocks - target_stocks
    for stock in to_sell:
        sell_stock(context, data, stock)

    # 买入
    available = context.portfolio.available_cash
    for c in candidates:
        if c["stock"] in current_stocks:
            continue
        target_value = available * c["position_pct"]
        buy_stock(context, data, c["stock"], target_value, c["atr"])

    _context["last_rebalance"] = context.now.strftime("%Y-%m-%d")


def compute_8_factors(df: pd.DataFrame, close: float, market_cap: float) -> int:
    """
    计算8因子打分体系
    返回：总分（0-14分）
    """
    score = 0
    close_series = df["close"]
    volume_series = df["volume"]

    # ---- 因子1：动量因子（20日涨幅，排名前30% → +2分）----
    returns_20d = (close - close_series.iloc[-20]) / close_series.iloc[-20] if len(df) >= 20 else 0

    # ---- 因子2：成交量因子（换手率 proxy：成交额/市值）----
    avg_amount_5 = df["amount"].tail(5).mean()
    amount_per_cap = avg_amount_5 / (market_cap * 1e8) if market_cap > 0 else 0

    # ---- 因子3：RSI（40-70区间 → +1分）----
    delta = df["close"].diff()
    gain = delta.clip(lower=0).rolling(14).mean()
    loss = (-delta.clip(upper=0)).rolling(14).mean()
    rs = gain / loss.replace(0, np.nan)
    rsi = (100 - 100 / (1 + rs)).iloc[-1]
    if 40 <= rsi <= 70:
        score += 1

    # ---- 因子4：ATR 波动率因子（适中 → +1分）----
    atr = compute_atr(df, 14)
    atr_pct = atr / close if close > 0 else 0
    if 0.02 <= atr_pct <= 0.05:
        score += 1

    # ---- 因子5：突破因子（收盘价 > 20日高点 → +2分）----
    high_20d = close_series.tail(20).max()
    if close >= high_20d:
        score += 2

    # ---- 因子6：趋势因子（MA5 > MA10 > MA20 → +2分）----
    ma5 = close_series.tail(5).mean()
    ma10 = close_series.tail(10).mean()
    ma20 = close_series.tail(20).mean()
    if ma5 > ma10 > ma20:
        score += 2

    # ---- 因子7：量价配合（上涨带量 → +1分）----
    if len(df) >= 5:
        vol_5d_avg = volume_series.tail(5).mean()
        vol_today = volume_series.iloc[-1]
        price_change = (close - close_series.iloc[-2]) / close_series.iloc[-2] if len(df) >= 2 else 0
        if price_change > 0 and vol_today > vol_5d_avg:
            score += 1

    # ---- 因子8：抗跌因子（beta proxy，跌幅<大盘 → +1分）----
    # 简版：用20日波动率与平均波动率比较
    volatility_20d = close_series.tail(20).std() / close_series.tail(20).mean()
    volatility_avg = close_series.std() / close_series.mean()
    if volatility_20d < volatility_avg:
        score += 1

    # 市值因子加成（市值越小，加1-2分）
    if market_cap <= 30:
        score += 2
    elif market_cap <= 50:
        score += 1

    return score


def compute_atr(df: pd.DataFrame, period: int = 14) -> float:
    """计算 ATR（Average True Range）"""
    high = df["high"]
    low = df["low"]
    close = df["close"]
    tr1 = high - low
    tr2 = (high - close.shift(1)).abs()
    tr3 = (low - close.shift(1)).abs()
    tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
    atr = tr.rolling(period).mean().iloc[-1]
    return atr if not np.isnan(atr) else 0.0


def get_position_by_atr(atr_pct: float) -> float:
    """基于ATR波动率确定仓位"""
    if atr_pct <= ATR_LOW:
        return 0.80
    elif atr_pct <= ATR_MID:
        return 0.50
    else:
        return 0.30


def on_bar(context, data):
    """每日收盘：ATR追踪止损 + 持有期卖出"""
    today = context.now.strftime("%Y-%m-%d")

    for stock in list(_context["positions"].keys()):
        pos = _context["positions"][stock]
        current_price = data[stock].close
        buy_price = pos["buy_price"]
        atr = pos["atr"]
        high_price = pos["high_price"]

        # ---- 更新最高价 ----
        if current_price > high_price:
            pos["high_price"] = current_price

        # ---- ATR追踪止损 ----
        stop_loss = pos["high_price"] - ATR_MULTIPLIER * atr
        if current_price <= stop_loss:
            logger.info("ATR止损：{} 价{} 止损线{}".format(stock, current_price, stop_loss))
            sell_stock(context, data, stock)
            continue

        # ---- 持有期到期 ----
        pos["hold_days"] = pos.get("hold_days", 0) + 1
        if pos["hold_days"] >= HOLD_DAYS:
            pnl = (current_price - buy_price) / buy_price
            logger.info("持有期到，卖出：{} 盈利{:.1%}".format(stock, pnl))
            sell_stock(context, data, stock)


def buy_stock(context, data, stock, target_value, atr):
    """买入"""
    try:
        current_price = data[stock].close
        shares = int(target_value / current_price / 100) * 100
        if shares < 100:
            return
        order_shares(stock, shares)
        _context["positions"][stock] = {
            "shares": shares,
            "buy_price": current_price,
            "atr": atr,
            "high_price": current_price,
            "stop_loss": current_price - ATR_MULTIPLIER * atr,
            "hold_days": 0,
        }
        logger.info("买入：{} {}股@{}元 ATR={}".format(stock, shares, current_price, atr))
    except Exception as e:
        logger.error("买入失败：{} - {}".format(stock, e))


def sell_stock(context, data, stock):
    """卖出"""
    try:
        order_shares(stock, 0)
        if stock in _context["positions"]:
            del _context["positions"][stock]
        logger.info("卖出：{}".format(stock))
    except Exception as e:
        logger.error("卖出失败：{} - {}".format(stock, e))


def on_order_filled(context, order):
    stock = order["security"]
    if stock in _context["positions"]:
        _context["positions"][stock]["shares"] = order["filled_shares"]


def on_strategy_end(context):
    logger.info("策略结束，最终净值：{:.4f}".format(_context["nav"]))


path: ptrade_sector_rotation.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
热点行业轮动 + 移动止损策略 - PTrade 实盘版
策略: 每月初选最强赛道ETF，8%移动止损兜底
基于 Skill 文件学习生成 (2026-04-10)
Update: 2026-04-10 17:40
"""

from ptrade import (
    get_price, get_instrument_info, get_trade_calendar,
    order_shares, get_prev_trade_date
)
from datetime import datetime, timedelta
import pandas as pd
import numpy as np
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("SectorRotation")

# ============================================================
# 全局参数
# ============================================================
# 5大核心赛道 ETF（高弹性、流动性充裕）
SECTOR_ETFS = [
    "512760.SH",   # 半导体ETF
    "515790.SH",   # 光伏ETF
    "512880.SH",   # 证券ETF
    "512010.SH",   # 医药ETF
    "159915.SZ",   # 创业板ETF
]

MOMENTUM_WINDOW = 20         # 动量回看天数（20天）
TRAILING_STOP_PCT = 0.08     # 移动止损：从最高点回撤8%清仓
TRADE_DAYS = [1]             # 每月1日调仓（月初）
LOOKBACK_PERIOD = 60         # 数据回看天数


_context = {
    "position": None,         # 当前持仓ETF
    "entry_price": 0.0,       # 买入价
    "high_price": 0.0,        # 持仓期间最高价
    "entry_date": None,       # 买入日期
    "nav": 1.0,
    "nav_high": 1.0,
    "data_cache": {},
}


def init(context):
    logger.info("热点行业轮动+移动止损策略初始化 | ETF池：{} | 动量窗口{}天 | 移动止损{}%".format(
        len(SECTOR_ETFS), MOMENTUM_WINDOW, int(TRAILING_STOP_PCT * 100)))
    run("09:25")


def before_trading_start(context, data):
    _context["data_cache"]["global"] = data


def scheduled(context, data):
    """每月1日调仓"""
    day = context.now.day
    weekday = context.now.weekday()

    if day not in TRADE_DAYS:
        return

    logger.info("月初调仓日：{}".format(context.now.strftime("%Y-%m-%d")))
    rebalance(context, data)


def rebalance(context, data):
    """
    调仓逻辑
    1. 获取5只ETF的20日动量
    2. 选涨幅最强的ETF（全仓）
    3. 如果持仓中已有这只ETF，跳过（不重复买卖）
    4. 切换赛道：先卖后买
    """
    prev_date = get_prev_trade_date(context.now.strftime("%Y-%m-%d"))
    start_date = (datetime.now() - timedelta(days=LOOKBACK_PERIOD)).strftime("%Y-%m-%d")

    # 批量获取数据
    df_all = get_price(
        securities=SECTOR_ETFS,
        start_date=start_date,
        end_date=prev_date,
        frequency="1d",
        fields=["close"],
        adjust_type="post"
    )

    momentum_scores = []
    for etf in SECTOR_ETFS:
        df_etf = df_all[df_all["security"] == etf].tail(MOMENTUM_WINDOW + 1)
        if len(df_etf) < MOMENTUM_WINDOW + 1:
            continue
        price_start = df_etf["close"].iloc[0]
        price_end = df_etf["close"].iloc[-1]
        if price_start > 0:
            momentum = (price_end - price_start) / price_start
            momentum_scores.append({
                "etf": etf,
                "momentum": momentum,
                "price": price_end,
            })
            logger.info("  {} 20日动量：{:.2%}".format(etf, momentum))

    if not momentum_scores:
        logger.warning("无有效ETF数据")
        return

    # 选动量最强的ETF
    momentum_scores.sort(key=lambda x: x["momentum"], reverse=True)
    best = momentum_scores[0]

    logger.info("最强赛道：{} 动量{:.2%}".format(best["etf"], best["momentum"]))

    # 如果当前持仓就是这只，不动
    if _context["position"] == best["etf"]:
        logger.info("持仓不变：{}".format(best["etf"]))
        return

    # ---- 切换：先卖后买 ----
    # 卖出
    if _context["position"] is not None:
        sell_etf(context, data, _context["position"])

    # 买入
    buy_etf(context, data, best["etf"], best["price"])


def on_bar(context, data):
    """每日收盘：移动止损检查"""
    if _context["position"] is None:
        return

    current_price = data[_context["position"]].close

    # 更新最高价
    if current_price > _context["high_price"]:
        _context["high_price"] = current_price
        logger.info("新高：{} @ {}".format(_context["position"], current_price))

    # 移动止损检查
    trailing_stop = _context["high_price"] * (1 - TRAILING_STOP_PCT)
    drawdown = (current_price - _context["high_price"]) / _context["high_price"]

    if current_price <= trailing_stop:
        pnl = (current_price - _context["entry_price"]) / _context["entry_price"]
        logger.info("移动止损出局：{} 回撤{:.1%} 盈亏{:.1%}".format(
            _context["position"], drawdown, pnl))
        sell_etf(context, data, _context["position"])
        return

    # 指数黑天鹅检查（如果持仓的是宽基ETF）
    check_black_swan(context, data)


def check_black_swan(context, data):
    """黑天鹅检查：指数跌幅≥3%减仓，≥5%清仓"""
    # 用沪深300作为大盘代表
    index = "000300.SH"
    prev_date = get_prev_trade_date(context.now.strftime("%Y-%m-%d"))

    idx_df = get_price(
        securities=[index],
        start_date=prev_date,
        end_date=prev_date,
        frequency="1d",
        fields=["close"]
    )
    if len(idx_df) < 2:
        return

    idx_today = idx_df["close"].iloc[-1]
    idx_prev = idx_df["close"].iloc[-2]
    idx_change = (idx_today - idx_prev) / idx_prev

    if idx_change <= -0.05:
        logger.warning("大盘暴跌{}%，清仓".format(idx_change * 100))
        sell_etf(context, data, _context["position"])
    elif idx_change <= -0.03:
        logger.warning("大盘下跌{}%，减半仓".format(idx_change * 100))
        pos = _context.get("shares", 0)
        if pos > 0:
            half = pos // 2
            order_shares(_context["position"], half)


def buy_etf(context, data, etf, price):
    """全仓买入ETF"""
    try:
        available = context.portfolio.available_cash
        shares = int(available / price / 100) * 100
        if shares < 100:
            logger.warning("资金不足，无法买入")
            return
        order_shares(etf, shares)
        _context["position"] = etf
        _context["entry_price"] = price
        _context["high_price"] = price
        _context["entry_date"] = context.now.strftime("%Y-%m-%d")
        _context["shares"] = shares
        logger.info("买入：{} {}股@{}元".format(etf, shares, price))
    except Exception as e:
        logger.error("买入失败：{} - {}".format(etf, e))


def sell_etf(context, data, etf):
    """清仓ETF"""
    try:
        order_shares(etf, 0)
        logger.info("卖出：{}".format(etf))
        _context["position"] = None
        _context["entry_price"] = 0.0
        _context["high_price"] = 0.0
        _context["entry_date"] = None
        _context["shares"] = 0
    except Exception as e:
        logger.error("卖出失败：{} - {}".format(etf, e))


def on_order_filled(context, order):
    if _context["position"] == order["security"]:
        _context["shares"] = order["filled_shares"]


def on_strategy_end(context):
    logger.info("策略结束，最终净值：{:.4f}".format(_context["nav"]))


path: ptrade_smallcap_final.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
纯小市值策略 - PTrade 实盘版
策略: 每周一买市值最小的股票，持有5天，不追高
基于 SimTradeLab + BaoStock 混合回测验证 (2018-2026)
Update: 2026-04-10 17:30
"""

from ptrade import (
    get_price, get_instrument_info, get_trade_calendar,
    order_shares, order_target, get_prev_trade_date
)
from datetime import datetime, timedelta
import pandas as pd
import numpy as np
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("SmallCap")

# ============================================================
# 全局参数（可调整）
# ============================================================
MAX_MARKET_CAP = 60      # 亿元，市值上限
MAX_PRICE = 12           # 元，股价上限
HOLD_DAYS = 5            # 持有天数
STOP_LOSS_PCT = -0.08    # 止损线 -8%
TRADE_DAYS = [1, 15]     # 每月调仓日：1号和15号
MAX_POS_PCT = 0.20       # 单只仓位上限 20%
DRAWDOWN_STOP = -0.10    # 熔断线：净值从高点回撤 -10% 停止开仓

# ============================================================
# Context（全局状态，禁止用全局变量）
# ============================================================
_context = {
    "positions": {},      #持仓: {stock: {"shares":, "buy_price":, "hold_days":, "entry_date":}}
    "nav": 1.0,          #净值
    "nav_high": 1.0,     #净值高点
    "data_cache": {},    #数据缓存，避免重复API调用
    "last_rebalance": None,  #上次调仓日
}


def init(context):
    """初始化策略"""
    logger.info("纯小市值策略初始化 | MAX_CAP={}亿 | HOLD={}天 | STOP={}".format(
        MAX_MARKET_CAP, HOLD_DAYS, STOP_LOSS_PCT))
    # 设置运行时间：每天 09:25 定时执行
    run("09:25")


def before_trading_start(context, data):
    """盘前准备：缓存全局行情数据"""
    _context["data_cache"]["global"] = data


def scheduled(context, data):
    """定时任务：每天 09:25 检查是否调仓"""
    today = context.now.strftime("%Y-%m-%d")
    day = context.now.day
    weekday = context.now.weekday()  # 0=周一

    # 只有周一才操作，且日期在 TRADE_DAYS 内
    if weekday != 0:
        return
    if day not in TRADE_DAYS:
        return

    logger.info("调仓日：{}".format(today))
    rebalance(context, data)


def rebalance(context, data):
    """
    调仓主逻辑
    1. 选股：市值从小到大排序，取前N只
    2. 过滤：ST/停牌/涨跌停/股价>12元/MA60空头
    3. 分配仓位：等权，单只上限20%
    4. 卖出：不在候选名单的持仓
    5. 买入：候选名单中无持仓的标的
    """
    # ---- 风控：净值回撤熔断 ----
    if _context["nav"] < _context["nav_high"] * (1 + DRAWDOWN_STOP):
        logger.warning("净值回撤超过{}，停止开仓".format(DRAWDOWN_STOP))
        # 发出全部卖单
        for stock in list(_context["positions"].keys()):
            sell_stock(context, data, stock)
        return

    # ---- 步骤1：获取全市场股票列表 ----
    all_stocks = get_trade_calendar()
    # 过滤沪深A股（ST范围：上交所+深交所）
    all_stocks = [s for s in all_stocks if s.endswith(".SH") or s.endswith(".SZ")]

    # ---- 步骤2：批量获取数据 ----
    prev_date = get_prev_trade_date(context.now.strftime("%Y-%m-%d"))
    batch_data = get_price(
        securities=all_stocks,
        start_date=(datetime.now() - timedelta(days=90)).strftime("%Y-%m-%d"),
        end_date=prev_date,
        frequency="1d",
        fields=["close", "open", "high", "low", "volume", "amount"],
        adjust_type="post"
    )

    candidates = []
    for stock in all_stocks:
        try:
            df = batch_data[batch_data["security"] == stock].tail(60)  # 最近60天
            if len(df) < 60:
                continue

            close = df["close"].iloc[-1]  # 昨日收盘价
            open_price = df["open"].iloc[-1]  # 今日开盘价（用于过滤涨跌停）

            # ---- 过滤规则 ----
            # 1. 股价过滤
            if close > MAX_PRICE:
                continue

            # 2. 涨跌停过滤（ST/退市净化）
            if len(df) >= 2:
                prev_close = df["close"].iloc[-2]
                if prev_close > 0:
                    change_pct = (close - prev_close) / prev_close
                    if change_pct > 0.095 or change_pct < -0.095:  # 涨跌停
                        continue

            # 3. MA60 过滤（收盘价需在MA60上方）
            ma60 = df["close"].rolling(60).mean().iloc[-1]
            if close < ma60:
                continue

            # 4. 停牌过滤
            if is_suspended(stock, prev_date):
                continue

            # 5. 市值获取（Ptrade API）
            info = get_instrument_info(stock)
            market_cap = info.get("market_cap", 999)  # 亿元
            if market_cap > MAX_MARKET_CAP:
                continue

            # 候选股票评分（市值越小分越高）
            score = MAX_MARKET_CAP - market_cap
            candidates.append({
                "stock": stock,
                "market_cap": market_cap,
                "close": close,
                "score": score,
            })

        except Exception as e:
            continue

    # ---- 排序：按市值升序 ----
    candidates.sort(key=lambda x: x["market_cap"])
    candidates = candidates[:10]  # 最多10只

    if not candidates:
        logger.warning("无候选股票")
        return

    logger.info("候选股票：{} 只".format(len(candidates)))
    for c in candidates:
        logger.info("  {} 市值{}亿 价{}元".format(c["stock"], c["market_cap"], c["close"]))

    # ---- 步骤3：计算目标仓位 ----
    target_stocks = set([c["stock"] for c in candidates])
    current_stocks = set(_context["positions"].keys())

    # ---- 步骤4：卖出不在候选名单的持仓 ----
    to_sell = current_stocks - target_stocks
    for stock in to_sell:
        sell_stock(context, data, stock)

    # ---- 步骤5：买入候选名单中无持仓的标的 ----
    to_buy = target_stocks - current_stocks
    available_capital = context.portfolio.available_cash
    per_position = available_capital * MAX_POS_PCT  # 单只仓位上限

    for stock in to_buy:
        if available_capital < per_position * 0.5:
            break
        buy_stock(context, data, stock, per_position)

    _context["last_rebalance"] = context.now.strftime("%Y-%m-%d")


def on_bar(context, data):
    """每日收盘检查：止损 + 持有期到期卖出"""
    today = context.now.strftime("%Y-%m-%d")

    for stock in list(_context["positions"].keys()):
        pos = _context["positions"][stock]
        current_price = data[stock].close
        buy_price = pos["buy_price"]
        hold_days = pos["hold_days"]
        entry_date = pos["entry_date"]

        # ---- 止损检查 ----
        pnl_pct = (current_price - buy_price) / buy_price
        if pnl_pct <= STOP_LOSS_PCT:
            logger.info("止损出局：{} 亏损{:.1%}".format(stock, pnl_pct))
            sell_stock(context, data, stock)
            continue

        # ---- 持有期到期卖出（HOLD_DAYS天后） ----
        hold_days += 1
        pos["hold_days"] = hold_days
        if hold_days >= HOLD_DAYS:
            logger.info("持有期到，卖出：{} 盈利{:.1%}".format(stock, pnl_pct))
            sell_stock(context, data, stock)


def buy_stock(context, data, stock, target_value):
    """买入股票（整手处理）"""
    try:
        current_price = data[stock].close
        # 整手：100股的整数倍
        shares = int(target_value / current_price / 100) * 100
        if shares < 100:
            return

        order_shares(stock, shares)
        _context["positions"][stock] = {
            "shares": shares,
            "buy_price": current_price,
            "hold_days": 0,
            "entry_date": context.now.strftime("%Y-%m-%d"),
        }
        logger.info("买入：{} {}股@{}元".format(stock, shares, current_price))
    except Exception as e:
        logger.error("买入失败：{} - {}".format(stock, e))


def sell_stock(context, data, stock):
    """卖出股票（清仓）"""
    try:
        order_shares(stock, 0)  # PTrade 清仓写法
        if stock in _context["positions"]:
            del _context["positions"][stock]
        logger.info("卖出：{}".format(stock))
    except Exception as e:
        logger.error("卖出失败：{} - {}".format(stock, e))


def is_suspended(stock, date):
    """检查股票是否停牌"""
    try:
        df = get_price(
            securities=[stock],
            start_date=date,
            end_date=date,
            frequency="1d",
            fields=["volume"],
        )
        if len(df) == 0:
            return True
        return df["volume"].iloc[-1] == 0
    except:
        return True


def on_order_filled(context, order):
    """订单成交回调（处理部分成交）"""
    stock = order["security"]
    if stock in _context["positions"]:
        _context["positions"][stock]["shares"] = order["filled_shares"]


def on_strategy_end(context):
    """策略结束：更新净值记录"""
    logger.info("策略结束，最终净值：{:.4f}".format(_context["nav"]))


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