Merge branch 'dev' into main — 분할매도 잔량·ActiveTrade 정리

This commit is contained in:
2026-09-23 10:36:02 +09:00
5 changed files with 64 additions and 0 deletions

View File

@@ -1457,6 +1457,25 @@ async def sell_stock(
await db.commit()
# ActiveTrade 정리 — 누락 시 유령 active 행이 남아, 같은 종목을 다시 매수했을 때
# 옛 remaining_pct(예: 0.50)가 살아나 1차 익절이 건너뛰어진다.
# (2026-09-23 확인: 삼아알미늄·LG 등 수동 청산 종목이 active 상태로 잔존)
from app.services.db_worker import (
enqueue_close_active_trade as _enq_close_at,
enqueue_active_trade_upsert as _enq_at_upsert,
)
try:
if quantity >= original_holding_qty:
await _enq_close_at(stock_code, strategy_name=None)
else:
await _enq_at_upsert({
"stock_code": stock_code,
"quantity": original_holding_qty - quantity,
"current_price": float(executed_price),
})
except Exception as _at_err:
logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_at_err}")
# ✅ 인메모리 캐시 즉시 반영 (KIS 재동기화 전에도 화면에서 즉시 제거)
if quantity >= original_holding_qty:
portfolio_cache.holdings = [

View File

@@ -349,6 +349,9 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
_at_data = {
"stock_code": stock_code,
"remaining_pct": new_remaining,
# 잔량도 함께 갱신 — 누락 시 재기동 복원이 원래 수량으로 되살아나
# 보유량보다 많이 매도한다(2026-09-23 DT LG씨엔에스 사례와 동일 패턴)
"quantity": quantity - sell_qty,
"highest_since_entry": latest_price if latest_price > (trailing_high or 0) else trailing_high,
}
if active_trade is None:

View File

@@ -1256,6 +1256,7 @@ async def _execute_sell(
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"remaining_pct": new_remaining,
"quantity": max(0, total_qty - quantity), # 잔량 갱신(과다매도 방지)
"highest_since_entry": current_price,
})
except Exception as _at_err:

View File

@@ -399,6 +399,9 @@ async def _monitor_us_exits(exchange_rate: float = _DEFAULT_FX_RATE) -> List[Dic
"stock_code": ticker,
"strategy_name": "us_mean_reversion",
"remaining_pct": round(new_remaining, 2),
# 잔량 갱신 — 누락 시 다음 사이클이 원래 수량 기준으로 매도량을
# 계산해 보유량보다 많이 판다(total_qty = trade.quantity)
"quantity": max(0, total_qty - sell_qty),
"current_price": current_price,
})

View File

@@ -98,3 +98,41 @@ async def test_dt_partial_exit_updates_active_trade_quantity():
at = [u for u in upserts if u.get("stock_code") == "064400"]
assert at and at[-1]["quantity"] == 4, f"ActiveTrade 잔량 갱신 누락: {upserts}"
day_trading_state.positions.clear()
@pytest.mark.asyncio
async def test_swing_partial_exit_persists_remaining_quantity():
"""스윙 분할매도도 ActiveTrade.quantity를 갱신해야 한다(DT와 동일 패턴)."""
import inspect
from app.services.swing import exit_service as ses
src = inspect.getsource(ses)
block = src[src.index("분할 매도 후 remaining_pct 갱신"):]
block = block[:block.index("await enqueue_active_trade_upsert(_at_data)")]
assert '"quantity": quantity - sell_qty' in block, "스윙 분할매도에 잔량 갱신 누락"
def test_us_partial_exit_persists_remaining_quantity():
"""미국 스윙도 동일 — total_qty = trade.quantity 를 읽으므로 갱신이 없으면 과다매도."""
import inspect
from app.services import us_market_scheduler as us
src = inspect.getsource(us)
i = src.index('"strategy_name": "us_mean_reversion",\n "remaining_pct"')
block = src[i:i + 500]
assert '"quantity": max(0, total_qty - sell_qty)' in block, "미국 분할매도에 잔량 갱신 누락"
def test_domestic_executor_partial_exit_persists_quantity():
import inspect
from app.services import trade_executor as te
src = inspect.getsource(te)
i = src.index('"remaining_pct": new_remaining,\n "quantity"')
assert 'max(0, total_qty - quantity)' in src[i:i + 200]
def test_manual_sell_closes_active_trade():
"""수동 전량 매도는 ActiveTrade를 closed 로 만들어야 유령 행이 남지 않는다."""
import inspect
from app.api.endpoints import portfolio as pf
src = inspect.getsource(pf.sell_stock)
assert "_enq_close_at(stock_code" in src
assert '"quantity": original_holding_qty - quantity' in src