5. HistoryPanelを使用した横断的な銘柄選択要因の研究

横断的な銘柄選択: 多要素の重み付け -> グループ化されたポートフォリオのリターンと CAGR を比較

銘柄の横断的な選択を行うと、「非常に現実的だが、非常に気まずい」と感じる状況に遭遇することがよくあります。

  • 私たちは明らかに多数の銘柄 (数十、数百) を保有しており、スクリーニングには複数の指標 (PE、PB、EBITDA、モメンタム、ボラティリティなど) を使用する必要があることもわかっています。

  • しかし、条件をコードに記述すると、「形状が一致しない」「条件ブロードキャストが間違っている」「スクリーニングされた銘柄が不安定」という問題が発生しやすくなります。

  • 最終的には 1 つのポートフォリオ曲線が得られますが、どの条件が実際にリターンの差を引き起こしているのかを明確に説明することはできません。

このチュートリアルは、「最初に機能させてから強化する」というリズムに従って、このパイプラインを再利用可能な研究ワークフローに構築します: 多要素 (断面) -> 断面条件 -> where マスク -> portfolio + benchmark -> normalize/cum_return + CAGR -> plot/highlight の説明。

もう一度、位置づけを明確にします。これは依然としてリサーチ指向の粗い集計であり、取引バックテスト エンジンではありません。


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

現時点では、多くの要素を追求したり、パラメータを微調整したりするつもりはありません。 最小限の実行可能な証明では、次の 2 つのことだけを実行する必要があります。

  1. 複数の株式を使用すると、横断的なスクリーニングを実行できます。

  2. 最終的には、組み合わせた曲線をプロットして、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

しかし、単に「計画を立てることができる」だけでは十分ではありません。 これを日々の銘柄選択のリサーチツールとして本当に使用したい場合は、少なくとも次の問題に遭遇することになります。

  1. 複数の要素の条件を調整する方法: PE/PB/モメンタム/ボラティリティなどの概念は理解するのが簡単ですが、コードを作成すると落とし穴に遭遇します。いくつかの要素は(M,L)であり、いくつかは(M,L,1)として記述され、さらにHistoryPanelにはフィールド列に独自の3次元が付属しているため、ブロードキャストでは「一致するが、一致しない」ことが簡単に発生します。あなたの考え方。」最悪の点は、こうした間違いによってエラーがスローされないことが多いということです。結果が「間違っている」ように見えるだけです。

  2. 銘柄選択は横断的な決定です: 横断的な選択とは、「各銘柄を個別に判断する」のではなく、「同じ日に多数の銘柄からサブセットを選択する」ことです。これは、保有株数が毎日変化する可能性があることを意味します。今日は 10 株、明日は 3 株、翌日には 0 株になる可能性があります。 「毎日選択したバスケット」を明示的に(マスクなどで)固定しないと、「その日に実際にどれが選択されたか」を確認することができません。

  3. ベンチマークの比較がなければ、結論は出ません。組み合わせた曲線が良好に見えても、それが効果的であるとは限りません。市場のベータ版に乗っているだけかもしれません。 000300.SH を参照として取り入れます。少なくとも、この画面はアルファを生み出しているのか、それとも広範な市場に従っているだけなのかという、最も重要な質問の答えになります。

  4. 説明なしの返品: 断面スクリーニングでは、「カーブが 1 つだけ残っている」状態になることがよくあります。しかし、実際の研究には説明が必要です。リターンの差が最も大きかった日に、たまたまスタイルのローテーションが発生したのでしょうか?審査基準により、ボラティリティやドローダウンが大きくなるという窮地に追い込まれたのでしょうか? 「重要な分岐セグメント」を素早く特定し、次にどの要素を強化するかを決定できるでしょうか?

幸いなことに、これらの機能はすべて段階的に満たすことができます。次に「因子構築」から始めます。


5.2. 0.5 まず、最終結果 (最終的に何が得られるか) を表示します。

この記事に従うと、非常に「研究に適した」 3 つの出力が得られます。

  1. ポートフォリオ曲線比較表: LONG / SHORT / 000300.SH (または少なくとも LONG / 000300.SH) は 1.0 から始まるように正規化されているため、サンプル期間内でスクリーニング ルールに識別力があるかどうかが一目でわかります。

  2. CAGR 概要表: 累積収益を年換算の指標に変換し、異なる期間での比較を容易にします。

  3. 単一の「主要な乖離日/乖離セグメント」ハイライト チャート: ロング ポートフォリオがベンチマークから最も明確に乖離するポイントをマークし、レビューと説明を容易にします。


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

  • 複数の共有の HistoryPanel + 000300.SH を取得します

  • マルチファクターの構築 (並行する 2 つのルート: プロキシファクターの固定しきい値バージョン + オプションの真の評価バージョン)

  • 多要素条件を (M,L) bool に結合し、where() を使用してリサーチマスクを生成します

  • portfolio(mask=...) を使用してポートフォリオのロング/ショート曲線を取得し、ベンチマークと比較します

  • normalize/cum_return を使用して CAGR テーブルを導出する

  • plot(highlight=...) を使用して「最大差分日/キー分岐セグメント」を強調表示し、説明可能にします。

  • 最後に、「単一関数として実行される」完全なコードを提供します。


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

2.1 このセクションの解決の目的

横断的調査で最もよくある罠の 1 つは次のとおりです。ストックが多すぎるため、一度にプロットを消化できない。銘柄が少なすぎると「横断的審査」の意味が反映できません

したがって、最初はサンプルを 30 ~ 80 株以内に保つことをお勧めします (実際には、独自の株プールに置き換えることができます)。 このセクションで行うことは 1 つだけです。データを取得し、close が存在することを確認することです。

2.2 最低限必要な原則

後続のすべてのポートフォリオ集計はデフォルトで close を中心とするため、closehp.htypes にある限り、完全な閉ループをエンドツーエンドで実行できます。 OHLC が完了しているかどうかによって、ローソク足をプロットできるかどうか、およびイベント形式の説明ができるかどうかが決まります。この記事は主に断面スクリーニングに焦点を当てており、OHLC はオプションです。

2.3 実行可能なコード + 期待される結果

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 このセクションの解決の目的

このステップで必要なのは、スクリーニング条件に直接組み合わせることができる因子行列です。 「断面図」では、各要素が最終的に同じ形状 ((M, L)) に到達するようにします。

簡単にするために、ここでは 2 つのトラックに分割します。

  • ルート A (初心者に推奨): 市場データ/テクニカル指標から完全に「代用係数」を導き出し、固定しきい値と組み合わせて、誰もが実行できるようにします。

  • トラック B (オプションの拡張): ローカル データ ソースに PE/PB/EBITDA などの評価フィールドが既にある場合は、ここでそれらを置き換えます

どちらのルートも最終的には同じ cond_long/cond_short を出力し、その後のワークフローはまったく同じです。

3.2 最低限必要な原則

HistoryPanel.kline.* は、追加の列を含む新しいパネルを返します。例:

  • sma_20

  • macd_hist_12_26_9

  • bbands_upper_20_2_2 など

これらの列はすべて、hp.htypes.index(name) を介して (M, L) の 2D マトリックスに配置できます。 次に、固定しきい値を使用して多要素条件を記述し、最終条件が (M, L) bool であることを確認します。

3.3 トラック A: 代理係数 (固定しきい値。最初にこれを機能させることをお勧めします)

3.3.1 解決すべきこと

読者がすぐにフローに参加できるように、次の 3 つの「非常に直感的な」代理要素を使用します。

  • 値プロキシ: close / sma20 (移動平均からの偏差が低いほど、より「安い」)

  • 勢いプロキシ: macd_hist (強さ/弱さ)

  • リスクプロキシ: ボリンジャーバンド幅 (ボラティリティの大きさ)

ここで「研究慣例における誠実さ」の 2 つの点を強調する価値があります。

  • これらはすべて代理であり、財務的な意味での「真の評価」ではありません。その価値は次のとおりです。誰もが市場データからそれらを導き出すことができ、特定のスタイルの下では実際に層化効果を生み出すことができます。

  • また、これらには明確な制限もあります。close/sma20 はトレンドを安い/高いと誤解する可能性があります。 MACD は途切れ途切れのレンジで値下がりし続ける可能性があります。帯域幅フィルターは「ボラティリティによって生み出される機会」も除外します。この記事の焦点は、パイプラインをエンドツーエンドで実行することです。しきい値自体は、再現可能な開始点にすぎません。

3.3.2 実行可能なコード + 期待される結果

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()))

この時点で、断面フィルタリングの「コア要素」: cond_long/cond_short (M,L bool) を取得しました。

次に、非常に実用的な健全性チェックを行う必要があります。毎日いくつの銘柄が選択されるかです。 この数値が 0 (空のバスケット) であることが多い場合、ポートフォリオの曲線は断続的となり、結論は不安定になります。この数がほぼ常にすべての銘柄である場合、スクリーニングは意味がありません。

このチェックを追加できます (ロジックは変更されません。しきい値が「厳しすぎる/緩すぎる」かどうかを判断するのに役立つだけです)。

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 ルート B: 真の評価係数 (オプションの拡張機能、フィールド名は hp.htypes の影響を受ける)

3.4.1 解決すべきこと

ローカル データ ソースに既に評価フィールド (PE/PB/EBITDA など) がダウンロードされている場合は、それらを入れ替えることができます。htype の名前はデータ ソース/データ型によって異なる可能性があるため、このセクションではフィールド名をハードコーディングしません。最も堅牢なアプローチは次のとおりです。

  1. 最初の印刷物 hp.htypes

  2. ローカル設定で実際の列名を見つけます。

  3. 次に、固定しきい値を使用して条件を書き込みます

3.4.2 実行可能なコード (例)

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)

最終的に (M, L) cond_long/cond_short になる限り、残りのワークフローはルート A とまったく同じです。

ローカル データにこれらのフィールドが単に含まれていないことがわかったとしても、それは問題ありません。まさにそれが、メインのナラティブ パスに「プロキシ要素バージョン」を配置する理由です。ルート B は拡張ブランチとして存在しますが、ルート B に依存せずに完全なリサーチ ループをエンドツーエンドで実行できます。


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

4.1 このセクションの解決の目的

cond_long/cond_short を、portfolio(mask=...) に直接入力できるリサーチマスクに変換します。

4.2 最低限必要な原則

hp.where() は、(M, L) ブール条件の (M, L, N) マスクへの再構成をサポートしています。 このステップにより、「横断的スクリーニングルール」が「研究定義」として固定化され、その後のすべての集計と収益計算はこれに準拠します。

これは、横断的調査で構築すべき最も価値のある習慣の 1 つでもあります。決して 1 つの「ポートフォリオ曲線」だけを保持しないでください。「どの名前が毎日バスケットに入るのか」に関するルールも明示的に保持してください。マスクがそのルールです。 「その日は誰を正確に選んだのですか?」などのレビューの質問に答えることができます。

4.3 実行可能なコード + 期待される結果

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 このセクションの解決の目的

取引日ごとに、条件を満たす銘柄のセットをポートフォリオ カーブ (ロング/ショート) に集計し、000300.SH と比較します。

5.2 最低限必要な原則

  • portfolio は取引執行ではなく、大まかな調査集計用です

  • benchmark_output='tag_along' は出力にベンチマーク行を追加し、同じチャート上で簡単に比較できるようにします。

これは次のように考えることができます。毎日、「その日に選択された銘柄セット」の均等加重平均をとり、曲線を取得します。 セットは毎日変化するため、この曲線は基本的に次の質問に答えます。

毎日「基準を満たすバッチ」だけを保持している場合、この動的なバスケットは長期的にはどのように機能するでしょうか?

5.3 実行可能なコード + 期待される結果

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 このセクションの解決の目的

私たちは、「曲線を確認する」ことと、「再利用可能な数値の概要を取得する」ことの両方を望んでいます。 そこで、次の 2 つのことを行います。

  • normalize: 開始点を揃えます (視覚的に比較しやすくするため)

  • cum_return + CAGR: 集計表を出力します

6.2 必要最小限の原則

cum_return は累積リターン cumret_* を出力します。終了値と年数を併用すると、CAGR を導き出すことができます。 (CAGR の正確な定義については、前の説明を参照してください。ここでは導出を拡張しません。)

6.3 実行可能なコード (例)

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))

読者が一目で比較できるように、小さな表 (少なくとも LONG/SHORT/ベンチマークの 3 行を含む) に整理することをお勧めします。

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 このセクションの解決の目的

横断的な調査を最後まで行うと、最も説明が必要な質問は次のとおりです。ロングとベンチマークが最も乖離しているストレッチで、正確に何が起こったのか?

そこで、highlight を使用して「重要な分岐日」セグメント (累積リターンの最大/最小ポイント、または自分で選択した間隔など) を強調表示し、読者の注意をチャートに引き戻します。

7.2 必要最小限の原則

ハイライトは短縮表現 'max'/'min' をサポートし、1D ブール条件もサポートします。 チュートリアルの安定性を保つために、最初に短縮版 (つまずく可能性が最も低い) を示し、その後 1D bool 形式を提供します。

7.3 実行可能なコード + 期待される出力

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

期待される結果: チャートは、LONG ポートフォリオ曲線の最大点 (または、特定のチャートで定義された最大点) をマークします。 クロスセクション調査では通常、これを「リマインダー」として扱います。最大点付近は、長い間市場に対して最も強い追い風が吹いていた時期であることが多く、スクリーニング基準が特定のスタイル(強いトレンド、低いボラティリティなど)に導いたかどうかを振り返る価値があります。

「ロングとベンチマークの乖離」をより厳密に一致させたい場合は、より実用的なアプローチを使用できます。まず超過収益曲線 (ロングとベンチマークの差) を計算し、次にその超過曲線上の最大点の位置を抽出して、1D ブール ハイライト条件に変換します。 (これはオプションの拡張機能であり、メインの物語で拡張する必要はありません。)


5.10. 8.完全なコード (単一関数実行可能バージョン)

以下は、ノートブックにコピーしてワンクリックで実行できる完全な「単一関数実行可能」バージョンです。内容は次のとおりです。

  • トラック A (固定しきい値を持つ代理係数) をメインラインとして使用します。

  • 毎日選択された番号の健全性チェック。

  • where -> portfolio -> cum_return -> CAGR の完全な閉ループ。

  • 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. 小结与边界

この時点で、「横断的な多要素銘柄選択」のリサーチループが完了しました。 もう一度強調する必要があります: portfolio/cum_return は研究指向の粗い集計であり、実際の取引実行セマンティクスは含まれていません。 このスクリーニング ロジックを戦略バックテストに移行する場合は、条件/要因を戦略シグナルとして出力し、取引レイヤーの詳細を処理するために Operator/Backtester に渡すことをお勧めします。


5.12. 付録: 図のインデックス (ノートブックで生成/スクリーンショットを作成することをお勧めします)

推奨される配置場所

何が見えるか

img/3.2_minimal_run.png

§0

より多くのシェアを備えた最小限の実行可能なチャート

img/3.2_pf_compare.png

§0.5 または §6

LONG/SHORT/000300.SHの正規化曲線の比較

img/3.2_highlight_key_day.png

§7

「キーポイント」(最大ポイントなど)を強調表示する例