Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The long-term direction is a personal AI analyst and desktop co-pilot that can m
| Memory | SQLite-backed episodic and semantic memory foundation |
| Terminal dashboard | Rich live dashboard for state, latest response, logs, insights, and visual placeholder status |
| Finance MVP | Stock quote routing, daily market snapshots, compact spoken finance replies |
| Broker sandbox | Trading 212 demo adapter, read-only account data, and local demo trade proposal journaling |
| Broker sandbox | Trading 212 demo adapter, read-only account data, local demo trade proposal journaling, and pending-proposal review |
| Validation | Pytest suite plus a validation harness that writes structured session reports |

## What Aria Is Not
Expand Down Expand Up @@ -156,6 +156,7 @@ Aria's safety model is architectural, not cosmetic:
- Trading 212 is demo-only in the current code path.
- Broker operations are read-only until a separate human-gated execution layer exists.
- Demo trade proposals are local records only; confirmation does not send broker orders in the current build.
- Trade confirmations prefer typed input; voice recovery is limited to matching an existing pending proposal.
- Proactive analyst output is queued by default so it does not interrupt conversation.
- Test and validation output should exist before code is merged.
- Aria may propose changes, but the user reviews and approves them.
Expand Down
5 changes: 5 additions & 0 deletions core/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,11 @@ def _matches_finance_current_event(text_lower: str) -> bool:

def _matches_trade_order_proposal(text: str, text_lower: str) -> bool:
"""Route explicit order intent to the demo trade proposal layer."""
if any(term in text_lower for term in ("review", "show", "read", "what is", "what's")) and any(
term in text_lower for term in ("pending trade", "trade proposal", "pending proposal")
):
return True

if "cancel" in text_lower and any(
term in text_lower for term in ("trade", "order", "proposal")
):
Expand Down
108 changes: 101 additions & 7 deletions core/trading/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,29 @@
SELL_ALIASES = ("sell",)
SIDE_TERMS = BUY_ALIASES + SELL_ALIASES

NUMBER_WORDS = {
"zero": "0",
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
"ten": "10",
"eleven": "11",
"twelve": "12",
"thirteen": "13",
"fourteen": "14",
"fifteen": "15",
"twenty": "20",
"thirty": "30",
"fifty": "50",
"hundred": "100",
}

SYMBOL_ALIASES = {
"apple": "AAPL",
"nvidia": "NVDA",
Expand Down Expand Up @@ -111,6 +134,8 @@ def confirmation_phrase(self) -> str:
def handle_trade_command(text: str) -> str:
"""Handle a routed trade proposal, confirmation, or cancellation command."""
text_lower = text.lower()
if _is_review_command(text_lower):
return describe_pending_trade(text)
if _is_cancel_command(text_lower):
return cancel_pending_trade(text)
if _is_confirm_command(text_lower):
Expand All @@ -135,18 +160,19 @@ def stage_trade_proposal(text: str) -> str:
_pending_proposal = proposal
_append_journal("proposal_staged", proposal, {})
log.info(
"Demo trade proposal staged: id=%s side=%s symbol=%s quantity=%s notional=%s",
"Demo trade proposal staged: id=%s side=%s symbol=%s quantity=%s "
"notional=%s required_confirmation=%r",
proposal.id,
proposal.side,
proposal.symbol,
proposal.quantity,
proposal.notional_value,
proposal.confirmation_phrase(),
)
return (
f"Demo trade proposal staged: {proposal.side} {_format_size(proposal)} "
f"{proposal.symbol} at market. Execution is disabled in this build. "
f"To confirm the proposal record, say: {proposal.confirmation_phrase()}. "
"To cancel it, say: cancel trade."
f"Demo proposal staged: {proposal.side} {_format_size(proposal)} "
f"{proposal.symbol}. Execution disabled. Use typed confirmation, "
"or ask me to review pending trade."
)


Expand All @@ -165,9 +191,13 @@ def confirm_pending_trade(text: str) -> str:
proposal,
{"source_text": text, "required_phrase": proposal.confirmation_phrase()},
)
log.info(
"Pending demo trade confirmation phrase: %s",
proposal.confirmation_phrase(),
)
return (
"That confirmation did not match the pending demo trade. "
f"Say: {proposal.confirmation_phrase()}. Or say: cancel trade."
"Use typed confirmation or ask me to review pending trade."
)

blocked = _replace_status(proposal, "blocked", ["execution_disabled"])
Expand Down Expand Up @@ -202,6 +232,22 @@ def cancel_pending_trade(text: str = "") -> str:
return f"Cancelled the pending demo trade proposal for {proposal.symbol}."


def describe_pending_trade(text: str = "") -> str:
"""Describe the active pending proposal and log the exact confirmation."""
proposal = _active_pending_proposal()
if proposal is None:
_append_journal("review_without_pending_proposal", None, {"source_text": text})
return "There is no pending demo trade proposal, Chan."

_append_journal("proposal_reviewed", proposal, {"source_text": text})
log.info("Pending demo trade confirmation phrase: %s", proposal.confirmation_phrase())
return (
f"Pending demo proposal: {proposal.side} {_format_size(proposal)} "
f"{proposal.symbol} at market. Execution is disabled. "
"Use typed confirmation, or say cancel trade."
)


def parse_trade_proposal(text: str) -> TradeProposal:
"""Parse user text into a structured proposal and validation status."""
created_at = _utc_now()
Expand Down Expand Up @@ -392,13 +438,61 @@ def _pending_expired(proposal: TradeProposal) -> bool:
def _confirmation_matches_proposal(text: str, proposal: TradeProposal) -> bool:
normalized = _normalize_confirmation_text(text)
required = _normalize_confirmation_text(proposal.confirmation_phrase())
return required in normalized
if required in normalized:
return True

recovered = _extract_confirmation_fields(text)
if not recovered["confirmed"] or not recovered["has_demo_context"]:
return False
if recovered["side"] != proposal.side:
return False
if recovered["symbol"] != proposal.symbol:
return False

if proposal.quantity is not None:
return recovered["quantity"] == proposal.quantity
if proposal.notional_value is not None:
return recovered["notional_value"] == proposal.notional_value
return False


def _normalize_confirmation_text(text: str) -> str:
return re.sub(r"\s+", " ", text.lower().replace(",", " ")).strip()


def _extract_confirmation_fields(text: str) -> dict[str, Any]:
recovered_text = _normalize_trade_confirmation_text(text)
return {
"confirmed": "confirm" in recovered_text.split(),
"has_demo_context": any(
term in recovered_text for term in ("demo", "trade", "proposal", "order")
),
"side": _extract_side(recovered_text),
"symbol": _extract_symbol(recovered_text),
"quantity": _extract_quantity(recovered_text),
"notional_value": _extract_notional(recovered_text)[0],
"recovered_text": recovered_text,
}


def _normalize_trade_confirmation_text(text: str) -> str:
"""Normalize common STT mistakes only for pending-trade confirmation."""
text_lower = text.lower()
text_lower = re.sub(r"\b(\d+)upple\b", r"\1 apple", text_lower)
text_lower = re.sub(r"[^a-z0-9£$.\s]", " ", text_lower)
for word, number in NUMBER_WORDS.items():
text_lower = re.sub(rf"\b{word}\b", number, text_lower)
text_lower = re.sub(r"\bby\b", "buy", text_lower)
text_lower = text_lower.replace("a a p l", "aapl")
return re.sub(r"\s+", " ", text_lower).strip()


def _is_review_command(text_lower: str) -> bool:
if not any(term in text_lower for term in ("review", "show", "read", "what is", "what's")):
return False
return any(term in text_lower for term in ("pending trade", "trade proposal", "pending proposal"))


def _is_cancel_command(text_lower: str) -> bool:
return "cancel" in text_lower and any(term in text_lower for term in ("trade", "order", "proposal"))

Expand Down
13 changes: 10 additions & 3 deletions docs/trading212-demo-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,19 @@ Examples:
Aria, buy 5 AAPL in demo.
Aria, sell 3 shares of NVDA in demo.
Aria, buy 250 pounds of Apple in the demo account.
Aria, review pending trade proposal.
Aria, cancel trade.
Aria, confirm demo buy 5 AAPL.
```

The confirmation phrase confirms the local proposal record only. In this phase,
Aria responds that execution is disabled and does not send an order to
Trading 212.
Aria keeps spoken proposal replies short to reduce TTS latency. The exact
confirmation phrase is written to `aria.log` and is safest through the terminal
typed input path. Voice confirmation can recover narrow speech-to-text errors
seen in testing, such as `by` for `buy`, `five` for `5`, and `Apple` for
`AAPL`, but only when the recovered side, size, and symbol match the pending
proposal. The confirmation phrase confirms the local proposal record only. In
this phase, Aria responds that execution is disabled and does not send an order
to Trading 212.

Proposal events are written to:

Expand All @@ -90,6 +96,7 @@ data/trading/trade_proposals.jsonl
Journal event types include:

- `proposal_staged`
- `proposal_reviewed`
- `proposal_blocked`
- `confirmation_phrase_mismatch`
- `proposal_confirmed_execution_blocked`
Expand Down
6 changes: 6 additions & 0 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ def test_demo_trade_cancel_routes_to_proposal_layer() -> None:
assert route == {"intent": "trade_order_proposal", "tier": 1}


def test_pending_trade_review_routes_to_proposal_layer() -> None:
route = classify("review pending trade proposal")

assert route == {"intent": "trade_order_proposal", "tier": 1}


def test_generic_buy_request_does_not_route_to_trade_proposal() -> None:
route = classify("I need to buy milk later")

Expand Down
51 changes: 48 additions & 3 deletions tests/test_trade_proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ def test_parse_notional_trade_proposal() -> None:
def test_stage_trade_proposal_writes_journal(proposal_journal: Path) -> None:
response = proposals.stage_trade_proposal("sell 3 shares of NVDA in demo")

assert "Demo trade proposal staged" in response
assert "Demo proposal staged" in response
assert len(response) < 130
pending = proposals.get_pending_proposal_for_tests()
assert pending is not None
assert pending.symbol == "NVDA"
Expand Down Expand Up @@ -99,12 +100,56 @@ def test_confirmation_requires_matching_phrase(proposal_journal: Path) -> None:
response = proposals.confirm_pending_trade("confirm trade")

assert "did not match" in response
assert "confirm demo buy 5 AAPL" not in response
assert proposals.get_pending_proposal_for_tests() is not None

events = _read_events(proposal_journal)
assert events[-1]["event_type"] == "confirmation_phrase_mismatch"


def test_confirmation_recovers_common_stt_errors(proposal_journal: Path) -> None:
proposals.stage_trade_proposal("buy 5 AAPL in demo")

response = proposals.confirm_pending_trade("confirm trade by five apple in demo")

assert "Execution is disabled" in response
assert proposals.get_pending_proposal_for_tests() is None

events = _read_events(proposal_journal)
assert events[-1]["event_type"] == "proposal_confirmed_execution_blocked"
assert events[-1]["source_text"] == "confirm trade by five apple in demo"


def test_confirmation_recovers_by_and_fused_apple_stt_error(proposal_journal: Path) -> None:
proposals.stage_trade_proposal("buy 5 AAPL in demo")

response = proposals.confirm_pending_trade("Confirm demo by 5upple")

assert "Execution is disabled" in response
assert proposals.get_pending_proposal_for_tests() is None


def test_confirmation_recovery_still_requires_matching_symbol(proposal_journal: Path) -> None:
proposals.stage_trade_proposal("buy 5 AAPL in demo")

response = proposals.confirm_pending_trade("confirm demo by five nvidia")

assert "did not match" in response
assert proposals.get_pending_proposal_for_tests() is not None


def test_describe_pending_trade_logs_review(proposal_journal: Path) -> None:
proposals.stage_trade_proposal("buy 5 AAPL in demo")

response = proposals.describe_pending_trade("review pending trade proposal")

assert "Pending demo proposal: buy 5 AAPL" in response
assert "Execution is disabled" in response

events = _read_events(proposal_journal)
assert events[-1]["event_type"] == "proposal_reviewed"


def test_cancel_pending_trade(proposal_journal: Path) -> None:
proposals.stage_trade_proposal("buy 5 AAPL in demo")

Expand Down Expand Up @@ -132,7 +177,7 @@ def test_rejects_missing_order_size(proposal_journal: Path) -> None:
def test_brain_trade_handler_stages_without_execution(proposal_journal: Path) -> None:
response = brain._handle_trade_order_proposal("buy 5 AAPL in demo")

assert "Demo trade proposal staged" in response
assert "Execution is disabled" in response
assert "Demo proposal staged" in response
assert "Execution disabled" in response
events = _read_events(proposal_journal)
assert events[0]["event_type"] == "proposal_staged"
23 changes: 20 additions & 3 deletions tools/run_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,13 +380,30 @@ def check_trade_proposal_safety() -> ValidationItem:
)

staged = proposals.stage_trade_proposal("buy 5 AAPL in demo")
if "Demo trade proposal staged" not in staged:
if "Demo proposal staged" not in staged:
return _item(
"trade_proposal_safety",
"fail",
"valid demo trade proposal was not staged",
response=staged,
)
if len(staged) >= 130:
return _item(
"trade_proposal_safety",
"fail",
"demo trade proposal response is too long for voice",
response=staged,
response_chars=len(staged),
)

reviewed = proposals.describe_pending_trade("review pending trade proposal")
if "Pending demo proposal" not in reviewed:
return _item(
"trade_proposal_safety",
"fail",
"pending trade review did not describe the proposal",
response=reviewed,
)

mismatch = proposals.confirm_pending_trade("confirm trade")
if "did not match" not in mismatch:
Expand All @@ -397,12 +414,12 @@ def check_trade_proposal_safety() -> ValidationItem:
response=mismatch,
)

confirmed = proposals.confirm_pending_trade("confirm demo buy 5 AAPL")
confirmed = proposals.confirm_pending_trade("confirm trade by five apple in demo")
if "Execution is disabled" not in confirmed:
return _item(
"trade_proposal_safety",
"fail",
"confirmed proposal did not block execution",
"recovered voice confirmation did not block execution",
response=confirmed,
)

Expand Down