4. HistoryPanel を使用して横断的なタイミング要因を調査する

クロスセクションのタイミング: ファクターしきい値 -> ブールマスク -> ポートフォリオのリターンと CAGR を比較

このチュートリアルは、非常に一般的で非常に実践的な調査シナリオを対象としています。つまり、少数の銘柄 (数個から十数個) しか持っていないため、各銘柄の独自のタイムラインに沿ってホールド/ホールドしないタイミングを決定したいと考えています。次に、それらの決定を単一のポートフォリオ曲線に集約し、最後にベンチマーク (HS300) と比較して、再利用可能な年率指標 (CAGR) を取得します。

まず、位置づけを明確にしましょう。ここでは、HistoryPanel軽量要素リサーチ コンテナ として機能します。その強みは、「データ -> 条件 -> 研究定義(マスク) -> ポートフォリオの集計 -> 視覚的な説明」を結び付けることで、ルールをさらに掘り下げる価値があるかどうかを迅速に検証できることです。これは取引バックテスト エンジンではありません。取引コスト、決済、スリッページ、資本制約、最小取引サイズなどの完全な取引セマンティクスは処理しません。この記事の目標は、取引ループではなく、調査ループを構築することです。


4.1. 0. 开场:先跑通一个最小可用的研究闭环

以下のコードが示すように、わずか数行で HistoryPanel を取得し、最初のチャート (折れ線グラフでもローソク足でも) をプロットして、「データと視覚化のパイプラインが機能している」ことを証明できます。

import qteasy as qt

stocks = ['000001.SZ', '600519.SH', '300750.SZ']
benchmark = '000300.SH'
shares = stocks + [benchmark]

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

# 最小可跑:能出图即可
fig = hp.plot(interactive=True)
fig

これを Notebook で実行すると、通常は上記のスニペットからすぐにグラフを表示できます。

しかし、単に「何かを計画する」ことができるだけでは十分ではありません。 本当に毎日の研究ツールとして使用したい場合は、少なくとも次の問題に遭遇することになります。

  1. タイミング ルールの実装方法: 「MACD>0 のときにホールドする」などのルールを念頭に置くのは簡単ですが、コードとして記述すると、列名が間違っていたり、列が欠落していたり​​、すべてが NaN になったり、異なる銘柄が一貫性なく配置されたりするなど、歪んでしまうことがよくあります。その結果、調査スクリプトは実行するとすぐにエラーが発生します。さらに悪いことに、エラーは発生しないものの、結論は信頼できません。

  2. マスクの形状を揃える方法: 条件は基本的に 2D (ストック × 時間) ですが、HistoryPanel は 3D (ストック × 時間 × フィールド) です。形状が揃っていない場合、「株式をフィルタリングしていると思っていても、実際にはフィールドをフィルタリングしている」という微妙なバグが発生する可能性があります。そして、この種のバグは多くの場合、すぐにはエラーをスローしません。単に資本曲線が「少しずれている」ように見えるだけです。

  3. ベンチマークがなければ、パフォーマンスを判断するのは難しい: ポートフォリオの曲線だけを見ると、「自分の背中をたたく」のが簡単になります。 2022 年の一部の特定のスタイルや市場全体のトレンドに乗っているだけかもしれません。000300.SH を参考として持ち込むことで、少なくともより現実的な質問に答えることができます。このタイミング ルールは 超過利益を生み出しているのか、それとも単に市場に合わせて動いているだけなのでしょうか?

  4. 結果はあるが説明が難しい: たとえ長期ポートフォリオがアウトパフォームしたとしても、「なぜ勝ったのか」を説明するのは依然として困難です。実際の調査は再現可能でレビュー可能である必要があります。リターンが明確な変曲点を示したら、すぐにチャートに戻って「どのトリガーがポジション状態を変化させたか」を正確に特定できるでしょうか?

幸いなことに、これらの機能は段階的に満たすことができます。この記事では、「最初に実行してから強化する」というリズムに従って、本当に使える小規模な研究ワークフローに具体化します。


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

ペースを安定させるために、まず「終了状態」を明確にしましょう。この記事に従うと、少なくとも 2 種類の出力が得られます。

  1. ポートフォリオレベルの比較: LONG / SHORT / 000300.SH 曲線 (すべて 1.0 から始まるように正規化) を同じチャートに配置すると、「タイミング ルールが調査期間中に有効であるかどうか」が一目でわかります。

  2. 単一資産レベルの説明: 特定の株式のローソク足チャート (または価格曲線) をプロットし、「保有条件がトリガーされる」時点を強調表示します。こうすることで、ポートフォリオの曲線に異常なセグメントが見つかったときに、すぐに単一銘柄のチャートに戻って、「トリガー ポイント」が直感と一致するかどうかを確認できます。

注: この記事では、GIF を出力する必要はありません。より推奨されるアプローチは、まず Notebook でプロットを動作させ、スクリーンショットを撮るだけです。ブログ投稿を書いたり、プレゼンテーションをしたりする場合は、主要な手順を GIF に記録します。


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

始める前に、目標を明確にして、各ステップでどのような問題を解決しているのかを理解しましょう。

  • 個別株の小さなセットの HistoryPanel + 000300.SH を取得します

  • 解釈可能なタイミング要素を導出する (例: MACD)

  • 比較演算を使用してブール条件を取得し、where() を使用してリサーチマスクを生成します

  • portfolio(mask=...) を使用してポートフォリオ曲線に集計し、ベンチマークと比較します

  • normalize/cum_return を使用して累積収益を取得し、CAGR の概要を導き出します

  • plot(highlight=...) を使用して、チャート上の「トリガー ポイント」を説明します

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

  • 範囲の境界を明確にする: これは取引バックテスト エンジンではありません

記事全体は同じリズムを保っています: 最初にこのセクションが解決する目的を説明します -> 次に必要最小限の原則を説明します -> 最後にキーコードと期待される結果を示します。 繰り返されるコードは必要に応じて省略されますが、すべてのセクションは記事を最後まで実行することで再現可能であることが保証されています。


4.4. 1.1 再現の前提条件: データはローカルに準備されていますか?

この記事の例では、デフォルトでデータ ソースがローカルに構成されており、qt.get_kline()2022010120221231 の毎日のバー データを正常にフェッチできることを前提としています。

環境にまだデータがない場合、最も一般的な症状は次のとおりです。 qt.get_kline() が空のパネルを返すか、後でインジケーターを計算するときにすべて NaN を取得します。 「途中でデータがないことに気づく」という事態を避けるために、セクション 2 を終了した後、次のことを必ず確認することをお勧めします。

  • hp.shape の時間の長さが 100 より大きいかどうか (1 年の日足バーは通常約 200)。

  • hp.htypes には少なくとも open/high/low/close が含まれていますか。

  • hp.hdates が学習したい期間を継続的にカバーしているかどうか。

まず独自の環境にデータをダウンロードする必要がある場合は、最初に「データのダウンロードとデータ ソースの構成」に関する章を完了してください。この記事では、データ パイプラインの詳細には触れず、HistoryPanel 自体に関する調査ワークフローに焦点を当てます。


4.5. 2. 准备数据:三只个股 + 一个基准指数(HS300)

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

まずデータをクリーンアップしましょう。クロスセクションのタイミングに 3 つの個別銘柄を使用し、後の portfolio(..., benchmark=...) 比較のためのベンチマークとして 000300.SH (CSI 300) をパネルに追加します。

ここで最も重要なステップは、OHLC カラムが完了していることを確認するです。 ローソク足をプロットする場合でも、後でパターン認識を行う場合でも、open/high/low/close を避けることはできないからです。

2.2 最低限必要な原則

HistoryPanel は 3D データ: (share, time, htype) です。 後続の where/mask/portfolio/cum_return はすべて「形状の整列」の前提条件に依存します。 したがって、最初に shape/shares/htypes を出力し、最小限のフィールド検証を実行します。これにより、「書き込み途中でエラーが発生するだけ」という落とし穴のほとんどを事前にブロックできます。

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

import qteasy as qt

stocks = ['000001.SZ', '600519.SH', '300750.SZ']
benchmark = '000300.SH'
shares = stocks + [benchmark]

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

print('hp.shape:', hp.shape)
print('hp.shares:', hp.shares)
print('hp.htypes:', hp.htypes)
print('last_date:', hp.hdates[-1])

required = {'open', 'high', 'low', 'close'}
missing = [c for c in required if c not in set(hp.htypes)]
if missing:
    raise ValueError(f'Missing required OHLC columns in htypes: {missing}')
if benchmark not in hp.shares:
    raise ValueError(f'Benchmark {benchmark} not found in shares: {hp.shares}')

# 额外做一个“是否真的有数据”的快速检查(避免空面板/全 NaN 继续往下跑)
if hp.shape[1] < 50:
    raise ValueError(
        'Not enough data points loaded (too few hdates). '
        'Please check your local datasource and date range.'
    )

以下が表示されるはずです:

  • hp.shares には 3 つの個別株 + 000300.SH が含まれます

  • hp.htypes には少なくとも open/high/low/close (場合によっては vol) を含める必要があります。


4.6. 3. 派生择时因子:MACD(把信号落成一列可复用的数据)

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

まず、十分に一般的で、十分に解釈可能で、「すぐに使用できる」タイミング要素、つまり MACD を選択します。 このセクションの目標は単純です。MACD を計算し、それを HistoryPanel の新しい列にすることで、後の条件付きフィルターでその列と直接比較できるようになります。

3.2 最低限必要な原則

hp.kline.macd()new HistoryPanel を返し、3 つの列を htypes に追加します。

  • macd_12_26_9

  • macd_signal_12_26_9

  • macd_hist_12_26_9

この名前付けに注意してください。デフォルトでは接尾辞が付いています。裸の macd_hist ではありません。 このステップでは列名を出力するので、後で条件を記述するときに推測して間違える必要がなくなります。

また、多くの人が初めて因子調査を行うとき、無意識のうちにさまざまな場所で「指標を再計算」する可能性があります。短期的には問題ないように見えますが、列を追加してグラフを描画し始めると、「同じ名前の列が上書きされる/同義の列が複数コピーされる」という混乱に陥りがちです。より堅牢なアプローチは、最初にインジケーターを列に計算し、明示的に htypes に書き込み、その後の条件、集計、および視覚化をそれらの列のみに依存させることです。

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

hp_macd = hp.kline.macd(price_htype='close', fastperiod=12, slowperiod=26, signalperiod=9)
print('new htypes (tail):', hp_macd.htypes[-6:])

予想: macd_hist_12_26_9htypes に表示されるはずです。


4.7. 4. 因子阈值 -> bool 条件 -> where() 研究 mask

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

次に、タイミング ルールを明確に説明します。たとえば、「MACD ヒストグラムが 0 より大きい場合は長く、0 以下の場合は短い」とします。 このステップで提供する必要があるのは、次の 2 つのマスクです。

  • mask_long: ロングポートフォリオ集計に参加するグリッドポイント

  • mask_short: どのグリッド ポイントがショート ポートフォリオの集計に参加するか

4.2 最低限必要な原則

2.2.8 以降、HistoryPanel は比較演算の直接実行をサポートします。たとえば、hp_macd > 0numpy.ndarray(bool) を返します。 そして、hp.where(condition) は、ブロードキャスト可能なさまざまな条件を hp.values と同じ形状の (M,L,N) マスクに正規化します。

このステップは重要です。私たちは「データを削除する」のではなく、「調査規約を定義する」のです。マスクが False のグリッド ポイントは、portfolio/cum_return で欠落しているものとして扱われます (集約に参加しないか、パスが壊れる原因になります)。 したがって、マスクは「研究ルールそのもの」である。

ここで、「形」についてもう少しわかりやすく説明してみましょう。

  • あなたの頭の中のタイミングルールは「1 日あたり 1 銘柄につき 1 つの True/False」であるため、最も自然な条件形状は (M, L) です。

  • ただし、HistoryPanel の値は、N フィールド列を持つ (M, L, N) です。

  • where() が行うことは、条件を (M, L, N) に拡張/ブロードキャストすることです。これにより、グリッド ポイントによるフィルター処理が必要な後続の計算に 単一の統合されたエントリ ポイントが与えられます。

「最初にすべての条件を where() で実行する」という習慣を身につけると、後で portfolio(mask=...)cum_return(mask=...) に条件を入力するときに形状の落とし穴に遭遇する可能性が低くなります。

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

import numpy as np

factor_col = 'macd_hist_12_26_9'

# 取单列子面板(形状 (M,L,1)),再与标量比较得到 bool ndarray
cond_long = (hp_macd[factor_col] > 0.0)     # numpy.ndarray(bool)
cond_short = (hp_macd[factor_col] <= 0.0)  # numpy.ndarray(bool)

# 规整为 (M,L,N) 研究 mask
mask_long = hp.where(cond_long)
mask_short = hp.where(cond_short)

print('cond_long shape:', getattr(cond_long, 'shape', None))
print('mask_long shape:', mask_long.shape)
print('mask_long dtype:', mask_long.dtype)

# 只做一个直觉检查:最后一天 long 有多少格点为 True
print('true_count_last_day(long):', int(mask_long[:, -1, 0].sum()))

期待される:

  • mask_long.shape == hp.shape

  • mask_long.dtype == bool


4.8. 5. 用 portfolio(mask=...) 聚合组合曲线,并与 benchmark 对比

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

私たちはトレーディングのバックテストを行っているのではなく、単にリサーチ指向の「大まかな集計」を行っているだけです。毎日、基準(ロング/ショート)を満たす銘柄を単一のポートフォリオ曲線に集計し、比較のためのベンチマークとして000300.SHを抽出します。

5.2 最低限必要な原則

HistoryPanel.portfolio() (私たちが使用するもの) のいくつかの重要なポイント:

  • mask=: where() と同じ形状ルールに従います。 False のグリッド ポイントは集計に参加しません。

  • benchmark= + benchmark_output='tag_along': ベンチマーク行を出力に追加します

  • 出力は依然として HistoryPanel であり、タイムラインは変更されません。

繰り返しますが、これはリサーチ指向の集計です。取引コストは含まれず、資本の制約もなく、リバランスも実行されません。

これを「リサーチ規約に基づく均等加重バスケット」と考えることができます。毎日、基準を満たす銘柄が単一の曲線に均等加重平均されます。その目的は、実際の取引をシミュレートすることではなく、より基本的な質問に答えることです。

この基準を使用して「保持セット」を定義した場合、サンプル期間にわたって体系的なパフォーマンスの違いが示されますか?

この質問に対する答えが「はい」の場合にのみ、努力を続けて実際のトレーディング戦略のバックテストにアップグレードする価値があります。

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

benchmark = '000300.SH'

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

pf_short = hp.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.htypes:', pf_long.htypes)
print('pf_long.shape:', pf_long.shape)

予期: pf_long.shares には、LONG000300.SH の 2 つの行が含まれます。


4.9. 6. normalize / cum_return + CAGR:把曲线变成可比较的年化摘要

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

2 つの曲線を並べて見ると傾向がわかりますが、「この調査期間中、ベンチマークよりも正確にどれくらい強いのか?」をすぐに要約するのは困難です。 そこで、次の 2 つのことを行います。

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

  2. cum_return + CAGR: 再利用可能な年間集計表を作成します

6.2 必要最小限の原則

  • normalize(base_index=0) は、ベースライン ポイントを 1.0 (研究規約) にスケールします。これは、同じチャート上でプロットして比較するのに最適です。

  • cum_return(method='simple') は累積収益を出力します cumret_*

  • CAGR の本質は「等価年率成長率」です。

    [ \text{CAGR}=(1+R)^{1/T}-1 ]

ここで、(R) は期間にわたる累積収益 (終了値)、(T) は年数です。

「CAGR を計算する理由」について、ここにもう 1 つの文を追加しましょう。非常に多くの場合、異なる長さの期間 (半年、1 年、2 年) を比較します。累積リターンだけを見れば、「期間が長ければ長いほど素晴らしい」と結論付けてしまいがちです。 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_close
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])

# benchmark 行同样在 shares 里(tag_along)
cumret_bm_long_end = float(cr_long.values[cr_long.shares.index('000300.SH'), -1, 0])
cumret_bm_short_end = float(cr_short.values[cr_short.shares.index('000300.SH'), -1, 0])

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

print(summary)

3 行の概要テーブルが表示されるはずです。少なくとも 2 つのことに注意することをお勧めします。

  • LONG 対 000300.SH: 本当に優れたパフォーマンスを示しましたか (より高い CAGR および/またはより高い累積収益)?

  • SHORT パフォーマンス: 必ずしも「お金を失う」必要はありませんが、条件が本当にサンプルを分離しているかどうか (つまり、LONG エンドと SHORT エンドの違いが明らかかどうか) を判断するのに役立ちます。


4.10. 7. 可视化解释:用 plot(highlight=...) 把“触发点”标回图上

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

収益曲線はすでに得られていますが、説明 という最後の部分がまだ欠けています。 このステップでは、「レビューしやすい」ことを行います。単一銘柄のローソク足チャートでトリガー ポイントを強調表示し、読者が「いつタイミング条件がトリガーされたか」を一目で確認できるようにします。

7.2 必要最小限の原則

HistoryPanel.plot(highlight=...) は 2 つの一般的な使用法をサポートしています。

  • 略記: highlight='max'/'min'

  • 明示的: highlight={'condition': <1D bool over time>, 'style': {...}}

注意すべき点が 1 つあります。静的レンダリング パスでは、condition は **1D 時間軸 ** (チャート上の散布マーカーに使用されます) を指向しています。したがって、ここでは強調表示に単一銘柄の時間軸で 1D ブール値を使用する方がより堅牢です。

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

import numpy as np

primary_share = '000001.SZ'

# 从 cond_long(M,L,1 或 M,L,N 的 bool)里抽出该 share 的时间轴 1D 条件
si = hp_macd.shares.index(primary_share)
cond_1d = np.asarray(cond_long[si, :, 0], dtype=bool).ravel()

# 为了让图更清爽,这里只看最后 200 个交易日(你也可以改成全区间)
hp_one = hp.loc[-200:]

fig = hp_one.plot(
    shares=[primary_share],
    interactive=True,
    highlight={'condition': cond_1d[-200:], 'style': {'marker': 'x', 's': 50}},
)
fig

期待される結果: チャート上に一連のハイライトされたポイントが表示されます (macd_hist_12_26_9 > 0 の時間に対応)。 このステップの価値は、ポートフォリオ曲線の一部が突然悪化した場合に、単一銘柄チャートに戻って、トリガーポイントが途切れ途切れの範囲に集中していないか、前後に「ホイップソー」されているかどうかをすぐに確認し、次にフィルター条件 (トレンドフィルター、ボラティリティフィルターなど) を追加するかどうかを決定できることです。


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

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

  • MACD -> mask -> portfolio -> CAGR の完全な研究ループを実行します。

  • LONG/SHORT/benchmark の正規化曲線をプロットします。

  • 単一銘柄チャートをプロットし、トリガー ポイントを強調表示します (説明と確認を容易にするため)。

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


def demo_vertical_timing(
        stocks: list,
        benchmark: str = '000300.SH',
        start: str = '20220101',
        end: str = '20221231',
        primary_share: str = '000001.SZ',
):
    \"\"\"演示纵向择时研究闭环:MACD 阈值 -> mask -> 组合曲线 -> CAGR -> 高亮解释。

    Parameters
    ----------
    stocks : list
        个股代码列表(建议 3~15 只,太多不利于解释)。
    benchmark : str, default '000300.SH'
        基准指数代码(示例使用沪深 300)。
    start : str, default '20220101'
        起始日期(YYYYMMDD)。
    end : str, default '20221231'
        结束日期(YYYYMMDD)。
    primary_share : str, default '000001.SZ'
        用于做“触发点高亮解释”的单只股票代码。

    Returns
    -------
    dict
        结果对象集合,便于你在 Notebook 里继续查看:
        - hp: 原始面板
        - hp_macd: 含 MACD 列的面板
        - pf_long/pf_short: 组合面板(含 benchmark 行)
        - summary: CAGR 摘要表(DataFrame)
        - fig_pf: 组合对比图
        - fig_one: 单股高亮图
    \"\"\"
    shares = list(stocks) + [benchmark]

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

    required = {'open', 'high', 'low', 'close'}
    missing = [c for c in required if c not in set(hp.htypes)]
    if missing:
        raise ValueError(f'Missing required OHLC columns in htypes: {missing}')
    if benchmark not in hp.shares:
        raise ValueError(f'Benchmark {benchmark} not found in shares: {hp.shares}')
    if hp.shape[1] < 50:
        raise ValueError(
            'Not enough data points loaded (too few hdates). '
            'Please check your local datasource and date range.'
        )
    if primary_share not in hp.shares:
        raise ValueError(f'primary_share "{primary_share}" not found in shares')

    # 1) 派生因子:MACD(默认 12_26_9)
    hp_macd = hp.kline.macd(price_htype='close', fastperiod=12, slowperiod=26, signalperiod=9)
    factor_col = 'macd_hist_12_26_9'
    if factor_col not in hp_macd.htypes:
        raise ValueError(f'Required factor htype "{factor_col}" not found after macd()')

    # 2) 条件 -> mask(研究口径)
    cond_long = (hp_macd[factor_col] > 0.0)
    cond_short = (hp_macd[factor_col] <= 0.0)
    mask_long = hp.where(cond_long)
    mask_short = hp.where(cond_short)

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

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

    # 5) 图:组合曲线对比(先 normalize,便于肉眼比较)
    pf_view = pf_long.normalize(htypes='close', base_index=0)
    fig_pf = pf_view.plot(interactive=True)

    # 6) 图:单股触发点解释(1D 时间轴条件)
    si = hp_macd.shares.index(primary_share)
    cond_1d = np.asarray(cond_long[si, :, 0], dtype=bool).ravel()
    lookback = min(200, len(cond_1d))
    fig_one = hp.loc[-lookback:].plot(
        shares=[primary_share],
        interactive=True,
        highlight={'condition': cond_1d[-lookback:], 'style': {'marker': 'x', 's': 50}},
    )

    return {
        'hp': hp,
        'hp_macd': hp_macd,
        'pf_long': pf_long,
        'pf_short': pf_short,
        'summary': summary,
        'fig_pf': fig_pf,
        'fig_one': fig_one,
    }


res = demo_vertical_timing(
    stocks=['000001.SZ', '600519.SH', '300750.SZ'],
    benchmark='000300.SH',
    start='20220101',
    end='20221231',
    primary_share='000001.SZ',
)
res['fig_pf']

注: 上記の normalize は、視覚的な比較をより直感的にするためだけのものです。統計については、引き続き cum_return または生の価格シリーズに依存する必要があります。


4.12. 9. 小结与边界

この時点までに、「小規模な計測器セットでのタイミング」のための完全な研究ループをすでに実行しています。データ -> 係数 -> ブール条件 -> where マスク -> portfolio + benchmark -> cum_return + CAGR -> plot(highlight) の説明。

注: ここでの portfolio/cum_return はすべてリサーチ指向の計算であり、取引コスト、スリッページ、決済、資本制約などの完全なバックテスト セマンティクスは 含まれていません。リサーチ ロジックを実際の戦略のバックテストに移行したい場合は、要因/条件を戦略で使用可能なデータ列またはシグナルとして出力し、それらを Operator/Backtester に渡してトレーディング層のセマンティクスを処理することをお勧めします。


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

推奨される配置場所

何が見えるか

img/3.1_minimal_run.png

§0 最小限の実行可能なキックオフ

get_kline -> plot パイプラインが機能することを証明する

img/3.1_pf_compare.png

§0.5 または §6

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

img/3.1_highlight_one_share.png

§7

単一銘柄チャート上のトリガーポイントを強調表示します(確認と説明を容易にするため)