Skill v1.0.0
currentAutomated scan100/100name: mmr-loop-skill description: Autonomous trading loop for the MMR platform. Continuously monitors portfolio, scans for opportunities, creates trade proposals, and manages risk. Requires the 'mmr' skill to be loaded first. Use when the user asks to start monitoring, trading autonomously, or running a trading loop. metadata: author: mmr version: "1.0" dependencies: mmr-skill
MMR Trading Loop
This skill turns the MMR trading platform into an autonomous trading agent. It implements a phased state machine that continuously monitors your portfolio, scans for opportunities, and creates trade proposals for your review.
Important: This skill requires the mmr skill to be loaded first. Load both:
await load_skill("mmr", "all")await load_skill("mmr-loop", "all")
Where the loop's commands run
The loop's core cycle is DuckDB-backed (portfolio_risk reads the group store, session_status counts proposals, propose writes one). While the mmr Docker container is up, the live DuckDB lives in the mmr_db_data volume and those calls must execute inside the container — from the host they return ShadowedDatabaseError and the cycle stalls at PROPOSE. MMRHelpers routes through docker exec automatically when the .db_in_container_volume marker is present and mmr-mmr-1 is running; confirm once at loop start:
mode = await MMRHelpers.exec_mode()assert mode["mode"] == "container" or not mode["db_marker_present"], mode
The "run sequentially, DuckDB is single-writer" guidance below is about the lock, not about which database file is open — both apply. See mmr-skill § Where commands run for overrides (MMR_SKILL_EXEC, MMR_CONTAINER).
How It Works
The loop runs a repeating cycle with four phases:
PRE-FLIGHT → MONITOR → ANALYZE → PROPOSE → DIGEST → (sleep) → PRE-FLIGHT → ...
PRE-FLIGHT: Checks status() for trader_service connectivity and IB Gateway upstream connection. If IB Gateway can't reach IBKR servers, the cycle skips directly to DIGEST — no wasted API calls that would timeout.
MONITOR: Quick health check. Calls portfolio_snapshot() and portfolio_diff() (~500 tokens). If nothing moved (all positions unchanged), skips directly to DIGEST — no wasted API calls or context.
ANALYZE: Only runs when MONITOR finds something interesting (positions moved, market open, new cycle). Runs portfolio_risk() for warnings, scans with ideas() using rotating presets (momentum → mean-reversion → breakout → volatile → gap-down), checks news for held positions.
PROPOSE: Creates trade proposals via propose() with auto-sizing (ATR-adjusted), group tagging, confidence scores, and reasoning. Never auto-executes — proposals wait for user approval.
DIGEST: Writes cycle summary to memory (persists across compactions), compacts context with compact("drop-helpers-results"), sleeps until next cycle.
Starting the Loop
To start the trading loop, register hooks and initialize state:
result = await load_skill("mmr", "all")result = await load_skill("mmr-loop", "all")# Initialize the loop engineawait TradingLoop.start()
The loop will run until you say "stop" or call await TradingLoop.stop().
User Interaction
While the loop is running, you can interrupt at any time:
- "stop" or "pause" — stops the loop
- "approve 42" — approve a pending proposal
- "reject 42" — reject a proposal
- "status" — get current loop state and cycle count
- "skip" — skip to next cycle immediately
- Any other question — the loop pauses, answers, then resumes
Configuration
The loop reads configuration from the TradingLoop.config dict. Override before starting:
TradingLoop.config["scan_interval_seconds"] = 300 # 5 minutes between cyclesTradingLoop.config["scan_presets"] = ["momentum", "mean-reversion"]TradingLoop.config["max_proposals_per_cycle"] = 1TradingLoop.config["auto_approve"] = False # NEVER set to True without understanding the risks
See references/LOOP_CONFIG.md for full configuration reference.
Safety Boundaries
- Proposals, not trades: The loop creates proposals but NEVER auto-executes. You approve or reject.
- Position limits: Respects
max_positionsfrom position_sizing.yaml. Stops proposing at the limit. - Group budgets: Checks group allocation budgets before proposing. Over-budget = warning, not block.
- Gates apply to OPENS only; exits are never refused: The risk gate, trading filter, leverage check, and any approval gate run on exposure-increasing orders. Any order that reduces the live broker position — a close, a protective stop, a position-reducing SELL — is classified exit-class server-side and is never blocked. (The old client
skip_risk_gateflag is deprecated and ignored — the exit-class classifier decides, not a flag.) - Gates fail closed on opens (expect brief warm-up refusals after a restart): When a critical input can't be read (daily PnL, account value, a price to value a market order), opening exposure is refused with a reason naming the missing datum. For a few seconds after a
trader_servicerestart, while IB feeds warm up, market opens can be refused with "…could not be read — fail-closed" — this is by design, self-clears quickly, and never affects closes. Don't treat these as a fault; skip that cycle's open and retry next cycle. The hourly rate limit now counts realORDER_SUBMITTEDevents. - Proposal state machine: Terminal statuses (EXECUTED / REJECTED / FAILED / EXPIRED) are immutable. If
approve()fails at the broker (margin rejection, bracket rollback), OR the sized amount doesn't cover one whole share (approvefloors amount→shares and refuses at zero — it no longer bumps to 1 share), the proposal moves to FAILED — create a new proposal rather than re-approving the old one. On a tiny amount / very high-priced instrument, raise the amount. - Scanner fails loudly:
ideas()withlocation=raises on "no results" rather than returning an empty list, so the loop will see a real error for misconfigured markets instead of silently making zero proposals. Log the error, skip the scan for that cycle, and continue. - Signed exposure in risk reports:
portfolio_risk()returnsnet_exposure_pct,long_exposure_pct,short_exposure_pctalongsidegross_exposure_pct. For long-only loops these are effectively the same; for long/short loops, readnet_exposure_pctfirst — correlation-cluster warnings now fire on net, so a correlated long/short hedge pair won't false-alarm. - Strategy gauntlet gate ("no hash, no live"): If the loop ever deploys or enables a strategy, an
auto_executestrategy is refused without a PASS record frommmr strategies gauntlet <module> --class <Class>for its exact source hash (no override flag). Not part of the propose/approve path, but a hard boundary if you touch the roster. - Opt-in approval hardening (default OFF — not on the running stack): Three Phase-2 defenses exist but ship disabled (see
docs/SAFETY_ROADMAP.md). When enabled: a server-side notional tier requires an out-of-bandapprover_keyfor an above-threshold OPEN (exits exempt);proposal_showsplitstrusted(server-recomputed facts) fromuntrusted(proposer text) — decide only ontrusted; and anMMR_ROLE=proposercontext cannot approve/buy/sell/resize. Do not assume any of these are active unless configured. - Context management: Aggressive compaction keeps context at ~12K tokens. Can run indefinitely.
What Gets Written to Memory
Each cycle writes a summary entry:
Key: trading_loop_cycle_{N}Summary: "Cycle N — Portfolio $67K (+$20 daily). Scanned momentum. Created 1 proposal (BHP BUY $3,600). HHI 0.064, no warnings."
You can review cycle history anytime:
keys = read_memory_keys()for k in keys:if k["key"].startswith("trading_loop_cycle_"):print(f'{k["key"]}: {k["summary"]}')
Position Tracking
The loop has a built-in position monitor that checks tracked positions between cycles. When a position hits its stop-loss or take-profit threshold, the LLM is alerted. Between alerts, monitoring happens silently in the hook — no LLM turns are wasted.
# Track positions after entering tradesawait TradingLoop.track_position("BHP", "LONG", entry=50.15, qty=350, stop_pct=-1.5, add_pct=2.0)await TradingLoop.track_position("PLS", "SHORT", entry=4.58, qty=3800)# Or use module-level convenience functionsawait track_position("BHP", "LONG", 50.15, 350)# Check what's being trackedstatus = await TradingLoop.status()# status["tracked_symbols"] → ["BHP", "PLS"]# Stop trackingawait TradingLoop.untrack_position("BHP")await TradingLoop.untrack_all() # clear all
The monitor uses snapshots_batch() for efficiency (~4s for all symbols vs ~4s per symbol). Configure timing with monitor_interval_seconds (default 180s). Default thresholds: monitor_stop_pct=-1.5, monitor_add_pct=2.0.
Quick Reference
| Function | Description | |
|---|---|---|
await TradingLoop.start() | Start the loop (registers hooks) | |
await TradingLoop.stop() | Stop the loop (unregisters hooks) | |
await TradingLoop.status() | Get running state, cycle count, tracked positions | |
await TradingLoop.track_position(sym, side, entry, qty, ...) | Add position to monitor watchlist | |
await TradingLoop.untrack_position(sym) | Remove from watchlist | |
await TradingLoop.untrack_all() | Clear all tracked positions | |
await start_trading_loop(**overrides) | Start with config overrides in one call | |
await stop_trading_loop() | Alias for TradingLoop.stop() | |
TradingLoop.config["key"] = value | Change config before or during loop |
Architecture Notes
- Hook sleeps internally via
asyncio.sleep()— no wasted LLM turns between cycles - Timer-based gating: pure elapsed time, no iteration-modulo tricks
- Position monitor runs between full cycles, only alerts the LLM when thresholds are breached
- Uses
snapshots_batch()for efficient multi-symbol price checks (~4s total) - Uses
delegate_task()for parallel data gathering (portfolio + risk + scan simultaneously) - Context stays at ~12K tokens after compaction across unlimited cycles
- Rotating scan presets ensure different patterns are checked each cycle