当事者になって分かったストックオプションに関してあまり理解されていないと思ったこと

はじめに

今回の記事はストックオプションに関するものです。全く技術的な要素が無く普段の投稿と違うテイストの記事ですが、このブログの訪問者にはベンチャー企業に勤めてるかたが多いんじゃないか、つまり訪問者の方にとって何かしら有益な情報になるのではないかと思ってここに書きます。

個人的な話ですが、僕は株式に限っても10年以上トレードをやっており、さらに株式以外のFX等を含めると18年近くトレーダーをやっています。さらに初めてベンチャーに転職した頃にストックオプションとは?といった趣旨のWeb記事などをよく読んでいたのでそこそこ理解しているつもりでいました。しかしそれでも実際に前職でストックオプションを付与され無事に会社がIPOして行使できた過程の中で初めて理解したことがいくつかあったのでそれをまとめます。また、ついでに一般的なSOの説明記事等で取り上げられていない、特に新卒就職からずっとベンチャーで働いてる人たちにはあまり知られてないんじゃないかなって思ったことも書いていきます。

逆に、そもそもストックオプションとは何かとか上場とは何かみたいな話は書きませんのでその辺は世にたくさんある他の記事を参照してください。

免責事項

記事の内容は正確なものになるように努めますが、自分はストックオプションの専門家ではなく、1社で行使を経験しただけなのでこの記事の内容に基づいた判断や行動は自己責任でお願いします。本編の最初に書きますがストックオプションにはざっくり分けても多くの種類があり、各企業や主幹事証券会社の設計によって事情が変わりえます。疑問に思ったことはご自身の勤め先の担当者に確認してください。

参考にこの記事を書いている僕が経験したSOは無償SOで税制適格SOです。その他の種類のSOについては未経験なので、この記事では言及しません。また、主幹証券会社は野村證券でした。証券会社による差もあると思いますので注意してください。

それでは以下本文です。

1. ストックオプションには複数の種類があり、会社ごとにも細かい違いがある

これは比較的よく知られていることですね。最近信託SOというのも話題になっていますが、一口にストックオプションと言っても多くの種類が存在します。有償SOと無償SOや、税制適格SOとそうでないもの、信託型SOとそうでないものなど、ざっくり分けても複数種類があります。

さらに細かく言えば、ストックオプションは会社ごとに設計されるものであり、行使条件等が会社によって細々と違います。例えば在籍年数が一定を越えないと全部は行使できないとか退職後の行使を認めるかどうかとかの規約が会社によって違います。同じ会社であっても個人個人でロックアップの有無などが変わったりもします。

免責事項に書いたことの繰り返しですがご自身が付与されたSOの設計がどうなっているのかはよく確認する必要があります。

また、これらの事情から個々の会社ごとの差異を除いたSOの一般論として語れる内容には限界があり、世の中のSOに関する各記事に説明不十分な点が出てしまうのも仕方ないと思いました。

2. ストックオプションの権利行使と取得した株式の売却はそれぞれ別の手続き

世の中のWeb記事のざっくりとした解説では「ストックオプションとは決められたか価格で株を買う権利であり、その価格より株価が高い状態で行使すると差額分を利益として得られる。」と言った解説がされています。要するに「行使価格100円のSOを株価300円の時に行使したら一株当たり200円の利益を得られる」と言った趣旨のことが書いてあります。

これで僕は「SOを行使してその株を売る」という1個の手続きが存在するのかな、と勘違いしていました。要するに「SO行使します」と言ったら(行使価格と時価の差分)*(株数)の行使益みたいなものがポンと振り込まれるのかと思っていました。

実際は、行使は行使、売却は売却で別々の手続きです。まずSOを行使して株を買って、その後保有し続けるか売却するかという話になります。

3. ストックオプションを行使するときはお金を払う

2.の続きですね。行使と売却が同時にできず、まずSOを行使するだけという手続きがあるので当然の話ですが、株を買う権利を行使するならその購入費用を支払わないといけません。

自分が100万円分のSOを行使したいなら100万円払う必要がありますし、1000万円分のSOを行使したいなら1000万円、1億円分なら1億円を指定口座に振り込む必要があります。

これ読んで心配になった人もいるかと思いますが、SOの一部を行使して、その株を売って、それで得た資金でまたSOを行使して、と繰り返すことも可能なので自己資金がなくてSOが無駄になるってことはあまりないと思います。ただ、後に書きますが、株をなかなか売れない人もいるのでこの手段は全員がスムーズに使えるわけでは無いのでその点は注意です。

4. 税制適格ストックオプションは年間の行使額に上限がある

税制適格ストックオプションは税率が優遇される代わりに色々制限がつきますが、その中で社員が特に気にしないといけないのがこの点です。2023年時点では税制適格ストックオプションの年間の行使価格の上限が1200万円となっています。

もう少し細かくいうと、租税特別措置法 第29条の2というのがあって、「二 当該新株予約権の行使に係る権利行使価額の年間の合計額が、1200万円を超えないこと。」と定められています。

ただ、これは税制上の優遇を受けられる法律上の上限行使額が年間1200万ってことで、各企業ごとにうちは上限x万円までしか行使できないとか別途規約があったり、税制上の優遇がされなくていいならいくらでも行使していいとか個々に規定がある可能性もあるのでよく確認してください。

これが大きく影響するのは多額のSOを条件に招致される役員さん等でしょうね。

5. ストックオプションの行使には証券会社に専用口座が必要

これは主幹証券会社によって違うかもしれません。僕が経験したのが野村證券だけなのでそれを例に書きます。

実は、新卒で入った会社を辞める時に持株会の株を売るために野村證券の口座を作りました。その後は手数料が高いので使わず、解約手続きも電話が繋がらずできなかったのでただ放置していた口座でした。SOを付与された当時の勤め先の主幹証券会社が野村證券になった時にじゃあこの口座を使えるかと思っていたのですが、ストックオプションの取引にはストックオプション専用講座が必要ということになり野村證券に改めてもう一口座開設の手続きが必要でした。

一般的に自分が複数の証券会社に口座を持っている場合、口座間で株式の移管が行えるものなのですが、税制適格ストックオプションの税制上の優遇を受けるためには他の証券会社に移さず、この専用講座で売却する必要があります。

野村證券なので売却手数料は馬鹿みたいに高かったです。普段ネット証券を使っている方は驚くと思います。ただ逆にSOを行使して株式を購入する時は手数料はなく、純粋に行使価格と株数を掛けた金額をちょうどが支払い金額でした。この辺も主幹証券会社によって事情が変わると思います。

6. ストックオプションの行使には時間がかかる

これはタイトルそのままです。会社や主幹証券会社によって日数は少々変わると思いますが。SOを行使したい旨を会社に伝えて申請書をもらって書いて提出して承認してもらって行使資金の振込先口座を教えてもらってお金を振り込んでそこから手続きが進んで、自分の証券口座に株式が移されるという手順を順に踏んでいくことになり、1個1個の手順が数日がかりになります。

通常の株の購入みたいに今日買いたいと思ってもすぐできるものでは無いということです。

7. ストックオプションを行使して得た株は自由に売れるわけでは無い

これはストックオプションに限った話ではなく、上場企業社員の一般論です。通常は、上場企業の社員は自分が勤める会社の株を自由に売り買いできず、会社ごとに何かしらの規約があります。

また、IPO固有の事情としてはロックアップというものがあって、一部の大株主や役員は新規上場から一定期間該当株式の売買ができない取り決めになります。

僕はこのロックアップの対象外だったので上場直後から複数の知人から「ロックアップついてなかったから、もういつでも売れるんじゃ無いですか?売りました?」と言った趣旨のことを言われました。しかし、ロックアップがなかったからと言って自由には売れません。

これは本当に会社ごとに規約が違うので自社に確認するべきですが、売買できる期間が決まっていて売買を希望するなら事前に申請が必要とかそういう種類のルールがあると思います。非上場企業の場合、現時点でルールがなかったとしてもおそらくIPOする時にこの種のルールが作られると思います。

また、退職したとしても一定期間はその企業の関係者扱いになります。これが法律で決まってるのか各企業の自主的な規制なのかと言った詳しいことは知りませんが、自分が新卒時就職した上場企業も退職後1年間は証券口座上で関係者扱いだったのでおそらく一般的なルールなのでしょう。

8. 実はストックオプションはノーリスクでは無い

ここまでに買いた2, 6, 7 の組み合わせから導かれる結論がこれです。なんか世の中には株価が下がったらストックオプションを行使しなければいいだけだからノーリスクだという主張をする記事があります。実は僕はそれを信じていたので2.の行使と売却が同時にできるのではという勘違いをしていました。

ストックオプションの行使と株式の売却が別の手続きで、それぞれがタイムリーに行えない以上、ストックオプションを行使してから売るまでに株価が行使価格を下回って損をするリスクはあります。また、行使だけに限ってももう取り消しできないという段階で株価が暴落し、市場価格より高い金額で購入することになるリスクはあります。

9. ストックオプションの保有状況はIPO時に公表される

実は当事者になって一番びっくりしたのがこれです。日本取引所グループのサイトに新規上場企業の情報がまとまったページがあります。ここのPDFを見ていくと各企業の株主の情報が載っているのですが、現物株を持ってる人だけでなくストックオプションの保有者も公開されます。公開されるのは氏名と住所(市や区まで)と株数ですね。

参考: 新規上場会社情報 | 日本取引所グループ

7. で知人にばれた元ネタはこれと、これを転載したIPO情報サイトです。

10. ストックオプション専用口座で発生する利益にかかる税金は源泉徴収では無い

これはもしかしたら証券会社によって違うかもしれません。少なくとも僕が使っているところでは特定口座と扱いが異なり、売却益からはまだ税金が取られていないので時期が来たら確定申告して自分で納税することになります。これ自体は今後やることなのでこれ以上語れることはありません。

11. なかなか株を売れない人もいるらしい

これは自分はそうではなかったので伝聞だけの話です。会社の中でも特に要職にある人はそう気軽に株式を売却できないらしいですね。こうなると3. で書いたテクニックのSOを行使してその株の売却資金で次のSOを行使するって手段が使えなくなります。

また、7. の上場企業の従業員は自由に売買できないって話にもつながりますが、インサイダー情報を知ってる人という扱いになると売買を申請しても却下される可能性があるとも聞きました。

これは会社だけでなく個人個人によって状況が大きく異なる点なので各自がよく確認してください。

12. 売却時期の判断は難しい

これはただの感想。実際難しいです。個人的にはちょっとしくじったと思ってますがもう仕方ないですね。個人投資家としてのキャリアの中でもこんな大きなポジションを持ったことはなかったですし、さらに自分は新規上場株を扱ってこなかったので、これまでの取引経験が活かせたような実感はありませんでした。

まとめ

株取引をやっているので自分は詳しい方だと思っていましたが、それでもストックオプションに関して誤解していることが複数ありました。人生でそう何度も経験することではなく、当事者のほとんどが情報不足で直面するわりにインパクトが大きいことなので、この記事が訪問者の方々のキャリアの中でなにかしらお役に立てれば幸いです。

免責事項の中で書いたことの繰り返しになりますが僕自身も1回経験しただけで、ストックオプションは他にも種類があるため、他の種類のストックオプションにはそれはそれで固有の事情があると思います。ベンチャーで働く皆様におかれましては各社の制度をよく理解し良いベンチャーライフをお送りください。

Jupyter(ipython)のマジックコマンドを自作する

Jupyterには便利なマジックコマンド(%や%%を付けて呼び出すアレです)がたくさんありますが、あれを自作する方法を紹介します。

ドキュメントは IPythonのドキュメントのこちらを参照します。
参考: Defining custom magics — IPython 8.14.0 documentation

簡単な方法は、register_line_magic, register_cell_magic, register_line_cell_magic の3種のデコレーターをマジックコマンドとして使いたい関数につけることです。

register_line_magicはその行の文字列を格納する引数を1個だけ、register_cell_magicとregister_line_cell_magicは、マジックコマンドと同じ行の文字列を格納する引数と、セル内の文字列を格納する引数の2個をもちます。

ざっと、受け取った文字列をprintするだけのコマンドを作ってみましょう。3種類それぞれのサンプルです。

from IPython.core.magic import register_line_magic
from IPython.core.magic import register_cell_magic
from IPython.core.magic import register_line_cell_magic


@register_line_magic
def line_magic(line):
    print(line)


@register_cell_magic
def cell_magic(line, cell):
    print(f"line: {line}")
    print(f"cell:\n{cell}")


@register_line_cell_magic
def line_cell_magic(line, cell=None):
    print(f"line: {line}")
    if cell:
        print(f"cell:\n{cell}")

順番に使ってみます。

%line_magic ラインマジックテスト
print()
# 以下出力
# ラインマジックテスト
%%cell_magic セルマジックと同じ行のテキスト
セルマジック内のテキスト
その2行目

# 以下出力。
"""
line: セルマジックと同じ行のテキスト
cell:
セルマジック内のテキスト
その2行目
"""
%line_cell_magic ラインマジックとして動作させた場合

# 以下出力
# line: ラインマジックとして動作させた場合
%%line_cell_magic セルマジックとして動作させた場合。
セルの中身

# 以下出力
"""
line: セルマジックとして動作させた場合。
cell:
セルの中身
"""

めっちゃ簡単ですね。

最初のマジックコマンドを定義したコードをPythonファイルとして保存して、import可能なディレクトリに置いておくと、インポートして使うこともできる様になります。例えば、 my_magic.py というファイル名で保存しておけば次の様に使えます。

import my_magic


%line_magic 読み込んだモジュールのマジックコマンドが使える
# 読み込んだモジュールのマジックコマンドが使える

my_magic.line_magic("普通の関数としても呼び出せる")
# 普通の関数としても呼び出せる

さて、通常マジックコマンドをライブラリ等から読み込んで使う場合、この様にimport するのではなく、%load_ext して使うことが多いと思います。これは、先ほどあげたドキュメントのページでベストプラクティスとされているのがその方式だからです。@register_* のデコレーターで直接登録する上記の方法は推奨されてないんですね。

その代わりにどうするかというと、 load_ipython_extension というメソッドを持つpythonファイルを作り、このメソッドの中で定義した関数たちを register_magic_function でマジックコマンドへ登録していきます。

引数は順に、登録する関数本体、コマンドの種類(省略したら’line’)、マジックコマンドとして呼び出す時の名前(省略したら元の関数名)です。

例えば、 my_ext.py というファイルを作りその中を次の様にします。

def load_ipython_extension(ipython):
    ipython.register_magic_function(
        line,
        magic_kind='line',
        magic_name='line_magic'
    )

    ipython.register_magic_function(
        cell,
        magic_kind='cell',
        magic_name='cell_magic'
    )

    ipython.register_magic_function(
        line_cell,
        magic_kind='line_cell',
        magic_name='line_cell_magic'
    )


def line(line):
    print(line)


def cell(line, cell):
    print(f"line: {line}")
    print(f"cell:\n{cell}")


def line_cell(line, cell=None):
    print(f"line: {line}")
    if cell:
        print(f"cell:\n{cell}")

各メソッドそれぞれにはデコレーターはつきません。

この様なファイルを用意すると、load_ext で読み込んだ時に、load_ipython_extension が実行されて、その中でマジックコマンドの登録が行われます。結果、次の様に使えます。

%load_ext my_ext


%line_magic ロードしたマジックコマンドが使えた
# ロードしたマジックコマンドが使えた

先ほどのimportした場合との挙動の違いとしては、これは明示的にマジックコマンドの読み込みだけを行っているので、各メソッドはインポートはされておらず、個々のメソッドの、line, cell, line_cell は名前空間に登録されてないということです。(マジックコマンドとして登録された、line_magic, cell_magic, line_cell_magic の名前でなら通常のメソッドと同じ様に使うことも可能です)

以上が簡単なマジックコマンドの作り方になります。

lru_cacheによるメモ化をクラスのメソッドに使うとメモリリークを引き起こすことがある

もう結構前なのですが、メモ化というテクニックを紹介しました。
参考: pythonの関数をメモ化する

これは@functools.lru_cacheというデコレーターを使って、関数の戻り値を記録しておいて何度も同じ関数を実行するコストを削減するのでしたね。計算コストが削減される代わりに、結果を保存しておく分メモリを消費します。

僕はこれを結構使ってたのですが、最近、これをクラスのメソッドに対して利用しているとメモリリークを引き起こすことがあるという気になる情報を得ました。ブログで紹介しちゃった責任もあるので、今回はその問題について調べました。

この問題は何箇所かで指摘されていて、一例を挙げるとこのissueなどがあります。
参考: functools.lru_cache keeps objects alive forever · Issue #64058 · python/cpython

こっちのYoutubeでも話されていますね。
参考: don’t lru_cache methods! (intermediate) anthony explains #382 – YouTube

具体的に説明していくために超単純なクラスを作って実験していきましょう。
まず、そのまま返すメソッドを持ってるだけのシンプルクラスを作ります。そして、このクラスがメモリから解放されたことを確認できる様に、デストラクターが呼び出されたらメッセージを表示する様にしておきます。これをインスタンス化して関数を1回使って、delで破棄します。

class sample1:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f"インスタンス: {self.name} を破棄しました。")

    def identity(self, x):
        return x


a = sample1("a")
print(a.identity(5))
# 5
del a
# インスタンス: a を破棄しました。

デストラクターがちゃんと呼び出されていますね。

これが、メソッドがメモ化されていたらどうなるのかやってみます。

from functools import lru_cache


class sample2:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f"インスタンス: {self.name} を破棄しました。")

    @lru_cache(maxsize=None)
    def identity(self, x):
        return x


b = sample2("b")
print(b.identity(5))
# 5
del b
# 何も表示されない。

今度はガベージコレクターが動きませんでしたね。これは、変数bを削除したことによって変数bからの参照は消えたのですが、メソッドのidentity の一つ目の引数がそのインスタンス自体をとっていて、これを含めてキャッシュしているので、キャッシュがインスタンス自身への参照を保存しているためガベージコレクションの対象にならなかったのです。そのため、インスタンスbが確保していたメモリは解放されず、占拠されたままになります。

ちなみに、循環参照の状態なので、明示的にガベージコレクターを動かすと消えます。

import gc


gc.collect()
# インスタンス: b を破棄しました。

ちなみに、最初のキャッシュが発生した時に循環参照が生まれているのでメモ化したメソッドを一回も使わなかったら普通に消えます。

c = sample2("c")
del c
# インスタンス: c を破棄しました。

以上の様な問題があるので、クラスメソッドで lru_casheを使う時は気をつけて使うことをお勧めします。

とはいえ、最近のMacBookくらいのメモリ量であれば、インスタンスが何個か過剰に残ったとしてそれでメモリが枯渇する様なことはないんじゃないかなとも思います。仮にメモリがピンチになる様な使い方をしていたとしても、maxsizeを適切に設定してメモリサイズを押さえておくとか、明示的にgc/collect()動かすとかの対応が取れるかと。

僕としては、メモリが解放されないことよりも、デストラクターが動かなくてそこに仕込んだ後始末形の処理が動かないのが気になりましたね。

Pythonのloggingモジュールの使い方

これもずっと前に書いたつもりでいたら書いてなかったので書きます。プログラムを開発していると、ログを実装することがあります。Jupyterでインタラクティブにやる時はprintで十分なのですが、機械学習モデルや何かしらのロジックを実装して本番環境で稼働させるなら何かしらログが残る仕組みは必須です。そして僕は横着してJupyterのファイルをそのままバッチとして使い始めたりもするのでJupyterでもロギングを使うことがあります。

その様な時、Pythonにはloggingモジュールという大変便利なモジュールが標準で用意されています。
参考: logging — Python 用ロギング機能 — Python 3.11.4 ドキュメント

このloggingライブラについては必ずドキュメントを読んでから使うことをお勧めします。というのも、このモジュールにはアンチパターンが多く、例えばルートロガーをそのまま使うなどの、適当に書いたらそう書いちゃう実装がリスクが大きいからです。

最近はドキュメントでも上の方で、logging.getLogger(name)を使えってちゃんと書かれる様になりましたね。それでは、使い方書いていきます。

getLoggerを用いたロガーの取得

loggingモジュールでは最初にロガーを取得します。これをやることでそのモジュールごとに独立したロガーが生成され、設定をいじってもシステム全体の他のロガーの設定に影響が出ない様にできます。ロガーには名前をつけるのですが、__name__という変数を使うと自動的にモジュール名が入るので良いでしょう。スクリプト本体の場合は__name__の値は__main__になります。

import logging


logger = logging.getLogger(__name__)

これでloggerが生成されましたが、初めて使った人がそのまま利用しようとすると、デバッグログやinfoのログが出ないことに気づくと思います。

# debugと info は何も出力しない。
logger.debug("デバック")
logger.info("インフォ")

# warning/error/critical は標準エラー出力に出力
logger.warning("ワーニング")
# WARNING:__main__:ワーニング
logger.error("エラー")
# ERROR:__main__:エラー
logger.critical("クリティカル")
# CRITICAL:__main__:クリティカル

ざっくり言うと初期設定ではこういう動きに設定されているということです。
もっと詳しく説明すると、この時点ではloggerにはログの出力先(これをhandlerという)が設定されておらず、この様な場合は警告以上の重要度のメッセージを「lastResort」っていう最終手段のハンドラが出力してくれているという状況になっています。ハンドラが設定されてなくても重要なメッセージはユーザーに伝えようと言うモジュールの思いやりです。

さて、上記のままではせっかくのデバッグメッセージ等が揉み消されるし出力も簡素すぎるのでここから色々設定していきます。

ハンドラーの作成と追加

まず、先ほどの例では出力先を制御しているハンドラが設定されていないので、こちらの追加方法を説明します。

これはファイルに書き出したい場合や標準エラー出力に出力したい場合などに備えて複数のクラスがあるのでそれをインスタンス化し、 addHandlerでロガーに設定します。複数設定も可能です。

ハンドラたちのドキュメントはこちら。次の様なイメージで設定します。


参考: logging.handlers — ロギングハンドラ — Python 3.11.4 ドキュメント

# ファイルに書き出すハンドラ
file_handler = logging.FileHandler('{ファイルパス}')
logger.addHandler(file_handler)

# 標準エラー出力に書き出すハンドラ
stream_handler = logging.StreamHandler()
logger.addHandler(stream_handler)

ログレベルの設定

ログにはログレベルという概念があって、ロガーと、出力先であるハンドラそれぞれがどのレベル以上のログを出力するかという持っています。両方を満たさなければ出力されません。例えば、ロガーはINFO以上、ハンドラがERROR以上を出力する設定なら、ERROR以上のログだけが出力されるという様な仕組みです。

ハンドラが個別に設定を持っていることで、標準エラー出力とログファイルに別々の設定を提供したりできますし、ロガー自体が設定を持っているので、開発中と本番運用中で一括して設定を変えることなどもできます。

ログレベルはこちらの通り で、
NOTSET(0) → DEBUG(10) → INFO(20) → WARNING(30) → ERROR(40) → CRITICAL(50)
の順です。

それぞれ setLevelメソッドを持っているのでそれで設定します。上記のレベルは数値でもいいし、モジュールが定数を持ってます。

次の様なイメージで設定します。

# ロガーのログレベル設定
logger.setLevel(logging.DEBUG)

handler = logging.StreamHandler()
# ハンドラのログレベル設定
handler.setLevel(logging.INFO)
logger.addHandler(stream_handler)

ハンドラごとに設定を変えれば、DEBUGはファイルだけ、INFO以上はファイルと標準出力両方といった設定ができます。

フォーマッターの使い方

ここまでで、ログの出力有無の制御ができたので、最後に出力内容を設定します。これはフォーマッターという仕組みで実現します。

設定によって詳細な時刻や、ログレベル、モジュール名、行番号などを情報に加えることができます。例によってこれもハンドラごとに設定できるので、標準エラー出力には時刻はいらないけど、ファイルには時刻も残したいって出しわけなどができますね。

フォーマッター中で使える変数はこちらの表にまとまっています。
参考: LogRecord 属性

実際に使うのはこの辺かな。

  • asctime ・・・ 時刻を人間が読める書式にしたもの。
  • levelname・・・DEBUGとかERRORなどのログレベル。
  • message・・・ロギングの時に渡されたメッセージ本文。
  • name・・・ロガーの名前。
  • funcName・・・ロギングの呼び出しを含む関数の名前。
  • lineno・・・ソース行番号

使い方のイメージとしては、次の様に %スタイルでフォーマットを作り、setFormatterでセットします。

formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)

上記の例は%スタイルでこれはFormatterのコンストラクタのstyle引数がデフォルトの’%’だからこうなってるのですが、style引数に ‘{‘ を渡せば中括弧スタイルでも定義できます。個人的はそちらの方が好きです。

# 以下の二つは同じ
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
formatter = logging.Formatter("{asctime} - {levelname} - {message}", style="{")

一通り動かす

以上をまとめて、書いておきます。これをベースに好みの形でいじっていけば良いロガーがが作れると思います。

import logging


logger = logging.getLogger(__name__)
logger.handlers.clear()
logger.setLevel(logging.DEBUG)

formatter = logging.Formatter("{asctime} - {levelname} - {message}", style="{")

file_handler = logging.FileHandler("sample.log")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)

stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)

# 使い方。debugはファイルのみ、それ以外はファイルと標準エラー出力に表示。
logger.debug("debug")
logger.info("info")
logger.warning("warning")
logger.error("error")
logger.critical("critical")

何度も書くのは面倒なので自分は上記のコードに色々工夫を入れたものモジュール化して使い回しています。(訳あってこの間、久しぶりに書き直すことになりました。その時loggerの使い方を記事にしてないことに気づいたことがこの記事の発端でした。)