Skip to content

feat(add-cash): the cash ask accepts an amount or a percentage, and the engine owns the disclosed conversion (closes #662) - #698

Merged
atomchung merged 1 commit into
mainfrom
claude/662-cash-input-contract
Jul 31, 2026
Merged

feat(add-cash): the cash ask accepts an amount or a percentage, and the engine owns the disclosed conversion (closes #662)#698
atomchung merged 1 commit into
mainfrom
claude/662-cash-input-contract

Conversation

@atomchung

Copy link
Copy Markdown
Owner

Summary

A user answering "30%" to the cash-anchor ask used to cost three free-form clarification round-trips, because the ask only accepted an absolute currency amount and nothing disambiguated that (#662's trigger, a real /fomo-qa dogfood session). Maintainer disposition (2026-08-01, resolving the issue's three proposals as proposal 2 with the denominator named):

  • The ask accepts either an absolute currency amount or a percentage. Neither format is an error.
  • The denominator for a percentage is the account's total value — cash plus current position market value, never the position value alone. The ask states this in plain words so the user is never guessing.
  • A percentage answer gets exactly one denominator confirmation before conversion, never more free-form clarifications.
  • The engine owns the conversion (not the agent) and discloses the derivation.
  • The stored anchor remains an absolute amount; the percentage is an input format, never a new stored kind.

What changed

  • skills/fomo-kernel/engine/trade_recap.py: new resolve_cash_anchor_input(raw_anchor, held_mv, aggregate_currency) — the one place a {currency, percent_of_total, as_of} anchor is accepted and converted to the absolute-amount shape cash_position has always taken. Wired into main() right where TR_CASH is parsed, against the same held_mv the card was priced from (frozen prices, never a fresh fetch). New CashAnchorInputError fails the whole run closed (caught in __main__ beside MissingAggregateCurrencyRate) rather than silently reaching cash_position as an anchor it can't use. build_state() gains a cash_anchor_conversion field (None on the unchanged absolute-amount path) so the derivation reaches the CLI layer.
  • skills/fomo-kernel/engine/review.py: _cash_anchor_status's next_action now states both accepted formats and the denominator in plain words, and gives both --cash shapes. cmd_add_cash surfaces the derivation as a top-level anchor_conversion key only when a percentage was converted — the absolute-amount response carries no new key at all. add-cash --cash's argparse help documents the new shape.
  • skills/fomo-kernel/references/data-contract.md: the cash-anchor beat statement now documents both formats, the algebra, the multi-currency scope, and the fail-closed case.
  • skills/fomo-kernel/flows/first-review.md / flows/weekly-review.md: the short route-specific ask pointers now mention both formats and the one-confirmation rule.
  • docs/maintainer-guide.md: the cash-anchor mirrored-surfaces row documents what [design·process] Cash-anchor ask has no format contract — a percentage answer needs three round-trips to resolve #662 adds and its readers.
  • Tests: tests/test_engine_units.py (pure-function algebra + every fail-closed case), tests/test_review_v2.py (real-CLI end-to-end wiring + regression + fail-closed), tests/test_tr_json_contract.py (registers the new STATE_KEYS field).

The algebra, spelled out exactly as the issue's disposition asked: with cash included in the denominator, a p% answer means cash = p/(100-p) * position_value, not p × position_value. 30% against a 70,000 position value is 30,000 on a 100,000 total — never the 21,000 a p/100 misreading of the denominator would produce.

Proof (all mandatory items from the issue)

  1. Correct conversion, exact number. tests/test_engine_units.py::test_resolve_cash_anchor_input_converts_percent_with_the_correct_denominator pins 30% / 70,000 → 30,000 (total 100,000), and asserts != 21,000. tests/test_review_v2.py::test_add_cash_converts_a_percentage_against_this_sessions_frozen_position_value drives the real CLI on an engine-priced review, independently recomputes 20/80 * position_value, and confirms the amended card's cash.weight lands exactly on 0.20.
  2. Disclosure. Both tests above assert anchor_conversion/derivation carries percent_of_total, position_value, currency, formula, and amount.
  3. Absolute-amount regression. test_add_cash_percent_leaves_the_absolute_amount_response_unchanged asserts "anchor_conversion" not in out for an absolute-amount payload, and every pre-existing add-cash test in test_review_v2.py (the [defect·P1] add-cash refuses the session it exists for: the drift gate compares question_queue and state_snapshot whole, so the card-beat cash answer is unrecordable #665 recompute suite) still passes unmodified.
  4. Fails closed on no usable position value. test_add_cash_percent_refuses_without_a_usable_position_value prepares a fully-exited (zero-holdings) book and confirms a percentage payload refuses with a named error, leaves no pending session or ledger row behind, while a plain absolute amount on the same book still works.
  5. Mutation proof (cp backup/restore, __pycache__ cleared, import-checked each time):
    • (a) Formula mutated to p * position_value (wrong denominator): reddened test_resolve_cash_anchor_input_converts_percent_with_the_correct_denominator (21000.0 visible), test_resolve_cash_anchor_input_discloses_the_derivation (also pins 30000.0), and the integration test ((55480.0, 69350.0, 277400.0) visible — 55480 = 20% of 277400, the wrong reading).
    • (b) Derivation dropped (return resolved, None): reddened test_resolve_cash_anchor_input_discloses_the_derivation ("a percentage anchor was converted but the derivation was not disclosed...") and the integration test ("a percentage was converted but the response discloses nothing about it").
    • Restored from backup and diffed identical before re-running the suite.
  6. Full offline suite green: python3 tests/run_all.pyPASS: all 49 suites passed. (also verified again after rebasing onto the latest origin/main).

Scope discipline

  • No percentage handling anywhere except this input contract — consider --cash (a separate code path, never routes through trade_recap.py) is untouched.
  • Per-currency absolute anchors (the list form) are untouched; a percentage inside a multi-currency list is explicitly refused rather than silently dropped.
  • No copy-catalog (copy/*.json) changes — the ask is agent-composed prose guided by next_action, not a fixed locale template.

Assumption flagged for owner review

Multi-currency percentage scope: per the issue's own guidance, a percentage answer on a multi-currency cash book is ambiguous (which currency's bucket?). I took the "smallest honest cut" the issue named: a percentage is accepted only as a single anchor stated in the book's own aggregate currency (currency_meta.aggregate_currency — USD for a mixed book, the book's own currency otherwise), validated and refused on a currency mismatch rather than silently relabelled. Per-currency absolute anchors (the pre-existing list form) are completely unaffected.

🤖 Generated with Claude Code

…he engine owns the disclosed conversion (closes #662)

A user answering "30%" to the cash-anchor ask used to cost three free-form
clarification round-trips, because the ask only accepted an absolute
currency amount and nothing disambiguated that. Owner disposition
2026-08-01: accept both formats; the denominator a percentage names is the
account's total value (cash plus current position market value, never the
position value alone); state that denominator in plain words and confirm it
once; the engine, never the agent, converts and discloses the derivation.

trade_recap.resolve_cash_anchor_input is the one place a
{currency, percent_of_total, as_of} anchor is accepted and converted to the
absolute-amount shape cash_position has always taken, called from main()
against the same frozen held_mv the card was priced from -- never a second
market resolution. The algebra is p/(100-p) * held_mv, not p/100 * held_mv:
30% against a 70,000 position value is 30,000 on a 100,000 total, never the
21,000 a p/100 misreading of the denominator would produce. A non-finite or
non-positive held_mv refuses rather than converting against a garbage
denominator, and a multi-currency book accepts the percentage only as a
single anchor in the aggregate currency, never inside the per-currency list
-- absolute-amount anchors, single or listed, are untouched.

review.py's cmd_add_cash surfaces the conversion's derivation (percent,
position value, currency, formula, amount) as anchor_conversion only when a
percentage was converted, so the absolute-amount response carries no new
key at all. _cash_anchor_status's next_action, the flow docs, and
references/data-contract.md's cash-anchor beat statement now state both
accepted formats and the denominator in plain words.

Proof: tests/test_engine_units.py pins the algebra and every fail-closed
case (bad range, non-positive held_mv, both amount and percent, percent
inside a multi-currency list, currency not the aggregate). tests/
test_review_v2.py drives the real CLI on an engine-priced review: the
converted amount and the amended card's cash weight both land exactly on
the requested percentage, the absolute-amount response is unchanged, and a
fully-exited book refuses a percentage rather than converting against a
zero position value. tests/test_tr_json_contract.py's STATE_KEYS registers
the new cash_anchor_conversion field. Mutation-tested by hand (cp
backup/restore, __pycache__ cleared, import-checked): breaking the algebra
to p * held_mv reddens three tests with the wrong number (21000/55480)
visible; dropping the derivation reddens two with a clear "not disclosed"
assertion. Full offline suite green (49/49 suites).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@atomchung
atomchung marked this pull request as ready for review July 31, 2026 20:08
@atomchung
atomchung merged commit db6e114 into main Jul 31, 2026
3 checks passed
@atomchung
atomchung deleted the claude/662-cash-input-contract branch July 31, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant