Compare commits
12 Commits
feature/is
...
adc5211fd2
| Author | SHA1 | Date | |
|---|---|---|---|
| adc5211fd2 | |||
|
|
67e0e8df41 | ||
| ffdb99c6c7 | |||
|
|
ce5ea5abde | ||
| 5ae302b083 | |||
|
|
d31a61cd0b | ||
|
|
1c7a17320c | ||
| f58d42fdb0 | |||
|
|
0b20251de0 | ||
| bffe6e9288 | |||
|
|
0146d1bf8a | ||
| 497564e75c |
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -133,7 +133,7 @@ class OverseasBroker:
|
||||
"AUTH": "",
|
||||
"EXCD": ranking_excd,
|
||||
"NDAY": "0",
|
||||
"GUBN": "1",
|
||||
"GUBN": "0", # 0=전체(상승+하락), 1=상승만 — 변동성 스캐너는 전체 필요
|
||||
"VOL_RANG": "0",
|
||||
}
|
||||
|
||||
|
||||
45
src/main.py
45
src/main.py
@@ -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,
|
||||
@@ -696,7 +730,7 @@ async def trading_cycle(
|
||||
open_position = get_open_position(db_conn, stock_code, market.code)
|
||||
if open_position:
|
||||
entry_price = safe_float(open_position.get("price"), 0.0)
|
||||
if entry_price > 0:
|
||||
if entry_price > 0 and current_price > 0:
|
||||
loss_pct = (current_price - entry_price) / entry_price * 100
|
||||
stop_loss_threshold = -2.0
|
||||
take_profit_threshold = 3.0
|
||||
@@ -891,10 +925,13 @@ async def trading_cycle(
|
||||
# - SELL: -0.2% below last price — ensures fill even when price dips slightly
|
||||
# (placing at exact last price risks no-fill if the bid is just below).
|
||||
overseas_price: float
|
||||
# KIS requires at most 2 decimal places for prices >= $1 (≥1달러 소수점 2자리 제한).
|
||||
# Penny stocks (< $1) keep 4 decimal places to preserve price precision.
|
||||
_price_decimals = 2 if current_price >= 1.0 else 4
|
||||
if decision.action == "BUY":
|
||||
overseas_price = round(current_price * 1.002, 4)
|
||||
overseas_price = round(current_price * 1.002, _price_decimals)
|
||||
else:
|
||||
overseas_price = round(current_price * 0.998, 4)
|
||||
overseas_price = round(current_price * 0.998, _price_decimals)
|
||||
result = await overseas_broker.send_overseas_order(
|
||||
exchange_code=market.exchange_code,
|
||||
stock_code=stock_code,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()."""
|
||||
|
||||
@@ -1170,7 +1246,8 @@ class TestOverseasBalanceParsing:
|
||||
mock_overseas_broker_with_buy_scenario.send_overseas_order.assert_called_once()
|
||||
call_kwargs = mock_overseas_broker_with_buy_scenario.send_overseas_order.call_args
|
||||
sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
|
||||
expected_price = round(182.5 * 1.002, 4) # 0.2% premium for BUY limit orders
|
||||
# KIS requires max 2 decimal places for prices >= $1 (#252)
|
||||
expected_price = round(182.5 * 1.002, 2) # 0.2% premium for BUY limit orders
|
||||
assert sent_price == expected_price, (
|
||||
f"Expected limit price {expected_price} (182.5 * 1.002) but got {sent_price}. "
|
||||
"BUY uses +0.2% to improve fill rate while minimising overpayment (#211)."
|
||||
@@ -1249,12 +1326,133 @@ class TestOverseasBalanceParsing:
|
||||
overseas_broker.send_overseas_order.assert_called_once()
|
||||
call_kwargs = overseas_broker.send_overseas_order.call_args
|
||||
sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
|
||||
expected_price = round(sell_price * 0.998, 4) # -0.2% for SELL limit orders
|
||||
# KIS requires max 2 decimal places for prices >= $1 (#252)
|
||||
expected_price = round(sell_price * 0.998, 2) # -0.2% for SELL limit orders
|
||||
assert sent_price == expected_price, (
|
||||
f"Expected SELL limit price {expected_price} (182.5 * 0.998) but got {sent_price}. "
|
||||
"SELL uses -0.2% to ensure fill even when price dips slightly (#211)."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overseas_buy_price_rounded_to_2_decimals_for_dollar_plus_stock(
|
||||
self,
|
||||
mock_domestic_broker: MagicMock,
|
||||
mock_playbook: DayPlaybook,
|
||||
mock_risk: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_decision_logger: MagicMock,
|
||||
mock_context_store: MagicMock,
|
||||
mock_criticality_assessor: MagicMock,
|
||||
mock_telegram: MagicMock,
|
||||
mock_overseas_market: MagicMock,
|
||||
) -> None:
|
||||
"""BUY price for $1+ stocks is rounded to 2 decimal places (issue #252).
|
||||
|
||||
KIS rejects prices with more than 2 decimal places for stocks priced >= $1.
|
||||
current_price=50.1234 * 1.002 = 50.22... should be sent as 50.22, not 50.2236.
|
||||
"""
|
||||
overseas_broker = MagicMock()
|
||||
overseas_broker.get_overseas_balance = AsyncMock(
|
||||
return_value={
|
||||
"output1": [],
|
||||
"output2": [{"frcr_evlu_tota": "0", "frcr_dncl_amt_2": "10000", "frcr_buy_amt_smtl": "0"}],
|
||||
}
|
||||
)
|
||||
overseas_broker.get_overseas_price = AsyncMock(
|
||||
return_value={"output": {"last": "50.1234", "rate": "0"}}
|
||||
)
|
||||
overseas_broker.send_overseas_order = AsyncMock(
|
||||
return_value={"rt_cd": None, "msg1": "주문접수"}
|
||||
)
|
||||
|
||||
db_conn = init_db(":memory:")
|
||||
decision_logger = DecisionLogger(db_conn)
|
||||
|
||||
engine = MagicMock(spec=ScenarioEngine)
|
||||
engine.evaluate = MagicMock(return_value=_make_buy_match())
|
||||
|
||||
await trading_cycle(
|
||||
broker=mock_domestic_broker,
|
||||
overseas_broker=overseas_broker,
|
||||
scenario_engine=engine,
|
||||
playbook=mock_playbook,
|
||||
risk=mock_risk,
|
||||
db_conn=db_conn,
|
||||
decision_logger=decision_logger,
|
||||
context_store=mock_context_store,
|
||||
criticality_assessor=mock_criticality_assessor,
|
||||
telegram=mock_telegram,
|
||||
market=mock_overseas_market,
|
||||
stock_code="TQQQ",
|
||||
scan_candidates={},
|
||||
)
|
||||
|
||||
overseas_broker.send_overseas_order.assert_called_once()
|
||||
sent_price = overseas_broker.send_overseas_order.call_args[1].get("price") or \
|
||||
overseas_broker.send_overseas_order.call_args[0][4]
|
||||
# 50.1234 * 1.002 = 50.2235... rounded to 2 decimals = 50.22
|
||||
assert sent_price == round(50.1234 * 1.002, 2), (
|
||||
f"Expected 2-decimal price {round(50.1234 * 1.002, 2)} but got {sent_price} (#252)"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overseas_penny_stock_price_keeps_4_decimals(
|
||||
self,
|
||||
mock_domestic_broker: MagicMock,
|
||||
mock_playbook: DayPlaybook,
|
||||
mock_risk: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_decision_logger: MagicMock,
|
||||
mock_context_store: MagicMock,
|
||||
mock_criticality_assessor: MagicMock,
|
||||
mock_telegram: MagicMock,
|
||||
mock_overseas_market: MagicMock,
|
||||
) -> None:
|
||||
"""BUY price for penny stocks (< $1) uses 4 decimal places (issue #252)."""
|
||||
overseas_broker = MagicMock()
|
||||
overseas_broker.get_overseas_balance = AsyncMock(
|
||||
return_value={
|
||||
"output1": [],
|
||||
"output2": [{"frcr_evlu_tota": "0", "frcr_dncl_amt_2": "10000", "frcr_buy_amt_smtl": "0"}],
|
||||
}
|
||||
)
|
||||
overseas_broker.get_overseas_price = AsyncMock(
|
||||
return_value={"output": {"last": "0.5678", "rate": "0"}}
|
||||
)
|
||||
overseas_broker.send_overseas_order = AsyncMock(
|
||||
return_value={"rt_cd": None, "msg1": "주문접수"}
|
||||
)
|
||||
|
||||
db_conn = init_db(":memory:")
|
||||
decision_logger = DecisionLogger(db_conn)
|
||||
|
||||
engine = MagicMock(spec=ScenarioEngine)
|
||||
engine.evaluate = MagicMock(return_value=_make_buy_match())
|
||||
|
||||
await trading_cycle(
|
||||
broker=mock_domestic_broker,
|
||||
overseas_broker=overseas_broker,
|
||||
scenario_engine=engine,
|
||||
playbook=mock_playbook,
|
||||
risk=mock_risk,
|
||||
db_conn=db_conn,
|
||||
decision_logger=decision_logger,
|
||||
context_store=mock_context_store,
|
||||
criticality_assessor=mock_criticality_assessor,
|
||||
telegram=mock_telegram,
|
||||
market=mock_overseas_market,
|
||||
stock_code="PENNYX",
|
||||
scan_candidates={},
|
||||
)
|
||||
|
||||
overseas_broker.send_overseas_order.assert_called_once()
|
||||
sent_price = overseas_broker.send_overseas_order.call_args[1].get("price") or \
|
||||
overseas_broker.send_overseas_order.call_args[0][4]
|
||||
# 0.5678 * 1.002 = 0.56893... rounded to 4 decimals = 0.5689
|
||||
assert sent_price == round(0.5678 * 1.002, 4), (
|
||||
f"Expected 4-decimal price {round(0.5678 * 1.002, 4)} but got {sent_price} (#252)"
|
||||
)
|
||||
|
||||
|
||||
class TestScenarioEngineIntegration:
|
||||
"""Test scenario engine integration in trading_cycle."""
|
||||
@@ -2048,6 +2246,92 @@ async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> N
|
||||
broker.send_order.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_loss_not_triggered_when_current_price_is_zero() -> None:
|
||||
"""HOLD must stay HOLD when current_price=0 even if entry_price is set (issue #251).
|
||||
|
||||
A price API failure that returns 0.0 must not cause a false -100% stop-loss.
|
||||
"""
|
||||
db_conn = init_db(":memory:")
|
||||
decision_logger = DecisionLogger(db_conn)
|
||||
|
||||
buy_decision_id = decision_logger.log_decision(
|
||||
stock_code="005930",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="BUY",
|
||||
confidence=90,
|
||||
rationale="entry",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
log_trade(
|
||||
conn=db_conn,
|
||||
stock_code="005930",
|
||||
action="BUY",
|
||||
confidence=90,
|
||||
rationale="entry",
|
||||
quantity=1,
|
||||
price=100.0, # valid entry price
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
decision_id=buy_decision_id,
|
||||
)
|
||||
|
||||
broker = MagicMock()
|
||||
# Price API returns 0.0 — simulates API failure or pre-market unavailability
|
||||
broker.get_current_price = AsyncMock(return_value=(0.0, 0.0, 0.0))
|
||||
broker.get_balance = AsyncMock(
|
||||
return_value={
|
||||
"output2": [
|
||||
{
|
||||
"tot_evlu_amt": "100000",
|
||||
"dnca_tot_amt": "10000",
|
||||
"pchs_amt_smtl_amt": "90000",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||
|
||||
market = MagicMock()
|
||||
market.name = "Korea"
|
||||
market.code = "KR"
|
||||
market.exchange_code = "KRX"
|
||||
market.is_domestic = True
|
||||
|
||||
telegram = MagicMock()
|
||||
telegram.notify_trade_execution = AsyncMock()
|
||||
telegram.notify_fat_finger = AsyncMock()
|
||||
telegram.notify_circuit_breaker = AsyncMock()
|
||||
telegram.notify_scenario_matched = AsyncMock()
|
||||
|
||||
await trading_cycle(
|
||||
broker=broker,
|
||||
overseas_broker=MagicMock(),
|
||||
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_hold_match())),
|
||||
playbook=_make_playbook("KR"),
|
||||
risk=MagicMock(),
|
||||
db_conn=db_conn,
|
||||
decision_logger=decision_logger,
|
||||
context_store=MagicMock(
|
||||
get_latest_timeframe=MagicMock(return_value=None),
|
||||
set_context=MagicMock(),
|
||||
),
|
||||
criticality_assessor=MagicMock(
|
||||
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||
get_timeout=MagicMock(return_value=5.0),
|
||||
),
|
||||
telegram=telegram,
|
||||
market=market,
|
||||
stock_code="005930",
|
||||
scan_candidates={},
|
||||
)
|
||||
|
||||
# No SELL order must be placed — current_price=0 must suppress stop-loss
|
||||
broker.send_order.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
|
||||
"""SELL quantity must come from broker balance output1, not DB.
|
||||
@@ -3818,6 +4102,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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user