diff --git a/backend/app/api/screening.py b/backend/app/api/screening.py index 7d0044e..be7c453 100644 --- a/backend/app/api/screening.py +++ b/backend/app/api/screening.py @@ -1,5 +1,6 @@ -from datetime import date -from typing import List, Optional +from datetime import date, timedelta +from decimal import Decimal +from typing import Any, List, Optional, cast from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session @@ -7,13 +8,122 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.api.deps import CurrentUser from app.models.screening import ScreeningSignal, AutoOrder +from app.models.stock import MarketIndex, Price, Sector, Stock from app.schemas.screening import ( ScreeningSignalResponse, AutoOrderResponse, WatchlistItem, ScreeningSummary, + SectorStrongSignalResponse, RecentSectorCandidateResponse, ) router = APIRouter(tags=["screening"]) +TRADING_VALUE_THRESHOLD = Decimal("200000000000") +KOSPI_INDEX_CODE = "1001" + + +def _calculate_signal_strength(signal: ScreeningSignal) -> Decimal: + trading_value = Decimal(cast(Optional[int], signal.trading_value) or 0) + daily_return = Decimal(str(cast(Optional[Decimal], signal.daily_return) or 0)) + limit_up_multiplier = Decimal("2") if cast(bool, signal.is_limit_up) else Decimal("1") + return (trading_value / TRADING_VALUE_THRESHOLD) * limit_up_multiplier * (Decimal("1") + max(daily_return, Decimal("0"))) + + +def _price_trading_value(row: Price) -> int: + trading_value = cast(Optional[int], row.trading_value) + if trading_value is not None: + return trading_value + return int(cast(Decimal, row.close) * cast(int, row.volume)) + + +def _kospi_return(db: Session, start_date: date, end_date: date) -> Decimal: + rows = ( + db.query(MarketIndex) + .filter( + MarketIndex.code == KOSPI_INDEX_CODE, + MarketIndex.date >= start_date, + MarketIndex.date <= end_date, + ) + .order_by(MarketIndex.date) + .all() + ) + if len(rows) < 2: + return Decimal("0") + + first_close = cast(Decimal, rows[0].close) + last_close = cast(Decimal, rows[-1].close) + if first_close <= 0: + return Decimal("0") + return (last_close / first_close) - Decimal("1") + + +def _score_recent_candidate( + *, + ticker: str, + name: Optional[str], + sector: str, + rows: list[Price], + kospi_return: Decimal, +) -> Optional[dict[str, Any]]: + if len(rows) < 2: + return None + + ordered = sorted(rows, key=lambda row: cast(date, row.date)) + first = ordered[0] + latest = ordered[-1] + previous = ordered[-2] + + first_close = cast(Decimal, first.close) + latest_close = cast(Decimal, latest.close) + previous_close = cast(Decimal, previous.close) + if first_close <= 0 or previous_close <= 0: + return None + + daily_return = (latest_close / previous_close) - Decimal("1") + one_month_return = (latest_close / first_close) - Decimal("1") + relative_strength = one_month_return - kospi_return + + trading_value = _price_trading_value(latest) + recent_values = [_price_trading_value(row) for row in ordered[-20:]] + avg_trading_value_20 = int(sum(recent_values) / len(recent_values)) if recent_values else 0 + trading_value_ratio = ( + Decimal(trading_value) / Decimal(avg_trading_value_20) + if avg_trading_value_20 > 0 + else Decimal("0") + ) + + recent_closes = [cast(Decimal, row.close) for row in ordered[-5:]] + ma5 = sum(recent_closes) / Decimal(len(recent_closes)) + ma5_support = latest_close >= ma5 + breakout = latest_close > cast(Decimal, previous.high) + + score = ( + max(relative_strength, Decimal("0")) * Decimal("100") + + max(daily_return, Decimal("0")) * Decimal("50") + + min(trading_value_ratio, Decimal("5")) * Decimal("10") + + (Decimal(trading_value) / TRADING_VALUE_THRESHOLD) * Decimal("20") + + (Decimal("15") if breakout else Decimal("0")) + + (Decimal("10") if ma5_support else Decimal("0")) + ) + + return { + "ticker": ticker, + "name": name, + "sector": sector, + "latest_date": cast(date, latest.date), + "close_price": latest_close, + "daily_return": daily_return, + "one_month_return": one_month_return, + "relative_strength": relative_strength, + "trading_value": trading_value, + "avg_trading_value_20": avg_trading_value_20, + "trading_value_ratio": trading_value_ratio, + "ma5_support": ma5_support, + "breakout": breakout, + "score": score, + "is_stronger_than_source": False, + } + + @router.get("/api/screening/today", response_model=List[ScreeningSignalResponse]) async def get_today_screening( current_user: CurrentUser, @@ -70,6 +180,179 @@ async def get_watchlist( return signals +@router.get("/api/screening/sector-strongest", response_model=List[SectorStrongSignalResponse]) +async def get_sector_strongest_signals( + current_user: CurrentUser, + db: Session = Depends(get_db), + target_date: Optional[date] = Query(None), +): + """섹터별로 감지된 스크리닝 신호 중 가장 강한 매수 후보를 반환한다.""" + screen_date = target_date or date.today() + signals = ( + db.query(ScreeningSignal) + .filter(ScreeningSignal.screen_date == screen_date) + .all() + ) + + sector_groups: dict[str, list[ScreeningSignal]] = {} + for signal in signals: + sector = cast(Optional[str], signal.sector) or "미분류" + sector_groups.setdefault(sector, []).append(signal) + + strongest: list[dict[str, Any]] = [] + for sector, sector_signals in sector_groups.items(): + ranked = sorted( + sector_signals, + key=lambda s: ( + _calculate_signal_strength(s), + Decimal(str(cast(Optional[Decimal], s.daily_return) or 0)), + Decimal(cast(Optional[int], s.trading_value) or 0), + cast(str, s.ticker), + ), + reverse=True, + ) + leader = ranked[0] + strongest.append({ + "sector": sector, + "signal_count": len(sector_signals), + "id": cast(int, leader.id), + "screen_date": cast(date, leader.screen_date), + "ticker": cast(str, leader.ticker), + "name": cast(Optional[str], leader.name), + "trading_value": cast(Optional[int], leader.trading_value), + "is_limit_up": cast(bool, leader.is_limit_up), + "daily_return": cast(Optional[Decimal], leader.daily_return), + "signal_strength": _calculate_signal_strength(leader), + "status": cast(str, leader.status), + }) + + strongest.sort(key=lambda item: (item["signal_strength"], item["sector"]), reverse=True) + return strongest + + +@router.get("/api/screening/recent-sector-candidates", response_model=List[RecentSectorCandidateResponse]) +async def get_recent_sector_candidates( + current_user: CurrentUser, + db: Session = Depends(get_db), + as_of: Optional[date] = Query(None), + window_days: int = Query(30, ge=5, le=60), + limit_per_signal: int = Query(5, ge=1, le=20), +): + """최근 KJB 매수 신호 섹터에서 더 강한 후보 종목을 찾는다.""" + latest_price = ( + db.query(Price) + .order_by(Price.date.desc()) + .first() + ) + end_date = as_of or (cast(date, latest_price.date) if latest_price else date.today()) + start_date = end_date - timedelta(days=window_days) + kospi_return = _kospi_return(db, start_date, end_date) + + signals = ( + db.query(ScreeningSignal) + .filter( + ScreeningSignal.screen_date >= start_date, + ScreeningSignal.screen_date <= end_date, + ) + .order_by(ScreeningSignal.screen_date.desc(), ScreeningSignal.ticker) + .all() + ) + + responses: list[dict[str, Any]] = [] + for signal in signals: + signal_id = cast(int, signal.id) + source_ticker = cast(str, signal.ticker) + sector = cast(Optional[str], signal.sector) + if not sector: + continue + + sector_rows = ( + db.query(Sector) + .filter(Sector.sector_name == sector) + .all() + ) + sector_tickers = sorted({cast(str, row.ticker) for row in sector_rows}) + if source_ticker not in sector_tickers: + sector_tickers.append(source_ticker) + + stock_rows = ( + db.query(Stock) + .filter(Stock.ticker.in_(sector_tickers), Stock.market == "KOSPI") + .all() + ) + stock_names = {cast(str, stock.ticker): cast(str, stock.name) for stock in stock_rows} + candidate_tickers = sorted(stock_names) + if source_ticker not in candidate_tickers: + candidate_tickers.append(source_ticker) + stock_names[source_ticker] = cast(Optional[str], signal.name) or source_ticker + + price_rows = ( + db.query(Price) + .filter( + Price.ticker.in_(candidate_tickers), + Price.date >= start_date, + Price.date <= end_date, + ) + .order_by(Price.ticker, Price.date) + .all() + ) + rows_by_ticker: dict[str, list[Price]] = {} + for row in price_rows: + rows_by_ticker.setdefault(cast(str, row.ticker), []).append(row) + + source_metrics = _score_recent_candidate( + ticker=source_ticker, + name=stock_names.get(source_ticker, cast(Optional[str], signal.name)), + sector=sector, + rows=rows_by_ticker.get(source_ticker, []), + kospi_return=kospi_return, + ) + if source_metrics is None: + source_metrics = { + "score": _calculate_signal_strength(signal), + "one_month_return": Decimal("0"), + "relative_strength": Decimal("0"), + } + source_score = cast(Decimal, source_metrics["score"]) + + candidates: list[dict[str, Any]] = [] + for ticker in candidate_tickers: + if ticker == source_ticker: + continue + metrics = _score_recent_candidate( + ticker=ticker, + name=stock_names.get(ticker), + sector=sector, + rows=rows_by_ticker.get(ticker, []), + kospi_return=kospi_return, + ) + if metrics is None: + continue + is_stronger = cast(Decimal, metrics["score"]) > source_score + if not is_stronger: + continue + metrics["is_stronger_than_source"] = True + candidates.append(metrics) + + candidates.sort(key=lambda item: cast(Decimal, item["score"]), reverse=True) + limited_candidates = candidates[:limit_per_signal] + responses.append({ + "signal_id": signal_id, + "screen_date": cast(date, signal.screen_date), + "ticker": source_ticker, + "name": cast(Optional[str], signal.name), + "sector": sector, + "source_score": source_score, + "source_one_month_return": cast(Decimal, source_metrics["one_month_return"]), + "source_relative_strength": cast(Decimal, source_metrics["relative_strength"]), + "stronger_count": len(candidates), + "candidates": limited_candidates, + }) + + responses.sort(key=lambda item: (item["stronger_count"], item["screen_date"]), reverse=True) + return responses + + @router.post("/api/screening/execute", response_model=dict) async def execute_screening( current_user: CurrentUser, @@ -113,9 +396,13 @@ async def execute_screening( results = [] for signal in watching: - if signal.ticker in held_tickers: + signal_id = cast(int, signal.id) + signal_ticker = cast(str, signal.ticker) + signal_screen_date = cast(date, signal.screen_date) + + if signal_ticker in held_tickers: results.append({ - "ticker": signal.ticker, + "ticker": signal_ticker, "success": False, "status": "skipped", "message": "Already held", @@ -125,18 +412,18 @@ async def execute_screening( rows = ( db.query(Price) .filter( - Price.ticker == signal.ticker, - Price.date >= (signal.screen_date - timedelta(days=20)), + Price.ticker == signal_ticker, + Price.date >= (signal_screen_date - timedelta(days=20)), Price.date <= latest_date, ) .order_by(Price.date) .all() ) price_df = _price_rows_to_frame(rows) - entry = signal_gen.check_entry(signal.ticker, price_df, signal.screen_date) + entry = signal_gen.check_entry(signal_ticker, price_df, signal_screen_date) if not entry: results.append({ - "ticker": signal.ticker, + "ticker": signal_ticker, "success": False, "status": "waiting", "message": "Entry condition not met", @@ -155,7 +442,7 @@ async def execute_screening( qty = min(qty, max_qty_by_order) if qty <= 0: results.append({ - "ticker": signal.ticker, + "ticker": signal_ticker, "success": False, "status": "skipped", "entry_price": entry_price, @@ -165,13 +452,14 @@ async def execute_screening( }) continue - signal.entry_date = entry["entry_date"].date() if hasattr(entry["entry_date"], "date") else entry["entry_date"] - signal.entry_price = entry_price + entry_date = entry["entry_date"].date() if hasattr(entry["entry_date"], "date") else entry["entry_date"] + setattr(signal, "entry_date", entry_date) + setattr(signal, "entry_price", Decimal(str(entry_price))) if not execute_orders: - signal.status = "watching" + setattr(signal, "status", "watching") results.append({ - "ticker": signal.ticker, + "ticker": signal_ticker, "success": True, "status": "planned", "entry_price": entry_price, @@ -182,23 +470,23 @@ async def execute_screening( }) continue - order = executor.place_buy_order(ticker=signal.ticker, qty=qty, price=int(entry_price)) + order = executor.place_buy_order(ticker=signal_ticker, qty=qty, price=int(entry_price)) auto_order = AutoOrder( order_date=datetime.now(), - ticker=signal.ticker, + ticker=signal_ticker, order_type="buy", qty=qty, price=entry_price, order_no=order.order_no, status="filled" if order.success else "rejected", - screening_signal_id=signal.id, + screening_signal_id=signal_id, ) db.add(auto_order) if order.success: - signal.status = "entered" - held_tickers.add(signal.ticker) + setattr(signal, "status", "entered") + held_tickers.add(signal_ticker) results.append({ - "ticker": signal.ticker, + "ticker": signal_ticker, "success": order.success, "status": "ordered" if order.success else "rejected", "order_no": order.order_no, diff --git a/backend/app/schemas/screening.py b/backend/app/schemas/screening.py index 37bfbc5..9782ded 100644 --- a/backend/app/schemas/screening.py +++ b/backend/app/schemas/screening.py @@ -62,6 +62,51 @@ class WatchlistItem(BaseModel): from_attributes = True +class SectorStrongSignalResponse(BaseModel): + sector: str + signal_count: int + id: int + screen_date: date + ticker: str + name: Optional[str] = None + trading_value: Optional[int] = None + is_limit_up: bool = False + daily_return: Optional[FloatDecimal] = None + signal_strength: FloatDecimal + status: str + + +class SectorCandidateSignal(BaseModel): + ticker: str + name: Optional[str] = None + sector: str + latest_date: date + close_price: FloatDecimal + daily_return: FloatDecimal + one_month_return: FloatDecimal + relative_strength: FloatDecimal + trading_value: int + avg_trading_value_20: int + trading_value_ratio: FloatDecimal + ma5_support: bool + breakout: bool + score: FloatDecimal + is_stronger_than_source: bool + + +class RecentSectorCandidateResponse(BaseModel): + signal_id: int + screen_date: date + ticker: str + name: Optional[str] = None + sector: str + source_score: FloatDecimal + source_one_month_return: FloatDecimal + source_relative_strength: FloatDecimal + stronger_count: int + candidates: List[SectorCandidateSignal] + + class ScreeningSummary(BaseModel): date: date market_state: str diff --git a/backend/app/services/collectors/etf_price_collector.py b/backend/app/services/collectors/etf_price_collector.py index 8fdaa7b..50c9311 100644 --- a/backend/app/services/collectors/etf_price_collector.py +++ b/backend/app/services/collectors/etf_price_collector.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from json import JSONDecodeError import pandas as pd +from sqlalchemy import func from sqlalchemy.orm import Session from sqlalchemy.dialects.postgresql import insert @@ -235,6 +236,16 @@ class ETFPriceCollector(BaseCollector): return total_records + def _has_records_for_end_date(self) -> bool: + """Return True when the requested end date has any collected ETF price rows.""" + end_date = datetime.strptime(self.end_date, "%Y%m%d").date() + return ( + self.db.query(func.count(ETFPrice.ticker)) + .filter(ETFPrice.date == end_date) + .scalar() + > 0 + ) + def collect(self) -> int: """Collect price data for all ETFs.""" client = get_krx_client() @@ -243,7 +254,12 @@ class ETFPriceCollector(BaseCollector): logger.info("Collecting ETF prices via KRX Open API") total = self._collect_openapi() logger.info(f"Collected {total} ETF price records via Open API") - return total + if total > 0 and self._has_records_for_end_date(): + return total + logger.warning( + "KRX Open API did not populate end_date %s ETF price rows, falling back to pykrx", + self.end_date, + ) except Exception as e: logger.warning(f"KRX Open API failed, falling back to pykrx: {e}") diff --git a/backend/app/services/collectors/price_collector.py b/backend/app/services/collectors/price_collector.py index 12e66cd..cb6d19f 100644 --- a/backend/app/services/collectors/price_collector.py +++ b/backend/app/services/collectors/price_collector.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from json import JSONDecodeError import pandas as pd +from sqlalchemy import func from sqlalchemy.orm import Session from sqlalchemy.dialects.postgresql import insert @@ -238,6 +239,16 @@ class PriceCollector(BaseCollector): return total_records + def _has_records_for_end_date(self) -> bool: + """Return True when the requested end date has any collected price rows.""" + end_date = datetime.strptime(self.end_date, "%Y%m%d").date() + return ( + self.db.query(func.count(Price.ticker)) + .filter(Price.date == end_date) + .scalar() + > 0 + ) + def collect(self) -> int: """Collect price data for all stocks.""" client = get_krx_client() @@ -246,9 +257,12 @@ class PriceCollector(BaseCollector): logger.info("Collecting stock prices via KRX Open API") total = self._collect_openapi() logger.info(f"Collected {total} price records via Open API") - if total > 0: + if total > 0 and self._has_records_for_end_date(): return total - logger.warning("KRX Open API returned 0 price records, falling back to pykrx") + logger.warning( + "KRX Open API did not populate end_date %s price rows, falling back to pykrx", + self.end_date, + ) except Exception as e: logger.warning(f"KRX Open API failed, falling back to pykrx: {e}") diff --git a/backend/tests/unit/test_price_collector_openapi_fallback.py b/backend/tests/unit/test_price_collector_openapi_fallback.py new file mode 100644 index 0000000..3443d4e --- /dev/null +++ b/backend/tests/unit/test_price_collector_openapi_fallback.py @@ -0,0 +1,121 @@ +"""Tests for OpenAPI partial-data fallback in price collectors.""" +from datetime import date +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.database import Base +from app.models.stock import AssetClass, ETF, Price, Stock +from app.services.collectors.etf_price_collector import ETFPriceCollector +from app.services.collectors.price_collector import PriceCollector + + +def make_db(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + Session = sessionmaker(bind=engine) + session = Session() + return session, engine + + +def test_price_collector_falls_back_when_openapi_misses_end_date(): + db, engine = make_db() + try: + db.add( + Stock( + ticker="005930", + name="Samsung Electronics", + market="KOSPI", + base_date=date(2026, 5, 15), + ) + ) + db.add( + Price( + ticker="005930", + date=date(2026, 5, 14), + open=70000, + high=71000, + low=69000, + close=70500, + volume=1000, + ) + ) + db.commit() + + with patch("app.services.collectors.price_collector.get_krx_client", return_value=object()), \ + patch.object(PriceCollector, "_collect_openapi", return_value=1), \ + patch.object(PriceCollector, "_collect_pykrx", return_value=1) as mock_pykrx: + total = PriceCollector(db, start_date="20260514", end_date="20260515").collect() + + assert total == 1 + mock_pykrx.assert_called_once() + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_price_collector_skips_fallback_when_openapi_has_end_date(): + db, engine = make_db() + try: + db.add( + Stock( + ticker="005930", + name="Samsung Electronics", + market="KOSPI", + base_date=date(2026, 5, 15), + ) + ) + db.add( + Price( + ticker="005930", + date=date(2026, 5, 15), + open=70000, + high=71000, + low=69000, + close=70500, + volume=1000, + ) + ) + db.commit() + + with patch("app.services.collectors.price_collector.get_krx_client", return_value=object()), \ + patch.object(PriceCollector, "_collect_openapi", return_value=1), \ + patch.object(PriceCollector, "_collect_pykrx", return_value=1) as mock_pykrx: + total = PriceCollector(db, start_date="20260514", end_date="20260515").collect() + + assert total == 1 + mock_pykrx.assert_not_called() + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_etf_price_collector_falls_back_when_openapi_returns_zero(): + db, engine = make_db() + try: + db.add( + ETF( + ticker="069500", + name="KODEX 200", + asset_class=AssetClass.EQUITY, + market="KOSPI", + ) + ) + db.commit() + + with patch("app.services.collectors.etf_price_collector.get_krx_client", return_value=object()), \ + patch.object(ETFPriceCollector, "_collect_openapi", return_value=0), \ + patch.object(ETFPriceCollector, "_collect_pykrx", return_value=1) as mock_pykrx: + total = ETFPriceCollector(db, start_date="20260515", end_date="20260515").collect() + + assert total == 1 + mock_pykrx.assert_called_once() + finally: + db.close() + Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/unit/test_screening_sector_strongest.py b/backend/tests/unit/test_screening_sector_strongest.py new file mode 100644 index 0000000..b306cc7 --- /dev/null +++ b/backend/tests/unit/test_screening_sector_strongest.py @@ -0,0 +1,180 @@ +from datetime import date +from decimal import Decimal + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models.screening import ScreeningSignal +from app.models.stock import MarketIndex, Price, Sector, Stock + + +def _add_signal( + db: Session, + *, + ticker: str, + sector: str, + trading_value: int, + daily_return: Decimal, + is_limit_up: bool = False, +) -> None: + db.add( + ScreeningSignal( + screen_date=date(2026, 5, 22), + ticker=ticker, + name=f"{ticker} Corp", + sector=sector, + trading_value=trading_value, + daily_return=daily_return, + is_limit_up=is_limit_up, + status="pending", + ) + ) + + +def _add_stock_with_sector(db: Session, ticker: str, name: str, sector: str) -> None: + db.add(Stock(ticker=ticker, name=name, market="KOSPI", base_date=date(2026, 5, 22))) + db.add( + Sector( + ticker=ticker, + sector_code=f"S{ticker[-2:]}", + company_name=name, + sector_name=sector, + base_date=date(2026, 5, 22), + ) + ) + + +def _add_price_series( + db: Session, + ticker: str, + *, + start_close: int, + step: int, + latest_volume: int, +) -> None: + for offset in range(31): + current_date = date(2026, 4, 22 + offset) if offset <= 8 else date(2026, 5, offset - 8) + close = Decimal(start_close + (step * offset)) + volume = latest_volume if offset == 30 else 1_000_000 + db.add( + Price( + ticker=ticker, + date=current_date, + open=close - Decimal("10"), + high=close + Decimal("20"), + low=close - Decimal("20"), + close=close, + volume=volume, + trading_value=int(close) * volume, + ) + ) + + +def _add_kospi_series(db: Session) -> None: + for offset in range(31): + current_date = date(2026, 4, 22 + offset) if offset <= 8 else date(2026, 5, offset - 8) + close = Decimal(2500 + offset) + db.add( + MarketIndex( + code="1001", + date=current_date, + name="KOSPI", + open=close, + high=close + Decimal("5"), + low=close - Decimal("5"), + close=close, + volume=1_000_000, + trading_value=100_000_000_000, + ) + ) + + +def test_sector_strongest_returns_top_signal_per_sector( + client: TestClient, + auth_headers: dict, + db: Session, +): + _add_signal( + db, + ticker="000001", + sector="반도체", + trading_value=250_000_000_000, + daily_return=Decimal("0.0500"), + ) + _add_signal( + db, + ticker="000002", + sector="반도체", + trading_value=400_000_000_000, + daily_return=Decimal("0.0300"), + ) + _add_signal( + db, + ticker="000003", + sector="2차전지", + trading_value=220_000_000_000, + daily_return=Decimal("0.0100"), + ) + _add_signal( + db, + ticker="000004", + sector="2차전지", + trading_value=210_000_000_000, + daily_return=Decimal("0.3000"), + is_limit_up=True, + ) + db.commit() + + response = client.get( + "/api/screening/sector-strongest?target_date=2026-05-22", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + + by_sector = {item["sector"]: item for item in data} + assert by_sector["반도체"]["ticker"] == "000002" + assert by_sector["반도체"]["signal_count"] == 2 + assert by_sector["2차전지"]["ticker"] == "000004" + assert by_sector["2차전지"]["is_limit_up"] is True + assert by_sector["2차전지"]["signal_strength"] > by_sector["반도체"]["signal_strength"] + + +def test_recent_sector_candidates_returns_stronger_same_sector_stocks( + client: TestClient, + auth_headers: dict, + db: Session, +): + _add_signal( + db, + ticker="000001", + sector="반도체", + trading_value=120_000_000_000, + daily_return=Decimal("0.0200"), + ) + _add_stock_with_sector(db, "000001", "기준전자", "반도체") + _add_stock_with_sector(db, "000002", "강한전자", "반도체") + _add_stock_with_sector(db, "000003", "약한전자", "반도체") + _add_stock_with_sector(db, "000004", "다른섹터", "자동차") + _add_kospi_series(db) + _add_price_series(db, "000001", start_close=10000, step=5, latest_volume=1_000_000) + _add_price_series(db, "000002", start_close=10000, step=250, latest_volume=8_000_000) + _add_price_series(db, "000003", start_close=10000, step=-5, latest_volume=1_000_000) + _add_price_series(db, "000004", start_close=10000, step=400, latest_volume=10_000_000) + db.commit() + + response = client.get( + "/api/screening/recent-sector-candidates?as_of=2026-05-22&window_days=30", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["ticker"] == "000001" + assert data[0]["sector"] == "반도체" + assert data[0]["stronger_count"] == 1 + assert data[0]["candidates"][0]["ticker"] == "000002" + assert data[0]["candidates"][0]["is_stronger_than_source"] is True diff --git a/frontend/src/app/screening/page.tsx b/frontend/src/app/screening/page.tsx index 41eeeb3..e6e68ea 100644 --- a/frontend/src/app/screening/page.tsx +++ b/frontend/src/app/screening/page.tsx @@ -18,7 +18,7 @@ import { } from '@/components/ui/dialog'; import { api } from '@/lib/api'; import { toast } from 'sonner'; -import { ScanSearch, Star, History, Settings2, ListFilter, AlertTriangle, Briefcase, Wallet } from 'lucide-react'; +import { ScanSearch, Star, History, Settings2, ListFilter, AlertTriangle, Briefcase, Wallet, TrendingUp } from 'lucide-react'; // ────────────────────────────────────────────────────────── // Types @@ -56,6 +56,51 @@ interface WatchlistItem { status: string; } +interface SectorStrongSignal { + sector: string; + signal_count: number; + id: number; + screen_date: string; + ticker: string; + name: string | null; + trading_value: number | null; + is_limit_up: boolean; + daily_return: number | null; + signal_strength: number; + status: string; +} + +interface SectorCandidateSignal { + ticker: string; + name: string | null; + sector: string; + latest_date: string; + close_price: number; + daily_return: number; + one_month_return: number; + relative_strength: number; + trading_value: number; + avg_trading_value_20: number; + trading_value_ratio: number; + ma5_support: boolean; + breakout: boolean; + score: number; + is_stronger_than_source: boolean; +} + +interface RecentSectorCandidateGroup { + signal_id: number; + screen_date: string; + ticker: string; + name: string | null; + sector: string; + source_score: number; + source_one_month_return: number; + source_relative_strength: number; + stronger_count: number; + candidates: SectorCandidateSignal[]; +} + interface AutoOrder { id: number; order_date: string; @@ -282,6 +327,147 @@ function WatchlistTab({ items, loading }: { items: WatchlistItem[]; loading: boo ); } +function SectorStrongestTab({ items, loading }: { items: SectorStrongSignal[]; loading: boolean }) { + if (loading) return ; + if (items.length === 0) { + return ( +
+ +

섹터별 강한 매수 신호가 없습니다.

+
+ ); + } + + return ( +
+ + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
섹터대표 종목강도 점수일간 수익률거래대금(억)섹터 신호 수상태
{item.sector} +
+
+

{item.name ?? item.ticker}

+

{item.ticker}

+
+ {item.is_limit_up && 상한가} +
+
{item.signal_strength.toFixed(2)}= 0 ? 'text-green-600' : 'text-red-600'}`}> + {fmtPct(item.daily_return)} + + {item.trading_value !== null ? (item.trading_value / 100000000).toFixed(1) : '-'} + {item.signal_count} + {item.status} +
+
+ ); +} + +function RecentSectorCandidatesTab({ + groups, + loading, +}: { + groups: RecentSectorCandidateGroup[]; + loading: boolean; +}) { + if (loading) return ; + if (groups.length === 0) { + return ( +
+ +

최근 1달 섹터 후보가 없습니다.

+
+ ); + } + + return ( +
+ + + + + + + + + + + + + + {groups.flatMap((group) => { + if (group.candidates.length === 0) { + return [ + + + + , + ]; + } + + return group.candidates.map((candidate, idx) => ( + + + + + + + + + + )); + })} + +
기준 신호강한 후보후보 점수1개월 수익률상대강도거래대금 배율조건
+

{group.name ?? group.ticker}

+

{group.sector} · {group.screen_date}

+
+ 기준 신호보다 강한 후보 없음 +
+ {idx === 0 && ( + <> +

{group.name ?? group.ticker}

+

{group.sector} · 기준 {group.source_score.toFixed(2)}

+ + )} +
+

{candidate.name ?? candidate.ticker}

+

{candidate.ticker}

+
{candidate.score.toFixed(2)}= 0 ? 'text-green-600' : 'text-red-600'}`}> + {fmtPct(candidate.one_month_return)} + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {fmtPct(candidate.relative_strength)} + {candidate.trading_value_ratio.toFixed(2)}x +
+ {candidate.ma5_support && 5MA} + {candidate.breakout && 돌파} +
+
+
+ ); +} + function AutoOrdersTab({ orders, loading }: { orders: AutoOrder[]; loading: boolean }) { if (loading) return ; if (orders.length === 0) { @@ -384,6 +570,8 @@ export default function ScreeningPage() { const [pageLoading, setPageLoading] = useState(true); const [todaySignals, setTodaySignals] = useState([]); + const [sectorStrongest, setSectorStrongest] = useState([]); + const [recentSectorCandidates, setRecentSectorCandidates] = useState([]); const [history, setHistory] = useState([]); const [watchlist, setWatchlist] = useState([]); const [autoOrders, setAutoOrders] = useState([]); @@ -391,6 +579,8 @@ export default function ScreeningPage() { const [balance, setBalance] = useState(null); const [todayLoading, setTodayLoading] = useState(false); + const [sectorStrongestLoading, setSectorStrongestLoading] = useState(false); + const [recentSectorCandidatesLoading, setRecentSectorCandidatesLoading] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [watchlistLoading, setWatchlistLoading] = useState(false); const [ordersLoading, setOrdersLoading] = useState(false); @@ -403,6 +593,8 @@ export default function ScreeningPage() { const loadData = useCallback(async () => { setTodayLoading(true); + setSectorStrongestLoading(true); + setRecentSectorCandidatesLoading(true); setHistoryLoading(true); setWatchlistLoading(true); setOrdersLoading(true); @@ -410,6 +602,8 @@ export default function ScreeningPage() { const results = await Promise.allSettled([ api.get('/api/screening/today'), + api.get('/api/screening/sector-strongest'), + api.get('/api/screening/recent-sector-candidates?window_days=30'), api.get('/api/screening/history'), api.get('/api/screening/watchlist'), api.get('/api/trading/orders'), @@ -418,13 +612,17 @@ export default function ScreeningPage() { ]); if (results[0].status === 'fulfilled') setTodaySignals(results[0].value); - if (results[1].status === 'fulfilled') setHistory(results[1].value); - if (results[2].status === 'fulfilled') setWatchlist(results[2].value); - if (results[3].status === 'fulfilled') setAutoOrders(results[3].value); - if (results[4].status === 'fulfilled') setPositions(results[4].value.positions); - if (results[5].status === 'fulfilled') setBalance(results[5].value); + if (results[1].status === 'fulfilled') setSectorStrongest(results[1].value); + if (results[2].status === 'fulfilled') setRecentSectorCandidates(results[2].value); + if (results[3].status === 'fulfilled') setHistory(results[3].value); + if (results[4].status === 'fulfilled') setWatchlist(results[4].value); + if (results[5].status === 'fulfilled') setAutoOrders(results[5].value); + if (results[6].status === 'fulfilled') setPositions(results[6].value.positions); + if (results[7].status === 'fulfilled') setBalance(results[7].value); setTodayLoading(false); + setSectorStrongestLoading(false); + setRecentSectorCandidatesLoading(false); setHistoryLoading(false); setWatchlistLoading(false); setOrdersLoading(false); @@ -470,9 +668,9 @@ export default function ScreeningPage() { const kpiCards = [ { label: '오늘 신호 수', value: todaySignals.length }, + { label: '섹터 대표 신호', value: sectorStrongest.length }, + { label: '1달 강한 후보', value: recentSectorCandidates.reduce((sum, group) => sum + group.stronger_count, 0) }, { label: '워치리스트', value: watchlist.length }, - { label: '자동 주문 이력', value: autoOrders.length }, - { label: '전체 이력', value: history.length }, ]; return ( @@ -600,6 +798,14 @@ export default function ScreeningPage() { 오늘의 신호 + + + 섹터별 강한 신호 + + + + 1달 섹터 후보 + 이력 @@ -622,6 +828,12 @@ export default function ScreeningPage() { + + + + + +