Skip to content

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 ​

  1. 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. The history records a funding_source audit 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.
  2. 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) or INVEST_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 a state_claim idempotency gate (ADR-016). If the buy fails, unclaim allows retry.
  3. 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_cny for ledger purposes. Monthly reconciliation against actual fund balances is used for calibration. Smart DCA amounts may fluctuate, making amount_cny an estimation baseline.
  • Currency: For CNY-denominated targets, use amount_cny / price directly. For non-CNY targets, convert using utils.fx.to_base before 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).