797 lines
26 KiB
Python
797 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
StockBuddy SQLite 数据层
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
import pandas as pd
|
|
|
|
DATA_DIR = Path.home() / ".stockbuddy"
|
|
DB_PATH = DATA_DIR / "stockbuddy.db"
|
|
ANALYSIS_CACHE_TTL_SECONDS = 600
|
|
ANALYSIS_CACHE_MAX_ROWS = 1000
|
|
AUX_CACHE_TTL_SECONDS = 1800
|
|
AUX_CACHE_MAX_ROWS = 2000
|
|
|
|
|
|
def _utc_now_iso() -> str:
|
|
return datetime.utcnow().replace(microsecond=0).isoformat()
|
|
|
|
|
|
def ensure_data_dir() -> None:
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def get_connection() -> sqlite3.Connection:
|
|
ensure_data_dir()
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
return conn
|
|
|
|
|
|
def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
|
try:
|
|
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
return [row[1] for row in rows]
|
|
except sqlite3.OperationalError:
|
|
return []
|
|
|
|
|
|
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
|
|
columns = _table_columns(conn, table)
|
|
if columns and column not in columns:
|
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
|
|
|
|
|
|
def _migrate_schema(conn: sqlite3.Connection) -> None:
|
|
positions_cols = _table_columns(conn, "positions")
|
|
if positions_cols and "watchlist_id" not in positions_cols:
|
|
conn.execute("DROP TABLE positions")
|
|
positions_cols = []
|
|
|
|
if positions_cols:
|
|
_ensure_column(conn, "positions", "account_id", "account_id INTEGER")
|
|
|
|
|
|
def init_db() -> None:
|
|
with get_connection() as conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS watchlist (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
code TEXT NOT NULL UNIQUE,
|
|
market TEXT NOT NULL,
|
|
tencent_symbol TEXT NOT NULL,
|
|
name TEXT,
|
|
exchange TEXT,
|
|
currency TEXT,
|
|
last_price REAL,
|
|
pe REAL,
|
|
pb REAL,
|
|
market_cap TEXT,
|
|
week52_high REAL,
|
|
week52_low REAL,
|
|
quote_time TEXT,
|
|
is_watched INTEGER NOT NULL DEFAULT 0,
|
|
meta_json TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_watchlist_market ON watchlist (market, code);
|
|
CREATE INDEX IF NOT EXISTS idx_watchlist_is_watched ON watchlist (is_watched, updated_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
market TEXT,
|
|
currency TEXT,
|
|
cash_balance REAL NOT NULL DEFAULT 0,
|
|
available_cash REAL,
|
|
note TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_accounts_market_currency
|
|
ON accounts (market, currency);
|
|
|
|
CREATE TABLE IF NOT EXISTS stock_rules (
|
|
code TEXT PRIMARY KEY,
|
|
lot_size INTEGER,
|
|
tick_size REAL,
|
|
allows_odd_lot INTEGER NOT NULL DEFAULT 0,
|
|
source TEXT DEFAULT 'manual',
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY (code) REFERENCES watchlist(code) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS kline_daily (
|
|
code TEXT NOT NULL,
|
|
trade_date TEXT NOT NULL,
|
|
open REAL NOT NULL,
|
|
high REAL NOT NULL,
|
|
low REAL NOT NULL,
|
|
close REAL NOT NULL,
|
|
volume REAL NOT NULL,
|
|
adj_type TEXT NOT NULL DEFAULT 'qfq',
|
|
source TEXT NOT NULL DEFAULT 'tencent',
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (code, trade_date, adj_type)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_kline_daily_code_date
|
|
ON kline_daily (code, trade_date DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS positions (
|
|
watchlist_id INTEGER PRIMARY KEY,
|
|
account_id INTEGER,
|
|
buy_price REAL NOT NULL,
|
|
shares INTEGER NOT NULL,
|
|
buy_date TEXT,
|
|
note TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY (watchlist_id) REFERENCES watchlist(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS analysis_cache (
|
|
cache_key TEXT PRIMARY KEY,
|
|
code TEXT NOT NULL,
|
|
period TEXT NOT NULL,
|
|
result_json TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_analysis_cache_expires_at
|
|
ON analysis_cache (expires_at);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_analysis_cache_code_period
|
|
ON analysis_cache (code, period, created_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS aux_cache (
|
|
cache_key TEXT PRIMARY KEY,
|
|
code TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
result_json TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_aux_cache_expires_at
|
|
ON aux_cache (expires_at);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_aux_cache_code_category
|
|
ON aux_cache (code, category, created_at DESC);
|
|
"""
|
|
)
|
|
_migrate_schema(conn)
|
|
conn.commit()
|
|
|
|
|
|
def cleanup_analysis_cache(conn: sqlite3.Connection | None = None) -> None:
|
|
own_conn = conn is None
|
|
conn = conn or get_connection()
|
|
try:
|
|
now = _utc_now_iso()
|
|
conn.execute("DELETE FROM analysis_cache WHERE expires_at <= ?", (now,))
|
|
overflow = conn.execute(
|
|
"SELECT COUNT(*) AS cnt FROM analysis_cache"
|
|
).fetchone()["cnt"] - ANALYSIS_CACHE_MAX_ROWS
|
|
if overflow > 0:
|
|
conn.execute(
|
|
"""
|
|
DELETE FROM analysis_cache
|
|
WHERE cache_key IN (
|
|
SELECT cache_key
|
|
FROM analysis_cache
|
|
ORDER BY created_at ASC
|
|
LIMIT ?
|
|
)
|
|
""",
|
|
(overflow,),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
if own_conn:
|
|
conn.close()
|
|
|
|
|
|
def clear_analysis_cache() -> int:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
count = conn.execute("SELECT COUNT(*) AS cnt FROM analysis_cache").fetchone()["cnt"]
|
|
conn.execute("DELETE FROM analysis_cache")
|
|
conn.commit()
|
|
return count
|
|
|
|
|
|
def cleanup_aux_cache(conn: sqlite3.Connection | None = None) -> None:
|
|
own_conn = conn is None
|
|
conn = conn or get_connection()
|
|
try:
|
|
now = _utc_now_iso()
|
|
conn.execute("DELETE FROM aux_cache WHERE expires_at <= ?", (now,))
|
|
overflow = conn.execute(
|
|
"SELECT COUNT(*) AS cnt FROM aux_cache"
|
|
).fetchone()["cnt"] - AUX_CACHE_MAX_ROWS
|
|
if overflow > 0:
|
|
conn.execute(
|
|
"""
|
|
DELETE FROM aux_cache
|
|
WHERE cache_key IN (
|
|
SELECT cache_key
|
|
FROM aux_cache
|
|
ORDER BY created_at ASC
|
|
LIMIT ?
|
|
)
|
|
""",
|
|
(overflow,),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
if own_conn:
|
|
conn.close()
|
|
|
|
|
|
def clear_aux_cache() -> int:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
count = conn.execute("SELECT COUNT(*) AS cnt FROM aux_cache").fetchone()["cnt"]
|
|
conn.execute("DELETE FROM aux_cache")
|
|
conn.commit()
|
|
return count
|
|
|
|
|
|
def get_cached_aux(code: str, category: str) -> dict | None:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
cleanup_aux_cache(conn)
|
|
cache_key = f"{code}:{category}"
|
|
row = conn.execute(
|
|
"""
|
|
SELECT result_json
|
|
FROM aux_cache
|
|
WHERE cache_key = ? AND expires_at > ?
|
|
""",
|
|
(cache_key, _utc_now_iso()),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
result = json.loads(row["result_json"])
|
|
result["_from_cache"] = True
|
|
return result
|
|
|
|
|
|
def set_cached_aux(code: str, category: str, result: dict, ttl_seconds: int = AUX_CACHE_TTL_SECONDS) -> None:
|
|
init_db()
|
|
now = _utc_now_iso()
|
|
expires_at = (datetime.utcnow() + timedelta(seconds=ttl_seconds)).replace(microsecond=0).isoformat()
|
|
cache_key = f"{code}:{category}"
|
|
with get_connection() as conn:
|
|
cleanup_aux_cache(conn)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO aux_cache (cache_key, code, category, result_json, expires_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(cache_key) DO UPDATE SET
|
|
result_json = excluded.result_json,
|
|
expires_at = excluded.expires_at,
|
|
created_at = excluded.created_at
|
|
""",
|
|
(cache_key, code, category, json.dumps(result, ensure_ascii=False), expires_at, now),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def get_cached_analysis(code: str, period: str) -> dict | None:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
cleanup_analysis_cache(conn)
|
|
cache_key = f"{code}:{period}"
|
|
row = conn.execute(
|
|
"""
|
|
SELECT result_json
|
|
FROM analysis_cache
|
|
WHERE cache_key = ? AND expires_at > ?
|
|
""",
|
|
(cache_key, _utc_now_iso()),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
result = json.loads(row["result_json"])
|
|
result["_from_cache"] = True
|
|
return result
|
|
|
|
|
|
def set_cached_analysis(code: str, period: str, result: dict) -> None:
|
|
init_db()
|
|
now = _utc_now_iso()
|
|
expires_at = (datetime.utcnow() + timedelta(seconds=ANALYSIS_CACHE_TTL_SECONDS)).replace(microsecond=0).isoformat()
|
|
cache_key = f"{code}:{period}"
|
|
with get_connection() as conn:
|
|
cleanup_analysis_cache(conn)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO analysis_cache (cache_key, code, period, result_json, expires_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(cache_key) DO UPDATE SET
|
|
result_json = excluded.result_json,
|
|
expires_at = excluded.expires_at,
|
|
created_at = excluded.created_at
|
|
""",
|
|
(cache_key, code, period, json.dumps(result, ensure_ascii=False), expires_at, now),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def upsert_watchlist_item(
|
|
*,
|
|
code: str,
|
|
market: str,
|
|
tencent_symbol: str,
|
|
name: str | None = None,
|
|
exchange: str | None = None,
|
|
currency: str | None = None,
|
|
last_price: float | None = None,
|
|
pe: float | None = None,
|
|
pb: float | None = None,
|
|
market_cap: str | None = None,
|
|
week52_high: float | None = None,
|
|
week52_low: float | None = None,
|
|
quote_time: str | None = None,
|
|
is_watched: bool | None = None,
|
|
meta: dict | None = None,
|
|
) -> dict:
|
|
init_db()
|
|
now = _utc_now_iso()
|
|
with get_connection() as conn:
|
|
existing = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
|
|
created_at = existing["created_at"] if existing else now
|
|
current_is_watched = existing["is_watched"] if existing else 0
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO watchlist (
|
|
code, market, tencent_symbol, name, exchange, currency, last_price,
|
|
pe, pb, market_cap, week52_high, week52_low, quote_time, is_watched,
|
|
meta_json, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(code) DO UPDATE SET
|
|
market = excluded.market,
|
|
tencent_symbol = excluded.tencent_symbol,
|
|
name = COALESCE(excluded.name, watchlist.name),
|
|
exchange = COALESCE(excluded.exchange, watchlist.exchange),
|
|
currency = COALESCE(excluded.currency, watchlist.currency),
|
|
last_price = COALESCE(excluded.last_price, watchlist.last_price),
|
|
pe = COALESCE(excluded.pe, watchlist.pe),
|
|
pb = COALESCE(excluded.pb, watchlist.pb),
|
|
market_cap = COALESCE(excluded.market_cap, watchlist.market_cap),
|
|
week52_high = COALESCE(excluded.week52_high, watchlist.week52_high),
|
|
week52_low = COALESCE(excluded.week52_low, watchlist.week52_low),
|
|
quote_time = COALESCE(excluded.quote_time, watchlist.quote_time),
|
|
is_watched = excluded.is_watched,
|
|
meta_json = COALESCE(excluded.meta_json, watchlist.meta_json),
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(
|
|
code,
|
|
market,
|
|
tencent_symbol,
|
|
name,
|
|
exchange,
|
|
currency,
|
|
last_price,
|
|
pe,
|
|
pb,
|
|
market_cap,
|
|
week52_high,
|
|
week52_low,
|
|
quote_time,
|
|
int(current_is_watched if is_watched is None else is_watched),
|
|
json.dumps(meta, ensure_ascii=False) if meta else None,
|
|
created_at,
|
|
now,
|
|
),
|
|
)
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
|
|
return dict(row)
|
|
|
|
|
|
def get_watchlist_item(code: str) -> dict | None:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
row = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def list_watchlist(only_watched: bool = False) -> list[dict]:
|
|
init_db()
|
|
sql = "SELECT * FROM watchlist"
|
|
params = ()
|
|
if only_watched:
|
|
sql += " WHERE is_watched = 1"
|
|
sql += " ORDER BY updated_at DESC, code ASC"
|
|
with get_connection() as conn:
|
|
rows = conn.execute(sql, params).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
def set_watch_status(code: str, watched: bool) -> dict | None:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
row = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
|
|
if not row:
|
|
return None
|
|
conn.execute(
|
|
"UPDATE watchlist SET is_watched = ?, updated_at = ? WHERE code = ?",
|
|
(int(watched), _utc_now_iso(), code),
|
|
)
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def list_accounts() -> list[dict]:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT id, name, market, currency, cash_balance, available_cash, note, created_at, updated_at
|
|
FROM accounts
|
|
ORDER BY market IS NULL, market, currency IS NULL, currency, name
|
|
"""
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
def get_account(identifier: int | str) -> dict | None:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
if isinstance(identifier, int) or (isinstance(identifier, str) and identifier.isdigit()):
|
|
row = conn.execute("SELECT * FROM accounts WHERE id = ?", (int(identifier),)).fetchone()
|
|
else:
|
|
row = conn.execute("SELECT * FROM accounts WHERE name = ?", (identifier,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def upsert_account(
|
|
*,
|
|
name: str,
|
|
market: str | None = None,
|
|
currency: str | None = None,
|
|
cash_balance: float | None = None,
|
|
available_cash: float | None = None,
|
|
note: str = "",
|
|
) -> dict:
|
|
init_db()
|
|
now = _utc_now_iso()
|
|
with get_connection() as conn:
|
|
existing = conn.execute("SELECT * FROM accounts WHERE name = ?", (name,)).fetchone()
|
|
created_at = existing["created_at"] if existing else now
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO accounts (name, market, currency, cash_balance, available_cash, note, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
market = COALESCE(excluded.market, accounts.market),
|
|
currency = COALESCE(excluded.currency, accounts.currency),
|
|
cash_balance = COALESCE(excluded.cash_balance, accounts.cash_balance),
|
|
available_cash = COALESCE(excluded.available_cash, accounts.available_cash),
|
|
note = CASE WHEN excluded.note = '' THEN accounts.note ELSE excluded.note END,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(
|
|
name,
|
|
market,
|
|
currency,
|
|
0 if cash_balance is None else cash_balance,
|
|
available_cash,
|
|
note,
|
|
created_at,
|
|
now,
|
|
),
|
|
)
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM accounts WHERE name = ?", (name,)).fetchone()
|
|
return dict(row)
|
|
|
|
|
|
def upsert_stock_rule(
|
|
*,
|
|
code: str,
|
|
lot_size: int | None = None,
|
|
tick_size: float | None = None,
|
|
allows_odd_lot: bool = False,
|
|
source: str = "manual",
|
|
) -> dict:
|
|
init_db()
|
|
now = _utc_now_iso()
|
|
with get_connection() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO stock_rules (code, lot_size, tick_size, allows_odd_lot, source, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(code) DO UPDATE SET
|
|
lot_size = COALESCE(excluded.lot_size, stock_rules.lot_size),
|
|
tick_size = COALESCE(excluded.tick_size, stock_rules.tick_size),
|
|
allows_odd_lot = excluded.allows_odd_lot,
|
|
source = excluded.source,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(code, lot_size, tick_size, int(allows_odd_lot), source, now),
|
|
)
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM stock_rules WHERE code = ?", (code,)).fetchone()
|
|
return dict(row)
|
|
|
|
|
|
def get_stock_rule(code: str) -> dict | None:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
row = conn.execute("SELECT * FROM stock_rules WHERE code = ?", (code,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def get_latest_kline_date(code: str, adj_type: str = "qfq") -> str | None:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
row = conn.execute(
|
|
"SELECT MAX(trade_date) AS latest_date FROM kline_daily WHERE code = ? AND adj_type = ?",
|
|
(code, adj_type),
|
|
).fetchone()
|
|
return row["latest_date"] if row and row["latest_date"] else None
|
|
|
|
|
|
def upsert_kline_df(code: str, df, adj_type: str = "qfq", source: str = "tencent") -> int:
|
|
import pandas as pd
|
|
|
|
if df.empty:
|
|
return 0
|
|
init_db()
|
|
now = _utc_now_iso()
|
|
records = []
|
|
for idx, row in df.sort_index().iterrows():
|
|
trade_date = pd.Timestamp(idx).strftime("%Y-%m-%d")
|
|
records.append(
|
|
(
|
|
code,
|
|
trade_date,
|
|
float(row["Open"]),
|
|
float(row["High"]),
|
|
float(row["Low"]),
|
|
float(row["Close"]),
|
|
float(row["Volume"]),
|
|
adj_type,
|
|
source,
|
|
now,
|
|
)
|
|
)
|
|
with get_connection() as conn:
|
|
conn.executemany(
|
|
"""
|
|
INSERT INTO kline_daily (
|
|
code, trade_date, open, high, low, close, volume, adj_type, source, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(code, trade_date, adj_type) DO UPDATE SET
|
|
open = excluded.open,
|
|
high = excluded.high,
|
|
low = excluded.low,
|
|
close = excluded.close,
|
|
volume = excluded.volume,
|
|
source = excluded.source,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
records,
|
|
)
|
|
conn.commit()
|
|
return len(records)
|
|
|
|
|
|
def get_kline_df(code: str, limit: int, adj_type: str = "qfq"):
|
|
import pandas as pd
|
|
|
|
init_db()
|
|
with get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT trade_date, open, high, low, close, volume
|
|
FROM kline_daily
|
|
WHERE code = ? AND adj_type = ?
|
|
ORDER BY trade_date DESC
|
|
LIMIT ?
|
|
""",
|
|
(code, adj_type, limit),
|
|
).fetchall()
|
|
if not rows:
|
|
return pd.DataFrame()
|
|
records = [
|
|
{
|
|
"Date": row["trade_date"],
|
|
"Open": row["open"],
|
|
"High": row["high"],
|
|
"Low": row["low"],
|
|
"Close": row["close"],
|
|
"Volume": row["volume"],
|
|
}
|
|
for row in reversed(rows)
|
|
]
|
|
df = pd.DataFrame(records)
|
|
df["Date"] = pd.to_datetime(df["Date"])
|
|
df.set_index("Date", inplace=True)
|
|
return df
|
|
|
|
|
|
def list_positions() -> list[dict]:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT
|
|
p.watchlist_id,
|
|
p.account_id,
|
|
a.name AS account_name,
|
|
a.market AS account_market,
|
|
a.currency AS account_currency,
|
|
a.cash_balance AS account_cash_balance,
|
|
a.available_cash AS account_available_cash,
|
|
w.code,
|
|
w.market,
|
|
w.name,
|
|
w.currency,
|
|
p.buy_price,
|
|
p.shares,
|
|
p.buy_date,
|
|
p.note,
|
|
p.created_at AS added_at,
|
|
p.updated_at,
|
|
sr.lot_size,
|
|
sr.tick_size,
|
|
sr.allows_odd_lot,
|
|
sr.source AS lot_rule_source
|
|
FROM positions p
|
|
JOIN watchlist w ON w.id = p.watchlist_id
|
|
LEFT JOIN accounts a ON a.id = p.account_id
|
|
LEFT JOIN stock_rules sr ON sr.code = w.code
|
|
ORDER BY w.code ASC
|
|
"""
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
def get_position(code: str) -> dict | None:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT
|
|
p.watchlist_id,
|
|
p.account_id,
|
|
a.name AS account_name,
|
|
a.market AS account_market,
|
|
a.currency AS account_currency,
|
|
a.cash_balance AS account_cash_balance,
|
|
a.available_cash AS account_available_cash,
|
|
w.code,
|
|
w.market,
|
|
w.name,
|
|
w.currency,
|
|
p.buy_price,
|
|
p.shares,
|
|
p.buy_date,
|
|
p.note,
|
|
p.created_at AS added_at,
|
|
p.updated_at,
|
|
sr.lot_size,
|
|
sr.tick_size,
|
|
sr.allows_odd_lot,
|
|
sr.source AS lot_rule_source
|
|
FROM positions p
|
|
JOIN watchlist w ON w.id = p.watchlist_id
|
|
LEFT JOIN accounts a ON a.id = p.account_id
|
|
LEFT JOIN stock_rules sr ON sr.code = w.code
|
|
WHERE w.code = ?
|
|
""",
|
|
(code,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def upsert_position(
|
|
*,
|
|
code: str,
|
|
market: str,
|
|
tencent_symbol: str,
|
|
buy_price: float,
|
|
shares: int,
|
|
buy_date: str | None,
|
|
note: str = "",
|
|
account_id: int | None = None,
|
|
name: str | None = None,
|
|
currency: str | None = None,
|
|
meta: dict | None = None,
|
|
) -> dict:
|
|
init_db()
|
|
watch = upsert_watchlist_item(
|
|
code=code,
|
|
market=market,
|
|
tencent_symbol=tencent_symbol,
|
|
name=name,
|
|
currency=currency,
|
|
is_watched=True,
|
|
meta=meta,
|
|
)
|
|
now = _utc_now_iso()
|
|
with get_connection() as conn:
|
|
existing = conn.execute(
|
|
"SELECT created_at, account_id FROM positions WHERE watchlist_id = ?", (watch["id"],)
|
|
).fetchone()
|
|
created_at = existing["created_at"] if existing else now
|
|
account_id_value = account_id if account_id is not None else (existing["account_id"] if existing else None)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO positions (watchlist_id, account_id, buy_price, shares, buy_date, note, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(watchlist_id) DO UPDATE SET
|
|
account_id = excluded.account_id,
|
|
buy_price = excluded.buy_price,
|
|
shares = excluded.shares,
|
|
buy_date = excluded.buy_date,
|
|
note = excluded.note,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(watch["id"], account_id_value, buy_price, shares, buy_date, note, created_at, now),
|
|
)
|
|
conn.commit()
|
|
return get_position(code)
|
|
|
|
|
|
def remove_position(code: str) -> bool:
|
|
init_db()
|
|
with get_connection() as conn:
|
|
row = conn.execute("SELECT id FROM watchlist WHERE code = ?", (code,)).fetchone()
|
|
if not row:
|
|
return False
|
|
cur = conn.execute("DELETE FROM positions WHERE watchlist_id = ?", (row["id"],))
|
|
conn.commit()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def update_position_fields(
|
|
code: str,
|
|
price: float | None = None,
|
|
shares: int | None = None,
|
|
note: str | None = None,
|
|
account_id: int | None = None,
|
|
) -> dict | None:
|
|
current = get_position(code)
|
|
if not current:
|
|
return None
|
|
watch = get_watchlist_item(code)
|
|
return upsert_position(
|
|
code=code,
|
|
market=watch["market"],
|
|
tencent_symbol=watch["tencent_symbol"],
|
|
buy_price=price if price is not None else current["buy_price"],
|
|
shares=shares if shares is not None else current["shares"],
|
|
buy_date=current.get("buy_date"),
|
|
note=note if note is not None else current.get("note", ""),
|
|
account_id=account_id if account_id is not None else current.get("account_id"),
|
|
name=watch.get("name"),
|
|
currency=watch.get("currency"),
|
|
meta=json.loads(watch["meta_json"]) if watch.get("meta_json") else None,
|
|
)
|