ADR-018 — Automatic DCA and Bullet Pool Cash Semantics (DCA + dip-reserve)
Date: 2026-06-23 Status: Accepted Related: 016-ledger-mutation-idempotency · 017-config-via-api
Context
Users utilize third-party platforms (e.g., JD Finance) for daily automatic DCA, with deductions coming from salary/bank accounts. However, openInvest's buy() always deducts from portfolio.cash (ledger consistency: buying X yuan = deducting X yuan cash). If daily DCA also follows this accounting method, it would incorrectly consume portfolio cash—whereas the true semantics of portfolio cash is "the bullet pool (dip-reserve) available for immediate deployment," not the funding source for daily DCA. Observed in practice: a ¥30k bullet pool deducted at ¥100/day for DCA would be depleted in approximately 10 months, after which the committee would find "no bullets" when attempting to add positions during a dip.
Root cause: buy() hardcodes the "funding source" as deducting from portfolio cash, making it impossible to represent the scenario of "external new money entering to build positions, leaving the bullet pool untouched."
An incidental data bug was also discovered: when adding a new target_asset, price/indicator fetching only triggers a 5-day incremental refresh → the DB only contains 5 days of history → RSI/MA120/MA250/regime are all N/A, resulting in REGIME=unknown (reproduced with 510500.SS / SPY).
Decision
buy(source_type=...)Funding Source Parameter:cash_deduct(default): Deducts from portfolio cash, preserving existing behavior.external_funding: External new money builds positions, portfolio cash remains untouched. Thehistoryrecords afunding_sourceaudit field.- This clarifies the semantics of portfolio cash = dip-reserve bullet pool: It is only used for manual dip-buying or committee position additions, and is not consumed by daily DCA.
DCAConfig+jobs/dca_daily:- Configuration via
dca.auto_dca_enabled/auto_dca_amount_cny/auto_dca_symbols. The dataclass defaults to disabled (safe mode). - Enabled via
/api/config(whitelist, ADR-017) orINVEST_DCA_*env vars. - Daily, for each symbol, fetch the price using
get_quote→units = amount_cny / price→buy(source_type="external_funding"). - Each
(date, symbol)pair uses astate_claimidempotency gate (ADR-016). If the buy fails,unclaimallows retry.
- Configuration via
Data Depth Self-Healing:
get_history_data, when DB history is less than_MIN_HISTORY_ROWS(60 bars), initially fetches a full 2-year backfill to ensure long-period indicators/regime can be calculated; only uses 5-day incremental updates when sufficient history already exists.
Consequences / Constraints
- Reconciliation Approach: The system cannot see the actual execution on third-party platforms. It estimates daily purchase volume based on
amount_cnyfor ledger purposes. Monthly reconciliation against actual fund balances is used for calibration. Smart DCA amounts may fluctuate, makingamount_cnyan estimation baseline. - Currency: For CNY-denominated targets, use
amount_cny / pricedirectly. For non-CNY targets, convert usingutils.fx.to_basebefore dividing by price. - Proxy Tracking: For off-exchange feeder funds (where yfinance cannot fetch NAV), use the on-exchange ETF symbol of the same index as a value proxy for ledger entries (e.g., JD 110020 → openInvest records 510300.SS). Shares are value proxies, but market cap/PnL tracking relative to the index remains accurate.
- Open Pool Model (Already Implemented):
wealth_context.monthly_contribution_cny— the user's monthly external replenishment quota for the investment pool. The WealthContextOfficer treats portfolio cash as a flow rather than a closed snapshot: low cash does not automatically imply a liquidity risk, and the 30% cash target is softened (the hard rule remains: the current position addition limit is still = current portfolio cash).