Skip to content

Commit bb8d486

Browse files
costajohntclaude
andcommitted
Fix 5 bugs, add execution script tests, remove dead dependency
Bug fixes: - portfolio.py: handle null current_price (market halt/delisted) - portfolio.py: make starting_cash configurable via STARTING_CASH env var - position_manager.py: abort sell if stop cancellation fails (prevent oversell) - pyproject.toml: remove unused anthropic dependency - Remove stale .bak migration files from git Test coverage: - test_screener_trade.py: 10 tests (pause, risk, crisis, PDT, filters, dry run) - test_momentum_trade.py: 8 tests (pause, risk, crisis, PDT, filters, dry run) - test_scan_with_sentiment.py: 19 tests (composite_signal logic + main guards) - test_portfolio.py: 6 tests (null price, STARTING_CASH, P&L, no positions) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d258d5e commit bb8d486

12 files changed

Lines changed: 808 additions & 38 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ OPENROUTER_API_KEY=your_openrouter_key_here
1616
# Optional — enables insider sentiment + earnings calendar signals
1717
FINNHUB_API_KEY=your_finnhub_key_here
1818

19+
# Portfolio dashboard starting cash (default: 100000)
20+
# Update this if you reset or fund your paper account with a different amount
21+
# STARTING_CASH=100000
22+
1923
# Telegram notifications (optional)
2024
# TELEGRAM_BOT_TOKEN=your_bot_token_here
2125
# TELEGRAM_CHAT_ID=your_chat_id_here

data/cooldowns.json.bak

Lines changed: 0 additions & 1 deletion
This file was deleted.

data/high_water_marks.json.bak

Lines changed: 0 additions & 1 deletion
This file was deleted.

data/partial_profits.json.bak

Lines changed: 0 additions & 1 deletion
This file was deleted.

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ readme = "README.md"
66
requires-python = ">=3.14"
77
dependencies = [
88
"alpaca-py>=0.43.2",
9-
"anthropic>=0.83.0",
109
"finnhub-python>=2.4.27",
1110
"flask>=3.1.2",
1211
"numpy>=2.4.2",

scripts/portfolio.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ def main():
1313
client = get_trading_client()
1414
account = client.get_account()
1515

16-
starting_cash = 100_000.00 # IMPORTANT: Update this if you reset the paper account
16+
import os
17+
starting_cash = float(os.environ.get("STARTING_CASH", "100000"))
1718
portfolio_value = float(account.portfolio_value)
1819
total_pnl = portfolio_value - starting_cash
1920
total_pnl_pct = (total_pnl / starting_cash) * 100
@@ -39,12 +40,14 @@ def main():
3940

4041
total_unrealized = 0
4142
for p in sorted(positions, key=lambda x: x.symbol):
43+
if p.current_price is None:
44+
print(f" {p.symbol:<8} ** price unavailable (market halt / delisted?) **")
45+
continue
4246
qty = float(p.qty)
4347
entry = float(p.avg_entry_price)
4448
current = float(p.current_price)
4549
pnl = float(p.unrealized_pl)
4650
pnl_pct = float(p.unrealized_plpc) * 100
47-
market_val = float(p.market_value)
4851
total_unrealized += pnl
4952

5053
print(f" {p.symbol:<8} {qty:>6.1f} ${entry:>9.2f} ${current:>9.2f} "

scripts/position_manager.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,8 +416,17 @@ def main():
416416
if n_cancelled:
417417
print(f" ^ Cancelled {n_cancelled} open order(s) for {symbol}")
418418
except Exception as cancel_err:
419-
print(f" ^ WARNING: Failed to cancel open orders: {cancel_err}")
420-
print(f" ^ Proceeding with sell — risk of double execution")
419+
print(f" ^ CRITICAL: Failed to cancel open orders: {cancel_err}")
420+
print(f" ^ Aborting sell to prevent oversell (stop may still be live)")
421+
try:
422+
send_alert(
423+
f"SELL ABORTED: {symbol}",
424+
f"Could not cancel open orders for {symbol}: {cancel_err}\n"
425+
f"Sell aborted to prevent double execution. Manual intervention needed.",
426+
)
427+
except Exception:
428+
pass
429+
continue
421430

422431
try:
423432
order = MarketOrderRequest(

tests/test_momentum_trade.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Tests for momentum_trade.py — auto-trade execution pipeline guards and vetoes."""
2+
3+
import sys
4+
from types import SimpleNamespace
5+
from unittest.mock import patch, MagicMock
6+
7+
import pytest
8+
9+
from strategies.enums import Action, InsiderRecommendation
10+
11+
12+
def _mock_account(equity=100_000, daytrade_count=0):
13+
return SimpleNamespace(equity=str(equity), daytrade_count=daytrade_count)
14+
15+
16+
def _mock_candidate(symbol="TEST", price=50.0, mom_10d=15.0, mom_50d=25.0, avg_volume=500_000):
17+
return SimpleNamespace(symbol=symbol, price=price, mom_10d=mom_10d, mom_50d=mom_50d, avg_volume=avg_volume)
18+
19+
20+
def _mock_insider(rec=InsiderRecommendation.NEUTRAL, earnings_in_days=None):
21+
return SimpleNamespace(recommendation=rec, earnings_in_days=earnings_in_days, reasoning="")
22+
23+
24+
class TestAgentPause:
25+
def test_paused_exits_early(self, monkeypatch, capsys):
26+
monkeypatch.setattr(sys, "argv", ["momentum_trade.py", "--execute"])
27+
with patch("scripts.momentum_trade.acquire_process_lock", return_value=True), \
28+
patch("scripts.momentum_trade.is_strategy_paused", return_value=True):
29+
from scripts.momentum_trade import main
30+
main()
31+
assert "PAUSED" in capsys.readouterr().out
32+
33+
34+
class TestRiskGuard:
35+
def test_unhealthy_halts(self, monkeypatch, capsys):
36+
monkeypatch.setattr(sys, "argv", ["momentum_trade.py", "--execute", "--skip-sentiment"])
37+
with patch("scripts.momentum_trade.acquire_process_lock", return_value=True), \
38+
patch("scripts.momentum_trade.is_strategy_paused", return_value=False), \
39+
patch("scripts.momentum_trade.check_portfolio_health", return_value=(False, "loss exceeded")):
40+
from scripts.momentum_trade import main
41+
main()
42+
assert "HALTED" in capsys.readouterr().out
43+
44+
45+
class TestCrisisRegime:
46+
def test_crisis_halts(self, monkeypatch, capsys):
47+
monkeypatch.setattr(sys, "argv", ["momentum_trade.py", "--execute", "--skip-sentiment"])
48+
with patch("scripts.momentum_trade.acquire_process_lock", return_value=True), \
49+
patch("scripts.momentum_trade.is_strategy_paused", return_value=False), \
50+
patch("scripts.momentum_trade.check_portfolio_health", return_value=(True, "")), \
51+
patch("scripts.momentum_trade.get_trading_client") as mock_tc, \
52+
patch("scripts.momentum_trade.get_data_client"), \
53+
patch("scripts.momentum_trade.get_market_regime", return_value=("crisis", {"realized_vol": 40})), \
54+
patch("scripts.momentum_trade.send_alert"):
55+
mock_tc.return_value.get_all_positions.return_value = []
56+
mock_tc.return_value.get_account.return_value = _mock_account()
57+
from scripts.momentum_trade import main
58+
main()
59+
assert "CRISIS" in capsys.readouterr().out
60+
61+
62+
class TestPDTGuard:
63+
def test_pdt_limit_halts(self, monkeypatch, capsys):
64+
monkeypatch.setattr(sys, "argv", ["momentum_trade.py", "--execute", "--skip-sentiment"])
65+
with patch("scripts.momentum_trade.acquire_process_lock", return_value=True), \
66+
patch("scripts.momentum_trade.is_strategy_paused", return_value=False), \
67+
patch("scripts.momentum_trade.check_portfolio_health", return_value=(True, "")), \
68+
patch("scripts.momentum_trade.get_trading_client") as mock_tc, \
69+
patch("scripts.momentum_trade.get_data_client"), \
70+
patch("scripts.momentum_trade.get_market_regime", return_value=("normal", {})), \
71+
patch("scripts.momentum_trade.load_cooldowns", return_value={}):
72+
mock_tc.return_value.get_all_positions.return_value = []
73+
mock_tc.return_value.get_account.return_value = _mock_account(daytrade_count=3)
74+
from scripts.momentum_trade import main
75+
main()
76+
assert "PDT limit" in capsys.readouterr().out
77+
78+
79+
class TestCandidateFilters:
80+
def _run_with_candidate(self, monkeypatch, candidate, extra_patches=None):
81+
monkeypatch.setattr(sys, "argv", ["momentum_trade.py", "--execute", "--skip-sentiment"])
82+
patches = {
83+
"scripts.momentum_trade.acquire_process_lock": MagicMock(return_value=True),
84+
"scripts.momentum_trade.is_strategy_paused": MagicMock(return_value=False),
85+
"scripts.momentum_trade.check_portfolio_health": MagicMock(return_value=(True, "")),
86+
"scripts.momentum_trade.get_market_regime": MagicMock(return_value=("normal", {})),
87+
"scripts.momentum_trade.get_liquid_symbols": MagicMock(return_value=[candidate.symbol]),
88+
"scripts.momentum_trade.screen_for_momentum": MagicMock(return_value=[candidate]),
89+
"scripts.momentum_trade.load_cooldowns": MagicMock(return_value={}),
90+
"scripts.momentum_trade.is_on_cooldown": MagicMock(return_value=False),
91+
"scripts.momentum_trade.log_signal": MagicMock(),
92+
"scripts.momentum_trade.log_audit_entry": MagicMock(),
93+
"scripts.momentum_trade.send_alert": MagicMock(),
94+
}
95+
if extra_patches:
96+
patches.update(extra_patches)
97+
98+
mock_tc = MagicMock()
99+
mock_tc.get_all_positions.return_value = []
100+
mock_tc.get_account.return_value = _mock_account()
101+
patches["scripts.momentum_trade.get_trading_client"] = MagicMock(return_value=mock_tc)
102+
patches["scripts.momentum_trade.get_data_client"] = MagicMock()
103+
104+
ctx = {k: patch(k, v) for k, v in patches.items()}
105+
for cm in ctx.values():
106+
cm.start()
107+
try:
108+
from scripts.momentum_trade import main
109+
main()
110+
finally:
111+
for cm in ctx.values():
112+
cm.stop()
113+
114+
def test_existing_position_skipped(self, monkeypatch, capsys):
115+
monkeypatch.setattr(sys, "argv", ["momentum_trade.py", "--execute", "--skip-sentiment"])
116+
candidate = _mock_candidate("HELD")
117+
with patch("scripts.momentum_trade.acquire_process_lock", return_value=True), \
118+
patch("scripts.momentum_trade.is_strategy_paused", return_value=False), \
119+
patch("scripts.momentum_trade.check_portfolio_health", return_value=(True, "")), \
120+
patch("scripts.momentum_trade.get_trading_client") as mock_tc, \
121+
patch("scripts.momentum_trade.get_data_client"), \
122+
patch("scripts.momentum_trade.get_market_regime", return_value=("normal", {})), \
123+
patch("scripts.momentum_trade.get_liquid_symbols", return_value=["HELD"]), \
124+
patch("scripts.momentum_trade.screen_for_momentum", return_value=[candidate]), \
125+
patch("scripts.momentum_trade.load_cooldowns", return_value={}):
126+
mock_tc.return_value.get_all_positions.return_value = [SimpleNamespace(symbol="HELD", market_value=5000)]
127+
mock_tc.return_value.get_account.return_value = _mock_account()
128+
from scripts.momentum_trade import main
129+
main()
130+
assert "Traded: 0" in capsys.readouterr().out
131+
132+
def test_insider_bearish_skipped(self, monkeypatch, capsys):
133+
candidate = _mock_candidate("BAD")
134+
self._run_with_candidate(monkeypatch, candidate, {
135+
"scripts.momentum_trade.get_insider_signal": MagicMock(return_value=_mock_insider(InsiderRecommendation.BEARISH)),
136+
})
137+
assert "insider selling" in capsys.readouterr().out
138+
139+
def test_earnings_imminent_skipped(self, monkeypatch, capsys):
140+
candidate = _mock_candidate("EARN")
141+
self._run_with_candidate(monkeypatch, candidate, {
142+
"scripts.momentum_trade.get_insider_signal": MagicMock(return_value=_mock_insider(earnings_in_days=3)),
143+
})
144+
assert "earnings in 3d" in capsys.readouterr().out
145+
146+
147+
class TestDryRun:
148+
def test_dry_run_no_orders(self, monkeypatch, capsys):
149+
monkeypatch.setattr(sys, "argv", ["momentum_trade.py", "--skip-sentiment"])
150+
candidate = _mock_candidate("DRY")
151+
with patch("scripts.momentum_trade.acquire_process_lock", return_value=True), \
152+
patch("scripts.momentum_trade.is_strategy_paused", return_value=False), \
153+
patch("scripts.momentum_trade.get_data_client"), \
154+
patch("scripts.momentum_trade.get_trading_client") as mock_tc, \
155+
patch("scripts.momentum_trade.get_market_regime", return_value=("normal", {})), \
156+
patch("scripts.momentum_trade.get_liquid_symbols", return_value=["DRY"]), \
157+
patch("scripts.momentum_trade.screen_for_momentum", return_value=[candidate]), \
158+
patch("scripts.momentum_trade.load_cooldowns", return_value={}), \
159+
patch("scripts.momentum_trade.is_on_cooldown", return_value=False), \
160+
patch("scripts.momentum_trade.get_insider_signal", return_value=_mock_insider()), \
161+
patch("scripts.momentum_trade.check_sector_guard", return_value=(True, "")), \
162+
patch("scripts.momentum_trade.calculate_momentum_size", return_value=3000), \
163+
patch("scripts.momentum_trade.apply_regime_to_size", return_value=3000), \
164+
patch("scripts.momentum_trade.format_momentum_sizing", return_value="base=$1000"), \
165+
patch("scripts.momentum_trade.log_signal"), \
166+
patch("scripts.momentum_trade.log_audit_entry"), \
167+
patch("scripts.momentum_trade.send_alert"):
168+
mock_tc.return_value.get_all_positions.return_value = []
169+
mock_tc.return_value.get_account.return_value = _mock_account()
170+
from scripts.momentum_trade import main
171+
main()
172+
mock_tc.return_value.submit_order.assert_not_called()

tests/test_portfolio.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Tests for portfolio.py — null price handling, STARTING_CASH config, P&L calculation."""
2+
3+
from types import SimpleNamespace
4+
from unittest.mock import patch, MagicMock
5+
6+
import pytest
7+
8+
9+
@pytest.fixture(autouse=True)
10+
def _mock_alpaca_imports():
11+
"""Ensure alpaca SDK enums/requests are available even when other tests stub sys.modules."""
12+
with patch("alpaca.trading.enums.QueryOrderStatus", SimpleNamespace(ALL="all")), \
13+
patch("alpaca.trading.requests.GetOrdersRequest", MagicMock()):
14+
yield
15+
16+
17+
def _mock_account(portfolio_value=105_000, cash=50_000, buying_power=100_000, daytrade_count=0):
18+
return SimpleNamespace(
19+
portfolio_value=str(portfolio_value),
20+
cash=str(cash),
21+
buying_power=str(buying_power),
22+
daytrade_count=daytrade_count,
23+
)
24+
25+
26+
def _mock_position(symbol, qty=10, entry=100.0, current=110.0, pnl=100.0, pnl_pct=0.1, market_value=1100.0):
27+
return SimpleNamespace(
28+
symbol=symbol,
29+
qty=str(qty),
30+
avg_entry_price=str(entry),
31+
current_price=str(current) if current is not None else None,
32+
unrealized_pl=str(pnl),
33+
unrealized_plpc=str(pnl_pct),
34+
market_value=str(market_value),
35+
)
36+
37+
38+
class TestNullPriceHandling:
39+
"""Positions with current_price=None should not crash the dashboard."""
40+
41+
@patch("strategies.equity_tracker.format_equity_summary", return_value="")
42+
@patch("strategies.equity_tracker.record_daily_snapshot")
43+
@patch("strategies.state_db.ensure_db")
44+
@patch("scripts.portfolio.get_trading_client")
45+
def test_null_price_prints_warning(self, mock_client, mock_db, mock_record, mock_fmt, capsys):
46+
mock_client.return_value.get_account.return_value = _mock_account()
47+
mock_client.return_value.get_all_positions.return_value = [
48+
_mock_position("HALT", current=None),
49+
]
50+
mock_db.return_value = MagicMock()
51+
mock_client.return_value.get_orders.return_value = []
52+
53+
from scripts.portfolio import main
54+
main()
55+
56+
output = capsys.readouterr().out
57+
assert "HALT" in output
58+
assert "price unavailable" in output
59+
60+
@patch("strategies.equity_tracker.format_equity_summary", return_value="")
61+
@patch("strategies.equity_tracker.record_daily_snapshot")
62+
@patch("strategies.state_db.ensure_db")
63+
@patch("scripts.portfolio.get_trading_client")
64+
def test_normal_position_displays(self, mock_client, mock_db, mock_record, mock_fmt, capsys):
65+
mock_client.return_value.get_account.return_value = _mock_account()
66+
mock_client.return_value.get_all_positions.return_value = [
67+
_mock_position("AAPL", qty=10, entry=150.0, current=160.0, pnl=100.0, pnl_pct=0.0667),
68+
]
69+
mock_db.return_value = MagicMock()
70+
mock_client.return_value.get_orders.return_value = []
71+
72+
from scripts.portfolio import main
73+
main()
74+
75+
output = capsys.readouterr().out
76+
assert "AAPL" in output
77+
assert "160.00" in output
78+
79+
@patch("strategies.equity_tracker.format_equity_summary", return_value="")
80+
@patch("strategies.equity_tracker.record_daily_snapshot")
81+
@patch("strategies.state_db.ensure_db")
82+
@patch("scripts.portfolio.get_trading_client")
83+
def test_mixed_null_and_normal_positions(self, mock_client, mock_db, mock_record, mock_fmt, capsys):
84+
mock_client.return_value.get_account.return_value = _mock_account()
85+
mock_client.return_value.get_all_positions.return_value = [
86+
_mock_position("GOOD", current=50.0),
87+
_mock_position("HALT", current=None),
88+
]
89+
mock_db.return_value = MagicMock()
90+
mock_client.return_value.get_orders.return_value = []
91+
92+
from scripts.portfolio import main
93+
main()
94+
95+
output = capsys.readouterr().out
96+
assert "GOOD" in output
97+
assert "HALT" in output
98+
assert "price unavailable" in output
99+
100+
101+
class TestStartingCash:
102+
"""STARTING_CASH env var controls P&L baseline."""
103+
104+
@patch("strategies.equity_tracker.format_equity_summary", return_value="")
105+
@patch("strategies.equity_tracker.record_daily_snapshot")
106+
@patch("strategies.state_db.ensure_db")
107+
@patch("scripts.portfolio.get_trading_client")
108+
def test_default_starting_cash(self, mock_client, mock_db, mock_record, mock_fmt, monkeypatch, capsys):
109+
monkeypatch.delenv("STARTING_CASH", raising=False)
110+
mock_client.return_value.get_account.return_value = _mock_account(portfolio_value=105_000)
111+
mock_client.return_value.get_all_positions.return_value = []
112+
mock_db.return_value = MagicMock()
113+
mock_client.return_value.get_orders.return_value = []
114+
115+
from scripts.portfolio import main
116+
main()
117+
118+
output = capsys.readouterr().out
119+
# Default $100K, portfolio $105K = +$5,000 P&L
120+
assert "+5,000" in output
121+
122+
@patch("strategies.equity_tracker.format_equity_summary", return_value="")
123+
@patch("strategies.equity_tracker.record_daily_snapshot")
124+
@patch("strategies.state_db.ensure_db")
125+
@patch("scripts.portfolio.get_trading_client")
126+
def test_custom_starting_cash(self, mock_client, mock_db, mock_record, mock_fmt, monkeypatch, capsys):
127+
monkeypatch.setenv("STARTING_CASH", "200000")
128+
mock_client.return_value.get_account.return_value = _mock_account(portfolio_value=210_000)
129+
mock_client.return_value.get_all_positions.return_value = []
130+
mock_db.return_value = MagicMock()
131+
mock_client.return_value.get_orders.return_value = []
132+
133+
from scripts.portfolio import main
134+
main()
135+
136+
output = capsys.readouterr().out
137+
# $200K start, $210K now = +$10,000 P&L
138+
assert "+10,000" in output
139+
140+
141+
class TestNoPositions:
142+
@patch("strategies.equity_tracker.format_equity_summary", return_value="")
143+
@patch("strategies.equity_tracker.record_daily_snapshot")
144+
@patch("strategies.state_db.ensure_db")
145+
@patch("scripts.portfolio.get_trading_client")
146+
def test_no_positions_message(self, mock_client, mock_db, mock_record, mock_fmt, capsys):
147+
mock_client.return_value.get_account.return_value = _mock_account()
148+
mock_client.return_value.get_all_positions.return_value = []
149+
mock_db.return_value = MagicMock()
150+
mock_client.return_value.get_orders.return_value = []
151+
152+
from scripts.portfolio import main
153+
main()
154+
155+
assert "No open positions" in capsys.readouterr().out

0 commit comments

Comments
 (0)