feat: include current holdings in realtime trading loop for exit evaluation (#165) #168

Closed
agentson wants to merge 1 commits from feature/issue-165-holdings-in-trading-loop into main
3 changed files with 123 additions and 2 deletions

View File

@@ -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]:

View File

@@ -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

View File

@@ -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") == []