Skip to content

augur: actor policy — one channel for buys and sells, target allocation over a cash band #3739

Description

@agentydragon

The agent's transactions should all come from one place: a policy in the RL sense — a box taking the world state as the agent can see it and emitting actions. Everything below follows from insisting on exactly one such channel.

Contract

Batched. The observation is a struct of arrays: every field carries the rollout axis ((account, R), (lot, R), …), and the policy is a pure jittable function (batch of observations) → (batch of actions), actions shaped (action_slot, R). No per-rollout Python anywhere. A learned policy drops into the same signature later.

Only what's visible. The view carries the agent's own account balances, holdings at their mark, per-lot basis and holding period, and YTD income/tax. Not other agents, not the rest_of_world contra row, and nothing dated after the current month.

Sim calculates, never approximates. A policy may only act on prices the simulator can compute exactly. Equities mark off the realized path, so they can be sold. A bond before maturity cannot — its price is a discount of remaining cashflows on a curve that does not exist yet — so a policy asking to sell one must be rejected loudly until #3740's marking work lands, never served a book-value stand-in.

What it subsumes

  • LiquidityPolicy's ordered asset_preference_chain — removed outright, not deprecated. A strict preference chain is not a limit case of weights (it drains a sleeve to zero; weights never do), so keeping both would mean two real behaviors where only one is wanted.
  • ScheduledAssetPurchase (added in augur: buy assets mid-horizon, and make cost basis per-rollout #3743, tombstoned there) — a scheduled buy is a policy that ignores state. Two channels for one action drift on ordering against obligations, on the underfunding clamp, on basis, and on the event log. Its execution layer stays and is what the policy emits into: pre-allocated lot slots, per-rollout cost basis, whole-quanta rounding, the external contra credit, settling after obligations.

Target allocation

Integer relative weights over a declared sleeve universe. A fraction is derivable from weights, so storing fractions stores a computed quantity and needs a float sum-to-one validator to defend it. Anything not named is outside the policy's scope and out of the denominator — a target over an asset you cannot trade (PE before liquidity, a rung you will not break) makes the policy permanently overweight something it cannot sell, and it thrashes.

Splitting a withdrawal is water-filling: find a level L with Σ max(0, valueᵢ − L·weightᵢ) = S, each sleeve giving up max(0, valueᵢ − L·weightᵢ). Every sleeve still holding anything ends at the same value-per-weight, which is the definition of on-target — so a withdrawal lands the portfolio on the target ratios rather than nearer them.

Rebalancing happens only through cashflow. There is no standalone rebalance action, because turnover and its tax drag would swamp the effect the allocation study is trying to measure. Zero drift plus zero cashflow must produce zero actions.

Cash band

[floor, ceiling] replaces cash_buffer_trigger_below_usd + cash_buffer_sale_usd (a fixed sale slug is undefined whenever the shortfall exceeds it).

  • cash < floor → sell up to the ceiling
  • cash > ceiling → buy down to the floor
  • inside the band → no action

That is (s,S): cross a bound, go to the far edge. It minimizes crossings, which matters here because every sale is taxable and, more importantly, a thin buffer makes you a forced seller into every dip — the risk the whole exercise exists to price. Recorded counterargument: selling to the ceiling realizes gains earlier than necessary, and deferral is worth real money. Far-edge vs near-edge is empirical, and it is the first rule to vary if results look sensitive to it.

Timing. The sell decision fires once, at the start of the month, sized to known outflows this month + restore to ceiling, and then obligations pay. Obligations are scheduled, so that is a calculation, not a forecast. One sale a month like a real person — and an unpayable obligation then means genuinely nothing left to sell, which is what makes RUIN meaningful for #3740.

Bounds are AmountSpec, so a floor can be CPI-indexed; a flat $50k floor in 2056 is not a floor. The better long-run form is months of spending, which self-indexes and interacts correctly with the tier state — deferred to #3738, because it is circular with that state and the break has to read last month's obligation.

Full-stack

Not sim-internal. Deleting the ordered policy changes api/wire.py's FundingPolicy, product/scenarios.py's translation, input_helpers.ts defaults and payload, the "Cash buffer" group in scenario_editor.tsx, SellOrderControl in forms.tsx, ~8 server_test.py call sites, and SPEC's product paragraph. The UI mapping: "Sell preference" (ordered drag-list) → per-holding integer weights; "Trigger below" / "Sell amount" → floor / ceiling; "Buffer index" survives and applies to both bounds. One atomic PR — no transitional shims.

Acceptance

  • Same view ⇒ same actions, independent of rollout index; rollouts never couple.
  • No future information: two scenarios identical through month m, diverging by a large shock at m+1, produce identical actions at m.
  • The view's account count equals the agent's own accounts; rest_of_world is unreachable from it.
  • Weights (3,1) and (30,10) produce identical action streams.
  • Zero drift, zero cashflow ⇒ zero actions.
  • A withdrawal splits exactly — sleeve amounts sum to the request, no sleeve driven negative or past its value, however awkward the rounding.
  • Cash crossing a bound returns to the far edge; inside the band, nothing fires.
  • A ladder that self-liquidates gets rebuilt: the bond sleeve reverts to target rather than decaying monotonically (needs augur: classify trajectories — ruin / forced tier drop / intact #3740's par-yield path).
  • A pre-maturity bond sale is rejected at compile time until marking exists.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions