Show refresh progress and preserve signal visibility
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user