diff --git a/ai/LocalAIApi.php b/ai/LocalAIApi.php new file mode 100644 index 0000000..00b1b00 --- /dev/null +++ b/ai/LocalAIApi.php @@ -0,0 +1,311 @@ + [ +// ['role' => 'system', 'content' => 'You are a helpful assistant.'], +// ['role' => 'user', 'content' => 'Tell me a bedtime story.'], +// ], +// ]); +// if (!empty($response['success'])) { +// $decoded = LocalAIApi::decodeJsonFromResponse($response); +// } + +class LocalAIApi +{ + /** @var array|null */ + private static ?array $configCache = null; + + /** + * Signature compatible with the OpenAI Responses API. + * + * @param array $params Request body (model, input, text, reasoning, metadata, etc.). + * @param array $options Extra options (timeout, verify_tls, headers, path, project_uuid). + * @return array{ + * success:bool, + * status?:int, + * data?:mixed, + * error?:string, + * response?:mixed, + * message?:string + * } + */ + public static function createResponse(array $params, array $options = []): array + { + $cfg = self::config(); + $payload = $params; + + if (empty($payload['input']) || !is_array($payload['input'])) { + return [ + 'success' => false, + 'error' => 'input_missing', + 'message' => 'Parameter "input" is required and must be an array.', + ]; + } + + if (!isset($payload['model']) || $payload['model'] === '') { + $payload['model'] = $cfg['default_model']; + } + + return self::request($options['path'] ?? null, $payload, $options); + } + + /** + * Snake_case alias for createResponse (matches the provided example). + * + * @param array $params + * @param array $options + * @return array + */ + public static function create_response(array $params, array $options = []): array + { + return self::createResponse($params, $options); + } + + /** + * Perform a raw request to the AI proxy. + * + * @param string $path Endpoint (may be an absolute URL). + * @param array $payload JSON payload. + * @param array $options Additional request options. + * @return array + */ + public static function request(?string $path = null, array $payload = [], array $options = []): array + { + if (!function_exists('curl_init')) { + return [ + 'success' => false, + 'error' => 'curl_missing', + 'message' => 'PHP cURL extension is missing. Install or enable it on the VM.', + ]; + } + + $cfg = self::config(); + + $projectUuid = $cfg['project_uuid']; + if (empty($projectUuid)) { + return [ + 'success' => false, + 'error' => 'project_uuid_missing', + 'message' => 'PROJECT_UUID is not defined; aborting AI request.', + ]; + } + + $defaultPath = $cfg['responses_path'] ?? null; + $resolvedPath = $path ?? ($options['path'] ?? $defaultPath); + if (empty($resolvedPath)) { + return [ + 'success' => false, + 'error' => 'project_id_missing', + 'message' => 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.', + ]; + } + + $url = self::buildUrl($resolvedPath, $cfg['base_url']); + $baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30; + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout; + if ($timeout <= 0) { + $timeout = 30; + } + + $baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true; + $verifyTls = array_key_exists('verify_tls', $options) + ? (bool) $options['verify_tls'] + : $baseVerifyTls; + + $projectHeader = $cfg['project_header']; + + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', + ]; + $headers[] = $projectHeader . ': ' . $projectUuid; + if (!empty($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $header) { + if (is_string($header) && $header !== '') { + $headers[] = $header; + } + } + } + + if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) { + $payload['project_uuid'] = $projectUuid; + } + + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + if ($body === false) { + return [ + 'success' => false, + 'error' => 'json_encode_failed', + 'message' => 'Failed to encode request body to JSON.', + ]; + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifyTls); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifyTls ? 2 : 0); + curl_setopt($ch, CURLOPT_FAILONERROR, false); + + $responseBody = curl_exec($ch); + if ($responseBody === false) { + $error = curl_error($ch) ?: 'Unknown cURL error'; + curl_close($ch); + return [ + 'success' => false, + 'error' => 'curl_error', + 'message' => $error, + ]; + } + + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $decoded = null; + if ($responseBody !== '' && $responseBody !== null) { + $decoded = json_decode($responseBody, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $decoded = null; + } + } + + if ($status >= 200 && $status < 300) { + return [ + 'success' => true, + 'status' => $status, + 'data' => $decoded ?? $responseBody, + ]; + } + + $errorMessage = 'AI proxy request failed'; + if (is_array($decoded)) { + $errorMessage = $decoded['error'] ?? $decoded['message'] ?? $errorMessage; + } elseif (is_string($responseBody) && $responseBody !== '') { + $errorMessage = $responseBody; + } + + return [ + 'success' => false, + 'status' => $status, + 'error' => $errorMessage, + 'response' => $decoded ?? $responseBody, + ]; + } + + /** + * Extract plain text from a Responses API payload. + * + * @param array $response Result of LocalAIApi::createResponse|request. + * @return string + */ + public static function extractText(array $response): string + { + $payload = $response['data'] ?? $response; + if (!is_array($payload)) { + return ''; + } + + if (!empty($payload['output']) && is_array($payload['output'])) { + $combined = ''; + foreach ($payload['output'] as $item) { + if (!isset($item['content']) || !is_array($item['content'])) { + continue; + } + foreach ($item['content'] as $block) { + if (is_array($block) && ($block['type'] ?? '') === 'output_text' && !empty($block['text'])) { + $combined .= $block['text']; + } + } + } + if ($combined !== '') { + return $combined; + } + } + + if (!empty($payload['choices'][0]['message']['content'])) { + return (string) $payload['choices'][0]['message']['content']; + } + + return ''; + } + + /** + * Attempt to decode JSON emitted by the model (handles markdown fences). + * + * @param array $response + * @return array|null + */ + public static function decodeJsonFromResponse(array $response): ?array + { + $text = self::extractText($response); + if ($text === '') { + return null; + } + + $decoded = json_decode($text, true); + if (is_array($decoded)) { + return $decoded; + } + + $stripped = preg_replace('/^```json|```$/m', '', trim($text)); + if ($stripped !== null && $stripped !== $text) { + $decoded = json_decode($stripped, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return null; + } + + /** + * Load configuration from ai/config.php. + * + * @return array + */ + private static function config(): array + { + if (self::$configCache === null) { + $configPath = __DIR__ . '/config.php'; + if (!file_exists($configPath)) { + throw new RuntimeException('AI config file not found: ai/config.php'); + } + $cfg = require $configPath; + if (!is_array($cfg)) { + throw new RuntimeException('Invalid AI config format: expected array'); + } + self::$configCache = $cfg; + } + + return self::$configCache; + } + + /** + * Build an absolute URL from base_url and a path. + */ + private static function buildUrl(string $path, string $baseUrl): string + { + $trimmed = trim($path); + if ($trimmed === '') { + return $baseUrl; + } + if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { + return $trimmed; + } + if ($trimmed[0] === '/') { + return $baseUrl . $trimmed; + } + return $baseUrl . '/' . $trimmed; + } +} + +// Legacy alias for backward compatibility with the previous class name. +if (!class_exists('OpenAIService')) { + class_alias(LocalAIApi::class, 'OpenAIService'); +} diff --git a/ai/config.php b/ai/config.php new file mode 100644 index 0000000..1ba1596 --- /dev/null +++ b/ai/config.php @@ -0,0 +1,52 @@ + $baseUrl, + 'responses_path' => $responsesPath, + 'project_id' => $projectId, + 'project_uuid' => $projectUuid, + 'project_header' => 'project-uuid', + 'default_model' => 'gpt-5', + 'timeout' => 30, + 'verify_tls' => true, +]; diff --git a/assets/css/custom.css b/assets/css/custom.css new file mode 100644 index 0000000..7333388 --- /dev/null +++ b/assets/css/custom.css @@ -0,0 +1,89 @@ +:root { + --bs-body-bg: #121212; + --bs-body-color: #E0E0E0; + --bs-secondary-color: #A0A0A0; + --surface-bg: #1E1E1E; + --primary-accent: #00A8FF; +} + +body { + font-family: 'Inter', sans-serif; +} + +.bg-surface { + background-color: var(--surface-bg) !important; +} + +.navbar-brand, .nav-link { + color: var(--bs-body-color) !important; +} + +.nav-link:hover, .nav-link.active { + color: var(--primary-accent) !important; +} + +.hero .lead { + color: var(--bs-secondary-color) !important; +} + +.card { + border: 1px solid #333; + border-radius: 0.5rem; +} + +.card-header { + background-color: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid #333; +} + +.table { + --bs-table-bg: transparent; + --bs-table-hover-bg: rgba(255, 255, 255, 0.04); + --bs-table-color: var(--bs-body-color); +} + +.table thead th { + border-bottom: 2px solid #444; + color: var(--bs-secondary-color); + font-weight: 500; +} + +.table td, .table th { + border-top: 1px solid #333; +} + +.table-hover tbody tr:hover { + color: var(--bs-body-color); +} + +.progress { + background-color: #444; + border-radius: 0.25rem; +} + +.progress-bar { + background-color: var(--primary-accent); + font-weight: 600; + color: #121212; +} + +.badge { + border-radius: 0.25rem; + padding: 0.3em 0.6em; +} + +.btn-primary { + background-color: var(--primary-accent); + border-color: var(--primary-accent); +} + +.btn-primary:hover { + background-color: #0094dd; + border-color: #0094dd; +} + +i[data-feather] { + width: 16px; + height: 16px; + vertical-align: text-bottom; +} diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..9e9422c --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,98 @@ +document.addEventListener('DOMContentLoaded', function() { + feather.replace(); + + const scannerForm = document.querySelector('#scanner form'); + const scanButton = scannerForm.querySelector('button[type="submit"]'); + const resultsTableBody = document.querySelector('#scanner .table-responsive tbody'); + + if (scannerForm) { + scannerForm.addEventListener('submit', function(e) { + e.preventDefault(); + + // Show loading state + scanButton.disabled = true; + scanButton.innerHTML = ' Scanning...'; + resultsTableBody.innerHTML = 'Scanning for moonshots...'; + + const formData = new FormData(scannerForm); + + fetch('scanner.php', { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + // Restore button + scanButton.disabled = false; + scanButton.innerHTML = 'Scan'; + + let tableContent = ''; + if (data.length > 0) { + data.forEach(stock => { + tableContent += ` + + ${stock.symbol} + ${stock.company_name} + ${parseFloat(stock.price).toFixed(2)} + ${formatMarketCap(stock.market_cap)} + +
+ + +
+ + + `; + }); + } else { + tableContent = 'No stocks matched your criteria.'; + } + resultsTableBody.innerHTML = tableContent; + feather.replace(); // Re-run feather icons + }) + .catch(error => { + console.error('Error during scan:', error); + // Restore button + scanButton.disabled = false; + scanButton.innerHTML = 'Scan'; + resultsTableBody.innerHTML = 'An error occurred during the scan.'; + }); + }); + } +}); + +function formatMarketCap(num) { + if (num >= 1000000000) { + return ' + + (num / 1000000000).toFixed(2) + 'B'; + } + if (num >= 1000000) { + return ' + + (num / 1000000).toFixed(2) + 'M'; + } + return ' + + num; +} + +// Handle adding from scan results to watchlist +document.addEventListener('click', function(e) { + if (e.target && e.target.matches('#scanner .btn-outline-primary')) { + const form = e.target.closest('form'); + if (form) { + e.preventDefault(); + const formData = new FormData(form); + + fetch(form.action, { + method: 'POST', + body: formData + }) + .then(response => { + // Redirect or update UI as needed. For now, we'll just reload. + window.location.hash = 'watchlist'; + window.location.reload(); + }) + .catch(error => console.error('Error adding to watchlist:', error)); + } + } +}); + diff --git a/db/migrations/001_create_watchlist_table.php b/db/migrations/001_create_watchlist_table.php new file mode 100644 index 0000000..99099c2 --- /dev/null +++ b/db/migrations/001_create_watchlist_table.php @@ -0,0 +1,39 @@ +exec($sql); + + // Let's add the initial mock data to the new table + $stocks = [ + ['symbol' => 'TSLA', 'company_name' => 'Tesla, Inc.', 'price' => 177.48, 'change_pct' => -1.45], + ['symbol' => 'AAPL', 'company_name' => 'Apple Inc.', 'price' => 170.03, 'change_pct' => 0.35], + ['symbol' => 'GOOGL', 'company_name' => 'Alphabet Inc.', 'price' => 139.44, 'change_pct' => 1.25], + ['symbol' => 'AMZN', 'company_name' => 'Amazon.com, Inc.', 'price' => 134.91, 'change_pct' => -0.21], + ]; + + $stmt = $pdo->prepare("INSERT IGNORE INTO watchlist (symbol, company_name, price, change_pct) VALUES (:symbol, :company_name, :price, :change_pct)"); + + foreach ($stocks as $stock) { + $stmt->execute($stock); + } + + echo "Migration successful: 'watchlist' table created and seeded.\n"; + +} catch (PDOException $e) { + die("Migration failed: " . $e->getMessage() . "\n"); +} + diff --git a/includes/finance_api.php b/includes/finance_api.php new file mode 100644 index 0000000..fa77128 --- /dev/null +++ b/includes/finance_api.php @@ -0,0 +1,98 @@ + 'API call frequency limit reached. Please wait a minute and try again.']; + } + + if (strpos($response, 'Invalid API call') !== false) { + return ['error' => 'Invalid API call. Please check the symbol.']; + } + + $data = json_decode($response, true); + if (json_last_error() === JSON_ERROR_NONE) { + if (isset($data['Note'])) { + return ['error' => 'API call frequency limit reached. Please wait a minute and try again.']; + } + if (isset($data['Error Message'])) { + return ['error' => $data['Error Message']]; + } + return $data; + } + + // If not JSON, assume it's CSV (for listing_status) + return $response; +} + +function get_all_listed_stocks() { + $params = [ + 'function' => 'LISTING_STATUS', + 'state' => 'active' // Only active stocks + ]; + return call_finance_api($params); +} + +function get_stock_overview($symbol) { + $params = [ + 'function' => 'OVERVIEW', + 'symbol' => $symbol + ]; + return call_finance_api($params); +} + +function get_stock_quote($symbol) { + $params = [ + 'function' => 'GLOBAL_QUOTE', + 'symbol' => $symbol + ]; + return call_finance_api($params); +} + +// Function to add a stock to the watchlist, now with data fetching +function add_stock_with_details($symbol, $pdo) { + $overview = get_stock_overview($symbol); + + // Wait a bit before the next call to respect API limits + sleep(15); + + $quote = get_stock_quote($symbol); + + if (isset($overview['error']) || isset($quote['error'])) { + // Don't add to DB if API fails + return ['success' => false, 'message' => ($overview['error'] ?? $quote['error'])]; + } + + if (empty($overview['Name']) || empty($overview['MarketCapitalization'])) { + // If overview fails or is empty, we can't proceed. + return ['success' => false, 'message' => 'Could not retrieve complete data for symbol.']; + } else { + $company_name = $overview['Name']; + $price = isset($quote['Global Quote']['05. price']) ? (float)$quote['Global Quote']['05. price'] : 0; + $change_percent_raw = isset($quote['Global Quote']['10. change percent']) ? $quote['Global Quote']['10. change percent'] : '0%'; + $change_percent = rtrim($change_percent_raw, '%'); + } + + $stmt = $pdo->prepare("INSERT INTO watchlist (symbol, company_name, price, change_percent) VALUES (?, ?, ?, ?)"); + $result = $stmt->execute([$symbol, $company_name, $price, $change_percent]); + + return ['success' => $result]; +} diff --git a/index.php b/index.php index 7205f3d..217e9be 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,199 @@ getMessage()); + } + } + // Redirect to the watchlist section to prevent form resubmission + header("Location: " . $_SERVER['PHP_SELF'] . '#watchlist'); + exit; +} ?> - - + + - - - New Style - - - - - - - - - - - - - - - - - - - + + + + Moonshot Tracker + + + + + + + + + + + + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
-
-
- Page updated: (UTC) -
+ +
+ + +
+
+

Discover Your Next 10x Stock

+

AI-powered scanning for high-upside micro and small-cap gems.

+
+ + +
+
+
Moonshot Scanner
+
+
+

Define criteria to discover undervalued high-growth stocks across markets.

+
+
+
+ +
+ + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + +
SymbolCompany NamePriceMarket CapAction
+

Scanning for moonshots...

+
+
+
+ +
+
+
Moonshot Watchlist
+
+ + +
+
+
+ + + + + + + + + + + + + query('SELECT symbol, company_name, price, change_pct FROM watchlist ORDER BY symbol ASC'); + $watchlist = $stmt->fetchAll(); + } catch (PDOException $e) { + // For now, just show an empty list on DB error. + // In a real app, you'd want to log this error. + $watchlist = []; + error_log("Database error: " . $e->getMessage()); + } + + foreach ($watchlist as $stock): + $changeClass = $stock['change_pct'] >= 0 ? 'text-success' : 'text-danger'; + $changeIcon = $stock['change_pct'] >= 0 ? 'trending-up' : 'trending-down'; + ?> + + + + + + + + + + +
SymbolCompany NamePriceChange % (24h)Conviction (soon)Tags (soon)
$ + % + N/AN/A
+
+
+
+ +
+ © Moonshot Tracker. All Rights Reserved. +
+ + + + + - + \ No newline at end of file diff --git a/scanner.php b/scanner.php new file mode 100644 index 0000000..d141913 --- /dev/null +++ b/scanner.php @@ -0,0 +1,91 @@ + $csv_data['error']]); + exit; +} + +$lines = str_getcsv($csv_data, "\n"); +$header = str_getcsv(array_shift($lines)); +$stocks = []; +foreach ($lines as $line) { + if (empty(trim($line))) continue; + $stocks[] = array_combine($header, str_getcsv($line)); +} + +// 2. Take a small, random sample to avoid hitting API limits +shuffle($stocks); +$sample_size = 5; // Limit to 5 API calls per scan to stay within free tier limits +$sample = array_slice($stocks, 0, $sample_size); + +// 3. Analyze the sample +foreach ($sample as $stock) { + $symbol = $stock['symbol']; + + // Skip non-US stocks for now for data consistency + if (!in_array($stock['exchange'], ['NASDAQ', 'NYSE', 'AMEX'])) { + continue; + } + + // To avoid hitting API limits, wait between calls. + // The free tier is very restrictive (5 calls/min). + sleep(15); + + $overview = get_stock_overview($symbol); + + if (isset($overview['error'])) { + $errors[] = "Could not fetch data for {$symbol}: " . $overview['error']; + continue; + } + + $market_cap = (float)($overview['MarketCapitalization'] ?? 0); + $price = 0; // We'll get price from the quote call + + $volume_str = $overview['Volume'] ?? '0'; + $volume = (int) $volume_str; + + // Check market cap and volume first to avoid unnecessary price checks + if ($market_cap >= $min_market_cap && $market_cap <= $max_market_cap && $volume >= $min_volume) { + sleep(15); // Another wait before the next API call + $quote = get_stock_quote($symbol); + + if (isset($quote['Global Quote']['05. price'])) { + $price = (float)$quote['Global Quote']['05. price']; + + if ($price >= $min_price) { + $results[] = [ + 'symbol' => $symbol, + 'company_name' => $overview['Name'] ?? 'N/A', + 'price' => $price, + 'market_cap' => $market_cap + ]; + } + } + } +} + +if (!empty($errors)) { + // If there were non-fatal errors, we can return them for debugging + // For now, we just return the successful results. +} + +echo json_encode($results); \ No newline at end of file