Compare commits
2 Commits
feature/is
...
faa23b3f1b
| Author | SHA1 | Date | |
|---|---|---|---|
| faa23b3f1b | |||
|
|
5844ec5ad3 |
22
src/db.py
22
src/db.py
@@ -237,28 +237,6 @@ def get_open_position(
|
|||||||
return {"decision_id": row[1], "price": row[2], "quantity": row[3]}
|
return {"decision_id": row[1], "price": row[2], "quantity": row[3]}
|
||||||
|
|
||||||
|
|
||||||
def get_open_positions_by_market(
|
|
||||||
conn: sqlite3.Connection, market: str
|
|
||||||
) -> list[str]:
|
|
||||||
"""Return stock codes with a net positive position in the given market.
|
|
||||||
|
|
||||||
Uses net BUY - SELL quantity aggregation to avoid false positives from
|
|
||||||
the simpler "latest record is BUY" heuristic. A stock is considered
|
|
||||||
open only when the bot's own recorded trades leave a positive net quantity.
|
|
||||||
"""
|
|
||||||
cursor = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT stock_code
|
|
||||||
FROM trades
|
|
||||||
WHERE market = ?
|
|
||||||
GROUP BY stock_code
|
|
||||||
HAVING SUM(CASE WHEN action = 'BUY' THEN quantity ELSE -quantity END) > 0
|
|
||||||
""",
|
|
||||||
(market,),
|
|
||||||
)
|
|
||||||
return [row[0] for row in cursor.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
def get_recent_symbols(
|
def get_recent_symbols(
|
||||||
conn: sqlite3.Connection, market: str, limit: int = 30
|
conn: sqlite3.Connection, market: str, limit: int = 30
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
|||||||
35
src/main.py
35
src/main.py
@@ -32,7 +32,6 @@ from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, Risk
|
|||||||
from src.db import (
|
from src.db import (
|
||||||
get_latest_buy_trade,
|
get_latest_buy_trade,
|
||||||
get_open_position,
|
get_open_position,
|
||||||
get_open_positions_by_market,
|
|
||||||
get_recent_symbols,
|
get_recent_symbols,
|
||||||
init_db,
|
init_db,
|
||||||
log_trade,
|
log_trade,
|
||||||
@@ -388,8 +387,10 @@ async def trading_cycle(
|
|||||||
if entry_price > 0:
|
if entry_price > 0:
|
||||||
loss_pct = (current_price - entry_price) / entry_price * 100
|
loss_pct = (current_price - entry_price) / entry_price * 100
|
||||||
stop_loss_threshold = -2.0
|
stop_loss_threshold = -2.0
|
||||||
|
take_profit_threshold = 3.0
|
||||||
if stock_playbook and stock_playbook.scenarios:
|
if stock_playbook and stock_playbook.scenarios:
|
||||||
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
||||||
|
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
|
||||||
|
|
||||||
if loss_pct <= stop_loss_threshold:
|
if loss_pct <= stop_loss_threshold:
|
||||||
decision = TradeDecision(
|
decision = TradeDecision(
|
||||||
@@ -407,6 +408,22 @@ async def trading_cycle(
|
|||||||
loss_pct,
|
loss_pct,
|
||||||
stop_loss_threshold,
|
stop_loss_threshold,
|
||||||
)
|
)
|
||||||
|
elif loss_pct >= take_profit_threshold:
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="SELL",
|
||||||
|
confidence=90,
|
||||||
|
rationale=(
|
||||||
|
f"Take-profit triggered ({loss_pct:.2f}% >= "
|
||||||
|
f"{take_profit_threshold:.2f}%)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Take-profit override for %s (%s): %.2f%% >= %.2f%%",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
loss_pct,
|
||||||
|
take_profit_threshold,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Decision for %s (%s): %s (confidence=%d)",
|
"Decision for %s (%s): %s (confidence=%d)",
|
||||||
stock_code,
|
stock_code,
|
||||||
@@ -1865,21 +1882,7 @@ async def run(settings: Settings) -> None:
|
|||||||
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
|
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
|
||||||
|
|
||||||
# Get active stocks from scanner (dynamic, no static fallback)
|
# Get active stocks from scanner (dynamic, no static fallback)
|
||||||
# Also include current holdings so stop-loss / take-profit
|
stock_codes = active_stocks.get(market.code, [])
|
||||||
# can trigger even when a position drops off the scanner.
|
|
||||||
scanner_codes = active_stocks.get(market.code, [])
|
|
||||||
held_codes = get_open_positions_by_market(db_conn, market.code)
|
|
||||||
# Union: scanner candidates first, then holdings not already present.
|
|
||||||
# dict.fromkeys preserves insertion order and removes duplicates.
|
|
||||||
stock_codes = list(dict.fromkeys(scanner_codes + held_codes))
|
|
||||||
if held_codes:
|
|
||||||
new_held = [c for c in held_codes if c not in set(scanner_codes)]
|
|
||||||
if new_held:
|
|
||||||
logger.info(
|
|
||||||
"Holdings added to loop for %s (not in scanner): %s",
|
|
||||||
market.name,
|
|
||||||
new_held,
|
|
||||||
)
|
|
||||||
if not stock_codes:
|
if not stock_codes:
|
||||||
logger.debug("No active stocks for market %s", market.code)
|
logger.debug("No active stocks for market %s", market.code)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for database helper functions."""
|
"""Tests for database helper functions."""
|
||||||
|
|
||||||
from src.db import get_open_position, get_open_positions_by_market, init_db, log_trade
|
from src.db import get_open_position, init_db, log_trade
|
||||||
|
|
||||||
|
|
||||||
def test_get_open_position_returns_latest_buy() -> None:
|
def test_get_open_position_returns_latest_buy() -> None:
|
||||||
@@ -58,87 +58,3 @@ def test_get_open_position_returns_none_when_latest_is_sell() -> None:
|
|||||||
def test_get_open_position_returns_none_when_no_trades() -> None:
|
def test_get_open_position_returns_none_when_no_trades() -> None:
|
||||||
conn = init_db(":memory:")
|
conn = init_db(":memory:")
|
||||||
assert get_open_position(conn, "AAPL", "US_NASDAQ") is None
|
assert get_open_position(conn, "AAPL", "US_NASDAQ") is None
|
||||||
|
|
||||||
|
|
||||||
# --- get_open_positions_by_market tests ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_open_positions_by_market_returns_net_positive_stocks() -> None:
|
|
||||||
"""Stocks with net BUY quantity > 0 are included."""
|
|
||||||
conn = init_db(":memory:")
|
|
||||||
log_trade(
|
|
||||||
conn=conn, stock_code="005930", action="BUY", confidence=90,
|
|
||||||
rationale="entry", quantity=5, price=70000.0, market="KR",
|
|
||||||
exchange_code="KRX", decision_id="d1",
|
|
||||||
)
|
|
||||||
log_trade(
|
|
||||||
conn=conn, stock_code="000660", action="BUY", confidence=85,
|
|
||||||
rationale="entry", quantity=3, price=100000.0, market="KR",
|
|
||||||
exchange_code="KRX", decision_id="d2",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = get_open_positions_by_market(conn, "KR")
|
|
||||||
assert set(result) == {"005930", "000660"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_open_positions_by_market_excludes_fully_sold_stocks() -> None:
|
|
||||||
"""Stocks where BUY qty == SELL qty are excluded (net qty = 0)."""
|
|
||||||
conn = init_db(":memory:")
|
|
||||||
log_trade(
|
|
||||||
conn=conn, stock_code="005930", action="BUY", confidence=90,
|
|
||||||
rationale="entry", quantity=3, price=70000.0, market="KR",
|
|
||||||
exchange_code="KRX", decision_id="d1",
|
|
||||||
)
|
|
||||||
log_trade(
|
|
||||||
conn=conn, stock_code="005930", action="SELL", confidence=95,
|
|
||||||
rationale="exit", quantity=3, price=71000.0, market="KR",
|
|
||||||
exchange_code="KRX", decision_id="d2",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = get_open_positions_by_market(conn, "KR")
|
|
||||||
assert "005930" not in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_open_positions_by_market_includes_partially_sold_stocks() -> None:
|
|
||||||
"""Stocks with partial SELL (net qty > 0) are still included."""
|
|
||||||
conn = init_db(":memory:")
|
|
||||||
log_trade(
|
|
||||||
conn=conn, stock_code="005930", action="BUY", confidence=90,
|
|
||||||
rationale="entry", quantity=5, price=70000.0, market="KR",
|
|
||||||
exchange_code="KRX", decision_id="d1",
|
|
||||||
)
|
|
||||||
log_trade(
|
|
||||||
conn=conn, stock_code="005930", action="SELL", confidence=95,
|
|
||||||
rationale="partial exit", quantity=2, price=71000.0, market="KR",
|
|
||||||
exchange_code="KRX", decision_id="d2",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = get_open_positions_by_market(conn, "KR")
|
|
||||||
assert "005930" in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_open_positions_by_market_is_market_scoped() -> None:
|
|
||||||
"""Only stocks from the specified market are returned."""
|
|
||||||
conn = init_db(":memory:")
|
|
||||||
log_trade(
|
|
||||||
conn=conn, stock_code="005930", action="BUY", confidence=90,
|
|
||||||
rationale="entry", quantity=3, price=70000.0, market="KR",
|
|
||||||
exchange_code="KRX", decision_id="d1",
|
|
||||||
)
|
|
||||||
log_trade(
|
|
||||||
conn=conn, stock_code="AAPL", action="BUY", confidence=85,
|
|
||||||
rationale="entry", quantity=2, price=200.0, market="NASD",
|
|
||||||
exchange_code="NAS", decision_id="d2",
|
|
||||||
)
|
|
||||||
|
|
||||||
kr_result = get_open_positions_by_market(conn, "KR")
|
|
||||||
nasd_result = get_open_positions_by_market(conn, "NASD")
|
|
||||||
|
|
||||||
assert kr_result == ["005930"]
|
|
||||||
assert nasd_result == ["AAPL"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_open_positions_by_market_returns_empty_when_no_trades() -> None:
|
|
||||||
"""Empty list returned when no trades exist for the market."""
|
|
||||||
conn = init_db(":memory:")
|
|
||||||
assert get_open_positions_by_market(conn, "KR") == []
|
|
||||||
|
|||||||
@@ -1396,6 +1396,207 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
|
|||||||
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
|
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None:
|
||||||
|
"""HOLD decision should be overridden to SELL when take-profit threshold is reached."""
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
decision_logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
buy_decision_id = decision_logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
conn=db_conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
quantity=1,
|
||||||
|
price=100.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id=buy_decision_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
# Current price 106.0 → +6% gain, above take_profit_pct=3.0
|
||||||
|
broker.get_current_price = AsyncMock(return_value=(106.0, 6.0, 0.0))
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "100000",
|
||||||
|
"dnca_tot_amt": "10000",
|
||||||
|
"pchs_amt_smtl_amt": "90000",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
scenario = StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=30),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=88,
|
||||||
|
stop_loss_pct=-2.0,
|
||||||
|
take_profit_pct=3.0,
|
||||||
|
rationale="take profit policy",
|
||||||
|
)
|
||||||
|
playbook = DayPlaybook(
|
||||||
|
date=date(2026, 2, 8),
|
||||||
|
market="KR",
|
||||||
|
stock_playbooks=[
|
||||||
|
{"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=_make_hold_match())
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "Korea"
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
market.is_domestic = True
|
||||||
|
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_trade_execution = AsyncMock()
|
||||||
|
telegram.notify_fat_finger = AsyncMock()
|
||||||
|
telegram.notify_circuit_breaker = AsyncMock()
|
||||||
|
telegram.notify_scenario_matched = AsyncMock()
|
||||||
|
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
broker.send_order.assert_called_once()
|
||||||
|
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> None:
|
||||||
|
"""HOLD should remain HOLD when P&L is within stop-loss and take-profit bounds."""
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
decision_logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
buy_decision_id = decision_logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
conn=db_conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
quantity=1,
|
||||||
|
price=100.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id=buy_decision_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
# Current price 101.0 → +1% gain, within [-2%, +3%] range
|
||||||
|
broker.get_current_price = AsyncMock(return_value=(101.0, 1.0, 0.0))
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "100000",
|
||||||
|
"dnca_tot_amt": "10000",
|
||||||
|
"pchs_amt_smtl_amt": "90000",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
scenario = StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=30),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=88,
|
||||||
|
stop_loss_pct=-2.0,
|
||||||
|
take_profit_pct=3.0,
|
||||||
|
rationale="within range policy",
|
||||||
|
)
|
||||||
|
playbook = DayPlaybook(
|
||||||
|
date=date(2026, 2, 8),
|
||||||
|
market="KR",
|
||||||
|
stock_playbooks=[
|
||||||
|
{"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=_make_hold_match())
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "Korea"
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
market.is_domestic = True
|
||||||
|
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_trade_execution = AsyncMock()
|
||||||
|
telegram.notify_fat_finger = AsyncMock()
|
||||||
|
telegram.notify_circuit_breaker = AsyncMock()
|
||||||
|
telegram.notify_scenario_matched = AsyncMock()
|
||||||
|
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
broker.send_order.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_market_close_runs_daily_review_flow() -> None:
|
async def test_handle_market_close_runs_daily_review_flow() -> None:
|
||||||
"""Market close should aggregate, create scorecard, lessons, and notify."""
|
"""Market close should aggregate, create scorecard, lessons, and notify."""
|
||||||
|
|||||||
Reference in New Issue
Block a user