Add account-aware execution constraints
This commit is contained in:
190
scripts/db.py
190
scripts/db.py
@@ -48,15 +48,24 @@ def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
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:
|
||||
_migrate_schema(conn)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS watchlist (
|
||||
@@ -83,6 +92,31 @@ def init_db() -> None:
|
||||
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,
|
||||
@@ -102,13 +136,15 @@ def init_db() -> None:
|
||||
|
||||
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 (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 (
|
||||
@@ -142,6 +178,7 @@ def init_db() -> None:
|
||||
ON aux_cache (code, category, created_at DESC);
|
||||
"""
|
||||
)
|
||||
_migrate_schema(conn)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -408,6 +445,107 @@ def set_watch_status(code: str, watched: bool) -> dict | None:
|
||||
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:
|
||||
@@ -504,6 +642,12 @@ def list_positions() -> list[dict]:
|
||||
"""
|
||||
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,
|
||||
@@ -513,9 +657,15 @@ def list_positions() -> list[dict]:
|
||||
p.buy_date,
|
||||
p.note,
|
||||
p.created_at AS added_at,
|
||||
p.updated_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()
|
||||
@@ -529,6 +679,12 @@ def get_position(code: str) -> dict | None:
|
||||
"""
|
||||
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,
|
||||
@@ -538,9 +694,15 @@ def get_position(code: str) -> dict | None:
|
||||
p.buy_date,
|
||||
p.note,
|
||||
p.created_at AS added_at,
|
||||
p.updated_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,),
|
||||
@@ -557,6 +719,7 @@ def upsert_position(
|
||||
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,
|
||||
@@ -574,21 +737,23 @@ def upsert_position(
|
||||
now = _utc_now_iso()
|
||||
with get_connection() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT created_at FROM positions WHERE watchlist_id = ?", (watch["id"],)
|
||||
"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, buy_price, shares, buy_date, note, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
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"], buy_price, shares, buy_date, note, created_at, now),
|
||||
(watch["id"], account_id_value, buy_price, shares, buy_date, note, created_at, now),
|
||||
)
|
||||
conn.commit()
|
||||
return get_position(code)
|
||||
@@ -605,7 +770,13 @@ def remove_position(code: str) -> bool:
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def update_position_fields(code: str, price: float | None = None, shares: int | None = None, note: str | None = None) -> dict | None:
|
||||
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
|
||||
@@ -618,6 +789,7 @@ def update_position_fields(code: str, price: float | None = None, shares: int |
|
||||
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,
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
|
||||
用法:
|
||||
python3 portfolio_manager.py list
|
||||
python3 portfolio_manager.py add <代码> --price <买入价> --shares <数量> [--date <日期>] [--note <备注>]
|
||||
python3 portfolio_manager.py add <代码> --price <买入价> --shares <数量> [--date <日期>] [--note <备注>] [--account <账户名或ID>]
|
||||
python3 portfolio_manager.py remove <代码>
|
||||
python3 portfolio_manager.py update <代码> [--price <价格>] [--shares <数量>] [--note <备注>]
|
||||
python3 portfolio_manager.py update <代码> [--price <价格>] [--shares <数量>] [--note <备注>] [--account <账户名或ID>]
|
||||
python3 portfolio_manager.py analyze [--output <输出文件>]
|
||||
python3 portfolio_manager.py account-list
|
||||
python3 portfolio_manager.py account-upsert <账户名> [--market <市场>] [--currency <币种>] [--cash <总现金>] [--available-cash <可用现金>] [--note <备注>]
|
||||
python3 portfolio_manager.py rule-set <代码> [--lot-size <每手股数>] [--tick-size <最小价位>] [--odd-lot]
|
||||
python3 portfolio_manager.py watch-list
|
||||
python3 portfolio_manager.py watch-add <代码>
|
||||
python3 portfolio_manager.py watch-remove <代码>
|
||||
@@ -20,19 +23,24 @@ import json
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from db import (
|
||||
DB_PATH,
|
||||
get_account,
|
||||
get_watchlist_item,
|
||||
init_db,
|
||||
list_accounts as db_list_accounts,
|
||||
list_positions as db_list_positions,
|
||||
list_watchlist as db_list_watchlist,
|
||||
remove_position as db_remove_position,
|
||||
set_watch_status,
|
||||
update_position_fields,
|
||||
upsert_account,
|
||||
upsert_position,
|
||||
upsert_stock_rule,
|
||||
upsert_watchlist_item,
|
||||
)
|
||||
from analyze_stock import fetch_tencent_quote, normalize_stock_code, analyze_stock
|
||||
@@ -41,14 +49,18 @@ except ImportError:
|
||||
sys.path.insert(0, script_dir)
|
||||
from db import (
|
||||
DB_PATH,
|
||||
get_account,
|
||||
get_watchlist_item,
|
||||
init_db,
|
||||
list_accounts as db_list_accounts,
|
||||
list_positions as db_list_positions,
|
||||
list_watchlist as db_list_watchlist,
|
||||
remove_position as db_remove_position,
|
||||
set_watch_status,
|
||||
update_position_fields,
|
||||
upsert_account,
|
||||
upsert_position,
|
||||
upsert_stock_rule,
|
||||
upsert_watchlist_item,
|
||||
)
|
||||
from analyze_stock import fetch_tencent_quote, normalize_stock_code, analyze_stock
|
||||
@@ -58,6 +70,15 @@ def normalize_code(code: str) -> str:
|
||||
return normalize_stock_code(code)["code"]
|
||||
|
||||
|
||||
def resolve_account(account_ref: str | None):
|
||||
if not account_ref:
|
||||
return None
|
||||
account = get_account(account_ref)
|
||||
if not account:
|
||||
raise ValueError(f"账户不存在: {account_ref}")
|
||||
return account
|
||||
|
||||
|
||||
def ensure_watch_item(code: str, watched: bool = False) -> dict:
|
||||
stock = normalize_stock_code(code)
|
||||
quote = fetch_tencent_quote(stock["code"])
|
||||
@@ -81,6 +102,65 @@ def ensure_watch_item(code: str, watched: bool = False) -> dict:
|
||||
)
|
||||
|
||||
|
||||
def derive_execution_constraints(position: dict, current_price: float | None = None) -> dict:
|
||||
shares = int(position.get("shares") or 0)
|
||||
lot_size = position.get("lot_size")
|
||||
allows_odd_lot = bool(position.get("allows_odd_lot") or False)
|
||||
if lot_size is None or lot_size <= 0:
|
||||
whole_lots = None
|
||||
remainder = None
|
||||
can_partial_sell = None
|
||||
sellable_min_unit = 1 if allows_odd_lot else None
|
||||
else:
|
||||
whole_lots = shares // lot_size
|
||||
remainder = shares % lot_size
|
||||
can_partial_sell = allows_odd_lot or whole_lots >= 2 or remainder > 0
|
||||
sellable_min_unit = 1 if allows_odd_lot else lot_size
|
||||
|
||||
estimated_cash_if_sell_all = round(shares * current_price, 2) if current_price is not None else None
|
||||
return {
|
||||
"lot_size": lot_size,
|
||||
"allows_odd_lot": allows_odd_lot,
|
||||
"sellable_min_unit": sellable_min_unit,
|
||||
"whole_lots": whole_lots,
|
||||
"odd_lot_remainder": remainder,
|
||||
"can_partial_sell": can_partial_sell,
|
||||
"estimated_cash_if_sell_all": estimated_cash_if_sell_all,
|
||||
}
|
||||
|
||||
|
||||
def derive_position_snapshot(position: dict, analysis: dict) -> dict:
|
||||
current_price = analysis.get("current_price")
|
||||
buy_price = position.get("buy_price")
|
||||
shares = int(position.get("shares") or 0)
|
||||
cost = round((buy_price or 0) * shares, 2)
|
||||
market_value = round((current_price or 0) * shares, 2) if current_price is not None else None
|
||||
pnl = round((current_price - buy_price) * shares, 2) if current_price is not None and buy_price is not None else None
|
||||
pnl_pct = round((current_price - buy_price) / buy_price * 100, 2) if current_price is not None and buy_price not in (None, 0) else None
|
||||
execution = derive_execution_constraints(position, current_price)
|
||||
return {
|
||||
"buy_price": buy_price,
|
||||
"shares": shares,
|
||||
"buy_date": position.get("buy_date"),
|
||||
"cost": cost,
|
||||
"market_value": market_value,
|
||||
"pnl": pnl,
|
||||
"pnl_pct": pnl_pct,
|
||||
"note": position.get("note", ""),
|
||||
"currency": position.get("currency"),
|
||||
"market": position.get("market"),
|
||||
"account": {
|
||||
"id": position.get("account_id"),
|
||||
"name": position.get("account_name"),
|
||||
"market": position.get("account_market"),
|
||||
"currency": position.get("account_currency"),
|
||||
"cash_balance": position.get("account_cash_balance"),
|
||||
"available_cash": position.get("account_available_cash"),
|
||||
},
|
||||
"execution_constraints": execution,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 持仓管理
|
||||
# ─────────────────────────────────────────────
|
||||
@@ -99,7 +179,7 @@ def list_positions():
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def add_position(code: str, price: float, shares: int, date: str = None, note: str = ""):
|
||||
def add_position(code: str, price: float, shares: int, date: str = None, note: str = "", account_ref: str = None):
|
||||
init_db()
|
||||
normalized = normalize_stock_code(code)
|
||||
existing = next((p for p in db_list_positions() if p["code"] == normalized["code"]), None)
|
||||
@@ -107,6 +187,7 @@ def add_position(code: str, price: float, shares: int, date: str = None, note: s
|
||||
print(json.dumps({"error": f"{normalized['code']} 已在持仓中,请使用 update 命令更新"}, ensure_ascii=False))
|
||||
return
|
||||
|
||||
account = resolve_account(account_ref)
|
||||
watch = ensure_watch_item(normalized["code"], watched=True)
|
||||
position = upsert_position(
|
||||
code=normalized["code"],
|
||||
@@ -116,6 +197,7 @@ def add_position(code: str, price: float, shares: int, date: str = None, note: s
|
||||
shares=shares,
|
||||
buy_date=date or datetime.now().strftime("%Y-%m-%d"),
|
||||
note=note,
|
||||
account_id=account.get("id") if account else None,
|
||||
name=watch.get("name"),
|
||||
currency=watch.get("currency"),
|
||||
meta=json.loads(watch["meta_json"]) if watch.get("meta_json") else None,
|
||||
@@ -133,16 +215,53 @@ def remove_position(code: str):
|
||||
print(json.dumps({"message": f"已移除 {normalized_code}"}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def update_position(code: str, price: float = None, shares: int = None, note: str = None):
|
||||
def update_position(code: str, price: float = None, shares: int = None, note: str = None, account_ref: str = None):
|
||||
init_db()
|
||||
normalized_code = normalize_code(code)
|
||||
position = update_position_fields(normalized_code, price=price, shares=shares, note=note)
|
||||
account = resolve_account(account_ref) if account_ref else None
|
||||
position = update_position_fields(normalized_code, price=price, shares=shares, note=note, account_id=account.get("id") if account else None)
|
||||
if not position:
|
||||
print(json.dumps({"error": f"{normalized_code} 不在持仓中"}, ensure_ascii=False))
|
||||
return
|
||||
print(json.dumps({"message": f"已更新 {normalized_code}", "position": position}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 账户与交易规则管理
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def list_accounts():
|
||||
init_db()
|
||||
accounts = db_list_accounts()
|
||||
print(json.dumps({
|
||||
"total_accounts": len(accounts),
|
||||
"accounts": accounts,
|
||||
"portfolio_db": str(DB_PATH),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def save_account(name: str, market: str = None, currency: str = None, cash: float = None, available_cash: float = None, note: str = ""):
|
||||
init_db()
|
||||
account = upsert_account(
|
||||
name=name,
|
||||
market=market,
|
||||
currency=currency,
|
||||
cash_balance=cash,
|
||||
available_cash=available_cash,
|
||||
note=note,
|
||||
)
|
||||
print(json.dumps({"message": f"已保存账户 {name}", "account": account}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def set_rule(code: str, lot_size: int = None, tick_size: float = None, odd_lot: bool = False):
|
||||
init_db()
|
||||
normalized_code = normalize_code(code)
|
||||
ensure_watch_item(normalized_code, watched=False)
|
||||
rule = upsert_stock_rule(code=normalized_code, lot_size=lot_size, tick_size=tick_size, allows_odd_lot=odd_lot)
|
||||
print(json.dumps({"message": f"已设置 {normalized_code} 的交易规则", "rule": rule}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 关注池管理
|
||||
# ─────────────────────────────────────────────
|
||||
@@ -186,40 +305,44 @@ def analyze_portfolio(output_file: str = None):
|
||||
return
|
||||
|
||||
results = []
|
||||
account_totals = defaultdict(lambda: {"cost": 0.0, "market_value": 0.0})
|
||||
market_currency_totals = defaultdict(lambda: {"cost": 0.0, "market_value": 0.0})
|
||||
|
||||
for i, pos in enumerate(positions):
|
||||
code = pos["code"]
|
||||
print(f"正在分析 {code} ({i+1}/{len(positions)})...", file=sys.stderr)
|
||||
analysis = analyze_stock(code)
|
||||
|
||||
if analysis.get("current_price") and pos.get("buy_price"):
|
||||
current = analysis["current_price"]
|
||||
buy = pos["buy_price"]
|
||||
shares = pos.get("shares", 0)
|
||||
pnl = (current - buy) * shares
|
||||
pnl_pct = (current - buy) / buy * 100
|
||||
|
||||
analysis["portfolio_info"] = {
|
||||
"buy_price": buy,
|
||||
"shares": shares,
|
||||
"buy_date": pos.get("buy_date"),
|
||||
"cost": round(buy * shares, 2),
|
||||
"market_value": round(current * shares, 2),
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"note": pos.get("note", ""),
|
||||
"currency": pos.get("currency"),
|
||||
"market": pos.get("market"),
|
||||
}
|
||||
|
||||
analysis["portfolio_info"] = derive_position_snapshot(pos, analysis)
|
||||
results.append(analysis)
|
||||
|
||||
snapshot = analysis["portfolio_info"]
|
||||
market_value = snapshot.get("market_value") or 0.0
|
||||
cost = snapshot.get("cost") or 0.0
|
||||
|
||||
account_name = snapshot.get("account", {}).get("name") or "未分配账户"
|
||||
account_totals[account_name]["cost"] += cost
|
||||
account_totals[account_name]["market_value"] += market_value
|
||||
|
||||
mc_key = f"{snapshot.get('market') or 'UNKNOWN'}:{snapshot.get('currency') or 'UNKNOWN'}"
|
||||
market_currency_totals[mc_key]["cost"] += cost
|
||||
market_currency_totals[mc_key]["market_value"] += market_value
|
||||
|
||||
if i < len(positions) - 1 and not analysis.get("_from_cache"):
|
||||
time.sleep(2)
|
||||
|
||||
total_cost = sum(r.get("portfolio_info", {}).get("cost", 0) for r in results)
|
||||
total_value = sum(r.get("portfolio_info", {}).get("market_value", 0) for r in results)
|
||||
total_cost = sum(r.get("portfolio_info", {}).get("cost", 0) or 0 for r in results)
|
||||
total_value = sum(r.get("portfolio_info", {}).get("market_value", 0) or 0 for r in results)
|
||||
total_pnl = total_value - total_cost
|
||||
|
||||
for analysis in results:
|
||||
snapshot = analysis.get("portfolio_info", {})
|
||||
market_value = snapshot.get("market_value") or 0.0
|
||||
account = snapshot.get("account") or {}
|
||||
account_name = account.get("name") or "未分配账户"
|
||||
account_total_value = account_totals[account_name]["market_value"]
|
||||
snapshot["position_weight_of_portfolio_pct"] = round(market_value / total_value * 100, 2) if total_value > 0 else None
|
||||
snapshot["position_weight_of_account_pct"] = round(market_value / account_total_value * 100, 2) if account_total_value > 0 else None
|
||||
|
||||
summary = {
|
||||
"analysis_time": datetime.now().isoformat(),
|
||||
"total_positions": len(results),
|
||||
@@ -227,6 +350,24 @@ def analyze_portfolio(output_file: str = None):
|
||||
"total_market_value": round(total_value, 2),
|
||||
"total_pnl": round(total_pnl, 2),
|
||||
"total_pnl_pct": round(total_pnl / total_cost * 100, 2) if total_cost > 0 else 0,
|
||||
"accounts": [
|
||||
{
|
||||
"name": name,
|
||||
"total_cost": round(v["cost"], 2),
|
||||
"total_market_value": round(v["market_value"], 2),
|
||||
"total_pnl": round(v["market_value"] - v["cost"], 2),
|
||||
}
|
||||
for name, v in sorted(account_totals.items())
|
||||
],
|
||||
"market_currency_breakdown": [
|
||||
{
|
||||
"market_currency": key,
|
||||
"total_cost": round(v["cost"], 2),
|
||||
"total_market_value": round(v["market_value"], 2),
|
||||
"total_pnl": round(v["market_value"] - v["cost"], 2),
|
||||
}
|
||||
for key, v in sorted(market_currency_totals.items())
|
||||
],
|
||||
"positions": results,
|
||||
}
|
||||
|
||||
@@ -245,6 +386,7 @@ def main():
|
||||
subparsers = parser.add_subparsers(dest="command", help="子命令")
|
||||
|
||||
subparsers.add_parser("list", help="列出所有持仓")
|
||||
subparsers.add_parser("account-list", help="列出账户")
|
||||
|
||||
add_parser = subparsers.add_parser("add", help="添加持仓")
|
||||
add_parser.add_argument("code", help="股票代码")
|
||||
@@ -252,6 +394,7 @@ def main():
|
||||
add_parser.add_argument("--shares", type=int, required=True, help="持有数量")
|
||||
add_parser.add_argument("--date", help="买入日期 (YYYY-MM-DD)")
|
||||
add_parser.add_argument("--note", default="", help="备注")
|
||||
add_parser.add_argument("--account", help="账户名或账户ID")
|
||||
|
||||
rm_parser = subparsers.add_parser("remove", help="移除持仓")
|
||||
rm_parser.add_argument("code", help="股票代码")
|
||||
@@ -261,11 +404,26 @@ def main():
|
||||
up_parser.add_argument("--price", type=float, help="买入价格")
|
||||
up_parser.add_argument("--shares", type=int, help="持有数量")
|
||||
up_parser.add_argument("--note", help="备注")
|
||||
up_parser.add_argument("--account", help="账户名或账户ID")
|
||||
|
||||
analyze_parser = subparsers.add_parser("analyze", help="批量分析持仓")
|
||||
analyze_parser.add_argument("--output", help="输出JSON文件")
|
||||
|
||||
watch_list_parser = subparsers.add_parser("watch-list", help="列出关注池")
|
||||
account_parser = subparsers.add_parser("account-upsert", help="新增或更新账户")
|
||||
account_parser.add_argument("name", help="账户名")
|
||||
account_parser.add_argument("--market", help="市场,例如 HK/CN/US")
|
||||
account_parser.add_argument("--currency", help="币种,例如 HKD/CNY/USD")
|
||||
account_parser.add_argument("--cash", type=float, help="账户总现金")
|
||||
account_parser.add_argument("--available-cash", type=float, help="账户可用现金")
|
||||
account_parser.add_argument("--note", default="", help="备注")
|
||||
|
||||
rule_parser = subparsers.add_parser("rule-set", help="设置股票交易规则")
|
||||
rule_parser.add_argument("code", help="股票代码")
|
||||
rule_parser.add_argument("--lot-size", type=int, help="一手股数")
|
||||
rule_parser.add_argument("--tick-size", type=float, help="最小价位变动")
|
||||
rule_parser.add_argument("--odd-lot", action="store_true", help="允许碎股")
|
||||
|
||||
subparsers.add_parser("watch-list", help="列出关注池")
|
||||
watch_add_parser = subparsers.add_parser("watch-add", help="添加关注股票")
|
||||
watch_add_parser.add_argument("code", help="股票代码")
|
||||
watch_remove_parser = subparsers.add_parser("watch-remove", help="取消关注股票")
|
||||
@@ -273,24 +431,34 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "list":
|
||||
list_positions()
|
||||
elif args.command == "add":
|
||||
add_position(args.code, args.price, args.shares, args.date, args.note)
|
||||
elif args.command == "remove":
|
||||
remove_position(args.code)
|
||||
elif args.command == "update":
|
||||
update_position(args.code, args.price, args.shares, args.note)
|
||||
elif args.command == "analyze":
|
||||
analyze_portfolio(args.output)
|
||||
elif args.command == "watch-list":
|
||||
list_watchlist()
|
||||
elif args.command == "watch-add":
|
||||
add_watch(args.code)
|
||||
elif args.command == "watch-remove":
|
||||
remove_watch(args.code)
|
||||
else:
|
||||
parser.print_help()
|
||||
try:
|
||||
if args.command == "list":
|
||||
list_positions()
|
||||
elif args.command == "add":
|
||||
add_position(args.code, args.price, args.shares, args.date, args.note, args.account)
|
||||
elif args.command == "remove":
|
||||
remove_position(args.code)
|
||||
elif args.command == "update":
|
||||
update_position(args.code, args.price, args.shares, args.note, args.account)
|
||||
elif args.command == "analyze":
|
||||
analyze_portfolio(args.output)
|
||||
elif args.command == "account-list":
|
||||
list_accounts()
|
||||
elif args.command == "account-upsert":
|
||||
save_account(args.name, args.market, args.currency, args.cash, args.available_cash, args.note)
|
||||
elif args.command == "rule-set":
|
||||
set_rule(args.code, args.lot_size, args.tick_size, args.odd_lot)
|
||||
elif args.command == "watch-list":
|
||||
list_watchlist()
|
||||
elif args.command == "watch-add":
|
||||
add_watch(args.code)
|
||||
elif args.command == "watch-remove":
|
||||
remove_watch(args.code)
|
||||
else:
|
||||
parser.print_help()
|
||||
except ValueError as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user