AIがずっと「見送り」し続ける銘柄を、毎週自動で入れ替える仕組みを作った【AI自動売買】
- この記事で分かること
- コードに直書きしていたウォッチリストをDBテーブルに移し、「直近7日で8回以上判断して、すべて見送り」の銘柄を毎週月曜に自動で除外・補充するジョブの実装
- 実際に使った環境
- Python / SQLAlchemy / APScheduler / J-Quants API(無料プラン)/ kabuステーションAPI。2026-09-16から本番の自動売買システムで稼働
- 筆者の結論
- 手作業の入れ替えを一度やってから、その手順と判断基準をそのままコードにした。ただし執筆時点(2026-09-28)では、自動のジョブが実際に銘柄を入れ替えた実績はまだ無い
AIに30万円を渡して日本株を自動売買させる実験では、AIが売買を判断する対象を「ウォッチリスト」として固定しています。使っている株価データのJ-Quants無料プランが約90日遅延しているため、毎日全銘柄からスクリーニングし直すことができず、あらかじめ「実際に買える銘柄」を選んでおく方式にしているためです。
ところが、運用を続けるうちに、ウォッチリストの中にAIが一度も買わない銘柄が出てきました。この記事では、そうした銘柄を毎週自動で入れ替える仕組みを作った経緯と、その設計を紹介します。
🛠 実装してみて分かったこと
いきなり自動化せず、最初は同じ作業を手でやりました。DBの判断記録を見て、見送られ続けている銘柄を探し、スクリーニングで代わりを選び、コードを書き換えて再起動する。一度手でやってみると、「何件の判断があれば見送り続けていると言えるか」「代わりはどう選ぶか」といった判断基準がはっきりしました。自動化したのは、その手順をそのままコードに移しただけです。
きっかけ: 同じ銘柄ばかり売買していた
当初のウォッチリストは12銘柄でした。運用を始めてしばらくすると、住友化学やパーソルホールディングスなど、同じ銘柄ばかりを売買していることに気づきました。
AIの判断記録を確認すると、12銘柄のうち4銘柄(日産自動車・三菱自動車・東京電力ホールディングス・セブン銀行)は、一度も買われていませんでした。すべての判断がSKIP(見送り)で、理由はROEやEPSがマイナス、PERが同業他社より割高、といった財務上のものです。
AIの判断は筋が通っていて、バグではありません。問題は、買う価値のある候補が実質8銘柄しかなかったことでした。そこでまず、同じスクリーニング方法で8銘柄を追加し、ウォッチリストを20銘柄に広げました。
1回目は手作業で入れ替えた
20銘柄に広げた後も、先の4銘柄は相変わらず財務上の理由で見送られ続けていました。これは「バグ」ではなく「機会損失」です。買う見込みの無い銘柄に判断の枠を使い続けるより、別の銘柄に入れ替えたほうがいい。
そこで2026年9月16日、この4銘柄を外し、まだウォッチリストに無い業種(空運・不動産・建設・地方銀行)から新しく4銘柄を選んで入れ替えました。この時点では、ウォッチリストはPythonのコードに定数として直書きしていたので、入れ替えのたびにコードを書き換えて、スケジューラを再起動する必要がありました。
同じ日のうちに、この作業を完全に自動化することにしました。
ウォッチリストをコードからDBへ移す
自動化の前提として、ウォッチリストをコードの定数からDBのテーブル(watchlist_entries)に移しました。コードの中にある限り、銘柄を入れ替えるにはコードを書き換えるしかありません。DBに置けば、プログラム自身が銘柄を追加・除外できます。
| カラム | 内容 |
|---|---|
symbol | 銘柄コード(主キー) |
name / sector | 銘柄名・業種 |
active | 現在ウォッチリストに入っているか |
added_at / removed_at | 追加・除外した日時 |
note | 追加・除外の理由 |
銘柄を外すときは、行を削除せずにactive=Falseにします。理由は2つあります。いつ・なぜ入れ替えたかの履歴を残すためと、一度外した銘柄を、スクリーニングが再び候補に挙げてしまうのを防ぐためです。後者は、スクリーニングの除外リストに「このテーブルに一度でも登場したすべての銘柄」を使うことで実現しています。
移行時は、テーブルが空の場合だけ、それまでコードに書いていた20銘柄を初期データとして投入するようにしました。
入れ替えの流れ
入れ替えは、毎週月曜の17:30に実行される定期ジョブで行います。引け後の記事・動画生成(16:00)の後、市場データの収集(18:00)の前という時刻にしているのは、新しく入れた銘柄が、その日18:00のデータ収集からすぐ対象になるようにするためです。
1. 「見送り続けている銘柄」を見つける
判定の条件は、直近7日間に8件以上の判断があり、そのすべてがSKIPだったことです。
CHRONIC_SKIP_LOOKBACK_DAYS = 7
CHRONIC_SKIP_MIN_DECISIONS = 8
def find_chronic_skip_symbols(
session: Session,
symbols: list[str],
*,
lookback_days: int = CHRONIC_SKIP_LOOKBACK_DAYS,
min_decisions: int = CHRONIC_SKIP_MIN_DECISIONS,
) -> list[str]:
cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=lookback_days)
chronic: list[str] = []
for symbol in symbols:
actions = (
session.execute(
select(AiDecision.action)
.where(AiDecision.symbol == symbol, AiDecision.decision_time >= cutoff)
.order_by(AiDecision.decision_time)
)
.scalars()
.all()
)
if len(actions) < min_decisions:
continue
if all(action == "SKIP" for action in actions):
chronic.append(symbol)
return chronic「8件以上」という下限を置いているのは、判断の回数が少ないうちに「見送り続けている」と決めつけないためです。このシステムは1銘柄を1日に最大10回ほど判断するので、8件はおよそ1〜2営業日分にあたります。追加したばかりの銘柄が、1〜2回見送られただけで外されることはありません。
また、対象はすべてがSKIPの銘柄だけです。1回でもBUYの判断が出た銘柄や、保有中の銘柄に対するHOLD(保有継続)は対象外です。
2. 代わりの候補を探す
見送り続けている銘柄が見つかったら、J-Quantsの全銘柄の日次データから、機械的な条件で候補を絞り込みます。条件は、最初にウォッチリストを作ったときや、手作業で入れ替えたときと同じです。
| 条件 | 値 |
|---|---|
| 終値 | 100〜600円 |
| 時価総額 | 100億円以上 |
| 出来高 | 10万株以上 |
| 銘柄コード | 4桁の数字のみ |
| 除外 | このテーブルに一度でも登場した銘柄、値動きが極端に投機的になりやすい銘柄 |
終値を100〜600円に絞っているのは、元本30万円のうち1銘柄に使える上限の目安(約6万円)で、100株を買える価格帯にするためです。
「投機的な銘柄」の除外は、手作業で入れ替えたときに人間が個別に判断していた基準を、キーワードのリストとして明文化しました。暗号資産関連や宇宙ベンチャーなどの名前を含む銘柄を除外しています。
def _is_speculative(name: str | None) -> bool:
if not name:
return False
normalized = unicodedata.normalize("NFKC", name).lower()
return any(keyword in normalized for keyword in SPECULATIVE_NAME_KEYWORDS)銘柄名は全角・半角が混ざることがあるため、unicodedata.normalize("NFKC", ...)で表記を揃えてから照合しています。
3. 実際に買えるか、業種が偏らないかを確かめる
J-Quantsのデータは約90日前のものなので、今の株価は分かりません。そこで、候補を時価総額の大きい順に見ていき、kabuステーションAPIで今の株価を取得して、100株を予算内で買えるかを1銘柄ずつ確認します。
このとき、まだウォッチリストに無い業種を優先します。新しい業種の候補が尽きた場合は、業種の重複を許して、予算内で買える銘柄で埋めます。業種の分散にこだわりすぎて、銘柄自体が見つからない事態を避けるためです。
kabuステーションへの問い合わせは、1銘柄ごとに0.25秒の間隔を空けています。短時間に連続で問い合わせると、レート制限(HTTP 429)にかかることが実運用で分かっているためです。
4. 入れ替えて、変化があったときだけ通知する
最後に、見送り続けていた銘柄をactive=Falseにし、選んだ銘柄を新しく追加します。それぞれのnoteには、「直近7日間で8件以上の判断がすべてSKIPだったため自動除外」「自動見直しによるスクリーニングで新規追加」といった理由を記録します。
入れ替えがあった週だけ、Discordに除外・追加した銘柄を通知します。対象の銘柄が無かった週は通知しません。このジョブは「毎週動いていること」ではなく「入れ替えが起きたこと」を知らせたいものなので、毎週「変化なし」と通知されてもノイズになるだけだからです。ジョブがエラーで失敗したときは、他のジョブと同じく、エラーの記録とDiscordへの通知を行います。
執筆時点の状況: 自動の入れ替えはまだ起きていない
このジョブは2026年9月16日から動いていますが、執筆時点(9月28日)では、自動のジョブが実際に銘柄を入れ替えたことはまだありません。ウォッチリストの20銘柄は、すべて9月16日に投入したときのままです。
直近7日間の判断記録を見ると、条件に当てはまる銘柄がありません。例えばKLab(3656)は14回の判断のうち11回がSKIPでしたが、BUYの判断も3回出ているため対象外です。判断の回数が8件に届かない銘柄もあります。
手作業で入れ替えた時点で、見送り続けていた4銘柄が外れたことを考えると、今のところは想定どおりの状態だと考えています。
この記事を書く途中で見つけた2つの弱点と修正
この記事を書くために実装を見直したところ、2つの弱点が見つかりました。まだ自動の入れ替えが一度も動いていなかったため実害は出ていませんが、初めて動いたときに影響するものだったので、9月28日に修正しました。
弱点1: 手作業で外した4銘柄が、除外リストに入っていなかった
DBのテーブルを作ったのは、手作業での入れ替えが終わった後でした。そのため、手作業で外した東京電力ホールディングス・日産自動車・三菱自動車・セブン銀行の4銘柄は、テーブルに一度も登場しておらず、「一度外した銘柄を候補に戻さない」仕組みの対象になっていませんでした。スクリーニングの条件を満たせば、再び候補に挙がる可能性があったわけです。
見直しジョブの冒頭で、この4銘柄を「除外済み」としてテーブルに登録する処理を足しました。
def register_previously_removed_symbols(session: Session, symbols: list[str], *, reason: str) -> list[str]:
known = _all_known_symbols(session)
registered: list[str] = []
now = dt.datetime.now(dt.timezone.utc)
for symbol in symbols:
if symbol in known:
continue
session.add(WatchlistEntry(symbol=symbol, active=False, removed_at=now, note=reason))
registered.append(symbol)
return registeredすでにテーブルにある銘柄には触れないので、毎週のジョブで何度呼ばれても結果は変わりません。
弱点2: 初期の20銘柄に業種の情報が無かった
初期投入した20銘柄は、銘柄コードだけを登録していて、業種(sector)が空でした。そのため「まだウォッチリストに無い業種を優先する」判定で、既存の20銘柄の業種がまったく考慮されていませんでした。例えば、すでに建設業の銘柄を持っていても、建設業の候補を「新しい業種」として優先してしまう状態です。
入れ替えの対象が見つかったときに、J-Quantsの銘柄マスタから業種を埋めてから判定するようにしました。銘柄マスタは候補のスクリーニングでも使うので、1回だけ取得して両方に使い回しています。
def backfill_missing_metadata(session: Session, master_by_code: dict[str, dict]) -> list[str]:
filled: list[str] = []
entries = session.execute(select(WatchlistEntry).where(WatchlistEntry.sector.is_(None))).scalars().all()
for entry in entries:
info = master_by_code.get(entry.symbol)
if not info or not info.get("S33Nm"):
continue
# 会社名(`name`)は埋めない。`symbol_names.symbol_name()`は`name`を
# `SYMBOL_NAMES`より優先するため、マスタの全角表記(例:「NTT」)で
# 上書きすると、記事・動画の銘柄名表示が変わってしまう。
entry.sector = info["S33Nm"]
filled.append(entry.symbol)
return filled最初は業種と一緒に会社名も埋めていましたが、この会社名は記事や動画の銘柄名の表示にも使われています。J-Quantsの会社名は「NTT」のような全角英数字で返ってくるため、そのまま入れると、トレード日誌の「NTT」が「NTT」に変わってしまいます。直前に気づいて、業種だけを埋める形にしました。あわせて、自動で新しく追加する銘柄の会社名は、全角英数字を半角に揃えて(UnicodeのNFKC正規化)から保存するようにしています。
どちらの修正にも、同じ状況を再現するテストを追加しました。本番のDBには、4銘柄の登録と20銘柄の業種の補完を直接適用済みです。
よくある質問
Q. AIが見送り続けるのは、AIの判断が間違っているからではないのですか? A. 記録を確認した範囲では、判断は妥当でした。見送りの理由はROEやEPSがマイナス、PERが同業他社より割高といった財務上のもので、筋の通ったものでした。問題はAIではなく、買える候補の数が少なすぎたことだと判断しています。
Q. HOLD(保有継続)が続いている銘柄も入れ替えの対象になりますか? A. なりません。対象は、判断がすべてSKIP(見送り)だった銘柄だけです。保有中の銘柄に対するHOLDや、一度でもBUYの判断が出た銘柄は対象外です。
Q. 一度外した銘柄が、また候補として戻ってくることはありますか? A. 戻ってきません。外した銘柄はDBに除外済みとして残し、次回以降のスクリーニング対象から外しています。自動化する前に手作業で外した4銘柄が当初この記録に含まれていなかったため、2026年9月28日に除外済みとして登録し直しました。
まとめ
AIが一度も買わない銘柄がウォッチリストに残り続けるのは、AIの誤りではなく、候補の選び方の問題でした。まず手作業で入れ替えてみて判断基準を固め、その手順をそのまま週次のジョブにしました。見送り続けているかの判定には「判断の回数の下限」を、代わりの選び方には「今の株価で買えるか」と「業種の分散」を入れています。
自動の入れ替えが実際に動いたら、どの銘柄がどう入れ替わったかも、このブログで報告します。
免責事項
この記事は投資助言ではありません。特定の銘柄や取引手法を推奨するものではなく、AIに実際のお金を運用させる実験の実装内容を記録したものです。記事中の銘柄名は、実験のウォッチリストの説明のために挙げたもので、売買を推奨するものではありません。投資は自己責任で行ってください。
AI日本株トレード実験