Adopt structured outputs for GPT entry generation

This commit is contained in:
Hiroaki86
2026-05-30 22:01:57 +09:00
parent 652da68675
commit bbc8ea1a80
8 changed files with 250 additions and 84 deletions
+5 -1
View File
@@ -92,7 +92,8 @@ MT5 が読む結果ファイルは `utf-16 LE` で出力します。結果ファ
3. H1 の短期 36 本、中期 72 本からチャート PNG と数値要約を作る。
4. H1 インバランス初動を Python 側で判定し、矛盾する候補を抑止する。
5. OpenAI Responses API に候補価格・予測ゾーン生成を依頼する。
6. GPT 出力を既存13行形式と分割エントリー用ゾーン形式へ展開し、価格の大小関係と距離を検証する。
出力は Structured Outputs のJSON Schemaで固定する。
6. GPT JSON出力を既存13行形式と分割エントリー用ゾーン形式へ展開し、価格の大小関係、距離、reward/riskを検証する。
7. `target_prices.txt``target_zones.txt` を書き、両方の完了後に `process_done_entry.txt` を作成する。
H4 状態ごとの許可戦略:
@@ -132,6 +133,9 @@ EA側で分割エントリーを有効にした場合は、この予測ゾーン
## OpenAI 設定
H1 エントリー候補生成では OpenAI API を使用します。
Responses APIの `text.format` にJSON Schemaを渡し、GPT出力を自然文やCSV風テキストではなく
`schema_version``strategies` を持つ構造化JSONに固定します。
JSONが不正、API応答が未完了、価格条件やreward/risk条件に合わない場合は停止値へ倒します。
必須:
@@ -207,8 +207,8 @@ EAファイルはこのPythonプロジェクトの外部連携先として扱う
| `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/market/target_zones.py` | GPT出力予測ゾーン分割エントリー用7行形式へ変換。 |
| `src/ea_py/market/target_prices.py` | 既存13行形式への変換と価格整合性チェック。 |
| `src/ea_py/market/target_zones.py` | Structured OutputsのGPT JSON出力予測ゾーンへ変換し、分割エントリー用7行形式へ展開。 |
| `src/ea_py/market/imbalance.py` | H1インバランス初動判定。 |
| `src/ea_py/prompts/trend_prompt.py` | H4相場環境判定プロンプトの生成。 |
| `src/ea_py/prompts/entry_prompt.py` | H1候補価格生成プロンプトの生成。 |
+2
View File
@@ -8,6 +8,7 @@ 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
ENTRY_RESPONSE_SCHEMA_VERSION = 1
ATR_PERIOD = 14
ATR_BASELINE_PERIOD = 20
@@ -24,6 +25,7 @@ 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
MIN_ENTRY_REWARD_RISK_RATIO = 1.20
CANDLE_TREND = 72
CANDLE_SHORT = 36
+12 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
import json
import logging
@@ -30,6 +30,10 @@ class ResponsesApiDiagnostics:
error: str
usage: str
def is_completed(self) -> bool:
"""ライブ判定に使える完了レスポンスかどうかを返す。"""
return self.status == "completed" and not self.incomplete_details and not self.error
def to_log_text(self) -> str:
"""デバッグログへそのまま書ける1行診断文字列を返す。"""
parts = [
@@ -89,6 +93,7 @@ def call_responses_api(
user_text: str,
image_data_urls: Sequence[str],
max_output_tokens: int,
response_text_format: Mapping[str, Any] | None = None,
) -> ResponsesApiResult:
"""Responses APIへテキストとチャート画像を送り、出力テキストを返す。
@@ -96,6 +101,9 @@ def call_responses_api(
`user_text` はH4/H1の具体的な分析依頼、数値要約、出力フォーマットを含む。
`image_data_urls` にはPNGをdata URL化したチャート画像を渡す。空文字は無視する。
`response_text_format` が指定された場合は Responses API の `text.format` へ渡し、
JSON Schemaなどの構造化出力をAPI側でも強制する。
戻り値は `response.output_text` をstripした文字列とAPI診断情報。
API例外や空/不正な出力の安全側処理は、この薄いラッパーではなく
呼び出し元のパイプラインとパース関数が担当する。
@@ -121,6 +129,9 @@ def call_responses_api(
"reasoning": {"effort": reasoning_effort},
"max_output_tokens": max_output_tokens,
}
if response_text_format is not None:
create_params["text"] = {"format": dict(response_text_format)}
response = client.responses.create(**create_params)
diagnostics = _extract_response_diagnostics(response)
text = (response.output_text or "").strip()
@@ -11,7 +11,6 @@ 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,
@@ -22,6 +21,7 @@ from ea_py.constants import (
IMBALANCE_SENSITIVITY,
INSTRUMENT,
MARKET_STATE_LABELS,
MIN_ENTRY_REWARD_RISK_RATIO,
MT_ENCODING,
TECHNICAL_ERROR_STOP,
USE_IMBALANCE_FILTER,
@@ -31,14 +31,13 @@ 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,
parse_json_to_entry_zones,
sanitize_entry_zones,
stop_entry_zones,
zones_to_numeric_list,
@@ -54,7 +53,7 @@ from ea_py.prompts.entry_prompt import (
build_imbalance_guidance,
build_caution_block,
build_common_rules_block,
build_common_rules_block_debug,
build_entry_response_text_format,
build_header,
build_market_state_guidance,
build_numeric_summary,
@@ -206,7 +205,8 @@ def run_pipeline() -> None:
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}"
f"floor={ENTRY_MAX_DISTANCE_MIN_PRICE:.2f}, "
f"min_reward_risk={MIN_ENTRY_REWARD_RISK_RATIO:.2f}"
)
header = build_header(
current_price=current_price,
@@ -224,14 +224,10 @@ def run_pipeline() -> None:
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
system_content = build_system_content_block_debug() if config.debug_print else build_system_content_block()
common_rules = build_common_rules_block(selected_strategies, max_entry_distance)
response_text_format = build_entry_response_text_format(selected_strategies)
max_tokens = ENTRY_MAX_OUTPUT_TOKENS
user_text = "\n\n".join([header, market_state_guidance, imbalance_guidance, common_rules, caution]).strip()
@@ -246,9 +242,14 @@ def run_pipeline() -> None:
user_text=user_text,
image_data_urls=images_data_urls,
max_output_tokens=max_tokens,
response_text_format=response_text_format,
)
gpt_reply = gpt_result.text
api_diagnostics = gpt_result.diagnostics.to_log_text()
if not gpt_result.diagnostics.is_completed():
logger.error("OpenAI response incomplete or failed: %s", api_diagnostics)
write_entry_output(stop_numeric_list())
return
except Exception:
logger.exception("OpenAI APIエラー")
write_entry_output(stop_numeric_list())
@@ -262,21 +263,18 @@ def run_pipeline() -> None:
gpt_reply,
)
entry_zones = parse_json_to_entry_zones(gpt_reply)
entry_zones = sanitize_entry_zones(
entry_zones,
selected_strategies,
current_price,
max_entry_distance=max_entry_distance,
min_reward_risk_ratio=MIN_ENTRY_REWARD_RISK_RATIO,
)
numeric_list = zones_to_numeric_list(entry_zones)
zone_text = format_target_zones(entry_zones, candidate_id)
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,
@@ -290,23 +288,13 @@ def run_pipeline() -> None:
selected_strategies=selected_strategies,
imbalance_summary=imbalance_summary,
numeric_summary=numeric_summary,
numeric_lines=numeric_lines,
numeric_lines=gpt_reply,
post_filter_summary=post_filter_summary,
sanitized_numeric_list=numeric_list,
reason_text=f"{reason_text}\n\nTARGET_ZONES\n{zone_text}".strip(),
reason_text=f"STRUCTURED_JSON\n{gpt_reply}\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)
+111 -41
View File
@@ -3,33 +3,70 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from ea_py.constants import CANDLE_LONG, CANDLE_SHORT, ENTRY_TIMEFRAME, MARKET_STATE_LABELS
from ea_py.constants import CANDLE_LONG, CANDLE_SHORT, ENTRY_RESPONSE_SCHEMA_VERSION, 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
from ea_py.types import OhlcBar, OhlcSummary
ENTRY_REASON_CODES = (
"trend_breakout",
"pullback",
"range_edge",
"range_reversal",
"skip_weak_signal",
"skip_mid_range",
"skip_distance",
"skip_risk",
"skip_conflict",
"skip_other",
)
def build_system_content_block() -> str:
"""通常モード用のシステムメッセージを返す。"""
return (
"あなたは優秀な投資アドバイザーです。"
"ユーザーの指示を厳密に守り、指定された形式の数値のみを出力してください。"
"思考過程や説明文は一切出力してはいけません"
"あなたはアルゴリズム取引用の価格候補生成器です。"
"投資助言や自然文説明ではなく、指定JSON Schemaに一致する機械可読な候補だけを返してください。"
"ユーザーの制約、market_state、選択戦略、価格関係を厳密に守ってください"
"迷う場合は必ずskipにしてください。"
).strip()
def build_system_content_block_debug() -> str:
"""デバッグモード用のシステムメッセージを返す。"""
return (
"あなたは優秀な投資アドバイザーです。"
"ユーザーの指示を厳密に守ってください。"
"出力は必ず指定されたブロック構造に従ってください。"
"NUMERIC OUTPUT では指定フォーマットの数値行のみ。"
"REASON OUTPUT では理由を文章で簡潔に。"
"あなたはアルゴリズム取引用の価格候補生成器です。"
"指定JSON Schemaに一致する機械可読な候補だけを返してください。"
"自然文の理由や思考過程は出力せず、理由はreason_codeだけで表してください。"
).strip()
def _safe_ratio(numerator: float, denominator: float) -> float:
"""0除算を避けて比率を返す。"""
if denominator <= 0.0:
return 0.0
return numerator / denominator
def _format_position_metrics(summary: OhlcSummary, current_price: float) -> str:
"""レンジ内位置と高安までの距離を要約行へ追加する。"""
high = float(summary["high"])
low = float(summary["low"])
price_range = float(summary["range"])
eatr = float(summary["eatr"])
range_position = _safe_ratio(current_price - low, price_range)
distance_to_high = high - current_price
distance_to_low = current_price - low
return (
f" 現在価格レンジ位置={range_position:.2f}0=安値付近, 1=高値付近), "
f"高値まで={distance_to_high:.2f}({_safe_ratio(distance_to_high, eatr):.2f}EATR), "
f"安値まで={distance_to_low:.2f}({_safe_ratio(distance_to_low, eatr):.2f}EATR)"
)
def build_numeric_summary(
current_price: float,
ohlc_short: Sequence[OhlcBar],
@@ -50,6 +87,7 @@ def build_numeric_summary(
上ヒゲ={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}
{_format_position_metrics(sum_short, current_price)}
- 中期({sum_long["n"]}本):
高値={sum_long["high"]:.2f}, 安値={sum_long["low"]:.2f}, レンジ={sum_long["range"]:.2f},
@@ -58,6 +96,7 @@ def build_numeric_summary(
上ヒゲ={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}
{_format_position_metrics(sum_long, current_price)}
""".strip()
@@ -91,6 +130,8 @@ market_state = {trend_state}{MARKET_STATE_LABELS.get(trend_state, "UNKNOWN")}
【H1の役割】
- H1では、H4 market_state と整合する方向・セットアップだけを候補にしてください。
- H1がH4方向と明確に逆行、またはレンジ中央で優位性が弱い場合は見送ってください。
- 画像と数値要約が矛盾する場合は、バックテスト再現性を優先して数値要約を主根拠にしてください。
- 外部ニュース、経済指標、未知のファンダメンタル材料は考慮しないでください。
- 実際の発注タイミングはEA側のM15確定足フィルターで確認します。
H1ではM15の細かな反転を先読みせず、1時間以内に到達しうる妥当な候補価格を重視してください。
""".strip()
@@ -223,7 +264,7 @@ def build_common_rules_block(
"""選択戦略に応じた価格決定ルールとGPT出力形式を作る。
`selected_strategies` にはH4 market_stateと整合する戦略番号だけを渡す。
GPTには対象戦略のだけを、`戦略番号,entry,tp,sl,zone_low,zone_high` の数値行で返すよう指定する。
GPTには対象戦略の候補だけを、JSON Schemaに一致するオブジェクトで返すよう指定する。
各戦略のentry/tp/slの大小関係、1時間以内の到達条件、12時間以内の決済目線、
条件が弱い場合に `0.00,0.00,0.00,0.00,0.00` で見送るルールもここで明示する。
@@ -267,9 +308,10 @@ def build_common_rules_block(
- zone_high は予測ゾーンの高い価格
- エントリー基準価格は必ず zone_low 以上 zone_high 以下にしてください。
- 各価格の大小関係が、その戦略の条件と整合しているか必ず検証してください。
- それぞれの戦略において、利益の期待値が最大になるように価格を設定してください。
- reward/risk が 1.20 未満になる候補は、期待値が弱いため見送ってください。
- SL距離がH1の値幅に対して広すぎる、またはTPまでの余地が小さすぎる候補は見送ってください。
- 条件が弱い、ブレイク直後、レンジ中央付近、EATR基準でリスクが大きすぎる等で見送る場合は、
その戦略行を「戦略番号,0.00,0.00,0.00,0.00,0.00」としてください。
decision を "skip" とし、entry/tp/sl/zone_low/zone_high をすべて 0.0 にしてください。
{distance_rule}
【時間条件(全戦略共通)】
@@ -278,17 +320,13 @@ def build_common_rules_block(
- エントリー後12時間以内に利確・損切に到達しなければ、その時点の価格でクローズ。
【出力ルール(最重要)】
以下の形式で **対象戦略の行だけ** 出力してください。
- 1行につき1戦略
- 行の順序は {out_order}
- 各行は以下の6つをカンマ区切りで出力
戦略番号,エントリー基準価格,利確目標価格,ロスカット基準価格,予測ゾーン下限,予測ゾーン上限
- 数値のみを出力し、説明文・空行・記号は一切出力してはいけません。
- 各行の価格は必ず小数点以下2桁まで出力すること(例: 4812.62)。
- 見送り行も必ず小数点以下2桁の 0.00 を使うこと。
- Responses APIのJSON Schemaに一致するJSONオブジェクトだけを出力してください。
- schema_version は {ENTRY_RESPONSE_SCHEMA_VERSION}
- strategies には対象戦略だけを入れ、順序は {out_order}
- 各strategy要素は strategy, decision, entry, tp, sl, zone_low, zone_high, reason_code を必ず含めてください。
- decision は "use" または "skip" のみ。
- reason_code は次のいずれかのみ: {", ".join(ENTRY_REASON_CODES)}
- 自然文、Markdown、余分なキー、空行は一切出力しないでください。
""".strip()
@@ -296,23 +334,8 @@ 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()
"""デバッグ時もライブと同じStructured Outputsルールを使う"""
return build_common_rules_block(selected_strategies, max_entry_distance)
def build_caution_block() -> str:
@@ -321,3 +344,50 @@ def build_caution_block() -> str:
※ market_state とチャート/数値要約が矛盾すると判断した場合は、安全側に倒してください。
具体的には対象戦略を 0.00,0.00,0.00 で見送ってください。
""".strip()
def build_entry_response_text_format(selected_strategies: Sequence[int]) -> dict[str, Any]:
"""Responses API Structured Outputs用のJSON Schemaを返す。"""
strategy_enum = [int(strategy) for strategy in selected_strategies if strategy in (1, 2, 3, 4)]
if not strategy_enum:
strategy_enum = [1, 2, 3, 4]
strategy_item_schema: dict[str, Any] = {
"type": "object",
"additionalProperties": False,
"properties": {
"strategy": {"type": "integer", "enum": strategy_enum},
"decision": {"type": "string", "enum": ["use", "skip"]},
"entry": {"type": "number"},
"tp": {"type": "number"},
"sl": {"type": "number"},
"zone_low": {"type": "number"},
"zone_high": {"type": "number"},
"reason_code": {"type": "string", "enum": list(ENTRY_REASON_CODES)},
},
"required": [
"strategy",
"decision",
"entry",
"tp",
"sl",
"zone_low",
"zone_high",
"reason_code",
],
}
return {
"type": "json_schema",
"name": "hit_ea_entry_candidates",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"schema_version": {"type": "integer", "enum": [ENTRY_RESPONSE_SCHEMA_VERSION]},
"strategies": {"type": "array", "items": strategy_item_schema},
},
"required": ["schema_version", "strategies"],
},
}
@@ -70,6 +70,36 @@ def test_call_responses_api_sends_reasoning_and_omits_temperature() -> None:
assert "temperature" not in fake_client.responses.create_params
def test_call_responses_api_sends_structured_text_format() -> None:
"""Structured Outputs用のtext.formatをResponses APIへ渡す。"""
fake_client = FakeClient(FakeResponse(output_text='{"schema_version":1,"strategies":[]}'))
response_text_format = {
"type": "json_schema",
"name": "entry_candidates",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {"schema_version": {"type": "integer"}},
"required": ["schema_version"],
},
}
actual = call_responses_api(
client=fake_client, # type: ignore[arg-type]
model="gpt-5.5-2026-04-23",
reasoning_effort="low",
system_content="Return JSON.",
user_text="Classify.",
image_data_urls=[],
max_output_tokens=128,
response_text_format=response_text_format,
)
assert actual.text.startswith("{")
assert fake_client.responses.create_params["text"] == {"format": response_text_format}
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(
@@ -91,3 +121,4 @@ def test_call_responses_api_returns_incomplete_diagnostics_for_empty_text() -> N
assert actual.text == ""
assert "max_output_tokens" in actual.diagnostics.incomplete_details
assert not actual.diagnostics.is_completed()
@@ -6,6 +6,7 @@ from ea_py.market.target_prices import parse_lines_to_13_allow_subset, sanitize_
from ea_py.market.target_zones import (
build_candidate_id,
format_target_zones,
parse_json_to_entry_zones,
parse_lines_to_entry_zones_allow_subset,
sanitize_entry_zones,
zones_to_numeric_list,
@@ -74,6 +75,51 @@ def test_parse_lines_to_entry_zones_allow_subset_valid_zone_returns_zone_values(
assert actual[2].zone_high == 1898.0
def test_parse_json_to_entry_zones_valid_structured_output_returns_zone_values() -> None:
"""Structured OutputsのJSON候補を戦略別ゾーンへ展開する。"""
actual = parse_json_to_entry_zones(
"""
{
"schema_version": 1,
"strategies": [
{
"strategy": 2,
"decision": "use",
"entry": 1895.0,
"tp": 1910.0,
"sl": 1885.0,
"zone_low": 1892.0,
"zone_high": 1898.0,
"reason_code": "range_edge"
},
{
"strategy": 4,
"decision": "skip",
"entry": 0.0,
"tp": 0.0,
"sl": 0.0,
"zone_low": 0.0,
"zone_high": 0.0,
"reason_code": "skip_mid_range"
}
]
}
"""
)
assert actual[2].entry == 1895.0
assert actual[2].zone_low == 1892.0
assert actual[2].zone_high == 1898.0
assert actual[4].entry == 0.0
def test_parse_json_to_entry_zones_invalid_json_returns_stop_values() -> None:
"""不正JSONは全停止ゾーンへ倒す。"""
actual = parse_json_to_entry_zones("2,1895.00,1910.00,1885.00,1892.00,1898.00")
assert all(zone.entry == 0.0 for zone in actual.values())
def test_sanitize_entry_zones_invalid_unselected_strategy_zeroes_it() -> None:
"""未選択戦略のゾーンは停止値へ補正する。"""
parsed = parse_lines_to_entry_zones_allow_subset(
@@ -96,6 +142,20 @@ def test_sanitize_entry_zones_allows_near_zone_edge_when_entry_is_farther() -> N
assert actual[2].entry == 1890.0
def test_sanitize_entry_zones_rejects_low_reward_risk_ratio() -> None:
"""reward/riskが閾値未満の候補は停止値へ補正する。"""
parsed = parse_lines_to_entry_zones_allow_subset("2,1895.00,1900.00,1885.00,1892.00,1898.00")
actual = sanitize_entry_zones(
parsed,
selected_strategies=[2],
current_price=1900.0,
min_reward_risk_ratio=1.2,
)
assert actual[2].entry == 0.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")