Consolidate Python ignore rules into root gitignore

This commit is contained in:
Hiroaki86
2026-05-27 23:01:28 +09:00
commit fa3394415d
399 changed files with 509103 additions and 0 deletions
@@ -0,0 +1,197 @@
---
name: fastapi-api
description: FastAPIでAPIRouter、Pydanticスキーマ、Service、Repository、Depends、SQLAlchemy、APIテストを分離して実装・修正するときに使用する。
---
# FastAPI API Skill
## When to use
- FastAPIのエンドポイントを新規作成するとき
- 既存APIを修正するとき
- API層、Service層、Repository層を分離するとき
- SQLAlchemyを使用してDBアクセスを実装するとき
- ORMだけでは複雑になる処理を `sqlalchemy.text()` またはSQLファイルで実装するとき
- Pydanticのリクエスト・レスポンススキーマを作成するとき
- FastAPIの依存関係を `Depends` で整理するとき
- FastAPIのAPIテストを追加するとき
## 基本方針
- FastAPIはバックエンドAPIとして使用する。
- API層、Service層、Repository層を分離する。
- エンドポイント関数は薄く保つ。
- エンドポイント関数にDBアクセス、ファイルI/O、外部システム連携、重い処理を直接書かない。
- FastAPIでDBアクセスを行う場合は、原則としてSQLAlchemyを使用する。
- 単純なCRUDはSQLAlchemy ORMを優先する。
- 複雑なJOIN、集計、ウィンドウ関数、帳票用SQL、大量データ取得、N+1問題の回避でORM記述が複雑になる場合は、`sqlalchemy.text()` または `.sql` ファイルのSQLを使用する。
- 既存の設計、命名、ディレクトリ構成を優先する。
- 新規ファイルを作る場合は、既存の類似機能と同じ配置・命名に合わせる。
## Standard files
FastAPIの新規APIを追加する場合は、原則として以下のファイルを作成・修正する。
```text
src/app/api/routers/<feature>.py
src/app/schemas/<feature>.py
src/app/services/<feature>_service.py
src/app/repositories/<feature>_repository.py
src/app/api/dependencies.py
tests/api/test_<feature>.py
```
既存の構成がある場合は、新規構成を勝手に作らず、既存構成を優先する。
## Router
- `APIRouter` を使用する。
- `prefix``tags` を適切に設定する。
- ルーターは機能単位で分割する。
- エンドポイント関数では以下のみを行う。
- リクエストデータの受け取り
- `Depends` による依存関係の受け取り
- Serviceの呼び出し
- レスポンスの返却
- DB処理はRepository層に書く。
- 業務ロジックはService層に書く。
## Request / Response
- リクエストボディはPydanticモデルで定義する。
- レスポンスはPydanticモデルで定義する。
- 可能な限り `response_model` を指定する。
- DBモデル、DB行、内部オブジェクトをそのままAPIレスポンスとして返さない。
- APIレスポンスには、外部に公開してよい項目だけを含める。
- Pydantic v2を前提にする場合は、ORMオブジェクト変換に `ConfigDict(from_attributes=True)``model_validate()` を使用する。
## Service
- 業務ロジックはService層に書く。
- ServiceはRepositoryを呼び出して必要なデータを取得・保存する。
- ServiceはFastAPI固有の `Request``Response` に依存しすぎない。
- Service内で例外を握りつぶさない。
- 必要に応じて独自例外に変換して呼び出し元へ伝える。
- トランザクション境界をService層で管理する場合は、成功時commit、失敗時rollbackを明確にする。
## Repository
- DBアクセスはRepository層に書く。
- FastAPIでDBアクセスを行う場合は、原則としてSQLAlchemyを使用する。
- RepositoryはSQLAlchemyの `Session` またはDB接続を受け取る。
- Router層から直接SQLAlchemyのクエリを組み立てない。
- 単純なCRUD、主キー検索、単純な条件検索はSQLAlchemy ORMを優先する。
- SQL Server用RepositoryとOracleDB用Repositoryを分離する。
- OracleDBは参照専用とし、原則 `SELECT` のみ実行する。
- OracleDBに対して `INSERT` / `UPDATE` / `DELETE` / `MERGE` / `CREATE` / `ALTER` / `DROP` は実行しない。
- SQL Server用SQLとOracleDB用SQLを混在させない。
- 認証情報、接続文字列、ユーザー名、パスワードをコードに直書きしない。
## SQLAlchemy / SQL
- SQLAlchemyは可能な限り2.x系の記述スタイルを優先する。
- ORMモデルはDBテーブル構造を表すものとして扱い、APIレスポンスにはPydanticモデルを使用する。
- ORMのリレーションを使用する場合は、N+1問題に注意する。
- N+1問題が発生する可能性がある場合は、`selectinload()``joinedload()`、明示的な `join()`、またはSQLを使用する。
- 複雑なJOIN、集計、ウィンドウ関数、帳票用SQL、大量データ取得、性能要件が強い処理は、無理にORMだけで書かない。
- ORMで記述すると可読性や性能が悪くなる場合は、`sqlalchemy.text()` を使用してSQLを明示的に記述する。
- 長いSQL、再利用するSQL、DBごとに差があるSQLは `.sql` ファイルに分離する。
- `text()` を使用する場合も、ユーザー入力値をSQL文字列へ直接埋め込まない。
- `text()` では必ずバインドパラメータを使用する。
- 文字列連結でSQLを組み立てない。
- RepositoryはDBアクセス結果をServiceが扱いやすい形で返す。
- レスポンス生成時に遅延ロードが発生しないよう、Repository層で必要なデータを取得しきる。
## Dependency Injection
- FastAPIの `Depends` を使用する。
- Settings、SQLAlchemy `Session`、DB接続、Repository、Service は依存関係として注入できる形にする。
- SQLAlchemy `Session` はリクエスト単位で生成・終了する。
- Repository生成時にSQLAlchemy `Session` を渡し、Repository内部でグローバルなSessionを直接参照しない。
- テストで差し替えやすいように、依存関係は関数化する。
## Error Handling
- 入力不正は `HTTPException` で適切なHTTPステータスコードを返す。
- リソースが存在しない場合は `404` を返す。
- 内部例外の詳細をAPIレスポンスにそのまま返さない。
- 内部エラーは `logging` で記録する。
- クライアント向けレスポンスと内部ログを分離する。
- 必要に応じて独自例外をService層で発生させ、Router層でHTTPレスポンスへ変換する。
## Async / Sync
- `async def``def` は処理内容に応じて使い分ける。
- 同期DBドライバや同期SQLAlchemy Sessionを使う場合は、無理に `async def` にしない。
- ブロッキングI/Oを `async def` の中で直接実行しない。
- 長時間処理はAPIリクエスト内で完結させず、WorkerやJob Queueへの分離を検討する。
## Testing
- FastAPIのAPIテストでは `TestClient` を使用する。
- DB、外部API、ファイルI/Oはmockまたはdependency overrideで差し替える。
- Repositoryのテストでは、ORMで取得するケースと `text()` で取得するケースを必要に応じて分ける。
- 正常系、異常系、バリデーションエラー、権限エラーをテストする。
- dependency override を使用した場合は、テスト後に `app.dependency_overrides.clear()` を実行する。
## Output checklist
FastAPI APIを作成・修正する場合は、必要に応じて以下をセットで検討する。
1. Router
2. Schema
3. Service
4. Repository
5. SQLAlchemy Session / Dependency
6. ORMモデルまたはSQLファイル
7. Test
8. 実行・確認コマンド
## Example
### ORMを使用するRepository
```python
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.job import Job
class JobRepository:
def __init__(self, session: Session) -> None:
self._session = session
def fetch_job(self, job_id: int) -> Job | None:
stmt = select(Job).where(Job.job_id == job_id)
return self._session.scalars(stmt).first()
```
### text() を使用するRepository
```python
from sqlalchemy import text
from sqlalchemy.orm import Session
class JobRepository:
def __init__(self, session: Session) -> None:
self._session = session
def fetch_job_summary(self, job_id: int) -> dict[str, object] | None:
stmt = text("""
SELECT
J.JOB_ID,
J.STATUS,
COUNT(S.SLOT_ID) AS SLOT_COUNT
FROM dbo.SAW6_JOB_QUEUE AS J
LEFT JOIN dbo.SAW6_JOB_QUEUE_SLOT AS S
ON S.JOB_ID = J.JOB_ID
WHERE J.JOB_ID = :job_id
GROUP BY
J.JOB_ID,
J.STATUS
""")
row = self._session.execute(stmt, {"job_id": job_id}).mappings().first()
return dict(row) if row is not None else None
```
@@ -0,0 +1,254 @@
---
name: oracle-select
description: Oracle Database から SELECT 文でデータを取得する処理、SQL作成、Python連携コードを作成・修正するときに使用する。Oracleは参照専用とし、INSERT、UPDATE、DELETE、MERGE、DDLは扱わない。
---
# Oracle SELECT Skill
## When to use
- Oracle Database から `SELECT` でデータを取得するとき
- Oracle用の参照SQLを作成・修正するとき
- Python から Oracle Database に接続して読み取り処理を実装するとき
- Oracle のテーブル・ビューから取得したデータを pandas DataFrame に変換するとき
- SQL Server や他DBではなく、Oracle固有のSQL構文に合わせる必要があるとき
## Scope
このSkillでは Oracle Database に対する読み取り専用処理のみを扱う。
許可する操作:
- `SELECT`
- `WITH`
- `JOIN`
- `WHERE`
- `GROUP BY`
- `HAVING`
- `ORDER BY`
- `FETCH FIRST n ROWS ONLY`
- Pythonからの読み取り処理
- pandas DataFrame への変換
扱わない操作:
- `INSERT`
- `UPDATE`
- `DELETE`
- `MERGE`
- `CREATE`
- `ALTER`
- `DROP`
- `TRUNCATE`
- `GRANT`
- `COMMIT`
- `ROLLBACK`
## Rules
- Oracle Database 用の `SELECT` 文として作成する。
- 参照専用を前提にし、データ更新系SQLは作成しない。
- 認証情報、接続文字列、パスワードをコードに直書きしない。
- 接続情報は `.env`、環境変数、Settings クラスなどから取得する。
- SQLには必要な列を明示し、原則 `SELECT *` は避ける。
- 条件値は文字列連結せず、バインド変数を使用する。
- Pythonで実行する場合は `oracledb` の使用を基本にする。
- pandasで取得する場合は `pd.read_sql_query()` または `pd.read_sql()` を使用する。
- 大量データ取得が想定される場合は、取得期間、キー条件、件数制限を検討する。
- 日付条件では `TO_DATE()``TRUNC()`、バインド変数の型に注意する。
- Oracleの文字列結合は `||` を使用する。
- SQL Server 固有構文は使用しない。
- `TOP`
- `GETDATE()`
- `ISNULL()`
- `LEN()`
- `DATEADD()`
- `DATEDIFF()`
- `[]` による識別子囲み
- 件数制限は Oracle 12c 以降では `FETCH FIRST n ROWS ONLY` を優先する。
- 古いOracle互換が必要な場合のみ `ROWNUM` を使用する。
- テーブル名・カラム名は既存DBの定義に合わせる。
- 別名を付ける場合は読みやすい名前にする。
- SQLは保守しやすいように整形する。
- エラー時はユーザー向けメッセージと詳細ログを分ける。
## SQL Style
### 基本
```sql
SELECT
T.TOOL_ID,
T.PROCESS_DATE,
T.STATUS_CODE,
T.CREATED_AT
FROM
SAMPLE_TABLE T
WHERE
T.TOOL_ID = :tool_id
AND T.PROCESS_DATE >= :start_date
AND T.PROCESS_DATE < :end_date
ORDER BY
T.PROCESS_DATE DESC
```
### 件数制限
```sql
SELECT
T.TOOL_ID,
T.PROCESS_DATE,
T.STATUS_CODE
FROM
SAMPLE_TABLE T
WHERE
T.TOOL_ID = :tool_id
ORDER BY
T.PROCESS_DATE DESC
FETCH FIRST 100 ROWS ONLY
```
### WITH句
```sql
WITH TARGET_DATA AS (
SELECT
T.TOOL_ID,
T.PROCESS_DATE,
T.STATUS_CODE
FROM
SAMPLE_TABLE T
WHERE
T.PROCESS_DATE >= :start_date
AND T.PROCESS_DATE < :end_date
)
SELECT
TOOL_ID,
STATUS_CODE,
COUNT(*) AS CNT
FROM
TARGET_DATA
GROUP BY
TOOL_ID,
STATUS_CODE
ORDER BY
TOOL_ID,
STATUS_CODE
```
## Python Rules
- Oracle接続は `oracledb` を基本にする。
- 接続情報は環境変数やSettingsから取得する。
- SQLとパラメータは分離する。
- SQL文字列へユーザー入力を直接埋め込まない。
- DataFrame取得時は `params` を使用する。
- 接続とカーソルは `with` で管理する。
- SELECT専用の処理として実装し、更新処理は追加しない。
## Python Example
```python
from __future__ import annotations
import os
from datetime import datetime
import oracledb
import pandas as pd
def get_oracle_connection() -> oracledb.Connection:
user = os.environ["ORACLE_USER"]
password = os.environ["ORACLE_PASSWORD"]
dsn = os.environ["ORACLE_DSN"]
return oracledb.connect(
user=user,
password=password,
dsn=dsn,
)
def fetch_sample_data(
tool_id: str,
start_date: datetime,
end_date: datetime,
) -> pd.DataFrame:
sql = '''
SELECT
T.TOOL_ID,
T.PROCESS_DATE,
T.STATUS_CODE,
T.CREATED_AT
FROM
SAMPLE_TABLE T
WHERE
T.TOOL_ID = :tool_id
AND T.PROCESS_DATE >= :start_date
AND T.PROCESS_DATE < :end_date
ORDER BY
T.PROCESS_DATE DESC
'''
params = {
"tool_id": tool_id,
"start_date": start_date,
"end_date": end_date,
}
with get_oracle_connection() as conn:
return pd.read_sql_query(sql, conn, params=params)
```
## Output Format
1. 目的
2. Oracle SELECT SQL
3. Pythonから実行する場合のコード
4. バインド変数の説明
5. 注意点
## Example Output
### 目的
指定した装置IDと処理期間に一致するOracle上の測定結果を取得する。
### Oracle SELECT SQL
```sql
SELECT
T.TOOL_ID,
T.PROCESS_DATE,
T.STATUS_CODE,
T.RESULT_VALUE
FROM
MEASURE_RESULT T
WHERE
T.TOOL_ID = :tool_id
AND T.PROCESS_DATE >= :start_date
AND T.PROCESS_DATE < :end_date
ORDER BY
T.PROCESS_DATE DESC
```
### Python Code
```python
params = {
"tool_id": tool_id,
"start_date": start_date,
"end_date": end_date,
}
with get_oracle_connection() as conn:
df = pd.read_sql_query(sql, conn, params=params)
```
### 注意点
- `:tool_id``:start_date``:end_date` はバインド変数として渡す。
- `SELECT *` は避け、必要な列だけ取得する。
- 大量データになる場合は、期間条件や件数制限を追加する。
- このSkillでは更新系SQLは作成しない。
@@ -0,0 +1,47 @@
---
name: python-base
description: Python 3.13プロジェクトで基本的なコーディング、型ヒント、pathlib、例外処理、関数分割を行うときに使用する。
---
# Python Base Skill
## When to use
- Pythonコードを新規作成するとき
- 既存コードをリファクタリングするとき
- 関数分割、型ヒント追加、pathlib化を行うとき
- UI、DB、I/O、ビジネスロジックを分離するとき
## Rules
- Python 3.13 を前提にする。
- すべての関数に引数と戻り値の型ヒントを付ける。
- 戻り値がない関数には `-> None` を付ける。
- `Any` は必要最小限にする。
- `os.path` ではなく `pathlib.Path` を使用する。
- `print` ではなく `logging` を使用する。
- magic number は定数化する。
- 例外は握りつぶさない。
- UI、DB、I/O、ビジネスロジックを分離する。
- 副作用がある関数は、関数名から意図が分かるようにする。
## Example
```python
from __future__ import annotations
from pathlib import Path
def load_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
```
## Checklist
- [ ] 型ヒントがある
- [ ] 戻り値型がある
- [ ] `Path` を使用している
- [ ] `print` を使用していない
- [ ] 例外を握りつぶしていない
- [ ] 1関数1責務になっている
@@ -0,0 +1,62 @@
---
name: python-logging
description: Pythonプロジェクトでlogging設定、TOML設定、logger分離、例外ログを実装・修正するときに使用する。
---
# Python Logging Skill
## When to use
- logging設定を新規作成するとき
- TOMLからlogging設定を読み込むとき
- `print` を logging に置き換えるとき
- SQL、アプリ、Workerなどloggerを分離するとき
- 例外ログの出力を修正するとき
## Rules
- `print` は使用しない。
- `logging` を使用する。
- logging設定は TOML で管理する。
- logger は用途別に分離する。
- SQL、アプリ、バッチ、Worker、外部I/Oは必要に応じて別loggerにする。
- 例外発生時は `logger.exception(...)` を優先する。
- ユーザー向けメッセージと内部ログを分離する。
## Example
```python
from __future__ import annotations
import logging
import logging.config
import tomllib
from pathlib import Path
def setup_logging(toml_path: Path) -> None:
with toml_path.open("rb") as f:
config = tomllib.load(f)
logging.config.dictConfig(config)
logger = logging.getLogger(__name__)
```
## Exception Example
```python
try:
run_task()
except Exception:
logger.exception("Task failed")
raise
```
## Checklist
- [ ] `print` を使用していない
- [ ] `logger = logging.getLogger(__name__)` を使っている
- [ ] 例外時に `logger.exception` を使っている
- [ ] ユーザー表示と内部ログが分離されている
@@ -0,0 +1,59 @@
---
name: python-settings
description: Pythonプロジェクトでpydantic-settingsを使った環境変数、.env.dev、設定クラスを実装・修正するときに使用する。
---
# Python Settings Skill
## When to use
- `settings.py` を新規作成するとき
- pydantic-settings で設定クラスを作るとき
- `.env.dev` とOS環境変数の切り替えを実装するとき
- DB接続情報、ログ出力先、共有フォルダなどを設定化するとき
## Rules
- 設定は `pydantic_settings.BaseSettings` で一元管理する。
- 開発環境は `.env.dev` を使用する。
- テスト環境と本番環境はOS環境変数を使用する。
- パス系の設定値は `pathlib.Path` で扱う。
- DB接続情報、APIキー、パスワードをコードに直書きしない。
- Settingsクラスはアプリ起動時に1回読み込む。
- 必須設定が不足した場合は、明確なValidationErrorとして扱う。
## Example
```python
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_",
env_file=".env.dev",
env_file_encoding="utf-8",
)
db_server: str
db_database: str
db_username: str | None = None
db_password: str | None = None
log_root: Path
output_root: Path
def get_settings() -> Settings:
return Settings()
```
## Checklist
- [ ] 秘密情報をコードに直書きしていない
- [ ] 環境変数prefixが統一されている
- [ ] パス系設定に `Path` を使っている
- [ ] 必須設定の不足を隠していない
@@ -0,0 +1,74 @@
---
name: python-testing
description: Pythonプロジェクトでpytestの単体テスト、例外テスト、fixture、parametrize、integration testを作成・修正するときに使用する。
---
# Python Testing Skill
## When to use
- pytest のテストを新規作成するとき
- 既存ロジックのテストを追加するとき
- リファクタリング前に仕様を固定するとき
- 例外・境界値・異常系をテストするとき
- DB / I/O を含む処理をmockまたはintegration testに分離するとき
## Purpose
- 仕様をコードとして固定する。
- 変更によるデグレードを防ぐ。
- ロジックを安全にリファクタリングできる状態を作る。
## Rules
- AAA パターンを使用する。
- 1テスト1Actにする。
- テスト名は `test_<対象>_<条件>_<期待結果>` にする。
- fixture は前提条件の名前にする。
- fixture には assert を書かない。
- 分岐パターンは `pytest.mark.parametrize` で表現する。
- 例外は型とメッセージの両方を確認する。
- DB、I/O、現在時刻、外部APIはUnit Testではmockする。
- Integration Testには `@pytest.mark.integration` を付ける。
## Example
```python
import pytest
@pytest.mark.parametrize(
("input_value", "expected"),
[
("OK", True),
("NG", False),
("", False),
],
)
def test_judge_status_various_inputs_returns_expected(
input_value: str,
expected: bool,
) -> None:
assert judge_status(input_value) is expected
def test_validate_input_missing_column_raises_value_error() -> None:
with pytest.raises(ValueError, match="missing column"):
validate_input(df)
```
## Commands
```bash
uv run pytest
uv run pytest -m "not integration"
uv run pytest -q
```
## Checklist
- [ ] AAAパターンになっている
- [ ] 1テスト1Actになっている
- [ ] テスト名で条件と期待結果が分かる
- [ ] 境界値と異常系が含まれている
- [ ] 外部依存がUnit Testに混ざっていない
@@ -0,0 +1,48 @@
---
name: sqlserver-ddl
description: SQL Server の CREATE TABLE、INDEX、UNIQUE制約、DDL修正、SQLファイル作成を行うときに使用する。
---
# SQL Server DDL Skill
## When to use
- SQL Server のテーブルを新規作成するとき
- CREATE TABLE を修正するとき
- INDEX、UNIQUE制約、FOREIGN KEY を追加するとき
- SQL Server用DDLをMarkdownやSQLファイルに整理するとき
## Rules
- SQL Server 用のDDLとして作成する。
- テーブル名・カラム名は既存ルールに合わせる。
- 主キーは原則 `ID INT IDENTITY(1,1) NOT NULL` を基本にする。
- 日時型は `DATETIME2(0)` を優先する。
- 作成日時は `CREATED_AT DATETIME2(0) NOT NULL DEFAULT SYSDATETIME()` を基本にする。
- 更新日時が必要な場合は `UPDATED_AT DATETIME2(0) NULL` を使用する。
- 検索条件に使う列には INDEX を検討する。
- 重複防止が必要な組み合わせには UNIQUE 制約を作成する。
- DDL、INDEX、補足説明をセットで出力する。
## Output Format
1. CREATE TABLE
2. CREATE INDEX
3. UNIQUE制約
4. 補足説明
## Example
```sql
CREATE TABLE dbo.SAMPLE_TABLE (
ID INT IDENTITY(1,1) NOT NULL,
TOOL_ID NVARCHAR(50) NOT NULL,
STATUS_CODE NVARCHAR(20) NOT NULL,
CREATED_AT DATETIME2(0) NOT NULL DEFAULT SYSDATETIME(),
UPDATED_AT DATETIME2(0) NULL,
CONSTRAINT PK_SAMPLE_TABLE PRIMARY KEY CLUSTERED (ID)
);
CREATE INDEX IX_SAMPLE_TABLE_TOOL_ID
ON dbo.SAMPLE_TABLE (TOOL_ID);
```
@@ -0,0 +1,43 @@
---
name: streamlit-page
description: Streamlitページからロジックを分離し、UI、session_state、service、repositoryに整理するときに使用する。
---
# Streamlit Page Refactor Skill
## When to use
- Streamlitページの処理が長くなったとき
- UIとロジックを分離したいとき
- `st.session_state` の管理を専用クラスに寄せたいとき
- DB処理、ファイルI/O、集計処理を `src/` 側へ移したいとき
## Rules
- ページファイルには画面表示、入力受付、結果表示を中心に残す。
- ビジネスロジックは service 層へ移す。
- DBアクセスは repository 層へ移す。
- DataFrame加工は pure function として分離する。
- `st.session_state` は専用クラスまたは専用関数で管理する。
- ユーザー向けエラーと内部ログを分離する。
- 長時間処理はJob Queue / Workerへの分離を検討する。
## Suggested Structure
```text
src/
├─ pages/
├─ services/
├─ repositories/
├─ models/
├─ settings.py
└─ exceptions.py
```
## Checklist
- [ ] ページファイルにDB処理が残っていない
- [ ] ページファイルに重い処理が残っていない
- [ ] session_stateのキーが散らばっていない
- [ ] ユーザー表示エラーと内部ログが分かれている
- [ ] service / repository / utility の責務が分かれている
+334
View File
@@ -0,0 +1,334 @@
---
description: FastAPI 関連ファイルを編集するときのルール
globs: "**/api/**/*.py,**/routers/**/*.py,**/routes/**/*.py,**/schemas/**/*.py,**/services/**/*.py,**/repositories/**/*.py,**/main.py,**/app.py"
alwaysApply: false
---
# FastAPI Instructions
## 基本方針
- FastAPI はバックエンドAPIとして使用する。
- API層、Service層、Repository層を分離する。
- FastAPIでDBアクセスを行う場合は、原則としてSQLAlchemyを使用する。
- 単純なCRUDはSQLAlchemy ORMを優先し、複雑なJOIN・集計・N+1問題の回避でORM記述が複雑になる場合は、`sqlalchemy.text()` または `.sql` ファイルのSQLを使用する。
- エンドポイント関数にビジネスロジック、DBアクセス、ファイルI/Oを直接書かない。
- 既存の設計、命名、ディレクトリ構成を優先する。
- 新規ファイルを作成する場合は、既存の類似機能と同じ配置・命名に合わせる。
- 詳細なフォルダ構成は `docs/architecture/folder-structure.md` を参照する。
## 標準フォルダ構成
FastAPI関連コードは、原則として以下の配置を優先する。
```text
src/app/main.py
src/app/api/dependencies.py
src/app/api/routers/<feature>.py
src/app/schemas/<feature>.py
src/app/services/<feature>_service.py
src/app/repositories/<feature>_repository.py
tests/api/test_<feature>.py
```
既存の構成がある場合は、新規構成を勝手に作らず、既存構成を優先する。
## 構成
- ルーティングは `APIRouter` を使用する。
- ルーターは機能単位で分割する。
- `main.py` または `app.py` では FastAPI アプリ作成、middleware、router登録を中心に書く。
- 共通依存関係は `dependencies.py` または `api/dependencies.py` に分離する。
- リクエスト・レスポンスのPydanticモデルは `schemas.py` または `schemas/` に分離する。
- 業務ロジックは `services/` に分離する。
- DBアクセスは `repositories/` に分離する。
## Router
- `APIRouter` を使用する。
- `prefix`、`tags` を適切に設定する。
- ルーターは機能単位で分割する。
- エンドポイント関数は薄く保つ。
- エンドポイントでは以下のみを行う。
- リクエストデータの受け取り
- `Depends` による依存関係の受け取り
- Serviceの呼び出し
- レスポンスの返却
- DB処理はRepository層に書く。
- 業務ロジックはService層に書く。
- ファイルI/O、外部システム連携、重い処理をエンドポイントに直接書かない。
## Request / Response
- リクエストボディは Pydantic モデルで定義する。
- レスポンスは Pydantic モデルで定義する。
- 可能な限り `response_model` を指定する。
- DBモデル、DB行、内部オブジェクトをそのままAPIレスポンスとして返さない。
- APIレスポンスには、外部に公開してよい項目だけを含める。
- 入力値の制約は、可能な範囲でPydantic側に定義する。
- Pydantic v2 を前提にする場合は、ORMオブジェクト変換に `ConfigDict(from_attributes=True)` と `model_validate()` を使用する。
## Service
- 業務ロジックはService層に書く。
- ServiceはRepositoryを呼び出して必要なデータを取得・保存する。
- ServiceはFastAPI固有の `Request` や `Response` に依存しすぎない。
- Serviceはテストしやすいように、入力と出力を明確にする。
- Service内で例外を握りつぶさない。
- 必要に応じて独自例外に変換して呼び出し元へ伝える。
## Repository
- DBアクセスはRepository層に書く。
- DBアクセスは既存のDB接続クラス・Repository層を優先する。
- FastAPIでDBアクセスを行う場合は、原則としてSQLAlchemyを使用する。
- SQLAlchemyは可能な限り2.x系の記述スタイルを優先する。
- RepositoryはSQLAlchemyの `Session` またはDB接続を受け取り、Router層から直接DBへアクセスさせない。
- SQL Server用RepositoryとOracleDB用Repositoryを分離する。
- SQL Server と OracleDB の接続処理、Repository、SQLファイルは分離する。
- OracleDBは参照専用とし、原則 `SELECT` のみ実行する。
- OracleDBに対して `INSERT` / `UPDATE` / `DELETE` / `MERGE` / `CREATE` / `ALTER` / `DROP` は実行しない。
- SQL Serverへの保存・更新・削除はSQL Server用Repositoryで行う。
- SQLは可能な限り `.sql` ファイルに分離する。
- SQL Server用SQLとOracleDB用SQLを混在させない。
- 文字列連結でSQLを組み立てない。
- パラメータ付きSQLを使用する。
- 認証情報、接続文字列、ユーザー名、パスワードをコードに直書きしない。
## SQLAlchemy / SQL
- 単純なCRUD、主キー検索、単純な条件検索はSQLAlchemy ORMを優先する。
- ORMモデルはDBテーブル構造を表すものとして扱い、APIレスポンスにはPydanticモデルを使用する。
- ORMのリレーションを使用する場合は、N+1問題に注意する。
- N+1問題が発生する可能性がある場合は、以下のいずれかで対策する。
- `selectinload()` を使用する。
- `joinedload()` を使用する。
- 明示的な `join()` を使用する。
- `sqlalchemy.text()` または `.sql` ファイルに分離したSQLを使用する。
- 複雑なJOIN、集計、ウィンドウ関数、帳票用SQL、大量データ取得、性能要件が強い処理は、無理にORMだけで書かない。
- ORMで記述すると可読性や性能が悪くなる場合は、`sqlalchemy.text()` を使用してSQLを明示的に記述する。
- 長いSQL、再利用するSQL、DBごとに差があるSQLは `.sql` ファイルに分離する。
- `text()` を使用する場合も、ユーザー入力値をSQL文字列へ直接埋め込まない。
- `text()` では必ずバインドパラメータを使用する。
- RepositoryはDBアクセス結果をServiceが扱いやすい形で返す。
- レスポンス生成時に遅延ロードが発生しないよう、Repository層で必要なデータを取得しきる。
- トランザクション境界はService層またはRepository層で明確に管理し、成功時はcommit、失敗時はrollbackする。
### ORMを優先する例
```python
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.job import Job
class JobRepository:
def __init__(self, session: Session) -> None:
self._session = session
def fetch_job(self, job_id: int) -> Job | None:
stmt = select(Job).where(Job.job_id == job_id)
return self._session.scalars(stmt).first()
```
### text() を使用する例
```python
from sqlalchemy import text
from sqlalchemy.orm import Session
class JobRepository:
def __init__(self, session: Session) -> None:
self._session = session
def fetch_job_summary(self, job_id: int) -> dict[str, object] | None:
stmt = text("""
SELECT
J.JOB_ID,
J.STATUS,
COUNT(S.SLOT_ID) AS SLOT_COUNT
FROM dbo.SAW6_JOB_QUEUE AS J
LEFT JOIN dbo.SAW6_JOB_QUEUE_SLOT AS S
ON S.JOB_ID = J.JOB_ID
WHERE J.JOB_ID = :job_id
GROUP BY
J.JOB_ID,
J.STATUS
""")
row = self._session.execute(stmt, {"job_id": job_id}).mappings().first()
return dict(row) if row is not None else None
```
## Dependency Injection
- FastAPI の `Depends` を使用する。
- Settings、SQLAlchemy `Session`、DB接続、Repository、Service は依存関係として注入できる形にする。
- 認証・認可・DB接続・設定取得などの共通処理は依存関係として共通化する。
- テストで差し替えやすいように、依存関係は関数化する。
- グローバル変数に直接依存する実装を避ける。
- SQLAlchemy `Session` はリクエスト単位で生成・終了する。
- Repository生成時にSQLAlchemy `Session` を渡し、Repository内部でグローバルなSessionを直接参照しない。
## Error Handling
- 入力不正は `HTTPException` で適切なHTTPステータスコードを返す。
- リソースが存在しない場合は `404` を返す。
- 未認証は `401`、権限不足は `403` を返す。
- 内部例外の詳細をAPIレスポンスにそのまま返さない。
- 内部エラーは `logging` で記録する。
- クライアント向けレスポンスと内部ログを分離する。
- 例外を握りつぶさない。
- 必要に応じて独自例外をService層で発生させ、Router層でHTTPレスポンスへ変換する。
## Async / Sync
- `async def` と `def` は処理内容に応じて使い分ける。
- 同期DBドライバを使う場合は、無理に `async def` にしない。
- ブロッキングI/Oを `async def` の中で直接実行しない。
- 長時間処理はAPIリクエスト内で完結させず、WorkerやJob Queueへの分離を検討する。
- CSV出力待ち、ログ検索、重い集計、外部プログラム待ちなどはAPIから分離する。
## Testing
- FastAPI のAPIテストでは `TestClient` を使用する。
- DB、外部API、ファイルI/Oはmockまたはdependency overrideで差し替える。
- 正常系、異常系、バリデーションエラー、権限エラーをテストする。
- Integration Test は `@pytest.mark.integration` を付けて通常実行から分離する。
- テスト名は `test_<対象>_<条件>_<期待結果>` にする。
- dependency override を使用した場合は、テスト後に `app.dependency_overrides.clear()` を実行する。
## 作成・修正時の出力方針
FastAPI APIを作成・修正する場合は、必要に応じて以下をセットで検討する。
1. Router
2. Schema
3. Service
4. Repository
5. Dependency
6. Test
7. 関連するSQLファイル
8. 実行・確認コマンド
## Example
### Router
```python
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies import get_job_service
from app.schemas.job import JobResponse
from app.services.job_service import JobService
router = APIRouter(prefix="/jobs", tags=["jobs"])
@router.get("/{job_id}", response_model=JobResponse)
def get_job(
job_id: int,
service: JobService = Depends(get_job_service),
) -> JobResponse:
job = service.get_job(job_id)
if job is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found",
)
return JobResponse.model_validate(job)
```
### Schema
```python
from pydantic import BaseModel, ConfigDict
class JobResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
job_id: int
status: str
```
### Dependency
```python
from collections.abc import Generator
from fastapi import Depends
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.repositories.job_repository import JobRepository
from app.services.job_service import JobService
engine = create_engine("mssql+pyodbc://...", pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db_session() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
finally:
session.close()
def get_job_repository(
session: Session = Depends(get_db_session),
) -> JobRepository:
return JobRepository(session=session)
def get_job_service(
repository: JobRepository = Depends(get_job_repository),
) -> JobService:
return JobService(repository=repository)
```
### Service
```python
from app.repositories.job_repository import JobRepository
class JobService:
def __init__(self, repository: JobRepository) -> None:
self._repository = repository
def get_job(self, job_id: int) -> object | None:
return self._repository.fetch_job(job_id)
```
### Test
```python
from fastapi.testclient import TestClient
from app.api.dependencies import get_job_service
from app.main import app
class FakeJobService:
def get_job(self, job_id: int) -> dict[str, int | str]:
return {"job_id": job_id, "status": "DONE"}
def test_get_job_valid_id_returns_job():
app.dependency_overrides[get_job_service] = lambda: FakeJobService()
try:
client = TestClient(app)
response = client.get("/jobs/1")
assert response.status_code == 200
assert response.json() == {"job_id": 1, "status": "DONE"}
finally:
app.dependency_overrides.clear()
```
+31
View File
@@ -0,0 +1,31 @@
---
description: logging 関連ファイルを編集するときのルール
globs: "**/*.py,config/**/*.toml"
alwaysApply: false
---
# Logging Instructions
## Basic Rules
- `print` は使用しない。
- `logging` を使用する。
- logging設定は TOML で管理する。
- logger は用途別に分離する。
- 例外発生時は `logger.exception(...)` を優先する。
- ユーザー向けメッセージと内部ログを分離する。
## Logger Separation
必要に応じて、以下のように logger を分ける。
- アプリ全体: `app`
- SQL / DB: `sql`
- バッチ / Worker: `worker`
- 外部I/O: `io`
## Log Level
- 開発環境では `DEBUG` 以上を出力する。
- 本番環境では必要に応じて `INFO` 以上を基本にする。
- エラー原因の調査に必要な情報は内部ログに残す。
+17
View File
@@ -0,0 +1,17 @@
---
description: OracleDB 関連ファイルを編集するときのルール
globs: "**/oracle/**/*.py,**/repositories/**/*oracle*.py,**/db/**/*oracle*.py,**/sql/oracle/**/*.sql"
alwaysApply: false
---
# OracleDB Instructions
- OracleDB は外部システムからデータを取得するための参照専用DBとして扱う。
- OracleDB に対しては原則 `SELECT` のみ実行する。
- `INSERT` / `UPDATE` / `DELETE` / `MERGE` / `CREATE` / `ALTER` / `DROP` は実行しない。
- OracleDB用SQLは `sql/oracle/` または `docs/sql/oracle/` に分離する。
- SQL Server用SQLとOracleDB用SQLを混在させない。
- OracleDB用RepositoryとSQL Server用Repositoryを分離する。
- OracleDBから取得したデータを加工・保存する場合、保存先はSQL Server側Repositoryで扱う。
- 認証情報、接続文字列、ユーザー名、パスワードをコードに直書きしない。
- パラメータ付きSQLを使用し、文字列連結でSQLを組み立てない。
+27
View File
@@ -0,0 +1,27 @@
---
description: pandas 関連ファイルを編集するときのルール
globs: "**/*.py"
alwaysApply: false
---
# Pandas Instructions
## Basic Rules
- DataFrame操作は関数に分離する。
- 可能な限りベクトル化された操作を使用する。
- 不要な for ループは避ける。
- index の不一致による NaN 混入に注意する。
- concat / merge / join の後は、必要に応じて index と列を検証する。
- 入力DataFrameを破壊的に変更する場合は、関数名またはdocstringで明示する。
## Validation
- 必須列が存在するか確認する。
- 行数、index、列名の整合性を確認する。
- 欠損値、0、空文字、型不一致を考慮する。
## Visualization
- 可視化は原則 matplotlib を使用する。
- seaborn は明示的に依頼された場合のみ使用する。
+31
View File
@@ -0,0 +1,31 @@
---
description: AGENTS.md を正本とするプロジェクト全体のCursor向け補足
alwaysApply: true
---
# Cursor Project Rules
## 位置づけ
- 共通ルールの正本は、リポジトリルートの `AGENTS.md` とする。
- このファイルには、共通ルールを長文で再掲しない。
- Python、DB、FastAPI、Streamlit、Worker、Logging、テスト、フォルダ構成などの基本方針は `AGENTS.md` に従う。
- 技術領域ごとの詳細指示がある場合は、対象ファイルに応じて専用の指示ファイルも参照する。
- DDL作成、Worker分離、Streamlitページ改修など、特定作業の詳細手順が必要な場合は `.agents/skills/` 配下の `SKILL.md` を参照する。
- このファイルと `AGENTS.md` の内容が矛盾する場合は、`AGENTS.md` を優先する。
## 作業方針
- 変更前に、既存コード、既存ドキュメント、既存の命名規則、既存のディレクトリ構成を確認する。
- 大きな変更は、小さな単位に分けて提案・実装する。
- 既存の設計を無視して、新しい構成や新しい仕組みを勝手に作らない。
- 既存の類似実装がある場合は、その配置、命名、責務分離に合わせる。
- 変更によりロジックが変わる場合は、テストの追加・修正を検討する。
- 変更後の確認コマンドは、`AGENTS.md` の「品質チェック / テスト」に従う。
## Cursor 向け補足
- Cursor は、まず `AGENTS.md` の共通方針に従う。
- ファイル種別・技術領域ごとの詳細指示がある場合は、`.cursor/rules/` 配下の `*.mdc` を参照する。
- このファイルには、Cursor固有の運用方針だけを書く。
- GitHub Copilot向けの `.github/copilot-instructions.md` と、補足内容が大きくズレないようにする。
+48
View File
@@ -0,0 +1,48 @@
---
description: test 関連ファイルを編集するときのルール
globs: "tests/**/*.py"
alwaysApply: false
---
# Pytest Instructions
## Basic Rules
- pytest を使用する。
- AAA パターンで書く。
- 1テスト1責務にする。
- 1テスト1Actにする。
- テスト名は `test_<対象>_<条件>_<期待結果>` にする。
## Arrange / Act / Assert
- Arrange: 前提データ、fixture、mockを準備する。
- Act: 対象処理を1回だけ実行する。
- Assert: 結果を検証する。
## Fixtures
- fixture は前提条件の名前にする。
- fixture には assert を書かない。
- fixture にテストロジックを書かない。
- Arrange が長くなる場合は fixture 化する。
## Parametrize
- 分岐、境界値、異常系は `pytest.mark.parametrize` を優先する。
- テスト内で if/else を増やしすぎない。
## Exception Test
- 例外テストでは `pytest.raises(..., match=...)` を使用する。
- 例外の型だけでなく、必要に応じてメッセージも検証する。
## Mock / Integration
- Unit TestではDB、ファイルI/O、現在時刻、外部APIをmockする。
- Integration Testには `@pytest.mark.integration` を付ける。
- 通常実行では以下を使用する。
```bash
uv run pytest -m "not integration"
```
+40
View File
@@ -0,0 +1,40 @@
---
description: python 関連ファイルを編集するときのルール
globs: "**/*.py"
alwaysApply: false
---
# Python Instructions
## Version / Environment
- Python 3.13 を前提にする。
- パッケージ管理と仮想環境管理には `uv` を使用する。
## Type Hints
- すべての関数に引数と戻り値の型ヒントを付ける。
- `Any` は必要最小限にする。
- `dict` / `list` は可能な限り具体的に型指定する。
- 戻り値がない関数は `-> None` を明示する。
## Path / File
- `pathlib.Path` を使用する。
- `os.path` は使用しない。
- ファイル読み書きでは `encoding="utf-8"` を明示する。
- パスを受け取る関数では、可能な限り `Path` 型を使用する。
## Error Handling
- 例外は握りつぶさない。
- 原因を失わないように `raise ... from e` を使用する。
- ユーザー表示用エラーと内部エラーを分離する。
- 内部エラーは `logging` で記録する。
## Design
- UI、DB、I/O、ビジネスロジックを分離する。
- 副作用のある処理は関数名から意図が分かるようにする。
- magic number は定数化する。
- 1つの関数に複数の責務を持たせない。
+26
View File
@@ -0,0 +1,26 @@
---
description: setttings 関連ファイルを編集するときのルール
globs: "**/settings.py,**/config/**/*.py,**/.env.example,**/*.env.example"
alwaysApply: false
---
# Settings Instructions
## Basic Rules
- 設定は `pydantic_settings.BaseSettings` で一元管理する。
- 開発環境は `.env.dev` を使用する。
- テスト環境と本番環境はOS環境変数を使用する。
- パス系の設定値は `pathlib.Path` で扱う。
- DB接続情報、APIキー、パスワードをコードに直書きしない。
## Naming
- 環境変数のprefixはプロジェクト単位で統一する。
- 例: `APP_`, `XXX1_`, `WEB_XXX1_`
## Validation
- 必須設定が不足した場合は、明確なValidationErrorとして扱う。
- 設定の不足をデフォルト値で隠さない。
- 本番用の秘密情報を `.env` ファイルに残さない。
+29
View File
@@ -0,0 +1,29 @@
---
description: SQL Server 関連ファイルを編集するときのルール
globs: "**/*.sql,**/db/**/*.py,**/repository/**/*.py,**/repositories/**/*.py,**/models/**/*.py"
alwaysApply: false
---
# SQL Server Instructions
## Basic Rules
- SQL Server 用の構文で書く。
- DB接続は `pyodbc` または既存のDB接続クラスを使用する。
- テーブル名・カラム名は既存DDLに合わせる。
- SQLは可能な限り `.sql` ファイルに分離する。
- 認証情報、接続文字列、パスワードをコードに直書きしない。
## Transaction
- INSERT / UPDATE / DELETE では commit / rollback を明示する。
- 例外発生時は rollback する。
- 例外は握りつぶさず、ログに記録して再raiseまたは独自例外に変換する。
## DDL
- 主キーは原則 `IDENTITY(1,1)` を使用する。
- 日時型は特別な理由がなければ `DATETIME2(0)` を使用する。
- 作成日時は `CREATED_AT DATETIME2(0) NOT NULL DEFAULT SYSDATETIME()` を基本にする。
- 検索条件に使う列には INDEX を検討する。
- 重複防止が必要な組み合わせには UNIQUE 制約を検討する。
+28
View File
@@ -0,0 +1,28 @@
---
description: Streamlit 関連ファイルを編集するときのルール
globs: "app.py,pages/**/*.py,**/streamlit/**/*.py"
alwaysApply: false
---
# Streamlit Instructions
## Basic Rules
- UIとロジックを分離する。
- Streamlitページには画面表示、入力受付、結果表示を中心に書く。
- DBアクセス、ファイルI/O、集計処理は `src/` 側に分離する。
- `st.session_state` は専用クラスまたは専用関数で管理する。
- ユーザー向けエラーと内部エラーを分離する。
## Error Handling
- ユーザーに表示するメッセージは分かりやすくする。
- 詳細な例外情報は logging に出力する。
- 例外を握りつぶさない。
- 既存のユーザー向け例外クラスがある場合は、それを優先する。
## Long Running Task
- 長時間処理をStreamlitのリクエスト内で直接実行しない。
- 時間がかかる処理はWorker、Job Queue、外部プロセスへの分離を検討する。
- Streamlit側はジョブ登録とステータス表示を担当する。
+26
View File
@@ -0,0 +1,26 @@
# GitHub Copilot Instructions
## 位置づけ
- 共通ルールの正本は、リポジトリルートの `AGENTS.md` とする。
- このファイルには、共通ルールを長文で再掲しない。
- Python、DB、FastAPI、Streamlit、Worker、Logging、テスト、フォルダ構成などの基本方針は `AGENTS.md` に従う。
- 技術領域ごとの詳細指示がある場合は、対象ファイルに応じて専用の指示ファイルも参照する。
- DDL作成、Worker分離、Streamlitページ改修など、特定作業の詳細手順が必要な場合は `.agents/skills/` 配下の `SKILL.md` を参照する。
- このファイルと `AGENTS.md` の内容が矛盾する場合は、`AGENTS.md` を優先する。
## 作業方針
- 変更前に、既存コード、既存ドキュメント、既存の命名規則、既存のディレクトリ構成を確認する。
- 大きな変更は、小さな単位に分けて提案・実装する。
- 既存の設計を無視して、新しい構成や新しい仕組みを勝手に作らない。
- 既存の類似実装がある場合は、その配置、命名、責務分離に合わせる。
- 変更によりロジックが変わる場合は、テストの追加・修正を検討する。
- 変更後の確認コマンドは、`AGENTS.md` の「品質チェック / テスト」に従う。
## GitHub Copilot 向け補足
- GitHub Copilot は、まず `AGENTS.md` の共通方針に従う。
- ファイル種別・技術領域ごとの詳細指示がある場合は、`.github/instructions/` 配下の `*.instructions.md` を参照する。
- このファイルには、Copilot固有の運用方針だけを書く。
- Cursor向けの `.cursor/rules/project.mdc` と、補足内容が大きくズレないようにする。
@@ -0,0 +1,332 @@
---
applyTo: "**/api/**/*.py,**/routers/**/*.py,**/routes/**/*.py,**/schemas/**/*.py,**/services/**/*.py,**/repositories/**/*.py,**/main.py,**/app.py"
---
# FastAPI Instructions
## 基本方針
- FastAPI はバックエンドAPIとして使用する。
- API層、Service層、Repository層を分離する。
- FastAPIでDBアクセスを行う場合は、原則としてSQLAlchemyを使用する。
- 単純なCRUDはSQLAlchemy ORMを優先し、複雑なJOIN・集計・N+1問題の回避でORM記述が複雑になる場合は、`sqlalchemy.text()` または `.sql` ファイルのSQLを使用する。
- エンドポイント関数にビジネスロジック、DBアクセス、ファイルI/Oを直接書かない。
- 既存の設計、命名、ディレクトリ構成を優先する。
- 新規ファイルを作成する場合は、既存の類似機能と同じ配置・命名に合わせる。
- 詳細なフォルダ構成は `docs/architecture/folder-structure.md` を参照する。
## 標準フォルダ構成
FastAPI関連コードは、原則として以下の配置を優先する。
```text
src/app/main.py
src/app/api/dependencies.py
src/app/api/routers/<feature>.py
src/app/schemas/<feature>.py
src/app/services/<feature>_service.py
src/app/repositories/<feature>_repository.py
tests/api/test_<feature>.py
```
既存の構成がある場合は、新規構成を勝手に作らず、既存構成を優先する。
## 構成
- ルーティングは `APIRouter` を使用する。
- ルーターは機能単位で分割する。
- `main.py` または `app.py` では FastAPI アプリ作成、middleware、router登録を中心に書く。
- 共通依存関係は `dependencies.py` または `api/dependencies.py` に分離する。
- リクエスト・レスポンスのPydanticモデルは `schemas.py` または `schemas/` に分離する。
- 業務ロジックは `services/` に分離する。
- DBアクセスは `repositories/` に分離する。
## Router
- `APIRouter` を使用する。
- `prefix``tags` を適切に設定する。
- ルーターは機能単位で分割する。
- エンドポイント関数は薄く保つ。
- エンドポイントでは以下のみを行う。
- リクエストデータの受け取り
- `Depends` による依存関係の受け取り
- Serviceの呼び出し
- レスポンスの返却
- DB処理はRepository層に書く。
- 業務ロジックはService層に書く。
- ファイルI/O、外部システム連携、重い処理をエンドポイントに直接書かない。
## Request / Response
- リクエストボディは Pydantic モデルで定義する。
- レスポンスは Pydantic モデルで定義する。
- 可能な限り `response_model` を指定する。
- DBモデル、DB行、内部オブジェクトをそのままAPIレスポンスとして返さない。
- APIレスポンスには、外部に公開してよい項目だけを含める。
- 入力値の制約は、可能な範囲でPydantic側に定義する。
- Pydantic v2 を前提にする場合は、ORMオブジェクト変換に `ConfigDict(from_attributes=True)``model_validate()` を使用する。
## Service
- 業務ロジックはService層に書く。
- ServiceはRepositoryを呼び出して必要なデータを取得・保存する。
- ServiceはFastAPI固有の `Request``Response` に依存しすぎない。
- Serviceはテストしやすいように、入力と出力を明確にする。
- Service内で例外を握りつぶさない。
- 必要に応じて独自例外に変換して呼び出し元へ伝える。
## Repository
- DBアクセスはRepository層に書く。
- DBアクセスは既存のDB接続クラス・Repository層を優先する。
- FastAPIでDBアクセスを行う場合は、原則としてSQLAlchemyを使用する。
- SQLAlchemyは可能な限り2.x系の記述スタイルを優先する。
- RepositoryはSQLAlchemyの `Session` またはDB接続を受け取り、Router層から直接DBへアクセスさせない。
- SQL Server用RepositoryとOracleDB用Repositoryを分離する。
- SQL Server と OracleDB の接続処理、Repository、SQLファイルは分離する。
- OracleDBは参照専用とし、原則 `SELECT` のみ実行する。
- OracleDBに対して `INSERT` / `UPDATE` / `DELETE` / `MERGE` / `CREATE` / `ALTER` / `DROP` は実行しない。
- SQL Serverへの保存・更新・削除はSQL Server用Repositoryで行う。
- SQLは可能な限り `.sql` ファイルに分離する。
- SQL Server用SQLとOracleDB用SQLを混在させない。
- 文字列連結でSQLを組み立てない。
- パラメータ付きSQLを使用する。
- 認証情報、接続文字列、ユーザー名、パスワードをコードに直書きしない。
## SQLAlchemy / SQL
- 単純なCRUD、主キー検索、単純な条件検索はSQLAlchemy ORMを優先する。
- ORMモデルはDBテーブル構造を表すものとして扱い、APIレスポンスにはPydanticモデルを使用する。
- ORMのリレーションを使用する場合は、N+1問題に注意する。
- N+1問題が発生する可能性がある場合は、以下のいずれかで対策する。
- `selectinload()` を使用する。
- `joinedload()` を使用する。
- 明示的な `join()` を使用する。
- `sqlalchemy.text()` または `.sql` ファイルに分離したSQLを使用する。
- 複雑なJOIN、集計、ウィンドウ関数、帳票用SQL、大量データ取得、性能要件が強い処理は、無理にORMだけで書かない。
- ORMで記述すると可読性や性能が悪くなる場合は、`sqlalchemy.text()` を使用してSQLを明示的に記述する。
- 長いSQL、再利用するSQL、DBごとに差があるSQLは `.sql` ファイルに分離する。
- `text()` を使用する場合も、ユーザー入力値をSQL文字列へ直接埋め込まない。
- `text()` では必ずバインドパラメータを使用する。
- RepositoryはDBアクセス結果をServiceが扱いやすい形で返す。
- レスポンス生成時に遅延ロードが発生しないよう、Repository層で必要なデータを取得しきる。
- トランザクション境界はService層またはRepository層で明確に管理し、成功時はcommit、失敗時はrollbackする。
### ORMを優先する例
```python
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.job import Job
class JobRepository:
def __init__(self, session: Session) -> None:
self._session = session
def fetch_job(self, job_id: int) -> Job | None:
stmt = select(Job).where(Job.job_id == job_id)
return self._session.scalars(stmt).first()
```
### text() を使用する例
```python
from sqlalchemy import text
from sqlalchemy.orm import Session
class JobRepository:
def __init__(self, session: Session) -> None:
self._session = session
def fetch_job_summary(self, job_id: int) -> dict[str, object] | None:
stmt = text("""
SELECT
J.JOB_ID,
J.STATUS,
COUNT(S.SLOT_ID) AS SLOT_COUNT
FROM dbo.SAW6_JOB_QUEUE AS J
LEFT JOIN dbo.SAW6_JOB_QUEUE_SLOT AS S
ON S.JOB_ID = J.JOB_ID
WHERE J.JOB_ID = :job_id
GROUP BY
J.JOB_ID,
J.STATUS
""")
row = self._session.execute(stmt, {"job_id": job_id}).mappings().first()
return dict(row) if row is not None else None
```
## Dependency Injection
- FastAPI の `Depends` を使用する。
- Settings、SQLAlchemy `Session`、DB接続、Repository、Service は依存関係として注入できる形にする。
- 認証・認可・DB接続・設定取得などの共通処理は依存関係として共通化する。
- テストで差し替えやすいように、依存関係は関数化する。
- グローバル変数に直接依存する実装を避ける。
- SQLAlchemy `Session` はリクエスト単位で生成・終了する。
- Repository生成時にSQLAlchemy `Session` を渡し、Repository内部でグローバルなSessionを直接参照しない。
## Error Handling
- 入力不正は `HTTPException` で適切なHTTPステータスコードを返す。
- リソースが存在しない場合は `404` を返す。
- 未認証は `401`、権限不足は `403` を返す。
- 内部例外の詳細をAPIレスポンスにそのまま返さない。
- 内部エラーは `logging` で記録する。
- クライアント向けレスポンスと内部ログを分離する。
- 例外を握りつぶさない。
- 必要に応じて独自例外をService層で発生させ、Router層でHTTPレスポンスへ変換する。
## Async / Sync
- `async def``def` は処理内容に応じて使い分ける。
- 同期DBドライバを使う場合は、無理に `async def` にしない。
- ブロッキングI/Oを `async def` の中で直接実行しない。
- 長時間処理はAPIリクエスト内で完結させず、WorkerやJob Queueへの分離を検討する。
- CSV出力待ち、ログ検索、重い集計、外部プログラム待ちなどはAPIから分離する。
## Testing
- FastAPI のAPIテストでは `TestClient` を使用する。
- DB、外部API、ファイルI/Oはmockまたはdependency overrideで差し替える。
- 正常系、異常系、バリデーションエラー、権限エラーをテストする。
- Integration Test は `@pytest.mark.integration` を付けて通常実行から分離する。
- テスト名は `test_<対象>_<条件>_<期待結果>` にする。
- dependency override を使用した場合は、テスト後に `app.dependency_overrides.clear()` を実行する。
## 作成・修正時の出力方針
FastAPI APIを作成・修正する場合は、必要に応じて以下をセットで検討する。
1. Router
2. Schema
3. Service
4. Repository
5. Dependency
6. Test
7. 関連するSQLファイル
8. 実行・確認コマンド
## Example
### Router
```python
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies import get_job_service
from app.schemas.job import JobResponse
from app.services.job_service import JobService
router = APIRouter(prefix="/jobs", tags=["jobs"])
@router.get("/{job_id}", response_model=JobResponse)
def get_job(
job_id: int,
service: JobService = Depends(get_job_service),
) -> JobResponse:
job = service.get_job(job_id)
if job is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found",
)
return JobResponse.model_validate(job)
```
### Schema
```python
from pydantic import BaseModel, ConfigDict
class JobResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
job_id: int
status: str
```
### Dependency
```python
from collections.abc import Generator
from fastapi import Depends
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.repositories.job_repository import JobRepository
from app.services.job_service import JobService
engine = create_engine("mssql+pyodbc://...", pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db_session() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
finally:
session.close()
def get_job_repository(
session: Session = Depends(get_db_session),
) -> JobRepository:
return JobRepository(session=session)
def get_job_service(
repository: JobRepository = Depends(get_job_repository),
) -> JobService:
return JobService(repository=repository)
```
### Service
```python
from app.repositories.job_repository import JobRepository
class JobService:
def __init__(self, repository: JobRepository) -> None:
self._repository = repository
def get_job(self, job_id: int) -> object | None:
return self._repository.fetch_job(job_id)
```
### Test
```python
from fastapi.testclient import TestClient
from app.api.dependencies import get_job_service
from app.main import app
class FakeJobService:
def get_job(self, job_id: int) -> dict[str, int | str]:
return {"job_id": job_id, "status": "DONE"}
def test_get_job_valid_id_returns_job():
app.dependency_overrides[get_job_service] = lambda: FakeJobService()
try:
client = TestClient(app)
response = client.get("/jobs/1")
assert response.status_code == 200
assert response.json() == {"job_id": 1, "status": "DONE"}
finally:
app.dependency_overrides.clear()
```
@@ -0,0 +1,29 @@
---
applyTo: "**/*.py,config/**/*.toml"
---
# Logging Instructions
## Basic Rules
- `print` は使用しない。
- `logging` を使用する。
- logging設定は TOML で管理する。
- logger は用途別に分離する。
- 例外発生時は `logger.exception(...)` を優先する。
- ユーザー向けメッセージと内部ログを分離する。
## Logger Separation
必要に応じて、以下のように logger を分ける。
- アプリ全体: `app`
- SQL / DB: `sql`
- バッチ / Worker: `worker`
- 外部I/O: `io`
## Log Level
- 開発環境では `DEBUG` 以上を出力する。
- 本番環境では必要に応じて `INFO` 以上を基本にする。
- エラー原因の調査に必要な情報は内部ログに残す。
@@ -0,0 +1,15 @@
---
applyTo: "**/oracle/**/*.py,**/repositories/**/*oracle*.py,**/db/**/*oracle*.py,**/sql/oracle/**/*.sql"
---
# OracleDB Instructions
- OracleDB は外部システムからデータを取得するための参照専用DBとして扱う。
- OracleDB に対しては原則 `SELECT` のみ実行する。
- `INSERT` / `UPDATE` / `DELETE` / `MERGE` / `CREATE` / `ALTER` / `DROP` は実行しない。
- OracleDB用SQLは `sql/oracle/` または `docs/sql/oracle/` に分離する。
- SQL Server用SQLとOracleDB用SQLを混在させない。
- OracleDB用RepositoryとSQL Server用Repositoryを分離する。
- OracleDBから取得したデータを加工・保存する場合、保存先はSQL Server側Repositoryで扱う。
- 認証情報、接続文字列、ユーザー名、パスワードをコードに直書きしない。
- パラメータ付きSQLを使用し、文字列連結でSQLを組み立てない。
@@ -0,0 +1,25 @@
---
applyTo: "**/*.py"
---
# Pandas Instructions
## Basic Rules
- DataFrame操作は関数に分離する。
- 可能な限りベクトル化された操作を使用する。
- 不要な for ループは避ける。
- index の不一致による NaN 混入に注意する。
- concat / merge / join の後は、必要に応じて index と列を検証する。
- 入力DataFrameを破壊的に変更する場合は、関数名またはdocstringで明示する。
## Validation
- 必須列が存在するか確認する。
- 行数、index、列名の整合性を確認する。
- 欠損値、0、空文字、型不一致を考慮する。
## Visualization
- 可視化は原則 matplotlib を使用する。
- seaborn は明示的に依頼された場合のみ使用する。
@@ -0,0 +1,46 @@
---
applyTo: "tests/**/*.py"
---
# Pytest Instructions
## Basic Rules
- pytest を使用する。
- AAA パターンで書く。
- 1テスト1責務にする。
- 1テスト1Actにする。
- テスト名は `test_<対象>_<条件>_<期待結果>` にする。
## Arrange / Act / Assert
- Arrange: 前提データ、fixture、mockを準備する。
- Act: 対象処理を1回だけ実行する。
- Assert: 結果を検証する。
## Fixtures
- fixture は前提条件の名前にする。
- fixture には assert を書かない。
- fixture にテストロジックを書かない。
- Arrange が長くなる場合は fixture 化する。
## Parametrize
- 分岐、境界値、異常系は `pytest.mark.parametrize` を優先する。
- テスト内で if/else を増やしすぎない。
## Exception Test
- 例外テストでは `pytest.raises(..., match=...)` を使用する。
- 例外の型だけでなく、必要に応じてメッセージも検証する。
## Mock / Integration
- Unit TestではDB、ファイルI/O、現在時刻、外部APIをmockする。
- Integration Testには `@pytest.mark.integration` を付ける。
- 通常実行では以下を使用する。
```bash
uv run pytest -m "not integration"
```
@@ -0,0 +1,38 @@
---
applyTo: "**/*.py"
---
# Python Instructions
## Version / Environment
- Python 3.13 を前提にする。
- パッケージ管理と仮想環境管理には `uv` を使用する。
## Type Hints
- すべての関数に引数と戻り値の型ヒントを付ける。
- `Any` は必要最小限にする。
- `dict` / `list` は可能な限り具体的に型指定する。
- 戻り値がない関数は `-> None` を明示する。
## Path / File
- `pathlib.Path` を使用する。
- `os.path` は使用しない。
- ファイル読み書きでは `encoding="utf-8"` を明示する。
- パスを受け取る関数では、可能な限り `Path` 型を使用する。
## Error Handling
- 例外は握りつぶさない。
- 原因を失わないように `raise ... from e` を使用する。
- ユーザー表示用エラーと内部エラーを分離する。
- 内部エラーは `logging` で記録する。
## Design
- UI、DB、I/O、ビジネスロジックを分離する。
- 副作用のある処理は関数名から意図が分かるようにする。
- magic number は定数化する。
- 1つの関数に複数の責務を持たせない。
@@ -0,0 +1,24 @@
---
applyTo: "**/settings.py,**/config/**/*.py,**/.env.example,**/*.env.example"
---
# Settings Instructions
## Basic Rules
- 設定は `pydantic_settings.BaseSettings` で一元管理する。
- 開発環境は `.env.dev` を使用する。
- テスト環境と本番環境はOS環境変数を使用する。
- パス系の設定値は `pathlib.Path` で扱う。
- DB接続情報、APIキー、パスワードをコードに直書きしない。
## Naming
- 環境変数のprefixはプロジェクト単位で統一する。
- 例: `APP_`, `XXX1_`, `WEB_XXX1_`
## Validation
- 必須設定が不足した場合は、明確なValidationErrorとして扱う。
- 設定の不足をデフォルト値で隠さない。
- 本番用の秘密情報を `.env` ファイルに残さない。
@@ -0,0 +1,27 @@
---
applyTo: "**/*.sql,**/db/**/*.py,**/repository/**/*.py,**/repositories/**/*.py,**/models/**/*.py"
---
# SQL Server Instructions
## Basic Rules
- SQL Server 用の構文で書く。
- DB接続は `pyodbc` または既存のDB接続クラスを使用する。
- テーブル名・カラム名は既存DDLに合わせる。
- SQLは可能な限り `.sql` ファイルに分離する。
- 認証情報、接続文字列、パスワードをコードに直書きしない。
## Transaction
- INSERT / UPDATE / DELETE では commit / rollback を明示する。
- 例外発生時は rollback する。
- 例外は握りつぶさず、ログに記録して再raiseまたは独自例外に変換する。
## DDL
- 主キーは原則 `IDENTITY(1,1)` を使用する。
- 日時型は特別な理由がなければ `DATETIME2(0)` を使用する。
- 作成日時は `CREATED_AT DATETIME2(0) NOT NULL DEFAULT SYSDATETIME()` を基本にする。
- 検索条件に使う列には INDEX を検討する。
- 重複防止が必要な組み合わせには UNIQUE 制約を検討する。
@@ -0,0 +1,26 @@
---
applyTo: "app.py,pages/**/*.py,**/streamlit/**/*.py"
---
# Streamlit Instructions
## Basic Rules
- UIとロジックを分離する。
- Streamlitページには画面表示、入力受付、結果表示を中心に書く。
- DBアクセス、ファイルI/O、集計処理は `src/` 側に分離する。
- `st.session_state` は専用クラスまたは専用関数で管理する。
- ユーザー向けエラーと内部エラーを分離する。
## Error Handling
- ユーザーに表示するメッセージは分かりやすくする。
- 詳細な例外情報は logging に出力する。
- 例外を握りつぶさない。
- 既存のユーザー向け例外クラスがある場合は、それを優先する。
## Long Running Task
- 長時間処理をStreamlitのリクエスト内で直接実行しない。
- 時間がかかる処理はWorker、Job Queue、外部プロセスへの分離を検討する。
- Streamlit側はジョブ登録とステータス表示を担当する。
+1
View File
@@ -0,0 +1 @@
3.13
+123
View File
@@ -0,0 +1,123 @@
# Project Agent Instructions
## 位置づけ
- このファイルは、`C:\ea_py` プロジェクトで作業するAIエージェント共通の正本とする。
- GitHub Copilot 固有の動作指示は、必要になった時点で `.github/copilot-instructions.md` に分離する。
- ファイル種別・技術領域ごとの詳細指示は、必要に応じて `.github/instructions/``.cursor/rules/``.agents/skills/` に分離する。
- 長文ルールを複数ファイルへ重複記載しない。共通ルールはこのファイルを優先する。
## プロジェクト概要
- このプロジェクトは、MT5のHIT-EAから出力されたOHLC CSVを読み取り、XAUUSD/GOLD向けのH4相場環境判定とH1エントリー候補価格を生成するPython補助アプリケーションである。
- 現行ソースでは、H4相場環境判定はルールベースで行い、H1エントリー候補価格の生成にOpenAI APIを使用する。
- Pythonは直接発注しない。発注、注文管理、M15確定足による最終タイミング判定はMQL5 EA側で行う。
- Pythonの主な責務は以下とする。
- `get_trend_reply.py`: H4 OHLCから相場環境 `market_state` を判定し、`trend_state.txt` を出力する。
- `get_entry_reply.py`: H1 OHLCとH4 `market_state` からエントリー候補 `target_prices.txt` を出力する。
- `bat/*.bat`: MT5 EAからPythonスクリプトを起動する固定エントリーポイント。
- 現在の実行入口はルート直下の `get_trend_reply.py` / `get_entry_reply.py` である。ファイル名や配置を変更する場合は、必ず `bat/` とMQL5 EA側の呼び出し設定も更新する。
## 基本方針
- 読みやすさ、保守性、安全なリファクタリングを優先する。
- 既存の実行契約を壊さない。特にMT5の入出力ファイル名、文字コード、doneファイル作成順序を変更する場合は慎重に行う。
- 不明点がある場合は、既存コード、`docs/architecture/folder-structure.md``docs/refactor/` 配下の設計メモを優先して判断する。
- 新しい仕組みを追加する前に、既存スクリプト内の類似処理を確認する。
- 認証情報、接続文字列、APIキー、パスワードをコードに直書きしない。OpenAI APIキーは `OPENAI_API_KEY` 環境変数から取得する。
- 取引ロジックは利益を保証しない。異常値、読込失敗、API失敗、パース失敗時は必ず新規注文を抑止する安全側へ倒す。
## 取引ロジック上の責務分離
- H4は相場環境判定を担当する。
- `0 = LOW_VOL_RANGE`
- `1 = HIGH_VOL_RANGE`
- `2 = LOW_VOL_UP`
- `3 = HIGH_VOL_UP`
- `4 = LOW_VOL_DOWN`
- `5 = HIGH_VOL_DOWN`
- `6 = TECHNICAL_ERROR_STOP`
- 旧異常ボラ閾値に達した場合も、現行ソースでは停止値6にせず、方向に応じた高ボラstateへ吸収する。
- データ不足、不正な方向値、EATR異常値など技術的に安全判定できない場合のみ、停止値6へ倒す。
- H1はエントリー候補価格の作成を担当する。
- H4 `market_state` と整合する戦略だけを候補にする。
- H4と矛盾する方向、レンジ中央、技術エラー停止、根拠が弱い候補は `0.00` で見送る。
- M15は最終的な発注タイミング確認を担当する。
- M15の判定はMQL5 EA側で行う。
- PythonのH1プロンプトでは、M15の細かな反転を先読みしすぎない。
- Pythonは売買判断の候補を作るだけで、注文送信、ポジション操作、未約定注文キャンセルは行わない。
## 入出力契約
- MT5連携ファイルは原則としてMT5の `MQL5\Files` 配下に置く。
- 入力CSV:
- `ohlc_H4.csv`: H4トレンド判定用。
- `ohlc_H1.csv`: H1エントリー候補生成用。
- CSVは `Time,Open,High,Low,Close` の列を前提にする。
- 出力ファイル:
- `trend_state.txt`: H4 `market_state` を1つの整数で出力する。
- `target_prices.txt`: 13行の数値を出力する。
- `process_done_trend.txt`: `trend_state.txt` の出力完了後に作成する。
- `process_done_entry.txt`: `target_prices.txt` の出力完了後に作成する。
- `target_prices.txt` の形式:
- 1行目: `res_chk`
- 2-4行目: T1 Buy Stop の `entry`, `tp`, `sl`
- 5-7行目: T2 Buy Limit の `entry`, `tp`, `sl`
- 8-10行目: T3 Sell Stop の `entry`, `tp`, `sl`
- 11-13行目: T4 Sell Limit の `entry`, `tp`, `sl`
- MT5が読む出力ファイルは、既存仕様に合わせて `utf-16 LE` を維持する。
- 結果ファイルを書き終えてからdoneファイルを作成する。古いdoneファイルが残った状態で新しい結果を書かない。
## Python
- Python 3.13 を前提にする。
- パッケージ管理と仮想環境管理は `uv` を使用する。
- すべての新規関数に引数と戻り値の型ヒントを付ける。戻り値がない関数は `-> None` とする。
- 変数名・関数名は `snake_case`、クラス名は `PascalCase`、定数名は `UPPER_SNAKE_CASE` を使用する。
- 新規コードでは `pathlib.Path` を優先する。既存の `os.path` は、該当箇所を触るタイミングで段階的に置き換える。
- 新規コードでは `logging` を使用する。既存のbat実行向け `print` は、ロギング整備時に段階的に置き換える。
- magic number は定数化する。
- 例外は握りつぶさない。外部連携失敗を捕捉する場合も、ログを残し、出力は安全側へ倒す。
- DataFrame加工、チャート描画、OpenAI呼び出し、プロンプト生成、パース、ファイルI/Oは関数またはモジュール単位で分離する。
- OpenAI APIのレスポンスは必ずバリデーションする。期待形式に合わない場合は新規注文停止または対象戦略見送りにする。
## フォルダ構成
- 詳細なフォルダ構成は `docs/architecture/folder-structure.md` を正本として参照する。
- 現在の実行入口はルート直下の2スクリプトとする。
- 主要処理は `src/ea_py/` 配下へ移行済みである。今後のリファクタリングでも共通処理は `src/ea_py/` 配下へ追加・整理し、ルート直下スクリプトは互換用の薄いエントリーポイントとして残す。
- テストコードは `tests/` 配下に配置する。
- ドキュメントは `docs/` 配下に配置する。
- 設定ファイルを追加する場合は `config/` 配下に配置する。ただしAPIキーなどの秘密情報は置かない。
- `work/` 配下は作業用・参照用の一時ファイルとして扱い、実行時の正本にしない。
- FastAPI、Streamlit、DB関連のフォルダは、実際にその機能を追加するまで作らない。
## 品質チェック / テスト
- pytest を使用する。
- 変更によりロジックが変わる場合は、テストの追加・修正を検討する。
- Unit TestではOpenAI API、ファイルI/O、現在時刻などの外部依存をmockする。
- Integration Testは `@pytest.mark.integration` を付けて通常実行から分離する。
- 変更後は、影響範囲に応じて以下を確認する。
```bash
uv run python -m py_compile C:/ea_py/get_trend_reply.py C:/ea_py/get_entry_reply.py
uv run ruff check .
uv run ty check
uv run pytest
```
## MQL5 / MT5 連携
- MQL5 EAファイルはこのリポジトリ外のMT5データフォルダに存在する場合がある。
- MT5配下のEAファイルを変更する場合は、変更前にバックアップを作成する。
- Python側の出力仕様を変更する場合は、MQL5側の読込処理も同時に確認する。
- batファイルのパス、Pythonスクリプト名、doneファイル名、出力ファイル名を変更する場合は、EA側の定義も合わせて更新する。
## Logging / Error Handling
- ユーザー表示用エラーと内部ログ用エラーを分離する。
- 内部エラーは詳細をログへ記録する。
- MT5へ返す結果は、EAが安全に処理できる単純な数値ファイルにする。
- APIやCSV読込で失敗した場合は、未定義状態のまま処理を続けず、停止値を出力する。
+195
View File
@@ -0,0 +1,195 @@
# ea_py
MT5 の HIT-EA から出力された OHLC CSV を読み取り、GOLD/XAUUSD 向けの相場環境判定とエントリー候補価格を返す Python 補助アプリケーションです。
Python 側は注文送信を行いません。発注、注文管理、M15 確定足による最終タイミング判定は MT5/MQL5 EA 側が担当します。
## 現在の実行入口
MT5 EA からは `bat/` 経由でルート直下の互換スクリプトを起動します。
```text
C:/ea_py/
├─ get_trend_reply.py
├─ get_entry_reply.py
├─ bat/
│ ├─ get_trend_reply.bat
│ └─ get_entry_reply.bat
└─ src/
└─ ea_py/
```
- `get_trend_reply.py`: `src/ea_py/pipelines/trend_pipeline.py` を起動する薄い入口。
- `get_entry_reply.py`: `src/ea_py/pipelines/entry_pipeline.py` を起動する薄い入口。
- `bat/get_trend_reply.bat`: `uv run python C:\ea_py\get_trend_reply.py` を実行。
- `bat/get_entry_reply.bat`: `uv run python C:\ea_py\get_entry_reply.py` を実行。
入口ファイル名や配置を変える場合は、`bat/` と MQL5 EA 側の呼び出し設定も合わせて更新してください。
## MT5 連携ファイル
既定の連携先は次の MT5 `MQL5/Files` ディレクトリです。
```text
C:/Users/new/AppData/Roaming/MetaQuotes/Terminal/5BDB0B60344C088C2FA5CA35699BAAFD/MQL5/Files/
```
パスの組み立ては `src/ea_py/paths.py` で定義しています。
### 入力
```text
ohlc_H4.csv
ohlc_H1.csv
```
CSV は UTF-8 読み込みで、次の列を必須とします。
```text
Time,Open,High,Low,Close
```
### 出力
```text
trend_state.txt
target_prices.txt
target_zones.txt
process_done_trend.txt
process_done_entry.txt
```
MT5 が読む結果ファイルは `utf-16 LE` で出力します。結果ファイルを書き終えたあとに done ファイルを作成します。古い done ファイルは結果書き込み前に削除されます。
## H4 トレンド判定
`get_trend_reply.py` は H4 OHLC から `trend_state.txt` を生成します。
現在の H4 判定は OpenAI API ではなく、ソースコード上のルールベース判定です。直近 72 本を使い、EMA、レンジ内位置、高安更新、DMI 系方向優位、効率比、EATR ベースのボラティリティを組み合わせて `market_state` を決定します。
```text
0 = LOW_VOL_RANGE
1 = HIGH_VOL_RANGE
2 = LOW_VOL_UP
3 = HIGH_VOL_UP
4 = LOW_VOL_DOWN
5 = HIGH_VOL_DOWN
6 = TECHNICAL_ERROR_STOP
```
`6` は現在の実装では技術エラー停止値です。CSV 読み込み失敗、データ不足、EATR 異常値など、安全側へ倒す必要がある場合に出力されます。
## H1 エントリー候補生成
`get_entry_reply.py``trend_state.txt` と H1 OHLC から `target_prices.txt` を生成します。
処理の概要:
1. H4 の `market_state` を読み込む。
2. `market_state` と整合する戦略だけを選ぶ。
3. H1 の短期 36 本、中期 72 本からチャート PNG と数値要約を作る。
4. H1 インバランス初動を Python 側で判定し、矛盾する候補を抑止する。
5. OpenAI Responses API に候補価格・予測ゾーン生成を依頼する。
6. GPT 出力を既存13行形式と分割エントリー用ゾーン形式へ展開し、価格の大小関係と距離を検証する。
7. `target_prices.txt``target_zones.txt` を書き、両方の完了後に `process_done_entry.txt` を作成する。
H4 状態ごとの許可戦略:
```text
0,1: T2 Buy Limit / T4 Sell Limit
2,3: T1 Buy Stop / T2 Buy Limit
4,5: T3 Sell Stop / T4 Sell Limit
6 : 全停止
```
`target_prices.txt` は 13 行です。
```text
1行目 : res_chk
2-4行目 : T1 Buy Stop の entry, tp, sl
5-7行目 : T2 Buy Limit の entry, tp, sl
8-10行目 : T3 Sell Stop の entry, tp, sl
11-13行目 : T4 Sell Limit の entry, tp, sl
```
有効候補が残らない場合や、CSV/API/パース/検証に失敗した場合は、13 行すべて `0` の停止値を返します。
分割エントリー用の `target_zones.txt` は7行です。
```text
1行目 : schema_version2
2行目 : res_chk
3行目 : candidate_idH1確定足時刻由来)
4-7行目 : strategy, zone_low, zone_high, tp, sl
```
EA側で分割エントリーを有効にした場合は、この予測ゾーンを `split_entry_count` 本に分割してpending注文を出します。ロットは総量分割または1注文固定を選択できます。
## OpenAI 設定
H1 エントリー候補生成では OpenAI API を使用します。
必須:
```powershell
$env:OPENAI_API_KEY = "..."
```
任意:
```powershell
$env:OPENAI_MODEL = "..."
$env:OPENAI_REASONING_EFFORT = "low"
```
`OPENAI_MODEL` 未設定時は `src/ea_py/constants.py``DEFAULT_GPT_MODEL` を使います。`OPENAI_REASONING_EFFORT``none`, `low`, `medium`, `high`, `xhigh` のいずれかです。
## 主なモジュール
```text
src/ea_py/config.py 環境変数から実行時設定を読み込む
src/ea_py/constants.py 共通定数
src/ea_py/paths.py MT5 連携ファイルパス
src/ea_py/io/ohlc_csv.py OHLC CSV 読み込みと検証
src/ea_py/io/mt5_files.py 結果ファイルと done ファイルの書き込み
src/ea_py/charting/candlestick.py ローソク足 PNG 生成
src/ea_py/market/volatility.py True Range / EATR / 数値要約
src/ea_py/market/trend_state.py H4 方向判定と market_state 合成
src/ea_py/market/target_prices.py GPT 出力のパースと価格検証
src/ea_py/market/imbalance.py H1 インバランス初動判定
src/ea_py/openai_client.py OpenAI Responses API ラッパー
src/ea_py/pipelines/ H4/H1 パイプライン
```
詳細なフォルダ構成は `docs/architecture/folder-structure.md` も参照してください。
## 開発
Python 3.13 と `uv` を前提にします。
```powershell
uv sync
```
個別実行:
```powershell
uv run python C:/ea_py/get_trend_reply.py
uv run python C:/ea_py/get_entry_reply.py
```
品質チェック:
```powershell
uv run python -m py_compile C:/ea_py/get_trend_reply.py C:/ea_py/get_entry_reply.py
uv run ruff check .
uv run ty check
uv run pytest
```
## 注意
- API キーや接続情報はコードへ直書きしないでください。
- `work/` 配下は作業用・参照用であり、実行時の正本ではありません。
- Python は候補価格を作るだけで、注文送信、ポジション操作、未約定注文キャンセルは行いません。
- 出力仕様を変える場合は、MQL5 EA 側の読み込み処理も同時に確認してください。
+27
View File
@@ -0,0 +1,27 @@
@echo off
setlocal
set APP_DIR=C:\ea_py
set PY_FILE=get_entry_reply.py
cd /d "%APP_DIR%"
if errorlevel 1 (
echo ERROR: APP_DIR not found: %APP_DIR%
exit /b 1
)
echo Current directory: %CD%
echo Run file: %APP_DIR%\%PY_FILE%
uv run python "%APP_DIR%\%PY_FILE%"
set PY_EXIT_CODE=%ERRORLEVEL%
if not "%PY_EXIT_CODE%"=="0" (
echo ERROR: Python script failed.
exit /b %PY_EXIT_CODE%
)
echo Finished.
exit /b 0
+27
View File
@@ -0,0 +1,27 @@
@echo off
setlocal
set APP_DIR=C:\ea_py
set PY_FILE=get_trend_reply.py
cd /d "%APP_DIR%"
if errorlevel 1 (
echo ERROR: APP_DIR not found: %APP_DIR%
exit /b 1
)
echo Current directory: %CD%
echo Run file: %APP_DIR%\%PY_FILE%
uv run python "%APP_DIR%\%PY_FILE%"
set PY_EXIT_CODE=%ERRORLEVEL%
if not "%PY_EXIT_CODE%"=="0" (
echo ERROR: Python script failed.
exit /b %PY_EXIT_CODE%
)
echo Finished.
exit /b 0
@@ -0,0 +1,229 @@
# Folder Structure
## 目的
このドキュメントは、`C:\ea_py` のフォルダ構成に関する正本である。
現在のプロジェクトは、MT5 HIT-EAから出力されたOHLC CSVをPythonで解析し、OpenAI APIを使ってGOLD/XAUUSD向けの相場環境とエントリー候補価格を返す補助アプリケーションである。
MT5 EAから `bat/` 経由でルート直下のPythonスクリプトが呼ばれているため、現時点ではルート直下の `get_trend_reply.py``get_entry_reply.py` を実行入口として維持する。
## 現在のフォルダ構成
```text
C:/ea_py/
├─ AGENTS.md
├─ README.md
├─ pyproject.toml
├─ uv.lock
├─ get_trend_reply.py
├─ get_entry_reply.py
├─ src/
│ └─ ea_py/
│ ├─ __init__.py
│ ├─ config.py
│ ├─ constants.py
│ ├─ paths.py
│ ├─ types.py
│ ├─ io/
│ │ ├─ __init__.py
│ │ ├─ debug_logs.py
│ │ ├─ mt5_files.py
│ │ └─ ohlc_csv.py
│ ├─ charting/
│ │ ├─ __init__.py
│ │ └─ candlestick.py
│ ├─ market/
│ │ ├─ __init__.py
│ │ ├─ volatility.py
│ │ ├─ trend_state.py
│ │ └─ target_prices.py
│ ├─ prompts/
│ │ ├─ __init__.py
│ │ ├─ trend_prompt.py
│ │ └─ entry_prompt.py
│ ├─ openai_client.py
│ └─ pipelines/
│ ├─ __init__.py
│ ├─ trend_pipeline.py
│ └─ entry_pipeline.py
├─ tests/
│ └─ unit/
│ ├─ test_trend_state.py
│ ├─ test_target_prices.py
│ └─ test_volatility.py
├─ bat/
│ ├─ get_trend_reply.bat
│ └─ get_entry_reply.bat
├─ docs/
│ ├─ architecture/
│ │ └─ folder-structure.md
│ └─ refactor/
│ └─ gold_strategy_20260503.md
└─ work/
├─ get_trend_reply.py
└─ get_entry_reply.py
```
## 現在の各ファイル・フォルダの責務
| パス | 責務 |
|---|---|
| `AGENTS.md` | AIエージェント向けの共通作業方針。 |
| `pyproject.toml` | Pythonバージョン、依存関係、開発ツール定義。 |
| `uv.lock` | `uv` による依存関係ロックファイル。 |
| `get_trend_reply.py` | H4判定パイプラインを呼び出すMT5/bat互換用の薄い実行入口。 |
| `get_entry_reply.py` | H1候補生成パイプラインを呼び出すMT5/bat互換用の薄い実行入口。 |
| `src/ea_py/` | Python補助アプリケーションの正本コード。設定、I/O、チャート生成、相場計算、プロンプト、OpenAI呼び出し、パイプラインを分離して置く。 |
| `tests/unit/` | 外部APIやMT5 I/Oに依存しない純粋ロジックのUnit Test。 |
| `bat/get_trend_reply.bat` | MT5 EAから `get_trend_reply.py` を起動するためのバッチファイル。 |
| `bat/get_entry_reply.bat` | MT5 EAから `get_entry_reply.py` を起動するためのバッチファイル。 |
| `docs/architecture/` | 構成、責務、設計方針の正本を置く。 |
| `docs/refactor/` | 戦略改善案、リファクタリングメモ、検討資料を置く。 |
| `work/` | 作業用・参照用の一時ファイル置き場。実行時の正本として扱わない。 |
## 外部連携ファイル
MT5側の `MQL5\Files` 配下に、Pythonとの連携ファイルが置かれる。
代表例:
```text
C:/Users/new/AppData/Roaming/MetaQuotes/Terminal/{terminal_ID}/MQL5/Files/
├─ ohlc_H4.csv
├─ ohlc_H1.csv
├─ trend_state.txt
├─ target_prices.txt
├─ process_done_trend.txt
├─ process_done_entry.txt
├─ process_running_trend.txt
├─ process_running_entry.txt
├─ debug_trend.txt
├─ debug_entry.txt
├─ tmp_chart_trend.png
├─ tmp_chart_short.png
└─ tmp_chart_long.png
```
これらは実行時生成物であり、原則として `C:\ea_py` 配下へコピーして正本化しない。
## MQL5 EAファイル
MQL5 EAファイルは、MT5データフォルダ配下に存在する。
例:
```text
C:/Users/new/AppData/Roaming/MetaQuotes/Terminal/{terminal_ID}/MQL5/Experts/MyProject/
└─ HIT-EA_refactor_ver3.mq5
```
EAファイルはこのPythonプロジェクトの外部連携先として扱う。Python側の出力形式を変更する場合は、EA側の読込処理も同時に確認する。
## 推奨フォルダ構成
現在は、以下の構成へ移行済みである。今後は必要に応じて、設定ファイルやIntegration Testを追加する。
ルート直下の `get_trend_reply.py``get_entry_reply.py` は、MT5/bat互換のため薄いエントリーポイントとして残す。
```text
C:/ea_py/
├─ AGENTS.md
├─ README.md
├─ pyproject.toml
├─ uv.lock
├─ get_trend_reply.py
├─ get_entry_reply.py
├─ bat/
│ ├─ get_trend_reply.bat
│ └─ get_entry_reply.bat
├─ src/
│ └─ ea_py/
│ ├─ __init__.py
│ ├─ config.py
│ ├─ paths.py
│ ├─ constants.py
│ ├─ types.py
│ ├─ io/
│ │ ├─ __init__.py
│ │ ├─ debug_logs.py
│ │ ├─ mt5_files.py
│ │ └─ ohlc_csv.py
│ ├─ charting/
│ │ ├─ __init__.py
│ │ └─ candlestick.py
│ ├─ market/
│ │ ├─ __init__.py
│ │ ├─ volatility.py
│ │ ├─ trend_state.py
│ │ └─ target_prices.py
│ ├─ prompts/
│ │ ├─ __init__.py
│ │ ├─ trend_prompt.py
│ │ └─ entry_prompt.py
│ ├─ openai_client.py
│ └─ pipelines/
│ ├─ __init__.py
│ ├─ trend_pipeline.py
│ └─ entry_pipeline.py
├─ tests/
│ └─ unit/
│ ├─ test_trend_state.py
│ ├─ test_target_prices.py
│ └─ test_volatility.py
└─ docs/
├─ architecture/
│ └─ folder-structure.md
└─ refactor/
└─ gold_strategy_20260503.md
```
## 推奨モジュール責務
| モジュール | 責務 |
|---|---|
| `src/ea_py/config.py` | 環境変数、モデル名、デバッグ設定などの設定読み込み。 |
| `src/ea_py/paths.py` | MT5データフォルダ、入力CSV、出力ファイルパスの組み立て。 |
| `src/ea_py/constants.py` | `market_state`、ATR期間、出力サイズなどの定数。 |
| `src/ea_py/types.py` | OHLCバー、OHLC要約、13行出力などの型定義。 |
| `src/ea_py/io/mt5_files.py` | doneファイル、runningファイル、atomic writeなどMT5連携I/O。 |
| `src/ea_py/io/ohlc_csv.py` | OHLC CSVの読込、型変換、バリデーション。 |
| `src/ea_py/io/debug_logs.py` | デバッグ理由ログの追記処理。 |
| `src/ea_py/charting/candlestick.py` | ローソク足PNG生成。 |
| `src/ea_py/market/volatility.py` | True Range、Exponential ATR、ボラティリティ分類。 |
| `src/ea_py/market/trend_state.py` | H4方向判定結果とボラティリティ分類の合成。 |
| `src/ea_py/market/target_prices.py` | GPT出力のパース、価格整合性チェック、13行形式への変換。 |
| `src/ea_py/prompts/trend_prompt.py` | H4相場環境判定プロンプトの生成。 |
| `src/ea_py/prompts/entry_prompt.py` | H1候補価格生成プロンプトの生成。 |
| `src/ea_py/openai_client.py` | OpenAI API呼び出しの薄いラッパー。 |
| `src/ea_py/pipelines/trend_pipeline.py` | H4判定処理全体のオーケストレーション。 |
| `src/ea_py/pipelines/entry_pipeline.py` | H1候補生成処理全体のオーケストレーション。 |
## 移行ルール
- まずは既存のルート直下スクリプトの実行契約を壊さない。
- 共通化する処理から `src/ea_py/` へ移し、ルート直下スクリプトは `main()` を呼ぶだけの薄い入口へ近づける。
- 1回の変更で、ファイル移動、ロジック変更、戦略変更を同時に大きく混ぜない。
- ファイル移動を行う場合は、`bat/`、import、テスト、ドキュメントを同時に更新する。
- `work/` 配下のファイルを実装の正本として参照しない。必要な差分だけを確認し、正本へ取り込む。
- FastAPI、Streamlit、SQL Server、OracleDBなどのフォルダは、実際にその機能を追加するまで作らない。
## テスト配置方針
- Unit Testは `tests/unit/` に置く。
- Integration Testは `tests/integration/` に置き、`@pytest.mark.integration` を付ける。
- OpenAI API、MT5ファイルI/O、現在時刻はUnit Testではmockする。
- 重要なテスト対象:
- `market_state` の分類
- ATR/ボラティリティ計算
- GPT出力パース
- `target_prices.txt` の13行契約
- ファイル書込順序とdoneファイル作成順序
- 異常時に安全側へ倒す処理
## 生成物・一時ファイル
- `__pycache__/``.pytest_cache/`、チャートPNG、MT5連携のdone/runningファイルは生成物として扱う。
- 生成物を設計上の正本にしない。
- デバッグログは原因調査に使うが、恒久的な仕様はドキュメントへ反映する。
@@ -0,0 +1,347 @@
# HIT-EA ゴールド向け戦略追加アイデア整理
## 目的
HIT-EA系の戦略に対して、ゴールド(XAUUSD)のようにボラティリティが大きい銘柄向けに追加できそうなアイデアを整理する。
本ファイルでは、以下の4項目に絞って記載する。
- ボラティリティ分類の導入
- 異常ボラティリティ時は停止する
- レンジ逆張りを厳しくする
- 高ボラトレンドでは逆張りを禁止する
現時点では、各提案内容の採用・不採用は判断しない。
後で検証・バックテスト・実装難易度を確認しながら、必要不要を判断する前提とする。
---
# 1. ボラティリティ分類の導入
## 1.1 概要
ゴールドは、通常のFX通貨ペアよりもボラティリティが大きく、同じ「上昇」「下降」「レンジ」でも値動きの強さによって有効な戦略が変わる。
そのため、H4の環境認識に対して、トレンド方向だけでなくボラティリティの高低を組み合わせた分類を導入する。
通常の分類例は以下のようになる。
```text
0 = レンジ
1 = 上昇トレンド
2 = 下降トレンド
```
ゴールド向けには、以下のように分類を拡張する。
```text
0 = 低ボラレンジ
1 = 高ボラレンジ
2 = 低ボラ上昇
3 = 高ボラ上昇
4 = 低ボラ下降
5 = 高ボラ下降
6 = 異常ボラ
```
## 1.2 判定の考え方
ボラティリティ分類では、以下のような情報を使って判定する。
```text
H4_ATR
H1_ATR
過去N本のATR平均
直近ローソク足の値幅
直近ローソク足のヒゲの長さ
スプレッド
```
例として、現在のATRが過去平均より明らかに大きい場合は「高ボラ」、さらに異常に大きい場合は「異常ボラ」と判定する。
```text
current_atr <= atr_ma * 1.2 → 通常ボラ
current_atr > atr_ma * 1.2 → 高ボラ
current_atr > atr_ma * 2.0 → 異常ボラ
```
倍率は仮の値であり、バックテストで調整する前提とする。
## 1.3 戦略選択例
| 環境 | 戦略の考え方 |
|---|---|
| 低ボラレンジ | 取引しない、または小さめのレンジ逆張り |
| 高ボラレンジ | レンジ端からの逆張り。ただし条件を厳しくする |
| 低ボラ上昇 | 押し目買い中心 |
| 高ボラ上昇 | ブレイク買い、浅い押し目買い中心 |
| 低ボラ下降 | 戻り売り中心 |
| 高ボラ下降 | ブレイク売り、浅い戻り売り中心 |
| 異常ボラ | 新規注文停止 |
## 1.4 期待できる効果
トレンド方向だけでなく、値動きの強さに応じて戦略を切り替えられる。
特にゴールドでは、通常ボラと高ボラで値動きの性質が大きく変わるため、同じ上昇トレンドでも以下のように戦略を分けやすくなる。
```text
低ボラ上昇 → 押し目買い中心
高ボラ上昇 → ブレイク買い、浅い押し目買い中心
```
また、異常ボラを明確に分類することで、無理に取引しない判断を入れやすくなる。
---
# 2. 異常ボラティリティ時は停止する
## 2.1 概要
ゴールドは、経済指標、要人発言、地政学リスク、金利関連ニュースなどで急変しやすい。
急変時に通常ロジックで新規注文を出すと、スリッページ、急反転、スプレッド拡大に巻き込まれる可能性がある。
そのため、異常ボラティリティを検知した場合は、新規注文を停止する条件を追加する。
## 2.2 停止条件例
以下のような条件を組み合わせて、異常ボラティリティを判定する。
```text
H1_ATR > 過去20本ATR平均 × 2.0
直近1本の値幅 > ATR × 2.5
現在スプレッド > 通常時の2倍
直近ローソク足のヒゲが極端に長い
短時間で大きく一方向に動いている
```
## 2.3 判定例
```text
if current_atr > atr_ma * 2.0:
新規注文停止
```
または、複数条件を使って停止判定を行う。
```text
異常ボラ判定 = false
if current_atr > atr_ma * 2.0:
異常ボラ判定 = true
if latest_candle_range > current_atr * 2.5:
異常ボラ判定 = true
if current_spread > average_spread * 2.0:
異常ボラ判定 = true
if 異常ボラ判定:
新規注文停止
```
## 2.4 停止時の処理方針
異常ボラティリティ時は、新規注文だけを停止し、既存ポジションは別ロジックで管理する。
```text
新規注文は出さない
未約定注文はキャンセル候補にする
既存ポジションは管理を継続する
追加エントリーは禁止する
必要に応じてSLを建値へ移動する
```
## 2.5 注意点
異常ボラ停止の条件を厳しくしすぎると、ゴールドがよく動く時間帯のチャンスを逃す可能性がある。
逆に条件を緩くしすぎると、急変相場で停止できない可能性がある。
そのため、最初はログ出力を行い、実際の相場で以下を確認してから閾値を調整する。
```text
通常時のATR
高ボラ時のATR
急変時のATR
通常時のスプレッド
急変時のスプレッド
直近足の平均的な値幅
```
## 2.6 期待できる効果
急騰急落の途中で無理にエントリーすることを防げる。
ゴールドでは特に重要度が高いリスク回避機能である。
---
# 3. レンジ逆張りを厳しくする
## 3.1 概要
ゴールドのレンジは振れ幅が大きく、一見レンジに見えてもそのままブレイクすることがある。
そのため、単純に以下のような判断だけで逆張りするのは危険である。
```text
レンジ上限だから売り
レンジ下限だから買い
```
ゴールド向けには、レンジ端に到達したことに加えて、反転の根拠やブレイク回避条件を追加する。
## 3.2 買い逆張り条件例
```text
H4 = レンジ
価格がレンジ下限付近
H1で下ヒゲが出た
直近安値を大きく割っていない
SLまでの距離がATR基準内
直近足の値幅が異常に大きくない
ブレイク直後ではない
```
## 3.3 売り逆張り条件例
```text
H4 = レンジ
価格がレンジ上限付近
H1で上ヒゲが出た
直近高値を大きく超えていない
SLまでの距離がATR基準内
直近足の値幅が異常に大きくない
ブレイク直後ではない
```
## 3.4 見送り条件例
レンジ内であっても、以下のような場合は逆張りを見送る。
```text
価格がレンジ中央付近にある
レンジ上限・下限まで十分近くない
直近足の値幅が大きすぎる
上限・下限を強くブレイクした直後
スプレッドが広い
異常ボラティリティ判定中
H4の環境がトレンドへ変化し始めている
```
## 3.5 戦略選択例
| H4環境 | 逆張り方針 |
|---|---|
| 低ボラレンジ | 条件を満たす場合のみ逆張り候補 |
| 高ボラレンジ | レンジ端のみ逆張り候補。ただし条件をより厳しくする |
| 高ボラ上昇 | 売り逆張りは禁止 |
| 高ボラ下降 | 買い逆張りは禁止 |
| 異常ボラ | 新規注文停止 |
## 3.6 期待できる効果
レンジブレイクに巻き込まれる逆張りを減らせる。
単純なレンジ上限・下限だけで判断するより、ゴールド特有の大きな振れに対応しやすくなる。
---
# 4. 高ボラトレンドでは逆張りを禁止する
## 4.1 概要
ゴールドで強いトレンドが出ている場合、逆張りは大きな損失につながりやすい。
特に高ボラティリティを伴う上昇トレンドや下降トレンドでは、一方向に大きく伸びることがある。
そのため、高ボラトレンド中はトレンドに逆らう注文を禁止する。
## 4.2 基本方針
高ボラ上昇では、売り逆張りを禁止する。
```text
H4 = 高ボラ上昇
→ 逆張り売りは禁止
→ ブレイク買い、浅い押し目買いのみ許可
```
高ボラ下降では、買い逆張りを禁止する。
```text
H4 = 高ボラ下降
→ 逆張り買いは禁止
→ ブレイク売り、浅い戻り売りのみ許可
```
## 4.3 条件例
高ボラ上昇トレンドの判定例。
```text
H4 = 上昇
H4_ATRが平均以上
H1の短期MAが上向き
H1終値が短期MAより上
直近高値を更新している
```
高ボラ下降トレンドの判定例。
```text
H4 = 下降
H4_ATRが平均以上
H1の短期MAが下向き
H1終値が短期MAより下
直近安値を更新している
```
## 4.4 許可する戦略例
| H4環境 | 許可する戦略 |
|---|---|
| 高ボラ上昇 | ブレイク買い、浅い押し目買い |
| 高ボラ下降 | ブレイク売り、浅い戻り売り |
| 高ボラレンジ | レンジ端のみ逆張り |
| 異常ボラ | 新規停止 |
## 4.5 禁止する戦略例
| H4環境 | 禁止する戦略 |
|---|---|
| 高ボラ上昇 | 逆張り売り |
| 高ボラ下降 | 逆張り買い |
| 異常ボラ | すべての新規注文 |
## 4.6 期待できる効果
強いトレンド中の危険な逆張りを減らせる。
ゴールドでは一方向に大きく伸びる局面があるため、逆方向の注文を制限することで大きな損失を避けやすくなる。
---
# 5. 全体方針
HIT-EAの基本構造は維持する。
```text
H4で環境認識
H1で候補作成
1時間以内に約定しなければキャンセル
環境に合わない戦略は出さない
```
ゴールド向けには、以下の4点を中心に検討する。
```text
ボラティリティ分類を導入する
異常ボラでは新規注文を停止する
レンジ逆張り条件を厳しくする
高ボラトレンド中の逆張りを禁止する
```
この方針により、ゴールド特有の大きな値動きに対して、攻める場面と止める場面を分けやすくする。
+21
View File
@@ -0,0 +1,21 @@
"""MT5/bat互換のH1エントリー候補生成入口。"""
from __future__ import annotations
import sys
from pathlib import Path
def main() -> None:
"""src配下のH1エントリー候補生成パイプラインを起動する。"""
src_path = Path(__file__).resolve().parent / "src"
if str(src_path) not in sys.path:
sys.path.insert(0, str(src_path))
from ea_py.pipelines.entry_pipeline import main as run_main
run_main()
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
"""MT5/bat互換のH4トレンド判定入口。"""
from __future__ import annotations
import sys
from pathlib import Path
def main() -> None:
"""src配下のH4トレンド判定パイプラインを起動する。"""
src_path = Path(__file__).resolve().parent / "src"
if str(src_path) not in sys.path:
sys.path.insert(0, str(src_path))
from ea_py.pipelines.trend_pipeline import main as run_main
run_main()
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
[project]
name = "ea_py"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13,<3.14"
dependencies = [
"fastapi>=0.136.1",
"markitdown[all]>=0.1.5",
"matplotlib>=3.10.9",
"openai>=2.33.0",
"pandera>=0.30.1",
"pyodbc>=5.3.0",
"pytest>=9.0.2",
"pytest-mock>=3.15.1",
"python-dotenv>=1.2.2",
"requests>=2.32.5",
"ruff>=0.15.7",
"streamlit>=1.55.0",
"streamlit-aggrid>=1.2.1.post2",
"streamlit-option-menu>=0.4.0",
"ty>=0.0.24",
"uvicorn>=0.46.0",
]
[dependency-groups]
dev = [
"ipykernel>=7.2.0",
]
[tool.pytest.ini_options]
pythonpath = ["src"]
[tool.ruff]
exclude = ["work"]
+2
View File
@@ -0,0 +1,2 @@
"""HIT-EA Python helper package."""
@@ -0,0 +1,2 @@
"""Chart rendering helpers."""
@@ -0,0 +1,81 @@
"""ローソク足チャート画像の生成とdata URL変換。"""
from __future__ import annotations
import base64
from pathlib import Path
from typing import Sequence
import matplotlib.pyplot as plt
from ea_py.types import OhlcBar
def ohlc_to_candlestick_png_file(
*,
ohlc_data: Sequence[OhlcBar],
save_path: Path,
instrument: str = "GOLD",
timeframe: str = "H4",
dark: bool = True,
dpi: int = 180,
figsize: tuple[int, int] = (12, 4),
) -> None:
"""OHLCバー配列をローソク足PNGとして保存する。"""
if not ohlc_data:
raise ValueError("ohlc_data is empty")
if dark:
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
ax.grid(True, linestyle=":", linewidth=0.6, alpha=0.6)
candle_w = 0.55
wick_lw = 1.0
for index, bar in enumerate(ohlc_data):
open_price = bar["Open"]
high_price = bar["High"]
low_price = bar["Low"]
close_price = bar["Close"]
is_up = close_price >= open_price
color = "#00ff66" if is_up else "#ff3355"
ax.vlines(index, low_price, high_price, linewidth=wick_lw, color=color)
body_low = min(open_price, close_price)
body_height = max(abs(close_price - open_price), 1e-8)
rect = plt.Rectangle(
(index - candle_w / 2, body_low),
candle_w,
body_height,
color=color,
alpha=0.95,
)
ax.add_patch(rect)
ax.yaxis.tick_right()
ax.yaxis.set_label_position("right")
dt_labels = [bar["DateTime"] for bar in ohlc_data]
candle_count = len(ohlc_data)
show_idx = [0, candle_count // 2, candle_count - 1] if candle_count >= 3 else list(range(candle_count))
ax.set_xticks(show_idx)
ax.set_xticklabels([dt_labels[i] for i in show_idx], fontsize=8)
ax.set_xlim(-1, candle_count)
ax.set_title(f"{instrument} {timeframe} ({candle_count} candles)", fontsize=10)
plt.tight_layout()
fig.savefig(save_path, format="png")
plt.close(fig)
def png_file_to_data_url(path: Path) -> str:
"""PNGファイルをOpenAI Vision入力用のdata URLへ変換する。"""
encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
return f"data:image/png;base64,{encoded}"
+56
View File
@@ -0,0 +1,56 @@
"""環境変数から実行時設定を読み込む。"""
from __future__ import annotations
from dataclasses import dataclass
import os
from ea_py.constants import DEBUG_PRINT, DEFAULT_GPT_MODEL, DEFAULT_REASONING_EFFORT, VALID_REASONING_EFFORTS
@dataclass(frozen=True)
class RuntimeConfig:
"""OpenAI呼び出しに必要な実行時設定。"""
api_key: str
model: str
reasoning_effort: str
debug_print: bool
def load_runtime_config(
*,
model: str | None = None,
reasoning_effort: str | None = None,
debug_print: bool = DEBUG_PRINT,
) -> RuntimeConfig:
"""OpenAI呼び出しに必要な実行時設定を読み込む。
APIキーは秘密情報のためコードや設定ファイルへ直書きせず、
`OPENAI_API_KEY` 環境変数からのみ取得する。
未設定の場合は `RuntimeError` を送出し、上位パイプラインで停止値を出力する。
`model` / `reasoning_effort` / `debug_print` は呼び出し側から上書き可能だが、
通常は `constants.py` のデフォルト値を使う。
`OPENAI_MODEL` と `OPENAI_REASONING_EFFORT` が設定されている場合は、
引数未指定時の実行時上書きとして扱う。
"""
api_key = os.getenv("OPENAI_API_KEY")
if api_key is None:
raise RuntimeError("OPENAI_API_KEY が環境変数に設定されていません。")
selected_model = model or os.getenv("OPENAI_MODEL") or DEFAULT_GPT_MODEL
selected_reasoning_effort = reasoning_effort or os.getenv("OPENAI_REASONING_EFFORT") or DEFAULT_REASONING_EFFORT
if selected_reasoning_effort not in VALID_REASONING_EFFORTS:
allowed = ", ".join(sorted(VALID_REASONING_EFFORTS))
raise RuntimeError(
f"OPENAI_REASONING_EFFORT が不正です: {selected_reasoning_effort!r}. "
f"allowed={allowed}"
)
return RuntimeConfig(
api_key=api_key,
model=selected_model,
reasoning_effort=selected_reasoning_effort,
debug_print=debug_print,
)
+54
View File
@@ -0,0 +1,54 @@
"""HIT-EA連携処理で共有する定数。"""
from __future__ import annotations
MT_ENCODING = "utf-16 LE"
DEFAULT_GPT_MODEL = "gpt-5.5-2026-04-23"
DEFAULT_REASONING_EFFORT = "low"
VALID_REASONING_EFFORTS = frozenset({"none", "low", "medium", "high", "xhigh"})
DEBUG_PRINT = True
ATR_PERIOD = 14
ATR_BASELINE_PERIOD = 20
HIGH_VOL_MULTIPLIER = 1.20
ABNORMAL_VOL_MULTIPLIER = 2.00
ABNORMAL_RANGE_MULTIPLIER = 2.50
MIN_VOL_BARS = ATR_PERIOD + 1
USE_IMBALANCE_FILTER = True
IMBALANCE_AVG_BODY_PERIOD = 20
IMBALANCE_SENSITIVITY = 2.5
IMBALANCE_MIN_AVG_BODY_SIZE = 0.01
ENTRY_MAX_DISTANCE_LIMIT_EATR_MULTIPLIER = 1.00
ENTRY_MAX_DISTANCE_STOP_EATR_MULTIPLIER = 1.50
ENTRY_MAX_DISTANCE_MIN_PRICE = 5.0
CANDLE_TREND = 72
CANDLE_SHORT = 36
CANDLE_LONG = 72
INSTRUMENT = "GOLD"
TREND_TIMEFRAME = "H4"
ENTRY_TIMEFRAME = "H1"
TARGET_SIZE = 13
TECHNICAL_ERROR_STOP = 6
TREND_MAX_OUTPUT_TOKENS = 128
TREND_DEBUG_MAX_OUTPUT_TOKENS = 512
ENTRY_MAX_OUTPUT_TOKENS = 512
ENTRY_DEBUG_MAX_OUTPUT_TOKENS = 1200
MARKET_STATE_LABELS: dict[int, str] = {
0: "LOW_VOL_RANGE",
1: "HIGH_VOL_RANGE",
2: "LOW_VOL_UP",
3: "HIGH_VOL_UP",
4: "LOW_VOL_DOWN",
5: "HIGH_VOL_DOWN",
6: "TECHNICAL_ERROR_STOP",
}
VALID_MARKET_STATES = frozenset(MARKET_STATE_LABELS)
+2
View File
@@ -0,0 +1,2 @@
"""I/O helpers for MT5 integration files."""
+105
View File
@@ -0,0 +1,105 @@
"""デバッグ理由ログをMT5 Files配下へ追記する。"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Sequence
from ea_py.constants import MARKET_STATE_LABELS
def now_str() -> str:
"""デバッグログ用の現在時刻文字列を返す。"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def append_debug_trend(
*,
path: Path,
model: str,
reasoning_effort: str,
max_output_tokens: int,
api_diagnostics: str,
current_price: float,
numeric_summary: str,
direction_numeric: str,
market_state: int,
classification_reason: str,
reason_text: str,
) -> None:
"""H4トレンド判定の数値要約、方向出力、分類理由を追記する。"""
with path.open("a", encoding="utf-8") as file:
file.write("=" * 60 + "\n")
file.write(f"DEBUG TIME : {now_str()}\n")
file.write(f"MODEL : {model}\n")
file.write(f"REASONING : {reasoning_effort}\n")
file.write(f"MAX TOKENS : {max_output_tokens}\n")
file.write(f"CURRENT PRICE : {current_price:.2f}\n")
file.write(f"MARKET STATE : {market_state} ({MARKET_STATE_LABELS.get(market_state, 'UNKNOWN')})\n")
file.write("=" * 60 + "\n\n")
file.write("---- API DIAGNOSTICS START ----\n")
file.write((api_diagnostics or "").strip() + "\n")
file.write("---- API DIAGNOSTICS END ----\n\n")
file.write("---- NUMERIC SUMMARY START ----\n")
file.write((numeric_summary or "").strip() + "\n")
file.write("---- NUMERIC SUMMARY END ----\n\n")
file.write("---- DIRECTION NUMERIC (0/1/2) START ----\n")
file.write((direction_numeric or "").strip() + "\n")
file.write("---- DIRECTION NUMERIC (0/1/2) END ----\n\n")
file.write("---- MARKET STATE CLASSIFICATION START ----\n")
file.write((classification_reason or "").strip() + "\n")
file.write("---- MARKET STATE CLASSIFICATION END ----\n\n")
file.write("---- REASON START ----\n")
file.write((reason_text or "").strip() + "\n")
file.write("---- REASON END ----\n\n")
def append_debug_entry(
*,
path: Path,
model: str,
reasoning_effort: str,
max_output_tokens: int,
api_diagnostics: str,
timeframe: str,
current_price: float,
trend_state: int,
selected_strategies: Sequence[int],
imbalance_summary: str,
numeric_summary: str,
numeric_lines: str,
post_filter_summary: str,
sanitized_numeric_list: Sequence[int | float] | None,
reason_text: str,
) -> None:
"""H1エントリー候補生成の数値行と理由を追記する。"""
with path.open("a", encoding="utf-8") as file:
file.write("=" * 60 + "\n")
file.write(f"DEBUG TIME : {now_str()}\n")
file.write(f"MODEL : {model}\n")
file.write(f"REASONING : {reasoning_effort}\n")
file.write(f"MAX TOKENS : {max_output_tokens}\n")
file.write(f"TIMEFRAME : {timeframe}\n")
file.write(f"MARKET_STATE(H4) : {trend_state} ({MARKET_STATE_LABELS.get(trend_state, 'UNKNOWN')})\n")
file.write(f"SELECTED_STRATEGY : {','.join(str(x) for x in selected_strategies)}\n")
file.write(f"IMBALANCE(H1) : {imbalance_summary}\n")
file.write(f"CURRENT PRICE : {current_price:.2f}\n")
file.write("=" * 60 + "\n\n")
file.write("---- API DIAGNOSTICS START ----\n")
file.write((api_diagnostics or "").strip() + "\n")
file.write("---- API DIAGNOSTICS END ----\n\n")
file.write("---- NUMERIC SUMMARY START ----\n")
file.write((numeric_summary or "").strip() + "\n")
file.write("---- NUMERIC SUMMARY END ----\n\n")
file.write("---- GPT NUMERIC LINES START ----\n")
file.write((numeric_lines or "").strip() + "\n")
file.write("---- GPT NUMERIC LINES END ----\n\n")
file.write("---- POST FILTER START ----\n")
file.write((post_filter_summary or "").strip() + "\n")
if sanitized_numeric_list is not None:
file.write("sanitized_numeric_list=" + ",".join(str(x) for x in sanitized_numeric_list) + "\n")
file.write("---- POST FILTER END ----\n\n")
file.write("---- REASON START ----\n")
file.write((reason_text or "").strip() + "\n")
file.write("---- REASON END ----\n\n")
+66
View File
@@ -0,0 +1,66 @@
"""MT5連携ファイルの読み書き補助。"""
from __future__ import annotations
from pathlib import Path
from typing import Collection
def write_text_atomic(path: Path, text: str, encoding: str) -> None:
"""一時ファイルへ書いてから対象ファイルへ置換する。"""
tmp_path = path.with_name(f"{path.name}.tmp")
tmp_path.write_text(text, encoding=encoding, newline="")
tmp_path.replace(path)
def write_result_then_done(
*,
result_path: Path,
result_text: str,
result_encoding: str,
done_path: Path,
) -> None:
"""MT5へ結果ファイルの完成を通知する順序で書き込む。
手順は必ず以下の順序にする。
1. 古いdoneファイルを削除する。
2. 結果ファイルを一時ファイルへ書き、atomic replaceで本番名へ置換する。
3. 結果ファイルが完成した後にdoneファイルを作成する。
MT5 EAはdoneファイルを完了シグナルとして扱うため、古いdoneが残った状態で
新しい結果を書き始めないことが重要。結果書き込みに失敗した場合はdoneを作らず、
EA側が未完成の結果を読むリスクを避ける。
"""
done_path.unlink(missing_ok=True)
write_text_atomic(result_path, result_text, result_encoding)
write_text_atomic(done_path, "", "utf-8")
def write_results_then_done(
*,
result_files: Collection[tuple[Path, str, str]],
done_path: Path,
) -> None:
"""複数の結果ファイルを書き終えてからdoneファイルを作成する。"""
done_path.unlink(missing_ok=True)
for result_path, result_text, result_encoding in result_files:
write_text_atomic(result_path, result_text, result_encoding)
write_text_atomic(done_path, "", "utf-8")
def read_int_file(
path: Path,
*,
encoding: str,
allowed_values: Collection[int],
default: int,
) -> int:
"""整数ファイルを読み、許可値以外や失敗時はdefaultを返す。"""
try:
if not path.exists():
return default
value = int(path.read_text(encoding=encoding).strip())
return value if value in allowed_values else default
except Exception:
return default
+48
View File
@@ -0,0 +1,48 @@
"""MT5が出力したOHLC CSVを読み込む。"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from ea_py.types import OhlcBar
REQUIRED_COLUMNS = ("Time", "Open", "High", "Low", "Close")
def read_ohlc_csv(path: Path) -> list[OhlcBar]:
"""MT5が出力したOHLC CSVを検証してOhlcBar配列へ変換する。
入力CSVは `Time,Open,High,Low,Close` 列を必須とする。
`Time` は文字列、価格列はfloatへ変換し、内部表現では既存プロンプトと
チャート生成処理に合わせて `Time` を `DateTime` キーへ写す。
必須列が欠けている場合や価格列のfloat変換に失敗した場合は例外を送出する。
上位パイプラインはその例外を捕捉し、MT5へ停止値を返す。
"""
df = pd.read_csv(path, encoding="utf-8")
missing_columns = [column for column in REQUIRED_COLUMNS if column not in df.columns]
if missing_columns:
joined = ", ".join(missing_columns)
raise ValueError(f"OHLC CSV missing column(s): {joined}")
df["Time"] = df["Time"].astype(str)
df["Open"] = df["Open"].astype(float)
df["High"] = df["High"].astype(float)
df["Low"] = df["Low"].astype(float)
df["Close"] = df["Close"].astype(float)
ohlc: list[OhlcBar] = []
for _, row in df.iterrows():
ohlc.append(
{
"DateTime": row["Time"],
"Open": float(row["Open"]),
"High": float(row["High"]),
"Low": float(row["Low"]),
"Close": float(row["Close"]),
}
)
return ohlc
+130
View File
@@ -0,0 +1,130 @@
"""OpenAI Responses API呼び出しの薄いラッパー。"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import json
import logging
from typing import Any
from openai import OpenAI
from openai.types.responses import (
EasyInputMessageParam,
ResponseInputImageParam,
ResponseInputMessageContentListParam,
ResponseInputParam,
ResponseInputTextParam,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ResponsesApiDiagnostics:
"""Responses APIの空出力診断に必要な最小メタデータ。"""
model: str
status: str
incomplete_details: str
error: str
usage: str
def to_log_text(self) -> str:
"""デバッグログへそのまま書ける1行診断文字列を返す。"""
parts = [
f"model={self.model or '-'}",
f"status={self.status or '-'}",
f"incomplete_details={self.incomplete_details or '-'}",
f"error={self.error or '-'}",
f"usage={self.usage or '-'}",
]
return "; ".join(parts)
@dataclass(frozen=True)
class ResponsesApiResult:
"""Responses APIの本文と診断情報。"""
text: str
diagnostics: ResponsesApiDiagnostics
def create_openai_client(api_key: str) -> OpenAI:
"""APIキーからOpenAIクライアントを生成する。"""
return OpenAI(api_key=api_key)
def _format_response_field(value: object) -> str:
"""SDKオブジェクトを短いJSON文字列へ変換する。"""
if value is None:
return ""
try:
if hasattr(value, "model_dump"):
dumped = value.model_dump(mode="json", exclude_none=True) # type: ignore[attr-defined]
return json.dumps(dumped, ensure_ascii=False, separators=(",", ":"))
return str(value)
except Exception:
return repr(value)
def _extract_response_diagnostics(response: object) -> ResponsesApiDiagnostics:
"""Responses APIレスポンスから安全な診断情報を抽出する。"""
return ResponsesApiDiagnostics(
model=str(getattr(response, "model", "") or ""),
status=str(getattr(response, "status", "") or ""),
incomplete_details=_format_response_field(getattr(response, "incomplete_details", None)),
error=_format_response_field(getattr(response, "error", None)),
usage=_format_response_field(getattr(response, "usage", None)),
)
def call_responses_api(
*,
client: OpenAI,
model: str,
reasoning_effort: str,
system_content: str,
user_text: str,
image_data_urls: Sequence[str],
max_output_tokens: int,
) -> ResponsesApiResult:
"""Responses APIへテキストとチャート画像を送り、出力テキストを返す。
`system_content` は判定器としての役割と出力制約を指定する。
`user_text` はH4/H1の具体的な分析依頼、数値要約、出力フォーマットを含む。
`image_data_urls` にはPNGをdata URL化したチャート画像を渡す。空文字は無視する。
戻り値は `response.output_text` をstripした文字列とAPI診断情報。
API例外や空/不正な出力の安全側処理は、この薄いラッパーではなく
呼び出し元のパイプラインとパース関数が担当する。
"""
text_part: ResponseInputTextParam = {"type": "input_text", "text": user_text}
content_parts: ResponseInputMessageContentListParam = [text_part]
for image_data_url in image_data_urls:
if image_data_url:
image_part: ResponseInputImageParam = {
"type": "input_image",
"image_url": image_data_url,
"detail": "auto",
}
content_parts.append(image_part)
system_message: EasyInputMessageParam = {"role": "system", "content": system_content}
user_message: EasyInputMessageParam = {"role": "user", "content": content_parts}
input_messages: ResponseInputParam = [system_message, user_message]
create_params: dict[str, Any] = {
"model": model,
"input": input_messages,
"reasoning": {"effort": reasoning_effort},
"max_output_tokens": max_output_tokens,
}
response = client.responses.create(**create_params)
diagnostics = _extract_response_diagnostics(response)
text = (response.output_text or "").strip()
if not text:
logger.warning("OpenAI response output_text is empty: %s", diagnostics.to_log_text())
return ResponsesApiResult(text=text, diagnostics=diagnostics)
+86
View File
@@ -0,0 +1,86 @@
"""MT5連携ファイルのパスを組み立てる。"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
DEFAULT_USER_NAME = "new"
DEFAULT_TERMINAL_ID = "5BDB0B60344C088C2FA5CA35699BAAFD"
@dataclass(frozen=True)
class Mt5PathSettings:
"""MT5データフォルダを特定するための設定。"""
user_name: str = DEFAULT_USER_NAME
terminal_id: str = DEFAULT_TERMINAL_ID
@dataclass(frozen=True)
class TrendPaths:
"""H4トレンド判定パイプラインが使用するファイル群。"""
input_csv: Path
trend_state: Path
done_trend: Path
tmp_chart: Path
debug_reason: Path
@dataclass(frozen=True)
class EntryPaths:
"""H1エントリー候補生成パイプラインが使用するファイル群。"""
input_csv: Path
output_prices: Path
output_zones: Path
trend_state: Path
done_entry: Path
tmp_short_chart: Path
tmp_long_chart: Path
debug_reason: Path
def terminal_files_dir(settings: Mt5PathSettings | None = None) -> Path:
"""MT5のMQL5/Filesディレクトリを返す。"""
mt5_settings = settings or Mt5PathSettings()
return (
Path("C:/Users")
/ mt5_settings.user_name
/ "AppData"
/ "Roaming"
/ "MetaQuotes"
/ "Terminal"
/ mt5_settings.terminal_id
/ "MQL5"
/ "Files"
)
def build_trend_paths(settings: Mt5PathSettings | None = None) -> TrendPaths:
"""H4トレンド判定用の入出力パスをまとめて返す。"""
base_dir = terminal_files_dir(settings)
return TrendPaths(
input_csv=base_dir / "ohlc_H4.csv",
trend_state=base_dir / "trend_state.txt",
done_trend=base_dir / "process_done_trend.txt",
tmp_chart=base_dir / "tmp_chart_trend.png",
debug_reason=base_dir / "debug_trend.txt",
)
def build_entry_paths(settings: Mt5PathSettings | None = None) -> EntryPaths:
"""H1エントリー候補生成用の入出力パスをまとめて返す。"""
base_dir = terminal_files_dir(settings)
return EntryPaths(
input_csv=base_dir / "ohlc_H1.csv",
output_prices=base_dir / "target_prices.txt",
output_zones=base_dir / "target_zones.txt",
trend_state=base_dir / "trend_state.txt",
done_entry=base_dir / "process_done_entry.txt",
tmp_short_chart=base_dir / "tmp_chart_short.png",
tmp_long_chart=base_dir / "tmp_chart_long.png",
debug_reason=base_dir / "debug_entry.txt",
)
@@ -0,0 +1,2 @@
"""Executable pipeline orchestration."""
@@ -0,0 +1,317 @@
"""H1 OHLCとH4 market_stateからtarget_pricesを生成するパイプライン。"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from ea_py.charting.candlestick import ohlc_to_candlestick_png_file, png_file_to_data_url
from ea_py.config import load_runtime_config
from ea_py.constants import (
CANDLE_LONG,
CANDLE_SHORT,
DEBUG_PRINT,
ENTRY_DEBUG_MAX_OUTPUT_TOKENS,
ENTRY_MAX_DISTANCE_LIMIT_EATR_MULTIPLIER,
ENTRY_MAX_DISTANCE_MIN_PRICE,
ENTRY_MAX_DISTANCE_STOP_EATR_MULTIPLIER,
ENTRY_MAX_OUTPUT_TOKENS,
ENTRY_TIMEFRAME,
IMBALANCE_AVG_BODY_PERIOD,
IMBALANCE_MIN_AVG_BODY_SIZE,
IMBALANCE_SENSITIVITY,
INSTRUMENT,
MARKET_STATE_LABELS,
MT_ENCODING,
TECHNICAL_ERROR_STOP,
USE_IMBALANCE_FILTER,
)
from ea_py.io.debug_logs import append_debug_entry
from ea_py.io.mt5_files import read_int_file, write_results_then_done
from ea_py.io.ohlc_csv import read_ohlc_csv
from ea_py.market.volatility import summarize_ohlc
from ea_py.market.target_prices import (
extract_entry_blocks_debug,
stop_numeric_list,
strategies_by_trend,
)
from ea_py.market.target_zones import (
build_candidate_id,
format_target_zones,
parse_lines_to_entry_zones_allow_subset,
sanitize_entry_zones,
stop_entry_zones,
zones_to_numeric_list,
)
from ea_py.market.imbalance import (
adjust_strategies_for_imbalance,
detect_imbalance_signal,
format_imbalance_summary,
)
from ea_py.openai_client import call_responses_api, create_openai_client
from ea_py.paths import build_entry_paths
from ea_py.prompts.entry_prompt import (
build_imbalance_guidance,
build_caution_block,
build_common_rules_block,
build_common_rules_block_debug,
build_header,
build_market_state_guidance,
build_numeric_summary,
build_system_content_block,
build_system_content_block_debug,
)
from ea_py.types import NumericList
logger = logging.getLogger(__name__)
TREND_STOP_STRATEGIES = frozenset({1, 3})
def write_entry_output(numeric_list: NumericList, zone_text: str | None = None) -> None:
"""target_prices.txtとtarget_zones.txtを書き、完了後にprocess_done_entry.txtを作る。"""
paths = build_entry_paths()
content = "".join(f"{number}\n" for number in numeric_list)
zone_content = zone_text if zone_text is not None else format_target_zones(stop_entry_zones(), "0")
write_results_then_done(
result_files=[
(paths.output_prices, content, MT_ENCODING),
(paths.output_zones, zone_content, MT_ENCODING),
],
done_path=paths.done_entry,
)
def build_entry_distance_limits(h1_eatr: float, selected_strategies: Sequence[int]) -> dict[int, float]:
"""H1 EATRから戦略別のエントリー許容距離を作る。"""
limits: dict[int, float] = {}
for strategy in selected_strategies:
multiplier = (
ENTRY_MAX_DISTANCE_STOP_EATR_MULTIPLIER
if strategy in TREND_STOP_STRATEGIES
else ENTRY_MAX_DISTANCE_LIMIT_EATR_MULTIPLIER
)
limits[strategy] = max(h1_eatr * multiplier, ENTRY_MAX_DISTANCE_MIN_PRICE)
return limits
def run_pipeline() -> None:
"""H1 OHLCとH4 market_stateからMT5向けtarget_pricesを生成する。
実行契約:
- `trend_state.txt` のH4 `market_state` を読み許可戦略だけを選ぶ
- `ohlc_H1.csv` から短期中期チャート画像と数値要約を作る
- OpenAIには選択済み戦略の候補価格だけを依頼し返却行を13値形式へ展開する
- GPT出力は `sanitize_numeric_list` で検証し`target_prices.txt` を書いた後に
`process_done_entry.txt` を作成する
安全側フォールバック:
H4が技術エラー停止値CSV読込失敗H1データ不足画像生成失敗OpenAI設定/API失敗
GPT出力不正または全候補が価格条件違反の場合は13値すべてを停止値にする
Pythonは候補価格を作るだけで注文送信やポジション操作は行わない
"""
paths = build_entry_paths()
trend_state = read_int_file(
paths.trend_state,
encoding=MT_ENCODING,
allowed_values=MARKET_STATE_LABELS.keys(),
default=TECHNICAL_ERROR_STOP,
)
selected_strategies = strategies_by_trend(trend_state)
if trend_state == TECHNICAL_ERROR_STOP or not selected_strategies:
logger.info(
"market_state=%s (%s). New entries stopped.",
trend_state,
MARKET_STATE_LABELS.get(trend_state, "UNKNOWN"),
)
write_entry_output(stop_numeric_list())
return
try:
ohlc_all = read_ohlc_csv(paths.input_csv)
except Exception:
logger.exception("CSV 読み込みエラー")
write_entry_output(stop_numeric_list())
return
if len(ohlc_all) < CANDLE_LONG:
logger.error("データ本数不足: len(ohlc)=%s (need >= %s)", len(ohlc_all), CANDLE_LONG)
write_entry_output(stop_numeric_list())
return
ohlc_short = ohlc_all[-CANDLE_SHORT:]
ohlc_long = ohlc_all[-CANDLE_LONG:]
current_price = float(ohlc_short[-1]["Close"])
candidate_id = build_candidate_id(str(ohlc_short[-1].get("DateTime", "")))
imbalance_analysis = detect_imbalance_signal(
ohlc_all,
avg_body_period=IMBALANCE_AVG_BODY_PERIOD,
sensitivity=IMBALANCE_SENSITIVITY,
min_avg_body_size=IMBALANCE_MIN_AVG_BODY_SIZE,
)
imbalance_summary = format_imbalance_summary(imbalance_analysis)
selected_strategies = adjust_strategies_for_imbalance(
selected_strategies,
trend_state=trend_state,
analysis=imbalance_analysis,
use_filter=USE_IMBALANCE_FILTER,
)
if not selected_strategies:
logger.info(
"H1 imbalance conflicts with market_state=%s (%s). New entries stopped. %s",
trend_state,
MARKET_STATE_LABELS.get(trend_state, "UNKNOWN"),
imbalance_summary,
)
write_entry_output(stop_numeric_list())
return
try:
ohlc_to_candlestick_png_file(
ohlc_data=ohlc_short,
save_path=paths.tmp_short_chart,
instrument=INSTRUMENT,
timeframe=ENTRY_TIMEFRAME,
dark=True,
)
ohlc_to_candlestick_png_file(
ohlc_data=ohlc_long,
save_path=paths.tmp_long_chart,
instrument=INSTRUMENT,
timeframe=ENTRY_TIMEFRAME,
dark=True,
)
images_data_urls = [
png_file_to_data_url(paths.tmp_short_chart),
png_file_to_data_url(paths.tmp_long_chart),
]
except Exception:
logger.exception("画像生成エラー")
write_entry_output(stop_numeric_list())
return
numeric_summary = build_numeric_summary(
current_price=current_price,
ohlc_short=ohlc_short,
ohlc_long=ohlc_long,
)
short_summary = summarize_ohlc(ohlc_short)
h1_eatr = float(short_summary.get("eatr", 0.0))
max_entry_distance = build_entry_distance_limits(h1_eatr, selected_strategies)
post_filter_summary = (
"entry_distance_guard="
f"max_distance_by_strategy={max_entry_distance}, "
f"h1_eatr={h1_eatr:.2f}, "
f"stop_multiplier={ENTRY_MAX_DISTANCE_STOP_EATR_MULTIPLIER:.2f}, "
f"limit_multiplier={ENTRY_MAX_DISTANCE_LIMIT_EATR_MULTIPLIER:.2f}, "
f"floor={ENTRY_MAX_DISTANCE_MIN_PRICE:.2f}"
)
header = build_header(
current_price=current_price,
numeric_summary=numeric_summary,
trend_state=trend_state,
)
market_state_guidance = build_market_state_guidance(trend_state)
imbalance_guidance = build_imbalance_guidance(imbalance_analysis)
caution = build_caution_block()
try:
config = load_runtime_config(debug_print=DEBUG_PRINT)
except RuntimeError:
logger.exception("OpenAI 設定エラー")
write_entry_output(stop_numeric_list())
return
if config.debug_print:
system_content = build_system_content_block_debug()
common_rules = build_common_rules_block_debug(selected_strategies, max_entry_distance)
max_tokens = ENTRY_DEBUG_MAX_OUTPUT_TOKENS
else:
system_content = build_system_content_block()
common_rules = build_common_rules_block(selected_strategies, max_entry_distance)
max_tokens = ENTRY_MAX_OUTPUT_TOKENS
user_text = "\n\n".join([header, market_state_guidance, imbalance_guidance, common_rules, caution]).strip()
api_diagnostics = ""
try:
client = create_openai_client(config.api_key)
gpt_result = call_responses_api(
client=client,
model=config.model,
reasoning_effort=config.reasoning_effort,
system_content=system_content,
user_text=user_text,
image_data_urls=images_data_urls,
max_output_tokens=max_tokens,
)
gpt_reply = gpt_result.text
api_diagnostics = gpt_result.diagnostics.to_log_text()
except Exception:
logger.exception("OpenAI APIエラー")
write_entry_output(stop_numeric_list())
return
if config.debug_print:
logger.info(
"---- TREND ---- %s selected=%s\n---- GPT REPLY START ----\n%s\n---- GPT REPLY END ----",
trend_state,
selected_strategies,
gpt_reply,
)
if config.debug_print:
numeric_lines, reason_text = extract_entry_blocks_debug(gpt_reply)
if not numeric_lines:
numeric_lines = gpt_reply
entry_zones = parse_lines_to_entry_zones_allow_subset(numeric_lines)
entry_zones = sanitize_entry_zones(
entry_zones,
selected_strategies,
current_price,
max_entry_distance=max_entry_distance,
)
numeric_list = zones_to_numeric_list(entry_zones)
zone_text = format_target_zones(entry_zones, candidate_id)
try:
append_debug_entry(
path=paths.debug_reason,
model=config.model,
reasoning_effort=config.reasoning_effort,
max_output_tokens=max_tokens,
api_diagnostics=api_diagnostics,
timeframe=ENTRY_TIMEFRAME,
current_price=current_price,
trend_state=trend_state,
selected_strategies=selected_strategies,
imbalance_summary=imbalance_summary,
numeric_summary=numeric_summary,
numeric_lines=numeric_lines,
post_filter_summary=post_filter_summary,
sanitized_numeric_list=numeric_list,
reason_text=f"{reason_text}\n\nTARGET_ZONES\n{zone_text}".strip(),
)
except Exception:
logger.exception("debug_entry.txt write error")
else:
entry_zones = parse_lines_to_entry_zones_allow_subset(gpt_reply)
entry_zones = sanitize_entry_zones(
entry_zones,
selected_strategies,
current_price,
max_entry_distance=max_entry_distance,
)
numeric_list = zones_to_numeric_list(entry_zones)
zone_text = format_target_zones(entry_zones, candidate_id)
write_entry_output(numeric_list, zone_text)
def main() -> None:
"""ログ設定を行ってH1エントリー候補生成パイプラインを起動する。"""
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
run_pipeline()
@@ -0,0 +1,104 @@
"""H4 OHLCからmarket_stateを生成するパイプライン。"""
from __future__ import annotations
import logging
from ea_py.constants import (
CANDLE_TREND,
DEBUG_PRINT,
MIN_VOL_BARS,
MT_ENCODING,
TECHNICAL_ERROR_STOP,
)
from ea_py.io.debug_logs import append_debug_trend
from ea_py.io.mt5_files import write_result_then_done
from ea_py.io.ohlc_csv import read_ohlc_csv
from ea_py.market.trend_state import (
classify_direction_from_ohlc,
classify_market_state,
)
from ea_py.market.volatility import summarize_ohlc
from ea_py.paths import build_trend_paths
from ea_py.prompts.trend_prompt import build_trend_numeric_summary
logger = logging.getLogger(__name__)
def write_trend_output(market_state: int) -> None:
"""trend_state.txtを書き、完了後にprocess_done_trend.txtを作る。"""
paths = build_trend_paths()
write_result_then_done(
result_path=paths.trend_state,
result_text=str(market_state),
result_encoding=MT_ENCODING,
done_path=paths.done_trend,
)
def run_pipeline() -> None:
"""H4 OHLCからMT5向けのmarket_stateを生成する。
実行契約:
- `ohlc_H4.csv` を読み直近H4足を数値要約へ変換する
- EMAレンジ内位置高安更新DMI系方向優位効率比から
H4方向0/1/2を判定する
- 方向0/1/2をEATRベースのボラティリティ分類と合成して
`market_state` 0..6 を決定する
- 最後に `trend_state.txt` をMT5用エンコーディングで書き
書き込み完了後に `process_done_trend.txt` を作成する
安全側フォールバック:
CSV読込失敗データ不足EATR異常値は
技術エラー停止値として6を出力しH1側で新規注文を抑止する
相場ボラティリティが旧異常閾値を超えた場合は停止せず高ボラstateへ吸収する
"""
paths = build_trend_paths()
market_state = TECHNICAL_ERROR_STOP
try:
ohlc_all = read_ohlc_csv(paths.input_csv)
except Exception:
logger.exception("CSV 読み込みエラー")
write_trend_output(market_state)
return
if len(ohlc_all) < MIN_VOL_BARS:
logger.error("データ本数不足: len(ohlc)=%s (need >= %s)", len(ohlc_all), MIN_VOL_BARS)
write_trend_output(market_state)
return
ohlc_trend = ohlc_all[-min(CANDLE_TREND, len(ohlc_all)) :]
current_price = float(ohlc_trend[-1]["Close"])
summary = summarize_ohlc(ohlc_trend)
numeric_summary = build_trend_numeric_summary(current_price=current_price, ohlc_trend=ohlc_trend)
direction_val, direction_reason = classify_direction_from_ohlc(ohlc_trend, summary)
market_state, market_state_reason = classify_market_state(direction_val, summary)
classification_reason = f"{direction_reason}\n{market_state_reason}"
if DEBUG_PRINT:
try:
append_debug_trend(
path=paths.debug_reason,
model="rule-based-h4-direction",
reasoning_effort="none",
max_output_tokens=0,
api_diagnostics="OpenAI trend direction call is not used.",
current_price=current_price,
numeric_summary=numeric_summary,
direction_numeric=str(direction_val),
market_state=market_state,
classification_reason=classification_reason,
reason_text=direction_reason,
)
except Exception:
logger.exception("debug_trend.txt write error")
write_trend_output(market_state)
def main() -> None:
"""ログ設定を行ってH4トレンド判定パイプラインを起動する。"""
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
run_pipeline()
@@ -0,0 +1,2 @@
"""Prompt builders for OpenAI calls."""
@@ -0,0 +1,323 @@
"""H1エントリー候補生成用のプロンプトを生成する。"""
from __future__ import annotations
from collections.abc import Sequence
from ea_py.constants import CANDLE_LONG, CANDLE_SHORT, ENTRY_TIMEFRAME, MARKET_STATE_LABELS
from ea_py.market.imbalance import ImbalanceAnalysis, format_imbalance_summary
from ea_py.market.volatility import summarize_ohlc
from ea_py.types import OhlcBar
def build_system_content_block() -> str:
"""通常モード用のシステムメッセージを返す。"""
return (
"あなたは優秀な投資アドバイザーです。"
"ユーザーの指示を厳密に守り、指定された形式の数値のみを出力してください。"
"思考過程や説明文は一切出力してはいけません。"
).strip()
def build_system_content_block_debug() -> str:
"""デバッグモード用のシステムメッセージを返す。"""
return (
"あなたは優秀な投資アドバイザーです。"
"ユーザーの指示を厳密に守ってください。"
"出力は必ず指定されたブロック構造に従ってください。"
"NUMERIC OUTPUT では指定フォーマットの数値行のみ。"
"REASON OUTPUT では理由を文章で簡潔に。"
).strip()
def build_numeric_summary(
current_price: float,
ohlc_short: Sequence[OhlcBar],
ohlc_long: Sequence[OhlcBar],
) -> str:
"""短期・中期H1チャートからプロンプト用の数値要約文を作る。"""
sum_short = summarize_ohlc(ohlc_short)
sum_long = summarize_ohlc(ohlc_long)
return f"""
数値要約チャートと同一データ
- 現在価格: {current_price:.2f}
- 短期({sum_short["n"]}):
高値={sum_short["high"]:.2f}, 安値={sum_short["low"]:.2f}, レンジ={sum_short["range"]:.2f},
EATR={sum_short["eatr"]:.2f}, 基準EATR={sum_short["eatr_baseline"]:.2f}, EATR比={sum_short["eatr_ratio"]:.2f},
直近足レンジ={sum_short["latest_range"]:.2f}, 直近足レンジ/EATR={sum_short["latest_range_to_eatr"]:.2f},
上ヒゲ={sum_short["latest_upper_wick"]:.2f}, 下ヒゲ={sum_short["latest_lower_wick"]:.2f},
平均実体={sum_short["avg_body"]:.2f},
上昇本数={sum_short["up"]}, 下落本数={sum_short["down"]}, 傾き={sum_short["slope"]:.4f}
- 中期({sum_long["n"]}):
高値={sum_long["high"]:.2f}, 安値={sum_long["low"]:.2f}, レンジ={sum_long["range"]:.2f},
EATR={sum_long["eatr"]:.2f}, 基準EATR={sum_long["eatr_baseline"]:.2f}, EATR比={sum_long["eatr_ratio"]:.2f},
直近足レンジ={sum_long["latest_range"]:.2f}, 直近足レンジ/EATR={sum_long["latest_range_to_eatr"]:.2f},
上ヒゲ={sum_long["latest_upper_wick"]:.2f}, 下ヒゲ={sum_long["latest_lower_wick"]:.2f},
平均実体={sum_long["avg_body"]:.2f},
上昇本数={sum_long["up"]}, 下落本数={sum_long["down"]}, 傾き={sum_long["slope"]:.4f}
""".strip()
def build_header(
current_price: float,
numeric_summary: str,
trend_state: int,
candle_short: int = CANDLE_SHORT,
candle_long: int = CANDLE_LONG,
) -> str:
"""現在価格、画像説明、H4 market_stateを含むヘッダー文を作る。"""
header = f"""
以下はXAUUSDGOLD{ENTRY_TIMEFRAME}足チャート画像です
- 1枚目短期直近{candle_short}
- 2枚目中期直近{candle_long}
現在価格は {current_price:.2f}短期チャートの最後の足の終値とします
重要外部環境判定H4
market_state = {trend_state}{MARKET_STATE_LABELS.get(trend_state, "UNKNOWN")}
この market_state は外部ロジックで確定した前提情報です必ず尊重してください
- 0 = 低ボラレンジ: レンジ端からの逆張りのみ候補
- 1 = 高ボラレンジ: レンジ端かつ反転根拠が強い逆張りのみ候補
- 2 = 低ボラ上昇: 買い優先
- 3 = 高ボラ上昇: 買い優先売り逆張りは禁止
- 4 = 低ボラ下降: 売り優先
- 5 = 高ボラ下降: 売り優先買い逆張りは禁止
- 6 = 技術エラー停止: Python/CSV/API/パース失敗時のみ新規注文停止
H1の役割
- H1ではH4 market_state と整合する方向セットアップだけを候補にしてください
- H1がH4方向と明確に逆行またはレンジ中央で優位性が弱い場合は見送ってください
- 実際の発注タイミングはEA側のM15確定足フィルターで確認します
H1ではM15の細かな反転を先読みせず1時間以内に到達しうる妥当な候補価格を重視してください
""".strip()
if numeric_summary:
header = f"{header}\n\n{numeric_summary}".strip()
return header
def build_imbalance_guidance(analysis: ImbalanceAnalysis) -> str:
"""Pythonで決定済みのH1インバランス判定をGPTへ伝える補助ルールを返す。"""
summary = format_imbalance_summary(analysis)
if analysis.signal == "BUY":
signal_rule = (
"- H1では買い方向の初動が検出されています。"
"ただしH4 market_stateと整合する戦略だけを維持し、H4と逆方向の直接候補は出さないでください。"
)
elif analysis.signal == "SELL":
signal_rule = (
"- H1では売り方向の初動が検出されています。"
"ただしH4 market_stateと整合する戦略だけを維持し、H4と逆方向の直接候補は出さないでください。"
)
else:
signal_rule = "- H1では明確なインバランス初動は検出されていません。既存のmarket_state別ルールを優先してください。"
return f"""
H1インバランス判定Pythonの数値ロジックで確定済み
- {summary}
{signal_rule}
- インバランス有無をあなたが再判定しないでください
- 実際の発注直前にはEA側のM15確定足フィルターが別途確認します
""".strip()
def build_market_state_guidance(trend_state: int) -> str:
"""H4 market_state別のH1候補生成ルールを返す。
H1は候補価格作成だけを担当しH4環境と矛盾する方向は出さない
レンジでは端からの逆張り条件を厳しくしトレンドではH4方向に沿う戦略だけを
許可する高ボラトレンドでは逆方向の逆張りを禁止し異常ボラや未知状態では
新規停止を指示する
"""
if trend_state == 0:
return """
market_state別の追加条件
- 低ボラレンジのためT2/T4はレンジ上限下限に十分近い場合だけ候補にしてください
- 価格がレンジ中央付近なら対象戦略を 0.00,0.00,0.00 で見送ってください
""".strip()
if trend_state == 1:
return """
market_state別の追加条件
- 高ボラレンジのため逆張り条件を通常より厳しくしてください
- T2はレンジ下限付近下ヒゲブレイク直後ではないことを重視してください
- T4はレンジ上限付近上ヒゲブレイク直後ではないことを重視してください
- 直近足レンジ/EATRが大きすぎるまたは端を強く抜けた直後なら対象戦略を 0.00,0.00,0.00 で見送ってください
""".strip()
if trend_state == 3:
return """
market_state別の追加条件
- 高ボラ上昇のため売り方向の逆張りは禁止です
- T1は上方向ブレイクT2は浅い押し目買いだけを候補にしてください
- 深い押し目や急落直後で買い根拠が弱い場合は対象戦略を 0.00,0.00,0.00 で見送ってください
""".strip()
if trend_state == 5:
return """
market_state別の追加条件
- 高ボラ下降のため買い方向の逆張りは禁止です
- T3は下方向ブレイクT4は浅い戻り売りだけを候補にしてください
- 深い戻りや急騰直後で売り根拠が弱い場合は対象戦略を 0.00,0.00,0.00 で見送ってください
""".strip()
if trend_state == 2:
return """
market_state別の追加条件
- 低ボラ上昇のため買い方向のみ候補にしてください
- T1は上方向ブレイクT2は押し目買いとして妥当な距離だけを候補にしてください
""".strip()
if trend_state == 4:
return """
market_state別の追加条件
- 低ボラ下降のため売り方向のみ候補にしてください
- T3は下方向ブレイクT4は戻り売りとして妥当な距離だけを候補にしてください
""".strip()
return """
market_state別の追加条件
- 技術エラー停止または不明な状態のため新規注文は停止してください
""".strip()
def build_strategy_distance_rules(
selected_strategies: Sequence[int],
max_entry_distance: float | dict[int, float] | None,
) -> str:
"""戦略別の現在価格からの距離制限ルール文を返す。"""
if max_entry_distance is None:
return ""
if isinstance(max_entry_distance, dict):
lines = []
for strategy in selected_strategies:
distance = max_entry_distance.get(strategy)
if distance is None or distance <= 0.0:
continue
lines.append(
f"- 戦略{strategy}のエントリー基準価格は現在価格から最大 {distance:.2f} 以内にしてください。"
)
if not lines:
return ""
return "\n".join(lines) + "\n この範囲を超える深い指値・遠いブレイク待ちは、遅延エントリーになりやすいため見送ってください。"
if max_entry_distance <= 0.0:
return ""
return (
f"- エントリー基準価格は現在価格から最大 {max_entry_distance:.2f} 以内にしてください。"
"この範囲を超える深い指値・遠いブレイク待ちは、遅延エントリーになりやすいため見送ってください。"
)
def build_common_rules_block(
selected_strategies: Sequence[int],
max_entry_distance: float | dict[int, float] | None = None,
) -> str:
"""選択戦略に応じた価格決定ルールとGPT出力形式を作る。
`selected_strategies` にはH4 market_stateと整合する戦略番号だけを渡す
GPTには対象戦略の行だけを`戦略番号,entry,tp,sl,zone_low,zone_high` の数値行で返すよう指定する
各戦略のentry/tp/slの大小関係1時間以内の到達条件12時間以内の決済目線
条件が弱い場合に `0.00,0.00,0.00,0.00,0.00` で見送るルールもここで明示する
出力の実検証は `parse_lines_to_13_allow_subset` `sanitize_numeric_list` が担当する
"""
descriptions = {
1: "1. 順張りエントリーの買い",
2: "2. 逆張りエントリーの買い",
3: "3. 順張りエントリーの売り",
4: "4. 逆張りエントリーの売り",
}
conditions = {
1: "(エントリー基準 > 現在価格, 利確目標 > エントリー基準, エントリー基準 > ロスカット基準)",
2: "(現在価格 > エントリー基準, 利確目標 > エントリー基準, エントリー基準 > ロスカット基準)",
3: "(現在価格 > エントリー基準, エントリー基準 > 利確目標, ロスカット基準 > エントリー基準)",
4: "(エントリー基準 > 現在価格, エントリー基準 > 利確目標, ロスカット基準 > エントリー基準)",
}
lines = []
for strategy in selected_strategies:
lines.append(f"{descriptions[strategy]}\n {conditions[strategy]}")
strategy_block = "\n\n".join(lines).strip()
out_order = "".join(str(strategy) for strategy in selected_strategies)
distance_rule = build_strategy_distance_rules(selected_strategies, max_entry_distance)
return f"""
このデータを用いて以下の戦略パターンについてのみ
それぞれ独立にエントリー条件を検討してください
対象戦略
{strategy_block}
価格決定ルール
- 各戦略ごとに以下の3つの価格を必ず決定してください
- エントリー基準価格
- 利確目標価格
- ロスカット基準価格
- 各戦略ごとにエントリー基準価格の周辺にある予測ゾーンも必ず決定してください
- zone_low は予測ゾーンの低い価格
- zone_high は予測ゾーンの高い価格
- エントリー基準価格は必ず zone_low 以上 zone_high 以下にしてください
- 各価格の大小関係がその戦略の条件と整合しているか必ず検証してください
- それぞれの戦略において利益の期待値が最大になるように価格を設定してください
- 条件が弱いブレイク直後レンジ中央付近EATR基準でリスクが大きすぎる等で見送る場合は
その戦略行を戦略番号,0.00,0.00,0.00,0.00,0.00としてください
{distance_rule}
時間条件全戦略共通
- 現在価格から1時間以内にエントリー基準価格に到達しなければその戦略はキャンセル
- 実際の発注はEA側でM15確定足の勢い反転候補価格への接近を確認してから行う
- エントリー後12時間以内に利確損切に到達しなければその時点の価格でクローズ
出力ルール最重要
以下の形式で **対象戦略の行だけ** 出力してください
- 1行につき1戦略
- 行の順序は {out_order}
- 各行は以下の6つをカンマ区切りで出力
戦略番号,エントリー基準価格,利確目標価格,ロスカット基準価格,予測ゾーン下限,予測ゾーン上限
- 数値のみを出力し説明文空行記号は一切出力してはいけません
- 各行の価格は必ず小数点以下2桁まで出力すること: 4812.62
- 見送り行も必ず小数点以下2桁の 0.00 を使うこと
""".strip()
def build_common_rules_block_debug(
selected_strategies: Sequence[int],
max_entry_distance: float | dict[int, float] | None = None,
) -> str:
"""理由出力を含む価格決定ルールと出力形式を作る。"""
base = build_common_rules_block(selected_strategies, max_entry_distance)
tail = """
デバッグ追加ルール
デバッグモードのため出力を次の3ブロック構成にしてください順番固定
### NUMERIC OUTPUT ###
ここには上記出力ルール最重要に従った数値行のみをそのまま出力してください
余計な文字や空行は禁止
### REASON OUTPUT ###
各出力行についてentry/tp/sl をそのように置いた意図を各12行で説明してください
最後にmarket_state をどう解釈したかを12行でまとめてください
### END ###
""".strip()
return f"{base}\n\n{tail}".strip()
def build_caution_block() -> str:
"""market_stateとH1判断が矛盾した場合の安全側ルールを返す。"""
return """
market_state とチャート/数値要約が矛盾すると判断した場合は安全側に倒してください
具体的には対象戦略を 0.00,0.00,0.00 で見送ってください
""".strip()
@@ -0,0 +1,100 @@
"""H4トレンド判定用のプロンプトを生成する。"""
from __future__ import annotations
from typing import Sequence
from ea_py.market.volatility import summarize_ohlc
from ea_py.types import OhlcBar
def build_trend_system_content() -> str:
"""通常モード用のシステムメッセージを返す。"""
return (
"あなたは相場分析の判定器です。"
"出力は数字1つ(0/1/2)のみ。"
"説明文、記号、空行、追加の数値は一切出力しない。"
"出力は必ず 0 または 1 または 2。"
).strip()
def build_trend_system_content_debug() -> str:
"""デバッグモード用のシステムメッセージを返す。"""
return (
"あなたは相場分析の判定器です。"
"ユーザーの指示を厳密に守ってください。"
"出力は必ず指定されたブロック構造に従ってください。"
"NUMERIC OUTPUT では 0/1/2 の数字1つのみ。"
"REASON OUTPUT では理由を文章で簡潔に。"
).strip()
def build_trend_numeric_summary(current_price: float, ohlc_trend: Sequence[OhlcBar]) -> str:
"""H4チャートと同一データ由来の数値要約文を作る。"""
summary = summarize_ohlc(ohlc_trend)
return f"""
数値要約チャートと同一データ
- 現在価格: {current_price:.2f}
- 対象本数: {summary["n"]}H4
高値={summary["high"]:.2f}, 安値={summary["low"]:.2f}, レンジ={summary["range"]:.2f},
EATR={summary["eatr"]:.2f}, 基準EATR={summary["eatr_baseline"]:.2f}, EATR比={summary["eatr_ratio"]:.2f},
直近足レンジ={summary["latest_range"]:.2f}, 直近足レンジ/EATR={summary["latest_range_to_eatr"]:.2f},
上ヒゲ={summary["latest_upper_wick"]:.2f}, 下ヒゲ={summary["latest_lower_wick"]:.2f},
平均実体={summary["avg_body"]:.2f},
上昇本数={summary["up"]}, 下落本数={summary["down"]}, 傾き={summary["slope"]:.4f}
""".strip()
def build_trend_user_prompt(current_price: float, numeric_summary: str, candle_count: int) -> str:
"""H4方向判定用のユーザープロンプトを作る。
GPTにはXAUUSD/GOLDの今後12時間目線で0=レンジ1=上昇2=下降の
いずれか1つだけを返すよう強制する
チャート画像と同一データ由来の数値要約を併用させるが方向が混在する場合は
事故回避としてレンジ(0)を選ぶよう指示する
この関数はプロンプト文字列を作るだけでOpenAI呼び出しや出力検証は行わない
"""
return f"""
あなたはXAUUSDGOLDのH4の今後12時間のトレンドを判定し
次のいずれかを **数字1つ** で出力してください
0 = 横ばいレンジ
1 = 上昇トレンド
2 = 下降トレンド
入力
- 現在価格: {current_price:.2f}
- 添付のH4チャート画像直近{candle_count}
- 数値要約同一データ由来:
{numeric_summary}
判定の考え方
- 画像と数値要約の両方を参考にして良い
- 判定ロジック傾きATRMAダウ理論レンジ幅などはあなたが最適だと思う方法でよい
- ただし自信がない方向が混在している場合は事故回避のため 0横ばいを選ぶこと
出力ルール
- 出力は 0 / 1 / 2 のどれか数字1つのみ
- それ以外は一切出力しない
""".strip()
def build_trend_user_prompt_debug(current_price: float, numeric_summary: str, candle_count: int) -> str:
"""理由出力を含むH4トレンド判定ユーザープロンプトを作る。"""
base = build_trend_user_prompt(current_price, numeric_summary, candle_count)
tail = """
デバッグ追加ルール
デバッグモードのため出力を次の3ブロック構成にしてください順番固定
### NUMERIC OUTPUT ###
0 / 1 / 2 のどれか数字1つのみ
余計な文字空行記号は禁止
### REASON OUTPUT ###
なぜその判定0/1/2にしたかを箇条書きで36点で簡潔に
最後に12時間目線での注意点を1行で
### END ###
""".strip()
return f"{base}\n\n{tail}".strip()
+38
View File
@@ -0,0 +1,38 @@
"""プロジェクト内で共有する型定義。"""
from __future__ import annotations
from typing import TypedDict
class OhlcBar(TypedDict):
"""MT5 OHLC CSVから読み込んだ1本分のローソク足。"""
DateTime: str
Open: float
High: float
Low: float
Close: float
class OhlcSummary(TypedDict):
"""ローソク足配列から算出した数値要約。"""
n: int
high: float
low: float
range: float
eatr: float
eatr_baseline: float
eatr_ratio: float
latest_range: float
latest_range_to_eatr: float
latest_upper_wick: float
latest_lower_wick: float
avg_body: float
up: int
down: int
slope: float
NumericList = list[int | float]
+29
View File
@@ -0,0 +1,29 @@
"""Runtime config loading tests."""
from __future__ import annotations
import pytest
from ea_py.config import load_runtime_config
def test_load_runtime_config_reads_model_and_reasoning_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""OpenAI model and reasoning effort can be overridden without code edits."""
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
monkeypatch.setenv("OPENAI_MODEL", "gpt-test")
monkeypatch.setenv("OPENAI_REASONING_EFFORT", "low")
actual = load_runtime_config()
assert actual.api_key == "test-key"
assert actual.model == "gpt-test"
assert actual.reasoning_effort == "low"
def test_load_runtime_config_rejects_invalid_reasoning_effort(monkeypatch: pytest.MonkeyPatch) -> None:
"""Invalid reasoning effort should fail before an API call is attempted."""
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
monkeypatch.setenv("OPENAI_REASONING_EFFORT", "fastest")
with pytest.raises(RuntimeError, match="OPENAI_REASONING_EFFORT"):
load_runtime_config()
+118
View File
@@ -0,0 +1,118 @@
"""H1インバランス判定のUnit Test。"""
from __future__ import annotations
import pytest
from ea_py.market.imbalance import (
adjust_strategies_for_imbalance,
calculate_average_body_size,
detect_imbalance_signal,
)
from ea_py.types import OhlcBar
def make_bar(open_price: float, close_price: float) -> OhlcBar:
"""テスト用OHLCバーを作る。"""
high = max(open_price, close_price)
low = min(open_price, close_price)
return {
"DateTime": "2026-01-01 00:00",
"Open": open_price,
"High": high,
"Low": low,
"Close": close_price,
}
def test_calculate_average_body_size_excludes_current_bar() -> None:
"""平均実体サイズには判定対象足を含めない。"""
ohlc = [make_bar(100.0, 101.0), make_bar(100.0, 102.0), make_bar(100.0, 110.0)]
actual = calculate_average_body_size(ohlc, current_index=2, period=2)
assert actual == 1.5
@pytest.mark.parametrize(
("open_price", "close_price", "expected"),
[
(100.0, 103.0, "BUY"),
(103.0, 100.0, "SELL"),
(100.0, 101.5, "NONE"),
],
)
def test_detect_imbalance_signal_various_current_bars_returns_expected(
open_price: float,
close_price: float,
expected: str,
) -> None:
"""直近確定足の実体が平均実体の感度倍率を超えた時だけ方向シグナルを返す。"""
ohlc = [make_bar(100.0, 101.0) for _ in range(20)]
ohlc.append(make_bar(open_price, close_price))
actual = detect_imbalance_signal(
ohlc,
avg_body_period=20,
sensitivity=2.0,
min_avg_body_size=0.01,
)
assert actual.signal == expected
def test_detect_imbalance_signal_tiny_average_body_returns_none() -> None:
"""平均実体が極小の場合は誤検知を避ける。"""
ohlc = [make_bar(100.0, 100.0) for _ in range(20)]
ohlc.append(make_bar(100.0, 103.0))
actual = detect_imbalance_signal(
ohlc,
avg_body_period=20,
sensitivity=2.0,
min_avg_body_size=0.01,
)
assert actual.signal == "NONE"
def test_adjust_strategies_for_imbalance_conflicting_up_trend_keeps_stop_strategy() -> None:
"""H4上昇中の売りインバランスは順張りStopだけ残す。"""
ohlc = [make_bar(100.0, 101.0) for _ in range(20)]
ohlc.append(make_bar(103.0, 100.0))
analysis = detect_imbalance_signal(
ohlc,
avg_body_period=20,
sensitivity=2.0,
min_avg_body_size=0.01,
)
actual = adjust_strategies_for_imbalance(
[1, 2],
trend_state=3,
analysis=analysis,
use_filter=True,
)
assert actual == [1]
def test_adjust_strategies_for_imbalance_conflicting_down_trend_keeps_stop_strategy() -> None:
"""H4下降中の買いインバランスは順張りStopだけ残す。"""
ohlc = [make_bar(100.0, 101.0) for _ in range(20)]
ohlc.append(make_bar(100.0, 103.0))
analysis = detect_imbalance_signal(
ohlc,
avg_body_period=20,
sensitivity=2.0,
min_avg_body_size=0.01,
)
actual = adjust_strategies_for_imbalance(
[3, 4],
trend_state=5,
analysis=analysis,
use_filter=True,
)
assert actual == [3]
@@ -0,0 +1,93 @@
"""OpenAI Responses API wrapper tests."""
from __future__ import annotations
from typing import Any
from ea_py.openai_client import call_responses_api
class FakeDumpable:
"""Minimal SDK-like object exposing model_dump."""
def __init__(self, data: dict[str, object]) -> None:
self._data = data
def model_dump(self, *, mode: str, exclude_none: bool) -> dict[str, object]:
"""Return JSON-serializable diagnostic data."""
return self._data
class FakeResponse:
"""Minimal Responses API response shape used by the wrapper."""
def __init__(self, *, output_text: str, incomplete_details: object | None = None) -> None:
self.output_text = output_text
self.model = "gpt-5.5-2026-04-23"
self.status = "completed"
self.incomplete_details = incomplete_details
self.error = None
self.usage = FakeDumpable({"input_tokens": 10, "output_tokens": 1})
class FakeResponsesResource:
"""Capture create parameters and return a fixed response."""
def __init__(self, response: FakeResponse) -> None:
self.response = response
self.create_params: dict[str, Any] = {}
def create(self, **kwargs: Any) -> FakeResponse:
"""Record the request payload."""
self.create_params = kwargs
return self.response
class FakeClient:
"""Minimal OpenAI client shape."""
def __init__(self, response: FakeResponse) -> None:
self.responses = FakeResponsesResource(response)
def test_call_responses_api_sends_reasoning_and_omits_temperature() -> None:
"""GPT-5.5 requests should use reasoning controls instead of temperature."""
fake_client = FakeClient(FakeResponse(output_text=" 0\n"))
actual = call_responses_api(
client=fake_client, # type: ignore[arg-type]
model="gpt-5.5-2026-04-23",
reasoning_effort="none",
system_content="Return one number.",
user_text="Classify.",
image_data_urls=[],
max_output_tokens=128,
)
assert actual.text == "0"
assert fake_client.responses.create_params["reasoning"] == {"effort": "none"}
assert fake_client.responses.create_params["max_output_tokens"] == 128
assert "temperature" not in fake_client.responses.create_params
def test_call_responses_api_returns_incomplete_diagnostics_for_empty_text() -> None:
"""Empty output_text keeps the API status details for live diagnosis."""
fake_client = FakeClient(
FakeResponse(
output_text="",
incomplete_details=FakeDumpable({"reason": "max_output_tokens"}),
)
)
actual = call_responses_api(
client=fake_client, # type: ignore[arg-type]
model="gpt-5.5-2026-04-23",
reasoning_effort="low",
system_content="Return one number.",
user_text="Classify.",
image_data_urls=[],
max_output_tokens=8,
)
assert actual.text == ""
assert "max_output_tokens" in actual.diagnostics.incomplete_details
@@ -0,0 +1,131 @@
"""target_prices変換と戦略選択のUnit Test。"""
from __future__ import annotations
from ea_py.market.target_prices import parse_lines_to_13_allow_subset, sanitize_numeric_list, strategies_by_trend
from ea_py.market.target_zones import (
build_candidate_id,
format_target_zones,
parse_lines_to_entry_zones_allow_subset,
sanitize_entry_zones,
zones_to_numeric_list,
)
def test_strategies_by_trend_range_state_returns_countertrend_strategies() -> None:
"""レンジstateでは逆張り買いと逆張り売りだけを選択する。"""
actual = strategies_by_trend(0)
assert actual == [2, 4]
def test_parse_lines_to_13_allow_subset_valid_subset_returns_13_values() -> None:
"""GPTの一部戦略行を13値のtarget_prices形式へ展開する。"""
actual = parse_lines_to_13_allow_subset("2,1900.00,1910.00,1890.00\n4,1930.00,1920.00,1940.00")
assert actual == [1, 0.0, 0.0, 0.0, 1900.0, 1910.0, 1890.0, 0.0, 0.0, 0.0, 1930.0, 1920.0, 1940.0]
def test_parse_lines_to_13_allow_subset_no_prices_returns_all_stop_values() -> None:
"""有効価格がない場合は全停止値を返す。"""
actual = parse_lines_to_13_allow_subset("2,0.00,0.00,0.00")
assert actual == [0] * 13
def test_sanitize_numeric_list_invalid_unselected_strategy_zeroes_it() -> None:
"""未選択戦略と価格条件違反の戦略を0.00へ補正する。"""
parsed = [1, 1910.0, 1920.0, 1900.0, 1890.0, 1900.0, 1880.0, 1880.0, 1870.0, 1890.0, 1930.0, 1920.0, 1940.0]
actual = sanitize_numeric_list(parsed, selected_strategies=[2, 4], current_price=1900.0)
assert actual == [1, 0.0, 0.0, 0.0, 1890.0, 1900.0, 1880.0, 0.0, 0.0, 0.0, 1930.0, 1920.0, 1940.0]
def test_sanitize_numeric_list_rejects_far_entry_price() -> None:
"""現在価格から遠すぎる候補は遅延エントリー防止のため0.00へ補正する。"""
parsed = [1, 0.0, 0.0, 0.0, 1870.0, 1900.0, 1860.0, 0.0, 0.0, 0.0, 1910.0, 1880.0, 1920.0]
actual = sanitize_numeric_list(parsed, selected_strategies=[2, 4], current_price=1900.0, max_entry_distance=20.0)
assert actual == [1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1910.0, 1880.0, 1920.0]
def test_sanitize_numeric_list_uses_strategy_specific_entry_distance() -> None:
"""戦略別の距離制限によりStop系だけ広い候補を残せる。"""
parsed = [1, 1925.0, 1940.0, 1915.0, 1875.0, 1900.0, 1865.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
actual = sanitize_numeric_list(
parsed,
selected_strategies=[1, 2],
current_price=1900.0,
max_entry_distance={1: 30.0, 2: 20.0},
)
assert actual == [1, 1925.0, 1940.0, 1915.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
def test_parse_lines_to_entry_zones_allow_subset_valid_zone_returns_zone_values() -> None:
"""GPTのゾーン行を戦略別ゾーンへ展開する。"""
actual = parse_lines_to_entry_zones_allow_subset("2,1895.00,1910.00,1885.00,1892.00,1898.00")
assert actual[2].entry == 1895.0
assert actual[2].zone_low == 1892.0
assert actual[2].zone_high == 1898.0
def test_sanitize_entry_zones_invalid_unselected_strategy_zeroes_it() -> None:
"""未選択戦略のゾーンは停止値へ補正する。"""
parsed = parse_lines_to_entry_zones_allow_subset(
"1,1910.00,1925.00,1900.00,1908.00,1912.00\n"
"2,1895.00,1910.00,1885.00,1892.00,1898.00"
)
actual = sanitize_entry_zones(parsed, selected_strategies=[2], current_price=1900.0)
assert actual[1].entry == 0.0
assert actual[2].entry == 1895.0
def test_sanitize_entry_zones_allows_near_zone_edge_when_entry_is_farther() -> None:
"""ゾーンの最寄り端が近い場合は広めの候補ゾーンを残す。"""
parsed = parse_lines_to_entry_zones_allow_subset("2,1890.00,1910.00,1880.00,1890.00,1896.00")
actual = sanitize_entry_zones(parsed, selected_strategies=[2], current_price=1900.0, max_entry_distance=5.0)
assert actual[2].entry == 1890.0
def test_zones_to_numeric_list_valid_zone_returns_legacy_13_values() -> None:
"""有効ゾーンのentry/tp/slから既存13行形式を生成する。"""
parsed = parse_lines_to_entry_zones_allow_subset("4,1910.00,1880.00,1920.00,1908.00,1912.00")
sanitized = sanitize_entry_zones(parsed, selected_strategies=[4], current_price=1900.0)
actual = zones_to_numeric_list(sanitized)
assert actual == [1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1910.0, 1880.0, 1920.0]
def test_format_target_zones_includes_schema_candidate_and_all_strategies() -> None:
"""MT5向けゾーンファイルはschema/res/candidate/4戦略行で出力する。"""
parsed = parse_lines_to_entry_zones_allow_subset("2,1895.00,1910.00,1885.00,1892.00,1898.00")
sanitized = sanitize_entry_zones(parsed, selected_strategies=[2], current_price=1900.0)
actual = format_target_zones(sanitized, "202605241300")
assert actual.splitlines() == [
"2",
"1",
"202605241300",
"1,0.00,0.00,0.00,0.00",
"2,1892.00,1898.00,1910.00,1885.00",
"3,0.00,0.00,0.00,0.00",
"4,0.00,0.00,0.00,0.00",
]
def test_build_candidate_id_datetime_text_returns_compact_digits() -> None:
"""H1確定足時刻からスペースなしの候補IDを作る。"""
actual = build_candidate_id("2026.05.24 13:00")
assert actual == "202605241300"
@@ -0,0 +1,160 @@
"""market_state分類とH4方向パースのUnit Test。"""
from __future__ import annotations
import math
import pytest
from ea_py.market.trend_state import (
classify_direction_from_ohlc,
classify_market_state,
parse_trend_012_or_none,
)
from ea_py.market.volatility import summarize_ohlc
from ea_py.types import OhlcBar, OhlcSummary
def summary_with(*, eatr_ratio: float, latest_range_to_eatr: float = 1.0, n: int = 20) -> OhlcSummary:
"""market_state分類テスト用のOHLC要約を作る。"""
return {
"n": n,
"high": 10.0,
"low": 1.0,
"range": 9.0,
"eatr": eatr_ratio,
"eatr_baseline": 1.0,
"eatr_ratio": eatr_ratio,
"latest_range": latest_range_to_eatr,
"latest_range_to_eatr": latest_range_to_eatr,
"latest_upper_wick": 0.2,
"latest_lower_wick": 0.2,
"avg_body": 0.5,
"up": 10,
"down": 10,
"slope": 0.1,
}
def linear_ohlc(*, start: float, step: float, count: int = 72) -> list[OhlcBar]:
"""方向判定テスト用の線形OHLCを作る。"""
bars: list[OhlcBar] = []
for index in range(count):
close = start + step * index
open_ = close - step * 0.5
high = max(open_, close) + 0.3
low = min(open_, close) - 0.3
bars.append(
{
"DateTime": str(index),
"Open": open_,
"High": high,
"Low": low,
"Close": close,
}
)
return bars
def oscillating_ohlc(*, center: float = 100.0, count: int = 72) -> list[OhlcBar]:
"""方向判定テスト用の往復的なレンジOHLCを作る。"""
closes = [center + math.sin(index * math.pi / 2.0) for index in range(count)]
bars: list[OhlcBar] = []
previous_close = closes[0]
for index, close in enumerate(closes):
open_ = previous_close
high = max(open_, close) + 0.8
low = min(open_, close) - 0.8
bars.append(
{
"DateTime": str(index),
"Open": open_,
"High": high,
"Low": low,
"Close": close,
}
)
previous_close = close
return bars
@pytest.mark.parametrize(
("text", "expected"),
[
("0", 0),
("answer: 1", 1),
("2\nreason", 2),
("3", None),
("", None),
],
)
def test_parse_trend_012_or_none_various_inputs_returns_expected(text: str, expected: int | None) -> None:
"""GPT方向出力から0/1/2だけを許可して抽出する。"""
actual = parse_trend_012_or_none(text)
assert actual == expected
def test_classify_direction_from_ohlc_clear_uptrend_returns_up() -> None:
"""明確なH4上昇は方向1を返す。"""
ohlc = linear_ohlc(start=100.0, step=1.0)
actual, _reason = classify_direction_from_ohlc(ohlc, summarize_ohlc(ohlc))
assert actual == 1
def test_classify_direction_from_ohlc_clear_downtrend_returns_down() -> None:
"""明確なH4下降は方向2を返す。"""
ohlc = linear_ohlc(start=170.0, step=-1.0)
actual, _reason = classify_direction_from_ohlc(ohlc, summarize_ohlc(ohlc))
assert actual == 2
def test_classify_direction_from_ohlc_choppy_range_returns_range() -> None:
"""往復的なH4レンジは方向0へ倒す。"""
ohlc = oscillating_ohlc()
actual, _reason = classify_direction_from_ohlc(ohlc, summarize_ohlc(ohlc))
assert actual == 0
@pytest.mark.parametrize(
("direction", "eatr_ratio", "expected"),
[
(0, 1.0, 0),
(0, 1.2, 1),
(1, 1.0, 2),
(1, 1.2, 3),
(2, 1.0, 4),
(2, 1.2, 5),
],
)
def test_classify_market_state_direction_and_volatility_returns_expected(
direction: int,
eatr_ratio: float,
expected: int,
) -> None:
"""方向判定とボラティリティ比から期待するmarket_stateへ分類する。"""
actual, _reason = classify_market_state(direction, summary_with(eatr_ratio=eatr_ratio))
assert actual == expected
def test_classify_market_state_extreme_volatility_returns_high_vol_state() -> None:
"""旧異常ボラティリティ閾値以上でも停止せず高ボラstateへ吸収する。"""
actual, _reason = classify_market_state(1, summary_with(eatr_ratio=2.0))
assert actual == 3
def test_classify_market_state_extreme_latest_range_returns_high_vol_state() -> None:
"""直近足レンジが旧異常閾値以上でも方向に応じた高ボラstateを返す。"""
actual, _reason = classify_market_state(2, summary_with(eatr_ratio=1.0, latest_range_to_eatr=2.5))
assert actual == 5
@@ -0,0 +1,37 @@
"""ボラティリティ計算のUnit Test。"""
from __future__ import annotations
from ea_py.market.volatility import calc_exponential_atr_values, calc_true_ranges, summarize_ohlc
from ea_py.types import OhlcBar
def test_calc_true_ranges_uses_previous_close_for_gap_range() -> None:
"""ギャップがある足では前回終値との差をTrue Rangeに反映する。"""
ohlc: list[OhlcBar] = [
{"DateTime": "1", "Open": 10.0, "High": 12.0, "Low": 9.0, "Close": 11.0},
{"DateTime": "2", "Open": 15.0, "High": 16.0, "Low": 14.0, "Close": 15.0},
]
actual = calc_true_ranges(ohlc)
assert actual == [3.0, 5.0]
def test_calc_exponential_atr_values_short_series_returns_seed_for_all_values() -> None:
"""期間未満のTR列では平均値を全要素のEATRとして返す。"""
actual = calc_exponential_atr_values([2.0, 4.0], period=14)
assert actual == [3.0, 3.0]
def test_summarize_ohlc_slope_uses_weighted_close() -> None:
"""傾きはClose単体ではなくWeighted Closeの始点/終点差から計算する。"""
ohlc: list[OhlcBar] = [
{"DateTime": "1", "Open": 10.0, "High": 20.0, "Low": 10.0, "Close": 10.0},
{"DateTime": "2", "Open": 10.0, "High": 30.0, "Low": 10.0, "Close": 10.0},
]
actual = summarize_ohlc(ohlc)
assert actual["slope"] == 2.5
+2148
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+630
View File
@@ -0,0 +1,630 @@
import os
import re
import base64
from datetime import datetime
import pandas as pd
import matplotlib.pyplot as plt
import openai
# =========================
# ユーザー環境設定
# =========================
user_name = "new"
user_path = f"C:\\Users\\{user_name}"
terminal_ID = "5BDB0B60344C088C2FA5CA35699BAAFD"
terminal_path = f"{user_path}\\AppData\\Roaming\\MetaQuotes\\Terminal\\{terminal_ID}\\MQL5\\Files"
MT_encoding = "utf-16 LE"
YOUR_API_KEY = os.getenv("OPENAI_API_KEY")
if YOUR_API_KEY is None:
raise RuntimeError("OPENAI_API_KEY が環境変数に設定されていません。")
gpt_model = "gpt-5.4"
# =========================
# 入出力ファイル
# =========================
input_file_path = f"{terminal_path}\\ohlc_H1.csv"
output_file_path = f"{terminal_path}\\target_prices.txt"
trend_file_path = os.path.join(terminal_path, "trend_state.txt")
done_entry_file_path = os.path.join(terminal_path, "process_done_entry.txt")
DEBUG_PRINT = False # True: 数値+理由をデバッグファイルへ / False: 現行通り数値のみ
# ★追加:デバッグ理由ログ
DEBUG_ENTRY_REASON_PATH = os.path.join(terminal_path, "debug_entry.txt")
# =========================
# ローソク足画像の設定
# =========================
CANDLE_SHORT = 36
CANDLE_LONG = 72
instrument = "GOLD"
timeframe = "H1"
TMP_SHORT_PATH = os.path.join(terminal_path, "tmp_chart_short.png")
TMP_LONG_PATH = os.path.join(terminal_path, "tmp_chart_long.png")
# =========================
# OpenAI クライアント
# =========================
client = openai.OpenAI(api_key=YOUR_API_KEY)
# =========================
# トレンド読み取り(H4側の結果を読む)
# =========================
def read_trend_state(path: str) -> int:
"""
return: 0=RANGE / 1=UP / 2=DOWN
読めない場合は 0安全側
"""
try:
if not os.path.exists(path):
return 0
s = open(path, "r", encoding=MT_encoding).read().strip()
v = int(s)
return v if v in (0, 1, 2) else 0
except Exception:
return 0
def strategies_by_trend(trend_state: int):
"""
トレンドに応じてGPTへ依頼する戦略を絞る
"""
if trend_state == 1:
return [1, 2] # 上昇: 順張り買い + 逆張り買い
if trend_state == 2:
return [3, 4] # 下降: 順張り売り + 逆張り売り
return [2, 4] # 横ばい: 逆張り買い + 逆張り売り
# =========================
# 数値要約作成
# =========================
def summarize_ohlc(ohlc):
n = len(ohlc)
highs = [b["High"] for b in ohlc]
lows = [b["Low"] for b in ohlc]
opens = [b["Open"] for b in ohlc]
closes = [b["Close"] for b in ohlc]
hi = max(highs)
lo = min(lows)
trs = []
prev_close = closes[0]
for b in ohlc:
tr = max(
b["High"] - b["Low"],
abs(b["High"] - prev_close),
abs(b["Low"] - prev_close),
)
trs.append(tr)
prev_close = b["Close"]
atr = sum(trs) / max(1, len(trs))
bodies = [abs(c - o) for o, c in zip(opens, closes)]
avg_body = sum(bodies) / max(1, len(bodies))
up_cnt = sum(1 for o, c in zip(opens, closes) if c >= o)
dn_cnt = n - up_cnt
slope = (closes[-1] - closes[0]) / max(1, n - 1)
return {
"n": n,
"high": hi,
"low": lo,
"range": hi - lo,
"atr": atr,
"avg_body": avg_body,
"up": up_cnt,
"down": dn_cnt,
"slope": slope,
}
# =========================
# ローソク足描画
# =========================
def ohlc_to_candlestick_png_file(
ohlc_data,
save_path,
instrument="XAUUSD",
timeframe="H1",
dark=True,
dpi=180,
figsize=(12, 4),
):
if not ohlc_data:
raise ValueError("ohlc_data is empty")
if dark:
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
ax.grid(True, linestyle=":", linewidth=0.6, alpha=0.6)
candle_w = 0.55
wick_lw = 1.0
for i, bar in enumerate(ohlc_data):
o = bar["Open"]
h = bar["High"]
l = bar["Low"]
c = bar["Close"]
up = c >= o
color = "#00ff66" if up else "#ff3355"
ax.vlines(i, l, h, linewidth=wick_lw, color=color)
body_low = min(o, c)
body_h = max(abs(c - o), 1e-8)
rect = plt.Rectangle(
(i - candle_w / 2, body_low),
candle_w,
body_h,
color=color,
alpha=0.95,
)
ax.add_patch(rect)
ax.yaxis.tick_right()
ax.yaxis.set_label_position("right")
dt_labels = [b["DateTime"] for b in ohlc_data]
n = len(ohlc_data)
show_idx = [0, n // 2, n - 1] if n >= 3 else list(range(n))
ax.set_xticks(show_idx)
ax.set_xticklabels([dt_labels[i] for i in show_idx], fontsize=8)
ax.set_xlim(-1, n)
ax.set_title(f"{instrument} {timeframe} ({n} candles)", fontsize=10)
plt.tight_layout()
fig.savefig(save_path, format="png")
plt.close(fig)
# =========================
# PNG → Base64(data URL)
# =========================
def png_file_to_data_url(path: str) -> str:
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:image/png;base64,{b64}"
# =========================
# system / prompt ブロック
# =========================
def build_system_content_block():
# ★通常(DEBUG=False):現行の「数値のみ」指示を維持
return (
"あなたは優秀な投資アドバイザーです。"
"ユーザーの指示を厳密に守り、指定された形式の数値のみを出力してください。"
"思考過程や説明文は一切出力してはいけません。"
).strip()
def build_system_content_block_debug():
# ★DEBUG=True:ブロック構造で「数値部分」と「理由」を同時出力
return (
"あなたは優秀な投資アドバイザーです。"
"ユーザーの指示を厳密に守ってください。"
"出力は必ず指定されたブロック構造に従ってください。"
"NUMERIC OUTPUT では指定フォーマットの数値行のみ。"
"REASON OUTPUT では理由を文章で簡潔に。"
).strip()
def build_numeric_summary(current_price, ohlc_short, ohlc_long):
sum_short = summarize_ohlc(ohlc_short)
sum_long = summarize_ohlc(ohlc_long)
return f"""
数値要約チャートと同一データ
- 現在価格: {current_price:.2f}
- 短期({sum_short["n"]}):
高値={sum_short["high"]:.2f}, 安値={sum_short["low"]:.2f}, レンジ={sum_short["range"]:.2f},
ATR={sum_short["atr"]:.2f}, 平均実体={sum_short["avg_body"]:.2f},
上昇本数={sum_short["up"]}, 下落本数={sum_short["down"]}, 傾き={sum_short["slope"]:.4f}
- 中期({sum_long["n"]}):
高値={sum_long["high"]:.2f}, 安値={sum_long["low"]:.2f}, レンジ={sum_long["range"]:.2f},
ATR={sum_long["atr"]:.2f}, 平均実体={sum_long["avg_body"]:.2f},
上昇本数={sum_long["up"]}, 下落本数={sum_long["down"]}, 傾き={sum_long["slope"]:.4f}
""".strip()
def build_header(current_price, numeric_summary, trend_state, candle_short=CANDLE_SHORT, candle_long=CANDLE_LONG):
header = f"""
以下はXAUUSDGOLD{timeframe}足チャート画像です
- 1枚目短期直近{candle_short}
- 2枚目中期直近{candle_long}
現在価格は {current_price:.2f}短期チャートの最後の足の終値とします
重要外部トレンド判定H4
trend_state = {trend_state} 0=RANGE, 1=UP, 2=DOWN
この trend_state は外部ロジックで確定した前提情報です必ず尊重してください
- trend_state=1UPのとき買い優先売り方向の提案は避ける
- trend_state=2DOWNのとき売り優先買い方向の提案は避ける
- trend_state=0RANGEのとき逆張り優先
""".strip()
if numeric_summary:
header = f"{header}\n\n{numeric_summary}".strip()
return header
def build_common_rules_block(selected_strategies):
desc = {
1: "1. 順張りエントリーの買い",
2: "2. 逆張りエントリーの買い",
3: "3. 順張りエントリーの売り",
4: "4. 逆張りエントリーの売り",
}
cond = {
1: "(エントリー基準 > 現在価格, 利確目標 > エントリー基準, エントリー基準 > ロスカット基準)",
2: "(現在価格 > エントリー基準, 利確目標 > エントリー基準, エントリー基準 > ロスカット基準)",
3: "(現在価格 > エントリー基準, エントリー基準 > 利確目標, ロスカット基準 > エントリー基準)",
4: "(エントリー基準 > 現在価格, エントリー基準 > 利確目標, ロスカット基準 > エントリー基準)",
}
lines = []
for k in selected_strategies:
lines.append(f"{desc[k]}\n {cond[k]}")
strategy_block = "\n\n".join(lines).strip()
out_order = "".join(str(x) for x in selected_strategies)
return f"""
このデータを用いて以下の戦略パターンについてのみ
それぞれ独立にエントリー条件を検討してください
対象戦略
{strategy_block}
価格決定ルール
- 各戦略ごとに以下の3つの価格を必ず決定してください
- エントリー基準価格
- 利確目標価格
- ロスカット基準価格
- 各価格の大小関係がその戦略の条件と整合しているか必ず検証してください
- それぞれの戦略において利益の期待値が最大になるように価格を設定してください
時間条件全戦略共通
- 現在価格から1時間以内にエントリー基準価格に到達しなければその戦略はキャンセル
- エントリー後12時間以内に利確損切に到達しなければその時点の価格でクローズ
出力ルール最重要
以下の形式で **対象戦略の行だけ** 出力してください
- 1行につき1戦略
- 行の順序は {out_order}
- 各行は以下の4つをカンマ区切りで出力
戦略番号,エントリー基準価格,利確目標価格,ロスカット基準価格
- 数値のみを出力し説明文空行記号は一切出力してはいけません
- 各行の価格は必ず小数点以下2桁まで出力すること: 4812.62
""".strip()
def build_common_rules_block_debug(selected_strategies):
# 元のルールは変えずに末尾へデバッグ出力を足す
base = build_common_rules_block(selected_strategies)
tail = """
デバッグ追加ルール
デバッグモードのため出力を次の3ブロック構成にしてください順番固定
### NUMERIC OUTPUT ###
ここには上記出力ルール最重要に従った数値行のみをそのまま出力してください
余計な文字や空行は禁止
### REASON OUTPUT ###
各出力行についてentry/tp/sl をそのように置いた意図を各12行で説明してください
最後にtrend_state をどう解釈したかを12行でまとめてください
### END ###
""".strip()
return f"{base}\n\n{tail}".strip()
def build_caution_block():
return """
trend_state とチャート/数値要約が矛盾すると判断した場合は安全側に倒してください
具体的にはエントリーしづらい価格現在価格から遠いにするか条件が成立しない価格にしてください
""".strip()
# =========================
# GPT呼び出し(画像付き)
# =========================
def call_gpt(client, model, system_content: str, user_text: str, images_data_urls, max_output_tokens: int = 220):
content_parts = [{"type": "input_text", "text": user_text}]
for u in images_data_urls:
content_parts.append({"type": "input_image", "image_url": u})
resp = client.responses.create(
model=model,
input=[
{"role": "system", "content": system_content},
{"role": "user", "content": content_parts},
],
temperature=0.0,
max_output_tokens=max_output_tokens,
)
return resp.output_text or ""
# =========================
# subset行 → 13数値(res + (en,tp,sl)*4
# =========================
TARGET_SIZE = 13
def parse_lines_to_13_allow_subset(gpt_reply: str):
out = [1] + [0.0] * 12 # res=1, あとは0埋め
try:
lines = [l.strip() for l in gpt_reply.strip().splitlines() if l.strip()]
for line in lines:
nums = re.findall(r"[-+]?\d+(?:\.\d+)?", line)
if len(nums) < 4:
continue
s = int(float(nums[0]))
if s not in (1, 2, 3, 4):
continue
entry = float(nums[1])
tp = float(nums[2])
sl = float(nums[3])
base = 1 + (s - 1) * 3
out[base + 0] = entry
out[base + 1] = tp
out[base + 2] = sl
any_price = any(v != 0.0 for v in out[1:])
if not any_price:
return [0] * TARGET_SIZE
return out
except Exception:
return [0] * TARGET_SIZE
# =========================
# デバッグブロック抽出(ENTRY用)
# =========================
def extract_entry_blocks_debug(gpt_text: str):
if not gpt_text:
return "", ""
m_num = re.search(
r"###\s*NUMERIC OUTPUT\s*###\s*(.*?)\s*###\s*REASON OUTPUT\s*###",
gpt_text,
re.DOTALL,
)
numeric_part = m_num.group(1).strip() if m_num else ""
m_reason = re.search(
r"###\s*REASON OUTPUT\s*###\s*(.*?)\s*###\s*END\s*###",
gpt_text,
re.DOTALL,
)
reason_part = m_reason.group(1).strip() if m_reason else ""
return numeric_part, reason_part
# =========================
# デバッグ理由ログ(追加)
# =========================
def _now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def write_debug_entry(
path: str,
current_price: float,
trend_state: int,
selected_strategies: list,
numeric_summary: str,
numeric_lines: str,
reason_text: str,
):
try:
with open(path, "a", encoding="utf-8") as f:
f.write("=" * 60 + "\n")
f.write(f"DEBUG TIME : {_now_str()}\n")
f.write(f"MODEL : {gpt_model}\n")
f.write(f"TIMEFRAME : {timeframe}\n")
f.write(f"TREND_STATE(H4) : {trend_state} (0=RANGE,1=UP,2=DOWN)\n")
f.write(f"SELECTED_STRATEGY : {','.join(str(x) for x in selected_strategies)}\n")
f.write(f"CURRENT PRICE : {current_price:.2f}\n")
f.write("=" * 60 + "\n\n")
f.write("---- NUMERIC SUMMARY START ----\n")
f.write((numeric_summary or "").strip() + "\n")
f.write("---- NUMERIC SUMMARY END ----\n\n")
f.write("---- GPT NUMERIC LINES START ----\n")
f.write((numeric_lines or "").strip() + "\n")
f.write("---- GPT NUMERIC LINES END ----\n\n")
f.write("---- REASON START ----\n")
f.write((reason_text or "").strip() + "\n")
f.write("---- REASON END ----\n\n")
except Exception as e:
print("debug_entry.txt write error:", e)
# =========================
# メイン処理:CSV→画像→GPT→13数値→保存→done
# =========================
def run_pipeline():
# ===== トレンド読み取り(H4側結果) =====
trend_state = read_trend_state(trend_file_path)
selected_strategies = strategies_by_trend(trend_state)
# CSV読み込み
try:
df = pd.read_csv(input_file_path, encoding="utf-8")
df["Time"] = df["Time"].astype(str)
df["Open"] = df["Open"].astype(float)
df["High"] = df["High"].astype(float)
df["Low"] = df["Low"].astype(float)
df["Close"] = df["Close"].astype(float)
except Exception as e:
print("CSV 読み込みエラー:", e)
numeric_list = [0] * TARGET_SIZE
_write_outputs(numeric_list)
return
if len(df) < CANDLE_LONG:
print(f"データ本数不足: len(df)={len(df)} (need >= {CANDLE_LONG})")
numeric_list = [0] * TARGET_SIZE
_write_outputs(numeric_list)
return
# OHLC整形(DateTimeキーで統一)
ohlc_all = []
for _, r in df.iterrows():
ohlc_all.append(
{
"DateTime": r["Time"],
"Open": float(r["Open"]),
"High": float(r["High"]),
"Low": float(r["Low"]),
"Close": float(r["Close"]),
}
)
ohlc_short = ohlc_all[-CANDLE_SHORT:]
ohlc_long = ohlc_all[-CANDLE_LONG:]
current_price = float(ohlc_short[-1]["Close"])
# 画像生成
try:
ohlc_to_candlestick_png_file(
ohlc_data=ohlc_short,
save_path=TMP_SHORT_PATH,
instrument=instrument,
timeframe=timeframe,
dark=True,
)
ohlc_to_candlestick_png_file(
ohlc_data=ohlc_long,
save_path=TMP_LONG_PATH,
instrument=instrument,
timeframe=timeframe,
dark=True,
)
img_short_url = png_file_to_data_url(TMP_SHORT_PATH)
img_long_url = png_file_to_data_url(TMP_LONG_PATH)
images_data_urls = [img_short_url, img_long_url]
except Exception as e:
print("画像生成エラー:", e)
numeric_list = [0] * TARGET_SIZE
_write_outputs(numeric_list)
return
# プロンプト作成(トレンドに応じて対象戦略を変更)
numeric_summary = build_numeric_summary(
current_price=current_price,
ohlc_short=ohlc_short,
ohlc_long=ohlc_long,
)
header = build_header(
current_price=current_price,
numeric_summary=numeric_summary,
trend_state=trend_state,
)
caution = build_caution_block()
if DEBUG_PRINT:
system_content = build_system_content_block_debug()
common_rules = build_common_rules_block_debug(selected_strategies)
max_tokens = 650
else:
system_content = build_system_content_block()
common_rules = build_common_rules_block(selected_strategies)
max_tokens = 220
user_text = "\n\n".join([header, common_rules, caution]).strip()
# GPT呼び出し
gpt_reply = ""
try:
gpt_reply = call_gpt(
client=client,
model=gpt_model,
system_content=system_content,
user_text=user_text,
images_data_urls=images_data_urls,
max_output_tokens=max_tokens,
).strip()
except Exception as e:
print("OpenAI APIエラー:", e)
numeric_list = [0] * TARGET_SIZE
_write_outputs(numeric_list)
return
if DEBUG_PRINT:
print("---- TREND ----", trend_state, "selected=", selected_strategies)
print("---- GPT REPLY START ----")
print(gpt_reply)
print("---- GPT REPLY END ----")
# =========================
# DEBUG: ブロック抽出して「数値部分」と「理由」を分離
# =========================
if DEBUG_PRINT:
numeric_lines, reason_text = extract_entry_blocks_debug(gpt_reply)
# numeric_lines が取れない場合は、全文から数値部分をパース(フォールバック)
if not numeric_lines:
numeric_lines = gpt_reply
numeric_list = parse_lines_to_13_allow_subset(numeric_lines)
# debug_entry.txt に保存
try:
write_debug_entry(
path=DEBUG_ENTRY_REASON_PATH,
current_price=current_price,
trend_state=trend_state,
selected_strategies=selected_strategies,
numeric_summary=numeric_summary,
numeric_lines=numeric_lines,
reason_text=reason_text,
)
except Exception as e:
print("debug_entry.txt write error:", e)
else:
# 通常運用:現行通り数値だけ
numeric_list = parse_lines_to_13_allow_subset(gpt_reply)
# 保存 & done
_write_outputs(numeric_list)
def _write_outputs(numeric_list):
# 結果をファイルに保存(MT5向け:utf-16 LE / 1行1数値)
try:
with open(output_file_path, mode="w", encoding=MT_encoding) as f:
for number in numeric_list:
f.write(f"{number}\n")
except Exception as e:
print("ファイル保存中にエラーが発生しました:", e)
# doneentry
try:
with open(done_entry_file_path, "w", encoding="utf-8") as f:
f.write("")
except Exception as e:
print("process_done_entry.txt 作成エラー:", e)
# =========================
# 実行
# =========================
if __name__ == "__main__":
run_pipeline()
+488
View File
@@ -0,0 +1,488 @@
import os
import re
import base64
from datetime import datetime
import pandas as pd
import matplotlib.pyplot as plt
import openai
# =========================
# ユーザー環境設定
# =========================
user_name = "new"
user_path = f"C:\\Users\\{user_name}"
terminal_ID = "5BDB0B60344C088C2FA5CA35699BAAFD"
terminal_path = f"{user_path}\\AppData\\Roaming\\MetaQuotes\\Terminal\\{terminal_ID}\\MQL5\\Files"
MT_encoding = "utf-16 LE"
YOUR_API_KEY = os.getenv("OPENAI_API_KEY")
if YOUR_API_KEY is None:
raise RuntimeError("OPENAI_API_KEY が環境変数に設定されていません。")
gpt_model = "gpt-5.4"
# =========================
# 入出力ファイル
# =========================
input_file_path = os.path.join(terminal_path, "ohlc_H4.csv")
trend_state_path = os.path.join(terminal_path, "trend_state.txt")
done_trend_path = os.path.join(terminal_path, "process_done_trend.txt")
# =========================
# チャート画像の設定
# =========================
CANDLE_TREND = 72
instrument = "GOLD"
timeframe = "H4"
TMP_TREND_PATH = os.path.join(terminal_path, "tmp_chart_trend.png")
DEBUG_PRINT = False # True: 理由も取得してファイル保存 / False: 数値(0/1/2)のみ
# デバッグ理由ログ
DEBUG_TREND_REASON_PATH = os.path.join(terminal_path, "debug_trend.txt")
# =========================
# OpenAI クライアント
# =========================
client = openai.OpenAI(api_key=YOUR_API_KEY)
# =========================
# ユーティリティ
# =========================
def _now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def write_debug_trend(path: str, current_price: float, numeric_summary: str, gpt_numeric: str, reason_text: str):
"""debug_trend.txt に追記(数値 + 理由)"""
try:
with open(path, "a", encoding="utf-8") as f:
f.write("=" * 60 + "\n")
f.write(f"DEBUG TIME : {_now_str()}\n")
f.write(f"MODEL : {gpt_model}\n")
f.write(f"CURRENT PRICE : {current_price:.2f}\n")
f.write("=" * 60 + "\n\n")
f.write("---- NUMERIC SUMMARY START ----\n")
f.write((numeric_summary or "").strip() + "\n")
f.write("---- NUMERIC SUMMARY END ----\n\n")
f.write("---- GPT NUMERIC (0/1/2) START ----\n")
f.write((gpt_numeric or "").strip() + "\n")
f.write("---- GPT NUMERIC (0/1/2) END ----\n\n")
f.write("---- REASON START ----\n")
f.write((reason_text or "").strip() + "\n")
f.write("---- REASON END ----\n\n")
except Exception as e:
print("debug_trend.txt write error:", e)
# =========================
# 数値要約作成
# =========================
def summarize_ohlc(ohlc):
"""
ohlc: [{"DateTime","Open","High","Low","Close"}, ...]
"""
n = len(ohlc)
highs = [b["High"] for b in ohlc]
lows = [b["Low"] for b in ohlc]
opens = [b["Open"] for b in ohlc]
closes = [b["Close"] for b in ohlc]
hi = max(highs)
lo = min(lows)
trs = []
prev_close = closes[0]
for b in ohlc:
tr = max(
b["High"] - b["Low"],
abs(b["High"] - prev_close),
abs(b["Low"] - prev_close),
)
trs.append(tr)
prev_close = b["Close"]
atr = sum(trs) / max(1, len(trs))
bodies = [abs(c - o) for o, c in zip(opens, closes)]
avg_body = sum(bodies) / max(1, len(bodies))
up_cnt = sum(1 for o, c in zip(opens, closes) if c >= o)
dn_cnt = n - up_cnt
slope = (closes[-1] - closes[0]) / max(1, n - 1)
return {
"n": n,
"high": hi,
"low": lo,
"range": hi - lo,
"atr": atr,
"avg_body": avg_body,
"up": up_cnt,
"down": dn_cnt,
"slope": slope,
}
# =========================
# ローソク足描画
# =========================
def ohlc_to_candlestick_png_file(
ohlc_data,
save_path,
instrument="XAUUSD",
timeframe="H4",
dark=True,
dpi=180,
figsize=(12, 4),
):
if not ohlc_data:
raise ValueError("ohlc_data is empty")
if dark:
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
ax.grid(True, linestyle=":", linewidth=0.6, alpha=0.6)
candle_w = 0.55
wick_lw = 1.0
for i, bar in enumerate(ohlc_data):
o = bar["Open"]
h = bar["High"]
l = bar["Low"]
c = bar["Close"]
up = c >= o
color = "#00ff66" if up else "#ff3355"
ax.vlines(i, l, h, linewidth=wick_lw, color=color)
body_low = min(o, c)
body_h = max(abs(c - o), 1e-8)
rect = plt.Rectangle(
(i - candle_w / 2, body_low),
candle_w,
body_h,
color=color,
alpha=0.95,
)
ax.add_patch(rect)
ax.yaxis.tick_right()
ax.yaxis.set_label_position("right")
dt_labels = [b["DateTime"] for b in ohlc_data]
n = len(ohlc_data)
show_idx = [0, n // 2, n - 1] if n >= 3 else list(range(n))
ax.set_xticks(show_idx)
ax.set_xticklabels([dt_labels[i] for i in show_idx], fontsize=8)
ax.set_xlim(-1, n)
ax.set_title(f"{instrument} {timeframe} ({n} candles)", fontsize=10)
plt.tight_layout()
fig.savefig(save_path, format="png")
plt.close(fig)
# =========================
# PNG → Base64(data URL)
# =========================
def png_file_to_data_url(path: str) -> str:
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:image/png;base64,{b64}"
# =========================
# system / prompt
# =========================
def build_trend_system_content():
# ★通常(DEBUG=False):現行通り「数値1つのみ」を強制
return (
"あなたは相場分析の判定器です。"
"出力は数字1つ(0/1/2)のみ。"
"説明文、記号、空行、追加の数値は一切出力しない。"
"出力は必ず 0 または 1 または 2。"
).strip()
def build_trend_system_content_debug():
# ★DEBUG=True:数値 + 理由を同時に返させる(ブロックで分離)
return (
"あなたは相場分析の判定器です。"
"ユーザーの指示を厳密に守ってください。"
"出力は必ず指定されたブロック構造に従ってください。"
"NUMERIC OUTPUT では 0/1/2 の数字1つのみ。"
"REASON OUTPUT では理由を文章で簡潔に。"
).strip()
def build_trend_numeric_summary(current_price: float, ohlc_trend):
s = summarize_ohlc(ohlc_trend)
return f"""
数値要約チャートと同一データ
- 現在価格: {current_price:.2f}
- 対象本数: {s["n"]}H4
高値={s["high"]:.2f}, 安値={s["low"]:.2f}, レンジ={s["range"]:.2f},
ATR={s["atr"]:.2f}, 平均実体={s["avg_body"]:.2f},
上昇本数={s["up"]}, 下落本数={s["down"]}, 傾き={s["slope"]:.4f}
""".strip()
def build_trend_user_prompt(current_price: float, numeric_summary: str, candle_count: int):
# ★元のプロンプト(変更なし)
return f"""
あなたはXAUUSDGOLDのH4の今後12時間のトレンドを判定し
次のいずれかを **数字1つ** で出力してください
0 = 横ばいレンジ
1 = 上昇トレンド
2 = 下降トレンド
入力
- 現在価格: {current_price:.2f}
- 添付のH4チャート画像直近{candle_count}
- 数値要約同一データ由来:
{numeric_summary}
判定の考え方
- 画像と数値要約の両方を参考にして良い
- 判定ロジック傾きATRMAダウ理論レンジ幅などはあなたが最適だと思う方法でよい
- ただし自信がない方向が混在している場合は事故回避のため 0横ばいを選ぶこと
出力ルール
- 出力は 0 / 1 / 2 のどれか数字1つのみ
- それ以外は一切出力しない
""".strip()
def build_trend_user_prompt_debug(current_price: float, numeric_summary: str, candle_count: int):
# ★元のプロンプトは変えず、末尾に「デバッグ出力形式」を追加
base = build_trend_user_prompt(current_price, numeric_summary, candle_count)
tail = """
デバッグ追加ルール
デバッグモードのため出力を次の3ブロック構成にしてください順番固定
### NUMERIC OUTPUT ###
0 / 1 / 2 のどれか数字1つのみ
余計な文字空行記号は禁止
### REASON OUTPUT ###
なぜその判定0/1/2にしたかを箇条書きで36点で簡潔に
最後に12時間目線での注意点を1行で
### END ###
""".strip()
return f"{base}\n\n{tail}".strip()
# =========================
# GPT呼び出し(画像付き)
# =========================
def call_gpt_trend(client, model, system_content: str, user_text: str, image_data_url: str | None, max_output_tokens: int):
content_parts = [{"type": "input_text", "text": user_text}]
if image_data_url:
content_parts.append({"type": "input_image", "image_url": image_data_url})
resp = client.responses.create(
model=model,
input=[
{"role": "system", "content": system_content},
{"role": "user", "content": content_parts},
],
temperature=0.0,
max_output_tokens=max_output_tokens,
)
return (resp.output_text or "").strip()
# =========================
# デバッグブロック抽出(trend用)
# =========================
def extract_trend_blocks_debug(gpt_text: str):
"""
### NUMERIC OUTPUT ### ... ### REASON OUTPUT ### ... ### END ###
を抽出
"""
if not gpt_text:
return "", ""
m_num = re.search(
r"###\s*NUMERIC OUTPUT\s*###\s*(.*?)\s*###\s*REASON OUTPUT\s*###",
gpt_text,
re.DOTALL,
)
numeric_part = m_num.group(1).strip() if m_num else ""
m_reason = re.search(
r"###\s*REASON OUTPUT\s*###\s*(.*?)\s*###\s*END\s*###",
gpt_text,
re.DOTALL,
)
reason_part = m_reason.group(1).strip() if m_reason else ""
return numeric_part, reason_part
# =========================
# GPT返信 → 0/1/2 に正規化
# =========================
def parse_trend_012(text: str) -> int:
if not isinstance(text, str):
return 0
m = re.findall(r"[-+]?\d+", text.strip())
if not m:
return 0
v = int(m[0])
return v if v in (0, 1, 2) else 0
# =========================
# 出力(0/1/2のみ) + done
# =========================
def _write_outputs(trend_val: int):
# trend_state.txt0/1/2 だけ)
try:
with open(trend_state_path, mode="w", encoding=MT_encoding, newline="") as f:
f.write(str(trend_val))
except Exception as e:
print("trend_state.txt 書き込みエラー:", e)
# doneファイル(空ファイルでOK)
try:
with open(done_trend_path, mode="w", encoding="utf-8", newline="") as f:
f.write("")
except Exception as e:
print("process_done_trend.txt 作成エラー:", e)
# =========================
# メイン処理:CSV→画像→GPT→trend_state.txt→done
# - DEBUG=False: 数値(0/1/2)のみ(現行通り)
# - DEBUG=True : 数値+理由(debug_trend.txtに保存)
# =========================
def run_pipeline():
trend_val = 0 # 安全側デフォルト
# CSV読み込み
try:
df = pd.read_csv(input_file_path, encoding="utf-8")
df["Time"] = df["Time"].astype(str)
df["Open"] = df["Open"].astype(float)
df["High"] = df["High"].astype(float)
df["Low"] = df["Low"].astype(float)
df["Close"] = df["Close"].astype(float)
except Exception as e:
print("CSV 読み込みエラー:", e)
_write_outputs(trend_val)
return
if len(df) < 5:
print(f"データ本数不足: len(df)={len(df)}")
_write_outputs(trend_val)
return
# OHLC整形
ohlc_all = []
for _, r in df.iterrows():
ohlc_all.append(
{
"DateTime": r["Time"],
"Open": float(r["Open"]),
"High": float(r["High"]),
"Low": float(r["Low"]),
"Close": float(r["Close"]),
}
)
# 直近 CANDLE_TREND 本
ohlc_trend = ohlc_all[-min(CANDLE_TREND, len(ohlc_all)) :]
current_price = float(ohlc_trend[-1]["Close"])
# 画像生成
image_url = None
try:
ohlc_to_candlestick_png_file(
ohlc_data=ohlc_trend,
save_path=TMP_TREND_PATH,
instrument=instrument,
timeframe=timeframe,
dark=True,
)
image_url = png_file_to_data_url(TMP_TREND_PATH)
except Exception as e:
print("画像生成エラー(画像なしで続行):", e)
image_url = None
# 数値要約(共通)
numeric_summary = build_trend_numeric_summary(current_price=current_price, ohlc_trend=ohlc_trend)
# =========================
# GPT呼び出し(DEBUGで完全分離)
# =========================
if DEBUG_PRINT:
system_content = build_trend_system_content_debug()
user_text = build_trend_user_prompt_debug(
current_price=current_price,
numeric_summary=numeric_summary,
candle_count=len(ohlc_trend),
)
max_tokens = 250
else:
system_content = build_trend_system_content()
user_text = build_trend_user_prompt(
current_price=current_price,
numeric_summary=numeric_summary,
candle_count=len(ohlc_trend),
)
max_tokens = 16
gpt_reply = ""
try:
gpt_reply = call_gpt_trend(
client=client,
model=gpt_model,
system_content=system_content,
user_text=user_text,
image_data_url=image_url,
max_output_tokens=max_tokens,
)
except Exception as e:
print("OpenAI APIエラー:", e)
gpt_reply = ""
if DEBUG_PRINT:
print("---- GPT TREND REPLY START ----")
print(gpt_reply)
print("---- GPT TREND REPLY END ----")
# =========================
# 解析・保存
# =========================
if DEBUG_PRINT:
numeric_part, reason_part = extract_trend_blocks_debug(gpt_reply)
# numeric抽出に失敗したら、全文からフォールバック
if not numeric_part:
numeric_part = gpt_reply
trend_val = parse_trend_012(numeric_part)
# debug_trend.txt に保存(数値+理由)
try:
write_debug_trend(
path=DEBUG_TREND_REASON_PATH,
current_price=current_price,
numeric_summary=numeric_summary,
gpt_numeric=str(trend_val),
reason_text=reason_part,
)
except Exception as e:
print("debug_trend.txt write error:", e)
else:
trend_val = parse_trend_012(gpt_reply)
# 出力(EA側は常に 0/1/2 のみ)
_write_outputs(trend_val)
# =========================
# 実行
# =========================
if __name__ == "__main__":
run_pipeline()