Compare commits
5 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
702653e52e | ||
|
|
854931bed2 | ||
| 33b5ff5e54 | |||
| 3923d03650 | |||
|
|
c57ccc4bca |
@@ -83,8 +83,8 @@ class MarketScanner:
|
|||||||
# Convert to orderbook-like structure
|
# Convert to orderbook-like structure
|
||||||
orderbook = {
|
orderbook = {
|
||||||
"output1": {
|
"output1": {
|
||||||
"stck_prpr": price_data.get("output", {}).get("last", "0"),
|
"stck_prpr": price_data.get("output", {}).get("last", "0") or "0",
|
||||||
"acml_vol": price_data.get("output", {}).get("tvol", "0"),
|
"acml_vol": price_data.get("output", {}).get("tvol", "0") or "0",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
src/main.py
47
src/main.py
@@ -33,6 +33,35 @@ from src.notifications.telegram_client import TelegramClient
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
||||||
|
"""Convert to float, handling empty strings and None.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Value to convert (string, float, or None)
|
||||||
|
default: Default value if conversion fails
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Converted float or default value
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> safe_float("123.45")
|
||||||
|
123.45
|
||||||
|
>>> safe_float("")
|
||||||
|
0.0
|
||||||
|
>>> safe_float(None)
|
||||||
|
0.0
|
||||||
|
>>> safe_float("invalid", 99.0)
|
||||||
|
99.0
|
||||||
|
"""
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
# Target stock codes to monitor per market
|
# Target stock codes to monitor per market
|
||||||
WATCHLISTS = {
|
WATCHLISTS = {
|
||||||
"KR": ["005930", "000660", "035420"], # Samsung, SK Hynix, NAVER
|
"KR": ["005930", "000660", "035420"], # Samsung, SK Hynix, NAVER
|
||||||
@@ -77,16 +106,16 @@ async def trading_cycle(
|
|||||||
balance_data = await broker.get_balance()
|
balance_data = await broker.get_balance()
|
||||||
|
|
||||||
output2 = balance_data.get("output2", [{}])
|
output2 = balance_data.get("output2", [{}])
|
||||||
total_eval = float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
|
total_eval = safe_float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
|
||||||
total_cash = float(
|
total_cash = safe_float(
|
||||||
balance_data.get("output2", [{}])[0].get("dnca_tot_amt", "0")
|
balance_data.get("output2", [{}])[0].get("dnca_tot_amt", "0")
|
||||||
if output2
|
if output2
|
||||||
else "0"
|
else "0"
|
||||||
)
|
)
|
||||||
purchase_total = float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0
|
purchase_total = safe_float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0
|
||||||
|
|
||||||
current_price = float(orderbook.get("output1", {}).get("stck_prpr", "0"))
|
current_price = safe_float(orderbook.get("output1", {}).get("stck_prpr", "0"))
|
||||||
foreigner_net = float(orderbook.get("output1", {}).get("frgn_ntby_qty", "0"))
|
foreigner_net = safe_float(orderbook.get("output1", {}).get("frgn_ntby_qty", "0"))
|
||||||
else:
|
else:
|
||||||
# Overseas market
|
# Overseas market
|
||||||
price_data = await overseas_broker.get_overseas_price(
|
price_data = await overseas_broker.get_overseas_price(
|
||||||
@@ -103,11 +132,11 @@ async def trading_cycle(
|
|||||||
else:
|
else:
|
||||||
balance_info = {}
|
balance_info = {}
|
||||||
|
|
||||||
total_eval = float(balance_info.get("frcr_evlu_tota", "0") or "0")
|
total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0")
|
||||||
total_cash = float(balance_info.get("frcr_dncl_amt_2", "0") or "0")
|
total_cash = safe_float(balance_info.get("frcr_dncl_amt_2", "0") or "0")
|
||||||
purchase_total = float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
|
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
|
||||||
|
|
||||||
current_price = float(price_data.get("output", {}).get("last", "0"))
|
current_price = safe_float(price_data.get("output", {}).get("last", "0"))
|
||||||
foreigner_net = 0.0 # Not available for overseas
|
foreigner_net = 0.0 # Not available for overseas
|
||||||
|
|
||||||
# Calculate daily P&L %
|
# Calculate daily P&L %
|
||||||
|
|||||||
@@ -6,7 +6,43 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
||||||
from src.main import trading_cycle
|
from src.main import safe_float, trading_cycle
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeFloat:
|
||||||
|
"""Test safe_float() helper function."""
|
||||||
|
|
||||||
|
def test_converts_valid_string(self):
|
||||||
|
"""Test conversion of valid numeric string."""
|
||||||
|
assert safe_float("123.45") == 123.45
|
||||||
|
assert safe_float("0") == 0.0
|
||||||
|
assert safe_float("-99.9") == -99.9
|
||||||
|
|
||||||
|
def test_handles_empty_string(self):
|
||||||
|
"""Test empty string returns default."""
|
||||||
|
assert safe_float("") == 0.0
|
||||||
|
assert safe_float("", 99.0) == 99.0
|
||||||
|
|
||||||
|
def test_handles_none(self):
|
||||||
|
"""Test None returns default."""
|
||||||
|
assert safe_float(None) == 0.0
|
||||||
|
assert safe_float(None, 42.0) == 42.0
|
||||||
|
|
||||||
|
def test_handles_invalid_string(self):
|
||||||
|
"""Test invalid string returns default."""
|
||||||
|
assert safe_float("invalid") == 0.0
|
||||||
|
assert safe_float("not_a_number", 100.0) == 100.0
|
||||||
|
assert safe_float("12.34.56") == 0.0
|
||||||
|
|
||||||
|
def test_handles_float_input(self):
|
||||||
|
"""Test float input passes through."""
|
||||||
|
assert safe_float(123.45) == 123.45
|
||||||
|
assert safe_float(0.0) == 0.0
|
||||||
|
|
||||||
|
def test_custom_default(self):
|
||||||
|
"""Test custom default value."""
|
||||||
|
assert safe_float("", -1.0) == -1.0
|
||||||
|
assert safe_float(None, 999.0) == 999.0
|
||||||
|
|
||||||
|
|
||||||
class TestTradingCycleTelegramIntegration:
|
class TestTradingCycleTelegramIntegration:
|
||||||
@@ -394,6 +430,26 @@ class TestOverseasBalanceParsing:
|
|||||||
broker.get_overseas_balance = AsyncMock(return_value={"output2": []})
|
broker.get_overseas_balance = AsyncMock(return_value={"output2": []})
|
||||||
return broker
|
return broker
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseas_broker_with_empty_price(self) -> MagicMock:
|
||||||
|
"""Create mock overseas broker returning empty string for price."""
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": ""}} # Empty string
|
||||||
|
)
|
||||||
|
broker.get_overseas_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"frcr_evlu_tota": "10000.00",
|
||||||
|
"frcr_dncl_amt_2": "5000.00",
|
||||||
|
"frcr_buy_amt_smtl": "4500.00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return broker
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_domestic_broker(self) -> MagicMock:
|
def mock_domestic_broker(self) -> MagicMock:
|
||||||
"""Create minimal mock domestic broker."""
|
"""Create minimal mock domestic broker."""
|
||||||
@@ -559,3 +615,37 @@ class TestOverseasBalanceParsing:
|
|||||||
|
|
||||||
# Verify balance API was called
|
# Verify balance API was called
|
||||||
mock_overseas_broker_with_empty.get_overseas_balance.assert_called_once()
|
mock_overseas_broker_with_empty.get_overseas_balance.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_overseas_price_empty_string(
|
||||||
|
self,
|
||||||
|
mock_domestic_broker: MagicMock,
|
||||||
|
mock_overseas_broker_with_empty_price: MagicMock,
|
||||||
|
mock_brain_hold: MagicMock,
|
||||||
|
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:
|
||||||
|
"""Test overseas price parsing with empty string (issue #49)."""
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
# Should not raise ValueError, should default to 0.0
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_domestic_broker,
|
||||||
|
overseas_broker=mock_overseas_broker_with_empty_price,
|
||||||
|
brain=mock_brain_hold,
|
||||||
|
risk=mock_risk,
|
||||||
|
db_conn=mock_db,
|
||||||
|
decision_logger=mock_decision_logger,
|
||||||
|
context_store=mock_context_store,
|
||||||
|
criticality_assessor=mock_criticality_assessor,
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="AAPL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify price API was called
|
||||||
|
mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once()
|
||||||
|
|||||||
@@ -338,6 +338,28 @@ class TestMarketScanner:
|
|||||||
assert metrics.stock_code == "AAPL"
|
assert metrics.stock_code == "AAPL"
|
||||||
assert metrics.current_price == 150.50
|
assert metrics.current_price == 150.50
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_stock_overseas_empty_price(
|
||||||
|
self,
|
||||||
|
scanner: MarketScanner,
|
||||||
|
mock_overseas_broker: OverseasBroker,
|
||||||
|
context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
"""Test scanning overseas stock with empty price string (issue #49)."""
|
||||||
|
mock_overseas_broker.get_overseas_price.return_value = {
|
||||||
|
"output": {
|
||||||
|
"last": "", # Empty string
|
||||||
|
"tvol": "", # Empty string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
market = MARKETS["US_NASDAQ"]
|
||||||
|
metrics = await scanner.scan_stock("AAPL", market)
|
||||||
|
|
||||||
|
assert metrics is not None
|
||||||
|
assert metrics.stock_code == "AAPL"
|
||||||
|
assert metrics.current_price == 0.0 # Should default to 0.0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_stock_error_handling(
|
async def test_scan_stock_error_handling(
|
||||||
self,
|
self,
|
||||||
|
|||||||
Reference in New Issue
Block a user