18 KiB
Event-Driven PIT Backtest Implementation Plan
For Hermes: Execute task-by-task. Use TDD for accounting/event logic, keep one task in progress, commit by phase, and run
requesting-code-reviewbefore delivery.
Goal: Replace the current calendar-rebalance/backtest proxy with a strict point-in-time, event-driven portfolio simulation that reinvests sale proceeds and paid dividends, respects 100-share lots, records realized/unrealized P&L, and reconciles final equity exactly.
Architecture: Build a deterministic event calendar from data releases, execution dates, dividend entitlement/payment dates, and the end valuation date. Feed those events through a portfolio ledger that owns cash, holdings, average cost, dividend receivables, orders, realized P&L, and costs. The API derives readiness/default dates from actual PIT coverage and blocks rather than silently falling back to current data.
Tech Stack: Python 3.11, Flask, filesystem PIT stores, Vue/Vite, unittest/pytest.
Confirmed Decisions
| Decision | Chosen default | Rationale |
|---|---|---|
| Backtest readiness | Strict PIT coverage for every required scoring input; otherwise block and report missing coverage | Prevents mixed-PIT results from looking valid |
| Signal execution | Freeze signal on release date D; execute at next available trading-day close after D | Avoids same-day close look-ahead |
| Dividend timing | Entitlement at ex-date; cash becomes available exactly 30 calendar days after ex-date | User-approved deterministic timing assumption; expose it as ex_date_plus_30d rather than claiming an observed payment date |
| Cost basis | Average cost | Simple, auditable, common portfolio accounting |
| Fees/taxes | All-in fee 0.3% of notional on every buy and every sell; do not add VAT/tax again | Explicit user decision; deterministic and auditable |
| Same-day releases | Coalesce all releases known by the execution cutoff into one frozen signal and one rebalance | Avoids churn from multiple events on the same day |
| End date on holiday/weekend | Value at latest trading close on or before end date | Deterministic and realistic |
These decisions were confirmed by the user and are implementation requirements.
Current-State Gap Matrix
| Requirement | Current behavior | Gap |
|---|---|---|
| Default start = first date advice is genuinely available | API/UI hardcode 2024-06-01 |
No readiness calculation; current disk has no factor vintages or Siamchart vintage manifest |
| Default end = yesterday | API/UI hardcode 2026-06-01 |
On 2026-08-28 +07, expected default is 2026-08-27 |
| Recompute only when new information arrives | _rebalance_dates() emits monthly/quarterly dates (backend/app/backtest.py:103-125) |
Calendar-driven, not release/event-driven |
| Strict PIT | Optional score_fn; current data fallback exists |
Current local data has 0 factor-vintage files and 0 Siamchart vintage snapshots, so no strict PIT-ready start exists |
| Execute after signal is known | _latest_close(..., d) uses close on rebalance date |
Can use the same close that occurred before/while data was released |
| Dividend entitlement and cash timing | credit_dividends() is called only at end, using final holdings (backtest.py:253-268) |
Wrong holdings-at-ex-date, no mid-run cash credit, no reinvestment, historic dividends can be overcounted |
| Sale P&L | Sell proceeds update cash only (backtest.py:232-241) |
No cost basis, realized P&L, fees, or per-trade ledger |
| Reinvest gains/dividends | Sale cash can fund buys in the same rebalance; dividends are added only after all rebalances | Dividends cannot fund later buys; profit/cash trace is absent |
| 100-share lots | allocate_capital() floors to 100 shares |
Already present; must preserve during target reconciliation |
| Final report | final holdings, total value, combined price P&L, dividend total | Must split realized/unrealized P&L, cash, receivables, costs, trades, and prove reconciliation |
| Current data coverage | Price snapshot: 9 symbols, 2024-01-03 to common 2026-07-17; no factor vintages/Siamchart manifest on disk | Strict PIT backtest should currently block, not manufacture a default start |
Target Event Flow
Coverage/readiness
→ earliest date where ALL PIT inputs + executable prices exist
→ initial recommendation event at start
→ freeze target signal
→ next trading day execution (100-share lots)
→ for each data release:
coalesce same-day changes
recompute recommendation from PIT state
if target changes: sell first → credit proceeds/realized P&L → buy using current cash
→ for each dividend:
ex-date: record entitlement using shares held before ex-date
ex-date + 30 calendar days: credit cash; available to next rebalance
→ end date:
mark holdings to latest close ≤ end
report cash + market value + dividend receivable (separate)
reconcile all P&L components
Accounting invariant:
ending_equity - initial_capital
= realized_trading_pnl
+ unrealized_trading_pnl
+ dividend_cash_received
+ accrued_dividend_receivable
- transaction_costs
price_pnl = realized_trading_pnl + unrealized_trading_pnl for compatibility.
Task 1: Add Strict Backtest Coverage and Default-Date Readiness
Objective: Derive valid start/end defaults from actual PIT stores and block when coverage is incomplete.
Files:
- Create:
backend/app/backtest_readiness.py - Modify:
backend/app/factor_vintages.py - Modify:
backend/app/siamchart_vintages.py - Modify:
backend/app/prices.pyor add a read-only adapter for price coverage - Modify:
backend/app/__init__.py - Test:
backend/tests/test_backtest_readiness.py
Steps:
- Write failing tests for:
- no factor vintages →
ready=false, missing factor list - no Siamchart snapshot → blocked
- price coverage starting after PIT factors → recommended start equals latest of first-ready dates
- recommended end = yesterday in Bangkok, bounded by latest price date
- user start earlier than readiness → HTTP 400 with exact missing coverage
- no factor vintages →
- Add store coverage APIs:
FactorVintageStore.first_release(factor_key)and coverage summarySiamchartVintageStore.first_retrieved_at()- price coverage: earliest/latest executable trading dates by required universe
- Implement
BacktestReadinessresponse:ready,recommended_start,recommended_end,missing,coverage,timezone
- Add
GET /api/v1/backtest/readiness. - Change
POST /api/v1/backtestdefaults to readiness-derived dates; never hardcode dates. - Verify targeted tests and API smoke.
Acceptance: Current local dataset returns ready=false because factor/Siamchart PIT history is absent; it does not start a purported PIT run.
Task 2: Build the Unified Backtest Event Calendar
Objective: Rebalance on information changes, not arbitrary monthly/quarterly dates.
Files:
- Create:
backend/app/backtest_events.py - Modify:
backend/app/factor_vintages.py - Modify:
backend/app/siamchart_vintages.py - Modify:
backend/app/dividend_ledger.py - Test:
backend/tests/test_backtest_events.py
Steps:
- Define typed/dataclass events:
SignalReleaseEvent(released_at, sources, factor_keys)ExecutionEvent(signal_date, execution_date)DividendEntitlementEvent(ex_date, symbol, per_share)DividendPaymentEvent(assumed_payment_date=ex_date+30d, entitlement_id, timing_method="ex_date_plus_30d")EndValuationEvent(end_date)
- Add chronological iterators to factor and Siamchart vintage stores.
- Build release calendar from all PIT changes within
[start, end]. - Coalesce releases on the same date.
- Map each signal date to the next available trading date strictly after the release date.
- Include dividend entitlement/payment events and final valuation event.
- Create an assumed payment event exactly 30 calendar days after ex-date. Keep the dividend as a receivable until that event, then credit spendable cash. Expose the timing assumption in every event/result.
- Test ordering, holiday/weekend handling, coalescing, and no-look-ahead execution.
Acceptance: A factor release three months after start generates one new frozen signal and, only if targets differ, one rebalance on the next trading day.
Task 3: Implement the Portfolio Accounting Ledger
Objective: Own all cash, holdings, cost basis, trades, dividends, and P&L in one auditable component.
Files:
- Create:
backend/app/portfolio_ledger.py - Test:
backend/tests/test_portfolio_ledger.py
Data model:
Position(symbol, qty, average_cost)
Trade(date, signal_date, symbol, side, qty, price, notional,
cost_basis_released, realized_pnl, fees, cash_after, reason)
DividendReceivable(symbol, ex_date, assumed_payment_date,
timing_method="ex_date_plus_30d", qty_entitled,
per_share, amount, status)
PortfolioState(cash, positions, receivables, realized_pnl,
dividend_cash, fees, trades)
Steps:
- Write RED tests for average-cost buys, partial sells, full sells, realized P&L, and no negative cash.
- Implement
buy():- qty positive and multiple of 100
- all-in fee =
notional * 0.003 - cost + fee <= cash
- recompute weighted average cost
- Implement
sell():- qty positive/multiple of 100 and <= position
- all-in fee =
notional * 0.003 - realized P&L = proceeds - released average cost - sell fee
- credit net proceeds (
notional - fee) to cash before later buys
- Implement dividend entitlement at ex-date using quantity held before ex-date.
- Implement payment-date cash credit; paid dividend can fund later orders.
- Keep unpaid dividends as receivables, not spendable cash.
- Add mark-to-market and reconciliation methods.
- Test cash conservation and accounting identity after mixed buys/sells/dividends.
Acceptance: Profitable sales and paid dividends increase cash available to subsequent 100-share-lot purchases; unpaid receivables do not.
Task 4: Reconcile Target Portfolio with Lot and Cash Constraints
Objective: Convert each frozen recommendation into executable sell/buy orders using current total equity and current cash.
Files:
- Modify:
backend/app/simulation.py - Create or modify:
backend/app/portfolio_rebalancer.py - Test:
backend/tests/test_portfolio_rebalancer.py
Steps:
- Preserve canonical 50/20/30 allocation rules and 100-share lots.
- Compute target quantities from current equity (cash + market value + paid dividends; exclude receivables).
- Generate sells first, execute them, then recompute available cash.
- Generate buys from remaining target deficits, never exceeding cash.
- If a target lot is unaffordable, skip it and retain cash; never buy odd lots.
- Rebalance only when target quantities differ from current quantities.
- Add tests for:
- sale profit funds next purchase
- dividend payment funds next purchase
- insufficient cash leaves cash and skips order
- unchanged recommendation produces zero trades
- no negative cash under rounding/fees
Acceptance: Every trade is in 100-share multiples, and ending cash is always non-negative.
Task 5: Rewrite Backtest Engine as Event Processor
Objective: Replace _rebalance_dates() calendar loop with the event calendar and portfolio ledger.
Files:
- Rewrite:
backend/app/backtest.py - Modify:
backend/app/pit_scorer.py - Test:
backend/tests/test_backtest.py - Test:
backend/tests/test_backtest_integration.py
Steps:
- RED test the complete scenario:
- start recommendation buys A
- release three months later changes target to B
- sell A at profit
- dividend entitlement/payment occurs
- sale proceeds + paid dividend fund B purchase
- final holdings/value and P&L reconcile
- Initialize from strict PIT readiness and initial signal.
- Process events in order:
- release → freeze scores/target
- execution → reconcile portfolio
- ex-date → entitlement
- payment → cash credit
- end → mark-to-market
- Record every release, rebalance, trade, and dividend event.
- Ensure
leakage_guard=trueonly if every score at every release is PIT; otherwise reject strict mode. - Remove monthly/quarterly frequency as the primary trigger. Optionally retain a separate explicit
scheduledresearch mode, clearly non-default/non-event-driven. - Verify no double-counted dividends and no repeated historical credits.
Acceptance: Engine output exactly follows the user-described lifecycle and passes accounting reconciliation for every tested scenario.
Task 6: Expand Backtest Result Contract and Durable Run Storage
Objective: Return all requested end-state and P&L breakdowns with a durable audit trail.
Files:
- Modify:
backend/app/backtest.py - Create:
backend/app/backtest_store.py - Modify:
backend/app/__init__.py - Test:
backend/tests/test_backtest_store.py - Modify: API tests in
backend/tests/test_api.py
Response v2:
{
"start": "...",
"end": "...",
"initial_capital": 1000000,
"ending_cash": 12345,
"ending_market_value": 1100000,
"dividend_receivable": 500,
"final_equity": 1112845,
"net_change": 112845,
"net_return": 0.112845,
"realized_trading_pnl": 45000,
"unrealized_trading_pnl": 50000,
"price_pnl": 95000,
"dividend_cash_received": 18000,
"transaction_costs": 155,
"fee_rate": 0.003,
"holdings": [{"symbol":"...","qty":100,"average_cost":...,"last_price":...,"market_value":...,"unrealized_pnl":...}],
"trades": [...],
"dividends": [...],
"rebalance_events": [...],
"accounting_reconciled": true,
"leakage_guard": true
}
Steps:
- Add result dataclasses and compatibility aliases (
final_value,dividend_income). - Add durable JSON run store (atomic writes) rather than process-local
backtest_runs. - Persist exact input coverage, event calendar, and scorer provenance with each run.
- Add reconciliation assertions before serializing; fail closed if imbalance exceeds tolerance.
- Test reload after app restart and API history output.
Task 7: Update Frontend Defaults, Controls, and Report
Objective: Make UI defaults/readiness and results reflect the new engine honestly.
Files:
- Modify:
frontend/src/App.vue - Test/build:
frontend/package.jsonexisting build
Steps:
- On load, call
/api/v1/backtest/readiness. - Set start to
recommended_startand end torecommended_end; remove hardcoded2024-06-01/2026-06-01. - If readiness is blocked, disable Run and show exact missing coverage.
- Replace “รายเดือน/รายไตรมาส” primary control with “ตามข้อมูลใหม่ (event-driven)”. Keep scheduled frequency only under advanced/descriptive mode if retained.
- Display:
- final holdings with qty, average cost, latest price, market value, unrealized P&L
- final equity, net change, return
- dividend cash received and dividend receivable
- realized trading P&L, unrealized P&L, total price P&L, fees
- event timeline / trade table
- Keep visible PIT/non-PIT and dated-ledger/proxy badges.
- Run production build and verify rendered result state.
Task 8: Verification and Independent Review Gate
Objective: Prove correctness before shipping.
Files:
- Create:
docs/test-evidence/YYYY-MM-DD-event-driven-backtest.md - Update:
docs/engineering-log.md - Update:
docs/HANDOFF.md
Required tests/gates:
- Targeted event-calendar suite.
- Portfolio-ledger suite.
- Rebalancer suite.
- Full backtest integration suite.
- Full backend suite.
- Frontend production build.
- Static security scan; no secrets/debug leftovers.
git diff --check, compileall.- Live API probe with a deterministic temporary PIT fixture.
- Browser verification of readiness-blocked and completed-result states.
- Fresh independent reviewer subagent; fail closed on any logic/security blocker.
- Auto-fix/re-review max two cycles.
Critical invariants:
- cash never negative
- all buy/sell quantities multiples of 100
- no trade before signal is knowable
- no dividend entitlement for shares bought on/after ex-date
- unpaid dividends never fund purchases
- paid dividends can fund subsequent purchases
- realized P&L uses average cost
- unchanged target creates no trade
- final accounting identity reconciles within one satang/rounding tolerance
- current local data returns readiness blocked rather than fake PIT performance
Files Likely to Change
backend/app/backtest.pybackend/app/backtest_readiness.py(new)backend/app/backtest_events.py(new)backend/app/portfolio_ledger.py(new)backend/app/portfolio_rebalancer.py(new)backend/app/backtest_store.py(new)backend/app/dividend_ledger.pybackend/app/factor_vintages.pybackend/app/siamchart_vintages.pybackend/app/prices.pybackend/app/simulation.pybackend/app/__init__.pybackend/tests/test_backtest*.py- new focused backend test modules
frontend/src/App.vue- engineering/test-evidence docs
Explicit Non-Goals
- Live order execution / MT5 dispatch
- Guessing historical factor values that were never recorded
- Claiming PIT performance before coverage readiness passes
- Broker-specific fee assumptions without configuration
- Odd-lot trading
Main Risks
- No current strict PIT-ready window: the local store has no factor vintages/Siamchart manifest. The correct initial UI state is blocked.
- Dividend payment timing is assumed: current Siamchart data supplies ex-date+DPS, not payment date. The user confirmed
assumed_payment_date = ex_date + 30 calendar days; every result must disclosetiming_method=ex_date_plus_30d. - Price quality: current Yahoo revised history is explicitly non-PIT; strict validated backtests need an evidence-bearing price archive contract.
- Universe coverage: current price snapshot contains 9 symbols, not the full 49-symbol board.
- Response compatibility: current UI/API expect scalar holdings and
price_pnl; v2 needs compatibility aliases during migration.
Open Questions
None for the core accounting/event model. The implementation decisions above are confirmed.