Files
SET100-Trading-system/primethai/webui.html
2026-09-30 14:31:28 +07:00

312 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="th">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PrimeThai · พอร์ตลงทุน</title>
<style>
:root { color-scheme:dark; --bg:#0c1118; --panel:#121b25; --line:#263645; --muted:#91a2b3; --text:#e9f0f6; --green:#62d6a1; --red:#ff8089; --amber:#ffd17b; --blue:#8ecbff; }
* { box-sizing:border-box; }
body { margin:0; background:radial-gradient(ellipse at top,#182635 0,var(--bg) 54%); color:var(--text); font:15px/1.5 system-ui,"Segoe UI",sans-serif; }
main { width:min(1240px,calc(100% - 36px)); margin:28px auto 48px; }
header { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:15px; }
.eyebrow { color:var(--green); text-transform:uppercase; letter-spacing:.13em; font-size:11px; font-weight:700; }
h1 { margin:4px 0 3px; font-size:clamp(26px,4vw,38px); letter-spacing:-.035em; }
h2 { margin:0; font-size:18px; letter-spacing:-.02em; }
.sub { color:var(--muted); margin:0; font-size:13px; }
nav { display:flex; align-items:center; flex-wrap:wrap; gap:9px; }
nav form { margin:0; }
a { color:var(--blue); }
.nav-link,button { border:1px solid #40556a; border-radius:9px; padding:10px 13px; background:#192838; color:var(--text); font:inherit; text-decoration:none; cursor:pointer; }
button.primary { border-color:#308663; background:#164d3b; color:#dff9ed; font-weight:700; }
button:disabled { opacity:.55; cursor:wait; }
.status { display:flex; align-items:center; gap:9px; border:1px solid var(--line); background:#111a23; border-radius:10px; padding:10px 13px; margin:13px 0; color:#d4e0e9; font-size:13px; }
.status.running { border-color:#80652d; background:#211d13; }
.status.error { border-color:#853e46; background:#28171b; }
.status.ready { border-color:#28674f; background:#12251e; }
.dot { width:8px; height:8px; border-radius:50%; background:var(--green); box-shadow:0 0 12px #62d6a188; flex:0 0 auto; }
.status.running .dot { background:var(--amber); box-shadow:0 0 12px #ffd17b99; animation:pulse 1.3s ease-in-out infinite; }
.status.error .dot { background:var(--red); box-shadow:0 0 12px #ff808999; }
@keyframes pulse { 50% { opacity:.35; transform:scale(.75); } }
.grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:11px; }
.card,.panel { background:linear-gradient(145deg,#15212d,#111a24); border:1px solid var(--line); border-radius:13px; }
.card { padding:15px 17px; min-height:105px; }
.label { color:var(--muted); font-size:13px; }
.value { font-size:27px; font-weight:720; letter-spacing:-.025em; margin-top:5px; font-variant-numeric:tabular-nums; }
.detail { color:var(--muted); font-size:12px; margin-top:2px; }
.positive { color:var(--green); } .negative { color:var(--red); } .warning { color:var(--amber); }
.panel { padding:18px; margin-top:12px; min-width:0; }
.panel-head { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:12px; }
.badge { display:inline-block; border:1px solid #425366; border-radius:999px; padding:4px 9px; color:#c8d5e0; font-size:11px; white-space:nowrap; }
.table-wrap { overflow:auto; }
table { width:100%; border-collapse:collapse; min-width:880px; }
th,td { text-align:left; padding:10px 9px; border-bottom:1px solid #263542; white-space:nowrap; }
th { color:var(--muted); font-size:12px; font-weight:600; }
td { font-size:13px; font-variant-numeric:tabular-nums; }
td.num,th.num { text-align:right; }
.symbol { color:#fff; font-weight:750; letter-spacing:.03em; }
.action { font-size:11px; font-weight:750; letter-spacing:.04em; }
.action.BUY { color:var(--green); } .action.SELL { color:var(--red); } .action.REVIEW,.action.PRICE_NEEDED,.action.BELOW_LOT { color:var(--amber); }
.mini { padding:5px 8px; font-size:11px; }
.actions { display:flex; gap:6px; }
.edit-panel { margin-top:12px; border:1px solid #40556a; border-radius:10px; padding:14px; background:#0e1720; }
[hidden] { display:none !important; }
.empty { text-align:center; color:var(--muted); padding:26px 8px; }
.two-col { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
label { display:grid; gap:5px; color:var(--muted); font-size:12px; }
input,select { width:100%; border:1px solid #35495b; border-radius:8px; padding:10px; background:#0d151d; color:var(--text); font:inherit; }
.full { grid-column:1/-1; }
.form-actions { display:flex; align-items:center; gap:9px; margin-top:11px; }
.help,.refresh-line { color:var(--muted); font-size:12px; margin-top:8px; }
.metric-list { display:grid; gap:0; margin-top:10px; }
.metric-row { display:flex; justify-content:space-between; gap:12px; padding:9px 0; border-bottom:1px solid #22313e; }
.metric-row:last-child { border:0; }
.metric-row span { color:var(--muted); }
footer { color:#708293; font-size:12px; margin-top:18px; }
@media(max-width:850px) { .grid{grid-template-columns:repeat(2,minmax(0,1fr))}.two-col{grid-template-columns:1fr}header{align-items:flex-start} }
@media(max-width:560px) { main{width:calc(100% - 22px);margin:17px auto 36px}header{flex-direction:column}.grid{gap:8px}.card{padding:12px;min-height:94px}.value{font-size:23px}.panel{padding:14px}.form-grid{grid-template-columns:1fr}.full{grid-column:auto}.panel-head{flex-direction:column}.badge{white-space:normal} }
</style>
</head>
<body>
<main>
<header>
<div>
<div class="eyebrow">PrimeThai · Portfolio</div>
<h1>พอร์ตลงทุน</h1>
<p class="sub">เป้าหมายจาก Donchian 55/20 + EMA 50/200 · บันทึกเงินและรายการซื้อขายไว้ในเครื่อง</p>
</div>
<nav>
<a class="nav-link" href="/backtest">ดูผล backtest</a>
<button class="primary" id="refresh" type="button">อัปเดตสัญญาณ</button>
<form action="/logout" method="post"><button type="submit">ออกจากระบบ</button></form>
</nav>
</header>
<div id="refresh-status" class="status"><span class="dot"></span><span id="refresh-status-text">กำลังอ่านสถานะการคำนวณ…</span></div>
<div class="status"><span class="dot"></span><span>กดบันทึกจากสัญญาณเพื่อปรับสมุดพอร์ตด้วยราคาอ้างอิง แล้วแก้เป็นจำนวน/ราคาที่ได้จริงภายหลัง · ไม่มีการส่งคำสั่งไปโบรกเกอร์</span></div>
<section class="grid" aria-label="สรุปพอร์ต">
<article class="card"><div class="label">มูลค่าพอร์ต ณ ราคาปิด</div><div id="equity" class="value">—</div><div id="asof" class="detail">กำลังอ่านข้อมูลราคา</div></article>
<article class="card"><div class="label">เงินสดคงเหลือ</div><div id="cash" class="value">—</div><div class="detail">เงินที่บันทึกไว้หลังรายการที่กรอก</div></article>
<article class="card"><div class="label">เงินลงทุนสุทธิ</div><div id="contributed" class="value">—</div><div class="detail">เงินเพิ่มเข้าพอร์ต หักเงินถอน</div></article>
<article class="card"><div class="label">กำไร/ขาดทุนที่ประเมิน</div><div id="pnl" class="value">—</div><div id="pnl-pct" class="detail">คำนวณจากราคาปิดล่าสุด</div></article>
</section>
<section class="panel">
<div class="panel-head">
<div><h2>หุ้นที่ถือและเป้าหมายของระบบ</h2><p class="sub">จำนวนเป้าหมายคำนวณตามน้ำหนักโมเดลและมูลค่าพอร์ต · ราคา limit อ้างอิงเท่ากับราคาปิดล่าสุด</p></div>
<span id="book-state" class="badge">กำลังโหลดบัญชี</span>
</div>
<div class="table-wrap"><table>
<thead><tr><th>หุ้น</th><th>โมเดล</th><th class="num">ถืออยู่</th><th class="num">เป้าหมาย</th><th>ปรับพอร์ต</th><th class="num">ราคาปิด</th><th class="num">ราคาอ้างอิง</th><th class="num">จำนวนเปลี่ยน</th><th>บันทึก</th></tr></thead>
<tbody id="positions"><tr><td colspan="9" class="empty">กำลังอ่านสัญญาณและพอร์ต…</td></tr></tbody>
</table></div>
<div class="metric-list">
<div class="metric-row"><span>มูลค่าซื้อที่ระบบเสนอ</span><strong id="buy-value">—</strong></div>
<div class="metric-row"><span>มูลค่าขายที่ระบบเสนอ</span><strong id="sell-value">—</strong></div>
<div class="metric-row"><span>เงินสดหลังรายการขาย/ซื้อเป้าหมาย</span><strong id="cash-after">—</strong></div>
</div>
<div class="help">ประมาณการก่อนค่าธรรมเนียมและเงื่อนไขการใช้เงินจากยอดขายของโบรกเกอร์</div>
<div id="portfolio-note" class="refresh-line"></div>
</section>
<section class="two-col">
<article class="panel">
<div class="panel-head"><div><h2>เพิ่มเงินลงทุน</h2><p class="sub">ครั้งแรกจะเริ่มพอร์ต; ครั้งต่อไปเพิ่มเงินสดและคำนวณเป้าหมายใหม่</p></div></div>
<form id="deposit-form">
<div class="form-grid">
<label>จำนวนเงิน (บาท)<input id="deposit-amount" type="number" min="1" step="0.01" required placeholder="เช่น 100000"></label>
<label>หมายเหตุ (ถ้ามี)<input id="deposit-note" maxlength="200" placeholder="เงินลงทุนครั้งแรก / เติมเงิน"></label>
</div>
<div class="form-actions"><button id="deposit-submit" class="primary" type="submit">ลงทุนครั้งแรก</button></div>
</form>
<div id="deposit-message" class="help">หลังเพิ่มเงิน กดบันทึกซื้อ/ขายในตารางเพื่อปรับสมุดพอร์ตตามแผน ระบบเริ่มด้วยราคาปิดล่าสุดและแก้เป็นราคาที่ได้จริงภายหลังได้</div>
</article>
</section>
<section class="panel">
<div class="panel-head"><div><h2>ประวัติเงินและรายการล่าสุด</h2><p class="sub">ข้อมูลบัญชีเก็บไว้ใน SQLite ภายในโฟลเดอร์โครงการและไม่เชื่อมบัญชีโบรกเกอร์</p></div><span class="badge">สูงสุด 10 รายการ</span></div>
<div class="table-wrap"><table style="min-width:620px">
<thead><tr><th>เวลา</th><th>รายการ</th><th>หุ้น</th><th class="num">จำนวน</th><th class="num">ราคา/เงิน</th><th class="num">ค่าธรรมเนียม</th><th>จัดการ</th></tr></thead>
<tbody id="ledger"><tr><td colspan="7" class="empty">ยังไม่มีรายการ</td></tr></tbody>
</table></div>
<form id="trade-edit-form" class="edit-panel" hidden>
<div class="panel-head"><div><h2>แก้รายการในสมุดพอร์ต</h2><p class="sub">ใส่จำนวนและราคาที่ได้จริง ระบบจะคำนวณเงินสดกับจำนวนหุ้นใหม่</p></div></div>
<input id="edit-trade-id" type="hidden">
<div class="form-grid">
<label>จำนวนหุ้นจริง<input id="edit-trade-quantity" type="number" min="1" step="1" required></label>
<label>ราคาที่ได้จริง (บาท)<input id="edit-trade-price" type="number" min="0.01" step="0.01" required></label>
<label>ค่าธรรมเนียมรวม (บาท)<input id="edit-trade-fees" type="number" min="0" step="0.01" value="0"></label>
</div>
<div class="form-actions"><button class="primary" type="submit">บันทึกการแก้ไข</button><button id="edit-trade-cancel" type="button">ปิด</button></div>
<div id="edit-trade-message" class="help"></div>
</form>
<form id="cash-event-form" style="margin-top:14px">
<div class="form-grid">
<label>เงินสดอื่น<select id="cash-event-kind"><option value="dividend">เงินปันผลรับ</option><option value="withdrawal">ถอนเงินออก</option></select></label>
<label>จำนวนเงิน (บาท)<input id="cash-event-amount" type="number" min="0.01" step="0.01" required></label>
<label>หมายเหตุ<input id="cash-event-note" maxlength="200" placeholder="เช่น ปันผล ADVANC"></label>
<div class="form-actions"><button type="submit">บันทึกรายการ</button></div>
</div>
</form>
<div id="cash-event-message" class="help">เงินปันผลและเงินถอนจะปรับเงินสดและผลกำไร/ขาดทุนเมื่อบันทึกรายการนี้</div>
</section>
<section class="panel">
<div class="panel-head"><div><h2>ข้อมูลและการอัปเดต</h2><p class="sub">เซิร์ฟเวอร์ดึง SiamChart EOD และคำนวณสัญญาณให้อัตโนมัติทุกวันจันทร์–ศุกร์หลัง 20:30 น. เวลาไทย หาก SiamChart ใช้ไม่ได้จะตรวจข้อมูลสำรองจาก Yahoo Finance · ราคาปิดไม่ใช่ราคา real-time</p></div></div>
<div id="data-state" class="refresh-line">กำลังตรวจแหล่งข้อมูล</div>
<div id="refresh-message" class="refresh-line"></div>
</section>
<footer>ระบบดึงข้อมูลและคำนวณสัญญาณอัตโนมัติ แต่คำสั่งซื้อขายยังต้องทำกับโบรกเกอร์เอง · ราคาอ้างอิงเป็นราคาปิด ไม่ใช่ real-time · ตรวจราคาและกำลังซื้อก่อนซื้อขาย</footer>
</main>
<script>
const nativeFetch=window.fetch.bind(window);
window.fetch=async(...args)=>{const response=await nativeFetch(...args);if(response.status===401){const next=encodeURIComponent(location.pathname+location.hash);location.replace('/login?error=expired&next='+next);}return response;};
const $ = id => document.getElementById(id);
const money = value => Number.isFinite(Number(value)) ? '฿' + Number(value).toLocaleString('th-TH',{minimumFractionDigits:2,maximumFractionDigits:2}) : '—';
const number = value => Number(value||0).toLocaleString('th-TH');
const price = value => value == null ? '—' : Number(value).toLocaleString('th-TH',{minimumFractionDigits:2,maximumFractionDigits:4});
const dateText = value => value ? new Date(value+'T00:00:00').toLocaleDateString('th-TH',{day:'numeric',month:'short',year:'numeric'}) : '—';
const actionText = value => ({BUY:'ซื้อเพิ่ม',SELL:'ลด/ขาย',HOLD:'ถือ',WAIT:'รอเงินลงทุน',REVIEW:'ตรวจราคา', 'PRICE NEEDED':'ขาดราคา','BELOW LOT':'ไม่ถึง 1 lot'})[value] || value;
const setMessage = (id,text,isError=false) => { $(id).textContent=text; $(id).className='help '+(isError?'negative':''); };
function renderRows(rows, accountExists, refresh, signalsReady) {
const body=$('positions'); body.replaceChildren();
if(!rows?.length) {
const tr=document.createElement('tr'),td=document.createElement('td');td.colSpan=9;td.className='empty';
td.textContent=refresh?.running?'กำลังคำนวณสัญญาณอยู่ ข้อมูลจะแสดงเมื่อเสร็จแล้ว…':signalsReady?'รอบนี้ยังไม่มีหุ้นที่ผ่านเกณฑ์สัญญาณ':'ยังไม่พบไฟล์สัญญาณ โปรดรอการคำนวณหรือดูรายละเอียดสถานะด้านบน';
tr.append(td);body.append(tr);return;
}
for(const row of rows) {
const tr=document.createElement('tr');
const values=[row.symbol,row.source_model||'—',number(row.quantity_held),row.target_quantity==null?'—':number(row.target_quantity),actionText(row.action),price(row.reference_close),price(row.suggested_limit_price),row.delta_quantity?number(Math.abs(row.delta_quantity)):'—'];
values.forEach((value,index)=>{const td=document.createElement('td');td.textContent=value;if(index===0)td.className='symbol';if([2,3,5,6,7].includes(index))td.classList.add('num');if(index===4)td.className='action '+String(row.action).replaceAll(' ','_');tr.append(td);});
const td=document.createElement('td');
if(accountExists && ['BUY','SELL'].includes(row.action)) {
const button=document.createElement('button');button.className='mini '+(row.action==='BUY'?'primary':'');button.textContent=row.action==='BUY'?'บันทึกซื้อ':'บันทึกขาย';
button.title='ปรับสมุดพอร์ตในแอปเท่านั้น ไม่ส่งคำสั่งให้โบรกเกอร์';
button.addEventListener('click',async()=>{
const actionLabel=row.action==='BUY'?'ซื้อ':'ขาย';
const accepted=confirm(`บันทึก${actionLabel} ${number(Math.abs(row.delta_quantity))} หุ้น ${row.symbol} โดยใช้ราคาปิดอ้างอิง ${price(row.suggested_limit_price)} บาท?\n\nการบันทึกนี้ปรับเฉพาะสมุดพอร์ตในแอป ไม่ส่งคำสั่งไปโบรกเกอร์ และแก้ราคากับจำนวนจริงภายหลังได้`);
if(!accepted)return;button.disabled=true;
try{const response=await fetch('/api/portfolio/signal-trade',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({symbol:row.symbol,side:row.action})});const result=await response.json();if(!response.ok)throw new Error(result.message);$('refresh-message').textContent=result.message;await load();}
catch(error){$('refresh-message').textContent=error.message;}
finally{button.disabled=false;}
});td.append(button);
} else {td.textContent='—';}
tr.append(td);body.append(tr);
}
}
function renderLedger(rows) {
const body=$('ledger');body.replaceChildren();
if(!rows?.length){const tr=document.createElement('tr'),td=document.createElement('td');td.colSpan=7;td.className='empty';td.textContent='ยังไม่มีรายการ';tr.append(td);body.append(tr);return;}
for(const row of rows){
const tr=document.createElement('tr');const time=row.occurred_at?new Date(row.occurred_at).toLocaleString('th-TH'):'—';
let kind=({deposit:'เพิ่มเงินลงทุน',withdrawal:'ถอนเงิน',dividend:'เงินปันผล',BUY:'ซื้อ',SELL:'ขาย'})[row.kind]||row.kind;
if(row.kind==='BUY'||row.kind==='SELL')kind+=row.is_estimated?' · ประมาณจากสัญญาณ':'จริง';if(row.is_void)kind+=' · ยกเลิกแล้ว';
const vals=[time,kind,row.symbol||'—',row.quantity?number(row.quantity):'—',row.price?money(row.price):money(row.amount),money(row.fees||0)];
vals.forEach((value,i)=>{const td=document.createElement('td');td.textContent=value;if(i>=3)td.classList.add('num');tr.append(td);});
const actionCell=document.createElement('td');
if(row.id && !row.is_void){
const group=document.createElement('div');group.className='actions';
const edit=document.createElement('button');edit.className='mini';edit.textContent='แก้';edit.addEventListener('click',()=>{
$('edit-trade-id').value=row.id;$('edit-trade-quantity').value=row.quantity;$('edit-trade-price').value=row.price;$('edit-trade-fees').value=row.fees||0;
$('edit-trade-message').textContent=`รายการ ${row.kind==='BUY'?'ซื้อ':'ขาย'} ${row.symbol} #${row.id}`;$('trade-edit-form').hidden=false;$('trade-edit-form').scrollIntoView({behavior:'smooth',block:'center'});
});group.append(edit);
if(row.is_estimated){const cancel=document.createElement('button');cancel.className='mini';cancel.textContent='ยกเลิก';cancel.addEventListener('click',async()=>{
if(!confirm(`ยกเลิกรายการประมาณการ #${row.id} ใช่หรือไม่? สมุดพอร์ตจะคำนวณเงินสดและหุ้นใหม่`))return;
cancel.disabled=true;try{const response=await fetch('/api/portfolio/void-trade',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({trade_id:row.id})});const result=await response.json();if(!response.ok)throw new Error(result.message);$('refresh-message').textContent=result.message;await load();}
catch(error){$('refresh-message').textContent=error.message;}finally{cancel.disabled=false;}
});group.append(cancel);}
actionCell.append(group);
} else actionCell.textContent='—';
tr.append(actionCell);body.append(tr);
}
}
function renderRefreshState(refresh, data) {
const banner=$('refresh-status'), text=$('refresh-status-text');
banner.className='status';
const stageLabels={starting:'กำลังเริ่มงาน',inspect_local_archive:'กำลังตรวจไฟล์ข้อมูล',refresh_price_sources:'กำลังดึงข้อมูลราคา',read_latest_eod_date:'กำลังตรวจวันข้อมูลล่าสุด',run_model_lab:'กำลังคำนวณ backtest และสัญญาณ',complete:'พร้อมใช้งาน',failed:'คำนวณไม่สำเร็จ',idle:'ยังไม่ได้เริ่ม'};
if(refresh?.running) {
banner.classList.add('running');
const stage=stageLabels[refresh.stage]||refresh.stage_message||'กำลังทำงาน';
const started=refresh.started_at?new Date(refresh.started_at).toLocaleString('th-TH'):'—';
const existing=data?.signals_count?` · มีสัญญาณชุดก่อนหน้า ${number(data.signals_count)} หุ้น`:' · สัญญาณชุดใหม่ยังไม่พร้อม';
text.textContent=`${stage} · เริ่ม ${started}${existing}`;
} else if(refresh?.error) {
banner.classList.add('error');
const stage=stageLabels[refresh.failed_stage]||refresh.failed_stage||'ไม่ทราบขั้นตอน';
text.textContent=`คำนวณไม่สำเร็จที่ ${stage}: ${refresh.error}`;
} else if(data?.signals_count) {
banner.classList.add('ready');
text.textContent=`พร้อมใช้งาน · มีสัญญาณ ${number(data.signals_count)} หุ้น · ข้อมูลสัญญาณถึง ${dateText(data.signal_as_of||data.price_as_of)}`;
} else if(!data?.signals_file_exists) {
banner.classList.add('error');
text.textContent='ยังไม่มีไฟล์สัญญาณบนเซิร์ฟเวอร์ · กด “อัปเดตสัญญาณ” แล้วรอให้คำนวณเสร็จ';
} else {
text.textContent='คำนวณเสร็จแล้ว แต่รอบนี้ไม่มีหุ้นที่ผ่านเกณฑ์';
}
}
async function load() {
try {
const response=await fetch('/api/portfolio',{cache:'no-store'});const d=await response.json();if(!response.ok)throw new Error(d.error||'อ่านพอร์ตไม่สำเร็จ');
renderRefreshState(d.refresh,d);
$('equity').textContent=money(d.equity);$('cash').textContent=money(d.cash);$('contributed').textContent=money(d.net_contributed);
$('asof').textContent='ราคาปิดถึง '+dateText(d.price_as_of)+' · สัญญาณ '+dateText(d.signal_as_of);
if(d.profit_loss==null){$('pnl').textContent='ยังประเมินไม่ได้';$('pnl').className='value warning';$('pnl-pct').textContent=d.unpriced_symbols?.length?'ราคาหุ้นที่ถือไม่ครบ: '+d.unpriced_symbols.join(', '):'เพิ่มเงินลงทุนและมีราคาครบก่อน';}
else {$('pnl').textContent=money(d.profit_loss);$('pnl').className='value '+(d.profit_loss>=0?'positive':'negative');$('pnl-pct').textContent=d.profit_loss_pct==null?'':(d.profit_loss_pct>=0?'+':'')+(d.profit_loss_pct*100).toFixed(2)+'% จากเงินลงทุนสุทธิ';}
$('book-state').textContent=d.account_exists?'เริ่มพอร์ตแล้ว · เพิ่มเงิน '+number(d.deposit_count)+' ครั้ง':'ยังไม่มีพอร์ต · เพิ่มเงินครั้งแรกเพื่อเริ่ม';
$('deposit-submit').textContent=d.account_exists?'เพิ่มเงินลงทุน':'ลงทุนครั้งแรก';
$('buy-value').textContent=money(d.buy_value);$('sell-value').textContent=money(d.sell_value);$('cash-after').textContent=money(d.cash_after_planned_trades);
const notes=[];
if(!d.account_exists)notes.push('เพิ่มเงินลงทุนครั้งแรกเพื่อคำนวณหุ้นและจำนวนเป้าหมาย');
if(d.rows?.some(row=>row.action==='BELOW LOT'))notes.push('บางเป้าหมายมีมูลค่าต่ำกว่าหุ้น 100 หุ้น จึงคงส่วนนั้นไว้เป็นเงินสด');
if(!d.complete&&d.account_exists)notes.push('ข้อมูลราคาหุ้นในพอร์ตไม่ครบหรือเก่ากว่าสัญญาณ จึงพักคำแนะนำซื้อขายจนกว่าจะตรวจราคาได้');
if(d.requires_sell_proceeds>0)notes.push('รายการซื้อเป้าหมายใช้เงินมากกว่าเงินสดปัจจุบัน; ต้องพิจารณารายการขายและกำลังซื้อจากโบรกเกอร์ก่อน');
if(d.refresh?.running)notes.push(d.refresh.stage_message||'กำลังอัปเดตข้อมูลและคำนวณสัญญาณ');
$('portfolio-note').textContent=notes.join(' · ');
renderRows(d.rows,d.account_exists,d.refresh,d.signals_ready);renderLedger(d.ledger);
let source='ข้อมูล EOD ถึง '+dateText(d.quote_archive_as_of||d.price_as_of);
if(d.price_history?.sessions)source+=' · เก็บประวัติ '+Number(d.price_history.sessions).toLocaleString('th-TH')+' วัน ('+dateText(d.price_history.first_date)+'–'+dateText(d.price_history.last_date)+') · ล่าสุดจาก '+(d.price_history.latest_source||'แหล่งเดิม');
if(d.quote_update_error)source+=' · อ่านราคาใหม่ไม่สำเร็จ: '+d.quote_update_error;
if(d.refresh?.download_status)source+=' · '+d.refresh.download_status;
if(d.automatic_download?.attempted_at)source+=' · ตรวจล่าสุด '+new Date(d.automatic_download.attempted_at).toLocaleString('th-TH');
if(d.backup_download?.attempted_at)source+=' · ตรวจแหล่งสำรองล่าสุด '+new Date(d.backup_download.attempted_at).toLocaleString('th-TH');
if(d.refresh?.error)source+=' · คำนวณไม่สำเร็จ: '+d.refresh.error;
$('data-state').textContent=source;
return d;
} catch(error) {$('refresh-status').className='status error';$('refresh-status-text').textContent='อ่านสถานะพอร์ตไม่สำเร็จ: '+error.message;$('data-state').textContent='อ่านพอร์ตไม่สำเร็จ: '+error.message;return null;}
}
$('deposit-form').addEventListener('submit',async event=>{event.preventDefault();const button=$('deposit-submit');button.disabled=true;try{const response=await fetch('/api/portfolio/deposit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({amount:Number($('deposit-amount').value),note:$('deposit-note').value})});const result=await response.json();if(!response.ok)throw new Error(result.message);setMessage('deposit-message',result.message);$('deposit-form').reset();await load();}catch(error){setMessage('deposit-message',error.message,true);}finally{button.disabled=false;}});
$('trade-edit-form').addEventListener('submit',async event=>{event.preventDefault();const button=$('trade-edit-form').querySelector('button[type=submit]');button.disabled=true;try{const body={trade_id:Number($('edit-trade-id').value),quantity:Number($('edit-trade-quantity').value),price:Number($('edit-trade-price').value),fees:Number($('edit-trade-fees').value||0)};const response=await fetch('/api/portfolio/edit-trade',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const result=await response.json();if(!response.ok)throw new Error(result.message);setMessage('edit-trade-message',result.message);await load();}catch(error){setMessage('edit-trade-message',error.message,true);}finally{button.disabled=false;}});
$('edit-trade-cancel').addEventListener('click',()=>{$('trade-edit-form').hidden=true;});
$('cash-event-form').addEventListener('submit',async event=>{event.preventDefault();const button=$('cash-event-form').querySelector('button[type=submit]');button.disabled=true;try{const body={kind:$('cash-event-kind').value,amount:Number($('cash-event-amount').value),note:$('cash-event-note').value};const response=await fetch('/api/portfolio/cash-event',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const result=await response.json();if(!response.ok)throw new Error(result.message);setMessage('cash-event-message',result.message);$('cash-event-form').reset();await load();}catch(error){setMessage('cash-event-message',error.message,true);}finally{button.disabled=false;}});
let refreshTimer=null;
async function waitForRefresh(){
if(refreshTimer)clearInterval(refreshTimer);
const started=Date.now();let done=false;
const poll=async()=>{const d=await load();if(!d?.refresh?.running||Date.now()-started>30*60*1000){done=true;if(refreshTimer)clearInterval(refreshTimer);refreshTimer=null;$('refresh').disabled=false;$('refresh').textContent='อัปเดตสัญญาณ';}};
await poll();if(!done)refreshTimer=setInterval(poll,5000);
}
$('refresh').addEventListener('click',async()=>{
const button=$('refresh');button.disabled=true;button.textContent='กำลังตรวจ…';
try{
const response=await fetch('/api/refresh',{method:'POST'});const result=await response.json();$('refresh-message').textContent=result.message;
if(result.started||response.status===409){waitForRefresh();}
else {button.disabled=false;button.textContent='อัปเดตสัญญาณ';await load();}
}catch(error){$('refresh-message').textContent=error.message;button.disabled=false;button.textContent='อัปเดตสัญญาณ';}
});
load();setInterval(load,60000);
</script>
</body>
</html>