fix: add KJB strategy to frontend strategy list and backtest form
All checks were successful
Deploy to Production / deploy (push) Successful in 1m37s

- Add KJB card to strategy page with Zap icon
- Create /strategy/kjb detail page with ranking table and rules reference
- Add KJB option to backtest strategy dropdown
- Add KJB-specific params UI (max positions, cash reserve, stop-loss, targets)
- Hide rebalance period selector when KJB is selected (uses daily simulation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-19 15:53:01 +09:00
parent 7150227c88
commit e6160fffc6
3 changed files with 301 additions and 2 deletions

View File

@ -60,6 +60,7 @@ const strategyOptions = [
{ value: 'multi_factor', label: '멀티 팩터' }, { value: 'multi_factor', label: '멀티 팩터' },
{ value: 'quality', label: '슈퍼 퀄리티' }, { value: 'quality', label: '슈퍼 퀄리티' },
{ value: 'value_momentum', label: '밸류 모멘텀' }, { value: 'value_momentum', label: '밸류 모멘텀' },
{ value: 'kjb', label: '김종봉 단기매매' },
]; ];
const periodOptions = [ const periodOptions = [
@ -98,6 +99,13 @@ export default function BacktestPage() {
const [vmValueWeight, setVmValueWeight] = useState(0.5); const [vmValueWeight, setVmValueWeight] = useState(0.5);
const [vmMomentumWeight, setVmMomentumWeight] = useState(0.5); const [vmMomentumWeight, setVmMomentumWeight] = useState(0.5);
// KJB params
const [kjbMaxPositions, setKjbMaxPositions] = useState(10);
const [kjbCashReserve, setKjbCashReserve] = useState(0.3);
const [kjbStopLoss, setKjbStopLoss] = useState(0.03);
const [kjbTarget1, setKjbTarget1] = useState(0.05);
const [kjbTarget2, setKjbTarget2] = useState(0.10);
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
try { try {
@ -167,6 +175,14 @@ export default function BacktestPage() {
value_weight: vmValueWeight, value_weight: vmValueWeight,
momentum_weight: vmMomentumWeight, momentum_weight: vmMomentumWeight,
}; };
} else if (strategyType === 'kjb') {
strategyParams = {
max_positions: kjbMaxPositions,
cash_reserve_ratio: kjbCashReserve,
stop_loss_pct: kjbStopLoss,
target1_pct: kjbTarget1,
target2_pct: kjbTarget2,
};
} }
const response = await api.post<{ id: number }>('/api/backtest', { const response = await api.post<{ id: number }>('/api/backtest', {
@ -379,7 +395,8 @@ export default function BacktestPage() {
</div> </div>
</div> </div>
{/* Rebalancing Period */} {/* Rebalancing Period (not for KJB) */}
{strategyType !== 'kjb' && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="period"> </Label> <Label htmlFor="period"> </Label>
<Select value={rebalancePeriod} onValueChange={setRebalancePeriod}> <Select value={rebalancePeriod} onValueChange={setRebalancePeriod}>
@ -395,6 +412,7 @@ export default function BacktestPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
{/* Initial Capital & Top N */} {/* Initial Capital & Top N */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@ -514,6 +532,80 @@ export default function BacktestPage() {
</div> </div>
)} )}
{strategyType === 'kjb' && (
<div className="space-y-3 p-3 bg-muted/50 rounded-lg">
<Label className="text-sm font-medium">KJB </Label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="kjb-max-pos" className="text-xs"> </Label>
<Input
id="kjb-max-pos"
type="number"
min="1"
max="30"
value={kjbMaxPositions}
onChange={(e) => setKjbMaxPositions(parseInt(e.target.value))}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label htmlFor="kjb-cash" className="text-xs"> </Label>
<Input
id="kjb-cash"
type="number"
step="0.05"
min="0"
max="0.9"
value={kjbCashReserve}
onChange={(e) => setKjbCashReserve(parseFloat(e.target.value))}
className="h-8"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label htmlFor="kjb-sl" className="text-xs"> %</Label>
<Input
id="kjb-sl"
type="number"
step="0.01"
min="0.01"
max="0.2"
value={kjbStopLoss}
onChange={(e) => setKjbStopLoss(parseFloat(e.target.value))}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label htmlFor="kjb-t1" className="text-xs">1 %</Label>
<Input
id="kjb-t1"
type="number"
step="0.01"
min="0.01"
max="0.5"
value={kjbTarget1}
onChange={(e) => setKjbTarget1(parseFloat(e.target.value))}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label htmlFor="kjb-t2" className="text-xs">2 %</Label>
<Input
id="kjb-t2"
type="number"
step="0.01"
min="0.01"
max="1"
value={kjbTarget2}
onChange={(e) => setKjbTarget2(parseFloat(e.target.value))}
className="h-8"
/>
</div>
</div>
</div>
)}
{/* Advanced options toggle */} {/* Advanced options toggle */}
<button <button
type="button" type="button"

View File

@ -0,0 +1,198 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
interface StockFactor {
ticker: string;
name: string;
market: string;
sector_name: string | null;
market_cap: number | null;
close_price: number | null;
per: number | null;
pbr: number | null;
dividend_yield: number | null;
value_score: number | null;
momentum_score: number | null;
total_score: number | null;
rank: number | null;
}
interface StrategyResult {
strategy_name: string;
base_date: string;
universe_count: number;
result_count: number;
stocks: StockFactor[];
}
export default function KJBStrategyPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [result, setResult] = useState<StrategyResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [topN, setTopN] = useState(30);
useEffect(() => {
const init = async () => {
try {
await api.getCurrentUser();
} catch {
router.push('/login');
} finally {
setInitialLoading(false);
}
};
init();
}, [router]);
const runStrategy = async () => {
setLoading(true);
setError(null);
try {
const data = await api.post<StrategyResult>('/api/strategy/kjb', {
universe: {
markets: ['KOSPI'],
},
top_n: topN,
});
setResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Strategy execution failed');
} finally {
setLoading(false);
}
};
const formatNumber = (value: number | null, decimals: number = 2) => {
if (value === null) return '-';
return value.toFixed(decimals);
};
const formatCurrency = (value: number | null) => {
if (value === null) return '-';
return new Intl.NumberFormat('ko-KR').format(value);
};
if (initialLoading) {
return (
<DashboardLayout>
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-48 rounded-xl" />
</DashboardLayout>
);
}
return (
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"> </h1>
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="space-y-2">
<Label htmlFor="top-n"> </Label>
<Input
id="top-n"
type="number"
min="1"
max="30"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-sm"></Label>
<p className="text-sm">KOSPI 30 </p>
</div>
</div>
<Button onClick={runStrategy} disabled={loading}>
{loading ? '실행 중...' : '전략 실행'}
</Button>
</CardContent>
</Card>
{/* Rules Reference */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-1">
<p><strong>:</strong> RS {'>'} 100 (KOSPI ) AND (20 OR )</p>
<p><strong>:</strong> -3%</p>
<p><strong>1 :</strong> +5% 50% , </p>
<p><strong>2 :</strong> +10% </p>
<p><strong> :</strong> 30% , 10</p>
</CardContent>
</Card>
{/* Results */}
{result && (
<Card>
<CardHeader>
<CardTitle>
({result.result_count}/{result.universe_count} )
</CardTitle>
<p className="text-sm text-muted-foreground">: {result.base_date}</p>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">()</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PBR</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<span className="font-medium" title={stock.ticker}>{stock.name || stock.ticker}</span>
</td>
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</DashboardLayout>
);
}

View File

@ -6,7 +6,7 @@ import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { StrategyCard } from '@/components/strategy/strategy-card'; import { StrategyCard } from '@/components/strategy/strategy-card';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { BarChart3, Star, TrendingUp } from 'lucide-react'; import { BarChart3, Star, TrendingUp, Zap } from 'lucide-react';
const strategies = [ const strategies = [
{ {
@ -36,6 +36,15 @@ const strategies = [
risk: 'high' as const, risk: 'high' as const,
stockCount: 25, stockCount: 25,
}, },
{
id: 'kjb',
title: '김종봉 단기매매',
description: 'KOSPI 대비 상대강도 + 박스권 돌파/장대양봉 기반 단기 트레이딩 전략입니다. 손절 -3%, 익절 +5%/+10%.',
icon: Zap,
expectedCagr: '20-30%',
risk: 'high' as const,
stockCount: 30,
},
]; ];
export default function StrategyListPage() { export default function StrategyListPage() {