Skip to content

Commit d20a5fb

Browse files
committed
bug fix and update to prompts
1 parent b26019f commit d20a5fb

8 files changed

Lines changed: 240 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## Bugfix — 2026-04-27 (risk manager stop-loss above entry price)
4+
5+
### Fixed
6+
- `prompts/risk_manager_v2.txt` — rewritten to prevent the model from anchoring `recommended_stop_loss` to historical BTC ATH levels (~$70–74k) instead of computing downward from the current price. Key changes: explicitly states the system is long-only (removed the "above entry for SELL" clause that confused the model); requires `recommended_stop_loss MUST be LESS THAN the current price`; provides the placement formula as a concrete equation (`stop = current_price − multiplier × ATR-14`); adds a worked example with actual numbers ($23k BTC / $1,500 ATR-14); adds "DO NOT use historical ATH prices as reference points"
7+
- `src/agents/risk.py``PROMPT_FILE` flipped from `risk_manager_v1.txt` to `risk_manager_v2.txt`
8+
- `src/backtest/signals.py` — new `_sanitize_stop_levels(close_price, sl_pct, tp_pct, atr_14)`: code-level guard that corrects invalid stop/target ratios regardless of LLM output. If `sl_pct ≥ 1.0` (stop above entry) or `≤ 0`, replaces with `2×ATR below entry` floored at 80% of entry. If `tp_pct ≤ 1.0` (target below entry), replaces with `3×ATR above entry` (preserves ≥1.5 R:R). Applied immediately after the raw ratio computation in the signal loop.
9+
10+
### Tests
11+
- `tests/test_backtest.py` — new `TestSanitizeStopLevels` (14 cases): ATH-anchoring bug reproduction, ATR fallback formula verification, 80% floor enforcement, valid values pass through unchanged, tp-below-entry correction, R:R ≥ 1.5 check after sanitization, zero-ATR and zero-close edge cases. Test suite: **291 → 305** passing.
12+
313
## Feature — 2026-04-22 (v2: restore agent sight + replace rigid consensus)
414

515
Implements `specs/v2.md` phases 1–4. Phase 5 (tuning + holdout backtest) is pending real API runs. Test suite: **235 → 283** passing.

prompts/risk_manager_v2.txt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
You are the risk manager for a Bitcoin swing trading system. You do NOT form a market view — you approve or veto trades based purely on portfolio risk parameters. You are the last line of defence before capital is deployed.
2+
3+
This system is LONG-ONLY. There are no short positions. SELL signals only close existing long positions; they never open new ones. All stop and target levels must be set for a LONG entry at the current price.
4+
5+
Hard rules enforced in code before you are called (do not re-check these):
6+
- Max drawdown halt: portfolio down > 15% from peak → already halted upstream
7+
- Max ATR multiplier: ATR-14 > 2× 30-candle average → already halted upstream
8+
9+
Your job — evaluate the trade opportunity against:
10+
1. Position sizing: approved_position_size_pct must not exceed 20% of portfolio value
11+
2. Risk per trade: the stop loss must be sized so that maximum loss ≤ 2% of portfolio value
12+
Formula: position_size_usd = (portfolio_value × 0.02) / (entry_price - stop_loss_price)
13+
3. Risk/reward: risk_reward_ratio = (take_profit - entry) / (entry - stop_loss) must be ≥ 1.5
14+
4. Conviction threshold: if the directional agents' average confidence is low (< 50), recommend veto
15+
16+
CRITICAL — stop and target placement for a LONG entry:
17+
18+
recommended_stop_loss MUST be LESS THAN the current price.
19+
→ Stop = current_price − (ATR_multiplier × ATR-14), where ATR_multiplier = 1.5 to 2.0
20+
→ This is a downside exit: if price falls to the stop, the position is closed at a loss.
21+
22+
recommended_take_profit MUST be GREATER THAN the current price.
23+
→ Target = current_price + (ATR_multiplier × ATR-14 × risk_reward_ratio)
24+
→ Minimum ratio is 1.5, so a 2×ATR stop needs at least a 3×ATR target.
25+
26+
Worked example (BTC at $23,000, ATR-14 = $1,500, portfolio = $250,000):
27+
stop_loss = 23,000 − (2.0 × 1,500) = 20,000 ← numerically LESS than 23,000 ✓
28+
take_profit = 23,000 + (3.0 × 1,500) = 27,500 ← numerically GREATER than 23,000 ✓
29+
risk_reward = (27,500 − 23,000) / (23,000 − 20,000) = 4,500 / 3,000 = 1.5 ✓
30+
risk_usd = (entry − stop) = $3,000 per BTC
31+
position_usd = (250,000 × 0.02) / 3,000 = $1,666 → approved_position_size_pct ≈ 0.67%
32+
33+
DO NOT use historical ATH prices as reference points. Use only the current price shown in the context.
34+
35+
When to veto (veto: true):
36+
- Risk/reward < 1.5
37+
- Position would exceed 20% of portfolio
38+
- Any hard numerical constraint cannot be satisfied
39+
40+
When not vetoing, set veto_reason to null.
41+
42+
Return ONLY valid JSON matching this exact schema — no markdown, no explanation, no extra keys:
43+
{
44+
"veto": true | false,
45+
"veto_reason": "<string or null>",
46+
"approved_position_size_pct": <float 0-100>,
47+
"recommended_stop_loss": <float>,
48+
"recommended_take_profit": <float>,
49+
"risk_reward_ratio": <float>,
50+
"notes": "<brief rationale>"
51+
}

src/agents/risk.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from src.models import MarketContext, RiskManagerOutput
77

88
MODEL = "claude-sonnet-4-6"
9-
PROMPT_FILE = "risk_manager_v1.txt"
9+
PROMPT_FILE = "risk_manager_v2.txt"
1010

1111

1212
def _build_user_message(ctx: MarketContext) -> str:

src/backtest/signals.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,56 @@ def _append_agent_log(path: Path, date: str, data: dict) -> None:
9797
f.write(json.dumps({"date": date, "output": data}) + "\n")
9898

9999

100+
def _sanitize_stop_levels(
101+
close_price: float,
102+
sl_pct: float,
103+
tp_pct: float,
104+
atr_14: float,
105+
) -> tuple[float, float]:
106+
"""Validate and correct stop-loss / take-profit ratios for a long position.
107+
108+
For a long entry at *close_price*:
109+
- sl_pct must be in (0.0, 1.0): stop is below entry price.
110+
- tp_pct must be > 1.0: target is above entry price.
111+
112+
When the risk manager returns a stop above the entry (sl_pct ≥ 1.0) —
113+
which happens when the model anchors to historical BTC price levels rather
114+
than computing downward from the current price — the ratio is replaced with
115+
an ATR-based fallback. The same fallback fires when values are zero or
116+
nonsensical.
117+
118+
Args:
119+
close_price: Current close price (the implied entry for a long).
120+
sl_pct: raw recommended_stop_loss / close_price from risk manager.
121+
tp_pct: raw recommended_take_profit / close_price from risk manager.
122+
atr_14: ATR-14 value from indicators (used for the fallback).
123+
124+
Returns:
125+
Corrected (sl_pct, tp_pct) — guaranteed sl_pct ∈ (0, 1) and tp_pct > 1.
126+
"""
127+
_SL_ATR_MULT = 2.0 # fallback: stop 2×ATR below entry
128+
_TP_ATR_MULT = 3.0 # fallback: target 3×ATR above entry (1.5 R:R on the stop)
129+
_MIN_SL_PCT = 0.80 # widest allowed stop: 20% below entry
130+
131+
# --- stop loss ---
132+
if sl_pct <= 0.0 or sl_pct >= 1.0:
133+
if close_price > 0 and atr_14 > 0:
134+
fallback_sl = close_price - _SL_ATR_MULT * atr_14
135+
sl_pct = max(_MIN_SL_PCT, fallback_sl / close_price)
136+
else:
137+
sl_pct = 0.95 # 5% stop as last resort
138+
139+
# --- take profit ---
140+
if tp_pct <= 1.0:
141+
if close_price > 0 and atr_14 > 0:
142+
fallback_tp = close_price + _TP_ATR_MULT * atr_14
143+
tp_pct = fallback_tp / close_price
144+
else:
145+
tp_pct = 1.075 # 7.5% target (1.5× a 5% stop)
146+
147+
return sl_pct, tp_pct
148+
149+
100150
async def generate_signals(
101151
full_ohlcv: pd.DataFrame,
102152
sentiment_history: dict,
@@ -207,6 +257,11 @@ async def generate_signals(
207257
# Store as ratios relative to close so they work at any price scale
208258
sl_pct = (sl_price / close_price) if close_price > 0 and sl_price > 0 else 0.0
209259
tp_pct = (tp_price / close_price) if close_price > 0 and tp_price > 0 else 0.0
260+
# Sanity-check: stop must be below entry (sl_pct < 1) and
261+
# target must be above entry (tp_pct > 1) for a long position.
262+
sl_pct, tp_pct = _sanitize_stop_levels(
263+
close_price, sl_pct, tp_pct, indicator_data["atr_14"]
264+
)
210265

211266
logger.info(
212267
"Cycle %s: signal=%s conviction=%s vetoed=%s",

src/data/news_historical.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@
4747

4848
CACHE_DIR = Path("tmp/cache/gdelt")
4949

50-
# Seconds to sleep after each uncached fetch (rate control)
51-
_POST_FETCH_SLEEP = 2.0
50+
# Seconds to sleep before each GDELT request (proactive rate control)
51+
_PRE_REQUEST_SLEEP = 2.0
52+
# Additional seconds to sleep after each uncached fetch
53+
_POST_FETCH_SLEEP = 1.0
5254
# Max retry attempts on 429
5355
_MAX_RETRIES = 3
5456

@@ -66,18 +68,23 @@ def _extract_domain(url: str) -> str:
6668

6769

6870
def _gdelt_get(params: dict) -> dict | None:
69-
"""GET GDELT with exponential-backoff retry on 429. Returns parsed JSON or None."""
70-
delay = 5.0
71+
"""GET GDELT with proactive rate pacing and exponential-backoff retry on 429.
72+
73+
Sleeps _PRE_REQUEST_SLEEP seconds before every attempt so that the request
74+
rate is controlled from the very first call rather than only after a 429.
75+
"""
76+
retry_delay = 10.0
7177
for attempt in range(_MAX_RETRIES):
78+
time.sleep(_PRE_REQUEST_SLEEP)
7279
try:
7380
resp = httpx.get(GDELT_URL, params=params, timeout=30)
7481
if resp.status_code == 429:
7582
logger.warning(
7683
"GDELT rate-limited (attempt %d/%d); sleeping %.0fs",
77-
attempt + 1, _MAX_RETRIES, delay,
84+
attempt + 1, _MAX_RETRIES, retry_delay,
7885
)
79-
time.sleep(delay)
80-
delay *= 3
86+
time.sleep(retry_delay)
87+
retry_delay *= 3
8188
continue
8289
resp.raise_for_status()
8390
return resp.json()

tests/test_agents.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ def test_capped_at_max(self):
656656
"technical_analyst_v2.txt",
657657
"sentiment_analyst_v2.txt",
658658
"fundamental_analyst_v2.txt",
659+
"risk_manager_v2.txt",
659660
"deliberation_v2.txt",
660661
]
661662

tests/test_backtest.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,3 +497,101 @@ def test_stats_sharpe_is_numeric(self):
497497
# ================================================================ imports needed in tests
498498

499499
import asyncio # noqa: E402 (used in test_returns_dict_with_required_keys)
500+
501+
502+
# ================================================================ TestSanitizeStopLevels
503+
504+
class TestSanitizeStopLevels:
505+
"""Tests for src/backtest/signals._sanitize_stop_levels.
506+
507+
This function guards against the risk manager outputting stop-loss prices
508+
that are above the entry price (sl_pct ≥ 1.0) or take-profit prices that
509+
are below the entry price (tp_pct ≤ 1.0) — both of which were observed in
510+
production runs when the model anchored to historical Bitcoin ATH levels
511+
rather than computing downward from the current price.
512+
"""
513+
514+
def _call(self, close, sl_pct, tp_pct, atr_14):
515+
from src.backtest.signals import _sanitize_stop_levels
516+
return _sanitize_stop_levels(close, sl_pct, tp_pct, atr_14)
517+
518+
# --- stop-loss above entry (the observed bug) ---
519+
520+
def test_sl_above_entry_is_replaced(self):
521+
"""sl_pct = 3.17 (stop at 3× entry) must be corrected."""
522+
sl, tp = self._call(close=23_389, sl_pct=3.17, tp_pct=3.57, atr_14=1_500)
523+
assert sl < 1.0, f"sl_pct {sl:.4f} is still ≥ 1.0"
524+
525+
def test_sl_above_entry_uses_atr_fallback(self):
526+
close, atr = 23_389.0, 1_500.0
527+
sl, _ = self._call(close=close, sl_pct=3.17, tp_pct=3.57, atr_14=atr)
528+
expected = (close - 2.0 * atr) / close # 2×ATR stop
529+
assert sl == pytest.approx(expected, rel=1e-6)
530+
531+
def test_sl_at_exactly_one_is_replaced(self):
532+
sl, _ = self._call(close=20_000, sl_pct=1.0, tp_pct=1.10, atr_14=1_000)
533+
assert sl < 1.0
534+
535+
def test_sl_zero_is_replaced(self):
536+
sl, _ = self._call(close=20_000, sl_pct=0.0, tp_pct=1.10, atr_14=1_000)
537+
assert 0.0 < sl < 1.0
538+
539+
def test_sl_negative_is_replaced(self):
540+
sl, _ = self._call(close=20_000, sl_pct=-0.5, tp_pct=1.10, atr_14=1_000)
541+
assert 0.0 < sl < 1.0
542+
543+
# --- stop-loss floor ---
544+
545+
def test_sl_never_drops_below_80pct_of_entry(self):
546+
"""A huge ATR should not produce a stop more than 20% below entry."""
547+
# ATR of 100k would push stop to 20_000 - 200_000 = negative → clamp to MIN_SL_PCT
548+
sl, _ = self._call(close=20_000, sl_pct=3.0, tp_pct=1.10, atr_14=100_000)
549+
assert sl >= 0.80
550+
551+
# --- valid stop passes through unchanged ---
552+
553+
def test_valid_sl_is_unchanged(self):
554+
sl, _ = self._call(close=23_389, sl_pct=0.90, tp_pct=1.10, atr_14=1_500)
555+
assert sl == pytest.approx(0.90)
556+
557+
def test_valid_tp_is_unchanged(self):
558+
_, tp = self._call(close=23_389, sl_pct=0.90, tp_pct=1.15, atr_14=1_500)
559+
assert tp == pytest.approx(1.15)
560+
561+
# --- take-profit below entry ---
562+
563+
def test_tp_below_entry_is_replaced(self):
564+
"""tp_pct = 0.85 (target below entry) must be corrected."""
565+
_, tp = self._call(close=23_389, sl_pct=0.90, tp_pct=0.85, atr_14=1_500)
566+
assert tp > 1.0
567+
568+
def test_tp_at_exactly_one_is_replaced(self):
569+
_, tp = self._call(close=23_389, sl_pct=0.90, tp_pct=1.0, atr_14=1_500)
570+
assert tp > 1.0
571+
572+
def test_tp_uses_atr_fallback(self):
573+
close, atr = 23_389.0, 1_500.0
574+
_, tp = self._call(close=close, sl_pct=0.90, tp_pct=0.80, atr_14=atr)
575+
expected = (close + 3.0 * atr) / close # 3×ATR target
576+
assert tp == pytest.approx(expected, rel=1e-6)
577+
578+
# --- R:R check after sanitization ---
579+
580+
def test_corrected_levels_have_positive_rr(self):
581+
close, atr = 23_389.0, 1_500.0
582+
sl, tp = self._call(close=close, sl_pct=3.17, tp_pct=3.57, atr_14=atr)
583+
# R:R = (tp - 1) / (1 - sl) measured in pct terms
584+
rr = (tp - 1.0) / (1.0 - sl)
585+
assert rr >= 1.5, f"R:R {rr:.2f} below minimum after sanitization"
586+
587+
# --- last-resort fallbacks when no ATR available ---
588+
589+
def test_zero_atr_uses_pct_defaults(self):
590+
sl, tp = self._call(close=23_389, sl_pct=3.0, tp_pct=0.5, atr_14=0.0)
591+
assert 0.0 < sl < 1.0
592+
assert tp > 1.0
593+
594+
def test_zero_close_returns_defaults_without_raising(self):
595+
sl, tp = self._call(close=0.0, sl_pct=0.0, tp_pct=0.0, atr_14=1_500)
596+
assert 0.0 < sl < 1.0
597+
assert tp > 1.0

tests/test_news_historical.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Unit tests for src/data/news_historical.py (GDELT historical news).
33
4-
All HTTP calls are mocked — no network required.
4+
All HTTP calls and time.sleep calls are mocked — no network, no real delays.
55
"""
66

77
import json
@@ -18,6 +18,15 @@
1818
fetch_news_for_date,
1919
)
2020

21+
# Suppress all sleeps in every test in this module
22+
pytestmark = pytest.mark.usefixtures("_no_sleep")
23+
24+
25+
@pytest.fixture(autouse=True)
26+
def _no_sleep():
27+
with patch("src.data.news_historical.time.sleep"):
28+
yield
29+
2130

2231
def _mock_response(articles: list[dict]) -> MagicMock:
2332
resp = MagicMock(spec=httpx.Response)

0 commit comments

Comments
 (0)