feat: add recent KJB sector candidate analysis
This commit is contained in:
parent
0bcf5bbf23
commit
6412c40caf
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
121
backend/tests/unit/test_price_collector_openapi_fallback.py
Normal file
121
backend/tests/unit/test_price_collector_openapi_fallback.py
Normal 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)
|
||||
180
backend/tests/unit/test_screening_sector_strongest.py
Normal file
180
backend/tests/unit/test_screening_sector_strongest.py
Normal 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
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user