File size: 10,220 Bytes
ba85b6b
 
 
f7a4423
ba85b6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7a4423
ba85b6b
 
 
 
 
 
 
 
f7a4423
ba85b6b
 
 
 
 
 
f7a4423
ba85b6b
 
 
 
 
 
 
 
 
 
 
 
 
f7a4423
ba85b6b
4f934ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ba85b6b
 
 
 
 
 
 
 
f7a4423
 
ba85b6b
 
 
4f934ef
 
 
 
 
ba85b6b
 
f7a4423
 
 
 
4f934ef
 
 
 
 
 
 
 
 
 
 
 
 
f7a4423
 
ba85b6b
 
 
 
 
 
 
 
 
 
 
 
f7a4423
 
 
 
 
 
 
ba85b6b
 
 
f7a4423
ba85b6b
 
 
f7a4423
ba85b6b
f7a4423
ba85b6b
 
 
 
 
 
 
 
 
 
 
 
f7a4423
ba85b6b
f7a4423
ba85b6b
 
 
 
 
 
 
 
 
 
f7a4423
 
 
ba85b6b
 
 
f7a4423
ba85b6b
 
 
4f934ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ba85b6b
 
f7a4423
4f934ef
 
 
 
 
 
ba85b6b
 
 
 
 
 
 
4f934ef
 
 
 
 
 
 
 
 
 
 
 
 
ba85b6b
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
"""
database.py — SQLite 資料庫層

單一資料表 `stocks`,每檔股票一列,欄位涵蓋四面向所有指標 + 初篩用的市場資料。
提供:init_db / upsert_stock / get_symbols_needing_update / query (依 VALUE_FILTERS 動態組 SQL)。
"""

import sqlite3
import datetime as dt
from contextlib import contextmanager

import config

SCHEMA = """
CREATE TABLE IF NOT EXISTS stocks (
    symbol            TEXT PRIMARY KEY,
    company_name      TEXT,
    sector            TEXT,
    industry          TEXT,
    country           TEXT,
    exchange          TEXT,

    -- 市場 / 流動性(初篩)
    price             REAL,
    market_cap        REAL,
    avg_volume        REAL,
    dollar_volume     REAL,
    beta              REAL,

    -- 財務面
    roic              REAL,
    roic_method       TEXT,    -- 'calculated'(真實公式) 或 'roa_proxy'(退回 ROA 代理)
    debt_to_equity    REAL,
    gross_margin      REAL,
    fcf_to_revenue    REAL,

    -- 成長面
    eps_yoy           REAL,
    eps_cv            REAL,    -- EPS 波動係數,越大越像週期股
    revenue_cagr_3y   REAL,
    operating_margin  REAL,

    -- 估值面
    pe                REAL,
    forward_pe        REAL,
    peg               REAL,
    ev_ebitda         REAL,

    -- 市場面
    volatility_annual REAL,
    momentum_12_1     REAL,

    passes_universe   INTEGER DEFAULT 0,   -- 是否通過初篩(含 dollar_volume)
    data_source       TEXT,
    updated_at        TEXT,

    -- 學術公開公式評分(Altman Z-Score / Piotroski F-Score / Mohanram G-Score)
    altman_z_score       REAL,    -- Altman(1968)破產風險評分;金融股不適用
    altman_zone          TEXT,    -- 'safe' / 'grey' / 'distress'
    piotroski_f_score    INTEGER, -- Piotroski(2000)財務體質評分,實得分數
    piotroski_max        INTEGER, -- 實際可計算的子項數(滿分基準,可能 <9)
    mohanram_roa            REAL, -- 以下為 Mohanram G-Score 原始比率(未跟同業比較前)
    mohanram_cfo_to_assets  REAL,
    mohanram_cfo_gt_ni       INTEGER,  -- 0/1,布林值
    mohanram_rd_to_assets   REAL,
    mohanram_capex_to_assets REAL,
    mohanram_roa_cv         REAL,      -- 多年 ROA 波動係數(公司內部,不需同業比較)
    mohanram_sales_growth_cv REAL,     -- 多年營收成長波動係數(公司內部,不需同業比較)
    mohanram_g_score        INTEGER,   -- 依同業中位數比較後的最終分數(0-7),由 pipeline 第二輪回填
    mohanram_g_max          INTEGER    -- 實際可計算的子項數(滿分基準,可能 <7)
);
CREATE INDEX IF NOT EXISTS idx_passes ON stocks(passes_universe);
CREATE INDEX IF NOT EXISTS idx_updated ON stocks(updated_at);
"""

_COLUMNS = [
    "symbol", "company_name", "sector", "industry", "country", "exchange",
    "price", "market_cap", "avg_volume", "dollar_volume", "beta",
    "roic", "roic_method", "debt_to_equity", "gross_margin", "fcf_to_revenue",
    "eps_yoy", "eps_cv", "revenue_cagr_3y", "operating_margin",
    "pe", "forward_pe", "peg", "ev_ebitda",
    "volatility_annual", "momentum_12_1",
    "passes_universe", "data_source", "updated_at",
    "altman_z_score", "altman_zone", "piotroski_f_score", "piotroski_max",
    "mohanram_roa", "mohanram_cfo_to_assets", "mohanram_cfo_gt_ni",
    "mohanram_rd_to_assets", "mohanram_capex_to_assets",
    "mohanram_roa_cv", "mohanram_sales_growth_cv",
    "mohanram_g_score", "mohanram_g_max",
]

# 為了向後相容舊資料庫檔(手動測試留下的),補齊缺少的欄位
_MIGRATIONS = {
    "roic_method": "ALTER TABLE stocks ADD COLUMN roic_method TEXT",
    "eps_cv": "ALTER TABLE stocks ADD COLUMN eps_cv REAL",
    "altman_z_score": "ALTER TABLE stocks ADD COLUMN altman_z_score REAL",
    "altman_zone": "ALTER TABLE stocks ADD COLUMN altman_zone TEXT",
    "piotroski_f_score": "ALTER TABLE stocks ADD COLUMN piotroski_f_score INTEGER",
    "piotroski_max": "ALTER TABLE stocks ADD COLUMN piotroski_max INTEGER",
    "mohanram_roa": "ALTER TABLE stocks ADD COLUMN mohanram_roa REAL",
    "mohanram_cfo_to_assets": "ALTER TABLE stocks ADD COLUMN mohanram_cfo_to_assets REAL",
    "mohanram_cfo_gt_ni": "ALTER TABLE stocks ADD COLUMN mohanram_cfo_gt_ni INTEGER",
    "mohanram_rd_to_assets": "ALTER TABLE stocks ADD COLUMN mohanram_rd_to_assets REAL",
    "mohanram_capex_to_assets": "ALTER TABLE stocks ADD COLUMN mohanram_capex_to_assets REAL",
    "mohanram_roa_cv": "ALTER TABLE stocks ADD COLUMN mohanram_roa_cv REAL",
    "mohanram_sales_growth_cv": "ALTER TABLE stocks ADD COLUMN mohanram_sales_growth_cv REAL",
    "mohanram_g_score": "ALTER TABLE stocks ADD COLUMN mohanram_g_score INTEGER",
    "mohanram_g_max": "ALTER TABLE stocks ADD COLUMN mohanram_g_max INTEGER",
}


@contextmanager
def connect(db_path=None):
    conn = sqlite3.connect(db_path or config.DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
        conn.commit()
    finally:
        conn.close()


def _ensure_columns(conn):
    existing = {row["name"] for row in conn.execute("PRAGMA table_info(stocks)")}
    for col, sql in _MIGRATIONS.items():
        if col not in existing:
            conn.execute(sql)


def init_db(db_path=None):
    with connect(db_path) as conn:
        conn.executescript(SCHEMA)
        _ensure_columns(conn)


def upsert_stock(record: dict, db_path=None):
    """record 為 dict,缺的欄位以 None 填。symbol 必填。"""
    record = {**record}
    record["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
    row = [record.get(c) for c in _COLUMNS]
    placeholders = ",".join("?" * len(_COLUMNS))
    updates = ",".join(f"{c}=excluded.{c}" for c in _COLUMNS if c != "symbol")
    sql = (
        f"INSERT INTO stocks ({','.join(_COLUMNS)}) VALUES ({placeholders}) "
        f"ON CONFLICT(symbol) DO UPDATE SET {updates}"
    )
    with connect(db_path) as conn:
        conn.execute(sql, row)


def get_symbols_needing_update(db_path=None, stale_days=None):
    """回傳尚未抓 / 資料過期的 symbol(給分批補資料用)。"""
    stale_days = stale_days if stale_days is not None else config.REFRESH_STALE_AFTER_DAYS
    cutoff = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=stale_days)).isoformat()
    with connect(db_path) as conn:
        rows = conn.execute(
            "SELECT symbol FROM stocks WHERE updated_at IS NULL OR updated_at < ? "
            "ORDER BY updated_at IS NOT NULL, updated_at ASC",
            (cutoff,),
        ).fetchall()
    return [r["symbol"] for r in rows]


def seed_universe(symbols, db_path=None):
    """把候選 symbol 先寫進去(其餘欄位待 enrich)。
    passes_universe 預設 0,等 enrich 拿到實際資料後才決定是否為 1,
    避免種子階段就被誤判為「通過初篩」。"""
    with connect(db_path) as conn:
        for s in symbols:
            conn.execute(
                "INSERT OR IGNORE INTO stocks (symbol, passes_universe) VALUES (?, 0)", (s,)
            )


def list_sectors(db_path=None):
    """回傳資料庫裡目前有的所有 sector(供 UI 下拉選單動態產生選項)。"""
    with connect(db_path) as conn:
        rows = conn.execute(
            "SELECT DISTINCT sector FROM stocks WHERE sector IS NOT NULL ORDER BY sector"
        ).fetchall()
    return [r["sector"] for r in rows]


def get_mohanram_raw_data(db_path=None):
    """
    取出所有通過初篩、且有 sector 的股票的 Mohanram 原始比率,
    供 pipeline 依 sector 分組算中位數、回填最終 0-7 分用。
    """
    with connect(db_path) as conn:
        rows = conn.execute(
            "SELECT symbol, sector, mohanram_roa, mohanram_cfo_to_assets, "
            "mohanram_cfo_gt_ni, mohanram_roa_cv, mohanram_sales_growth_cv, "
            "mohanram_rd_to_assets, mohanram_capex_to_assets "
            "FROM stocks WHERE passes_universe=1 AND sector IS NOT NULL"
        ).fetchall()
    return [dict(r) for r in rows]


def update_mohanram_score(symbol, score, max_n, db_path=None):
    """只更新 mohanram_g_score / mohanram_g_max,不動其他欄位。"""
    with connect(db_path) as conn:
        conn.execute(
            "UPDATE stocks SET mohanram_g_score=?, mohanram_g_max=? WHERE symbol=?",
            (score, max_n, symbol),
        )


def query(filters=None, ops=None, require_universe=True, order_by="roic DESC",
          market_cap_min=None, market_cap_max=None, sectors=None, db_path=None):
    """
    依 VALUE_FILTERS 動態組 WHERE。None 的條件自動略過。
    只比較「有資料」的列(該欄位 NOT NULL),避免缺值被誤判為通過。

    market_cap_min / market_cap_max:市值區間(單一欄位的上下限,VALUE_FILTERS
    的機制只支援單一比較方向,無法同時做 >= 又 <=,所以獨立成參數)。
    sectors:list[str],限定產業別(多選);None 或空 list 代表不限制。
    這兩個參數用來重現「市值介於 A-B + 限定產業」這類 scope-narrowing 策略
    (例如中型科技股策略),跟 VALUE_FILTERS 的四面向品質門檻是兩個獨立的層次。
    """
    filters = config.VALUE_FILTERS if filters is None else filters
    ops = config.VALUE_FILTER_OPS if ops is None else ops

    where, params = [], []
    if require_universe:
        where.append("passes_universe = 1")

    if market_cap_min is not None:
        where.append("market_cap IS NOT NULL AND market_cap >= ?")
        params.append(market_cap_min)
    if market_cap_max is not None:
        where.append("market_cap IS NOT NULL AND market_cap <= ?")
        params.append(market_cap_max)

    if sectors:
        placeholders = ",".join("?" * len(sectors))
        where.append(f"sector IN ({placeholders})")
        params.extend(sectors)

    for col, thr in filters.items():
        if thr is None:
            continue
        op = ops.get(col, ">=")
        where.append(f"({col} IS NOT NULL AND {col} {op} ?)")
        params.append(thr)

    sql = "SELECT * FROM stocks"
    if where:
        sql += " WHERE " + " AND ".join(where)
    if order_by:
        sql += f" ORDER BY {order_by}"
    with connect(db_path) as conn:
        return [dict(r) for r in conn.execute(sql, params).fetchall()]