株式スクリーナーに「勝率60%、サンプル10件」と表示されていたら、参考になりそうに見えます。しかし、そのうち6件が同じ銘柄の連日選出だったとしたら、この60%はいったい何を意味するのでしょうか。

スクリーナーで戦略を比べる人にも、自作のクオンツツールを運用する開発者にも共通する落とし穴があります。サンプル数が数えているのはシグナル発生回数かもしれず、異なる銘柄の数でも、実際に成立した取引の数でもありません。 勝率だけでは繰り返しの観測を十分な検証と取り違えかねません。

シグナル追跡システムの過去の保守記録を確認していて、まさにこの分母の問題に出会いました。以下では人工的に作った10件の選出記録をPythonとSQLで再計算し、**60%、25%、83.3%**という異なる数値を出します。最も見栄えのよい勝率を探すのではなく、何を分母にしたのか、いつ結果を確定するのか、繰り返し観測にはどれだけ証拠価値があるのかを明らかにします。

重複選出と分母の選び方:6日間で10回のシグナル

市場、戦略バージョン、保有期間を統一し、下表はすべてT+5の結果が確定済みだと仮定します。A〜Dは架空の銘柄です。リターンは手数料やスリッページを差し引く前のグロス値で、集計方法を示すために作った数字です。実際の相場から抽出したものではありません。

選出日銘柄とT+5リターンその日に選んだ銘柄のT+5等ウェイト平均
d1A +5%、B -3%+1%
d2A +4%、C -2%+1%
d3A +3%、B -1%+1%
d4A +6%+6%
d5A +2%+2%
d6A +1%、D -4%-1.5%

10件のうちプラスは6件で、すべてAです。「確定した選出イベントを1件ずつ」数えれば、イベント単位の勝率は6/10 = 60%。知りたいことが「各シグナルがT+5まで進んだとき、何割がプラスだったか」なら、この分母には意味があります。連続して選ばれたからといって、計算式そのものが間違いになるわけではありません。

ただし、10種類の銘柄で検証したことにはなりません。実際には4銘柄です。期間内の各銘柄について複数のT+5リターンを平均し、その平均がプラスの銘柄を数えると、該当するのはAだけ。**ユニーク銘柄単位の勝率は1/4 = 25%**になります。B、C、Dの平均値はマイナスです。

選出日でまとめる方法もあります。同じ日に確定したシグナルのT+5リターンを等ウェイト平均し、平均がプラスだった日を数えます。6日中5日なので、日次コホート勝率は5/6 = 83.3%。この6つのT+5平均値の算術平均は約+1.58%です。しかし、毎日運用資産が1.58%増えたという意味ではなく、そのまま年率換算もできません。

集計指標結果何が分かるか
イベント単位の勝率60%(6/10)確定した選出イベントのうちプラスの割合
ユニーク銘柄単位の勝率25%(1/4)銘柄ごとの平均T+5がプラスだった銘柄の割合
日次コホート勝率83.3%(5/6)選出日ごとの等ウェイト平均がプラスだった日の割合

この例では日次コホート勝率が一番高くなりました。銘柄が繰り返し選ばれても、イベント単位の勝率が必ず高くなるわけではありません。変わるのは各銘柄の集計上の重みです。連続して選ばれるのが負け銘柄なら、イベント単位の勝率はむしろ低くなり得ます。戦略を比べる前に、表示値が「イベント」「銘柄」「選出日」のどれを数えたものか確認します。

Pythonで3種類の勝率を実際に再計算する

以下はPython 3.13.5と標準ライブラリだけで実際に実行した完全なスクリプトです。入力は合成した10件の記録であって、「10行だけのスクリプト」ではありません。銘柄別・日別の平均T+5と3種類の勝率を出力します。結果について7個、入力構造について1個のassertがあり、末尾のASSERTIONS_PASS=7は結果の7個だけを数えています。

#!/usr/bin/env python3
"""Synthetic settled T+5 selection-outcome metric comparison, no real market data."""
from collections import defaultdict
from statistics import mean

# Entirely fictional stocks and T+5 returns (decimal units).
# Every row is a distinct snapshot-date pick in the SAME market, strategy, and horizon.
ROWS = [
    ("d1", "A", 0.05), ("d1", "B", -0.03),
    ("d2", "A", 0.04), ("d2", "C", -0.02),
    ("d3", "A", 0.03), ("d3", "B", -0.01),
    ("d4", "A", 0.06),
    ("d5", "A", 0.02),
    ("d6", "A", 0.01), ("d6", "D", -0.04),
]

def calc(rows):
    assert rows and all(isinstance(r, tuple) and len(r) == 3 for r in rows)
    symbol_returns = defaultdict(list)
    date_returns = defaultdict(list)
    for day, symbol, settled_return in rows:
        symbol_returns[symbol].append(settled_return)
        date_returns[day].append(settled_return)
    symbol_means = {symbol: mean(xs) for symbol, xs in symbol_returns.items()}
    date_means = {day: mean(xs) for day, xs in date_returns.items()}
    return {
        "settled_rows": len(rows),
        "distinct_symbols": len(symbol_returns),
        "distinct_pick_dates": len(date_returns),
        "sample_win_rate": sum(ret > 0 for _, _, ret in rows) / len(rows),
        "unique_symbol_win_rate": sum(ret > 0 for ret in symbol_means.values()) / len(symbol_means),
        "daily_cohort_win_rate": sum(ret > 0 for ret in date_means.values()) / len(date_means),
        "mean_daily_cohort_return": mean(date_means.values()),
        "symbol_mean_returns": symbol_means,
        "date_cohort_returns": date_means,
    }

if __name__ == "__main__":
    import json
    result = calc(ROWS)
    assert result["settled_rows"] == 10
    assert result["distinct_symbols"] == 4
    assert result["distinct_pick_dates"] == 6
    assert result["sample_win_rate"] == 0.6
    assert result["unique_symbol_win_rate"] == 0.25
    assert abs(result["daily_cohort_win_rate"] - 5 / 6) < 1e-12
    assert abs(result["mean_daily_cohort_return"] - 0.095 / 6) < 1e-12
    print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
    print("ASSERTIONS_PASS=7")

標準出力の全文です(架空の数値であり、実市場のバックテスト結果ではありません)。

{
  "daily_cohort_win_rate": 0.8333333333333334,
  "date_cohort_returns": {
    "d1": 0.010000000000000002,
    "d2": 0.01,
    "d3": 0.009999999999999998,
    "d4": 0.06,
    "d5": 0.02,
    "d6": -0.015
  },
  "distinct_pick_dates": 6,
  "distinct_symbols": 4,
  "mean_daily_cohort_return": 0.015833333333333335,
  "sample_win_rate": 0.6,
  "settled_rows": 10,
  "symbol_mean_returns": {
    "A": 0.035,
    "B": -0.02,
    "C": -0.02,
    "D": -0.04
  },
  "unique_symbol_win_rate": 0.25
}
ASSERTIONS_PASS=7

分母はイベントが6/10、ユニーク銘柄が1/4、日次コホートが5/6。日別T+5平均の0.01583333(約+1.58%)は、実際のポートフォリオが毎日得た純利益ではありません。実行コードと出力は全文を掲載したので再計算できます。元のUTF-8ファイルのバイト単位での照合用にSHA-256も残します。

  • Pythonスクリプト:0b554335c960b4a58eab671a693ef64cdba4ab46a6c6f653a93fa2ca220a7b8f
  • 標準出力:7b91fcdda0c6bea63d05788f25815af16321c647d5342463601599a81db1b8f2

ハッシュ値は元のUTF-8ファイルのバイト列に対するものです。Webページからコピーすると改行や空白が変わり、ハッシュ値が異なることがありますが、数値計算自体は検証できます。実際の価格データベースや証券口座には接続していません。

勝率集計に残る2つの罠:二重登録と確定日

シグナル追跡システムの過去の保守記録から、妥当だと感じた設計があります。正当な選出イベントをすべて保存し、イベント単位・ユニーク銘柄単位・日次コホートの勝率を並べて表示することです。集計定義は非公開の過去の技術メモに基づいており、今回は現行の本番DBを再検証していません。Pythonの実験は計算方法だけを確かめたもので、戦略の実績を証明するものではありません。

まず「繰り返し」を区別します。同じ銘柄が月曜と火曜に選ばれたなら、追跡可能な別々のシグナルです。一方、定期ジョブの再試行で同じ日・市場・戦略・銘柄・保有期間の同一イベントが2回登録されたなら、データの二重取り込みを疑います。前者を一括削除してはいけませんし、後者を新しい証拠に数えてもいけません。厳密なイベントキーはシグナルの出所や戦略の仕様にも左右されます。

もう一つの落とし穴は結果の確定方法です。T+5は対象市場の5営業セッションを意味すべきであり、バッチジョブが5回動いたという意味ではありません。決済価格も、遅れて動いた時点の現在値ではなく、定義した確定日の過去価格を使う必要があります。満期に達していない、または価格欠損でpendingの記録を確定済み勝率の分母に混ぜるべきではありません。過去の保守メモには営業日カレンダー、過去の終値、戦略バージョンの区別が記されていますが、現行本番環境を独立監査したわけではありません。

「候補がゼロ」「まだ確定していない」「確定したが全部マイナス」は別の状態です。二重登録がないか、確定日の価格が適切か、確定済み記録だけを数えているかを確認してから勝率を比べる方が、最大の数字を探すより役に立ちます。

保有期間の重なり:銘柄の重複排除だけでは統計的に独立にならない

銘柄でまとめた後でも観測は独立とは限りません。Aが6日連続で選ばれ、各記録がその後5営業日のリターンを測るなら、隣り合うラベルが同じ価格変動を一部共有します。銘柄別に平均しても、複数銘柄に作用する共通の市場要因は取り除けません。異なる銘柄数、異なる選出日数、統計的な有効サンプル数は別物です。

だから、隣り合う10件のT+5記録をランダムに学習用と検証用に振り分けて、独立した汎化性能の証拠とするのは危険です。scikit-learnのTimeSeriesSplitの説明は未来のデータで過去を学習しない時系列分割を扱い、グループ交差検証の解説は関連するグループが分割をまたぐリークを扱います。銘柄別グループ化と時系列分割では防ぐ問題が違います。

ラベルが将来5営業日にわたると、学習・検証それぞれの情報区間が分割境界をまたいで重なる可能性もあります。Hudson & ThamesのPurgedKFold実装は、そのように検証ラベルと情報区間が重なる学習サンプルを除外する例です。これは第三者のソースコード参考資料であり、この記事で実行したモデルではありません。時系列順に並べることや適当に1日空けることだけでは重なりは解消できません。

これらは検証の質を高める方法ですが、すべてのスクリーナーに精密そうな「有効サンプル数」を1つ表示すべきという話ではありません。実際に比較したい人には、イベント数、異なる銘柄数、選出日、保有期間、未確定の割合を並べる方が役立ちます。

読み取り専用SQLで分母を点検する:確定イベント・銘柄・日付

実際の判断に戻ります。この勝率を戦略比較に使ってよいのか? データが手元にあるなら選出イベントと確定結果を保持し、市場・戦略バージョン・エントリー条件・T+Nを揃えた上で3種類の勝率を比較します。グロスか取引費用控除後のネットかも明示しましょう。

画面の総勝率だけを信じず、同一市場・同一戦略バージョン・同一T+N・同一選出期間の確定イベントだけから始めます。次の読み取り専用SQLはPostgreSQL/SQLiteの一般的な集計構文です。your_settled_outcomes、example-v2、列名は例示用のプレースホルダーで、本番DBの実スキーマではありません。自分のデータに合わせ、確定条件を確認して読み取り専用コピーで試してください。

-- Read-only PostgreSQL/SQLite template. Replace table/columns and filters.
WITH settled AS (
  SELECT snapshot_date, tv_symbol, return_pct
  FROM your_settled_outcomes
  WHERE market = 'US'
    AND strategy_id = 'example-v2'
    AND horizon_days = 5
    AND snapshot_date >= '2026-07-01'
    AND snapshot_date < '2026-10-01'
    AND settled_trade_date IS NOT NULL
    AND return_pct IS NOT NULL
), by_symbol AS (
  SELECT tv_symbol, AVG(return_pct) AS mean_return
  FROM settled GROUP BY tv_symbol
), by_day AS (
  SELECT snapshot_date, AVG(return_pct) AS mean_return
  FROM settled GROUP BY snapshot_date
)
SELECT
  (SELECT COUNT(*) FROM settled) AS settled_rows,
  (SELECT COUNT(DISTINCT tv_symbol) FROM settled) AS unique_symbols,
  (SELECT COUNT(DISTINCT snapshot_date) FROM settled) AS unique_pick_days,
  (SELECT AVG(CASE WHEN return_pct > 0 THEN 1.0 ELSE 0.0 END) FROM settled) AS sample_win_rate,
  (SELECT AVG(CASE WHEN mean_return > 0 THEN 1.0 ELSE 0.0 END) FROM by_symbol) AS unique_symbol_win_rate,
  (SELECT AVG(CASE WHEN mean_return > 0 THEN 1.0 ELSE 0.0 END) FROM by_day) AS daily_cohort_win_rate;

このSQLもPythonと同じ3層で集計します。settledが確定イベント、by_symbolが銘柄別平均、by_dayが選出日別平均です。Linux x86_64上のPython 3.13.5のsqlite3モジュール(sqlite3.sqlite_versionは3.46.1)で合成10件に相当するSQLを実行し、**イベント10件、銘柄4種、選出日6日、比率0.6 / 0.25 / 0.833333…**を得ました。PostgreSQLにも本番DBにも接続していません。

同一日・同一銘柄の重複登録を疑う場合は、確定結果だけではなく元の選出イベントテーブルにもう1本の読み取り専用SQLを実行します。以下のテーブル名やフィルターは例です。自分の市場、戦略版、保有期間、対象期間に置き換えてください。

SELECT snapshot_date, tv_symbol, COUNT(*) AS repeat_rows
FROM your_signal_events
WHERE market = 'US' AND strategy_id = 'example-v2' AND horizon_days = 5
  AND snapshot_date >= '2026-07-01'
  AND snapshot_date < '2026-10-01'
GROUP BY snapshot_date, tv_symbol
HAVING COUNT(*) > 1;

同じ市場・戦略・保有期間における日付と銘柄の重複組み合わせを列挙します。先ほどの架空の10件をSQLiteで調べると最初は0件でしたが、2026-07-01のAを意図的に1件複製するとrepeat_rows = 2が返りました。これはクエリの動作確認であり、本番データに重複があるという主張ではありません。

検出されたからといってすぐ削除してはいけません。 同じ日に異なる発生源や時間帯で複数の正当なシグナルが出る場合があります。実際のイベントID、生成時刻、出所を照合して判断します。別の日の再選出は重複取り込みエラーではありません。未確定・価格欠損・旧戦略版の件数も分け、実運用の評価にはリターン分布と費用を含めます。

「毎日推奨銘柄を買えばこうなる」という運用成績を示すには、資金配分、ポジションの重なり、手数料、流動性、市場インパクト、スリッページを組み込んだポートフォリオのバックテストが必要です。前述の+1.58%にはこれらがないため、実現可能な日次ポートフォリオ利回りではありません。

関連する別の論点は、戦略をまたぐ選別スコアの比較です。そこでは異なる出所の「80点」が同じ意味なのかを考えます。この記事ではその前提となる、一度の選出・一銘柄・一選出日・一回の実行可能な取引は同じ統計単位ではないという点を確認しました。

次に「勝率60%、サンプル10件」を見たら、10件はシグナルイベントか、10種類の銘柄か?何営業日にまたがるか?実際に確定済みのT+Nはいくつか? と問い直してみてください。答えが分かればPythonとSQLで数字の意味を点検できます。その後に保有期間の重なり、取引費用、損益分布を確かめて、どの戦略に根拠があるか判断できます。

検証範囲:Python 3.13.5、SQLite 3.46.1と合成データ。工学的背景は過去の保守記録に基づくもので、本番相場データの監査や実行可能なポートフォリオのバックテストは行っていません。