AIツクリラボ
(更新: )・AI日本株自動売買実験・11分で読めます・🔵 実装記録

APScheduler + Windowsタスクスケジューラで完全無人の売買運用を組んだ

この記事で分かること
APScheduler(常駐プロセス内のCronTrigger)とWindowsタスクスケジューラ(ログオン時の起動・再起動監視)を役割分担させ、1日4種類のジョブを完全無人で回す仕組み
実際に使った環境
Python / APScheduler(BlockingScheduler) / Windowsタスクスケジューラ。AI日本株自動売買実験(`trading/`サブプロジェクト)の本番実装
筆者の結論
スケジューリングのロジックより、実機で2回スケジューラプロセスごと落ちる障害に遭遇し、そのたびに「1回の失敗が全体を止めない」設計に直していった過程が実装の大半でした

AI日本株自動売買の実験は、「無人でスケジュール運用すること」を絶対条件にしています。市場データ収集・実発注・記事とYouTube Shorts動画の生成という性質の異なる複数のジョブを、決まった時刻に、人の手を挟まず、失敗しても止まらずに回し続ける仕組みが必要でした。

🔵 実装してみて思うこと

スケジューリングのコード自体はAPScheduler(Pythonの定番ライブラリ)にCronトリガーを登録するだけで、思ったより単純でした。実際に大変だったのは実機で動かしてから起きた2つの障害への対処で、「無人運用が絶対」という条件は、平常時のロジックよりも異常時にどう振る舞うかの設計に重心を置くべきだと痛感しました。

1日4種類のジョブ構成

自宅PCで常時稼働させる前提の常駐プロセス(scripts/run_scheduler.py)に、次の4つのジョブをCronTriggerで登録しています。

scheduler.add_job(run_market_data_collection_job, trigger=CronTrigger(hour=18, minute=0, timezone="Asia/Tokyo"))
scheduler.add_job(run_live_trading_job, trigger=CronTrigger(hour="9,10,11,13,14", minute=0, timezone="Asia/Tokyo"))
scheduler.add_job(run_live_trading_job, trigger=CronTrigger(hour=14, minute=55, timezone="Asia/Tokyo"))
scheduler.add_job(run_eod_content_job, trigger=CronTrigger(hour=15, minute=30, timezone="Asia/Tokyo"))
  • 市場データ収集(18:00): 翌営業日のスクリーニングに使うデータを取得
  • 実発注(9・10・11・13・14時 + 14:55): 東証の立会時間帯に1時間おき。前場・後場の昼休み(11:30〜12:30)は除外し、大引け(15:00)直前の値動きも拾えるよう14:55にも追加している
  • 記事・動画生成(15:30): 引け後に日次記事の生成・commit、YouTube Shorts動画の生成・アップロードをまとめて実行

時系列で並べると、次のようになります。

東証の立会時間帯(9:00〜15:00、昼休み12:30除く)に沿って、実発注ジョブ(1時間おき+14:55)、15:30の記事・動画生成、18:00の市場データ収集を配置したタイムライン図
1日4種類のジョブの実行タイミング(平日・JST)

実発注を1時間おきに複数回動かしているのは、同じ営業日に複数回実行しても大丈夫な設計になっているためです。取引の冪等キー(日付・銘柄・売買の組み合わせ)により同一銘柄・同一サイドの重複発注は起きない仕組みにしているので、実行回数を増やすほど「まだ判断していない銘柄を拾う」「保有銘柄のSELL判断を日中に反映する」機会が増えるだけで、副作用がありません。

Windowsタスクスケジューラとの役割分担

スケジューリングそのものはAPScheduler(プロセス内で完結するPythonライブラリ)に任せていますが、そのプロセス自体をいつ起動するかはWindowsタスクスケジューラに任せています。

$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$scriptPath`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "AITradingScheduler" -Action $action -Trigger $trigger -Settings $settings -Force

役割分担は明確で、「いつジョブを実行するか」はAPScheduler、「プロセスが落ちたらどうするか」はWindowsタスクスケジューラが担当します。RestartCount 999のようにほぼ無制限の自動再起動を設定しているのは、PCの再起動やプロセスクラッシュがあっても、ログオンさえしていれば運用が自動的に復帰するようにするためです。

実機で遭遇した障害1: PowerShellがログ出力だけでプロセスを落とす

タスクスケジューラから起動する場合、コンソールが無いため、Pythonプロセスの標準出力をラッパー用のPowerShellスクリプト(run_scheduler_service.ps1)経由でログファイルにリダイレクトしています。

& $python 'scripts\run_scheduler.py' *>> $log

このラッパーに当初$ErrorActionPreference = 'Stop'を設定していたところ、起動直後の正常なINFOログが出力されただけでスクリプトが中断するという不可解な障害に遭遇しました。原因は、Pythonのloggingモジュールが既定でstderrに出力する仕様と、PowerShell 5.1がネイティブプロセスのstderr出力を(実際には成功していても)エラーレコードとして扱ってしまう挙動が組み合わさっていたことでした。$ErrorActionPreference = 'Stop'と組み合わさると、正常なログ出力だけでスクリプト全体が停止扱いになってしまいます。

対処は2段構えにしました。Python側のlogging.basicConfigで出力先を明示的にstdoutへ変更し、念のためラッパー側の$ErrorActionPreferenceも'Stop'にしないようにしています。

実機で遭遇した障害2: SQLiteのロックでスケジューラごと落ちた

もう1つの実障害は、起動直後に即座に実行している市場データ収集の中で発生しました。収集結果をSQLiteに保存する処理が、ある日突然database is lockedエラーで失敗し、その例外が握りつぶされずスケジューラプロセスそのものが落ちるという障害でした。実際にscheduler.logに残っていたエラーはこちらです。

sqlite3.OperationalError: database is locked

The above exception was the direct cause of the following exception:
...
sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) database is locked
[SQL: INSERT INTO system_events (occurred_at, level, category, message, detail) VALUES (?, ?, ?, ?, ?)]
[parameters: ('2026-09-11 00:25:03.178787', 'INFO', 'market_data_collection', '収集成功: 取得120件、新規保存0件', '{...}')]

原因は単純なコードレビューの見落としで、収集処理本体は例外を握りつぶす設計になっていたものの、結果をDBに保存する部分だけがtry/exceptで囲まれていなかったことでした。

try:
    with session_scope() as session:
        new_count = persist_new_market_snapshots(session, snapshots)
        session.add(SystemEvent(level="INFO", category="market_data_collection", ...))
except Exception as exc:
    logger.exception("failed to persist market data collection results")
    _record_event("ERROR", f"収集結果の保存に失敗: {exc}", symbols, from_date, to_date)
    return

保存処理もtry/exceptで囲み、さらに念のためrun_scheduler.py側の起動直後の呼び出し自体もtry/exceptで二重に保護しました。「1回の障害でスケジューラ全体を落とさない」という方針を、ジョブ関数の中だけでなく呼び出し側にも徹底したことで、以後は同様の障害が起きても他のジョブへの影響を防げるようになっています。

POINT

「無人で動くプロセス」を作るときは、ロジックが正しく動く場合だけでなく、「想定外の例外がどこで起きても、プロセス全体を道連れにしない」という設計を、末端の処理1つ1つにまで徹底することが重要だと、2つの実障害を通じて学びました。

よくある質問

Q. PCがスリープしていた場合、スケジュールされたジョブはどうなりますか? A. 各ジョブにmisfire_grace_time(実行予定時刻を過ぎても実行を許容する猶予時間)を設定しています。市場データ収集や記事生成は1時間まで、実発注ジョブは市場が動く前提の判断のため10分・大引け直前のジョブは3分までと、ジョブの性質によって許容範囲を変えています。猶予を過ぎるとその回の実行はスキップされます。

Q. スケジューラが異常終了した場合、誰かに気づく手段はありますか? A. Windowsタスクスケジューラ側に「失敗したら自動再起動する」設定を入れているため、プロセスが落ちても自動的に復帰します。ただし復帰したこと自体の通知は今のところ無く、Discord通知の実装記事で触れている売買結果の通知が来なくなることで間接的に気づく状態です。

まとめ

無人スケジュール運用の実装で一番時間を使ったのは、スケジューリングのロジックそのものではなく、実機で実際に起きた障害への対処でした。どちらも「例外を握りつぶす設計のはずが、ある一箇所だけ抜けていた」という共通点があり、無人運用を謳うなら、例外処理の網羅性を定期的に見直す必要があると感じています。ジョブの中身(実発注のリスク管理・記事生成)については売買ロジックの解説記事、Discord通知の実装記事もあわせてご覧ください。

この記事を書いた人

ラボ管理人

AIツール・ノーコード・Next.jsで実際に手を動かしながら、AIツクリラボを運営しています。

運営者情報を見る →