Compare commits
4 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a22d6cd4 | ||
| 03f8d220a4 | |||
|
|
305120f599 | ||
| faa23b3f1b |
131
src/main.py
131
src/main.py
@@ -106,6 +106,82 @@ def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_held_codes_from_balance(
|
||||||
|
balance_data: dict[str, Any],
|
||||||
|
*,
|
||||||
|
is_domestic: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return stock codes with a positive orderable quantity from a balance response.
|
||||||
|
|
||||||
|
Uses the broker's live output1 as the source of truth so that partial fills
|
||||||
|
and manual external trades are always reflected correctly.
|
||||||
|
"""
|
||||||
|
output1 = balance_data.get("output1", [])
|
||||||
|
if isinstance(output1, dict):
|
||||||
|
output1 = [output1]
|
||||||
|
if not isinstance(output1, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
codes: list[str] = []
|
||||||
|
for holding in output1:
|
||||||
|
if not isinstance(holding, dict):
|
||||||
|
continue
|
||||||
|
code_key = "pdno" if is_domestic else "ovrs_pdno"
|
||||||
|
code = str(holding.get(code_key, "")).strip().upper()
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
if is_domestic:
|
||||||
|
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
|
||||||
|
else:
|
||||||
|
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
|
||||||
|
if qty > 0:
|
||||||
|
codes.append(code)
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_held_qty_from_balance(
|
||||||
|
balance_data: dict[str, Any],
|
||||||
|
stock_code: str,
|
||||||
|
*,
|
||||||
|
is_domestic: bool,
|
||||||
|
) -> int:
|
||||||
|
"""Extract the broker-confirmed orderable quantity for a stock.
|
||||||
|
|
||||||
|
Uses the broker's live balance response (output1) as the source of truth
|
||||||
|
rather than the local DB, because DB records reflect order quantity which
|
||||||
|
may differ from actual fill quantity due to partial fills.
|
||||||
|
|
||||||
|
Domestic fields (VTTC8434R output1):
|
||||||
|
pdno — 종목코드
|
||||||
|
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
|
||||||
|
hldg_qty — 보유수량 (fallback)
|
||||||
|
|
||||||
|
Overseas fields (output1):
|
||||||
|
ovrs_pdno — 종목코드
|
||||||
|
ovrs_cblc_qty — 해외잔고수량 (preferred)
|
||||||
|
hldg_qty — 보유수량 (fallback)
|
||||||
|
"""
|
||||||
|
output1 = balance_data.get("output1", [])
|
||||||
|
if isinstance(output1, dict):
|
||||||
|
output1 = [output1]
|
||||||
|
if not isinstance(output1, list):
|
||||||
|
return 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
|
||||||
|
if is_domestic:
|
||||||
|
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
|
||||||
|
else:
|
||||||
|
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
|
||||||
|
return qty
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _determine_order_quantity(
|
def _determine_order_quantity(
|
||||||
*,
|
*,
|
||||||
action: str,
|
action: str,
|
||||||
@@ -113,10 +189,11 @@ def _determine_order_quantity(
|
|||||||
total_cash: float,
|
total_cash: float,
|
||||||
candidate: ScanCandidate | None,
|
candidate: ScanCandidate | None,
|
||||||
settings: Settings | None,
|
settings: Settings | None,
|
||||||
|
broker_held_qty: int = 0,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Determine order quantity using volatility-aware position sizing."""
|
"""Determine order quantity using volatility-aware position sizing."""
|
||||||
if action != "BUY":
|
if action == "SELL":
|
||||||
return 1
|
return broker_held_qty
|
||||||
if current_price <= 0 or total_cash <= 0:
|
if current_price <= 0 or total_cash <= 0:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -484,12 +561,20 @@ async def trading_cycle(
|
|||||||
trade_price = current_price
|
trade_price = current_price
|
||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
if decision.action in ("BUY", "SELL"):
|
if decision.action in ("BUY", "SELL"):
|
||||||
|
broker_held_qty = (
|
||||||
|
_extract_held_qty_from_balance(
|
||||||
|
balance_data, stock_code, is_domestic=market.is_domestic
|
||||||
|
)
|
||||||
|
if decision.action == "SELL"
|
||||||
|
else 0
|
||||||
|
)
|
||||||
quantity = _determine_order_quantity(
|
quantity = _determine_order_quantity(
|
||||||
action=decision.action,
|
action=decision.action,
|
||||||
current_price=current_price,
|
current_price=current_price,
|
||||||
total_cash=total_cash,
|
total_cash=total_cash,
|
||||||
candidate=candidate,
|
candidate=candidate,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
broker_held_qty=broker_held_qty,
|
||||||
)
|
)
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -909,12 +994,20 @@ async def run_daily_session(
|
|||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
order_succeeded = True
|
order_succeeded = True
|
||||||
if decision.action in ("BUY", "SELL"):
|
if decision.action in ("BUY", "SELL"):
|
||||||
|
daily_broker_held_qty = (
|
||||||
|
_extract_held_qty_from_balance(
|
||||||
|
balance_data, stock_code, is_domestic=market.is_domestic
|
||||||
|
)
|
||||||
|
if decision.action == "SELL"
|
||||||
|
else 0
|
||||||
|
)
|
||||||
quantity = _determine_order_quantity(
|
quantity = _determine_order_quantity(
|
||||||
action=decision.action,
|
action=decision.action,
|
||||||
current_price=stock_data["current_price"],
|
current_price=stock_data["current_price"],
|
||||||
total_cash=total_cash,
|
total_cash=total_cash,
|
||||||
candidate=candidate_map.get(stock_code),
|
candidate=candidate_map.get(stock_code),
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
broker_held_qty=daily_broker_held_qty,
|
||||||
)
|
)
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1881,8 +1974,38 @@ async def run(settings: Settings) -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
|
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
|
||||||
|
|
||||||
# Get active stocks from scanner (dynamic, no static fallback)
|
# Get active stocks from scanner (dynamic, no static fallback).
|
||||||
stock_codes = active_stocks.get(market.code, [])
|
# Also include currently-held positions so stop-loss /
|
||||||
|
# take-profit can fire even when a holding drops off the
|
||||||
|
# scanner. Broker balance is the source of truth here —
|
||||||
|
# unlike the local DB it reflects actual fills and any
|
||||||
|
# manual trades done outside the bot.
|
||||||
|
scanner_codes = active_stocks.get(market.code, [])
|
||||||
|
try:
|
||||||
|
if market.is_domestic:
|
||||||
|
held_balance = await broker.get_balance()
|
||||||
|
else:
|
||||||
|
held_balance = await overseas_broker.get_overseas_balance(
|
||||||
|
market.exchange_code
|
||||||
|
)
|
||||||
|
held_codes = _extract_held_codes_from_balance(
|
||||||
|
held_balance, is_domestic=market.is_domestic
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to fetch holdings for %s: %s — skipping holdings merge",
|
||||||
|
market.name, exc,
|
||||||
|
)
|
||||||
|
held_codes = []
|
||||||
|
|
||||||
|
stock_codes = list(dict.fromkeys(scanner_codes + held_codes))
|
||||||
|
extra_held = [c for c in held_codes if c not in set(scanner_codes)]
|
||||||
|
if extra_held:
|
||||||
|
logger.info(
|
||||||
|
"Holdings added to loop for %s (not in scanner): %s",
|
||||||
|
market.name, extra_held,
|
||||||
|
)
|
||||||
|
|
||||||
if not stock_codes:
|
if not stock_codes:
|
||||||
logger.debug("No active stocks for market %s", market.code)
|
logger.debug("No active stocks for market %s", market.code)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ class StockCondition(BaseModel):
|
|||||||
|
|
||||||
The ScenarioEngine evaluates all non-None fields as AND conditions.
|
The ScenarioEngine evaluates all non-None fields as AND conditions.
|
||||||
A condition matches only if ALL specified fields are satisfied.
|
A condition matches only if ALL specified fields are satisfied.
|
||||||
|
|
||||||
|
Technical indicator fields:
|
||||||
|
rsi_below / rsi_above — RSI threshold
|
||||||
|
volume_ratio_above / volume_ratio_below — volume vs previous day
|
||||||
|
price_above / price_below — absolute price level
|
||||||
|
price_change_pct_above / price_change_pct_below — intraday % change
|
||||||
|
|
||||||
|
Position-aware fields (require market_data enrichment from open position):
|
||||||
|
unrealized_pnl_pct_above — matches if unrealized P&L > threshold (e.g. 3.0 → +3%)
|
||||||
|
unrealized_pnl_pct_below — matches if unrealized P&L < threshold (e.g. -2.0 → -2%)
|
||||||
|
holding_days_above — matches if position held for more than N days
|
||||||
|
holding_days_below — matches if position held for fewer than N days
|
||||||
"""
|
"""
|
||||||
|
|
||||||
rsi_below: float | None = None
|
rsi_below: float | None = None
|
||||||
@@ -56,6 +68,10 @@ class StockCondition(BaseModel):
|
|||||||
price_below: float | None = None
|
price_below: float | None = None
|
||||||
price_change_pct_above: float | None = None
|
price_change_pct_above: float | None = None
|
||||||
price_change_pct_below: float | None = None
|
price_change_pct_below: float | None = None
|
||||||
|
unrealized_pnl_pct_above: float | None = None
|
||||||
|
unrealized_pnl_pct_below: float | None = None
|
||||||
|
holding_days_above: int | None = None
|
||||||
|
holding_days_below: int | None = None
|
||||||
|
|
||||||
def has_any_condition(self) -> bool:
|
def has_any_condition(self) -> bool:
|
||||||
"""Check if at least one condition field is set."""
|
"""Check if at least one condition field is set."""
|
||||||
@@ -70,6 +86,10 @@ class StockCondition(BaseModel):
|
|||||||
self.price_below,
|
self.price_below,
|
||||||
self.price_change_pct_above,
|
self.price_change_pct_above,
|
||||||
self.price_change_pct_below,
|
self.price_change_pct_below,
|
||||||
|
self.unrealized_pnl_pct_above,
|
||||||
|
self.unrealized_pnl_pct_below,
|
||||||
|
self.holding_days_above,
|
||||||
|
self.holding_days_below,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -294,7 +294,8 @@ class PreMarketPlanner:
|
|||||||
f' "stock_code": "...",\n'
|
f' "stock_code": "...",\n'
|
||||||
f' "scenarios": [\n'
|
f' "scenarios": [\n'
|
||||||
f' {{\n'
|
f' {{\n'
|
||||||
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n'
|
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,'
|
||||||
|
f' "unrealized_pnl_pct_above": 3.0, "holding_days_above": 5}},\n'
|
||||||
f' "action": "BUY|SELL|HOLD",\n'
|
f' "action": "BUY|SELL|HOLD",\n'
|
||||||
f' "confidence": 85,\n'
|
f' "confidence": 85,\n'
|
||||||
f' "allocation_pct": 10.0,\n'
|
f' "allocation_pct": 10.0,\n'
|
||||||
@@ -390,6 +391,10 @@ class PreMarketPlanner:
|
|||||||
price_below=cond_data.get("price_below"),
|
price_below=cond_data.get("price_below"),
|
||||||
price_change_pct_above=cond_data.get("price_change_pct_above"),
|
price_change_pct_above=cond_data.get("price_change_pct_above"),
|
||||||
price_change_pct_below=cond_data.get("price_change_pct_below"),
|
price_change_pct_below=cond_data.get("price_change_pct_below"),
|
||||||
|
unrealized_pnl_pct_above=cond_data.get("unrealized_pnl_pct_above"),
|
||||||
|
unrealized_pnl_pct_below=cond_data.get("unrealized_pnl_pct_below"),
|
||||||
|
holding_days_above=cond_data.get("holding_days_above"),
|
||||||
|
holding_days_below=cond_data.get("holding_days_below"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not condition.has_any_condition():
|
if not condition.has_any_condition():
|
||||||
|
|||||||
@@ -206,6 +206,37 @@ class ScenarioEngine:
|
|||||||
if condition.price_change_pct_below is not None:
|
if condition.price_change_pct_below is not None:
|
||||||
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
|
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
|
||||||
|
|
||||||
|
# Position-aware conditions
|
||||||
|
unrealized_pnl_pct = self._safe_float(market_data.get("unrealized_pnl_pct"))
|
||||||
|
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
|
||||||
|
if "unrealized_pnl_pct" not in market_data:
|
||||||
|
self._warn_missing_key("unrealized_pnl_pct")
|
||||||
|
if condition.unrealized_pnl_pct_above is not None:
|
||||||
|
checks.append(
|
||||||
|
unrealized_pnl_pct is not None
|
||||||
|
and unrealized_pnl_pct > condition.unrealized_pnl_pct_above
|
||||||
|
)
|
||||||
|
if condition.unrealized_pnl_pct_below is not None:
|
||||||
|
checks.append(
|
||||||
|
unrealized_pnl_pct is not None
|
||||||
|
and unrealized_pnl_pct < condition.unrealized_pnl_pct_below
|
||||||
|
)
|
||||||
|
|
||||||
|
holding_days = self._safe_float(market_data.get("holding_days"))
|
||||||
|
if condition.holding_days_above is not None or condition.holding_days_below is not None:
|
||||||
|
if "holding_days" not in market_data:
|
||||||
|
self._warn_missing_key("holding_days")
|
||||||
|
if condition.holding_days_above is not None:
|
||||||
|
checks.append(
|
||||||
|
holding_days is not None
|
||||||
|
and holding_days > condition.holding_days_above
|
||||||
|
)
|
||||||
|
if condition.holding_days_below is not None:
|
||||||
|
checks.append(
|
||||||
|
holding_days is not None
|
||||||
|
and holding_days < condition.holding_days_below
|
||||||
|
)
|
||||||
|
|
||||||
return len(checks) > 0 and all(checks)
|
return len(checks) > 0 and all(checks)
|
||||||
|
|
||||||
def _evaluate_global_condition(
|
def _evaluate_global_condition(
|
||||||
@@ -266,5 +297,9 @@ class ScenarioEngine:
|
|||||||
details["current_price"] = self._safe_float(market_data.get("current_price"))
|
details["current_price"] = self._safe_float(market_data.get("current_price"))
|
||||||
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
|
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
|
||||||
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
|
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
|
||||||
|
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
|
||||||
|
details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct"))
|
||||||
|
if condition.holding_days_above is not None or condition.holding_days_below is not None:
|
||||||
|
details["holding_days"] = self._safe_float(market_data.get("holding_days"))
|
||||||
|
|
||||||
return details
|
return details
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ from src.evolution.scorecard import DailyScorecard
|
|||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
from src.main import (
|
from src.main import (
|
||||||
_apply_dashboard_flag,
|
_apply_dashboard_flag,
|
||||||
|
_determine_order_quantity,
|
||||||
|
_extract_held_codes_from_balance,
|
||||||
|
_extract_held_qty_from_balance,
|
||||||
_handle_market_close,
|
_handle_market_close,
|
||||||
_run_context_scheduler,
|
_run_context_scheduler,
|
||||||
_run_evolution_loop,
|
_run_evolution_loop,
|
||||||
@@ -68,6 +71,141 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractHeldQtyFromBalance:
|
||||||
|
"""Tests for _extract_held_qty_from_balance()."""
|
||||||
|
|
||||||
|
def _domestic_balance(self, stock_code: str, ord_psbl_qty: int) -> dict:
|
||||||
|
return {
|
||||||
|
"output1": [{"pdno": stock_code, "ord_psbl_qty": str(ord_psbl_qty)}],
|
||||||
|
"output2": [{"dnca_tot_amt": "1000000"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_domestic_returns_ord_psbl_qty(self) -> None:
|
||||||
|
balance = self._domestic_balance("005930", 7)
|
||||||
|
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 7
|
||||||
|
|
||||||
|
def test_domestic_fallback_to_hldg_qty(self) -> None:
|
||||||
|
balance = {"output1": [{"pdno": "005930", "hldg_qty": "3"}]}
|
||||||
|
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 3
|
||||||
|
|
||||||
|
def test_domestic_returns_zero_when_not_found(self) -> None:
|
||||||
|
balance = self._domestic_balance("005930", 5)
|
||||||
|
assert _extract_held_qty_from_balance(balance, "000660", is_domestic=True) == 0
|
||||||
|
|
||||||
|
def test_domestic_returns_zero_when_output1_empty(self) -> None:
|
||||||
|
balance = {"output1": [], "output2": [{}]}
|
||||||
|
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0
|
||||||
|
|
||||||
|
def test_overseas_returns_ovrs_cblc_qty(self) -> None:
|
||||||
|
balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}]}
|
||||||
|
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 10
|
||||||
|
|
||||||
|
def test_overseas_fallback_to_hldg_qty(self) -> None:
|
||||||
|
balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]}
|
||||||
|
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 4
|
||||||
|
|
||||||
|
def test_case_insensitive_match(self) -> None:
|
||||||
|
balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "2"}]}
|
||||||
|
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractHeldCodesFromBalance:
|
||||||
|
"""Tests for _extract_held_codes_from_balance()."""
|
||||||
|
|
||||||
|
def test_returns_codes_with_positive_qty(self) -> None:
|
||||||
|
balance = {
|
||||||
|
"output1": [
|
||||||
|
{"pdno": "005930", "ord_psbl_qty": "5"},
|
||||||
|
{"pdno": "000660", "ord_psbl_qty": "3"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = _extract_held_codes_from_balance(balance, is_domestic=True)
|
||||||
|
assert set(result) == {"005930", "000660"}
|
||||||
|
|
||||||
|
def test_excludes_zero_qty_holdings(self) -> None:
|
||||||
|
balance = {
|
||||||
|
"output1": [
|
||||||
|
{"pdno": "005930", "ord_psbl_qty": "0"},
|
||||||
|
{"pdno": "000660", "ord_psbl_qty": "2"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = _extract_held_codes_from_balance(balance, is_domestic=True)
|
||||||
|
assert "005930" not in result
|
||||||
|
assert "000660" in result
|
||||||
|
|
||||||
|
def test_returns_empty_when_output1_missing(self) -> None:
|
||||||
|
balance: dict = {}
|
||||||
|
assert _extract_held_codes_from_balance(balance, is_domestic=True) == []
|
||||||
|
|
||||||
|
def test_overseas_uses_ovrs_pdno(self) -> None:
|
||||||
|
balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "3"}]}
|
||||||
|
result = _extract_held_codes_from_balance(balance, is_domestic=False)
|
||||||
|
assert result == ["AAPL"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetermineOrderQuantity:
|
||||||
|
"""Test _determine_order_quantity() — SELL uses broker_held_qty."""
|
||||||
|
|
||||||
|
def test_sell_returns_broker_held_qty(self) -> None:
|
||||||
|
result = _determine_order_quantity(
|
||||||
|
action="SELL",
|
||||||
|
current_price=105.0,
|
||||||
|
total_cash=50000.0,
|
||||||
|
candidate=None,
|
||||||
|
settings=None,
|
||||||
|
broker_held_qty=7,
|
||||||
|
)
|
||||||
|
assert result == 7
|
||||||
|
|
||||||
|
def test_sell_returns_zero_when_broker_qty_zero(self) -> None:
|
||||||
|
result = _determine_order_quantity(
|
||||||
|
action="SELL",
|
||||||
|
current_price=105.0,
|
||||||
|
total_cash=50000.0,
|
||||||
|
candidate=None,
|
||||||
|
settings=None,
|
||||||
|
broker_held_qty=0,
|
||||||
|
)
|
||||||
|
assert result == 0
|
||||||
|
|
||||||
|
def test_buy_without_position_sizing_returns_one(self) -> None:
|
||||||
|
result = _determine_order_quantity(
|
||||||
|
action="BUY",
|
||||||
|
current_price=50000.0,
|
||||||
|
total_cash=1000000.0,
|
||||||
|
candidate=None,
|
||||||
|
settings=None,
|
||||||
|
)
|
||||||
|
assert result == 1
|
||||||
|
|
||||||
|
def test_buy_with_zero_cash_returns_zero(self) -> None:
|
||||||
|
result = _determine_order_quantity(
|
||||||
|
action="BUY",
|
||||||
|
current_price=50000.0,
|
||||||
|
total_cash=0.0,
|
||||||
|
candidate=None,
|
||||||
|
settings=None,
|
||||||
|
)
|
||||||
|
assert result == 0
|
||||||
|
|
||||||
|
def test_buy_with_position_sizing_calculates_correctly(self) -> None:
|
||||||
|
settings = MagicMock(spec=Settings)
|
||||||
|
settings.POSITION_SIZING_ENABLED = True
|
||||||
|
settings.POSITION_VOLATILITY_TARGET_SCORE = 50.0
|
||||||
|
settings.POSITION_BASE_ALLOCATION_PCT = 10.0
|
||||||
|
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
|
||||||
|
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
|
||||||
|
# 1,000,000 * 10% = 100,000 budget // 50,000 price = 2 shares
|
||||||
|
result = _determine_order_quantity(
|
||||||
|
action="BUY",
|
||||||
|
current_price=50000.0,
|
||||||
|
total_cash=1000000.0,
|
||||||
|
candidate=None,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
assert result == 2
|
||||||
|
|
||||||
|
|
||||||
class TestSafeFloat:
|
class TestSafeFloat:
|
||||||
"""Test safe_float() helper function."""
|
"""Test safe_float() helper function."""
|
||||||
|
|
||||||
@@ -1240,13 +1378,14 @@ async def test_sell_updates_original_buy_decision_outcome() -> None:
|
|||||||
broker.get_current_price = AsyncMock(return_value=(120.0, 0.0, 0.0))
|
broker.get_current_price = AsyncMock(return_value=(120.0, 0.0, 0.0))
|
||||||
broker.get_balance = AsyncMock(
|
broker.get_balance = AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
|
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
|
||||||
"output2": [
|
"output2": [
|
||||||
{
|
{
|
||||||
"tot_evlu_amt": "100000",
|
"tot_evlu_amt": "100000",
|
||||||
"dnca_tot_amt": "10000",
|
"dnca_tot_amt": "10000",
|
||||||
"pchs_amt_smtl_amt": "90000",
|
"pchs_amt_smtl_amt": "90000",
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
@@ -1330,13 +1469,14 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
|
|||||||
broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
|
broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
|
||||||
broker.get_balance = AsyncMock(
|
broker.get_balance = AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
|
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
|
||||||
"output2": [
|
"output2": [
|
||||||
{
|
{
|
||||||
"tot_evlu_amt": "100000",
|
"tot_evlu_amt": "100000",
|
||||||
"dnca_tot_amt": "10000",
|
"dnca_tot_amt": "10000",
|
||||||
"pchs_amt_smtl_amt": "90000",
|
"pchs_amt_smtl_amt": "90000",
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
@@ -1430,13 +1570,14 @@ async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None:
|
|||||||
broker.get_current_price = AsyncMock(return_value=(106.0, 6.0, 0.0))
|
broker.get_current_price = AsyncMock(return_value=(106.0, 6.0, 0.0))
|
||||||
broker.get_balance = AsyncMock(
|
broker.get_balance = AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
|
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
|
||||||
"output2": [
|
"output2": [
|
||||||
{
|
{
|
||||||
"tot_evlu_amt": "100000",
|
"tot_evlu_amt": "100000",
|
||||||
"dnca_tot_amt": "10000",
|
"dnca_tot_amt": "10000",
|
||||||
"pchs_amt_smtl_amt": "90000",
|
"pchs_amt_smtl_amt": "90000",
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
@@ -1597,6 +1738,116 @@ async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> N
|
|||||||
broker.send_order.assert_not_called()
|
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.
|
||||||
|
|
||||||
|
The DB records order quantity which may differ from actual fill quantity.
|
||||||
|
This test verifies that we use the broker-confirmed orderable quantity.
|
||||||
|
"""
|
||||||
|
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={},
|
||||||
|
)
|
||||||
|
# DB records 10 shares ordered — but only 5 actually filled (partial fill scenario)
|
||||||
|
log_trade(
|
||||||
|
conn=db_conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
quantity=10, # ordered quantity (may differ from fill)
|
||||||
|
price=100.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id=buy_decision_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
# Stop-loss triggers (price dropped below -2%)
|
||||||
|
broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
# Broker confirms only 5 shares are actually orderable (partial fill)
|
||||||
|
"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}],
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "100000",
|
||||||
|
"dnca_tot_amt": "10000",
|
||||||
|
"pchs_amt_smtl_amt": "90000",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
scenario = StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=30),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=88,
|
||||||
|
stop_loss_pct=-2.0,
|
||||||
|
rationale="stop loss policy",
|
||||||
|
)
|
||||||
|
playbook = DayPlaybook(
|
||||||
|
date=date(2026, 2, 8),
|
||||||
|
market="KR",
|
||||||
|
stock_playbooks=[
|
||||||
|
{"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=_make_hold_match())
|
||||||
|
|
||||||
|
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=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
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={},
|
||||||
|
)
|
||||||
|
|
||||||
|
broker.send_order.assert_called_once()
|
||||||
|
call_kwargs = broker.send_order.call_args.kwargs
|
||||||
|
assert call_kwargs["order_type"] == "SELL"
|
||||||
|
# Must use broker-confirmed qty (5), NOT DB-recorded ordered qty (10)
|
||||||
|
assert call_kwargs["quantity"] == 5
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_market_close_runs_daily_review_flow() -> None:
|
async def test_handle_market_close_runs_daily_review_flow() -> None:
|
||||||
"""Market close should aggregate, create scorecard, lessons, and notify."""
|
"""Market close should aggregate, create scorecard, lessons, and notify."""
|
||||||
|
|||||||
@@ -440,3 +440,135 @@ class TestEvaluate:
|
|||||||
assert result.action == ScenarioAction.BUY
|
assert result.action == ScenarioAction.BUY
|
||||||
assert result.match_details["rsi"] == 25.0
|
assert result.match_details["rsi"] == 25.0
|
||||||
assert isinstance(result.match_details["rsi"], float)
|
assert isinstance(result.match_details["rsi"], float)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Position-aware condition tests (#171)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPositionAwareConditions:
|
||||||
|
"""Tests for unrealized_pnl_pct and holding_days condition fields."""
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_above_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_above should match when P&L exceeds threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 5.0}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_above_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_above should NOT match when P&L is below threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 2.0}) is False
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_below_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_below should match when P&L is under threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -3.5}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_below_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_below should NOT match when P&L is above threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -1.0}) is False
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_above_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_above should match when position held longer than threshold."""
|
||||||
|
condition = StockCondition(holding_days_above=5)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 7}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_above_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_above should NOT match when position held shorter."""
|
||||||
|
condition = StockCondition(holding_days_above=5)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 3}) is False
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_below_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_below should match when position held fewer days."""
|
||||||
|
condition = StockCondition(holding_days_below=3)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 1}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_below_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_below should NOT match when held more days."""
|
||||||
|
condition = StockCondition(holding_days_below=3)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 5}) is False
|
||||||
|
|
||||||
|
def test_combined_pnl_and_holding_days(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""Combined position-aware conditions should AND-evaluate correctly."""
|
||||||
|
condition = StockCondition(
|
||||||
|
unrealized_pnl_pct_above=3.0,
|
||||||
|
holding_days_above=5,
|
||||||
|
)
|
||||||
|
# Both met → match
|
||||||
|
assert engine.evaluate_condition(
|
||||||
|
condition,
|
||||||
|
{"unrealized_pnl_pct": 4.5, "holding_days": 7},
|
||||||
|
) is True
|
||||||
|
# Only pnl met → no match
|
||||||
|
assert engine.evaluate_condition(
|
||||||
|
condition,
|
||||||
|
{"unrealized_pnl_pct": 4.5, "holding_days": 3},
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_missing_unrealized_pnl_does_not_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""Missing unrealized_pnl_pct key should not match the condition."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||||
|
assert engine.evaluate_condition(condition, {}) is False
|
||||||
|
|
||||||
|
def test_missing_holding_days_does_not_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""Missing holding_days key should not match the condition."""
|
||||||
|
condition = StockCondition(holding_days_above=5)
|
||||||
|
assert engine.evaluate_condition(condition, {}) is False
|
||||||
|
|
||||||
|
def test_match_details_includes_position_fields(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""match_details should include position fields when condition specifies them."""
|
||||||
|
pb = _playbook(
|
||||||
|
scenarios=[
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(unrealized_pnl_pct_above=3.0),
|
||||||
|
action=ScenarioAction.SELL,
|
||||||
|
confidence=90,
|
||||||
|
rationale="Take profit",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.evaluate(
|
||||||
|
pb,
|
||||||
|
"005930",
|
||||||
|
{"unrealized_pnl_pct": 5.0},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result.action == ScenarioAction.SELL
|
||||||
|
assert "unrealized_pnl_pct" in result.match_details
|
||||||
|
assert result.match_details["unrealized_pnl_pct"] == 5.0
|
||||||
|
|
||||||
|
def test_position_conditions_parse_from_planner(self) -> None:
|
||||||
|
"""StockCondition should accept and store new fields from JSON parsing."""
|
||||||
|
condition = StockCondition(
|
||||||
|
unrealized_pnl_pct_above=3.0,
|
||||||
|
unrealized_pnl_pct_below=None,
|
||||||
|
holding_days_above=5,
|
||||||
|
holding_days_below=None,
|
||||||
|
)
|
||||||
|
assert condition.unrealized_pnl_pct_above == 3.0
|
||||||
|
assert condition.holding_days_above == 5
|
||||||
|
assert condition.has_any_condition() is True
|
||||||
|
|||||||
Reference in New Issue
Block a user