diff --git a/src/db.py b/src/db.py index d798a45..469757f 100644 --- a/src/db.py +++ b/src/db.py @@ -237,6 +237,28 @@ 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]: diff --git a/src/main.py b/src/main.py index ca7c11e..8a63a3a 100644 --- a/src/main.py +++ b/src/main.py @@ -32,6 +32,7 @@ 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, @@ -1864,7 +1865,21 @@ async def run(settings: Settings) -> None: logger.error("Smart Scanner failed for %s: %s", market.name, exc) # Get active stocks from scanner (dynamic, no static fallback) - stock_codes = active_stocks.get(market.code, []) + # Also include current holdings so stop-loss / take-profit + # can trigger even when a position drops off the scanner. + 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, + ) if not stock_codes: logger.debug("No active stocks for market %s", market.code) continue diff --git a/tests/test_db.py b/tests/test_db.py index fe956eb..67253e8 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,6 +1,6 @@ """Tests for database helper functions.""" -from src.db import get_open_position, init_db, log_trade +from src.db import get_open_position, get_open_positions_by_market, init_db, log_trade def test_get_open_position_returns_latest_buy() -> None: @@ -58,3 +58,87 @@ 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") == []