447 lines
14 KiB
Python
447 lines
14 KiB
Python
"""
|
|
매매 이력 관리 (SQLite)
|
|
- 매수/매도 기록 저장
|
|
- 누적 수익률 조회
|
|
- 통화별 수익 요약
|
|
- 일일 상태 관리 (재시작 중복 방지)
|
|
"""
|
|
|
|
import datetime
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
DB_PATH = Path(__file__).parent / "data" / "trade_history.db"
|
|
|
|
|
|
def _get_conn() -> sqlite3.Connection:
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(str(DB_PATH))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
return conn
|
|
|
|
|
|
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
|
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
return any(r["name"] == column for r in rows)
|
|
|
|
|
|
def _migrate_schema(conn: sqlite3.Connection):
|
|
"""기존 DB에 누락 컬럼을 idempotent하게 추가."""
|
|
if not _has_column(conn, "trades", "market"):
|
|
conn.execute("ALTER TABLE trades ADD COLUMN market TEXT NOT NULL DEFAULT 'KR'")
|
|
if not _has_column(conn, "trades", "currency"):
|
|
conn.execute("ALTER TABLE trades ADD COLUMN currency TEXT NOT NULL DEFAULT 'KRW'")
|
|
if not _has_column(conn, "pnl_log", "market"):
|
|
conn.execute("ALTER TABLE pnl_log ADD COLUMN market TEXT NOT NULL DEFAULT 'KR'")
|
|
if not _has_column(conn, "pnl_log", "currency"):
|
|
conn.execute("ALTER TABLE pnl_log ADD COLUMN currency TEXT NOT NULL DEFAULT 'KRW'")
|
|
|
|
|
|
def init_db():
|
|
"""테이블 생성 (최초 1회) + 스키마 마이그레이션."""
|
|
conn = _get_conn()
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS trades (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ticker TEXT NOT NULL,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
side TEXT NOT NULL CHECK(side IN ('BUY', 'SELL')),
|
|
qty INTEGER NOT NULL,
|
|
price REAL NOT NULL,
|
|
amount REAL NOT NULL,
|
|
market TEXT NOT NULL DEFAULT 'KR',
|
|
currency TEXT NOT NULL DEFAULT 'KRW',
|
|
order_no TEXT DEFAULT '',
|
|
reason TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS pnl_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ticker TEXT NOT NULL,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
buy_price REAL NOT NULL,
|
|
sell_price REAL NOT NULL,
|
|
qty INTEGER NOT NULL,
|
|
pnl REAL NOT NULL,
|
|
pnl_rate REAL NOT NULL,
|
|
market TEXT NOT NULL DEFAULT 'KR',
|
|
currency TEXT NOT NULL DEFAULT 'KRW',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS daily_state (
|
|
date TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
completed_at TEXT NOT NULL,
|
|
details TEXT DEFAULT '',
|
|
PRIMARY KEY (date, action)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS pnl_resets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
currency TEXT DEFAULT NULL,
|
|
reset_by TEXT DEFAULT '',
|
|
reason TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS budget_anchor (
|
|
market TEXT PRIMARY KEY,
|
|
anchor_amount REAL NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
|
|
_migrate_schema(conn)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def record_trade(
|
|
ticker: str,
|
|
name: str,
|
|
side: str,
|
|
qty: int,
|
|
price: float,
|
|
order_no: str = "",
|
|
reason: str = "",
|
|
market: str = "KR",
|
|
currency: str = "KRW",
|
|
):
|
|
"""매수/매도 기록 저장."""
|
|
side = side.upper()
|
|
market = (market or "KR").upper()
|
|
currency = (currency or "KRW").upper()
|
|
px = float(price)
|
|
amount = float(qty) * px
|
|
|
|
conn = _get_conn()
|
|
conn.execute(
|
|
"""INSERT INTO trades
|
|
(ticker, name, side, qty, price, amount, market, currency, order_no, reason)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(ticker, name, side, qty, px, amount, market, currency, order_no, reason),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def record_pnl(
|
|
ticker: str,
|
|
name: str,
|
|
buy_price: float,
|
|
sell_price: float,
|
|
qty: int,
|
|
market: str = "KR",
|
|
currency: str = "KRW",
|
|
):
|
|
"""실현 손익 기록."""
|
|
buy_px = float(buy_price)
|
|
sell_px = float(sell_price)
|
|
pnl = (sell_px - buy_px) * qty
|
|
pnl_rate = ((sell_px - buy_px) / buy_px * 100) if buy_px > 0 else 0.0
|
|
market = (market or "KR").upper()
|
|
currency = (currency or "KRW").upper()
|
|
|
|
conn = _get_conn()
|
|
conn.execute(
|
|
"""INSERT INTO pnl_log
|
|
(ticker, name, buy_price, sell_price, qty, pnl, pnl_rate, market, currency)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(ticker, name, buy_px, sell_px, qty, pnl, round(pnl_rate, 2), market, currency),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _aggregate_pnl(
|
|
market: str | None = None,
|
|
currency: str | None = None,
|
|
) -> dict:
|
|
conn = _get_conn()
|
|
where_clause, params = _build_pnl_where_clause(conn, market=market, currency=currency)
|
|
row = conn.execute(
|
|
f"""SELECT
|
|
COALESCE(SUM(pnl), 0) as total_pnl,
|
|
COUNT(*) as trade_count,
|
|
COALESCE(SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END), 0) as win_count,
|
|
COALESCE(SUM(CASE WHEN pnl <= 0 THEN 1 ELSE 0 END), 0) as loss_count
|
|
FROM pnl_log
|
|
{where_clause}""",
|
|
params,
|
|
).fetchone()
|
|
conn.close()
|
|
|
|
total = float(row["total_pnl"])
|
|
count = int(row["trade_count"])
|
|
win = int(row["win_count"])
|
|
loss = int(row["loss_count"])
|
|
win_rate = (win / count * 100) if count > 0 else 0.0
|
|
|
|
return {
|
|
"total_pnl": total,
|
|
"trade_count": count,
|
|
"win_count": win,
|
|
"loss_count": loss,
|
|
"win_rate": round(win_rate, 1),
|
|
}
|
|
|
|
|
|
def _get_pnl_reset_cutoff(
|
|
conn: sqlite3.Connection,
|
|
currency: str | None = None,
|
|
) -> str | None:
|
|
target_currency = (currency or "").upper() or None
|
|
if target_currency:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT MAX(created_at) AS cutoff
|
|
FROM pnl_resets
|
|
WHERE currency IS NULL OR currency = ?
|
|
""",
|
|
(target_currency,),
|
|
).fetchone()
|
|
else:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT MAX(created_at) AS cutoff
|
|
FROM pnl_resets
|
|
WHERE currency IS NULL
|
|
"""
|
|
).fetchone()
|
|
|
|
cutoff = row["cutoff"] if row else None
|
|
return str(cutoff) if cutoff else None
|
|
|
|
|
|
def _build_pnl_where_clause(
|
|
conn: sqlite3.Connection,
|
|
market: str | None = None,
|
|
currency: str | None = None,
|
|
) -> tuple[str, list[object]]:
|
|
conditions: list[str] = []
|
|
params: list[object] = []
|
|
if market:
|
|
conditions.append("market = ?")
|
|
params.append(market.upper())
|
|
if currency:
|
|
conditions.append("currency = ?")
|
|
params.append(currency.upper())
|
|
|
|
cutoff = _get_pnl_reset_cutoff(conn, currency=currency)
|
|
if cutoff:
|
|
conditions.append("created_at > ?")
|
|
params.append(cutoff)
|
|
|
|
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
return where_clause, params
|
|
|
|
|
|
def get_total_pnl(
|
|
market: str | None = None,
|
|
currency: str | None = None,
|
|
) -> dict:
|
|
"""누적 수익 요약 (기존 호환: 인자 없이 전체 반환)."""
|
|
return _aggregate_pnl(market=market, currency=currency)
|
|
|
|
|
|
def get_total_pnl_by_currency() -> dict[str, dict]:
|
|
"""통화별 누적 수익 요약."""
|
|
conn = _get_conn()
|
|
rows = conn.execute(
|
|
"SELECT DISTINCT currency FROM pnl_log ORDER BY currency"
|
|
).fetchall()
|
|
conn.close()
|
|
|
|
currencies = [r["currency"] for r in rows] or ["KRW", "USD"]
|
|
result: dict[str, dict] = {}
|
|
for cur in currencies:
|
|
result[cur] = _aggregate_pnl(currency=cur)
|
|
return result
|
|
|
|
|
|
def get_recent_trades(
|
|
limit: int = 20,
|
|
market: str | None = None,
|
|
currency: str | None = None,
|
|
) -> list[dict]:
|
|
"""최근 매매 이력."""
|
|
conn = _get_conn()
|
|
conditions: list[str] = []
|
|
params: list[object] = []
|
|
if market:
|
|
conditions.append("market = ?")
|
|
params.append(market.upper())
|
|
if currency:
|
|
conditions.append("currency = ?")
|
|
params.append(currency.upper())
|
|
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
params.append(limit)
|
|
rows = conn.execute(
|
|
f"SELECT * FROM trades {where_clause} ORDER BY id DESC LIMIT ?", params
|
|
).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def get_recent_pnl(
|
|
limit: int = 20,
|
|
market: str | None = None,
|
|
currency: str | None = None,
|
|
) -> list[dict]:
|
|
"""최근 실현손익."""
|
|
conn = _get_conn()
|
|
where_clause, params = _build_pnl_where_clause(conn, market=market, currency=currency)
|
|
params.append(limit)
|
|
rows = conn.execute(
|
|
f"SELECT * FROM pnl_log {where_clause} ORDER BY id DESC LIMIT ?", params
|
|
).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def get_ticker_summary(
|
|
market: str | None = None,
|
|
currency: str | None = None,
|
|
) -> list[dict]:
|
|
"""종목별 누적 수익 요약."""
|
|
conn = _get_conn()
|
|
where_clause, params = _build_pnl_where_clause(conn, market=market, currency=currency)
|
|
rows = conn.execute(
|
|
f"""SELECT
|
|
ticker, name, market, currency,
|
|
COUNT(*) as count,
|
|
SUM(pnl) as total_pnl,
|
|
AVG(pnl_rate) as avg_pnl_rate
|
|
FROM pnl_log
|
|
{where_clause}
|
|
GROUP BY ticker, name, market, currency
|
|
ORDER BY total_pnl DESC""",
|
|
params,
|
|
).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def reset_pnl_history(
|
|
currency: str | None = None,
|
|
reset_by: str = "",
|
|
reason: str = "",
|
|
) -> str:
|
|
"""실현손익 집계 기준 시점을 기록한다.
|
|
|
|
기존 손익 로그는 보존하고, 이후 조회 시 마지막 초기화 시점 이후 데이터만 집계한다.
|
|
"""
|
|
target_currency = (currency or "").upper() or None
|
|
reset_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
conn = _get_conn()
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO pnl_resets (currency, reset_by, reason, created_at)
|
|
VALUES (?, ?, ?, ?)
|
|
""",
|
|
(target_currency, reset_by, reason, reset_at),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return reset_at
|
|
|
|
|
|
# ─── 일일 상태 관리 (재시작 중복 방지) ─────────────────────
|
|
def is_action_done(action: str, date: str | None = None) -> bool:
|
|
"""오늘 해당 액션이 이미 완료되었는지 확인.
|
|
|
|
Args:
|
|
action: 'morning_buy', 'afternoon_sell', 'us_morning_buy' 등
|
|
date: 날짜 (기본: 오늘)
|
|
"""
|
|
if date is None:
|
|
date = datetime.date.today().isoformat()
|
|
conn = _get_conn()
|
|
row = conn.execute(
|
|
"SELECT 1 FROM daily_state WHERE date = ? AND action = ?",
|
|
(date, action),
|
|
).fetchone()
|
|
conn.close()
|
|
return row is not None
|
|
|
|
|
|
def mark_action_done(action: str, details: str = "", date: str | None = None):
|
|
"""해당 액션을 완료로 표시."""
|
|
if date is None:
|
|
date = datetime.date.today().isoformat()
|
|
now = datetime.datetime.now().isoformat()
|
|
conn = _get_conn()
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO daily_state (date, action, completed_at, details) VALUES (?, ?, ?, ?)",
|
|
(date, action, now, details),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def get_daily_state(date: str | None = None) -> list[dict]:
|
|
"""오늘 완료된 모든 액션 조회."""
|
|
if date is None:
|
|
date = datetime.date.today().isoformat()
|
|
conn = _get_conn()
|
|
rows = conn.execute(
|
|
"SELECT action, completed_at, details FROM daily_state WHERE date = ? ORDER BY completed_at",
|
|
(date,),
|
|
).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def get_budget_anchor(market: str = "KR") -> float:
|
|
"""시장별 자동매수 기준 자금(anchor) 조회."""
|
|
conn = _get_conn()
|
|
row = conn.execute(
|
|
"SELECT anchor_amount FROM budget_anchor WHERE market = ?",
|
|
((market or "KR").upper(),),
|
|
).fetchone()
|
|
conn.close()
|
|
return float(row["anchor_amount"]) if row else 0.0
|
|
|
|
|
|
def set_budget_anchor(market: str, anchor_amount: float) -> float:
|
|
"""시장별 자동매수 기준 자금을 저장한다."""
|
|
market = (market or "KR").upper()
|
|
amount = max(float(anchor_amount), 0.0)
|
|
now = datetime.datetime.now().isoformat()
|
|
|
|
conn = _get_conn()
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO budget_anchor (market, anchor_amount, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(market) DO UPDATE SET
|
|
anchor_amount = excluded.anchor_amount,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(market, amount, now),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return amount
|
|
|
|
|
|
def ensure_budget_anchor(market: str, available_cash: float) -> float:
|
|
"""기준 자금이 없으면 현재 예수금으로 초기화하고, 더 큰 값이 들어오면 상향 반영한다."""
|
|
market = (market or "KR").upper()
|
|
cash = max(float(available_cash), 0.0)
|
|
current = get_budget_anchor(market)
|
|
|
|
if cash > 0 and (current <= 0 or cash > current):
|
|
return set_budget_anchor(market, cash)
|
|
return current
|
|
|
|
|
|
# 모듈 로드 시 DB 초기화
|
|
init_db()
|