312 lines
33 KiB
HTML
312 lines
33 KiB
HTML
<!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>
|