TradingAgents/trade_history.py

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()