feat: add strategy pages (multi-factor, quality, value-momentum)

- Multi-factor strategy page with weight configuration
- Quality strategy page with F-Score filtering
- Value-momentum strategy page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-03 09:07:49 +09:00
parent 475a056bc8
commit aa2047a922
3 changed files with 693 additions and 0 deletions

View File

@ -0,0 +1,250 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';
interface User {
id: number;
username: string;
}
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;
value_score: number | null;
quality_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 MultiFactorPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [result, setResult] = useState<StrategyResult | null>(null);
const [error, setError] = useState<string | null>(null);
// Weights
const [valueWeight, setValueWeight] = useState(0.25);
const [qualityWeight, setQualityWeight] = useState(0.25);
const [momentumWeight, setMomentumWeight] = useState(0.25);
const [topN, setTopN] = useState(30);
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
} 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/multi-factor', {
universe: {
markets: ['KOSPI', 'KOSDAQ'],
exclude_stock_types: ['spac', 'preferred', 'reit'],
},
top_n: topN,
weights: {
value: valueWeight,
quality: qualityWeight,
momentum: momentumWeight,
low_vol: 1 - valueWeight - qualityWeight - momentumWeight,
},
});
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 (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"> </h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Settings */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div>
<label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="value-weight"
type="number"
min="0"
max="1"
step="0.05"
value={valueWeight}
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="quality-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="quality-weight"
type="number"
min="0"
max="1"
step="0.05"
value={qualityWeight}
onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="momentum-weight"
type="number"
min="0"
max="1"
step="0.05"
value={momentumWeight}
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>
<button
onClick={runStrategy}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? '실행 중...' : '전략 실행'}
</button>
</div>
{/* Results */}
{result && (
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">
({result.result_count}/{result.universe_count} )
</h2>
<p className="text-sm text-gray-500">: {result.base_date}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">()</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
</tr>
</thead>
<tbody className="divide-y">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<div className="font-medium">{stock.ticker}</div>
<div className="text-xs text-gray-500">{stock.name}</div>
</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.value_score)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.quality_score)}</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>
</div>
)}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,216 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';
interface User {
id: number;
username: string;
}
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;
quality_score: number | null;
fscore: number | null;
rank: number | null;
}
interface StrategyResult {
strategy_name: string;
base_date: string;
universe_count: number;
result_count: number;
stocks: StockFactor[];
}
export default function QualityStrategyPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
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 [minFscore, setMinFscore] = useState(7);
const [topN, setTopN] = useState(30);
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
} 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/quality', {
universe: {
markets: ['KOSPI', 'KOSDAQ'],
exclude_stock_types: ['spac', 'preferred', 'reit'],
},
top_n: topN,
min_fscore: minFscore,
});
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 (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"> </h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Settings */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label htmlFor="min-fscore" className="block text-sm font-medium text-gray-700 mb-1">
F-Score
</label>
<input
id="min-fscore"
type="number"
min="0"
max="9"
value={minFscore}
onChange={(e) => setMinFscore(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>
<button
onClick={runStrategy}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? '실행 중...' : '전략 실행'}
</button>
</div>
{/* Results */}
{result && (
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">
({result.result_count}/{result.universe_count} )
</h2>
<p className="text-sm text-gray-500">: {result.base_date}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">()</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600">F-Score</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
</tr>
</thead>
<tbody className="divide-y">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<div className="font-medium">{stock.ticker}</div>
<div className="text-xs text-gray-500">{stock.name}</div>
</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.dividend_yield)}%</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 rounded text-xs ${
(stock.fscore ?? 0) >= 8 ? 'bg-green-100 text-green-800' :
(stock.fscore ?? 0) >= 6 ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{stock.fscore}/9
</span>
</td>
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.quality_score)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,227 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';
interface User {
id: number;
username: string;
}
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 ValueMomentumPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
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 [valueWeight, setValueWeight] = useState(0.5);
const [momentumWeight, setMomentumWeight] = useState(0.5);
const [topN, setTopN] = useState(30);
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
} 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/value-momentum', {
universe: {
markets: ['KOSPI', 'KOSDAQ'],
exclude_stock_types: ['spac', 'preferred', 'reit'],
},
top_n: topN,
value_weight: valueWeight,
momentum_weight: momentumWeight,
});
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 (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"> </h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Settings */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="value-weight"
type="number"
min="0"
max="1"
step="0.1"
value={valueWeight}
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="momentum-weight"
type="number"
min="0"
max="1"
step="0.1"
value={momentumWeight}
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>
<button
onClick={runStrategy}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? '실행 중...' : '전략 실행'}
</button>
</div>
{/* Results */}
{result && (
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">
({result.result_count}/{result.universe_count} )
</h2>
<p className="text-sm text-gray-500">: {result.base_date}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">()</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
</tr>
</thead>
<tbody className="divide-y">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<div className="font-medium">{stock.ticker}</div>
<div className="text-xs text-gray-500">{stock.name}</div>
</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.value_score)}</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>
</div>
)}
</main>
</div>
</div>
);
}