feat: 모의 자동매매 자동체결 + 손익/이력 조회

- score-engine: TRADE_SETTINGS.auto_execute(기본 True) — auto_trade_scan_job이 제안한
  pending 주문을 즉시 자동 체결(모의). 체결로직을 _fill_order로 추출(수동버튼/자동 공용).
  /trade/history: 체결이력+일별 실현/평가손익+보유 평가손익. (전부 DB 시뮬레이션, 실제 브로커 전송 없음)
- dashboard-api: /api/trade-history 프록시.
- 가드 그대로: 종목당10%·일일손실-3% halt·강력매수+두LLM일치+확신도4·손절-8%·익절+15%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kyu
2026-06-03 13:16:30 +09:00
parent 0cb1b736a9
commit c5ef30b2be
2 changed files with 105 additions and 50 deletions
+5
View File
@@ -2110,6 +2110,11 @@ async def proxy_decisions_accuracy(days: int = Query(default=60, ge=7, le=365)):
"""결정안 실측 성과 (score-engine /decisions/accuracy)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/decisions/accuracy?days={days}")
@app.get("/api/trade-history")
async def proxy_trade_history(days: int = Query(default=30, ge=1, le=365)):
"""모의매매 이력 + 손익 (score-engine /trade/history)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/trade/history?days={days}")
async def _enrich_kr_names(rows):
"""rows의 kr_code/stock_code에 dart_corps.corp_name을 kr_name으로 첨부"""
+100 -50
View File
@@ -5867,6 +5867,7 @@ async def ensure_trading_tables():
# 자동매매 안전 설정 — 보수적 기본값
TRADE_SETTINGS = {
"enabled": True, # 자동매매 ON/OFF 토글
"auto_execute": True, # 제안 즉시 자동 체결(모의). False면 텔레그램 버튼 승인 대기
"max_position_pct": 10.0, # 종목당 자본 ≤ 10%
"daily_loss_limit_pct": -3.0, # 일일 손실 -3% 도달 시 매매 중단
"max_orders_per_day": 10,
@@ -6190,59 +6191,101 @@ async def trade_sell_scan():
return {"checked": len(codes), "proposed": len(results), "results": results}
async def _fill_order(conn, order_id: int, auto: bool = False) -> dict:
"""주문 체결(모의=DB 기록, 실제 브로커 전송 없음). auto=True면 자동매매 체결."""
order = await conn.fetchrow(
"SELECT * FROM trading_orders WHERE id=$1 AND status='pending'", order_id)
if not order:
return {"error": "주문 없음 또는 이미 처리됨"}
if order["expires_at"] and order["expires_at"] < datetime.now(timezone.utc):
await conn.execute(
"UPDATE trading_orders SET status='expired' WHERE id=$1", order_id)
return {"error": "주문 만료"}
tag = "자동체결" if auto else "체결"
if order["side"] == "buy":
await conn.execute("""
INSERT INTO user_portfolio (stock_code, stock_name, buy_price, qty, memo)
VALUES ($1, $2, $3, $4, $5)
""", order["stock_code"], order["stock_name"], order["price"],
order["qty"], f"자동매매 #{order_id} (모의)")
msg = (f"✅ <b>매수 {tag} #{order_id}</b> (모의)\n"
f"{order['stock_name']}({order['stock_code']}) "
f"{order['qty']}× {order['price']:,}")
else: # sell
held = await conn.fetchrow("""
SELECT id, buy_price, qty FROM user_portfolio
WHERE stock_code=$1 AND active=true LIMIT 1
""", order["stock_code"])
realized = 0
if held:
realized = (order["price"] - held["buy_price"]) * order["qty"]
await conn.execute(
"UPDATE user_portfolio SET active=false WHERE id=$1", held["id"])
await conn.execute("""
INSERT INTO trading_daily_pnl (dt, realized_pnl, trades_count)
VALUES (CURRENT_DATE, $1, 1)
ON CONFLICT (dt) DO UPDATE SET
realized_pnl = trading_daily_pnl.realized_pnl + EXCLUDED.realized_pnl,
trades_count = trading_daily_pnl.trades_count + 1,
updated_at = NOW()
""", realized)
sign = "🟢" if realized > 0 else "🔴"
msg = (f"{sign} <b>매도 {tag} #{order_id}</b> (모의)\n"
f"{order['stock_name']}({order['stock_code']}) "
f"{order['qty']}× {order['price']:,}\n"
f"실현손익: {realized:+,}")
await conn.execute("""
UPDATE trading_orders
SET status='filled', confirmed_at=NOW(), filled_at=NOW()
WHERE id=$1
""", order_id)
await send_telegram(msg)
return {"status": "filled", "order_id": order_id}
@app.post("/trade/confirm/{order_id}")
async def trade_confirm_v2(order_id: int): # noqa: F811 (이전 정의 override)
"""매수/매도 confirm 통합 처리."""
"""매수/매도 confirm 통합 처리 (수동 버튼)."""
async with pg_pool.acquire() as conn:
order = await conn.fetchrow(
"SELECT * FROM trading_orders WHERE id=$1 AND status='pending'", order_id)
if not order:
return {"error": "주문 없음 또는 이미 처리됨"}
if order["expires_at"] and order["expires_at"] < datetime.now(timezone.utc):
await conn.execute(
"UPDATE trading_orders SET status='expired' WHERE id=$1", order_id)
return {"error": "주문 만료"}
if order["side"] == "buy":
await conn.execute("""
INSERT INTO user_portfolio (stock_code, stock_name, buy_price, qty, memo)
VALUES ($1, $2, $3, $4, $5)
""", order["stock_code"], order["stock_name"], order["price"],
order["qty"], f"자동매매 #{order_id} (모의)")
msg = (f"✅ <b>매수 체결 #{order_id}</b>\n"
f"{order['stock_name']}({order['stock_code']}) "
f"{order['qty']}× {order['price']:,}")
else: # sell
# 보유 종목 비활성화 + 실현손익 계산
held = await conn.fetchrow("""
SELECT id, buy_price, qty FROM user_portfolio
WHERE stock_code=$1 AND active=true LIMIT 1
""", order["stock_code"])
realized = 0
if held:
realized = (order["price"] - held["buy_price"]) * order["qty"]
await conn.execute(
"UPDATE user_portfolio SET active=false WHERE id=$1", held["id"])
# 일일 손익 누적
await conn.execute("""
INSERT INTO trading_daily_pnl (dt, realized_pnl, trades_count)
VALUES (CURRENT_DATE, $1, 1)
ON CONFLICT (dt) DO UPDATE SET
realized_pnl = trading_daily_pnl.realized_pnl + EXCLUDED.realized_pnl,
trades_count = trading_daily_pnl.trades_count + 1,
updated_at = NOW()
""", realized)
sign = "🟢" if realized > 0 else "🔴"
msg = (f"{sign} <b>매도 체결 #{order_id}</b>\n"
f"{order['stock_name']}({order['stock_code']}) "
f"{order['qty']}× {order['price']:,}\n"
f"실현손익: {realized:+,}")
await conn.execute("""
UPDATE trading_orders
SET status='filled', confirmed_at=NOW(), filled_at=NOW()
WHERE id=$1
""", order_id)
await send_telegram(msg)
return {"status": "filled", "order_id": order_id}
return await _fill_order(conn, order_id, auto=False)
@app.get("/trade/history")
async def trade_history(days: int = Query(default=30, ge=1, le=365)):
"""모의매매 이력 + 손익 — 체결내역·일별손익·보유 평가손익."""
async with pg_pool.acquire() as conn:
fills = await conn.fetch("""
SELECT stock_code, stock_name, side, qty, price, filled_at
FROM trading_orders
WHERE status='filled' AND filled_at >= CURRENT_DATE - ($1::int)
ORDER BY filled_at DESC
""", days)
daily = await conn.fetch("""
SELECT dt, realized_pnl, unrealized_pnl, trades_count, halted
FROM trading_daily_pnl WHERE dt >= CURRENT_DATE - ($1::int) ORDER BY dt DESC
""", days)
holds = await conn.fetch("""
SELECT p.stock_code, p.stock_name, p.buy_price, p.qty,
(SELECT price FROM stock_technical WHERE stock_code=p.stock_code) cur
FROM user_portfolio p WHERE active=true
""")
realized = sum((r["realized_pnl"] or 0) for r in daily)
holdings, unreal = [], 0
for h in holds:
cur = h["cur"] or h["buy_price"]
pl = int((cur - h["buy_price"]) * h["qty"]) if h["buy_price"] else 0
unreal += pl
holdings.append({"code": h["stock_code"], "name": h["stock_name"],
"buy_price": h["buy_price"], "qty": h["qty"], "cur_price": cur,
"pl": pl, "pl_pct": round((cur / h["buy_price"] - 1) * 100, 1) if h["buy_price"] else 0})
return {
"realized_pnl": realized, "unrealized_pnl": unreal, "total_pnl": realized + unreal,
"fill_count": len(fills),
"holdings": holdings,
"daily_pnl": [{"dt": str(r["dt"]), "realized": r["realized_pnl"],
"unrealized": r["unrealized_pnl"], "trades": r["trades_count"],
"halted": r["halted"]} for r in daily],
"fills": [{"code": f["stock_code"], "name": f["stock_name"], "side": f["side"],
"qty": f["qty"], "price": f["price"], "at": str(f["filled_at"])} for f in fills],
}
async def auto_trade_scan_job():
@@ -6298,6 +6341,13 @@ async def auto_trade_scan_job():
sig = await evaluate_buy_signal(conn, c)
if sig["ok"]:
await propose_buy_order(conn, c)
# 자동실행(모의): auto_execute면 오늘 제안된 pending 주문 즉시 체결
if TRADE_SETTINGS.get("auto_execute"):
pend = await conn.fetch(
"SELECT id FROM trading_orders WHERE status='pending' "
"AND proposed_at::date=CURRENT_DATE")
for p in pend:
await _fill_order(conn, p["id"], auto=True)
logger.info("auto_trade_scan.done")
except Exception as e:
logger.error("auto_trade_scan.err", error=str(e))