stock-tracker

This commit is contained in:
C菌
2026-07-04 00:17:11 +08:00
commit 6087341a48
6463 changed files with 1929869 additions and 0 deletions
View File
Binary file not shown.
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = "sqlite:///./stock_tracker.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+20
View File
@@ -0,0 +1,20 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import engine, Base
from .routers import stocks, collections, shares
Base.metadata.create_all(bind=engine)
app = FastAPI(title="A股追踪", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(stocks.router, prefix="/api/stocks", tags=["股票"])
app.include_router(collections.router, prefix="/api/collections", tags=["集合"])
app.include_router(shares.router, prefix="/api", tags=["分享"])
+30
View File
@@ -0,0 +1,30 @@
from sqlalchemy import Column, Integer, String, Date, ForeignKey, DateTime, func
from sqlalchemy.orm import relationship
from .database import Base
class Collection(Base):
__tablename__ = "collections"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String, nullable=False)
short_code = Column(String, unique=True, nullable=True)
created_at = Column(DateTime, server_default=func.now())
stocks = relationship(
"CollectionStock",
back_populates="collection",
cascade="all, delete-orphan",
)
class CollectionStock(Base):
__tablename__ = "collection_stocks"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
collection_id = Column(Integer, ForeignKey("collections.id", ondelete="CASCADE"), nullable=False)
stock_code = Column(String, nullable=False)
stock_name = Column(String, nullable=False)
added_date = Column(Date, nullable=False)
collection = relationship("Collection", back_populates="stocks")
View File
+130
View File
@@ -0,0 +1,130 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import Collection, CollectionStock
from ..schemas import (
CollectionCreate,
CollectionUpdate,
CollectionOut,
CollectionListItem,
CollectionStockOut,
StockInput,
ShareResponse,
)
import secrets
import string
router = APIRouter()
def _generate_short_code(length=8):
chars = string.ascii_letters + string.digits
return "".join(secrets.choice(chars) for _ in range(length))
@router.post("", response_model=CollectionOut)
def create_collection(data: CollectionCreate, db: Session = Depends(get_db)):
col = Collection(name=data.name)
db.add(col)
db.flush()
for s in data.stocks:
db.add(CollectionStock(
collection_id=col.id,
stock_code=s.code,
stock_name=s.name,
added_date=date.fromisoformat(s.added_date),
))
db.commit()
db.refresh(col)
return col
@router.get("", response_model=list[CollectionListItem])
def list_collections(db: Session = Depends(get_db)):
cols = db.query(Collection).order_by(Collection.created_at.desc()).all()
return [
CollectionListItem(
id=c.id,
name=c.name,
stock_count=len(c.stocks),
short_code=c.short_code,
created_at=c.created_at,
)
for c in cols
]
@router.get("/{col_id}", response_model=CollectionOut)
def get_collection(col_id: int, db: Session = Depends(get_db)):
col = db.query(Collection).filter(Collection.id == col_id).first()
if not col:
raise HTTPException(404, "集合不存在")
return col
@router.put("/{col_id}", response_model=CollectionOut)
def update_collection(col_id: int, data: CollectionUpdate, db: Session = Depends(get_db)):
col = db.query(Collection).filter(Collection.id == col_id).first()
if not col:
raise HTTPException(404, "集合不存在")
if data.name is not None:
col.name = data.name
db.commit()
db.refresh(col)
return col
@router.delete("/{col_id}")
def delete_collection(col_id: int, db: Session = Depends(get_db)):
col = db.query(Collection).filter(Collection.id == col_id).first()
if not col:
raise HTTPException(404, "集合不存在")
db.delete(col)
db.commit()
return {"ok": True}
@router.post("/{col_id}/stocks", response_model=CollectionStockOut)
def add_stock(col_id: int, data: StockInput, db: Session = Depends(get_db)):
col = db.query(Collection).filter(Collection.id == col_id).first()
if not col:
raise HTTPException(404, "集合不存在")
stock = CollectionStock(
collection_id=col_id,
stock_code=data.code,
stock_name=data.name,
added_date=date.fromisoformat(data.added_date),
)
db.add(stock)
db.commit()
db.refresh(stock)
return stock
@router.delete("/{col_id}/stocks/{stock_code}")
def remove_stock(col_id: int, stock_code: str, db: Session = Depends(get_db)):
stock = db.query(CollectionStock).filter(
CollectionStock.collection_id == col_id,
CollectionStock.stock_code == stock_code,
).first()
if not stock:
raise HTTPException(404, "该股票不在集合中")
db.delete(stock)
db.commit()
return {"ok": True}
@router.post("/{col_id}/share", response_model=ShareResponse)
def share_collection(col_id: int, db: Session = Depends(get_db)):
col = db.query(Collection).filter(Collection.id == col_id).first()
if not col:
raise HTTPException(404, "集合不存在")
if not col.short_code:
col.short_code = _generate_short_code()
db.commit()
db.refresh(col)
return ShareResponse(
short_code=col.short_code,
url=f"/s/{col.short_code}",
)
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import Collection
from ..schemas import CollectionShareOut, CollectionOut, CollectionStockOut
from ..services.stock_data import get_kline, get_realtime_quote
router = APIRouter()
@router.get("/s/{short_code}")
def get_shared_collection(short_code: str, db: Session = Depends(get_db)):
col = db.query(Collection).filter(Collection.short_code == short_code).first()
if not col:
raise HTTPException(404, "分享链接不存在或已失效")
stocks_data = {}
for stock in col.stocks:
kline = get_kline(stock.stock_code, months=12)
quote = get_realtime_quote(stock.stock_code)
stocks_data[stock.stock_code] = {
"kline": kline,
"realtime": quote,
"added_date": str(stock.added_date),
}
return CollectionShareOut(
collection=col,
stocks_data=stocks_data,
)
+24
View File
@@ -0,0 +1,24 @@
from fastapi import APIRouter, Query
from ..services.stock_data import search_stocks, get_kline, get_realtime_quote
router = APIRouter()
@router.get("/search")
def search(q: str = Query(""), limit: int = Query(20)):
results = search_stocks(q, limit)
return {"data": results}
@router.get("/kline/{code}")
def kline(code: str, months: int = Query(12, ge=1, le=60)):
data = get_kline(code, months)
return {"data": data}
@router.get("/realtime/{code}")
def realtime(code: str):
quote = get_realtime_quote(code)
if quote is None:
return {"data": None, "error": "未找到该股票"}
return {"data": quote}
+77
View File
@@ -0,0 +1,77 @@
from datetime import date, datetime
from pydantic import BaseModel
from typing import Optional
# --- Stock ---
class StockItem(BaseModel):
code: str
name: str
class KlineItem(BaseModel):
date: str
open: float
close: float
high: float
low: float
volume: float
class RealtimeQuote(BaseModel):
code: str
name: str
price: float
change: float # 涨跌额
change_pct: float # 涨跌幅 %
high: float
low: float
open: float
prev_close: float
volume: float
amount: float
# --- Collection ---
class StockInput(BaseModel):
code: str
name: str
added_date: str # YYYY-MM-DD
class CollectionCreate(BaseModel):
name: str
stocks: list[StockInput] = []
class CollectionUpdate(BaseModel):
name: Optional[str] = None
class CollectionStockOut(BaseModel):
model_config = {"from_attributes": True}
id: int
stock_code: str
stock_name: str
added_date: date
class CollectionOut(BaseModel):
model_config = {"from_attributes": True}
id: int
name: str
short_code: Optional[str]
created_at: datetime
stocks: list[CollectionStockOut]
class CollectionListItem(BaseModel):
id: int
name: str
stock_count: int
short_code: Optional[str]
created_at: datetime
# --- Share ---
class ShareResponse(BaseModel):
short_code: str
url: str
class CollectionShareOut(BaseModel):
collection: CollectionOut
stocks_data: dict # code -> {kline: [...], realtime: {...}}
View File
+150
View File
@@ -0,0 +1,150 @@
import json
import urllib.request
import akshare as ak
from datetime import datetime, timedelta
from typing import Optional
# Cache for stock master list
_stock_list_cache: list[dict] = []
_stock_list_ts: Optional[datetime] = None
CACHE_TTL = timedelta(hours=6)
def _load_stock_list() -> list[dict]:
global _stock_list_cache, _stock_list_ts
now = datetime.now()
if _stock_list_cache and _stock_list_ts and (now - _stock_list_ts) < CACHE_TTL:
return _stock_list_cache
try:
df = ak.stock_info_a_code_name()
_stock_list_cache = df.to_dict(orient="records")
_stock_list_ts = now
except Exception:
if _stock_list_cache:
return _stock_list_cache
_stock_list_cache = []
return _stock_list_cache
def search_stocks(query: str, limit: int = 20) -> list[dict]:
if not query:
return []
stocks = _load_stock_list()
q = query.upper()
results = []
for s in stocks:
code = str(s.get("code", ""))
name = str(s.get("name", ""))
if q in code or q in name:
results.append({"code": code, "name": name})
if len(results) >= limit:
break
return results
def _sina_symbol(code: str) -> str:
"""Convert 6-digit code to Sina format: sh600519 or sz000001."""
if code.startswith("6") or code.startswith("9"):
return f"sh{code}"
return f"sz{code}"
def _tencent_symbol(code: str) -> str:
"""Convert to Tencent format."""
if code.startswith("6") or code.startswith("9"):
return f"sh{code}"
return f"sz{code}"
def get_kline(code: str, months: int = 12) -> list[dict]:
"""Get daily K-line via Sina Finance."""
end_date = datetime.now()
start_date = end_date - timedelta(days=months * 31)
symbol = _sina_symbol(code)
# Sina K-line API - scale=240 means daily, datalen estimated
days_needed = (end_date - start_date).days
url = (
f"https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/"
f"CN_MarketData.getKLineData?symbol={symbol}&scale=240&ma=no&datalen={days_needed}"
)
try:
req = urllib.request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Referer": "https://finance.sina.com.cn/",
},
)
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("gbk")
items = json.loads(raw)
except Exception:
return []
results = []
for item in items:
results.append({
"date": item["day"],
"open": float(item["open"]),
"close": float(item["close"]),
"high": float(item["high"]),
"low": float(item["low"]),
"volume": float(item.get("volume", 0)),
})
return results
def get_realtime_quote(code: str) -> Optional[dict]:
"""Get real-time quote via Tencent Finance."""
symbol = _tencent_symbol(code)
url = f"https://qt.gtimg.cn/q={symbol}"
try:
req = urllib.request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Referer": "https://gu.qq.com/",
},
)
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("gbk")
except Exception:
return None
# Tencent format fields:
# [0]=market [1]=name [2]=code [3]=price [4]=prev_close [5]=open
# [6]=volume(手) [30]=datetime [31]=change [32]=change_pct [33]=high [34]=low
try:
start = raw.index('"') + 1
end = raw.rindex('"')
parts = raw[start:end].split("~")
if len(parts) < 35:
return None
price = float(parts[3])
prev_close = float(parts[4])
open_p = float(parts[5])
high = float(parts[33])
low = float(parts[34])
change = float(parts[31])
change_pct = float(parts[32])
volume_shares = float(parts[6]) * 100 # 手 -> 股
return {
"code": code,
"name": parts[1],
"price": price,
"change": change,
"change_pct": change_pct,
"high": high,
"low": low,
"open": open_p,
"prev_close": prev_close,
"volume": volume_shares,
"amount": 0,
}
except (ValueError, IndexError):
return None