대시보드 4개 탭 추가: 10공식 매트릭스 / 백테스트 / 미증시 동조 / 매크로·섹터
dashboard-api/main.py:
- SCORE_ENGINE_URL/US_MARKET_URL/AUX_SIGNAL_URL 환경변수
- _proxy_get 헬퍼
- 신규 8개 엔드포인트:
/api/formulas/matrix - 10공식 신호 매트릭스 (stock_scores 직접 조회)
/api/weights - 학습된 공식 가중치 proxy
/api/backtest - 추천 백테스트 proxy
/api/sector-concentration - 섹터 집중도 + 30% 경고 proxy
/api/usmarket/etfs - 미국 섹터 ETF
/api/usmarket/pairs - KR↔US 페어 + 60일 회귀 베타
/api/usmarket/signals - 동조 시그널
/api/macro/ecos - 한국 매크로 (USD/KRW, 국고채)
dashboard-api/index.html:
- 탭 4개 추가 (formulas/backtest/usmarket/macro)
- renderFormulas: 매트릭스 테이블 (한글 매수/매도 신호 색상 + 툴팁)
- renderBacktest: 7d/30d 카드 + 등급별 표 + 기간 슬라이드
- renderUsMarket: ETF + 페어 + 시그널 3패널
- renderMacro: KOSPI/USD-KRW/국고채 카드 + 섹터 집중도 막대 + 가중치
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+297
-1
@@ -201,6 +201,10 @@ body:not(.auth-required) #login-screen { display:none !important; }
|
||||
<button class="tab" onclick="showTab('news',this)">📰 뉴스피드</button>
|
||||
<button class="tab" onclick="showTab('supplydemand',this)">💹 수급</button>
|
||||
<button class="tab" onclick="showTab('alerts',this)">🚨 알림</button>
|
||||
<button class="tab" onclick="showTab('formulas',this)">🧮 10공식</button>
|
||||
<button class="tab" onclick="showTab('backtest',this)">📈 백테스트</button>
|
||||
<button class="tab" onclick="showTab('usmarket',this)">🌎 미증시</button>
|
||||
<button class="tab" onclick="showTab('macro',this)">🌐 매크로·섹터</button>
|
||||
</div>
|
||||
<div id="overview"></div>
|
||||
<div id="performance" style="display:none"></div>
|
||||
@@ -212,6 +216,10 @@ body:not(.auth-required) #login-screen { display:none !important; }
|
||||
<div id="news" style="display:none"></div>
|
||||
<div id="supplydemand" style="display:none"></div>
|
||||
<div id="alerts" style="display:none"></div>
|
||||
<div id="formulas" style="display:none"></div>
|
||||
<div id="backtest" style="display:none"></div>
|
||||
<div id="usmarket" style="display:none"></div>
|
||||
<div id="macro" style="display:none"></div>
|
||||
<div class="footer">Trading AI System • 뉴스/공시 기반 자동 분석 • 투자 판단의 참고 자료로만 활용하세요</div>
|
||||
</div>
|
||||
<script>
|
||||
@@ -224,7 +232,7 @@ function ftime(ts){if(!ts)return'-';const d=new Date(ts),m=Math.floor((Date.now(
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
function showTab(id,el){
|
||||
['overview','performance','signals','surge','portfolio','mystock','recommend','news','supplydemand','alerts'].forEach(t=>{
|
||||
['overview','performance','signals','surge','portfolio','mystock','recommend','news','supplydemand','alerts','formulas','backtest','usmarket','macro'].forEach(t=>{
|
||||
document.getElementById(t).style.display=t===id?'block':'none'});
|
||||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||||
if(el)el.classList.add('active');
|
||||
@@ -233,6 +241,10 @@ function showTab(id,el){
|
||||
if(id==='performance') renderPerformance();
|
||||
if(id==='supplydemand') renderSupplyDemand();
|
||||
if(id==='surge') renderVolumeSurge();
|
||||
if(id==='formulas') renderFormulas();
|
||||
if(id==='backtest') renderBacktest();
|
||||
if(id==='usmarket') renderUsMarket();
|
||||
if(id==='macro') renderMacro();
|
||||
}
|
||||
|
||||
async function renderVolumeSurge(){
|
||||
@@ -1677,6 +1689,290 @@ async function sendChat(){
|
||||
document.getElementById('chat-send').disabled=false;
|
||||
msgs.scrollTop=msgs.scrollHeight;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 🧮 10공식 신호 매트릭스
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function _voteColor(v, pos){ // pos=true=매수신호, false=매도신호
|
||||
if(v==null) return '#37474F';
|
||||
return pos ? '#69F0AE' : '#FF8A80';
|
||||
}
|
||||
function _cell(label, val, hit, tooltip){
|
||||
const c = hit ? (hit==='buy'?'#69F0AE':'#FF8A80') : '#546E7A';
|
||||
const bg = hit ? (hit==='buy'?'rgba(0,230,118,0.10)':'rgba(255,82,82,0.10)') : 'rgba(255,255,255,0.03)';
|
||||
return `<td title="${tooltip||''}" style="padding:6px 8px;text-align:center;font-family:'JetBrains Mono',monospace;font-size:11px;color:${c};background:${bg};border-radius:6px">${val}</td>`;
|
||||
}
|
||||
async function renderFormulas(){
|
||||
const el=document.getElementById('formulas');
|
||||
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">10공식(매직포뮬러·F-Score·Altman Z·PEG·12-1 모멘텀·Beneish·GP/A·G-Score·Amihud·BAB) 신호 매트릭스 — 오늘 점수 상위</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
try{
|
||||
const [rows, weights] = await Promise.all([
|
||||
api('/formulas/matrix?limit=50'),
|
||||
api('/weights')
|
||||
]);
|
||||
if(!Array.isArray(rows)||!rows.length){el.innerHTML='<div class="empty">데이터가 없습니다</div>';return;}
|
||||
const w = (weights && weights.weights) || {};
|
||||
let wStr = Object.entries(w).map(([k,v])=>`<span style="margin-right:10px"><b style="color:#69F0AE">${k}</b> <span style="font-family:'JetBrains Mono',monospace">${(+v).toFixed(2)}</span></span>`).join('');
|
||||
let h = `<div style="background:rgba(0,230,118,0.04);border:1px solid rgba(0,230,118,0.12);border-radius:10px;padding:10px 14px;margin-bottom:14px;font-size:11px;color:#90A4AE">학습 가중치: ${wStr||'<i>미학습</i>'}</div>`;
|
||||
h += `<div style="overflow:auto;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:14px;padding:10px"><table style="width:100%;border-collapse:separate;border-spacing:2px;font-size:11px">
|
||||
<thead><tr style="color:#90A4AE">
|
||||
<th style="text-align:left;padding:8px 6px">종목</th>
|
||||
<th style="text-align:right;padding:8px 6px">점수</th>
|
||||
<th>등급</th>
|
||||
<th title="매수 / 매도 보팅 합">B/S</th>
|
||||
<th title="Greenblatt 매직포뮬러 (ROC+EY)">Magic</th>
|
||||
<th title="Piotroski F-Score (0~7)">F</th>
|
||||
<th title="Altman Z">Z</th>
|
||||
<th title="Lynch PEG">PEG</th>
|
||||
<th title="12-1개월 모멘텀">12-1%</th>
|
||||
<th title="Beneish M-Score (분식의심도)">Bnsh</th>
|
||||
<th title="Novy-Marx GP/A">GP/A%</th>
|
||||
<th title="Mohanram G-Score (vs 섹터 중앙값)">G</th>
|
||||
<th title="Amihud 비유동성">Amh</th>
|
||||
<th title="저베타 알파 (Frazzini-Pedersen BAB)">β</th>
|
||||
</tr></thead><tbody>`;
|
||||
rows.forEach(r=>{
|
||||
let sig = r.signals || {};
|
||||
if(typeof sig === 'string'){ try{ sig = JSON.parse(sig); }catch{ sig = {}; } }
|
||||
const hit = (k)=>{
|
||||
const v = sig[k];
|
||||
if(v==='buy' || v==='매수') return 'buy';
|
||||
if(v==='sell' || v==='매도') return 'sell';
|
||||
return null;
|
||||
};
|
||||
const recC = RCOL[r.recommendation] || '#90A4AE';
|
||||
const code6 = r.stock_code;
|
||||
h += `<tr style="background:rgba(255,255,255,0.02);cursor:pointer" onclick="openStockChart('${code6}')">
|
||||
<td style="padding:8px 6px;color:#CFD8DC"><b>${esc(r.stock_name||code6)}</b> <span style="color:#546E7A;font-size:10px;font-family:'JetBrains Mono',monospace">${code6}</span></td>
|
||||
<td style="padding:8px 6px;text-align:right;font-family:'JetBrains Mono',monospace;font-weight:700;color:${recC}">${(+r.total_score).toFixed(1)}</td>
|
||||
<td style="text-align:center"><span class="badge" style="background:${recC}22;color:${recC};font-size:10px;padding:3px 7px">${r.recommendation||'-'}</span></td>
|
||||
<td style="text-align:center;color:#90A4AE;font-family:'JetBrains Mono',monospace">${r.buy_votes||0}/${r.sell_votes||0}</td>
|
||||
${_cell('Magic', r.magic_score!=null?(+r.magic_score).toFixed(0):'-', hit('magic'), 'ROC + EY 임계값 합')}
|
||||
${_cell('F', r.f_score!=null?(+r.f_score).toFixed(0):'-', hit('fscore'), '7신호 합')}
|
||||
${_cell('Z', r.altman_z!=null?(+r.altman_z).toFixed(2):'-', hit('altman'), 'Altman Z (≥2.6 안전)')}
|
||||
${_cell('PEG', r.peg!=null?(+r.peg).toFixed(2):'-', hit('peg'), 'PER/성장률 (≤1.5 매수)')}
|
||||
${_cell('12-1%', r.momentum_pct!=null?(+r.momentum_pct).toFixed(1):'-', hit('momentum'), '12-1개월 모멘텀')}
|
||||
${_cell('Bnsh', r.beneish_score!=null?(+r.beneish_score).toFixed(0):'-', hit('beneish'), 'Beneish M (분식의심도)')}
|
||||
${_cell('GP/A%', r.gpa_pct!=null?(+r.gpa_pct).toFixed(1):'-', hit('gpa'), '영업이익/총자산')}
|
||||
${_cell('G', r.g_score!=null?(+r.g_score).toFixed(0):'-', hit('gscore'), 'Mohanram G (vs 섹터)')}
|
||||
${_cell('Amh', r.amihud_illiq!=null?(+r.amihud_illiq).toFixed(0):'-', hit('amihud'), 'Amihud 비유동성')}
|
||||
${_cell('β', r.market_beta!=null?(+r.market_beta).toFixed(2):'-', hit('beta'), '60일 KOSPI 회귀 베타')}
|
||||
</tr>`;
|
||||
});
|
||||
h += `</tbody></table></div>`;
|
||||
h += `<div style="margin-top:14px;font-size:11px;color:#546E7A;line-height:1.6">초록=매수 신호, 빨강=매도 신호, 회색=중립/미산출. 종목 행 클릭 시 차트 모달. <br>📚 매트릭스 셀 위에 마우스를 올리면 공식 설명이 표시됩니다.</div>`;
|
||||
el.innerHTML = h;
|
||||
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 📈 백테스트 성과
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function _btCard(label, v, suffix, danger){
|
||||
if(v==null||isNaN(v)) return `<div class="card"><div class="label">${label}</div><div class="val" style="font-size:24px;color:#546E7A">-</div></div>`;
|
||||
const c = danger ? (v<0?'#FF5252':'#00E676') : '#69F0AE';
|
||||
return `<div class="card"><div class="label">${label}</div><div class="val" style="color:${c}">${(+v).toFixed(2)}${suffix||''}</div></div>`;
|
||||
}
|
||||
function _btTable(byRec){
|
||||
const order = ['강력매수','매수관심','관망','매도관심','강력매도'];
|
||||
let h = `<table style="width:100%;border-collapse:separate;border-spacing:2px;font-size:12px">
|
||||
<thead><tr style="color:#90A4AE">
|
||||
<th style="text-align:left;padding:8px 6px">등급</th>
|
||||
<th>표본 N</th><th>평균 수익률</th><th>승률</th><th>샤프</th><th>MDD</th><th>알파</th>
|
||||
</tr></thead><tbody>`;
|
||||
order.forEach(rec=>{
|
||||
const d = byRec[rec] || {n:0};
|
||||
const recC = RCOL[rec] || '#90A4AE';
|
||||
if(!d.n){
|
||||
h += `<tr style="background:rgba(255,255,255,0.02)"><td style="padding:8px"><span class="badge" style="background:${recC}22;color:${recC}">${rec}</span></td><td colspan="6" style="text-align:center;color:#546E7A">표본 없음</td></tr>`;
|
||||
}else{
|
||||
const rC = d.avg_return_pct>=0?'#69F0AE':'#FF8A80';
|
||||
const aC = (d.avg_alpha_pct||0)>=0?'#69F0AE':'#FF8A80';
|
||||
h += `<tr style="background:rgba(255,255,255,0.02)">
|
||||
<td style="padding:8px"><span class="badge" style="background:${recC}22;color:${recC}">${rec}</span></td>
|
||||
<td style="text-align:center;color:#90A4AE">${d.n}</td>
|
||||
<td style="text-align:center;color:${rC};font-family:'JetBrains Mono',monospace;font-weight:700">${(+d.avg_return_pct).toFixed(2)}%</td>
|
||||
<td style="text-align:center;color:#CFD8DC;font-family:'JetBrains Mono',monospace">${(+d.win_rate_pct).toFixed(1)}%</td>
|
||||
<td style="text-align:center;color:#CFD8DC;font-family:'JetBrains Mono',monospace">${(+d.sharpe).toFixed(2)}</td>
|
||||
<td style="text-align:center;color:#FF8A80;font-family:'JetBrains Mono',monospace">${(+d.max_drawdown_pct).toFixed(2)}%</td>
|
||||
<td style="text-align:center;color:${aC};font-family:'JetBrains Mono',monospace">${(+d.avg_alpha_pct).toFixed(2)}%</td>
|
||||
</tr>`;
|
||||
}
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
return h;
|
||||
}
|
||||
async function renderBacktest(){
|
||||
const el=document.getElementById('backtest');
|
||||
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">추천 등급별 백테스트 성과 (기간/홀딩 슬라이드)</div>
|
||||
<div style="margin-bottom:14px;display:flex;gap:8px;align-items:center">
|
||||
<span style="color:#90A4AE;font-size:12px">기간:</span>
|
||||
<select id="bt-days" onchange="renderBacktest()" style="background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#CFD8DC;padding:6px 10px;border-radius:6px;font-size:12px">
|
||||
<option value="30">30일</option><option value="60">60일</option><option value="90">90일</option><option value="180" selected>180일</option><option value="360">360일</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
// re-read select if exists
|
||||
const daysSel = document.getElementById('bt-days');
|
||||
const days = daysSel ? +daysSel.value : 180;
|
||||
try{
|
||||
const d = await api(`/backtest?days=${days}`);
|
||||
if(d.error){el.innerHTML=`<div class="empty">오류: ${esc(d.error)}</div>`;return;}
|
||||
const oa = (d.overall && (d.overall['7d']||d.overall['30d'])) || {};
|
||||
const oa30 = (d.overall && d.overall['30d']) || {};
|
||||
let h = `<div style="font-size:12px;color:#546E7A;margin-bottom:14px">기간 ${d.period_days||days}일 · 표본 ${d.total_recommendations||0}건</div>
|
||||
<div style="margin-bottom:14px;display:flex;gap:8px;align-items:center">
|
||||
<span style="color:#90A4AE;font-size:12px">기간:</span>
|
||||
<select id="bt-days" onchange="renderBacktest()" style="background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#CFD8DC;padding:6px 10px;border-radius:6px;font-size:12px">
|
||||
${[30,60,90,180,360].map(x=>`<option value="${x}" ${x===days?'selected':''}>${x}일</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<h3 style="color:#90A4AE;font-size:13px;margin:8px 0 10px">7일 보유</h3>
|
||||
<div class="cards">
|
||||
${_btCard('N (표본)', oa.n, '')}
|
||||
${_btCard('평균 수익률', oa.avg_return_pct, '%', true)}
|
||||
${_btCard('승률', oa.win_rate_pct, '%')}
|
||||
${_btCard('샤프', oa.sharpe, '', true)}
|
||||
${_btCard('MDD', oa.max_drawdown_pct, '%', true)}
|
||||
${_btCard('알파', oa.avg_alpha_pct, '%', true)}
|
||||
</div>
|
||||
<div class="panel" style="margin-top:14px"><h3>등급별 성과 (7일)</h3>${_btTable(d.by_recommendation_7d||{})}</div>`;
|
||||
if(oa30.n){
|
||||
h += `<h3 style="color:#90A4AE;font-size:13px;margin:18px 0 10px">30일 보유</h3>
|
||||
<div class="cards">
|
||||
${_btCard('N', oa30.n, '')}
|
||||
${_btCard('평균 수익률', oa30.avg_return_pct, '%', true)}
|
||||
${_btCard('승률', oa30.win_rate_pct, '%')}
|
||||
${_btCard('샤프', oa30.sharpe, '', true)}
|
||||
${_btCard('MDD', oa30.max_drawdown_pct, '%', true)}
|
||||
${_btCard('알파', oa30.avg_alpha_pct, '%', true)}
|
||||
</div>
|
||||
<div class="panel" style="margin-top:14px"><h3>등급별 성과 (30일)</h3>${_btTable(d.by_recommendation_30d||{})}</div>`;
|
||||
}
|
||||
h += `<div style="margin-top:14px;font-size:11px;color:#546E7A">알파 = 추천 평균 수익률 − 같은 기간 KOSPI 수익률. 음수면 시장 대비 underperform.</div>`;
|
||||
el.innerHTML = h;
|
||||
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 🌎 미증시 동조
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
async function renderUsMarket(){
|
||||
const el=document.getElementById('usmarket');
|
||||
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">미국 섹터 ETF·KR↔US 페어 회귀로 한국 종목별 동조 시그널 산출 (매일 KST 08:00)</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
try{
|
||||
const [etfs, pairs, signals] = await Promise.all([
|
||||
api('/usmarket/etfs'), api('/usmarket/pairs'), api('/usmarket/signals')
|
||||
]);
|
||||
let h = `<div class="grid2">`;
|
||||
// 섹터 ETF
|
||||
h += `<div class="panel"><h3>섹터 ETF (${Array.isArray(etfs)?etfs.length:0})</h3>`;
|
||||
if(Array.isArray(etfs) && etfs.length){
|
||||
h += `<table style="width:100%;font-size:11px;border-collapse:separate;border-spacing:1px">
|
||||
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">티커</th><th style="text-align:left">설명</th><th style="text-align:left">한국 섹터</th></tr></thead><tbody>`;
|
||||
etfs.slice(0,30).forEach(e=>{
|
||||
const kw = Array.isArray(e.sector_keywords)?e.sector_keywords.join(', '):'';
|
||||
h += `<tr><td style="padding:5px 6px;color:#69F0AE;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(e.etf_ticker)}</td><td style="color:#CFD8DC">${esc(e.description||'-')}</td><td style="color:#90A4AE;font-size:10px">${esc(kw)}</td></tr>`;
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
} else h += `<div class="empty">데이터 없음</div>`;
|
||||
h += `</div>`;
|
||||
// 페어
|
||||
h += `<div class="panel"><h3>KR ↔ US 페어 (${Array.isArray(pairs)?pairs.length:0})</h3>`;
|
||||
if(Array.isArray(pairs) && pairs.length){
|
||||
h += `<table style="width:100%;font-size:11px;border-collapse:separate;border-spacing:1px">
|
||||
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">US</th><th>KR</th><th title="60일 회귀 베타">β</th><th title="60일 상관">ρ</th><th>N</th></tr></thead><tbody>`;
|
||||
pairs.slice(0,30).forEach(p=>{
|
||||
const beta = p.beta_60d!=null?(+p.beta_60d).toFixed(2):'-';
|
||||
const corr = p.correlation_60d!=null?(+p.correlation_60d).toFixed(2):'-';
|
||||
h += `<tr><td style="padding:5px 6px;color:#69F0AE;font-family:'JetBrains Mono',monospace">${esc(p.us_ticker)}</td><td style="color:#CFD8DC;font-family:'JetBrains Mono',monospace">${esc(p.kr_code)}</td><td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#FFD740">${beta}</td><td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#90A4AE">${corr}</td><td style="text-align:center;color:#546E7A">${p.sample_size||'-'}</td></tr>`;
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
} else h += `<div class="empty">데이터 없음</div>`;
|
||||
h += `</div></div>`;
|
||||
// 시그널
|
||||
h += `<div class="panel" style="margin-top:14px"><h3>최신 동조 시그널 (${Array.isArray(signals)?signals.length:0})</h3>`;
|
||||
if(Array.isArray(signals) && signals.length){
|
||||
h += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px">`;
|
||||
signals.slice(0,40).forEach(s=>{
|
||||
const score = s.signal_score!=null ? +s.signal_score : 0;
|
||||
const c = score>0?'#69F0AE':score<0?'#FF8A80':'#90A4AE';
|
||||
h += `<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.05);border-radius:10px;padding:10px 12px">
|
||||
<div style="display:flex;justify-content:space-between"><span style="color:#CFD8DC;font-family:'JetBrains Mono',monospace">${esc(s.kr_code||'-')}</span><span style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace">${score>0?'+':''}${score.toFixed(1)}</span></div>
|
||||
<div style="font-size:10px;color:#546E7A;margin-top:4px">${esc(s.reason||'')}</div>
|
||||
</div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
} else h += `<div class="empty">시그널 없음 (아직 계산 전)</div>`;
|
||||
h += `</div>`;
|
||||
el.innerHTML = h;
|
||||
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 🌐 매크로 + 섹터 집중도
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
async function renderMacro(){
|
||||
const el=document.getElementById('macro');
|
||||
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">한국 매크로 지표 (ECOS) + 추천 종목 섹터 집중도 + 학습 가중치</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
try{
|
||||
const [m, conc, w] = await Promise.all([
|
||||
api('/macro/ecos'), api('/sector-concentration'), api('/weights')
|
||||
]);
|
||||
let h = `<h3 style="color:#90A4AE;font-size:13px;margin-bottom:10px">한국 매크로</h3>`;
|
||||
if(m && !m.error){
|
||||
h += `<div class="cards">`;
|
||||
const items = [
|
||||
['KOSPI', m.kospi, ''],
|
||||
['USD/KRW', m.usdkrw, '원'],
|
||||
['국고채 3년', m.kor_3y, '%'],
|
||||
['국고채 10년', m.kor_10y, '%'],
|
||||
];
|
||||
items.forEach(([label, obj, unit])=>{
|
||||
if(!obj){h += `<div class="card"><div class="label">${label}</div><div class="val" style="font-size:24px;color:#546E7A">-</div></div>`;return;}
|
||||
h += `<div class="card"><div class="label">${label}</div><div class="val" style="font-size:28px;color:#69F0AE">${(+obj.value).toLocaleString('ko-KR',{maximumFractionDigits:3})}<span style="font-size:14px;color:#90A4AE">${unit}</span></div><div class="sub">${obj.date||''}</div></div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
} else h += `<div class="empty">매크로 데이터 없음 (aux-signal 미실행?)</div>`;
|
||||
// 섹터 집중도
|
||||
h += `<h3 style="color:#90A4AE;font-size:13px;margin:20px 0 10px">추천 종목 섹터 집중도</h3>`;
|
||||
if(conc && !conc.error && Array.isArray(conc.sectors)){
|
||||
if(Array.isArray(conc.warnings) && conc.warnings.length){
|
||||
h += `<div style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.25);border-radius:10px;padding:12px 14px;margin-bottom:12px;color:#FF8A80;font-size:12px">⚠ ${conc.warnings.map(esc).join(' / ')}</div>`;
|
||||
}
|
||||
h += `<div class="panel"><table style="width:100%;font-size:12px;border-collapse:separate;border-spacing:1px">
|
||||
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">섹터</th><th>종목 수</th><th>비중</th><th>평균 점수</th><th style="width:40%">분포</th></tr></thead><tbody>`;
|
||||
conc.sectors.slice(0,20).forEach(s=>{
|
||||
const pct = +s.share_pct;
|
||||
const c = pct>=30?'#FF8A80':pct>=15?'#FFD740':'#69F0AE';
|
||||
h += `<tr><td style="padding:6px 8px;color:#CFD8DC">${esc(s.sector||'-')}</td>
|
||||
<td style="text-align:center;color:#90A4AE">${s.count}</td>
|
||||
<td style="text-align:center;color:${c};font-family:'JetBrains Mono',monospace;font-weight:700">${pct.toFixed(1)}%</td>
|
||||
<td style="text-align:center;color:#90A4AE;font-family:'JetBrains Mono',monospace">${(+s.avg_score).toFixed(1)}</td>
|
||||
<td><div style="background:rgba(255,255,255,0.05);border-radius:3px;height:6px;overflow:hidden"><div style="background:${c};height:100%;width:${Math.min(pct,100)}%"></div></div></td>
|
||||
</tr>`;
|
||||
});
|
||||
h += `</tbody></table></div>`;
|
||||
} else h += `<div class="empty">섹터 집중도 데이터 없음</div>`;
|
||||
// 가중치
|
||||
h += `<h3 style="color:#90A4AE;font-size:13px;margin:20px 0 10px">10공식 학습 가중치</h3>`;
|
||||
if(w && w.weights){
|
||||
const ws = Object.entries(w.weights).sort((a,b)=>b[1]-a[1]);
|
||||
const maxW = Math.max(...ws.map(x=>+x[1]),1);
|
||||
h += `<div class="panel"><div style="font-size:11px;color:#546E7A;margin-bottom:10px">학습일 ${w.config_date||'-'} · 백테스트 ${w.period_days||90}일 · 표본 ${w.sample_size||0}</div>`;
|
||||
ws.forEach(([k,v])=>{
|
||||
const ratio = (+v / maxW * 100);
|
||||
h += `<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
|
||||
<div style="width:80px;color:#CFD8DC;font-size:12px"><b>${k}</b></div>
|
||||
<div style="flex:1;background:rgba(255,255,255,0.05);border-radius:3px;height:8px;overflow:hidden"><div style="background:#69F0AE;height:100%;width:${ratio}%"></div></div>
|
||||
<div style="width:50px;text-align:right;font-family:'JetBrains Mono',monospace;color:#69F0AE;font-size:12px">${(+v).toFixed(2)}</div>
|
||||
</div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
} else h += `<div class="empty">학습된 가중치 없음 (POST /learn-weights 호출 필요)</div>`;
|
||||
el.innerHTML = h;
|
||||
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 종목 차트 모달 -->
|
||||
|
||||
+80
-4
@@ -9,10 +9,13 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from auth import hash_password, verify_password, create_token, current_user, dummy_verify
|
||||
|
||||
TA_ENGINE_URL = os.getenv("TA_ENGINE_URL", "http://ta-engine:8484")
|
||||
KIS_API_URL = os.getenv("KIS_API_URL", "http://kis-api:8585")
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
|
||||
CHAT_MODEL = os.getenv("CHAT_MODEL", "exaone3.5:7.8b")
|
||||
TA_ENGINE_URL = os.getenv("TA_ENGINE_URL", "http://ta-engine:8484")
|
||||
KIS_API_URL = os.getenv("KIS_API_URL", "http://kis-api:8585")
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
|
||||
SCORE_ENGINE_URL = os.getenv("SCORE_ENGINE_URL", "http://score-engine:8686")
|
||||
US_MARKET_URL = os.getenv("US_MARKET_URL", "http://us-market:8383")
|
||||
AUX_SIGNAL_URL = os.getenv("AUX_SIGNAL_URL", "http://aux-signal:8282")
|
||||
CHAT_MODEL = os.getenv("CHAT_MODEL", "exaone3.5:7.8b")
|
||||
|
||||
class PositionReq(BaseModel):
|
||||
code: str
|
||||
@@ -1994,6 +1997,79 @@ async def calendar_api(days: int = Query(default=30, ge=1, le=180)):
|
||||
"recent_dart": [dict(r) for r in recent][:30]}
|
||||
|
||||
|
||||
async def _proxy_get(url: str, timeout: float = 10.0):
|
||||
"""외부 서비스 GET 프록시 (실패 시 빈 응답)"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as c:
|
||||
r = await c.get(url)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
return {"error": str(e), "source": url}
|
||||
|
||||
|
||||
@app.get("/api/formulas/matrix")
|
||||
async def formulas_matrix(limit: int = Query(default=50, ge=5, le=200)):
|
||||
"""종목 × 10공식 신호 매트릭스 (오늘 스코어 기준 상위 N)"""
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT s.stock_code, COALESCE(d.corp_name, s.stock_name) AS stock_name,
|
||||
s.total_score, s.recommendation, s.buy_votes, s.sell_votes,
|
||||
s.magic_score, s.f_score, s.altman_z, s.peg, s.momentum_pct,
|
||||
s.beneish_score, s.gpa_pct, s.g_score, s.amihud_illiq, s.market_beta,
|
||||
s.signals, s.sector
|
||||
FROM stock_scores s
|
||||
LEFT JOIN dart_corps d ON d.stock_code = s.stock_code
|
||||
WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores)
|
||||
AND COALESCE(d.is_active, true) = true
|
||||
ORDER BY s.total_score DESC
|
||||
LIMIT $1
|
||||
""", limit)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/weights")
|
||||
async def proxy_weights():
|
||||
"""공식별 학습 가중치 (score-engine /learn-weights)"""
|
||||
return await _proxy_get(f"{SCORE_ENGINE_URL}/learn-weights")
|
||||
|
||||
|
||||
@app.get("/api/backtest")
|
||||
async def proxy_backtest(days: int = Query(default=180, ge=30, le=720)):
|
||||
"""추천 백테스트 (score-engine /backtest)"""
|
||||
return await _proxy_get(f"{SCORE_ENGINE_URL}/backtest?days={days}", timeout=30.0)
|
||||
|
||||
|
||||
@app.get("/api/sector-concentration")
|
||||
async def proxy_sector_concentration():
|
||||
"""섹터 집중도 + 30% 초과 경고 (score-engine /sector/concentration)"""
|
||||
return await _proxy_get(f"{SCORE_ENGINE_URL}/sector/concentration")
|
||||
|
||||
|
||||
@app.get("/api/usmarket/etfs")
|
||||
async def proxy_us_etfs():
|
||||
"""미국 섹터 ETF 목록"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/etfs")
|
||||
|
||||
|
||||
@app.get("/api/usmarket/pairs")
|
||||
async def proxy_us_pairs():
|
||||
"""KR↔US 페어 + 60일 회귀 베타"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/pairs")
|
||||
|
||||
|
||||
@app.get("/api/usmarket/signals")
|
||||
async def proxy_us_signals():
|
||||
"""미증시 동조 시그널 (전체)"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/signal/latest")
|
||||
|
||||
|
||||
@app.get("/api/macro/ecos")
|
||||
async def proxy_macro_ecos():
|
||||
"""한국 매크로 (aux-signal /macro/latest: USD/KRW, 국고채 10년, KOSPI)"""
|
||||
return await _proxy_get(f"{AUX_SIGNAL_URL}/macro/latest")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
return FileResponse("/app/index.html")
|
||||
|
||||
Reference in New Issue
Block a user