1444 lines
43 KiB
Python
1444 lines
43 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Feed Hunter Web Portal
|
|
Self-contained dashboard for monitoring the X/Twitter feed intelligence pipeline
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import glob
|
|
from datetime import datetime, timezone
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
from socketserver import ThreadingMixIn
|
|
from urllib.parse import urlparse, parse_qs
|
|
|
|
|
|
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
|
daemon_threads = True
|
|
import re
|
|
|
|
# Configuration
|
|
PORT = 8888
|
|
_PORTAL_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
_PROJECT_DIR = os.path.dirname(_PORTAL_DIR)
|
|
DATA_DIR = os.path.join(_PROJECT_DIR, "data")
|
|
SKILLS_DIR = os.path.join(os.path.dirname(_PROJECT_DIR), "skills", "deep-scraper", "scripts")
|
|
X_FEED_DIR = os.path.join(os.path.dirname(_PROJECT_DIR), "..", "data", "x-feed")
|
|
|
|
class FeedHunterHandler(BaseHTTPRequestHandler):
|
|
|
|
def do_GET(self):
|
|
try:
|
|
parsed_path = urlparse(self.path)
|
|
path = parsed_path.path
|
|
query = parse_qs(parsed_path.query)
|
|
|
|
if path == '/' or path == '/dashboard':
|
|
self.serve_dashboard()
|
|
elif path == '/feed':
|
|
self.serve_feed_view()
|
|
elif path == '/investigations':
|
|
self.serve_investigations()
|
|
elif path == '/simulations':
|
|
self.serve_simulations()
|
|
elif path == '/status':
|
|
self.serve_status()
|
|
elif path == '/api/data':
|
|
self.serve_api_data(query.get('type', [''])[0])
|
|
elif path.startswith('/static/'):
|
|
self.serve_static(path)
|
|
else:
|
|
self.send_error(404)
|
|
except Exception as e:
|
|
try:
|
|
self.send_response(500)
|
|
self.send_header('Content-type', 'text/html')
|
|
self.end_headers()
|
|
self.wfile.write(f"<h1>500 Error</h1><pre>{e}</pre>".encode())
|
|
except:
|
|
pass
|
|
|
|
def serve_dashboard(self):
|
|
"""Main dashboard overview"""
|
|
data = self.get_dashboard_data()
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Feed Hunter Dashboard</title>
|
|
<link rel="stylesheet" href="/static/css/style.css">
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="nav-brand">🖤 Feed Hunter</div>
|
|
<div class="nav-links">
|
|
<a href="/dashboard" class="active">Dashboard</a>
|
|
<a href="/feed">Feed</a>
|
|
<a href="/investigations">Investigations</a>
|
|
<a href="/simulations">Simulations</a>
|
|
<a href="/status">Status</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<div class="cards">
|
|
<div class="card">
|
|
<h3>Active Simulations</h3>
|
|
<div class="metric-large">{data['active_simulations']}</div>
|
|
<div class="metric-small">Total Bankroll: ${data['total_bankroll']:,.0f}</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Recent Scrapes</h3>
|
|
<div class="metric-large">{data['recent_scrapes']}</div>
|
|
<div class="metric-small">Last: {data['last_scrape']}</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Signals Today</h3>
|
|
<div class="metric-large">{data['signals_today']}</div>
|
|
<div class="metric-small">Investigated: {data['investigated_today']}</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>P&L</h3>
|
|
<div class="metric-large ${data['total_pnl_class']}">${data['total_pnl']:,.0f}</div>
|
|
<div class="metric-small">{data['total_pnl_pct']:+.1f}%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>Recent Activity</h3>
|
|
<div class="activity-feed">
|
|
{data['recent_activity']}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Active Positions</h3>
|
|
<div class="positions-summary">
|
|
{data['active_positions']}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/dashboard.js"></script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
self.send_html(html)
|
|
|
|
def serve_feed_view(self):
|
|
"""Latest scraped posts with triage status"""
|
|
posts = self.get_latest_posts()
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Feed Hunter - Latest Feed</title>
|
|
<link rel="stylesheet" href="/static/css/style.css">
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="nav-brand">🖤 Feed Hunter</div>
|
|
<div class="nav-links">
|
|
<a href="/dashboard">Dashboard</a>
|
|
<a href="/feed" class="active">Feed</a>
|
|
<a href="/investigations">Investigations</a>
|
|
<a href="/simulations">Simulations</a>
|
|
<a href="/status">Status</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<h2>Latest Feed ({len(posts)} posts)</h2>
|
|
|
|
<div class="feed-posts">
|
|
{self.render_posts(posts)}
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/feed.js"></script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
self.send_html(html)
|
|
|
|
def serve_investigations(self):
|
|
"""Investigation reports view"""
|
|
investigations = self.get_investigations()
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Feed Hunter - Investigations</title>
|
|
<link rel="stylesheet" href="/static/css/style.css">
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="nav-brand">🖤 Feed Hunter</div>
|
|
<div class="nav-links">
|
|
<a href="/dashboard">Dashboard</a>
|
|
<a href="/feed">Feed</a>
|
|
<a href="/investigations" class="active">Investigations</a>
|
|
<a href="/simulations">Simulations</a>
|
|
<a href="/status">Status</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<h2>Investigation Reports</h2>
|
|
|
|
<div class="investigations">
|
|
{self.render_investigations(investigations)}
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/investigations.js"></script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
self.send_html(html)
|
|
|
|
def serve_simulations(self):
|
|
"""Simulation tracker view"""
|
|
sims = self.get_simulation_data()
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Feed Hunter - Simulations</title>
|
|
<link rel="stylesheet" href="/static/css/style.css">
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="nav-brand">🖤 Feed Hunter</div>
|
|
<div class="nav-links">
|
|
<a href="/dashboard">Dashboard</a>
|
|
<a href="/feed">Feed</a>
|
|
<a href="/investigations">Investigations</a>
|
|
<a href="/simulations" class="active">Simulations</a>
|
|
<a href="/status">Status</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<h2>Paper Trading Simulations</h2>
|
|
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>Active Positions</h3>
|
|
<div class="positions">
|
|
{self.render_active_positions(sims.get('active', {}))}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Performance</h3>
|
|
<div class="performance-metrics">
|
|
{self.render_performance_metrics(sims)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Trade History</h3>
|
|
<div class="trade-history">
|
|
{self.render_trade_history(sims.get('history', {}).get('closed', []) if isinstance(sims.get('history'), dict) else sims.get('history', []))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/simulations.js"></script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
self.send_html(html)
|
|
|
|
def serve_status(self):
|
|
"""Pipeline status view"""
|
|
status = self.get_status_data()
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Feed Hunter - Status</title>
|
|
<link rel="stylesheet" href="/static/css/style.css">
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="nav-brand">🖤 Feed Hunter</div>
|
|
<div class="nav-links">
|
|
<a href="/dashboard">Dashboard</a>
|
|
<a href="/feed">Feed</a>
|
|
<a href="/investigations">Investigations</a>
|
|
<a href="/simulations">Simulations</a>
|
|
<a href="/status" class="active">Status</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<h2>Pipeline Status</h2>
|
|
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>Chrome Debug</h3>
|
|
<div class="status-indicator ${status['chrome']['class']}">
|
|
{status['chrome']['status']}
|
|
</div>
|
|
<div class="status-detail">{status['chrome']['detail']}</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Last Pipeline Run</h3>
|
|
<div class="status-time">{status['last_run']['time']}</div>
|
|
<div class="status-detail">{status['last_run']['detail']}</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Next Scheduled</h3>
|
|
<div class="status-time">{status['next_run']['time']}</div>
|
|
<div class="status-detail">{status['next_run']['detail']}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>System Health</h3>
|
|
<div class="health-checks">
|
|
{self.render_health_checks(status['health'])}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/status.js"></script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
self.send_html(html)
|
|
|
|
def serve_api_data(self, data_type):
|
|
"""JSON API endpoint for real-time data"""
|
|
try:
|
|
if data_type == 'dashboard':
|
|
data = self.get_dashboard_data()
|
|
elif data_type == 'simulations':
|
|
data = self.get_simulation_data()
|
|
elif data_type == 'status':
|
|
data = self.get_status_data()
|
|
else:
|
|
data = {"error": "Unknown data type"}
|
|
|
|
self.send_json(data)
|
|
except Exception as e:
|
|
self.send_json({"error": str(e)})
|
|
|
|
def serve_static(self, path):
|
|
"""Serve static files (CSS/JS)"""
|
|
file_path = path[8:] # Remove /static/
|
|
if file_path == 'css/style.css':
|
|
self.send_css(self.get_css())
|
|
elif file_path == 'js/dashboard.js':
|
|
self.send_js(self.get_dashboard_js())
|
|
elif file_path == 'js/feed.js':
|
|
self.send_js(self.get_feed_js())
|
|
elif file_path == 'js/investigations.js':
|
|
self.send_js(self.get_investigations_js())
|
|
elif file_path == 'js/simulations.js':
|
|
self.send_js(self.get_simulations_js())
|
|
elif file_path == 'js/status.js':
|
|
self.send_js(self.get_status_js())
|
|
else:
|
|
self.send_error(404)
|
|
|
|
# Data loading methods
|
|
|
|
def get_dashboard_data(self):
|
|
"""Collect dashboard overview data"""
|
|
data = {
|
|
'active_simulations': 0,
|
|
'total_bankroll': 0,
|
|
'recent_scrapes': 0,
|
|
'last_scrape': 'None',
|
|
'signals_today': 0,
|
|
'investigated_today': 0,
|
|
'total_pnl': 0,
|
|
'total_pnl_pct': 0,
|
|
'total_pnl_class': 'positive',
|
|
'recent_activity': '<div class="empty-state">No recent activity</div>',
|
|
'active_positions': '<div class="empty-state">No active positions</div>'
|
|
}
|
|
|
|
try:
|
|
# Load active simulations
|
|
active_path = os.path.join(DATA_DIR, "simulations", "active.json")
|
|
if os.path.exists(active_path):
|
|
with open(active_path) as f:
|
|
active = json.load(f)
|
|
positions = active.get('positions', [])
|
|
data['active_simulations'] = len(positions)
|
|
data['total_bankroll'] = active.get('bankroll_used', 0)
|
|
|
|
# Calculate P&L
|
|
total_pnl = sum(pos.get('unrealized_pnl', 0) for pos in positions)
|
|
data['total_pnl'] = total_pnl
|
|
data['total_pnl_class'] = 'positive' if total_pnl >= 0 else 'negative'
|
|
|
|
if data['total_bankroll'] > 0:
|
|
data['total_pnl_pct'] = (total_pnl / data['total_bankroll']) * 100
|
|
|
|
# Render active positions
|
|
if positions:
|
|
pos_html = ""
|
|
for pos in positions:
|
|
pos_html += f"""
|
|
<div class="position-item">
|
|
<div class="position-asset">{pos.get('asset', 'Unknown')[:50]}</div>
|
|
<div class="position-pnl ${data['total_pnl_class']}">${pos.get('unrealized_pnl', 0):,.0f}</div>
|
|
</div>
|
|
"""
|
|
data['active_positions'] = pos_html
|
|
|
|
# Count investigations today
|
|
inv_pattern = os.path.join(DATA_DIR, "investigations", "inv-*.json")
|
|
today = datetime.now().strftime('%Y%m%d')
|
|
today_invs = [f for f in glob.glob(inv_pattern) if today in os.path.basename(f)]
|
|
data['investigated_today'] = len(today_invs)
|
|
|
|
# Recent activity from investigations
|
|
if today_invs:
|
|
activity_html = ""
|
|
for inv_file in today_invs[-5:]: # Last 5
|
|
with open(inv_file) as f:
|
|
inv = json.load(f)
|
|
verdict = inv.get('investigation', {}).get('verdict', 'Unknown')
|
|
author = inv.get('source_post', {}).get('author', 'Unknown')
|
|
activity_html += f"""
|
|
<div class="activity-item">
|
|
<span class="activity-time">Today</span>
|
|
<span class="activity-text">Investigated {author}: {verdict}</span>
|
|
</div>
|
|
"""
|
|
data['recent_activity'] = activity_html
|
|
|
|
except Exception as e:
|
|
print(f"Dashboard data error: {e}")
|
|
|
|
return data
|
|
|
|
def get_latest_posts(self):
|
|
"""Get latest scraped posts with triage data"""
|
|
posts = []
|
|
try:
|
|
# Find latest x-feed directory
|
|
x_feed_pattern = os.path.join(X_FEED_DIR, "20*")
|
|
x_feed_dirs = sorted(glob.glob(x_feed_pattern))
|
|
|
|
if x_feed_dirs:
|
|
latest_dir = x_feed_dirs[-1]
|
|
posts_file = os.path.join(latest_dir, "posts.json")
|
|
triage_file = os.path.join(latest_dir, "triage.json")
|
|
|
|
# Load posts
|
|
if os.path.exists(posts_file):
|
|
with open(posts_file) as f:
|
|
posts_data = json.load(f)
|
|
posts = posts_data.get('posts', [])
|
|
|
|
# Load triage data and merge
|
|
if os.path.exists(triage_file):
|
|
with open(triage_file) as f:
|
|
triage = json.load(f)
|
|
# Merge triage data with posts
|
|
for post in posts:
|
|
post_id = post.get('id')
|
|
for category, triaged_posts in triage.items():
|
|
if category == 'investigation_queue':
|
|
continue
|
|
for tpost in triaged_posts:
|
|
if tpost.get('id') == post_id:
|
|
post['triage'] = {
|
|
'category': category,
|
|
'priority': tpost.get('priority'),
|
|
'reason': tpost.get('reason')
|
|
}
|
|
break
|
|
|
|
except Exception as e:
|
|
print(f"Posts loading error: {e}")
|
|
|
|
return posts[:50] # Limit to last 50
|
|
|
|
def get_investigations(self):
|
|
"""Get all investigation reports"""
|
|
investigations = []
|
|
try:
|
|
inv_pattern = os.path.join(DATA_DIR, "investigations", "inv-*.json")
|
|
for inv_file in sorted(glob.glob(inv_pattern), reverse=True):
|
|
with open(inv_file) as f:
|
|
inv = json.load(f)
|
|
investigations.append(inv)
|
|
except Exception as e:
|
|
print(f"Investigations loading error: {e}")
|
|
|
|
return investigations
|
|
|
|
def get_simulation_data(self):
|
|
"""Get simulation data (active + history)"""
|
|
data = {'active': {}, 'history': []}
|
|
|
|
try:
|
|
# Load active positions
|
|
active_path = os.path.join(DATA_DIR, "simulations", "active.json")
|
|
if os.path.exists(active_path):
|
|
with open(active_path) as f:
|
|
data['active'] = json.load(f)
|
|
|
|
# Load history if it exists
|
|
history_path = os.path.join(DATA_DIR, "simulations", "history.json")
|
|
if os.path.exists(history_path):
|
|
with open(history_path) as f:
|
|
data['history'] = json.load(f)
|
|
|
|
except Exception as e:
|
|
print(f"Simulation data error: {e}")
|
|
|
|
return data
|
|
|
|
def get_status_data(self):
|
|
"""Get pipeline status information"""
|
|
status = {
|
|
'chrome': {'status': 'Unknown', 'class': 'unknown', 'detail': 'Checking...'},
|
|
'last_run': {'time': 'Unknown', 'detail': 'No recent runs found'},
|
|
'next_run': {'time': 'Unknown', 'detail': 'Check schedule in config.json'},
|
|
'health': []
|
|
}
|
|
|
|
try:
|
|
# Check Chrome debug port
|
|
import urllib.request
|
|
try:
|
|
urllib.request.urlopen('http://127.0.0.1:9222/json', timeout=2)
|
|
status['chrome'] = {
|
|
'status': 'Running',
|
|
'class': 'healthy',
|
|
'detail': 'Debug port 9222 accessible'
|
|
}
|
|
except:
|
|
status['chrome'] = {
|
|
'status': 'Down',
|
|
'class': 'error',
|
|
'detail': 'Port 9222 not accessible'
|
|
}
|
|
|
|
# Check for recent pipeline runs
|
|
x_feed_pattern = os.path.join(X_FEED_DIR, "20*")
|
|
x_feed_dirs = sorted(glob.glob(x_feed_pattern))
|
|
if x_feed_dirs:
|
|
latest = os.path.basename(x_feed_dirs[-1])
|
|
# Parse timestamp from dirname (format: 20260207-234451)
|
|
try:
|
|
dt = datetime.strptime(latest, '%Y%m%d-%H%M%S')
|
|
status['last_run'] = {
|
|
'time': dt.strftime('%Y-%m-%d %H:%M:%S'),
|
|
'detail': f"Scraped data in {latest}"
|
|
}
|
|
except:
|
|
status['last_run'] = {
|
|
'time': latest,
|
|
'detail': 'Found recent scrape data'
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Status check error: {e}")
|
|
|
|
return status
|
|
|
|
# Rendering methods
|
|
|
|
def render_posts(self, posts):
|
|
"""Render feed posts with triage status"""
|
|
if not posts:
|
|
return '<div class="empty-state">No posts found</div>'
|
|
|
|
html = ""
|
|
for post in posts:
|
|
triage = post.get('triage', {})
|
|
category = triage.get('category', 'unknown')
|
|
priority = triage.get('priority', 0)
|
|
|
|
# Color coding based on triage category
|
|
if category == 'high_value':
|
|
status_class = 'high-value'
|
|
status_text = f'High Value (Priority: {priority})'
|
|
elif category == 'worth_investigating':
|
|
status_class = 'investigate'
|
|
status_text = f'Worth Investigating (Priority: {priority})'
|
|
elif category == 'dismissed':
|
|
status_class = 'dismissed'
|
|
status_text = 'Dismissed'
|
|
else:
|
|
status_class = 'unknown'
|
|
status_text = 'Not Triaged'
|
|
|
|
html += f"""
|
|
<div class="post-item">
|
|
<div class="post-header">
|
|
<div class="post-author">{post.get('author', 'Unknown')}</div>
|
|
<div class="post-status {status_class}">{status_text}</div>
|
|
</div>
|
|
<div class="post-content">{post.get('text', '')[:200]}...</div>
|
|
<div class="post-footer">
|
|
<span class="post-time">{post.get('timestamp', 'Unknown time')}</span>
|
|
<span class="post-metrics">{post.get('likes', 0)} likes • {post.get('retweets', 0)} retweets</span>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
return html
|
|
|
|
def render_investigations(self, investigations):
|
|
"""Render investigation reports with rich links"""
|
|
if not investigations:
|
|
return '<div class="empty-state">No investigations found</div>'
|
|
|
|
html = ""
|
|
for inv in investigations:
|
|
investigation = inv.get('investigation', {})
|
|
verdict = investigation.get('verdict', 'Unknown')
|
|
risk_score = investigation.get('risk_assessment', {}).get('score', 0)
|
|
risk_notes = investigation.get('risk_assessment', {}).get('notes', [])
|
|
source = inv.get('source_post', {})
|
|
verified = investigation.get('verified_data', {})
|
|
claim_vs = investigation.get('claim_vs_reality', {})
|
|
profile_url = investigation.get('profile_url', '')
|
|
strategy_notes = investigation.get('strategy_notes', '')
|
|
suggested = inv.get('suggested_simulation', {})
|
|
|
|
verdict_class = 'verified' if 'VERIFIED' in verdict else 'failed'
|
|
|
|
# Build links section
|
|
links_html = '<div class="investigation-links">'
|
|
if source.get('url'):
|
|
links_html += f'<a href="{source["url"]}" target="_blank" class="inv-link">📝 Original Post</a>'
|
|
if source.get('author'):
|
|
author = source["author"].replace("@", "")
|
|
links_html += f'<a href="https://x.com/{author}" target="_blank" class="inv-link">🐦 {source["author"]} on X</a>'
|
|
if profile_url:
|
|
links_html += f'<a href="{profile_url}" target="_blank" class="inv-link">👤 Polymarket Profile</a>'
|
|
# Extract wallet if present in the investigation data
|
|
wallet = inv.get('investigation', {}).get('wallet_address', '')
|
|
if not wallet:
|
|
# Try to find it in verified data or elsewhere
|
|
for key, val in verified.items():
|
|
if isinstance(val, str) and val.startswith('0x'):
|
|
wallet = val
|
|
break
|
|
if wallet:
|
|
links_html += f'<a href="https://polygonscan.com/address/{wallet}" target="_blank" class="inv-link">🔗 Wallet on Polygonscan</a>'
|
|
links_html += '</div>'
|
|
|
|
# Build verified data section
|
|
verified_html = ''
|
|
if verified:
|
|
verified_html = '<div class="investigation-verified"><h4>Verified Data</h4><div class="verified-grid">'
|
|
for key, val in verified.items():
|
|
label = key.replace('_', ' ').title()
|
|
verified_html += f'<div class="verified-item"><span class="verified-label">{label}</span><span class="verified-value">{val}</span></div>'
|
|
verified_html += '</div></div>'
|
|
|
|
# Build claim vs reality section
|
|
claim_html = ''
|
|
if claim_vs:
|
|
claim_html = '<div class="investigation-claims"><h4>Claim vs Reality</h4>'
|
|
for key, val in claim_vs.items():
|
|
label = key.replace('_', ' ').title()
|
|
claim_html += f'<div class="claim-row"><span class="claim-label">{label}</span><span class="claim-value">{val}</span></div>'
|
|
claim_html += '</div>'
|
|
|
|
# Risk notes
|
|
risk_html = ''
|
|
if risk_notes:
|
|
risk_html = '<div class="investigation-risk"><h4>Risk Assessment</h4><ul>'
|
|
for note in risk_notes:
|
|
risk_html += f'<li>{note}</li>'
|
|
risk_html += '</ul></div>'
|
|
|
|
# Strategy notes
|
|
strategy_html = ''
|
|
if strategy_notes:
|
|
strategy_html = f'<div class="investigation-strategy"><h4>Strategy Notes</h4><p>{strategy_notes}</p></div>'
|
|
|
|
html += f"""
|
|
<div class="investigation-item">
|
|
<div class="investigation-header">
|
|
<div class="investigation-author">{source.get('author', 'Unknown')}</div>
|
|
<div class="investigation-verdict {verdict_class}">{verdict}</div>
|
|
</div>
|
|
<div class="investigation-claim">"{source.get('claim', 'No claim')}"</div>
|
|
{links_html}
|
|
{verified_html}
|
|
{claim_html}
|
|
<div class="investigation-score">Risk Score: <strong>{risk_score}/10</strong></div>
|
|
{risk_html}
|
|
{strategy_html}
|
|
</div>
|
|
"""
|
|
|
|
return html
|
|
|
|
def render_active_positions(self, active_data):
|
|
"""Render active trading positions"""
|
|
positions = active_data.get('positions', [])
|
|
if not positions:
|
|
return '<div class="empty-state">No active positions</div>'
|
|
|
|
html = ""
|
|
for pos in positions:
|
|
pnl = pos.get('unrealized_pnl', 0)
|
|
pnl_class = 'positive' if pnl >= 0 else 'negative'
|
|
|
|
html += f"""
|
|
<div class="position-item">
|
|
<div class="position-header">
|
|
<div class="position-asset">{pos.get('asset', 'Unknown')}</div>
|
|
<div class="position-pnl {pnl_class}">${pnl:,.0f}</div>
|
|
</div>
|
|
<div class="position-details">
|
|
<span>Size: ${pos.get('size', 0):,.0f}</span>
|
|
<span>Entry: ${pos.get('entry_price', 0):.2f}</span>
|
|
<span>Current: ${pos.get('current_price', 0):.2f}</span>
|
|
</div>
|
|
<div class="position-strategy">{pos.get('strategy', 'Unknown strategy')}</div>
|
|
</div>
|
|
"""
|
|
|
|
return html
|
|
|
|
def render_performance_metrics(self, sim_data):
|
|
"""Render performance metrics"""
|
|
active = sim_data.get('active', {})
|
|
positions = active.get('positions', [])
|
|
|
|
if not positions:
|
|
return '<div class="empty-state">No performance data</div>'
|
|
|
|
total_pnl = sum(pos.get('unrealized_pnl', 0) for pos in positions)
|
|
bankroll = active.get('bankroll_used', 1)
|
|
pnl_pct = (total_pnl / bankroll) * 100 if bankroll > 0 else 0
|
|
|
|
return f"""
|
|
<div class="metric-row">
|
|
<div class="metric-label">Total P&L</div>
|
|
<div class="metric-value ${'positive' if total_pnl >= 0 else 'negative'}">${total_pnl:,.0f}</div>
|
|
</div>
|
|
<div class="metric-row">
|
|
<div class="metric-label">Return %</div>
|
|
<div class="metric-value ${'positive' if pnl_pct >= 0 else 'negative'}">{pnl_pct:+.1f}%</div>
|
|
</div>
|
|
<div class="metric-row">
|
|
<div class="metric-label">Bankroll Used</div>
|
|
<div class="metric-value">${bankroll:,.0f}</div>
|
|
</div>
|
|
"""
|
|
|
|
def render_trade_history(self, history):
|
|
"""Render trade history"""
|
|
if not history:
|
|
return '<div class="empty-state">No trade history</div>'
|
|
|
|
html = ""
|
|
for trade in history[-10:]: # Last 10 trades
|
|
pnl = trade.get('realized_pnl', 0)
|
|
pnl_class = 'positive' if pnl >= 0 else 'negative'
|
|
|
|
html += f"""
|
|
<div class="trade-item">
|
|
<div class="trade-header">
|
|
<div class="trade-asset">{trade.get('asset', 'Unknown')}</div>
|
|
<div class="trade-pnl {pnl_class}">${pnl:,.0f}</div>
|
|
</div>
|
|
<div class="trade-details">
|
|
<span>Closed: {trade.get('closed_at', 'Unknown')}</span>
|
|
<span>Duration: {trade.get('duration_hours', 0):.1f}h</span>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
return html
|
|
|
|
def render_health_checks(self, health_data):
|
|
"""Render system health checks"""
|
|
return '''
|
|
<div class="health-item healthy">
|
|
<div class="health-name">Data Directory</div>
|
|
<div class="health-status">OK</div>
|
|
</div>
|
|
<div class="health-item healthy">
|
|
<div class="health-name">Config File</div>
|
|
<div class="health-status">OK</div>
|
|
</div>
|
|
'''
|
|
|
|
# Static file content
|
|
|
|
def get_css(self):
|
|
"""Main stylesheet"""
|
|
return """
|
|
/* Feed Hunter Portal - Dark Theme */
|
|
:root {
|
|
--bg-primary: #0d1117;
|
|
--bg-secondary: #161b22;
|
|
--bg-tertiary: #21262d;
|
|
--text-primary: #f0f6fc;
|
|
--text-secondary: #8b949e;
|
|
--border-color: #30363d;
|
|
--accent-blue: #58a6ff;
|
|
--success-green: #3fb950;
|
|
--warning-orange: #ffab40;
|
|
--error-red: #f85149;
|
|
--positive-green: #3fb950;
|
|
--negative-red: #f85149;
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
line-height: 1.5;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* Navigation */
|
|
.navbar {
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding: 1rem 2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.nav-brand {
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
color: var(--accent-blue);
|
|
}
|
|
|
|
.nav-links {
|
|
display: flex;
|
|
gap: 2rem;
|
|
}
|
|
|
|
.nav-links a {
|
|
color: var(--text-secondary);
|
|
text-decoration: none;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 6px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.nav-links a:hover {
|
|
color: var(--text-primary);
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.nav-links a.active {
|
|
color: var(--accent-blue);
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
/* Container */
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 2rem;
|
|
}
|
|
|
|
/* Cards */
|
|
.card {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.card h3 {
|
|
color: var(--text-primary);
|
|
margin-bottom: 1rem;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
/* Grid layouts */
|
|
.cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
/* Metrics */
|
|
.metric-large {
|
|
font-size: 2.5rem;
|
|
font-weight: bold;
|
|
color: var(--text-primary);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.metric-small {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.metric-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.metric-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.metric-label {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.metric-value {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.positive {
|
|
color: var(--positive-green) !important;
|
|
}
|
|
|
|
.negative {
|
|
color: var(--negative-red) !important;
|
|
}
|
|
|
|
/* Posts */
|
|
.feed-posts {
|
|
display: grid;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.post-item {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.post-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.post-author {
|
|
font-weight: bold;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.post-status {
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 12px;
|
|
font-size: 0.8rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.post-status.high-value {
|
|
background: var(--success-green);
|
|
color: #000;
|
|
}
|
|
|
|
.post-status.investigate {
|
|
background: var(--warning-orange);
|
|
color: #000;
|
|
}
|
|
|
|
.post-status.dismissed {
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.post-status.unknown {
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.post-content {
|
|
color: var(--text-primary);
|
|
margin-bottom: 0.75rem;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.post-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* Investigations */
|
|
.investigation-item {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.investigation-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.investigation-author {
|
|
font-weight: bold;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.investigation-verdict {
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 12px;
|
|
font-size: 0.8rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.investigation-verdict.verified {
|
|
background: var(--success-green);
|
|
color: #000;
|
|
}
|
|
|
|
.investigation-verdict.failed {
|
|
background: var(--error-red);
|
|
color: #fff;
|
|
}
|
|
|
|
.investigation-claim {
|
|
color: var(--text-primary);
|
|
margin-bottom: 0.5rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
.investigation-score {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.investigation-links {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
margin: 0.75rem 0;
|
|
}
|
|
|
|
.inv-link {
|
|
display: inline-block;
|
|
padding: 0.35rem 0.75rem;
|
|
background: var(--bg-tertiary);
|
|
color: var(--accent-blue);
|
|
text-decoration: none;
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
border: 1px solid var(--border-color);
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.inv-link:hover {
|
|
background: var(--border-color);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.investigation-verified, .investigation-claims, .investigation-risk, .investigation-strategy {
|
|
margin: 1rem 0;
|
|
padding: 1rem;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.investigation-verified h4, .investigation-claims h4, .investigation-risk h4, .investigation-strategy h4 {
|
|
color: var(--accent-blue);
|
|
margin-bottom: 0.75rem;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.verified-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.verified-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0.5rem;
|
|
background: var(--bg-secondary);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.verified-label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.verified-value {
|
|
color: var(--text-primary);
|
|
font-weight: bold;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.claim-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.4rem 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.claim-row:last-child { border-bottom: none; }
|
|
|
|
.claim-label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.claim-value {
|
|
color: var(--text-primary);
|
|
font-size: 0.9rem;
|
|
text-align: right;
|
|
max-width: 60%;
|
|
}
|
|
|
|
.investigation-risk ul {
|
|
list-style: none;
|
|
padding: 0;
|
|
}
|
|
|
|
.investigation-risk li {
|
|
padding: 0.3rem 0;
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.investigation-risk li::before {
|
|
content: "⚠️ ";
|
|
}
|
|
|
|
.investigation-strategy p {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* Positions */
|
|
.position-item {
|
|
background: var(--bg-tertiary);
|
|
border-radius: 6px;
|
|
padding: 1rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.position-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.position-asset {
|
|
font-weight: bold;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.position-pnl {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.position-details {
|
|
display: flex;
|
|
gap: 1rem;
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.position-strategy {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Status indicators */
|
|
.status-indicator {
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.status-indicator.healthy {
|
|
color: var(--success-green);
|
|
}
|
|
|
|
.status-indicator.error {
|
|
color: var(--error-red);
|
|
}
|
|
|
|
.status-indicator.unknown {
|
|
color: var(--warning-orange);
|
|
}
|
|
|
|
.status-time {
|
|
font-size: 1.2rem;
|
|
font-weight: bold;
|
|
color: var(--text-primary);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.status-detail {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.health-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 6px;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
/* Activity feed */
|
|
.activity-item {
|
|
display: flex;
|
|
gap: 1rem;
|
|
padding: 0.75rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.activity-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.activity-time {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.activity-text {
|
|
color: var(--text-primary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
/* Empty states */
|
|
.empty-state {
|
|
text-align: center;
|
|
color: var(--text-secondary);
|
|
padding: 2rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Buttons */
|
|
button {
|
|
background: var(--accent-blue);
|
|
color: #fff;
|
|
border: none;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 6px;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
button:hover {
|
|
background: #4493e1;
|
|
}
|
|
|
|
/* Mobile responsiveness */
|
|
@media (max-width: 768px) {
|
|
.navbar {
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.nav-links {
|
|
gap: 1rem;
|
|
}
|
|
|
|
.container {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.cards {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.position-details {
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.post-footer {
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
}
|
|
"""
|
|
|
|
def get_dashboard_js(self):
|
|
"""Dashboard JavaScript"""
|
|
return """
|
|
// Dashboard real-time updates
|
|
let updateInterval;
|
|
|
|
function startDashboardUpdates() {
|
|
updateInterval = setInterval(updateDashboard, 30000); // 30 seconds
|
|
}
|
|
|
|
function updateDashboard() {
|
|
fetch('/api/data?type=dashboard')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
console.error('Dashboard update error:', data.error);
|
|
return;
|
|
}
|
|
|
|
// Update key metrics
|
|
updateElement('.metric-large', data.total_pnl, (el, val) => {
|
|
el.textContent = `$${val.toLocaleString()}`;
|
|
el.className = `metric-large ${data.total_pnl_class}`;
|
|
});
|
|
|
|
// Update other metrics as needed
|
|
})
|
|
.catch(error => console.error('Dashboard update failed:', error));
|
|
}
|
|
|
|
function updateElement(selector, value, updater) {
|
|
const elements = document.querySelectorAll(selector);
|
|
elements.forEach(el => {
|
|
if (updater) {
|
|
updater(el, value);
|
|
} else {
|
|
el.textContent = value;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Start updates when page loads
|
|
document.addEventListener('DOMContentLoaded', startDashboardUpdates);
|
|
|
|
// Stop updates when page unloads
|
|
window.addEventListener('beforeunload', () => {
|
|
if (updateInterval) clearInterval(updateInterval);
|
|
});
|
|
"""
|
|
|
|
def get_feed_js(self):
|
|
return "// Feed view JavaScript - placeholder"
|
|
|
|
def get_investigations_js(self):
|
|
return """
|
|
function showInvestigationDetail(invId) {
|
|
alert('Investigation detail view for: ' + invId);
|
|
// TODO: Implement modal or detail view
|
|
}
|
|
"""
|
|
|
|
def get_simulations_js(self):
|
|
return "// Simulations JavaScript - placeholder"
|
|
|
|
def get_status_js(self):
|
|
return """
|
|
// Auto-refresh status page
|
|
setInterval(() => {
|
|
location.reload();
|
|
}, 60000); // 1 minute
|
|
"""
|
|
|
|
# Response helpers
|
|
|
|
def send_html(self, content):
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'text/html')
|
|
self.end_headers()
|
|
self.wfile.write(content.encode())
|
|
|
|
def send_css(self, content):
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'text/css')
|
|
self.end_headers()
|
|
self.wfile.write(content.encode())
|
|
|
|
def send_js(self, content):
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'application/javascript')
|
|
self.end_headers()
|
|
self.wfile.write(content.encode())
|
|
|
|
def send_json(self, data):
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(data).encode())
|
|
|
|
def log_message(self, format, *args):
|
|
# Suppress default logging
|
|
pass
|
|
|
|
def main():
|
|
"""Start the Feed Hunter portal server"""
|
|
print(f"🖤 Feed Hunter Portal starting on localhost:{PORT}")
|
|
print(f"📊 Dashboard: http://localhost:{PORT}/")
|
|
print(f"🔍 Status: http://localhost:{PORT}/status")
|
|
print("")
|
|
|
|
try:
|
|
server = ThreadedHTTPServer(('0.0.0.0', PORT), FeedHunterHandler)
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\n🛑 Portal stopped")
|
|
except Exception as e:
|
|
print(f"❌ Server error: {e}")
|
|
|
|
if __name__ == "__main__":
|
|
main() |