Compare commits

...

5 Commits

8 changed files with 457 additions and 108 deletions
+33 -4
View File
@@ -1,10 +1,13 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from jinja2 import Environment, FileSystemLoader
from app.database import engine, Base
from starlette.middleware.base import BaseHTTPMiddleware
from app.database import engine, Base, SessionLocal
from app.models import User
from app.seed import seed
from app.routers import contracts, analysis, data_input, admin
from app.routers import contracts, analysis, data_input, admin, auth
TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -14,6 +17,30 @@ def setup_jinja(app: FastAPI):
app.state.templates = env
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Public paths
if request.url.path in ("/auth/login", "/auth/logout") or request.url.path.startswith("/auth/"):
return await call_next(request)
# Check session
user_id = request.cookies.get("ft_session")
if user_id:
db = SessionLocal()
try:
user = db.query(User).filter(User.id == int(user_id)).first()
if user:
request.state.user = user
return await call_next(request)
except (ValueError, TypeError):
pass
finally:
db.close()
# Redirect to login
return RedirectResponse(f"/auth/login?next={request.url.path}", status_code=303)
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_jinja(app)
@@ -24,6 +51,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="期货量化系统", lifespan=lifespan)
app.add_middleware(AuthMiddleware)
app.include_router(auth.router)
app.include_router(contracts.router)
app.include_router(analysis.router)
app.include_router(data_input.router)
@@ -32,5 +62,4 @@ app.include_router(admin.router)
@app.get("/")
def root():
from fastapi.responses import RedirectResponse
return RedirectResponse("/contracts")
+27
View File
@@ -1,3 +1,5 @@
import hashlib
import os
from datetime import date
from sqlalchemy import String, Integer, Float, Date, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -60,3 +62,28 @@ class PositionSnapshot(Base):
position: Mapped[int] = mapped_column(Integer)
delta: Mapped[int] = mapped_column(Integer, default=0)
avg_cost: Mapped[float] = mapped_column(Float)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
password_hash: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(20), default="user")
@staticmethod
def hash_password(password: str) -> str:
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)
return salt.hex() + ":" + dk.hex()
def check_password(self, password: str) -> bool:
try:
salt_hex, dk_hex = self.password_hash.split(":")
salt = bytes.fromhex(salt_hex)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)
return dk.hex() == dk_hex
except (ValueError, AttributeError):
return False
+35 -12
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Product, Contract
from app.models import Product, Contract, DailyBar
from app.collector import sync_active_contracts, sync_one_contract
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -22,23 +22,35 @@ def admin_page(request: Request, db: Session = Depends(get_db)):
.order_by(Contract.code)
.all()
)
contract_list = []
for c in contracts:
bar_count = (
db.query(DailyBar)
.filter(DailyBar.contract == c.code)
.count()
)
contract_list.append({
"id": c.id, "code": c.code, "name": c.name,
"is_active": c.is_active, "bar_count": bar_count,
})
product_data.append({
"id": p.id,
"code": p.code,
"name": p.name,
"exchange": p.exchange,
"contracts": [
{"id": c.id, "code": c.code, "name": c.name, "is_active": c.is_active}
for c in contracts
],
"contracts": contract_list,
})
total_contracts = sum(len(pd["contracts"]) for pd in product_data)
template = request.app.state.templates.get_template("admin.html")
return HTMLResponse(
template.render(
request=request,
active_nav="admin",
products=product_data,
total_contracts=total_contracts,
exchanges=EXCHANGES,
)
)
@@ -57,7 +69,7 @@ def create_product(
p = Product(code=code.upper(), name=name, exchange=exchange)
db.add(p)
db.commit()
return RedirectResponse("/admin/", status_code=303)
return RedirectResponse("/admin/?tab=product", status_code=303)
@router.post("/contract")
@@ -78,7 +90,7 @@ def create_contract(
)
db.add(c)
db.commit()
return RedirectResponse("/admin/", status_code=303)
return RedirectResponse("/admin/?tab=contract", status_code=303)
@router.post("/contract/{contract_id}/toggle")
@@ -87,7 +99,7 @@ def toggle_contract(contract_id: int, db: Session = Depends(get_db)):
if c:
c.is_active = not c.is_active
db.commit()
return RedirectResponse("/admin/", status_code=303)
return RedirectResponse("/admin/?tab=contract", status_code=303)
@router.post("/contract/{contract_id}/delete")
@@ -96,7 +108,7 @@ def delete_contract(contract_id: int, db: Session = Depends(get_db)):
if c:
db.delete(c)
db.commit()
return RedirectResponse("/admin/", status_code=303)
return RedirectResponse("/admin/?tab=contract", status_code=303)
@router.post("/product/{product_id}/delete")
@@ -105,7 +117,7 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
if p:
db.delete(p)
db.commit()
return RedirectResponse("/admin/", status_code=303)
return RedirectResponse("/admin/?tab=product", status_code=303)
@router.post("/sync")
@@ -113,10 +125,21 @@ def sync_all(request: Request):
results = sync_active_contracts()
total = sum(results.values())
print(f"[sync] Synced {total} bars across {len(results)} contracts: {results}")
return RedirectResponse("/admin/?synced=" + str(total), status_code=303)
return RedirectResponse(f"/admin/?tab=sync&synced={total}", status_code=303)
@router.post("/sync/{contract_code}")
def sync_single(contract_code: str):
count = sync_one_contract(contract_code.upper())
return RedirectResponse(f"/admin/?synced={count}", status_code=303)
return RedirectResponse(f"/admin/?tab=sync&synced={count}", status_code=303)
@router.post("/sync/product/{product_id}")
def sync_product(product_id: int, request: Request, db: Session = Depends(get_db)):
contracts = db.query(Contract).filter(
Contract.product_id == product_id, Contract.is_active == True
).all()
total = 0
for c in contracts:
total += sync_one_contract(c.code)
return RedirectResponse(f"/admin/?tab=sync&synced={total}", status_code=303)
+52
View File
@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
SESSION_COOKIE = "ft_session"
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
user_id = request.cookies.get(SESSION_COOKIE)
if not user_id:
return None
try:
return db.query(User).filter(User.id == int(user_id)).first()
except (ValueError, TypeError):
return None
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
template = request.app.state.templates.get_template("login.html")
return HTMLResponse(template.render(request=request))
@router.post("/login")
def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == username).first()
if not user or not user.check_password(password):
template = request.app.state.templates.get_template("login.html")
return HTMLResponse(
template.render(request=request, error="用户名或密码错误"),
status_code=401,
)
resp = RedirectResponse("/contracts/", status_code=303)
resp.set_cookie(SESSION_COOKIE, str(user.id), httponly=True, max_age=86400 * 7)
return resp
@router.get("/logout")
def logout():
resp = RedirectResponse("/auth/login", status_code=303)
resp.delete_cookie(SESSION_COOKIE)
return resp
+11 -1
View File
@@ -1,7 +1,7 @@
"""Seed database from existing data files. Run once manually or on first start."""
from datetime import date
from app.database import engine, Base, SessionLocal
from app.models import DailyBar, PositionSnapshot, Product, Contract
from app.models import DailyBar, PositionSnapshot, Product, Contract, User
from app.engine.lock_strategy import compute_amp_5d
# --- Seed OHLCV data ---
@@ -202,6 +202,16 @@ def seed():
db = SessionLocal()
try:
# --- Seed default user ---
if not db.query(User).first():
admin = User(
username="admin",
password_hash=User.hash_password("admin123"),
role="admin",
)
db.add(admin)
db.flush()
# --- Seed products and contracts ---
if not db.query(Product).first():
fg = Product(code="FG", name="玻璃", exchange="CZCE")
+141 -39
View File
@@ -5,20 +5,86 @@
{% block content %}
<style>
.tabs { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid var(--border); }
.tab-btn {
padding: 10px 20px; border: none; background: none;
font-size: 0.88rem; font-weight: 500; color: var(--sub);
cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: color .12s, border-color .12s;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
</style>
{# ── Sync Bar ── #}
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;">
<span style="font-size:0.85rem;color:var(--sub);">数据同步</span>
{% set tab = request.query_params.get('tab', 'sync') %}
<div class="tabs">
<button class="tab-btn{% if tab == 'sync' %} active{% endif %}" onclick="switchTab('sync')">📡 数据同步</button>
<button class="tab-btn{% if tab == 'product' %} active{% endif %}" onclick="switchTab('product')">🏷️ 品种管理</button>
<button class="tab-btn{% if tab == 'contract' %} active{% endif %}" onclick="switchTab('contract')">📋 合约管理</button>
</div>
{# ═══════════════════ Tab: 数据同步 ═══════════════════ #}
<div class="tab-panel{% if tab == 'sync' %} active{% endif %}" id="tab-sync">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;">
<span style="font-size:0.85rem;color:var(--sub);">全部合约</span>
<form method="post" action="/admin/sync" style="display:inline;">
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步全部合约</button>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步全部</button>
</form>
{% if request.query_params.get('synced') %}
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 已同步 {{ request.query_params.synced }} 条</span>
{% endif %}
<span style="font-size:0.78rem;color:var(--sub);margin-left:auto;">{{ total_contracts }} 个合约</span>
</div>
{% if products %}
{% for p in products %}
<div style="margin-bottom:16px;border:1px solid var(--border);border-radius:10px;overflow:hidden;">
<div class="product-header"
style="display:flex;align-items:center;gap:12px;padding:12px 18px;background:var(--th-bg);cursor:pointer;user-select:none;"
onclick="toggleProduct(this)">
<span class="collapse-arrow" style="font-size:0.75rem;transition:transform .2s;display:inline-block;transform:rotate(-90deg);"></span>
<strong style="font-size:0.92rem;">{{ p.code }} · {{ p.name }}</strong>
<span style="font-size:0.78rem;color:var(--sub);">{{ p.exchange }}</span>
<span style="font-size:0.78rem;color:var(--sub);margin-left:8px;">{{ p.contracts|length }} 合约</span>
<form method="post" action="/admin/sync/product/{{ p.id }}" style="display:inline;margin-left:auto;" onclick="event.stopPropagation()">
<button type="submit" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--accent);color:#fff;border-radius:5px;">同步该品种</button>
</form>
</div>
<div class="product-body" style="display:none;">
<table style="font-size:0.84rem;">
<tr><th style="text-align:left;padding:8px 14px;">合约</th><th style="text-align:center;padding:8px 14px;">数据条数</th><th style="text-align:center;padding:8px 14px;">状态</th><th style="text-align:center;padding:8px 14px;">操作</th></tr>
{% for c in p.contracts %}
<tr>
<td style="text-align:left;padding:6px 14px;"><strong>{{ c.code }}</strong></td>
<td style="text-align:center;padding:6px 14px;">{{ c.bar_count }}</td>
<td style="text-align:center;padding:6px 14px;">
{% if c.is_active %}<span class="badge badge-up">启用</span>{% else %}<span class="badge badge-down">停用</span>{% endif %}
</td>
<td style="text-align:center;padding:6px 14px;">
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;font-size:0.82rem;">查看</a>
<form method="post" action="/admin/sync/{{ c.code }}" style="display:inline;margin-left:8px;">
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;">↻ 同步</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先在「品种管理」中新建。</div>
{% endif %}
</div>
{# ── New Product ── #}
<div class="section-title">新建品种</div>
<div class="form-card" style="margin-bottom:24px;">
{# ═══════════════════ Tab: 品种管理 ═══════════════════ #}
<div class="tab-panel{% if tab == 'product' %} active{% endif %}" id="tab-product">
<div class="form-card" style="margin-bottom:24px;">
<div class="section-title">新建品种</div>
<form method="post" action="/admin/product" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
<label>品种代码</label>
@@ -38,14 +104,39 @@
</div>
<button type="submit" class="btn btn-primary">新建</button>
</form>
</div>
{% if products %}
<div class="table-wrap">
<table>
<tr><th>代码</th><th>名称</th><th>交易所</th><th>合约数</th><th>操作</th></tr>
{% for p in products %}
<tr>
<td><strong>{{ p.code }}</strong></td>
<td>{{ p.name }}</td>
<td>{{ p.exchange }}</td>
<td>{{ p.contracts|length }}</td>
<td>
<form method="post" action="/admin/product/{{ p.id }}/delete" onsubmit="return confirm('删除品种 {{ p.code }} 及其所有合约?')" style="display:inline;">
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先新建。</div>
{% endif %}
</div>
{# ── Product List ── #}
{% for p in products %}
<div class="section-title">{{ p.code }} · {{ p.name }} <span style="font-weight:400;color:var(--sub);font-size:0.78rem;">{{ p.exchange }}</span></div>
{# ═══════════════════ Tab: 合约管理 ═══════════════════ #}
<div class="tab-panel{% if tab == 'contract' %} active{% endif %}" id="tab-contract">
{% if products %}
{% for p in products %}
<div class="section-title" style="margin-top:{% if not loop.first %}24px{% else %}0{% endif %};">{{ p.code }} · {{ p.name }}</div>
{# ── New Contract ── #}
<div class="form-card" style="margin-bottom:12px;">
<div class="form-card" style="margin-bottom:12px;">
<form method="post" action="/admin/contract" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<input type="hidden" name="product_id" value="{{ p.id }}">
<div class="form-group" style="margin-bottom:0;flex:1;min-width:140px;">
@@ -58,29 +149,18 @@
</div>
<button type="submit" class="btn btn-primary">添加合约</button>
</form>
</div>
</div>
{# ── Contract List ── #}
{% if p.contracts %}
<div class="table-wrap" style="margin-bottom:20px;">
<table>
<tr><th>合约代码</th><th>名称</th><th>状态</th><th>行情</th><th>操作</th></tr>
{% if p.contracts %}
<div class="table-wrap" style="margin-bottom:20px;">
<table>
<tr><th>合约代码</th><th>名称</th><th>状态</th><th>操作</th></tr>
{% for c in p.contracts %}
<tr>
<td><strong>{{ c.code }}</strong></td>
<td>{{ c.name }}</td>
<td>
{% if c.is_active %}
<span class="badge badge-up">启用</span>
{% else %}
<span class="badge badge-down">停用</span>
{% endif %}
</td>
<td>
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;">查看</a>
<form method="post" action="/admin/sync/{{ c.code }}" style="display:inline;margin-left:8px;">
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.78rem;">↻ 同步</button>
</form>
{% if c.is_active %}<span class="badge badge-up">启用</span>{% else %}<span class="badge badge-down">停用</span>{% endif %}
</td>
<td>
<form method="post" action="/admin/contract/{{ c.id }}/toggle" style="display:inline;">
@@ -88,22 +168,44 @@
{% if c.is_active %}停用{% else %}启用{% endif %}
</button>
</form>
<form method="post" action="/admin/contract/{{ c.id }}/delete" style="display:inline;" onsubmit="return confirm('删除 {{ c.code }}此操作不可恢复。')">
<form method="post" action="/admin/contract/{{ c.id }}/delete" style="display:inline;" onsubmit="return confirm('删除 {{ c.code }}')">
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</table>
</div>
{% else %}
<div style="color:var(--sub);font-size:0.85rem;margin-bottom:16px;">暂无合约</div>
{% endif %}
{% endfor %}
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先在「品种管理」中新建。</div>
{% endif %}
</div>
{% endif %}
<form method="post" action="/admin/product/{{ p.id }}/delete" onsubmit="return confirm('删除品种 {{ p.code }} 及其所有合约?')" style="text-align:right;margin-bottom:28px;">
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除品种 {{ p.code }}</button>
</form>
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先新建。</div>
{% endfor %}
<script>
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
document.querySelector('.tab-btn[onclick="switchTab(\'' + name + '\')"]').classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
var url = new URL(window.location);
url.searchParams.set('tab', name);
history.replaceState(null, '', url);
}
function toggleProduct(header) {
var body = header.nextElementSibling;
var arrow = header.querySelector('.collapse-arrow');
if (body.style.display === 'none') {
body.style.display = 'block';
arrow.style.transform = 'rotate(0deg)';
} else {
body.style.display = 'none';
arrow.style.transform = 'rotate(-90deg)';
}
}
</script>
{% endblock %}
+5 -1
View File
@@ -206,7 +206,11 @@
</a>
</nav>
<div class="sidebar-footer">
<button class="theme-btn" id="themeToggle">🌙 暗色模式</button>
<div style="font-size:0.78rem;color:var(--sub);margin-bottom:8px;">
👤 {{ request.state.user.username if request.state.user else '—' }}
</div>
<a href="/auth/logout" style="font-size:0.78rem;color:var(--sub);text-decoration:none;">退出登录</a>
<button class="theme-btn" style="margin-top:8px;" id="themeToggle">🌙 暗色模式</button>
</div>
</aside>
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 · 期货量化</title>
<style>
:root {
--bg: #f8fafc; --surface: #fff; --fg: #1e293b; --sub: #64748b;
--border: #e2e8f0; --accent: #3b82f6; --danger: #ef4444;
}
[data-theme="dark"] {
--bg: #0f172a; --surface: #1e293b; --fg: #e2e8f0; --sub: #94a3b8;
--border: #334155; --accent: #60a5fa; --danger: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
background: var(--bg); color: var(--fg);
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
}
.login-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 40px; width: 360px; max-width: 90vw;
}
.login-box h1 { font-size: 1.3rem; text-align: center; margin-bottom: 8px; }
.login-box .sub { color: var(--sub); font-size: 0.85rem; text-align: center; margin-bottom: 28px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--sub); margin-bottom: 5px; }
.form-group input {
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: 6px; font-size: 0.9rem; background: var(--bg); color: var(--fg);
}
.form-group input:focus { outline: none; border-color: var(--accent); }
.btn {
width: 100%; padding: 10px; border: none; border-radius: 6px;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
background: var(--accent); color: #fff; margin-top: 8px;
}
.btn:hover { opacity: 0.9; }
.error {
background: #fef2f2; color: var(--danger); padding: 10px 14px;
border-radius: 6px; font-size: 0.85rem; margin-bottom: 16px; text-align: center;
}
.toggle-btn {
position: fixed; top: 16px; right: 24px;
background: var(--surface); border: 1px solid var(--border);
color: var(--fg); padding: 6px 14px; border-radius: 6px;
cursor: pointer; font-size: 0.82rem; z-index: 10;
}
.toggle-btn:hover { opacity: 0.8; }
</style>
</head>
<body>
<button class="toggle-btn" id="themeToggle">🌙 暗色模式</button>
<div class="login-box">
<h1>📊 期货量化系统</h1>
<p class="sub">请输入账号密码</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" required autofocus>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn">登 录</button>
</form>
</div>
<script>
var themeBtn = document.getElementById('themeToggle');
themeBtn.addEventListener('click', toggleTheme);
function setTheme(dark) {
if (dark) {
document.documentElement.setAttribute('data-theme', 'dark');
themeBtn.textContent = '☀️ 亮色模式';
} else {
document.documentElement.removeAttribute('data-theme');
themeBtn.textContent = '🌙 暗色模式';
}
try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {}
}
function toggleTheme() {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
(function() {
try {
var s = localStorage.getItem('ft-theme');
if (s === 'dark' || (!s && matchMedia('(prefers-color-scheme: dark)').matches)) setTheme(true);
} catch(e) {}
})();
</script>
</body>
</html>