| """ |
| Execution Agent β Trade plan construction. |
| 100% rule-based (zero LLM tokens). |
| Builds complete trade plans with entry, targets, and scaling logic. |
| """ |
| import logging |
| import time |
|
|
| logger = logging.getLogger(__name__) |
|
|
| def _build_summary_html(signal: dict, news_items: list, sentiment_label: str) -> str: |
| s_icon = "π’" if sentiment_label == "Bullish" else ("π΄" if sentiment_label == "Bearish" else "βͺ") |
| top_news = news_items[0].get("title", "") if news_items else signal.get("reason", "No recent news") |
| if len(top_news) > 65: |
| top_news = top_news[:62] + "..." |
| return f"<strong>{s_icon} {sentiment_label}</strong><br><span style='font-size:0.75rem;'>{top_news}</span>" |
|
|
| def construct_trade_plan(signal: dict) -> dict: |
| """ |
| Build a complete, actionable trade plan from a signal. |
| |
| Returns a trade plan with: |
| - Entry strategy (limit/market, scaling) |
| - Stop loss (strict) |
| - Multiple targets (T1/T2/T3) with scaling out logic |
| - Time-based exit rules |
| """ |
| entry = signal.get("entry_price", 0) |
| sl = signal.get("stop_loss", 0) |
| t1 = signal.get("target_1", 0) |
| t2 = signal.get("target_2", 0) |
| t3 = signal.get("target_3", 0) |
| quantity = signal.get("quantity", 0) |
| signal_type = signal.get("signal_type", "") |
| horizon = signal.get("horizon_days", 10) |
|
|
| |
| if signal_type in ("volume_breakout", "bb_squeeze_breakout"): |
| entry_type = "market" |
| entry_note = "Enter at market β breakout requires immediate execution" |
| else: |
| entry_type = "limit" |
| entry_note = f"Place limit order at {entry:.2f} or better" |
|
|
| |
| |
| tranche_1_qty = int(quantity * 0.6) or 1 |
| tranche_2_qty = quantity - tranche_1_qty |
| pullback_entry = round(entry * 0.985, 2) |
|
|
| |
| |
| exit_plan = [] |
| remaining = quantity |
| if t1 and remaining > 0: |
| t1_qty = int(quantity * 0.4) or 1 |
| exit_plan.append({"target": "T1", "price": t1, "quantity": t1_qty, "action": "Sell 40%"}) |
| remaining -= t1_qty |
| if t2 and remaining > 0: |
| t2_qty = int(quantity * 0.3) or 1 |
| exit_plan.append({"target": "T2", "price": t2, "quantity": min(t2_qty, remaining), "action": "Sell 30%"}) |
| remaining -= t2_qty |
| if t3 and remaining > 0: |
| exit_plan.append({"target": "T3", "price": t3, "quantity": remaining, "action": "Sell remaining"}) |
|
|
| |
| risk_rules = [ |
| f"Hard stop loss at {sl:.2f} β NO exceptions", |
| f"Trail stop to breakeven after T1 is hit", |
| f"Time exit: Close position after {horizon} trading days if no target hit", |
| "If market regime changes to high_volatility, tighten stop by 50%", |
| ] |
|
|
| |
| fund_data = signal.get("fundamental_data", {}) |
| news_items = signal.get("news_items", []) |
| risk_decision = signal.get("risk_decision", "approved") |
|
|
| |
| tech_narrative = ( |
| f"<div class='narrative-section'>" |
| f"<div class='narrative-heading'><i class='fa-solid fa-chart-line'></i> Technical Analysis</div>" |
| f"<div class='narrative-body'>" |
| f"Pattern: <strong>{signal_type.replace('_', ' ').title()}</strong> detected.<br>" |
| f"Momentum Score: <span class='narrative-metric'>{signal.get('metrics_momentum', 0):.0f}/100</span> | " |
| f"Volume: <span class='narrative-metric'>{signal.get('metrics_volume', 1):.1f}x</span> avg | " |
| f"Confidence: <span class='narrative-metric'>{signal.get('confidence', 0)*100:.0f}%</span><br>" |
| f"Horizon: {horizon} trading days | Regime: {signal.get('regime', 'N/A')}" |
| f"</div></div>" |
| ) |
|
|
| |
| pe_str = f"{fund_data.get('pe_ratio', 0):.1f}" if fund_data.get('pe_ratio') else "N/A" |
| roe_str = f"{fund_data.get('roe', 0)*100:.1f}%" if fund_data.get('roe') else "N/A" |
| rev_growth_str = f"{fund_data.get('revenue_growth', 0)*100:.1f}%" if fund_data.get('revenue_growth') else "N/A" |
| margin_str = f"{fund_data.get('profit_margin', 0)*100:.1f}%" if fund_data.get('profit_margin') else "N/A" |
| rec_str = fund_data.get('recommendation', 'N/A') |
| sector_str = fund_data.get('sector', 'Unknown') |
| fund_score = signal.get('fundamental_score', signal.get('metrics_fundamental', 50)) |
|
|
| fund_narrative = ( |
| f"<div class='narrative-section'>" |
| f"<div class='narrative-heading'><i class='fa-solid fa-building-columns'></i> Fundamental Quality</div>" |
| f"<div class='narrative-body'>" |
| f"Sector: <strong>{sector_str}</strong> | Score: <span class='narrative-metric'>{fund_score:.0f}/100</span><br>" |
| f"P/E: {pe_str} | ROE: {roe_str} | Rev Growth: {rev_growth_str} | Margin: {margin_str}<br>" |
| f"Analyst: <span class='narrative-tag'>{rec_str}</span>" |
| f"</div></div>" |
| ) |
|
|
| |
| sentiment_score = signal.get('sentiment_score', 0) |
| sentiment_label = "Bullish" if sentiment_score > 0.2 else ("Bearish" if sentiment_score < -0.2 else "Neutral") |
| sentiment_class = "positive" if sentiment_score > 0.2 else ("negative" if sentiment_score < -0.2 else "neutral") |
|
|
| news_lines = "" |
| if news_items: |
| for n in news_items[:3]: |
| s_icon = "π’" if n.get("sentiment") == "positive" else ("π΄" if n.get("sentiment") == "negative" else "βͺ") |
| news_lines += f"{s_icon} {n.get('title', '')[:70]} <span class='narrative-source'>({n.get('source', '')})</span><br>" |
| else: |
| news_lines = "No ticker-specific news found in recent feeds.<br>" |
|
|
| news_narrative = ( |
| f"<div class='narrative-section'>" |
| f"<div class='narrative-heading'><i class='fa-solid fa-newspaper'></i> News & Sentiment</div>" |
| f"<div class='narrative-body'>" |
| f"Overall: <span class='narrative-sentiment {sentiment_class}'>{sentiment_label}</span> " |
| f"(score: {sentiment_score:+.2f}) | {signal.get('news_count', 0)} articles matched<br>" |
| f"{news_lines}" |
| f"</div></div>" |
| ) |
|
|
| |
| sl_pct = round(abs(entry - sl) / entry * 100, 2) if entry else 0 |
| risk_emoji = "β
" if risk_decision == "approved" else "β οΈ" |
| risk_label = "Approved" if risk_decision == "approved" else "Reduced Size" |
|
|
| trade_narrative = ( |
| f"<div class='narrative-section'>" |
| f"<div class='narrative-heading'><i class='fa-solid fa-shield-halved'></i> Risk & Trade Plan</div>" |
| f"<div class='narrative-body'>" |
| f"Risk Status: {risk_emoji} <strong>{risk_label}</strong> | " |
| f"Alpha Score: <span class='narrative-metric gold'>{signal.get('alpha_score', 0):.1f}</span><br>" |
| f"Entry: {entry_type.upper()} @ <span class='mono'>{entry:.2f}</span> | " |
| f"SL: <span class='mono negative'>{sl:.2f}</span> (-{sl_pct:.1f}%) | " |
| f"T1: <span class='mono positive'>{t1:.2f}</span> | " |
| f"T2: <span class='mono positive'>{t2:.2f}</span> | " |
| f"T3: <span class='mono positive'>{t3:.2f}</span><br>" |
| f"R:R = <strong>{signal.get('risk_reward', 0):.1f}</strong> | " |
| f"Qty: {quantity} shares | " |
| f"Position: βΉ{signal.get('position_value', 0):,.0f} ({signal.get('position_pct', 0):.1f}% portfolio)" |
| f"</div></div>" |
| ) |
|
|
| full_narrative = tech_narrative + fund_narrative + news_narrative + trade_narrative |
|
|
| trade_plan = { |
| **signal, |
| "trade_plan": { |
| "entry": { |
| "type": entry_type, |
| "price": entry, |
| "note": entry_note, |
| "tranche_1": {"qty": tranche_1_qty, "price": entry, "note": "Initial entry"}, |
| "tranche_2": {"qty": tranche_2_qty, "price": pullback_entry, "note": "Add on pullback"}, |
| }, |
| "stop_loss": { |
| "price": sl, |
| "type": "hard", |
| "pct_from_entry": round(abs(entry - sl) / entry * 100, 2), |
| }, |
| "targets": exit_plan, |
| "risk_rules": risk_rules, |
| "horizon_days": horizon, |
| "max_hold_date": f"{horizon} trading days from entry", |
| }, |
| "status": "pending", |
| "created_at": time.time(), |
| "agent_name": "execution_agent", |
| "narrative": full_narrative, |
| "reason_summary": _build_summary_html(signal, news_items, sentiment_label), |
| } |
|
|
| logger.info( |
| f"Trade plan [{signal['ticker']}]: {entry_type} entry @ {entry:.2f}, " |
| f"SL={sl:.2f}, T1={t1:.2f}, T2={t2:.2f}, qty={quantity}" |
| ) |
| return trade_plan |
|
|
|
|
| def construct_all_plans(signals: list[dict]) -> list[dict]: |
| """Build trade plans for all sized signals.""" |
| plans = [construct_trade_plan(s) for s in signals] |
| logger.info(f"Constructed {len(plans)} trade plans") |
| return plans |
|
|