「毎朝7時」に設定した定期タスクが、日曜日の午後に動き出したことはありますか。
日本株のAI銘柄スクリーニング&継続学習端末「ALPHA FORGE(アルファフォージ)」を個人で開発・運用しています(開発には Claude Code を使っています)。定期実行には Celery beat を使っていますが、しばらく止めていた beat を再起動したところ、休場日の日曜日に銘柄抽出タスクが動き出しました。この記事では、その原因と、平日判定・祝日判定・同日冪等ガードの3段のガードをテストで固定した方法をまとめます。
【要約】
- Celery beat は、止めていた間に実行予定を過ぎたエントリを、再起動した直後にまとめて実行することがある(追いつき実行)
- 対策は3段。同じ日の二重実行を防ぐ冪等ガード、土日判定、
jpholidayによる祝日判定。どれも beat から呼ばれるタスクの側に置く - 再発防止は「気をつける」ではなく、実際に事故が起きた日付を固定したテストで担保する
※本記事はnote連載[ALPHA FORGE 開発秘話 #06] の技術的な部分を、エンジニア向けに再構成したものです。
環境
| 項目 | 内容 |
|---|---|
| OS | Windows 11 |
| 開発 | Claude Code(CLI) |
| バックエンド | Python 3.11 / FastAPI / Celery(beat + --pool=solo ワーカー)+ Redis |
| 祝日判定 | jpholiday |
何が起きたか
時系列は次のとおりです。
- 9/14:Celery beat を初めて起動したところ、溜まっていたスケジュールが一斉に動き、銘柄抽出の二重実行と重い学習処理の同時実行が起きた。原因を詰める前に、beat を自動起動の対象から外した
- 9/20(日曜):修正が落ち着いたので beat を再起動。朝の銘柄抽出タスクが「まだ今日の分をやっていない」とみなされて即座に実行され、金曜の終値だけを材料にした無効な予測が作られた
- 同日:進み具合を見ていた私が「今日はマーケット開いていないです」と気づき、さらに「次の市場が開くのは24日ですね」と、翌日からの3連休(敬老の日・国民の休日・秋分の日)も休場だと指摘した
東証は土日と祝日は開いていません。休場日に作った予測は、答え合わせの台帳を汚すだけです。
なぜ日曜日に動いたのか
Celery beat(既定の PersistentScheduler)は、各エントリの最終実行時刻をファイルに保存しています。止めていた間に予定時刻を過ぎたエントリは、再起動した時点で「実行予定を過ぎている」と判定され、起動直後に実行されます。
crontab で「平日の朝7時」と書いていても、それは「いつ予定に入るか」の指定にすぎません。止まっていた間の分を再起動時に取り返す動きは別物で、タスク側から見ると、実行される日時は保証されません。
beat のスケジュールだけで「休場日に動かない」「1日1回だけ動く」を保証しようとしないでください。停止・再起動・二重起動のどれでも崩れます。守りたい条件は、タスクの中で判定するのが確実です。
対策:タスク側に3段のガードを置く
銘柄抽出タスクの先頭で、次の3つを判定しています(抜粋)。
def _is_weekday_jst() -> bool:
return datetime.now(JST).weekday() < 5 # 5=土, 6=日
def _is_trading_day_jst() -> bool:
# 国民の休日・振替休日・秋分の日なども計算で判定できる(ネットワーク不要)
return _is_weekday_jst() and not jpholiday.is_holiday(datetime.now(JST).date())
@celery_app.task(name="backend.tasks.run_picks_task")
def run_picks_task(horizon_type: str) -> dict[str, object]:
if not _is_trading_day_jst():
return {"status": "skipped_non_trading_day", "picks": 0, "rejected": 0}
async def _run() -> dict[str, object]:
today = datetime.now(JST).date().isoformat()
already_issued = await pl.list_picks(horizon_type=horizon_type, issued_from=f"{today}T00:00:00", limit=1)
if already_issued:
return {"status": "skipped_already_issued_today", "picks": 0, "rejected": 0}
result = await run_picks(horizon_type)
return {"status": result.status, "picks": len(result.picks), "rejected": len(result.rejected)}
return asyncio.run(_run())
ポイントは次の3つです。
- 同日冪等ガード:その日の分がすでに台帳にあれば何もしない。追いつき実行と通常の実行が重なっても二重に作らない
-
平日判定+祝日判定:
weekday()だけでは祝日を検出できない。実際、土日判定を入れた時点では3連休のことが抜けていて、人間の「24日ですね」の一言でjpholidayを追加した - ガードは beat から呼ばれるタスクの側にだけ置く:人間が API から手動で実行する経路(動作確認用)は、休場日でも毎回実行できるように、意図的にガードの外に置いている
「自動で勝手に動く」経路と「人間が意図して動かす」経路を分けておくと、休場日の動作確認のような正当な手動実行まで止めずに済みます。
テストで固定する
「気をつける」では再発を防げないので、実際に事故が起きた日付を固定したテストにしています(抜粋)。
def _freeze(monkeypatch: pytest.MonkeyPatch, iso: str) -> None:
frozen = real_datetime.fromisoformat(iso)
fake = type("_Frozen", (_FrozenDatetime,), {"_frozen": frozen})
monkeypatch.setattr(tasks, "datetime", fake)
def test_run_picks_task_skips_on_weekend_without_touching_ledger(monkeypatch: pytest.MonkeyPatch) -> None:
_freeze(monkeypatch, "2026-09-20T07:30:00+09:00") # 日曜(実際に誤発火した日)
run_calls = _wire(monkeypatch, existing_today=False)
result = tasks.run_picks_task("short_term")
assert run_calls == []
assert result == {"status": "skipped_non_trading_day", "picks": 0, "rejected": 0}
def test_run_picks_task_skips_on_national_holiday_weekday(monkeypatch: pytest.MonkeyPatch) -> None:
_freeze(monkeypatch, "2026-09-21T07:30:00+09:00") # 月曜・敬老の日
run_calls = _wire(monkeypatch, existing_today=False)
result = tasks.run_picks_task("mid_term")
assert run_calls == []
assert result == {"status": "skipped_non_trading_day", "picks": 0, "rejected": 0}
同じファイルに「同日分があれば実行しない」「平日で同日分がなければ実行する」のテストもあり、4通りで挙動を固定しています。datetime はモジュールの名前空間ごと差し替えているので、タスク内の datetime.now(JST) が固定した時刻を返します。
jpholiday は国民の祝日を判定するライブラリで、東証の年末年始の休業日(12/31〜1/3)は祝日に含まれません。年末年始も止めたい場合は、別途判定を足す必要があります(このプロジェクトでも未対応として記録しています)。
止めたルールを元に戻す
9/14 に決めた「beat は自動起動しない」というルールは、AI(Claude Code)への作業ルールとして CLAUDE.md に書いていました。9/22 に「この方針は今も必要か」を AI に見直させたところ、当時の根本原因(休場日判定がない・同じ日の二重実行を防げない)がコードで解消され、テストで固定されていることを確認して報告してきました。そこで人間が判断し、「起動してよい」に戻しています。
一時的な禁止ルールには、理由を一緒に書いておくことをおすすめします。理由があれば、「その理由はもう解消したか」を確かめるだけで、安心して元に戻せます。
まとめ
- Celery beat は再起動時に、過ぎた予定を取り返すように実行することがある。スケジュールだけで実行日を保証しない
- 守りたい条件(営業日のみ・1日1回)は、タスクの中で判定する。祝日は
weekday()では検出できない - 手動実行の経路はガードの外に置き、自動実行だけを守る
- 再発防止は、事故が起きた日付を固定したテストで担保する
休場日に気づいたのは人間、ガードとテストを実装したのは AI、運用に戻す判断をしたのは人間、という分担でした。
次回は、ニュースが全銘柄で0件になっていた「エラーの出ない失敗」の話を書きます。
☕ note.comで開発秘話と毎朝の検証ログを連載中
note.comでは、ALPHA FORGE の開発秘話と、毎朝のアルゴリズム検証ログを公開しています。
👉 元記事(note連載第6話):
【ALPHA FORGE 開発秘話 #06】日曜日に勝手に働き出したAI ― 休場日ガード顛末記
免責事項
本記事は個人開発およびAIエージェントを活用したシステム開発の技術記録です。特定の銘柄の売買推奨や投資助言を行うものではありません。本ツールは証券会社等への発注機能を持ちません。記事中のコードは抜粋であり、パスや環境固有の値は省略しています。
⚠️ 【システム検証記録に関する免責事項・注意事項】
- 本記事は、独自開発アルゴリズム「ALPHA FORGE」の動作検証およびデータ分析過程を公開する技術・運用の個人的な記録ログです。
- 掲載されているすべてのデータ(各スコア、基準観測値、統計的変動上限、シナリオ無効化水準など)は、過去の市場データに基づき数式(ATR等)により機械的に算出されたバックテスト・シミュレーション用のパラメータであり、特定の有価証券の売買勧誘、取引の推奨、投資助言・代理行為を目的としたものではありません。
- また、将来の株価変動や運用成果を保証するものではありません。実際の投資判断および最終決定は、必ずご自身の責任と判断において行っていただけますようお願いいたします。