Skip to content

Commit ca3c0c9

Browse files
costajohntclaude
andauthored
Improve code quality: exceptions, structured logging, resilience, error policy (#98)
* fix: add custom exception hierarchy, structured logging, and consolidated resilience - Add strategies/exceptions.py with TradingError hierarchy (#78): TradingHaltError (fail-closed), SignalUnavailableError (fail-open), OrderExecutionError, RateLimitError, DataValidationError, StateCorruptionError - Replace print() with structured logging in 6 scripts (#67): screener_trade, momentum_trade, scan_with_sentiment, position_manager, screener, drift_monitor - Wrap EDGAR HTTP calls with @retry_api() decorator (#66): _fetch_edgar_tickers() and _fetch_edgar_facts() in fundamentals.py - Standardize error handling policy (#61): TradingHaltError breaks loops (fail-closed), SignalUnavailableError degrades gracefully (fail-open) - Update all affected tests for new exception types and caplog Closes #61, closes #66, closes #67, closes #78 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent TradingHaltError from being silently swallowed in signal layers The `except Exception` handlers around sentiment and insider calls in all three trade scripts would swallow TradingHaltError from a tripped circuit breaker, allowing trading to continue without safety checks. Add explicit `except TradingHaltError: raise` guards so circuit breaker propagates. Also update RUNBOOK.md to reference TradingHaltError instead of RuntimeError. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update cooldown docstring and move TradingHaltError to top-level import - cooldown.py docstring still referenced SystemExit instead of StateCorruptionError - fundamentals.py had unnecessary local import of TradingHaltError inside function body Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address 8 issues from PR review (consistent circuit breaker handling) - Change TradingHaltError handling from `raise` to `break` for insider and sentiment checks in momentum_trade, screener_trade, scan_with_sentiment (6 sites) — matches the fundamentals pattern so circuit breaker allows graceful loop exit with summary/alerts instead of crashing mid-loop - Fix misaligned continuation indentation in logger.info calls for stop order logging in screener_trade.py and momentum_trade.py (2 sites) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve rebase conflicts (custom exceptions + sanitized errors + caplog) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1ebd330 commit ca3c0c9

20 files changed

Lines changed: 465 additions & 282 deletions

RUNBOOK.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ In `daily_run.sh` (local cron), the `run_step()` function logs failures and incr
194194
### Symptoms
195195

196196
- Telegram/macOS alert: "CIRCUIT BREAKER TRIPPED"
197-
- All API calls raise `RuntimeError("Circuit breaker is tripped ...")`
197+
- All API calls raise `TradingHaltError("Circuit breaker is tripped ...")`
198198
- Log line: `CRITICAL ... CIRCUIT BREAKER TRIPPED: 5 consecutive API failures in 10 minutes`
199199
- Pipeline skips remaining steps with `*** CIRCUIT BREAKER: skipping <step> ***`
200200

scripts/drift_monitor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def main():
131131

132132
drifts = compare_metrics(backtest, live)
133133
report = format_drift_report(drifts)
134-
print(report)
134+
logger.info("%s", report)
135135

136136
if drifts and args.alert:
137137
try:

scripts/momentum_trade.py

Lines changed: 53 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
)
3939
from strategies.cooldown import is_on_cooldown, load_cooldowns
4040
from strategies.enums import Action, FundamentalsRecommendation, InsiderRecommendation
41+
from strategies.exceptions import SignalUnavailableError, TradingHaltError
4142
from strategies.fundamentals import get_fundamentals_signal
4243
from strategies.insider import get_insider_signal, get_upcoming_earnings
4344
from strategies.log import get_logger
@@ -128,32 +129,32 @@ def main():
128129
# Load cooldowns
129130
cooldowns = load_cooldowns()
130131

131-
print("=" * 80)
132-
print(" MOMENTUM AUTO-TRADE")
133-
print(f" Min momentum: {args.min_momentum}% | Max trades: {args.max_trades} | base=${args.amount}/trade")
134-
print(f" Account equity: ${account_equity:,.2f}")
135-
print(f" Mode: {'LIVE EXECUTE' if args.execute else 'DRY RUN'}")
136-
print(f" Market regime: {regime.upper()} "
137-
f"(vol={regime_details.get('realized_vol', 'N/A')}, "
138-
f"SPY={regime_details.get('spy_deviation_pct', 'N/A')}%)")
132+
logger.info("=" * 80)
133+
logger.info(" MOMENTUM AUTO-TRADE")
134+
logger.info(" Min momentum: %s%% | Max trades: %s | base=$%s/trade",
135+
args.min_momentum, args.max_trades, args.amount)
136+
logger.info(" Account equity: $%s", f"{account_equity:,.2f}")
137+
logger.info(" Mode: %s", "LIVE EXECUTE" if args.execute else "DRY RUN")
138+
logger.info(" Market regime: %s (vol=%s, SPY=%s%%)",
139+
regime.upper(),
140+
regime_details.get('realized_vol', 'N/A'),
141+
regime_details.get('spy_deviation_pct', 'N/A'))
139142
if daytrade_count >= 3:
140143
logger.warning("PDT: %d/3 day trades used — new buys cannot be same-day stopped", daytrade_count)
141-
print("=" * 80)
144+
logger.info("=" * 80)
142145

143146
# PDT guard -- halt new entries when at limit (positions opened today cannot be
144147
# protected by same-day stop-loss, which is a safety concern)
145148
if args.execute and daytrade_count >= 3:
146149
logger.warning("HALTED: PDT limit reached (%s/3 day trades used)", daytrade_count)
147150
logger.warning("New positions cannot be same-day stopped — skipping entries for safety.")
148151
return
149-
print()
150152

151153
# 1. Screen the market for momentum
152-
print(" Screening market for momentum...")
154+
logger.info(" Screening market for momentum...")
153155
symbols = get_liquid_symbols()
154156
candidates = screen_for_momentum(symbols, min_momentum=args.min_momentum)
155-
print(f" -> {len(candidates)} candidates found")
156-
print()
157+
logger.info(" Found %d candidates", len(candidates))
157158

158159
# 2. Filter and analyze top candidates
159160
trades_placed = 0
@@ -199,6 +200,9 @@ def _log_vetoed(veto: str, sent=None):
199200
# Insider check (fast, do this first)
200201
try:
201202
insider = get_insider_signal(symbol)
203+
except TradingHaltError:
204+
logger.warning("SKIP %s Circuit breaker tripped — halting insider check", symbol)
205+
break # Circuit breaker — stop processing
202206
except Exception as e:
203207
logger.warning("%s: Insider check failed: %s", symbol, e)
204208
insider = None
@@ -208,44 +212,41 @@ def _log_vetoed(veto: str, sent=None):
208212

209213
# Skip if earnings are imminent
210214
if insider and insider.earnings_in_days is not None and insider.earnings_in_days <= 5:
211-
print(f" SKIP {symbol:6s} ${c.price:>8.2f} ({c.mom_10d:+.1f}% 10d) "
212-
f"— earnings in {insider.earnings_in_days}d")
215+
logger.info(" SKIP %s $%.2f (%+.1f%% 10d) — earnings in %sd",
216+
symbol, c.price, c.mom_10d, insider.earnings_in_days)
213217
_log_vetoed(f"earnings_imminent: {insider.earnings_in_days}d")
214218
continue
215219

216220
# Skip if heavy insider selling
217221
if insider and insider.recommendation == InsiderRecommendation.BEARISH:
218-
print(f" SKIP {symbol:6s} ${c.price:>8.2f} ({c.mom_10d:+.1f}% 10d) "
219-
f"— insider selling")
222+
logger.info(" SKIP %s $%.2f (%+.1f%% 10d) — insider selling",
223+
symbol, c.price, c.mom_10d)
220224
_log_vetoed("insider_bearish")
221225
continue
222226

223227
# Sector guard — prevent overloading correlated positions
224228
allowed, guard_reason = check_sector_guard(symbol, all_positions)
225229
if not allowed:
226-
print(f" SKIP {symbol:6s} ${c.price:>8.2f} ({c.mom_10d:+.1f}% 10d) "
227-
f"— {guard_reason}")
230+
logger.info(" SKIP %s $%.2f (%+.1f%% 10d) — %s",
231+
symbol, c.price, c.mom_10d, guard_reason)
228232
_log_vetoed(f"sector_guard: {guard_reason}")
229233
continue
230234

231235
# Fundamentals screen (veto garbage companies before paying for sentiment API)
232236
try:
233237
fundamentals = get_fundamentals_signal(symbol)
234-
except RuntimeError as e:
235-
if "Circuit breaker" in str(e):
236-
logger.warning("SKIP %s Circuit breaker tripped — halting fundamentals", symbol)
237-
fundamentals = None
238-
continue
239-
raise
240-
except Exception as e:
238+
except TradingHaltError:
239+
logger.warning("SKIP %s Circuit breaker tripped — halting fundamentals", symbol)
240+
break # Circuit breaker — stop processing
241+
except SignalUnavailableError as e:
241242
logger.warning("WARN %s Fundamentals check failed: %s", symbol, e)
242-
fundamentals = None
243+
fundamentals = None # Fail-open: proceed without fundamentals
243244

244245
if fundamentals and fundamentals.recommendation == FundamentalsRecommendation.REJECT:
245-
print(f" SKIP {symbol:6s} ${c.price:>8.2f} ({c.mom_10d:+.1f}% 10d) "
246-
f"— fundamentals reject")
246+
logger.info(" SKIP %s $%.2f (%+.1f%% 10d) — fundamentals reject",
247+
symbol, c.price, c.mom_10d)
247248
if fundamentals.reasoning:
248-
print(f" {fundamentals.reasoning[:70]}")
249+
logger.info(" %s", fundamentals.reasoning[:70])
249250
_log_vetoed("fundamentals_reject")
250251
continue
251252

@@ -266,16 +267,19 @@ def _log_vetoed(veto: str, sent=None):
266267
try:
267268
articles = fetch_news(symbol, days=3, limit=8)
268269
sentiment = analyze_sentiment(symbol, articles)
270+
except TradingHaltError:
271+
logger.warning("SKIP %s Circuit breaker tripped — halting sentiment check", symbol)
272+
break # Circuit breaker — stop processing
269273
except Exception as e:
270274
logger.warning("%s: Sentiment check failed: %s", symbol, e)
271275
sentiment_failed = True
272276

273277
# Skip if sentiment is strongly negative
274278
if sentiment and sentiment.score < -0.3 and sentiment.confidence > 0.6:
275-
print(f" SKIP {symbol:6s} ${c.price:>8.2f} ({c.mom_10d:+.1f}% 10d) "
276-
f"— bad sentiment ({sentiment.score:+.2f})")
279+
logger.info(" SKIP %s $%.2f (%+.1f%% 10d) — bad sentiment (%+.2f)",
280+
symbol, c.price, c.mom_10d, sentiment.score)
277281
if sentiment.reasoning:
278-
print(f" {sentiment.reasoning[:70]}")
282+
logger.info(" %s", sentiment.reasoning[:70])
279283
_log_vetoed("negative_sentiment", sent=sentiment)
280284
continue
281285

@@ -295,7 +299,7 @@ def _log_vetoed(veto: str, sent=None):
295299

296300
# Validate price before using it in calculations
297301
if c.price is None or c.price <= 0:
298-
print(f" SKIP {symbol:6s} invalid price ({c.price!r}) — skipping")
302+
logger.warning(" SKIP %s invalid price (%r) — skipping", symbol, c.price)
299303
_log_vetoed("invalid_price")
300304
continue
301305

@@ -310,8 +314,8 @@ def _log_vetoed(veto: str, sent=None):
310314
)
311315
trade_amount = apply_regime_to_size(trade_amount, regime)
312316
if trade_amount <= 0:
313-
print(f" SKIP {symbol:6s} ${c.price:>8.2f} ({c.mom_10d:+.1f}% 10d) "
314-
f"— regime blocked (size reduced to $0)")
317+
logger.info(" SKIP %s $%.2f (%+.1f%% 10d) — regime blocked (size reduced to $0)",
318+
symbol, c.price, c.mom_10d)
315319
_log_vetoed("regime_blocked")
316320
continue
317321
sizing_info = format_momentum_sizing(
@@ -325,8 +329,8 @@ def _log_vetoed(veto: str, sent=None):
325329
)
326330

327331
if trade_amount < c.price:
328-
print(f" SKIP {symbol:6s} ${c.price:>8.2f} ({c.mom_10d:+.1f}% 10d) "
329-
f"— price exceeds position budget ${trade_amount:.0f}")
332+
logger.info(" SKIP %s $%.2f (%+.1f%% 10d) — price exceeds position budget $%.0f",
333+
symbol, c.price, c.mom_10d, trade_amount)
330334
_log_vetoed("price_exceeds_budget")
331335
continue
332336

@@ -340,12 +344,12 @@ def _log_vetoed(veto: str, sent=None):
340344
if sentiment:
341345
sent_str = f" sent={sentiment.score:+.2f}"
342346

343-
print(f" BUY {symbol:6s} ${c.price:>8.2f} ({c.mom_10d:+.1f}% 10d / {c.mom_50d:+.1f}% 50d) "
344-
f"qty={qty}{sent_str}{insider_str}")
345-
print(f" sizing: {sizing_info}")
347+
logger.info(" BUY %s $%.2f (%+.1f%% 10d / %+.1f%% 50d) qty=%s%s%s",
348+
symbol, c.price, c.mom_10d, c.mom_50d, qty, sent_str, insider_str)
349+
logger.info(" sizing: %s", sizing_info)
346350

347351
if sentiment and sentiment.reasoning:
348-
print(f" {sentiment.reasoning[:70]}")
352+
logger.info(" %s", sentiment.reasoning[:70])
349353

350354
# Execute — market order (position_manager handles all exits: trailing stop, TP, breakeven)
351355
signal_id = str(uuid.uuid4())
@@ -360,12 +364,12 @@ def _log_vetoed(veto: str, sent=None):
360364
time_in_force=TimeInForce.DAY,
361365
)
362366
result = trading_client.submit_order(order)
363-
print(f" ^ EXECUTED: {result.id}")
367+
logger.info(" ^ EXECUTED: %s", result.id)
364368
executed = True
365369
fill_price = float(result.filled_avg_price) if result.filled_avg_price else None
366370
slippage = compute_slippage_bps(fill_price, c.price, side="buy") if fill_price else None
367371
if slippage is not None:
368-
print(f" slippage: {slippage:+.1f} bps")
372+
logger.info(" slippage: %+.1f bps", slippage)
369373
log_trade(symbol, Action.BUY.value, qty, str(result.id), result.status.value,
370374
slippage_bps=slippage, strategy_source="momentum", signal_id=signal_id)
371375
existing.add(symbol)
@@ -380,8 +384,8 @@ def _log_vetoed(veto: str, sent=None):
380384
max_loss_pct = compute_max_loss_pct(atr_pct)
381385
stop_price = c.price * (1 - max_loss_pct / 100)
382386
stop_result = submit_stop_order(trading_client, symbol, qty, stop_price)
383-
print(f" ^ STOP ORDER: {stop_result.id} @ ${stop_price:.2f} "
384-
f"(-{max_loss_pct:.1f}%, ATR={atr_pct:.1f}%)")
387+
logger.info(" ^ STOP ORDER: %s @ $%.2f (-%.1f%%, ATR=%.1f%%)",
388+
stop_result.id, stop_price, max_loss_pct, atr_pct)
385389
except Exception as stop_err:
386390
logger.warning("Stop order failed: %s", stop_err)
387391
logger.warning("Position manager will enforce max-loss on next run.")
@@ -431,11 +435,10 @@ def _log_vetoed(veto: str, sent=None):
431435

432436
if executed or not args.execute:
433437
trades_placed += 1
434-
print()
435438

436-
print("-" * 80)
437-
print(f" Analyzed: {analyzed} | Traded: {trades_placed}/{args.max_trades}")
438-
print("-" * 80)
439+
logger.info("-" * 80)
440+
logger.info(" Analyzed: %d | Traded: %d/%d", analyzed, trades_placed, args.max_trades)
441+
logger.info("-" * 80)
439442

440443
# Send alert if we made trades
441444
if trades_placed > 0 and args.execute:

0 commit comments

Comments
 (0)