feat: add recent KJB sector candidate analysis

This commit is contained in:
머니페니 2026-05-24 21:08:03 +09:00
parent 0bcf5bbf23
commit 6412c40caf
7 changed files with 906 additions and 30 deletions

View File

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

View File

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

View File

@ -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}")

View File

@ -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}")

View File

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

View File

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

View File

@ -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 <Skeleton className="h-48 w-full" />;
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<TrendingUp className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm"> .</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">()</th>
<th className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<tr key={`${item.sector}-${item.id}`} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm font-medium">{item.sector}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div>
<p className="font-medium text-sm">{item.name ?? item.ticker}</p>
<p className="text-xs font-mono text-muted-foreground">{item.ticker}</p>
</div>
{item.is_limit_up && <Badge className="bg-red-500 text-white text-xs"></Badge>}
</div>
</td>
<td className="px-4 py-3 text-sm text-right font-semibold">{item.signal_strength.toFixed(2)}</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${(item.daily_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{fmtPct(item.daily_return)}
</td>
<td className="px-4 py-3 text-sm text-right">
{item.trading_value !== null ? (item.trading_value / 100000000).toFixed(1) : '-'}
</td>
<td className="px-4 py-3 text-sm text-center">{item.signal_count}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs ${statusBadge(item.status)}`}>{item.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function RecentSectorCandidatesTab({
groups,
loading,
}: {
groups: RecentSectorCandidateGroup[];
loading: boolean;
}) {
if (loading) return <Skeleton className="h-48 w-full" />;
if (groups.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<TrendingUp className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm"> 1 .</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">1 </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{groups.flatMap((group) => {
if (group.candidates.length === 0) {
return [
<tr key={`${group.signal_id}-empty`} className="hover:bg-muted/50">
<td className="px-4 py-3">
<p className="font-medium text-sm">{group.name ?? group.ticker}</p>
<p className="text-xs text-muted-foreground">{group.sector} · {group.screen_date}</p>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground" colSpan={6}>
</td>
</tr>,
];
}
return group.candidates.map((candidate, idx) => (
<tr key={`${group.signal_id}-${candidate.ticker}`} className="hover:bg-muted/50">
<td className="px-4 py-3">
{idx === 0 && (
<>
<p className="font-medium text-sm">{group.name ?? group.ticker}</p>
<p className="text-xs text-muted-foreground">{group.sector} · {group.source_score.toFixed(2)}</p>
</>
)}
</td>
<td className="px-4 py-3">
<p className="font-medium text-sm">{candidate.name ?? candidate.ticker}</p>
<p className="text-xs font-mono text-muted-foreground">{candidate.ticker}</p>
</td>
<td className="px-4 py-3 text-sm text-right font-semibold">{candidate.score.toFixed(2)}</td>
<td className={`px-4 py-3 text-sm text-right ${(candidate.one_month_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{fmtPct(candidate.one_month_return)}
</td>
<td className={`px-4 py-3 text-sm text-right ${(candidate.relative_strength ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{fmtPct(candidate.relative_strength)}
</td>
<td className="px-4 py-3 text-sm text-right">{candidate.trading_value_ratio.toFixed(2)}x</td>
<td className="px-4 py-3 text-center">
<div className="flex justify-center gap-1">
{candidate.ma5_support && <Badge variant="outline" className="text-xs">5MA</Badge>}
{candidate.breakout && <Badge variant="outline" className="text-xs"></Badge>}
</div>
</td>
</tr>
));
})}
</tbody>
</table>
</div>
);
}
function AutoOrdersTab({ orders, loading }: { orders: AutoOrder[]; loading: boolean }) {
if (loading) return <Skeleton className="h-48 w-full" />;
if (orders.length === 0) {
@ -384,6 +570,8 @@ export default function ScreeningPage() {
const [pageLoading, setPageLoading] = useState(true);
const [todaySignals, setTodaySignals] = useState<ScreeningSignal[]>([]);
const [sectorStrongest, setSectorStrongest] = useState<SectorStrongSignal[]>([]);
const [recentSectorCandidates, setRecentSectorCandidates] = useState<RecentSectorCandidateGroup[]>([]);
const [history, setHistory] = useState<ScreeningSignal[]>([]);
const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);
const [autoOrders, setAutoOrders] = useState<AutoOrder[]>([]);
@ -391,6 +579,8 @@ export default function ScreeningPage() {
const [balance, setBalance] = useState<TradingBalance | null>(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<ScreeningSignal[]>('/api/screening/today'),
api.get<SectorStrongSignal[]>('/api/screening/sector-strongest'),
api.get<RecentSectorCandidateGroup[]>('/api/screening/recent-sector-candidates?window_days=30'),
api.get<ScreeningSignal[]>('/api/screening/history'),
api.get<WatchlistItem[]>('/api/screening/watchlist'),
api.get<AutoOrder[]>('/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() {
<ScanSearch className="h-4 w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="sector-strongest">
<TrendingUp className="h-4 w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="recent-sector-candidates">
<TrendingUp className="h-4 w-4 mr-1" />
1
</TabsTrigger>
<TabsTrigger value="history">
<History className="h-4 w-4 mr-1" />
@ -622,6 +828,12 @@ export default function ScreeningPage() {
<TabsContent value="today">
<TodaySignalsTab signals={todaySignals} loading={todayLoading} />
</TabsContent>
<TabsContent value="sector-strongest">
<SectorStrongestTab items={sectorStrongest} loading={sectorStrongestLoading} />
</TabsContent>
<TabsContent value="recent-sector-candidates">
<RecentSectorCandidatesTab groups={recentSectorCandidates} loading={recentSectorCandidatesLoading} />
</TabsContent>
<TabsContent value="history">
<HistoryTab signals={history} loading={historyLoading} />
</TabsContent>