Compare commits

..

10 Commits

Author SHA1 Message Date
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
8 changed files with 101 additions and 13 deletions

View File

@@ -346,8 +346,10 @@ class GeminiClient:
# Validate required fields # Validate required fields
if not all(k in data for k in ("action", "confidence", "rationale")): if not all(k in data for k in ("action", "confidence", "rationale")):
logger.warning("Missing fields in Gemini response — defaulting to HOLD") 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( return TradeDecision(
action="HOLD", confidence=0, rationale="Missing required fields" action="HOLD", confidence=0, rationale=raw
) )
action = str(data["action"]).upper() action = str(data["action"]).upper()
@@ -439,6 +441,18 @@ class GeminiClient:
action="HOLD", confidence=0, rationale=f"API error: {exc}", token_count=token_count 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) decision = self.parse_response(raw)
self._total_decisions += 1 self._total_decisions += 1

View File

@@ -179,8 +179,8 @@ class PromptOptimizer:
# Minimal instructions # Minimal instructions
prompt = ( prompt = (
f"{market_name} trader. Analyze:\n{data_str}\n\n" f"{market_name} trader. Analyze:\n{data_str}\n\n"
'Return JSON: {"act":"BUY"|"SELL"|"HOLD","conf":<0-100>,"reason":"<text>"}\n' 'Return JSON: {"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n'
"Rules: act=BUY/SELL/HOLD, conf=0-100, reason=concise. No markdown." "Rules: action=BUY/SELL/HOLD, confidence=0-100, rationale=concise. No markdown."
) )
else: else:
# Data only (for cached contexts where instructions are known) # 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_mrkt_div_code": "J",
"fid_cond_scr_div_code": "20170", "fid_cond_scr_div_code": "20170",
"fid_input_iscd": "0000", "fid_input_iscd": "0000",
"fid_rank_sort_cls_code": "0000", "fid_rank_sort_cls_code": "0",
"fid_input_cnt_1": str(limit), "fid_input_cnt_1": str(limit),
"fid_prc_cls_code": "0", "fid_prc_cls_code": "0",
"fid_input_price_1": "0", "fid_input_price_1": "0",
@@ -466,7 +466,7 @@ class KISBroker:
rankings = [] rankings = []
for item in data.get("output", [])[:limit]: for item in data.get("output", [])[:limit]:
rankings.append({ 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", ""), "name": item.get("hts_kor_isnm", ""),
"price": _safe_float(item.get("stck_prpr", "0")), "price": _safe_float(item.get("stck_prpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")), "volume": _safe_float(item.get("acml_vol", "0")),

View File

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

View File

@@ -93,9 +93,21 @@ class TestMalformedJsonHandling:
def test_json_with_missing_fields_returns_hold(self, settings): def test_json_with_missing_fields_returns_hold(self, settings):
client = GeminiClient(settings) client = GeminiClient(settings)
decision = client.parse_response('{"action": "BUY"}') raw = '{"action": "BUY"}'
decision = client.parse_response(raw)
assert decision.action == "HOLD" assert decision.action == "HOLD"
assert decision.confidence == 0 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): def test_json_with_invalid_action_returns_hold(self, settings):
client = GeminiClient(settings) client = GeminiClient(settings)
@@ -290,9 +302,10 @@ class TestPromptOverride:
client = GeminiClient(settings) client = GeminiClient(settings)
custom_prompt = "You are a playbook generator. Return JSON with scenarios." custom_prompt = "You are a playbook generator. Return JSON with scenarios."
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
mock_response = MagicMock() mock_response = MagicMock()
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "test"}' mock_response.text = playbook_json
with patch.object( with patch.object(
client._client.aio.models, client._client.aio.models,
@@ -305,7 +318,7 @@ class TestPromptOverride:
"current_price": 0, "current_price": 0,
"prompt_override": custom_prompt, "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 # Verify the custom prompt was sent, not a built prompt
mock_generate.assert_called_once() 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 "contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
) )
assert actual_prompt == custom_prompt assert actual_prompt == custom_prompt
# Raw response preserved in rationale without parse_response (#247)
assert decision.rationale == playbook_json
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prompt_override_skips_optimization(self, settings): async def test_prompt_override_skips_parse_response(self, settings):
"""prompt_override should bypass prompt optimization.""" """prompt_override bypasses parse_response — no Missing fields warning, raw preserved."""
client = GeminiClient(settings) client = GeminiClient(settings)
client._enable_optimization = True client._enable_optimization = True
custom_prompt = "Custom playbook prompt" custom_prompt = "Custom playbook prompt"
playbook_json = '{"market_outlook": "bullish", "stocks": [{"stock_code": "AAPL"}]}'
mock_response = MagicMock() 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( with patch.object(
client._client.aio.models, client._client.aio.models,
@@ -341,6 +387,7 @@ class TestPromptOverride:
actual_prompt = mock_generate.call_args[1].get( 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 "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 assert actual_prompt == custom_prompt
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -354,6 +354,8 @@ class TestFetchMarketRankings:
assert "ranking/fluctuation" in url assert "ranking/fluctuation" in url
assert headers.get("tr_id") == "FHPST01700000" assert headers.get("tr_id") == "FHPST01700000"
assert params.get("fid_cond_scr_div_code") == "20170" 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 @pytest.mark.asyncio
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None: 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]["price"] == 75000.0
assert result[0]["change_rate"] == 2.5 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) # KRX tick unit / round-down helpers (issue #157)

View File

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

View File

@@ -124,6 +124,10 @@ class TestPromptOptimizer:
assert len(prompt) < 300 assert len(prompt) < 300
assert "005930" in prompt assert "005930" in prompt
assert "75000" 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): def test_build_compressed_prompt_no_instructions(self):
"""Test compressed prompt without instructions.""" """Test compressed prompt without instructions."""