File size: 9,164 Bytes
645673f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
"""Intraday Catalyst Polling Daemon for PrimoGreedy.

Runs as a systemd timer on the VPS (every 15 minutes during market hours,
9:00-16:30 EST on weekdays).  When a trigger fires, dispatches a GitHub
Actions ``repository_dispatch`` event to run the pipeline for the triggered
ticker.

Trigger conditions (any one fires):
    1. Volume > 3x average daily volume for a seed ticker
    2. New Form 4 insider purchase > $50K for a tracked ticker
    3. Price move > 10% intraday for a seed ticker

Usage:
    python catalyst_poll.py

Environment variables:
    FINNHUB_API_KEY     β€” Finnhub REST API key
    GITHUB_TOKEN        β€” GitHub PAT with repo scope
    GITHUB_REPO         β€” e.g. "CiscoPonce/primogreedy"
    VPS_API_URL         β€” PrimoGreedy data API URL
    VPS_API_KEY         β€” Data API key
"""

import os
import sys
import time
import logging
from datetime import datetime, timezone

import requests
from dotenv import load_dotenv

load_dotenv()

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [catalyst_poll] %(levelname)s: %(message)s",
)
logger = logging.getLogger("catalyst_poll")

FINNHUB_API_KEY = os.getenv("FINNHUB_API_KEY", "")
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "")
GITHUB_REPO = os.getenv("GITHUB_REPO", "CiscoPonce/primogreedy")
VPS_API_URL = os.getenv("VPS_API_URL", "").rstrip("/")
VPS_API_KEY = os.getenv("VPS_API_KEY", "")

VOLUME_MULTIPLIER = 3.0
INSIDER_MIN_VALUE = 50_000
PRICE_MOVE_PCT = 10.0

SEED_TICKERS = [
    "HNRA", "TLSS", "MNTS", "BMTX", "KORE", "RVSN", "DRUG", "HYSR",
    "AAON", "CASS", "GIII", "PATK", "MGRC", "LCNB", "CHCO",
]


def _finnhub_get(path: str, params: dict) -> dict:
    """Wrapper for Finnhub REST calls."""
    params["token"] = FINNHUB_API_KEY
    try:
        resp = requests.get(
            f"https://finnhub.io/api/v1/{path}",
            params=params,
            timeout=10,
        )
        resp.raise_for_status()
        return resp.json()
    except Exception as exc:
        logger.warning("Finnhub %s error: %s", path, exc)
        return {}


def check_unusual_volume() -> list[str]:
    """Check seed tickers for unusual volume (>3x average)."""
    triggered = []
    for ticker in SEED_TICKERS:
        try:
            quote = _finnhub_get("quote", {"symbol": ticker})
            if not quote or not quote.get("v"):
                continue

            profile = _finnhub_get("stock/metric", {
                "symbol": ticker,
                "metric": "all",
            })
            metrics = profile.get("metric", {})
            avg_vol = metrics.get("10DayAverageTradingVolume", 0)

            if avg_vol and avg_vol > 0:
                avg_vol_shares = avg_vol * 1_000_000
                current_vol = quote.get("v", 0)
                if current_vol > avg_vol_shares * VOLUME_MULTIPLIER:
                    logger.info(
                        "VOLUME TRIGGER: %s β€” current %s vs avg %s (%.1fx)",
                        ticker, f"{current_vol:,.0f}", f"{avg_vol_shares:,.0f}",
                        current_vol / avg_vol_shares,
                    )
                    triggered.append(ticker)
        except Exception as exc:
            logger.warning("Volume check error for %s: %s", ticker, exc)

    return triggered


def check_price_moves() -> list[str]:
    """Check seed tickers for >10% intraday price moves."""
    triggered = []
    for ticker in SEED_TICKERS:
        try:
            quote = _finnhub_get("quote", {"symbol": ticker})
            if not quote:
                continue

            prev_close = quote.get("pc", 0)
            current = quote.get("c", 0)

            if prev_close > 0 and current > 0:
                pct_change = abs((current - prev_close) / prev_close) * 100
                if pct_change >= PRICE_MOVE_PCT:
                    direction = "UP" if current > prev_close else "DOWN"
                    logger.info(
                        "PRICE TRIGGER: %s β€” %s %.1f%% ($%.2f -> $%.2f)",
                        ticker, direction, pct_change, prev_close, current,
                    )
                    triggered.append(ticker)
        except Exception as exc:
            logger.warning("Price check error for %s: %s", ticker, exc)

    return triggered


def check_insider_filings() -> list[str]:
    """Check SEC EDGAR for new Form 4 insider purchases > $50K."""
    triggered = []
    try:
        headers = {
            "User-Agent": "PrimoGreedy/1.0 (contact@primogreedy.com)",
            "Accept": "application/json",
        }
        resp = requests.get(
            "https://efts.sec.gov/LATEST/search-index",
            params={
                "q": '"acquired" AND "Form 4"',
                "dateRange": "custom",
                "startdt": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
                "enddt": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
                "forms": "4",
            },
            headers=headers,
            timeout=10,
        )
        if resp.status_code != 200:
            logger.info("SEC EDGAR returned %d, skipping insider check", resp.status_code)
            return []

        data = resp.json()
        hits = data.get("hits", {}).get("hits", [])[:30]

        for hit in hits:
            src = hit.get("_source", {})
            tickers = src.get("tickers", [])
            if tickers:
                ticker = tickers[0].upper()
                if ticker in SEED_TICKERS:
                    logger.info("INSIDER TRIGGER: Form 4 filing for %s", ticker)
                    triggered.append(ticker)

    except Exception as exc:
        logger.warning("SEC insider check error: %s", exc)

    return triggered


def dispatch_github_workflow(ticker: str) -> bool:
    """Fire a GitHub Actions repository_dispatch event for a specific ticker."""
    if not GITHUB_TOKEN:
        logger.warning("GITHUB_TOKEN not set β€” cannot dispatch")
        return False

    url = f"https://api.github.com/repos/{GITHUB_REPO}/dispatches"
    headers = {
        "Authorization": f"Bearer {GITHUB_TOKEN}",
        "Accept": "application/vnd.github.v3+json",
    }
    payload = {
        "event_type": "catalyst-alert",
        "client_payload": {
            "ticker": ticker,
            "triggered_at": datetime.now(timezone.utc).isoformat(),
        },
    }

    try:
        resp = requests.post(url, json=payload, headers=headers, timeout=10)
        if resp.status_code == 204:
            logger.info("Dispatched catalyst alert for %s", ticker)
            return True
        logger.warning("GitHub dispatch failed (%d): %s", resp.status_code, resp.text)
        return False
    except Exception as exc:
        logger.error("GitHub dispatch error: %s", exc)
        return False


def log_trigger_to_vps(ticker: str, trigger_type: str) -> None:
    """Log the catalyst trigger to the VPS agent_runs table."""
    if not VPS_API_URL:
        return
    try:
        import uuid
        requests.post(
            f"{VPS_API_URL}/runs",
            headers={"X-API-Key": VPS_API_KEY, "Content-Type": "application/json"},
            json={
                "id": str(uuid.uuid4()),
                "ticker": ticker,
                "status": f"CATALYST:{trigger_type}",
                "model": "catalyst_poll",
                "latency_ms": 0,
                "region": "USA",
            },
            timeout=5,
        )
    except Exception as exc:
        logger.warning("VPS log error: %s", exc)


def is_market_hours() -> bool:
    """Check if we're within US market hours (9:00-16:30 EST, weekdays)."""
    from datetime import timezone, timedelta
    est = timezone(timedelta(hours=-5))
    now = datetime.now(est)

    if now.weekday() >= 5:
        return False

    market_open = now.replace(hour=9, minute=0, second=0, microsecond=0)
    market_close = now.replace(hour=16, minute=30, second=0, microsecond=0)
    return market_open <= now <= market_close


def main():
    """Run one polling cycle: check all triggers, dispatch if needed."""
    if not is_market_hours():
        logger.info("Outside market hours β€” skipping")
        return

    logger.info("Starting catalyst poll cycle...")

    all_triggered = set()

    volume_triggers = check_unusual_volume()
    all_triggered.update(volume_triggers)

    price_triggers = check_price_moves()
    all_triggered.update(price_triggers)

    insider_triggers = check_insider_filings()
    all_triggered.update(insider_triggers)

    if not all_triggered:
        logger.info("No catalysts detected this cycle")
        return

    logger.info("Catalysts detected for: %s", ", ".join(all_triggered))

    for ticker in all_triggered:
        trigger_type = []
        if ticker in volume_triggers:
            trigger_type.append("VOLUME")
        if ticker in price_triggers:
            trigger_type.append("PRICE")
        if ticker in insider_triggers:
            trigger_type.append("INSIDER")

        dispatch_github_workflow(ticker)
        log_trigger_to_vps(ticker, "+".join(trigger_type))

    logger.info("Catalyst poll cycle complete β€” %d triggers fired", len(all_triggered))


if __name__ == "__main__":
    main()