5. Uso de HistoryPanel para estudiar factores transversales de selección de acciones

Selección de acciones transversal: ponderación multifactorial -> comparar rentabilidades de carteras agrupadas y CAGR

Al realizar una selección transversal de acciones, a menudo nos encontramos con una situación que parece «muy real pero muy incómoda»:

  • Claramente tenemos un montón de acciones (docenas, cientos), y también sabemos que debemos usar múltiples métricas para filtrar (PE, PB, EBITDA, impulso, volatilidad…)

  • Pero una vez que escribe las condiciones en el código, es fácil encontrarse con «las formas no coinciden», «la transmisión de condiciones es incorrecta» o «las existencias analizadas son inestables».

  • Al final, se obtiene una única curva de cartera, pero no se puede explicar claramente: qué condiciones están impulsando realmente las diferencias de rentabilidad.

Este tutorial sigue un ritmo de “hacer que funcione primero, luego mejorarlo” para convertir este proceso en un flujo de trabajo de investigación reutilizable: multifactor (transversal) -> condiciones transversales -> where máscara -> portfolio + benchmark -> normalize/cum_return + CAGR -> plot/highlight explicación.

Nuevamente, para aclarar el posicionamiento: esto sigue siendo una agregación burda orientada a la investigación, no un motor de backtesting comercial.


5.1. 0. 开场:先跑通一个“横向筛选 -> 组合曲线对比”的最小版本

Por ahora no analizaremos muchos factores ni ajustaremos parámetros. Una prueba mínima ejecutable sólo necesita hacer dos cosas:

  1. Con varias acciones, puede realizar una evaluación transversal;

  2. Al final, podrás trazar una curva combinada y compararla con 000300.SH.

import qteasy as qt

benchmark = '000300.SH'
shares = [
    '000001.SZ', '600519.SH', '300750.SZ', '000333.SZ', '600036.SH',
    '601318.SH', '002415.SZ', '000858.SZ', '600276.SH', '000725.SZ',
    benchmark,
]

hp = qt.get_kline(
    shares=shares,
    start='20220101',
    end='20221231',
    freq='D',
    as_panel=True,
)

fig = hp.plot(interactive=True)
fig

Sin embargo, el simple hecho de “ser capaz de planearlo” está lejos de ser suficiente. Si realmente queremos utilizarlo como herramienta de investigación de selección de acciones en el día a día, nos encontraremos con al menos los siguientes problemas:

  1. Cómo alinear condiciones de múltiples factores: conceptos como PE/PB/momentum/volatilidad son fáciles de entender, pero una vez que escribes el código te toparás con obstáculos: algunos factores son (M,L), otros los escribiste como (M,L,1), y además de eso, HistoryPanel viene con su propia tercera dimensión para las columnas de campo, por lo que es fácil para la transmisión «coincidir, pero no en la forma en que lo haces». pensar”. La peor parte es que estos errores a menudo no generan errores; simplemente hacen que los resultados parezcan «apagados».

  2. La selección de acciones es una decisión transversal: la selección transversal no es «hacer un juicio sobre cada acción individualmente», sino «el mismo día, elegir un subconjunto de un grupo de acciones». Esto significa que su conjunto de tenencias puede cambiar todos los días: 10 acciones hoy, 3 mañana y posiblemente 0 al día siguiente. Si no solidifica explícitamente «la canasta seleccionada cada día» (por ejemplo, con una máscara), simplemente no podrá revisar «cuáles fueron realmente seleccionadas ese día».

  3. Sin una comparación de referencia, no hay conclusión: una curva combinada que se vea bien no significa que sea efectiva; puede que simplemente esté en la fase beta del mercado. Traiga 000300.SH como referencia; como mínimo, responde a la pregunta más crítica: ¿esta pantalla está creando alfa o simplemente sigue al mercado en general?

  4. Devoluciones sin explicación: el análisis transversal puede terminar fácilmente con “sólo queda una curva”. Pero la investigación real necesita una explicación: en los días con las mayores diferencias de rentabilidad, ¿se produjo una rotación de estilos? ¿Los criterios de selección nos arrinconaron con una alta volatilidad/alta caída de la inversión? ¿Podemos identificar rápidamente el “segmento de divergencia clave” y luego decidir qué factor fortalecer a continuación?

Afortunadamente, todas estas capacidades se pueden completar paso a paso. A continuación, comenzaremos con la «construcción de factores».


5.2. 0.5 Primero, muestra el resultado final (con qué terminaremos)

Después de seguir este artículo, obtendrá tres resultados muy «favorables para la investigación»:

  1. Un cuadro comparativo de curvas de cartera: LONG / SHORT / 000300.SH (o al menos LONG / 000300.SH) normalizado para comenzar desde 1.0, para que pueda saber de un vistazo si la regla de selección tiene poder discriminativo dentro del período de muestra.

  2. Una tabla de resumen CAGR: convierta los rendimientos acumulados en una métrica anualizada, lo que facilita la comparación entre diferentes períodos.

  3. Un único gráfico destacado de “día de divergencia clave/segmento de divergencia”: marque los puntos donde la cartera larga diverge más claramente del índice de referencia, lo que facilita su revisión y explicación.


5.3. 1. 目标(我们这篇文章要完成什么)

  • Obtenga el HistoryPanel para múltiples recursos compartidos + 000300.SH

  • Construir multifactores (dos rutas en paralelo: una versión de umbral fijo de factor proxy + una versión opcional de valoración verdadera)

  • Combine condiciones de múltiples factores en un bool (M,L) y use where() para generar la máscara de investigación

  • Utilice portfolio(mask=...) para obtener las curvas de cartera larga/corta y compararlas con el punto de referencia

  • Utilice normalize/cum_return para derivar la tabla CAGR

  • Utilice plot(highlight=...) para resaltar el “día de diferencia máxima/segmento de divergencia clave” y hacerlo explicable

  • Al final, proporcionamos un fragmento de código completo que «se ejecuta como una única función».


5.4. 2. 准备数据:多 shares + benchmark(控制数量,让图可读)

2.1 Qué pretende resolver esta sección

Una de las trampas más comunes en la investigación transversal es: demasiadas acciones y no se pueden digerir los gráficos de una sola vez; hay muy pocas acciones y no se puede reflejar el significado de “evaluación transversal”.

Por lo tanto, sugerimos mantener primero la muestra entre 30 y 80 acciones (en la práctica, puede reemplazarla con su propio conjunto de acciones). Esta sección solo hace una cosa: tener los datos a mano y confirmar que close existe.

2.2 Principios mínimos necesarios

De forma predeterminada, toda la agregación de cartera posterior se centra en close, por lo que siempre que close esté en hp.htypes, podemos ejecutar el ciclo cerrado completo de un extremo a otro. El hecho de que OHLC esté completo determina si podemos trazar velas japonesas y si podemos dar explicaciones al estilo de eventos; Este artículo se centra principalmente en la detección transversal y la OHLC puede ser opcional.

2.3 Código ejecutable + resultados esperados

import qteasy as qt

benchmark = '000300.SH'
shares = [
    # 这里用少量示意;正式可替换成你自己的一篮子股票池
    '000001.SZ', '600519.SH', '300750.SZ', '000333.SZ', '600036.SH',
    '601318.SH', '002415.SZ', '000858.SZ', '600276.SH', '000725.SZ',
    benchmark,
]

hp = qt.get_kline(
    shares=shares,
    start='20220101',
    end='20221231',
    freq='D',
    as_panel=True,
)

print('hp.shape:', hp.shape)
print('hp.shares count:', len(hp.shares))
print('hp.htypes:', hp.htypes)
if 'close' not in hp.htypes:
    raise ValueError(f'Missing close column, htypes: {hp.htypes}')

# 避免“其实没数据还继续往下跑”
if hp.shape[1] < 50:
    raise ValueError(
        'Not enough data points loaded (too few hdates). '
        'Please check your local datasource and date range.'
    )

5.5. 3. 多因子构造(两条路线并列):固定阈值代理版 + 可选真实估值版

3.1 Qué pretende resolver esta sección

En este paso, lo que queremos es: una matriz de factores que pueda combinarse directamente en condiciones de detección. En una «sección transversal», queremos que cada factor finalmente tenga la misma forma: (M, L).

Para simplificar, aquí nos dividimos en dos pistas:

  • Ruta A (recomendada para principiantes): derivar “factores proxy” enteramente a partir de datos de mercado/indicadores técnicos, junto con umbrales fijos, para garantizar que todos puedan ejecutarlo

  • Pista B (mejora opcional): si su fuente de datos local ya tiene campos de valoración como PE/PB/EBITDA, reemplácelos aquí

En última instancia, ambas rutas generan el mismo cond_long/cond_short y el flujo de trabajo posterior es exactamente el mismo.

3.2 Principios mínimos necesarios

HistoryPanel.kline.* devolverá un nuevo panel con columnas adicionales, por ejemplo:

  • sma_20

  • macd_hist_12_26_9

  • ⟦CÓDIGO0⟧ etc.

Todas estas columnas se pueden ubicar a través de hp.htypes.index(name) en una matriz 2D de (M, L). Luego podemos escribir condiciones multifactor con umbrales fijos y asegurarnos de que la condición final sea un (M, L) bool.

3.3 Vía A: Factores proxy (umbrales fijos; se recomienda que esto funcione primero)

3.3.1 Qué resolver

Utilizamos tres factores proxy «muy intuitivos» para ayudar a los lectores a entrar en el flujo rápidamente:

  • Valor proxy: close / sma20 (cuanto menor es la desviación de la media móvil, más “barato” es)

  • Proxy de impulso: macd_hist (fuerza/debilidad)

  • Proxy de riesgo: ancho de la banda de Bollinger (magnitud de la volatilidad)

Vale la pena enfatizar aquí dos puntos de “honestidad en las convenciones de investigación”:

  • Todos estos son valores aproximados, no “valoración real” en el sentido financiero. Su valor radica en que todos pueden derivarlos de datos de mercado y, bajo ciertos estilos, pueden producir un efecto de estratificación.

  • También tienen limitaciones claras: close/sma20 puede confundir una tendencia con barata/cara; El MACD puede seguir cayendo en un rango entrecortado; un filtro de ancho de banda también filtrará las «oportunidades creadas por la volatilidad». El objetivo de este artículo es conseguir que el proceso funcione de un extremo a otro; los umbrales en sí son sólo un punto de partida reproducible.

3.3.2 Código ejecutable + resultados esperados

import numpy as np

hp2 = hp.kline.sma(window=20, price_htype='close')       # sma_20
hp2 = hp2.kline.macd(price_htype='close')                # macd_hist_12_26_9
hp2 = hp2.kline.bbands(window=20, price_htype='close')   # bbands_*_20_2_2

vals = hp2.values.astype(float)

close = vals[:, :, hp2.htypes.index('close')]
sma20 = vals[:, :, hp2.htypes.index('sma_20')]
macd_hist = vals[:, :, hp2.htypes.index('macd_hist_12_26_9')]

upper = vals[:, :, hp2.htypes.index('bbands_upper_20_2_2')]
mid   = vals[:, :, hp2.htypes.index('bbands_middle_20_2_2')]
lower = vals[:, :, hp2.htypes.index('bbands_lower_20_2_2')]

value_proxy = close / sma20
momentum_proxy = macd_hist
risk_proxy = (upper - lower) / mid

# 固定阈值(入门优先:简单、可跑、可理解)
A_VALUE = 1.02     # 强一点:价格明显强于均线才算“强势”
C_MOM = 0.0        # MACD 柱 > 0 视为偏多
B_RISK = 0.18      # 带宽太大视为波动过强,先过滤掉

cond_long = (value_proxy > A_VALUE) & (momentum_proxy > C_MOM) & (risk_proxy < B_RISK)
cond_short = (value_proxy < 1.0 / A_VALUE) & (momentum_proxy < -C_MOM) & (risk_proxy < B_RISK)

print('cond_long shape:', cond_long.shape)   # 期望 (M, L)
print('cond_short shape:', cond_short.shape)
print('selected_count_last_day(long):', int(cond_long[:, -1].sum()))

En este punto hemos obtenido los «ingredientes principales» para el filtrado transversal: cond_long/cond_short (M,L bool).

A continuación, todavía tenemos que hacer una comprobación de cordura muy práctica: cuántas acciones se seleccionan cada día. Si este número suele ser 0 (una cesta vacía), la curva de su cartera será intermitente y sus conclusiones serán inestables; Si este número es casi siempre de todas las acciones, entonces la selección no tiene sentido.

Puede agregar esta verificación (no cambia la lógica; solo nos ayuda a juzgar si los umbrales son «demasiado estrictos/demasiado flexibles»):

selected_count_by_day = cond_long.sum(axis=0)  # (L,)
print('selected_count stats (long):')
print('  min/max:', int(selected_count_by_day.min()), int(selected_count_by_day.max()))
print('  mean:', float(selected_count_by_day.mean()))
print('  p10/p50/p90:', np.quantile(selected_count_by_day.astype(float), [0.1, 0.5, 0.9]))

3.4 Ruta B: factores de valoración verdaderos (mejora opcional; los nombres de los campos están sujetos a hp.htypes)

3.4.1 Qué hay que resolver

Si su fuente de datos local ya ha descargado campos de valoración (por ejemplo, PE/PB/EBITDA), entonces podemos intercambiarlos. En esta sección no codificaremos los nombres de los campos, porque la denominación htype puede diferir entre las fuentes de datos/tipos de datos. El enfoque más sólido es:

  1. Primera impresión hp.htypes

  2. Encuentre los nombres de las columnas reales en su configuración local

  3. Luego escriba las condiciones usando umbrales fijos.

3.4.2 Código ejecutable (ilustrativo)

print('available htypes:', hp.htypes)

# 假设你在 htypes 里找到了这三个字段(名称以你的本地为准)
# pe_name = 'pe' or 'pe_ttm' ...
# pb_name = 'pb' ...
# ebitda_name = 'ebitda' ...

# pe = hp.values[:, :, hp.htypes.index(pe_name)]
# pb = hp.values[:, :, hp.htypes.index(pb_name)]
# ebitda = hp.values[:, :, hp.htypes.index(ebitda_name)]

# 固定阈值示例(仅示意,阈值需要你按资产池与口径调整)
# cond_long = (pe < 15.0) & (pb < 2.0) & (ebitda > 1e9)

Mientras termines con (M, L) cond_long/cond_short, el resto del flujo de trabajo es exactamente el mismo que la Ruta A.

Si descubre que sus datos locales simplemente no tienen estos campos, está bien: es exactamente por eso que colocamos la «versión de factor proxy» en la ruta narrativa principal. La ruta B existe como una rama mejorada, pero aún puedes ejecutar el ciclo de investigación completo de un extremo a otro sin depender de él.


5.6. 4. 横向筛选:条件组合 -> where() 研究 mask

4.1 Qué pretende resolver esta sección

Convertimos cond_long/cond_short en una máscara de investigación que se puede introducir directamente en portfolio(mask=...).

4.2 Principios mínimos necesarios

hp.where() admite remodelar las condiciones booleanas (M, L) en una máscara (M, L, N). Este paso solidifica las “reglas de selección transversal” en una “definición de investigación”, y todos los cálculos de agregación y retorno posteriores siguen esto como estándar.

Este es también uno de los hábitos más valiosos que se pueden desarrollar en la investigación transversal: nunca mantengas una única “curva de cartera”; mantén también explícitamente la regla de “qué nombres entran en la canasta cada día”. La máscara es esa regla. Le permite responder preguntas de revisión como «¿a quién elegimos exactamente ese día?»

4.3 Código ejecutable + resultado esperado

mask_long = hp2.where(cond_long)
mask_short = hp2.where(cond_short)

print('mask_long shape:', mask_long.shape)   # 期望 (M,L,N)
print('mask_long dtype:', mask_long.dtype)

5.7. 5. 两组组合曲线 + benchmark:portfolio(mask=...)

5.1 Qué pretende resolver esta sección

Para cada día de negociación, agregue el conjunto de acciones que cumplen las condiciones en una curva de cartera (larga/corta) y compárela con 000300.SH.

5.2 Los principios mínimos necesarios

  • portfolio es para agregación de investigación general, no para ejecución comercial

  • benchmark_output='tag_along' agregará la fila de referencia a la salida, lo que facilitará la comparación en el mismo gráfico

Puedes pensar en ello como: para cada día, toma un promedio de igual peso del “conjunto de acciones seleccionadas ese día” para obtener una curva. Debido a que el conjunto cambia todos los días, esta curva esencialmente responde a una pregunta:

Si cada día solo tengo “el lote que cumple con los criterios”, ¿cómo se comportará esta cesta dinámica a largo plazo?

5.3 Código ejecutable + resultados esperados

benchmark = '000300.SH'

pf_long = hp2.portfolio(
    htypes='close',
    mode='equal',
    mask=mask_long,
    benchmark=benchmark,
    benchmark_output='tag_along',
    new_share_name='LONG',
)

pf_short = hp2.portfolio(
    htypes='close',
    mode='equal',
    mask=mask_short,
    benchmark=benchmark,
    benchmark_output='tag_along',
    new_share_name='SHORT',
)

print('pf_long.shares:', pf_long.shares)
print('pf_long.shape:', pf_long.shape)

5.8. 6. normalize / cum_return + CAGR:给出可比较的年化摘要表

6.1 Qué pretende resolver esta sección

Queremos «mirar la curva» y «tener un resumen numérico reutilizable». Entonces hacemos dos cosas:

  • normalize: alinear el punto inicial (para una comparación visual más sencilla)

  • cum_return + CAGR: generar una tabla resumen

6.2 El principio de necesidad mínima

cum_return genera el retorno acumulado cumret_*; el uso del valor final junto con el número de años le permite derivar la CAGR. (Para obtener la definición exacta de CAGR, consulte la discusión anterior; no ampliaremos la derivación aquí).

6.3 Código ejecutable (ilustrativo)

import pandas as pd

def _years_between(hdates) -> float:
    idx = pd.DatetimeIndex(hdates)
    days = (idx[-1] - idx[0]).days
    return max(1e-9, days / 365.25)

def _cagr_from_cumret(cumret_end: float, years: float) -> float:
    return (1.0 + cumret_end) ** (1.0 / years) - 1.0

years = _years_between(pf_long.hdates)
cr_long = pf_long.cum_return(htypes='close', method='simple')

cumret_long_end = float(cr_long.values[cr_long.shares.index('LONG'), -1, 0])
cumret_bm_end = float(cr_long.values[cr_long.shares.index('000300.SH'), -1, 0])

print('CAGR(long):', _cagr_from_cumret(cumret_long_end, years))
print('CAGR(bm):', _cagr_from_cumret(cumret_bm_end, years))

Le sugiero que lo organice en una tabla pequeña (que incluya al menos tres filas: LARGA/CORTA/punto de referencia), para que los lectores puedan comparar de un vistazo:

cr_short = pf_short.cum_return(htypes='close', method='simple')
cumret_short_end = float(cr_short.values[cr_short.shares.index('SHORT'), -1, 0])

summary = pd.DataFrame(
    {
        'cum_return_end': [cumret_long_end, cumret_short_end, cumret_bm_end],
        'CAGR': [
            _cagr_from_cumret(cumret_long_end, years),
            _cagr_from_cumret(cumret_short_end, years),
            _cagr_from_cumret(cumret_bm_end, years),
        ],
    },
    index=['LONG', 'SHORT', '000300.SH'],
)
print('\\n[CAGR summary]')
print(summary)

5.9. 7. 可视化与解释:用 plot(highlight=...) 高亮“关键差异日”

7.1 Qué pretende resolver esta sección

Cuando se lleva la investigación transversal hasta el final, el tipo de pregunta que más necesita explicación es: en el tramo donde el largo y el punto de referencia divergen más, ¿qué pasó exactamente?

Por eso usamos highlight para resaltar un segmento del “día de divergencia clave” (por ejemplo, el punto de rendimiento acumulado máximo/mínimo, o un intervalo que usted mismo elija), devolviendo la atención del lector al gráfico.

7.2 El principio de necesidad mínima

resaltado admite la abreviatura 'max'/'min' y también admite una condición bool 1D. Para mantener estable el tutorial, primero demostraremos la versión abreviada (la que es menos probable que te haga tropezar) y luego proporcionaremos el formato bool 1D.

7.3 Código ejecutable + resultado esperado

fig = pf_long.plot(interactive=True, highlight='max')
fig

Resultado esperado: el gráfico marcará el punto máximo de la curva de cartera LARGA (o el punto máximo definido en un gráfico determinado). En la investigación transversal, normalmente tratamos esto como un “recordatorio”: alrededor del punto máximo suele ser el período en el que durante mucho tiempo hubo el viento de cola más fuerte en relación con el mercado, y vale la pena mirar hacia atrás para ver si los criterios de selección nos llevaron a un estilo particular (por ejemplo, tendencia fuerte, baja volatilidad, etc.).

Si desea alinearse más estrechamente con «la divergencia entre largo y punto de referencia», puede utilizar un enfoque más práctico: primero calcule la curva de exceso de rendimiento (la diferencia entre largo y punto de referencia), luego extraiga la posición del punto máximo en esa curva de exceso y conviértala en una condición de resaltado bool 1D. (Esta es una mejora opcional; no es necesario ampliarla en la narrativa principal).


5.10. 8. 完整代码(单函数可跑版本)

A continuación se muestra una versión completa “ejecutable con una sola función” que puede copiar en una computadora portátil y ejecutar con un solo clic. Cubre:

  • Pista A (factor proxy con un umbral fijo) como línea principal;

  • Un control de cordura sobre el número seleccionado cada día;

  • Un circuito cerrado completo de where -> portfolio -> cum_return -> CAGR;

  • Una demostración estable de plot(highlight=...).

import numpy as np
import pandas as pd
import qteasy as qt


def demo_horizontal_multifactor(
        shares: list,
        benchmark: str = '000300.SH',
        start: str = '20220101',
        end: str = '20221231',
):
    \"\"\"演示横向多因子截面筛选:代理因子 -> 每日篮子 -> portfolio -> CAGR -> 高亮解释。

    Parameters
    ----------
    shares : list
        股票池(必须包含 benchmark;建议 30~80 只更像“横向筛选”)。
    benchmark : str, default '000300.SH'
        基准指数代码。
    start : str, default '20220101'
        起始日期(YYYYMMDD)。
    end : str, default '20221231'
        结束日期(YYYYMMDD)。

    Returns
    -------
    dict
        包含 hp/hp2/pf_long/pf_short/summary/fig 等结果对象。
    \"\"\"
    if benchmark not in shares:
        raise ValueError('benchmark must be included in shares')

    hp = qt.get_kline(
        shares=shares,
        start=start,
        end=end,
        freq='D',
        as_panel=True,
    )
    if 'close' not in hp.htypes:
        raise ValueError('Missing close column in htypes')
    if hp.shape[1] < 50:
        raise ValueError(
            'Not enough data points loaded (too few hdates). '
            'Please check your local datasource and date range.'
        )

    # 1) 路线 A:代理因子(固定阈值)
    hp2 = hp.kline.sma(window=20, price_htype='close')
    hp2 = hp2.kline.macd(price_htype='close')
    hp2 = hp2.kline.bbands(window=20, price_htype='close')

    vals = hp2.values.astype(float)
    close = vals[:, :, hp2.htypes.index('close')]
    sma20 = vals[:, :, hp2.htypes.index('sma_20')]
    macd_hist = vals[:, :, hp2.htypes.index('macd_hist_12_26_9')]
    upper = vals[:, :, hp2.htypes.index('bbands_upper_20_2_2')]
    mid = vals[:, :, hp2.htypes.index('bbands_middle_20_2_2')]
    lower = vals[:, :, hp2.htypes.index('bbands_lower_20_2_2')]

    value_proxy = close / sma20
    momentum_proxy = macd_hist
    risk_proxy = (upper - lower) / mid

    A_VALUE = 1.02
    C_MOM = 0.0
    B_RISK = 0.18

    cond_long = (value_proxy > A_VALUE) & (momentum_proxy > C_MOM) & (risk_proxy < B_RISK)
    cond_short = (value_proxy < 1.0 / A_VALUE) & (momentum_proxy < -C_MOM) & (risk_proxy < B_RISK)

    # 2) sanity check:每天入选数量
    selected_count_by_day = cond_long.sum(axis=0)
    print('\\n[Selection count stats]')
    print('  min/max:', int(selected_count_by_day.min()), int(selected_count_by_day.max()))
    print('  mean:', float(selected_count_by_day.mean()))
    print('  p10/p50/p90:', np.quantile(selected_count_by_day.astype(float), [0.1, 0.5, 0.9]))

    # 3) 条件 -> mask(研究口径)
    mask_long = hp2.where(cond_long)
    mask_short = hp2.where(cond_short)

    # 4) 组合聚合 + benchmark
    pf_long = hp2.portfolio(
        htypes='close',
        mode='equal',
        mask=mask_long,
        benchmark=benchmark,
        benchmark_output='tag_along',
        new_share_name='LONG',
    )
    pf_short = hp2.portfolio(
        htypes='close',
        mode='equal',
        mask=mask_short,
        benchmark=benchmark,
        benchmark_output='tag_along',
        new_share_name='SHORT',
    )

    # 5) cum_return -> CAGR
    def _years_between(hdates) -> float:
        idx = pd.DatetimeIndex(hdates)
        days = (idx[-1] - idx[0]).days
        return max(1e-9, days / 365.25)

    def _cagr_from_cumret(cumret_end: float, years: float) -> float:
        return (1.0 + cumret_end) ** (1.0 / years) - 1.0

    years = _years_between(pf_long.hdates)
    cr_long = pf_long.cum_return(htypes='close', method='simple')
    cr_short = pf_short.cum_return(htypes='close', method='simple')

    cumret_long_end = float(cr_long.values[cr_long.shares.index('LONG'), -1, 0])
    cumret_short_end = float(cr_short.values[cr_short.shares.index('SHORT'), -1, 0])
    cumret_bm_end = float(cr_long.values[cr_long.shares.index(benchmark), -1, 0])

    summary = pd.DataFrame(
        {
            'cum_return_end': [cumret_long_end, cumret_short_end, cumret_bm_end],
            'CAGR': [
                _cagr_from_cumret(cumret_long_end, years),
                _cagr_from_cumret(cumret_short_end, years),
                _cagr_from_cumret(cumret_bm_end, years),
            ],
        },
        index=['LONG', 'SHORT', benchmark],
    )
    print('\\n[CAGR summary]')
    print(summary)

    # 6) 图:组合对比(归一化更直观)
    fig = pf_long.normalize(htypes='close', base_index=0).plot(interactive=True, highlight='max')
    return {
        'hp': hp,
        'hp2': hp2,
        'pf_long': pf_long,
        'pf_short': pf_short,
        'summary': summary,
        'fig': fig,
    }


res = demo_horizontal_multifactor(
    shares=[
        '000001.SZ', '600519.SH', '300750.SZ', '000333.SZ', '600036.SH',
        '601318.SH', '002415.SZ', '000858.SZ', '600276.SH', '000725.SZ',
        '000300.SH',
    ],
    benchmark='000300.SH',
    start='20220101',
    end='20221231',
)
res['fig']

5.11. 9. 小结与边界

En este punto, hemos completado el ciclo de investigación para la «selección de acciones transversal y multifactorial». Es necesario enfatizar nuevamente: portfolio/cum_return es una agregación burda orientada a la investigación y no incluye una semántica real de ejecución comercial. Si desea migrar esta lógica de detección a una prueba retrospectiva de estrategia, se recomienda generar las condiciones/factores como señales de estrategia y entregárselas al Operator/Backtester para manejar los detalles de la capa comercial.


5.12. Apéndice: índice de figuras (recomendado generar/captura de pantalla en el Notebook)

Figura

Ubicación sugerida

lo que veras

img/3.2_minimal_run.png

§0

El gráfico mínimo ejecutable con más acciones

img/3.2_pf_compare.png

§0.5 o §6

Comparación de curvas normalizadas de LONG/SHORT/000300.SH

img/3.2_highlight_key_day.png

§7

Ejemplo de cómo resaltar un «punto clave» (por ejemplo, el punto máximo)