Show refresh progress and preserve signal visibility

This commit is contained in:
PrimeThai Automation
2026-09-30 14:31:28 +07:00
parent 84d61846d3
commit 762fa18503
3 changed files with 95 additions and 12 deletions

View File

@@ -288,3 +288,5 @@ docker compose up -d --build --force-recreate primethai
```
If login still returns 403, look for `[auth]` entries with `event: origin_rejected` in `docker compose logs --tail=100 primethai`. These show the reason, received Origin, expected origin, configured origins and selected proxy headers. `null_origin_check_page_referrer_policy` indicates a hidden/opaque origin; reload the page and check browser/privacy or proxy referrer-policy settings. `origin_mismatch` indicates a public scheme/host mismatch; compare the logged headers with the public URL and the configured allowed origins. Logs exclude passwords, session cookies and request bodies. Missing, null and unrelated origins remain rejected.
The home page shows the refresh stage at the top. A `409` from the refresh button means a refresh is already running (usually the automatic startup refresh); the page follows that existing job and keeps showing its current stage. If the report files are missing, the page explicitly says that signals are not ready yet. A failed calculation shows the failed stage and exception in the page and in the container log.

View File

@@ -21,7 +21,13 @@
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; }
.dot { width:8px; height:8px; border-radius:50%; background:var(--green); box-shadow:0 0 12px #62d6a188; }
.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; }
@@ -77,6 +83,7 @@
</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="สรุปพอร์ต">
@@ -165,9 +172,13 @@
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) {
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='ยังไม่มีสัญญาณจากข้อมูล EOD';tr.append(td);body.append(tr);return; }
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)):'—'];
@@ -216,9 +227,35 @@
}
}
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(', '):'เพิ่มเงินลงทุนและมีราคาครบก่อน';}
@@ -231,9 +268,9 @@
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('กำลังอัปเดตข้อมูลและคำนวณสัญญาณ');
if(d.refresh?.running)notes.push(d.refresh.stage_message||'กำลังอัปเดตข้อมูลและคำนวณสัญญาณ');
$('portfolio-note').textContent=notes.join(' · ');
renderRows(d.rows,d.account_exists);renderLedger(d.ledger);
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;
@@ -242,7 +279,8 @@
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;
} catch(error) {$('data-state').textContent='อ่านพอร์ตไม่สำเร็จ: '+error.message;}
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;}});
@@ -252,8 +290,21 @@
$('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;}});
async function waitForRefresh(){const started=Date.now();const timer=setInterval(async()=>{await load();const response=await fetch('/api/portfolio',{cache:'no-store'});const d=await response.json();if(!d.refresh?.running||Date.now()-started>180000){clearInterval(timer);$('refresh').disabled=false;}},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)waitForRefresh();else button.disabled=false;}catch(error){$('refresh-message').textContent=error.message;button.disabled=false;}finally{button.textContent='อัปเดตสัญญาณ';}});
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>

View File

@@ -68,6 +68,9 @@ _LOGIN_MAX_FAILURES = 5
_LOGIN_WINDOW_SECONDS = 15 * 60
_STATE: dict[str, object] = {
"running": False,
"stage": "idle",
"stage_message": "ยังไม่ได้เริ่มการอัปเดต",
"stage_updated_at": None,
"started_at": None,
"completed_at": None,
"error": None,
@@ -179,7 +182,8 @@ def _report_data(report_dir: Path, initial_equity: float,
def _portfolio_data(archives: tuple[Path, ...], report_dir: Path) -> dict[str, object]:
union_dir = report_dir / "primary_union_donchian_ema"
signals = _read_csv(union_dir / "next_session_signals.csv")
signal_path = union_dir / "next_session_signals.csv"
signals = _read_csv(signal_path)
base_metrics = _read_json(union_dir / "base_cost/board_lot_100/metrics.json") or {}
signal_as_of = (signals[0].get("signal_as_of") if signals else
str(base_metrics.get("end", "")) or None)
@@ -196,7 +200,10 @@ def _portfolio_data(archives: tuple[Path, ...], report_dir: Path) -> dict[str, o
with _STATE_LOCK:
refresh = dict(_STATE)
result.update({
"signals_ready": (report_dir / "data_notes.json").is_file(),
"signals_ready": signal_path.is_file() and bool(signals),
"signals_file_exists": signal_path.is_file(),
"signals_count": len(signals),
"reports_ready": (report_dir / "data_notes.json").is_file(),
"signal_as_of": signal_as_of,
"quote_archive_as_of": price_as_of,
"quote_update_error": quote_error,
@@ -240,6 +247,15 @@ def _selected_state(state: dict[str, object], fields: tuple[str, ...]) -> dict[s
return {key: state[key] for key in fields if key in state}
def _set_refresh_stage(stage: str, message: str) -> None:
with _STATE_LOCK:
_STATE.update({
"stage": stage,
"stage_message": message,
"stage_updated_at": datetime.now(timezone.utc).isoformat(),
})
def _last_fetch_attempt_date() -> str | None:
state = _read_json(LATEST_FETCH_STATE)
value = state.get("last_attempt_date") if state else None
@@ -465,11 +481,13 @@ def _refresh_worker(archives: tuple[Path, ...], report_dir: Path, start: str,
initial_equity: float, fetch_latest: bool = True) -> None:
stage = "inspect_local_archive"
try:
_set_refresh_stage(stage, "กำลังตรวจไฟล์ข้อมูลที่มีอยู่ในเครื่อง")
destination = _daily_archive_destination(archives)
archive_mtime_before = (destination.stat().st_mtime_ns
if destination is not None and destination.is_file() else None)
if fetch_latest and destination is not None:
stage = "refresh_price_sources"
_set_refresh_stage(stage, "กำลังตรวจข้อมูล SiamChart และ Yahoo Finance สำรอง")
download_status = _fetch_latest_archive(destination)
primary_state = _read_json(LATEST_FETCH_STATE) or {}
now = datetime.now(THAILAND_TZ)
@@ -493,6 +511,7 @@ def _refresh_worker(archives: tuple[Path, ...], report_dir: Path, start: str,
else:
download_status = "คำนวณจากไฟล์ EOD ในเครื่อง"
stage = "read_latest_eod_date"
_set_refresh_stage(stage, "กำลังตรวจวันข้อมูลล่าสุด")
latest = _latest_archive_date(archives)
archive_mtime_after = (destination.stat().st_mtime_ns
if destination is not None and destination.is_file() else None)
@@ -501,6 +520,8 @@ def _refresh_worker(archives: tuple[Path, ...], report_dir: Path, start: str,
with _STATE_LOCK:
_STATE.update({"running": False, "completed_at": datetime.now(timezone.utc).isoformat(),
"error": None, "failed_stage": None,
"stage": "complete", "stage_message": "ข้อมูลและสัญญาณพร้อมใช้งาน",
"stage_updated_at": datetime.now(timezone.utc).isoformat(),
"data_as_of": latest.date().isoformat(),
"download_status": download_status})
_daily_log("refresh_completed", data_as_of=latest.date().isoformat(),
@@ -508,6 +529,7 @@ def _refresh_worker(archives: tuple[Path, ...], report_dir: Path, start: str,
return
end = (latest + pd.Timedelta(days=1)).date().isoformat()
stage = "run_model_lab"
_set_refresh_stage(stage, "กำลังคำนวณ backtest และสัญญาณหุ้น อาจใช้เวลาหลายนาที")
run_model_lab(
[str(path) for path in archives], start=start, end=end,
output_dir=report_dir, action_dir=Path("data/raw/actions"),
@@ -518,6 +540,8 @@ def _refresh_worker(archives: tuple[Path, ...], report_dir: Path, start: str,
with _STATE_LOCK:
_STATE.update({"running": False, "completed_at": datetime.now(timezone.utc).isoformat(),
"error": None, "failed_stage": None,
"stage": "complete", "stage_message": "คำนวณเสร็จแล้ว สัญญาณพร้อมใช้งาน",
"stage_updated_at": datetime.now(timezone.utc).isoformat(),
"data_as_of": latest.date().isoformat(),
"download_status": download_status})
_daily_log("refresh_completed", data_as_of=latest.date().isoformat(),
@@ -525,7 +549,10 @@ def _refresh_worker(archives: tuple[Path, ...], report_dir: Path, start: str,
except Exception as exc: # The UI reports local data or calculation errors to the user.
with _STATE_LOCK:
_STATE.update({"running": False, "completed_at": datetime.now(timezone.utc).isoformat(),
"error": f"{type(exc).__name__}: {exc}", "failed_stage": stage})
"error": f"{type(exc).__name__}: {exc}", "failed_stage": stage,
"stage": "failed",
"stage_message": f"ทำงานไม่สำเร็จที่ขั้นตอน {stage}",
"stage_updated_at": datetime.now(timezone.utc).isoformat()})
_daily_log("refresh_failed", stage=stage, error_type=type(exc).__name__, error=str(exc))
@@ -534,7 +561,10 @@ def _start_refresh(archives: tuple[Path, ...], report_dir: Path, start: str,
with _STATE_LOCK:
if bool(_STATE["running"]):
return False, "กำลังคำนวณข้อมูลชุดนี้อยู่แล้ว"
_STATE.update({"running": True, "started_at": datetime.now(timezone.utc).isoformat(),
_STATE.update({"running": True, "stage": "starting",
"stage_message": "กำลังเริ่มงานอัปเดตข้อมูล",
"stage_updated_at": datetime.now(timezone.utc).isoformat(),
"started_at": datetime.now(timezone.utc).isoformat(),
"completed_at": None, "error": None, "failed_stage": None})
worker = threading.Thread(
target=_refresh_worker,