Compare commits

..

12 Commits

Author SHA1 Message Date
ffdb99c6c7 Merge pull request 'feat: 시스템 외 매입 종목 stop-loss/take-profit 활성화 (pchs_avg_pric 반영) (#249)' (#250) from feature/issue-249-avg-price-sync into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #250
2026-02-25 02:20:03 +09:00
agentson
ce5ea5abde feat: 시스템 외 매입 종목에 pchs_avg_pric 반영 (#249)
Some checks failed
CI / test (pull_request) Has been cancelled
sync_positions_from_broker()에서 price=0.0 하드코딩으로 인해
stop-loss/take-profit이 외부 매수 종목에 작동하지 않던 문제를 수정한다.

- _extract_avg_price_from_balance() 헬퍼 추가 (pchs_avg_pric 추출)
- sync_positions_from_broker()에서 avg_price를 price 필드에 저장
- TestExtractAvgPriceFromBalance 단위 테스트 11개 추가
- TestSyncPositionsFromBroker 통합 테스트 3개 추가 (price 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 02:18:11 +09:00
5ae302b083 Merge pull request 'fix: prompt_override 시 parse_response 건너뛰어 Missing fields 경고 제거 (#247)' (#248) from feature/issue-247-skip-parse-response-on-prompt-override into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #248
2026-02-25 01:59:15 +09:00
agentson
d31a61cd0b fix: prompt_override 경로 _total_decisions 미카운트, 완료 로그 추가, 테스트 보완
Some checks failed
CI / test (pull_request) Has been cancelled
리뷰 지적 사항 반영:
- _total_decisions 카운트 제거 (플레이북 생성은 거래 결정이 아님 → 메트릭 왜곡 방지)
- "Gemini raw response received" INFO 로그 추가 (완료 추적 가능)
- test_prompt_override_takes_priority_over_optimization 신규 추가
  (enable_optimization=True 상태에서도 prompt_override 우선됨을 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:54:55 +09:00
agentson
1c7a17320c fix: prompt_override 시 parse_response 건너뛰어 Missing fields 경고 제거 (#247)
Some checks failed
CI / test (pull_request) Has been cancelled
pre_market_planner처럼 prompt_override를 사용하는 호출자는 플레이북 JSON 등
TradeDecision이 아닌 raw 텍스트를 기대한다. 기존에는 parse_response를 통과시켜
항상 "Missing fields" 경고가 발생했다.

decide()에서 prompt_override 감지 시 parse_response를 건너뛰고 raw 응답을
rationale에 담아 직접 반환하도록 수정한다.
정상 응답인데 경고가 뜨는 문제가 해결된다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:46:21 +09:00
f58d42fdb0 Merge pull request 'fix: parse_response missing fields 시 raw 보존으로 플레이북 생성 복구 (#245)' (#246) from feature/issue-245-parse-response-preserve-raw into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #246
2026-02-25 01:33:34 +09:00
agentson
0b20251de0 fix: parse_response에서 missing fields 시 raw 텍스트 보존 (#245)
Some checks failed
CI / test (pull_request) Has been cancelled
pre_market_planner는 prompt_override로 Gemini에 플레이북 JSON을 요청한다.
Gemini가 플레이북 JSON을 반환해도 parse_response가 action/confidence/rationale 키가
없다는 이유로 rationale="Missing required fields"를 반환해 실제 응답이 버려졌다.

이로 인해 플레이북 생성이 항상 실패하고 RSI 기반 기본 폴백이 사용됐으며,
RSI가 없는 해외 시장 데이터와 매칭되지 않아 모든 결정이 HOLD(confidence=0)였다.

수정: missing fields 시 rationale=raw로 설정해 실제 Gemini 응답을 보존한다.
pre_market_planner가 decision.rationale에서 플레이북 JSON을 추출하여 정상 파싱 가능.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:31:54 +09:00
bffe6e9288 Merge pull request 'fix: Gemini compressed prompt 키 불일치 및 해외 스캐너 GUBN=0 수정 (#242, #243)' (#244) from feature/issue-242-243-gemini-key-fix-overseas-scanner into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #244
2026-02-25 01:18:41 +09:00
agentson
0146d1bf8a fix: Gemini compressed prompt 키 불일치 및 해외 스캐너 GUBN=0 수정 (#242, #243)
Some checks failed
CI / test (pull_request) Has been cancelled
- prompt_optimizer: build_compressed_prompt의 JSON 키를 act/conf/reason에서
  action/confidence/rationale로 수정 (parse_response와 일치시킴)
  → Gemini 응답 100% HOLD로 처리되던 버그 수정
- overseas: fetch_overseas_rankings의 GUBN 파라미터를 1(상승)에서 0(전체)으로 변경
  → 변동성 스캐너가 상승/하락 모두 대상으로 NASDAQ 후보 발견 가능
- test: GUBN==0 검증, build_compressed_prompt 키 이름 검증 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:16:51 +09:00
497564e75c Merge pull request 'fix: KR 등락률순위 API 파라미터 오류 수정 — 스캐너 미동작 해결 (#240)' (#241) from feature/issue-240-kr-scanner-rank-param-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #241
2026-02-24 09:18:11 +09:00
agentson
988a56c07c fix: KR 등락률순위 API 파라미터 오류 수정 — 스캐너 미동작 해결 (#240)
Some checks failed
CI / test (pull_request) Has been cancelled
실전 API가 fid_rank_sort_cls_code='0000'(4자리)를 거부함.
'0'(1자리)으로 수정하고, 실전 응답의 종목코드 키가
mksc_shrn_iscd 대신 stck_shrn_iscd임을 반영하여 파싱 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:15:40 +09:00
c9f1345e3c Merge pull request 'fix: 대시보드 mode 배지 os.getenv 대신 settings.MODE 사용 (#237)' (#239) from feature/issue-237-dashboard-mode-badge-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #239
2026-02-24 06:52:29 +09:00
10 changed files with 276 additions and 14 deletions

View File

@@ -346,8 +346,10 @@ class GeminiClient:
# Validate required fields
if not all(k in data for k in ("action", "confidence", "rationale")):
logger.warning("Missing fields in Gemini response — defaulting to HOLD")
# Preserve raw text in rationale so prompt_override callers (e.g. pre_market_planner)
# can extract their own JSON format from decision.rationale (#245)
return TradeDecision(
action="HOLD", confidence=0, rationale="Missing required fields"
action="HOLD", confidence=0, rationale=raw
)
action = str(data["action"]).upper()
@@ -439,6 +441,18 @@ class GeminiClient:
action="HOLD", confidence=0, rationale=f"API error: {exc}", token_count=token_count
)
# prompt_override callers (e.g. pre_market_planner) expect raw text back,
# not a parsed TradeDecision. Skip parse_response to avoid spurious
# "Missing fields" warnings and return the raw response directly. (#247)
if "prompt_override" in market_data:
logger.info(
"Gemini raw response received (prompt_override, tokens=%d)", token_count
)
# Not a trade decision — don't inflate _total_decisions metrics
return TradeDecision(
action="HOLD", confidence=0, rationale=raw, token_count=token_count
)
decision = self.parse_response(raw)
self._total_decisions += 1

View File

@@ -179,8 +179,8 @@ class PromptOptimizer:
# Minimal instructions
prompt = (
f"{market_name} trader. Analyze:\n{data_str}\n\n"
'Return JSON: {"act":"BUY"|"SELL"|"HOLD","conf":<0-100>,"reason":"<text>"}\n'
"Rules: act=BUY/SELL/HOLD, conf=0-100, reason=concise. No markdown."
'Return JSON: {"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n'
"Rules: action=BUY/SELL/HOLD, confidence=0-100, rationale=concise. No markdown."
)
else:
# Data only (for cached contexts where instructions are known)

View File

@@ -430,7 +430,7 @@ class KISBroker:
"fid_cond_mrkt_div_code": "J",
"fid_cond_scr_div_code": "20170",
"fid_input_iscd": "0000",
"fid_rank_sort_cls_code": "0000",
"fid_rank_sort_cls_code": "0",
"fid_input_cnt_1": str(limit),
"fid_prc_cls_code": "0",
"fid_input_price_1": "0",
@@ -466,7 +466,7 @@ class KISBroker:
rankings = []
for item in data.get("output", [])[:limit]:
rankings.append({
"stock_code": item.get("mksc_shrn_iscd", ""),
"stock_code": item.get("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""),
"name": item.get("hts_kor_isnm", ""),
"price": _safe_float(item.get("stck_prpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),

View File

@@ -133,7 +133,7 @@ class OverseasBroker:
"AUTH": "",
"EXCD": ranking_excd,
"NDAY": "0",
"GUBN": "1",
"GUBN": "0", # 0=전체(상승+하락), 1=상승만 — 변동성 스캐너는 전체 필요
"VOL_RANG": "0",
}

View File

@@ -182,6 +182,9 @@ async def sync_positions_from_broker(
qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
avg_price = _extract_avg_price_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
log_trade(
conn=db_conn,
stock_code=stock_code,
@@ -189,7 +192,7 @@ async def sync_positions_from_broker(
confidence=0,
rationale="[startup-sync] Position detected from broker at startup",
quantity=qty,
price=0.0,
price=avg_price,
market=log_market,
exchange_code=market.exchange_code,
mode=settings.MODE,
@@ -321,6 +324,37 @@ def _extract_held_qty_from_balance(
return 0
def _extract_avg_price_from_balance(
balance_data: dict[str, Any],
stock_code: str,
*,
is_domestic: bool,
) -> float:
"""Extract the broker-reported average purchase price for a stock.
Uses ``pchs_avg_pric`` (매입평균가격) from the balance response (output1).
Returns 0.0 when absent so callers can use ``if price > 0`` as sentinel.
Domestic fields (VTTC8434R output1): pdno, pchs_avg_pric
Overseas fields (VTTS3012R output1): ovrs_pdno, pchs_avg_pric
"""
output1 = balance_data.get("output1", [])
if isinstance(output1, dict):
output1 = [output1]
if not isinstance(output1, list):
return 0.0
for holding in output1:
if not isinstance(holding, dict):
continue
code_key = "pdno" if is_domestic else "ovrs_pdno"
held_code = str(holding.get(code_key, "")).strip().upper()
if held_code != stock_code.strip().upper():
continue
return safe_float(holding.get("pchs_avg_pric"), 0.0)
return 0.0
def _determine_order_quantity(
*,
action: str,

View File

@@ -93,9 +93,21 @@ class TestMalformedJsonHandling:
def test_json_with_missing_fields_returns_hold(self, settings):
client = GeminiClient(settings)
decision = client.parse_response('{"action": "BUY"}')
raw = '{"action": "BUY"}'
decision = client.parse_response(raw)
assert decision.action == "HOLD"
assert decision.confidence == 0
# rationale preserves raw so prompt_override callers (e.g. pre_market_planner)
# can extract non-TradeDecision JSON from decision.rationale (#245)
assert decision.rationale == raw
def test_non_trade_decision_json_preserves_raw_in_rationale(self, settings):
"""Playbook JSON (no action/confidence/rationale) must be preserved for planner."""
client = GeminiClient(settings)
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
decision = client.parse_response(playbook_json)
assert decision.action == "HOLD"
assert decision.rationale == playbook_json
def test_json_with_invalid_action_returns_hold(self, settings):
client = GeminiClient(settings)
@@ -290,9 +302,10 @@ class TestPromptOverride:
client = GeminiClient(settings)
custom_prompt = "You are a playbook generator. Return JSON with scenarios."
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
mock_response = MagicMock()
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "test"}'
mock_response.text = playbook_json
with patch.object(
client._client.aio.models,
@@ -305,7 +318,7 @@ class TestPromptOverride:
"current_price": 0,
"prompt_override": custom_prompt,
}
await client.decide(market_data)
decision = await client.decide(market_data)
# Verify the custom prompt was sent, not a built prompt
mock_generate.assert_called_once()
@@ -313,17 +326,50 @@ class TestPromptOverride:
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
)
assert actual_prompt == custom_prompt
# Raw response preserved in rationale without parse_response (#247)
assert decision.rationale == playbook_json
@pytest.mark.asyncio
async def test_prompt_override_skips_optimization(self, settings):
"""prompt_override should bypass prompt optimization."""
async def test_prompt_override_skips_parse_response(self, settings):
"""prompt_override bypasses parse_response — no Missing fields warning, raw preserved."""
client = GeminiClient(settings)
client._enable_optimization = True
custom_prompt = "Custom playbook prompt"
playbook_json = '{"market_outlook": "bullish", "stocks": [{"stock_code": "AAPL"}]}'
mock_response = MagicMock()
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "ok"}'
mock_response.text = playbook_json
with patch.object(
client._client.aio.models,
"generate_content",
new_callable=AsyncMock,
return_value=mock_response,
):
with patch.object(client, "parse_response") as mock_parse:
market_data = {
"stock_code": "PLANNER",
"current_price": 0,
"prompt_override": custom_prompt,
}
decision = await client.decide(market_data)
# parse_response must NOT be called for prompt_override
mock_parse.assert_not_called()
# Raw playbook JSON preserved in rationale
assert decision.rationale == playbook_json
@pytest.mark.asyncio
async def test_prompt_override_takes_priority_over_optimization(self, settings):
"""prompt_override must win over enable_optimization=True."""
client = GeminiClient(settings)
client._enable_optimization = True
custom_prompt = "Explicit playbook prompt"
mock_response = MagicMock()
mock_response.text = '{"market_outlook": "neutral", "stocks": []}'
with patch.object(
client._client.aio.models,
@@ -341,6 +387,7 @@ class TestPromptOverride:
actual_prompt = mock_generate.call_args[1].get(
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
)
# The custom prompt must be used, not the compressed prompt
assert actual_prompt == custom_prompt
@pytest.mark.asyncio

View File

@@ -354,6 +354,8 @@ class TestFetchMarketRankings:
assert "ranking/fluctuation" in url
assert headers.get("tr_id") == "FHPST01700000"
assert params.get("fid_cond_scr_div_code") == "20170"
# 실전 API는 4자리("0000") 거부 — 1자리("0")여야 한다 (#240)
assert params.get("fid_rank_sort_cls_code") == "0"
@pytest.mark.asyncio
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
@@ -376,6 +378,27 @@ class TestFetchMarketRankings:
assert result[0]["price"] == 75000.0
assert result[0]["change_rate"] == 2.5
@pytest.mark.asyncio
async def test_fluctuation_parses_stck_shrn_iscd(self, broker: KISBroker) -> None:
"""실전 API는 mksc_shrn_iscd 대신 stck_shrn_iscd를 반환한다 (#240)."""
items = [
{
"stck_shrn_iscd": "015260",
"hts_kor_isnm": "에이엔피",
"stck_prpr": "794",
"acml_vol": "4896196",
"prdy_ctrt": "29.74",
"vol_inrt": "0",
}
]
mock_resp = _make_ranking_mock(items)
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
result = await broker.fetch_market_rankings(ranking_type="fluctuation")
assert len(result) == 1
assert result[0]["stock_code"] == "015260"
assert result[0]["change_rate"] == 29.74
# ---------------------------------------------------------------------------
# KRX tick unit / round-down helpers (issue #157)

View File

@@ -15,6 +15,7 @@ from src.logging.decision_logger import DecisionLogger
from src.main import (
_apply_dashboard_flag,
_determine_order_quantity,
_extract_avg_price_from_balance,
_extract_held_codes_from_balance,
_extract_held_qty_from_balance,
_handle_market_close,
@@ -76,6 +77,81 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
)
class TestExtractAvgPriceFromBalance:
"""Tests for _extract_avg_price_from_balance() (issue #249)."""
def test_domestic_returns_pchs_avg_pric(self) -> None:
"""Domestic balance with pchs_avg_pric returns the correct float."""
balance = {"output1": [{"pdno": "005930", "pchs_avg_pric": "68000.00"}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 68000.0
def test_overseas_returns_pchs_avg_pric(self) -> None:
"""Overseas balance with pchs_avg_pric returns the correct float."""
balance = {"output1": [{"ovrs_pdno": "AAPL", "pchs_avg_pric": "170.50"}]}
result = _extract_avg_price_from_balance(balance, "AAPL", is_domestic=False)
assert result == 170.5
def test_returns_zero_when_field_absent(self) -> None:
"""Returns 0.0 when pchs_avg_pric key is missing entirely."""
balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_zero_when_field_empty_string(self) -> None:
"""Returns 0.0 when pchs_avg_pric is an empty string."""
balance = {"output1": [{"pdno": "005930", "pchs_avg_pric": ""}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_zero_when_stock_not_found(self) -> None:
"""Returns 0.0 when the requested stock_code is not in output1."""
balance = {"output1": [{"pdno": "000660", "pchs_avg_pric": "100000.0"}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_zero_when_output1_empty(self) -> None:
"""Returns 0.0 when output1 is an empty list."""
balance = {"output1": []}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_zero_when_output1_key_absent(self) -> None:
"""Returns 0.0 when output1 key is missing from balance_data."""
balance: dict = {}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_handles_output1_as_dict(self) -> None:
"""Handles the edge case where output1 is a dict instead of a list."""
balance = {"output1": {"pdno": "005930", "pchs_avg_pric": "55000.0"}}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 55000.0
def test_case_insensitive_code_matching(self) -> None:
"""Stock code comparison is case-insensitive."""
balance = {"output1": [{"ovrs_pdno": "aapl", "pchs_avg_pric": "170.0"}]}
result = _extract_avg_price_from_balance(balance, "AAPL", is_domestic=False)
assert result == 170.0
def test_returns_zero_for_non_numeric_string(self) -> None:
"""Returns 0.0 when pchs_avg_pric contains a non-numeric value."""
balance = {"output1": [{"pdno": "005930", "pchs_avg_pric": "N/A"}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_correct_stock_among_multiple(self) -> None:
"""Returns only the avg price of the requested stock when output1 has multiple holdings."""
balance = {
"output1": [
{"pdno": "000660", "pchs_avg_pric": "150000.0"},
{"pdno": "005930", "pchs_avg_pric": "68000.0"},
]
}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 68000.0
class TestExtractHeldQtyFromBalance:
"""Tests for _extract_held_qty_from_balance()."""
@@ -3818,6 +3894,70 @@ class TestSyncPositionsFromBroker:
# Two distinct exchange codes (NASD, NYSE) → 2 calls
assert overseas_broker.get_overseas_balance.call_count == 2
@pytest.mark.asyncio
async def test_syncs_domestic_position_with_correct_avg_price(self) -> None:
"""Domestic position is stored with pchs_avg_pric as price (issue #249)."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
balance = {
"output1": [{"pdno": "005930", "ord_psbl_qty": "5", "pchs_avg_pric": "68000.0"}],
"output2": [{"tot_evlu_amt": "1000000", "dnca_tot_amt": "500000", "pchs_amt_smtl_amt": "500000"}],
}
broker = MagicMock()
broker.get_balance = AsyncMock(return_value=balance)
overseas_broker = MagicMock()
await sync_positions_from_broker(broker, overseas_broker, db_conn, settings)
from src.db import get_open_position
pos = get_open_position(db_conn, "005930", "KR")
assert pos is not None
assert pos["price"] == 68000.0
@pytest.mark.asyncio
async def test_syncs_overseas_position_with_correct_avg_price(self) -> None:
"""Overseas position is stored with pchs_avg_pric as price (issue #249)."""
settings = self._make_settings("US_NASDAQ")
db_conn = init_db(":memory:")
balance = {
"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10", "pchs_avg_pric": "170.0"}],
"output2": [{"frcr_evlu_tota": "50000", "frcr_dncl_amt_2": "10000", "frcr_buy_amt_smtl": "40000"}],
}
broker = MagicMock()
overseas_broker = MagicMock()
overseas_broker.get_overseas_balance = AsyncMock(return_value=balance)
await sync_positions_from_broker(broker, overseas_broker, db_conn, settings)
from src.db import get_open_position
pos = get_open_position(db_conn, "AAPL", "US_NASDAQ")
assert pos is not None
assert pos["price"] == 170.0
@pytest.mark.asyncio
async def test_syncs_position_with_zero_price_when_pchs_avg_pric_absent(self) -> None:
"""Fallback to price=0.0 when pchs_avg_pric is absent (issue #249)."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
# No pchs_avg_pric in output1
balance = {
"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}],
"output2": [{"tot_evlu_amt": "1000000", "dnca_tot_amt": "500000", "pchs_amt_smtl_amt": "500000"}],
}
broker = MagicMock()
broker.get_balance = AsyncMock(return_value=balance)
overseas_broker = MagicMock()
await sync_positions_from_broker(broker, overseas_broker, db_conn, settings)
from src.db import get_open_position
pos = get_open_position(db_conn, "005930", "KR")
assert pos is not None
assert pos["price"] == 0.0
# ---------------------------------------------------------------------------
# Domestic BUY double-prevention (issue #206) — trading_cycle integration

View File

@@ -124,7 +124,7 @@ class TestFetchOverseasRankings:
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
assert params["EXCD"] == "NAS"
assert params["NDAY"] == "0"
assert params["GUBN"] == "1"
assert params["GUBN"] == "0" # 0=전체(상승+하락), 변동성 스캐너에 필요
assert params["VOL_RANG"] == "0"
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")

View File

@@ -124,6 +124,10 @@ class TestPromptOptimizer:
assert len(prompt) < 300
assert "005930" in prompt
assert "75000" in prompt
# Keys must match parse_response expectations (#242)
assert '"action"' in prompt
assert '"confidence"' in prompt
assert '"rationale"' in prompt
def test_build_compressed_prompt_no_instructions(self):
"""Test compressed prompt without instructions."""