snp500맵 배치 완료.

This commit is contained in:
eld_master 2025-10-13 00:50:05 +09:00
parent 27689133ec
commit 21d77f79da
2 changed files with 685 additions and 0 deletions

View File

@ -717,6 +717,75 @@ const ENERGY_INDUSTRIES = [
}
];
// UTILITIES 섹터 산업군 (256px × 258px)
const UTILITIES_INDUSTRIES = [
// 상단 70% (180.6px)
{
name: "UTILITIES - REGULATED ELECTRIC",
x1: 0, y1: 30, x2: 256, y2: 210.6,
isSmall: false
},
// 하단 30% (77.4px) - 3개를 1:1:1 가로 분할
{
name: "UTILITIES - RENEWABLE",
x1: 0, y1: 210.6, x2: 85.33, y2: 258,
isSmall: true
},
{
name: "UTILITIES - INDEPENDENT POWER PRODUCERS",
x1: 85.33, y1: 210.6, x2: 170.67, y2: 258,
isSmall: true
},
{
name: "UTILITIES - DIVERSIFIED",
x1: 170.67, y1: 210.6, x2: 256, y2: 258,
isSmall: true
}
];
// BASIC MATERIALS 섹터 산업군 (256px × 205px)
const BASIC_MATERIALS_INDUSTRIES = [
// 좌측 50% (128px) - 세로 전체
{
name: "SPECIALTY CHEMICALS",
x1: 0, y1: 30, x2: 128, y2: 205,
isSmall: false
},
// 우측 50% (128px)
// 상단 40% (70px) - 1:1 가로 분할
{
name: "GOLD",
x1: 128, y1: 30, x2: 192, y2: 100,
isSmall: true
},
{
name: "BUILDING MATERIALS",
x1: 192, y1: 30, x2: 256, y2: 100,
isSmall: true
},
// 하단 60% (105px) - 2×2 그리드
{
name: "AGRICULTURAL INPUTS",
x1: 128, y1: 100, x2: 192, y2: 152.5,
isSmall: true
},
{
name: "COPPER",
x1: 192, y1: 100, x2: 256, y2: 152.5,
isSmall: true
},
{
name: "STEEL",
x1: 128, y1: 152.5, x2: 192, y2: 205,
isSmall: true
},
{
name: "CHEMICALS",
x1: 192, y1: 152.5, x2: 256, y2: 205,
isSmall: true
}
];
// 날짜 형식 변환: YYYY-MM-DD → YYYYMMDD
function formatDateToAPI(dateStr: string): string {
return dateStr.replace(/-/g, "");
@ -6229,6 +6298,563 @@ export function SP500Page() {
);
})}
{/* UTILITIES 섹터 산업군 배치 */}
{box.name === "UTILITIES" &&
UTILITIES_INDUSTRIES.map((industry, idx) => {
const headerHeight = industry.isSmall ? 8 : 10;
return (
<div
key={idx}
className="industry-box"
style={{
position: "absolute",
left: `${industry.x1 + 1}px`,
top: `${industry.y1 + 1}px`,
width: `${industry.x2 - industry.x1 - 2}px`,
height: `${industry.y2 - industry.y1 - 2}px`,
border: "none",
background: "rgba(30, 40, 50, 0.6)",
overflow: "hidden",
transition: "all 0.2s ease",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "rgba(74, 144, 226, 1)";
e.currentTarget.style.background = "rgba(74, 144, 226, 0.15)";
e.currentTarget.style.boxShadow = "0 0 12px rgba(74, 144, 226, 0.5)";
e.currentTarget.style.transform = "scale(1.02)";
handleIndustryHover(industry.name, e);
}}
onMouseMove={handleMouseMove}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "rgba(74, 144, 226, 0.8)";
e.currentTarget.style.background = "rgba(30, 40, 50, 0.6)";
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.transform = "scale(1)";
handleIndustryLeave();
}}
>
{/* Industry 헤더 배경색 */}
<div
style={{
position: "absolute",
top: 0,
left: "0px",
width: `${industry.x2 - industry.x1 - 2}px`,
height: `${headerHeight}px`,
background: (() => {
if (!currentData || !previousData) return "rgba(75, 80, 85, 0.9)";
const stocks = Object.entries(currentData.data)
.filter(([, stock]) => stock.industry === industry.name)
.map(([ticker, currentStock]) => {
const previousStock = previousData.data[ticker];
if (!previousStock) return null;
const changePercent = ((currentStock.price - previousStock.price) / previousStock.price) * 100;
return { marketCap: currentStock.marketCap, changePercent };
})
.filter((s): s is { marketCap: number; changePercent: number } => s !== null);
if (stocks.length === 0) return "rgba(75, 80, 85, 0.9)";
const totalMarketCap = stocks.reduce((sum, s) => sum + s.marketCap, 0);
const avgChange = stocks.reduce((sum, s) => sum + (s.changePercent * s.marketCap / totalMarketCap), 0);
return getStockColor(avgChange);
})(),
borderRadius: "3px 3px 0 0",
display: "flex",
alignItems: "center",
paddingLeft: "4px",
}}
>
<div
style={{
fontSize: industry.isSmall ? "7px" : "8px",
fontWeight: 700,
color: "#fff",
textShadow: "1px 1px 2px rgba(0, 0, 0, 0.8)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "clip",
}}
>
{industry.name}
</div>
</div>
{/* Industry 내용 영역 */}
<div
style={{
position: "absolute",
top: `${headerHeight}px`,
left: 0,
width: "100%",
height: `${industry.y2 - industry.y1 - headerHeight - 2}px`,
}}
>
{(() => {
if (!currentData || !previousData) return null;
const contentWidth = industry.x2 - industry.x1 - 2;
const contentHeight = industry.y2 - industry.y1 - headerHeight - 2;
const industryStocks = Object.entries(currentData.data)
.filter(([, stock]) => stock.industry === industry.name)
.map(([ticker, currentStock]) => {
const previousStock = previousData.data[ticker];
if (!previousStock) return null;
const changePercent = ((currentStock.price - previousStock.price) / previousStock.price) * 100;
return { ticker, marketCap: currentStock.marketCap, changePercent };
})
.filter((s): s is { ticker: string; marketCap: number; changePercent: number } => s !== null);
if (industryStocks.length === 0) return null;
// 수동 배치 또는 기본 로직
let layout: Array<{ ticker: string; x: number; y: number; width: number; height: number; changePercent: number }> = [];
if (industry.name === "UTILITIES - REGULATED ELECTRIC") {
// UTILITIES - REGULATED ELECTRIC 수동 배치 - 4×3 그리드
const cellWidth = contentWidth / 4;
const cellHeight = contentHeight / 3;
// 시가총액 기준 정렬
const sortedStocks = industryStocks.sort((a, b) => b.marketCap - a.marketCap);
const top11Stocks = sortedStocks.slice(0, 11);
// 4×3 그리드 (11개 주식 배치)
top11Stocks.forEach((stock, index) => {
const col = index % 4;
const row = Math.floor(index / 4);
layout.push({
ticker: stock.ticker,
x: col * cellWidth,
y: row * cellHeight,
width: cellWidth,
height: cellHeight,
changePercent: stock.changePercent
});
});
// 12번째 칸 - 집계 박스 (나머지 주식들의 평균 등락폭)
const aggregatedStocks = sortedStocks.slice(11);
if (aggregatedStocks.length > 0) {
const totalMarketCap = aggregatedStocks.reduce((sum, s) => sum + s.marketCap, 0);
const weightedAvgChange = aggregatedStocks.reduce((sum, s) =>
sum + (s.changePercent * s.marketCap / totalMarketCap), 0
);
layout.push({
ticker: `+${aggregatedStocks.length}`,
x: 3 * cellWidth,
y: 2 * cellHeight,
width: cellWidth,
height: cellHeight,
changePercent: weightedAvgChange
});
}
} else {
// 기본 로직: 시가총액 기준 상위 2개만 선택하여 1:1 가로 분할
const top2Stocks = industryStocks
.sort((a, b) => b.marketCap - a.marketCap)
.slice(0, 2);
if (top2Stocks.length === 1) {
layout.push({
ticker: top2Stocks[0].ticker,
x: 0,
y: 0,
width: contentWidth,
height: contentHeight,
changePercent: top2Stocks[0].changePercent
});
} else if (top2Stocks.length === 2) {
layout.push({
ticker: top2Stocks[0].ticker,
x: 0,
y: 0,
width: contentWidth / 2,
height: contentHeight,
changePercent: top2Stocks[0].changePercent
});
layout.push({
ticker: top2Stocks[1].ticker,
x: contentWidth / 2,
y: 0,
width: contentWidth / 2,
height: contentHeight,
changePercent: top2Stocks[1].changePercent
});
}
}
return layout.map((item, i) => {
const boxArea = item.width * item.height;
let tickerFontSize = 16;
let percentFontSize = 11;
let showPercent = true;
let showPercentSign = true;
if (boxArea > 15000) {
tickerFontSize = 32;
percentFontSize = Math.round(tickerFontSize * 0.65);
} else if (boxArea > 8000) {
tickerFontSize = 24;
percentFontSize = Math.round(tickerFontSize * 0.65);
} else if (boxArea > 3000) {
tickerFontSize = 16;
percentFontSize = Math.round(tickerFontSize * 0.7);
} else if (boxArea > 1000) {
tickerFontSize = Math.max(9, Math.min(12, Math.round(Math.sqrt(boxArea) / 4)));
percentFontSize = Math.round(tickerFontSize * 0.75);
showPercentSign = false;
} else {
tickerFontSize = Math.max(6, Math.min(9, Math.round(Math.sqrt(boxArea) / 5)));
showPercent = false;
}
return (
<div
key={i}
style={{
position: "absolute",
left: `${item.x}px`,
top: `${item.y}px`,
width: `${item.width}px`,
height: `${item.height}px`,
background: getStockColor(item.changePercent),
border: "1px solid rgba(0, 0, 0, 0.3)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "2px",
cursor: "pointer",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "scale(1.05)";
e.currentTarget.style.zIndex = "10";
e.currentTarget.style.boxShadow = "0 0 8px rgba(255, 255, 255, 0.5)";
handleStockHover(item.ticker, e);
}}
onMouseMove={handleMouseMove}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
e.currentTarget.style.zIndex = "1";
e.currentTarget.style.boxShadow = "none";
handleStockLeave();
}}
>
<div style={{ fontSize: `${tickerFontSize}px`, fontWeight: 700, color: "#fff", textShadow: "1px 1px 2px rgba(0, 0, 0, 0.8)" }}>
{item.ticker}
</div>
{showPercent && (
<div style={{ fontSize: `${percentFontSize}px`, fontWeight: 600, color: "#fff", textShadow: "1px 1px 2px rgba(0, 0, 0, 0.8)" }}>
{item.changePercent > 0 ? '+' : ''}{item.changePercent.toFixed(2)}{showPercentSign ? '%' : ''}
</div>
)}
</div>
);
});
})()}
</div>
</div>
);
})}
{/* BASIC MATERIALS 섹터 산업군 배치 */}
{box.name === "BASIC MATERIALS" &&
BASIC_MATERIALS_INDUSTRIES.map((industry, idx) => {
const headerHeight = industry.isSmall ? 8 : 10;
return (
<div
key={idx}
className="industry-box"
style={{
position: "absolute",
left: `${industry.x1 + 1}px`,
top: `${industry.y1 + 1}px`,
width: `${industry.x2 - industry.x1 - 2}px`,
height: `${industry.y2 - industry.y1 - 2}px`,
border: "none",
background: "rgba(30, 40, 50, 0.6)",
overflow: "hidden",
transition: "all 0.2s ease",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "rgba(74, 144, 226, 1)";
e.currentTarget.style.background = "rgba(74, 144, 226, 0.15)";
e.currentTarget.style.boxShadow = "0 0 12px rgba(74, 144, 226, 0.5)";
e.currentTarget.style.transform = "scale(1.02)";
handleIndustryHover(industry.name, e);
}}
onMouseMove={handleMouseMove}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "rgba(74, 144, 226, 0.8)";
e.currentTarget.style.background = "rgba(30, 40, 50, 0.6)";
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.transform = "scale(1)";
handleIndustryLeave();
}}
>
{/* Industry 헤더 배경색 */}
<div
style={{
position: "absolute",
top: 0,
left: "0px",
width: `${industry.x2 - industry.x1 - 2}px`,
height: `${headerHeight}px`,
background: (() => {
if (!currentData || !previousData) return "rgba(75, 80, 85, 0.9)";
const stocks = Object.entries(currentData.data)
.filter(([, stock]) => stock.industry === industry.name)
.map(([ticker, currentStock]) => {
const previousStock = previousData.data[ticker];
if (!previousStock) return null;
const changePercent = ((currentStock.price - previousStock.price) / previousStock.price) * 100;
return { marketCap: currentStock.marketCap, changePercent };
})
.filter((s): s is { marketCap: number; changePercent: number } => s !== null);
if (stocks.length === 0) return "rgba(75, 80, 85, 0.9)";
const totalMarketCap = stocks.reduce((sum, s) => sum + s.marketCap, 0);
const avgChange = stocks.reduce((sum, s) => sum + (s.changePercent * s.marketCap / totalMarketCap), 0);
return getStockColor(avgChange);
})(),
borderRadius: "3px 3px 0 0",
display: "flex",
alignItems: "center",
paddingLeft: "4px",
}}
>
<div
style={{
fontSize: industry.isSmall ? "7px" : "8px",
fontWeight: 700,
color: "#fff",
textShadow: "1px 1px 2px rgba(0, 0, 0, 0.8)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "clip",
}}
>
{industry.name}
</div>
</div>
{/* Industry 내용 영역 */}
<div
style={{
position: "absolute",
top: `${headerHeight}px`,
left: 0,
width: "100%",
height: `${industry.y2 - industry.y1 - headerHeight - 2}px`,
}}
>
{(() => {
if (!currentData || !previousData) return null;
const contentWidth = industry.x2 - industry.x1 - 2;
const contentHeight = industry.y2 - industry.y1 - headerHeight - 2;
const industryStocks = Object.entries(currentData.data)
.filter(([, stock]) => stock.industry === industry.name)
.map(([ticker, currentStock]) => {
const previousStock = previousData.data[ticker];
if (!previousStock) return null;
const changePercent = ((currentStock.price - previousStock.price) / previousStock.price) * 100;
return { ticker, marketCap: currentStock.marketCap, changePercent };
})
.filter((s): s is { ticker: string; marketCap: number; changePercent: number } => s !== null);
if (industryStocks.length === 0) return null;
// 수동 배치 또는 기본 로직
let layout: Array<{ ticker: string; x: number; y: number; width: number; height: number; changePercent: number }> = [];
if (industry.name === "SPECIALTY CHEMICALS") {
// SPECIALTY CHEMICALS 수동 배치
const stockMap = new Map(industryStocks.map(s => [s.ticker, s]));
// 1. LIN - 상단 40% (가로 전체)
const lin = stockMap.get('LIN');
if (lin) {
layout.push({
ticker: 'LIN',
x: 0,
y: 0,
width: contentWidth,
height: contentHeight * 0.4,
changePercent: lin.changePercent
});
}
// 하단 60% - 3×2 그리드
const bottomY = contentHeight * 0.4;
const bottomHeight = contentHeight * 0.6;
const cellWidth = contentWidth / 3;
const cellHeight = bottomHeight / 2;
// 시가총액 기준 정렬 (LIN 제외)
const sortedStocks = industryStocks
.filter(s => s.ticker !== 'LIN')
.sort((a, b) => b.marketCap - a.marketCap);
const top5Stocks = sortedStocks.slice(0, 5);
// 3×2 그리드 (5개 주식 배치)
top5Stocks.forEach((stock, index) => {
const col = index % 3;
const row = Math.floor(index / 3);
layout.push({
ticker: stock.ticker,
x: col * cellWidth,
y: bottomY + row * cellHeight,
width: cellWidth,
height: cellHeight,
changePercent: stock.changePercent
});
});
// 6번째 칸 - 집계 박스 (나머지 주식들의 평균 등락폭)
const aggregatedStocks = sortedStocks.slice(5);
if (aggregatedStocks.length > 0) {
const totalMarketCap = aggregatedStocks.reduce((sum, s) => sum + s.marketCap, 0);
const weightedAvgChange = aggregatedStocks.reduce((sum, s) =>
sum + (s.changePercent * s.marketCap / totalMarketCap), 0
);
layout.push({
ticker: `+${aggregatedStocks.length}`,
x: 2 * cellWidth,
y: bottomY + cellHeight,
width: cellWidth,
height: cellHeight,
changePercent: weightedAvgChange
});
}
} else {
// 기본 로직: 시가총액 기준 상위 2개만 선택하여 1:1 가로 분할
const top2Stocks = industryStocks
.sort((a, b) => b.marketCap - a.marketCap)
.slice(0, 2);
if (top2Stocks.length === 1) {
layout.push({
ticker: top2Stocks[0].ticker,
x: 0,
y: 0,
width: contentWidth,
height: contentHeight,
changePercent: top2Stocks[0].changePercent
});
} else if (top2Stocks.length === 2) {
layout.push({
ticker: top2Stocks[0].ticker,
x: 0,
y: 0,
width: contentWidth / 2,
height: contentHeight,
changePercent: top2Stocks[0].changePercent
});
layout.push({
ticker: top2Stocks[1].ticker,
x: contentWidth / 2,
y: 0,
width: contentWidth / 2,
height: contentHeight,
changePercent: top2Stocks[1].changePercent
});
}
}
return layout.map((item, i) => {
const boxArea = item.width * item.height;
let tickerFontSize = 16;
let percentFontSize = 11;
let showPercent = true;
let showPercentSign = true;
if (boxArea > 15000) {
tickerFontSize = 32;
percentFontSize = Math.round(tickerFontSize * 0.65);
} else if (boxArea > 8000) {
tickerFontSize = 24;
percentFontSize = Math.round(tickerFontSize * 0.65);
} else if (boxArea > 3000) {
tickerFontSize = 16;
percentFontSize = Math.round(tickerFontSize * 0.7);
} else if (boxArea > 1000) {
tickerFontSize = Math.max(9, Math.min(12, Math.round(Math.sqrt(boxArea) / 4)));
percentFontSize = Math.round(tickerFontSize * 0.75);
showPercentSign = false;
} else {
tickerFontSize = Math.max(6, Math.min(9, Math.round(Math.sqrt(boxArea) / 5)));
showPercent = false;
}
return (
<div
key={i}
style={{
position: "absolute",
left: `${item.x}px`,
top: `${item.y}px`,
width: `${item.width}px`,
height: `${item.height}px`,
background: getStockColor(item.changePercent),
border: "1px solid rgba(0, 0, 0, 0.3)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "2px",
cursor: "pointer",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "scale(1.05)";
e.currentTarget.style.zIndex = "10";
e.currentTarget.style.boxShadow = "0 0 8px rgba(255, 255, 255, 0.5)";
handleStockHover(item.ticker, e);
}}
onMouseMove={handleMouseMove}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
e.currentTarget.style.zIndex = "1";
e.currentTarget.style.boxShadow = "none";
handleStockLeave();
}}
>
<div style={{ fontSize: `${tickerFontSize}px`, fontWeight: 700, color: "#fff", textShadow: "1px 1px 2px rgba(0, 0, 0, 0.8)" }}>
{item.ticker}
</div>
{showPercent && (
<div style={{ fontSize: `${percentFontSize}px`, fontWeight: 600, color: "#fff", textShadow: "1px 1px 2px rgba(0, 0, 0, 0.8)" }}>
{item.changePercent > 0 ? '+' : ''}{item.changePercent.toFixed(2)}{showPercentSign ? '%' : ''}
</div>
)}
</div>
);
});
})()}
</div>
</div>
);
})}
{/* COMMUNICATION SERVICES 섹터 산업군 배치 */}
{box.name === "COMMUNICATION SERVICES" &&
COMMUNICATION_SERVICES_INDUSTRIES.map((industry, idx) => {

View File

@ -0,0 +1,59 @@
import psycopg2
# PostgreSQL 연결
conn = psycopg2.connect(
host="3.38.180.110",
port=8088,
database="if_invest",
user="eldsoft",
password="eld240510"
)
cursor = conn.cursor()
# UTILITIES 섹터의 모든 산업군 확인
cursor.execute("""
SELECT industry, COUNT(*) as stock_count
FROM invest_product_code
WHERE sector = 'UTILITIES'
AND is_sp500 = 'Y'
GROUP BY industry
ORDER BY stock_count DESC, industry
""")
print("UTILITIES 섹터 산업군별 주식 개수:")
print("=" * 80)
results = cursor.fetchall()
for industry, count in results:
print(f"{industry}: {count}")
print("\n" + "=" * 80)
# 각 산업군별 상위 2개 주식 확인
industries = [
"UTILITIES - RENEWABLE",
"UTILITIES - INDEPENDENT POWER PRODUCERS",
"UTILITIES - DIVERSIFIED",
"UTILITIES - REGULATED ELECTRIC"
]
for industry in industries:
print(f"\n{industry}:")
print("-" * 80)
cursor.execute("""
SELECT invest_code, code_desc_en
FROM invest_product_code
WHERE sector = 'UTILITIES'
AND industry = %s
AND is_sp500 = 'Y'
ORDER BY invest_code
""", (industry,))
stocks = cursor.fetchall()
for ticker, name in stocks:
print(f" {ticker}: {name}")
cursor.close()
conn.close()