- PLAN.md: full implementation plan from issue - config.py: configurable thresholds, API keys via .env - ingestion/: EDGAR RSS poller + Form 4 XML parser - db/: SQLite schema + interface (WAL mode) - signals/: filter engine (buy/10b5-1/value/role) + cluster detector - alerts/: Slack webhook alert with score gating - broker/: Alpaca paper/live trade execution - backtest/: historical signal backtesting with yfinance - main.py: CLI entrypoint (run | fetch-once | backtest)
65 lines
2.2 KiB
Python
65 lines
2.2 KiB
Python
import logging
|
|
import requests
|
|
|
|
import config
|
|
from db.db import mark_signal_alerted
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def format_signal(signal: dict) -> str:
|
|
filing = signal.get("filing", {})
|
|
ticker = signal["ticker"]
|
|
insider = filing.get("insider_name", "Unknown")
|
|
role = filing.get("role", "Unknown")
|
|
tx_date = filing.get("transaction_date", "")
|
|
shares = filing.get("shares")
|
|
price = filing.get("price")
|
|
total_value = filing.get("total_value") or signal.get("total_cluster_value", 0)
|
|
cluster_size = signal["cluster_size"]
|
|
score = signal["score"]
|
|
is_10b51 = "Yes" if filing.get("is_10b51") else "No"
|
|
accession = filing.get("accession_number", "")
|
|
|
|
shares_str = f"{shares:,.0f}" if shares else "N/A"
|
|
price_str = f"${price:,.2f}" if price else "N/A"
|
|
value_str = f"${total_value:,.0f}" if total_value else "N/A"
|
|
edgar_url = f"https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&type=4&dateb=&owner=include&count=10&search_text=&ticker={ticker}"
|
|
|
|
return (
|
|
f"*INSIDER BUY SIGNAL*\n"
|
|
f"Ticker: ${ticker}\n"
|
|
f"Insider: {insider} ({role})\n"
|
|
f"Date: {tx_date}\n"
|
|
f"Shares: {shares_str} @ {price_str} = {value_str}\n"
|
|
f"Cluster: {cluster_size} insider(s) in last {config.CLUSTER_WINDOW_DAYS} days\n"
|
|
f"Score: {score}\n"
|
|
f"10b5-1: {is_10b51}\n"
|
|
f"EDGAR: {edgar_url}"
|
|
)
|
|
|
|
|
|
def send_slack_alert(signal: dict) -> bool:
|
|
if not config.SLACK_WEBHOOK_URL:
|
|
logger.warning("SLACK_WEBHOOK_URL not configured")
|
|
return False
|
|
|
|
if signal.get("score", 0) < config.SCORE_ALERT_THRESHOLD:
|
|
logger.debug(f"Signal score {signal['score']} below threshold {config.SCORE_ALERT_THRESHOLD}")
|
|
return False
|
|
|
|
text = format_signal(signal)
|
|
try:
|
|
resp = requests.post(
|
|
config.SLACK_WEBHOOK_URL,
|
|
json={"text": text},
|
|
timeout=10,
|
|
)
|
|
resp.raise_for_status()
|
|
mark_signal_alerted(signal["id"])
|
|
logger.info(f"Slack alert sent for {signal['ticker']}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to send Slack alert: {e}")
|
|
return False
|