백테스트 수정중
This commit is contained in:
@@ -27,10 +27,57 @@ from app.services.backtest_engine import (
|
||||
from app.services.stock_universe import StockUniverse, StockFilter
|
||||
from app.services.ml_ensemble import get_ensemble
|
||||
from app.services.feature_engineering import FEATURE_COLUMNS
|
||||
from app.services.strategy_config_service import strategy_cfg
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 전략 프리셋 정의 (모듈 레벨) ───────────────────────────────────────────────
|
||||
|
||||
_STRATEGY_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"conservative": {
|
||||
"name": "보수적",
|
||||
"params": {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": -5.0,
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": 8.0,
|
||||
"MAX_TOTAL_POSITIONS": 3,
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": 25,
|
||||
},
|
||||
},
|
||||
"balanced": {
|
||||
"name": "균형형",
|
||||
"params": {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": -6.0,
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": 12.0,
|
||||
"MAX_TOTAL_POSITIONS": 5,
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": 30,
|
||||
},
|
||||
},
|
||||
"aggressive": {
|
||||
"name": "공격적",
|
||||
"params": {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": -8.0,
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": 20.0,
|
||||
"MAX_TOTAL_POSITIONS": 8,
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": 35,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _get_preset(preset_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""프리셋 이름으로 파라미터 반환. 'current'이면 DB 현재 설정 로드."""
|
||||
if preset_key == "current":
|
||||
await strategy_cfg._refresh()
|
||||
params = {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": strategy_cfg.get("STOP_LOSS_PERCENT", -6.0),
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": strategy_cfg.get("TAKE_PROFIT_PERCENT", 12.0),
|
||||
"MAX_TOTAL_POSITIONS": strategy_cfg.get("MAX_HOLDINGS_COUNT", 5),
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", 30.0),
|
||||
}
|
||||
return {"name": "현재 설정", "params": params}
|
||||
return _STRATEGY_PRESETS.get(preset_key)
|
||||
|
||||
# 전역 변수: 생성된 학습 데이터 임시 저장
|
||||
_cached_training_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
@@ -65,6 +112,14 @@ class ComparePresetsRequest(BaseModel):
|
||||
presets: List[str] = Field(["conservative", "balanced", "aggressive"], description="비교할 프리셋 목록")
|
||||
|
||||
|
||||
class ComparePresetsUniverseRequest(BaseModel):
|
||||
universe_preset: str = Field("large_cap", description="유니버스 프리셋")
|
||||
start_date: str
|
||||
end_date: str
|
||||
initial_capital: float = 10_000_000
|
||||
presets: List[str] = Field(["conservative", "balanced", "aggressive"], description="비교할 프리셋 목록")
|
||||
|
||||
|
||||
class RunUniverseBacktestRequest(BaseModel):
|
||||
"""전체 종목 백테스트 요청"""
|
||||
universe_preset: str = Field("large_cap", description="유니버스 프리셋 (large_cap, kospi_all, ...)")
|
||||
@@ -76,6 +131,17 @@ class RunUniverseBacktestRequest(BaseModel):
|
||||
top_n_results: int = Field(20, description="성과 상위 N개만 반환")
|
||||
|
||||
|
||||
class GenerateTrainingDataUniverseRequest(BaseModel):
|
||||
"""전체 종목 ML 학습 데이터 생성 요청"""
|
||||
universe_preset: str = Field("large_cap", description="유니버스 프리셋")
|
||||
start_date: str = Field(..., description="시작일 (YYYY-MM-DD)")
|
||||
end_date: str = Field(..., description="종료일 (YYYY-MM-DD)")
|
||||
forward_days: int = Field(5, description="N일 후 수익률로 라벨링")
|
||||
up_threshold: float = Field(0.03, description="UP 레이블 임계값")
|
||||
down_threshold: float = Field(-0.03, description="DOWN 레이블 임계값")
|
||||
min_samples_per_stock: int = Field(50, description="종목당 최소 샘플 수")
|
||||
|
||||
|
||||
# ── API 엔드포인트 ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/universe-presets")
|
||||
@@ -129,31 +195,13 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str
|
||||
|
||||
logger.info(f"전체 백테스트 시작: {len(stock_codes)}종목")
|
||||
|
||||
# 2. 전략 파라미터
|
||||
PRESETS = {
|
||||
"conservative": {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": -5.0,
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": 8.0,
|
||||
"MAX_TOTAL_POSITIONS": 3,
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": 25,
|
||||
},
|
||||
"balanced": {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": -6.0,
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": 12.0,
|
||||
"MAX_TOTAL_POSITIONS": 5,
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": 30,
|
||||
},
|
||||
"aggressive": {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": -8.0,
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": 20.0,
|
||||
"MAX_TOTAL_POSITIONS": 8,
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": 35,
|
||||
},
|
||||
}
|
||||
|
||||
strategy_params = PRESETS.get(req.strategy_preset, PRESETS["balanced"])
|
||||
# 2. 전략 파라미터 ('current'이면 DB에서 로드)
|
||||
preset = await _get_preset(req.strategy_preset) or await _get_preset("balanced")
|
||||
strategy_params = preset["params"]
|
||||
|
||||
# 3. 각 종목별로 백테스트 (병렬)
|
||||
no_data_count = 0
|
||||
|
||||
async def backtest_single(code: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
config = BacktestConfig(
|
||||
@@ -167,7 +215,11 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str
|
||||
)
|
||||
engine = BacktestEngine()
|
||||
result = await engine.run_backtest(config)
|
||||
|
||||
|
||||
# DB에 해당 종목/기간 데이터 없으면 제외
|
||||
if not result.equity_curve:
|
||||
return None
|
||||
|
||||
return {
|
||||
"stock_code": code,
|
||||
"total_return_pct": result.total_return_pct,
|
||||
@@ -179,15 +231,17 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str
|
||||
except Exception as e:
|
||||
logger.error(f"{code} 백테스트 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# 병렬 실행 (10개씩 배치)
|
||||
results = []
|
||||
batch_size = 10
|
||||
for i in range(0, len(stock_codes), batch_size):
|
||||
batch = stock_codes[i:i + batch_size]
|
||||
batch_results = await asyncio.gather(*[backtest_single(code) for code in batch])
|
||||
results.extend([r for r in batch_results if r is not None])
|
||||
logger.info(f"진행률: {len(results)}/{len(stock_codes)}")
|
||||
valid = [r for r in batch_results if r is not None]
|
||||
no_data_count += len(batch) - len(valid)
|
||||
results.extend(valid)
|
||||
logger.info(f"진행률: {len(results)}/{len(stock_codes)} (데이터없음: {no_data_count})")
|
||||
|
||||
# 4. 수익률 상위 N개 정렬
|
||||
results_sorted = sorted(results, key=lambda x: x["total_return_pct"], reverse=True)
|
||||
@@ -197,6 +251,7 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str
|
||||
|
||||
return {
|
||||
"total_tested": len(results),
|
||||
"no_data_count": no_data_count,
|
||||
"top_performers": top_results,
|
||||
"universe_preset": req.universe_preset,
|
||||
"strategy_preset": req.strategy_preset,
|
||||
@@ -205,6 +260,61 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate-training-data-universe")
|
||||
async def api_generate_training_data_universe(req: GenerateTrainingDataUniverseRequest) -> Dict[str, Any]:
|
||||
"""
|
||||
전체 종목 ML 학습 데이터 생성 (유니버스 프리셋 기반)
|
||||
|
||||
유니버스에 속한 모든 종목의 과거 데이터를 병렬로 수집하여 학습 데이터를 생성합니다.
|
||||
"""
|
||||
global _cached_training_data
|
||||
|
||||
try:
|
||||
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
|
||||
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="날짜 형식 오류 (YYYY-MM-DD)")
|
||||
|
||||
# 유니버스 프리셋에서 종목 리스트
|
||||
presets = StockUniverse.get_preset_universes()
|
||||
if req.universe_preset not in presets:
|
||||
raise HTTPException(status_code=400, detail=f"잘못된 유니버스 프리셋: {req.universe_preset}")
|
||||
|
||||
stock_filter = presets[req.universe_preset]["filter"]
|
||||
stock_codes = await StockUniverse.get_filtered_stocks(stock_filter)
|
||||
|
||||
if not stock_codes:
|
||||
raise HTTPException(status_code=404, detail="해당 유니버스에 종목이 없습니다")
|
||||
|
||||
logger.info(f"전체 종목 학습 데이터 생성 시작: {len(stock_codes)}종목")
|
||||
|
||||
config = TrainingDataConfig(
|
||||
stock_codes=stock_codes,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
forward_days=req.forward_days,
|
||||
up_threshold=req.up_threshold,
|
||||
down_threshold=req.down_threshold,
|
||||
min_samples_per_stock=req.min_samples_per_stock,
|
||||
)
|
||||
|
||||
result = await generate_training_data(config)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
_cached_training_data = result
|
||||
|
||||
return {
|
||||
"total_samples": result["total_samples"],
|
||||
"label_distribution": result["label_distribution"],
|
||||
"config": result["config"],
|
||||
"universe_preset": req.universe_preset,
|
||||
"total_stocks_requested": len(stock_codes),
|
||||
"message": f"{len(stock_codes)}종목에서 {result['total_samples']}개 샘플 생성 완료. /train-model로 학습하거나 /download-training-data로 다운로드하세요.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate-training-data")
|
||||
async def api_generate_training_data(req: GenerateTrainingDataRequest) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -308,28 +418,35 @@ async def api_train_model(background_tasks: BackgroundTasks) -> Dict[str, Any]:
|
||||
async def api_run_backtest(req: RunBacktestRequest) -> Dict[str, Any]:
|
||||
"""
|
||||
전략 성과 백테스트 실행
|
||||
|
||||
과거 데이터로 전략을 실행하여 수익률, MDD, 승률 등을 계산합니다.
|
||||
|
||||
strategy_params가 없으면 전략 설정 페이지의 현재 DB 값을 자동 사용합니다.
|
||||
"""
|
||||
try:
|
||||
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
|
||||
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="날짜 형식 오류")
|
||||
|
||||
|
||||
# strategy_params 없으면 DB 현재 설정 자동 로드
|
||||
strategy_params = req.strategy_params
|
||||
if strategy_params is None:
|
||||
preset = await _get_preset("current")
|
||||
strategy_params = preset["params"]
|
||||
logger.info(f"strategy_params 미지정 → DB 현재 설정 사용: {strategy_params}")
|
||||
|
||||
config = BacktestConfig(
|
||||
stock_codes=req.stock_codes,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
initial_capital=req.initial_capital,
|
||||
strategy_params=req.strategy_params,
|
||||
strategy_params=strategy_params,
|
||||
max_positions=req.max_positions,
|
||||
position_size=req.position_size,
|
||||
)
|
||||
|
||||
|
||||
engine = BacktestEngine()
|
||||
result = await engine.run_backtest(config)
|
||||
|
||||
|
||||
# BacktestResult를 dict로 변환
|
||||
from dataclasses import asdict
|
||||
return asdict(result)
|
||||
@@ -348,44 +465,13 @@ async def api_compare_presets(req: ComparePresetsRequest) -> Dict[str, Any]:
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="날짜 형식 오류")
|
||||
|
||||
# 프리셋 정의 (frontend/src/utils/strategyPresets.ts와 동기화)
|
||||
PRESETS = {
|
||||
"conservative": {
|
||||
"name": "보수적",
|
||||
"params": {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": -5.0,
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": 8.0,
|
||||
"MAX_TOTAL_POSITIONS": 3,
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": 25,
|
||||
}
|
||||
},
|
||||
"balanced": {
|
||||
"name": "균형형",
|
||||
"params": {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": -6.0,
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": 12.0,
|
||||
"MAX_TOTAL_POSITIONS": 5,
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": 30,
|
||||
}
|
||||
},
|
||||
"aggressive": {
|
||||
"name": "공격적",
|
||||
"params": {
|
||||
"MEAN_REVERSION_STOP_LOSS_PCT": -8.0,
|
||||
"MEAN_REVERSION_TAKE_PROFIT_PCT": 20.0,
|
||||
"MAX_TOTAL_POSITIONS": 8,
|
||||
"MEAN_REVERSION_RSI_THRESHOLD": 35,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
results = {}
|
||||
|
||||
|
||||
for preset_key in req.presets:
|
||||
if preset_key not in PRESETS:
|
||||
preset = await _get_preset(preset_key)
|
||||
if preset is None:
|
||||
continue
|
||||
|
||||
preset = PRESETS[preset_key]
|
||||
|
||||
config = BacktestConfig(
|
||||
stock_codes=req.stock_codes,
|
||||
start_date=start_date,
|
||||
@@ -394,10 +480,10 @@ async def api_compare_presets(req: ComparePresetsRequest) -> Dict[str, Any]:
|
||||
strategy_params=preset["params"],
|
||||
max_positions=preset["params"]["MAX_TOTAL_POSITIONS"],
|
||||
)
|
||||
|
||||
|
||||
engine = BacktestEngine()
|
||||
result = await engine.run_backtest(config)
|
||||
|
||||
|
||||
results[preset_key] = {
|
||||
"name": preset["name"],
|
||||
"total_return_pct": result.total_return_pct,
|
||||
@@ -407,9 +493,71 @@ async def api_compare_presets(req: ComparePresetsRequest) -> Dict[str, Any]:
|
||||
"total_trades": result.total_trades,
|
||||
"equity_curve": result.equity_curve,
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
"presets": results,
|
||||
"period": f"{req.start_date} ~ {req.end_date}",
|
||||
"stock_count": len(req.stock_codes),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/compare-presets-universe")
|
||||
async def api_compare_presets_universe(req: ComparePresetsUniverseRequest) -> Dict[str, Any]:
|
||||
"""
|
||||
프리셋 비교 백테스트 (유니버스 프리셋 기반)
|
||||
|
||||
선택한 유니버스의 모든 종목을 대상으로 보수적/균형형/공격적 프리셋을 비교합니다.
|
||||
"""
|
||||
try:
|
||||
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
|
||||
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="날짜 형식 오류")
|
||||
|
||||
presets_map = StockUniverse.get_preset_universes()
|
||||
if req.universe_preset not in presets_map:
|
||||
raise HTTPException(status_code=400, detail=f"잘못된 유니버스 프리셋: {req.universe_preset}")
|
||||
|
||||
stock_filter = presets_map[req.universe_preset]["filter"]
|
||||
stock_codes = await StockUniverse.get_filtered_stocks(stock_filter)
|
||||
|
||||
if not stock_codes:
|
||||
raise HTTPException(status_code=404, detail="해당 유니버스에 종목이 없습니다")
|
||||
|
||||
logger.info(f"유니버스 프리셋 비교 시작: {req.universe_preset} ({len(stock_codes)}종목)")
|
||||
|
||||
results = {}
|
||||
|
||||
for preset_key in req.presets:
|
||||
preset = await _get_preset(preset_key)
|
||||
if preset is None:
|
||||
continue
|
||||
|
||||
config = BacktestConfig(
|
||||
stock_codes=stock_codes,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
initial_capital=req.initial_capital,
|
||||
strategy_params=preset["params"],
|
||||
max_positions=preset["params"]["MAX_TOTAL_POSITIONS"],
|
||||
)
|
||||
|
||||
engine = BacktestEngine()
|
||||
result = await engine.run_backtest(config)
|
||||
|
||||
results[preset_key] = {
|
||||
"name": preset["name"],
|
||||
"total_return_pct": result.total_return_pct,
|
||||
"mdd_pct": result.mdd_pct,
|
||||
"win_rate": result.win_rate,
|
||||
"sharpe_ratio": result.sharpe_ratio,
|
||||
"total_trades": result.total_trades,
|
||||
"equity_curve": result.equity_curve,
|
||||
}
|
||||
|
||||
return {
|
||||
"presets": results,
|
||||
"period": f"{req.start_date} ~ {req.end_date}",
|
||||
"stock_count": len(stock_codes),
|
||||
"universe_preset": req.universe_preset,
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ class BacktestEngine:
|
||||
avg_lose=0.0,
|
||||
max_win=0.0,
|
||||
max_lose=0.0,
|
||||
equity_curve=[],
|
||||
equity_curve=self.equity_curve,
|
||||
trades=[],
|
||||
final_positions=[]
|
||||
)
|
||||
@@ -445,7 +445,8 @@ class BacktestEngine:
|
||||
prev_portfolio_value = config.initial_capital
|
||||
|
||||
for current_date in all_dates:
|
||||
current_date_obj = datetime.strptime(current_date, "%Y%m%d").date()
|
||||
fmt = "%Y%m%d" if len(current_date) == 8 else "%Y-%m-%d"
|
||||
current_date_obj = datetime.strptime(current_date, fmt).date()
|
||||
|
||||
# 각 종목별 신호 생성 및 주문 실행
|
||||
current_prices = {}
|
||||
|
||||
@@ -111,8 +111,8 @@ class StockLSTM:
|
||||
self.seq_std = X_seq.std(axis=(0, 1)) + 1e-8
|
||||
X_norm = (X_seq - self.seq_mean) / self.seq_std
|
||||
|
||||
X_tensor = torch.FloatTensor(X_norm).to(self.device)
|
||||
y_tensor = torch.LongTensor(y).to(self.device)
|
||||
X_tensor = torch.FloatTensor(X_norm.copy()).to(self.device)
|
||||
y_tensor = torch.LongTensor(y.copy()).to(self.device)
|
||||
|
||||
dataset = TensorDataset(X_tensor, y_tensor)
|
||||
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
|
||||
@@ -157,7 +157,7 @@ class StockLSTM:
|
||||
|
||||
self.model.eval()
|
||||
X_norm = (X_seq - self.seq_mean) / self.seq_std
|
||||
X_tensor = torch.FloatTensor(X_norm).to(self.device)
|
||||
X_tensor = torch.FloatTensor(X_norm.copy()).to(self.device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = self.model(X_tensor)
|
||||
|
||||
@@ -274,8 +274,24 @@ async def get_all_krx_stocks_from_fdr(
|
||||
return stocks
|
||||
|
||||
except ImportError:
|
||||
logger.error("FinanceDataReader 미설치. pip install finance-datareader")
|
||||
return []
|
||||
logger.warning("FinanceDataReader 미설치. DB 폴백 사용")
|
||||
return await _get_stocks_from_db(market)
|
||||
except Exception as e:
|
||||
logger.error(f"FDR 조회 실패: {e}")
|
||||
logger.warning(f"FDR 조회 실패: {e}. DB 폴백 사용")
|
||||
return await _get_stocks_from_db(market)
|
||||
|
||||
|
||||
async def _get_stocks_from_db(market: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""FDR 실패 시 DB의 StockDailyPrice에서 고유 종목 코드 조회"""
|
||||
try:
|
||||
from sqlalchemy import distinct
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(distinct(StockDailyPrice.stock_code))
|
||||
result = await session.execute(stmt)
|
||||
codes = [row[0] for row in result.all()]
|
||||
|
||||
logger.info(f"DB 폴백: {len(codes)}개 종목 조회 (market 필터 미적용)")
|
||||
return [{'code': c, 'name': c, 'market': market or 'KOSPI'} for c in codes]
|
||||
except Exception as e:
|
||||
logger.error(f"DB 폴백 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
BIN
backend/models/ensemble/lgbm_model.pkl
Normal file
BIN
backend/models/ensemble/lgbm_model.pkl
Normal file
Binary file not shown.
BIN
backend/models/ensemble/lstm_model.pt
Normal file
BIN
backend/models/ensemble/lstm_model.pt
Normal file
Binary file not shown.
1
backend/models/ensemble/metrics.json
Normal file
1
backend/models/ensemble/metrics.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
BIN
backend/models/ensemble/rf_model.pkl
Normal file
BIN
backend/models/ensemble/rf_model.pkl
Normal file
Binary file not shown.
BIN
backend/models/ensemble/scaler.pkl
Normal file
BIN
backend/models/ensemble/scaler.pkl
Normal file
Binary file not shown.
@@ -29,6 +29,7 @@ feedparser==6.0.11
|
||||
joblib>=1.3.0
|
||||
beautifulsoup4>=4.12.0
|
||||
httpx>=0.27.1
|
||||
finance-datareader>=0.9.50
|
||||
# ML Ensemble
|
||||
torch>=2.1.0
|
||||
lightgbm>=4.1.0
|
||||
|
||||
@@ -20,7 +20,9 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, T
|
||||
// ── ML 학습 데이터 생성 탭 ───────────────────────────────────────────────
|
||||
|
||||
function MLTrainingTab() {
|
||||
const [mode, setMode] = useState<'manual' | 'universe'>('manual')
|
||||
const [stockCodes, setStockCodes] = useState('005930,000660,035720')
|
||||
const [universePreset, setUniversePreset] = useState('large_cap')
|
||||
const [startDate, setStartDate] = useState('2023-01-01')
|
||||
const [endDate, setEndDate] = useState('2025-12-31')
|
||||
const [forwardDays, setForwardDays] = useState(5)
|
||||
@@ -28,17 +30,34 @@ function MLTrainingTab() {
|
||||
const [downThreshold, setDownThreshold] = useState(-3.0)
|
||||
const [result, setResult] = useState<any>(null)
|
||||
|
||||
const { data: universePresets } = useQuery({
|
||||
queryKey: ['universe-presets'],
|
||||
queryFn: async () => (await api.get('/backtest/universe-presets')).data,
|
||||
})
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/backtest/generate-training-data', {
|
||||
stock_codes: stockCodes.split(',').map(c => c.trim()),
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
forward_days: forwardDays,
|
||||
up_threshold: upThreshold / 100,
|
||||
down_threshold: downThreshold / 100,
|
||||
})
|
||||
return res.data
|
||||
if (mode === 'universe') {
|
||||
const res = await api.post('/backtest/generate-training-data-universe', {
|
||||
universe_preset: universePreset,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
forward_days: forwardDays,
|
||||
up_threshold: upThreshold / 100,
|
||||
down_threshold: downThreshold / 100,
|
||||
})
|
||||
return res.data
|
||||
} else {
|
||||
const res = await api.post('/backtest/generate-training-data', {
|
||||
stock_codes: stockCodes.split(',').map(c => c.trim()),
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
forward_days: forwardDays,
|
||||
up_threshold: upThreshold / 100,
|
||||
down_threshold: downThreshold / 100,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResult(data)
|
||||
@@ -71,16 +90,61 @@ function MLTrainingTab() {
|
||||
<Database size={20} className="text-blue-400" />
|
||||
ML 학습 데이터 생성 설정
|
||||
</h3>
|
||||
|
||||
{/* 종목 선택 모드 토글 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setMode('manual')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
mode === 'manual'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white border border-slate-700'
|
||||
}`}
|
||||
>
|
||||
직접 입력
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('universe')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 ${
|
||||
mode === 'universe'
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white border border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Globe size={12} />
|
||||
전체 종목 (유니버스)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">종목 코드 (쉼표 구분)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stockCodes}
|
||||
onChange={(e) => setStockCodes(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||
placeholder="005930,000660,035720"
|
||||
/>
|
||||
{mode === 'manual' ? (
|
||||
<>
|
||||
<label className="block text-xs text-slate-400 mb-1">종목 코드 (쉼표 구분)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stockCodes}
|
||||
onChange={(e) => setStockCodes(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||
placeholder="005930,000660,035720"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-xs text-slate-400 mb-1">종목 유니버스</label>
|
||||
<select
|
||||
value={universePreset}
|
||||
onChange={(e) => setUniversePreset(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||
>
|
||||
{universePresets && Object.entries(universePresets).map(([key, preset]: any) => (
|
||||
<option key={key} value={key}>
|
||||
{preset.description || key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
@@ -140,7 +204,11 @@ function MLTrainingTab() {
|
||||
<button
|
||||
onClick={() => generateMutation.mutate()}
|
||||
disabled={generateMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium disabled:opacity-50"
|
||||
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg font-medium disabled:opacity-50 ${
|
||||
mode === 'universe'
|
||||
? 'bg-orange-600 hover:bg-orange-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
<Play size={16} />
|
||||
{generateMutation.isPending ? '생성 중...' : '데이터 생성'}
|
||||
@@ -176,6 +244,12 @@ function MLTrainingTab() {
|
||||
<p className="text-xs text-slate-400">총 샘플 수</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{result.total_samples?.toLocaleString()}</p>
|
||||
</div>
|
||||
{result.total_stocks_requested && (
|
||||
<div className="bg-slate-800 rounded-lg p-4">
|
||||
<p className="text-xs text-slate-400">대상 종목 수</p>
|
||||
<p className="text-2xl font-bold text-orange-400 mt-1">{result.total_stocks_requested}개</p>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(result.label_distribution || {}).map(([label, count]: any) => (
|
||||
<div key={label} className="bg-slate-800 rounded-lg p-4">
|
||||
<p className="text-xs text-slate-400">{label}</p>
|
||||
@@ -198,49 +272,68 @@ function MLTrainingTab() {
|
||||
|
||||
// ── 전략 성과 테스트 탭 ──────────────────────────────────────────────────────
|
||||
|
||||
const PRESET_CHART_CONFIG = [
|
||||
{ key: 'conservative', label: '보수적', color: 'rgb(34, 197, 94)', bg: 'rgba(34, 197, 94, 0.1)' },
|
||||
{ key: 'balanced', label: '균형형', color: 'rgb(59, 130, 246)', bg: 'rgba(59, 130, 246, 0.1)' },
|
||||
{ key: 'aggressive', label: '공격적', color: 'rgb(239, 68, 68)', bg: 'rgba(239, 68, 68, 0.1)' },
|
||||
{ key: 'current', label: '현재 설정', color: 'rgb(168, 85, 247)', bg: 'rgba(168, 85, 247, 0.1)' },
|
||||
]
|
||||
|
||||
function StrategyTestTab() {
|
||||
const [mode, setMode] = useState<'manual' | 'universe'>('manual')
|
||||
const [stockCodes, setStockCodes] = useState('005930,000660,035720')
|
||||
const [universePreset, setUniversePreset] = useState('large_cap')
|
||||
const [startDate, setStartDate] = useState('2024-01-01')
|
||||
const [endDate, setEndDate] = useState('2024-12-31')
|
||||
const [includeCurrentSettings, setIncludeCurrentSettings] = useState(false)
|
||||
const [compareResults, setCompareResults] = useState<any>(null)
|
||||
|
||||
const { data: universePresets } = useQuery({
|
||||
queryKey: ['universe-presets'],
|
||||
queryFn: async () => (await api.get('/backtest/universe-presets')).data,
|
||||
})
|
||||
|
||||
const compareMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/backtest/compare-presets', {
|
||||
stock_codes: stockCodes.split(',').map(c => c.trim()),
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
presets: ['conservative', 'balanced', 'aggressive'],
|
||||
})
|
||||
return res.data
|
||||
const presets = ['conservative', 'balanced', 'aggressive', ...(includeCurrentSettings ? ['current'] : [])]
|
||||
if (mode === 'universe') {
|
||||
const res = await api.post('/backtest/compare-presets-universe', {
|
||||
universe_preset: universePreset,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
presets,
|
||||
})
|
||||
return res.data
|
||||
} else {
|
||||
const res = await api.post('/backtest/compare-presets', {
|
||||
stock_codes: stockCodes.split(',').map(c => c.trim()),
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
presets,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setCompareResults(data)
|
||||
},
|
||||
})
|
||||
|
||||
const allCurves = compareResults
|
||||
? PRESET_CHART_CONFIG.map(cfg => compareResults.presets?.[cfg.key]?.equity_curve).filter(Boolean)
|
||||
: []
|
||||
const longestCurve = allCurves.reduce((a, b) => ((a?.length ?? 0) >= (b?.length ?? 0) ? a : b), [])
|
||||
|
||||
const chartData = compareResults ? {
|
||||
labels: compareResults.presets?.conservative?.equity_curve?.map((p: any) => p.date) || [],
|
||||
datasets: [
|
||||
{
|
||||
label: '보수적',
|
||||
data: compareResults.presets?.conservative?.equity_curve?.map((p: any) => p.return_pct * 100) || [],
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
},
|
||||
{
|
||||
label: '균형형',
|
||||
data: compareResults.presets?.balanced?.equity_curve?.map((p: any) => p.return_pct * 100) || [],
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
{
|
||||
label: '공격적',
|
||||
data: compareResults.presets?.aggressive?.equity_curve?.map((p: any) => p.return_pct * 100) || [],
|
||||
borderColor: 'rgb(239, 68, 68)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
},
|
||||
],
|
||||
labels: longestCurve?.map((p: any) => p.date) || [],
|
||||
datasets: PRESET_CHART_CONFIG
|
||||
.filter(cfg => compareResults.presets?.[cfg.key])
|
||||
.map(cfg => ({
|
||||
label: cfg.label,
|
||||
data: compareResults.presets[cfg.key].equity_curve?.map((p: any) => p.return_pct * 100) || [],
|
||||
borderColor: cfg.color,
|
||||
backgroundColor: cfg.bg,
|
||||
})),
|
||||
} : null
|
||||
|
||||
return (
|
||||
@@ -251,15 +344,61 @@ function StrategyTestTab() {
|
||||
<BarChart3 size={20} className="text-purple-400" />
|
||||
프리셋 비교 백테스트
|
||||
</h3>
|
||||
|
||||
{/* 종목 선택 모드 토글 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setMode('manual')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
mode === 'manual'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white border border-slate-700'
|
||||
}`}
|
||||
>
|
||||
직접 입력
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('universe')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 ${
|
||||
mode === 'universe'
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white border border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Globe size={12} />
|
||||
전체 종목 (유니버스)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">종목 코드</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stockCodes}
|
||||
onChange={(e) => setStockCodes(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||
/>
|
||||
{mode === 'manual' ? (
|
||||
<>
|
||||
<label className="block text-xs text-slate-400 mb-1">종목 코드</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stockCodes}
|
||||
onChange={(e) => setStockCodes(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||
placeholder="005930,000660,035720"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-xs text-slate-400 mb-1">종목 유니버스</label>
|
||||
<select
|
||||
value={universePreset}
|
||||
onChange={(e) => setUniversePreset(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||
>
|
||||
{universePresets && Object.entries(universePresets).map(([key, preset]: any) => (
|
||||
<option key={key} value={key}>
|
||||
{preset.description || key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">시작일</label>
|
||||
@@ -280,19 +419,39 @@ function StrategyTestTab() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => compareMutation.mutate()}
|
||||
disabled={compareMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium mt-4 disabled:opacity-50"
|
||||
>
|
||||
<Play size={16} />
|
||||
{compareMutation.isPending ? '실행 중...' : '백테스트 실행'}
|
||||
</button>
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<button
|
||||
onClick={() => compareMutation.mutate()}
|
||||
disabled={compareMutation.isPending}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg font-medium disabled:opacity-50 ${
|
||||
mode === 'universe'
|
||||
? 'bg-orange-600 hover:bg-orange-700'
|
||||
: 'bg-purple-600 hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
<Play size={16} />
|
||||
{compareMutation.isPending ? '실행 중...' : '백테스트 실행'}
|
||||
</button>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeCurrentSettings}
|
||||
onChange={(e) => setIncludeCurrentSettings(e.target.checked)}
|
||||
className="w-4 h-4 rounded accent-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-300">현재 설정도 비교에 포함</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결과 비교 */}
|
||||
{compareResults && (
|
||||
<>
|
||||
{compareResults.universe_preset && (
|
||||
<div className="px-4 py-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-xs text-orange-400">
|
||||
유니버스: {compareResults.universe_preset} — {compareResults.stock_count}종목 대상
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Object.entries(compareResults.presets || {}).map(([key, result]: any) => (
|
||||
<div key={key} className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||
@@ -330,7 +489,7 @@ function StrategyTestTab() {
|
||||
{/* 차트 */}
|
||||
{chartData && (
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||
<h3 className="text-lg font-bold text-white mb-4">수익 곱선 비교</h3>
|
||||
<h3 className="text-lg font-bold text-white mb-4">수익 곡선 비교</h3>
|
||||
<Line
|
||||
data={chartData}
|
||||
options={{
|
||||
@@ -419,6 +578,7 @@ function UniverseBacktestTab() {
|
||||
<option value="conservative">보수적</option>
|
||||
<option value="balanced">균형형</option>
|
||||
<option value="aggressive">공격적</option>
|
||||
<option value="current">현재 설정 (DB)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,6 +628,16 @@ function UniverseBacktestTab() {
|
||||
<>
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||
<h3 className="text-lg font-bold text-white mb-4">테스트 개요</h3>
|
||||
{result.total_tested === 0 && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-400 text-sm">
|
||||
선택한 유니버스의 종목들이 DB에 가격 데이터가 없습니다. 해당 기간 동안 시스템이 모니터링한 종목만 백테스트가 가능합니다.
|
||||
</div>
|
||||
)}
|
||||
{result.no_data_count > 0 && result.total_tested > 0 && (
|
||||
<div className="mb-4 px-4 py-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-400 text-xs">
|
||||
DB 데이터 없음: {result.no_data_count}종목 제외됨 (실제 테스트: {result.total_tested}종목)
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-slate-800 rounded-lg p-4">
|
||||
<p className="text-xs text-slate-400">테스트 종목 수</p>
|
||||
|
||||
@@ -52,11 +52,21 @@ const GROUP_ICONS: Record<string, string> = {
|
||||
|
||||
// 핵심 설정 파라미터 (기본 확장)
|
||||
const CORE_PARAMS = [
|
||||
'MEAN_REVERSION_STOP_LOSS_PCT',
|
||||
'MEAN_REVERSION_TAKE_PROFIT_PCT',
|
||||
'MAX_TOTAL_POSITIONS',
|
||||
// 리스크
|
||||
'STOP_LOSS_PERCENT',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'MAX_HOLDINGS_COUNT',
|
||||
'MAX_POSITION_SIZE',
|
||||
'MAX_DAILY_TRADES',
|
||||
'MDD_PAUSE_THRESHOLD',
|
||||
'MDD_FORCE_THRESHOLD',
|
||||
// 전략
|
||||
'MEAN_REVERSION_RSI_THRESHOLD',
|
||||
'RSI_OVERSOLD_LEVEL',
|
||||
'ATR_STOP_MULTIPLIER',
|
||||
// 기능 토글
|
||||
'DAY_TRADE_ENABLED',
|
||||
'ML_ENSEMBLE_ENABLED',
|
||||
]
|
||||
|
||||
// ── 프리셋 카드 컴포넌트 ────────────────────────────────────────
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import { format, differenceInMinutes } from 'date-fns'
|
||||
import { ko } from 'date-fns/locale'
|
||||
import { TrendingUp, TrendingDown, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { AlertCircle, List, BarChart2 } from 'lucide-react'
|
||||
import HtsPage from '../components/HtsPage'
|
||||
import api from '../lib/api'
|
||||
import { useState, Fragment } from 'react'
|
||||
|
||||
interface Trade {
|
||||
id: string
|
||||
created_at: string
|
||||
executed_at?: string
|
||||
status: 'EXECUTED' | 'FAILED' | 'PENDING' | string
|
||||
status: string
|
||||
stock_code: string
|
||||
stock_name?: string
|
||||
order_type: 'BUY' | 'SELL'
|
||||
@@ -26,176 +27,433 @@ interface Trade {
|
||||
profit_loss_rate?: number | null
|
||||
}
|
||||
|
||||
const TradeHistory = () => {
|
||||
const {
|
||||
data: trades = [],
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<Trade[]>({
|
||||
queryKey: ['trade-history'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/trades', {
|
||||
params: { limit: 100, days: 30 },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
// exit_reason 텍스트에서 손익 파싱 (예: "손익: 24,000원", "손익: -5,000원")
|
||||
function parseProfitFromReason(exitReason: string | null | undefined): { amount: number | null; rate: number | null } {
|
||||
if (!exitReason) return { amount: null, rate: null }
|
||||
const amountMatch = exitReason.match(/손익:\s*([-\d,]+)원/)
|
||||
const rateMatch = exitReason.match(/\(([-+]?\d+\.?\d*)%/)
|
||||
return {
|
||||
amount: amountMatch ? parseInt(amountMatch[1].replace(/,/g, ''), 10) : null,
|
||||
rate: rateMatch ? parseFloat(rateMatch[1]) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: Trade['status']) => {
|
||||
switch (status) {
|
||||
case 'EXECUTED':
|
||||
return <CheckCircle className="text-success" size={20} />
|
||||
case 'FAILED':
|
||||
return <XCircle className="text-danger" size={20} />
|
||||
case 'PENDING':
|
||||
return <Clock className="text-warning" size={20} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
const fmtKRW = (v: number) =>
|
||||
new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW', maximumFractionDigits: 0 }).format(v)
|
||||
|
||||
const fmtPrice = (v: number | null | string) => {
|
||||
const n = v == null ? null : Number(v)
|
||||
return n == null ? '-' : fmtKRW(n)
|
||||
}
|
||||
|
||||
|
||||
// ── 종목별 뷰: 매수→매도 페어 매칭 ──────────────────────────────
|
||||
interface TradePair {
|
||||
stockCode: string
|
||||
stockName: string
|
||||
buy: Trade
|
||||
sell: Trade | null // null = 미청산
|
||||
holdMinutes: number | null
|
||||
pl: { amount: number | null; rate: number | null }
|
||||
}
|
||||
|
||||
function buildPairs(trades: Trade[]): TradePair[] {
|
||||
// 종목별로 분리, 시간순 정렬
|
||||
const byStock: Record<string, Trade[]> = {}
|
||||
for (const t of [...trades].reverse()) { // 오래된 순
|
||||
if (!byStock[t.stock_code]) byStock[t.stock_code] = []
|
||||
byStock[t.stock_code].push(t)
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value == null) {
|
||||
return '시장가'
|
||||
}
|
||||
const pairs: TradePair[] = []
|
||||
for (const [code, list] of Object.entries(byStock)) {
|
||||
const buys = list.filter(t => t.order_type === 'BUY')
|
||||
const sells = list.filter(t => t.order_type === 'SELL')
|
||||
const name = list[0].stock_name ?? code
|
||||
const usedSells = new Set<string>()
|
||||
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: 'KRW',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
for (const buy of buys) {
|
||||
// 매수 이후 가장 가까운 매도 찾기
|
||||
const sell = sells.find(s =>
|
||||
!usedSells.has(s.id) &&
|
||||
new Date(s.created_at) >= new Date(buy.created_at)
|
||||
) ?? null
|
||||
|
||||
if (sell) usedSells.add(sell.id)
|
||||
|
||||
const holdMinutes = sell
|
||||
? differenceInMinutes(new Date(sell.created_at), new Date(buy.created_at))
|
||||
: null
|
||||
|
||||
const pl = sell
|
||||
? (sell.profit_loss != null
|
||||
? { amount: Number(sell.profit_loss), rate: sell.profit_loss_rate != null ? Number(sell.profit_loss_rate) : null }
|
||||
: parseProfitFromReason(sell.exit_reason))
|
||||
: { amount: null, rate: null }
|
||||
|
||||
pairs.push({ stockCode: code, stockName: name, buy, sell, holdMinutes, pl })
|
||||
}
|
||||
}
|
||||
// 최신 매수 기준 내림차순
|
||||
return pairs.sort((a, b) =>
|
||||
new Date(b.buy.created_at).getTime() - new Date(a.buy.created_at).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function fmtHold(minutes: number | null) {
|
||||
if (minutes == null) return '보유중'
|
||||
if (minutes < 60) return `${minutes}분`
|
||||
if (minutes < 1440) return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분`
|
||||
return `${Math.floor(minutes / 1440)}일`
|
||||
}
|
||||
|
||||
// ── 종목별 페어 테이블 ────────────────────────────────────────────
|
||||
function PairView({ pairs }: { pairs: TradePair[] }) {
|
||||
if (!pairs.length) return <div className="text-center py-16 text-slate-500 text-sm">거래 이력이 없습니다.</div>
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/80">
|
||||
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">종목</th>
|
||||
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">매수</th>
|
||||
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs">매수가</th>
|
||||
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">매도</th>
|
||||
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs">매도가</th>
|
||||
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs">수량</th>
|
||||
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs">보유기간</th>
|
||||
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs w-28">손익</th>
|
||||
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">매도 이유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{pairs.map((p) => {
|
||||
const isOpen = p.sell === null
|
||||
const plColor = p.pl.amount == null ? '' : p.pl.amount >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||
return (
|
||||
<tr key={p.buy.id} className="hover:bg-slate-800/40 transition-colors border-l-2 border-l-blue-500/40">
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="font-semibold text-white">
|
||||
{p.stockName ? `${p.stockName}(${p.stockCode})` : p.stockCode}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-xs text-blue-300 whitespace-nowrap">
|
||||
{format(new Date(p.buy.created_at), 'MM/dd HH:mm', { locale: ko })}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right text-white tabular-nums">
|
||||
{fmtPrice(p.buy.executed_price ?? p.buy.order_price)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-xs whitespace-nowrap">
|
||||
{isOpen
|
||||
? <span className="text-yellow-400 font-medium">미청산</span>
|
||||
: <span className="text-red-300">{format(new Date(p.sell!.created_at), 'MM/dd HH:mm', { locale: ko })}</span>
|
||||
}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right tabular-nums text-slate-300">
|
||||
{isOpen ? '-' : fmtPrice(p.sell!.executed_price ?? p.sell!.order_price)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right text-slate-300 tabular-nums">
|
||||
{(p.buy.executed_quantity ?? p.buy.order_quantity)?.toLocaleString()}주
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right text-slate-400 text-xs tabular-nums">
|
||||
{fmtHold(p.holdMinutes)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right tabular-nums">
|
||||
{!isOpen && p.pl.amount != null ? (
|
||||
<div>
|
||||
<div className={`font-bold ${plColor}`}>
|
||||
{p.pl.amount >= 0 ? '+' : ''}{fmtKRW(p.pl.amount)}
|
||||
</div>
|
||||
{p.pl.rate != null && (
|
||||
<div className={`text-[11px] ${p.pl.rate >= 0 ? 'text-emerald-500' : 'text-red-500'}`}>
|
||||
({p.pl.rate >= 0 ? '+' : ''}{p.pl.rate.toFixed(2)}%)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-600">{isOpen ? '보유중' : '-'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 max-w-xs">
|
||||
<span className="text-[11px] text-slate-400 line-clamp-2">
|
||||
{isOpen
|
||||
? (p.buy.entry_reason || p.buy.message || '-')
|
||||
: (p.sell!.exit_reason || p.sell!.message || '-')
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 일별 총합 뷰 ─────────────────────────────────────────────────
|
||||
interface DailySummary {
|
||||
date: string
|
||||
buyCount: number
|
||||
sellCount: number
|
||||
totalPnl: number
|
||||
wins: number
|
||||
losses: number
|
||||
stocks: string[]
|
||||
}
|
||||
|
||||
function buildDailySummary(trades: Trade[]): DailySummary[] {
|
||||
const byDate: Record<string, DailySummary> = {}
|
||||
for (const t of trades) {
|
||||
const date = t.created_at.slice(0, 10)
|
||||
if (!byDate[date]) byDate[date] = { date, buyCount: 0, sellCount: 0, totalPnl: 0, wins: 0, losses: 0, stocks: [] }
|
||||
const s = byDate[date]
|
||||
if (t.order_type === 'BUY') {
|
||||
s.buyCount++
|
||||
} else {
|
||||
s.sellCount++
|
||||
const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount
|
||||
if (pl != null) { s.totalPnl += pl; if (pl > 0) s.wins++; else if (pl < 0) s.losses++ }
|
||||
}
|
||||
if (!s.stocks.includes(t.stock_code)) s.stocks.push(t.stock_code)
|
||||
}
|
||||
return Object.values(byDate).sort((a, b) => b.date.localeCompare(a.date))
|
||||
}
|
||||
|
||||
function DailySummaryView({ trades }: { trades: Trade[] }) {
|
||||
const [expandedDate, setExpandedDate] = useState<string | null>(null)
|
||||
const summaries = buildDailySummary(trades)
|
||||
|
||||
if (!summaries.length) return <div className="text-center py-16 text-slate-500 text-sm">거래 이력이 없습니다.</div>
|
||||
|
||||
const getStockBreakdown = (date: string) => {
|
||||
const byStock: Record<string, { name: string; buys: number; sells: number; pnl: number; hasPnl: boolean }> = {}
|
||||
for (const t of trades.filter(t => t.created_at.slice(0, 10) === date)) {
|
||||
const code = t.stock_code
|
||||
if (!byStock[code]) byStock[code] = { name: t.stock_name ?? code, buys: 0, sells: 0, pnl: 0, hasPnl: false }
|
||||
if (t.order_type === 'BUY') {
|
||||
byStock[code].buys++
|
||||
} else {
|
||||
byStock[code].sells++
|
||||
const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount
|
||||
if (pl != null) { byStock[code].pnl += pl; byStock[code].hasPnl = true }
|
||||
}
|
||||
}
|
||||
return Object.entries(byStock).map(([code, v]) => ({ code, ...v }))
|
||||
}
|
||||
|
||||
return (
|
||||
<HtsPage contentClassName="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold text-white">매매 로그</h2>
|
||||
{isLoading && <span className="text-sm text-slate-400">데이터 갱신 중...</span>}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/80">
|
||||
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">날짜</th>
|
||||
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">거래 종목 (클릭하여 상세보기)</th>
|
||||
<th className="text-center py-2.5 px-3 text-slate-500 font-medium text-xs">매수</th>
|
||||
<th className="text-center py-2.5 px-3 text-slate-500 font-medium text-xs">매도</th>
|
||||
<th className="text-center py-2.5 px-3 text-slate-500 font-medium text-xs">승 / 패</th>
|
||||
<th className="text-center py-2.5 px-3 text-slate-500 font-medium text-xs">승률</th>
|
||||
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs w-36">실현 손익</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{summaries.map(s => {
|
||||
const total = s.wins + s.losses
|
||||
const wr = total > 0 ? ((s.wins / total) * 100).toFixed(0) + '%' : '-'
|
||||
const pnlColor = s.totalPnl > 0 ? 'text-emerald-400' : s.totalPnl < 0 ? 'text-red-400' : 'text-slate-400'
|
||||
const hasPnl = s.wins > 0 || s.losses > 0
|
||||
const isExpanded = expandedDate === s.date
|
||||
return (
|
||||
<Fragment key={s.date}>
|
||||
<tr className={`transition-colors ${isExpanded ? 'bg-slate-800/30' : 'hover:bg-slate-800/40'}`}>
|
||||
<td className="py-3 px-3 text-slate-300 whitespace-nowrap text-xs font-medium">
|
||||
{format(new Date(s.date + 'T00:00:00'), 'yyyy/MM/dd (EEE)', { locale: ko })}
|
||||
</td>
|
||||
<td
|
||||
className="py-3 px-3 text-xs cursor-pointer select-none group"
|
||||
onClick={() => setExpandedDate(isExpanded ? null : s.date)}
|
||||
>
|
||||
<span className="text-white font-medium group-hover:text-blue-400 transition-colors">
|
||||
{s.stocks.length}종목 <span className="text-slate-500 text-[10px]">{isExpanded ? '▲' : '▼'}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-slate-500">
|
||||
{s.stocks.slice(0, 5).join(', ')}{s.stocks.length > 5 ? ` 외 ${s.stocks.length - 5}` : ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center text-blue-400 tabular-nums">{s.buyCount}건</td>
|
||||
<td className="py-3 px-3 text-center text-red-400 tabular-nums">{s.sellCount}건</td>
|
||||
<td className="py-3 px-3 text-center tabular-nums text-xs">
|
||||
<span className="text-emerald-400">{s.wins}승</span>
|
||||
<span className="text-slate-600 mx-1">/</span>
|
||||
<span className="text-red-400">{s.losses}패</span>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center text-yellow-400 tabular-nums text-xs">{wr}</td>
|
||||
<td className={`py-3 px-3 text-right font-bold tabular-nums ${pnlColor}`}>
|
||||
{!hasPnl ? '-' : (s.totalPnl >= 0 ? '+' : '') + fmtKRW(s.totalPnl)}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan={7} className="bg-slate-950/70 px-6 py-4 border-b border-slate-800">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{getStockBreakdown(s.date).map(st => {
|
||||
const pnlC = st.pnl > 0 ? 'text-emerald-400' : st.pnl < 0 ? 'text-red-400' : 'text-slate-500'
|
||||
return (
|
||||
<div key={st.code} className="bg-slate-800/60 border border-slate-700/50 rounded-lg px-3 py-2.5">
|
||||
<div className="font-semibold text-white text-xs mb-2 truncate">
|
||||
{st.name !== st.code ? `${st.name}(${st.code})` : st.code}
|
||||
</div>
|
||||
<div className="flex gap-2 text-[11px] mb-1.5">
|
||||
<span className="text-blue-400">매수 {st.buys}건</span>
|
||||
<span className="text-slate-600">·</span>
|
||||
<span className="text-red-400">매도 {st.sells}건</span>
|
||||
</div>
|
||||
<div className={`font-bold text-xs ${pnlC}`}>
|
||||
{!st.hasPnl ? <span className="text-slate-600">-</span> : (st.pnl >= 0 ? '+' : '') + fmtKRW(st.pnl)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
const TradeHistory = () => {
|
||||
const [viewMode, setViewMode] = useState<'log' | 'pair'>('pair')
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
const [dateFrom, setDateFrom] = useState<string>(today)
|
||||
const [dateTo, setDateTo] = useState<string>(today)
|
||||
|
||||
const { data: trades = [], isLoading, isError } = useQuery<Trade[]>({
|
||||
queryKey: ['trade-history'],
|
||||
queryFn: async () => (await api.get('/trades', { params: { limit: 200, days: 30 } })).data,
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
|
||||
// 날짜 범위 필터
|
||||
const filteredTrades = trades.filter(t => {
|
||||
const d = t.created_at.slice(0, 10)
|
||||
if (dateFrom && d < dateFrom) return false
|
||||
if (dateTo && d > dateTo) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 요약 통계
|
||||
const sells = filteredTrades.filter(t => t.order_type === 'SELL')
|
||||
const buys = filteredTrades.filter(t => t.order_type === 'BUY')
|
||||
const totalPnl = sells.reduce((acc, t) => {
|
||||
const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount
|
||||
return acc + (pl ?? 0)
|
||||
}, 0)
|
||||
const wins = sells.filter(t => {
|
||||
const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount
|
||||
return pl != null && pl > 0
|
||||
}).length
|
||||
const winRate = sells.length > 0 ? ((wins / sells.length) * 100).toFixed(1) : '-'
|
||||
|
||||
const pairs = buildPairs(filteredTrades)
|
||||
|
||||
return (
|
||||
<HtsPage contentClassName="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold text-white">매매 로그</h2>
|
||||
{isLoading && <span className="text-xs text-slate-500">갱신 중...</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* 뷰 토글 */}
|
||||
<div className="flex items-center bg-slate-800 rounded-lg p-0.5 gap-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('pair')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
viewMode === 'pair' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<BarChart2 size={13} /> 종목별
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('log')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
viewMode === 'log' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<List size={13} /> 전체 로그
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="card border border-danger/40 bg-danger/10 text-danger">
|
||||
최근 거래 내역을 불러오는 중 문제가 발생했습니다.
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-red-400 text-sm">
|
||||
<AlertCircle size={15} /> 거래 내역을 불러오는 중 오류가 발생했습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700">
|
||||
<th className="text-left py-3 px-4 text-slate-400">시간</th>
|
||||
<th className="text-left py-3 px-4 text-slate-400">상태</th>
|
||||
<th className="text-left py-3 px-4 text-slate-400">종목</th>
|
||||
<th className="text-center py-3 px-4 text-slate-400">구분</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400">가격</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400">수량</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400">총액</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400">손익</th>
|
||||
<th className="text-left py-3 px-4 text-slate-400">이유/신호</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((trade) => (
|
||||
<tr key={trade.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
|
||||
<td className="py-3 px-4 text-slate-300 text-sm whitespace-nowrap">
|
||||
{format(new Date(trade.created_at), 'MM/dd HH:mm:ss', { locale: ko })}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(trade.status)}
|
||||
<span className="text-sm text-slate-300">{trade.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<div className="text-white font-medium">{trade.stock_code}</div>
|
||||
{trade.stock_name && (
|
||||
<div className="text-xs text-slate-400">{trade.stock_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${trade.order_type === 'BUY'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-danger/20 text-danger'
|
||||
}`}
|
||||
>
|
||||
{trade.order_type === 'BUY' ? (
|
||||
<>
|
||||
<TrendingUp size={12} className="mr-1" />
|
||||
매수
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown size={12} className="mr-1" />
|
||||
매도
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{formatCurrency(trade.executed_price || trade.order_price)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{(trade.executed_quantity || trade.order_quantity).toLocaleString()}주
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white font-medium">
|
||||
{trade.total_amount != null ? formatCurrency(trade.total_amount) : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{trade.order_type === 'SELL' && trade.profit_loss != null ? (
|
||||
<div className="space-y-0.5">
|
||||
<div className={`font-medium ${Number(trade.profit_loss) >= 0 ? 'text-primary' : 'text-danger'
|
||||
}`}>
|
||||
{Number(trade.profit_loss) >= 0 ? '+' : ''}{formatCurrency(Number(trade.profit_loss))}
|
||||
</div>
|
||||
{trade.profit_loss_rate != null && (
|
||||
<div className={`text-xs ${Number(trade.profit_loss_rate) >= 0 ? 'text-primary' : 'text-danger'
|
||||
}`}>
|
||||
({Number(trade.profit_loss_rate) >= 0 ? '+' : ''}{Number(trade.profit_loss_rate).toFixed(2)}%)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-400 text-sm max-w-md">
|
||||
{trade.order_type === 'BUY' && trade.entry_reason && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-primary text-xs font-medium">📈 진입 이유</div>
|
||||
<div>{trade.entry_reason}</div>
|
||||
{trade.signals && trade.signals.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{trade.signals.slice(0, 3).map((signal, idx) => (
|
||||
<span key={idx} className="text-xs bg-slate-700 px-2 py-0.5 rounded">
|
||||
{signal}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{trade.order_type === 'SELL' && trade.exit_reason && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-danger text-xs font-medium">📉 매도 이유</div>
|
||||
<div>{trade.exit_reason}</div>
|
||||
</div>
|
||||
)}
|
||||
{!trade.entry_reason && !trade.exit_reason && (trade.message || '-')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{[
|
||||
{ label: '총 거래', value: filteredTrades.length + '건', cls: 'text-white' },
|
||||
{ label: '매수', value: buys.length + '건', cls: 'text-blue-400' },
|
||||
{ label: '매도', value: sells.length + '건', cls: 'text-red-400' },
|
||||
{
|
||||
label: '실현 손익',
|
||||
value: totalPnl === 0 ? '-' : (totalPnl > 0 ? '+' : '') + fmtKRW(totalPnl),
|
||||
cls: totalPnl > 0 ? 'text-emerald-400' : totalPnl < 0 ? 'text-red-400' : 'text-slate-400',
|
||||
},
|
||||
{ label: '승률', value: winRate === '-' ? '-' : winRate + '%', cls: 'text-yellow-400' },
|
||||
].map(({ label, value, cls }) => (
|
||||
<div key={label} className="bg-slate-900/60 border border-slate-800 rounded-xl px-4 py-3">
|
||||
<div className="text-xs text-slate-500 mb-1">{label}</div>
|
||||
<div className={`text-lg font-bold ${cls}`}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!trades.length && !isLoading && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400">거래 이력이 없습니다.</p>
|
||||
</div>
|
||||
{/* 검색 필터 */}
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-xl px-4 py-3 flex items-center gap-3">
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap">기간 조회</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="bg-slate-800 text-white text-xs rounded px-2 py-1.5 border border-slate-700 outline-none focus:border-slate-500"
|
||||
/>
|
||||
<span className="text-slate-500 text-xs">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="bg-slate-800 text-white text-xs rounded px-2 py-1.5 border border-slate-700 outline-none focus:border-slate-500"
|
||||
/>
|
||||
{(dateFrom || dateTo) && (
|
||||
<button
|
||||
onClick={() => { setDateFrom(''); setDateTo('') }}
|
||||
className="text-xs text-slate-400 hover:text-white transition-colors px-2 py-1 rounded bg-slate-800 border border-slate-700"
|
||||
>초기화</button>
|
||||
)}
|
||||
</div>
|
||||
{(dateFrom || dateTo) && (
|
||||
<span className="text-xs text-slate-500">
|
||||
{filteredTrades.length}건 검색됨
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
{viewMode === 'pair' ? (
|
||||
<PairView pairs={pairs} />
|
||||
) : (
|
||||
<DailySummaryView trades={filteredTrades} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user