feat: include current holdings in realtime trading loop for exit evaluation (#165)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
스캐너 후보 종목뿐 아니라 현재 보유 종목도 매 사이클마다 평가해 stop-loss / take-profit이 실제로 동작하도록 개선. - db.py: get_open_positions_by_market() 추가 - net BUY - SELL 집계 쿼리로 실제 보유 종목 코드 목록 반환 - 단순 "최신 레코드 = BUY" 방식보다 안전 (이중 매도 방지) - main.py: 실시간 루프에서 스캐너 후보 + 보유 종목을 union으로 구성 - dict.fromkeys로 순서 유지하며 중복 제거 - 스캐너에 없는 보유 종목은 로그로 명시 - 보유 종목은 Playbook 없으면 HOLD → stop-loss/take-profit 체크 - tests/test_db.py: get_open_positions_by_market 테스트 5개 추가 - net 양수 종목 포함, 전량 매도 제외, 부분 매도 포함 - 마켓 범위 격리, 거래 없을 때 빈 리스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
22
src/db.py
22
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]:
|
||||
|
||||
17
src/main.py
17
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
|
||||
|
||||
Reference in New Issue
Block a user