대시보드 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:
kyu
2026-05-20 22:09:32 +09:00
parent 70de874ae1
commit a02d23de8d
2 changed files with 377 additions and 5 deletions
+297 -1
View File
@@ -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
View File
@@ -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")