diff --git a/frontend/src/app/strategy/multi-factor/page.tsx b/frontend/src/app/strategy/multi-factor/page.tsx new file mode 100644 index 0000000..0a831c4 --- /dev/null +++ b/frontend/src/app/strategy/multi-factor/page.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [result, setResult] = useState(null); + const [error, setError] = useState(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('/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 ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+

멀티 팩터 전략

+ + {error && ( +
+ {error} +
+ )} + + {/* Settings */} +
+

전략 설정

+
+
+ + setValueWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setQualityWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setMomentumWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setTopN(parseInt(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ +
+ + {/* Results */} + {result && ( +
+
+

+ 결과 ({result.result_count}/{result.universe_count} 종목) +

+

기준일: {result.base_date}

+
+
+ + + + + + + + + + + + + + + + + + {result.stocks.map((stock) => ( + + + + + + + + + + + + + + ))} + +
순위종목섹터시가총액(억)현재가PERPBR밸류퀄리티모멘텀종합
{stock.rank} +
{stock.ticker}
+
{stock.name}
+
{stock.sector_name || '-'}{formatCurrency(stock.market_cap)}{formatCurrency(stock.close_price)}{formatNumber(stock.per)}{formatNumber(stock.pbr)}{formatNumber(stock.value_score)}{formatNumber(stock.quality_score)}{formatNumber(stock.momentum_score)}{formatNumber(stock.total_score)}
+
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/strategy/quality/page.tsx b/frontend/src/app/strategy/quality/page.tsx new file mode 100644 index 0000000..fed5e9e --- /dev/null +++ b/frontend/src/app/strategy/quality/page.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [result, setResult] = useState(null); + const [error, setError] = useState(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('/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 ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+

슈퍼 퀄리티 전략

+ + {error && ( +
+ {error} +
+ )} + + {/* Settings */} +
+

전략 설정

+
+
+ + setMinFscore(parseInt(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setTopN(parseInt(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ +
+ + {/* Results */} + {result && ( +
+
+

+ 결과 ({result.result_count}/{result.universe_count} 종목) +

+

기준일: {result.base_date}

+
+
+ + + + + + + + + + + + + + + + + {result.stocks.map((stock) => ( + + + + + + + + + + + + + ))} + +
순위종목섹터시가총액(억)현재가PERPBR배당률F-Score퀄리티
{stock.rank} +
{stock.ticker}
+
{stock.name}
+
{stock.sector_name || '-'}{formatCurrency(stock.market_cap)}{formatCurrency(stock.close_price)}{formatNumber(stock.per)}{formatNumber(stock.pbr)}{formatNumber(stock.dividend_yield)}% + = 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 + + {formatNumber(stock.quality_score)}
+
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/strategy/value-momentum/page.tsx b/frontend/src/app/strategy/value-momentum/page.tsx new file mode 100644 index 0000000..0569450 --- /dev/null +++ b/frontend/src/app/strategy/value-momentum/page.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [result, setResult] = useState(null); + const [error, setError] = useState(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('/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 ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+

밸류 모멘텀 전략

+ + {error && ( +
+ {error} +
+ )} + + {/* Settings */} +
+

전략 설정

+
+
+ + setValueWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setMomentumWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setTopN(parseInt(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ +
+ + {/* Results */} + {result && ( +
+
+

+ 결과 ({result.result_count}/{result.universe_count} 종목) +

+

기준일: {result.base_date}

+
+
+ + + + + + + + + + + + + + + + + {result.stocks.map((stock) => ( + + + + + + + + + + + + + ))} + +
순위종목섹터시가총액(억)현재가PERPBR밸류모멘텀종합
{stock.rank} +
{stock.ticker}
+
{stock.name}
+
{stock.sector_name || '-'}{formatCurrency(stock.market_cap)}{formatCurrency(stock.close_price)}{formatNumber(stock.per)}{formatNumber(stock.pbr)}{formatNumber(stock.value_score)}{formatNumber(stock.momentum_score)}{formatNumber(stock.total_score)}
+
+
+ )} +
+
+
+ ); +}