SPEC 018 Buy Sell Opportunity - rafaelfqueiroz/allmywallet GitHub Wiki

SPEC-018 β€” Buy/Sell Opportunity

Status Draft
PRD source F8 (FR-9.1–9.27)
Depends on SPEC-002, SPEC-004, SPEC-007, SPEC-008
Required by β€”, SPEC-021
Milestone M7

Description

Following every price movement across a portfolio is work nobody does reliably. This spec lets a user write down the prices that would matter to them, and have the product watch instead β€” showing each asset's state on screen and sending an email when it changes.

Two constraints shaped the whole design, and both are external.

The first is the quote budget. SPEC-008 DL-008-04 derives the polling set from actual positions, and DL-008-06 deliberately keeps on-demand lookups out of it, "otherwise idle curiosity permanently inflates recurring spend against a tight quota". PRD risk R5 already rates that ceiling β€” 15,000 requests/month at one ticker per call β€” as the highest-likelihood risk in the document. A watchlist of assets nobody owns is precisely the spend DL-008-06 refused. So rules exist only on assets the user actually holds, and this feature adds zero requests to the budget: rules are evaluated when a quote is written, never on a schedule of their own. The cost is real and worth stating plainly β€” the green state means add to a position you hold, never open a position in something new.

The second is the advice line. The product supplies no thresholds, no formulas and no view about any asset. It stores numbers the user typed, compares them to a price it already displays, and reports which of the user's own rules currently matches. Even the meaning of a bound belongs to the user: "below R$30" may be a buy for someone averaging down and a sell for someone running a stop-loss, so the state attached to each bound is chosen, not assumed.

One honesty requirement follows from the first constraint. Quotes carry roughly a 30-minute delay and are polled only during market hours, so a crossing that reverses inside the interval is never observed. The feature says so where rules are configured rather than implying a completeness it cannot deliver.

Business rules

What can carry a rule

ID Rule
BR-018-01 A rule exists only on an asset the user currently holds in a non-zero position.
BR-018-02 A rule exists only on an asset with an observable market price β€” variable income (stocks, FIIs, BDRs, ETFs) and Tesouro Direto. CDB, LCI and LCA have no market price and can never carry a rule.
BR-018-03 If a position closes to zero, its rules are deactivated and retained, and reactivate automatically if the asset is held again. Rules are not silently deleted by a sale.
BR-018-04 Rules are per user. Two users watching the same asset hold independent rules and share nothing but the quote.

The rule model

ID Rule
BR-018-05 An asset carries at most one lower bound and one upper bound, each optional; at least one must be set for the rule to be valid.
BR-018-06 Each bound carries a user-chosen state: buy, hold or sell. The product never assigns a state to a threshold.
BR-018-07 The user also chooses the default state applying between the bounds. It defaults to hold and can be changed.
BR-018-08 The lower bound must be strictly less than the upper bound, enforced at write time. No price can then match both, so conflict is impossible by construction rather than resolved by a precedence rule the user cannot see.
BR-018-09 Thresholds are absolute amounts in BRL, entered by the user. v1 offers no derived thresholds β€” nothing computed from average cost, historical range or yield.
BR-018-10 Thresholds are money and obey the money rules (CR-4, AR-06–AR-10). A threshold is never a floating-point number at any point in its life.

Evaluation

ID Rule
BR-018-11 A rule is evaluated when a new quote is stored for its asset (SPEC-008). It never triggers a fetch and never runs on a schedule of its own, so the feature adds nothing to the request budget.
BR-018-12 The evaluated state is the state of the matching bound, or the default state when the price lies between the bounds.
BR-018-13 The last observed state is persisted per rule, so a change can be distinguished from a repeat.
BR-018-14 Evaluation uses the same stored quote the rest of the product displays, so no screen can disagree with another about the price.
BR-018-15 Because quotes are delayed and polled only during market hours, a crossing that reverses within the polling interval is never observed. This is stated wherever rules are configured, not buried in a help page.
BR-018-16 With no usable quote β€” stale beyond its tier or absent β€” the state reads unknown. A state is never carried forward from a price known to be old.

Display

ID Rule
BR-018-17 The state is shown as a colour and a text label: buy green, hold yellow, sell blue. Colour is never the sole carrier (SPEC-016 BR-016-16).
BR-018-18 Wording states the user's own rule β€” "below your R$ 30,00 buy threshold". No wording may present the state as the product's judgement (SPEC-015 BR-015-06).
BR-018-19 The state appears on a dedicated watch screen and as a badge in the holdings list, with the quote timestamp and delay tier alongside (SPEC-008 BR-008-24).
BR-018-20 The watch screen shows, per asset, the current price, both bounds, the current state, and the time of the last email sent for it.

Notification

ID Rule
BR-018-21 An email is sent when the evaluated state differs from the last observed state. Repeat evaluations producing the same state send nothing.
BR-018-22 After a send, a cooldown suppresses further email for that asset for notifications.opportunity_cooldown_hours (SPEC-002; default 24), regardless of any state changes within the window.
BR-018-23 The cooldown is a deliberate trade with a real cost: an asset that genuinely reaches its lower bound in the morning and its upper bound the same afternoon emails only the first. In-app state is always current, and BR-018-20 shows when the last email went, so the suppression is visible rather than silent.
BR-018-24 Every send is recorded in a notification log keyed on asset, state and the observation that triggered it. Job delivery is at-least-once (AR-19), so without this a retry after partial failure would resend.
BR-018-25 Email requires explicit opt-in, recorded through the Consent entity (SPEC-004 BR-004-06, BR-004-07). It is off by default.
BR-018-26 Per-asset mute and a global unsubscribe are both available, and unsubscribe is honoured without requiring sign-in.
BR-018-27 Email is sent only during market hours plus a configurable quiet window (notifications.quiet_hours, SPEC-002).
BR-018-28 Email is pt-BR through i18n (SPEC-016) and carries only the asset, its price, the user's own threshold and the resulting state. No CPF, no position size, no portfolio value β€” an inbox is not a place to put holdings. Two links are permitted, because a link carries no figure: the unsubscribe link (BR-018-26) and a link to the user's rule for that asset (SPEC-022 BR-022-08; added in #212, acknowledged by the owner on 2026-10-11).

Acceptance Criteria

  • A rule can be created only for an asset held in a non-zero position; an unheld asset offers no rule form.
  • A CDB offers no rule form and states that it has no market price to watch.
  • Selling a position to zero deactivates its rules without deleting them; rebuying reactivates them with the same thresholds.
  • A lower bound at or above the upper bound is rejected at write time.
  • "Below R$ 30 = buy" and "below R$ 30 = sell" are both expressible, and produce opposite states at the same price.
  • The default state between bounds is user-settable and defaults to hold.
  • Evaluating rules issues zero provider requests β€” verified by asserting the request count is unchanged across a full evaluation cycle.
  • A price crossing a bound changes the displayed state on the next quote write.
  • Every state is rendered with both a colour and a text label; an automated check fails the build if a state renders as colour alone.
  • No string in the feature presents a state as the product's own recommendation β€” human review, recorded in the PR.
  • A state change with notifications enabled sends exactly one email.
  • A second state change inside the cooldown window sends no email, while the in-app state updates immediately.
  • Re-running the evaluation job over the same quote sends no duplicate email.
  • A user who has not opted in receives no email, and the in-app feature works fully without opting in.
  • Unsubscribing from an email link stops all opportunity email without a sign-in.
  • An asset whose quote is stale shows the unknown state and sends nothing.
  • Email bodies contain no CPF, no position size and no portfolio value β€” asserted by a scan over rendered output.
  • The delay disclosure (BR-018-15) is present on the rule-configuration screen.

Out of Scope

  • Rules on assets the user does not hold. The defining constraint β€” see DL-018-01.
  • Derived or computed thresholds β€” percentage from preΓ§o mΓ©dio, distance from a 52-week extreme, dividend-yield triggers. Yield-based rules are additionally unbuildable on the free tier, which carries no dividend data (PRD Q8); offering one would ship a rule that silently never fires.
  • Push notifications, SMS, WhatsApp. Email only in v1.
  • Real-time or sub-cadence evaluation. Bounded by SPEC-008's cadence, not by this spec.
  • Order placement or broker integration β€” prohibited by SPEC-003 BR-003-08.
  • Backtesting β€” "how often would this rule have fired last year" needs a price history the free tier does not provide.
  • Rules on CDB, LCI and LCA (BR-018-02).

Decision Logs

# Decision Alternatives considered Rationale
DL-018-01 Rules only on assets already held. A watchlist covering any asset; watch-only assets on a slower cadence; upgrading to the paid tier A watchlist is exactly the recurring spend SPEC-008 DL-008-06 refused, against a ceiling PRD R5 already rates as the highest-likelihood risk. Holding the asset is the signal that it matters, which is the same test DL-008-06 applied. The cost is stated rather than hidden: the buy state means add to a position, never start one.
DL-018-02 The user assigns each bound's state. Fixed semantics β€” lower bound is always buy, upper always sell Fixed semantics cannot express a stop-loss, where crossing below a price is a sell. One model covers averaging down and stopping out because the meaning is supplied by the person who has the strategy, which also keeps every judgement out of the product.
DL-018-03 Two bounds, non-overlapping. An ordered rule list, first match wins; any number of rules, narrowest match wins An ordered list hands the user an ordering problem and hides dead rules behind earlier ones. Narrowest-match resolves conflicts by a rule the user can neither see nor override. Forbidding overlap at write time makes conflict impossible rather than resolved.
DL-018-04 Evaluate on quote write, never on a schedule. A dedicated evaluation schedule A separate schedule would either duplicate polling or read prices the display has not caught up to, producing two screens that disagree. Evaluating on write costs nothing against the quota and cannot diverge from what the user sees.
DL-018-05 Cooldown timer, default 24h. Margin-based re-arm (require the price to clear the threshold by a configured margin); email on every state change Cooldown is the simplest rule to explain and to reason about when an email arrives. The cost is recorded deliberately: a genuine second crossing inside the window is suppressed exactly as a false one would be. If that case shows up in practice, a margin-based re-arm is the replacement, and BR-018-23's visible last-sent timestamp is what would surface it.
DL-018-06 Green / yellow / blue, never colour alone. Green / yellow / red Green-yellow-red is the convention and the worst choice available here: red-green is the most common form of colour blindness, and red additionally codes a sell as danger when it usually means taking a profit. Blue is distinguishable under deuteranopia and carries no valence.
DL-018-07 Email opt-in, off by default, minimal content. On by default for all users; richer email including position value Consent belongs in the Consent entity for LGPD purposes, and default-on would collect that consent by assumption. Minimal content follows the same reasoning as SPEC-004 BR-004-02 on CPF: email is the least controlled channel the product has, so it carries the least data that still makes the message useful.
DL-018-08 Notification log keyed on the observation. Rely on the persisted last-state column alone pg-boss delivers at least once (AR-19). A handler that crashes after sending but before writing its state would resend on retry. The log makes the send idempotent against the specific observation rather than against the state.