diff --git a/README.md b/README.md index 03fb1ce..8eb9a60 100644 --- a/README.md +++ b/README.md @@ -47,20 +47,35 @@ Stock dividends are manual portfolio adjustments: Ratios use `owned:new` exactly as written in the issuer notice. Equivalent unreduced ratios are accepted and the entered ratio is preserved in the reply. -The bot validates syntax, tickers, and arithmetic safety, but does not look up -notices or prevent duplicate calls. The caller is responsible for verifying the -notice and avoiding accidental repeated adjustments. +The bot validates syntax, tickers, and arithmetic safety. `/stock_portfolio` +also checks SSI iBoard for cash and explicit share-dividend events published +since each holding's last successful check. The portfolio is always sent first; +each event then appears in its own message with an `Apply dividend` button. If +no relevant event exists, no additional message is sent. + +Suggestions expire after 24 hours and are bound to the Telegram user who +requested the portfolio, the originating chat, and the event message. Another +group member cannot apply them. Acceptance calculates from the user's current +holding at click time, records the provider event atomically with the portfolio +change, and prevents the same SSI event from being applied twice. SSI iBoard is +an undocumented, best-effort source; failures do not prevent the portfolio from +being shown. The bot does not persist dated lots, so suggestions are not legal +record-date entitlement calculations. Users should verify the issuer notice. + +The manual commands remain available, but they do not carry an SSI event ID. +Applying an event manually and then accepting its button can therefore record +the same dividend twice; use one method for a given event. ### Stock and coin P&L accounting Stock and coin portfolios embed each open position under `assets.`. Both store `quantity` and total remaining `base`; stock positions additionally -store `dividendCheckedAt`. Stock cash is stored directly as `vnd`; coin cash -remains `usd`. Buys add their actual spend. Partial sells remove basis using the -weighted-average method and report realized P&L; full sells remove the position -and its basis. Stock share dividends add shares without adding cost, which -lowers the derived average price, while cash dividends do not change position -basis. +store `dividendCheckedAt` and an `openedAt` lifecycle marker. Stock cash is +stored directly as `vnd`; coin cash remains `usd`. Buys add their actual spend. +Partial sells remove basis using the weighted-average method and report realized +P&L; full sells remove the position and its basis. Stock share dividends add +shares without adding cost, which lowers the derived average price, while cash +dividends do not change position basis. `/stock_portfolio` and `/coin_portfolio` show aligned monospace tables with average entry price and unrealized P&L for each priced position. `Account P&L` remains the broader @@ -68,11 +83,20 @@ account value minus all top-ups, so it also reflects realized proceeds, dividend cash, and idle cash. If any current quote is unavailable, totals are marked partial and numeric Account P&L is withheld. -For stock positions, `dividendCheckedAt` is a future dividend-event cursor. It -is initialized when a position is first bought, preserved across later buys and -sells, and advanced when a stock dividend is recorded. A full exit removes the +For stock positions, `dividendCheckedAt` is the dividend-event discovery cursor. +It is initialized when a position is first bought, preserved across later buys +and sells, and advanced after a successful event check or when a manual stock +dividend is recorded. Failed checks do not advance it. A full exit removes the cursor; reopening the position starts it again. Coin positions do not store a -dividend cursor. +dividend cursor. Applied SSI event identities are retained in the stock +portfolio as `appliedDividendEvents. = +` for idempotency and audit history. SSI queries +overlap the previous Asia/Saigon calendar day to avoid missing provider rows +whose publication time has only day precision; pending and applied provider IDs +suppress duplicate suggestions. The stock-only `assets..openedAt` +marker identifies the current position lifecycle and invalidates suggestion +buttons after a full sale and later repurchase. Existing positions adopt this +behavior without a startup migration. ## Layout diff --git a/docs/journals/260721-1815-stock-dividend-event-buttons.md b/docs/journals/260721-1815-stock-dividend-event-buttons.md new file mode 100644 index 0000000..2083f42 --- /dev/null +++ b/docs/journals/260721-1815-stock-dividend-event-buttons.md @@ -0,0 +1,73 @@ +--- +type: technical-journal +topic: stock-dividend-event-buttons +conducted_at: 2026-07-21T18:15:00+07:00 +status: complete +--- + +# Stock Dividend Event Buttons Journal + +## Context + +`/stock_portfolio` needed to surface recent dividend events for held tickers +without obscuring the portfolio or silently changing financial state. SSI +iBoard was selected as a best-effort discovery source, while the existing +manual dividend commands remain available. + +## What Happened + +- The portfolio response is sent first. Each discovered cash or explicit share + dividend follows in its own message with an `Apply dividend` button; a ticker + with no relevant event produces no extra message. +- Added a replaceable SSI provider with pagination, local event classification, + stable event IDs, deduplication, and exact ratio handling. +- Added callback registration to the shared module framework and persisted + short-lived pending actions separately from portfolios. +- Successful checks advance `dividendCheckedAt`; failed checks leave the cursor + unchanged and never prevent the portfolio from being displayed. +- Added `openedAt` to distinguish the holding lifecycle and retained applied SSI + event identities for auditability and replay protection. + +SSI discovery accepts only day-granularity date windows. Queries therefore use +a cursor overlap at the lower boundary, then locally filter and deduplicate the +returned events. This avoids missing events around a date boundary while stable +SSI event IDs prevent overlap from producing repeated actionable suggestions. + +## Key Decisions + +- Callback data carries an opaque random token, not trusted dividend values. + Pending actions expire after 24 hours and are bound to the requesting + Telegram user, originating chat, and event message. +- A callback reloads the current portfolio and calculates from the holding at + click time. Another group member, an expired action, or a holding sold and + reopened after suggestion creation cannot apply it. +- Applied SSI event IDs are recorded atomically with the portfolio mutation, so + duplicate clicks and retries cannot apply the same provider event twice. +- Event fetches occur outside the per-user mutation lock. Cursor persistence + reloads and merges under that lock, preserving concurrent buys, sells, and + manual dividends without holding a lock across network calls. +- Notification or provider failures do not advance the affected cursor. A later + portfolio request can safely retry rather than lose an event. + +## Verification + +- Focused stock and module tests passed. +- Full suite passed: `go test -count=1 ./...`. +- Stock race tests and 20-iteration concurrency stress tests passed. +- `go vet ./...` and `go build ./...` passed. +- `golangci-lint run` completed with zero issues. +- Independent testing, debugging, and code review found no blockers. + +## Risks and Limitations + +- SSI iBoard is undocumented and has no published stability or availability + guarantee; the provider can require replacement if its contract changes. +- The portfolio has no dated transaction lots, so current quantity is not proof + of record-date entitlement. Users must verify the issuer notice. +- Manual commands do not record an SSI event ID. Applying manually and later + accepting the related button can count the same dividend twice. + +## Next + +Observe SSI response stability and callback behavior in real use. Commit these +changes only if the user requests it. diff --git a/plans/reports/pm-260721-1815-stock-dividend-event-buttons.md b/plans/reports/pm-260721-1815-stock-dividend-event-buttons.md new file mode 100644 index 0000000..068548e --- /dev/null +++ b/plans/reports/pm-260721-1815-stock-dividend-event-buttons.md @@ -0,0 +1,57 @@ +--- +type: project-status-report +topic: stock-dividend-event-buttons +reported_at: 2026-07-21T18:15:00+07:00 +status: completed +--- + +# Stock Dividend Event Buttons Completion + +## Summary + +`/stock_portfolio` now checks SSI dividend events for held tickers after sending +the portfolio. Relevant events receive owner-bound, expiring Apply buttons; +successful checks with no events remain silent. + +## Completed + +- [x] Replaceable SSI provider with pagination, local classification, exact + share ratios, event-ID deduplication, and bounded date overlap. +- [x] Per-ticker cursor checks that preserve concurrent portfolio writes. +- [x] Opaque 24-hour pending actions bound to caller, chat, and message. +- [x] Current-holding cash/share application with replay-safe event ledger. +- [x] Position lifecycle binding that rejects buttons after full sale/reopen. +- [x] Shared callback registration, visibility validation, and authorization. +- [x] README and regression tests updated. + +## Verification + +| Gate | Result | +|---|---| +| Focused stock/modules tests | Passed | +| Full `go test -count=1 ./...` | Passed | +| Stock race and concurrency stress tests | Passed | +| `go vet ./...` | Passed | +| `golangci-lint run` | Passed, 0 issues | +| `go build ./...` | Passed | +| Independent final review | Passed, no blockers | + +## Plan Sync + +No active phase plan maps to this incremental request. Existing stock-dividend +and cost-basis plans were already completed before this work and remain +unchanged. Session task tracking is fully complete. + +## Remaining Risks + +- SSI iBoard is undocumented and best-effort. +- SSI publication timestamps are day-granularity; a bounded overlap prevents + same-day loss, while pending actions and applied IDs suppress duplicates. +- The portfolio has no dated-lot ledger, so acceptance uses holdings at click + time rather than legal record-date entitlement. +- Manual dividend commands cannot attach SSI event IDs; users must not apply + the same event both manually and through its button. + +## Next Step + +Commit only after explicit user approval.