Skip to content

Commit 289b273

Browse files
committed
Fix buy-sizing bug and improve AI analysis quality
- Fix critical bug in strategy engine where new position buys used target_value as price estimate, resulting in qty=1 regardless of actual stock price. Now uses current_price from technicals data. - Pass risk parameters (stop loss, take profit, position limits) to Claude prompt so AI can calibrate signals to actual config. - Return and log analysis_summary from Claude for better visibility. - Increase historical data window from 60 to 90 days so SMA_50 has enough data points to be meaningful. - Update README to document cryptocurrency support and the TRADING_CRYPTO_WATCHLIST variable. https://claude.ai/code/session_01YFZGn9hgwhbfYJZGRV75ZN
1 parent 3507c70 commit 289b273

File tree

6 files changed

+79
-27
lines changed

6 files changed

+79
-27
lines changed

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
# Financial Agent
22

3-
AI-powered stock portfolio analyzer and trading agent that runs as a GitHub Action.
3+
AI-powered stock and cryptocurrency portfolio analyzer and trading agent that runs as a GitHub Action.
44

55
## How It Works
66

7-
Every 30 minutes during market hours (Mon-Fri), the agent:
7+
The agent runs every 30 minutes, 24/7. Crypto is analyzed on every run; stocks are only analyzed when the US market is open.
88

9-
1. **Checks** if the market is open
10-
2. **Fetches** your current portfolio from Alpaca
11-
3. **Runs technical analysis** (RSI, MACD, Bollinger Bands, etc.) on your watchlist
9+
1. **Checks** if the stock market is open (crypto always trades)
10+
2. **Fetches** your current portfolio from Alpaca (stocks + crypto)
11+
3. **Runs technical analysis** (RSI, MACD, Bollinger Bands, etc.) on your watchlists
1212
4. **Sends everything to Claude** for AI-powered analysis
1313
5. **Generates trade orders** with position sizing and risk management
1414
6. **Executes trades** (or logs them in dry-run mode)
@@ -39,6 +39,7 @@ Go to **Settings > Secrets and variables > Actions > Variables** and add:
3939
| `ALPACA_BASE_URL` | `https://paper-api.alpaca.markets` | Broker URL. Use `https://api.alpaca.markets` for live trading |
4040
| `ANTHROPIC_MODEL` | `claude-sonnet-4-20250514` | Claude model for analysis |
4141
| `TRADING_WATCHLIST` | `AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,JPM,V,JNJ` | Comma-separated stock symbols |
42+
| `TRADING_CRYPTO_WATCHLIST` | `BTC/USD,ETH/USD,SOL/USD` | Comma-separated crypto pairs (Alpaca format) |
4243
| `TRADING_STRATEGY` | `balanced` | Strategy: `balanced`, `conservative`, `momentum` |
4344
| `TRADING_DRY_RUN` | `true` | Set to `false` to enable real trades |
4445
| `TRADING_MAX_POSITION_PCT` | `0.10` | Max portfolio % for a single position |
@@ -107,8 +108,9 @@ The trading agent runs automatically on schedule. You can also trigger it manual
107108

108109
- **Dry-run mode** is ON by default — no real trades until you explicitly disable it
109110
- **Paper trading** URL is the default broker endpoint
110-
- **Position limits** prevent over-concentration in a single stock
111+
- **Position limits** prevent over-concentration in any single asset
111112
- **Cash reserves** ensure you always maintain a minimum cash buffer
113+
- **Separate asset pipelines** — crypto and stocks are analyzed independently with asset-appropriate risk rules
112114
- **Daily trade limits** prevent runaway execution
113115
- **Environment protection** requires manual approval for the trading environment
114116
- **CI pipeline** runs lint, type checks, tests, and security scans on every PR

src/financial_agent/analysis/ai_analyzer.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,20 @@ def __init__(self, ai_config: AIConfig, trading_config: TradingConfig) -> None:
6363
self._model = ai_config.model
6464
self._max_tokens = ai_config.max_tokens
6565
self._strategy = trading_config.strategy
66+
self._max_position_pct = trading_config.max_position_pct
67+
self._stop_loss_pct = trading_config.stop_loss_pct
68+
self._take_profit_pct = trading_config.take_profit_pct
69+
self._min_cash_reserve_pct = trading_config.min_cash_reserve_pct
6670

6771
def analyze(
6872
self,
6973
portfolio: PortfolioSnapshot,
7074
technicals: dict[str, dict[str, float]],
71-
) -> list[TradeSignal]:
72-
"""Send portfolio + technical data to Claude and parse trade signals."""
75+
) -> tuple[list[TradeSignal], str]:
76+
"""Send portfolio + technical data to Claude and parse trade signals.
77+
78+
Returns a tuple of (signals, analysis_summary).
79+
"""
7380
prompt = self._build_prompt(portfolio, technicals)
7481

7582
log.info("ai_analysis_started", model=self._model, symbols=list(technicals.keys()))
@@ -82,16 +89,17 @@ def analyze(
8289
)
8390

8491
raw_text = response.content[0].text
85-
signals = self._parse_response(raw_text)
92+
signals, analysis_summary = self._parse_response(raw_text)
8693

8794
log.info(
8895
"ai_analysis_complete",
8996
signal_count=len(signals),
9097
buy_count=sum(1 for s in signals if s.signal == SignalType.BUY),
9198
sell_count=sum(1 for s in signals if s.signal == SignalType.SELL),
99+
analysis_summary=analysis_summary,
92100
)
93101

94-
return signals
102+
return signals, analysis_summary
95103

96104
def _build_prompt(
97105
self,
@@ -114,6 +122,12 @@ def _build_prompt(
114122

115123
return f"""## Current Strategy Mode: {self._strategy}
116124
125+
## Risk Parameters
126+
- Max position size: {self._max_position_pct * 100:.0f}% of portfolio
127+
- Stop loss target: {self._stop_loss_pct * 100:.1f}%
128+
- Take profit target: {self._take_profit_pct * 100:.1f}%
129+
- Min cash reserve: {self._min_cash_reserve_pct * 100:.0f}% of portfolio
130+
117131
## Portfolio Overview
118132
- Equity: ${portfolio.equity:,.2f}
119133
- Cash: ${portfolio.cash:,.2f} ({portfolio.cash / portfolio.equity * 100:.1f}% of equity)
@@ -134,8 +148,8 @@ def _build_prompt(
134148
Analyze the above data and provide your trading signals as JSON.
135149
"""
136150

137-
def _parse_response(self, raw: str) -> list[TradeSignal]:
138-
"""Parse Claude's JSON response into TradeSignal objects."""
151+
def _parse_response(self, raw: str) -> tuple[list[TradeSignal], str]:
152+
"""Parse Claude's JSON response into TradeSignal objects and summary."""
139153
# Strip markdown code fences if present
140154
text = raw.strip()
141155
if text.startswith("```"):
@@ -148,7 +162,9 @@ def _parse_response(self, raw: str) -> list[TradeSignal]:
148162
data = json.loads(text)
149163
except json.JSONDecodeError:
150164
log.error("ai_response_parse_error", raw=raw[:500])
151-
return []
165+
return [], ""
166+
167+
analysis_summary = data.get("analysis_summary", "")
152168

153169
signals: list[TradeSignal] = []
154170
for entry in data.get("signals", []):
@@ -170,4 +186,4 @@ def _parse_response(self, raw: str) -> list[TradeSignal]:
170186
except (KeyError, ValueError) as e:
171187
log.warning("skipping_invalid_signal", entry=entry, error=str(e))
172188

173-
return signals
189+
return signals, analysis_summary

src/financial_agent/main.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def main() -> None:
5959

6060
if all_crypto:
6161
log.info("crypto_analysis_started", symbols=all_crypto)
62-
crypto_bars = broker.get_crypto_historical_bars(all_crypto, days=60)
62+
crypto_bars = broker.get_crypto_historical_bars(all_crypto, days=90)
6363
crypto_technicals = technical.compute_indicators(crypto_bars)
6464
technicals.update(crypto_technicals)
6565
log.info("crypto_analysis_complete", analyzed=len(crypto_technicals))
@@ -71,7 +71,7 @@ def main() -> None:
7171
all_stocks = list(set(stock_watchlist + held_stocks))
7272

7373
log.info("stock_analysis_started", symbols=all_stocks)
74-
stock_bars = broker.get_historical_bars(all_stocks, days=60)
74+
stock_bars = broker.get_historical_bars(all_stocks, days=90)
7575
stock_technicals = technical.compute_indicators(stock_bars)
7676
technicals.update(stock_technicals)
7777
log.info("stock_analysis_complete", analyzed=len(stock_technicals))
@@ -83,10 +83,10 @@ def main() -> None:
8383
return
8484

8585
# Step 4: AI analysis (single pass with all technicals)
86-
signals = ai.analyze(portfolio, technicals)
86+
signals, analysis_summary = ai.analyze(portfolio, technicals)
8787

8888
# Step 5: Generate orders
89-
orders = engine.generate_orders(signals, portfolio)
89+
orders = engine.generate_orders(signals, portfolio, technicals)
9090
log.info("orders_generated", count=len(orders))
9191

9292
# Step 6: Execute orders
@@ -100,6 +100,7 @@ def main() -> None:
100100
"equity": portfolio.equity,
101101
"cash": portfolio.cash,
102102
"market_open": market_open,
103+
"analysis_summary": analysis_summary,
103104
"signals": {
104105
"buy": sum(1 for s in signals if s.signal == SignalType.BUY),
105106
"sell": sum(1 for s in signals if s.signal == SignalType.SELL),

src/financial_agent/strategy/engine.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,17 @@ def generate_orders(
2929
self,
3030
signals: list[TradeSignal],
3131
portfolio: PortfolioSnapshot,
32+
technicals: dict[str, dict[str, float]] | None = None,
3233
) -> list[TradeOrder]:
3334
"""Convert trade signals into concrete orders with position sizing and risk checks."""
3435
orders: list[TradeOrder] = []
36+
technicals = technicals or {}
3537

3638
for signal in signals:
3739
if signal.signal == SignalType.HOLD:
3840
continue
3941

40-
order = self._signal_to_order(signal, portfolio)
42+
order = self._signal_to_order(signal, portfolio, technicals)
4143
if order is not None:
4244
orders.append(order)
4345

@@ -57,10 +59,11 @@ def _signal_to_order(
5759
self,
5860
signal: TradeSignal,
5961
portfolio: PortfolioSnapshot,
62+
technicals: dict[str, dict[str, float]],
6063
) -> TradeOrder | None:
6164
"""Convert a single signal to an order with position sizing."""
6265
if signal.signal == SignalType.BUY:
63-
return self._size_buy_order(signal, portfolio)
66+
return self._size_buy_order(signal, portfolio, technicals)
6467
elif signal.signal == SignalType.SELL:
6568
return self._size_sell_order(signal, portfolio)
6669
return None
@@ -69,6 +72,7 @@ def _size_buy_order(
6972
self,
7073
signal: TradeSignal,
7174
portfolio: PortfolioSnapshot,
75+
technicals: dict[str, dict[str, float]],
7276
) -> TradeOrder | None:
7377
"""Size a buy order respecting position limits and cash reserves."""
7478
# Enforce minimum cash reserve
@@ -98,9 +102,16 @@ def _size_buy_order(
98102
if target_value < 1.0:
99103
return None
100104

101-
# Estimate qty (using current price from signal context)
105+
# Get current price: existing position > technicals > skip
102106
current_pos = portfolio.get_position(signal.symbol)
103-
est_price = current_pos.current_price if current_pos else target_value
107+
if current_pos:
108+
est_price = current_pos.current_price
109+
elif signal.symbol in technicals and "current_price" in technicals[signal.symbol]:
110+
est_price = technicals[signal.symbol]["current_price"]
111+
else:
112+
log.warning("skip_buy_no_price", symbol=signal.symbol)
113+
return None
114+
104115
qty = round(target_value / est_price, 2) if est_price > 0 else 0
105116

106117
if qty <= 0:

tests/unit/test_ai_analyzer.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ def test_parse_valid_response(self):
4343
]
4444
}
4545
"""
46-
signals = analyzer._parse_response(raw)
46+
signals, summary = analyzer._parse_response(raw)
4747
assert len(signals) == 2
4848
assert signals[0].symbol == "AAPL"
4949
assert signals[0].signal == SignalType.BUY
5050
assert signals[0].confidence == 0.75
5151
assert signals[1].signal == SignalType.HOLD
52+
assert summary == "Market looks bullish"
5253

5354
def test_parse_code_fenced_response(self):
5455
analyzer = self._make_analyzer()
@@ -60,19 +61,21 @@ def test_parse_code_fenced_response(self):
6061
]
6162
}
6263
```"""
63-
signals = analyzer._parse_response(raw)
64+
signals, summary = analyzer._parse_response(raw)
6465
assert len(signals) == 1
6566
assert signals[0].signal == SignalType.SELL
67+
assert summary == "Test"
6668

6769
def test_parse_invalid_json(self):
6870
analyzer = self._make_analyzer()
69-
signals = analyzer._parse_response("this is not json")
71+
signals, summary = analyzer._parse_response("this is not json")
7072
assert signals == []
73+
assert summary == ""
7174

7275
def test_parse_missing_fields(self):
7376
analyzer = self._make_analyzer()
7477
raw = '{"signals": [{"symbol": "AAPL"}]}'
75-
signals = analyzer._parse_response(raw)
78+
signals, summary = analyzer._parse_response(raw)
7679
assert len(signals) == 0 # Should skip invalid entries
7780

7881
def test_crypto_symbol_gets_crypto_asset_class(self):
@@ -96,7 +99,7 @@ def test_crypto_symbol_gets_crypto_asset_class(self):
9699
]
97100
}
98101
"""
99-
signals = analyzer._parse_response(raw)
102+
signals, summary = analyzer._parse_response(raw)
100103
assert len(signals) == 2
101104
assert signals[0].asset_class == AssetClass.CRYPTO
102105
assert signals[1].asset_class == AssetClass.US_EQUITY

tests/unit/test_strategy_engine.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,26 @@ def test_buy_respects_cash_reserve(self):
124124
engine = StrategyEngine(_make_config(min_cash_reserve_pct=0.50))
125125
# Cash is 20% of equity but reserve is 50%, so no buys allowed
126126
portfolio = _make_portfolio(equity=100000.0, cash=20000.0)
127+
technicals = {"AAPL": {"current_price": 160.0}}
127128
signals = [_make_signal(signal=SignalType.BUY)]
129+
orders = engine.generate_orders(signals, portfolio, technicals)
130+
assert len(orders) == 0
131+
132+
def test_buy_new_position_uses_technicals_price(self):
133+
engine = StrategyEngine(_make_config())
134+
portfolio = _make_portfolio(equity=100000.0, cash=20000.0)
135+
technicals = {"AAPL": {"current_price": 160.0}}
136+
signals = [_make_signal(signal=SignalType.BUY, confidence=0.8)]
137+
orders = engine.generate_orders(signals, portfolio, technicals)
138+
assert len(orders) == 1
139+
# target_value = min(10000*0.8, 10000, 10000) = 8000
140+
# qty = 8000 / 160 = 50.0
141+
assert orders[0].qty == 50.0
142+
143+
def test_buy_new_position_skipped_without_price(self):
144+
engine = StrategyEngine(_make_config())
145+
portfolio = _make_portfolio(equity=100000.0, cash=20000.0)
146+
signals = [_make_signal(signal=SignalType.BUY, confidence=0.8)]
128147
orders = engine.generate_orders(signals, portfolio)
129148
assert len(orders) == 0
130149

0 commit comments

Comments
 (0)