diff --git a/primethai/portfolio_book.py b/primethai/portfolio_book.py index 3967dff..15db222 100644 --- a/primethai/portfolio_book.py +++ b/primethai/portfolio_book.py @@ -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, diff --git a/primethai/webui.html b/primethai/webui.html index fdcb8dd..725afc3 100644 --- a/primethai/webui.html +++ b/primethai/webui.html @@ -132,9 +132,10 @@