diff --git a/app/routers/trades.py b/app/routers/trades.py index 01cb3ba..e1b1e8c 100644 --- a/app/routers/trades.py +++ b/app/routers/trades.py @@ -35,6 +35,7 @@ async def list_trade_setups( min_confidence=min_confidence, recommended_action=recommended_action, live_recommendation=True, + exclude_open_trade_tickers=True, ) data = [] diff --git a/app/services/alert_service.py b/app/services/alert_service.py index 5f5c078..1a4ccbf 100644 --- a/app/services/alert_service.py +++ b/app/services/alert_service.py @@ -286,7 +286,11 @@ async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]: async def _qualified_setups(db: AsyncSession) -> list[dict]: # live_recommendation: gate and format on current score/sentiment context, # not the values frozen into the setup at scan time. - setups = await get_trade_setups(db, live_recommendation=True) + setups = await get_trade_setups( + db, + live_recommendation=True, + exclude_open_trade_tickers=True, + ) config = await get_activation_config(db) return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)] diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index 25168dc..5944801 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.exceptions import NotFoundError from app.models.fundamental import FundamentalData from app.models.ohlcv import OHLCVRecord +from app.models.paper_trade import PaperTrade from app.models.score import CompositeScore, DimensionScore from app.models.sentiment import SentimentScore from app.models.signal_context_snapshot import SignalContextSnapshot @@ -599,6 +600,7 @@ async def get_trade_setups( recommended_action: str | None = None, symbol: str | None = None, live_recommendation: bool = False, + exclude_open_trade_tickers: bool = False, ) -> list[dict]: """Get latest stored trade setups, optionally filtered.""" stmt = ( @@ -615,6 +617,15 @@ async def get_trade_setups( stmt = stmt.where(TradeSetup.confidence_score >= min_confidence) if recommended_action is not None and not live_recommendation: stmt = stmt.where(TradeSetup.recommended_action == recommended_action) + if exclude_open_trade_tickers: + open_trade_result = await db.execute( + select(PaperTrade.ticker_id) + .where(PaperTrade.status == "open") + .distinct() + ) + open_ticker_ids = {ticker_id for ticker_id, in open_trade_result.all()} + if open_ticker_ids: + stmt = stmt.where(~TradeSetup.ticker_id.in_(open_ticker_ids)) stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc()) diff --git a/tests/unit/test_rr_scanner_preservation.py b/tests/unit/test_rr_scanner_preservation.py index 26cdba7..5967f38 100644 --- a/tests/unit/test_rr_scanner_preservation.py +++ b/tests/unit/test_rr_scanner_preservation.py @@ -21,12 +21,14 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.ohlcv import OHLCVRecord +from app.models.paper_trade import PaperTrade from app.models.signal_context_snapshot import SignalContextSnapshot from app.models.sr_level import SRLevel from app.models.ticker import Ticker from app.models.trade_setup import TradeSetup from app.models.score import CompositeScore, DimensionScore from app.models.sentiment import SentimentScore +from app.models.user import User from app.services.rr_scanner_service import scan_ticker, get_trade_setups @@ -442,6 +444,100 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async ) +@pytest.mark.asyncio +async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades( + db_session: AsyncSession, +): + now = datetime.now(timezone.utc) + + if await db_session.get(User, 1) is None: + db_session.add( + User(id=1, username="u", password_hash="x", role="user", has_access=True) + ) + await db_session.flush() + + open_ticker = Ticker(symbol="OPENQ") + closed_ticker = Ticker(symbol="CLOSEDQ") + free_ticker = Ticker(symbol="FREEQ") + db_session.add_all([open_ticker, closed_ticker, free_ticker]) + await db_session.flush() + + db_session.add_all([ + TradeSetup( + ticker_id=open_ticker.id, + direction="long", + entry_price=100.0, + stop_loss=95.0, + target=112.0, + rr_ratio=2.4, + composite_score=80.0, + detected_at=now, + ), + TradeSetup( + ticker_id=closed_ticker.id, + direction="long", + entry_price=100.0, + stop_loss=95.0, + target=113.0, + rr_ratio=2.6, + composite_score=70.0, + detected_at=now, + ), + TradeSetup( + ticker_id=free_ticker.id, + direction="long", + entry_price=100.0, + stop_loss=95.0, + target=114.0, + rr_ratio=2.8, + composite_score=60.0, + detected_at=now, + ), + PaperTrade( + user_id=1, + ticker_id=open_ticker.id, + direction="long", + entry_price=100.0, + shares=10.0, + stop_loss=95.0, + target=112.0, + status="open", + opened_at=now, + ), + PaperTrade( + user_id=1, + ticker_id=closed_ticker.id, + direction="long", + entry_price=100.0, + shares=10.0, + stop_loss=95.0, + target=113.0, + status="closed", + opened_at=now - timedelta(days=2), + close_price=113.0, + closed_at=now - timedelta(days=1), + close_reason="target", + ), + ]) + await db_session.flush() + + default_rows = await get_trade_setups(db_session) + assert {"OPENQ", "CLOSEDQ", "FREEQ"}.issubset( + {row["symbol"] for row in default_rows} + ) + + discovery_rows = await get_trade_setups( + db_session, + exclude_open_trade_tickers=True, + ) + discovery_symbols = {row["symbol"] for row in discovery_rows} + assert "OPENQ" not in discovery_symbols + assert {"CLOSEDQ", "FREEQ"}.issubset(discovery_symbols) + + ticker_rows = await get_trade_setups(db_session, symbol="OPENQ") + assert [row["symbol"] for row in ticker_rows] == ["OPENQ"] + + async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup: """Stored setup frozen at scan time (conf 82, neutral) vs. current context (bullish sentiment, composite 96) that yields live confidence 97."""