Consolidate Python ignore rules into root gitignore
This commit is contained in:
@@ -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 の責務が分かれている
|
||||
@@ -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()
|
||||
```
|
||||
@@ -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` 以上を基本にする。
|
||||
- エラー原因の調査に必要な情報は内部ログに残す。
|
||||
@@ -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を組み立てない。
|
||||
@@ -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 は明示的に依頼された場合のみ使用する。
|
||||
@@ -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` と、補足内容が大きくズレないようにする。
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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つの関数に複数の責務を持たせない。
|
||||
@@ -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` ファイルに残さない。
|
||||
@@ -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 制約を検討する。
|
||||
@@ -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
@@ -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側はジョブ登録とステータス表示を担当する。
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -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読込で失敗した場合は、未定義状態のまま処理を続けず、停止値を出力する。
|
||||
|
||||
@@ -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_version(2)
|
||||
2行目 : res_chk
|
||||
3行目 : candidate_id(H1確定足時刻由来)
|
||||
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 側の読み込み処理も同時に確認してください。
|
||||
@@ -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
|
||||
@@ -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
|
||||
ボラティリティ分類を導入する
|
||||
異常ボラでは新規注文を停止する
|
||||
レンジ逆張り条件を厳しくする
|
||||
高ボラトレンド中の逆張りを禁止する
|
||||
```
|
||||
|
||||
この方針により、ゴールド特有の大きな値動きに対して、攻める場面と止める場面を分けやすくする。
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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}"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""I/O helpers for MT5 integration files."""
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"""
|
||||
以下はXAUUSD(GOLD)の{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 をそのように置いた意図を各1〜2行で説明してください。
|
||||
最後に「market_state をどう解釈したか」を1〜2行でまとめてください。
|
||||
|
||||
### 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"""
|
||||
あなたはXAUUSD(GOLD)のH4の今後12時間のトレンドを判定し、
|
||||
次のいずれかを **数字1つ** で出力してください。
|
||||
|
||||
0 = 横ばい(レンジ)
|
||||
1 = 上昇トレンド
|
||||
2 = 下降トレンド
|
||||
|
||||
【入力】
|
||||
- 現在価格: {current_price:.2f}
|
||||
- 添付のH4チャート画像(直近{candle_count}本)
|
||||
- 数値要約(同一データ由来):
|
||||
{numeric_summary}
|
||||
|
||||
【判定の考え方】
|
||||
- 画像と数値要約の両方を参考にして良い。
|
||||
- 判定ロジック(傾き、ATR、MA、ダウ理論、レンジ幅など)はあなたが最適だと思う方法でよい。
|
||||
- ただし「自信がない」「方向が混在している」場合は事故回避のため 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)にしたかを、箇条書きで3〜6点で簡潔に。
|
||||
最後に「12時間目線での注意点」を1行で。
|
||||
|
||||
### END ###
|
||||
""".strip()
|
||||
return f"{base}\n\n{tail}".strip()
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Generated
+2148
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"""
|
||||
以下はXAUUSD(GOLD)の{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=1(UP)のとき:買い優先(売り方向の提案は避ける)
|
||||
- trend_state=2(DOWN)のとき:売り優先(買い方向の提案は避ける)
|
||||
- trend_state=0(RANGE)のとき:逆張り優先
|
||||
""".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 をそのように置いた意図を各1〜2行で説明してください。
|
||||
最後に「trend_state をどう解釈したか」を1〜2行でまとめてください。
|
||||
|
||||
### 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)
|
||||
|
||||
# done(entry)
|
||||
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()
|
||||
@@ -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"""
|
||||
あなたはXAUUSD(GOLD)のH4の今後12時間のトレンドを判定し、
|
||||
次のいずれかを **数字1つ** で出力してください。
|
||||
|
||||
0 = 横ばい(レンジ)
|
||||
1 = 上昇トレンド
|
||||
2 = 下降トレンド
|
||||
|
||||
【入力】
|
||||
- 現在価格: {current_price:.2f}
|
||||
- 添付のH4チャート画像(直近{candle_count}本)
|
||||
- 数値要約(同一データ由来):
|
||||
{numeric_summary}
|
||||
|
||||
【判定の考え方】
|
||||
- 画像と数値要約の両方を参考にして良い。
|
||||
- 判定ロジック(傾き、ATR、MA、ダウ理論、レンジ幅など)はあなたが最適だと思う方法でよい。
|
||||
- ただし「自信がない」「方向が混在している」場合は事故回避のため 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)にしたかを、箇条書きで3〜6点で簡潔に。
|
||||
最後に「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.txt(0/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()
|
||||
Reference in New Issue
Block a user