Skip to content
Merged
12 changes: 12 additions & 0 deletions config/prompts/portfolio_manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,18 @@ Rules:
- Cash **above** ceiling and macro is risk-on / transitional → you are
under-deploying; either size up high-conviction names or lower your
hurdle one notch
- **DEPLOYMENT GAP fact (when present in your facts block): answer it
here, explicitly.** The facts may show invested% more than 15pp under
macro's `target_invested_pct`. That gap was measured as the single
largest P&L drag over Apr–Jul 2026 (idle cash while macro said 72–75%
invested, book sat at ~39%). Your `cash_target` step must then contain
ONE of: (a) targets this session that close most of the gap, or (b) a
named, checkable blocker per unfilled slot — "no candidate passed the
R/R filter today", "regime gate: macro flipped transitional",
"top candidates all earnings-queued". A generic "staying selective /
cautious" is NOT a valid answer; it's how three months of drag
happened. (This does not override RM, caps, or the drawdown-halving —
it forces the gap to be ADDRESSED, not auto-filled.)
- Align with Macro's `position_guidance.cash_recommendation_pct` when
present, but **regime-based floors ALWAYS override** (advisory is
soft; floor is hard)
Expand Down
28 changes: 26 additions & 2 deletions config/prompts/position_reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,13 @@ A SELL or REDUCE must point to ONE of:
entry thesis has actually occurred (not "I worry it might")
- **HIGH-conviction state_change that reverses the thesis** — not any news,
specifically a state_change labeled HIGH that contradicts the entry
rationale
rationale. **Single-source cap on winners:** when the ONLY trigger is one
news state_change (nothing else corroborates — tech rating unchanged,
thesis_invalid_if not met, no earnings signal) and the position is a >10%
winner, first-day action is capped at REDUCE (≤50%); a full SELL requires
either a second corroborating signal or the story surviving into the next
session. (2026-06-25 autopsy: a +18% AAPL position was fully exited
same-day on one component-cost story; the story faded, the stock didn't.)
- **Earnings filing bearish for this position** — the just-filed 10-Q/10-K
analysis comes back with `sentiment=bearish` AND `conviction ∈ {medium, high}`
on a name you're long. A `bearish` + `low` conviction filing is mixed-signal
Expand All @@ -131,7 +137,17 @@ Every position has deterministic numbers:
don't trim a fast winner). <0.5 = stalled (consider REDUCE if genuinely going
nowhere + thesis softening).
- `to_stop` / `to_target` = % distance to the respective levels. <2% to stop
= critical zone.
= critical zone. **`to_stop` is ADVISORY DISTANCE, never a trigger: only
the broker fills stops.** "Close to stop" or "will gap through the stop
overnight" is NOT a reason to SELL ahead of it — pre-empting the stop
converts protection into a realized whipsaw (GS 2026-05-18: sold at
+0.4%-to-stop "before the gap"; no gap came, the stock ran).
- `atr_pct` = ATR(14) as % of price — one day's normal range. `stop_distance_atrs`
= stop distance in ATR units. **Think in ATRs, not raw %**: a 3% gap is roomy
for a staples name and suicidal for a high-beta one. A stop <1.25 ATRs away
is inside daily noise — the pipeline will REJECT a TRAIL_STOP into that band
(without a hard trigger), so don't propose one; if you genuinely want out,
say SELL/REDUCE with the trigger named.
- `weight_pct` = current $ weight of book.

Flags the pipeline may attach:
Expand Down Expand Up @@ -194,6 +210,14 @@ Respond ONLY with valid JSON matching `PositionReview`:
negligible protection gain — if the right new stop is within 2% of the old
one, just HOLD. The stop can only go UP; you cannot widen it later, so do not
ratchet a young position's stop up into its own noise band.
**Pipeline enforcement (don't fight it, plan around it):** without a hard
trigger cited in `reason`, a TRAIL_STOP is REJECTED when (a) a trail on the
same symbol was already accepted within the last ~2 trading days (ratchet
cooldown — the ×1.02 minimum means back-to-back trails walk the stop ≥2%
per session straight into the noise band; GE was ratcheted 7× in 8 sessions
this way), or (b) the new stop lands within 1.25×ATR14 of the current price
(inside one day's range — routine volatility would fill it). One considered
trail beats daily nudges.
- **REDUCE** — sells 50% of the position. Use for: drift_flag firing, parabolic
exhaustion confirmed, target_breach with momentum fading, correlation
cluster rebalance. **If a 50% reduce would still leave `weight_pct > 12%`
Expand Down
12 changes: 12 additions & 0 deletions config/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ risk:
# a mandatory de-lever directive when cash is already negative.
allow_margin: false

# Idle-cash sweep — park cash above the reserve in a T-bill ETF, release it
# automatically before BUYs. The vehicle is cash-equivalent everywhere:
# hidden from every LLM view, counted as cash by the risk engine, stopless
# by design, first to liquidate in force_delever. Deterministic, zero-LLM.
# (2026-07-16 forensics: ~84% idle cash for weeks = ~$300/mo risk-free carry
# left on the table; see src/execution/cash_sweep.py.)
cash_sweep:
enabled: true
symbol: "SGOV" # iShares 0-3mo T-bills; BIL is the fallback choice
reserve_pct: 1.0 # % of equity kept as raw cash for fees/slippage
min_order_usd: 500 # don't churn sub-$500 parking orders

trading:
universe:
# Index ETF
Expand Down
33 changes: 33 additions & 0 deletions scripts/run_if_et_window.sh
Original file line number Diff line number Diff line change
Expand Up @@ -190,16 +190,49 @@ if [[ -f "${PROJECT_ROOT}/.env" ]]; then
set +a
fi

# Best-effort Telegram push from BASH. RC5 (2026-07-16): when `timeout`
# SIGTERM/SIGKILLs python, the finally-block notifier never runs — 13
# straight days of morning kills produced ZERO failure notifications.
# Python cannot be trusted to report its own violent death; the wrapper can.
notify_telegram() {
local text="$1"
[[ "${TELEGRAM_DISABLED:-0}" == "1" ]] && return 0
[[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]] && return 0
curl -sS --max-time 10 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${text}" >/dev/null 2>&1 || true
}

# External dead-man's switch (CLAUDE.md wishlist): if HEALTHCHECKS_URL is
# set in .env, ping it on success and <url>/fail on failure. An absent ping
# then alerts from OUTSIDE the host — the only coverage for total host
# death / evening itself not firing.
ping_healthcheck() {
local suffix="${1:-}"
[[ -z "${HEALTHCHECKS_URL:-}" ]] && return 0
curl -fsS --max-time 10 --retry 2 "${HEALTHCHECKS_URL}${suffix}" >/dev/null 2>&1 || true
}

if "$TIMEOUT" --kill-after=30 1200 "$PYTHON" main.py --mode "$MODE"; then
# intra_check is intentionally guard-less (see last-run guard block above) —
# we don't write the marker for it, so the next 30-min tick can fire freely.
if [[ "$MODE" != "intra_check" ]]; then
echo "${ET_DATE} ${NOW_UNIX}" > "$LAST_FILE"
fi
ping_healthcheck
exit 0
else
STATUS=$?
fi

echo "[$(date '+%Y-%m-%d %H:%M:%S %Z')] ${MODE} failed with status ${STATUS}; not updating last-run guard" >&2
# Bash-side push ONLY for violent deaths (124=timeout, 137=SIGKILL,
# 143=SIGTERM) — those skip python's finally-block notifier entirely. For
# ordinary non-zero exits python already pushed its own FAILED message;
# pushing again here would just teach the operator to ignore duplicates.
if [[ "$STATUS" -eq 124 || "$STATUS" -eq 137 || "$STATUS" -eq 143 ]]; then
notify_telegram "🔴 quant-agent ${MODE} KILLED (status ${STATUS}) on ${ET_DATE} — python got no chance to notify. Next tick retries (morning resumes from the decision checkpoint if one was written)."
fi
ping_healthcheck "/fail"
exit "$STATUS"
28 changes: 28 additions & 0 deletions src/agents/position_reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,34 @@ def _opt_section(title: str, body: str) -> str:
f"{tilt_note}\n"
).rstrip() + "\n"

# Deterministic post-exit reality (computed from trades × live
# prices, NO self-assessment): what the tape did after our recent
# exits. Rendered even when nightly grades are absent — the
# 2026-07-16 audit found grades said 97% "correct" while 53% of
# exits ran ≥5% higher within 20 days.
reality = trade_grade_summary.get("post_exit_reality") if trade_grade_summary else None
if reality and reality.get("n"):
worst_lines = "; ".join(
f"{w['symbol']} exited {w['date']} → {w['move_pct']:+.1f}% since"
for w in (reality.get("worst") or [])
)
frac = reality["n_higher_5pct"] / reality["n"]
reality_note = ""
if frac >= 0.5:
reality_note = (
"⚠️ Over HALF of recent exits ran ≥5% after you sold — the "
"tape says your exits fire too early. Any SELL/tighten today "
"must name a hard trigger, not price action."
)
grade_section += (
f"### Post-exit reality check (deterministic, from price data)\n"
f"Exits in window: {reality['n']}; ran ≥5% higher after exit: "
f"{reality['n_higher_5pct']}; avg move since exit: "
f"{reality['avg_move_pct']:+.1f}%\n"
f"Biggest post-exit runs: {worst_lines}\n"
f"{reality_note}\n"
).rstrip() + "\n"

if recent_performance:
r5 = recent_performance.get("rolling_5d_pct")
r20 = recent_performance.get("rolling_20d_pct")
Expand Down
39 changes: 39 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,42 @@ class RiskConfig(BaseModel):
allow_margin: bool = False


class CashSweepConfig(BaseModel):
"""Idle-cash sweep into a T-bill ETF (default SGOV).

The sweep vehicle is treated as CASH-EQUIVALENT everywhere: excluded
from every LLM-facing position view, excluded from risk-engine exposure
math (its market value counts toward cash in the cash_only filter),
exempt from stop-coverage audits (it deliberately carries no stop), and
force_delever liquidates it FIRST. Deterministic and zero-LLM — the
LLM never decides to park or unpark; the pipeline bookends do.
"""
enabled: bool = False
"""Master switch. False = the sweeper is inert everywhere (no view
filtering, no funding sells, no parking buys)."""

symbol: str = "SGOV"
"""The parking vehicle. Must be a cash-like T-bill ETF (SGOV/BIL);
anything with real market beta breaks the cash-equivalence assumption
that justifies every exemption listed above."""

reserve_pct: float = Field(default=1.0, ge=0, le=20)
"""% of equity kept as raw cash (fees, slippage, partial fills).
Excess above the reserve is parked."""

min_order_usd: float = Field(default=500.0, ge=0)
"""Don't churn sub-$500 parking orders — spread + noise beat the
few cents of yield."""

@field_validator("symbol")
@classmethod
def _symbol_nonempty(cls, v: str) -> str:
v = (v or "").strip().upper()
if not v:
raise ValueError("cash_sweep.symbol must be a non-empty ticker")
return v


class ScheduleConfig(BaseModel):
earnings_preprocess: str = "08:00"
morning: str
Expand Down Expand Up @@ -243,6 +279,9 @@ class AppConfig(BaseModel):
trading: TradingConfig
storage: StorageConfig
evolution: EvolutionConfig = Field(default_factory=EvolutionConfig)
# Optional section — a settings.yaml without it gets a disabled sweeper
# (enabled=False default), so older configs keep working unchanged.
cash_sweep: CashSweepConfig = Field(default_factory=CashSweepConfig)

@model_validator(mode="after")
def _check_llm_provider_keys(self):
Expand Down
Loading
Loading