Defer P&L until after actual purchase date

This commit is contained in:
PrimeThai Automation
2026-09-30 15:17:22 +07:00
parent bfe2c5dd24
commit 16e04271b3
4 changed files with 132 additions and 18 deletions

View File

@@ -4,19 +4,21 @@ from __future__ import annotations
import csv
from contextlib import closing
from datetime import datetime, timezone
from datetime import date, datetime, time
import math
from pathlib import Path
import re
import sqlite3
import zipfile
from typing import Any
from zoneinfo import ZoneInfo
DEFAULT_BOOK = Path("data/portfolio/prime_thai_portfolio.sqlite3")
MAX_CASH_AMOUNT = 1_000_000_000_000.0
_EOD_FILE = re.compile(r"set-history_EOD_(\d{4}-\d{2}-\d{2})\.csv$", re.IGNORECASE)
_SYMBOL = re.compile(r"[A-Z0-9]{1,12}$")
THAILAND_TZ = ZoneInfo("Asia/Bangkok")
def _connect(database: str | Path = DEFAULT_BOOK) -> sqlite3.Connection:
@@ -130,6 +132,29 @@ def _read_ledger(connection: sqlite3.Connection) -> tuple[list[sqlite3.Row], lis
return cash_events, trades
def _trade_date(value: object) -> str:
"""Return a ledger timestamp's calendar date in the Thai market timezone."""
text = str(value or "").strip()
try:
parsed = datetime.fromisoformat(text)
except ValueError:
try:
return date.fromisoformat(text[:10]).isoformat()
except ValueError:
return text[:10]
if parsed.tzinfo is not None:
parsed = parsed.astimezone(THAILAND_TZ)
return parsed.date().isoformat()
def _normalize_trade_date(value: object) -> str:
text = str(value or "").strip()
try:
return date.fromisoformat(text).isoformat()
except ValueError as exc:
raise ValueError("วันที่ซื้อจริงต้องเป็นรูปแบบ YYYY-MM-DD") from exc
def _calculate_book(cash_events: list[sqlite3.Row], trades: list[sqlite3.Row]) -> dict[str, Any]:
cash = 0.0
net_contributed = 0.0
@@ -146,12 +171,17 @@ def _calculate_book(cash_events: list[sqlite3.Row], trades: list[sqlite3.Row]) -
elif event["kind"] == "dividend":
cash += amount
positions: dict[str, dict[str, float | int]] = {}
positions: dict[str, dict[str, Any]] = {}
for trade in trades:
if "is_void" in trade.keys() and bool(trade["is_void"]):
continue
symbol = str(trade["symbol"])
position = positions.setdefault(symbol, {"quantity": 0, "cost_basis": 0.0, "realized_pnl": 0.0})
position = positions.setdefault(symbol, {
"quantity": 0,
"cost_basis": 0.0,
"realized_pnl": 0.0,
"last_buy_date": None,
})
quantity = int(trade["quantity"])
price = float(trade["price"])
fees = float(trade["fees"])
@@ -160,6 +190,9 @@ def _calculate_book(cash_events: list[sqlite3.Row], trades: list[sqlite3.Row]) -
cash -= gross + fees
position["quantity"] = int(position["quantity"]) + quantity
position["cost_basis"] = float(position["cost_basis"]) + gross + fees
buy_date = _trade_date(trade["occurred_at"])
if not position["last_buy_date"] or buy_date > str(position["last_buy_date"]):
position["last_buy_date"] = buy_date
else:
held = int(position["quantity"])
average_cost = float(position["cost_basis"]) / held if held else 0.0
@@ -167,6 +200,8 @@ def _calculate_book(cash_events: list[sqlite3.Row], trades: list[sqlite3.Row]) -
position["realized_pnl"] = float(position["realized_pnl"]) + gross - fees - average_cost * quantity
position["cost_basis"] = max(0.0, float(position["cost_basis"]) - average_cost * quantity)
position["quantity"] = held - quantity
if int(position["quantity"]) <= 0:
position["last_buy_date"] = None
positions = {symbol: position for symbol, position in positions.items()
if int(position["quantity"]) > 0}
return {"cash": cash, "net_contributed": net_contributed,
@@ -228,6 +263,7 @@ def record_trade(symbol: str, side: str, quantity: int, price: float, fees: floa
def update_trade(trade_id: int, quantity: int, price: float, fees: float = 0.0, *,
trade_date: str | None = None,
database: str | Path = DEFAULT_BOOK) -> None:
"""Edit a previously logged signal trade to match the actual fill."""
if isinstance(quantity, bool) or int(quantity) != quantity or int(quantity) <= 0:
@@ -236,6 +272,8 @@ def update_trade(trade_id: int, quantity: int, price: float, fees: float = 0.0,
price, fees = float(price), float(fees)
if not math.isfinite(price) or price <= 0 or not math.isfinite(fees) or fees < 0:
raise ValueError("ราคาต้องมากกว่า 0 และค่าธรรมเนียมต้องไม่ติดลบ")
normalized_trade_date = (_normalize_trade_date(trade_date)
if trade_date is not None and str(trade_date).strip() else None)
with closing(_connect(database)) as connection:
connection.execute("BEGIN IMMEDIATE")
@@ -257,10 +295,19 @@ def update_trade(trade_id: int, quantity: int, price: float, fees: float = 0.0,
resulting_cash = float(book["cash"]) - old_quantity * old_price + old_fees + quantity * price - fees
if resulting_cash < -0.005:
raise ValueError(f"แก้รายการแล้วเงินสดจะติดลบ {abs(resulting_cash):,.2f} บาท")
connection.execute(
"UPDATE trades SET quantity=?, price=?, fees=?, is_estimated=0 WHERE id=?",
(quantity, price, fees, int(trade_id)),
)
if normalized_trade_date is None:
connection.execute(
"UPDATE trades SET quantity=?, price=?, fees=?, is_estimated=0 WHERE id=?",
(quantity, price, fees, int(trade_id)),
)
else:
occurred_at = datetime.combine(
date.fromisoformat(normalized_trade_date), time(12, 0), tzinfo=THAILAND_TZ,
).isoformat()
connection.execute(
"UPDATE trades SET quantity=?, price=?, fees=?, occurred_at=?, is_estimated=0 WHERE id=?",
(quantity, price, fees, occurred_at, int(trade_id)),
)
connection.execute("COMMIT")
except Exception:
connection.execute("ROLLBACK")
@@ -328,7 +375,7 @@ def portfolio_view(signals: list[dict[str, str]], *, board_lot: int = 100,
"allocation_score": score,
}
positions: dict[str, dict[str, float | int]] = book["positions"]
positions: dict[str, dict[str, Any]] = book["positions"]
missing_prices = sorted(symbol for symbol in positions if symbol not in prices)
signal_as_of = signal_as_of or max(
(str(row.get("signal_as_of", "")) for row in signals), default=""
@@ -339,6 +386,13 @@ def portfolio_view(signals: list[dict[str, str]], *, board_lot: int = 100,
if symbol in prices and signal_as_of and prices[symbol]["as_of"] < signal_as_of)
incomplete_symbols = sorted(set(missing_prices) | set(stale_prices))
complete = not incomplete_symbols and not signal_quote_mismatch
# An EOD close from the purchase date can precede the user's actual fill.
# Keep it as a reference price, but start unrealized P&L on the next date.
valuation_pending_symbols = sorted(
symbol for symbol, position in positions.items()
if symbol in prices and position.get("last_buy_date")
and prices[symbol]["as_of"] <= str(position["last_buy_date"])
)
total_market_value = 0.0
for symbol, position in positions.items():
if symbol in prices:
@@ -370,7 +424,9 @@ def portfolio_view(signals: list[dict[str, str]], *, board_lot: int = 100,
delta = (int(target_quantity) - quantity) if target_quantity is not None else None
market_value = quantity * close if close is not None else None
average_cost = cost_basis / quantity if quantity else None
unrealized = market_value - cost_basis if market_value is not None else None
unrealized = (market_value - cost_basis
if market_value is not None and symbol not in valuation_pending_symbols
else None)
if mark is None or symbol in stale_prices:
action = "PRICE NEEDED" if quantity else "WAIT"
elif not complete and account_exists:
@@ -491,11 +547,14 @@ def portfolio_view(signals: list[dict[str, str]], *, board_lot: int = 100,
"net_contributed": net_contributed,
"market_value": total_market_value,
"equity": equity,
"profit_loss": equity - net_contributed if complete else None,
"profit_loss_pct": (equity / net_contributed - 1.0) if complete and net_contributed > 0 else None,
"profit_loss": (equity - net_contributed
if complete and not valuation_pending_symbols else None),
"profit_loss_pct": (equity / net_contributed - 1.0)
if complete and not valuation_pending_symbols and net_contributed > 0 else None,
"complete": complete,
"unpriced_symbols": missing_prices,
"stale_price_symbols": stale_prices,
"valuation_pending_symbols": valuation_pending_symbols,
"signal_quote_mismatch": signal_quote_mismatch,
"price_as_of": quote_as_of or None,
"buy_value": buy_value,

View File

@@ -132,9 +132,10 @@
<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>
<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-date" type="date" required></label>
<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>
@@ -169,6 +170,12 @@
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 localDateInput = value => {
if(!value)return '';
const parts=new Intl.DateTimeFormat('en-US',{timeZone:'Asia/Bangkok',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date(value));
const get=type=>parts.find(part=>part.type===type)?.value||'';
return `${get('year')}-${get('month')}-${get('day')}`;
};
const actionText = value => ({BUY:'ซื้อเพิ่ม',SELL:'ลด/ขาย',HOLD:'ถือ',WAIT:'รอเงินลงทุน',REVIEW:'ตรวจราคา', 'PRICE NEEDED':'ขาดราคา','BELOW LOT':'ไม่ถึง 1 lot',BUDGET_LIMIT:'งบไม่พอ'})[value] || value;
const setMessage = (id,text,isError=false) => { $(id).textContent=text; $(id).className='help '+(isError?'negative':''); };
@@ -214,7 +221,7 @@
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-id').value=row.id;$('edit-trade-date').value=localDateInput(row.occurred_at);$('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()=>{
@@ -259,7 +266,7 @@
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(', '):'เพิ่มเงินลงทุนและมีราคาครบก่อน';}
if(d.profit_loss==null){$('pnl').textContent='ยังประเมินไม่ได้';$('pnl').className='value warning';$('pnl-pct').textContent=d.valuation_pending_symbols?.length?'รอราคาปิดหลังวันที่ซื้อ: '+d.valuation_pending_symbols.join(', '):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?'เพิ่มเงินลงทุน':'ลงทุนครั้งแรก';
@@ -268,6 +275,7 @@
if(!d.account_exists)notes.push('เพิ่มเงินลงทุนครั้งแรกเพื่อคำนวณหุ้นและจำนวนเป้าหมาย');
if(d.rows?.some(row=>row.action==='BELOW LOT'))notes.push('บางเป้าหมายมีมูลค่าต่ำกว่าหุ้น 100 หุ้น จึงคงส่วนนั้นไว้เป็นเงินสด');
if(d.capital_excluded_count>0)notes.push(`งบปัจจุบันเลือก ${number(d.capital_selected_count)} หุ้นจาก ${number(d.capital_selected_count+d.capital_excluded_count)} หุ้นที่ผ่านสัญญาณ; รายการที่เหลือขึ้น “งบไม่พอ”`);
if(d.valuation_pending_symbols?.length)notes.push('รายการที่ซื้อวันนี้ใช้ราคาที่แก้เป็นต้นทุนจริงแล้ว และจะเริ่มคิดกำไรจากราคาปิดของวันถัดไป');
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||'กำลังอัปเดตข้อมูลและคำนวณสัญญาณ');
@@ -287,7 +295,7 @@
$('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;}});
$('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),trade_date:$('edit-trade-date').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;}});

View File

@@ -974,9 +974,11 @@ def _handler_factory(archives: tuple[Path, ...], report_dir: Path,
raise ValueError("รหัสรายการและจำนวนหุ้นต้องเป็นจำนวนเต็ม")
update_trade(
int(raw_trade_id), int(raw_quantity), float(payload.get("price", 0)),
float(payload.get("fees", 0)), database=DEFAULT_BOOK,
float(payload.get("fees", 0)),
trade_date=(str(payload.get("trade_date", "")).strip() or None),
database=DEFAULT_BOOK,
)
message = f"แก้จำนวน/ราคาจริงของรายการ #{int(raw_trade_id)} แล้ว"
message = f"แก้วันที่ซื้อ/จำนวน/ราคาจริงของรายการ #{int(raw_trade_id)} แล้ว"
elif path.endswith("/void-trade"):
raw_trade_id = float(payload.get("trade_id", 0))
if not raw_trade_id.is_integer():

View File

@@ -1,8 +1,15 @@
import unittest
import os
from datetime import datetime, timedelta
from pathlib import Path
from primethai.portfolio_book import add_cash_event, portfolio_view, record_trade, update_price_marks
from primethai.portfolio_book import (
add_cash_event,
portfolio_view,
record_trade,
update_price_marks,
update_trade,
)
class PortfolioCapitalSelectionTests(unittest.TestCase):
@@ -52,5 +59,43 @@ class PortfolioCapitalSelectionTests(unittest.TestCase):
database.unlink(missing_ok=True)
class PortfolioValuationDateTests(unittest.TestCase):
def test_purchase_day_close_is_not_counted_until_next_day(self):
database = Path(__file__).with_name(f".portfolio_valuation_test_{os.getpid()}.sqlite3")
today = datetime.now().astimezone().date()
purchase_date = today.isoformat()
next_date = (today + timedelta(days=1)).isoformat()
signals = [{
"symbol": "AAA",
"source_model": "donchian_55_20+ema50_200_cross",
"target_weight": "1",
"model_agreement": "2",
"signal_as_of": purchase_date,
}]
try:
add_cash_event("deposit", 10_000, database=database)
update_price_marks(purchase_date, {"AAA": 10.0}, database=database)
trade_id = record_trade("AAA", "BUY", 100, 10.0, database=database)
# The user edits the signal's reference price to the actual fill.
update_trade(trade_id, 100, 9.0, trade_date=purchase_date, database=database)
same_day = portfolio_view(signals, signal_as_of=purchase_date, database=database)
self.assertIsNone(same_day["profit_loss"])
self.assertEqual(same_day["valuation_pending_symbols"], ["AAA"])
self.assertIsNone(same_day["rows"][0]["unrealized_pnl"])
update_price_marks(next_date, {"AAA": 10.0}, database=database)
next_day = portfolio_view(
[{**signals[0], "signal_as_of": next_date}],
signal_as_of=next_date,
database=database,
)
self.assertEqual(next_day["valuation_pending_symbols"], [])
self.assertAlmostEqual(next_day["profit_loss"], 100.0)
self.assertAlmostEqual(next_day["rows"][0]["unrealized_pnl"], 100.0)
finally:
database.unlink(missing_ok=True)
if __name__ == "__main__":
unittest.main()