feat: add trade_id + full trade record to trade_results

Each buy generates a UUID trade_id stored in positions table.
Each sell links via same trade_id in trade_results, enabling
round-trip grouping of buy→sell pairs.

Additional fields saved per trade:
- fee_krw: commission amount (0.05% each side)
- krw_profit: net KRW profit/loss after fees
- buy_price / sell_price: exact prices
- invested_krw: capital deployed
- sell_reason: trailing stop / time stop / etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-01 05:54:06 +09:00
parent 60e739d18b
commit bcef128155
2 changed files with 61 additions and 11 deletions

View File

@@ -112,6 +112,7 @@ def upsert_position(
amount: float, amount: float,
invested_krw: int, invested_krw: int,
entry_time: str, # ISO 포맷 문자열 entry_time: str, # ISO 포맷 문자열
trade_id: str = "",
) -> None: ) -> None:
"""포지션 저장 또는 갱신 (MERGE).""" """포지션 저장 또는 갱신 (MERGE)."""
sql = """ sql = """
@@ -124,9 +125,9 @@ def upsert_position(
invested_krw = :invested_krw, invested_krw = :invested_krw,
updated_at = SYSTIMESTAMP updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN WHEN NOT MATCHED THEN
INSERT (ticker, buy_price, peak_price, amount, invested_krw, entry_time) INSERT (ticker, buy_price, peak_price, amount, invested_krw, entry_time, trade_id)
VALUES (:ticker, :buy_price, :peak_price, :amount, :invested_krw, VALUES (:ticker, :buy_price, :peak_price, :amount, :invested_krw,
TO_TIMESTAMP(:entry_time, 'YYYY-MM-DD"T"HH24:MI:SS.FF6')) TO_TIMESTAMP(:entry_time, 'YYYY-MM-DD"T"HH24:MI:SS.FF6'), :trade_id)
""" """
with _conn() as conn: with _conn() as conn:
conn.cursor().execute(sql, { conn.cursor().execute(sql, {
@@ -136,6 +137,7 @@ def upsert_position(
"amount": amount, "amount": amount,
"invested_krw": invested_krw, "invested_krw": invested_krw,
"entry_time": entry_time, "entry_time": entry_time,
"trade_id": trade_id,
}) })
@@ -170,12 +172,37 @@ def ensure_trade_results_table() -> None:
raise raise
def record_trade(ticker: str, is_win: bool, pnl_pct: float) -> None: def record_trade(
"""거래 결과 저장.""" ticker: str,
is_win: bool,
pnl_pct: float,
fee_krw: float = 0.0,
krw_profit: float = 0.0,
trade_id: str = "",
buy_price: float = 0.0,
sell_price: float = 0.0,
invested_krw: int = 0,
sell_reason: str = "",
) -> None:
"""거래 결과 저장 (수수료·KRW 손익·trade_id 포함)."""
with _conn() as conn: with _conn() as conn:
conn.cursor().execute( conn.cursor().execute(
"INSERT INTO trade_results (ticker, is_win, pnl_pct) VALUES (:t, :w, :p)", "INSERT INTO trade_results "
{"t": ticker, "w": 1 if is_win else 0, "p": round(pnl_pct, 4)}, "(ticker, is_win, pnl_pct, fee_krw, krw_profit, "
" trade_id, buy_price, sell_price, invested_krw, sell_reason) "
"VALUES (:t, :w, :p, :f, :k, :tid, :bp, :sp, :ikrw, :sr)",
{
"t": ticker,
"w": 1 if is_win else 0,
"p": round(pnl_pct, 4),
"f": round(fee_krw, 2),
"k": round(krw_profit, 2),
"tid": trade_id,
"bp": buy_price,
"sp": sell_price,
"ikrw": invested_krw,
"sr": sell_reason,
},
) )
@@ -250,7 +277,8 @@ def load_positions() -> list[dict]:
"""저장된 전체 포지션 로드.""" """저장된 전체 포지션 로드."""
sql = """ sql = """
SELECT ticker, buy_price, peak_price, amount, invested_krw, SELECT ticker, buy_price, peak_price, amount, invested_krw,
TO_CHAR(entry_time, 'YYYY-MM-DD"T"HH24:MI:SS.FF6') AS entry_time TO_CHAR(entry_time, 'YYYY-MM-DD"T"HH24:MI:SS.FF6') AS entry_time,
trade_id
FROM positions FROM positions
""" """
with _conn() as conn: with _conn() as conn:
@@ -265,6 +293,7 @@ def load_positions() -> list[dict]:
"amount": float(r[3]), "amount": float(r[3]),
"invested_krw": int(r[4]), "invested_krw": int(r[4]),
"entry_time": r[5], "entry_time": r[5],
"trade_id": r[6] or "",
} }
for r in rows for r in rows
] ]

View File

@@ -6,6 +6,7 @@ import logging
import os import os
import threading import threading
import time import time
import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@@ -60,7 +61,13 @@ def _get_history(ticker: str) -> list[bool]:
return _trade_history[ticker] return _trade_history[ticker]
def _update_history(ticker: str, is_win: bool, pnl_pct: float) -> None: def _update_history(
ticker: str, is_win: bool, pnl_pct: float,
fee_krw: float = 0.0, krw_profit: float = 0.0,
trade_id: str = "", buy_price: float = 0.0,
sell_price: float = 0.0, invested_krw: int = 0,
sell_reason: str = "",
) -> None:
"""매도 후 in-memory 이력 갱신 + DB 기록.""" """매도 후 in-memory 이력 갱신 + DB 기록."""
hist = _trade_history.setdefault(ticker, []) hist = _trade_history.setdefault(ticker, [])
hist.append(is_win) hist.append(is_win)
@@ -68,7 +75,10 @@ def _update_history(ticker: str, is_win: bool, pnl_pct: float) -> None:
if len(hist) > WF_WINDOW * 2: if len(hist) > WF_WINDOW * 2:
_trade_history[ticker] = hist[-WF_WINDOW:] _trade_history[ticker] = hist[-WF_WINDOW:]
try: try:
record_trade(ticker, is_win, pnl_pct) record_trade(
ticker, is_win, pnl_pct, fee_krw, krw_profit,
trade_id, buy_price, sell_price, invested_krw, sell_reason,
)
except Exception as e: except Exception as e:
logger.error(f"거래 이력 저장 실패 {ticker}: {e}") logger.error(f"거래 이력 저장 실패 {ticker}: {e}")
@@ -83,6 +93,7 @@ def _db_upsert(ticker: str, pos: dict) -> None:
amount=pos["amount"], amount=pos["amount"],
invested_krw=pos["invested_krw"], invested_krw=pos["invested_krw"],
entry_time=pos["entry_time"].isoformat(), entry_time=pos["entry_time"].isoformat(),
trade_id=pos.get("trade_id", ""),
) )
except Exception as e: except Exception as e:
logger.error(f"포지션 DB 저장 실패 {ticker}: {e}") logger.error(f"포지션 DB 저장 실패 {ticker}: {e}")
@@ -158,6 +169,7 @@ def restore_positions() -> None:
"amount": amount, "amount": amount,
"invested_krw": s["invested_krw"], "invested_krw": s["invested_krw"],
"entry_time": entry_time, "entry_time": entry_time,
"trade_id": s.get("trade_id", ""),
} }
logger.info( logger.info(
f"[복원] {ticker} 매수가={s['buy_price']:,.0f}원 | 현재가={current:,.0f}" f"[복원] {ticker} 매수가={s['buy_price']:,.0f}원 | 현재가={current:,.0f}"
@@ -252,17 +264,19 @@ def buy(ticker: str) -> bool:
actual_price = order_krw / amount if amount > 0 else pyupbit.get_current_price(ticker) actual_price = order_krw / amount if amount > 0 else pyupbit.get_current_price(ticker)
entry_time = datetime.now() entry_time = datetime.now()
trade_id = str(uuid.uuid4())
_positions[ticker] = { _positions[ticker] = {
"buy_price": actual_price, "buy_price": actual_price,
"peak_price": actual_price, "peak_price": actual_price,
"amount": amount, "amount": amount,
"invested_krw": order_krw, "invested_krw": order_krw,
"entry_time": entry_time, "entry_time": entry_time,
"trade_id": trade_id,
} }
_db_upsert(ticker, _positions[ticker]) _db_upsert(ticker, _positions[ticker])
logger.info( logger.info(
f"[매수] {ticker} @ {actual_price:,.0f}원 (실체결가) | " f"[매수] {ticker} @ {actual_price:,.0f}원 (실체결가) | "
f"수량={amount} | 투자금={order_krw:,}" f"수량={amount} | 투자금={order_krw:,} | trade_id={trade_id[:8]}"
) )
notify_buy(ticker, actual_price, amount, order_krw) notify_buy(ticker, actual_price, amount, order_krw)
return True return True
@@ -318,7 +332,14 @@ def sell(ticker: str, reason: str = "") -> bool:
upsert_sell_price(ticker, current) # DB 영구 저장 upsert_sell_price(ticker, current) # DB 영구 저장
except Exception as e: except Exception as e:
logger.error(f"직전 매도가 DB 저장 실패 {ticker}: {e}") logger.error(f"직전 매도가 DB 저장 실패 {ticker}: {e}")
_update_history(ticker, pnl > 0, pnl) # walk-forward 이력 갱신 _update_history(
ticker, pnl > 0, pnl, fee, krw_profit,
trade_id=pos.get("trade_id", ""),
buy_price=pos["buy_price"],
sell_price=current or pos["buy_price"],
invested_krw=pos["invested_krw"],
sell_reason=reason,
)
del _positions[ticker] del _positions[ticker]
try: try:
delete_position(ticker) delete_position(ticker)