Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added fintick/assets/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions fintick/assets/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fintick/assets/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
159 changes: 156 additions & 3 deletions fintick/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
open_database,
)

ASSET_DIR = Path(__file__).resolve().parent / "assets"
ASSET_ROUTES = {
"/og.png": ("og.png", "image/png"),
"/favicon.svg": ("favicon.svg", "image/svg+xml"),
"/favicon.ico": ("favicon.svg", "image/svg+xml"),
"/apple-touch-icon.png": ("apple-touch-icon.png", "image/png"),
}

DEFAULT_LIMIT = 100
MAX_LIMIT = 250
# 'unconfirmed' sinks to the bottom: it is breaking that the wire never caught up on.
Expand Down Expand Up @@ -138,13 +146,111 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str,
}


SITE_ORIGIN = os.environ.get("FINTICK_SITE_ORIGIN", "https://fintick.fyi").rstrip("/")

# Crawlable: the board itself. Not crawlable: the JSON API (no prose to index) and the
# ?ops operator view, which is the same page plus telemetry and would read as duplicate
# content. The canonical link handles ?ops for engines that ignore the query rule.
ROBOTS_TXT = """User-agent: *
Allow: /$
Disallow: /api/
Disallow: /*?ops

Sitemap: {origin}/sitemap.xml
"""

SITEMAP_XML = """<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>{origin}/</loc>
<changefreq>hourly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
"""

# AEO: a plain-language brief for answer engines and browsing agents, which reward a
# stated method and explicit limits over marketing copy.
LLMS_TXT = """# FinTick

> A live tape of financial events, scored by whether independent news has caught up yet.

FinTick ingests one public financial stream, aggregates its posts into distinct events
using a language model, extracts structured facts from each, then searches independent
news sources to corroborate them.

## How an event is classified

- **breaking** — no independent source has reported it yet. The stream is ahead of the wire.
- **unconfirmed** — was breaking, and the wire still had not corroborated it after the
configured window elapsed.
- **developing** — partially corroborated.
- **confirmed** — independently reported by one or more outlets.
- **contradicted** — an independent source disputes it.

## Method and limits

- Aggregation performs the deduplication: repeated posts about one event merge into that
event and are retained as `seen N times` evidence rather than discarded.
- Every ingested post is preserved by immutable URI and receives exactly one durable
outcome. A post belongs to at most one event.
- Social posts are treated as stream signal, never as independent corroboration.
- FinTick reports whether an event has been corroborated. It does not assert that an
event is true, and it is not investment advice.

## Endpoints

- `/` — the board.
- `/api/feed` — the same events as JSON.

## Source

- https://github.com/msitarzewski/fintick
"""

DASHBOARD_HTML = r'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#0b0d0c" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f3f1ea" media="(prefers-color-scheme: light)">
<title>FinTick — The Edge Board</title>
<meta name="description" content="A live tape of financial events. FinTick aggregates a public stream into distinct events, extracts the facts, then hunts independent news to corroborate them — an event no outlet has confirmed yet is flagged breaking.">
<link rel="canonical" href="https://fintick.fyi/">
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="FinTick">
<meta property="og:title" content="FinTick — ahead of the wire">
<meta property="og:description" content="A live tape of financial events, scored by whether independent news has caught up yet.">
<meta property="og:url" content="https://fintick.fyi/">
<meta property="og:image" content="https://fintick.fyi/og.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="FinTick — a breaking financial event with zero external sources, ahead of the wire.">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="FinTick — ahead of the wire">
<meta name="twitter:description" content="A live tape of financial events, scored by whether independent news has caught up yet.">
<meta name="twitter:image" content="https://fintick.fyi/og.png">
<meta name="twitter:image:alt" content="FinTick — a breaking financial event with zero external sources, ahead of the wire.">
<script type="application/ld+json">
{"@context":"https://schema.org","@graph":[
{"@type":"WebSite","@id":"https://fintick.fyi/#website","url":"https://fintick.fyi/","name":"FinTick",
"description":"A live tape of financial events, scored by whether independent news has caught up yet.",
"inLanguage":"en"},
{"@type":"WebApplication","@id":"https://fintick.fyi/#app","url":"https://fintick.fyi/","name":"FinTick",
"applicationCategory":"FinanceApplication","browserRequirements":"Requires JavaScript.",
"operatingSystem":"Any","isAccessibleForFree":true,
"offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},
"description":"FinTick ingests a public financial stream, aggregates posts into distinct events, extracts structured facts, and searches independent news to corroborate each one. An event with no external sources is flagged breaking.",
"featureList":["Event aggregation from a public stream","Structured fact extraction","Independent news corroboration","Breaking detection when no outlet has reported an event yet"]}
]}
</script>
<script>try{var t=localStorage.getItem('fintick-theme');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t}catch(e){}
/* Operator telemetry (pipeline health + status pill) is hidden for public visitors.
Reveal it with ?ops (or ?ops=1) — it persists per-browser; ?ops=0 clears it. */
Expand Down Expand Up @@ -307,22 +413,69 @@ def __init__(self, address: tuple[str, int], database: str | Path) -> None:


class DashboardHandler(BaseHTTPRequestHandler):
def _send(self, status: HTTPStatus, body: bytes, content_type: str) -> None:
# Social scrapers and link validators HEAD an og:image before fetching it, and the
# base handler answers 501 for any verb it has no do_* for — which reads as a broken
# image. HEAD routes through do_GET and drops the body.
_head_only = False

def do_HEAD(self) -> None: # noqa: N802
self._head_only = True
try:
self.do_GET()
finally:
self._head_only = False

def _send(
self,
status: HTTPStatus,
body: bytes,
content_type: str,
cache_control: str = "no-store",
) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("Cache-Control", cache_control)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
"connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
)
self.end_headers()
self.wfile.write(body)
# HEAD keeps every header, including Content-Length, but sends no body.
if not self._head_only:
self.wfile.write(body)

def _send_asset(self, name: str, content_type: str) -> None:
try:
body = (ASSET_DIR / name).read_bytes()
except OSError as error:
self.log_error("asset %s unavailable: %s", name, error)
self._send(HTTPStatus.NOT_FOUND, b"not found", "text/plain; charset=utf-8")
return
self._send(HTTPStatus.OK, body, content_type, cache_control="public, max-age=86400")

def do_GET(self) -> None: # noqa: N802
parts = urlsplit(self.path)
if parts.path == "/robots.txt":
body = ROBOTS_TXT.format(origin=SITE_ORIGIN).encode()
self._send(HTTPStatus.OK, body, "text/plain; charset=utf-8",
cache_control="public, max-age=3600")
return
if parts.path == "/sitemap.xml":
body = SITEMAP_XML.format(origin=SITE_ORIGIN).encode()
self._send(HTTPStatus.OK, body, "application/xml; charset=utf-8",
cache_control="public, max-age=3600")
return
if parts.path == "/llms.txt":
self._send(HTTPStatus.OK, LLMS_TXT.encode(), "text/plain; charset=utf-8",
cache_control="public, max-age=3600")
return
asset = ASSET_ROUTES.get(parts.path)
if asset is not None:
self._send_asset(*asset)
return
if parts.path in {"/", "/index.html"}:
self._send(HTTPStatus.OK, DASHBOARD_HTML.encode(), "text/html; charset=utf-8")
return
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
include = ["fintick*"]

# Share-card image and icons are served from disk at runtime; without this they are
# dropped from a built distribution and /og.png 404s.
[tool.setuptools.package-data]
fintick = ["assets/*"]
92 changes: 91 additions & 1 deletion tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ def test_limit_validation_and_cap(self) -> None:
self.assertEqual(read_feed(self.database, limit=10_000)["count"], 1)


class DashboardHttpTests(EventBoardFixture):
class ServedBoardFixture(EventBoardFixture):
"""A running board. Carries no tests, so subclasses do not re-run each other's."""

def setUp(self) -> None:
super().setUp()
self.server = DashboardServer(("127.0.0.1", 0), self.database)
Expand All @@ -174,6 +176,8 @@ def _stop_server(self) -> None:
self.server.server_close()
self.thread.join(timeout=2)


class DashboardHttpTests(ServedBoardFixture):
def test_dashboard_is_v2_event_board_and_auto_refreshing(self) -> None:
with urllib.request.urlopen(self.base_url + "/", timeout=2) as response:
body = response.read().decode()
Expand Down Expand Up @@ -226,5 +230,91 @@ def test_html_has_reduced_motion_and_accessibility_status(self) -> None:
self.assertIn('href="#feed"', DASHBOARD_HTML)


class DiscoverabilityTests(ServedBoardFixture):
"""Metadata and crawler routes: a broken one fails silently in production."""

def _get(self, path: str) -> tuple[int, dict[str, str], bytes]:
with urllib.request.urlopen(self.base_url + path, timeout=2) as response:
return response.status, dict(response.headers), response.read()

def test_share_metadata_is_complete_and_absolute(self) -> None:
head = DASHBOARD_HTML.split("</head>")[0]
for tag in (
'<meta name="description"',
'<link rel="canonical" href="https://fintick.fyi/">',
'<meta property="og:title"',
'<meta property="og:description"',
'<meta property="og:image" content="https://fintick.fyi/og.png">',
'<meta property="og:image:width" content="1200">',
'<meta property="og:image:height" content="630">',
'<meta property="og:image:alt"',
'<meta name="twitter:card" content="summary_large_image">',
'<meta name="twitter:image" content="https://fintick.fyi/og.png">',
'<link rel="icon" href="/favicon.svg"',
):
self.assertIn(tag, head, f"missing share metadata: {tag}")
# Relative og:image is the classic silent break — scrapers do not resolve it.
for prop in ("og:image", "twitter:image", "og:url"):
value = head.split(f'"{prop}" content="')[1].split('"')[0]
self.assertTrue(value.startswith("https://"), f"{prop} must be absolute")

def test_structured_data_parses(self) -> None:
head = DASHBOARD_HTML.split("</head>")[0]
block = head.split('<script type="application/ld+json">')[1].split("</script>")[0]
graph = json.loads(block)["@graph"]
self.assertEqual({node["@type"] for node in graph}, {"WebSite", "WebApplication"})

def test_robots_points_at_sitemap_and_shields_api_and_ops(self) -> None:
status, headers, body = self._get("/robots.txt")
text = body.decode()
self.assertEqual(status, 200)
self.assertTrue(headers["Content-Type"].startswith("text/plain"))
self.assertIn("Disallow: /api/", text)
self.assertIn("Disallow: /*?ops", text)
self.assertIn("Sitemap: https://fintick.fyi/sitemap.xml", text)

def test_sitemap_is_well_formed_xml(self) -> None:
import xml.etree.ElementTree as ET

status, _, body = self._get("/sitemap.xml")
self.assertEqual(status, 200)
root = ET.fromstring(body.decode())
namespace = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
self.assertEqual(root.tag, f"{namespace}urlset")
locations = [url.findtext(f"{namespace}loc") for url in root]
self.assertEqual(locations, ["https://fintick.fyi/"])

def test_llms_txt_states_the_method_and_its_limits(self) -> None:
status, _, body = self._get("/llms.txt")
text = body.decode()
self.assertEqual(status, 200)
self.assertIn("# FinTick", text)
for status_name in ("breaking", "unconfirmed", "confirmed", "contradicted"):
self.assertIn(status_name, text)
self.assertIn("not investment advice", text)

def test_assets_are_served_with_types_and_cached(self) -> None:
for path, content_type in (
("/og.png", "image/png"),
("/favicon.svg", "image/svg+xml"),
("/apple-touch-icon.png", "image/png"),
):
status, headers, body = self._get(path)
self.assertEqual(status, 200, path)
self.assertEqual(headers["Content-Type"], content_type, path)
self.assertIn("max-age", headers["Cache-Control"], path)
self.assertTrue(body, f"{path} served empty")

def test_head_is_answered_for_scrapers(self) -> None:
# Social scrapers HEAD an og:image first; the base handler 501s without do_HEAD,
# which reads to them as a broken image.
for path in ("/", "/og.png"):
request = urllib.request.Request(self.base_url + path, method="HEAD")
with urllib.request.urlopen(request, timeout=2) as response:
self.assertEqual(response.status, 200, path)
self.assertEqual(response.read(), b"", f"{path} HEAD returned a body")
self.assertTrue(response.headers["Content-Length"], path)


if __name__ == "__main__":
unittest.main()