Compare commits
5 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7640a30d7 | ||
| 03f8d220a4 | |||
|
|
305120f599 | ||
| faa23b3f1b | |||
|
|
5844ec5ad3 |
22
src/db.py
22
src/db.py
@@ -237,28 +237,6 @@ def get_open_position(
|
||||
return {"decision_id": row[1], "price": row[2], "quantity": row[3]}
|
||||
|
||||
|
||||
def get_open_positions_by_market(
|
||||
conn: sqlite3.Connection, market: str
|
||||
) -> list[str]:
|
||||
"""Return stock codes with a net positive position in the given market.
|
||||
|
||||
Uses net BUY - SELL quantity aggregation to avoid false positives from
|
||||
the simpler "latest record is BUY" heuristic. A stock is considered
|
||||
open only when the bot's own recorded trades leave a positive net quantity.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT stock_code
|
||||
FROM trades
|
||||
WHERE market = ?
|
||||
GROUP BY stock_code
|
||||
HAVING SUM(CASE WHEN action = 'BUY' THEN quantity ELSE -quantity END) > 0
|
||||
""",
|
||||
(market,),
|
||||
)
|
||||
return [row[0] for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def get_recent_symbols(
|
||||
conn: sqlite3.Connection, market: str, limit: int = 30
|
||||
) -> list[str]:
|
||||
|
||||
188
src/main.py
188
src/main.py
@@ -32,7 +32,6 @@ from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, Risk
|
||||
from src.db import (
|
||||
get_latest_buy_trade,
|
||||
get_open_position,
|
||||
get_open_positions_by_market,
|
||||
get_recent_symbols,
|
||||
init_db,
|
||||
log_trade,
|
||||
@@ -107,6 +106,82 @@ def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
|
||||
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(
|
||||
*,
|
||||
action: str,
|
||||
@@ -114,16 +189,40 @@ def _determine_order_quantity(
|
||||
total_cash: float,
|
||||
candidate: ScanCandidate | None,
|
||||
settings: Settings | None,
|
||||
broker_held_qty: int = 0,
|
||||
playbook_allocation_pct: float | None = None,
|
||||
scenario_confidence: int = 80,
|
||||
) -> int:
|
||||
"""Determine order quantity using volatility-aware position sizing."""
|
||||
if action != "BUY":
|
||||
return 1
|
||||
"""Determine order quantity using volatility-aware position sizing.
|
||||
|
||||
Priority:
|
||||
1. playbook_allocation_pct (AI-specified) scaled by scenario_confidence
|
||||
2. Fallback: volatility-score-based allocation from scanner candidate
|
||||
"""
|
||||
if action == "SELL":
|
||||
return broker_held_qty
|
||||
if current_price <= 0 or total_cash <= 0:
|
||||
return 0
|
||||
|
||||
if settings is None or not settings.POSITION_SIZING_ENABLED:
|
||||
return 1
|
||||
|
||||
# Use AI-specified allocation_pct if available
|
||||
if playbook_allocation_pct is not None:
|
||||
# Confidence scaling: confidence 80 → 1.0x, confidence 95 → 1.19x
|
||||
confidence_scale = scenario_confidence / 80.0
|
||||
effective_pct = min(
|
||||
settings.POSITION_MAX_ALLOCATION_PCT,
|
||||
max(
|
||||
settings.POSITION_MIN_ALLOCATION_PCT,
|
||||
playbook_allocation_pct * confidence_scale,
|
||||
),
|
||||
)
|
||||
budget = total_cash * (effective_pct / 100.0)
|
||||
quantity = int(budget // current_price)
|
||||
return max(0, quantity)
|
||||
|
||||
# Fallback: volatility-score-based allocation
|
||||
target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE)
|
||||
observed_score = candidate.score if candidate else target_score
|
||||
observed_score = max(1.0, min(100.0, observed_score))
|
||||
@@ -388,8 +487,10 @@ async def trading_cycle(
|
||||
if entry_price > 0:
|
||||
loss_pct = (current_price - entry_price) / entry_price * 100
|
||||
stop_loss_threshold = -2.0
|
||||
take_profit_threshold = 3.0
|
||||
if stock_playbook and stock_playbook.scenarios:
|
||||
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
||||
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
|
||||
|
||||
if loss_pct <= stop_loss_threshold:
|
||||
decision = TradeDecision(
|
||||
@@ -407,6 +508,22 @@ async def trading_cycle(
|
||||
loss_pct,
|
||||
stop_loss_threshold,
|
||||
)
|
||||
elif loss_pct >= take_profit_threshold:
|
||||
decision = TradeDecision(
|
||||
action="SELL",
|
||||
confidence=90,
|
||||
rationale=(
|
||||
f"Take-profit triggered ({loss_pct:.2f}% >= "
|
||||
f"{take_profit_threshold:.2f}%)"
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
"Take-profit override for %s (%s): %.2f%% >= %.2f%%",
|
||||
stock_code,
|
||||
market.name,
|
||||
loss_pct,
|
||||
take_profit_threshold,
|
||||
)
|
||||
logger.info(
|
||||
"Decision for %s (%s): %s (confidence=%d)",
|
||||
stock_code,
|
||||
@@ -467,12 +584,23 @@ async def trading_cycle(
|
||||
trade_price = current_price
|
||||
trade_pnl = 0.0
|
||||
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
|
||||
)
|
||||
matched_scenario = match.matched_scenario
|
||||
quantity = _determine_order_quantity(
|
||||
action=decision.action,
|
||||
current_price=current_price,
|
||||
total_cash=total_cash,
|
||||
candidate=candidate,
|
||||
settings=settings,
|
||||
broker_held_qty=broker_held_qty,
|
||||
playbook_allocation_pct=matched_scenario.allocation_pct if matched_scenario else None,
|
||||
scenario_confidence=match.confidence,
|
||||
)
|
||||
if quantity <= 0:
|
||||
logger.info(
|
||||
@@ -892,12 +1020,20 @@ async def run_daily_session(
|
||||
trade_pnl = 0.0
|
||||
order_succeeded = True
|
||||
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(
|
||||
action=decision.action,
|
||||
current_price=stock_data["current_price"],
|
||||
total_cash=total_cash,
|
||||
candidate=candidate_map.get(stock_code),
|
||||
settings=settings,
|
||||
broker_held_qty=daily_broker_held_qty,
|
||||
)
|
||||
if quantity <= 0:
|
||||
logger.info(
|
||||
@@ -1864,22 +2000,38 @@ async def run(settings: Settings) -> None:
|
||||
except Exception as exc:
|
||||
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
|
||||
|
||||
# Get active stocks from scanner (dynamic, no static fallback)
|
||||
# Also include current holdings so stop-loss / take-profit
|
||||
# can trigger even when a position drops off the scanner.
|
||||
# Get active stocks from scanner (dynamic, no static fallback).
|
||||
# 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, [])
|
||||
held_codes = get_open_positions_by_market(db_conn, market.code)
|
||||
# Union: scanner candidates first, then holdings not already present.
|
||||
# dict.fromkeys preserves insertion order and removes duplicates.
|
||||
stock_codes = list(dict.fromkeys(scanner_codes + held_codes))
|
||||
if held_codes:
|
||||
new_held = [c for c in held_codes if c not in set(scanner_codes)]
|
||||
if new_held:
|
||||
logger.info(
|
||||
"Holdings added to loop for %s (not in scanner): %s",
|
||||
market.name,
|
||||
new_held,
|
||||
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:
|
||||
logger.debug("No active stocks for market %s", market.code)
|
||||
continue
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for database helper functions."""
|
||||
|
||||
from src.db import get_open_position, get_open_positions_by_market, init_db, log_trade
|
||||
from src.db import get_open_position, init_db, log_trade
|
||||
|
||||
|
||||
def test_get_open_position_returns_latest_buy() -> None:
|
||||
@@ -58,87 +58,3 @@ def test_get_open_position_returns_none_when_latest_is_sell() -> None:
|
||||
def test_get_open_position_returns_none_when_no_trades() -> None:
|
||||
conn = init_db(":memory:")
|
||||
assert get_open_position(conn, "AAPL", "US_NASDAQ") is None
|
||||
|
||||
|
||||
# --- get_open_positions_by_market tests ---
|
||||
|
||||
|
||||
def test_get_open_positions_by_market_returns_net_positive_stocks() -> None:
|
||||
"""Stocks with net BUY quantity > 0 are included."""
|
||||
conn = init_db(":memory:")
|
||||
log_trade(
|
||||
conn=conn, stock_code="005930", action="BUY", confidence=90,
|
||||
rationale="entry", quantity=5, price=70000.0, market="KR",
|
||||
exchange_code="KRX", decision_id="d1",
|
||||
)
|
||||
log_trade(
|
||||
conn=conn, stock_code="000660", action="BUY", confidence=85,
|
||||
rationale="entry", quantity=3, price=100000.0, market="KR",
|
||||
exchange_code="KRX", decision_id="d2",
|
||||
)
|
||||
|
||||
result = get_open_positions_by_market(conn, "KR")
|
||||
assert set(result) == {"005930", "000660"}
|
||||
|
||||
|
||||
def test_get_open_positions_by_market_excludes_fully_sold_stocks() -> None:
|
||||
"""Stocks where BUY qty == SELL qty are excluded (net qty = 0)."""
|
||||
conn = init_db(":memory:")
|
||||
log_trade(
|
||||
conn=conn, stock_code="005930", action="BUY", confidence=90,
|
||||
rationale="entry", quantity=3, price=70000.0, market="KR",
|
||||
exchange_code="KRX", decision_id="d1",
|
||||
)
|
||||
log_trade(
|
||||
conn=conn, stock_code="005930", action="SELL", confidence=95,
|
||||
rationale="exit", quantity=3, price=71000.0, market="KR",
|
||||
exchange_code="KRX", decision_id="d2",
|
||||
)
|
||||
|
||||
result = get_open_positions_by_market(conn, "KR")
|
||||
assert "005930" not in result
|
||||
|
||||
|
||||
def test_get_open_positions_by_market_includes_partially_sold_stocks() -> None:
|
||||
"""Stocks with partial SELL (net qty > 0) are still included."""
|
||||
conn = init_db(":memory:")
|
||||
log_trade(
|
||||
conn=conn, stock_code="005930", action="BUY", confidence=90,
|
||||
rationale="entry", quantity=5, price=70000.0, market="KR",
|
||||
exchange_code="KRX", decision_id="d1",
|
||||
)
|
||||
log_trade(
|
||||
conn=conn, stock_code="005930", action="SELL", confidence=95,
|
||||
rationale="partial exit", quantity=2, price=71000.0, market="KR",
|
||||
exchange_code="KRX", decision_id="d2",
|
||||
)
|
||||
|
||||
result = get_open_positions_by_market(conn, "KR")
|
||||
assert "005930" in result
|
||||
|
||||
|
||||
def test_get_open_positions_by_market_is_market_scoped() -> None:
|
||||
"""Only stocks from the specified market are returned."""
|
||||
conn = init_db(":memory:")
|
||||
log_trade(
|
||||
conn=conn, stock_code="005930", action="BUY", confidence=90,
|
||||
rationale="entry", quantity=3, price=70000.0, market="KR",
|
||||
exchange_code="KRX", decision_id="d1",
|
||||
)
|
||||
log_trade(
|
||||
conn=conn, stock_code="AAPL", action="BUY", confidence=85,
|
||||
rationale="entry", quantity=2, price=200.0, market="NASD",
|
||||
exchange_code="NAS", decision_id="d2",
|
||||
)
|
||||
|
||||
kr_result = get_open_positions_by_market(conn, "KR")
|
||||
nasd_result = get_open_positions_by_market(conn, "NASD")
|
||||
|
||||
assert kr_result == ["005930"]
|
||||
assert nasd_result == ["AAPL"]
|
||||
|
||||
|
||||
def test_get_open_positions_by_market_returns_empty_when_no_trades() -> None:
|
||||
"""Empty list returned when no trades exist for the market."""
|
||||
conn = init_db(":memory:")
|
||||
assert get_open_positions_by_market(conn, "KR") == []
|
||||
|
||||
@@ -14,6 +14,9 @@ from src.evolution.scorecard import DailyScorecard
|
||||
from src.logging.decision_logger import DecisionLogger
|
||||
from src.main import (
|
||||
_apply_dashboard_flag,
|
||||
_determine_order_quantity,
|
||||
_extract_held_codes_from_balance,
|
||||
_extract_held_qty_from_balance,
|
||||
_handle_market_close,
|
||||
_run_context_scheduler,
|
||||
_run_evolution_loop,
|
||||
@@ -68,6 +71,219 @@ 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
|
||||
|
||||
def test_determine_order_quantity_uses_playbook_allocation_pct(self) -> None:
|
||||
"""playbook_allocation_pct should take priority over volatility-based sizing."""
|
||||
settings = MagicMock(spec=Settings)
|
||||
settings.POSITION_SIZING_ENABLED = True
|
||||
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
|
||||
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
|
||||
# playbook says 20%, confidence 80 → scale=1.0 → 20%
|
||||
# 1,000,000 * 20% = 200,000 // 50,000 price = 4 shares
|
||||
result = _determine_order_quantity(
|
||||
action="BUY",
|
||||
current_price=50000.0,
|
||||
total_cash=1000000.0,
|
||||
candidate=None,
|
||||
settings=settings,
|
||||
playbook_allocation_pct=20.0,
|
||||
scenario_confidence=80,
|
||||
)
|
||||
assert result == 4
|
||||
|
||||
def test_determine_order_quantity_confidence_scales_allocation(self) -> None:
|
||||
"""Higher confidence should produce a larger allocation (up to max)."""
|
||||
settings = MagicMock(spec=Settings)
|
||||
settings.POSITION_SIZING_ENABLED = True
|
||||
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
|
||||
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
|
||||
# confidence 96 → scale=1.2 → 10% * 1.2 = 12%
|
||||
# 1,000,000 * 12% = 120,000 // 50,000 price = 2 shares
|
||||
result = _determine_order_quantity(
|
||||
action="BUY",
|
||||
current_price=50000.0,
|
||||
total_cash=1000000.0,
|
||||
candidate=None,
|
||||
settings=settings,
|
||||
playbook_allocation_pct=10.0,
|
||||
scenario_confidence=96,
|
||||
)
|
||||
# scale = 96/80 = 1.2 → effective_pct = 12.0
|
||||
# budget = 1_000_000 * 0.12 = 120_000 → qty = 120_000 // 50_000 = 2
|
||||
assert result == 2
|
||||
|
||||
def test_determine_order_quantity_confidence_clamped_to_max(self) -> None:
|
||||
"""Confidence scaling should not exceed POSITION_MAX_ALLOCATION_PCT."""
|
||||
settings = MagicMock(spec=Settings)
|
||||
settings.POSITION_SIZING_ENABLED = True
|
||||
settings.POSITION_MAX_ALLOCATION_PCT = 15.0
|
||||
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
|
||||
# playbook 20% * scale 1.5 = 30% → clamped to 15%
|
||||
# 1,000,000 * 15% = 150,000 // 50,000 price = 3 shares
|
||||
result = _determine_order_quantity(
|
||||
action="BUY",
|
||||
current_price=50000.0,
|
||||
total_cash=1000000.0,
|
||||
candidate=None,
|
||||
settings=settings,
|
||||
playbook_allocation_pct=20.0,
|
||||
scenario_confidence=120, # extreme → scale = 1.5
|
||||
)
|
||||
assert result == 3
|
||||
|
||||
def test_determine_order_quantity_fallback_when_no_playbook(self) -> None:
|
||||
"""Without playbook_allocation_pct, falls back to volatility-based sizing."""
|
||||
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
|
||||
# Same as test_buy_with_position_sizing_calculates_correctly (no playbook)
|
||||
result = _determine_order_quantity(
|
||||
action="BUY",
|
||||
current_price=50000.0,
|
||||
total_cash=1000000.0,
|
||||
candidate=None,
|
||||
settings=settings,
|
||||
playbook_allocation_pct=None, # explicit None → fallback
|
||||
)
|
||||
assert result == 2
|
||||
|
||||
|
||||
class TestSafeFloat:
|
||||
"""Test safe_float() helper function."""
|
||||
|
||||
@@ -1240,13 +1456,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_balance = AsyncMock(
|
||||
return_value={
|
||||
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
|
||||
"output2": [
|
||||
{
|
||||
"tot_evlu_amt": "100000",
|
||||
"dnca_tot_amt": "10000",
|
||||
"pchs_amt_smtl_amt": "90000",
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
)
|
||||
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||
@@ -1330,13 +1547,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_balance = AsyncMock(
|
||||
return_value={
|
||||
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
|
||||
"output2": [
|
||||
{
|
||||
"tot_evlu_amt": "100000",
|
||||
"dnca_tot_amt": "10000",
|
||||
"pchs_amt_smtl_amt": "90000",
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
)
|
||||
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||
@@ -1396,6 +1614,318 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
|
||||
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None:
|
||||
"""HOLD decision should be overridden to SELL when take-profit threshold is reached."""
|
||||
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,
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
decision_id=buy_decision_id,
|
||||
)
|
||||
|
||||
broker = MagicMock()
|
||||
# Current price 106.0 → +6% gain, above take_profit_pct=3.0
|
||||
broker.get_current_price = AsyncMock(return_value=(106.0, 6.0, 0.0))
|
||||
broker.get_balance = AsyncMock(
|
||||
return_value={
|
||||
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
|
||||
"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,
|
||||
take_profit_pct=3.0,
|
||||
rationale="take profit 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()
|
||||
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> None:
|
||||
"""HOLD should remain HOLD when P&L is within stop-loss and take-profit bounds."""
|
||||
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,
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
decision_id=buy_decision_id,
|
||||
)
|
||||
|
||||
broker = MagicMock()
|
||||
# Current price 101.0 → +1% gain, within [-2%, +3%] range
|
||||
broker.get_current_price = AsyncMock(return_value=(101.0, 1.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"})
|
||||
|
||||
scenario = StockScenario(
|
||||
condition=StockCondition(rsi_below=30),
|
||||
action=ScenarioAction.BUY,
|
||||
confidence=88,
|
||||
stop_loss_pct=-2.0,
|
||||
take_profit_pct=3.0,
|
||||
rationale="within range 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_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
|
||||
async def test_handle_market_close_runs_daily_review_flow() -> None:
|
||||
"""Market close should aggregate, create scorecard, lessons, and notify."""
|
||||
|
||||
Reference in New Issue
Block a user